Compare commits

...

177 Commits
v1.0.0 ... main

Author SHA1 Message Date
cwhzy 2f44ddf6ec
update README.md.
Signed-off-by: cwhzy <cwhzy@foxmail.com>
2024-07-01 04:51:16 +00:00
cxfksword f337406ac9 tweak: optimize identify 2024-06-07 21:45:01 +08:00
cxfksword cb00027b77 fix: episode number not correctly identified. #82 2024-05-25 16:00:19 +08:00
cxfksword 9e9074bb5a build: update github action 2024-05-22 20:38:52 +08:00
cxfksword 747e69f3bf tweak: read attribute from season folder name 2024-05-18 16:12:52 +08:00
cxfksword 104d200e7b fix: actor lack of overview. close #80 2024-05-18 16:04:05 +08:00
cxfksword 8ded89422f
Merge pull request #78 from cxfksword/10.9/preview
tweak: update generate manifest version
2024-05-12 18:04:12 +08:00
cxfksword 25a844a2c4 tweak: update generate manifest version 2024-05-12 18:03:34 +08:00
cxfksword f1f35a6d32
Merge pull request #77 from cxfksword/10.9/preview
Compatible with Jellyfin 10.9
2024-05-12 18:00:02 +08:00
cxfksword 96be3222f9 feat: support path name attribute. close #75 2024-05-12 17:57:07 +08:00
cxfksword 1b547f7aaf Compatible with Jellyfin 10.9. #76 2024-05-12 17:15:46 +08:00
cxfksword 408929fc03 tweak: update log 2024-04-21 14:40:23 +08:00
cxfksword e26794fdcd docs: update README.md 2024-04-13 15:30:16 +08:00
cxfksword 2eca5c9a22 fix: fix find tmdb id by season imdb 2024-04-13 15:26:07 +08:00
cxfksword 23d10b94ed
Update issue template 2024-03-11 21:39:58 +08:00
cxfksword 6f3863eaa7 fix: fix movie similar list only one. #66 2024-02-28 11:02:18 +08:00
cxfksword a9acabc056 fix: support SxxEPxx format. close #60 2024-02-23 14:48:55 +08:00
cxfksword 169ce81a94 tweak: show douban cookie valid msg 2024-02-21 10:23:52 +08:00
cxfksword 3c054cfa2d tweak: split tmdb logo configuration. 2024-02-07 12:16:02 +08:00
cxfksword 10c3414920 fix: can't parse name with number. close #64 2024-02-07 11:54:39 +08:00
cxfksword 513a668c40 feat: add movie logo. close #63 2024-02-03 14:04:52 +08:00
cxfksword 9c0448898a tweak: optimize log output 2024-01-06 10:16:59 +08:00
cxfksword b7541fbb03 tweak(anitomy): update version v0.4.0 2024-01-06 10:15:52 +08:00
cxfksword f222258080 tweak(anitomy): support EP 2024-01-06 10:08:20 +08:00
cxfksword f5cf162e67 feat: add large poster configuration and optimize image handle. close #59 close #61 2023-12-30 15:00:10 +08:00
cxfksword 54fd425849 build: update generate manifest script 2023-12-16 15:41:18 +08:00
cxfksword 223c93ec49 ci: override release 2023-12-16 15:23:19 +08:00
cxfksword 64b401c049 docs: update README.md 2023-12-16 15:22:58 +08:00
cxfksword e3a4ea6d73 tweak: revert back cn domain. close #57 2023-12-16 15:12:18 +08:00
cxfksword a1b8533376
ci: Update issue templates 2023-12-02 17:10:06 +08:00
cxfksword 2522faebe2
ci: Update issue templates 2023-12-02 17:07:20 +08:00
cxfksword e3a559d970 build: update build script 2023-12-02 16:42:13 +08:00
cxfksword 17a2d093a6 tweak: optimize search match 2023-11-29 12:35:42 +08:00
cxfksword 2d25d398bd build: update build script 2023-11-25 22:45:24 +08:00
cxfksword ae7b22707e build: update build script 2023-11-25 09:46:44 +08:00
cxfksword 2655f025cc fix: remove ghproxy domain. #53 2023-11-25 09:44:57 +08:00
cxfksword 7a214962a5 build: update build script 2023-11-25 00:48:01 +08:00
cxfksword 0ed27ce2f3 build: update build script 2023-11-25 00:23:52 +08:00
cxfksword 7776b87e18 build: update build script 2023-11-24 23:42:04 +08:00
cxfksword b5436c10cc build: update build script 2023-11-24 23:32:27 +08:00
cxfksword 376b2967cc build: update build script 2023-11-24 23:28:18 +08:00
cxfksword ea4788f894 build: update build script 2023-11-24 23:21:33 +08:00
cxfksword d0f8f1e579
Update build.yaml 2023-11-24 22:56:14 +08:00
cxfksword 61d2a5a97f
Update build.yaml 2023-11-24 22:54:16 +08:00
cxfksword d27f0faf65
Update build.yaml 2023-11-24 22:45:02 +08:00
cxfksword a180b07716
Update build.yaml 2023-11-24 22:38:54 +08:00
cxfksword f263ded8dc build: update build script 2023-11-24 22:14:46 +08:00
cxfksword 63d8694c21 build: update build script 2023-11-24 21:58:21 +08:00
cxfksword 7589b61131 build: update build script 2023-11-24 21:50:19 +08:00
cxfksword 8fc6c8aa12 build: update build script 2023-11-24 21:46:10 +08:00
cxfksword 7b92a811b2 build: update build script 2023-11-24 21:34:24 +08:00
cxfksword 3513da7f12 tweak: replace ghproxy.com domain 2023-11-09 15:30:03 +08:00
cxfksword a6cdbce02d Revert "refactor: add douban httpclient"
This reverts commit 7e4246003d.
2023-09-16 21:45:17 +08:00
cxfksword 04c3ef949e tweak: update ui 2023-09-16 21:44:55 +08:00
cxfksword ca06af4b53 feat: add tmdb proxy config. close #45 2023-09-16 11:20:42 +08:00
cxfksword 45e4a40c28 fix build error 2023-09-03 16:18:27 +08:00
cxfksword c29f80725b tweak: change TMDB default config 2023-09-03 16:09:56 +08:00
cxfksword 7e4246003d refactor: add douban httpclient 2023-09-03 16:03:12 +08:00
cxfksword ed1a72dad6 docs: update README.md 2023-07-20 23:00:25 +08:00
cxfksword 5f5a5d091d ci: fix build 2023-07-20 22:29:23 +08:00
cxfksword 54574cf0ce ci: fix build 2023-07-20 22:23:01 +08:00
cxfksword e7eabfe7d7 ci: add beta 2023-07-20 22:07:45 +08:00
cxfksword 276498e179 ci: add beta 2023-07-20 21:58:21 +08:00
cxfksword c371144985 fix: fix not found dll 2023-07-20 21:37:42 +08:00
cxfksword 6e43e35c88
Merge pull request #40 from cxfksword/ILRepack
repack dll
2023-07-20 21:25:41 +08:00
cxfksword c43e2791ea refactor: rename api 2023-07-20 21:23:32 +08:00
cxfksword 51222ffb2f build: repack to one dll assembly 2023-07-19 13:54:33 +08:00
cxfksword a62d6e9ac7 fix: fix download actor image on https. (close #39) 2023-07-18 21:26:19 +08:00
cxfksword 4ee8e53705 build: repack to one dll assembly 2023-07-14 14:15:30 +08:00
cxfksword 41293f00e4 ci: update inactive action 2023-07-14 09:50:14 +08:00
cxfksword aa37dd4e99 fix: fix missing parts of English name. close #37 2023-07-14 09:49:45 +08:00
cxfksword 0d3aa8ce80 test: fix test 2023-07-11 16:30:00 +08:00
cxfksword 556f46e205 ci: update github action 2023-07-11 14:57:39 +08:00
cxfksword 2b07875250 fix: fix download douban image. close #35 2023-07-11 14:57:24 +08:00
cxfksword cff4008c57 Optimize person identity 2023-05-28 14:44:29 +08:00
cxfksword 71335df943 Optimize tmdb find 2023-05-28 14:44:05 +08:00
cxfksword 92215507b8 Optimize tmdb find #32 2023-05-28 12:21:17 +08:00
cxfksword fa3166ce63 Optimize identity. close #32 2023-05-28 10:49:40 +08:00
cxfksword 097b0514c4 Optimize person identity 2023-05-19 21:40:58 +08:00
cxfksword bf97db9fb4 Fix wrong imdb id 2023-05-13 11:17:48 +08:00
cxfksword e32e897559 Refactor code 2023-04-30 16:51:05 +08:00
cxfksword 771e14f2ad Optimize log 2023-04-14 23:30:59 +08:00
cxfksword 277ec77d15 Optimize identity 2023-04-06 23:05:37 +08:00
cxfksword ad03b16689 Update README.md 2023-04-06 23:05:05 +08:00
cxfksword 0722c03c23 Update github action 2023-04-06 23:04:47 +08:00
cxfksword 07ab257b2b Optimze series original name 2023-04-01 11:43:47 +08:00
cxfksword 84e87f9667 Update github action 2023-03-29 10:33:09 +08:00
cxfksword c5a0bf57be Update README.md 2023-03-25 14:35:28 +08:00
cxfksword 3f216c3336 Fix parse search year 2023-03-19 12:15:58 +08:00
cxfksword f1a1587ff3 Optimize identity 2023-03-19 12:02:35 +08:00
cxfksword c5fa522be5 Fix search only return one result 2023-03-19 00:19:02 +08:00
cxfksword a51c145361 Optimize identity 2023-03-18 23:37:01 +08:00
cxfksword a7431c5bd5 Add official rating. close #19 2023-03-18 11:58:39 +08:00
cxfksword 7b779a79b2 Fix search tmdb identity. close #21 2023-03-18 10:51:43 +08:00
cxfksword f93134139a Optimize identity 2023-03-12 16:07:24 +08:00
cxfksword 8ccf56b209 Update build 2023-03-11 13:53:22 +08:00
cxfksword f99b359926 Season remove tmdb actor 2023-03-11 13:52:35 +08:00
cxfksword 3f0ad0112d Optimize identity 2023-03-05 15:53:56 +08:00
cxfksword ebc701845a Fix long overview 2023-03-01 21:36:13 +08:00
cxfksword 4f4433fe63 Optimize identity 2023-02-25 09:58:42 +08:00
cxfksword 4e31feecc6 Remove default season number. #13 2023-02-23 20:03:01 +08:00
cxfksword e020a5950c Optimize identity 2023-02-22 21:18:03 +08:00
cxfksword a754686a5a Optimize identity 2023-02-22 21:15:36 +08:00
cxfksword 1cb72f488a Remove unused code 2023-02-21 22:48:23 +08:00
cxfksword 9af8599fad Optimize anime identity 2023-02-21 22:36:22 +08:00
cxfksword 5f7ce25b5a Optimize anime identity 2023-02-21 21:36:35 +08:00
cxfksword 7f347542fa Optimize anime identity 2023-02-21 21:33:26 +08:00
cxfksword ea3dfec554 Optimize anime identity 2023-02-21 19:10:09 +08:00
cxfksword 702fac4596 Optimize anime identity 2023-02-21 18:44:34 +08:00
cxfksword b46ab340f1 Optimize identity 2023-02-20 21:09:08 +08:00
cxfksword ee19b16c7b Optimize refresh metadata 2023-02-19 17:21:36 +08:00
cxfksword d4c4589758 Optimize identity 2023-02-18 21:47:24 +08:00
cxfksword 2ccabcf10a Optimize code 2023-02-18 16:01:59 +08:00
cxfksword 641102baa7 fix #14: old tmdb plugin meta bug 2023-02-18 15:45:41 +08:00
cxfksword 7bf1ea9970 Optimize code 2023-02-17 15:06:12 +08:00
cxfksword e982ea1233 Support movie collections 2023-02-17 14:44:35 +08:00
cxfksword 885217fd10 Change configuration default 2023-02-17 14:17:41 +08:00
cxfksword 5b03b345cb Remove unused code 2023-02-17 12:48:52 +08:00
cxfksword d74541e109 Optimize douban cookie reload 2023-02-17 12:39:51 +08:00
cxfksword aa4e8dae59 Support movie collections 2023-02-17 12:19:11 +08:00
cxfksword fefcc7ab11 Format actor overview 2023-02-17 12:08:22 +08:00
cxfksword 7656bb6094 Optimize identity 2023-02-05 15:23:41 +08:00
cxfksword 5f80adeebc Optimize identify 2023-02-03 22:45:31 +08:00
cxfksword 0b521f26d9 Update README.md 2023-02-03 09:24:32 +08:00
cxfksword c9df266b99 Optimize image 2023-01-19 18:37:55 +08:00
cxfksword 584d981713 Optimize introduce multiline 2023-01-19 12:47:15 +08:00
cxfksword e761d27024 fix: android app not show image 2023-01-19 12:20:38 +08:00
cxfksword dc4b7ba71d Fix variety show actor list 2023-01-18 18:39:59 +08:00
cxfksword b3792761c4 Fix variety show actor list 2023-01-18 18:25:58 +08:00
cxfksword 996a6a14d1 Optimize backdrop 2023-01-18 16:54:05 +08:00
cxfksword b01c8a4b5b Optimize backdrop 2023-01-18 16:39:14 +08:00
cxfksword d7db6dbc33 feat: add tmdb backdrop 2023-01-17 15:37:37 +08:00
cxfksword 8cb221a204 feat: add tmdb backdrop 2023-01-17 15:06:04 +08:00
cxfksword 194cb3471c fix: actor exception 2023-01-14 10:32:50 +08:00
cxfksword c79bfc5bd1 feat: add douban rate limit #12 2023-01-13 20:34:26 +08:00
cxfksword eede72d771 Remove unused code 2023-01-07 14:27:19 +08:00
cxfksword 88ed0e2cd5 Optimize parse name 2023-01-05 21:48:01 +08:00
cxfksword ab4e3ea873 Fix logger name 2023-01-05 15:17:40 +08:00
cxfksword 8e6b5df03f Fix proxy image 2023-01-05 15:06:18 +08:00
cxfksword c9b418da70 fix actor meta loss #10 2023-01-02 14:12:21 +08:00
cxfksword 2958a52ea5 Optimize identity 2022-12-30 14:34:04 +08:00
cxfksword 33d921f1a9 Optimize douban cookie 2022-12-23 21:45:29 +08:00
cxfksword ef4509d3f6 fix #7 2022-12-23 21:43:10 +08:00
cxfksword 66bafb9199 Fix actor image error #5 2022-12-18 12:01:25 +08:00
cxfksword ff7286af98 Fix bug 2022-12-16 00:41:49 +08:00
cxfksword da2ffec151 Fix check language 2022-12-15 17:24:06 +08:00
cxfksword 6cc53a4c92 Fix douban imdb parse 2022-12-15 17:04:39 +08:00
cxfksword 488bf80551 Optimize indentity mixed zh&en name 2022-12-15 16:38:30 +08:00
cxfksword b7ee0b956d Add tmdb api host config 2022-12-14 15:28:30 +08:00
cxfksword d3e839ec0e
Merge pull request #4 from C0dErBJ/parse_fix
优化识别
2022-12-14 14:49:34 +08:00
jlzhu 07e2f98c07 1.豆瓣api增加原名以及描述的摘取
2.优化名称截取,增加通过词库过滤干扰字符
3.修改TMDB接口地址
2022-12-12 16:43:07 +08:00
cxfksword 2899054876 Update github action 2022-11-12 11:47:13 +08:00
cxfksword e9061d8774 Fix null exception 2022-11-12 11:46:53 +08:00
cxfksword a49dcc71dc Optimize code 2022-11-10 17:14:01 +08:00
cxfksword 367dcf882c Optimize special season identity 2022-11-08 13:03:43 +08:00
cxfksword cdbb451743 Change logo 2022-11-02 11:54:05 +08:00
cxfksword 53345203b0 Change logo 2022-11-02 11:44:24 +08:00
cxfksword ae30940672 Optimize season & episode metadata fetch 2022-11-01 22:43:11 +08:00
cxfksword 20f753a826 Optimize season & episode metadata fetch 2022-11-01 13:19:47 +08:00
cxfksword be91a0af8a Optimize season meta fetch 2022-10-30 17:24:43 +08:00
cxfksword 02db6790f7
Update README.md 2022-10-29 22:27:37 +08:00
cxfksword 0631023fb1
Update README.md 2022-10-29 22:27:11 +08:00
cxfksword 8fb715039c
Update README.md 2022-10-29 22:26:39 +08:00
cxfksword 5b7145dce1
Update README.md 2022-10-29 22:19:19 +08:00
cxfksword 1e2a1762ee
Update README.md 2022-10-29 22:18:40 +08:00
cxfksword 4328960a31
Update README.md 2022-10-29 21:59:32 +08:00
cxfksword 50a6dd8cfe Merge branch 'main' of github.com:cxfksword/jellyfin-plugin-metashark 2022-10-29 21:45:18 +08:00
cxfksword 6b56ba4ea0 Parse anime special 2022-10-29 21:42:19 +08:00
cxfksword e021654359
Update README.md 2022-10-29 00:13:11 +08:00
cxfksword 14a77b0bc5
Update README.md 2022-10-29 00:11:00 +08:00
cxfksword 49b1a2f498 Add tmdb search toggle config 2022-10-28 11:54:04 +08:00
cxfksword 0effb688f5 Fix build script 2022-10-27 21:29:29 +08:00
cxfksword c109ba4c35 Update build script 2022-10-27 10:27:43 +08:00
cxfksword 2d1341f30b Fix person bug 2022-10-26 17:56:57 +08:00
cxfksword 54e9288dfa Fix parse name error 2022-10-26 15:42:14 +08:00
cxfksword 192865c7e1 Optimize anime name format 2022-10-26 14:27:33 +08:00
cxfksword ee8a090b11 Fix tmdb error 2022-10-25 21:20:56 +08:00
313 changed files with 9172 additions and 10638 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
Jellyfin.Plugin.MetaShark/Vendor/** linguist-vendored

View File

@ -0,0 +1,28 @@
---
name: 刮削失败相关问题
about: 报告刮削失败相关问题.
title: "[刮削]"
labels: ''
assignees: ''
---
**描述错误**
对错误是什么的清晰简明描述。
**屏幕截图**
请添加问题截图以帮助解释您的问题。
**日志**
请提供jellyfin打印的该影片的刮削日志。
日志查看方法: 控制台->高级->日志->点击log_yyyymmdd.log格式文件
**运行环境(请填写以下信息):**
- 操作系统:[例如 linux]
- jellyfin 版本:[例如 10.8.9]
- 插件版本:[例如 1.7.1]

47
.github/workflows/beta.yaml vendored Normal file
View File

@ -0,0 +1,47 @@
name: "🚀 Beta"
on:
workflow_dispatch:
env:
dotnet-version: 8.0.x
python-version: 3.8
project: Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj
artifact: metashark
jobs:
build:
runs-on: ubuntu-latest
name: Build & Release
steps:
- uses: actions/checkout@v3
- name: Get tags (For CHANGELOG)
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Setup dotnet
uses: actions/setup-dotnet@v3
id: dotnet
with:
dotnet-version: ${{ env.dotnet-version }}
- name: Change default dotnet version
run: |
echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json
- name: Initialize workflow variables
id: vars
run: |
VERSION=$(echo "${GITHUB_REF#refs/*/}" | sed s/^v//)
VERSION="$VERSION.0"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "APP_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_OUTPUT
- name: Build
run: |
dotnet restore ${{ env.project }} --no-cache
dotnet publish --nologo --no-restore --configuration=Release --framework=net8.0 ${{ env.project }}
mkdir -p artifacts
cp ./Jellyfin.Plugin.MetaShark/bin/Release/net8.0/Jellyfin.Plugin.MetaShark.dll ./artifacts/
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: ${{steps.vars.outputs.APP_NAME}}
path: artifacts
retention-days: 7

View File

@ -12,8 +12,12 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-dotnet@v3
id: dotnet
with:
dotnet-version: 6.0.x
dotnet-version: 8.0.x
- name: Change default dotnet version
run: |
echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json
- name: Install dependencies
run: dotnet restore
- name: Build

View File

@ -0,0 +1,20 @@
name: "🚫 Close Inactive"
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
jobs:
close-inactive:
runs-on: ubuntu-latest
steps:
- name: close-issues
uses: actions/stale@v7
with:
stale-issue-message: "This issue was closed due to inactive more than 30 days. You can reopen it if you think it should continue."
exempt-issue-labels: "FAQ,question,bug,enhancement"
days-before-stale: 30
days-before-close: 0
days-before-pr-stale: -1
days-before-pr-close: -1

View File

@ -5,7 +5,7 @@ on:
tags: ["*"]
env:
dotnet-version: 6.0.x
dotnet-version: 8.0.x
python-version: 3.8
project: Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj
artifact: metashark
@ -21,41 +21,42 @@ jobs:
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Setup dotnet
uses: actions/setup-dotnet@v3
id: dotnet
with:
dotnet-version: ${{ env.dotnet-version }}
- name: Change default dotnet version
run: |
echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json
- name: Setup python
uses: actions/setup-python@v4
with:
python-version: ${{ env.python-version }}
- name: Initialize workflow variables
id: vars
run: |
VERSION=$(echo "${GITHUB_REF#refs/*/}" | sed s/^v//)
VERSION="$VERSION.0"
echo ::set-output name=VERSION::${VERSION}
echo ::set-output name=APP_NAME::$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')
- name: Install dependencies
run: dotnet restore ${{ env.project }} --no-cache
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "APP_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_OUTPUT
- name: Build
run: dotnet publish --nologo --no-restore --configuration=Release --framework=net6.0 --output=artifacts -p:Version=${{steps.vars.outputs.VERSION}} ${{ env.project }}
- name: Compress build files
uses: thedoctor0/zip-release@main
with:
type: "zip"
directory: "artifacts"
filename: "artifacts.zip"
exclusions: "*.json *.pdb"
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: ${{ env.python-version }}
- name: Install JPRM
run: python -m pip install jprm
- name: Run JPRM
run: chmod +x ./build_plugin.sh && ./build_plugin.sh ${{ env.artifact }} ${{steps.vars.outputs.VERSION}} ${GITHUB_REF#refs/*/}
run: |
dotnet restore ${{ env.project }} --no-cache
dotnet publish --nologo --no-restore --configuration=Release --framework=net8.0 -p:Version=${{steps.vars.outputs.VERSION}} ${{ env.project }}
mkdir -p artifacts
zip -j ./artifacts/${{ env.artifact }}_${{steps.vars.outputs.VERSION}}.zip ./Jellyfin.Plugin.MetaShark/bin/Release/net8.0/Jellyfin.Plugin.MetaShark.dll
- name: Generate manifest
run: python3 ./scripts/generate_manifest.py ./artifacts/${{ env.artifact }}_${{steps.vars.outputs.VERSION}}.zip ${GITHUB_REF#refs/*/}
env:
CN_DOMAIN: ${{ vars.CN_DOMAIN }}
- name: Publish release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./${{ env.artifact }}/${{ env.artifact }}_*.zip
file: ./artifacts/${{ env.artifact }}_*.zip
tag: ${{ github.ref }}
release_name: '${{ github.ref_name }}: Jellyfin v10.9'
file_glob: true
overwrite: true
- name: Publish manifest
uses: svenstaro/upload-release-action@v2
with:

4
.gitignore vendored
View File

@ -5,5 +5,5 @@ obj/
artifacts
**/.DS_Store
metashark/
manifest_cn.json
manifest.json
*.json
.vscode

135
AnitomySharp/Anitomy.cs Normal file
View File

@ -0,0 +1,135 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// A library capable of parsing Anime filenames.
///
/// 用于解析动漫文件名的库。
///
/// This code is a C++ to C# port of <see href="https://github.com/erengy/anitomy">Anitomy</see>,
/// using the already existing Java port <see href="https://github.com/Vorror/anitomyJ">AnitomyJ</see> as a reference.
/// </summary>
public class AnitomySharp
{
/// <summary>
///
/// </summary>
private AnitomySharp() { }
/// <summary>
/// Parses an anime <paramref name="filename"/> into its consituent elements.
///
/// 将动画文件名拆分为其组成元素。
/// </summary>
/// <param name="filename">the anime file name 动画文件名</param>
/// <returns>the list of parsed elements 分解后的元素列表</returns>
public static IEnumerable<Element> Parse(string filename)
{
return Parse(filename, new Options());
}
/// <summary>
/// Parses an anime <paramref name="filename"/> into its constituent elements.
///
/// 将动画文件名拆分为其组成元素。
/// </summary>
/// <param name="filename">the anime file name 动画文件名</param>
/// <param name="options">the options to parse with, use <see cref="Parse(string)"/> to use default options</param>
/// <returns>the list of parsed elements 分解后的元素列表</returns>
/// <remarks>**逻辑:**
/// 1. 提取文件扩展名;
/// 2.
/// 3. #TODO
/// </remarks>
public static IEnumerable<Element> Parse(string filename, Options options)
{
var elements = new List<Element>(32);
var tokens = new List<Token>();
/** remove/parse extension */
var fname = filename;
if (options.ParseFileExtension)
{
var extension = "";
if (RemoveExtensionFromFilename(ref fname, ref extension))
{
/** 将文件扩展名元素加入元素列表 */
elements.Add(new Element(Element.ElementCategory.ElementFileExtension, extension));
}
}
/** set filename */
if (string.IsNullOrEmpty(filename))
{
return elements;
}
/** 将去除扩展名后的文件名加入元素列表 */
elements.Add(new Element(Element.ElementCategory.ElementFileName, fname));
/** tokenize
1.
2.
*/
var isTokenized = new Tokenizer(fname, elements, options, tokens).Tokenize();
if (!isTokenized)
{
return elements;
}
new Parser(elements, options, tokens).Parse();
// elements.ForEach(x => Console.WriteLine("\"" + x.Category + "\"" + ": " + "\"" + x.Value + "\""));
return elements;
}
/// <summary>
/// Removes the extension from the <paramref name="filename"/>
///
/// 确认扩展名有效,即在指定的<see cref="Element.ElementCategory.ElementFileExtension">文件扩展名元素类别</see>中,然后去除文件扩展名
/// </summary>
/// <param name="filename">the ref that will be updated with the new filename</param>
/// <param name="extension">the ref that will be updated with the file extension</param>
/// <returns>if the extension was successfully separated from the filename</returns>
private static bool RemoveExtensionFromFilename(ref string filename, ref string extension)
{
int position;
if (string.IsNullOrEmpty(filename) || (position = filename.LastIndexOf('.')) == -1)
{
return false;
}
/** remove file extension */
extension = filename.Substring(position + 1);
if (extension.Length > 4 || !extension.All(char.IsLetterOrDigit))
{
return false;
}
/** check if valid anime extension */
var keyword = KeywordManager.Normalize(extension);
if (!KeywordManager.Contains(Element.ElementCategory.ElementFileExtension, keyword))
{
return false;
}
filename = filename.Substring(0, position);
return true;
}
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>AnitomySharp.NET6</PackageId>
<PackageVersion>0.4.0</PackageVersion>
<Version>0.4.0</Version>
<Authors>tabratton;senritsu;chu-shen</Authors>
<Description>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.
This fork of AnitomySharp is inspired by tabratton and senritsu, which adds more custom rules.
</Description>
<RepositoryUrl>https://github.com/chu-shen/AnitomySharp.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>Anitomy Anime</PackageTags>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<DocumentationFile>AnitomySharp.xml</DocumentationFile>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Pack="true" Visible="false" PackagePath="" />
<None Include="..\README.md" Pack="true" PackagePath=""/>
</ItemGroup>
</Project>

197
AnitomySharp/Element.cs Normal file
View File

@ -0,0 +1,197 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp
{
/// <summary>
/// An <see cref="Element"/> represents an identified Anime <see cref="Token"/>.
/// A single filename may contain multiple of the same
/// token(e.g <see cref="ElementCategory.ElementEpisodeNumber"/>).
///
/// 一个元素即是一个已标识的标记(token)
///
/// 单个文件名可能包含多个相同的标记,比如:`ElementEpisodeNumber`元素类别的标记
/// </summary>
public class Element
{
/// <summary>
/// Element Categories
///
/// 元素类别
/// </summary>
public enum ElementCategory
{
/// <summary>
/// 元素类别:动画季度,不带<see cref="ElementAnimeSeasonPrefix"/>前缀
/// </summary>
ElementAnimeSeason,
/// <summary>
/// 元素类别:季度前缀,用于标识<see cref="ElementAnimeSeason">季度</see>的元素类别
/// </summary>
ElementAnimeSeasonPrefix,
/// <summary>
/// 元素类别:动画名
/// </summary>
ElementAnimeTitle,
/// <summary>
/// 元素类别:动画类型
/// </summary>
ElementAnimeType,
/// <summary>
/// 元素类别:动画年份,唯一
/// </summary>
ElementAnimeYear,
/// <summary>
/// 元素类别:音频术语
/// </summary>
ElementAudioTerm,
/// <summary>
/// 元素类别:设备,用于标识设备类型
/// </summary>
ElementDeviceCompatibility,
/// <summary>
/// 元素类别:剧集数
/// </summary>
ElementEpisodeNumber,
/// <summary>
/// 元素类别:等效剧集数,常见于多季度番剧
/// </summary>
ElementEpisodeNumberAlt,
/// <summary>
/// 元素类别剧集前缀比如“E”
/// </summary>
ElementEpisodePrefix,
/// <summary>
/// 元素类别:剧集名
/// </summary>
ElementEpisodeTitle,
/// <summary>
/// 元素类别:文件校验码,唯一
/// </summary>
ElementFileChecksum,
/// <summary>
/// 元素类别:文件扩展名,唯一
/// </summary>
ElementFileExtension,
/// <summary>
/// 文件名,唯一
/// </summary>
ElementFileName,
/// <summary>
/// 元素类别:语言
/// </summary>
ElementLanguage,
/// <summary>
/// 元素类别:其他,暂时无法分类的元素
/// </summary>
ElementOther,
/// <summary>
/// 元素类别:发布组,唯一
/// </summary>
ElementReleaseGroup,
/// <summary>
/// 元素类别:发布信息
/// </summary>
ElementReleaseInformation,
/// <summary>
/// 元素类别:发布版本
/// </summary>
ElementReleaseVersion,
/// <summary>
/// 元素类别:来源
/// </summary>
ElementSource,
/// <summary>
/// 元素类别:字幕
/// </summary>
ElementSubtitles,
/// <summary>
/// 元素类别:视频分辨率
/// </summary>
ElementVideoResolution,
/// <summary>
/// 元素类别:视频术语
/// </summary>
ElementVideoTerm,
/// <summary>
/// 元素类别:卷数
/// </summary>
ElementVolumeNumber,
/// <summary>
/// 元素类别:卷前缀
/// </summary>
ElementVolumePrefix,
/// <summary>
/// 元素类别:未知元素类型
/// </summary>
ElementUnknown
}
/// <summary>
///
/// </summary>
public ElementCategory Category { get; set; }
/// <summary>
///
/// </summary>
public string Value { get; }
/// <summary>
/// Constructs a new Element
///
/// 构造一个元素
/// </summary>
/// <param name="category">the category of the element</param>
/// <param name="value">the element's value</param>
public Element(ElementCategory category, string value)
{
Category = category;
Value = value;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override int GetHashCode()
{
return -1926371015 + Value.GetHashCode();
}
/// <summary>
///
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public override bool Equals(object obj)
{
if (this == obj)
{
return true;
}
if (obj == null || GetType() != obj.GetType())
{
return false;
}
var other = (Element)obj;
return Category.Equals(other.Category);
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override string ToString()
{
return $"Element{{category={Category}, value='{Value}'}}";
}
}
}

464
AnitomySharp/Keyword.cs Normal file
View File

@ -0,0 +1,464 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// A class to manager the list of known anime keywords. This class is analogous to <c>keyword.cpp</c> of Anitomy, and <c>KeywordManager.java</c> of AnitomyJ
///
/// 本类用于管理已知动漫关键词列表
///
/// </summary>
public static class KeywordManager
{
/// <summary>
/// 包含所有关键词的内部关键词元素词典,比较器忽略大小写
/// </summary>
private static readonly Dictionary<string, Keyword> Keys = new Dictionary<string, Keyword>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 文件扩展名,无值,比较器忽略大小写
/// </summary>
private static readonly Dictionary<string, Keyword> Extensions = new Dictionary<string, Keyword>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// ~~一眼真~~
///
/// 在主逻辑前预处理用的关键词集合,优先处理后被视为一个标识(token),不会被后续操作拆散。
///
/// 如果关键词中部分字符包含在<see cref="Options.AllowedDelimiters"/>,强烈建议添加到此列表(注意:仅添加无歧义关键词)
///
/// 如果没有添加,后续处理的时候会被<see cref="Options.AllowedDelimiters"/>拆分。不过程序带了验证方法<see cref="Tokenizer.ValidateDelimiterTokens"/>,可以一定程度上重新还原关键词
/// </summary>
private static readonly List<Tuple<Element.ElementCategory, List<string>>> PeekEntries;
/// <summary>
/// 添加元素类别的关键词至<see cref="Keys"/>
/// </summary>
static KeywordManager()
{
var optionsDefault = new KeywordOptions();
var optionsInvalid = new KeywordOptions(true, true, false);
var optionsUnidentifiable = new KeywordOptions(false, true, true);
var optionsUnidentifiableInvalid = new KeywordOptions(false, true, false);
var optionsUnidentifiableUnsearchable = new KeywordOptions(false, false, true);
Add(Element.ElementCategory.ElementAnimeSeasonPrefix,
optionsUnidentifiable,
new List<string> { "SAISON", "SEASON" });
Add(Element.ElementCategory.ElementAnimeType,
optionsUnidentifiable,
new List<string> {
"GEKIJOUBAN", "MOVIE",
"OAD", "OAV", "ONA", "OVA",
"TV",
"番外編", "總集編","DRAMA",
"映像特典","特典","特典アニメ",
// 特典 Special 剩下的各种类型可以全部命名成 SP对于较特殊意义的特典也可以自定义命名
"SPECIAL", "SPECIALS", "SP", "SPs", "特報",
// 真人特典 Interview/Talk/Stage... 目前我们对于节目、采访、舞台活动、制作等三次元画面的长视频,一概怼成 IV。
"IV",
// 音乐视频 Music Video
"MV"});
// add "SP" to ElementAnimeType with optionsUnidentifiable
// Add(Element.ElementCategory.ElementAnimeType,
// optionsUnidentifiableUnsearchable,
// new List<string> { "SP" }); // e.g. "Yumeiro Patissiere SP Professional", but it is widely used to represent special
Add(Element.ElementCategory.ElementAnimeType,
optionsUnidentifiableInvalid,
new List<string> {
// https://github.com/vcb-s/VCB-S_Collation/blob/master/specification.md
// 无字 OP/ED Non-Credit Opening/Ending
"ED", "ENDING", "NCED", "NCOP", "OP", "OPENING",
// 预告 Preview 预告下一话内容 注意编号表示其预告的是第几话的内容而不是跟在哪一话后面
"PREVIEW", "YOKOKU", "予告",
// 菜单 Menu BD/DVD 播放选择菜单
"MENU",
// 广告 Commercial Message 电视放送广告,时长一般在 7s/15s/30s/45s/... 左右
"CM","SPOT",
// 语音信息
"MESSAGE",
// 宣传片/预告片 Promotion Video / Trailer 一般时长在 1~2min 命名参考原盘和 jsum
"PV", "Teaser","TRAILER",
// 真人特典 Interview/Talk/Stage... 目前我们对于节目、采访、舞台活动、制作等三次元画面的长视频,一概怼成 IV。
"INTERVIEW",
"EVENT", "TOKUTEN", "LOGO"});
Add(Element.ElementCategory.ElementAudioTerm,
optionsDefault,
new List<string> {
// Audio channels
"5.1","7.1","2CH", "6CH",
"DTS", "DTS-ES", "DTS-MA", "DTS-HD","DTS-HDMA",
"TRUE-HD", "TRUEHD", "THD",
// Audio codec
"AAC", "AACX2", "AACX3", "AACX4", "2XAAC", "3XAAC", "2AAC", "3AAC",
"AC3", "AC3X2","AC3X3", "EAC3", "E-AC-3",
"FLAC", "FLACX2", "FLACX3", "FLACX4", "2XFLAC", "3XFLAC", "2FLAC", "3FLAC", "4FLAC",
"LOSSLESS", "MP3", "OGG", "VORBIS",
"ATMOS",
// Audio language
"DUAL","DUALAUDIO"
});
Add(Element.ElementCategory.ElementDeviceCompatibility,
optionsDefault,
new List<string> { "IPAD3", "IPHONE5", "IPOD", "PS3", "PS3アプコン", "XBOX", "XBOX360", "PSP" });
Add(Element.ElementCategory.ElementDeviceCompatibility,
optionsUnidentifiable,
new List<string> { "ANDROID" });
Add(Element.ElementCategory.ElementEpisodePrefix,
optionsDefault,
new List<string> { "EP", "EP.", "EPS", "EPS.", "EPISODE", "EPISODE.", "EPISODES", "CAPITULO", "EPISODIO", "EPIS\u00F3DIO", "FOLGE" });
Add(Element.ElementCategory.ElementEpisodePrefix,
optionsInvalid,
new List<string> { "E", "\\x7B2C" }); // single-letter episode keywords are not valid tokens
Add(Element.ElementCategory.ElementFileExtension,
optionsDefault,
new List<string> { "3GP", "AVI", "DIVX", "FLV", "M2TS", "MKV", "MOV", "MP4", "MPG",
"OGM", "RM", "RMVB", "TS", "WEBM", "WMV" });
Add(Element.ElementCategory.ElementFileExtension,
optionsInvalid,
new List<string> { "AAC", "AIFF", "FLAC", "M4A", "MP3", "MKA", "OGG", "WAV", "WMA", "7Z", "RAR", "ZIP", "ASS", "SRT" });
Add(Element.ElementCategory.ElementLanguage,
optionsDefault,
new List<string> { "ENG", "ENGLISH", "ESPANO", "JAP", "PT-BR", "SPANISH", "VOSTFR",
"ZH-HANS", "ZH-HANT", "CHS", "CHT", "CHN", "JPN", "JPSC", "JPTC" });
Add(Element.ElementCategory.ElementLanguage,
optionsUnidentifiable,
new List<string> { "ESP", "ITA", "SC", "TC" }); // e.g. "Tokyo ESP:, "Bokura ga Ita"
Add(Element.ElementCategory.ElementOther,
optionsDefault,
new List<string> { "REMASTER", "REMASTERED", "UNCUT", "TS", "VFR", "WIDESCREEN", "WS", "SPURSENGINE","DISC" });
Add(Element.ElementCategory.ElementReleaseGroup,
optionsDefault,
new List<string> {
// rip group
"AI-RAWS","AIROTA","ANK-RAWS","ANK","ANE","AKATOMBA-RAWS","ATTKC","BEANSUB","BEATRICE-RAWS",
"CASO","COOLCOMIC","COMMIE","DANNI","DMG","DYMY","EUPHO","EMTP-RAWS","ENKANREC","EXILED-DESTINY","FLSNOW",
"FREEWIND","FZSD","GTX-RAWS","GST","HAKUGETSU","HQR","HKG","JYFANSUB","JSUM","KAGURA","KAMETSU",
"KAMIGAMI-RAWS","KAMIGAMI","诸神字幕组","KNA-SUBS","KOEISUB","KTXP","LOWPOWER-RAWS","LKSUB",
"LIUYUN","LOLIHOUSE","LITTLEBAKAS!","MABORS","MAWEN1250","MGRT","MMZY-SUB","MH","MOOZZI2",
"PUSSUB","POPGO","PHILOSOPHY-RAWS","PPP-RAW","QTS","RARBG","RATH","REINFORCE","RUELL-NEXT","RUELL-RAWS",
"R1RAW","SNOW-RAWS","SFEO-RAWS","SHINSEN-SUBS","SHIROKOI","SWEETSUB","SUMISORA","SOFCJ-RAWS","TSDM",
"THORA","TUCAPTIONS","TXXZ","UCCUSS","UHA-WINGS","U2-RIP","VCB-STUDIO","VCB-S","XYX98","XKSUB","XRIP",
"异域-11番小队","YYDM","YUSYABU","YLBUDSUB","ZAGZAD","AHU-SUB",
"HYSUB", "SAKURATO", "SKYMOON-RAWS", "COMICAT&KISSSUB","FUSSOIR",
// bangumi
"ANI", "NC-RAWS", "LILITH-RAWS", "NAN-RAWS","MINGY","NANDESUKA","KISSSUB",
// other
"PTER",
// echi
"脸肿字幕组","魔穗字幕组","桜都字幕组","MAHOXOKAZU","極彩花夢",
// Unidentifiable
"YUUKI"
});
Add(Element.ElementCategory.ElementReleaseInformation,
optionsDefault,
new List<string> {
"BATCH", "COMPLETE", "PATCH", "REMUX", "REV", "REPACK", "FIN",
"生肉", "熟肉",
// source
"BILIBILI","B-GLOBAL", "BAHA", "GYAO!", "U-NEXT","SENTAI"});
Add(Element.ElementCategory.ElementReleaseInformation,
optionsDefault,
new List<string> {
// echi
"18禁", "18禁アニメ", "15禁", "無修正", "无修正", "无码", "無碼","有码", "NOWATERMARK","CENSORED","UNCENSORED","DECENSORED","有修正","无删减","未删减","有删减",
// echi Studios
"AIC","ANIMAC","ANIMAN","APPLE","BOOTLEG","CELEB","CHERRYLIPS","CHIPPAI","COLLABORATIONWORKS","COSMOS","DREAMディースリー","DISCOVERY","DODER","EDGEエッジ","EDGE","EROZUKI","HILLS","HONNYBIT","JAM","JHV","JVD","MILKY蜜","MILKY","MOONROCK","MS-PICTURES","NUR","OFFICEAO","PASHMINAA","PASHMINA","PETIT","PINKPINEAPPLE","PIXY","PORO","QUEENBEE","SCHOOLZONE","SHS","SPERMATION","STARLINK","TDKコア","UNCEN","UTAMARO","VIB","ZIZ","アニアン","ひまじん","エイベックス・トラックス","オブテイン・フューチャー","クランベリー","ジャパンホームビデオ","ジュエル","バニラ","ピンクパイナップル","ファイブウェイズ","ミューズ","プリンセス・プロダクション","れもんは~と","れもんは〜と","アムモ","じゅうしぃまんご~","メリージェーン","メリー・ジェーン","せるふぃっしゅ","アームス","アスミック","フェアリーダスト","メリー_ジェーン","ばにぃうぉ~か~","ショーテン","あんてきぬすっ","エンゼルフィッシュ","オゼ","ガールズトーク","クリムゾン","サークルトリビュート","こっとんど~る","カナメプロダクション","オレンジビデオハウス","ウエスト・ケープ・コーポレーション","メリー・ジェーン","アートミック","シネマパラダイス","あかとんぼ","ディースリー","ディスカバリー","ナック映画","メディアバンク","ボイジャーエンターテイメント","ミントハウス","フレンズ","ミルクセーキ","ハニーディップ","パック・イン・ビデオ","サン出版","わるきゅ~れ++","虫プロダクション","バンダイビジュアル","ハピネット・ピクチャーズXビクターエンタテインメント","ちちのや","センテスタジオ","メディアセブン","セブン","スタジオ・ファンタジア","ショコラ","カレス・コミュニケーションズ","ソフト・オン・デマンド","ソフト・オン・デマンド","セントリリア","2匹目のどぜう","37℃","北栄商事","創美企画","創美","大映映像","大映","晋遊舎","晋遊社","鈴木みら乃","虎の穴","蜜MITSU","魔人","妄想実現めでぃあ","遊人","真空間","宇宙企画"
});
Add(Element.ElementCategory.ElementReleaseInformation,
optionsUnidentifiable,
new List<string> { "END", "FINAL" }); // e.g. "The End of Evangelion", 'Final Approach"
Add(Element.ElementCategory.ElementReleaseVersion,
optionsDefault,
new List<string> { "V0", "V1", "V2", "V3", "V4" });
Add(Element.ElementCategory.ElementSource,
optionsDefault,
new List<string> {"BD", "BDRIP", "BD-BOX", "BDBOX", "UHD", "UHDRIP", "BLURAY", "BLU-RAY",
"DVD", "DVD5", "DVD9", "DVD-R2J", "DVDRIP", "DVD-RIP",
"R2DVD", "R2J", "R2JDVD", "R2JDVDRIP",
"HDTV", "HDTVRIP", "TVRIP", "TV-RIP",
"WEBCAST", "WEBRIP", "WEB-DL", "WEB",
"DLRIP"});
Add(Element.ElementCategory.ElementSubtitles,
optionsDefault,
new List<string> { "ASS", "GB", "BIG5", "DUB", "DUBBED", "HARDSUB", "HARDSUBS", "RAW", "SOFTSUB",
"SOFTSUBS", "SUB", "SUBBED", "SUBTITLED" });
Add(Element.ElementCategory.ElementVideoTerm,
optionsDefault,
new List<string> {
// Frame rate
"24FPS", "30FPS", "48FPS", "60FPS", "120FPS","SVFI",
// Video codec
"8BIT", "8-BIT", "10BIT", "10BITS", "10-BIT", "10-BITS",
"HEVC-10BIT", "HEVC-YUV420P10","X264-10BIT", "X264-HI10P",
"HI10", "HI10P", "MA10P","MA444-10P", "HI444", "HI444P", "HI444PP",
"H264", "H265", "X264", "X265",
"AVC", "HEVC", "HEVC2", "DIVX", "DIVX5", "DIVX6", "XVID",
"YUV420", "YUV420P8", "YUV420P10", "YUV420P10LE", "YUV444", "YUV444P10", "YUV444P10LE","AV1",
"MAIN10", "MAIN10P", "MAIN12", "MAIN12P",
"HDR", "HDR10", "HMAX","DOVI","DOLBY VISION",
// Video format
"AVI", "RMVB", "WMV", "WMV3", "WMV9", "MKV", "MP4", "MPEG",
// Video quality
"HQ", "LQ",
// Video resolution
"UHD", "HD", "SD"});
Add(Element.ElementCategory.ElementVolumePrefix,
optionsDefault,
new List<string> { "VOL", "VOL.", "VOLUME" });
PeekEntries = new List<Tuple<Element.ElementCategory, List<string>>>
{
Tuple.Create(Element.ElementCategory.ElementAnimeType, new List<string> {
// 预告
"WEB PREVIEW" }),
Tuple.Create(Element.ElementCategory.ElementAudioTerm, new List<string> { "2.0CH", "5.1CH", "7.1CH", "DTS5.1", "MA.5.1", "MA.2.0", "MA.7.1", "TRUEHD5.1", "DDP5.1", "DD5.1", "DUAL AUDIO" }),
Tuple.Create(Element.ElementCategory.ElementVideoTerm, new List<string> { "H.264", "H264", "H.265", "X.264", "23.976FPS", "29.97FPS", "59.94FPS", "59.940FPS" }),// e.g. "H264-Flac"
Tuple.Create(Element.ElementCategory.ElementVideoResolution, new List<string> { "480P", "720P", "1080P", "2160P", "4K", "6K", "8K" }),
Tuple.Create(Element.ElementCategory.ElementReleaseGroup, new List<string> { "X_X", "A.I.R.NESSUB", "FUDAN_NRC", "T.H.X", "MAHO.SUB", "OKAZU.SUB", "THUNDER.SUB","ORION ORIGIN", "NEKOMOE KISSATEN" }),
Tuple.Create(Element.ElementCategory.ElementReleaseInformation, new List<string> {
// echi
"NO WATERMARK", "ALL PRODUCTS", "AN DERCEN", "BLUE EYES", "BOMB! CUTE! BOMB!", "COLLABORATION WORKS", "GREEN BUNNY", "GOLD BEAR", "HOODS ENTERTAINMENT", "HOT BEAR", "KING BEE", "PLATINUM MILKY", "MOON ROCK", "OBTAIN FUTURE", "QUEEN BEE", "SOFT DEMAND", "STUDIO ZEALOT", "SURVIVE MORE", "WHITE BEAR", "メリー ジェーン", "ビーム エンタテインメント", "蜜 -MITSU-","W.C.C.","J.A.V.N.","HSHARE.NET" })
};
}
/// <summary>
/// 字符串(<paramref name="word"/>)转换为大写
/// </summary>
/// <param name="word">待转换的字符串</param>
/// <returns>返回当前字符串的大写形式</returns>
public static string Normalize(string word)
{
return string.IsNullOrEmpty(word) ? word : word.ToUpperInvariant();
}
/// <summary>
/// 判断元素列表中是否包含给定的字符串(<paramref name="keyword"/>)
/// </summary>
/// <param name="category">元素类别</param>
/// <param name="keyword">待判断的字符串</param>
/// <returns>`true`表示包含</returns>
public static bool Contains(Element.ElementCategory category, string keyword)
{
var keys = GetKeywordContainer(category);
if (keys.TryGetValue(keyword, out var foundEntry))
{
return foundEntry.Category == category;
}
return false;
}
/// <summary>
/// 判断预处理元素列表中是否包含给定的字符串(<paramref name="keyword"/>)
/// </summary>
/// <param name="category">元素类别</param>
/// <param name="keyword">待判断的字符串</param>
/// <returns>`true`表示包含</returns>
public static bool ContainsInPeekEntries(Element.ElementCategory category, string keyword)
{
return PeekEntries.Any(entry => entry.Item1 == category && entry.Item2.Contains(keyword, StringComparer.OrdinalIgnoreCase));
}
/// <summary>
/// Finds a particular <c>keyword</c>. If found sets <c>category</c> and <c>options</c> to the found search result.
///
/// 查找给定的关键词,并更新其元素分类和关键词配置
///
/// 如果在<see cref="Keys"/>中找到,则将<see cref="Keys"/>中此关键词对应的元素分类和关键词配置赋给给定的关键词
/// </summary>
/// <param name="keyword">the keyword to search for</param>
/// <param name="category">the reference that will be set/changed to the found keyword category</param>
/// <param name="options">the reference that will be set/changed to the found keyword options</param>
/// <returns>true if the keyword was found</returns>
public static bool FindAndSet(string keyword, ref Element.ElementCategory category, ref KeywordOptions options)
{
var keys = GetKeywordContainer(category);
if (!keys.TryGetValue(keyword, out var foundEntry))
{
return false;
}
if (category == Element.ElementCategory.ElementUnknown)
{
category = foundEntry.Category;
}
else if (foundEntry.Category != category)
{
return false;
}
options = foundEntry.Options;
return true;
}
/// <summary>
/// Given a particular <c>filename</c> and <c>range</c> attempt to preidentify the token before we attempt the main parsing logic
///
/// 在使用主处理逻辑前,尝试对给定的文件名和范围预先确定标记(token),关键词来自<see cref="PeekEntries"/>
/// </summary>
/// <param name="filename">the filename</param>
/// <param name="range">the search range</param>
/// <param name="elements">elements array that any pre-identified elements will be added to</param>
/// <param name="preidentifiedTokens">elements array that any pre-identified token ranges will be added to</param>
public static void PeekAndAdd(string filename, TokenRange range, List<Element> elements, List<TokenRange> preidentifiedTokens)
{
var endR = range.Offset + range.Size;
/** 获得本次操作的字符串 */
var search = filename.Substring(range.Offset, endR > filename.Length ? filename.Length - range.Offset : endR - range.Offset);
foreach (var entry in PeekEntries)
{
foreach (var keyword in entry.Item2)
{
var foundIdx = search.IndexOf(keyword, StringComparison.CurrentCultureIgnoreCase);
if (foundIdx == -1) continue;
foundIdx += range.Offset;
/** 将一眼真的关键字加入元素列表 */
elements.Add(new Element(entry.Item1, filename.Substring(foundIdx, keyword.Length)));
// elements.Add(new Element(entry.Item1, keyword));
/** 将匹配到的关键词字符串范围添加到preidentifiedTokens */
preidentifiedTokens.Add(new TokenRange(foundIdx, keyword.Length));
}
}
}
// Private API
/// <summary>
/// Returns the appropriate keyword container.
///
/// 返回合适的内部关键词元素词典<see cref="Keys"/>
///
/// 如果元素类型为文件扩展名,则返回空值的<see cref="Extensions"/>,否则返回<see cref="Keys"/>
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
private static Dictionary<string, Keyword> GetKeywordContainer(Element.ElementCategory category)
{
return category == Element.ElementCategory.ElementFileExtension ? Extensions : Keys;
}
/// <summary>
/// Adds a <c>category</c>, <c>options</c>, and <c>keywords</c> to the internal keywords list.
///
/// 将元素分类、关键词配置添加给指定的关键词列表。最终形成一个内部关键词元素词典<see cref="Keys"/>
/// </summary>
/// <param name="category"></param>
/// <param name="options"></param>
/// <param name="keywords"></param>
private static void Add(Element.ElementCategory category, KeywordOptions options, IEnumerable<string> keywords)
{
var keys = GetKeywordContainer(category);
foreach (var key in keywords.Where(k => !string.IsNullOrEmpty(k) && !keys.ContainsKey(k)))
{
keys[key] = new Keyword(category, options);
}
}
}
/// <summary>
/// Keyword options for a particular keyword.
///
/// 关键词配置
/// </summary>
public class KeywordOptions
{
/// <summary>
/// 是否可分辨,是否会产生歧义,是否会出现在动画标题中
/// </summary>
public bool Identifiable { get; }
/// <summary>
/// 是否可检索 #TODO
///
/// <see cref="ParserHelper.IsElementCategorySearchable"/>
/// </summary>
public bool Searchable { get; }
/// <summary>
/// 是否有效 #TODO
/// </summary>
public bool Valid { get; }
/// <summary>
/// 默认关键词配置:可识别,可检索,有效
/// </summary>
public KeywordOptions() : this(true, true, true) { }
/// <summary>
/// Constructs a new keyword options
///
/// 构造一个关键词配置
/// </summary>
/// <param name="identifiable">if the token is identifiable</param>
/// <param name="searchable">if the token is searchable</param>
/// <param name="valid">if the token is valid</param>
public KeywordOptions(bool identifiable, bool searchable, bool valid)
{
Identifiable = identifiable;
Searchable = searchable;
Valid = valid;
}
}
/// <summary>
/// A Keyword
///
/// 关键词结构体
/// </summary>
public struct Keyword
{
/// <summary>
/// 元素类别 <see cref="Element.ElementCategory"/>
/// </summary>
public readonly Element.ElementCategory Category;
/// <summary>
/// 关键词配置 <see cref="KeywordOptions"/>
/// </summary>
public readonly KeywordOptions Options;
/// <summary>
/// Constructs a new Keyword
///
/// 构造一个新的关键词
/// </summary>
/// <param name="category">the category of the keyword</param>
/// <param name="options">the keyword's options</param>
public Keyword(Element.ElementCategory category, KeywordOptions options)
{
Category = category;
Options = options;
}
}
}

58
AnitomySharp/Options.cs Normal file
View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp
{
/// <summary>
/// AnitomySharp search configuration options
///
/// 提取元素时的默认配置项
/// </summary>
public class Options
{
/// <summary>
/// 提取元素时使用的分隔符
/// </summary>
public string AllowedDelimiters { get; }
/// <summary>
/// 是否尝试提取集数。`true`表示提取
/// </summary>
public bool ParseEpisodeNumber { get; }
/// <summary>
/// 是否尝试提取本集标题。`true`表示提取
/// </summary>
public bool ParseEpisodeTitle { get; }
/// <summary>
/// 是否提取文件扩展名。`true`表示提取
/// </summary>
public bool ParseFileExtension { get; }
/// <summary>
/// 是否提取发布组。`true`表示提取
/// </summary>
public bool ParseReleaseGroup { get; }
/// <summary>
/// 提取元素时的配置项
/// </summary>
/// <param name="delimiters">默认值:" _.+,|"</param>
/// <param name="episode">默认值true</param>
/// <param name="title">默认值true</param>
/// <param name="extension">默认值true</param>
/// <param name="group">默认值true</param>
public Options(string delimiters = " _.+,| ", bool episode = true, bool title = true, bool extension = true, bool group = true)
{
AllowedDelimiters = delimiters;
ParseEpisodeNumber = episode;
ParseEpisodeTitle = title;
ParseFileExtension = extension;
ParseReleaseGroup = group;
}
}
}

558
AnitomySharp/Parser.cs Normal file
View File

@ -0,0 +1,558 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace AnitomySharp
{
/// <summary>
/// Class to classify <see cref="Token"/>s
///
/// 用于标记(token)分类的类
/// </summary>
public class Parser
{
/// <summary>
/// 用于确认<see cref="Element.ElementCategory.ElementEpisodeNumber"/>元素是否已存在
/// </summary>
public bool IsEpisodeKeywordsFound { get; private set; }
/// <summary>
/// <see cref="ParserHelper"/>
/// </summary>
public ParserHelper ParseHelper { get; }
/// <summary>
/// <see cref="ParserNumber"/>
/// </summary>
public ParserNumber ParseNumber { get; }
/// <summary>
/// 元素列表 <see cref="Elements"/>
/// </summary>
public List<Element> Elements { get; }
/// <summary>
/// 标记列表 <see cref="Tokens"/>
/// </summary>
public List<Token> Tokens { get; }
/// <summary>
/// 提取元素时的<see cref="Options">配置项</see>
/// </summary>
private Options Options { get; }
/// <summary>
/// Constructs a new token parser
///
/// 构造一个标记(token)解析
///
/// 并创建ParserHelper和ParserNumber各一个实例
/// </summary>
/// <param name="elements">the list where parsed elements will be added</param>
/// <param name="options">the parser options</param>
/// <param name="tokens">the list of tokens</param>
public Parser(List<Element> elements, Options options, List<Token> tokens)
{
Elements = elements;
Options = options;
Tokens = tokens;
ParseHelper = new ParserHelper(this);
ParseNumber = new ParserNumber(this);
}
/// <summary>
/// Begins the parsing process
///
/// 开始处理
/// </summary>
/// <returns></returns>
public bool Parse()
{
SearchForKeywords();
SearchForIsolatedNumbers();
if (Options.ParseEpisodeNumber)
{
SearchForEpisodeNumber();
}
SearchForAnimeTitle();
if (Options.ParseReleaseGroup && Empty(Element.ElementCategory.ElementReleaseGroup))
{
SearchForReleaseGroup();
}
if (Options.ParseEpisodeTitle && !Empty(Element.ElementCategory.ElementEpisodeNumber))
{
SearchForEpisodeTitle();
}
ValidateElements();
return Empty(Element.ElementCategory.ElementAnimeTitle);
}
/// <summary>
/// Search for anime keywords.
///
/// 主要是根据关键词列表匹配标记(token),并将匹配到的关键字添加到元素列表
/// </summary>
private void SearchForKeywords()
{
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
/** 过滤已知标记类型的标记 */
if (token.Category != Token.TokenCategory.Unknown) continue;
var word = token.Content;
word = word.Trim(" -".ToCharArray());
if (string.IsNullOrEmpty(word)) continue;
// Don't bother if the word is a number that cannot be CRC
if (word.Length != 8 && StringHelper.IsNumericString(word)) continue;
var keyword = KeywordManager.Normalize(word);
var category = Element.ElementCategory.ElementUnknown;
var keywordOptions = new KeywordOptions();
/** 首先在关键词列表中匹配关键词如无则执行else */
if (KeywordManager.FindAndSet(keyword, ref category, ref keywordOptions))
{
/** 根据配置跳过发布组元素 */
if (!Options.ParseReleaseGroup && category == Element.ElementCategory.ElementReleaseGroup) continue;
/** 跳过配置为不能搜索的元素 */
if (!ParseHelper.IsElementCategorySearchable(category) || !keywordOptions.Searchable) continue;
/** 跳过已经包含的Singular元素类别 */
if (ParseHelper.IsElementCategorySingular(category) && !Empty(category)) continue;
switch (category)
{
case Element.ElementCategory.ElementAnimeSeasonPrefix:
ParseHelper.CheckAndSetAnimeSeasonKeyword(token, i);
continue;
case Element.ElementCategory.ElementEpisodePrefix when keywordOptions.Valid:
ParseHelper.CheckExtentKeyword(Element.ElementCategory.ElementEpisodeNumber, i, token);
continue;
case Element.ElementCategory.ElementReleaseVersion:
word = word.Substring(1);
break;
case Element.ElementCategory.ElementVolumePrefix:
ParseHelper.CheckExtentKeyword(Element.ElementCategory.ElementVolumeNumber, i, token);
continue;
}
}
else
{
/** 如果还不存在ElementFileChecksum元素类型且该标记满足Crc32规则 */
if (Empty(Element.ElementCategory.ElementFileChecksum) && ParserHelper.IsCrc32(word))
{
category = Element.ElementCategory.ElementFileChecksum;
}
/** 如果还不存在ElementVideoResolution元素类型且该标记满足分辨率规则 */
else if (Empty(Element.ElementCategory.ElementVideoResolution) && ParserHelper.IsResolution(word))
{
category = Element.ElementCategory.ElementVideoResolution;
}
}
/** 如果此标记的元素分类仍为ElementUnknown则跳过此标记的处理*/
if (category == Element.ElementCategory.ElementUnknown) continue;
Elements.Add(new Element(category, word));
if (keywordOptions.Identifiable)
{
token.Category = Token.TokenCategory.Identifier;
}
}
}
/// <summary>
/// Search for episode number.
///
/// 匹配标记列表中的集数
/// </summary>
private void SearchForEpisodeNumber()
{
var tokens = new List<int>();
var allTokens = new List<int>();
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
// List all unknown tokens that contain a number
if (token.Category == Token.TokenCategory.Unknown &&
ParserHelper.IndexOfFirstDigit(token.Content) != -1)
{
tokens.Add(i);
allTokens.Add(i);
}
}
if (tokens.Count == 0)
{
// search Japanese Pattern without number
if (Empty(Element.ElementCategory.ElementEpisodeNumber))
{
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
if (token.Category == Token.TokenCategory.Unknown &&
ParserHelper.IndexOfFirstDigit(token.Content) == -1)
{
ParseNumber.MatchJapaneseCounterPattern(token.Content, token);
}
}
}
return;
}
IsEpisodeKeywordsFound = !Empty(Element.ElementCategory.ElementEpisodeNumber);
// If a token matches a known episode pattern, it has to be the episode number
if (ParseNumber.SearchForEpisodePatterns(tokens)) return;
// We have previously found an episode number via keywords
if (!Empty(Element.ElementCategory.ElementEpisodeNumber)) return;
// From now on, we're only interested in numeric tokens
tokens.RemoveAll(r => !StringHelper.IsNumericString(Tokens[r].Content));
// e.g. "01 (176)", "29 (04)"
if (ParseNumber.SearchForEquivalentNumbers(tokens)) return;
// e.g. " - 08"
if (ParseNumber.SearchForSeparatedNumbers(tokens)) return;
// "e.g. "[12]", "(2006)"
if (ParseNumber.SearchForIsolatedNumbers(tokens)) return;
// e.g. "OVA 3", "OtherToken[Hint05]", "[Web Preview 06]": maybe incorrect, so put the last
if (ParseNumber.SearchForSymbolWithEpisode(allTokens)) return;
// e.g. [13(341)], [13 (341)]
if (ParseNumber.SearchForEquivalentNumbersWithBracket(allTokens)) return;
// Consider using the last number as a last resort
ParseNumber.SearchForLastNumber(tokens);
}
/// <summary>
/// Search for anime title
///
/// 搜索动画名
/// </summary>
private void SearchForAnimeTitle()
{
var enclosedTitle = false;
var tokenBegin = Token.FindToken(Tokens, 0, Tokens.Count, Token.TokenFlag.FlagNotEnclosed, Token.TokenFlag.FlagUnknown);
// without ReleaseGroup, only anime title e.g. "[2005][Paniponi Dash!][BDRIP][1080P][1-26Fin+OVA+SP]"
var tokenBeginWithNoReleaseGroup = Tokens.Count;
// If that doesn't work, find the first unknown token in the second enclosed
// group, assuming that the first one is the release group
if (!Token.InListRange(tokenBegin, Tokens))
{
tokenBegin = 0;
enclosedTitle = true;
var skippedPreviousGroup = false;
do
{
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagUnknown);
tokenBeginWithNoReleaseGroup = tokenBegin;
if (!Token.InListRange(tokenBegin, Tokens)) break;
// Ignore groups that are composed of non-Latin characters or non-Chinese characters
// 对于同时有中英文名称,并且两者分割开来,如:“[異域字幕組][漆黑的子彈][Black Bullet][11][1280x720][繁体].mp4”则只会返回第一个匹配到的
if ((StringHelper.IsMostlyLatinString(Tokens[tokenBegin].Content) || StringHelper.IsMostlyChineseString(Tokens[tokenBegin].Content)) && skippedPreviousGroup)
{
break;
}
// if ReleaseGroup is empty
if (Options.ParseReleaseGroup && Empty(Element.ElementCategory.ElementReleaseGroup))
{
// Get the first unknown token of the next group
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket);
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagUnknown);
}
// make sure the new token don't in Element.ElementCategory
// if in or outListRange
// return pretoken
// TODO match other ElementCategory
if ((Token.InListRange(tokenBegin, Tokens) && KeywordManager.Contains(Element.ElementCategory.ElementAnimeType, Tokens[tokenBegin].Content.ToUpper()))
|| tokenBegin == Tokens.Count)
{
tokenBegin = tokenBeginWithNoReleaseGroup;
}
// 去除纯数字标题
// skip token with only number
if (Regex.Match(Tokens[tokenBegin].Content, ParserNumber.RegexMatchOnlyStart + @"^[0-9]+$" + ParserNumber.RegexMatchOnlyEnd).Success)
{
tokenBegin = tokenBeginWithNoReleaseGroup;
}
skippedPreviousGroup = true;
} while (Token.InListRange(tokenBegin, Tokens));
}
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until an identifier (or a bracket, if the title is enclosed) is found
var tokenEnd = Token.FindToken(
Tokens,
tokenBegin,
Tokens.Count,
Token.TokenFlag.FlagIdentifier,
enclosedTitle ? Token.TokenFlag.FlagBracket : Token.TokenFlag.FlagNone);
// If within the interval there's an open bracket without its matching pair,
// move the upper endpoint back to the bracket
if (!enclosedTitle)
{
var lastBracket = tokenEnd;
var bracketOpen = false;
for (var i = tokenBegin; i < tokenEnd; i++)
{
if (Tokens[i].Category != Token.TokenCategory.Bracket) continue;
lastBracket = i;
bracketOpen = !bracketOpen;
}
if (bracketOpen) tokenEnd = lastBracket;
}
// If the interval ends with an enclosed group (e.g. "Anime Title [Fansub]"),
// move the upper endpoint back to the beginning of the group. We ignore
// parentheses in order to keep certain groups (e.g. "(TV)") intact.
if (!enclosedTitle)
{
var token = Token.FindPrevToken(Tokens, tokenEnd, Token.TokenFlag.FlagNotDelimiter);
while (ParseHelper.IsTokenCategory(token, Token.TokenCategory.Bracket) && Tokens[token].Content[0] != ')')
{
token = Token.FindPrevToken(Tokens, token, Token.TokenFlag.FlagBracket);
if (!Token.InListRange(token, Tokens)) continue;
tokenEnd = token;
token = Token.FindPrevToken(Tokens, tokenEnd, Token.TokenFlag.FlagNotDelimiter);
}
}
ParseHelper.BuildElement(Element.ElementCategory.ElementAnimeTitle, false, Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
}
/// <summary>
/// Search for release group
///
/// 搜索发布组
/// </summary>
private void SearchForReleaseGroup()
{
for (int tokenBegin = 0, tokenEnd = tokenBegin; tokenBegin < Tokens.Count;)
{
// Find the first enclosed unknown token
tokenBegin = Token.FindToken(Tokens, tokenEnd, Tokens.Count, Token.TokenFlag.FlagEnclosed, Token.TokenFlag.FlagUnknown);
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until a bracket or identifier is found
tokenEnd = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket, Token.TokenFlag.FlagIdentifier);
// 去除纯数字发布组
if (Regex.Match(Tokens[tokenBegin].Content, ParserNumber.RegexMatchOnlyStart + @"^[0-9]+$" + ParserNumber.RegexMatchOnlyEnd).Success) continue;
if (!Token.InListRange(tokenEnd, Tokens) || Tokens[tokenEnd].Category != Token.TokenCategory.Bracket) continue;
// Ignore if it's not the first non-delimiter token in group
var prevToken = Token.FindPrevToken(Tokens, tokenBegin, Token.TokenFlag.FlagNotDelimiter);
if (Token.InListRange(prevToken, Tokens) && Tokens[prevToken].Category != Token.TokenCategory.Bracket) continue;
ParseHelper.BuildElement(Element.ElementCategory.ElementReleaseGroup, true, Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
return;
}
}
/// <summary>
/// Search for episode title
///
/// 搜索剧集标题
/// </summary>
private void SearchForEpisodeTitle()
{
int tokenBegin;
var tokenEnd = 0;
do
{
// Find the first non-enclosed unknown token
tokenBegin = Token.FindToken(Tokens, tokenEnd, Tokens.Count, Token.TokenFlag.FlagNotEnclosed, Token.TokenFlag.FlagUnknown);
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until a bracket or identifier is found
tokenEnd = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket, Token.TokenFlag.FlagIdentifier);
// Ignore if it's only a dash
if (tokenEnd - tokenBegin <= 2 && ParserHelper.IsDashCharacter(Tokens[tokenBegin].Content[0])) continue;
//if (tokenBegin.Pos == null || tokenEnd.Pos == null) continue;
ParseHelper.BuildElement(Element.ElementCategory.ElementEpisodeTitle, false, Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
return;
} while (Token.InListRange(tokenBegin, Tokens));
}
/// <summary>
/// Search for isolated numbers
///
/// 搜索孤立数字的处理逻辑
/// </summary>
private void SearchForIsolatedNumbers()
{
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
/** 跳过括号标记类型的标记 */
if (token.Category != Token.TokenCategory.Unknown) continue;
var tokenContent = token.Content;
// e.g. "2016-17"
const string regexPattern = ParserNumber.RegexMatchOnlyStart + @"(\d{1,4})([-~&+])(\d{2,4})" + ParserNumber.RegexMatchOnlyEnd;
var match = Regex.Match(token.Content, regexPattern);
if (match.Success)
{
tokenContent = tokenContent.Split(match.Groups[2].Value)[0];
}
if (!StringHelper.IsNumericString(tokenContent))
{
continue;
}
// e.g. "[2021 OVA]"
if(ParseHelper.IsNextTokenContainAnimeType(i)&&!ParseHelper.IsTokenIsolated(i)){}
// TODO may not be necessary
// if (!ParseHelper.IsTokenIsolated(i))
// {
// continue;
// }
var number = StringHelper.StringToInt(tokenContent);
// Anime year
if (number >= ParserNumber.AnimeYearMin && number <= ParserNumber.AnimeYearMax)
{
if (Empty(Element.ElementCategory.ElementAnimeYear))
{
Elements.Add(new Element(Element.ElementCategory.ElementAnimeYear, tokenContent));
token.Category = Token.TokenCategory.Identifier;
continue;
}
}
// Video resolution
if (number != 480 && number != 720 && number != 1080 && number != 2160) continue;
// If these numbers are isolated, it's more likely for them to be the
// video resolution rather than the episode number. Some fansub groups use these without the "p" suffix.
// if (!Empty(Element.ElementCategory.ElementVideoResolution)) continue;
Elements.Add(new Element(Element.ElementCategory.ElementVideoResolution, token.Content));
token.Category = Token.TokenCategory.Identifier;
}
}
/// <summary>
/// Validate Elements
///
/// 验证元素有效性
/// </summary>
private void ValidateElements()
{
if (!Empty(Element.ElementCategory.ElementAnimeType) && !Empty(Element.ElementCategory.ElementEpisodeTitle))
{
var episodeTitle = Get(Element.ElementCategory.ElementEpisodeTitle);
for (var i = 0; i < Elements.Count;)
{
var el = Elements[i];
if (el.Category == Element.ElementCategory.ElementAnimeType)
{
if (episodeTitle.Contains(el.Value))
{
if (episodeTitle.Length == el.Value.Length)
{
Elements.RemoveAll(element =>
element.Category == Element.ElementCategory.ElementEpisodeTitle); // invalid episode title
}
else
{
var keyword = KeywordManager.Normalize(el.Value);
if (KeywordManager.Contains(Element.ElementCategory.ElementAnimeType, keyword))
{
i = Erase(el); // invalid anime type
continue;
}
}
}
}
++i;
}
}
}
/// <summary>
/// Returns whether or not the parser contains this category
///
/// 判断当前的元素列表<see cref="Elements"/>是否包含传入的元素类别
/// </summary>
/// <param name="category"></param>
/// <returns>不包含则返回`true`,否则`false`</returns>
private bool Empty(Element.ElementCategory category)
{
return Elements.All(element => element.Category != category);
}
/// <summary>
/// Returns the value of a particular category
///
/// 返回传入元素类别的值
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
private string Get(Element.ElementCategory category)
{
var foundElement = Elements.Find(element => element.Category == category);
if (foundElement != null) return foundElement.Value;
Element e = new Element(category, "");
Elements.Add(e);
foundElement = e;
return foundElement.Value;
}
/// <summary>
/// Deletes the first element with the same <c>element.Category</c> and returns the deleted element's position.
///
/// 删除第一个具有相同<see cref="Element.Category"/>的元素
/// </summary>
/// <param name="element"></param>
/// <returns>返回被删除元素的位置</returns>
private int Erase(Element element)
{
var removedIdx = -1;
for (var i = 0; i < Elements.Count; i++)
{
var currentElement = Elements[i];
if (element.Category != currentElement.Category) continue;
removedIdx = i;
Elements.RemoveAt(i);
break;
}
return removedIdx;
}
}
}

View File

@ -0,0 +1,461 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AnitomySharp
{
/// <summary>
/// Utility class to assist in the parsing.
///
/// 辅助解析的工具类
/// </summary>
public class ParserHelper
{
/// <summary>
/// 破折号
/// </summary>
private const string Dashes = "-\u2010\u2011\u2012\u2013\u2014\u2015";
/// <summary>
/// 带空格的破折号
/// </summary>
private const string DashesWithSpace = " -\u2010\u2011\u2012\u2013\u2014\u2015";
/// <summary>
/// 英文与数字匹配词典
/// </summary>
private static readonly Dictionary<string, string> Ordinals = new Dictionary<string, string>
{
{"1st", "1"}, {"First", "1"},
{"2nd", "2"}, {"Second", "2"},
{"3rd", "3"}, {"Third", "3"},
{"4th", "4"}, {"Fourth", "4"},
{"5th", "5"}, {"Fifth", "5"},
{"6th", "6"}, {"Sixth", "6"},
{"7th", "7"}, {"Seventh", "7"},
{"8th", "8"}, {"Eighth", "8"},
{"9th", "9"}, {"Ninth", "9"},
{"一", "1"}, {"壱", "1"},
{"二", "2"}, {"弐", "2"},
{"三", "3"}, {"参", "3"},
{"四", "4"}, {"上", "1"},
{"五", "5"}, {"下", "2"},
{"六", "6"}, {"前", "1"},
{"七", "7"}, {"後", "2"},
{"八", "8"}, {"中", "2"}, //most only 2 episodes
{"九", "9"}, {"", "1"},
{"十", "10"},{"Ⅱ", "2"},
{"Ⅲ", "3"}
};
/// <summary>
///
/// </summary>
private readonly Parser _parser;
/// <summary>
///
/// </summary>
/// <param name="parser"></param>
public ParserHelper(Parser parser)
{
_parser = parser;
}
/// <summary>
/// Returns whether or not the <c>result</c> matches the <c>category</c>.
///
/// 判断传入的标记(token)的类型是否与传入的类别一致
/// </summary>
/// <param name="result"></param>
/// <param name="category"></param>
/// <returns></returns>
public bool IsTokenCategory(int result, Token.TokenCategory category)
{
return Token.InListRange(result, _parser.Tokens) && _parser.Tokens[result].Category == category;
}
/// <summary>
/// Returns whether or not the <c>str</c> is a CRC string.
///
/// 如果给定字符串为CRC则返回`true`
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsCrc32(string str)
{
return str != null && str.Length == 8 && StringHelper.IsHexadecimalString(str);
}
/// <summary>
/// Returns whether or not the <c>character</c> is a dash character
///
/// 判断给定字符是否为破折号
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
public static bool IsDashCharacter(char c)
{
return Dashes.Contains(c.ToString());
}
/// <summary>
/// Returns a number from an original (e.g. 2nd)
///
/// 转换原始值中的英文数字
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static string GetNumberFromOrdinal(string str)
{
if (string.IsNullOrEmpty(str)) return "";
return Ordinals.TryGetValue(str, out var foundString) ? foundString : "";
}
/// <summary>
/// 转换原始值中的全角数字
///
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static string GetNumberFromFullWidth(string str)
{
string output = str;
for (int i = 0; i < str.Length; i++)
{
if (char.IsDigit(str[i]))
{
int fullwidthDigit = (int)str[i];
if (fullwidthDigit >= 65296 && fullwidthDigit <= 65305)
{
int halfwidthDigit = fullwidthDigit - 65248;
output = output.Replace(str[i], (char)halfwidthDigit);
}
}
}
return output;
}
/// <summary>
/// Returns the index of the first digit in the <c>str</c>; -1 otherwise.
///
/// 返回<c>str</c>中第一个数字的索引位置
/// </summary>
/// <param name="str"></param>
/// <returns>如果无数字,则返回-1</returns>
public static int IndexOfFirstDigit(string str)
{
if (string.IsNullOrEmpty(str)) return -1;
for (var i = 0; i < str.Length; i++)
{
if (char.IsDigit(str, i))
{
return i;
}
}
return -1;
}
/// <summary>
/// Returns whether or not the <c>str</c> is a resolution.
///
/// 如果给定字符串为分辨率,则返回`true`
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsResolution(string str)
{
if (string.IsNullOrEmpty(str)) return false;
const int minWidthSize = 3;
const int minHeightSize = 3;
if (str.Length >= minWidthSize + 1 + minHeightSize)
{
var pos = str.IndexOfAny("xX\u00D7".ToCharArray());
if (pos == -1 || pos < minWidthSize || pos > str.Length - (minHeightSize + 1)) return false;
return !str.Where((t, i) => i != pos && !char.IsDigit(t)).Any();
}
if (str.Length < minHeightSize + 1) return false;
{
if (char.ToLower(str[str.Length - 1]) != 'p') return false;
for (var i = 0; i < str.Length - 1; i++)
{
if (!char.IsDigit(str[i])) return false;
}
return true;
}
}
/// <summary>
/// Returns whether or not the <c>category</c> is searchable.
///
///
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
public bool IsElementCategorySearchable(Element.ElementCategory category)
{
switch (category)
{
case Element.ElementCategory.ElementAnimeSeasonPrefix:
case Element.ElementCategory.ElementAnimeType:
case Element.ElementCategory.ElementAudioTerm:
case Element.ElementCategory.ElementDeviceCompatibility:
case Element.ElementCategory.ElementEpisodePrefix:
case Element.ElementCategory.ElementFileChecksum:
case Element.ElementCategory.ElementLanguage:
case Element.ElementCategory.ElementOther:
case Element.ElementCategory.ElementReleaseGroup:
case Element.ElementCategory.ElementReleaseInformation:
case Element.ElementCategory.ElementReleaseVersion:
case Element.ElementCategory.ElementSource:
case Element.ElementCategory.ElementSubtitles:
case Element.ElementCategory.ElementVideoResolution:
case Element.ElementCategory.ElementVideoTerm:
case Element.ElementCategory.ElementVolumePrefix:
return true;
default:
return false;
}
}
/// <summary>
/// Returns whether the <c>category</c> is singular.
///
///
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
public bool IsElementCategorySingular(Element.ElementCategory category)
{
switch (category)
{
case Element.ElementCategory.ElementAnimeSeason:
case Element.ElementCategory.ElementAnimeType:
case Element.ElementCategory.ElementAudioTerm:
case Element.ElementCategory.ElementDeviceCompatibility:
case Element.ElementCategory.ElementEpisodeNumber:
case Element.ElementCategory.ElementLanguage:
case Element.ElementCategory.ElementOther:
case Element.ElementCategory.ElementReleaseInformation:
case Element.ElementCategory.ElementSource:
case Element.ElementCategory.ElementVideoTerm:
return false;
default:
return false;
}
}
/// <summary>
/// Returns whether or not a token at the current <c>pos</c> is isolated(surrounded by braces).
///
/// 判断当前位置标记(token)是否孤立,是否被括号包裹
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public bool IsTokenIsolated(int pos)
{
var prevToken = Token.FindPrevToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(prevToken, Token.TokenCategory.Bracket)) return false;
var nextToken = Token.FindNextToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
return IsTokenCategory(nextToken, Token.TokenCategory.Bracket);
}
/// <summary>
/// Returns whether or not a token at the current <c>pos</c> is isolated(surrounded by braces, delimiter).
///
/// 判断当前位置标记(token)是否孤立,前面是否为分隔符,后面是否为括号包裹
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public bool IsTokenIsolatedWithDelimiterAndBracket(int pos)
{
var prevToken = Token.FindPrevToken(_parser.Tokens, pos, Token.TokenFlag.FlagNone);
if (!IsTokenCategory(prevToken, Token.TokenCategory.Delimiter)) return false;
var nextToken = Token.FindNextToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
return IsTokenCategory(nextToken, Token.TokenCategory.Bracket);
}
/// <summary>
/// Returns whether or not a token at the current <c>pos+1</c> is ElementAnimeType.
///
/// 判断当前标记(token)的下一个标记的类型是否为ElementAnimeType。如果是则返回`true`
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public bool IsNextTokenContainAnimeType(int pos)
{
var prevToken = Token.FindPrevToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(prevToken, Token.TokenCategory.Bracket)) return false;
var nextToken = Token.FindNextToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(prevToken, _parser.Tokens) || !Token.InListRange(nextToken, _parser.Tokens)) return false;
return KeywordManager.Contains(Element.ElementCategory.ElementAnimeType, _parser.Tokens[nextToken].Content);
}
/// <summary>
/// 判断当前标记(token)的上一个标记的类型是否为ElementAnimeType。如果是则返回`true`
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public bool IsPrevTokenContainAnimeType(int pos)
{
var prevToken = Token.FindPrevToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
var nextToken = Token.FindNextToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if(!Token.InListRange(prevToken, _parser.Tokens)||!Token.InListRange(nextToken, _parser.Tokens)) return false;
if (!IsTokenCategory(nextToken, Token.TokenCategory.Bracket)) return false;
return KeywordManager.Contains(Element.ElementCategory.ElementAnimeType, _parser.Tokens[prevToken].Content);
}
/// <summary>
/// 判断当前标记(token)的上一个标记的类型是否为ElementAnimeType在 PeekEntries 中)。如果是,则返回`true`
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public bool IsPrevTokenContainAnimeTypeInPeekEntries(int pos)
{
var prevToken = Token.FindPrevToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
var nextToken = Token.FindNextToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(prevToken, _parser.Tokens) || !Token.InListRange(nextToken, _parser.Tokens)) return false;
if (!IsTokenCategory(nextToken, Token.TokenCategory.Bracket)) return false;
return KeywordManager.ContainsInPeekEntries(Element.ElementCategory.ElementAnimeType, _parser.Tokens[prevToken].Content);
}
/// <summary>
/// Finds and sets the anime season keyword.
///
/// 查找动画季度关键词并添加对应元素
/// </summary>
/// <param name="token"></param>
/// <param name="currentTokenPos"></param>
/// <returns></returns>
public bool CheckAndSetAnimeSeasonKeyword(Token token, int currentTokenPos)
{
void SetAnimeSeason(Token first, Token second, string content)
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeSeason, content));
first.Category = Token.TokenCategory.Identifier;
second.Category = Token.TokenCategory.Identifier;
}
var previousToken = Token.FindPrevToken(_parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (Token.InListRange(previousToken, _parser.Tokens))
{
var number = GetNumberFromOrdinal(_parser.Tokens[previousToken].Content);
if (!string.IsNullOrEmpty(number))
{
SetAnimeSeason(_parser.Tokens[previousToken], token, number);
return true;
}
}
var nextToken = Token.FindNextToken(_parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(nextToken, _parser.Tokens) ||
!StringHelper.IsNumericString(_parser.Tokens[nextToken].Content)) return false;
SetAnimeSeason(token, _parser.Tokens[nextToken], _parser.Tokens[nextToken].Content);
return true;
}
/// <summary>
/// A Method to find the correct volume/episode number when prefixed (i.e. Vol.4).
///
/// 用于查找带前缀的正确的卷数/集数值
/// </summary>
/// <param name="category">the category we're searching for</param>
/// <param name="currentTokenPos">the current token position</param>
/// <param name="token">the token</param>
/// <returns>true if we found the volume/episode number</returns>
public bool CheckExtentKeyword(Element.ElementCategory category, int currentTokenPos, Token token)
{
var nToken = Token.FindNextToken(_parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(nToken, Token.TokenCategory.Unknown)) return false;
if (IndexOfFirstDigit(_parser.Tokens[nToken].Content) != 0) return false;
switch (category)
{
case Element.ElementCategory.ElementEpisodeNumber:
if (!_parser.ParseNumber.MatchEpisodePatterns(_parser.Tokens[nToken].Content, _parser.Tokens[nToken]))
{
_parser.ParseNumber.SetEpisodeNumber(_parser.Tokens[nToken].Content, _parser.Tokens[nToken], false);
}
break;
case Element.ElementCategory.ElementVolumeNumber:
if (!_parser.ParseNumber.MatchVolumePatterns(_parser.Tokens[nToken].Content, _parser.Tokens[nToken]))
{
_parser.ParseNumber.SetVolumeNumber(_parser.Tokens[nToken].Content, _parser.Tokens[nToken], false);
}
break;
}
token.Category = Token.TokenCategory.Identifier;
return true;
}
/// <summary>
///
/// </summary>
/// <param name="category"></param>
/// <param name="keepDelimiters"></param>
/// <param name="tokens"></param>
public void BuildElement(Element.ElementCategory category, bool keepDelimiters, List<Token> tokens)
{
var element = new StringBuilder();
for (var i = 0; i < tokens.Count; i++)
{
var token = tokens[i];
switch (token.Category)
{
case Token.TokenCategory.Unknown:
element.Append(token.Content);
token.Category = Token.TokenCategory.Identifier;
break;
case Token.TokenCategory.Bracket:
element.Append(token.Content);
break;
case Token.TokenCategory.Delimiter:
var delimiter = "";
if (!string.IsNullOrEmpty(token.Content))
{
delimiter = token.Content[0].ToString();
}
if (keepDelimiters)
{
element.Append(delimiter);
}
else if (Token.InListRange(i, tokens))
{
switch (delimiter)
{
case ",":
case "&":
element.Append(delimiter);
break;
default:
element.Append(' ');
break;
}
}
break;
}
}
if (!keepDelimiters)
{
element = new StringBuilder(element.ToString().Trim(DashesWithSpace.ToCharArray()));
}
if (!string.IsNullOrEmpty(element.ToString()))
{
_parser.Elements.Add(new Element(category, element.ToString()));
}
}
}
}

View File

@ -0,0 +1,900 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace AnitomySharp
{
/// <summary>
/// A utility class to assist in number parsing.
/// </summary>
public class ParserNumber
{
/// <summary>
/// 动画最小计算年份
/// </summary>
public const int AnimeYearMin = 1900;
/// <summary>
/// 动画最大计算年份
/// </summary>
public const int AnimeYearMax = 2100;
/// <summary>
///
/// </summary>
private const int EpisodeNumberMax = 9999;
/// <summary>
/// 最大卷数
/// </summary>
private const int VolumeNumberMax = 99;
/// <summary>
/// 正则开头
/// </summary>
public const string RegexMatchOnlyStart = @"\A(?:";
/// <summary>
/// 正则结尾
/// </summary>
public const string RegexMatchOnlyEnd = @")\z";
/// <summary>
///
/// </summary>
private readonly Parser _parser;
/// <summary>
///
/// </summary>
/// <param name="parser"></param>
public ParserNumber(Parser parser)
{
_parser = parser;
}
/// <summary>
/// Returns whether or not the <c>number</c> is a volume number
///
/// 返验证卷数字符串是否有效
/// </summary>
private static bool IsValidVolumeNumber(string number)
{
return StringHelper.StringToInt(number) <= VolumeNumberMax;
}
/// <summary>
/// Returns whether or not the <c>number</c> is a valid episode number.
///
/// 验证集数字符串是否有效
/// </summary>
private static bool IsValidEpisodeNumber(string number)
{
// Eliminate non numeric portion of number, then parse as double.
var temp = "";
for (var i = 0; i < number.Length && char.IsDigit(number[i]); i++)
{
temp += number[i];
}
return !string.IsNullOrEmpty(temp) && double.Parse(temp) <= EpisodeNumberMax;
}
/// <summary>
/// Sets the alternative episode number.
/// </summary>
private bool SetAlternativeEpisodeNumber(string number, Token token)
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementEpisodeNumberAlt, number));
token.Category = Token.TokenCategory.Identifier;
return true;
}
/// <summary>
/// Sets the volume number.
///
/// 添加卷数元素
/// </summary>
/// <param name="number">the number</param>
/// <param name="token">the token which contains the volume number</param>
/// <param name="validate">true if we should check if it's a valid number, false to disable verification</param>
/// <returns>true if the volume number was set</returns>
public bool SetVolumeNumber(string number, Token token, bool validate)
{
if (validate && !IsValidVolumeNumber(number)) return false;
_parser.Elements.Add(new Element(Element.ElementCategory.ElementVolumeNumber, number));
token.Category = Token.TokenCategory.Identifier;
return true;
}
/// <summary>
/// Sets the anime episode number.
///
/// 添加集数元素
/// </summary>
/// <param name="number">the episode number</param>
/// <param name="token">the token which contains the volume number</param>
/// <param name="validate">true if we should check if it's a valid episode number; false to disable validation</param>
/// <returns>true if the episode number was set</returns>
public bool SetEpisodeNumber(string number, Token token, bool validate)
{
if (validate && !IsValidEpisodeNumber(number)) return false;
token.Category = Token.TokenCategory.Identifier;
var category = Element.ElementCategory.ElementEpisodeNumber;
/** Handle equivalent numbers */
if (_parser.IsEpisodeKeywordsFound)
{
foreach (var element in _parser.Elements)
{
if (element.Category != Element.ElementCategory.ElementEpisodeNumber) continue;
/** The larger number gets to be the alternative one */
var comparison = StringHelper.StringToInt(number) - StringHelper.StringToInt(element.Value);
if (comparison > 0)
{
category = Element.ElementCategory.ElementEpisodeNumberAlt;
}
else if (comparison < 0)
{
element.Category = Element.ElementCategory.ElementEpisodeNumberAlt;
}
else
{
return false; /** No need to add the same number twice */
}
break;
}
}
_parser.Elements.Add(new Element(category, number));
return true;
}
/// <summary>
/// Checks if a number follows the specified <c>token</c>
///
/// 确认此标记中是否包含给定元素类型的关键词,如果包含且其能满足匹配模式,则添加此元素
/// </summary>
/// <param name="category">the category to set if a number follows the <c>token</c></param>
/// <param name="token">the token</param>
/// <returns>true if a number follows the token; false otherwise</returns>
private bool NumberComesAfterPrefix(Element.ElementCategory category, Token token)
{
var numberBegin = ParserHelper.IndexOfFirstDigit(token.Content);
var prefix = StringHelper.SubstringWithCheck(token.Content, 0, numberBegin).ToUpperInvariant();
if (!KeywordManager.Contains(category, prefix)) return false;
var number = StringHelper.SubstringWithCheck(token.Content, numberBegin, token.Content.Length - numberBegin);
switch (category)
{
case Element.ElementCategory.ElementEpisodePrefix:
if (!MatchEpisodePatterns(number, token))
{
SetEpisodeNumber(number, token, false);
}
return true;
case Element.ElementCategory.ElementVolumePrefix:
if (!MatchVolumePatterns(number, token))
{
SetVolumeNumber(number, token, false);
}
return true;
default:
return false;
}
}
/// <summary>
/// Checks whether the number precedes the word "of"
/// </summary>
/// <param name="token">the token</param>
/// <param name="currentTokenIdx">the index of the token</param>
/// <returns>true if the token precedes the word "of"</returns>
private bool NumberComesBeforeAnotherNumber(Token token, int currentTokenIdx)
{
var separatorToken = Token.FindNextToken(_parser.Tokens, currentTokenIdx, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(separatorToken, _parser.Tokens)) return false;
var separators = new List<Tuple<string, bool>>
{
Tuple.Create("&", true),
Tuple.Create("of", false)
};
foreach (var separator in separators)
{
if (_parser.Tokens[separatorToken].Content != separator.Item1) continue;
var otherToken = Token.FindNextToken(_parser.Tokens, separatorToken, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(otherToken, _parser.Tokens)
|| !StringHelper.IsNumericString(_parser.Tokens[otherToken].Content)) continue;
SetEpisodeNumber(token.Content, token, false);
if (separator.Item2)
{
SetEpisodeNumber(_parser.Tokens[otherToken].Content, _parser.Tokens[otherToken], false);
}
_parser.Tokens[separatorToken].Category = Token.TokenCategory.Identifier;
_parser.Tokens[otherToken].Category = Token.TokenCategory.Identifier;
return true;
}
return false;
}
// EPISODE MATCHERS
/// <summary>
/// Attempts to find an episode/season inside a <c>word</c>
///
/// 在传入的字符串中共尝试匹配季/集
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the word was matched to an episode/season number</returns>
public bool MatchEpisodePatterns(string word, Token token)
{
if (StringHelper.IsNumericString(word)) return false;
word = word.Trim(" -".ToCharArray());
// 根据前后是否为数字进行分流处理
var numericFront = char.IsDigit(word[0]);
var numericBack = char.IsDigit(word[word.Length - 1]);
if (numericFront && numericBack)
{
// e.g. "01v2"
if (MatchSingleEpisodePattern(word, token))
{
return true;
}
// e.g. "01-02", "03-05v2"
if (MatchMultiEpisodePattern(word, token))
{
return true;
}
// e.g. "07.5"
if (MatchFractionalEpisodePattern(word, token))
{
return true;
}
}
if (numericBack)
{
// e.g. "2x01", "S01E03", "S01-02xE001-150"
if (MatchSeasonAndEpisodePattern(word, token))
{
return true;
}
// e.g. "#01", "#02-03v2"
if (MatchNumberSignPattern(word, token))
{
return true;
}
}
// e.g. "ED1", "OP4a", "OVA2"
if (!numericFront && MatchTypeAndEpisodePattern(word, token))
{
return true;
}
// e.g. "4a", "111C"
if (numericFront && !numericBack && MatchPartialEpisodePattern(word, token))
{
return true;
}
// e.g. "01-24Fin"
if (word.IndexOf("fin", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (MatchMultiEpisodePattern(word, token))
{
return true;
}
}
// U+8A71 is used as counter for stories, episodes of TV series, etc.
return MatchJapaneseCounterPattern(word, token);
}
/// <summary>
/// Match a single episode pattern. e.g. "01v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSingleEpisodePattern(string word, Token token)
{
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,4})[vV](\d)" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
SetEpisodeNumber(match.Groups[1].Value, token, false);
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
return true;
}
/// <summary>
/// Match a multi episode pattern. e.g. "01-02", "03-05v2", "01-24Fin".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchMultiEpisodePattern(string word, Token token)
{
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,4})(?:[vV](\d))?[-~&+](\d{1,4})(?:[vV](\d))?(FIN|Fin|fin)?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
var lowerBound = match.Groups[1].Value;
var upperBound = match.Groups[3].Value;
/** Avoid matching expressions such as "009-1" or "5-2" */
if (StringHelper.StringToInt(lowerBound) >= StringHelper.StringToInt(upperBound)) return false;
if (!SetEpisodeNumber(lowerBound, token, true)) return false;
SetEpisodeNumber(upperBound, token, true);
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
}
if (!string.IsNullOrEmpty(match.Groups[4].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[4].Value));
}
if (!string.IsNullOrEmpty(match.Groups[5].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseInformation, match.Groups[5].Value));
}
return true;
}
/// <summary>
/// Match season and episode patterns. e.g. "2x01", "S01E03", "S01-02xE001-150", "S01E06v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSeasonAndEpisodePattern(string word, Token token)
{
const string regexPattern = RegexMatchOnlyStart + @"S?(\d{1,2})(?:-S?(\d{1,2}))?(?:x|[ ._-x]?EP?)(\d{1,4})(?:-E?P?(\d{1,4}))?(?:[vV](\d{1,2}))?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeSeason, match.Groups[1].Value));
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeSeason, match.Groups[2].Value));
}
SetEpisodeNumber(match.Groups[3].Value, token, false);
if (!string.IsNullOrEmpty(match.Groups[4].Value))
{
SetEpisodeNumber(match.Groups[4].Value, token, false);
}
return true;
}
/// <summary>
/// Match type and episode. e.g. "ED1", "OP4a", "OVA2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchTypeAndEpisodePattern(string word, Token token)
{
var numberBegin = ParserHelper.IndexOfFirstDigit(word);
var prefix = StringHelper.SubstringWithCheck(word, 0, numberBegin);
var category = Element.ElementCategory.ElementAnimeType;
var options = new KeywordOptions();
if (!KeywordManager.FindAndSet(KeywordManager.Normalize(prefix), ref category, ref options)) return false;
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeType, prefix));
var number = word.Substring(numberBegin);
if (!MatchEpisodePatterns(number, token) && !SetEpisodeNumber(number, token, true)) return false;
var foundIdx = _parser.Tokens.IndexOf(token);
if (foundIdx == -1) return true;
token.Content = number;
_parser.Tokens.Insert(foundIdx,
new Token(options.Identifiable ? Token.TokenCategory.Identifier : Token.TokenCategory.Unknown, token.Enclosed, prefix));
return true;
}
/// <summary>
/// Match fractional episodes. e.g. "07.5"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchFractionalEpisodePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word))
{
word = "";
}
const string regexPattern = RegexMatchOnlyStart + @"\d+\.5" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
return match.Success && SetEpisodeNumber(word, token, true);
}
/// <summary>
/// Match partial episodes. e.g. "4a", "111C".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchPartialEpisodePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) return false;
var foundIdx = Enumerable.Range(0, word.Length)
.DefaultIfEmpty(word.Length)
.FirstOrDefault(value => !char.IsDigit(word[value]));
var suffixLength = word.Length - foundIdx;
bool IsValidSuffix(int c) => c >= 'A' && c <= 'C' || c >= 'a' && c <= 'c';
return suffixLength == 1 && IsValidSuffix(word[foundIdx]) && SetEpisodeNumber(word, token, true);
}
/// <summary>
/// Match episodes with number signs. e.g. "#01", "#02-03v2"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchNumberSignPattern(string word, Token token)
{
if (string.IsNullOrEmpty(word) || word[0] != '#') word = "";
const string regexPattern = RegexMatchOnlyStart + @"#(\d{1,4})(?:[-~&+](\d{1,4}))?(?:[vV](\d))?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
if (!SetEpisodeNumber(match.Groups[1].Value, token, true)) return false;
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
SetEpisodeNumber(match.Groups[2].Value, token, false);
}
if (!string.IsNullOrEmpty(match.Groups[3].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[3].Value));
}
return true;
}
/// <summary>
/// Match Japanese patterns. e.g. U+8A71 is used as counter for stories, episodes of TV series, etc.
///
/// 匹配日文中常见顺序词
///
/// 符合这种匹配模式的,一般在集数后都紧跟本集标题 #TODO
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
public bool MatchJapaneseCounterPattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) return false;
// 1st|2nd|3rd| #TODO
string regexPattern = @"(|Ⅱ|Ⅲ)";
var match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = ParserHelper.GetNumberFromOrdinal(match.Groups[1].Value);
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
regexPattern = @"([上中下前後])([巻卷編编])";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = ParserHelper.GetNumberFromOrdinal(match.Groups[1].Value);
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
// 全角数字:\uFF10-\uFF19
regexPattern = @"([第全]?)([0-9一二三四五六七八九十壱弐参\uFF10-\uFF19]+)([回集話话幕夜発縛])";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = match.Groups[2].Value;
if (!StringHelper.IsNumericString(episodeNumber))
{
episodeNumber = ParserHelper.GetNumberFromOrdinal(episodeNumber);
}
episodeNumber = ParserHelper.GetNumberFromFullWidth(episodeNumber);
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
regexPattern = @"([第全]?)([0-9一二三四五六七八九十壱弐参\uFF10-\uFF19]+)([期章巻卷])";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = match.Groups[2].Value;
if (!StringHelper.IsNumericString(episodeNumber))
{
episodeNumber = ParserHelper.GetNumberFromOrdinal(episodeNumber);
}
episodeNumber = ParserHelper.GetNumberFromFullWidth(episodeNumber);
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
regexPattern = @"(EPISODE|ACT|scene|ep|screen|voice|case|menu|rail|round|game|page|collection|cage|office|doll|Princess)([ \.\-_])([0-9]+)";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = match.Groups[3].Value;
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
regexPattern = @"(vol|volume)([ \.\-_])([0-9]+)";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = match.Groups[3].Value;
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
else
{
return false;
}
}
// VOLUME MATCHES
/// <summary>
/// Attempts to find an episode/season inside a <c>word</c>
///
/// 在传入的字符串中共尝试匹配季/集
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the word was matched to an episode/season number</returns>
public bool MatchVolumePatterns(string word, Token token)
{
// All patterns contain at least one non-numeric character
if (StringHelper.IsNumericString(word)) return false;
word = word.Trim(" -".ToCharArray());
var numericFront = char.IsDigit(word[0]);
var numericBack = char.IsDigit(word[word.Length - 1]);
if (numericFront && numericBack)
{
// e.g. "01v2" e.g. "01-02", "03-05v2"
return MatchSingleVolumePattern(word, token) || MatchMultiVolumePattern(word, token);
}
return false;
}
/// <summary>
/// Match single volume. e.g. "01v2"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSingleVolumePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) word = "";
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,2})[vV](\d)" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
SetVolumeNumber(match.Groups[1].Value, token, false);
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
return true;
}
/// <summary>
/// Match multi-volume. e.g. "01-02", "03-05v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchMultiVolumePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) word = "";
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,2})[-~&+](\d{1,2})(?:[vV](\d))?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
var lowerBound = match.Groups[1].Value;
var upperBound = match.Groups[2].Value;
if (StringHelper.StringToInt(lowerBound) >= StringHelper.StringToInt(upperBound)) return false;
if (!SetVolumeNumber(lowerBound, token, true)) return false;
SetVolumeNumber(upperBound, token, false);
if (string.IsNullOrEmpty(match.Groups[3].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[3].Value));
}
return true;
}
// SEARCH
/// <summary>
/// Searches for isolated numbers in a list of <c>tokens</c>.
///
/// 搜索孤立数字
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an isolated number was found</returns>
public bool SearchForIsolatedNumbers(IEnumerable<int> tokens)
{
return tokens
.Where(it => _parser.Tokens[it].Enclosed && _parser.ParseHelper.IsTokenIsolated(it))
.Any(it => SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true));
}
/// <summary>
/// Searches for separated numbers in a list of <c>tokens</c>.
///
/// 搜索带分隔符的数字
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true fi a separated number was found</returns>
public bool SearchForSeparatedNumbers(List<int> tokens)
{
foreach (var it in tokens)
{
var previousToken = Token.FindPrevToken(_parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
// See if the number has a preceding "-" separator
if (!_parser.ParseHelper.IsTokenCategory(previousToken, Token.TokenCategory.Unknown)
|| !ParserHelper.IsDashCharacter(_parser.Tokens[previousToken].Content[0])) continue;
if (!SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true)) continue;
_parser.Tokens[previousToken].Category = Token.TokenCategory.Identifier;
return true;
}
return false;
}
/// <summary>
/// Searches for episode patterns in a list of <c>tokens</c>.
///
/// 在标记列表中匹配集数模式
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an episode number was found</returns>
public bool SearchForEpisodePatterns(List<int> tokens)
{
foreach (var it in tokens)
{
var numericFront = _parser.Tokens[it].Content.Length > 0 && char.IsDigit(_parser.Tokens[it].Content[0]);
if (!numericFront)
{
// e.g. "EP.1", "Vol.1"
if (NumberComesAfterPrefix(Element.ElementCategory.ElementEpisodePrefix, _parser.Tokens[it]))
{
return true;
}
if (NumberComesAfterPrefix(Element.ElementCategory.ElementVolumePrefix, _parser.Tokens[it]))
{
continue;
}
}
else
{
// e.g. "8 & 10", "01 of 24"
if (NumberComesBeforeAnotherNumber(_parser.Tokens[it], it))
{
return true;
}
}
// Look for other patterns
if (MatchEpisodePatterns(_parser.Tokens[it].Content, _parser.Tokens[it]))
{
return true;
}
}
return false;
}
/// <summary>
/// 搜索同动画类型同时出现的集数
/// </summary>
/// <param name="tokens"></param>
/// <returns></returns>
public bool SearchForSymbolWithEpisode(List<int> tokens)
{
// Match from back to front
for (int i = tokens.Count - 1; i >= 0; i--)
{
var it = tokens[i];
// e.g. OVA 3, [Web Preview 06]: Web Preview in PeekEntries
if ((_parser.ParseHelper.IsPrevTokenContainAnimeType(it) || _parser.ParseHelper.IsPrevTokenContainAnimeTypeInPeekEntries(it)) && !_parser.ParseHelper.IsTokenIsolated(it))
{
SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], false);
return true;
}
// e.g. OtherToken[Hint05]
// it>1: makesure this token is not first one
if (it > 1 && _parser.Tokens[it].Enclosed && _parser.ParseHelper.IsTokenIsolated(it))
{
var tokenContent = _parser.Tokens[it].Content;
var numberBegin = ParserHelper.IndexOfFirstDigit(tokenContent);
var prefix = StringHelper.SubstringWithCheck(tokenContent, 0, numberBegin);
var number = StringHelper.SubstringWithCheck(tokenContent, numberBegin, tokenContent.Length - numberBegin);
// token should be: alphaNumeric
if (prefix != "" && StringHelper.IsAlphaString(prefix) && StringHelper.IsNumericString(number))
{
SetEpisodeNumber(number, _parser.Tokens[it], true);
return true;
}
}
// e.g. OtherToken[Disc 01]
if (it > 1 && _parser.Tokens[it].Enclosed && _parser.ParseHelper.IsTokenIsolatedWithDelimiterAndBracket(it) && StringHelper.IsNumericString(_parser.Tokens[it].Content))
{
SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true);
return true;
}
}
return false;
}
/// <summary>
/// Searches for equivalent number in a list of <c>tokens</c>. e.g. 08(114)
///
/// 匹配自带等效集数的数字,常见于分割放送
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an equivalent number was found</returns>
public bool SearchForEquivalentNumbers(List<int> tokens)
{
foreach (var it in tokens)
{
// Find number must be isolated.
if (_parser.ParseHelper.IsTokenIsolated(it) || !IsValidEpisodeNumber(_parser.Tokens[it].Content))
{
continue;
}
// Find the first enclosed, non-delimiter token
var nextToken = Token.FindNextToken(_parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
if (!_parser.ParseHelper.IsTokenCategory(nextToken, Token.TokenCategory.Bracket)) continue;
nextToken = Token.FindNextToken(_parser.Tokens, nextToken, Token.TokenFlag.FlagEnclosed,
Token.TokenFlag.FlagNotDelimiter);
if (!_parser.ParseHelper.IsTokenCategory(nextToken, Token.TokenCategory.Unknown)) continue;
// Check if it's an isolated number
if (!_parser.ParseHelper.IsTokenIsolated(nextToken)
|| !StringHelper.IsNumericString(_parser.Tokens[nextToken].Content)
|| !IsValidEpisodeNumber(_parser.Tokens[nextToken].Content))
{
continue;
}
var list = new List<Token> { _parser.Tokens[it], _parser.Tokens[nextToken] };
list.Sort((o1, o2) => StringHelper.StringToInt(o1.Content) - StringHelper.StringToInt(o2.Content));
SetEpisodeNumber(list[0].Content, list[0], false);
SetAlternativeEpisodeNumber(list[1].Content, list[1]);
return true;
}
return false;
}
/// <summary>
/// Searches for equivalent number in a list of <c>tokens</c>. e.g. 08(114)
///
/// 匹配自带等效集数的数字,常见于分割放送,匹配括号包裹的数字
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an equivalent number was found</returns>
public bool SearchForEquivalentNumbersWithBracket(List<int> tokens)
{
foreach (var it in tokens)
{
// Find the first enclosed, non-delimiter token
var nextToken = Token.FindNextToken(_parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(nextToken, _parser.Tokens) || !(_parser.Tokens[it].Content.Contains("(") || _parser.Tokens[nextToken].Content.Contains(")")))
{
continue;
}
// e.g. [13(341)]
if (it > 1 && _parser.Tokens[it].Enclosed && _parser.ParseHelper.IsTokenIsolated(it))
{
string[] episodes = _parser.Tokens[it].Content.Split(new string[] { "(", ")" }, StringSplitOptions.RemoveEmptyEntries);
if (StringHelper.IsNumericString(episodes[0]) && StringHelper.IsNumericString(episodes[1]))
{
SetEpisodeNumber(episodes[0], _parser.Tokens[it], false);
SetAlternativeEpisodeNumber(episodes[1], _parser.Tokens[it]);
return true;
}
}
// e.g. [13 (341)]
if (it > 1 && _parser.Tokens[nextToken].Enclosed && _parser.ParseHelper.IsTokenIsolatedWithDelimiterAndBracket(nextToken))
{
string episode = _parser.Tokens[nextToken].Content.Replace("(", "").Replace(")", "");
if (StringHelper.IsNumericString(_parser.Tokens[it].Content) && StringHelper.IsNumericString(episode))
{
SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true);
SetAlternativeEpisodeNumber(episode, _parser.Tokens[nextToken]);
return true;
}
}
}
return false;
}
/// <summary>
/// Searches for the last number token in a list of <c>tokens</c>
///
/// 搜索最后一个数字
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if the last number token was found</returns>
public bool SearchForLastNumber(List<int> tokens)
{
for (var i = tokens.Count - 1; i >= 0; i--)
{
var it = tokens[i];
// Assuming that episode number always comes after the title,
// the first token cannot be what we're looking for
if (it == 0) continue;
if (_parser.Tokens[it].Enclosed) continue;
// Ignore if it's the first non-enclosed, non-delimiter token
if (_parser.Tokens.GetRange(0, it)
.All(r => r.Enclosed || r.Category == Token.TokenCategory.Delimiter))
{
continue;
}
var previousToken = Token.FindPrevToken(_parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
if (_parser.ParseHelper.IsTokenCategory(previousToken, Token.TokenCategory.Unknown))
{
if (_parser.Tokens[previousToken].Content.Equals("Movie", StringComparison.InvariantCultureIgnoreCase)
|| _parser.Tokens[previousToken].Content.Equals("Part", StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
}
// We'll use this number after all
if (SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true))
{
return true;
}
}
return false;
}
}
}

View File

@ -0,0 +1,166 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// A string helper class that is analogous to <c>string.cpp</c> of the original Anitomy, and <c>StringHelper.java</c> of AnitomyJ.
/// </summary>
public static class StringHelper
{
/// <summary>
/// Returns whether or not the character is alphanumeric
///
/// 如果给定字符为字母或数字,则返回`true`
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
public static bool IsAlphanumericChar(char c)
{
return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z';
}
/// <summary>
/// Returns whether or not the character is a hex character.
///
/// 如果给定字符为十六进制字符,则返回`true`
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
private static bool IsHexadecimalChar(char c)
{
return c >= '0' && c <= '9' || c >= 'A' && c <= 'F' || c >= 'a' && c <= 'f';
}
/// <summary>
/// Returns whether or not the character is a latin character
///
/// 判断给定字符是否为拉丁字符
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
private static bool IsLatinChar(char c)
{
// We're just checking until the end of the Latin Extended-B block,
// rather than all the blocks that belong to the Latin script.
return c <= '\u024F';
}
/// <summary>
/// Returns whether or not the character is a Chinese character
///
/// 判断给定字符是否为中文字符
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
private static bool IsChineseChar(char c)
{
// We're just checking until the end of the Latin Extended-B block,
// rather than all the blocks that belong to the Latin script.
return c <= '\u9FFF' && c >= '\u4E00';
}
/// <summary>
/// Returns whether or not the <c>str</c> is a hex string.
///
/// 如果给定字符串为十六进制字符串,则返回`true`
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsHexadecimalString(string str)
{
return !string.IsNullOrEmpty(str) && str.All(IsHexadecimalChar);
}
/// <summary>
/// Returns whether or not the <c>str</c> is mostly a latin string.
///
/// 判断给定字符串是否过半字符为拉丁
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsMostlyLatinString(string str)
{
var length = !string.IsNullOrEmpty(str) ? 1.0 : str.Length;
return str.Where(IsLatinChar).Count() / length >= 0.5;
}
/// <summary>
/// Returns whether or not the <c>str</c> is mostly a Chinese string.
///
/// 判断给定字符串是否过半字符为中文
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsMostlyChineseString(string str)
{
var length = !string.IsNullOrEmpty(str) ? 1.0 : str.Length;
return str.Where(IsChineseChar).Count() / length >= 0.5;
}
/// <summary>
/// Returns whether or not the <c>str</c> is a numeric string.
///
/// 判断字符串是否全数字
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsNumericString(string str)
{
return str.All(char.IsDigit);
}
/// <summary>
/// Returns whether or not the <c>str</c> is a alpha string.
///
/// 判断字符串是否全字母
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsAlphaString(string str)
{
return str.All(char.IsLetter);
}
/// <summary>
/// Returns the int value of the <c>str</c>; 0 otherwise.
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static int StringToInt(string str)
{
try
{
return int.Parse(str);
}
catch (Exception ex)
{
Console.WriteLine(ex);
return 0;
}
}
/// <summary>
/// 提取给定范围的子字符串
/// </summary>
/// <param name="str"></param>
/// <param name="start"></param>
/// <param name="count"></param>
/// <returns></returns>
public static string SubstringWithCheck(string str, int start, int count)
{
if (start + count > str.Length) count = str.Length - start;
return str.Substring(start, count);
}
}
}

331
AnitomySharp/Token.cs Normal file
View File

@ -0,0 +1,331 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// An anime filename is tokenized into individual <see cref="Token"/>s. This class represents an individual token.
///
/// 动画文件名被标记化为单一的标记(token)
/// </summary>
public class Token
{
/// <summary>
/// The category of the token.
///
/// 标记(token)类型
/// </summary>
public enum TokenCategory
{
/// <summary>
/// 未知类型,
///
/// 包括:无括号/分隔符的字符串;分隔符分割后的字符串
/// </summary>
Unknown,
/// <summary>
/// 括号
/// </summary>
Bracket,
/// <summary>
/// 分隔符包括Options.AllowedDelimiters
/// </summary>
Delimiter,
/// <summary>
/// 标识符包括关键词一眼真keyword<see cref="KeywordManager.PeekEntries"/>被添加到token
/// </summary>
Identifier,
/// <summary>
/// 无效,错误的标记,不会出现在最后的标记(token)列表中。比如在<see cref="Tokenizer.ValidateDelimiterTokens">验证分隔符切分的标记</see>时,规则匹配到的无效标记(token)
/// </summary>
Invalid
}
/// <summary>
/// TokenFlag, used for searching specific token categories. This allows granular searching of TokenCategories.
///
/// 标记符,用于细粒度搜索特定的标记类型(<see cref="TokenCategory"/>)。
/// </summary>
public enum TokenFlag
{
/// <summary>
/// None 无
/// </summary>
FlagNone,
// Categories
/// <summary>
/// 括号符
/// </summary>
FlagBracket,
/// <summary>
///
/// </summary>
FlagNotBracket,
/// <summary>
/// 分隔符
/// </summary>
FlagDelimiter,
/// <summary>
///
/// </summary>
FlagNotDelimiter,
/// <summary>
/// 标识符
/// </summary>
FlagIdentifier,
/// <summary>
///
/// </summary>
FlagNotIdentifier,
/// <summary>
/// 未知
/// </summary>
FlagUnknown,
/// <summary>
///
/// </summary>
FlagNotUnknown,
/// <summary>
/// 有效
/// </summary>
FlagValid,
/// <summary>
///
/// </summary>
FlagNotValid,
// Enclosed (Meaning that it is enclosed in some bracket (e.g. [ ] ))
/// <summary>
/// 闭合符
/// </summary>
FlagEnclosed,
/// <summary>
/// 未闭合符
/// </summary>
FlagNotEnclosed
}
/// <summary>
/// Set of token category flags
///
/// 标识符分类列表
/// </summary>
private static readonly List<TokenFlag> FlagMaskCategories = new List<TokenFlag>
{
TokenFlag.FlagBracket, TokenFlag.FlagNotBracket,
TokenFlag.FlagDelimiter, TokenFlag.FlagNotDelimiter,
TokenFlag.FlagIdentifier, TokenFlag.FlagNotIdentifier,
TokenFlag.FlagUnknown, TokenFlag.FlagNotUnknown,
TokenFlag.FlagValid, TokenFlag.FlagNotValid
};
/// <summary>
/// Set of token enclosed flags
///
/// 闭合的标识符列表
/// </summary>
private static readonly List<TokenFlag> FlagMaskEnclosed = new List<TokenFlag>
{
TokenFlag.FlagEnclosed, TokenFlag.FlagNotEnclosed
};
/// <summary>
/// 标记的类型
/// </summary>
public TokenCategory Category { get; set; }
/// <summary>
/// 标记的内容
/// </summary>
public string Content { get; set; }
/// <summary>
/// 标记是否被括号包裹
/// </summary>
public bool Enclosed { get; }
/// <summary>
/// Constructs a new token
///
/// 构造一个新的标记(token)
/// </summary>
/// <param name="category">the token category</param>
/// <param name="enclosed">whether or not the token is enclosed in braces</param>
/// <param name="content">the token content</param>
public Token(TokenCategory category, bool enclosed, string content)
{
Category = category;
Enclosed = enclosed;
Content = content;
}
/// <summary>
/// Validates a token against the <c>flags</c>. The <c>flags</c> is used as a search parameter.
///
/// 验证传入的标记(token)是否满足标记符(flag)
/// </summary>
/// <param name="token">the token</param>
/// <param name="flags">the flags the token must conform against</param>
/// <returns>true if the token conforms to the set of <c>flags</c>; false otherwise</returns>
private static bool CheckTokenFlags(Token token, ICollection<TokenFlag> flags)
{
// Simple alias to check if flag is a part of the set
bool CheckFlag(TokenFlag flag)
{
return flags.Contains(flag);
}
// Make sure token is the correct closure
if (flags.Any(f => FlagMaskEnclosed.Contains(f)))
{
var success = CheckFlag(TokenFlag.FlagEnclosed) == token.Enclosed;
if (!success) return false; // Not enclosed correctly (e.g. enclosed when we're looking for non-enclosed).
}
// Make sure token is the correct category
if (!flags.Any(f => FlagMaskCategories.Contains(f))) return true;
var secondarySuccess = false;
void CheckCategory(TokenFlag fe, TokenFlag fn, TokenCategory c)
{
if (secondarySuccess) return;
var result = CheckFlag(fe) ? token.Category == c : CheckFlag(fn) && token.Category != c;
secondarySuccess = result;
}
CheckCategory(TokenFlag.FlagBracket, TokenFlag.FlagNotBracket, TokenCategory.Bracket);
CheckCategory(TokenFlag.FlagDelimiter, TokenFlag.FlagNotDelimiter, TokenCategory.Delimiter);
CheckCategory(TokenFlag.FlagIdentifier, TokenFlag.FlagNotIdentifier, TokenCategory.Identifier);
CheckCategory(TokenFlag.FlagUnknown, TokenFlag.FlagNotUnknown, TokenCategory.Unknown);
CheckCategory(TokenFlag.FlagNotValid, TokenFlag.FlagValid, TokenCategory.Invalid);
return secondarySuccess;
}
/// <summary>
/// Given a list of <c>tokens</c>, searches for any token token that matches the list of <c>flags</c>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="begin">the search starting position.</param>
/// <param name="end">the search ending position.</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindToken(List<Token> tokens, int begin, int end, params TokenFlag[] flags)
{
return FindTokenBase(tokens, begin, end, i => i < tokens.Count, i => i + 1, flags);
}
/// <summary>
/// Given a list of <c>tokens</c>, searches for the next token in <c>tokens</c> that matches the list of <c>flags</c>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="first">the search starting position.</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindNextToken(List<Token> tokens, int first, params TokenFlag[] flags)
{
return FindTokenBase(tokens, first + 1, tokens.Count, i => i < tokens.Count, i => i + 1, flags);
}
/// <summary>
/// Given a list of <c>tokens</c>, searches for the previous token in <c>tokens</c> that matches the list of <c>flags</c>.
///
/// 在给定的标记列表中搜索匹配输入的标记符前一个标记
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="begin">the search starting position. Exclusive of position.Pos</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindPrevToken(List<Token> tokens, int begin, params TokenFlag[] flags)
{
return FindTokenBase(tokens, begin - 1, -1, i => i >= 0, i => i - 1, flags);
}
/// <summary>
/// Given a list of tokens finds the first token that passes <see cref="CheckTokenFlags"/>.
///
/// 在给定的标记列表中找到第一个通过<see cref="CheckTokenFlags"/>的标记(token)
/// </summary>
/// <param name="tokens">the list of the tokens to search</param>
/// <param name="begin">the start index of the search.</param>
/// <param name="end">the end index of the search.</param>
/// <param name="shouldContinue">a function that returns whether or not we should continue searching</param>
/// <param name="next">a function that returns the next search index</param>
/// <param name="flags">the flags that each token should be validated against</param>
/// <returns>the found token</returns>
private static int FindTokenBase(
List<Token> tokens,
int begin,
int end,
Func<int, bool> shouldContinue,
Func<int, int> next,
params TokenFlag[] flags)
{
var find = new List<TokenFlag>();
find.AddRange(flags);
for (var i = begin; shouldContinue(i); i = next(i))
{
var token = tokens[i];
if (CheckTokenFlags(token, find))
{
return i;
}
}
return end;
}
/// <summary>
///
/// </summary>
/// <param name="pos"></param>
/// <param name="list"></param>
/// <returns></returns>
public static bool InListRange(int pos, List<Token> list)
{
return -1 < pos && pos < list.Count;
}
/// <summary>
///
/// </summary>
/// <param name="o"></param>
/// <returns></returns>
public override bool Equals(object o)
{
if (this == o) return true;
if (!(o is Token)) return false;
var token = (Token)o;
return Enclosed == token.Enclosed && Category == token.Category && Equals(Content, token.Content);
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override int GetHashCode()
{
var hashCode = -1776802967;
hashCode = hashCode * -1521134295 + Category.GetHashCode();
hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Content);
hashCode = hashCode * -1521134295 + Enclosed.GetHashCode();
return hashCode;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override string ToString()
{
return $"Token{{category={Category}, content='{Content}', enclosed={Enclosed}}}";
}
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp
{
/// <summary>
/// 标记(token)的位置
/// </summary>
public struct TokenRange
{
/// <summary>
/// 偏移值
/// </summary>
public int Offset;
/// <summary>
/// Token长度
/// </summary>
public int Size;
/// <summary>
/// 构造<see cref="TokenRange"/>
/// </summary>
/// <param name="offset"></param>
/// <param name="size"></param>
public TokenRange(int offset, int size)
{
Offset = offset;
Size = size;
}
}
}

391
AnitomySharp/Tokenizer.cs Normal file
View File

@ -0,0 +1,391 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AnitomySharp
{
/// <summary>
/// A class that will tokenize an anime filename.
///
/// 用于动画文件名标记化的分词器
/// </summary>
public class Tokenizer
{
/// <summary>
/// 用于标记化的文件名
/// </summary>
private readonly string _filename;
/// <summary>
/// 用于添加预处理后标记(token)的元素列表
/// </summary>
private readonly List<Element> _elements;
/// <summary>
/// 用于解析的配置
/// </summary>
private readonly Options _options;
/// <summary>
/// 用于存储标记(token)的列表
/// </summary>
private readonly List<Token> _tokens;
/// <summary>
/// 括号列表
/// </summary>
private static readonly List<Tuple<string, string>> Brackets = new List<Tuple<string, string>>
{
new Tuple<string, string>("(", ")"), // U+0028-U+0029
new Tuple<string, string>("[", "]"), // U+005B-U+005D Square bracket
new Tuple<string, string>("{", "}"), // U+007B-U+007D Curly bracket
new Tuple<string, string>("\u300C", "\u300D"), // Corner bracket 「」
new Tuple<string, string>("\u300E", "\u300F"), // White corner bracket 『 』
new Tuple<string, string>("\u3010", "\u3011"), // Black lenticular bracket 【】
new Tuple<string, string>("\u3014", "\u3015"), // Black lenticular bracket
new Tuple<string, string>("\u3016", "\u3017"), // Black lenticular bracket 〖 〗
new Tuple<string, string>("\uFF08", "\uFF09"), // Fullwidth parenthesis
new Tuple<string, string>("\uFF3B", "\uFF3D"), // Fullwidth parenthesis
new Tuple<string, string>("\uFF5B", "\uFF5D") // Fullwidth parenthesis
};
/// <summary>
/// Tokenize a filename into <see cref="Element"/>s
///
/// 将传入的文件名标记化,拆分为单个元素
///
/// </summary>
/// <param name="filename">the filename</param>
/// <param name="elements">the list of elements where pre-identified tokens will be added</param>
/// <param name="options">the parser options</param>
/// <param name="tokens">the list of tokens where tokens will be added</param>
public Tokenizer(string filename, List<Element> elements, Options options, List<Token> tokens)
{
_filename = filename;
_elements = elements;
_options = options;
_tokens = tokens;
}
/// <summary>
/// Returns true if tokenization was successful; false otherwise.
///
/// 按照括号列表执行分词,根据<see cref="_tokens"/>大小判断是否标记化成功。成功返回true否则为false。
/// </summary>
/// <returns></returns>
public bool Tokenize()
{
TokenizeByBrackets();
return _tokens.Count > 0;
}
/// <summary>
/// Adds a token to the internal list of tokens
///
/// 添加标记(token)至<see cref="_tokens">_tokens列表</see>
/// </summary>
/// <param name="category">the token category</param>
/// <param name="enclosed">whether or not the token is enclosed in braces</param>
/// <param name="range">the token range</param>
private void AddToken(Token.TokenCategory category, bool enclosed, TokenRange range)
{
_tokens.Add(new Token(category, enclosed, StringHelper.SubstringWithCheck(_filename, range.Offset, range.Size)));
}
/// <summary>
/// 根据<see cref="Options.AllowedDelimiters">分隔符配置</see>,提取当前字符串范围内出现过的分隔符
/// </summary>
/// <param name="range"></param>
/// <returns></returns>
private string GetDelimiters(TokenRange range)
{
var delimiters = new StringBuilder();
bool IsDelimiter(char c)
{
/** alphanumeric不属于分隔符 */
if (StringHelper.IsAlphanumericChar(c)) return false;
return _options.AllowedDelimiters.Contains(c.ToString()) && !delimiters.ToString().Contains(c.ToString());
}
foreach (var i in Enumerable.Range(range.Offset, Math.Min(_filename.Length, range.Offset + range.Size) - range.Offset)
.Where(value => IsDelimiter(_filename[value])))
{
delimiters.Append(_filename[i]);
}
return delimiters.ToString();
}
/// <summary>
/// Tokenize by bracket.
///
/// 使用括号列表规则进行分词
/// </summary>
/// <remarks>括号总是成对出现。将括号作为停用符,将文件名划为多块</remarks>
private void TokenizeByBrackets()
{
/** 匹配到的(右)括号类型 */
string matchingBracket = null;
/** 返回范围内第一个(左)括号位置 */
int FindFirstBracket(int start, int end)
{
for (var i = start; i < end; i++)
{
foreach (var bracket in Brackets)
{
/** 和括号列表中每对的第一个括号进行比较 */
if (!_filename[i].Equals(char.Parse(bracket.Item1))) continue;
matchingBracket = bracket.Item2;
return i;
}
}
return -1;
}
/** 括号是否闭合 */
var isBracketOpen = false;
for (var i = 0; i < _filename.Length;)
{
/**
1. (isBracketOpen = false)使1()
2. (isBracketOpen = true)使2()(matchingBracket)
*/
var foundIdx = !isBracketOpen ? FindFirstBracket(i, _filename.Length) : _filename.IndexOf(matchingBracket, i, StringComparison.Ordinal);
/**
1.
2.
3. */
var range = new TokenRange(i, foundIdx == -1 ? _filename.Length : foundIdx - i);
if (range.Size > 0)
{
// Check if our range contains any known anime identifiers
TokenizeByPreidentified(isBracketOpen, range);
}
if (foundIdx != -1)
{
// mark as bracket 标记为括号并添加到_tokens列表
AddToken(Token.TokenCategory.Bracket, true, new TokenRange(range.Offset + range.Size, 1));
/** 括号是否闭合 取反 */
isBracketOpen = !isBracketOpen;
i = foundIdx + 1;
}
else
{
break;
}
}
}
/// <summary>
/// Tokenize by looking for known anime identifiers
///
/// 根据已知的动画关键词列表来分词
/// </summary>
/// <param name="enclosed">whether or not the current <c>range</c> is enclosed in braces. 当前范围是否位于闭合的括号中。</param>
/// <param name="range">the token range 标记的范围</param>
private void TokenizeByPreidentified(bool enclosed, TokenRange range)
{
var preidentifiedTokens = new List<TokenRange>();
// Find known anime identifiers
KeywordManager.PeekAndAdd(_filename, range, _elements, preidentifiedTokens);
var offset = range.Offset;
var subRange = new TokenRange(range.Offset, 0);
while (offset < range.Offset + range.Size)
{
foreach (var preidentifiedToken in preidentifiedTokens)
{
if (offset != preidentifiedToken.Offset) continue;
if (subRange.Size > 0)
{
TokenizeByDelimiters(enclosed, subRange);
}
AddToken(Token.TokenCategory.Identifier, enclosed, preidentifiedToken);
/** subRange偏移量移至此token后 */
subRange.Offset = preidentifiedToken.Offset + preidentifiedToken.Size;
offset = subRange.Offset - 1; // It's going to be incremented below
}
/**
1. (keyword)Size为0
2. Size大于0 */
subRange.Size = ++offset - subRange.Offset;
}
// Either there was no preidentified token range, or we're now about to process the tail of our current range
/**
1.
2.
*/
if (subRange.Size > 0)
{
TokenizeByDelimiters(enclosed, subRange);
}
}
/// <summary>
/// Tokenize by delimiters allowed in <see cref="Options"/>.AllowedDelimiters.
///
/// 使用提取元素时的分隔符配置进行分词
/// </summary>
/// <param name="enclosed">whether or not the current <code>range</code> is enclosed in braces</param>
/// <param name="range">the token range</param>
private void TokenizeByDelimiters(bool enclosed, TokenRange range)
{
var delimiters = GetDelimiters(range);
/** 如果这段字符串无分隔符则整个作为Unknown类型的标记(token) */
if (string.IsNullOrEmpty(delimiters))
{
AddToken(Token.TokenCategory.Unknown, enclosed, range);
return;
}
for (int i = range.Offset, end = range.Offset + range.Size; i < end;)
{
var found = Enumerable.Range(i, Math.Min(end, _filename.Length) - i)
.Where(c => delimiters.Contains(_filename[c].ToString()))
.DefaultIfEmpty(end)
.FirstOrDefault();
var subRange = new TokenRange(i, found - i);
if (subRange.Size > 0)
{
/** 分隔符分割后的字符串作为Unknown类型的标记(token) */
AddToken(Token.TokenCategory.Unknown, enclosed, subRange);
}
if (found != end)
{
/** 分隔符作为Delimiter类型的标记(token) */
AddToken(Token.TokenCategory.Delimiter, enclosed, new TokenRange(subRange.Offset + subRange.Size, 1));
i = found + 1;
}
else
{
break;
}
}
ValidateDelimiterTokens();
}
/// <summary>
/// Validates tokens (make sure certain words delimited by certain tokens aren't split)
///
/// 验证标记,确保由配置的分隔符提取标记(token)时<see cref="TokenizeByDelimiters"/>不会将有意义的单词拆分
/// </summary>
private void ValidateDelimiterTokens()
{
bool IsDelimiterToken(int it)
{
return Token.InListRange(it, _tokens) && _tokens[it].Category == Token.TokenCategory.Delimiter;
}
bool IsUnknownToken(int it)
{
return Token.InListRange(it, _tokens) && _tokens[it].Category == Token.TokenCategory.Unknown;
}
bool IsSingleCharacterToken(int it)
{
return IsUnknownToken(it) && _tokens[it].Content.Length == 1 && _tokens[it].Content[0] != '-';
}
void AppendTokenTo(Token src, Token dest)
{
dest.Content += src.Content;
src.Category = Token.TokenCategory.Invalid;
}
for (var i = 0; i < _tokens.Count; i++)
{
var token = _tokens[i];
if (token.Category != Token.TokenCategory.Delimiter) continue;
var delimiter = token.Content[0];
var prevToken = Token.FindPrevToken(_tokens, i, Token.TokenFlag.FlagValid);
var nextToken = Token.FindNextToken(_tokens, i, Token.TokenFlag.FlagValid);
// Check for single-character tokens to prevent splitting group names,
// keywords, episode numbers, etc.
if (delimiter != ' ' && delimiter != '_')
{
// Single character token
if (IsSingleCharacterToken(prevToken))
{
AppendTokenTo(token, _tokens[prevToken]);
while (IsUnknownToken(nextToken))
{
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
nextToken = Token.FindNextToken(_tokens, i, Token.TokenFlag.FlagValid);
if (!IsDelimiterToken(nextToken) || _tokens[nextToken].Content[0] != delimiter) continue;
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
nextToken = Token.FindNextToken(_tokens, nextToken, Token.TokenFlag.FlagValid);
}
continue;
}
if (IsSingleCharacterToken(nextToken))
{
AppendTokenTo(token, _tokens[prevToken]);
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
continue;
}
}
// Check for adjacent delimiters
if (IsUnknownToken(prevToken) && IsDelimiterToken(nextToken))
{
var nextDelimiter = _tokens[nextToken].Content[0];
if (delimiter != nextDelimiter && delimiter != ',')
{
if (nextDelimiter == ' ' || nextDelimiter == '_')
{
AppendTokenTo(token, _tokens[prevToken]);
}
}
}
else if (IsDelimiterToken(prevToken) && IsDelimiterToken(nextToken))
{
var prevDelimiter = _tokens[prevToken].Content[0];
var nextDelimiter = _tokens[nextToken].Content[0];
if (prevDelimiter == nextDelimiter && prevDelimiter != delimiter)
{
token.Category = Token.TokenCategory.Unknown; // e.g. "& in "_&_"
}
}
// Check for other special cases
if (delimiter != '&' && delimiter != '+') continue;
if (!IsUnknownToken(prevToken) || !IsUnknownToken(nextToken)) continue;
if (!StringHelper.IsNumericString(_tokens[prevToken].Content)
|| !StringHelper.IsNumericString(_tokens[nextToken].Content)) continue;
AppendTokenTo(token, _tokens[prevToken]);
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]); // e.g. 01+02
}
// Remove invalid tokens
_tokens.RemoveAll(token => token.Category == Token.TokenCategory.Invalid);
}
}
}

View File

@ -1,14 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using Jellyfin.Plugin.MetaShark.Providers;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.MetaShark.Test
{
@ -40,7 +32,7 @@ namespace Jellyfin.Plugin.MetaShark.Test
[TestMethod]
public void TestSearch()
{
var keyword = "重返少年时";
var keyword = "声生不息";
var api = new DoubanApi(loggerFactory);
Task.Run(async () =>
@ -48,6 +40,27 @@ namespace Jellyfin.Plugin.MetaShark.Test
try
{
var result = await api.SearchAsync(keyword, CancellationToken.None);
TestContext.WriteLine(result.ToJson());
}
catch (Exception ex)
{
TestContext.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestSearchBySuggest()
{
var keyword = "重返少年时";
var api = new DoubanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.SearchBySuggestAsync(keyword, CancellationToken.None);
var str = result.ToJson();
TestContext.WriteLine(result.ToJson());
}
@ -60,7 +73,7 @@ namespace Jellyfin.Plugin.MetaShark.Test
[TestMethod]
public void TestGetVideoByBvidAsync()
public void TestGetVideoBySidAsync()
{
var sid = "26654184";
@ -80,6 +93,28 @@ namespace Jellyfin.Plugin.MetaShark.Test
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestFixGetImage()
{
// 演员入驻了豆瓣, 下载的不是演员的头像#5
var sid = "35460157";
var api = new DoubanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.GetMovieAsync(sid, CancellationToken.None);
Assert.AreEqual<string>("https://img2.doubanio.com/view/celebrity/raw/public/p1598199472.61.jpg", result.Celebrities.First(x => x.Name == "刘陆").Img);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetCelebritiesBySidAsync()
{
@ -100,5 +135,74 @@ namespace Jellyfin.Plugin.MetaShark.Test
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetCelebritiesByCidAsync()
{
var cid = "1340364";
var api = new DoubanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.GetCelebrityAsync(cid, CancellationToken.None);
TestContext.WriteLine(result.ToJson());
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetCelebrityPhotosAsync()
{
var cid = "1322205";
var api = new DoubanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.GetCelebrityPhotosAsync(cid, CancellationToken.None);
TestContext.WriteLine(result.ToJson());
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestParseCelebrityName()
{
var api = new DoubanApi(loggerFactory);
var name = "佩吉·陆 Peggy Lu";
var result = api.ParseCelebrityName(name);
Assert.AreEqual<string>(result, "佩吉·陆");
name = "Antony Coleman Antony Coleman";
result = api.ParseCelebrityName(name);
Assert.AreEqual<string>(result, "Antony Coleman");
name = "Dick Cook";
result = api.ParseCelebrityName(name);
Assert.AreEqual<string>(result, "Dick Cook");
name = "李凡秀";
result = api.ParseCelebrityName(name);
Assert.AreEqual<string>(result, "李凡秀");
}
}
}

View File

@ -0,0 +1,95 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.Serialization;
using Microsoft.AspNetCore.Http;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class EpisodeProviderTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestGetMetadata()
{
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
Task.Run(async () =>
{
var info = new EpisodeInfo()
{
Name = "Spice and Wolf",
Path = "/test/Spice and Wolf/S00/[VCB-Studio] Spice and Wolf II [01][Hi444pp_1080p][x264_flac].mkv",
MetadataLanguage = "zh",
ParentIndexNumber = 0,
SeriesProviderIds = new Dictionary<string, string>() { { MetadataProvider.Tmdb.ToString(), "26707" } },
IsAutomated = false,
};
var provider = new EpisodeProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestFixParseInfo()
{
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var provider = new EpisodeProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[POPGO][Stand_Alone_Complex][05][1080P][BluRay][x264_FLACx2_AC3x1][chs_jpn][D87C36B6].mkv" });
Assert.AreEqual(parseResult.IndexNumber, 5);
parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/Fullmetal Alchemist Brotherhood.E05.1920X1080" });
Assert.AreEqual(parseResult.IndexNumber, 5);
parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[SAIO-Raws] Neon Genesis Evangelion 05 [BD 1440x1080 HEVC-10bit OPUSx2 ASSx2].mkv" });
Assert.AreEqual(parseResult.IndexNumber, 5);
parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[Moozzi2] Samurai Champloo [SP03] Battlecry (Opening) PV (BD 1920x1080 x.264 AC3).mkv" });
Assert.AreEqual(parseResult.IndexNumber, 3);
Assert.AreEqual(parseResult.ParentIndexNumber, 0);
}
}
}

View File

@ -0,0 +1,57 @@
using Jellyfin.Plugin.MetaShark.Api;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class ImdbApiTest
{
private TestContext testContextInstance;
/// <summary>
/// Gets or sets the test context which provides
/// information about and functionality for the current test run.
/// </summary>
public TestContext TestContext
{
get { return testContextInstance; }
set { testContextInstance = value; }
}
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestCheckPersonNewImdbID()
{
var api = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
try
{
var id = "nm1123737";
var result = await api.CheckPersonNewIDAsync(id, CancellationToken.None);
Assert.AreEqual("nm0170924", result);
id = "nm0170924";
result = await api.CheckPersonNewIDAsync(id, CancellationToken.None);
Assert.AreEqual(null, result);
}
catch (Exception ex)
{
TestContext.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
}
}

View File

@ -1,13 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.18.2" />
@ -15,9 +12,9 @@
<PackageReference Include="MSTest.TestFramework" Version="2.2.8" />
<PackageReference Include="coverlet.collector" Version="3.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.MetaShark\Jellyfin.Plugin.MetaShark.csproj" />
<ProjectReference Include="..\Jellyfin.Plugin.MetaShark\Jellyfin.Plugin.MetaShark.csproj">
<ExcludeAssets />
</ProjectReference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,109 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class MovieImageProviderTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestGetMovieImage()
{
var info = new MediaBrowser.Controller.Entities.Movies.Movie()
{
Name = "秒速5厘米",
PreferredMetadataLanguage = "zh",
ProviderIds = new Dictionary<string, string> { { BaseProvider.DoubanProviderId, "2043546" }, { MetadataProvider.Tmdb.ToString(), "38142" } }
};
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new MovieImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetImages(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetMovieImageFromTMDB()
{
var info = new MediaBrowser.Controller.Entities.Movies.Movie()
{
PreferredMetadataLanguage = "zh",
ProviderIds = new Dictionary<string, string> { { MetadataProvider.Tmdb.ToString(), "752" }, { Plugin.ProviderId, MetaSource.Tmdb.ToString() } }
};
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new MovieImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetImages(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetImageResponse()
{
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new MovieImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetImageResponse("https://img1.doubanio.com/view/photo/m/public/p2893270877.jpg", CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
}
}

View File

@ -0,0 +1,102 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class MovieProviderTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestGetMetadata()
{
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var info = new MovieInfo() { Name = "我", MetadataLanguage = "zh" };
var provider = new MovieProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetSearchResults(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetMetadataAnime()
{
var info = new MovieInfo() { Name = "[SAIO-Raws] もののけ姫 Mononoke Hime [BD 1920x1036 HEVC-10bit OPUSx2 AC3]" };
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new MovieProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetMetadataByTMDB()
{
var info = new MovieInfo() { Name = "人生大事", MetadataLanguage = "zh", ProviderIds = new Dictionary<string, string> { { Plugin.ProviderId, MetaSource.Tmdb.ToString() }, { MetadataProvider.Tmdb.ToString(), "945664" } } };
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new MovieProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
}
}

View File

@ -0,0 +1,323 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using Jellyfin.Plugin.MetaShark.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class ParseNameTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestMovieParse()
{
// 混合中英文
var fileName = "新世界.New.World.2013.BluRay.1080p.x265.10bit.MNHD-FRDS";
var parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, "新世界");
Assert.AreEqual(parseResult.Name, "New World");
Assert.AreEqual(parseResult.Year, 2013);
fileName = "V字仇杀队.V.for.Vendetta.2006";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, "V字仇杀队");
Assert.AreEqual(parseResult.Name, "V for Vendetta");
Assert.AreEqual(parseResult.Year, 2006);
fileName = "罗马假日.Roman.Holiday.1953.WEB-DL.1080p.x265.AAC.2Audios.GREENOTEA";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, "罗马假日");
Assert.AreEqual(parseResult.Name, "Roman Holiday");
Assert.AreEqual(parseResult.Year, 1953);
fileName = "【更多蓝光电影访问】红辣椒[简繁中文字幕].Paprika.2006.RERiP.1080p.BluRay.x264.DTS-WiKi";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, "红辣椒");
Assert.AreEqual(parseResult.Name, "Paprika");
Assert.AreEqual(parseResult.Year, 2006);
// 只英文
fileName = "A.Chinese.Odyssey.Part.1.1995.BluRay.1080p.x265.10bit.2Audio-MiniHD";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "A Chinese Odyssey Part 1");
Assert.AreEqual(parseResult.Year, 1995);
fileName = "New.World.2013.BluRay.1080p.x265.10bit.MNHD-FRDS";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "New World");
Assert.AreEqual(parseResult.Year, 2013);
fileName = "Who.Am.I.1998.1080p.BluRay.x264.DTS-FGT";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "Who Am I");
Assert.AreEqual(parseResult.Year, 1998);
// 只中文
fileName = "机动战士高达 逆袭的夏亚";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "机动战士高达 逆袭的夏亚");
Assert.AreEqual(parseResult.Year, null);
fileName = "秒速5厘米";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "秒速5厘米");
Assert.AreEqual(parseResult.Year, null);
// 标题加年份
fileName = "V字仇杀队 (2006)";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "V字仇杀队");
Assert.AreEqual(parseResult.Year, 2006);
fileName = "逃学威龙2 (1992)";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "逃学威龙2");
Assert.AreEqual(parseResult.Year, 1992);
// anime
fileName = "[SAIO-Raws] もののけ姫 Mononoke Hime [BD 1920x1036 HEVC-10bit OPUSx2 AC3].mp4";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, "もののけ姫");
Assert.AreEqual(parseResult.Name, "Mononoke Hime");
Assert.AreEqual(parseResult.Year, null);
}
[TestMethod]
public void TestTVSeriesParse()
{
// 混合中英文
var fileName = "航海王:狂热行动.One.Piece.Stampede.2019.BD720P.X264.AAC.Japanese&Mandarin.CHS.Mp4Ba";
var parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, "航海王:狂热行动");
Assert.AreEqual(parseResult.Name, "One Piece Stampede");
Assert.AreEqual(parseResult.Year, 2019);
// 混合中英文带副标题
// 只英文
fileName = "She-Hulk.Attorney.at.Law.S01.1080p.WEBRip.x265-RARBG";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.Name, "She-Hulk Attorney at Law");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.Year, null);
fileName = "Bright.Future.S01.2022.2160p.HDR.WEB-DL.H265.AAC-BlackTV[BTBTT]";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.Name, "Bright Future");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.Year, 2022);
fileName = "Back.to.the.Future.Part.II.1989.BluRay.1080p.x265.10bit.2Audio-MiniHD[BTBTT]";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.Name, "Back to the Future Part II");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, 1989);
// anime混合中日文
fileName = "[异域-11番小队][罗马浴场 THERMAE_ROMAE][1-6+SP][BDRIP][720P][X264-10bit_AAC]";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.ChineseName, "罗马浴场");
Assert.AreEqual(parseResult.Name, "THERMAE ROMAE");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, null);
// anime
fileName = "[Nekomoe kissaten][Shin Ikkitousen][01-03][720p][CHT]";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.Name, "Shin Ikkitousen");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, null);
fileName = "[SAIO-Raws] Fullmetal Alchemist Brotherhood [BD 1920x1080 HEVC-10bit OPUS][2009]";
parseResult = NameParser.Parse(fileName);
Assert.AreEqual(parseResult.Name, "Fullmetal Alchemist Brotherhood");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, 2009);
}
[TestMethod]
public void TestEposideParse()
{
// 普通数字
var fileName = "03.mp4";
var parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "03");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 3);
fileName = "03 4K.mp4";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "03");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 3);
// 混合中英文
fileName = "新世界.New.World.2013.BluRay.1080p.x265.10bit.MNHD-FRDS";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.ChineseName, "新世界");
Assert.AreEqual(parseResult.Name, "New World");
Assert.AreEqual(parseResult.Year, 2013);
// 只英文 S01E01
fileName = "She-Hulk.Attorney.At.Law.S01E01.1080p.WEBRip.x265-RARBG";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "She-Hulk Attorney At Law");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.IndexNumber, 1);
// 测试 SXXEPXX 格式
fileName = "神探狄仁杰2 Detective.Dee.Ⅱ.S02EP02.2006.2160p.WEB-DL.x264.AAC-HQC";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.ChineseName, "神探狄仁杰2");
Assert.AreEqual(parseResult.Name, "Detective Dee Ⅱ");
Assert.AreEqual(parseResult.ParentIndexNumber, 2);
Assert.AreEqual(parseResult.IndexNumber, 2);
// 日文
fileName = "プロポーズ大作戦Ep05_x264.mp4";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "プロポーズ大作戦Ep05");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 5);
fileName = "[01] [ANK-Raws] あっちこっち 01 (BDrip 1920x1080 HEVC-YUV420P10 FLAC)";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "あっちこっち 01");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 1);
// 只中文
fileName = "齊天大聖 第02集";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "齊天大聖");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 2);
fileName = "齊天大聖 第 02 期";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "齊天大聖");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 2);
// anime
fileName = "[YYDM-11FANS][THERMAE_ROMAE][02][BDRIP][720P][X264-10bit_AAC][7FF2269F]";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "THERMAE ROMAE");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 2);
// anime带季数
fileName = "[WMSUB][Detective Conan - Zeros Tea Time ][S01][E06][BIG5][1080P].mp4";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "Detective Conan - Zeros Tea Time");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.IndexNumber, 6);
fileName = "[KTXP][Machikado_Mazoku_S2][01][BIG5][1080p]";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "Machikado Mazoku");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 1);
fileName = "[異域字幕組][她和她的貓 - Everything Flows -][She and Her Cat - Everything Flows -][01][720p][繁體]";
parseResult = NameParser.ParseEpisode(fileName);
Assert.AreEqual(parseResult.Name, "她和她的貓 - Everything Flows");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 1);
// anime特典
fileName = "[KissSub][Steins;Gate][SP][GB_BIG5_JP][BDrip][1080P][HEVC] 边界曲面的缺失之环";
parseResult = NameParser.ParseEpisode(fileName);
Assert.IsTrue(parseResult.IsSpecial);
Assert.AreEqual(parseResult.Name, "边界曲面的缺失之环");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, null);
}
[TestMethod]
public void TestCheckExtra()
{
var fileName = "[VCB-Studio] Spice and Wolf [CM02][Ma10p_1080p][x265_flac]";
var parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "[VCB-Studio] Spice and Wolf [Menu01_2][Ma10p_1080p][x265_flac]";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "[VCB-Studio] Spice and Wolf [NCED][Ma10p_1080p][x265_flac]";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "[VCB-Studio] Spice and Wolf [NCOP][Ma10p_1080p][x265_flac]";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "[VCB-Studio] Spice and Wolf II [Drama02][Ma10p_1080p][x265_flac].mp4";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "Evangelion 3.0+1.11 Thrice Upon a Time - Voice Message01 In Shin Evangelion No All Night Nippo (BDRIP 1920x1080 x265 10bit ac3)";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "Evangelion 3.0+1.11 Thrice Upon a Time - Message1 In Kinyoubi Roadshow (BDRIP 1920x1080 x265 10bit ac3)";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
// fileName = "Evangelion 3.0+1.11 Thrice Upon a Time - CM (BDRIP 1920x1080 x265 10bit ac3)";
// parseResult = NameParser.Parse(fileName);
// Assert.IsTrue(parseResult.IsExtra);
// fileName = "Evangelion 3.0+1.11 Thrice Upon a Time - Logo (BDRIP 1920x1080 x265 10bit ac3)";
// parseResult = NameParser.Parse(fileName);
// Assert.IsTrue(parseResult.IsExtra);
// fileName = "Evangelion 3.0+1.11 Thrice Upon a Time - Audio Guide (BDRIP 1920x1080 x265 10bit ac3)";
// parseResult = NameParser.Parse(fileName);
// Assert.IsTrue(parseResult.IsExtra);
// fileName = "Evangelion 3.0+1.11 Thrice Upon a Time - BD&DVD PV (BDRIP 1920x1080 x265 10bit ac3).mkv";
// parseResult = NameParser.Parse(fileName);
// Assert.IsTrue(parseResult.IsExtra);
}
}
}

View File

@ -0,0 +1,79 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class PersonProviderTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestGetMetadata()
{
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var info = new PersonLookupInfo() { ProviderIds = new Dictionary<string, string>() { { BaseProvider.DoubanProviderId, "1016771" } } };
var provider = new PersonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetMetadataByTmdb()
{
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var info = new PersonLookupInfo() { ProviderIds = new Dictionary<string, string>() { { MetadataProvider.Tmdb.ToString(), "78871" } } };
var provider = new PersonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
}
}

View File

@ -0,0 +1,113 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class SeasonProviderTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestGetMetadata()
{
var info = new SeasonInfo() { Name = "第 18 季", IndexNumber = 18, SeriesProviderIds = new Dictionary<string, string>() { { BaseProvider.DoubanProviderId, "2059529" }, { MetadataProvider.Tmdb.ToString(), "34860" } } };
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new SeasonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGuessSeasonNumberByFileName()
{
var info = new SeasonInfo() { };
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
var provider = new SeasonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
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");
Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/向往的生活/第2季");
Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/向往的生活 第2季");
Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/向往的生活/第三季");
Assert.AreEqual(result, 3);
result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/攻壳机动队Ghost_in_The_Shell_S.A.C._2nd_GIG");
Assert.AreEqual(result, 2);
// result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/Spice and Wolf/Spice and Wolf 2");
// Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/Spice and Wolf/Spice and Wolf 2 test");
Assert.AreEqual(result, null);
result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/[BDrip] Made in Abyss S02 [7鲁ACG x Sakurato]");
Assert.AreEqual(result, 2);
}
[TestMethod]
public void TestGuestDoubanSeasonByYearAsync()
{
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new SeasonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GuestDoubanSeasonByYearAsync("机动战士高达0083 星尘的回忆", 1991, null, CancellationToken.None);
Assert.AreEqual(result, "1766564");
}).GetAwaiter().GetResult();
}
}
}

View File

@ -0,0 +1,59 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Test
{
[TestClass]
public class SeriesImageProviderTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestGetMovieImageFromTMDB()
{
var info = new MediaBrowser.Controller.Entities.Movies.Movie()
{
PreferredMetadataLanguage = "zh",
ProviderIds = new Dictionary<string, string> { { MetadataProvider.Tmdb.ToString(), "67534" }, { Plugin.ProviderId, MetaSource.Tmdb.ToString() } }
};
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new SeriesImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetImages(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
}
}

View File

@ -3,6 +3,7 @@ using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using System;
@ -26,20 +27,21 @@ namespace Jellyfin.Plugin.MetaShark.Test
}));
[TestMethod]
public void TestGetMetadata()
{
var info = new SeriesInfo() { Name = "外科医生奉达熙", ProviderIds = new Dictionary<string, string>() { { BaseProvider.DoubanProviderId, "2241528" } } };
var info = new SeriesInfo() { Name = "天下长河" };
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new SeriesProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, doubanApi, tmdbApi, omdbApi);
var provider = new SeriesProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
@ -48,5 +50,30 @@ namespace Jellyfin.Plugin.MetaShark.Test
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetAnimeMetadata()
{
var info = new SeriesInfo() { Name = "命运-冠位嘉年华" };
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var imdbApi = new ImdbApi(loggerFactory);
Task.Run(async () =>
{
var provider = new SeriesProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.AreEqual(result.Item.Name, "命运/冠位指定嘉年华 公元2020奥林匹亚英灵限界大祭");
Assert.AreEqual(result.Item.OriginalTitle, "Fate/Grand Carnival");
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
}
}

View File

@ -1,5 +1,6 @@
using Jellyfin.Plugin.MetaShark.Core;
using Emby.Naming.TV;
namespace Jellyfin.Plugin.MetaShark.Test
{

View File

@ -8,6 +8,7 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Find;
using TMDbLib.Objects.Languages;
namespace Jellyfin.Plugin.MetaShark.Test
@ -36,6 +37,26 @@ namespace Jellyfin.Plugin.MetaShark.Test
}));
[TestMethod]
public void TestGetMovie()
{
var api = new TmdbApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.GetMovieAsync(752, "zh", "zh", CancellationToken.None)
.ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.Images.ToJson());
}
catch (Exception ex)
{
TestContext.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
@ -47,12 +68,7 @@ namespace Jellyfin.Plugin.MetaShark.Test
{
try
{
var result = await api.GetSeriesAsync(13372, "zh", BaseProvider.GetImageLanguagesParam("zh"), CancellationToken.None)
.ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.Images.ToJson());
result = await api.GetSeriesAsync(13372, "zh", null, CancellationToken.None)
var result = await api.GetSeriesAsync(13372, "zh", "zh", CancellationToken.None)
.ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.Images.ToJson());
@ -74,15 +90,10 @@ namespace Jellyfin.Plugin.MetaShark.Test
{
try
{
var result = await api.GetEpisodeAsync(13372, 1, 1, "zh", BaseProvider.GetImageLanguagesParam("zh"), CancellationToken.None)
var result = await api.GetEpisodeAsync(13372, 1, 1, "zh", "zh", CancellationToken.None)
.ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.Images.Stills.ToJson());
result = await api.GetEpisodeAsync(13372, 1, 1, null, null, CancellationToken.None)
.ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.ToJson());
}
catch (Exception ex)
{
@ -96,14 +107,35 @@ namespace Jellyfin.Plugin.MetaShark.Test
[TestMethod]
public void TestSearch()
{
var keyword = "重返少年时";
var keyword = "狼与香辛料";
var api = new TmdbApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.SearchSeriesAsync(keyword, "zh", CancellationToken.None)
var result = await api.SearchSeriesAsync(keyword, "zh", CancellationToken.None).ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.ToJson());
}
catch (Exception ex)
{
TestContext.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestFindByExternalId()
{
var api = new TmdbApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.FindByExternalIdAsync("tt5924366", FindExternalSource.Imdb, "zh", CancellationToken.None)
.ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.ToJson());

View File

@ -6,6 +6,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.MetaShark",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.MetaShark.Test", "Jellyfin.Plugin.MetaShark.Test\Jellyfin.Plugin.MetaShark.Test.csproj", "{80814353-4291-4230-8C4A-4C45CAD4D5D3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnitomySharp", "AnitomySharp\AnitomySharp.csproj", "{B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -20,6 +22,10 @@ Global
{80814353-4291-4230-8C4A-4C45CAD4D5D3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80814353-4291-4230-8C4A-4C45CAD4D5D3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80814353-4291-4230-8C4A-4C45CAD4D5D3}.Release|Any CPU.Build.0 = Release|Any CPU
{B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -2,43 +2,33 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.MetaShark.Model;
using System.Threading;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Common.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using System.Net;
using Jellyfin.Plugin.MetaShark.Api.Http;
using System.Web;
using static Microsoft.Extensions.Logging.EventSource.LoggingEventSource;
using Microsoft.Extensions.Caching.Memory;
using Jellyfin.Plugin.MetaShark.Providers;
using AngleSharp;
using System.Net.WebSockets;
using Jellyfin.Data.Entities.Libraries;
using AngleSharp.Dom;
using System.Text.RegularExpressions;
using Jellyfin.Plugin.MetaShark.Core;
using System.Data;
using TMDbLib.Objects.Movies;
using System.Xml.Linq;
using RateLimiter;
using ComposableAsync;
namespace Jellyfin.Plugin.MetaShark.Api
{
public class DoubanApi : IDisposable
{
const string HTTP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44";
public const string HTTP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44";
public const string HTTP_REFERER = "https://www.douban.com/";
private readonly ILogger<DoubanApi> _logger;
private HttpClient httpClient;
private CookieContainer _cookieContainer;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly IMemoryCache _memoryCache;
private static readonly object _lock = new object();
@ -47,7 +37,10 @@ namespace Jellyfin.Plugin.MetaShark.Api
Regex regId = new Regex(@"/(\d+?)/", RegexOptions.Compiled);
Regex regSid = new Regex(@"sid: (\d+?),", RegexOptions.Compiled);
Regex regCat = new Regex(@"\[(.+?)\]", RegexOptions.Compiled);
Regex regYear = new Regex(@"(\d{4})", RegexOptions.Compiled);
Regex regYear = new Regex(@"([12][890][0-9][0-9])", RegexOptions.Compiled);
Regex regTitle = new Regex(@"<title>([\w\W]+?)</title>", RegexOptions.Compiled);
Regex regKeywordMeta = new Regex(@"<meta name=""keywords"" content=""(.+?)""", RegexOptions.Compiled);
Regex regOriginalName = new Regex(@"原名[:](.+?)\s*?\/", RegexOptions.Compiled);
Regex regDirector = new Regex(@"导演: (.+?)\n", RegexOptions.Compiled);
Regex regWriter = new Regex(@"编剧: (.+?)\n", RegexOptions.Compiled);
Regex regActor = new Regex(@"主演: (.+?)\n", RegexOptions.Compiled);
@ -55,22 +48,27 @@ namespace Jellyfin.Plugin.MetaShark.Api
Regex regCountry = new Regex(@"制片国家/地区: (.+?)\n", RegexOptions.Compiled);
Regex regLanguage = new Regex(@"语言: (.+?)\n", RegexOptions.Compiled);
Regex regDuration = new Regex(@"片长: (.+?)\n", RegexOptions.Compiled);
Regex regScreen = new Regex(@"上映日期: (.+?)\n", RegexOptions.Compiled);
Regex regScreen = new Regex(@"(上映日期|首播): (.+?)\n", RegexOptions.Compiled);
Regex regSubname = new Regex(@"又名: (.+?)\n", RegexOptions.Compiled);
Regex regImdb = new Regex(@"IMDb: (.+?)$", RegexOptions.Compiled);
Regex regImdb = new Regex(@"IMDb: (tt\d+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
Regex regSite = new Regex(@"官方网站: (.+?)\n", RegexOptions.Compiled);
Regex regNameMath = new Regex(@"(.+第\w季|[\w\uff1a\uff01\uff0c\u00b7]+)\s*(.*)", RegexOptions.Compiled);
Regex regRole = new Regex(@"\([饰|配] (.+?)\)", RegexOptions.Compiled);
Regex regBackgroundImage = new Regex(@"url\((.+?)\)", RegexOptions.Compiled);
Regex regGender = new Regex(@"性别: \n(.+?)\n", RegexOptions.Compiled);
Regex regConstellation = new Regex(@"星座: \n(.+?)\n", RegexOptions.Compiled);
Regex regBirthdate = new Regex(@"出生日期: \n(.+?)\n", RegexOptions.Compiled);
Regex regLifedate = new Regex(@"生卒日期: \n(.+?) 至", RegexOptions.Compiled);
Regex regBirthplace = new Regex(@"出生地: \n(.+?)\n", RegexOptions.Compiled);
Regex regCelebrityRole = new Regex(@"职业: \n(.+?)\n", RegexOptions.Compiled);
Regex regNickname = new Regex(@"更多外文名: \n(.+?)\n", RegexOptions.Compiled);
Regex regFamily = new Regex(@"家庭成员: \n(.+?)\n", RegexOptions.Compiled);
Regex regCelebrityImdb = new Regex(@"imdb编号: \n(.+?)\n", RegexOptions.Compiled);
Regex regRole = new Regex(@"\([饰|配]?\s*?(.+?)\)", RegexOptions.Compiled);
Regex regBackgroundImage = new Regex(@"url\(([^)]+?)\)$", RegexOptions.Compiled);
Regex regLifedate = new Regex(@"(.+?) 至 (.+)", RegexOptions.Compiled);
Regex regHtmlTag = new Regex(@"<.?>", RegexOptions.Compiled);
Regex regImgHost = new Regex(@"\/\/(img\d+?)\.", RegexOptions.Compiled);
// 匹配除了换行符之外所有空白
Regex regOverviewSpace = new Regex(@"\n[^\S\n]+", RegexOptions.Compiled);
Regex regPhotoId = new Regex(@"/photo/(\d+?)/", RegexOptions.Compiled);
Regex regLoginName = new Regex(@"<div[^>]*?db-usr-profile[^>]*?>[\w\W]*?<h1>([^>]*?)<", RegexOptions.Compiled);
// 默认200毫秒请求1次
private TimeLimiter _defaultTimeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(200));
// 未登录最多1分钟10次请求不然5分钟后会被封ip
private TimeLimiter _guestTimeConstraint = TimeLimiter.Compose(new CountByIntervalAwaitableConstraint(10, TimeSpan.FromMinutes(1)), new CountByIntervalAwaitableConstraint(1, TimeSpan.FromMilliseconds(5000)));
// 登录后最多1分钟20次请求不然会触发机器人检验
private TimeLimiter _loginedTimeConstraint = TimeLimiter.Compose(new CountByIntervalAwaitableConstraint(20, TimeSpan.FromMinutes(1)), new CountByIntervalAwaitableConstraint(1, TimeSpan.FromMilliseconds(3000)));
/// <summary>
/// Initializes a new instance of the <see cref="DoubanApi"/> class.
@ -82,25 +80,44 @@ namespace Jellyfin.Plugin.MetaShark.Api
_memoryCache = new MemoryCache(new MemoryCacheOptions());
var handler = new HttpClientHandlerEx();
this.SetDoubanCookie(handler.CookieContainer);
httpClient = new HttpClient(handler, true);
httpClient.Timeout = TimeSpan.FromSeconds(10);
httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT);
this._cookieContainer = handler.CookieContainer;
httpClient = new HttpClient(handler);
httpClient.Timeout = TimeSpan.FromSeconds(20);
httpClient.DefaultRequestHeaders.Add("User-Agent", HTTP_USER_AGENT);
httpClient.DefaultRequestHeaders.Add("Origin", "https://movie.douban.com");
httpClient.DefaultRequestHeaders.Add("Referer", "https://movie.douban.com/");
this.LoadLoadDoubanCookie();
if (Plugin.Instance != null)
{
Plugin.Instance.ConfigurationChanged += (_, _) =>
{
this.LoadLoadDoubanCookie();
};
}
}
private void SetDoubanCookie(CookieContainer cookieContainer)
private void LoadLoadDoubanCookie()
{
var configCookie = Plugin.Instance?.Configuration.DoubanCookies.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(configCookie))
lock (_lock)
{
return;
var uri = new Uri("https://douban.com/");
// 清空旧的cookie
var cookies = _cookieContainer.GetCookies(uri);
foreach (Cookie co in cookies)
{
co.Expires = DateTime.Now.Subtract(TimeSpan.FromDays(1));
}
var uri = new Uri("https://douban.com/");
// 附加新的cookie
if (!string.IsNullOrEmpty(configCookie))
{
var arr = configCookie.Split(';');
foreach (var str in arr)
{
@ -114,13 +131,28 @@ namespace Jellyfin.Plugin.MetaShark.Api
var value = cookieArr[1].Trim();
try
{
cookieContainer.Add(new Cookie(key, value, "/", ".douban.com"));
_cookieContainer.Add(new Cookie(key, value, "/", ".douban.com"));
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
this._logger.LogDebug(ex, ex.Message);
}
}
}
}
}
public async Task<List<DoubanSubject>> SearchMovieAsync(string keyword, CancellationToken cancellationToken)
{
var result = await this.SearchAsync(keyword, cancellationToken).ConfigureAwait(false);
return result.Where(x => x.Category == "电影").ToList();
}
public async Task<List<DoubanSubject>> SearchTVAsync(string keyword, CancellationToken cancellationToken)
{
var result = await this.SearchAsync(keyword, cancellationToken).ConfigureAwait(false);
return result.Where(x => x.Category == "电视剧").ToList();
}
public async Task<List<DoubanSubject>> SearchAsync(string keyword, CancellationToken cancellationToken)
@ -132,19 +164,21 @@ namespace Jellyfin.Plugin.MetaShark.Api
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
List<DoubanSubject> searchResult;
if (_memoryCache.TryGetValue<List<DoubanSubject>>(cacheKey, out searchResult))
{
return searchResult;
}
await LimitRequestFrequently();
keyword = HttpUtility.UrlEncode(keyword);
var url = $"https://www.douban.com/search?cat=1002&q={keyword}";
var encodedKeyword = HttpUtility.UrlEncode(keyword);
var url = $"https://www.douban.com/search?cat=1002&q={encodedKeyword}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
this._logger.LogWarning("douban搜索请求失败. keyword: {0} statusCode: {1}", keyword, response.StatusCode);
return list;
}
@ -156,6 +190,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
foreach (var movieElement in movieElements)
{
var ratingStr = movieElement.GetText("div.rating-info") ?? string.Empty;
if (ratingStr.Contains("尚未播出"))
{
continue;
}
var rating = movieElement.GetText("div.rating-info>.rating_nums") ?? "0";
var img = movieElement.GetAttr("a.nbg>img", "src") ?? string.Empty;
var oncick = movieElement.GetAttr("div.title a", "onclick") ?? string.Empty;
@ -163,9 +203,10 @@ namespace Jellyfin.Plugin.MetaShark.Api
var name = movieElement.GetText("div.title a") ?? string.Empty;
var titleStr = movieElement.GetText("div.title>h3>span") ?? string.Empty;
var cat = titleStr.GetMatchGroup(this.regCat);
var subjectStr = movieElement.GetText("div.rating-info>.subject-cast") ?? string.Empty;
var subjectStr = movieElement.GetText("div.rating-info>span:last-child") ?? string.Empty;
var year = subjectStr.GetMatchGroup(this.regYear);
var originalName = subjectStr.GetMatchGroup(this.regOriginalName);
var desc = movieElement.GetText("div.content>p") ?? string.Empty;
if (cat != "电影" && cat != "电视剧")
{
continue;
@ -174,15 +215,88 @@ namespace Jellyfin.Plugin.MetaShark.Api
var movie = new DoubanSubject();
movie.Sid = sid;
movie.Name = name;
movie.OriginalName = !string.IsNullOrEmpty(originalName) ? originalName : name;
movie.Genre = cat;
movie.Category = cat;
movie.Img = img;
movie.Rating = rating.ToFloat();
movie.Year = year.ToInt();
movie.Intro = desc;
list.Add(movie);
}
if (list.Count > 0)
{
_memoryCache.Set<List<DoubanSubject>>(cacheKey, list, expiredOption);
}
else
{
if (body.Contains("sec.douban.com"))
{
this._logger.LogWarning("douban触发风控可能ip被封请到插件配置中打开防封禁功能。。。keyword: {0}", keyword);
}
else
{
this._logger.LogWarning("douban搜索不到内容这消息大量出现时可能触发了爬虫风控。。。keyword: {0}", keyword);
}
}
return list;
}
public async Task<List<DoubanSubject>> SearchBySuggestAsync(string keyword, CancellationToken cancellationToken)
{
var list = new List<DoubanSubject>();
if (string.IsNullOrEmpty(keyword))
{
return list;
}
await LimitRequestFrequently();
try
{
var encodedKeyword = HttpUtility.UrlEncode(keyword);
var url = $"https://www.douban.com/j/search_suggest?q={encodedKeyword}";
using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, url))
{
requestMessage.Headers.Add("Origin", "https://www.douban.com");
requestMessage.Headers.Add("Referer", "https://www.douban.com/");
var response = await httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
this._logger.LogWarning("douban suggest请求失败. keyword: {0} statusCode: {1}", keyword, response.StatusCode);
return list;
}
JsonSerializerOptions? serializeOptions = null;
var result = await response.Content.ReadFromJsonAsync<DoubanSuggestResult>(serializeOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Cards != null)
{
foreach (var suggest in result.Cards)
{
if (suggest.Type != "movie")
{
continue;
}
var movie = new DoubanSubject();
movie.Sid = suggest.Sid;
movie.Name = suggest.Title;
movie.Year = suggest.Year.ToInt();
list.Add(movie);
}
}
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "SearchBySuggestAsync error. keyword: {0}", keyword);
}
return list;
}
@ -201,6 +315,8 @@ namespace Jellyfin.Plugin.MetaShark.Api
return movie;
}
await LimitRequestFrequently();
var url = $"https://movie.douban.com/subject/{sid}/";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
@ -215,21 +331,18 @@ namespace Jellyfin.Plugin.MetaShark.Api
var contentNode = doc.QuerySelector("#content");
if (contentNode != null)
{
var nameStr = contentNode.GetText("h1>span:first-child");
var match = this.regNameMath.Match(nameStr);
var name = string.Empty;
var orginalName = string.Empty;
if (match.Success && match.Groups.Count == 3)
{
name = match.Groups[1].Value;
orginalName = match.Groups[2].Value;
}
var nameStr = contentNode.GetText("h1>span:first-child") ?? string.Empty;
var name = GetTitle(body);
var orginalName = nameStr.Replace(name, "").Trim();
var yearStr = contentNode.GetText("h1>span.year") ?? string.Empty;
var year = yearStr.GetMatchGroup(this.regYear);
var rating = contentNode.GetText("div.rating_self strong.rating_num") ?? "0";
var img = contentNode.GetAttr("a.nbgnbg>img", "src") ?? string.Empty;
var intro = contentNode.GetText("div.indent>span") ?? string.Empty;
intro = intro.Replace("©豆瓣", string.Empty);
var category = contentNode.QuerySelector("div.episode_list") == null ? "电影" : "电视剧";
var intro = contentNode.GetText("div#link-report-intra>span.all") ?? contentNode.GetText("div#link-report-intra>span") ?? string.Empty;
intro = formatOverview(intro);
var info = contentNode.GetText("#info") ?? string.Empty;
var director = info.GetMatchGroup(this.regDirector);
@ -239,10 +352,11 @@ namespace Jellyfin.Plugin.MetaShark.Api
var country = info.GetMatchGroup(this.regCountry);
var language = info.GetMatchGroup(this.regLanguage);
var duration = info.GetMatchGroup(this.regDuration);
var screen = info.GetMatchGroup(this.regScreen);
var subname = info.GetMatchGroup(this.regSubname);
var imdb = info.GetMatchGroup(this.regImdb);
var site = info.GetMatchGroup(this.regSite);
var matchs = this.regScreen.Match(info);
var screen = matchs.Groups.Count > 2 ? matchs.Groups[2].Value : string.Empty;
movie.Sid = sid;
movie.Name = name;
@ -254,6 +368,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
movie.Subname = subname;
movie.Director = director;
movie.Genre = genre;
movie.Category = category;
movie.Country = country;
movie.Language = language;
movie.Duration = duration;
@ -307,6 +422,8 @@ namespace Jellyfin.Plugin.MetaShark.Api
return celebrities;
}
await LimitRequestFrequently();
var list = new List<DoubanCelebrity>();
var url = $"https://movie.douban.com/subject/{sid}/celebrities";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
@ -318,8 +435,17 @@ namespace Jellyfin.Plugin.MetaShark.Api
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var context = BrowsingContext.New();
var doc = await context.OpenAsync(req => req.Content(body), cancellationToken).ConfigureAwait(false);
var celebrityElements = doc.QuerySelectorAll("#content ul.celebrities-list li.celebrity");
var celebritiesElements = doc.QuerySelectorAll("div#celebrities>.list-wrapper");
foreach (var celebritiesNode in celebritiesElements)
{
var celebritiesTitle = celebritiesNode.GetText("h2") ?? string.Empty;
if (!celebritiesTitle.Contains("导演") && !celebritiesTitle.Contains("演员"))
{
continue;
}
var celebrityElements = celebritiesNode.QuerySelectorAll("ul.celebrities-list li.celebrity");
foreach (var node in celebrityElements)
{
@ -328,8 +454,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
var celebrityImgStr = node.GetAttr("div.avatar", "style") ?? string.Empty;
var celebrityImg = celebrityImgStr.GetMatchGroup(this.regBackgroundImage);
var celebrityNameStr = node.GetText("div.info a.name") ?? string.Empty;
var arr = celebrityNameStr.Split(" ");
var celebrityName = arr.Length > 1 ? arr[0] : string.Empty;
var celebrityName = this.ParseCelebrityName(celebrityNameStr);
// 有时存在演员信息缺少名字的
if (string.IsNullOrEmpty(celebrityName))
{
continue;
}
var celebrityRoleStr = node.GetText("div.info span.role") ?? string.Empty;
var celebrityRole = celebrityRoleStr.GetMatchGroup(this.regRole);
var arrRole = celebrityRoleStr.Split(" ");
@ -339,11 +469,6 @@ namespace Jellyfin.Plugin.MetaShark.Api
celebrityRole = celebrityRoleType;
}
if (celebrityRoleType != "导演" && celebrityRoleType != "配音" && celebrityRoleType != "演员")
{
continue;
}
var celebrity = new DoubanCelebrity();
celebrity.Id = celebrityId;
celebrity.Name = celebrityName;
@ -353,8 +478,9 @@ namespace Jellyfin.Plugin.MetaShark.Api
list.Add(celebrity);
}
}
_memoryCache.Set<List<DoubanCelebrity>>(cacheKey, list.Take(15).ToList(), expiredOption);
_memoryCache.Set<List<DoubanCelebrity>>(cacheKey, list, expiredOption);
return list;
}
@ -385,39 +511,64 @@ namespace Jellyfin.Plugin.MetaShark.Api
var contentNode = doc.QuerySelector("#content");
if (contentNode != null)
{
var img = contentNode.GetAttr("#headline .nbg img", "src") ?? string.Empty;
var name = contentNode.GetText("h1") ?? string.Empty;
var intro = contentNode.GetText("#intro span.all") ?? string.Empty;
if (string.IsNullOrEmpty(intro))
celebrity.Img = contentNode.GetAttr("img.avatar", "src") ?? string.Empty;
var nameStr = contentNode.GetText("h1.subject-name") ?? string.Empty;
celebrity.Name = this.ParseCelebrityName(nameStr);
celebrity.EnglishName = nameStr.Replace(celebrity.Name, "").Trim();
var family = string.Empty;
var propertyNodes = contentNode.QuerySelectorAll("ul.subject-property>li");
foreach (var li in propertyNodes)
{
intro = contentNode.GetText("#intro div.bd") ?? string.Empty;
var label = li.GetText("span.label") ?? string.Empty;
var value = li.GetText("span.value") ?? string.Empty;
switch (label)
{
case "性别:":
celebrity.Gender = value;
break;
case "星座:":
celebrity.Constellation = value;
break;
case "出生日期:":
celebrity.Birthdate = value;
break;
case "去世日期:":
celebrity.Enddate = value;
break;
case "生卒日期:":
var match = this.regLifedate.Match(value);
if (match.Success && match.Groups.Count > 2)
{
celebrity.Birthdate = match.Groups[1].Value.Trim();
celebrity.Enddate = match.Groups[2].Value.Trim();
}
break;
case "出生地:":
celebrity.Birthplace = value;
break;
case "职业:":
celebrity.Role = value;
break;
case "更多外文名:":
celebrity.NickName = value;
break;
case "家庭成员:":
family = value;
break;
case "IMDb编号:":
celebrity.Imdb = value;
break;
default:
break;
}
var info = contentNode.GetText("div.info") ?? string.Empty;
var gender = info.GetMatchGroup(this.regGender);
var constellation = info.GetMatchGroup(this.regConstellation);
var birthdate = info.GetMatchGroup(this.regBirthdate);
var lifedate = info.GetMatchGroup(this.regLifedate);
if (string.IsNullOrEmpty(birthdate))
{
birthdate = lifedate;
}
var birthplace = info.GetMatchGroup(this.regBirthplace);
var role = info.GetMatchGroup(this.regCelebrityRole);
var nickname = info.GetMatchGroup(this.regNickname);
var family = info.GetMatchGroup(this.regFamily);
var imdb = info.GetMatchGroup(this.regCelebrityImdb);
celebrity.Img = img;
celebrity.Gender = gender;
celebrity.Birthdate = birthdate;
celebrity.Nickname = nickname;
celebrity.Imdb = imdb;
celebrity.Birthplace = birthplace;
celebrity.Name = name;
celebrity.Intro = intro;
celebrity.Constellation = constellation;
celebrity.Role = role;
// 保留段落关系,把段落替换为换行符
var intro = contentNode.GetHtml("section.subject-intro div.content") ?? string.Empty;
intro = regHtmlTag.Replace(intro.Replace("</p>", "\n"), "");
celebrity.Intro = formatOverview(intro);
_memoryCache.Set<DoubanCelebrity?>(cacheKey, celebrity, expiredOption);
return celebrity;
}
@ -427,27 +578,182 @@ namespace Jellyfin.Plugin.MetaShark.Api
return null;
}
public async Task<List<DoubanPhoto>> GetWallpaperBySidAsync(string sid, CancellationToken cancellationToken)
public async Task<List<DoubanPhoto>> GetCelebrityPhotosAsync(string cid, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(sid))
var list = new List<DoubanPhoto>();
if (string.IsNullOrEmpty(cid))
{
return new List<DoubanPhoto>();
return list;
}
var cacheKey = $"photo_{sid}";
var cacheKey = $"celebrity_photo_{cid}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
List<DoubanPhoto> photos;
if (_memoryCache.TryGetValue<List<DoubanPhoto>>(cacheKey, out photos))
if (_memoryCache.TryGetValue<List<DoubanPhoto>>(cacheKey, out var photos))
{
return photos;
}
await LimitRequestFrequently();
try
{
var url = $"https://movie.douban.com/celebrity/{cid}/photos/";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return list;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var context = BrowsingContext.New();
var doc = await context.OpenAsync(req => req.Content(body), cancellationToken).ConfigureAwait(false);
var elements = doc.QuerySelectorAll(".poster-col3>li");
foreach (var node in elements)
{
var href = node.QuerySelector("a")?.GetAttribute("href") ?? string.Empty;
var id = href.GetMatchGroup(this.regPhotoId);
var raw = node.QuerySelector("img")?.GetAttribute("src") ?? string.Empty;
var size = node.GetText("div.prop") ?? string.Empty;
var photo = new DoubanPhoto();
photo.Id = id;
photo.Size = size;
photo.Raw = raw;
if (!string.IsNullOrEmpty(size))
{
var arr = size.Split('x');
if (arr.Length == 2)
{
photo.Width = arr[0].ToInt();
photo.Height = arr[1].ToInt();
}
}
list.Add(photo);
}
_memoryCache.Set<List<DoubanPhoto>>(cacheKey, list, expiredOption);
}
catch (Exception ex)
{
this._logger.LogError(ex, "GetCelebrityPhotosAsync error. cid: {0}", cid);
}
return list;
}
public string ParseCelebrityName(string nameString)
{
if (string.IsNullOrEmpty(nameString))
{
return string.Empty;
}
// 只有中文名情况
var idx = nameString.IndexOf(" ", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
return nameString.Trim();
}
// 中英名混合情况
var firstName = nameString.Substring(0, idx);
if (firstName.HasChinese())
{
return firstName.Trim();
}
// 英文名重复两次的情况
var nextIndex = nameString[idx..].IndexOf(firstName, StringComparison.OrdinalIgnoreCase);
if (nextIndex >= 0)
{
nextIndex = idx + nextIndex;
return nameString[..nextIndex].Trim();
}
// 只有英文名情况
return nameString.Trim();
}
public async Task<List<DoubanCelebrity>> SearchCelebrityAsync(string keyword, CancellationToken cancellationToken)
{
var list = new List<DoubanCelebrity>();
if (string.IsNullOrEmpty(keyword))
{
return list;
}
var cacheKey = $"search_celebrity_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
if (_memoryCache.TryGetValue<List<DoubanCelebrity>>(cacheKey, out var searchResult))
{
return searchResult;
}
keyword = HttpUtility.UrlEncode(keyword);
var url = $"https://movie.douban.com/celebrities/search?search_text={keyword}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return list;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var context = BrowsingContext.New();
var doc = await context.OpenAsync(req => req.Content(body), cancellationToken).ConfigureAwait(false);
var elements = doc.QuerySelectorAll("div.article .result");
foreach (var el in elements)
{
var celebrity = new DoubanCelebrity();
var img = el.GetAttr("div.pic img", "src") ?? string.Empty;
var href = el.GetAttr("h3>a", "href") ?? string.Empty;
var cid = href.GetMatchGroup(this.regId);
var nameStr = el.GetText("h3>a") ?? string.Empty;
var arr = nameStr.Split(" ");
var name = arr.Length > 1 ? arr[0] : nameStr;
celebrity.Name = name;
celebrity.Img = img;
celebrity.Id = cid;
list.Add(celebrity);
}
_memoryCache.Set<List<DoubanCelebrity>>(cacheKey, list, expiredOption);
return list;
}
public async Task<List<DoubanPhoto>> GetWallpaperBySidAsync(string sid, CancellationToken cancellationToken)
{
var list = new List<DoubanPhoto>();
if (string.IsNullOrEmpty(sid))
{
return list;
}
var cacheKey = $"photo_{sid}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
if (_memoryCache.TryGetValue<List<DoubanPhoto>>(cacheKey, out var photos))
{
return photos;
}
await LimitRequestFrequently();
try
{
var url = $"https://movie.douban.com/subject/{sid}/photos?type=W&start=0&sortby=size&size=a&subtype=a";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return new List<DoubanPhoto>();
return list;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
@ -459,21 +765,13 @@ namespace Jellyfin.Plugin.MetaShark.Api
{
var id = node.GetAttribute("data-id") ?? string.Empty;
var small = $"https://img2.doubanio.com/view/photo/s/public/p{id}.jpg";
var medium = $"https://img2.doubanio.com/view/photo/m/public/p{id}.jpg";
var large = $"https://img2.doubanio.com/view/photo/l/public/p{id}.jpg";
var imgUrl = node.QuerySelector("img")?.GetAttribute("src") ?? string.Empty;
var imgHost = regImgHost.FirstMatchGroup(imgUrl, "img2");
var small = $"https://{imgHost}.doubanio.com/view/photo/s/public/p{id}.jpg";
var medium = $"https://{imgHost}.doubanio.com/view/photo/m/public/p{id}.jpg";
var large = $"https://{imgHost}.doubanio.com/view/photo/l/public/p{id}.jpg";
var raw = $"https://{imgHost}.doubanio.com/view/photo/raw/public/p{id}.jpg";
var size = node.GetText("div.prop") ?? string.Empty;
var width = string.Empty;
var height = string.Empty;
if (!string.IsNullOrEmpty(size))
{
var arr = size.Split('x');
if (arr.Length == 2)
{
width = arr[0];
height = arr[1];
}
}
var photo = new DoubanPhoto();
photo.Id = id;
@ -481,29 +779,107 @@ namespace Jellyfin.Plugin.MetaShark.Api
photo.Small = small;
photo.Medium = medium;
photo.Large = large;
photo.Width = width.ToInt();
photo.Height = height.ToInt();
photo.Raw = raw;
if (!string.IsNullOrEmpty(size))
{
var arr = size.Split('x');
if (arr.Length == 2)
{
photo.Width = arr[0].ToInt();
photo.Height = arr[1].ToInt();
}
}
list.Add(photo);
}
_memoryCache.Set<List<DoubanPhoto>>(cacheKey, list, expiredOption);
}
catch (Exception ex)
{
this._logger.LogError(ex, "GetWallpaperBySidAsync error. sid: {0}", sid);
}
return list;
}
public async Task<bool> CheckLoginAsync(CancellationToken cancellationToken)
{
try
{
var url = "https://www.douban.com/mine/";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
var requestUrl = response.RequestMessage?.RequestUri?.ToString();
if (requestUrl == null || requestUrl.Contains("accounts.douban.com") || requestUrl.Contains("login") || requestUrl.Contains("sec.douban.com"))
{
return false;
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "CheckLoginAsync error.");
}
protected void LimitRequestFrequently()
{
lock (_lock)
{
var ts = DateTime.Now - lastRequestTime;
var diff = (int)(200 - ts.TotalMilliseconds);
if (diff > 0)
{
Thread.Sleep(diff);
return true;
}
lastRequestTime = DateTime.Now;
public async Task<DoubanLoginInfo> GetLoginInfoAsync(CancellationToken cancellationToken)
{
var loginInfo = new DoubanLoginInfo();
try
{
var url = "https://www.douban.com/mine/";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
var requestUrl = response.RequestMessage?.RequestUri?.ToString();
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var loginName = this.Match(body, regLoginName).Trim();
loginInfo.Name = loginName;
loginInfo.IsLogined = !(requestUrl == null || requestUrl.Contains("accounts.douban.com") || requestUrl.Contains("login") || requestUrl.Contains("sec.douban.com"));
}
catch (Exception ex)
{
this._logger.LogError(ex, "GetLoginInfoAsync error.");
}
return loginInfo;
}
protected async Task LimitRequestFrequently()
{
if (IsEnableAvoidRiskControl())
{
var configCookie = Plugin.Instance?.Configuration.DoubanCookies.Trim() ?? string.Empty;
if (!string.IsNullOrEmpty(configCookie))
{
await this._loginedTimeConstraint;
}
else
{
await this._guestTimeConstraint;
}
}
else
{
await this._defaultTimeConstraint;
}
}
private string GetTitle(string body)
{
var title = string.Empty;
var keyword = Match(body, regKeywordMeta);
if (!string.IsNullOrEmpty(keyword))
{
title = keyword.Split(",").FirstOrDefault();
if (!string.IsNullOrEmpty(title))
{
return title.Trim();
}
}
title = Match(body, regTitle);
return title.Replace("(豆瓣)", "").Trim();
}
private string? GetText(IElement el, string css)
@ -539,6 +915,17 @@ namespace Jellyfin.Plugin.MetaShark.Api
return string.Empty;
}
private string formatOverview(string intro)
{
intro = intro.Replace("©豆瓣", string.Empty);
return regOverviewSpace.Replace(intro, "\n").Trim();
}
private bool IsEnableAvoidRiskControl()
{
return Plugin.Instance?.Configuration.EnableDoubanAvoidRiskControl ?? false;
}
public void Dispose()
{
@ -550,6 +937,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
{
if (disposing)
{
httpClient.Dispose();
_memoryCache.Dispose();
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MetaShark.Api.Http
{
public class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _logger;
public LoggingHandler(HttpMessageHandler innerHandler, ILoggerFactory loggerFactory)
: base(innerHandler)
{
_logger = loggerFactory.CreateLogger<LoggingHandler>();
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
_logger.LogInformation((request.RequestUri?.ToString() ?? string.Empty));
return await base.SendAsync(request, cancellationToken);
}
}
}

View File

@ -0,0 +1,129 @@
using Jellyfin.Plugin.MetaShark.Core;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Api
{
public class ImdbApi : IDisposable
{
private readonly ILogger<DoubanApi> _logger;
private readonly IMemoryCache _memoryCache;
private readonly HttpClient httpClient;
Regex regId = new Regex(@"/(tt\d+)", RegexOptions.Compiled);
Regex regPersonId = new Regex(@"/(nm\d+)", RegexOptions.Compiled);
public ImdbApi(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<DoubanApi>();
_memoryCache = new MemoryCache(new MemoryCacheOptions());
var handler = new HttpClientHandler()
{
AllowAutoRedirect = false
};
httpClient = new HttpClient(handler);
httpClient.Timeout = TimeSpan.FromSeconds(10);
}
/// <summary>
/// 通过imdb获取信息会返回最新的imdb id
/// </summary>
public async Task<string?> CheckNewIDAsync(string id, CancellationToken cancellationToken)
{
var cacheKey = $"CheckNewImdbID_{id}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
if (this._memoryCache.TryGetValue<string?>(cacheKey, out var item))
{
return item;
}
try
{
var url = $"https://www.imdb.com/title/{id}/";
var resp = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (resp.Headers.TryGetValues("Location", out var values))
{
var location = values.First();
var newId = location.GetMatchGroup(this.regId);
if (!string.IsNullOrEmpty(newId))
{
item = newId;
}
}
this._memoryCache.Set(cacheKey, item, expiredOption);
return item;
}
catch (Exception ex)
{
this._logger.LogError(ex, "CheckNewImdbID error. id: {0}", id);
this._memoryCache.Set<string?>(cacheKey, null, expiredOption);
return null;
}
return null;
}
/// <summary>
/// 通过imdb获取信息会返回最新的imdb id
/// </summary>
public async Task<string?> CheckPersonNewIDAsync(string id, CancellationToken cancellationToken)
{
var cacheKey = $"CheckPersonNewImdbID_{id}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
if (this._memoryCache.TryGetValue<string?>(cacheKey, out var item))
{
return item;
}
try
{
var url = $"https://www.imdb.com/name/{id}/";
var resp = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (resp.Headers.TryGetValues("Location", out var values))
{
var location = values.First();
var newId = location.GetMatchGroup(this.regPersonId);
if (!string.IsNullOrEmpty(newId))
{
item = newId;
}
}
this._memoryCache.Set(cacheKey, item, expiredOption);
return item;
}
catch (Exception ex)
{
this._logger.LogError(ex, "CheckPersonNewImdbID error. id: {0}", id);
this._memoryCache.Set<string?>(cacheKey, null, expiredOption);
return null;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_memoryCache.Dispose();
}
}
private bool IsEnable()
{
return Plugin.Instance?.Configuration.EnableTmdb ?? true;
}
}
}

View File

@ -1,15 +1,9 @@
using Jellyfin.Extensions.Json;
using Jellyfin.Plugin.MetaShark.Api.Http;
using Jellyfin.Plugin.MetaShark.Model;
using Jellyfin.Plugin.MetaShark.Model;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@ -30,12 +24,22 @@ namespace Jellyfin.Plugin.MetaShark.Api
httpClient.Timeout = TimeSpan.FromSeconds(5);
}
/// <summary>
/// 通过imdb获取信息会返回最新的imdb id
/// </summary>
/// <param name="id">imdb id</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<OmdbItem?> GetByImdbID(string id, CancellationToken cancellationToken)
{
if (!this.IsEnable())
{
return null;
}
var cacheKey = $"GetByImdbID_{id}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
OmdbItem? item;
if (this._memoryCache.TryGetValue(cacheKey, out item))
if (this._memoryCache.TryGetValue<OmdbItem?>(cacheKey, out var item))
{
return item;
}
@ -69,5 +73,10 @@ namespace Jellyfin.Plugin.MetaShark.Api
_memoryCache.Dispose();
}
}
private bool IsEnable()
{
return Plugin.Instance?.Configuration.EnableTmdb ?? true;
}
}
}

View File

@ -23,11 +23,11 @@ namespace Jellyfin.Plugin.MetaShark.Api
public class TmdbApi : IDisposable
{
public const string DEFAULT_API_KEY = "4219e299c89411838049ab0dab19ebd5";
public const string DEFAULT_API_HOST = "api.tmdb.org";
private const int CacheDurationInHours = 1;
private readonly ILogger<TmdbApi> _logger;
private readonly IMemoryCache _memoryCache;
private readonly TMDbClient _tmDbClient;
private readonly PluginConfiguration _config;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbApi"/> class.
@ -36,10 +36,11 @@ namespace Jellyfin.Plugin.MetaShark.Api
{
_logger = loggerFactory.CreateLogger<TmdbApi>();
_memoryCache = new MemoryCache(new MemoryCacheOptions());
_config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var apiKey = string.IsNullOrEmpty(_config.TmdbApiKey) ? DEFAULT_API_KEY : _config.TmdbApiKey;
_tmDbClient = new TMDbClient(apiKey);
_tmDbClient.RequestTimeout = TimeSpan.FromSeconds(10);
var config = Plugin.Instance?.Configuration;
var apiKey = string.IsNullOrEmpty(config?.TmdbApiKey) ? DEFAULT_API_KEY : config.TmdbApiKey;
var host = string.IsNullOrEmpty(config?.TmdbHost) ? DEFAULT_API_HOST : config.TmdbHost;
_tmDbClient = new TMDbClient(apiKey, true, host, null, config?.GetTmdbWebProxy());
_tmDbClient.Timeout = TimeSpan.FromSeconds(10);
// Not really interested in NotFoundException
_tmDbClient.ThrowApiExceptions = false;
}
@ -54,7 +55,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb movie or null if not found.</returns>
public async Task<Movie?> GetMovieAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken)
{
if (!this._config.EnableTmdb)
if (!this.IsEnable())
{
return null;
}
@ -65,6 +66,8 @@ namespace Jellyfin.Plugin.MetaShark.Api
return movie;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
movie = await _tmDbClient.GetMovieAsync(
@ -81,6 +84,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return movie;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return null;
}
}
/// <summary>
/// Gets a collection from the TMDb API based on its TMDb id.
@ -125,7 +134,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb tv show information or null if not found.</returns>
public async Task<TvShow?> GetSeriesAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken)
{
if (!this._config.EnableTmdb)
if (!this.IsEnable())
{
return null;
}
@ -136,6 +145,8 @@ namespace Jellyfin.Plugin.MetaShark.Api
return series;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
series = await _tmDbClient.GetTvShowAsync(
@ -152,6 +163,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return series;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return null;
}
}
/// <summary>
/// Gets a tv season from the TMDb API based on the tv show's TMDb id.
@ -164,7 +181,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb tv season information or null if not found.</returns>
public async Task<TvSeason?> GetSeasonAsync(int tvShowId, int seasonNumber, string language, string imageLanguages, CancellationToken cancellationToken)
{
if (!this._config.EnableTmdb)
if (!this.IsEnable())
{
return null;
}
@ -175,6 +192,8 @@ namespace Jellyfin.Plugin.MetaShark.Api
return season;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
season = await _tmDbClient.GetTvSeasonAsync(
@ -185,13 +204,17 @@ namespace Jellyfin.Plugin.MetaShark.Api
extraMethods: TvSeasonMethods.Credits | TvSeasonMethods.Images | TvSeasonMethods.ExternalIds | TvSeasonMethods.Videos,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (season != null)
{
_memoryCache.Set(key, season, TimeSpan.FromHours(CacheDurationInHours));
}
return season;
}
catch (Exception ex)
{
// 可能网络有问题,缓存一下避免频繁请求
_memoryCache.Set(key, season, TimeSpan.FromSeconds(30));
this._logger.LogError(ex, ex.Message);
return null;
}
}
/// <summary>
/// Gets a movie from the TMDb API based on the tv show's TMDb id.
@ -205,12 +228,19 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb tv episode information or null if not found.</returns>
public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string language, string imageLanguages, CancellationToken cancellationToken)
{
if (!this.IsEnable())
{
return null;
}
var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out TvEpisode episode))
{
return episode;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
episode = await _tmDbClient.GetTvEpisodeAsync(
@ -229,6 +259,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return episode;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return null;
}
}
/// <summary>
/// Gets a person eg. cast or crew member from the TMDb API based on its TMDb id.
@ -238,7 +274,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb person information or null if not found.</returns>
public async Task<Person?> GetPersonAsync(int personTmdbId, CancellationToken cancellationToken)
{
if (!this._config.EnableTmdb)
if (!this.IsEnable())
{
return null;
}
@ -249,6 +285,9 @@ namespace Jellyfin.Plugin.MetaShark.Api
return person;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
person = await _tmDbClient.GetPersonAsync(
@ -263,6 +302,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return person;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return null;
}
}
/// <summary>
/// Gets an item from the TMDb API based on its id from an external service eg. IMDb id, TvDb id.
@ -278,12 +323,19 @@ namespace Jellyfin.Plugin.MetaShark.Api
string language,
CancellationToken cancellationToken)
{
if (!this.IsEnable())
{
return null;
}
var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out FindContainer result))
{
return result;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
result = await _tmDbClient.FindAsync(
@ -299,6 +351,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return result;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return null;
}
}
/// <summary>
/// Searches for a tv show using the TMDb API based on its name.
@ -309,7 +367,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb tv show information.</returns>
public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, CancellationToken cancellationToken)
{
if (!this._config.EnableTmdb)
if (!this.IsEnable())
{
return new List<SearchTv>();
}
@ -349,12 +407,19 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb person information.</returns>
public async Task<IReadOnlyList<SearchPerson>> SearchPersonAsync(string name, CancellationToken cancellationToken)
{
if (!this.IsEnable())
{
return new List<SearchPerson>();
}
var key = $"searchperson-{name}";
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson> person))
{
return person.Results;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
@ -368,6 +433,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return searchResults.Results;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return new List<SearchPerson>();
}
}
/// <summary>
/// Searches for a movie based on its name using the TMDb API.
@ -391,7 +462,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb movie information.</returns>
public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, CancellationToken cancellationToken)
{
if (!this._config.EnableTmdb)
if (!this.IsEnable())
{
return new List<SearchMovie>();
}
@ -402,6 +473,8 @@ namespace Jellyfin.Plugin.MetaShark.Api
return movies.Results;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
@ -415,6 +488,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return searchResults.Results;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return new List<SearchMovie>();
}
}
/// <summary>
/// Searches for a collection based on its name using the TMDb API.
@ -425,12 +504,19 @@ namespace Jellyfin.Plugin.MetaShark.Api
/// <returns>The TMDb collection information.</returns>
public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, CancellationToken cancellationToken)
{
if (!this.IsEnable())
{
return new List<SearchCollection>();
}
var key = $"collectionsearch-{name}-{language}";
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection> collections))
{
return collections.Results;
}
try
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
@ -444,6 +530,12 @@ namespace Jellyfin.Plugin.MetaShark.Api
return searchResults.Results;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
return new List<SearchCollection>();
}
}
/// <summary>
/// Gets the absolute URL of the poster.
@ -505,6 +597,16 @@ namespace Jellyfin.Plugin.MetaShark.Api
return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.StillSizes[^1], filePath).ToString();
}
public string? GetLogoUrl(string filePath)
{
if (string.IsNullOrEmpty(filePath))
{
return null;
}
return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.LogoSizes[^1], filePath).ToString();
}
/// <inheritdoc />
public void Dispose()
{
@ -555,8 +657,14 @@ namespace Jellyfin.Plugin.MetaShark.Api
return language;
}
public string GetImageLanguagesParam(string preferredLanguage)
{
if (string.IsNullOrEmpty(preferredLanguage))
{
return null;
}
var languages = new List<string>();
if (!string.IsNullOrEmpty(preferredLanguage))
@ -584,5 +692,10 @@ namespace Jellyfin.Plugin.MetaShark.Api
return string.Join(',', languages);
}
private bool IsEnable()
{
return Plugin.Instance?.Configuration.EnableTmdb ?? true;
}
}
}

View File

@ -1,43 +1,99 @@
using MediaBrowser.Model.Plugins;
using System.Net;
using System.Reflection;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.MetaShark.Configuration;
/// <summary>
/// The configuration options.
/// </summary>
public enum SomeOptions
{
/// <summary>
/// Option one.
/// </summary>
OneOption,
/// <summary>
/// Second option.
/// </summary>
AnotherOption
}
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version.ToString();
public const int MAX_CAST_MEMBERS = 15;
public const int MAX_SEARCH_RESULT = 5;
public string Pattern { get; set; } = @"(S\d{2}|E\d{2}|HDR|\d{3,4}p|WEBRip|WEB|YIFY|BrRip|BluRay|H265|H264|x264|AAC\.\d\.\d|AAC|HDTV|mkv|mp4)|(\[.*\])|(\-\w+|\{.*\}|【.*】|\(.*\)|\d+MB)|(\.|\-)";
public bool EnableTmdb { get; set; } = true;
public string TmdbApiKey { get; set; } = string.Empty;
/// <summary>
/// 插件版本
/// </summary>
public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? string.Empty;
public string DoubanCookies { get; set; } = string.Empty;
/// <summary>
/// 豆瓣开启防封禁
/// </summary>
public bool EnableDoubanAvoidRiskControl { get; set; } = false;
/// <summary>
/// 豆瓣海报使用大图
/// </summary>
public bool EnableDoubanLargePoster { get; set; } = false;
/// <summary>
/// 豆瓣背景图使用原图
/// </summary>
public bool EnableDoubanBackdropRaw { get; set; } = false;
/// <summary>
/// 豆瓣图片代理地址
/// </summary>
public string DoubanImageProxyBaseUrl { get; set; } = string.Empty;
public int MaxCastMembers { get; set; } = 15;
/// <summary>
/// 启用获取tmdb元数据
/// </summary>
public bool EnableTmdb { get; set; } = true;
public int MaxSearchResult { get; set; } = 3;
/// <summary>
/// 启用显示tmdb搜索结果
/// </summary>
public bool EnableTmdbSearch { get; set; } = false;
/// <summary>
/// 启用tmdb获取背景图
/// </summary>
public bool EnableTmdbBackdrop { get; set; } = true;
/// <summary>
/// 启用tmdb获取商标
/// </summary>
public bool EnableTmdbLogo { get; set; } = true;
/// <summary>
/// 是否获取电影系列信息
/// </summary>
public bool EnableTmdbCollection { get; set; } = true;
/// <summary>
/// 是否获取tmdb分级信息
/// </summary>
public bool EnableTmdbOfficialRating { get; set; } = true;
/// <summary>
/// tmdb api key
/// </summary>
public string TmdbApiKey { get; set; } = string.Empty;
/// <summary>
/// tmdb api host
/// </summary>
public string TmdbHost { get; set; } = string.Empty;
/// <summary>
/// 代理服务器类型0-禁用1-http2-https3-socket5
/// </summary>
public string TmdbProxyType { get; set; } = string.Empty;
/// <summary>
/// 代理服务器host
/// </summary>
public string TmdbProxyPort { get; set; } = string.Empty;
/// <summary>
/// 代理服务器端口
/// </summary>
public string TmdbProxyHost { get; set; } = string.Empty;
public IWebProxy GetTmdbWebProxy()
{
if (!string.IsNullOrEmpty(TmdbProxyType))
{
return new WebProxy($"{TmdbProxyType}://{TmdbProxyHost}:{TmdbProxyPort}", true);
}
return null;
}
}

View File

@ -22,29 +22,141 @@
</div>
<form id="TemplateConfigForm">
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend>
<h3>豆瓣</h3>
</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="DoubanCookies">豆瓣网站cookie</label>
<label class="inputLabel inputLabelUnfocused" for="DoubanCookies">豆瓣网站cookie<span
id="login_msg"
style="margin-left: 8px; display: none;"></span></label>
<textarea rows="5" is="emby-input" type="text" id="DoubanCookies" name="DoubanCookies"
class="emby-input"></textarea>
<div class="fieldDescription">可为空,填写可搜索到需登录访问的影片.(需重启才能生效)</div>
class="emby-input" placeholder="_vwo_uuid_v2=1; __utmv=2; ..."></textarea>
<div class="fieldDescription">可为空,填写可搜索到需登录访问的影片,使用(www.douban.com)分号“;”分隔格式cookie.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableDoubanAvoidRiskControl">
<input id="EnableDoubanAvoidRiskControl" name="EnableDoubanAvoidRiskControl"
type="checkbox" is="emby-checkbox" />
<span class="checkboxLabel" style="position:relative">启用防封禁
<img style="position: absolute; top:-12px; width: 32px; height:32px"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAABS9JREFUeF7tmWtoHFUUx/9ndne2NmLph5q0yc4m1SSzMS2opRIpGEGLtWotqBS0NIIgCCotVD+aItSi+EB80S8V0YgKWouPNhUMqbWKsWqpya5pQjITRb8IKsruzO4cuXcf7m42yTRkgzs799Puzp17z/md/zn3sYQ6b1Tn/sMH4Cugzgn4KVDnAvCLoJ8CfgrUOQE/BepcAP4q4KeAnwJ1TsBPgToXwMWtAudbG5oCGfsVIlwHYF2NwPsFwIhuWDsq2es6BUbWYWVDUP2CgKtrxPFZZuqGNctf1wDGNLWfgCdq1fmc3e/phnVPsQ+uASQ09TwDV9UyAAb/FjPspkUBiGsq17LzedvL08C1AnwAFRTAwIGYYfULuvFI6HMQ9f7fVbKkCqg2ADF+gHlaVULHk449sBSAaw7AUiuspgAQ0N9pWAeWMsWWA8AZBgaJ0Q4F3WBsrFAXvgVwAcA0QJcBvBbM14NoTaEv8xARDZUAyD8srTXfAJgUYxFoNYObAdw6Vy2qKgCGcihmpk7kJ59sa2i0HPs1MO4s2M60r9NMPV9u4MR6rLLt0EEQPSSeVdq1id+LNmSmw3iky7SOlo81poWuJZDYtN1e/qxqAEbbVkSDdqYpHVA2Bck53TFlf581WBozIg1hDOimda/4KM4VQcfeS8A/9qXWU92jsKTUNXVM+M+Oc6P4HptJD8lxWoLZFUZReuWOlHCfPm29JX4ajai7CLgDhHOZoDLQPZk0pqIr1yY5LYKxoRhC1QAkoupOZryfn0xh54YOMz2cc+qYiIZCdFvHdOrj8Sasyajq1wDasv3ptG6ktkhnWtSdipIdZ55V5g8AwyA6pTg84xAGCk4SvapPp6SKEpp6hIG+ZQEQ19Q3AcjoSuOZX4qZ9sMSQCT0spB2QLVWtV/An/GI+iQRtGLDHMbxmGm9PdESbrcV/mkBAFlsQJIJBjv4kcCD6YB6tHvq71/z41Y6v1RNAXFN/RBChrlGwDudhrVLyldTDxKwVTesTcVOV/o8fiXCGUtNLgSAGR+kQfs3mKmJgsMtwV5SaIdu2Htz8846wFUNwFg09BwxyYlzGnhRN+xHC1Jk3q6b9uXyezT0ADv/qWUWiFyVnycFTuiGdYt0snVFKxynTwG2MbAZzEO6acv6sawKSGjh9YDzJYMaxeQOaGuXkTqZBRA6yaCbHIV7uqbsr2RalG6t/2Kmu4hY3DVcg+ydQ3sJgNIUe0Y3rMcKcIvyXBTPQuGscIRflAISWmg3g94oj1SxgeLZePMlLelAZjeYzuaXw4QW3MJQTuXe/UQ3rO35vplApofBqxUok51G6rMKUSs5v4uIAughoEM3LFlAZZQZm5kwzMSDRIHfY1PJKQk5Gt4P5qeL7b5oAHFNfRzAoUr5Wi5RdpQHYz+nZAETreI+AHTYgXM4ZthiM1RoQsqK4+xhQB6u8q3c4EQkvI+Jn2Xm+2Om/XrJGJFQHxEdyb8zqoVvVsCDiwYQ10IvACTzuGJjlmt0oREJ+Z4B8AOAKAixOXaCooSfI8ZZ8S5D3jHG5pwHOMbAdyLSIGzL9yNghkEfMXOjQtjIwBXyWZFdTNRMQPtcQOe8D4hr6rsA7p7HqJp85CoF4lH1UzBklfVSc3UlVr6h8RIAAAtfinrl6qtS4Fxdi+c2D3sAtHok+kvzx4hHYMxyw/WtsA/AowR8BXg0sK7d8hXgGpVHO/oK8GhgXbvlK8A1Ko929BXg0cC6dstXgGtUHu3oK8CjgXXtlq8A16g82vFfwx5yXxq/mYIAAAAASUVORK5CYII="
alt="beta" />
</span>
</label>
<div class="fieldDescription">勾选后刮削会变慢适合刮削大量影片时使用建议搭配网站cookie一起使用</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="DoubanImageProxyBaseUrl">豆瓣图片代理地址</label>
<input id="DoubanImageProxyBaseUrl" name="DoubanImageProxyBaseUrl" type="text"
is="emby-input" placeholder="https://jellyfin.xxx.com:8920" />
<div class="fieldDescription">
可为空填写jellyfin访问域名有端口时要加上端口只有使用了Nginx代理、Docker部署或启用了HTTPS并且豆瓣图片无法显示时才需要填写
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableDoubanLargePoster">
<input id="EnableDoubanLargePoster" name="EnableDoubanLargePoster" type="checkbox"
is="emby-checkbox" />
<span>海报使用大图</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableDoubanBackdropRaw">
<input id="EnableDoubanBackdropRaw" name="EnableDoubanBackdropRaw" type="checkbox"
is="emby-checkbox" />
<span>背景图使用原图</span>
</label>
<div class="fieldDescription">原图豆瓣限制更严格,一般不建议开启</div>
</div>
</fieldset>
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend>
<h3>TheMovieDb</h3>
</legend>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<label class="emby-checkbox-label" for="EnableTmdb">
<input id="EnableTmdb" name="EnableTmdb" type="checkbox" is="emby-checkbox" />
<span>启用从TheMovieDb获取元数据</span>
</label>
<div class="fieldDescription">勾选后会尝试从TheMovieDb获取季度和剧集元数据</div>
<div class="fieldDescription">勾选后会尝试从TheMovieDb获取剧集元数据</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableTmdbSearch">
<input id="EnableTmdbSearch" name="EnableTmdbSearch" type="checkbox"
is="emby-checkbox" />
<span>显示TheMovieDb搜索结果</span>
</label>
<div class="fieldDescription">勾选后识别时会同时返回TheMovieDb搜索结果</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableTmdbBackdrop">
<input id="EnableTmdbBackdrop" name="EnableTmdbBackdrop" type="checkbox"
is="emby-checkbox" />
<span>使用TheMovieDb补全背景图</span>
</label>
<div class="fieldDescription">勾选后当影片在豆瓣找不到背景图时改使用TheMovieDb的补全</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableTmdbLogo">
<input id="EnableTmdbLogo" name="EnableTmdbLogo" type="checkbox"
is="emby-checkbox" />
<span>从TheMovieDb获取商标</span>
</label>
<div class="fieldDescription">勾选后使用TheMovieDb的商标图片补全</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableTmdbCollection">
<input id="EnableTmdbCollection" name="EnableTmdbCollection" type="checkbox"
is="emby-checkbox" />
<span>从TheMovieDb获取电影系列信息</span>
</label>
<div class="fieldDescription">勾选后,刮削会变慢,会自动创建电影系列合集(需先在媒体库配置中打开<b>自动添加到合集</b>功能)</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="EnableTmdbOfficialRating">
<input id="EnableTmdbOfficialRating" name="EnableTmdbOfficialRating" type="checkbox"
is="emby-checkbox" />
<span>从TheMovieDb获取影片分级信息</span>
</label>
<div class="fieldDescription">勾选后,刮削会变慢</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TmdbApiKey">Api Key</label>
<input id="TmdbApiKey" name="TmdbApiKey" type="text" is="emby-input" />
<div class="fieldDescription">填写自定义Api Key不填写会使用默认api key.(需重启才能生效)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TmdbHost">Api Host</label>
<input id="TmdbHost" name="TmdbHost" type="text" is="emby-input"
placeholder="api.tmdb.org" />
<div class="fieldDescription">
填写Api域名可选api.tmdb.org/api.themoviedb.org默认api.tmdb.org.(需重启才能生效)</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="TmdbProxyType">Api代理服务器</label>
<select is="emby-select" id="TmdbProxyType" name="TmdbProxyType"
class="TmdbProxyType emby-select-withcolor emby-select">
<option value="">禁用</option>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">Socks5</option>
</select>
<div class="fieldDescription">
选择Api代理服务器类型.(需重启才能生效)</div>
</div>
<div class="inputContainer tmdb-proxy-wapper">
<label class="inputLabel inputLabel-float inputLabelUnfocused"
for="TmdbProxyHost">Api代理服务器Host:</label>
<input is="emby-input" id="TmdbProxyHost" name="TmdbProxyHost" class="emby-input">
</div>
<div class="inputContainer tmdb-proxy-wapper">
<label class="inputLabel inputLabel-float inputLabelUnfocused"
for="TmdbProxyPort">Api代理服务器端口:</label>
<input is="emby-input" type="number" id="TmdbProxyPort" name="TmdbProxyPort"
pattern="[0-9]*" min="1" max="65535" class="emby-input">
</div>
</fieldset>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
@ -60,12 +172,33 @@
document.querySelector('#TemplateConfigPage')
.addEventListener('pageshow', function () {
console.log('metashark pageshow');
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
document.querySelector('#current_version').value = "v" + config.Version;
$('#current_version').text("v" + config.Version);
document.querySelector('#DoubanCookies').value = config.DoubanCookies;
document.querySelector('#DoubanImageProxyBaseUrl').value = config.DoubanImageProxyBaseUrl;
document.querySelector('#EnableDoubanAvoidRiskControl').checked = config.EnableDoubanAvoidRiskControl;
document.querySelector('#EnableDoubanLargePoster').checked = config.EnableDoubanLargePoster;
document.querySelector('#EnableDoubanBackdropRaw').checked = config.EnableDoubanBackdropRaw;
document.querySelector('#EnableTmdb').checked = config.EnableTmdb;
document.querySelector('#EnableTmdbSearch').checked = config.EnableTmdbSearch;
document.querySelector('#EnableTmdbBackdrop').checked = config.EnableTmdbBackdrop;
document.querySelector('#EnableTmdbLogo').checked = config.EnableTmdbLogo;
document.querySelector('#EnableTmdbCollection').checked = config.EnableTmdbCollection;
document.querySelector('#EnableTmdbOfficialRating').checked = config.EnableTmdbOfficialRating;
document.querySelector('#TmdbApiKey').value = config.TmdbApiKey;
document.querySelector('#TmdbHost').value = config.TmdbHost;
document.querySelector('#TmdbProxyType').value = config.TmdbProxyType;
document.querySelector('#TmdbProxyHost').value = config.TmdbProxyHost;
document.querySelector('#TmdbProxyPort').value = config.TmdbProxyPort;
changeProxyDisplay();
checkDoubanLogin();
Dashboard.hideLoadingMsg();
});
});
@ -75,16 +208,64 @@
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
config.DoubanCookies = document.querySelector('#DoubanCookies').value;
config.DoubanImageProxyBaseUrl = document.querySelector('#DoubanImageProxyBaseUrl').value;
config.EnableDoubanAvoidRiskControl = document.querySelector('#EnableDoubanAvoidRiskControl').checked;
config.EnableDoubanLargePoster = document.querySelector('#EnableDoubanLargePoster').checked;
config.EnableDoubanBackdropRaw = document.querySelector('#EnableDoubanBackdropRaw').checked;
config.EnableTmdb = document.querySelector('#EnableTmdb').checked;
config.EnableTmdbSearch = document.querySelector('#EnableTmdbSearch').checked;
config.EnableTmdbBackdrop = document.querySelector('#EnableTmdbBackdrop').checked;
config.EnableTmdbLogo = document.querySelector('#EnableTmdbLogo').checked;
config.EnableTmdbCollection = document.querySelector('#EnableTmdbCollection').checked;
config.EnableTmdbOfficialRating = document.querySelector('#EnableTmdbOfficialRating').checked;
config.TmdbApiKey = document.querySelector('#TmdbApiKey').value;
config.TmdbHost = document.querySelector('#TmdbHost').value;
config.TmdbProxyType = document.querySelector('#TmdbProxyType').value;
config.TmdbProxyHost = document.querySelector('#TmdbProxyHost').value;
config.TmdbProxyPort = document.querySelector('#TmdbProxyPort').value;
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
checkDoubanLogin();
});
});
e.preventDefault();
return false;
});
document.querySelector('#TmdbProxyType')
.addEventListener('change', function (e) {
changeProxyDisplay();
});
function changeProxyDisplay() {
let proxyType = document.querySelector('#TmdbProxyType').value;
if (proxyType) {
$('.tmdb-proxy-wapper').show()
} else {
$('.tmdb-proxy-wapper').hide()
}
}
function checkDoubanLogin() {
let cookie = document.querySelector('#DoubanCookies').value
if (!cookie || !$.trim(cookie)) {
$('#login_msg').hide();
return;
}
$.getJSON("/plugin/metashark/douban/checklogin", function (resp) {
if (resp && resp.code != 1) {
$('#login_msg').css("color", "red").text('(已失效)').show();
} else {
$('#login_msg').css("color", "").text('(已生效)').show();
}
})
}
</script>
</div>
</body>

View File

@ -0,0 +1,91 @@
using System.Threading;
using System.Linq;
using System.Net.Http;
using System.IO;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediaBrowser.Common.Net;
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Model;
namespace Jellyfin.Plugin.MetaShark.Controllers
{
[ApiController]
[AllowAnonymous]
[Route("/plugin/metashark")]
public class ApiController : ControllerBase
{
private readonly DoubanApi _doubanApi;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ApiController"/> class.
/// </summary>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
public ApiController(IHttpClientFactory httpClientFactory, DoubanApi doubanApi)
{
this._httpClientFactory = httpClientFactory;
this._doubanApi = doubanApi;
}
/// <summary>
/// 代理访问图片.
/// </summary>
[Route("proxy/image")]
[HttpGet]
public async Task<Stream> ProxyImage(string url)
{
if (string.IsNullOrEmpty(url))
{
throw new ResourceNotFoundException();
}
HttpResponseMessage response;
var httpClient = GetHttpClient();
using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, url))
{
requestMessage.Headers.Add("User-Agent", DoubanApi.HTTP_USER_AGENT);
requestMessage.Headers.Add("Referer", DoubanApi.HTTP_REFERER);
response = await httpClient.SendAsync(requestMessage);
}
var stream = await response.Content.ReadAsStreamAsync();
Response.StatusCode = (int)response.StatusCode;
if (response.Content.Headers.ContentType != null)
{
Response.ContentType = response.Content.Headers.ContentType.ToString();
}
Response.ContentLength = response.Content.Headers.ContentLength;
foreach (var header in response.Headers)
{
Response.Headers.Add(header.Key, header.Value.First());
}
return stream;
}
/// <summary>
/// 检查豆瓣cookie是否失效.
/// </summary>
[Route("douban/checklogin")]
[HttpGet]
public async Task<ApiResult> CheckDoubanLogin()
{
var loginInfo = await this._doubanApi.GetLoginInfoAsync(CancellationToken.None).ConfigureAwait(false);
return new ApiResult(loginInfo.IsLogined ? 1 : 0, loginInfo.Name);
}
private HttpClient GetHttpClient()
{
var client = _httpClientFactory.CreateClient(NamedClient.Default);
return client;
}
}
}

View File

@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.IO;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MediaBrowser.Model.IO;
using MediaBrowser.Controller.Providers;
using Jellyfin.Plugin.MetaShark.Providers;
using System.Runtime.InteropServices;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Common.Net;
namespace Jellyfin.Plugin.MetaShark.Controllers
{
[ApiController]
[AllowAnonymous]
[Route("/plugin/metashark")]
public class MetaSharkController : ControllerBase
{
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="MetaSharkController"/> class.
/// </summary>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
public MetaSharkController(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// 获取弹幕文件内容.
/// </summary>
/// <returns>xml弹幕文件内容</returns>
[Route("proxy/image")]
[HttpGet]
public async Task<Stream> ProxyImage(string url)
{
if (string.IsNullOrEmpty(url))
{
throw new ResourceNotFoundException();
}
var httpClient = GetHttpClient();
return await httpClient.GetStreamAsync(url).ConfigureAwait(false);
}
private HttpClient GetHttpClient()
{
var client = _httpClientFactory.CreateClient(NamedClient.Default);
return client;
}
}
}

View File

@ -20,15 +20,50 @@ namespace Jellyfin.Plugin.MetaShark.Core
return null;
}
public static string? GetHtml(this IElement el, string css)
{
var node = el.QuerySelector(css);
if (node != null)
{
return node.Html().Trim();
}
return null;
}
public static string GetTextOrDefault(this IElement el, string css, string defaultVal = "")
{
var node = el.QuerySelector(css);
if (node != null)
{
return node.Text().Trim();
}
return defaultVal;
}
public static string? GetAttr(this IElement el, string css, string attr)
{
var node = el.QuerySelector(css);
if (node != null)
{
return node.GetAttribute(attr).Trim();
var attrVal = node.GetAttribute(attr);
return attrVal != null ? attrVal.Trim() : null;
}
return null;
}
public static string? GetAttrOrDefault(this IElement el, string css, string attr, string defaultVal = "")
{
var node = el.QuerySelector(css);
if (node != null)
{
var attrVal = node.GetAttribute(attr);
return attrVal != null ? attrVal.Trim() : defaultVal;
}
return defaultVal;
}
}
}

View File

@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Emby.Naming.TV;
using Jellyfin.Plugin.MetaShark.Model;
namespace Jellyfin.Plugin.MetaShark.Core
{
public static class NameParser
{
private static readonly Regex yearReg = new Regex(@"[12][890][0-9][0-9]", RegexOptions.Compiled);
private static readonly Regex seasonSuffixReg = new Regex(@"[ .]S\d{1,2}$", RegexOptions.Compiled);
private static readonly Regex unusedReg = new Regex(@"\[.+?\]|\(.+?\)|【.+?】", RegexOptions.Compiled);
private static readonly Regex fixSeasonNumberReg = new Regex(@"(\[|\.)S(\d{1,2})(\]|\.)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex startWithHyphenCharReg = new Regex(@"^[-~]", RegexOptions.Compiled);
private static readonly Regex chineseIndexNumberReg = new Regex(@"第\s*?([0-9零一二三四五六七八九]+?)\s*?(集|章|话|話|期)", RegexOptions.Compiled);
private static readonly Regex normalizeNameReg = new Regex(@"第\s*?([0-9零一二三四五六七八九]+?)\s*?(集|章|话|話|期)", RegexOptions.Compiled);
private static readonly Regex specialIndexNumberReg = new Regex(@"ep(\d{1,2})", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex resolutionReg = new Regex(@"\d{3,4}x\d{3,4}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static ParseNameResult Parse(string fileName, bool isEpisode = false)
{
fileName = NormalizeFileName(fileName);
var parseResult = new ParseNameResult();
var anitomyResult = AnitomySharp.AnitomySharp.Parse(fileName);
var isAnime = IsAnime(fileName);
foreach (var item in anitomyResult)
{
switch (item.Category)
{
case AnitomySharp.Element.ElementCategory.ElementAnimeTitle:
// 处理混合中英文的标题中文一般在最前面如V字仇杀队.V.for.Vendetta
char[] delimiters = { ' ', '.' };
var firstSpaceIndex = item.Value.IndexOfAny(delimiters);
if (firstSpaceIndex > 0)
{
var firstString = item.Value.Substring(0, firstSpaceIndex);
var lastString = item.Value.Substring(firstSpaceIndex + 1);
if (firstString.HasChinese() && !lastString.HasChinese() && !startWithHyphenCharReg.IsMatch(lastString))
{
parseResult.ChineseName = CleanName(firstString);
parseResult.Name = CleanName(lastString);
}
else
{
parseResult.Name = CleanName(item.Value);
}
}
else
{
parseResult.Name = CleanName(item.Value);
}
break;
case AnitomySharp.Element.ElementCategory.ElementEpisodeTitle:
parseResult.EpisodeName = item.Value;
break;
case AnitomySharp.Element.ElementCategory.ElementAnimeSeason:
var seasonNumber = item.Value.ToInt();
if (seasonNumber > 0)
{
parseResult.ParentIndexNumber = seasonNumber;
}
break;
case AnitomySharp.Element.ElementCategory.ElementEpisodeNumber:
var year = ParseYear(item.Value);
if (year > 0)
{
parseResult.Year = year;
}
else
{
var episodeNumber = item.Value.ToInt();
if (episodeNumber > 0)
{
parseResult.IndexNumber = episodeNumber;
}
}
break;
case AnitomySharp.Element.ElementCategory.ElementAnimeType:
parseResult.AnimeType = item.Value;
break;
case AnitomySharp.Element.ElementCategory.ElementAnimeYear:
parseResult.Year = item.Value.ToInt();
break;
default:
break;
}
}
// 修正动画季信息特殊情况,格式:[SXX]
if (!parseResult.ParentIndexNumber.HasValue && isAnime)
{
var match = fixSeasonNumberReg.Match(fileName);
if (match.Success && match.Groups.Count > 2)
{
parseResult.ParentIndexNumber = match.Groups[2].Value.ToInt();
}
}
// 假如 Anitomy 解析不到 year尝试使用 jellyfin 默认 parser看能不能解析成功
if (parseResult.Year == null && !isAnime)
{
var nativeParseResult = ParseMovieByDefault(fileName);
if (nativeParseResult.Year != null)
{
parseResult = nativeParseResult;
}
}
// 假如 Anitomy 解析不到集数,判断 name 是否是数字集号
if (parseResult.IndexNumber is null && isEpisode)
{
if (!string.IsNullOrEmpty(parseResult.Name) && parseResult.Name.IsNumericString())
{
parseResult.IndexNumber = parseResult.Name.ToInt();
}
}
// 修复纯中文集数/特殊标识集数
if (parseResult.IndexNumber is null)
{
parseResult.IndexNumber = ParseChineseOrSpecialIndexNumber(fileName);
}
// 解析不到 title 时,或解析出多个 title 时,使用默认名
if (string.IsNullOrEmpty(parseResult.Name))
{
parseResult.Name = fileName;
}
return parseResult;
}
public static ParseNameResult ParseEpisode(string fileName)
{
return Parse(fileName, true);
}
private static string CleanName(string name)
{
// 电视剧名称后紧跟季信息时,会附加到名称中,需要去掉
name = seasonSuffixReg.Replace(name, string.Empty);
// 删除多余的[]/()附加信息
name = unusedReg.Replace(name, string.Empty);
return name.Replace(".", " ").Trim();
}
/// <summary>
/// emby原始电影解析
/// </summary>
public static ParseNameResult ParseMovieByDefault(string fileName)
{
// 默认解析器会错误把分辨率当年份,先删除
fileName = resolutionReg.Replace(fileName, "");
var parseResult = new ParseNameResult();
var nameOptions = new Emby.Naming.Common.NamingOptions();
var result = Emby.Naming.Video.VideoResolver.CleanDateTime(fileName, nameOptions);
if (Emby.Naming.Video.VideoResolver.TryCleanString(result.Name, nameOptions, out var cleanName))
{
parseResult.Name = CleanName(cleanName);
parseResult.Year = result.Year;
}
else
{
parseResult.Name = CleanName(result.Name);
parseResult.Year = result.Year;
}
return parseResult;
}
/// <summary>
/// emby原始剧集解析
/// </summary>
public static EpisodePathParserResult ParseEpisodeByDefault(string fileName)
{
// EpisodePathParser需要路径信息 这里添加一个分隔符模拟路径
var path = Path.DirectorySeparatorChar + fileName;
var nameOptions = new Emby.Naming.Common.NamingOptions();
return new EpisodePathParser(nameOptions)
.Parse(path, false);
}
private static int ParseYear(string val)
{
var match = yearReg.Match(val);
if (match.Success && match.Groups.Count > 0)
{
return match.Groups[0].Value.ToInt();
}
return 0;
}
private static string NormalizeFileName(string fileName)
{
// 去掉中文集数之间的空格要不然Anitomy解析不正确
fileName = normalizeNameReg.Replace(fileName, m => m.Value.Replace(" ", ""));
return fileName;
}
private static int? ParseChineseOrSpecialIndexNumber(string fileName)
{
var match = chineseIndexNumberReg.Match(fileName);
if (match.Success && match.Groups.Count > 1)
{
if (int.TryParse(match.Groups[1].Value, out var indexNumber))
{
return indexNumber;
}
var number = Utils.ChineseNumberToInt(match.Groups[1].Value);
if (number.HasValue)
{
return number;
}
}
else
{
match = specialIndexNumberReg.Match(fileName);
if (match.Success && match.Groups.Count > 1)
{
if (int.TryParse(match.Groups[1].Value, out var indexNumber))
{
return indexNumber;
}
}
}
return null;
}
public static bool IsSpecialDirectory(string path, bool isDirectory = false)
{
var folder = Path.GetFileName(Path.GetDirectoryName(path))?.ToUpper() ?? string.Empty;
if (isDirectory) {
folder = Path.GetFileName(path)?.ToUpper() ?? string.Empty;
}
return folder == "SP" || folder == "SPS" || folder == "SPECIALS" || folder.Contains("特典");
}
public static bool IsExtraDirectory(string path, bool isDirectory = false)
{
var folder = Path.GetFileName(Path.GetDirectoryName(path))?.ToUpper() ?? string.Empty;
if (isDirectory) {
folder = Path.GetFileName(path)?.ToUpper() ?? string.Empty;
}
return folder == "EXTRA"
|| folder == "MENU"
|| folder == "MENUS"
|| folder == "PV"
|| folder == "PV&CM"
|| folder == "CM"
|| folder == "BONUS"
|| folder.Contains("OPED")
|| folder.Contains("NCED")
|| folder.Contains("花絮");
}
// 判断是否为动漫
// https://github.com/jxxghp/nas-tools/blob/f549c924558fd49e183333285bc6a804af1a2cb7/app/media/meta/metainfo.py#L51
public static bool IsAnime(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
if (Regex.Match(name, @"【[+0-9XVPI-]+】\s*【", RegexOptions.IgnoreCase).Success)
{
return true;
}
if (Regex.Match(name, @"\s+-\s+[\dv]{1,4}\s+", RegexOptions.IgnoreCase).Success)
{
return true;
}
if (Regex.Match(name, @"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}", RegexOptions.IgnoreCase).Success)
{
return true;
}
if (Regex.Match(name, @"\[[+0-9XVPI-]+]\s*\[", RegexOptions.IgnoreCase).Success)
{
return true;
}
if (Regex.Match(name, @"\[.+\].*?\[.+?\]", RegexOptions.IgnoreCase).Success)
{
return true;
}
return false;
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using StringMetric;
namespace Jellyfin.Plugin.MetaShark.Core
{
public static class RegexExtension
{
public static string FirstMatchGroup(this Regex reg, string text, string defalutVal = "")
{
var match = reg.Match(text);
if (match.Success && match.Groups.Count > 1)
{
return match.Groups[1].Value.Trim();
}
return defalutVal;
}
}
}

View File

@ -44,6 +44,23 @@ namespace Jellyfin.Plugin.MetaShark.Core
return 0.0f;
}
public static bool IsChinese(this string s)
{
Regex chineseReg = new Regex(@"[\u4e00-\u9fa5]{1,}", RegexOptions.Compiled);
return chineseReg.IsMatch(s.Replace(" ", string.Empty).Trim());
}
public static bool HasChinese(this string s)
{
Regex chineseReg = new Regex(@"[\u4e00-\u9fa5]", RegexOptions.Compiled);
return chineseReg.Match(s).Success;
}
public static bool IsSameLanguage(this string s1, string s2)
{
return s1.IsChinese() == s2.IsChinese();
}
public static double Distance(this string s1, string s2)
{
var jw = new JaroWinkler();
@ -56,10 +73,15 @@ namespace Jellyfin.Plugin.MetaShark.Core
var match = reg.Match(text);
if (match.Success && match.Groups.Count > 1)
{
return match.Groups[1].Value;
return match.Groups[1].Value.Trim();
}
return string.Empty;
}
public static bool IsNumericString(this string str)
{
return str.All(char.IsDigit);
}
}
}

View File

@ -15,5 +15,59 @@ namespace Jellyfin.Plugin.MetaShark.Core
dateTime = dateTime.AddSeconds(unixTimeStamp).ToLocalTime();
return dateTime;
}
/// <summary>
/// 转换数字
/// </summary>
public static int? ChineseNumberToInt(string str)
{
if (string.IsNullOrEmpty(str)) return null;
var chineseNumberMap = new Dictionary<Char, Char>() {
{'一', '1'},
{'二', '2'},
{'三', '3'},
{'四', '4'},
{'五', '5'},
{'六', '6'},
{'七', '7'},
{'八', '8'},
{'九', '9'},
{'零', '0'},
};
var numberArr = str.ToCharArray().Select(x => chineseNumberMap.ContainsKey(x) ? chineseNumberMap[x] : x).ToArray();
var newNumberStr = new string(numberArr);
if (int.TryParse(new string(numberArr), out var number))
{
return number;
}
return null;
}
/// <summary>
/// 转换中文数字
/// </summary>
public static string? ToChineseNumber(int? number)
{
if (number is null) return null;
var chineseNumberMap = new Dictionary<Char, Char>() {
{'1','一'},
{'2','二'},
{'3','三'},
{'4','四'},
{'5','五'},
{'6','六'},
{'7','七'},
{'8','八'},
{'9','九'},
{'0','零'},
};
var numberArr = $"{number}".ToCharArray().Select(x => chineseNumberMap.ContainsKey(x) ? chineseNumberMap[x] : x).ToArray();
return new string(numberArr);
}
}
}

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="ILRepacker" AfterTargets="Build" Condition="'$(Configuration)'=='Release'">
<PropertyGroup>
<DoILRepack>false</DoILRepack>
</PropertyGroup>
<ItemGroup>
<InputAssemblies Include="$(OutputPath)$(AssemblyName).dll" />
<InputAssemblies Include="$(OutputPath)RateLimiter.dll" />
<InputAssemblies Include="$(OutputPath)ComposableAsync.Core.dll" />
<InputAssemblies Include="$(OutputPath)TMDbLib.dll" />
<InputAssemblies Include="$(OutputPath)Newtonsoft.Json.dll" />
<InputAssemblies Include="$(OutputPath)AngleSharp.dll" />
<InputAssemblies Include="$(OutputPath)AnitomySharp.dll" />
</ItemGroup>
<ILRepack
Parallel="false"
Internalize="true"
DebugInfo="true"
InputAssemblies="@(InputAssemblies)"
LibraryPath="$(OutputPath)"
TargetKind="Dll"
OutputFile="$(OutputPath)$(AssemblyName).dll"
/>
</Target>
</Project>

View File

@ -1,51 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.MetaShark</RootNamespace>
<GenerateDocumentationFile>False</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
<EnableDynamicLoading>true</EnableDynamicLoading>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="0.17.1" />
<PackageReference Include="AnitomySharp" Version="0.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="10.0.3">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Controller" Version="10.8.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.8.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<Compile Remove="Vendor\TMDbLib\**" />
<EmbeddedResource Remove="Vendor\TMDbLib\**" />
<None Remove="Vendor\TMDbLib\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.1" />
<PackageReference Include="ILRepack.Lib.MSBuild.Task" Version="2.0.32">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Controller" Version="10.9.0" />
<PackageReference Include="Jellyfin.Model" Version="10.9.0" />
<PackageReference Include="RateLimiter" Version="2.2.0" />
<PackageReference Include="TMDbLib" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<ItemGroup>
<None Include="Vendor\TMDbLib\TMDbLib.csproj" />
<ProjectReference Include="..\AnitomySharp\AnitomySharp.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MetaShark.Model;
public class ApiResult
{
[JsonPropertyName("code")]
public int Code { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
public ApiResult(int code, string msg = "")
{
this.Code = code;
this.Msg = msg;
}
}

View File

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MetaShark.Model
{
public class DoubanLoginInfo
{
public string Name { get; set; }
public bool IsLogined { get; set; }
}
}

View File

@ -1,11 +1,8 @@
using MediaBrowser.Model.Entities;
using Newtonsoft.Json;
using System;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MetaShark.Model
{
@ -31,6 +28,8 @@ namespace Jellyfin.Plugin.MetaShark.Model
public string Actor { get; set; }
// "genre": "奇幻 / 冒险",
public string Genre { get; set; }
// 电影/电视剧
public string Category { get; set; }
// "site": "www.harrypotter.co.uk",
public string Site { get; set; }
// "country": "美国 / 英国",
@ -66,6 +65,25 @@ namespace Jellyfin.Plugin.MetaShark.Model
public List<DoubanCelebrity> Celebrities { get; set; }
[JsonIgnore]
public List<DoubanCelebrity> LimitDirectorCelebrities
{
get
{
// 限制导演最多返回5个
var limitCelebrities = new List<DoubanCelebrity>();
if (Celebrities == null || Celebrities.Count == 0)
{
return limitCelebrities;
}
limitCelebrities.AddRange(Celebrities.Where(x => x.RoleType == MediaBrowser.Model.Entities.PersonType.Director && !string.IsNullOrEmpty(x.Name)).Take(5));
limitCelebrities.AddRange(Celebrities.Where(x => x.RoleType != MediaBrowser.Model.Entities.PersonType.Director && !string.IsNullOrEmpty(x.Name)));
return limitCelebrities;
}
}
[JsonIgnore]
public string ImgMiddle
{
@ -75,6 +93,15 @@ namespace Jellyfin.Plugin.MetaShark.Model
}
}
[JsonIgnore]
public string ImgLarge
{
get
{
return this.Img.Replace("s_ratio_poster", "l");
}
}
[JsonIgnore]
public string[] Genres
{
@ -83,6 +110,9 @@ namespace Jellyfin.Plugin.MetaShark.Model
return this.Genre.Split("/").Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToArray();
}
}
}
public class DoubanCelebrity
@ -96,8 +126,10 @@ namespace Jellyfin.Plugin.MetaShark.Model
public string Gender { get; set; }
public string Constellation { get; set; }
public string Birthdate { get; set; }
public string Enddate { get; set; }
public string Birthplace { get; set; }
public string Nickname { get; set; }
public string NickName { get; set; }
public string EnglishName { get; set; }
public string Imdb { get; set; }
public string Site { get; set; }
@ -108,16 +140,40 @@ namespace Jellyfin.Plugin.MetaShark.Model
{
if (string.IsNullOrEmpty(this._roleType))
{
return this.Role.Equals("导演", StringComparison.Ordinal) ? MediaBrowser.Model.Entities.PersonType.Director : MediaBrowser.Model.Entities.PersonType.Actor;
return this.Role.Contains("导演", StringComparison.Ordinal) ? MediaBrowser.Model.Entities.PersonType.Director : MediaBrowser.Model.Entities.PersonType.Actor;
}
return this._roleType.Equals("导演", StringComparison.Ordinal) ? MediaBrowser.Model.Entities.PersonType.Director : MediaBrowser.Model.Entities.PersonType.Actor;
return this._roleType.Contains("导演", StringComparison.Ordinal) ? MediaBrowser.Model.Entities.PersonType.Director : MediaBrowser.Model.Entities.PersonType.Actor;
}
set
{
_roleType = value;
}
}
public string? DisplayOriginalName
{
get
{
// 外国人才显示英文名
if (Name.Contains("·") && Birthplace != null && !Birthplace.Contains("中国"))
{
return EnglishName;
}
return null;
}
}
[JsonIgnore]
public string ImgMiddle
{
get
{
return this.Img.Replace("/raw/", "/m/").Replace("/s_ratio_poster/", "/m/");
}
}
}
public class DoubanPhoto
@ -126,8 +182,12 @@ namespace Jellyfin.Plugin.MetaShark.Model
public string Small { get; set; }
public string Medium { get; set; }
public string Large { get; set; }
/// <summary>
/// 原始图片url必须带referer访问
/// </summary>
public string Raw { get; set; }
public string Size { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
}
}

View File

@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Jellyfin.Plugin.MetaShark.Core;
namespace Jellyfin.Plugin.MetaShark.Model;
public class DoubanSuggest
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
[JsonPropertyName("year")]
public string Year { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
public string Sid
{
get
{
var regSid = new Regex(@"subject\/(\d+?)\/", RegexOptions.Compiled);
return this.Url.GetMatchGroup(regSid);
}
}
}

View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MetaShark.Model;
public class DoubanSuggestResult
{
[JsonPropertyName("cards")]
public List<DoubanSuggest>? Cards { get; set; }
}

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Model
{
public class GuessInfo
{
public int? episodeNumber { get; set; }
public int? seasonNumber { get; set; }
public string? Name { get; set; }
}
}

View File

@ -6,9 +6,34 @@ using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Model
{
public static class MetaSource
public enum MetaSource
{
public const string Douban = "douban";
public const string Tmdb = "tmdb";
Douban,
Tmdb,
None
}
public static class MetaSourceExtensions
{
public static MetaSource ToMetaSource(this string? str)
{
if (str == null)
{
return MetaSource.None;
}
if (str.ToLower().StartsWith("douban"))
{
return MetaSource.Douban;
}
if (str.ToLower().StartsWith("tmdb"))
{
return MetaSource.Tmdb;
}
return MetaSource.None;
}
}
}

View File

@ -0,0 +1,95 @@
using System.Collections.Specialized;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using MediaBrowser.Controller.Providers;
namespace Jellyfin.Plugin.MetaShark.Model
{
public class ParseNameResult : ItemLookupInfo
{
public string? ChineseName { get; set; } = null;
/// <summary>
/// 可能会解析不对最好只在动画SP中才使用
/// </summary>
public string? EpisodeName { get; set; } = null;
private string _animeType = string.Empty;
public string AnimeType
{
get
{
return _animeType.ToUpper();
}
set
{
_animeType = value;
}
}
public bool IsSpecial
{
get
{
return !string.IsNullOrEmpty(AnimeType) && AnimeType.ToUpper() == "SP";
}
}
public bool IsExtra
{
get
{
return !string.IsNullOrEmpty(AnimeType) && AnimeType.ToUpper() != "SP";
}
}
public string? PaddingZeroIndexNumber
{
get
{
if (!IndexNumber.HasValue)
{
return null;
}
return $"{IndexNumber:00}";
}
}
public string ExtraName
{
get
{
if (IndexNumber.HasValue)
{
return $"{AnimeType} {PaddingZeroIndexNumber}";
}
else
{
return $"{AnimeType}";
}
}
}
public string SpecialName
{
get
{
if (!string.IsNullOrEmpty(EpisodeName) && IndexNumber.HasValue)
{
return $"{EpisodeName} {IndexNumber}";
}
else if (!string.IsNullOrEmpty(EpisodeName))
{
return EpisodeName;
}
return Name;
}
}
}
}

View File

@ -4,8 +4,10 @@ using System.Globalization;
using Jellyfin.Plugin.MetaShark.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Plugin.MetaShark;
@ -24,15 +26,19 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary>
public const string ProviderId = "MetaSharkID";
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
public Plugin(IServerApplicationHost appHost, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
this._appHost = appHost;
Plugin.Instance = this;
}
/// <inheritdoc />
@ -54,8 +60,26 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
new PluginPageInfo
{
Name = this.Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace),
},
};
}
public string GetLocalApiBaseUrl()
{
return this._appHost.GetLocalApiUrl("127.0.0.1", "http");
}
public string GetApiBaseUrl(HttpRequest request)
{
int? requestPort = request.Host.Port;
if (requestPort == null
|| (requestPort == 80 && string.Equals(request.Scheme, "http", StringComparison.OrdinalIgnoreCase))
|| (requestPort == 443 && string.Equals(request.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
requestPort = -1;
}
return this._appHost.GetLocalApiUrl(request.Host.Host, request.Scheme, requestPort);
}
}

View File

@ -1,23 +1,25 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
using StringMetric;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using TMDbLib.Objects.General;
using Jellyfin.Plugin.MetaShark.Configuration;
using Jellyfin.Plugin.MetaShark.Core;
using Microsoft.AspNetCore.Http;
using MediaBrowser.Controller.Entities.TV;
namespace Jellyfin.Plugin.MetaShark.Providers
{
@ -38,160 +40,502 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// </summary>
public const string TmdbProviderName = "TheMovieDb";
protected readonly Configuration.PluginConfiguration _config;
protected readonly ILogger _logger;
protected readonly IHttpClientFactory _httpClientFactory;
protected readonly DoubanApi _doubanApi;
protected readonly TmdbApi _tmdbApi;
protected readonly OmdbApi _omdbApi;
protected readonly ImdbApi _imdbApi;
protected readonly ILibraryManager _libraryManager;
protected readonly IHttpContextAccessor _httpContextAccessor;
protected Regex regMetaSourcePrefix = new Regex(@"^\[.+\]", RegexOptions.Compiled);
protected Regex regSeasonNameSuffix = new Regex(@"\s第[0-9一二三四五六七八九十]+?季$|\sSeason\s\d+?$|(?<![0-9a-zA-Z])\d$", RegexOptions.Compiled);
protected Regex regDoubanIdAttribute = new Regex(@"\[(?:douban|doubanid)-(\d+?)\]", RegexOptions.Compiled);
public string Pattern
protected PluginConfiguration config
{
get
{
return this._config.Pattern;
return Plugin.Instance?.Configuration ?? new PluginConfiguration();
}
}
protected BaseProvider(IHttpClientFactory httpClientFactory, ILogger logger, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
protected BaseProvider(IHttpClientFactory httpClientFactory, ILogger logger, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
{
this._doubanApi = doubanApi;
this._tmdbApi = tmdbApi;
this._omdbApi = omdbApi;
this._imdbApi = imdbApi;
this._libraryManager = libraryManager;
this._logger = logger;
this._httpClientFactory = httpClientFactory;
this._config = Plugin.Instance == null ?
new Configuration.PluginConfiguration() :
Plugin.Instance.Configuration;
this._httpContextAccessor = httpContextAccessor;
}
protected async Task<string?> GuestByDoubanAsync(ItemLookupInfo info, CancellationToken cancellationToken)
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
// ParseName is required here.
// Caller provides the filename with extension stripped and NOT the parsed filename
var parsedName = this._libraryManager.ParseName(info.Name);
this.Log($"GuestByDouban of [name]: {info.Name} year: {info.Year} search name: {parsedName.Name}");
var result = await this._doubanApi.SearchAsync(parsedName.Name, cancellationToken).ConfigureAwait(false);
var jw = new JaroWinkler();
foreach (var item in result)
if (url.Contains("doubanio.com"))
{
if (jw.Similarity(parsedName.Name, item.Name) < 0.8)
// 相对链接补全
if (!url.StartsWith("http") && Plugin.Instance != null)
{
continue;
url = Plugin.Instance.GetLocalApiBaseUrl().TrimEnd('/') + url;
}
// 包含了代理地址的话从url解析出原始豆瓣图片地址
if (url.Contains("/proxy/image"))
{
var uri = new UriBuilder(url);
url = HttpUtility.ParseQueryString(uri.Query).Get("url");
}
if (parsedName.Year == null || parsedName.Year == 0)
this.Log("GetImageResponse url: {0}", url);
// 豆瓣图带referer下载
using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, url))
{
this.Log($"GuestByDouban of [name] found Sid: \"{item.Sid}\"");
requestMessage.Headers.Add("User-Agent", DoubanApi.HTTP_USER_AGENT);
requestMessage.Headers.Add("Referer", DoubanApi.HTTP_REFERER);
return await this._httpClientFactory.CreateClient().SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
}
}
else
{
this.Log("GetImageResponse url: {0}", url);
return await this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken).ConfigureAwait(false);
}
}
protected async Task<string?> GuessByDoubanAsync(ItemLookupInfo info, CancellationToken cancellationToken)
{
var fileName = GetOriginalFileName(info);
// 从文件名属性格式获取,如[douban-12345]或[doubanid-12345]
var doubanId = this.regDoubanIdAttribute.FirstMatchGroup(fileName);
if (!string.IsNullOrWhiteSpace(doubanId))
{
this.Log($"Found douban [id] by attr: {doubanId}");
return doubanId;
}
var parseResult = NameParser.Parse(fileName);
var searchName = !string.IsNullOrEmpty(parseResult.ChineseName) ? parseResult.ChineseName : parseResult.Name;
info.Year = parseResult.Year; // 默认parser对anime年份会解析出错以anitomy为准
this.Log($"GuessByDouban of [name]: {info.Name} [file_name]: {fileName} [year]: {info.Year} [search name]: {searchName}");
List<DoubanSubject> result;
DoubanSubject? item;
// 假如存在年份先通过suggest接口查找减少搜索页访问次数避免封禁suggest没法区分电影或电视剧排序也比搜索页差些
if (config.EnableDoubanAvoidRiskControl)
{
if (info.Year != null && info.Year > 0)
{
result = await this._doubanApi.SearchBySuggestAsync(searchName, cancellationToken).ConfigureAwait(false);
item = result.Where(x => x.Year == info.Year && x.Name == searchName).FirstOrDefault();
if (item != null)
{
this.Log($"Found douban [id]: {item.Name}({item.Sid}) (suggest)");
return item.Sid;
}
if (parsedName.Year == item.Year)
item = result.Where(x => x.Year == info.Year).FirstOrDefault();
if (item != null)
{
this.Log($"GuestByDouban of [name] found Sid: \"{item.Sid}\"");
this.Log($"Found douban [id]: {item.Name}({item.Sid}) (suggest)");
return item.Sid;
}
}
}
// 通过搜索页面查找
result = await this._doubanApi.SearchAsync(searchName, cancellationToken).ConfigureAwait(false);
var cat = info is MovieInfo ? "电影" : "电视剧";
// 存在年份时,返回对应年份的电影
if (info.Year != null && info.Year > 0)
{
item = result.Where(x => x.Category == cat && x.Year == info.Year).FirstOrDefault();
if (item != null)
{
this.Log($"Found douban [id]: {item.Name}({item.Sid})");
return item.Sid;
}
else
{
// TODO: 有年份找不到,直接返回,由其他插件接手查找(还是返回第一个好????)
return null;
}
}
//// 不存在年份计算相似度返回相似度大于0.8的第一个(可能出现冷门资源名称更相同的情况。。。)
// var jw = new JaroWinkler();
// item = result.Where(x => x.Category == cat && x.Rating > 5).OrderByDescending(x => Math.Max(jw.Similarity(searchName, x.Name), jw.Similarity(searchName, x.OriginalName))).FirstOrDefault();
// if (item != null && Math.Max(jw.Similarity(searchName, item.Name), jw.Similarity(searchName, item.OriginalName)) > 0.8)
// {
// return item.Sid;
// }
// 不存在年份时,返回豆瓣结果第一个
item = result.Where(x => x.Category == cat).FirstOrDefault();
if (item != null)
{
this.Log($"Found douban [id] by first match: {item.Name}({item.Sid})");
return item.Sid;
}
return null;
}
protected async Task<string?> GuestSeasonByDoubanAsync(string name, int? year, CancellationToken cancellationToken)
public async Task<string?> GuestDoubanSeasonByYearAsync(string seriesName, int? year, int? seasonNumber, CancellationToken cancellationToken)
{
if (year == null || year == 0)
{
return null;
}
this.Log($"GuestSeasonByDouban of [name]: {name} year: {year}");
var result = await this._doubanApi.SearchAsync(name, cancellationToken).ConfigureAwait(false);
var jw = new JaroWinkler();
foreach (var item in result)
{
this.Log($"GuestSeasonByDouban name: {name} item.Name: {item.Name} score: {jw.Similarity(name, item.Name)} ");
if (jw.Similarity(name, item.Name) < 0.8)
{
continue;
}
this.Log($"GuestDoubanSeasonByYear of [name]: {seriesName} [year]: {year}");
if (year == item.Year)
// 先通过suggest接口查找减少搜索页访问次数避免封禁suggest没法区分电影或电视剧排序也比搜索页差些
if (config.EnableDoubanAvoidRiskControl)
{
this.Log($"GuestSeasonByDouban of [name] found Sid: \"{item.Sid}\"");
return item.Sid;
var suggestResult = await this._doubanApi.SearchBySuggestAsync(seriesName, cancellationToken).ConfigureAwait(false);
var suggestItem = suggestResult.Where(x => x.Year == year && x.Name == seriesName).FirstOrDefault();
if (suggestItem != null)
{
this.Log($"Found douban [id]: {suggestItem.Name}({suggestItem.Sid}) (suggest)");
return suggestItem.Sid;
}
suggestItem = suggestResult.Where(x => x.Year == year).FirstOrDefault();
if (suggestItem != null)
{
this.Log($"Found douban [id]: {suggestItem.Name}({suggestItem.Sid}) (suggest)");
return suggestItem.Sid;
}
}
// 通过搜索页面查找
var result = await this._doubanApi.SearchAsync(seriesName, cancellationToken).ConfigureAwait(false);
var item = result.Where(x => x.Category == "电视剧" && x.Year == year).FirstOrDefault();
if (item != null && !string.IsNullOrEmpty(item.Sid))
{
// 判断名称中是否有第X季有的话和seasonNumber比较用于修正多季都在同一年时每次都是错误取第一个的情况
var nameIndexNumber = ParseChineseSeasonNumberByName(item.Name);
if (nameIndexNumber.HasValue && seasonNumber.HasValue && nameIndexNumber != seasonNumber)
{
this.Log($"GuestDoubanSeasonByYear not found!");
return null;
}
protected async Task<string?> GuestByTmdbAsync(ItemLookupInfo info, CancellationToken cancellationToken)
this.Log($"Found douban [id]: {item.Name}({item.Sid})");
return item.Sid;
}
this.Log($"GuestDoubanSeasonByYear not found!");
return null;
}
public async Task<string?> GuestDoubanSeasonBySeasonNameAsync(string name, int? seasonNumber, CancellationToken cancellationToken)
{
// ParseName is required here.
// Caller provides the filename with extension stripped and NOT the parsed filename
var parsedName = this._libraryManager.ParseName(info.Name);
this.Log($"GuestByTmdb of [name]: {info.Name} search name: {parsedName.Name}");
var jw = new JaroWinkler();
if (seasonNumber is null or 0)
{
return null;
}
var chineseSeasonNumber = Utils.ToChineseNumber(seasonNumber);
if (string.IsNullOrEmpty(chineseSeasonNumber))
{
return null;
}
var seasonName = $"{name}{seasonNumber}";
var chineseSeasonName = $"{name} 第{chineseSeasonNumber}季";
if (seasonNumber == 1)
{
seasonName = name;
}
this.Log($"GuestDoubanSeasonBySeasonNameAsync of [name]: {seasonName} 或 {chineseSeasonName}");
// 通过名称精确匹配
var result = await this._doubanApi.SearchAsync(name, cancellationToken).ConfigureAwait(false);
var item = result.Where(x => x.Category == "电视剧" && x.Rating > 0 && (x.Name == seasonName || x.Name == chineseSeasonName)).FirstOrDefault();
if (item != null && !string.IsNullOrEmpty(item.Sid))
{
this.Log($"Found douban [id]: {item.Name}({item.Sid})");
return item.Sid;
}
this.Log($"GuestDoubanSeasonBySeasonNameAsync not found!");
return null;
}
protected async Task<string?> GuestByTmdbAsync(string name, int? year, ItemLookupInfo info, CancellationToken cancellationToken)
{
var fileName = GetOriginalFileName(info);
this.Log($"GuestByTmdb of [name]: {name} [year]: {year}");
switch (info)
{
case MovieInfo:
var movieResults = await this._tmdbApi.SearchMovieAsync(name, year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
// 结果可能多个,优先取名称完全相同的
var movieItem = movieResults.Where(x => x.Title == name || x.OriginalTitle == name).FirstOrDefault();
if (movieItem != null)
{
this.Log($"Found tmdb [id]: {movieItem.Title}({movieItem.Id})");
return movieItem.Id.ToString(CultureInfo.InvariantCulture);
}
movieItem = movieResults.FirstOrDefault();
if (movieItem != null)
{
// bt种子都是英文名但电影是中日韩泰印法地区时都不适用相似匹配去掉限制
this.Log($"Found tmdb [id]: {movieItem.Title}({movieItem.Id})");
return movieItem.Id.ToString(CultureInfo.InvariantCulture);
}
break;
case SeriesInfo:
var seriesResults = await this._tmdbApi.SearchSeriesAsync(name, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
// 年份在豆瓣可能匹配到第三季但tmdb年份都是第一季的可能匹配不上例如脱口秀大会
// 优先年份和名称同时匹配
var seriesItem = seriesResults.Where(x => (x.Name == name || x.OriginalName == name) && x.FirstAirDate?.Year == year).FirstOrDefault();
if (seriesItem != null)
{
this.Log($"Found tmdb [id]: -> {seriesItem.Name}({seriesItem.Id})");
return seriesItem.Id.ToString(CultureInfo.InvariantCulture);
}
// 年份匹配
seriesItem = seriesResults.Where(x => x.FirstAirDate?.Year == year).FirstOrDefault();
if (seriesItem != null)
{
this.Log($"Found tmdb [id]: -> {seriesItem.Name}({seriesItem.Id})");
return seriesItem.Id.ToString(CultureInfo.InvariantCulture);
}
// 取名称完全相同的,可能综艺会有纯享版等非标准版本(例如:一年一度喜剧大赛)
seriesItem = seriesResults.Where(x => x.Name == name || x.OriginalName == name).FirstOrDefault();
if (seriesItem != null)
{
this.Log($"Found tmdb [id]: -> {seriesItem.Name}({seriesItem.Id})");
return seriesItem.Id.ToString(CultureInfo.InvariantCulture);
}
seriesItem = seriesResults.FirstOrDefault();
if (seriesItem != null)
{
// bt种子都是英文名但电影是中日韩泰印法地区时都不适用相似匹配去掉限制
this.Log($"Found tmdb [id]: -> {seriesItem.Name}({seriesItem.Id})");
return seriesItem.Id.ToString(CultureInfo.InvariantCulture);
}
break;
}
this.Log($"Not found tmdb id by [name]: {name} [year]: {year}");
return null;
}
protected async Task<string?> GetTmdbIdByImdbAsync(string imdb, string language, ItemLookupInfo info, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(imdb))
{
return null;
}
// 通过imdb获取tmdbId
var findResult = await this._tmdbApi.FindByExternalIdAsync(imdb, TMDbLib.Objects.Find.FindExternalSource.Imdb, language, cancellationToken).ConfigureAwait(false);
switch (info)
{
case MovieInfo:
var movieResults = await this._tmdbApi.SearchMovieAsync(parsedName.Name, parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
foreach (var item in movieResults)
if (findResult?.MovieResults != null && findResult.MovieResults.Count > 0)
{
if (jw.Similarity(parsedName.Name, item.Title) > 0.8)
{
this.Log($"GuestByTmdb of [name] found tmdb id: \"{item.Id}\"");
return item.Id.ToString(CultureInfo.InvariantCulture);
}
var tmdbId = findResult.MovieResults[0].Id;
this.Log($"Found tmdb [id]: {tmdbId} by imdb id: {imdb}");
return $"{tmdbId}";
}
break;
case SeriesInfo:
var seriesResults = await this._tmdbApi.SearchSeriesAsync(parsedName.Name, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
foreach (var item in seriesResults)
if (findResult?.TvResults != null && findResult.TvResults.Count > 0)
{
if (jw.Similarity(parsedName.Name, item.Name) > 0.8)
{
this.Log($"GuestByTmdb of [name] found tmdb id: \"{item.Id}\"");
return item.Id.ToString(CultureInfo.InvariantCulture);
var tmdbId = findResult.TvResults[0].Id;
this.Log($"Found tmdb [id]: {tmdbId} by imdb id: {imdb}");
return $"{tmdbId}";
}
if (findResult?.TvEpisode != null && findResult.TvEpisode.Count > 0)
{
var tmdbId = findResult.TvEpisode[0].ShowId;
this.Log($"Found tmdb [id]: {tmdbId} by imdb id: {imdb}");
return $"{tmdbId}";
}
if (findResult?.TvSeason != null && findResult.TvSeason.Count > 0)
{
var tmdbId = findResult.TvSeason[0].ShowId;
this.Log($"Found tmdb [id]: {tmdbId} by imdb id: {imdb}");
return $"{tmdbId}";
}
break;
default:
break;
}
this.Log($"Not found tmdb id by imdb id: {imdb}");
return null;
}
/// <summary>
/// 豆瓣的imdb id可能是旧的需要先从omdb接口获取最新的imdb id
/// </summary>
protected async Task<string> CheckNewImdbID(string imdb, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(imdb))
{
return imdb;
}
var omdbItem = await this._omdbApi.GetByImdbID(imdb, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(omdbItem?.ImdbID))
{
imdb = omdbItem.ImdbID;
}
return imdb;
}
public int? GuessSeasonNumberByDirectoryName(string path)
{
// TODO: 有时 series name 中会带有季信息
// 当没有 season 级目录时,或 season 文件夹特殊不规范命名时,会解析不到 seasonNumber这时 path 为空,直接返回
if (string.IsNullOrEmpty(path))
{
this.Log($"Season path is empty!");
return null;
}
var fileName = Path.GetFileName(path);
if (string.IsNullOrEmpty(fileName))
{
return null;
}
// 中文季名
var regSeason = new Regex(@"第([0-9零一二三四五六七八九]+?)(季|部)", RegexOptions.Compiled);
var match = regSeason.Match(fileName);
if (match.Success && match.Groups.Count > 1)
{
var seasonNumber = match.Groups[1].Value.ToInt();
if (seasonNumber <= 0)
{
seasonNumber = Utils.ChineseNumberToInt(match.Groups[1].Value) ?? 0;
}
if (seasonNumber > 0)
{
this.Log($"Found season number of filename: {fileName} seasonNumber: {seasonNumber}");
return seasonNumber;
}
}
// SXX 季名
regSeason = new Regex(@"(?<![a-z])S(\d\d?)(?![0-9a-z])", RegexOptions.Compiled | RegexOptions.IgnoreCase);
match = regSeason.Match(fileName);
if (match.Success && match.Groups.Count > 1)
{
var seasonNumber = match.Groups[1].Value.ToInt();
if (seasonNumber > 0)
{
this.Log($"Found season number of filename: {fileName} seasonNumber: {seasonNumber}");
return seasonNumber;
}
}
// 动漫季特殊命名
var seasonNameMap = new Dictionary<string, int>() {
{@"[ ._](I|1st)[ ._]", 1},
{@"[ ._](II|2nd)[ ._]", 2},
{@"[ ._](III|3rd)[ ._]", 3},
{@"[ ._](IIII|4th)[ ._]", 3},
};
foreach (var entry in seasonNameMap)
{
if (Regex.IsMatch(fileName, entry.Key))
{
this.Log($"Found season number of filename: {fileName} seasonNumber: {entry.Value}");
return entry.Value;
}
}
// // 带数字末尾的
// match = Regex.Match(fileName, @"[ ._](\d{1,2})$");
// if (match.Success && match.Groups.Count > 1)
// {
// var seasonNumber = match.Groups[1].Value.ToInt();
// if (seasonNumber > 0)
// {
// this.Log($"Found season number of filename: {fileName} seasonNumber: {seasonNumber}");
// return seasonNumber;
// }
// }
return null;
}
protected string AppendMetaSourcePrefix(string name, string source)
public int? ParseChineseSeasonNumberByName(string name)
{
if (string.IsNullOrEmpty(name))
var regSeason = new Regex(@"\s第([0-9零一二三四五六七八九]+?)(季|部)", RegexOptions.Compiled);
var match = regSeason.Match(name);
if (match.Success && match.Groups.Count > 1)
{
return name;
}
return $"[{source}]{name}";
}
protected string RemoveMetaSourcePrefix(string name)
var seasonNumber = match.Groups[1].Value.ToInt();
if (seasonNumber <= 0)
{
if (string.IsNullOrEmpty(name))
seasonNumber = Utils.ChineseNumberToInt(match.Groups[1].Value) ?? 0;
}
if (seasonNumber > 0)
{
return name;
return seasonNumber;
}
return regMetaSourcePrefix.Replace(name, string.Empty);
}
return null;
}
protected string GetProxyImageUrl(string url)
{
var baseUrl = this.GetBaseUrl();
var encodedUrl = HttpUtility.UrlEncode(url);
return $"/plugin/metashark/proxy/image/?url={encodedUrl}";
return $"{baseUrl}/plugin/metashark/proxy/image/?url={encodedUrl}";
}
protected string GetLocalProxyImageUrl(string url)
{
var baseUrl = Plugin.Instance?.GetLocalApiBaseUrl() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(config.DoubanImageProxyBaseUrl))
{
baseUrl = config.DoubanImageProxyBaseUrl.TrimEnd('/');
}
var encodedUrl = HttpUtility.UrlEncode(url);
return $"{baseUrl}/plugin/metashark/proxy/image/?url={encodedUrl}";
}
private string GetBaseUrl()
{
// 配置优先
if (!string.IsNullOrWhiteSpace(config.DoubanImageProxyBaseUrl))
{
return this.config.DoubanImageProxyBaseUrl.TrimEnd('/');
}
// TODOhttp请求时获取请求的host (nginx代理/docker中部署时没配置透传host时本方式会有问题)
// 除自动扫描之外都会执行这里修改图片功能图片是直接下载不走插件图片代理处理函数host拿不到就下载不了
if (Plugin.Instance != null && this._httpContextAccessor.HttpContext != null)
{
return Plugin.Instance.GetApiBaseUrl(this._httpContextAccessor.HttpContext.Request);
}
// 自动扫描刷新时,直接使用本地地址(127.0.0.1)
return Plugin.Instance?.GetLocalApiBaseUrl() ?? string.Empty;
}
protected void Log(string? message, params object?[] args)
@ -248,63 +592,80 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
/// <summary>
/// Normalizes a language string for use with TMDb's include image language parameter.
/// </summary>
/// <param name="preferredLanguage">The preferred language as either a 2 letter code with or without country code.</param>
/// <returns>The comma separated language string.</returns>
public static string GetImageLanguagesParam(string preferredLanguage)
protected string GetDoubanPoster(DoubanSubject subject)
{
var languages = new List<string>();
if (string.IsNullOrEmpty(subject.Img)) {
return string.Empty;
}
if (!string.IsNullOrEmpty(preferredLanguage))
var url = config.EnableDoubanLargePoster ? subject.ImgLarge : subject.ImgMiddle;
return this.GetProxyImageUrl(url);
}
protected string GetOriginalFileName(ItemLookupInfo info)
{
preferredLanguage = NormalizeLanguage(preferredLanguage);
languages.Add(preferredLanguage);
if (preferredLanguage.Length == 5) // like en-US
switch (info)
{
// Currently, TMDB supports 2-letter language codes only
// They are planning to change this in the future, thus we're
// supplying both codes if we're having a 5-letter code.
languages.Add(preferredLanguage.Substring(0, 2));
}
}
languages.Add("null");
if (!string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase))
case MovieInfo:
// 当movie放在文件夹中并只有一部影片时, info.name是根据文件夹名解析的但info.Path是影片的路径名
// 当movie放在文件夹中并有多部影片时info.Name和info.Path都是具体的影片
var directoryName = Path.GetFileName(Path.GetDirectoryName(info.Path));
if (!string.IsNullOrEmpty(directoryName) && directoryName.Contains(info.Name))
{
languages.Add("en");
return directoryName;
}
return Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
case EpisodeInfo:
return Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
default:
// series和season的info.Path是文件夹路径
return Path.GetFileName(info.Path) ?? info.Name;
}
}
return string.Join(',', languages);
}
/// <summary>
/// Normalizes a language string for use with TMDb's language parameter.
/// </summary>
/// <param name="language">The language code.</param>
/// <returns>The normalized language code.</returns>
public static string NormalizeLanguage(string language)
protected string? GetOriginalSeasonPath(EpisodeInfo info)
{
if (string.IsNullOrEmpty(language))
if (info.Path == null) {
return null;
}
var seasonPath = Path.GetDirectoryName(info.Path);
var item = this._libraryManager.FindByPath(seasonPath, true);
// 没有季文件夹
if (item is Series) {
return null;
}
return seasonPath;
}
protected bool IsVirtualSeason(EpisodeInfo info)
{
return language;
}
// They require this to be uppercase
// Everything after the hyphen must be written in uppercase due to a way TMDB wrote their api.
// See here: https://www.themoviedb.org/talk/5119221d760ee36c642af4ad?page=3#56e372a0c3a3685a9e0019ab
var parts = language.Split('-');
if (parts.Length == 2)
if (info.Path == null)
{
language = parts[0] + "-" + parts[1].ToUpperInvariant();
return false;
}
return language;
var seasonPath = Path.GetDirectoryName(info.Path);
var parent = this._libraryManager.FindByPath(seasonPath, true);
// 没有季文件夹
if (parent is Series) {
return true;
}
var seriesPath = Path.GetDirectoryName(seasonPath);
var series = this._libraryManager.FindByPath(seriesPath, true);
// 季文件夹不规范,没法识别
if (series is Series && parent is not Season) {
return true;
}
return false;
}
protected string RemoveSeasonSuffix(string name)
{
return regSeasonNameSuffix.Replace(name, "");
}
}
}

View File

@ -1,40 +1,26 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Extensions;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Languages;
using static System.Net.Mime.MediaTypeNames;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class EpisodeImageProvider : BaseProvider, IRemoteImageProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="EpisodeImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OddbImageProvider}"/> interface.</param>
/// <param name="doubanApi">Instance of <see cref="DoubanApi"/>.</param>
public EpisodeImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public EpisodeImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<EpisodeImageProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
@ -53,7 +39,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
this.Log($"GetEpisodeImages for item: {item.Name} number: {item.IndexNumber}");
this.Log($"GetEpisodeImages of [name]: {item.Name} number: {item.IndexNumber} ParentIndexNumber: {item.ParentIndexNumber}");
var episode = (MediaBrowser.Controller.Entities.TV.Episode)item;
var series = episode.Series;
@ -62,27 +48,27 @@ namespace Jellyfin.Plugin.MetaShark.Providers
if (seriesTmdbId <= 0)
{
this.Log($"Got images failed because the seriesTmdbId is empty!");
this.Log($"[GetEpisodeImages] The seriesTmdbId is empty!");
return Enumerable.Empty<RemoteImageInfo>();
}
var seasonNumber = episode.ParentIndexNumber;
var episodeNumber = episode.IndexNumber;
if (!seasonNumber.HasValue || !episodeNumber.HasValue)
if (seasonNumber is null or 0 || episodeNumber is null or 0)
{
this.Log($"Got images failed because the seasonNumber or episodeNumber is empty! seasonNumber: {seasonNumber} episodeNumber: {episodeNumber}");
this.Log($"[GetEpisodeImages] The seasonNumber or episodeNumber is empty! seasonNumber: {seasonNumber} episodeNumber: {episodeNumber}");
return Enumerable.Empty<RemoteImageInfo>();
}
var language = item.GetPreferredMetadataLanguage();
// 利用season缓存取剧集信息会更快
var seasonResult = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId, seasonNumber.Value, language, language, cancellationToken)
.GetSeasonAsync(seriesTmdbId, seasonNumber.Value, null, null, cancellationToken)
.ConfigureAwait(false);
if (seasonResult == null || seasonResult.Episodes.Count < episodeNumber.Value)
{
this.Log($"Not valid season data for seasonNumber: {seasonNumber} episodeNumber: {episodeNumber}");
this.Log($"[GetEpisodeImages] Can't get season data for seasonNumber: {seasonNumber} episodeNumber: {episodeNumber}");
return Enumerable.Empty<RemoteImageInfo>();
}
@ -102,14 +88,5 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken);
}
}
}

View File

@ -1,42 +1,31 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Common.Net;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class EpisodeProvider : BaseProvider, IRemoteMetadataProvider<Episode, EpisodeInfo>
public class EpisodeProvider : BaseProvider, IRemoteMetadataProvider<Episode, EpisodeInfo>, IDisposable
{
private readonly IMemoryCache _memoryCache;
private static readonly Regex[] EpisodeFileNameRegex =
{
new(@"\[([\d\.]{2,})\]"),
new(@"- ?([\d\.]{2,})"),
new(@"EP?([\d\.]{2,})", RegexOptions.IgnoreCase),
new(@"\[([\d\.]{2,})"),
new(@"#([\d\.]{2,})"),
new(@"(\d{2,})")
};
public EpisodeProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public EpisodeProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<EpisodeProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
this._memoryCache = new MemoryCache(new MemoryCacheOptions());
}
public string Name => Plugin.PluginName;
@ -45,40 +34,47 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo info, CancellationToken cancellationToken)
{
this.Log($"GetSearchResults of [name]: {info.Name}");
this.Log($"GetEpisodeSearchResults of [name]: {info.Name}");
return await Task.FromResult(Enumerable.Empty<RemoteSearchResult>());
}
/// <inheritdoc />
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken)
{
this.Log($"GetEpisodeMetadata of [name]: {info.Name} number: {info.IndexNumber}");
// 刷新元数据四种模式差别:
// 自动扫描匹配info的Name、IndexNumber和ParentIndexNumber是从文件名解析出来的假如命名不规范就会导致解析出错误值
// 识别info的Name、IndexNumber和ParentIndexNumber是从文件名解析出来的provinceIds有指定选择项的ProvinceId
// 覆盖所有元数据info的Name、IndexNumber和ParentIndexNumber是从文件名解析出来的provinceIds保留所有旧值
// 搜索缺少的元数据info的Name、IndexNumber和ParentIndexNumber是从当前的元数据获取provinceIds保留所有旧值
var fileName = Path.GetFileName(info.Path);
this.Log($"GetEpisodeMetadata of [name]: {info.Name} [fileName]: {fileName} number: {info.IndexNumber} ParentIndexNumber: {info.ParentIndexNumber} EnableTmdb: {config.EnableTmdb}");
var result = new MetadataResult<Episode>();
// 动画特典和extras处理
var specialResult = this.HandleAnimeExtras(info);
if (specialResult != null)
{
return specialResult;
}
// 使用AnitomySharp进行重新解析解决anime识别错误
info = this.FixParseInfo(info);
// 剧集信息只有tmdb有
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out var seriesTmdbId);
var seasonNumber = info.ParentIndexNumber; // 没有season级目录时会为null
var seasonNumber = info.ParentIndexNumber;
var episodeNumber = info.IndexNumber;
var indexNumberEnd = info.IndexNumberEnd;
if (episodeNumber is null or 0)
{
// 从文件名获取剧集的indexNumber
var fileName = Path.GetFileName(info.Path) ?? string.Empty;
episodeNumber = this.GuessEpisodeNumber(episodeNumber, fileName);
if (episodeNumber.HasValue && episodeNumber.Value > 0)
{
result.HasMetadata = true;
result.Item = new Episode
{
IndexNumber = episodeNumber
ParentIndexNumber = seasonNumber,
IndexNumber = episodeNumber,
Name = info.Name,
};
}
this.Log("GuessEpisodeNumber: fileName: {0} episodeNumber: {1}", fileName, episodeNumber);
}
if (episodeNumber is null or 0 || seasonNumber is null or 0 || string.IsNullOrEmpty(seriesTmdbId))
if (episodeNumber is null or 0 || seasonNumber is null || string.IsNullOrEmpty(seriesTmdbId))
{
this.Log("Lack meta message. episodeNumber: {0} seasonNumber: {1} seriesTmdbId:{2}", episodeNumber, seasonNumber, seriesTmdbId);
this.Log("Lack meta data. episodeNumber: {0} seasonNumber: {1} seriesTmdbId:{2}", episodeNumber, seasonNumber, seriesTmdbId);
return result;
}
@ -86,11 +82,23 @@ namespace Jellyfin.Plugin.MetaShark.Providers
var seasonResult = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber.Value, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
if (seasonResult == null || seasonResult.Episodes.Count < episodeNumber.Value)
if (seasonResult == null || seasonResult.Episodes == null || seasonResult.Episodes.Count < episodeNumber.Value)
{
this.Log("Cant found episode data from tmdb. Name: {0} seriesTmdbId: {1} seasonNumber: {2} episodeNumber: {3}", info.Name, seriesTmdbId, seasonNumber, episodeNumber);
return result;
}
// TODO自动搜索匹配或识别时判断tmdb剧集信息数目和视频是否一致不一致不处理现在通过IsAutomated判断不太准确
// if (info.IsAutomated)
// {
// var videoFilesCount = this.GetVideoFileCount(Path.GetDirectoryName(info.Path));
// if (videoFilesCount > 0 && seasonResult.Episodes.Count != videoFilesCount)
// {
// this.Log("Tmdb episode number not match. Name: {0} tmdb episode count: {1} video files count: {2}", info.Name, seasonResult.Episodes.Count, videoFilesCount);
// return result;
// }
// }
var episodeResult = seasonResult.Episodes[episodeNumber.Value - 1];
result.HasMetadata = true;
@ -106,7 +114,6 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
IndexNumber = episodeNumber,
ParentIndexNumber = seasonNumber,
IndexNumberEnd = info.IndexNumberEnd
};
@ -114,42 +121,184 @@ namespace Jellyfin.Plugin.MetaShark.Providers
item.ProductionYear = episodeResult.AirDate?.Year;
item.Name = episodeResult.Name;
item.Overview = episodeResult.Overview;
item.CommunityRating = Convert.ToSingle(episodeResult.VoteAverage);
item.CommunityRating = (float)System.Math.Round(episodeResult.VoteAverage, 1);
result.Item = item;
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
/// <summary>
/// 重新解析文件名
/// 注意:这里修改替换 ParentIndexNumber 值后,会重新触发 SeasonProvier 的 GetMetadata 方法,并带上最新的季数 IndexNumber
/// </summary>
public EpisodeInfo FixParseInfo(EpisodeInfo info)
{
this.Log("GetImageResponse url: {0}", url);
return _httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken);
}
// 使用 AnitomySharp 进行重新解析,解决 anime 识别错误
var fileName = Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
var parseResult = NameParser.ParseEpisode(fileName);
info.Year = parseResult.Year;
info.Name = parseResult.ChineseName ?? parseResult.Name;
private int? GuessEpisodeNumber(int? current, string fileName, double max = double.PositiveInfinity)
// 文件名带有季数数据时,从文件名解析出季数进行修正
// 修正文件名有特殊命名 SXXEPXX 时,默认解析到错误季数的问题,如神探狄仁杰 Detective.Dee.S01EP01.2006.2160p.WEB-DL.x264.AAC-HQC
// TODO: 会导致覆盖用户手动修改元数据的季数
if (parseResult.ParentIndexNumber.HasValue && parseResult.ParentIndexNumber > 0 && info.ParentIndexNumber != parseResult.ParentIndexNumber)
{
var episodeIndex = current;
this.Log("FixSeasonNumber by anitomy. old: {0} new: {1}", info.ParentIndexNumber, parseResult.ParentIndexNumber);
info.ParentIndexNumber = parseResult.ParentIndexNumber;
}
var result = AnitomySharp.AnitomySharp.Parse(fileName).FirstOrDefault(x => x.Category == AnitomySharp.Element.ElementCategory.ElementEpisodeNumber);
if (result != null)
// // 修正anime命名格式导致的seasonNumber错误从season元数据读取)
// if (info.ParentIndexNumber is null)
// {
// var episodeItem = this._libraryManager.FindByPath(info.Path, false);
// var season = episodeItem != null ? ((Episode)episodeItem).Season : null;
// if (season != null && season.IndexNumber.HasValue && info.ParentIndexNumber != season.IndexNumber)
// {
// info.ParentIndexNumber = season.IndexNumber;
// this.Log("FixSeasonNumber by season. old: {0} new: {1}", info.ParentIndexNumber, season.IndexNumber);
// }
// }
// 从季文件夹名称猜出 season number
// 没有 season 级目录或部分特殊不规范命名会变成虚拟季ParentIndexNumber 默认设为 1
// https://github.com/jellyfin/jellyfin/blob/926470829d91d93b4c0b22c5b8b89a791abbb434/Emby.Server.Implementations/Library/LibraryManager.cs#L2626
var isVirtualSeason = this.IsVirtualSeason(info);
var seasonFolderPath = this.GetOriginalSeasonPath(info);
if (info.ParentIndexNumber is null or 1 && isVirtualSeason && seasonFolderPath != null)
{
episodeIndex = result.Value.ToInt();
}
foreach (var regex in EpisodeFileNameRegex)
var guestSeasonNumber = this.GuessSeasonNumberByDirectoryName(seasonFolderPath);
if (guestSeasonNumber.HasValue && guestSeasonNumber != info.ParentIndexNumber)
{
if (!regex.IsMatch(fileName))
continue;
if (!int.TryParse(regex.Match(fileName).Groups[1].Value.Trim('.'), out var index))
continue;
episodeIndex = index;
break;
this.Log("FixSeasonNumber by season path. old: {0} new: {1}", info.ParentIndexNumber, guestSeasonNumber);
info.ParentIndexNumber = guestSeasonNumber;
}
}
return episodeIndex;
// 识别特典
if (info.ParentIndexNumber is null && NameParser.IsAnime(fileName) && (parseResult.IsSpecial || NameParser.IsSpecialDirectory(info.Path)))
{
this.Log("FixSeasonNumber to special. old: {0} new: 0", info.ParentIndexNumber);
info.ParentIndexNumber = 0;
}
// 特典优先使用文件名(特典除了前面特别设置,还有 SXX/Season XX 等默认的)
if (info.ParentIndexNumber.HasValue && info.ParentIndexNumber == 0)
{
info.Name = parseResult.SpecialName == info.Name ? fileName : parseResult.SpecialName;
}
// 修正 episode number
if (parseResult.IndexNumber.HasValue && info.IndexNumber != parseResult.IndexNumber)
{
this.Log("FixEpisodeNumber by anitomy. old: {0} new: {1}", info.IndexNumber, parseResult.IndexNumber);
info.IndexNumber = parseResult.IndexNumber;
}
return info;
}
private MetadataResult<Episode>? HandleAnimeExtras(EpisodeInfo info)
{
// 特典或extra视频可能和正片剧集放在同一目录
var fileName = Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
var parseResult = NameParser.ParseEpisode(fileName);
if (parseResult.IsExtra)
{
this.Log($"Found anime extra of [name]: {fileName}");
var result = new MetadataResult<Episode>();
result.HasMetadata = true;
// 假如已有ParentIndexNumber设为特典覆盖掉设为null不会替换旧值
if (info.ParentIndexNumber.HasValue)
{
result.Item = new Episode
{
ParentIndexNumber = 0,
IndexNumber = null,
Name = parseResult.ExtraName,
};
return result;
}
// 没ParentIndexNumber时只修改名称
result.Item = new Episode
{
Name = parseResult.ExtraName,
};
return result;
}
//// 特典也有 tmdb 剧集信息,不在这里处理
// if (parseResult.IsSpecial || NameParser.IsSpecialDirectory(info.Path))
// {
// this.Log($"Found anime sp of [name]: {fileName}");
// var result = new MetadataResult<Episode>();
// result.HasMetadata = true;
// result.Item = new Episode
// {
// ParentIndexNumber = 0,
// IndexNumber = parseResult.IndexNumber,
// Name = parseResult.SpecialName == info.Name ? fileName : parseResult.SpecialName,
// };
// return result;
// }
return null;
}
protected int GetVideoFileCount(string? dir)
{
if (dir == null)
{
return 0;
}
var cacheKey = $"filecount_{dir}";
if (this._memoryCache.TryGetValue<int>(cacheKey, out var videoFilesCount))
{
return videoFilesCount;
}
var dirInfo = new DirectoryInfo(dir);
if (dirInfo == null)
{
return 0;
}
var files = dirInfo.GetFiles();
var nameOptions = new Emby.Naming.Common.NamingOptions();
foreach (var fileInfo in files.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden)))
{
if (Emby.Naming.Video.VideoResolver.IsVideoFile(fileInfo.FullName, nameOptions))
{
videoFilesCount++;
}
}
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) };
this._memoryCache.Set<int>(cacheKey, videoFilesCount, expiredOption);
return videoFilesCount;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_memoryCache.Dispose();
}
}
}
}

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public static class ProviderIdsExtensions
{
public static MetaSource GetMetaSource(this IHasProviderIds instance, string name)
{
var value = instance.GetProviderId(name);
return value.ToMetaSource();
}
public static void TryGetMetaSource(this Dictionary<string, string> dict, string name, out MetaSource metaSource)
{
if (dict.TryGetValue(name, out var value))
{
metaSource = value.ToMetaSource();
}
else
{
metaSource = MetaSource.None;
}
}
}
}

View File

@ -0,0 +1,26 @@
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace Jellyfin.Plugin.MetaShark.Providers.ExternalId
{
/// <inheritdoc />
public class DoubanPersonExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => BaseProvider.DoubanProviderName;
/// <inheritdoc />
public string Key => BaseProvider.DoubanProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Person;
/// <inheritdoc />
public string UrlFormatString => "https://movie.douban.com/celebrity/{0}/";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Person;
}
}

View File

@ -3,35 +3,26 @@ using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Extensions;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Languages;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class MovieImageProvider : BaseProvider, IRemoteImageProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="MovieImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OddbImageProvider}"/> interface.</param>
/// <param name="doubanApi">Instance of <see cref="DoubanApi"/>.</param>
public MovieImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public MovieImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<MovieImageProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
@ -45,38 +36,46 @@ namespace Jellyfin.Plugin.MetaShark.Providers
public IEnumerable<ImageType> GetSupportedImages(BaseItem item) => new List<ImageType>
{
ImageType.Primary,
ImageType.Backdrop
ImageType.Backdrop,
ImageType.Logo,
};
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var sid = item.GetProviderId(DoubanProviderId);
var metaSource = item.GetProviderId(Plugin.ProviderId);
var metaSource = item.GetMetaSource(Plugin.ProviderId);
this.Log($"GetImages for item: {item.Name} [metaSource]: {metaSource}");
if (!string.IsNullOrEmpty(sid) && metaSource == MetaSource.Douban)
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
{
var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken);
var dropback = await GetBackdrop(sid, cancellationToken);
var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (primary == null || string.IsNullOrEmpty(primary.Img))
{
return Enumerable.Empty<RemoteImageInfo>();
}
var backdropImgs = await this.GetBackdrop(item, cancellationToken).ConfigureAwait(false);
var logoImgs = await this.GetLogos(item, cancellationToken).ConfigureAwait(false);
var res = new List<RemoteImageInfo> {
new RemoteImageInfo
{
ProviderName = primary.Name,
Url = primary.ImgMiddle,
Type = ImageType.Primary
}
ProviderName = this.Name,
Url = this.GetDoubanPoster(primary),
Type = ImageType.Primary,
},
};
res.AddRange(dropback);
res.AddRange(backdropImgs);
res.AddRange(logoImgs);
return res;
}
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb).ToInt();
if (tmdbId > 0)
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
if (metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId))
{
var language = item.GetPreferredMetadataLanguage();
var movie = await _tmdbApi
.GetMovieAsync(tmdbId, language, language, cancellationToken)
// 设定language会导致图片被过滤这里设为null保持取全部语言图片
var movie = await this._tmdbApi
.GetMovieAsync(tmdbId.ToInt(), null, null, cancellationToken)
.ConfigureAwait(false);
if (movie?.Images == null)
@ -86,77 +85,146 @@ namespace Jellyfin.Plugin.MetaShark.Providers
var remoteImages = new List<RemoteImageInfo>();
for (var i = 0; i < movie.Images.Posters.Count; i++)
{
var poster = movie.Images.Posters[i];
remoteImages.Add(new RemoteImageInfo
{
Url = _tmdbApi.GetPosterUrl(poster.FilePath),
CommunityRating = poster.VoteAverage,
VoteCount = poster.VoteCount,
Width = poster.Width,
Height = poster.Height,
ProviderName = Name,
remoteImages.AddRange(movie.Images.Posters.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetPosterUrl(x.FilePath),
Type = ImageType.Primary,
});
}
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
for (var i = 0; i < movie.Images.Backdrops.Count; i++)
{
var backdrop = movie.Images.Backdrops[i];
remoteImages.Add(new RemoteImageInfo
{
Url = _tmdbApi.GetPosterUrl(backdrop.FilePath),
CommunityRating = backdrop.VoteAverage,
VoteCount = backdrop.VoteCount,
Width = backdrop.Width,
Height = backdrop.Height,
ProviderName = Name,
remoteImages.AddRange(movie.Images.Backdrops.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetBackdropUrl(x.FilePath),
Type = ImageType.Backdrop,
RatingType = RatingType.Score
});
}
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
remoteImages.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetLogoUrl(x.FilePath),
Type = ImageType.Logo,
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
return remoteImages.OrderByLanguageDescending(language);
}
this.Log($"Got images failed because the sid of \"{item.Name}\" is empty!");
this.Log($"Got images failed because the images of \"{item.Name}\" is empty!");
return new List<RemoteImageInfo>();
}
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return await this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Query for a background photo
/// </summary>
/// <param name="sid">a subject/movie id</param>
/// <param name="cancellationToken">Instance of the <see cref="CancellationToken"/> interface.</param>
private async Task<IEnumerable<RemoteImageInfo>> GetBackdrop(string sid, CancellationToken cancellationToken)
private async Task<IEnumerable<RemoteImageInfo>> GetBackdrop(BaseItem item, CancellationToken cancellationToken)
{
this.Log("GetBackdrop of sid: {0}", sid);
var photo = await this._doubanApi.GetWallpaperBySidAsync(sid, cancellationToken);
var sid = item.GetProviderId(DoubanProviderId);
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
var list = new List<RemoteImageInfo>();
if (photo == null)
// 从豆瓣获取背景图
if (!string.IsNullOrEmpty(sid))
{
return list;
}
return photo.Where(x => x.Width > x.Height * 1.3).Select(x =>
var photo = await this._doubanApi.GetWallpaperBySidAsync(sid, cancellationToken).ConfigureAwait(false);
if (photo != null && photo.Count > 0)
{
this.Log("GetBackdrop from douban sid: {0}", sid);
list = photo.Where(x => x.Width >= 1280 && x.Width <= 4096 && x.Width > x.Height * 1.3).Select(x =>
{
if (config.EnableDoubanBackdropRaw)
{
return new RemoteImageInfo
{
ProviderName = Name,
Url = x.Large,
Url = this.GetProxyImageUrl(x.Raw),
Height = x.Height,
Width = x.Width,
Type = ImageType.Backdrop,
};
}
else
{
return new RemoteImageInfo
{
ProviderName = Name,
Url = this.GetProxyImageUrl(x.Large),
Type = ImageType.Backdrop,
};
}
}).ToList();
}
}
// 背景图缺失从TheMovieDb补充背景图
if (list.Count == 0 && config.EnableTmdbBackdrop && !string.IsNullOrEmpty(tmdbId))
{
var language = item.GetPreferredMetadataLanguage();
var movie = await _tmdbApi
.GetMovieAsync(tmdbId.ToInt(), language, language, cancellationToken)
.ConfigureAwait(false);
if (movie != null && !string.IsNullOrEmpty(movie.BackdropPath))
{
this.Log("GetBackdrop from tmdb id: {0}", tmdbId);
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = _tmdbApi.GetBackdropUrl(movie.BackdropPath),
Type = ImageType.Backdrop,
});
}
}
return list;
}
private async Task<IEnumerable<RemoteImageInfo>> GetLogos(BaseItem item, CancellationToken cancellationToken)
{
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
var list = new List<RemoteImageInfo>();
var language = item.GetPreferredMetadataLanguage();
if (this.config.EnableTmdbLogo && !string.IsNullOrEmpty(tmdbId))
{
this.Log("GetLogos from tmdb id: {0} lang: {1}", tmdbId, language);
var movie = await this._tmdbApi
.GetMovieAsync(tmdbId.ToInt(), language, language, cancellationToken)
.ConfigureAwait(false);
if (movie != null && movie.Images != null)
{
list.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetLogoUrl(x.FilePath),
Type = ImageType.Logo,
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
}
}
return list.OrderByLanguageDescending(language);
}
}
}

View File

@ -1,15 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using AngleSharp.Text;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
@ -19,20 +17,15 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using StringMetric;
using TMDbLib.Client;
using TMDbLib.Objects.Find;
using TMDbLib.Objects.Languages;
using TMDbLib.Objects.TvShows;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class MovieProvider : BaseProvider, IRemoteMetadataProvider<Movie, MovieInfo>
{
public MovieProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public MovieProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<MovieProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
@ -50,13 +43,14 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
// 从douban搜索
var res = await this._doubanApi.SearchAsync(info.Name, cancellationToken).ConfigureAwait(false);
result.AddRange(res.Take(this._config.MaxSearchResult).Select(x =>
var res = await this._doubanApi.SearchMovieAsync(info.Name, cancellationToken).ConfigureAwait(false);
result.AddRange(res.Take(Configuration.PluginConfiguration.MAX_SEARCH_RESULT).Select(x =>
{
return new RemoteSearchResult
{
SearchProviderName = DoubanProviderName,
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, x.Sid } },
// 注意jellyfin 会判断电影所有 provider id 是否有相同的,有相同的值就会认为是同一影片,会被合并不返回,必须保持 provider id 的唯一性
// 这里 Plugin.ProviderId 的值做这么复杂,是为了保持唯一
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, x.Sid }, { Plugin.ProviderId, $"{MetaSource.Douban}_{x.Sid}" } },
ImageUrl = this.GetProxyImageUrl(x.Img),
ProductionYear = x.Year,
Name = x.Name,
@ -65,19 +59,23 @@ namespace Jellyfin.Plugin.MetaShark.Providers
// 从tmdb搜索
if (this.config.EnableTmdbSearch)
{
var tmdbList = await _tmdbApi.SearchMovieAsync(info.Name, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
result.AddRange(tmdbList.Take(this._config.MaxSearchResult).Select(x =>
result.AddRange(tmdbList.Take(Configuration.PluginConfiguration.MAX_SEARCH_RESULT).Select(x =>
{
return new RemoteSearchResult
{
SearchProviderName = TmdbProviderName,
ProviderIds = new Dictionary<string, string> { { MetadataProvider.Tmdb.ToString(), x.Id.ToString(CultureInfo.InvariantCulture) } },
Name = x.Title ?? x.OriginalTitle,
// 注意jellyfin 会判断电影所有 provider id 是否有相同的,有相同的值就会认为是同一影片,会被合并不返回,必须保持 provider id 的唯一性
// 这里 Plugin.ProviderId 的值做这么复杂,是为了保持唯一
ProviderIds = new Dictionary<string, string> { { MetadataProvider.Tmdb.ToString(), x.Id.ToString(CultureInfo.InvariantCulture) }, { Plugin.ProviderId, $"{MetaSource.Tmdb}_{x.Id}" } },
Name = string.Format("[TMDB]{0}", x.Title ?? x.OriginalTitle),
ImageUrl = this._tmdbApi.GetPosterUrl(x.PosterPath),
Overview = x.Overview,
ProductionYear = x.ReleaseDate?.Year,
};
}));
}
return result;
}
@ -85,20 +83,28 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// <inheritdoc />
public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken)
{
this.Log($"GetMovieMetadata of [name]: {info.Name}");
var fileName = this.GetOriginalFileName(info);
this.Log($"GetMovieMetadata of [name]: {info.Name} [fileName]: {fileName} EnableTmdb: {config.EnableTmdb}");
var result = new MetadataResult<Movie>();
// 使用刷新元数据时providerIds会保留旧有值只有识别/新增才会没值
var sid = info.GetProviderId(DoubanProviderId);
var tmdbId = info.GetProviderId(MetadataProvider.Tmdb);
var metaSource = info.GetProviderId(Plugin.ProviderId);
if (string.IsNullOrEmpty(sid) && string.IsNullOrEmpty(tmdbId))
var metaSource = info.GetMetaSource(Plugin.ProviderId);
// 注意会存在元数据有tmdbId但metaSource没值的情况之前由TMDB插件刮削导致
var hasTmdbMeta = metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId);
var hasDoubanMeta = metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid);
if (!hasDoubanMeta && !hasTmdbMeta)
{
// 刷新元数据匹配搜索
sid = await this.GuestByDoubanAsync(info, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(sid))
// 处理extras影片
var extraResult = this.HandleExtraType(info);
if (extraResult != null)
{
tmdbId = await this.GuestByTmdbAsync(info, cancellationToken).ConfigureAwait(false);
return extraResult;
}
// 自动扫描搜索匹配元数据
sid = await this.GuessByDoubanAsync(info, cancellationToken).ConfigureAwait(false);
}
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
@ -113,7 +119,8 @@ namespace Jellyfin.Plugin.MetaShark.Providers
var movie = new Movie
{
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, subject.Sid }, { Plugin.ProviderId, MetaSource.Douban } },
// 这里 Plugin.ProviderId 的值做这么复杂,是为了保持唯一
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, subject.Sid }, { Plugin.ProviderId, $"{MetaSource.Douban}_{subject.Sid}" } },
Name = subject.Name,
OriginalTitle = subject.OriginalName,
CommunityRating = subject.Rating,
@ -121,31 +128,64 @@ namespace Jellyfin.Plugin.MetaShark.Providers
ProductionYear = subject.Year,
HomePageUrl = "https://www.douban.com",
Genres = subject.Genres,
// ProductionLocations = [x?.Country],
PremiereDate = subject.ScreenTime,
};
if (!string.IsNullOrEmpty(subject.Imdb))
{
movie.SetProviderId(MetadataProvider.Imdb, subject.Imdb);
var newImdbId = await this.CheckNewImdbID(subject.Imdb, cancellationToken).ConfigureAwait(false);
subject.Imdb = newImdbId;
movie.SetProviderId(MetadataProvider.Imdb, newImdbId);
// 通过imdb获取TMDB id
var movieResult = await this._tmdbApi.FindByExternalIdAsync(subject.Imdb, FindExternalSource.Imdb, null, cancellationToken).ConfigureAwait(false);
if (movieResult?.MovieResults != null && movieResult.MovieResults.Count > 0)
var newTmdbId = await this.GetTmdbIdByImdbAsync(subject.Imdb, info.MetadataLanguage, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(newTmdbId))
{
this.Log($"GetMovieMetadata of found tmdb [id]: \"{movieResult.MovieResults[0].Id}\"");
movie.SetProviderId(MetadataProvider.Tmdb, $"{movieResult.MovieResults[0].Id}");
tmdbId = newTmdbId;
movie.SetProviderId(MetadataProvider.Tmdb, tmdbId);
}
}
// 尝试通过搜索匹配获取tmdbId
if (string.IsNullOrEmpty(tmdbId) && subject.Year > 0)
{
var newTmdbId = await this.GuestByTmdbAsync(subject.Name, subject.Year, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(newTmdbId))
{
tmdbId = newTmdbId;
movie.SetProviderId(MetadataProvider.Tmdb, tmdbId);
}
}
// 通过imdb获取电影系列信息
if (this.config.EnableTmdbCollection && !string.IsNullOrEmpty(tmdbId))
{
var collectionName = await this.GetTmdbCollection(info, tmdbId, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(collectionName))
{
movie.CollectionName = collectionName;
}
}
// 通过imdb获取电影分级信息
if (this.config.EnableTmdbOfficialRating && !string.IsNullOrEmpty(tmdbId))
{
var officialRating = await this.GetTmdbOfficialRating(info, tmdbId, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(officialRating))
{
movie.OfficialRating = officialRating;
}
}
result.Item = movie;
result.QueriedById = true;
result.HasMetadata = true;
subject.Celebrities.ForEach(c => result.AddPerson(new PersonInfo
subject.LimitDirectorCelebrities.Take(Configuration.PluginConfiguration.MAX_CAST_MEMBERS).ToList().ForEach(c => result.AddPerson(new PersonInfo
{
Name = c.Name,
Type = c.RoleType,
Type = c.RoleType == PersonType.Director ? PersonKind.Director : PersonKind.Actor,
Role = c.Role,
ImageUrl = c.Img,
ImageUrl = this.GetLocalProxyImageUrl(c.Img),
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, c.Id } },
}));
@ -153,10 +193,19 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
if (metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId))
{
return await this.GetMetadataByTmdb(tmdbId, info, cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrEmpty(tmdbId))
this.Log($"匹配失败!可检查下年份是否与豆瓣一致,是否需要登录访问. [name]: {info.Name} [year]: {info.Year}");
return result;
}
private async Task<MetadataResult<Movie>> GetMetadataByTmdb(string tmdbId, MovieInfo info, CancellationToken cancellationToken)
{
this.Log($"GetMovieMetadata of tmdb [id]: \"{tmdbId}\"");
var result = new MetadataResult<Movie>();
var movieResult = await _tmdbApi
.GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
@ -169,12 +218,14 @@ namespace Jellyfin.Plugin.MetaShark.Providers
var movie = new Movie
{
Name = movieResult.Title ?? movieResult.OriginalTitle,
OriginalTitle = movieResult.OriginalTitle,
Overview = movieResult.Overview?.Replace("\n\n", "\n", StringComparison.InvariantCulture),
Tagline = movieResult.Tagline,
ProductionLocations = movieResult.ProductionCountries.Select(pc => pc.Name).ToArray()
};
var metadataResult = new MetadataResult<Movie>
result = new MetadataResult<Movie>
{
QueriedById = true,
HasMetadata = true,
ResultLanguage = info.MetadataLanguage,
Item = movie
@ -182,9 +233,17 @@ namespace Jellyfin.Plugin.MetaShark.Providers
movie.SetProviderId(MetadataProvider.Tmdb, tmdbId);
movie.SetProviderId(MetadataProvider.Imdb, movieResult.ImdbId);
movie.SetProviderId(Plugin.ProviderId, MetaSource.Tmdb);
// 这里 Plugin.ProviderId 的值做这么复杂,是为了保持唯一
movie.SetProviderId(Plugin.ProviderId, $"{MetaSource.Tmdb}_{tmdbId}");
movie.CommunityRating = Convert.ToSingle(movieResult.VoteAverage);
// 获取电影系列信息
if (this.config.EnableTmdbCollection && movieResult.BelongsToCollection != null)
{
movie.CollectionName = movieResult.BelongsToCollection.Name;
}
movie.CommunityRating = (float)System.Math.Round(movieResult.VoteAverage, 2);
movie.OfficialRating = this.GetTmdbOfficialRatingByData(movieResult, info.MetadataCountryCode);
movie.PremiereDate = movieResult.ReleaseDate;
movie.ProductionYear = movieResult.ReleaseDate?.Year;
@ -205,10 +264,30 @@ namespace Jellyfin.Plugin.MetaShark.Providers
result.AddPerson(person);
}
return metadataResult;
return result;
}
return result;
private MetadataResult<Movie>? HandleExtraType(MovieInfo info)
{
// 特典或extra视频可能和正片放在同一目录
// TODO插件暂时不支持设置影片为extra类型只能直接忽略处理最好放extras目录
var fileName = Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
var parseResult = NameParser.Parse(fileName);
if (parseResult.IsExtra)
{
this.Log($"Found extra of [name]: {fileName}");
return new MetadataResult<Movie>();
}
// 动画常用特典文件夹
if (NameParser.IsSpecialDirectory(info.Path) || NameParser.IsExtraDirectory(info.Path))
{
this.Log($"Found extra of [name]: {fileName}");
return new MetadataResult<Movie>();
}
return null;
}
@ -217,13 +296,13 @@ namespace Jellyfin.Plugin.MetaShark.Providers
// 演员
if (item.Credits?.Cast != null)
{
foreach (var actor in item.Credits.Cast.OrderBy(a => a.Order).Take(this._config.MaxCastMembers))
foreach (var actor in item.Credits.Cast.OrderBy(a => a.Order).Take(Configuration.PluginConfiguration.MAX_CAST_MEMBERS))
{
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
Role = actor.Character,
Type = PersonType.Actor,
Type = PersonKind.Actor,
SortOrder = actor.Order,
};
@ -266,7 +345,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
Name = person.Name.Trim(),
Role = person.Job,
Type = type
Type = type == PersonType.Director ? PersonKind.Director : (type == PersonType.Producer ? PersonKind.Producer : PersonKind.Actor),
};
if (!string.IsNullOrWhiteSpace(person.ProfilePath))
@ -285,13 +364,62 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
private async Task<String?> GetTmdbCollection(MovieInfo info, string tmdbId, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return await this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken).ConfigureAwait(false);
var movieResult = await _tmdbApi
.GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
if (movieResult != null && movieResult.BelongsToCollection != null)
{
return movieResult.BelongsToCollection.Name;
}
return null;
}
private async Task<String?> GetTmdbOfficialRating(ItemLookupInfo info, string tmdbId, CancellationToken cancellationToken)
{
var movieResult = await _tmdbApi
.GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
return GetTmdbOfficialRatingByData(movieResult, info.MetadataCountryCode);
}
private String? GetTmdbOfficialRatingByData(TMDbLib.Objects.Movies.Movie? movieResult, string preferredCountryCode)
{
if (movieResult == null || movieResult.Releases?.Countries == null)
{
return null;
}
var releases = movieResult.Releases.Countries.Where(i => !string.IsNullOrWhiteSpace(i.Certification)).ToList();
var ourRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, preferredCountryCode, StringComparison.OrdinalIgnoreCase));
var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase));
var minimumRelease = releases.FirstOrDefault();
if (ourRelease != null)
{
var ratingPrefix = string.Equals(preferredCountryCode, "us", StringComparison.OrdinalIgnoreCase) ? string.Empty : preferredCountryCode + "-";
var newRating = ratingPrefix + ourRelease.Certification;
newRating = newRating.Replace("de-", "FSK-", StringComparison.OrdinalIgnoreCase);
return newRating;
}
else if (usRelease != null)
{
return usRelease.Certification;
}
else if (minimumRelease != null)
{
return minimumRelease.Certification;
}
return null;
}
}

View File

@ -0,0 +1,83 @@
using Jellyfin.Plugin.MetaShark.Api;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class PersonImageProvider : BaseProvider, IRemoteImageProvider
{
public PersonImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<PersonImageProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
/// <inheritdoc />
public string Name => Plugin.PluginName;
/// <inheritdoc />
public bool Supports(BaseItem item) => item is Person;
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
yield return ImageType.Primary;
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var list = new List<RemoteImageInfo>();
var cid = item.GetProviderId(DoubanProviderId);
var metaSource = item.GetMetaSource(Plugin.ProviderId);
this.Log($"GetImages for item: {item.Name} [metaSource]: {metaSource}");
if (!string.IsNullOrEmpty(cid))
{
var celebrity = await this._doubanApi.GetCelebrityAsync(cid, cancellationToken).ConfigureAwait(false);
if (celebrity != null)
{
list.Add(new RemoteImageInfo
{
ProviderName = this.Name,
Url = this.GetProxyImageUrl(celebrity.Img),
Type = ImageType.Primary,
});
}
var photos = await this._doubanApi.GetCelebrityPhotosAsync(cid, cancellationToken).ConfigureAwait(false);
photos.ForEach(x =>
{
// 过滤不是竖图
if (x.Width < 400 || x.Height < x.Width * 1.3)
{
return;
}
list.Add(new RemoteImageInfo
{
ProviderName = this.Name,
Url = this.GetProxyImageUrl(x.Raw),
Width = x.Width,
Height = x.Height,
Type = ImageType.Primary,
});
});
}
if (list.Count == 0)
{
this.Log($"Got images failed because the images of \"{item.Name}\" is empty!");
}
return list;
}
}
}

View File

@ -1,20 +1,20 @@
using Jellyfin.Plugin.MetaShark.Api;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Find;
namespace Jellyfin.Plugin.MetaShark.Providers
{
@ -23,14 +23,8 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// </summary>
public class PersonProvider : BaseProvider, IRemoteMetadataProvider<Person, PersonLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="MovieImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OddbImageProvider}"/> interface.</param>
/// <param name="doubanApi">Instance of <see cref="DoubanApi"/>.</param>
public PersonProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public PersonProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<PersonProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
@ -40,63 +34,126 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken)
{
return await Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>());
this.Log($"GetPersonSearchResults of [name]: {searchInfo.Name}");
var result = new List<RemoteSearchResult>();
var cid = searchInfo.GetProviderId(DoubanProviderId);
if (!string.IsNullOrEmpty(cid))
{
var celebrity = await this._doubanApi.GetCelebrityAsync(cid, cancellationToken).ConfigureAwait(false);
if (celebrity != null)
{
result.Add(new RemoteSearchResult
{
SearchProviderName = DoubanProviderName,
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, celebrity.Id } },
ImageUrl = this.GetProxyImageUrl(celebrity.Img),
Name = celebrity.Name,
}
);
return result;
}
}
var res = await this._doubanApi.SearchCelebrityAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false);
result.AddRange(res.Take(Configuration.PluginConfiguration.MAX_SEARCH_RESULT).Select(x =>
{
return new RemoteSearchResult
{
SearchProviderName = DoubanProviderName,
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, x.Id } },
ImageUrl = this.GetProxyImageUrl(x.Img),
Name = x.Name,
};
}));
return result;
}
/// <inheritdoc />
public async Task<MetadataResult<Person>> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken)
{
MetadataResult<Person> result = new MetadataResult<Person>();
var result = new MetadataResult<Person>();
var cid = info.GetProviderId(DoubanProviderId);
this.Log($"GetPersonMetadata of [cid]: {cid}");
this.Log($"GetPersonMetadata of [name]: {info.Name} [cid]: {cid}");
if (!string.IsNullOrEmpty(cid))
{
var c = await this._doubanApi.GetCelebrityAsync(cid, cancellationToken).ConfigureAwait(false);
if (c != null)
{
Person p = new Person
var item = new Person
{
Name = c.Name,
// Name = c.Name.Trim(), // 名称需保持和info.Name一致不然会导致关联不到影片自动被删除
OriginalTitle = c.DisplayOriginalName, // 外国人显示英文名
HomePageUrl = c.Site,
Overview = c.Intro,
PremiereDate = DateTime.ParseExact(c.Birthdate, "yyyy年MM月dd日", System.Globalization.CultureInfo.CurrentCulture)
};
p.SetProviderId(Plugin.ProviderId, c.Id);
if (DateTime.TryParseExact(c.Birthdate, "yyyy年MM月dd日", null, DateTimeStyles.None, out var premiereDate))
{
item.PremiereDate = premiereDate;
item.ProductionYear = premiereDate.Year;
}
if (DateTime.TryParseExact(c.Enddate, "yyyy年MM月dd日", null, DateTimeStyles.None, out var endDate))
{
item.EndDate = endDate;
}
if (!string.IsNullOrWhiteSpace(c.Birthplace))
{
p.ProductionLocations = new[] { c.Birthplace };
item.ProductionLocations = new[] { c.Birthplace };
}
item.SetProviderId(DoubanProviderId, cid);
if (!string.IsNullOrEmpty(c.Imdb))
{
p.SetProviderId(MetadataProvider.Imdb, c.Imdb);
var newImdbId = await this._imdbApi.CheckPersonNewIDAsync(c.Imdb, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(newImdbId))
{
c.Imdb = newImdbId;
}
item.SetProviderId(MetadataProvider.Imdb, c.Imdb);
// 通过imdb获取TMDB id
var findResult = await this._tmdbApi.FindByExternalIdAsync(c.Imdb, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
if (findResult?.PersonResults != null && findResult.PersonResults.Count > 0)
{
var foundTmdbId = findResult.PersonResults.First().Id.ToString();
this.Log($"GetPersonMetadata of found tmdb [id]: {foundTmdbId}");
item.SetProviderId(MetadataProvider.Tmdb, $"{foundTmdbId}");
}
}
result.QueriedById = true;
result.HasMetadata = true;
result.Item = p;
result.Item = item;
return result;
}
}
// jellyfin强制最后一定使用默认的TheMovieDb插件获取一次这里不太必要除了使用自己的域名
var personTmdbId = info.GetProviderId(MetadataProvider.Tmdb);
this.Log($"GetPersonMetadata of [personTmdbId]: {personTmdbId}");
if (!string.IsNullOrEmpty(personTmdbId))
{
var person = await this._tmdbApi.GetPersonAsync(personTmdbId.ToInt(), cancellationToken).ConfigureAwait(false);
return await this.GetMetadataByTmdb(personTmdbId.ToInt(), info, cancellationToken).ConfigureAwait(false);
}
return result;
}
public async Task<MetadataResult<Person>> GetMetadataByTmdb(int personTmdbId, PersonLookupInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Person>();
var person = await this._tmdbApi.GetPersonAsync(personTmdbId, cancellationToken).ConfigureAwait(false);
if (person != null)
{
result.HasMetadata = true;
var item = new Person
{
// Take name from incoming info, don't rename the person
// TODO: This should go in PersonMetadataService, not each person provider
Name = info.Name,
// Name = info.Name.Trim(), // 名称需保持和info.Name一致不然会导致关联不到影片自动被删除
HomePageUrl = person.Homepage,
Overview = person.Biography,
PremiereDate = person.Birthday?.ToUniversalTime(),
@ -109,7 +166,6 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
item.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(person.ImdbId))
{
item.SetProviderId(MetadataProvider.Imdb, person.ImdbId);
@ -117,23 +173,12 @@ namespace Jellyfin.Plugin.MetaShark.Providers
result.HasMetadata = true;
result.Item = item;
return result;
}
}
return result;
}
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("Person GetImageResponse url: {0}", url);
return await this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken).ConfigureAwait(false);
}
private void Log(string? message, params object?[] args)
{
this._logger.LogInformation($"[MetaShark] {message}", args);
}
}
}

View File

@ -1,38 +1,28 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Extensions;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Languages;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class SeasonImageProvider : BaseProvider, IRemoteImageProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="SeasonImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OddbImageProvider}"/> interface.</param>
/// <param name="doubanApi">Instance of <see cref="DoubanApi"/>.</param>
public SeasonImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public SeasonImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeasonImageProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
@ -54,11 +44,11 @@ namespace Jellyfin.Plugin.MetaShark.Providers
this.Log($"GetSeasonImages for item: {item.Name} number: {item.IndexNumber}");
var season = (Season)item;
var series = season.Series;
var metaSource = series.GetProviderId(Plugin.ProviderId);
var metaSource = series.GetMetaSource(Plugin.ProviderId);
// get image from douban
var sid = item.GetProviderId(DoubanProviderId);
if (metaSource == MetaSource.Douban && !string.IsNullOrEmpty(sid))
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
{
var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (primary == null)
@ -70,9 +60,9 @@ namespace Jellyfin.Plugin.MetaShark.Providers
new RemoteImageInfo
{
ProviderName = primary.Name,
Url = primary.ImgMiddle,
Type = ImageType.Primary
}
Url = this.GetDoubanPoster(primary),
Type = ImageType.Primary,
},
};
return res;
}
@ -86,9 +76,8 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
var language = item.GetPreferredMetadataLanguage();
var seasonResult = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, language, language, cancellationToken)
.GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, cancellationToken)
.ConfigureAwait(false);
var posters = seasonResult?.Images?.Posters;
if (posters == null)
@ -107,6 +96,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
VoteCount = image.VoteCount,
Width = image.Width,
Height = image.Height,
Language = AdjustImageLanguage(image.Iso_639_1, language),
ProviderName = Name,
Type = ImageType.Primary,
};
@ -115,12 +105,5 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return remoteImages.OrderByLanguageDescending(language);
}
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return await this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -8,49 +8,27 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Find;
using TMDbLib.Objects.TvShows;
using Microsoft.AspNetCore.Http;
using Jellyfin.Data.Enums;
using System.IO;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class SeasonProvider : BaseProvider, IRemoteMetadataProvider<Season, SeasonInfo>
{
public SeasonProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public SeasonProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeasonProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
public string Name => Plugin.PluginName;
/// <summary>
/// Pattern for media name filtering
/// </summary>
private string _pattern;
public string Pattern
{
get
{
if (string.IsNullOrEmpty(_pattern))
{
return Plugin.Instance?.Configuration.Pattern;
}
return _pattern;
}
set
{
_pattern = value;
}
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo info, CancellationToken cancellationToken)
@ -62,37 +40,35 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// <inheritdoc />
public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken)
{
this.Log($"GetSeasonMetaData of [name]: {info.Name} number: {info.IndexNumber}");
var result = new MetadataResult<Season>();
// 使用刷新元数据时,之前识别的 seasonNumber 会保留,不会被覆盖
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out var seriesTmdbId);
info.SeriesProviderIds.TryGetValue(Plugin.ProviderId, out var metaSource);
info.SeriesProviderIds.TryGetMetaSource(Plugin.ProviderId, out var metaSource);
info.SeriesProviderIds.TryGetValue(DoubanProviderId, out var sid);
var seasonNumber = info.IndexNumber;
if (metaSource == MetaSource.Douban && !string.IsNullOrEmpty(sid))
var seasonNumber = info.IndexNumber; // S00/Season 00特典目录会为0
var seasonSid = info.GetProviderId(DoubanProviderId);
var fileName = Path.GetFileName(info.Path);
this.Log($"GetSeasonMetaData of [name]: {info.Name} [fileName]: {fileName} number: {info.IndexNumber} seriesTmdbId: {seriesTmdbId} sid: {sid} metaSource: {metaSource} EnableTmdb: {config.EnableTmdb}");
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
{
// 从sereis获取正确名称季名称有时不对
var series = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (series == null)
// seasonNumber 为 null 有三种情况:
// 1. 没有季文件夹时即虚拟季info.Path 为空
// 2. 一般不规范文件夹命名,没法被 EpisodeResolver 解析的info.Path 不为空,如:摇曳露营△
// 3. 特殊不规范文件夹命名,能被 EpisodeResolver 错误解析这时被当成了视频文件相当于没有季文件夹info.Path 为空,如:冰与火之歌 S02.列王的纷争.2012.1080p.Blu-ray.x265.10bit.AC3
// 相关代码https://github.com/jellyfin/jellyfin/blob/dc2eca9f2ca259b46c7b53f59251794903c730a4/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs#L70
if (seasonNumber is null)
{
return result;
}
var seiresName = series.Name;
// 存在tmdbid尝试从tmdb获取对应季的年份信息用于从豆瓣搜索对应季数据
int seasonYear = 0;
if (!string.IsNullOrEmpty(seriesTmdbId) && seasonNumber.HasValue)
{
var season = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber.Value, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
seasonYear = season?.AirDate?.Year ?? 0;
seasonNumber = this.GuessSeasonNumberByDirectoryName(info.Path);
}
if (!string.IsNullOrEmpty(seiresName) && seasonYear > 0)
// 搜索豆瓣季 id
if (string.IsNullOrEmpty(seasonSid))
{
var seasonSid = await this.GuestSeasonByDoubanAsync(seiresName, seasonYear, cancellationToken).ConfigureAwait(false);
seasonSid = await this.GuessDoubanSeasonId(sid, seriesTmdbId, seasonNumber, info, cancellationToken).ConfigureAwait(false);
}
// 获取季豆瓣数据
if (!string.IsNullOrEmpty(seasonSid))
{
var subject = await this._doubanApi.GetMovieAsync(seasonSid, cancellationToken).ConfigureAwait(false);
@ -104,59 +80,136 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, subject.Sid } },
Name = subject.Name,
OriginalTitle = subject.OriginalName,
CommunityRating = subject.Rating,
Overview = subject.Intro,
ProductionYear = subject.Year,
Genres = subject.Genres,
PremiereDate = subject.ScreenTime,
IndexNumber = info.IndexNumber,
PremiereDate = subject.ScreenTime, // 发行日期
IndexNumber = seasonNumber,
};
result.Item = movie;
result.HasMetadata = true;
subject.Celebrities.ForEach(c => result.AddPerson(new PersonInfo
subject.LimitDirectorCelebrities.Take(Configuration.PluginConfiguration.MAX_CAST_MEMBERS).ToList().ForEach(c => result.AddPerson(new PersonInfo
{
Name = c.Name,
Type = c.RoleType,
Type = c.RoleType == PersonType.Director ? PersonKind.Director : PersonKind.Actor,
Role = c.Role,
ImageUrl = c.Img,
ImageUrl = this.GetLocalProxyImageUrl(c.Img),
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, c.Id } },
}));
this.Log($"Season [{info.Name}] found douban [sid]: {seasonSid}");
return result;
}
}
else
{
this.Log($"Season [{info.Name}] not found douban season id!");
}
// 从豆瓣获取不到季信息直接使用series信息
result.Item = new Season
{
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, sid } },
Name = series.Name,
OriginalTitle = series.OriginalName,
CommunityRating = series.Rating,
Overview = series.Intro,
ProductionYear = series.Year,
Genres = series.Genres,
PremiereDate = series.ScreenTime,
};
result.QueriedById = true;
result.HasMetadata = true;
// 豆瓣找不到季数据尝试获取tmdb的季数据
if (string.IsNullOrEmpty(seasonSid) && !string.IsNullOrWhiteSpace(seriesTmdbId) && seasonNumber.HasValue && seasonNumber >= 0)
{
var tmdbResult = await this.GetMetadataByTmdb(info, seriesTmdbId, seasonNumber.Value, cancellationToken).ConfigureAwait(false);
if (tmdbResult != null)
{
return tmdbResult;
}
}
// 从豆瓣获取不到季信息
return result;
}
// series使用TMDB元数据来源
// tmdb季级没有对应id只通过indexNumber区分
if (string.IsNullOrWhiteSpace(seriesTmdbId) || !seasonNumber.HasValue)
if (metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(seriesTmdbId))
{
return await this.GetMetadataByTmdb(info, seriesTmdbId, seasonNumber, cancellationToken).ConfigureAwait(false);
}
return result;
}
public async Task<string?> GuessDoubanSeasonId(string? sid, string? seriesTmdbId, int? seasonNumber, ItemLookupInfo info, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(sid))
{
return null;
}
// 没有季文件夹或季文件夹名不规范时即虚拟季info.Path 会为空seasonNumber 为 null
if (string.IsNullOrEmpty(info.Path) && !seasonNumber.HasValue)
{
return null;
}
// 从季文件夹名属性格式获取,如 [douban-12345] 或 [doubanid-12345]
var fileName = this.GetOriginalFileName(info);
var doubanId = this.regDoubanIdAttribute.FirstMatchGroup(fileName);
if (!string.IsNullOrWhiteSpace(doubanId))
{
this.Log($"Found season douban [id] by attr: {doubanId}");
return doubanId;
}
// 从sereis获取正确名称info.Name当是标准格式如S01等时会变成第x季非标准名称默认文件名
var series = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (series == null)
{
return null;
}
var seriesName = this.RemoveSeasonSuffix(series.Name);
// 没有季id但存在tmdbid尝试从tmdb获取对应季的年份信息用于从豆瓣搜索对应季数据
var seasonYear = 0;
if (!string.IsNullOrEmpty(seriesTmdbId) && (seasonNumber.HasValue && seasonNumber > 0))
{
var season = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber.Value, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
seasonYear = season?.AirDate?.Year ?? 0;
}
if (!string.IsNullOrEmpty(seriesName) && seasonYear > 0)
{
var seasonSid = await this.GuestDoubanSeasonByYearAsync(seriesName, seasonYear, seasonNumber, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(seasonSid))
{
return seasonSid;
}
}
// 通过季名匹配douban id作为关闭tmdb api/api超时的后备方法使用
if (!string.IsNullOrEmpty(seriesName) && seasonNumber.HasValue && seasonNumber > 0)
{
return await this.GuestDoubanSeasonBySeasonNameAsync(seriesName, seasonNumber, cancellationToken).ConfigureAwait(false);
}
return null;
}
public async Task<MetadataResult<Season>> GetMetadataByTmdb(SeasonInfo info, string? seriesTmdbId, int? seasonNumber, CancellationToken cancellationToken)
{
var result = new MetadataResult<Season>();
if (string.IsNullOrEmpty(seriesTmdbId))
{
return result;
}
if (seasonNumber is null or 0)
{
return result;
}
var seasonResult = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber.Value, info.MetadataLanguage, null, cancellationToken)
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber ?? 0, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
if (seasonResult == null)
{
@ -164,9 +217,11 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return result;
}
result.HasMetadata = true;
result.Item = new Season
{
Name = seasonResult.Name,
IndexNumber = seasonNumber,
Overview = seasonResult.Overview,
PremiereDate = seasonResult.AirDate,
@ -177,99 +232,9 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
result.Item.SetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds.TvdbId);
}
foreach (var person in GetPersons(seasonResult))
{
result.AddPerson(person);
}
return result;
}
private IEnumerable<PersonInfo> GetPersons(TvSeason item)
{
// 演员
if (item.Credits?.Cast != null)
{
foreach (var actor in item.Credits.Cast.OrderBy(a => a.Order).Take(this._config.MaxCastMembers))
{
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
Role = actor.Character,
Type = PersonType.Actor,
SortOrder = actor.Order,
};
if (!string.IsNullOrWhiteSpace(actor.ProfilePath))
{
personInfo.ImageUrl = this._tmdbApi.GetProfileUrl(actor.ProfilePath);
}
if (actor.Id > 0)
{
personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture));
}
yield return personInfo;
}
}
// 导演
if (item.Credits?.Crew != null)
{
var keepTypes = new[]
{
PersonType.Director,
PersonType.Writer,
PersonType.Producer
};
foreach (var person in item.Credits.Crew)
{
// Normalize this
var type = MapCrewToPersonType(person);
if (!keepTypes.Contains(type, StringComparer.OrdinalIgnoreCase)
&& !keepTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
continue;
}
var personInfo = new PersonInfo
{
Name = person.Name.Trim(),
Role = person.Job,
Type = type
};
if (!string.IsNullOrWhiteSpace(person.ProfilePath))
{
personInfo.ImageUrl = this._tmdbApi.GetPosterUrl(person.ProfilePath);
}
if (person.Id > 0)
{
personInfo.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture));
}
yield return personInfo;
}
}
}
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return await this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken).ConfigureAwait(false);
}
private void Log(string? message, params object?[] args)
{
this._logger.LogInformation($"[MetaShark] {message}", args);
}
}
}

View File

@ -2,7 +2,6 @@
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
@ -10,28 +9,20 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Extensions;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Languages;
namespace Jellyfin.Plugin.MetaShark.Providers
{
public class SeriesImageProvider : BaseProvider, IRemoteImageProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="SeriesImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OddbImageProvider}"/> interface.</param>
/// <param name="doubanApi">Instance of <see cref="DoubanApi"/>.</param>
public SeriesImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public SeriesImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesImageProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
@ -45,38 +36,46 @@ namespace Jellyfin.Plugin.MetaShark.Providers
public IEnumerable<ImageType> GetSupportedImages(BaseItem item) => new List<ImageType>
{
ImageType.Primary,
ImageType.Backdrop
ImageType.Backdrop,
ImageType.Logo,
};
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var sid = item.GetProviderId(DoubanProviderId);
var metaSource = item.GetProviderId(Plugin.ProviderId);
var metaSource = item.GetMetaSource(Plugin.ProviderId);
this.Log($"GetImages for item: {item.Name} [metaSource]: {metaSource}");
if (!string.IsNullOrEmpty(sid) && metaSource == MetaSource.Douban)
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
{
var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken);
var dropback = await GetBackdrop(sid, cancellationToken);
var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (primary == null || string.IsNullOrEmpty(primary.Img))
{
return Enumerable.Empty<RemoteImageInfo>();
}
var backdropImgs = await this.GetBackdrop(item, cancellationToken).ConfigureAwait(false);
var logoImgs = await this.GetLogos(item, cancellationToken).ConfigureAwait(false);
var res = new List<RemoteImageInfo> {
new RemoteImageInfo
{
ProviderName = primary.Name,
Url = primary.ImgMiddle,
Type = ImageType.Primary
}
ProviderName = this.Name,
Url = this.GetDoubanPoster(primary),
Type = ImageType.Primary,
},
};
res.AddRange(dropback);
res.AddRange(backdropImgs);
res.AddRange(logoImgs);
return res;
}
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb).ToInt();
if (tmdbId > 0)
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
if (metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId))
{
var language = item.GetPreferredMetadataLanguage();
var movie = await _tmdbApi
.GetSeriesAsync(tmdbId, language, language, cancellationToken)
// 设定language会导致图片被过滤这里设为null保持取全部语言图片
var movie = await this._tmdbApi
.GetSeriesAsync(tmdbId.ToInt(), null, null, cancellationToken)
.ConfigureAwait(false);
if (movie?.Images == null)
@ -86,77 +85,146 @@ namespace Jellyfin.Plugin.MetaShark.Providers
var remoteImages = new List<RemoteImageInfo>();
for (var i = 0; i < movie.Images.Posters.Count; i++)
{
var poster = movie.Images.Posters[i];
remoteImages.Add(new RemoteImageInfo
{
Url = _tmdbApi.GetPosterUrl(poster.FilePath),
CommunityRating = poster.VoteAverage,
VoteCount = poster.VoteCount,
Width = poster.Width,
Height = poster.Height,
ProviderName = Name,
remoteImages.AddRange(movie.Images.Posters.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetPosterUrl(x.FilePath),
Type = ImageType.Primary,
});
}
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
for (var i = 0; i < movie.Images.Backdrops.Count; i++)
{
var backdrop = movie.Images.Backdrops[i];
remoteImages.Add(new RemoteImageInfo
{
Url = _tmdbApi.GetPosterUrl(backdrop.FilePath),
CommunityRating = backdrop.VoteAverage,
VoteCount = backdrop.VoteCount,
Width = backdrop.Width,
Height = backdrop.Height,
ProviderName = Name,
remoteImages.AddRange(movie.Images.Backdrops.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetBackdropUrl(x.FilePath),
Type = ImageType.Backdrop,
RatingType = RatingType.Score
});
}
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
remoteImages.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetLogoUrl(x.FilePath),
Type = ImageType.Logo,
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
return remoteImages.OrderByLanguageDescending(language);
}
this.Log($"Got images failed because the sid of \"{item.Name}\" is empty!");
this.Log($"Got images failed because the images of \"{item.Name}\" is empty!");
return new List<RemoteImageInfo>();
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken);
}
/// <summary>
/// Query for a background photo
/// </summary>
/// <param name="sid">a subject/movie id</param>
/// <param name="cancellationToken">Instance of the <see cref="CancellationToken"/> interface.</param>
private async Task<IEnumerable<RemoteImageInfo>> GetBackdrop(string sid, CancellationToken cancellationToken)
private async Task<IEnumerable<RemoteImageInfo>> GetBackdrop(BaseItem item, CancellationToken cancellationToken)
{
this.Log("GetBackdrop of sid: {0}", sid);
var photo = await this._doubanApi.GetWallpaperBySidAsync(sid, cancellationToken);
var sid = item.GetProviderId(DoubanProviderId);
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
var list = new List<RemoteImageInfo>();
if (photo == null)
// 从豆瓣获取背景图
if (!string.IsNullOrEmpty(sid))
{
return list;
}
return photo.Where(x => x.Width > x.Height * 1.3).Select(x =>
var photo = await this._doubanApi.GetWallpaperBySidAsync(sid, cancellationToken);
if (photo != null && photo.Count > 0)
{
this.Log("GetBackdrop from douban sid: {0}", sid);
list = photo.Where(x => x.Width >= 1280 && x.Width <= 4096 && x.Width > x.Height * 1.3).Select(x =>
{
if (config.EnableDoubanBackdropRaw)
{
return new RemoteImageInfo
{
ProviderName = Name,
Url = x.Large,
Url = this.GetProxyImageUrl(x.Raw),
Height = x.Height,
Width = x.Width,
Type = ImageType.Backdrop,
};
}
else
{
return new RemoteImageInfo
{
ProviderName = Name,
Url = this.GetProxyImageUrl(x.Large),
Type = ImageType.Backdrop,
};
}
}).ToList();
}
}
// 背景图缺失从TheMovieDb补充背景图
if (list.Count == 0 && config.EnableTmdbBackdrop && !string.IsNullOrEmpty(tmdbId))
{
var language = item.GetPreferredMetadataLanguage();
var movie = await _tmdbApi
.GetSeriesAsync(tmdbId.ToInt(), language, language, cancellationToken)
.ConfigureAwait(false);
if (movie != null && !string.IsNullOrEmpty(movie.BackdropPath))
{
this.Log("GetBackdrop from tmdb id: {0}", tmdbId);
list.Add(new RemoteImageInfo
{
ProviderName = this.Name,
Url = this._tmdbApi.GetBackdropUrl(movie.BackdropPath),
Type = ImageType.Backdrop,
});
}
}
return list;
}
private async Task<IEnumerable<RemoteImageInfo>> GetLogos(BaseItem item, CancellationToken cancellationToken)
{
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
var language = item.GetPreferredMetadataLanguage();
var list = new List<RemoteImageInfo>();
if (this.config.EnableTmdbLogo && !string.IsNullOrEmpty(tmdbId))
{
this.Log("GetLogos from tmdb id: {0} lang: {1}", tmdbId, language);
var movie = await this._tmdbApi
.GetSeriesAsync(tmdbId.ToInt(), language, language, cancellationToken)
.ConfigureAwait(false);
if (movie != null && movie.Images != null)
{
list.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo {
ProviderName = this.Name,
Url = this._tmdbApi.GetLogoUrl(x.FilePath),
Type = ImageType.Logo,
CommunityRating = x.VoteAverage,
VoteCount = x.VoteCount,
Width = x.Width,
Height = x.Height,
Language = this.AdjustImageLanguage(x.Iso_639_1, language),
RatingType = RatingType.Score,
}));
}
}
return list.OrderByLanguageDescending(language);
}
}
}

View File

@ -1,5 +1,5 @@
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
@ -7,16 +7,15 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Find;
using TMDbLib.Objects.TvShows;
using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider;
@ -24,8 +23,8 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
public class SeriesProvider : BaseProvider, IRemoteMetadataProvider<Series, SeriesInfo>
{
public SeriesProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, doubanApi, tmdbApi, omdbApi)
public SeriesProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi)
: base(httpClientFactory, loggerFactory.CreateLogger<SeriesProvider>(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi)
{
}
@ -43,13 +42,13 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
// 从douban搜索
var res = await this._doubanApi.SearchAsync(info.Name, cancellationToken).ConfigureAwait(false);
result.AddRange(res.Take(this._config.MaxSearchResult).Select(x =>
var res = await this._doubanApi.SearchTVAsync(info.Name, cancellationToken).ConfigureAwait(false);
result.AddRange(res.Take(Configuration.PluginConfiguration.MAX_SEARCH_RESULT).Select(x =>
{
return new RemoteSearchResult
{
SearchProviderName = DoubanProviderName,
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, x.Sid } },
// 这里 Plugin.ProviderId 的值做这么复杂,是为了和电影保持一致并唯一
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, x.Sid }, { Plugin.ProviderId, $"{MetaSource.Douban}_{x.Sid}" } },
ImageUrl = this.GetProxyImageUrl(x.Img),
ProductionYear = x.Year,
Name = x.Name,
@ -57,19 +56,22 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}));
// 尝试从tmdb搜索
if (this.config.EnableTmdbSearch)
{
var tmdbList = await this._tmdbApi.SearchSeriesAsync(info.Name, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
result.AddRange(tmdbList.Take(this._config.MaxSearchResult).Select(x =>
result.AddRange(tmdbList.Take(Configuration.PluginConfiguration.MAX_SEARCH_RESULT).Select(x =>
{
return new RemoteSearchResult
{
SearchProviderName = TmdbProviderName,
ProviderIds = new Dictionary<string, string> { { MetadataProvider.Tmdb.ToString(), x.Id.ToString(CultureInfo.InvariantCulture) } },
Name = x.Name ?? x.OriginalName,
// 这里 Plugin.ProviderId 的值做这么复杂,是为了和电影保持一致并唯一
ProviderIds = new Dictionary<string, string> { { MetadataProvider.Tmdb.ToString(), x.Id.ToString(CultureInfo.InvariantCulture) }, { Plugin.ProviderId, $"{MetaSource.Tmdb}_{x.Id}" } },
Name = string.Format("[TMDB]{0}", x.Name ?? x.OriginalName),
ImageUrl = this._tmdbApi.GetPosterUrl(x.PosterPath),
Overview = x.Overview,
ProductionYear = x.FirstAirDate?.Year,
};
}));
}
return result;
}
@ -77,26 +79,25 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// <inheritdoc />
public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken)
{
this.Log($"GetSeriesMetadata of [name]: {info.Name} [providerIds]: {info.ProviderIds.ToJson()}");
info.Name = this.RemoveMetaSourcePrefix(info.Name);
var fileName = this.GetOriginalFileName(info);
this.Log($"GetSeriesMetadata of [name]: {info.Name} [fileName]: {fileName} IsAutomated: {info.IsAutomated}");
var result = new MetadataResult<Series>();
var sid = info.GetProviderId(DoubanProviderId);
var tmdbId = info.GetProviderId(MetadataProvider.Tmdb);
var metaSource = info.GetProviderId(Plugin.ProviderId);
if (string.IsNullOrEmpty(sid) && string.IsNullOrEmpty(tmdbId))
var metaSource = info.GetMetaSource(Plugin.ProviderId);
// 注意会存在元数据有tmdbId但metaSource没值的情况之前由TMDB插件刮削导致
var hasTmdbMeta = metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId);
var hasDoubanMeta = metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid);
if (!hasDoubanMeta && !hasTmdbMeta)
{
// 刷新元数据自动匹配搜索
sid = await this.GuestByDoubanAsync(info, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(sid))
{
tmdbId = await this.GuestByTmdbAsync(info, cancellationToken).ConfigureAwait(false);
}
// 自动扫描搜索匹配元数据
sid = await this.GuessByDoubanAsync(info, cancellationToken).ConfigureAwait(false);
}
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
{
this.Log($"GetSeriesMetadata of douban [sid]: \"{sid}\"");
this.Log($"GetSeriesMetadata of douban [sid]: {sid}");
var subject = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (subject == null)
{
@ -104,33 +105,44 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
subject.Celebrities = await this._doubanApi.GetCelebritiesBySidAsync(sid, cancellationToken).ConfigureAwait(false);
var seriesName = RemoveSeasonSuffix(subject.Name);
var item = new Series
{
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, subject.Sid }, { Plugin.ProviderId, MetaSource.Douban } },
Name = subject.Name,
OriginalTitle = subject.OriginalName,
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, subject.Sid }, { Plugin.ProviderId, $"{MetaSource.Douban}_{subject.Sid}" } },
Name = seriesName,
OriginalTitle = RemoveSeasonSuffix(subject.OriginalName),
CommunityRating = subject.Rating,
Overview = subject.Intro,
ProductionYear = subject.Year,
HomePageUrl = "https://www.douban.com",
Genres = subject.Genres,
// ProductionLocations = [x?.Country],
PremiereDate = subject.ScreenTime,
Tagline = string.Empty,
};
// 设置imdb元数据
if (!string.IsNullOrEmpty(subject.Imdb))
{
item.SetProviderId(MetadataProvider.Imdb, subject.Imdb);
// 通过imdb获取TMDB id (豆瓣的imdb id可能是旧的需要先从omdb接口获取最新的imdb id
var omdbItem = await this._omdbApi.GetByImdbID(subject.Imdb, cancellationToken).ConfigureAwait(false);
if (omdbItem != null)
{
var findResult = await this._tmdbApi.FindByExternalIdAsync(omdbItem.ImdbID, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
if (findResult?.TvResults != null && findResult.TvResults.Count > 0)
{
this.Log($"GetSeriesMetadata found tmdb [id]: {findResult.TvResults[0].Id} by imdb id: {subject.Imdb}");
item.SetProviderId(MetadataProvider.Tmdb, $"{findResult.TvResults[0].Id}");
var newImdbId = await this.CheckNewImdbID(subject.Imdb, cancellationToken).ConfigureAwait(false);
subject.Imdb = newImdbId;
item.SetProviderId(MetadataProvider.Imdb, newImdbId);
}
// 搜索匹配tmdbId
var newTmdbId = await this.FindTmdbId(seriesName, subject.Imdb, subject.Year, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(newTmdbId))
{
tmdbId = newTmdbId;
item.SetProviderId(MetadataProvider.Tmdb, tmdbId);
}
// 通过imdb获取电影分级信息
if (this.config.EnableTmdbOfficialRating && !string.IsNullOrEmpty(tmdbId))
{
var officialRating = await this.GetTmdbOfficialRating(info, tmdbId, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(officialRating))
{
item.OfficialRating = officialRating;
}
}
@ -138,21 +150,35 @@ namespace Jellyfin.Plugin.MetaShark.Providers
result.Item = item;
result.QueriedById = true;
result.HasMetadata = true;
subject.Celebrities.ForEach(c => result.AddPerson(new PersonInfo
subject.LimitDirectorCelebrities.Take(Configuration.PluginConfiguration.MAX_CAST_MEMBERS).ToList().ForEach(c => result.AddPerson(new PersonInfo
{
Name = c.Name,
Type = c.RoleType,
Type = c.RoleType == PersonType.Director ? PersonKind.Director : PersonKind.Actor,
Role = c.Role,
ImageUrl = c.Img,
ImageUrl = this.GetLocalProxyImageUrl(c.Img),
ProviderIds = new Dictionary<string, string> { { DoubanProviderId, c.Id } },
}));
return result;
}
if (!string.IsNullOrEmpty(tmdbId))
if (metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId))
{
return await this.GetMetadataByTmdb(tmdbId, info, cancellationToken).ConfigureAwait(false);
}
this.Log($"匹配失败!可检查下年份是否与豆瓣一致,是否需要登录访问. [name]: {info.Name} [year]: {info.Year}");
return result;
}
private async Task<MetadataResult<Series>> GetMetadataByTmdb(string? tmdbId, ItemLookupInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Series>();
if (string.IsNullOrEmpty(tmdbId))
{
return result;
}
this.Log($"GetSeriesMetadata of tmdb [id]: \"{tmdbId}\"");
var tvShow = await _tmdbApi
.GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
@ -179,7 +205,73 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return result;
}
return result;
private async Task<string?> FindTmdbId(string name, string imdb, int? year, ItemLookupInfo info, CancellationToken cancellationToken)
{
// 通过imdb获取TMDB id
if (!string.IsNullOrEmpty(imdb))
{
var tmdbId = await this.GetTmdbIdByImdbAsync(imdb, info.MetadataLanguage, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(tmdbId))
{
return tmdbId;
}
else
{
this.Log($"Can not found tmdb [id] by imdb id: \"{imdb}\"");
}
}
// 尝试通过搜索匹配获取tmdbId
if (!string.IsNullOrEmpty(name) && year != null && year > 0)
{
var tmdbId = await this.GuestByTmdbAsync(name, year, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(tmdbId))
{
return tmdbId;
}
else
{
this.Log($"Can not found tmdb [id] by name: \"{name}\" and year: \"{year}\"");
}
}
return null;
}
private async Task<String?> GetTmdbOfficialRating(ItemLookupInfo info, string tmdbId, CancellationToken cancellationToken)
{
var tvShow = await _tmdbApi
.GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
return this.GetTmdbOfficialRatingByData(tvShow, info.MetadataCountryCode);
}
private String GetTmdbOfficialRatingByData(TvShow? tvShow, string preferredCountryCode)
{
if (tvShow != null)
{
var contentRatings = tvShow.ContentRatings.Results ?? new List<ContentRating>();
var ourRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, preferredCountryCode, StringComparison.OrdinalIgnoreCase));
var usRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase));
var minimumRelease = contentRatings.FirstOrDefault();
if (ourRelease != null)
{
return ourRelease.Rating;
}
else if (usRelease != null)
{
return usRelease.Rating;
}
else if (minimumRelease != null)
{
return minimumRelease.Rating;
}
}
return null;
}
private Series MapTvShowToSeries(TvShow seriesResult, string preferredCountryCode)
@ -192,7 +284,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
series.SetProviderId(MetadataProvider.Tmdb, seriesResult.Id.ToString(CultureInfo.InvariantCulture));
series.CommunityRating = Convert.ToSingle(seriesResult.VoteAverage);
series.CommunityRating = (float)System.Math.Round(seriesResult.VoteAverage, 2);
series.Overview = seriesResult.Overview;
@ -247,25 +339,9 @@ namespace Jellyfin.Plugin.MetaShark.Providers
series.SetProviderId(MetadataProvider.Tvdb, ids.TvdbId);
}
}
series.SetProviderId(Plugin.ProviderId, MetaSource.Tmdb);
var contentRatings = seriesResult.ContentRatings.Results ?? new List<ContentRating>();
series.SetProviderId(Plugin.ProviderId, $"{MetaSource.Tmdb}_{seriesResult.Id}");
series.OfficialRating = this.GetTmdbOfficialRatingByData(seriesResult, preferredCountryCode);
var ourRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, preferredCountryCode, StringComparison.OrdinalIgnoreCase));
var usRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase));
var minimumRelease = contentRatings.FirstOrDefault();
if (ourRelease != null)
{
series.OfficialRating = ourRelease.Rating;
}
else if (usRelease != null)
{
series.OfficialRating = usRelease.Rating;
}
else if (minimumRelease != null)
{
series.OfficialRating = minimumRelease.Rating;
}
return series;
}
@ -275,13 +351,13 @@ namespace Jellyfin.Plugin.MetaShark.Providers
// 演员
if (seriesResult.Credits?.Cast != null)
{
foreach (var actor in seriesResult.Credits.Cast.OrderBy(a => a.Order).Take(this._config.MaxCastMembers))
foreach (var actor in seriesResult.Credits.Cast.OrderBy(a => a.Order).Take(Configuration.PluginConfiguration.MAX_CAST_MEMBERS))
{
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
Role = actor.Character,
Type = PersonType.Actor,
Type = PersonKind.Actor,
SortOrder = actor.Order,
};
@ -325,7 +401,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
Name = person.Name.Trim(),
Role = person.Job,
Type = type
Type = type == PersonType.Director ? PersonKind.Director : (type == PersonType.Producer ? PersonKind.Producer : PersonKind.Actor),
};
if (!string.IsNullOrWhiteSpace(person.ProfilePath))
@ -344,17 +420,5 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken);
}
private void Log(string? message, params object?[] args)
{
this._logger.LogInformation($"[MetaShark] {message}", args);
}
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Model.Tasks;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using Jellyfin.Plugin.MetaShark.Core;
using Jellyfin.Plugin.MetaShark.Providers;
using Jellyfin.Plugin.MetaShark.Model;
namespace Jellyfin.Plugin.MetaShark.ScheduledTasks
{
public class FixMovieSimilarListTask : IScheduledTask
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger _logger;
public string Key => $"{Plugin.PluginName}FixMovieSimilarList";
public string Name => "修复电影推荐列表";
public string Description => $"修复电影推荐列表只有一部影片的问题。";
public string Category => Plugin.PluginName;
/// <summary>
/// Initializes a new instance of the <see cref="FixMovieSimilarListTask"/> class.
/// </summary>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public FixMovieSimilarListTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager)
{
_logger = loggerFactory.CreateLogger<FixMovieSimilarListTask>();
_libraryManager = libraryManager;
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new List<TaskTriggerInfo>();
// yield return new TaskTriggerInfo
// {
// Type = TaskTriggerInfo.TriggerWeekly,
// DayOfWeek = DayOfWeek.Monday,
// TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
// };
}
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
await Task.Yield();
progress?.Report(0);
// 只有电影有问题
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
// MediaTypes = new[] { MediaType.Video },
HasAnyProviderId = new Dictionary<string, string>() { { Plugin.ProviderId, string.Empty } },
IncludeItemTypes = new[] { BaseItemKind.Movie }
}).ToList();
_logger.LogInformation("Fix movie similar list for {0} videos.", items.Count);
var successCount = 0;
var failCount = 0;
foreach (var (item, idx) in items.WithIndex())
{
cancellationToken.ThrowIfCancellationRequested();
progress?.Report((double)idx / items.Count * 100);
try
{
// 判断电影带有旧的元数据,进行替换处理
var sid = item.GetProviderId(BaseProvider.DoubanProviderId);
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
var providerVal = item.GetProviderId(Plugin.ProviderId);
if (providerVal == "douban" && !string.IsNullOrEmpty(sid))
{
var detail = this._libraryManager.GetItemById(item.Id);
detail.SetProviderId(Plugin.ProviderId, $"{MetaSource.Douban}_{sid}");
await detail.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
if (providerVal == "tmdb" && !string.IsNullOrEmpty(tmdbId))
{
var detail = this._libraryManager.GetItemById(item.Id);
detail.SetProviderId(Plugin.ProviderId, $"{MetaSource.Tmdb}_{tmdbId}");
await detail.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
successCount++;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fix movie similar list for movie {0}: {1}", item.Name, ex.Message);
failCount++;
}
}
progress?.Report(100);
_logger.LogInformation("Exectue task completed. success: {0} fail: {1}", successCount, failCount);
}
}
}

View File

@ -1,18 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.MetaShark.Api;
using Jellyfin.Plugin.MetaShark.Providers;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MediaBrowser.Controller.Persistence;
using System.Net.Http;
namespace Jellyfin.Plugin.MetaShark
{
@ -20,20 +10,25 @@ namespace Jellyfin.Plugin.MetaShark
public class ServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection)
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<DoubanApi>((ctx) =>
serviceCollection.AddSingleton((ctx) =>
{
return new DoubanApi(ctx.GetRequiredService<ILoggerFactory>());
});
serviceCollection.AddSingleton<TmdbApi>((ctx) =>
serviceCollection.AddSingleton((ctx) =>
{
return new TmdbApi(ctx.GetRequiredService<ILoggerFactory>());
});
serviceCollection.AddSingleton<OmdbApi>((ctx) =>
serviceCollection.AddSingleton((ctx) =>
{
return new OmdbApi(ctx.GetRequiredService<ILoggerFactory>());
});
serviceCollection.AddSingleton((ctx) =>
{
return new ImdbApi(ctx.GetRequiredService<ILoggerFactory>());
});
}
}
}

View File

@ -1,4 +0,0 @@
[*.cs]
# CAC001: ConfigureAwaitChecker
dotnet_diagnostic.CAC001.severity = error

View File

@ -1,291 +0,0 @@
using System;
using TMDbLib.Objects.Account;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.General;
using ParameterType = TMDbLib.Rest.ParameterType;
using RestClient = TMDbLib.Rest.RestClient;
using RestRequest = TMDbLib.Rest.RestRequest;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Rest;
using TMDbLib.Utilities.Serializer;
namespace TMDbLib.Client
{
public partial class TMDbClient : IDisposable
{
private const string ApiVersion = "3";
private const string ProductionUrl = "api.themoviedb.org";
private readonly ITMDbSerializer _serializer;
private RestClient _client;
private TMDbConfig _config;
public TMDbClient(string apiKey, bool useSsl = true, string baseUrl = ProductionUrl, ITMDbSerializer serializer = null, IWebProxy proxy = null)
{
DefaultLanguage = null;
DefaultImageLanguage = null;
DefaultCountry = null;
_serializer = serializer ?? TMDbJsonSerializer.Instance;
//Setup proxy to use during requests
//Proxy is optional. If passed, will be used in every request.
WebProxy = proxy;
Initialize(baseUrl, useSsl, apiKey);
}
/// <summary>
/// The account details of the user account associated with the current user session
/// </summary>
/// <remarks>This value is automaticly populated when setting a user session</remarks>
public AccountDetails ActiveAccount { get; private set; }
public string ApiKey { get; private set; }
public TMDbConfig Config
{
get
{
if (!HasConfig)
throw new InvalidOperationException("Call GetConfig() or SetConfig() first");
return _config;
}
private set { _config = value; }
}
/// <summary>
/// ISO 3166-1 code. Ex. US
/// </summary>
public string DefaultCountry { get; set; }
/// <summary>
/// ISO 639-1 code. Ex en
/// </summary>
public string DefaultLanguage { get; set; }
/// <summary>
/// ISO 639-1 code. Ex en
/// </summary>
public string DefaultImageLanguage { get; set; }
public bool HasConfig { get; private set; }
/// <summary>
/// Throw exceptions when TMDbs API returns certain errors, such as Not Found.
/// </summary>
public bool ThrowApiExceptions
{
get => _client.ThrowApiExceptions;
set => _client.ThrowApiExceptions = value;
}
/// <summary>
/// The maximum number of times a call to TMDb will be retried
/// </summary>
/// <remarks>Default is 0</remarks>
public int MaxRetryCount
{
get => _client.MaxRetryCount;
set => _client.MaxRetryCount = value;
}
/// <summary>
/// The request timeout call to TMDb
/// </summary>
public TimeSpan RequestTimeout
{
get => _client.HttpClient.Timeout;
set => _client.HttpClient.Timeout = value;
}
/// <summary>
/// The session id that will be used when TMDb requires authentication
/// </summary>
/// <remarks>Use 'SetSessionInformation' to assign this value</remarks>
public string SessionId { get; private set; }
/// <summary>
/// The type of the session id, this will determine the level of access that is granted on the API
/// </summary>
/// <remarks>Use 'SetSessionInformation' to assign this value</remarks>
public SessionType SessionType { get; private set; }
/// <summary>
/// Gets or sets the Web Proxy to use during requests to TMDb API.
/// </summary>
/// <remarks>
/// The Web Proxy is optional. If set, every request will be sent through it.
/// Use the constructor for setting it.
///
/// For convenience, this library also offers a <see cref="IWebProxy"/> implementation.
/// Check <see cref="Utilities.TMDbAPIProxy"/> for more information.
/// </remarks>
public IWebProxy WebProxy { get; private set; }
/// <summary>
/// Used internally to assign a session id to a request. If no valid session is found, an exception is thrown.
/// </summary>
/// <param name="req">Request</param>
/// <param name="targetType">The target session type to set. If set to Unassigned, the method will take the currently set session.</param>
/// <param name="parameterType">The location of the paramter in the resulting query</param>
private void AddSessionId(RestRequest req, SessionType targetType = SessionType.Unassigned, ParameterType parameterType = ParameterType.QueryString)
{
if ((targetType == SessionType.Unassigned && SessionType == SessionType.GuestSession) ||
(targetType == SessionType.GuestSession))
{
// Either
// - We needed ANY session ID and had a Guest session id
// - We needed a Guest session id and had it
req.AddParameter("guest_session_id", SessionId, parameterType);
return;
}
if ((targetType == SessionType.Unassigned && SessionType == SessionType.UserSession) ||
(targetType == SessionType.UserSession))
{
// Either
// - We needed ANY session ID and had a User session id
// - We needed a User session id and had it
req.AddParameter("session_id", SessionId, parameterType);
return;
}
// We did not have the required session type ready
throw new UserSessionRequiredException();
}
public async Task<TMDbConfig> GetConfigAsync()
{
TMDbConfig config = await _client.Create("configuration").GetOfT<TMDbConfig>(CancellationToken.None).ConfigureAwait(false);
if (config == null)
throw new Exception("Unable to retrieve configuration");
// Store config
Config = config;
HasConfig = true;
return config;
}
public Uri GetImageUrl(string size, string filePath, bool useSsl = false)
{
string baseUrl = useSsl ? Config.Images.SecureBaseUrl : Config.Images.BaseUrl;
return new Uri(baseUrl + size + filePath);
}
[Obsolete("Use " + nameof(GetImageBytesAsync))]
public Task<byte[]> GetImageBytes(string size, string filePath, bool useSsl = false, CancellationToken token = default)
{
return GetImageBytesAsync(size, filePath, useSsl, token);
}
public async Task<byte[]> GetImageBytesAsync(string size, string filePath, bool useSsl = false, CancellationToken token = default)
{
Uri url = GetImageUrl(size, filePath, useSsl);
using HttpResponseMessage response = await _client.HttpClient.GetAsync(url, HttpCompletionOption.ResponseContentRead, token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP003:Dispose previous before re-assigning.", Justification = "Only called from ctor")]
private void Initialize(string baseUrl, bool useSsl, string apiKey)
{
if (string.IsNullOrWhiteSpace(baseUrl))
throw new ArgumentException("baseUrl");
if (string.IsNullOrWhiteSpace(apiKey))
throw new ArgumentException("apiKey");
ApiKey = apiKey;
// Cleanup the provided url so that we don't get any issues when we are configuring the client
if (baseUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
baseUrl = baseUrl.Substring("http://".Length);
else if (baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
baseUrl = baseUrl.Substring("https://".Length);
string httpScheme = useSsl ? "https" : "http";
_client = new RestClient(new Uri(string.Format("{0}://{1}/{2}/", httpScheme, baseUrl, ApiVersion)), _serializer, WebProxy);
_client.AddDefaultQueryString("api_key", apiKey);
}
/// <summary>
/// Used internally to determine if the current client has the required session set, if not an appropriate exception will be thrown
/// </summary>
/// <param name="sessionType">The type of session that is required by the calling method</param>
/// <exception cref="UserSessionRequiredException">Thrown if the calling method requires a user session and one isn't set on the client object</exception>
/// <exception cref="GuestSessionRequiredException">Thrown if the calling method requires a guest session and no session is set on the client object. (neither user or client type session)</exception>
private void RequireSessionId(SessionType sessionType)
{
if (string.IsNullOrWhiteSpace(SessionId))
{
if (sessionType == SessionType.GuestSession)
throw new UserSessionRequiredException();
else
throw new GuestSessionRequiredException();
}
if (sessionType == SessionType.UserSession && SessionType == SessionType.GuestSession)
throw new UserSessionRequiredException();
}
public void SetConfig(TMDbConfig config)
{
// Store config
Config = config;
HasConfig = true;
}
/// <summary>
/// Use this method to set the current client's authentication information.
/// The session id assigned here will be used by the client when ever TMDb requires it.
/// </summary>
/// <param name="sessionId">The session id to use when making calls that require authentication</param>
/// <param name="sessionType">The type of session id</param>
/// <remarks>
/// - Use the 'AuthenticationGetUserSessionAsync' and 'AuthenticationCreateGuestSessionAsync' methods to optain the respective session ids.
/// - User sessions have access to far for methods than guest sessions, these can currently only be used to rate media.
/// </remarks>
public async Task SetSessionInformationAsync(string sessionId, SessionType sessionType)
{
ActiveAccount = null;
SessionId = sessionId;
if (!string.IsNullOrWhiteSpace(sessionId) && sessionType == SessionType.Unassigned)
{
throw new ArgumentException("When setting the session id it must always be either a guest or user session");
}
SessionType = string.IsNullOrWhiteSpace(sessionId) ? SessionType.Unassigned : sessionType;
// Populate the related account information
if (sessionType == SessionType.UserSession)
{
try
{
ActiveAccount = await AccountGetDetailsAsync().ConfigureAwait(false);
}
catch (Exception)
{
// Unable to complete the full process so reset all values and throw the exception
ActiveAccount = null;
SessionId = null;
SessionType = SessionType.Unassigned;
throw;
}
}
}
public virtual void Dispose()
{
_client?.Dispose();
}
}
}

View File

@ -1,257 +0,0 @@
using TMDbLib.Utilities;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Account;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Lists;
using TMDbLib.Objects.Search;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<SearchContainer<T>> GetAccountListInternal<T>(int page, AccountSortBy sortBy, SortOrder sortOrder, string language, AccountListsMethods method, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest request = _client.Create("account/{accountId}/" + method.GetDescription());
request.AddUrlSegment("accountId", ActiveAccount.Id.ToString(CultureInfo.InvariantCulture));
AddSessionId(request, SessionType.UserSession);
if (page > 1)
request.AddParameter("page", page.ToString());
if (sortBy != AccountSortBy.Undefined)
request.AddParameter("sort_by", sortBy.GetDescription());
if (sortOrder != SortOrder.Undefined)
request.AddParameter("sort_order", sortOrder.GetDescription());
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
request.AddParameter("language", language);
SearchContainer<T> response = await request.GetOfT<SearchContainer<T>>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Change the favorite status of a specific movie. Either make the movie a favorite or remove that status depending on the supplied boolean value.
/// </summary>
/// <param name="mediaType">The type of media to influence</param>
/// <param name="mediaId">The id of the movie/tv show to influence</param>
/// <param name="isFavorite">True if you want the specified movie to be marked as favorite, false if not</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the the movie's favorite status was successfully updated, false if not</returns>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<bool> AccountChangeFavoriteStatusAsync(MediaType mediaType, int mediaId, bool isFavorite, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest request = _client.Create("account/{accountId}/favorite");
request.AddUrlSegment("accountId", ActiveAccount.Id.ToString(CultureInfo.InvariantCulture));
request.SetBody(new { media_type = mediaType.GetDescription(), media_id = mediaId, favorite = isFavorite });
AddSessionId(request, SessionType.UserSession);
PostReply response = await request.PostOfT<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 1 = "Success" - Returned when adding a movie as favorite for the first time
// status code 13 = "The item/record was deleted successfully" - When removing an item as favorite, no matter if it exists or not
// status code 12 = "The item/record was updated successfully" - Used when an item is already marked as favorite and trying to do so doing again
return response.StatusCode == 1 || response.StatusCode == 12 || response.StatusCode == 13;
}
/// <summary>
/// Change the state of a specific movie on the users watchlist. Either add the movie to the list or remove it, depending on the specified boolean value.
/// </summary>
/// <param name="mediaType">The type of media to influence</param>
/// <param name="mediaId">The id of the movie/tv show to influence</param>
/// <param name="isOnWatchlist">True if you want the specified movie to be part of the watchlist, false if not</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>True if the the movie's status on the watchlist was successfully updated, false if not</returns>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<bool> AccountChangeWatchlistStatusAsync(MediaType mediaType, int mediaId, bool isOnWatchlist, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest request = _client.Create("account/{accountId}/watchlist");
request.AddUrlSegment("accountId", ActiveAccount.Id.ToString(CultureInfo.InvariantCulture));
request.SetBody(new { media_type = mediaType.GetDescription(), media_id = mediaId, watchlist = isOnWatchlist });
AddSessionId(request, SessionType.UserSession);
PostReply response = await request.PostOfT<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 1 = "Success"
// status code 13 = "The item/record was deleted successfully" - When removing an item from the watchlist, no matter if it exists or not
// status code 12 = "The item/record was updated successfully" - Used when an item is already on the watchlist and trying to add it again
return response.StatusCode == 1 || response.StatusCode == 12 || response.StatusCode == 13;
}
/// <summary>
/// Will retrieve the details of the account associated with the current session id
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<AccountDetails> AccountGetDetailsAsync(CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest request = _client.Create("account");
AddSessionId(request, SessionType.UserSession);
AccountDetails response = await request.GetOfT<AccountDetails>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Get a list of all the movies marked as favorite by the current user
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<SearchMovie>> AccountGetFavoriteMoviesAsync(
int page = 1,
AccountSortBy sortBy = AccountSortBy.Undefined,
SortOrder sortOrder = SortOrder.Undefined,
string language = null, CancellationToken cancellationToken = default)
{
return await GetAccountListInternal<SearchMovie>(page, sortBy, sortOrder, language, AccountListsMethods.FavoriteMovies, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get a list of all the tv shows marked as favorite by the current user
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<SearchTv>> AccountGetFavoriteTvAsync(
int page = 1,
AccountSortBy sortBy = AccountSortBy.Undefined,
SortOrder sortOrder = SortOrder.Undefined,
string language = null, CancellationToken cancellationToken = default)
{
return await GetAccountListInternal<SearchTv>(page, sortBy, sortOrder, language, AccountListsMethods.FavoriteTv, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Retrieve all lists associated with the provided account id
/// This can be lists that were created by the user or lists marked as favorite
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<AccountList>> AccountGetListsAsync(int page = 1, string language = null, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest request = _client.Create("account/{accountId}/lists");
request.AddUrlSegment("accountId", ActiveAccount.Id.ToString(CultureInfo.InvariantCulture));
AddSessionId(request, SessionType.UserSession);
if (page > 1)
{
request.AddQueryString("page", page.ToString());
}
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
request.AddQueryString("language", language);
SearchContainer<AccountList> response = await request.GetOfT<SearchContainer<AccountList>>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Get a list of all the movies on the current users match list
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<SearchMovie>> AccountGetMovieWatchlistAsync(
int page = 1,
AccountSortBy sortBy = AccountSortBy.Undefined,
SortOrder sortOrder = SortOrder.Undefined,
string language = null, CancellationToken cancellationToken = default)
{
return await GetAccountListInternal<SearchMovie>(page, sortBy, sortOrder, language, AccountListsMethods.MovieWatchlist, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get a list of all the movies rated by the current user
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<SearchMovieWithRating>> AccountGetRatedMoviesAsync(
int page = 1,
AccountSortBy sortBy = AccountSortBy.Undefined,
SortOrder sortOrder = SortOrder.Undefined,
string language = null, CancellationToken cancellationToken = default)
{
return await GetAccountListInternal<SearchMovieWithRating>(page, sortBy, sortOrder, language, AccountListsMethods.RatedMovies, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get a list of all the tv show episodes rated by the current user
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<AccountSearchTvEpisode>> AccountGetRatedTvShowEpisodesAsync(
int page = 1,
AccountSortBy sortBy = AccountSortBy.Undefined,
SortOrder sortOrder = SortOrder.Undefined,
string language = null, CancellationToken cancellationToken = default)
{
return await GetAccountListInternal<AccountSearchTvEpisode>(page, sortBy, sortOrder, language, AccountListsMethods.RatedTvEpisodes, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get a list of all the tv shows rated by the current user
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<AccountSearchTv>> AccountGetRatedTvShowsAsync(
int page = 1,
AccountSortBy sortBy = AccountSortBy.Undefined,
SortOrder sortOrder = SortOrder.Undefined,
string language = null, CancellationToken cancellationToken = default)
{
return await GetAccountListInternal<AccountSearchTv>(page, sortBy, sortOrder, language, AccountListsMethods.RatedTv, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get a list of all the tv shows on the current users match list
/// </summary>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<SearchContainer<SearchTv>> AccountGetTvWatchlistAsync(
int page = 1,
AccountSortBy sortBy = AccountSortBy.Undefined,
SortOrder sortOrder = SortOrder.Undefined,
string language = null, CancellationToken cancellationToken = default)
{
return await GetAccountListInternal<SearchTv>(page, sortBy, sortOrder, language, AccountListsMethods.TvWatchlist, cancellationToken).ConfigureAwait(false);
}
private enum AccountListsMethods
{
[EnumValue("favorite/movies")]
FavoriteMovies,
[EnumValue("favorite/tv")]
FavoriteTv,
[EnumValue("rated/movies")]
RatedMovies,
[EnumValue("rated/tv")]
RatedTv,
[EnumValue("rated/tv/episodes")]
RatedTvEpisodes,
[EnumValue("watchlist/movies")]
MovieWatchlist,
[EnumValue("watchlist/tv")]
TvWatchlist,
}
}
}

View File

@ -1,87 +0,0 @@
using System;
using System.Threading.Tasks;
using System.Net;
using System.Threading;
using TMDbLib.Objects.Authentication;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<GuestSession> AuthenticationCreateGuestSessionAsync(CancellationToken cancellationToken = default)
{
RestRequest request = _client.Create("authentication/guest_session/new");
//{
// DateFormat = "yyyy-MM-dd HH:mm:ss UTC"
//};
GuestSession response = await request.GetOfT<GuestSession>(cancellationToken).ConfigureAwait(false);
return response;
}
public async Task<UserSession> AuthenticationGetUserSessionAsync(string initialRequestToken, CancellationToken cancellationToken = default)
{
RestRequest request = _client.Create("authentication/session/new");
request.AddParameter("request_token", initialRequestToken);
using RestResponse<UserSession> response = await request.Get<UserSession>(cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new UnauthorizedAccessException();
return await response.GetDataObject().ConfigureAwait(false);
}
/// <summary>
/// Conveniance method combining 'AuthenticationRequestAutenticationTokenAsync', 'AuthenticationValidateUserTokenAsync' and 'AuthenticationGetUserSessionAsync'
/// </summary>
/// <param name="username">A valid TMDb username</param>
/// <param name="password">The passoword for the provided login</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<UserSession> AuthenticationGetUserSessionAsync(string username, string password, CancellationToken cancellationToken = default)
{
Token token = await AuthenticationRequestAutenticationTokenAsync(cancellationToken).ConfigureAwait(false);
await AuthenticationValidateUserTokenAsync(token.RequestToken, username, password, cancellationToken).ConfigureAwait(false);
return await AuthenticationGetUserSessionAsync(token.RequestToken, cancellationToken).ConfigureAwait(false);
}
public async Task<Token> AuthenticationRequestAutenticationTokenAsync(CancellationToken cancellationToken = default)
{
RestRequest request = _client.Create("authentication/token/new");
using RestResponse<Token> response = await request.Get<Token>(cancellationToken).ConfigureAwait(false);
Token token = await response.GetDataObject().ConfigureAwait(false);
token.AuthenticationCallback = response.GetHeader("Authentication-Callback");
return token;
}
public async Task AuthenticationValidateUserTokenAsync(string initialRequestToken, string username, string password, CancellationToken cancellationToken = default)
{
RestRequest request = _client.Create("authentication/token/validate_with_login");
request.AddParameter("request_token", initialRequestToken);
request.AddParameter("username", username);
request.AddParameter("password", password);
RestResponse response;
try
{
response = await request.Get(cancellationToken).ConfigureAwait(false);
}
catch (AggregateException ex)
{
throw ex.InnerException;
}
using RestResponse _ = response;
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UnauthorizedAccessException("Call to TMDb returned unauthorized. Most likely the provided user credentials are invalid.");
}
}
}
}

View File

@ -1,28 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Certifications;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<CertificationsContainer> GetMovieCertificationsAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("certification/movie/list");
CertificationsContainer resp = await req.GetOfT<CertificationsContainer>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<CertificationsContainer> GetTvCertificationsAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("certification/tv/list");
CertificationsContainer resp = await req.GetOfT<CertificationsContainer>(cancellationToken).ConfigureAwait(false);
return resp;
}
}
}

View File

@ -1,109 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Changes;
using TMDbLib.Objects.General;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetChangesInternal<T>(string type, int page = 0, int? id = null, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
string resource;
if (id.HasValue)
resource = "{type}/{id}/changes";
else
resource = "{type}/changes";
RestRequest req = _client.Create(resource);
req.AddUrlSegment("type", type);
if (id.HasValue)
req.AddUrlSegment("id", id.Value.ToString());
if (page >= 1)
req.AddParameter("page", page.ToString());
if (startDate.HasValue)
req.AddParameter("start_date", startDate.Value.ToString("yyyy-MM-dd"));
if (endDate != null)
req.AddParameter("end_date", endDate.Value.ToString("yyyy-MM-dd"));
using RestResponse<T> resp = await req.Get<T>(cancellationToken).ConfigureAwait(false);
T res = await resp.GetDataObject().ConfigureAwait(false);
if (res is SearchContainer<ChangesListItem> asSearch)
{
// https://github.com/LordMike/TMDbLib/issues/296
asSearch.Results.RemoveAll(s => s.Id == 0);
}
return res;
}
/// <summary>
/// Get a list of movie ids that have been edited.
/// By default we show the last 24 hours and only 100 items per page.
/// The maximum number of days that can be returned in a single request is 14.
/// You can then use the movie changes API to get the actual data that has been changed. (.GetMovieChangesAsync)
/// </summary>
/// <remarks>the change log system to support this was changed on October 5, 2012 and will only show movies that have been edited since.</remarks>
public async Task<SearchContainer<ChangesListItem>> GetMoviesChangesAsync(int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
return await GetChangesInternal<SearchContainer<ChangesListItem>>("movie", page, startDate: startDate, endDate: endDate, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get a list of people ids that have been edited.
/// By default we show the last 24 hours and only 100 items per page.
/// The maximum number of days that can be returned in a single request is 14.
/// You can then use the person changes API to get the actual data that has been changed.(.GetPersonChangesAsync)
/// </summary>
/// <remarks>the change log system to support this was changed on October 5, 2012 and will only show people that have been edited since.</remarks>
public async Task<SearchContainer<ChangesListItem>> GetPeopleChangesAsync(int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
return await GetChangesInternal<SearchContainer<ChangesListItem>>("person", page, startDate: startDate, endDate: endDate, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get a list of TV show ids that have been edited.
/// By default we show the last 24 hours and only 100 items per page.
/// The maximum number of days that can be returned in a single request is 14.
/// You can then use the TV changes API to get the actual data that has been changed. (.GetTvShowChangesAsync)
/// </summary>
/// <remarks>
/// the change log system to properly support TV was updated on May 13, 2014.
/// You'll likely only find the edits made since then to be useful in the change log system.
/// </remarks>
public async Task<SearchContainer<ChangesListItem>> GetTvChangesAsync(int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
return await GetChangesInternal<SearchContainer<ChangesListItem>>("tv", page, startDate: startDate, endDate: endDate, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IList<Change>> GetMovieChangesAsync(int movieId, int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
ChangesContainer changesContainer = await GetChangesInternal<ChangesContainer>("movie", page, movieId, startDate, endDate, cancellationToken).ConfigureAwait(false);
return changesContainer.Changes;
}
public async Task<IList<Change>> GetPersonChangesAsync(int personId, int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
ChangesContainer changesContainer = await GetChangesInternal<ChangesContainer>("person", page, personId, startDate, endDate, cancellationToken).ConfigureAwait(false);
return changesContainer.Changes;
}
public async Task<IList<Change>> GetTvSeasonChangesAsync(int seasonId, int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
ChangesContainer changesContainer = await GetChangesInternal<ChangesContainer>("tv/season", page, seasonId, startDate, endDate, cancellationToken).ConfigureAwait(false);
return changesContainer.Changes;
}
public async Task<IList<Change>> GetTvEpisodeChangesAsync(int episodeId, int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default)
{
ChangesContainer changesContainer = await GetChangesInternal<ChangesContainer>("tv/episode", page, episodeId, startDate, endDate, cancellationToken).ConfigureAwait(false);
return changesContainer.Changes;
}
}
}

View File

@ -1,77 +0,0 @@
using System;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Collections;
using TMDbLib.Objects.General;
using TMDbLib.Rest;
using TMDbLib.Utilities;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetCollectionMethodInternal<T>(int collectionId, CollectionMethods collectionMethod, string language = null, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("collection/{collectionId}/{method}");
req.AddUrlSegment("collectionId", collectionId.ToString());
req.AddUrlSegment("method", collectionMethod.GetDescription());
if (language != null)
req.AddParameter("language", language);
T resp = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<Collection> GetCollectionAsync(int collectionId, CollectionMethods extraMethods = CollectionMethods.Undefined, CancellationToken cancellationToken = default)
{
return await GetCollectionAsync(collectionId, DefaultLanguage, null, extraMethods, cancellationToken).ConfigureAwait(false);
}
public async Task<Collection> GetCollectionAsync(int collectionId, string language, string includeImageLanguages, CollectionMethods extraMethods = CollectionMethods.Undefined, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("collection/{collectionId}");
req.AddUrlSegment("collectionId", collectionId.ToString());
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
includeImageLanguages ??= DefaultImageLanguage;
if (!string.IsNullOrWhiteSpace(includeImageLanguages))
req.AddParameter("include_image_language", includeImageLanguages);
string appends = string.Join(",",
Enum.GetValues(typeof(CollectionMethods))
.OfType<CollectionMethods>()
.Except(new[] { CollectionMethods.Undefined })
.Where(s => extraMethods.HasFlag(s))
.Select(s => s.GetDescription()));
if (appends != string.Empty)
req.AddParameter("append_to_response", appends);
//req.DateFormat = "yyyy-MM-dd";
using RestResponse<Collection> response = await req.Get<Collection>(cancellationToken).ConfigureAwait(false);
if (!response.IsValid)
return null;
Collection item = await response.GetDataObject().ConfigureAwait(false);
if (item != null)
item.Overview = WebUtility.HtmlDecode(item.Overview);
return item;
}
public async Task<ImagesWithId> GetCollectionImagesAsync(int collectionId, string language = null, CancellationToken cancellationToken = default)
{
return await GetCollectionMethodInternal<ImagesWithId>(collectionId, CollectionMethods.Images, language, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -1,65 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Companies;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Search;
using TMDbLib.Rest;
using TMDbLib.Utilities;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetCompanyMethodInternal<T>(int companyId, CompanyMethods companyMethod, int page = 0, string language = null, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("company/{companyId}/{method}");
req.AddUrlSegment("companyId", companyId.ToString());
req.AddUrlSegment("method", companyMethod.GetDescription());
if (page >= 1)
req.AddParameter("page", page.ToString());
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
T resp = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<Company> GetCompanyAsync(int companyId, CompanyMethods extraMethods = CompanyMethods.Undefined, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("company/{companyId}");
req.AddUrlSegment("companyId", companyId.ToString());
string appends = string.Join(",",
Enum.GetValues(typeof(CompanyMethods))
.OfType<CompanyMethods>()
.Except(new[] { CompanyMethods.Undefined })
.Where(s => extraMethods.HasFlag(s))
.Select(s => s.GetDescription()));
if (appends != string.Empty)
req.AddParameter("append_to_response", appends);
//req.DateFormat = "yyyy-MM-dd";
Company resp = await req.GetOfT<Company>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainerWithId<SearchMovie>> GetCompanyMoviesAsync(int companyId, int page = 0, CancellationToken cancellationToken = default)
{
return await GetCompanyMoviesAsync(companyId, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<SearchMovie>> GetCompanyMoviesAsync(int companyId, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await GetCompanyMethodInternal<SearchContainerWithId<SearchMovie>>(companyId, CompanyMethods.Movies, page, language, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -1,89 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Configuration;
using TMDbLib.Objects.Countries;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Languages;
using TMDbLib.Objects.Timezones;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<APIConfiguration> GetAPIConfiguration(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("configuration");
using RestResponse<APIConfiguration> response = await req.Get<APIConfiguration>(cancellationToken).ConfigureAwait(false);
return (await response.GetDataObject().ConfigureAwait(false));
}
public async Task<List<Country>> GetCountriesAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("configuration/countries");
using RestResponse<List<Country>> response = await req.Get<List<Country>>(cancellationToken).ConfigureAwait(false);
return await response.GetDataObject().ConfigureAwait(false);
}
public async Task<List<Language>> GetLanguagesAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("configuration/languages");
using RestResponse<List<Language>> response = await req.Get<List<Language>>(cancellationToken).ConfigureAwait(false);
return (await response.GetDataObject().ConfigureAwait(false));
}
public async Task<List<string>> GetPrimaryTranslationsAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("configuration/primary_translations");
using RestResponse<List<string>> response = await req.Get<List<string>>(cancellationToken).ConfigureAwait(false);
return (await response.GetDataObject().ConfigureAwait(false));
}
public async Task<Timezones> GetTimezonesAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("timezones/list");
using RestResponse<List<Dictionary<string, List<string>>>> resp = await req.Get<List<Dictionary<string, List<string>>>>(cancellationToken).ConfigureAwait(false);
List<Dictionary<string, List<string>>> item = await resp.GetDataObject().ConfigureAwait(false);
if (item == null)
return null;
Timezones result = new Timezones();
result.List = new Dictionary<string, List<string>>();
foreach (Dictionary<string, List<string>> dictionary in item)
{
KeyValuePair<string, List<string>> item1 = dictionary.First();
result.List[item1.Key] = item1.Value;
}
return result;
}
/// <summary>
/// Retrieves a list of departments and positions within
/// </summary>
/// <returns>Valid jobs and their departments</returns>
public async Task<List<Job>> GetJobsAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("configuration/jobs");
using RestResponse<List<Job>> response = await req.Get<List<Job>>(cancellationToken).ConfigureAwait(false);
return (await response.GetDataObject().ConfigureAwait(false));
}
}
}

View File

@ -1,29 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Credit;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<Credit> GetCreditsAsync(string id, CancellationToken cancellationToken = default)
{
return await GetCreditsAsync(id, DefaultLanguage, cancellationToken).ConfigureAwait(false);
}
public async Task<Credit> GetCreditsAsync(string id, string language, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("credit/{id}");
if (!string.IsNullOrEmpty(language))
req.AddParameter("language", language);
req.AddUrlSegment("id", id);
Credit resp = await req.GetOfT<Credit>(cancellationToken).ConfigureAwait(false);
return resp;
}
}
}

View File

@ -1,46 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Discover;
using TMDbLib.Objects.General;
using TMDbLib.Rest;
using TMDbLib.Utilities;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
/// <summary>
/// Can be used to discover movies matching certain criteria
/// </summary>
public DiscoverMovie DiscoverMoviesAsync()
{
return new DiscoverMovie(this);
}
internal async Task<SearchContainer<T>> DiscoverPerformAsync<T>(string endpoint, string language, int page, SimpleNamedValueCollection parameters, CancellationToken cancellationToken = default)
{
RestRequest request = _client.Create(endpoint);
if (page != 1 && page > 1)
request.AddParameter("page", page.ToString());
if (!string.IsNullOrWhiteSpace(language))
request.AddParameter("language", language);
foreach (KeyValuePair<string, string> pair in parameters)
request.AddParameter(pair.Key, pair.Value);
SearchContainer<T> response = await request.GetOfT<SearchContainer<T>>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Can be used to discover new tv shows matching certain criteria
/// </summary>
public DiscoverTv DiscoverTvShowsAsync()
{
return new DiscoverTv(this);
}
}
}

View File

@ -1,56 +0,0 @@
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Find;
using TMDbLib.Rest;
using TMDbLib.Utilities;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
/// <summary>
/// FindAsync movies, people and tv shows by an external id.
/// The following types can be found based on the specified external id's
/// - Movies: Imdb
/// - People: Imdb, FreeBaseMid, FreeBaseId, TvRage
/// - TV Series: Imdb, FreeBaseMid, FreeBaseId, TvRage, TvDb
/// </summary>
/// <param name="source">The source the specified id belongs to</param>
/// <param name="id">The id of the object you wish to located</param>
/// <returns>A list of all objects in TMDb that matched your id</returns>
/// <param name="cancellationToken">A cancellation token</param>
public Task<FindContainer> FindAsync(FindExternalSource source, string id, CancellationToken cancellationToken = default)
{
return FindAsync(source, id, null, cancellationToken);
}
/// <summary>
/// FindAsync movies, people and tv shows by an external id.
/// The following types can be found based on the specified external id's
/// - Movies: Imdb
/// - People: Imdb, FreeBaseMid, FreeBaseId, TvRage
/// - TV Series: Imdb, FreeBaseMid, FreeBaseId, TvRage, TvDb
/// </summary>
/// <param name="source">The source the specified id belongs to</param>
/// <param name="id">The id of the object you wish to located</param>
/// <returns>A list of all objects in TMDb that matched your id</returns>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es.</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<FindContainer> FindAsync(FindExternalSource source, string id, string language, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("find/{id}");
req.AddUrlSegment("id", WebUtility.UrlEncode(id));
req.AddParameter("external_source", source.GetDescription());
language ??= DefaultLanguage;
if (!string.IsNullOrEmpty(language))
req.AddParameter("language", language);
FindContainer resp = await req.GetOfT<FindContainer>(cancellationToken).ConfigureAwait(false);
return resp;
}
}
}

View File

@ -1,76 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Genres;
using TMDbLib.Objects.Search;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
[Obsolete("GetGenreMovies is deprecated, use DiscoverMovies instead")]
public async Task<SearchContainerWithId<SearchMovie>> GetGenreMoviesAsync(int genreId, int page = 0, bool? includeAllMovies = null, CancellationToken cancellationToken = default)
{
return await GetGenreMoviesAsync(genreId, DefaultLanguage, page, includeAllMovies, cancellationToken).ConfigureAwait(false);
}
[Obsolete("GetGenreMovies is deprecated, use DiscoverMovies instead")]
public async Task<SearchContainerWithId<SearchMovie>> GetGenreMoviesAsync(int genreId, string language, int page = 0, bool? includeAllMovies = null, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("genre/{genreId}/movies");
req.AddUrlSegment("genreId", genreId.ToString());
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
if (page >= 1)
req.AddParameter("page", page.ToString());
if (includeAllMovies.HasValue)
req.AddParameter("include_all_movies", includeAllMovies.Value ? "true" : "false");
SearchContainerWithId<SearchMovie> resp = await req.GetOfT<SearchContainerWithId<SearchMovie>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<List<Genre>> GetMovieGenresAsync(CancellationToken cancellationToken = default)
{
return await GetMovieGenresAsync(DefaultLanguage, cancellationToken).ConfigureAwait(false);
}
public async Task<List<Genre>> GetMovieGenresAsync(string language, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("genre/movie/list");
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
using RestResponse<GenreContainer> resp = await req.Get<GenreContainer>(cancellationToken).ConfigureAwait(false);
return (await resp.GetDataObject().ConfigureAwait(false)).Genres;
}
public async Task<List<Genre>> GetTvGenresAsync(CancellationToken cancellationToken = default)
{
return await GetTvGenresAsync(DefaultLanguage, cancellationToken).ConfigureAwait(false);
}
public async Task<List<Genre>> GetTvGenresAsync(string language, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("genre/tv/list");
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
using RestResponse<GenreContainer> resp = await req.Get<GenreContainer>(cancellationToken).ConfigureAwait(false);
return (await resp.GetDataObject().ConfigureAwait(false)).Genres;
}
}
}

View File

@ -1,85 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Search;
using TMDbLib.Objects.TvShows;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<SearchContainer<SearchMovieWithRating>> GetGuestSessionRatedMoviesAsync(int page = 0, CancellationToken cancellationToken = default)
{
return await GetGuestSessionRatedMoviesAsync(DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovieWithRating>> GetGuestSessionRatedMoviesAsync(string language, int page = 0, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest request = _client.Create("guest_session/{guest_session_id}/rated/movies");
if (page > 0)
request.AddParameter("page", page.ToString());
if (!string.IsNullOrEmpty(language))
request.AddParameter("language", language);
AddSessionId(request, SessionType.GuestSession, ParameterType.UrlSegment);
SearchContainer<SearchMovieWithRating> resp = await request.GetOfT<SearchContainer<SearchMovieWithRating>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainer<SearchTvShowWithRating>> GetGuestSessionRatedTvAsync(int page = 0, CancellationToken cancellationToken = default)
{
return await GetGuestSessionRatedTvAsync(DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchTvShowWithRating>> GetGuestSessionRatedTvAsync(string language, int page = 0, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest request = _client.Create("guest_session/{guest_session_id}/rated/tv");
if (page > 0)
request.AddParameter("page", page.ToString());
if (!string.IsNullOrEmpty(language))
request.AddParameter("language", language);
AddSessionId(request, SessionType.GuestSession, ParameterType.UrlSegment);
SearchContainer<SearchTvShowWithRating> resp = await request.GetOfT<SearchContainer<SearchTvShowWithRating>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainer<TvEpisodeWithRating>> GetGuestSessionRatedTvEpisodesAsync(int page = 0, CancellationToken cancellationToken = default)
{
return await GetGuestSessionRatedTvEpisodesAsync(DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<TvEpisodeWithRating>> GetGuestSessionRatedTvEpisodesAsync(string language, int page = 0, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest request = _client.Create("guest_session/{guest_session_id}/rated/tv/episodes");
if (page > 0)
request.AddParameter("page", page.ToString());
if (!string.IsNullOrEmpty(language))
request.AddParameter("language", language);
AddSessionId(request, SessionType.GuestSession, ParameterType.UrlSegment);
SearchContainer<TvEpisodeWithRating> resp = await request.GetOfT<SearchContainer<TvEpisodeWithRating>>(cancellationToken).ConfigureAwait(false);
return resp;
}
}
}

View File

@ -1,43 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Search;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<Keyword> GetKeywordAsync(int keywordId, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("keyword/{keywordId}");
req.AddUrlSegment("keywordId", keywordId.ToString());
Keyword resp = await req.GetOfT<Keyword>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainerWithId<SearchMovie>> GetKeywordMoviesAsync(int keywordId, int page = 0, CancellationToken cancellationToken = default)
{
return await GetKeywordMoviesAsync(keywordId, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<SearchMovie>> GetKeywordMoviesAsync(int keywordId, string language, int page = 0, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("keyword/{keywordId}/movies");
req.AddUrlSegment("keywordId", keywordId.ToString());
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
if (page >= 1)
req.AddParameter("page", page.ToString());
SearchContainerWithId<SearchMovie> resp = await req.GetOfT<SearchContainerWithId<SearchMovie>>(cancellationToken).ConfigureAwait(false);
return resp;
}
}
}

View File

@ -1,210 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Lists;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<bool> GetManipulateMediaListAsyncInternal(string listId, int movieId, string method, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
if (string.IsNullOrWhiteSpace(listId))
throw new ArgumentNullException(nameof(listId));
// Movie Id is expected by the API and can not be null
if (movieId <= 0)
throw new ArgumentOutOfRangeException(nameof(movieId));
RestRequest req = _client.Create("list/{listId}/{method}");
req.AddUrlSegment("listId", listId);
req.AddUrlSegment("method", method);
AddSessionId(req, SessionType.UserSession);
req.SetBody(new { media_id = movieId });
using RestResponse<PostReply> response = await req.Post<PostReply>(cancellationToken).ConfigureAwait(false);
// Status code 12 = "The item/record was updated successfully"
// Status code 13 = "The item/record was deleted successfully"
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Previous code checked for item=null
return item.StatusCode == 12 || item.StatusCode == 13;
}
/// <summary>
/// Retrieve a list by it's id
/// </summary>
/// <param name="listId">The id of the list you want to retrieve</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<GenericList> GetListAsync(string listId, string language = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(listId))
throw new ArgumentNullException(nameof(listId));
RestRequest req = _client.Create("list/{listId}");
req.AddUrlSegment("listId", listId);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
GenericList resp = await req.GetOfT<GenericList>(cancellationToken).ConfigureAwait(false);
return resp;
}
/// <summary>
/// Will check if the provided movie id is present in the specified list
/// </summary>
/// <param name="listId">Id of the list to check in</param>
/// <param name="movieId">Id of the movie to check for in the list</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<bool> GetListIsMoviePresentAsync(string listId, int movieId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(listId))
throw new ArgumentNullException(nameof(listId));
if (movieId <= 0)
throw new ArgumentOutOfRangeException(nameof(movieId));
RestRequest req = _client.Create("list/{listId}/item_status");
req.AddUrlSegment("listId", listId);
req.AddParameter("movie_id", movieId.ToString());
using RestResponse<ListStatus> response = await req.Get<ListStatus>(cancellationToken).ConfigureAwait(false);
return (await response.GetDataObject().ConfigureAwait(false)).ItemPresent;
}
/// <summary>
/// Adds a movie to a specified list
/// </summary>
/// <param name="listId">The id of the list to add the movie to</param>
/// <param name="movieId">The id of the movie to add</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>True if the method was able to add the movie to the list, will retrun false in case of an issue or when the movie was already added to the list</returns>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<bool> ListAddMovieAsync(string listId, int movieId, CancellationToken cancellationToken = default)
{
return await GetManipulateMediaListAsyncInternal(listId, movieId, "add_item", cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Clears a list, without confirmation.
/// </summary>
/// <param name="listId">The id of the list to clear</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>True if the method was able to remove the movie from the list, will retrun false in case of an issue or when the movie was not present in the list</returns>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<bool> ListClearAsync(string listId, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
if (string.IsNullOrWhiteSpace(listId))
throw new ArgumentNullException(nameof(listId));
RestRequest request = _client.Create("list/{listId}/clear");
request.AddUrlSegment("listId", listId);
request.AddParameter("confirm", "true");
AddSessionId(request, SessionType.UserSession);
using RestResponse<PostReply> response = await request.Post<PostReply>(cancellationToken).ConfigureAwait(false);
// Status code 12 = "The item/record was updated successfully"
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Previous code checked for item=null
return item.StatusCode == 12;
}
/// <summary>
/// Creates a new list for the user associated with the current session
/// </summary>
/// <param name="name">The name of the new list</param>
/// <param name="description">Optional description for the list</param>
/// <param name="language">Optional language that might indicate the language of the content in the list</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<string> ListCreateAsync(string name, string description = "", string language = null, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentNullException(nameof(name));
// Description is expected by the API and can not be null
if (string.IsNullOrWhiteSpace(description))
description = "";
RestRequest req = _client.Create("list");
AddSessionId(req, SessionType.UserSession);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
{
req.SetBody(new { name = name, description = description, language = language });
}
else
{
req.SetBody(new { name = name, description = description });
}
using RestResponse<ListCreateReply> response = await req.Post<ListCreateReply>(cancellationToken).ConfigureAwait(false);
return (await response.GetDataObject().ConfigureAwait(false)).ListId;
}
/// <summary>
/// Deletes the specified list that is owned by the user
/// </summary>
/// <param name="listId">A list id that is owned by the user associated with the current session id</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<bool> ListDeleteAsync(string listId, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
if (string.IsNullOrWhiteSpace(listId))
throw new ArgumentNullException(nameof(listId));
RestRequest req = _client.Create("list/{listId}");
req.AddUrlSegment("listId", listId);
AddSessionId(req, SessionType.UserSession);
using RestResponse<PostReply> response = await req.Delete<PostReply>(cancellationToken).ConfigureAwait(false);
// Status code 13 = success
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Previous code checked for item=null
return item.StatusCode == 13;
}
/// <summary>
/// Removes a movie from the specified list
/// </summary>
/// <param name="listId">The id of the list to add the movie to</param>
/// <param name="movieId">The id of the movie to add</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>True if the method was able to remove the movie from the list, will retrun false in case of an issue or when the movie was not present in the list</returns>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<bool> ListRemoveMovieAsync(string listId, int movieId, CancellationToken cancellationToken = default)
{
return await GetManipulateMediaListAsyncInternal(listId, movieId, "remove_item", cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -1,392 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Movies;
using TMDbLib.Objects.Reviews;
using TMDbLib.Objects.Search;
using TMDbLib.Rest;
using TMDbLib.Utilities;
using Credits = TMDbLib.Objects.Movies.Credits;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetMovieMethodInternal<T>(int movieId, MovieMethods movieMethod, string dateFormat = null,
string country = null,
string language = null, string includeImageLanguage = null, int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("movie/{movieId}/{method}");
req.AddUrlSegment("movieId", movieId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", movieMethod.GetDescription());
if (country != null)
req.AddParameter("country", country);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
if (!string.IsNullOrWhiteSpace(includeImageLanguage))
req.AddParameter("include_image_language", includeImageLanguage);
if (page >= 1)
req.AddParameter("page", page.ToString());
if (startDate.HasValue)
req.AddParameter("start_date", startDate.Value.ToString("yyyy-MM-dd"));
if (endDate != null)
req.AddParameter("end_date", endDate.Value.ToString("yyyy-MM-dd"));
T response = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Retrieves all information for a specific movie in relation to the current user account
/// </summary>
/// <param name="movieId">The id of the movie to get the account states for</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<AccountState> GetMovieAccountStateAsync(int movieId, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("movie/{movieId}/{method}");
req.AddUrlSegment("movieId", movieId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", MovieMethods.AccountStates.GetDescription());
AddSessionId(req, SessionType.UserSession);
using RestResponse<AccountState> response = await req.Get<AccountState>(cancellationToken).ConfigureAwait(false);
return await response.GetDataObject().ConfigureAwait(false);
}
public async Task<AlternativeTitles> GetMovieAlternativeTitlesAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieAlternativeTitlesAsync(movieId, DefaultCountry, cancellationToken).ConfigureAwait(false);
}
public async Task<AlternativeTitles> GetMovieAlternativeTitlesAsync(int movieId, string country, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<AlternativeTitles>(movieId, MovieMethods.AlternativeTitles, country: country, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<Movie> GetMovieAsync(int movieId, MovieMethods extraMethods = MovieMethods.Undefined, CancellationToken cancellationToken = default)
{
return await GetMovieAsync(movieId, DefaultLanguage, null, extraMethods, cancellationToken).ConfigureAwait(false);
}
public async Task<Movie> GetMovieAsync(string imdbId, MovieMethods extraMethods = MovieMethods.Undefined, CancellationToken cancellationToken = default)
{
return await GetMovieAsync(imdbId, DefaultLanguage, null, extraMethods, cancellationToken).ConfigureAwait(false);
}
public async Task<Movie> GetMovieAsync(int movieId, string language, string includeImageLanguage = null, MovieMethods extraMethods = MovieMethods.Undefined, CancellationToken cancellationToken = default)
{
return await GetMovieAsync(movieId.ToString(CultureInfo.InvariantCulture), language, includeImageLanguage, extraMethods, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Retrieves a movie by its IMDb Id
/// </summary>
/// <param name="imdbId">The IMDb id of the movie OR the TMDb id as string</param>
/// <param name="language">Language to localize the results in.</param>
/// <param name="includeImageLanguage">If specified the api will attempt to return localized image results eg. en,it,es.</param>
/// <param name="extraMethods">A list of additional methods to execute for this req as enum flags</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>The reqed movie or null if it could not be found</returns>
/// <remarks>Requires a valid user session when specifying the extra method 'AccountStates' flag</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned, see remarks.</exception>
public async Task<Movie> GetMovieAsync(string imdbId, string language, string includeImageLanguage = null, MovieMethods extraMethods = MovieMethods.Undefined, CancellationToken cancellationToken = default)
{
if (extraMethods.HasFlag(MovieMethods.AccountStates))
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("movie/{movieId}");
req.AddUrlSegment("movieId", imdbId);
if (extraMethods.HasFlag(MovieMethods.AccountStates))
AddSessionId(req, SessionType.UserSession);
if (language != null)
req.AddParameter("language", language);
includeImageLanguage ??= DefaultImageLanguage;
if (includeImageLanguage != null)
req.AddParameter("include_image_language", includeImageLanguage);
string appends = string.Join(",",
Enum.GetValues(typeof(MovieMethods))
.OfType<MovieMethods>()
.Except(new[] { MovieMethods.Undefined })
.Where(s => extraMethods.HasFlag(s))
.Select(s => s.GetDescription()));
if (appends != string.Empty)
req.AddParameter("append_to_response", appends);
using RestResponse<Movie> response = await req.Get<Movie>(cancellationToken).ConfigureAwait(false);
if (!response.IsValid)
return null;
Movie item = await response.GetDataObject().ConfigureAwait(false);
// Patch up data, so that the end user won't notice that we share objects between req-types.
if (item.Videos != null)
item.Videos.Id = item.Id;
if (item.AlternativeTitles != null)
item.AlternativeTitles.Id = item.Id;
if (item.Credits != null)
item.Credits.Id = item.Id;
if (item.Releases != null)
item.Releases.Id = item.Id;
if (item.Keywords != null)
item.Keywords.Id = item.Id;
if (item.Translations != null)
item.Translations.Id = item.Id;
if (item.AccountStates != null)
item.AccountStates.Id = item.Id;
if (item.ExternalIds != null)
item.ExternalIds.Id = item.Id;
// Overview is the only field that is HTML encoded from the source.
item.Overview = WebUtility.HtmlDecode(item.Overview);
return item;
}
public async Task<Credits> GetMovieCreditsAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<Credits>(movieId, MovieMethods.Credits, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns an object that contains all known exteral id's for the movie related to the specified TMDB id.
/// </summary>
/// <param name="id">The TMDb id of the target movie.</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<ExternalIdsMovie> GetMovieExternalIdsAsync(int id, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<ExternalIdsMovie>(id, MovieMethods.ExternalIds, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<ImagesWithId> GetMovieImagesAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieImagesAsync(movieId, DefaultLanguage, null, cancellationToken).ConfigureAwait(false);
}
public async Task<ImagesWithId> GetMovieImagesAsync(int movieId, string language, string includeImageLanguage = null, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<ImagesWithId>(movieId, MovieMethods.Images, language: language, includeImageLanguage: includeImageLanguage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<KeywordsContainer> GetMovieKeywordsAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<KeywordsContainer>(movieId, MovieMethods.Keywords, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<Movie> GetMovieLatestAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("movie/latest");
using RestResponse<Movie> resp = await req.Get<Movie>(cancellationToken).ConfigureAwait(false);
Movie item = await resp.GetDataObject().ConfigureAwait(false);
// Overview is the only field that is HTML encoded from the source.
if (item != null)
item.Overview = WebUtility.HtmlDecode(item.Overview);
return item;
}
public async Task<SearchContainerWithId<ListResult>> GetMovieListsAsync(int movieId, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieListsAsync(movieId, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<ListResult>> GetMovieListsAsync(int movieId, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<SearchContainerWithId<ListResult>>(movieId, MovieMethods.Lists, page: page, language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovie>> GetMovieRecommendationsAsync(int id, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieRecommendationsAsync(id, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovie>> GetMovieRecommendationsAsync(int id, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<SearchContainer<SearchMovie>>(id, MovieMethods.Recommendations, language: language, page: page, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithDates<SearchMovie>> GetMovieNowPlayingListAsync(string language = null, int page = 0, string region = null, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("movie/now_playing");
if (page >= 1)
req.AddParameter("page", page.ToString());
if (language != null)
req.AddParameter("language", language);
if (region != null)
req.AddParameter("region", region);
SearchContainerWithDates<SearchMovie> resp = await req.GetOfT<SearchContainerWithDates<SearchMovie>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainer<SearchMovie>> GetMoviePopularListAsync(string language = null, int page = 0, string region = null, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("movie/popular");
if (page >= 1)
req.AddParameter("page", page.ToString());
if (language != null)
req.AddParameter("language", language);
if (region != null)
req.AddParameter("region", region);
SearchContainer<SearchMovie> resp = await req.GetOfT<SearchContainer<SearchMovie>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<ResultContainer<ReleaseDatesContainer>> GetMovieReleaseDatesAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<ResultContainer<ReleaseDatesContainer>>(movieId, MovieMethods.ReleaseDates, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<Releases> GetMovieReleasesAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<Releases>(movieId, MovieMethods.Releases, dateFormat: "yyyy-MM-dd", cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<ReviewBase>> GetMovieReviewsAsync(int movieId, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieReviewsAsync(movieId, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<ReviewBase>> GetMovieReviewsAsync(int movieId, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<SearchContainerWithId<ReviewBase>>(movieId, MovieMethods.Reviews, page: page, language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovie>> GetMovieSimilarAsync(int movieId, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieSimilarAsync(movieId, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovie>> GetMovieSimilarAsync(int movieId, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<SearchContainer<SearchMovie>>(movieId, MovieMethods.Similar, page: page, language: language, dateFormat: "yyyy-MM-dd", cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovie>> GetMovieTopRatedListAsync(string language = null, int page = 0, string region = null, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("movie/top_rated");
if (page >= 1)
req.AddParameter("page", page.ToString());
if (language != null)
req.AddParameter("language", language);
if (region != null)
req.AddParameter("region", region);
SearchContainer<SearchMovie> resp = await req.GetOfT<SearchContainer<SearchMovie>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<TranslationsContainer> GetMovieTranslationsAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<TranslationsContainer>(movieId, MovieMethods.Translations, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithDates<SearchMovie>> GetMovieUpcomingListAsync(string language = null, int page = 0, string region = null, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("movie/upcoming");
if (page >= 1)
req.AddParameter("page", page.ToString());
if (language != null)
req.AddParameter("language", language);
if (region != null)
req.AddParameter("region", region);
SearchContainerWithDates<SearchMovie> resp = await req.GetOfT<SearchContainerWithDates<SearchMovie>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<ResultContainer<Video>> GetMovieVideosAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<ResultContainer<Video>>(movieId, MovieMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SingleResultContainer<Dictionary<string, WatchProviders>>> GetMovieWatchProvidersAsync(int movieId, CancellationToken cancellationToken = default)
{
return await GetMovieMethodInternal<SingleResultContainer<Dictionary<string, WatchProviders>>>(movieId, MovieMethods.WatchProviders, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> MovieRemoveRatingAsync(int movieId, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest req = _client.Create("movie/{movieId}/rating");
req.AddUrlSegment("movieId", movieId.ToString(CultureInfo.InvariantCulture));
AddSessionId(req);
using RestResponse<PostReply> response = await req.Delete<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 13 = "The item/record was deleted successfully."
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Previous code checked for item=null
return item != null && item.StatusCode == 13;
}
/// <summary>
/// Change the rating of a specified movie.
/// </summary>
/// <param name="movieId">The id of the movie to rate</param>
/// <param name="rating">The rating you wish to assign to the specified movie. Value needs to be between 0.5 and 10 and must use increments of 0.5. Ex. using 7.1 will not work and return false.</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>True if the the movie's rating was successfully updated, false if not</returns>
/// <remarks>Requires a valid guest or user session</remarks>
/// <exception cref="GuestSessionRequiredException">Thrown when the current client object doens't have a guest or user session assigned.</exception>
public async Task<bool> MovieSetRatingAsync(int movieId, double rating, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest req = _client.Create("movie/{movieId}/rating");
req.AddUrlSegment("movieId", movieId.ToString(CultureInfo.InvariantCulture));
AddSessionId(req);
req.SetBody(new { value = rating });
using RestResponse<PostReply> response = await req.Post<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 1 = "Success"
// status code 12 = "The item/record was updated successfully" - Used when an item was previously rated by the user
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Previous code checked for item=null
return item.StatusCode == 1 || item.StatusCode == 12;
}
}
}

View File

@ -1,57 +0,0 @@
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.General;
using TMDbLib.Objects.TvShows;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
/// <summary>
/// Retrieves a network by it's TMDb id. A network is a distributor of media content ex. HBO, AMC
/// </summary>
/// <param name="networkId">The id of the network object to retrieve</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<Network> GetNetworkAsync(int networkId, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("network/{networkId}");
req.AddUrlSegment("networkId", networkId.ToString(CultureInfo.InvariantCulture));
Network response = await req.GetOfT<Network>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Gets the logos of a network given a TMDb id
/// </summary>
/// <param name="networkId">The TMDb id of the network</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<NetworkLogos> GetNetworkImagesAsync(int networkId, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("network/{networkId}/images");
req.AddUrlSegment("networkId", networkId.ToString(CultureInfo.InvariantCulture));
NetworkLogos response = await req.GetOfT<NetworkLogos>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Gets the alternative names of a network given a TMDb id
/// </summary>
/// <param name="networkId">The TMDb id of the network</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<AlternativeNames> GetNetworkAlternativeNamesAsync(int networkId, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("network/{networkId}/alternative_names");
req.AddUrlSegment("networkId", networkId.ToString(CultureInfo.InvariantCulture));
AlternativeNames response = await req.GetOfT<AlternativeNames>(cancellationToken).ConfigureAwait(false);
return response;
}
}
}

View File

@ -1,170 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.General;
using TMDbLib.Objects.People;
using TMDbLib.Rest;
using TMDbLib.Utilities;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetPersonMethodInternal<T>(int personId, PersonMethods personMethod, string dateFormat = null, string country = null, string language = null,
int page = 0, DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("person/{personId}/{method}");
req.AddUrlSegment("personId", personId.ToString());
req.AddUrlSegment("method", personMethod.GetDescription());
// TODO: Dateformat?
//if (dateFormat != null)
// req.DateFormat = dateFormat;
if (country != null)
req.AddParameter("country", country);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
if (page >= 1)
req.AddParameter("page", page.ToString());
if (startDate.HasValue)
req.AddParameter("startDate", startDate.Value.ToString("yyyy-MM-dd"));
if (endDate != null)
req.AddParameter("endDate", endDate.Value.ToString("yyyy-MM-dd"));
T resp = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<Person> GetLatestPersonAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("person/latest");
// TODO: Dateformat?
//req.DateFormat = "yyyy-MM-dd";
Person resp = await req.GetOfT<Person>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<Person> GetPersonAsync(int personId, PersonMethods extraMethods = PersonMethods.Undefined,
CancellationToken cancellationToken = default)
{
return await GetPersonAsync(personId, DefaultLanguage, extraMethods, cancellationToken).ConfigureAwait(false);
}
public async Task<Person> GetPersonAsync(int personId, string language, PersonMethods extraMethods = PersonMethods.Undefined, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("person/{personId}");
req.AddUrlSegment("personId", personId.ToString());
if (language != null)
req.AddParameter("language", language);
string appends = string.Join(",",
Enum.GetValues(typeof(PersonMethods))
.OfType<PersonMethods>()
.Except(new[] { PersonMethods.Undefined })
.Where(s => extraMethods.HasFlag(s))
.Select(s => s.GetDescription()));
if (appends != string.Empty)
req.AddParameter("append_to_response", appends);
// TODO: Dateformat?
//req.DateFormat = "yyyy-MM-dd";
using RestResponse<Person> response = await req.Get<Person>(cancellationToken).ConfigureAwait(false);
if (!response.IsValid)
return null;
Person item = await response.GetDataObject().ConfigureAwait(false);
// Patch up data, so that the end user won't notice that we share objects between request-types.
if (item != null)
{
if (item.Images != null)
item.Images.Id = item.Id;
if (item.TvCredits != null)
item.TvCredits.Id = item.Id;
if (item.MovieCredits != null)
item.MovieCredits.Id = item.Id;
}
return item;
}
public async Task<ExternalIdsPerson> GetPersonExternalIdsAsync(int personId, CancellationToken cancellationToken = default)
{
return await GetPersonMethodInternal<ExternalIdsPerson>(personId, PersonMethods.ExternalIds, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<ProfileImages> GetPersonImagesAsync(int personId, CancellationToken cancellationToken = default)
{
return await GetPersonMethodInternal<ProfileImages>(personId, PersonMethods.Images, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<PersonResult>> GetPersonListAsync(PersonListType type, int page = 0, CancellationToken cancellationToken = default)
{
RestRequest req;
switch (type)
{
case PersonListType.Popular:
req = _client.Create("person/popular");
break;
default:
throw new ArgumentOutOfRangeException(nameof(type));
}
if (page >= 1)
req.AddParameter("page", page.ToString());
// TODO: Dateformat?
//req.DateFormat = "yyyy-MM-dd";
SearchContainer<PersonResult> resp = await req.GetOfT<SearchContainer<PersonResult>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<MovieCredits> GetPersonMovieCreditsAsync(int personId, CancellationToken cancellationToken = default)
{
return await GetPersonMovieCreditsAsync(personId, DefaultLanguage, cancellationToken).ConfigureAwait(false);
}
public async Task<MovieCredits> GetPersonMovieCreditsAsync(int personId, string language, CancellationToken cancellationToken = default)
{
return await GetPersonMethodInternal<MovieCredits>(personId, PersonMethods.MovieCredits, language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<TaggedImage>> GetPersonTaggedImagesAsync(int personId, int page, CancellationToken cancellationToken = default)
{
return await GetPersonTaggedImagesAsync(personId, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<TaggedImage>> GetPersonTaggedImagesAsync(int personId, string language, int page, CancellationToken cancellationToken = default)
{
return await GetPersonMethodInternal<SearchContainerWithId<TaggedImage>>(personId, PersonMethods.TaggedImages, language: language, page: page, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<TvCredits> GetPersonTvCreditsAsync(int personId, CancellationToken cancellationToken = default)
{
return await GetPersonTvCreditsAsync(personId, DefaultLanguage, cancellationToken).ConfigureAwait(false);
}
public async Task<TvCredits> GetPersonTvCreditsAsync(int personId, string language, CancellationToken cancellationToken = default)
{
return await GetPersonMethodInternal<TvCredits>(personId, PersonMethods.TvCredits, language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -1,23 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Reviews;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<Review> GetReviewAsync(string reviewId, CancellationToken cancellationToken = default)
{
RestRequest request = _client.Create("review/{reviewId}");
request.AddUrlSegment("reviewId", reviewId);
// TODO: Dateformat?
//request.DateFormat = "yyyy-MM-dd";
Review resp = await request.GetOfT<Review>(cancellationToken).ConfigureAwait(false);
return resp;
}
}
}

View File

@ -1,112 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Search;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> SearchMethodInternal<T>(string method, string query, int page, string language = null, bool? includeAdult = null, int year = 0, string dateFormat = null, string region = null, int primaryReleaseYear = 0, int firstAirDateYear = 0, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("search/{method}");
req.AddUrlSegment("method", method);
req.AddParameter("query", query);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
if (page >= 1)
req.AddParameter("page", page.ToString());
if (year >= 1)
req.AddParameter("year", year.ToString());
if (includeAdult.HasValue)
req.AddParameter("include_adult", includeAdult.Value ? "true" : "false");
// TODO: Dateformat?
//if (dateFormat != null)
// req.DateFormat = dateFormat;
if (!string.IsNullOrWhiteSpace(region))
req.AddParameter("region", region);
if (primaryReleaseYear >= 1)
req.AddParameter("primary_release_year", primaryReleaseYear.ToString());
if (firstAirDateYear >= 1)
req.AddParameter("first_air_date_year", firstAirDateYear.ToString());
T resp = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainer<SearchCollection>> SearchCollectionAsync(string query, int page = 0, CancellationToken cancellationToken = default)
{
return await SearchCollectionAsync(query, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchCollection>> SearchCollectionAsync(string query, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchCollection>>("collection", query, page, language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchCompany>> SearchCompanyAsync(string query, int page = 0, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchCompany>>("company", query, page, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchKeyword>> SearchKeywordAsync(string query, int page = 0, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchKeyword>>("keyword", query, page, cancellationToken: cancellationToken).ConfigureAwait(false);
}
[Obsolete("20200701 No longer present in public API")]
public async Task<SearchContainer<SearchList>> SearchListAsync(string query, int page = 0, bool includeAdult = false, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchList>>("list", query, page, includeAdult: includeAdult, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, int page = 0, bool includeAdult = false, int year = 0, string region = null, int primaryReleaseYear = 0, CancellationToken cancellationToken = default)
{
return await SearchMovieAsync(query, DefaultLanguage, page, includeAdult, year, region, primaryReleaseYear, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchMovie>> SearchMovieAsync(string query, string language, int page = 0, bool includeAdult = false, int year = 0, string region = null, int primaryReleaseYear = 0, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchMovie>>("movie", query, page, language, includeAdult, year, "yyyy-MM-dd", region, primaryReleaseYear, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchBase>> SearchMultiAsync(string query, int page = 0, bool includeAdult = false, int year = 0, string region = null, CancellationToken cancellationToken = default)
{
return await SearchMultiAsync(query, DefaultLanguage, page, includeAdult, year, region, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchBase>> SearchMultiAsync(string query, string language, int page = 0, bool includeAdult = false, int year = 0, string region = null, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchBase>>("multi", query, page, language, includeAdult, year, "yyyy-MM-dd", region, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchPerson>> SearchPersonAsync(string query, int page = 0, bool includeAdult = false, string region = null, CancellationToken cancellationToken = default)
{
return await SearchPersonAsync(query, DefaultLanguage, page, includeAdult, region, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchPerson>> SearchPersonAsync(string query, string language, int page = 0, bool includeAdult = false, string region = null, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchPerson>>("person", query, page, language, includeAdult, region: region, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchTv>> SearchTvShowAsync(string query, int page = 0, bool includeAdult = false, int firstAirDateYear = 0, CancellationToken cancellationToken = default)
{
return await SearchTvShowAsync(query, DefaultLanguage, page, includeAdult, firstAirDateYear, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchTv>> SearchTvShowAsync(string query, string language, int page = 0, bool includeAdult = false, int firstAirDateYear = 0, CancellationToken cancellationToken = default)
{
return await SearchMethodInternal<SearchContainer<SearchTv>>("tv", query, page, language, includeAdult, firstAirDateYear: firstAirDateYear, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -1,52 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Search;
using TMDbLib.Objects.Trending;
using TMDbLib.Rest;
using TMDbLib.Utilities;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
public async Task<SearchContainer<SearchMovie>> GetTrendingMoviesAsync(TimeWindow timeWindow, int page = 0, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("trending/movie/{time_window}");
req.AddUrlSegment("time_window", timeWindow.GetDescription());
if (page >= 1)
req.AddQueryString("page", page.ToString());
SearchContainer<SearchMovie> resp = await req.GetOfT<SearchContainer<SearchMovie>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainer<SearchTv>> GetTrendingTvAsync(TimeWindow timeWindow, int page = 0, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("trending/tv/{time_window}");
req.AddUrlSegment("time_window", timeWindow.GetDescription());
if (page >= 1)
req.AddQueryString("page", page.ToString());
SearchContainer<SearchTv> resp = await req.GetOfT<SearchContainer<SearchTv>>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<SearchContainer<SearchPerson>> GetTrendingPeopleAsync(TimeWindow timeWindow, int page = 0, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("trending/person/{time_window}");
req.AddUrlSegment("time_window", timeWindow.GetDescription());
if (page >= 1)
req.AddQueryString("page", page.ToString());
SearchContainer<SearchPerson> resp = await req.GetOfT<SearchContainer<SearchPerson>>(cancellationToken).ConfigureAwait(false);
return resp;
}
}
}

View File

@ -1,34 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.TvShows;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
/// <summary>
/// Retrieve a collection of tv episode groups by id
/// </summary>
/// <param name="id">Episode group id</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>The requested collection of tv episode groups</returns>
public async Task<TvGroupCollection> GetTvEpisodeGroupsAsync(string id, string language = null, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("tv/episode_group/{id}");
req.AddUrlSegment("id", id);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
using RestResponse<TvGroupCollection> response = await req.Get<TvGroupCollection>(cancellationToken).ConfigureAwait(false);
if (!response.IsValid)
return null;
return await response.GetDataObject().ConfigureAwait(false);
}
}
}

View File

@ -1,219 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.General;
using TMDbLib.Objects.TvShows;
using TMDbLib.Rest;
using TMDbLib.Utilities;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetTvEpisodeMethodInternal<T>(int tvShowId, int seasonNumber, int episodeNumber, TvEpisodeMethods tvShowMethod, string dateFormat = null, string language = null, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("tv/{id}/season/{season_number}/episode/{episode_number}/{method}");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("episode_number", episodeNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", tvShowMethod.GetDescription());
// TODO: Dateformat?
//if (dateFormat != null)
// req.DateFormat = dateFormat;
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
T resp = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return resp;
}
public async Task<TvEpisodeAccountState> GetTvEpisodeAccountStateAsync(int tvShowId, int seasonNumber, int episodeNumber, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("tv/{id}/season/{season_number}/episode/{episode_number}/account_states");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("episode_number", episodeNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", TvEpisodeMethods.AccountStates.GetDescription());
AddSessionId(req, SessionType.UserSession);
using RestResponse<TvEpisodeAccountState> response = await req.Get<TvEpisodeAccountState>(cancellationToken).ConfigureAwait(false);
return await response.GetDataObject().ConfigureAwait(false);
}
/// <summary>
/// Retrieve a specific episode using TMDb id of the associated tv show.
/// </summary>
/// <param name="tvShowId">TMDb id of the tv show the desired episode belongs to.</param>
/// <param name="seasonNumber">The season number of the season the episode belongs to. Note use 0 for specials.</param>
/// <param name="episodeNumber">The episode number of the episode you want to retrieve.</param>
/// <param name="extraMethods">Enum flags indicating any additional data that should be fetched in the same request.</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="includeImageLanguage">If specified the api will attempt to return localized image results eg. en,it,es.</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<TvEpisode> GetTvEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, TvEpisodeMethods extraMethods = TvEpisodeMethods.Undefined, string language = null, string includeImageLanguage = null, CancellationToken cancellationToken = default)
{
if (extraMethods.HasFlag(TvEpisodeMethods.AccountStates))
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("tv/{id}/season/{season_number}/episode/{episode_number}");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("episode_number", episodeNumber.ToString(CultureInfo.InvariantCulture));
if (extraMethods.HasFlag(TvEpisodeMethods.AccountStates))
AddSessionId(req, SessionType.UserSession);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
includeImageLanguage ??= DefaultImageLanguage;
if (!string.IsNullOrWhiteSpace(includeImageLanguage))
req.AddParameter("include_image_language", includeImageLanguage);
string appends = string.Join(",",
Enum.GetValues(typeof(TvEpisodeMethods))
.OfType<TvEpisodeMethods>()
.Except(new[] { TvEpisodeMethods.Undefined })
.Where(s => extraMethods.HasFlag(s))
.Select(s => s.GetDescription()));
if (appends != string.Empty)
req.AddParameter("append_to_response", appends);
using RestResponse<TvEpisode> response = await req.Get<TvEpisode>(cancellationToken).ConfigureAwait(false);
if (!response.IsValid)
return null;
TvEpisode item = await response.GetDataObject().ConfigureAwait(false);
// No data to patch up so return
if (item == null)
return null;
// Patch up data, so that the end user won't notice that we share objects between request-types.
if (item.Videos != null)
item.Videos.Id = item.Id ?? 0;
if (item.Credits != null)
item.Credits.Id = item.Id ?? 0;
if (item.Images != null)
item.Images.Id = item.Id ?? 0;
if (item.ExternalIds != null)
item.ExternalIds.Id = item.Id ?? 0;
return item;
}
public async Task<ResultContainer<TvEpisodeInfo>> GetTvEpisodesScreenedTheatricallyAsync(int tvShowId, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("tv/{tv_id}/screened_theatrically");
req.AddUrlSegment("tv_id", tvShowId.ToString(CultureInfo.InvariantCulture));
return await req.GetOfT<ResultContainer<TvEpisodeInfo>>(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns a credits object for the specified episode.
/// </summary>
/// <param name="tvShowId">The TMDb id of the target tv show.</param>
/// <param name="seasonNumber">The season number of the season the episode belongs to. Note use 0 for specials.</param>
/// <param name="episodeNumber">The episode number of the episode you want to retrieve information for.</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<CreditsWithGuestStars> GetTvEpisodeCreditsAsync(int tvShowId, int seasonNumber, int episodeNumber, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvEpisodeMethodInternal<CreditsWithGuestStars>(tvShowId, seasonNumber, episodeNumber, TvEpisodeMethods.Credits, dateFormat: "yyyy-MM-dd", language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns an object that contains all known exteral id's for the specified episode.
/// </summary>
/// <param name="tvShowId">The TMDb id of the target tv show.</param>
/// <param name="seasonNumber">The season number of the season the episode belongs to. Note use 0 for specials.</param>
/// <param name="episodeNumber">The episode number of the episode you want to retrieve information for.</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<ExternalIdsTvEpisode> GetTvEpisodeExternalIdsAsync(int tvShowId, int seasonNumber, int episodeNumber, CancellationToken cancellationToken = default)
{
return await GetTvEpisodeMethodInternal<ExternalIdsTvEpisode>(tvShowId, seasonNumber, episodeNumber, TvEpisodeMethods.ExternalIds, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Retrieves all images all related to the season of specified episode.
/// </summary>
/// <param name="tvShowId">The TMDb id of the target tv show.</param>
/// <param name="seasonNumber">The season number of the season the episode belongs to. Note use 0 for specials.</param>
/// <param name="episodeNumber">The episode number of the episode you want to retrieve information for.</param>
/// <param name="language">
/// If specified the api will attempt to return a localized result. ex: en,it,es.
/// For images this means that the image might contain language specifc text
/// </param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<StillImages> GetTvEpisodeImagesAsync(int tvShowId, int seasonNumber, int episodeNumber, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvEpisodeMethodInternal<StillImages>(tvShowId, seasonNumber, episodeNumber, TvEpisodeMethods.Images, language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<ResultContainer<Video>> GetTvEpisodeVideosAsync(int tvShowId, int seasonNumber, int episodeNumber, CancellationToken cancellationToken = default)
{
return await GetTvEpisodeMethodInternal<ResultContainer<Video>>(tvShowId, seasonNumber, episodeNumber, TvEpisodeMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> TvEpisodeRemoveRatingAsync(int tvShowId, int seasonNumber, int episodeNumber, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest req = _client.Create("tv/{id}/season/{season_number}/episode/{episode_number}/rating");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("episode_number", episodeNumber.ToString(CultureInfo.InvariantCulture));
AddSessionId(req);
using RestResponse<PostReply> response = await req.Delete<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 13 = "The item/record was deleted successfully."
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Original code had a check for item=null
return item.StatusCode == 13;
}
public async Task<bool> TvEpisodeSetRatingAsync(int tvShowId, int seasonNumber, int episodeNumber, double rating, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest req = _client.Create("tv/{id}/season/{season_number}/episode/{episode_number}/rating");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("episode_number", episodeNumber.ToString(CultureInfo.InvariantCulture));
AddSessionId(req);
req.SetBody(new { value = rating });
using RestResponse<PostReply> response = await req.Post<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 1 = "Success"
// status code 12 = "The item/record was updated successfully" - Used when an item was previously rated by the user
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Original code had a check for item=null
return item.StatusCode == 1 || item.StatusCode == 12;
}
}
}

View File

@ -1,164 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.General;
using TMDbLib.Objects.TvShows;
using TMDbLib.Rest;
using TMDbLib.Utilities;
using Credits = TMDbLib.Objects.TvShows.Credits;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetTvSeasonMethodInternal<T>(int tvShowId, int seasonNumber, TvSeasonMethods tvShowMethod, string dateFormat = null, string language = null, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("tv/{id}/season/{season_number}/{method}");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", tvShowMethod.GetDescription());
// TODO: Dateformat?
//if (dateFormat != null)
// req.DateFormat = dateFormat;
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
T response = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return response;
}
public async Task<ResultContainer<TvEpisodeAccountStateWithNumber>> GetTvSeasonAccountStateAsync(int tvShowId, int seasonNumber, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("tv/{id}/season/{season_number}/account_states");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", TvEpisodeMethods.AccountStates.GetDescription());
AddSessionId(req, SessionType.UserSession);
using RestResponse<ResultContainer<TvEpisodeAccountStateWithNumber>> response = await req.Get<ResultContainer<TvEpisodeAccountStateWithNumber>>(cancellationToken).ConfigureAwait(false);
return await response.GetDataObject().ConfigureAwait(false);
}
/// <summary>
/// Retrieve a season for a specifc tv Show by id.
/// </summary>
/// <param name="tvShowId">TMDb id of the tv show the desired season belongs to.</param>
/// <param name="seasonNumber">The season number of the season you want to retrieve. Note use 0 for specials.</param>
/// <param name="extraMethods">Enum flags indicating any additional data that should be fetched in the same request.</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="includeImageLanguage">If specified the api will attempt to return localized image results eg. en,it,es.</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>The requested season for the specified tv show</returns>
public async Task<TvSeason> GetTvSeasonAsync(int tvShowId, int seasonNumber, TvSeasonMethods extraMethods = TvSeasonMethods.Undefined, string language = null, string includeImageLanguage = null, CancellationToken cancellationToken = default)
{
if (extraMethods.HasFlag(TvSeasonMethods.AccountStates))
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("tv/{id}/season/{season_number}");
req.AddUrlSegment("id", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("season_number", seasonNumber.ToString(CultureInfo.InvariantCulture));
if (extraMethods.HasFlag(TvSeasonMethods.AccountStates))
AddSessionId(req, SessionType.UserSession);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
includeImageLanguage ??= DefaultImageLanguage;
if (!string.IsNullOrWhiteSpace(includeImageLanguage))
req.AddParameter("include_image_language", includeImageLanguage);
string appends = string.Join(",",
Enum.GetValues(typeof(TvSeasonMethods))
.OfType<TvSeasonMethods>()
.Except(new[] { TvSeasonMethods.Undefined })
.Where(s => extraMethods.HasFlag(s))
.Select(s => s.GetDescription()));
if (appends != string.Empty)
req.AddParameter("append_to_response", appends);
using RestResponse<TvSeason> response = await req.Get<TvSeason>(cancellationToken).ConfigureAwait(false);
if (!response.IsValid)
return null;
TvSeason item = await response.GetDataObject().ConfigureAwait(false);
// Nothing to patch up
if (item == null)
return null;
if (item.Images != null)
item.Images.Id = item.Id ?? 0;
if (item.Credits != null)
item.Credits.Id = item.Id ?? 0;
if (item.ExternalIds != null)
item.ExternalIds.Id = item.Id ?? 0;
if (item.AccountStates != null)
item.AccountStates.Id = item.Id ?? 0;
if (item.Videos != null)
item.Videos.Id = item.Id ?? 0;
return item;
}
/// <summary>
/// Returns a credits object for the season of the tv show associated with the provided TMDb id.
/// </summary>
/// <param name="tvShowId">The TMDb id of the target tv show.</param>
/// <param name="seasonNumber">The season number of the season you want to retrieve information for. Note use 0 for specials.</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<Credits> GetTvSeasonCreditsAsync(int tvShowId, int seasonNumber, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvSeasonMethodInternal<Credits>(tvShowId, seasonNumber, TvSeasonMethods.Credits, dateFormat: "yyyy-MM-dd", language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns an object that contains all known exteral id's for the season of the tv show related to the specified TMDB id.
/// </summary>
/// <param name="tvShowId">The TMDb id of the target tv show.</param>
/// <param name="seasonNumber">The season number of the season you want to retrieve information for. Note use 0 for specials.</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<ExternalIdsTvSeason> GetTvSeasonExternalIdsAsync(int tvShowId, int seasonNumber, CancellationToken cancellationToken = default)
{
return await GetTvSeasonMethodInternal<ExternalIdsTvSeason>(tvShowId, seasonNumber, TvSeasonMethods.ExternalIds, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Retrieves all images all related to the season of specified tv show.
/// </summary>
/// <param name="tvShowId">The TMDb id of the target tv show.</param>
/// <param name="seasonNumber">The season number of the season you want to retrieve information for. Note use 0 for specials.</param>
/// <param name="language">
/// If specified the api will attempt to return a localized result. ex: en,it,es.
/// For images this means that the image might contain language specifc text
/// </param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<PosterImages> GetTvSeasonImagesAsync(int tvShowId, int seasonNumber, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvSeasonMethodInternal<PosterImages>(tvShowId, seasonNumber, TvSeasonMethods.Images, language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<ResultContainer<Video>> GetTvSeasonVideosAsync(int tvShowId, int seasonNumber, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvSeasonMethodInternal<ResultContainer<Video>>(tvShowId, seasonNumber, TvSeasonMethods.Videos, language: language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -1,377 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.Changes;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Reviews;
using TMDbLib.Objects.Search;
using TMDbLib.Objects.TvShows;
using TMDbLib.Rest;
using TMDbLib.Utilities;
using Credits = TMDbLib.Objects.TvShows.Credits;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
private async Task<T> GetTvShowMethodInternal<T>(int id, TvShowMethods tvShowMethod, string dateFormat = null, string language = null, string includeImageLanguage = null, int page = 0, CancellationToken cancellationToken = default) where T : new()
{
RestRequest req = _client.Create("tv/{id}/{method}");
req.AddUrlSegment("id", id.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", tvShowMethod.GetDescription());
// TODO: Dateformat?
//if (dateFormat != null)
// req.DateFormat = dateFormat;
if (page > 0)
req.AddParameter("page", page.ToString());
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
includeImageLanguage ??= DefaultImageLanguage;
if (!string.IsNullOrWhiteSpace(includeImageLanguage))
req.AddParameter("include_image_language", includeImageLanguage);
T resp = await req.GetOfT<T>(cancellationToken).ConfigureAwait(false);
return resp;
}
private async Task<SearchContainer<SearchTv>> GetTvShowListInternal(int page, string language, string tvShowListType, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("tv/" + tvShowListType);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
if (page >= 1)
req.AddParameter("page", page.ToString());
SearchContainer<SearchTv> response = await req.GetOfT<SearchContainer<SearchTv>>(cancellationToken).ConfigureAwait(false);
return response;
}
public async Task<TvShow> GetLatestTvShowAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("tv/latest");
TvShow resp = await req.GetOfT<TvShow>(cancellationToken).ConfigureAwait(false);
return resp;
}
/// <summary>
/// Retrieves all information for a specific tv show in relation to the current user account
/// </summary>
/// <param name="tvShowId">The id of the tv show to get the account states for</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <remarks>Requires a valid user session</remarks>
/// <exception cref="UserSessionRequiredException">Thrown when the current client object doens't have a user session assigned.</exception>
public async Task<AccountState> GetTvShowAccountStateAsync(int tvShowId, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("tv/{tvShowId}/{method}");
req.AddUrlSegment("tvShowId", tvShowId.ToString(CultureInfo.InvariantCulture));
req.AddUrlSegment("method", TvShowMethods.AccountStates.GetDescription());
AddSessionId(req, SessionType.UserSession);
using RestResponse<AccountState> response = await req.Get<AccountState>(cancellationToken).ConfigureAwait(false);
return await response.GetDataObject().ConfigureAwait(false);
}
public async Task<ResultContainer<AlternativeTitle>> GetTvShowAlternativeTitlesAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<ResultContainer<AlternativeTitle>>(id, TvShowMethods.AlternativeTitles, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Retrieve a tv Show by id.
/// </summary>
/// <param name="id">TMDb id of the tv show to retrieve.</param>
/// <param name="extraMethods">Enum flags indicating any additional data that should be fetched in the same request.</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es.</param>
/// <param name="includeImageLanguage">If specified the api will attempt to return localized image results eg. en,it,es.</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>The requested Tv Show</returns>
public async Task<TvShow> GetTvShowAsync(int id, TvShowMethods extraMethods = TvShowMethods.Undefined, string language = null, string includeImageLanguage = null, CancellationToken cancellationToken = default)
{
if (extraMethods.HasFlag(TvShowMethods.AccountStates))
RequireSessionId(SessionType.UserSession);
RestRequest req = _client.Create("tv/{id}");
req.AddUrlSegment("id", id.ToString(CultureInfo.InvariantCulture));
if (extraMethods.HasFlag(TvShowMethods.AccountStates))
AddSessionId(req, SessionType.UserSession);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
if (!string.IsNullOrWhiteSpace(includeImageLanguage))
req.AddParameter("include_image_language", includeImageLanguage);
string appends = string.Join(",",
Enum.GetValues(typeof(TvShowMethods))
.OfType<TvShowMethods>()
.Except(new[] { TvShowMethods.Undefined })
.Where(s => extraMethods.HasFlag(s))
.Select(s => s.GetDescription()));
if (appends != string.Empty)
req.AddParameter("append_to_response", appends);
using RestResponse<TvShow> response = await req.Get<TvShow>(cancellationToken).ConfigureAwait(false);
if (!response.IsValid)
return null;
TvShow item = await response.GetDataObject().ConfigureAwait(false);
// No data to patch up so return
if (item == null)
return null;
// Patch up data, so that the end user won't notice that we share objects between request-types.
if (item.Translations != null)
item.Translations.Id = id;
if (item.AccountStates != null)
item.AccountStates.Id = id;
if (item.Recommendations != null)
item.Recommendations.Id = id;
if (item.ExternalIds != null)
item.ExternalIds.Id = id;
return item;
}
public async Task<ChangesContainer> GetTvShowChangesAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<ChangesContainer>(id, TvShowMethods.Changes, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<ResultContainer<ContentRating>> GetTvShowContentRatingsAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<ResultContainer<ContentRating>>(id, TvShowMethods.ContentRatings, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns a credits object for the tv show associated with the provided TMDb id.
/// </summary>
/// <param name="id">The TMDb id of the target tv show.</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<Credits> GetTvShowCreditsAsync(int id, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<Credits>(id, TvShowMethods.Credits, "yyyy-MM-dd", language, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns a credits object for the aggragation of tv show associated with the provided TMDb id.
/// </summary>
/// <param name="id">The TMDb id of the target tv show.</param>
/// <param name="language">If specified the api will attempt to return a localized result. ex: en,it,es </param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns></returns>
public async Task<CreditsAggregate> GetAggregateCredits(int id, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<CreditsAggregate>(id, TvShowMethods.CreditsAggregate, language: language, page: 0, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns an object that contains all known exteral id's for the tv show related to the specified TMDB id.
/// </summary>
/// <param name="id">The TMDb id of the target tv show.</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<ExternalIdsTvShow> GetTvShowExternalIdsAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<ExternalIdsTvShow>(id, TvShowMethods.ExternalIds, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Retrieves all images all related to the specified tv show.
/// </summary>
/// <param name="id">The TMDb id of the target tv show.</param>
/// <param name="language">
/// If specified the api will attempt to return a localized result. ex: en,it,es.
/// For images this means that the image might contain language specifc text
/// </param>
/// <param name="includeImageLanguage">If you want to include a fallback language (especially useful for backdrops) you can use the include_image_language parameter. This should be a comma separated value like so: include_image_language=en,null.</param>
/// <param name="cancellationToken">A cancellation token</param>
public async Task<ImagesWithId> GetTvShowImagesAsync(int id, string language = null, string includeImageLanguage = null, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<ImagesWithId>(id, TvShowMethods.Images, language: language, includeImageLanguage: includeImageLanguage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainerWithId<ReviewBase>> GetTvShowReviewsAsync(int id, string language = null, int page = 0, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<SearchContainerWithId<ReviewBase>>(id, TvShowMethods.Reviews, language: language, page: page, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<ResultContainer<Keyword>> GetTvShowKeywordsAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<ResultContainer<Keyword>>(id, TvShowMethods.Keywords, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches a dynamic list of TV Shows
/// </summary>
/// <param name="list">Type of list to fetch</param>
/// <param name="page">Page</param>
/// <param name="timezone">Only relevant for list type AiringToday</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns></returns>
public async Task<SearchContainer<SearchTv>> GetTvShowListAsync(TvShowListType list, int page = 0, string timezone = null, CancellationToken cancellationToken = default)
{
return await GetTvShowListAsync(list, DefaultLanguage, page, timezone, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches a dynamic list of TV Shows
/// </summary>
/// <param name="list">Type of list to fetch</param>
/// <param name="language">Language</param>
/// <param name="page">Page</param>
/// <param name="timezone">Only relevant for list type AiringToday</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns></returns>
public async Task<SearchContainer<SearchTv>> GetTvShowListAsync(TvShowListType list, string language, int page = 0, string timezone = null, CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("tv/{method}");
req.AddUrlSegment("method", list.GetDescription());
if (page > 0)
req.AddParameter("page", page.ToString());
if (!string.IsNullOrEmpty(timezone))
req.AddParameter("timezone", timezone);
language ??= DefaultLanguage;
if (!string.IsNullOrWhiteSpace(language))
req.AddParameter("language", language);
SearchContainer<SearchTv> resp = await req.GetOfT<SearchContainer<SearchTv>>(cancellationToken).ConfigureAwait(false);
return resp;
}
/// <summary>
/// Get the list of popular TV shows. This list refreshes every day.
/// </summary>
/// <returns>
/// Returns the basic information about a tv show.
/// For additional data use the main GetTvShowAsync method using the tv show id as parameter.
/// </returns>
public async Task<SearchContainer<SearchTv>> GetTvShowPopularAsync(int page = -1, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvShowListInternal(page, language, "popular", cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchTv>> GetTvShowSimilarAsync(int id, int page = 0, CancellationToken cancellationToken = default)
{
return await GetTvShowSimilarAsync(id, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchTv>> GetTvShowSimilarAsync(int id, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<SearchContainer<SearchTv>>(id, TvShowMethods.Similar, language: language, page: page, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchTv>> GetTvShowRecommendationsAsync(int id, int page = 0, CancellationToken cancellationToken = default)
{
return await GetTvShowRecommendationsAsync(id, DefaultLanguage, page, cancellationToken).ConfigureAwait(false);
}
public async Task<SearchContainer<SearchTv>> GetTvShowRecommendationsAsync(int id, string language, int page = 0, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<SearchContainer<SearchTv>>(id, TvShowMethods.Recommendations, language: language, page: page, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Get the list of top rated TV shows. By default, this list will only include TV shows that have 2 or more votes. This list refreshes every day.
/// </summary>
/// <returns>
/// Returns the basic information about a tv show.
/// For additional data use the main GetTvShowAsync method using the tv show id as parameter
/// </returns>
public async Task<SearchContainer<SearchTv>> GetTvShowTopRatedAsync(int page = -1, string language = null, CancellationToken cancellationToken = default)
{
return await GetTvShowListInternal(page, language, "top_rated", cancellationToken).ConfigureAwait(false);
}
public async Task<TranslationsContainerTv> GetTvShowTranslationsAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<TranslationsContainerTv>(id, TvShowMethods.Translations, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<ResultContainer<Video>> GetTvShowVideosAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<ResultContainer<Video>>(id, TvShowMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<SingleResultContainer<Dictionary<string, WatchProviders>>> GetTvShowWatchProvidersAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTvShowMethodInternal<SingleResultContainer<Dictionary<string, WatchProviders>>>(id, TvShowMethods.WatchProviders, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<bool> TvShowRemoveRatingAsync(int tvShowId, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest req = _client.Create("tv/{tvShowId}/rating");
req.AddUrlSegment("tvShowId", tvShowId.ToString(CultureInfo.InvariantCulture));
AddSessionId(req);
using RestResponse<PostReply> response = await req.Delete<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 13 = "The item/record was deleted successfully."
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Original code had a check for item=null
return item.StatusCode == 13;
}
/// <summary>
/// Change the rating of a specified tv show.
/// </summary>
/// <param name="tvShowId">The id of the tv show to rate</param>
/// <param name="rating">The rating you wish to assign to the specified tv show. Value needs to be between 0.5 and 10 and must use increments of 0.5. Ex. using 7.1 will not work and return false.</param>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>True if the the tv show's rating was successfully updated, false if not</returns>
/// <remarks>Requires a valid guest or user session</remarks>
/// <exception cref="GuestSessionRequiredException">Thrown when the current client object doens't have a guest or user session assigned.</exception>
public async Task<bool> TvShowSetRatingAsync(int tvShowId, double rating, CancellationToken cancellationToken = default)
{
RequireSessionId(SessionType.GuestSession);
RestRequest req = _client.Create("tv/{tvShowId}/rating");
req.AddUrlSegment("tvShowId", tvShowId.ToString(CultureInfo.InvariantCulture));
AddSessionId(req);
req.SetBody(new { value = rating });
using RestResponse<PostReply> response = await req.Post<PostReply>(cancellationToken).ConfigureAwait(false);
// status code 1 = "Success"
// status code 12 = "The item/record was updated successfully" - Used when an item was previously rated by the user
PostReply item = await response.GetDataObject().ConfigureAwait(false);
// TODO: Original code had a check for item=null
return item.StatusCode == 1 || item.StatusCode == 12;
}
}
}

View File

@ -1,64 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using TMDbLib.Objects.General;
using TMDbLib.Rest;
namespace TMDbLib.Client
{
public partial class TMDbClient
{
/// <summary>
/// Returns a list of all of the countries TMDb has watch provider (OTT/streaming) data for.
/// </summary>
/// <param name="cancellationToken">A cancellation token</param>
/// <remarks>Uses <see cref="DefaultLanguage"/> to translate data</remarks>
public async Task<ResultContainer<WatchProviderRegion>> GetWatchProviderRegionsAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("watch/providers/regions");
if (DefaultLanguage != null)
req.AddParameter("language", DefaultLanguage);
ResultContainer<WatchProviderRegion> response = await req.GetOfT<ResultContainer<WatchProviderRegion>>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Returns a list of the watch provider (OTT/streaming) data TMDb has available for movies.
/// </summary>
/// <param name="cancellationToken">A cancellation token</param>
/// <remarks>Uses <see cref="DefaultCountry"/> and <see cref="DefaultLanguage"/> to filter or translate data</remarks>
public async Task<ResultContainer<WatchProviderItem>> GetMovieWatchProvidersAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("watch/providers/movie");
if (DefaultLanguage != null)
req.AddParameter("language", DefaultLanguage);
if (DefaultCountry != null)
req.AddParameter("watch_region", DefaultCountry);
ResultContainer<WatchProviderItem> response = await req.GetOfT<ResultContainer<WatchProviderItem>>(cancellationToken).ConfigureAwait(false);
return response;
}
/// <summary>
/// Returns a list of the watch provider (OTT/streaming) data TMDb has available for shows.
/// </summary>
/// <param name="cancellationToken">A cancellation token</param>
/// <remarks>Uses <see cref="DefaultCountry"/> and <see cref="DefaultLanguage"/> to filter or translate data</remarks>
public async Task<ResultContainer<WatchProviderItem>> GetTvWatchProvidersAsync(CancellationToken cancellationToken = default)
{
RestRequest req = _client.Create("watch/providers/tv");
if (DefaultLanguage != null)
req.AddParameter("language", DefaultLanguage);
if (DefaultCountry != null)
req.AddParameter("watch_region", DefaultCountry);
ResultContainer<WatchProviderItem> response = await req.GetOfT<ResultContainer<WatchProviderItem>>(cancellationToken).ConfigureAwait(false);
return response;
}
}
}

Some files were not shown because too many files have changed in this diff Show More