├── .github └── workflows │ └── dotnetcore.yml ├── .gitignore ├── Jellyfin.Plugin.Douban.Tests ├── FrodoClientTest.cs ├── ImageProviderTest.cs ├── Jellyfin.Plugin.Douban.Tests.csproj ├── LRUCacheTest.cs ├── MovieProviderTest.cs ├── ServiceUtils.cs ├── TvProviderTest.cs └── test.json ├── Jellyfin.Plugin.Douban ├── CHANGELOG.md ├── Clients │ ├── FrodoAndroidClient.cs │ ├── IDoubanClient.cs │ ├── Response │ │ ├── SearchResult.cs │ │ └── Subject.cs │ └── WechatClient.cs ├── Configuration │ ├── PluginConfiguration.cs │ └── configPage.html ├── ExternalID.cs ├── Jellyfin.Plugin.Douban.csproj ├── LRUCache.cs ├── Plugin.cs └── Providers │ ├── BaseProvider.cs │ ├── ImageProvider.cs │ ├── MovieProvider.cs │ └── TVProvider.cs ├── LICENSE ├── README.md ├── assets ├── enable_advanced_settings.png ├── enable_douban_image_provider.png ├── enable_douban_provider.png └── language_and_country.png ├── build.yaml └── jellyfin-plugin-douban.sln /.github/workflows/dotnetcore.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Setup .NET Core 15 | uses: actions/setup-dotnet@v4 16 | with: 17 | dotnet-version: 6.0.x 18 | - name: Install dependencies 19 | run: dotnet restore 20 | - name: Build 21 | run: dotnet build --configuration Release --no-restore 22 | # - name: Test 23 | # run: dotnet test --no-restore --verbosity normal 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vs/ 4 | .vscode/ 5 | Properties/ 6 | *.user 7 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/FrodoClientTest.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | using Jellyfin.Plugin.Douban.Clients; 8 | 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | namespace Jellyfin.Plugin.Douban.Tests 13 | { 14 | public class FrodoClientTest 15 | { 16 | private readonly FrodoAndroidClient _client; 17 | 18 | public FrodoClientTest(ITestOutputHelper output) 19 | { 20 | var serviceProvider = ServiceUtils.BuildServiceProvider(output); 21 | _client = serviceProvider.GetService(); 22 | } 23 | 24 | //[Fact] 25 | public async void TestGetMovieItem() 26 | { 27 | // Test for right case. 28 | Response.Subject item = await _client.GetSubject("1291561", DoubanType.movie, CancellationToken.None); 29 | Assert.Equal("1291561", item.Id); 30 | Assert.False(item.Is_Tv); 31 | Assert.Equal("千与千寻", item.Title); 32 | 33 | // Test if the type of subject is error. 34 | await Assert.ThrowsAsync( 35 | () => _client.GetSubject("3016187", DoubanType.movie, CancellationToken.None)); 36 | 37 | // For cache 38 | item = await _client.GetSubject("1291561", DoubanType.movie, CancellationToken.None); 39 | Assert.Equal("千与千寻", item.Title); 40 | } 41 | 42 | // [Fact] 43 | public async void TestGetTvItem() 44 | { 45 | // Test for right case. 46 | Response.Subject item = await _client.GetSubject("3016187", DoubanType.tv, CancellationToken.None); 47 | Assert.Equal("3016187", item.Id); 48 | Assert.True(item.Is_Tv); 49 | Assert.Equal("权力的游戏 第一季", item.Title); 50 | 51 | // Test if the type of subject is error. 52 | await Assert.ThrowsAsync( 53 | () => _client.GetSubject("1291561", DoubanType.tv, CancellationToken.None)); 54 | } 55 | 56 | //[Fact] 57 | public async Task TestSearch() 58 | { 59 | // Test search movie. 60 | Response.SearchResult result = await _client.Search("权力的游戏 第一季", CancellationToken.None); 61 | Assert.Equal(5, result.Subjects.Items.Count); 62 | Assert.Equal("tv", result.Subjects.Items[0].Target_Type); 63 | Assert.Equal("3016187", result.Subjects.Items[0].Target.Id); 64 | 65 | // Test search TV. 66 | result = await _client.Search("千与千寻", CancellationToken.None); 67 | Assert.Equal(5, result.Subjects.Items.Count); 68 | Assert.Equal("movie", result.Subjects.Items[0].Target_Type); 69 | Assert.Equal("1291561", result.Subjects.Items[0].Target.Id); 70 | 71 | // Test not found. 72 | result = await _client.Search("abceasd234asd", CancellationToken.None); 73 | Assert.Empty(result.Subjects.Items); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/ImageProviderTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | using Jellyfin.Plugin.Douban.Providers; 8 | 9 | using MediaBrowser.Model.Entities; 10 | 11 | using Xunit; 12 | using Xunit.Abstractions; 13 | 14 | namespace Jellyfin.Plugin.Douban.Tests 15 | { 16 | public class ImageProviderTest 17 | { 18 | private readonly ImageProvider _provider; 19 | 20 | public ImageProviderTest(ITestOutputHelper output) 21 | { 22 | var serviceProvider = ServiceUtils.BuildServiceProvider(output); 23 | _provider = serviceProvider.GetService(); 24 | } 25 | 26 | 27 | [Fact] 28 | public async Task TestGetPrimary() 29 | { 30 | var list = await _provider.GetPrimary("5350027", "movie", CancellationToken.None); 31 | Assert.Single(list); 32 | foreach (var item in list) 33 | { 34 | Assert.Equal(ImageType.Primary, item.Type); 35 | Assert.EndsWith("p2530249558.jpg", item.Url); 36 | } 37 | } 38 | 39 | [Fact] 40 | public async Task TestGetBackdrop() 41 | { 42 | // Test 1: 43 | var list = await _provider.GetBackdrop("5350027", CancellationToken.None); 44 | foreach (var item in list) 45 | { 46 | Console.WriteLine(item.Url); 47 | Assert.Equal(ImageType.Backdrop, item.Type); 48 | } 49 | Assert.Single(list); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/Jellyfin.Plugin.Douban.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/LRUCacheTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | using Xunit; 8 | 9 | namespace Jellyfin.Plugin.Douban.Tests 10 | { 11 | public class LRUCacheTest 12 | { 13 | private LRUCache cache; 14 | 15 | [Fact] 16 | public void TestAdd() 17 | { 18 | cache = new LRUCache(2); 19 | cache.Add("1", "1"); 20 | cache.Add("2", "2"); 21 | cache.Add("3", "3"); 22 | 23 | // "1" should not exist. 24 | Assert.False(cache.TryGet("1", out string value)); 25 | Assert.True(cache.TryGet("2", out value)); 26 | Assert.Equal("2", value); 27 | Assert.True(cache.TryGet("3", out value)); 28 | Assert.Equal("3", value); 29 | 30 | cache.Add("4", "4"); 31 | Assert.False(cache.TryGet("2", out value)); 32 | Assert.True(cache.TryGet("4", out value)); 33 | Assert.Equal("4", value); 34 | } 35 | 36 | [Fact] 37 | public void TestLRU1() 38 | { 39 | cache = new LRUCache(2); 40 | cache.Add("1", "1"); 41 | cache.Add("2", "2"); 42 | cache.Add("3", "3"); 43 | 44 | Assert.True(cache.TryGet("2", out string value)); 45 | Assert.Equal("2", value); 46 | 47 | cache.Add("4", "4"); 48 | 49 | Assert.False(cache.TryGet("3", out _)); 50 | 51 | Assert.True(cache.TryGet("2", out value)); 52 | Assert.Equal("2", value); 53 | 54 | Assert.True(cache.TryGet("4", out value)); 55 | Assert.Equal("4", value); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/MovieProviderTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | using Jellyfin.Plugin.Douban.Providers; 9 | 10 | using MediaBrowser.Controller.Providers; 11 | using MediaBrowser.Model.Entities; 12 | 13 | using Xunit; 14 | using Xunit.Abstractions; 15 | 16 | namespace Jellyfin.Plugin.Douban.Tests 17 | { 18 | public class MovieProviderTest 19 | { 20 | private readonly MovieProvider _provider; 21 | public MovieProviderTest(ITestOutputHelper output) 22 | { 23 | var serviceProvider = ServiceUtils.BuildServiceProvider(output); 24 | _provider = serviceProvider.GetService(); 25 | } 26 | 27 | [Fact] 28 | public async Task TestGetSearchResults() 29 | { 30 | // Test 1: search metadata. 31 | MovieInfo info = new MovieInfo() 32 | { 33 | Name = "蝙蝠侠.黑暗骑士", 34 | }; 35 | 36 | var result = await _provider.GetSearchResults(info, CancellationToken.None); 37 | Assert.NotEmpty(result); 38 | string doubanId = result.FirstOrDefault()?.GetProviderId(BaseProvider.ProviderID); 39 | int? year = result.FirstOrDefault()?.ProductionYear; 40 | Assert.Equal("1851857", doubanId); 41 | Assert.Equal(2008, year); 42 | 43 | // Test 2: Already has provider Id. 44 | info.SetProviderId(BaseProvider.ProviderID, "1851857"); 45 | result = await _provider.GetSearchResults(info, CancellationToken.None); 46 | Assert.Single(result); 47 | doubanId = result.FirstOrDefault()?.GetProviderId(BaseProvider.ProviderID); 48 | year = result.FirstOrDefault()?.ProductionYear; 49 | Assert.Equal("1851857", doubanId); 50 | Assert.Equal(2008, year); 51 | } 52 | 53 | [Fact] 54 | public async Task TestGetMetadata() 55 | { 56 | // Test 1: Normal case. 57 | MovieInfo info = new MovieInfo() 58 | { 59 | Name = "Source Code" 60 | }; 61 | var meta = await _provider.GetMetadata(info, CancellationToken.None); 62 | Assert.True(meta.HasMetadata); 63 | Assert.Equal("源代码", meta.Item.Name); 64 | Assert.Equal("3075287", meta.Item.GetProviderId(BaseProvider.ProviderID)); 65 | Assert.Equal(DateTime.Parse("2011-08-30"), meta.Item.PremiereDate); 66 | 67 | // Test 2: Already has provider Id. 68 | info = new MovieInfo() 69 | { 70 | Name = "Source Code" 71 | }; 72 | info.SetProviderId(BaseProvider.ProviderID, "1851857"); 73 | meta = await _provider.GetMetadata(info, CancellationToken.None); 74 | Assert.True(meta.HasMetadata); 75 | Assert.Equal("蝙蝠侠:黑暗骑士", meta.Item.Name); 76 | 77 | // Test 2: Not movie type. 78 | info = new MovieInfo() 79 | { 80 | Name = "大秦帝国" 81 | }; 82 | meta = await _provider.GetMetadata(info, CancellationToken.None); 83 | Assert.False(meta.HasMetadata); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/ServiceUtils.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | 4 | using Xunit.Abstractions; 5 | 6 | namespace Jellyfin.Plugin.Douban.Tests 7 | { 8 | class ServiceUtils 9 | { 10 | public static ServiceProvider BuildServiceProvider(ITestOutputHelper output) where T : class 11 | { 12 | var services = new ServiceCollection() 13 | .AddHttpClient() 14 | //.AddLogging(builder => builder.AddXUnit(output).SetMinimumLevel(LogLevel.Debug)) 15 | .AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Debug)) 16 | .AddSingleton(); 17 | 18 | var serviceProvider = services.BuildServiceProvider(); 19 | 20 | // Used For FrodoAndroidClient which can not use typed ILogger. 21 | var logger = serviceProvider.GetService>(); 22 | services.AddSingleton(logger); 23 | 24 | return services.BuildServiceProvider(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/TvProviderTest.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using MediaBrowser.Controller.Providers; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | using Jellyfin.Plugin.Douban.Providers; 8 | 9 | namespace Jellyfin.Plugin.Douban.Tests 10 | { 11 | public class TvProviderTest 12 | { 13 | private readonly TVProvider _doubanProvider; 14 | public TvProviderTest(ITestOutputHelper output) 15 | { 16 | var serviceProvider = ServiceUtils.BuildServiceProvider(output); 17 | _doubanProvider = serviceProvider.GetService(); 18 | } 19 | 20 | [Fact] 21 | public async void TestSearchSeries() 22 | { 23 | SeriesInfo info = new SeriesInfo() 24 | { 25 | Name = "老友记", 26 | }; 27 | var result = await _doubanProvider.GetSearchResults(info, CancellationToken.None); 28 | 29 | Assert.NotEmpty(result); 30 | } 31 | 32 | [Fact] 33 | public async void TestGetEpisodeMetadata() 34 | { 35 | EpisodeInfo episodeInfo = new EpisodeInfo() 36 | { 37 | Name = "老友记 第一季", 38 | ParentIndexNumber = 1, 39 | IndexNumber = 1, 40 | }; 41 | 42 | episodeInfo.SeriesProviderIds["DoubanID"] = "1393859"; 43 | var metadataResult = await _doubanProvider.GetMetadata(episodeInfo, CancellationToken.None); 44 | Assert.True(metadataResult.HasMetadata); 45 | 46 | EpisodeInfo episodeInfo2 = new EpisodeInfo() 47 | { 48 | Name = "老友记 第一季", 49 | ParentIndexNumber = 1, 50 | IndexNumber = 2, 51 | }; 52 | 53 | episodeInfo2.SeriesProviderIds["DoubanID"] = "1393859"; 54 | var metadataResult2 = await _doubanProvider.GetMetadata(episodeInfo2, CancellationToken.None); 55 | Assert.True(metadataResult2.HasMetadata); 56 | } 57 | 58 | [Fact] 59 | public async void TestGetSeasonMetadata() 60 | { 61 | SeasonInfo seasonInfo = new SeasonInfo() 62 | { 63 | Name = "老友记 第二季" 64 | }; 65 | seasonInfo.SeriesProviderIds["DoubanID"] = "1393859"; 66 | var metadataResult = await _doubanProvider.GetMetadata(seasonInfo, CancellationToken.None); 67 | 68 | Assert.True(metadataResult.HasMetadata); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban.Tests/test.json: -------------------------------------------------------------------------------- 1 | {"rating": {"count": 856977, "max": 10, "star_count": 4.5, "value": 8.5}, "lineticket_url": "", "controversy_reason": "", "pubdate": ["2011-08-30(\u4e2d\u56fd\u5927\u9646)"], "last_episode_number": null, "interest_control_info": null, "pic": {"large": "https://img9.doubanio.com\/view\/photo\/m_ratio_poster\/public\/p988260245.jpg", "normal": "https://img9.doubanio.com\/view\/photo\/s_ratio_poster\/public\/p988260245.jpg"}, "year": "2011", "vendor_count": 3, "body_bg_color": "f4f8f9", "is_tv": false, "card_subtitle": "2011 \/ \u7f8e\u56fd \u52a0\u62ff\u5927 \/ \u79d1\u5e7b \u60ac\u7591 \u60ca\u609a \/ \u9093\u80af\u00b7\u743c\u65af \/ \u6770\u514b\u00b7\u5409\u4f26\u54c8\u5c14 \u7ef4\u62c9\u00b7\u6cd5\u7c73\u52a0", "album_no_interact": false, "ticket_price_info": "", "webisode_count": 0, "can_rate": true, "head_info": null, "forum_info": null, "share_activities": [], "webisode": null, "id": "3075287", "gallery_topic_count": 0, "languages": ["\u82f1\u8bed"], "genres": ["\u79d1\u5e7b", "\u60ac\u7591", "\u60ca\u609a"], "review_count": 2638, "variable_modules": [{"sub_modules": [], "id": "rating"}, {"sub_modules": [], "id": "other_interests"}, {"sub_modules": [], "id": "video_photos"}, {"sub_modules": [], "id": "tags"}, {"sub_modules": [{"id": "screenshot"}, {"id": "doulist"}, {"id": "status"}, {"id": "other_channels"}], "id": "share"}, {"sub_modules": [], "id": "related_items"}, {"sub_modules": [{"id": "count"}, {"data": [{"name": "\u70ed\u95e8", "id": "hot"}, {"name": "\u6700\u65b0", "id": "latest"}, {"name": "\u53cb\u90bb", "id": "friend"}], "id": "sort_by", "data_type": "sort_by"}, {"id": "rating_scope"}], "id": "comments"}, {"sub_modules": [], "id": "honor_infos"}, {"sub_modules": [], "id": "interest"}, {"sub_modules": [], "id": "related_subjects"}, {"sub_modules": [{"data": {"count": 2563, "title": "\u7efc\u5408", "uri": "douban:\/\/partial.douban.com\/subject\/3075287\/suggest", "source": "\u7efc\u5408", "type": "mixed_suggestion", "id": "mixed_suggestion"}, "id": "ugc_tab", "data_type": "ugc_tab"}, {"data": {"source": "reviews", "title": "\u5f71\u8bc4", "type": "review", "sort_by": [{"name": "\u70ed\u95e8", "id": "hot"}, {"name": "\u6700\u65b0", "id": "latest"}, {"name": "\u53cb\u90bb", "id": "friend"}]}, "id": "ugc_tab", "data_type": "ugc_tab"}, {"data": {"source": "forum_topics", "type": "forum", "title": "\u8ba8\u8bba"}, "id": "ugc_tab", "data_type": "ugc_tab"}], "id": "ugc_tabs"}], "title": "\u6e90\u4ee3\u7801", "intro": "\u5728\u963f\u5bcc\u6c57\u6267\u884c\u4efb\u52a1\u7684\u7f8e\u56fd\u7a7a\u519b\u98de\u884c\u5458\u79d1\u7279\u53f2\u8482\u6587\u65af\u4e0a\u5c09\uff08\u6770\u514b\u00b7\u5409\u4f26\u54c8\u5c14 Jake Gyllenhaal \u9970\uff09\u7a81\u7136\u60ca\u9192\uff0c\u53d1\u73b0\u81ea\u5df1\u5728\u4e00\u8f86\u9ad8\u901f\u884c\u9a76\u7684\u5217\u8f66\u4e0a\uff0c\u800c\u4ed6\u7684\u8eab\u8fb9\u5750\u7740\u4e00\u4e2a\u7d20\u4e0d\u76f8\u8bc6\u7684\u5973\u5b50\u514b\u91cc\u65af\u8482\u5b89\uff08\u7c73\u6b47\u5c14\u00b7\u83ab\u5a1c\u6c49 Michelle Monaghan \u9970\uff09\u6b63\u5728\u4e0e\u81ea\u5df1\u8bb2\u8bdd\u3002\u79d1\u5c14\u4e0d\u77e5\u81ea\u5df1\u4e3a\u4ec0\u4e48\u4f1a\u5728\u8fd9\u8f86\u8f66\u4e0a\uff0c\u800c\u4e14\u4ed6\u53d1\u73b0\u81ea\u5df1\u5c45\u7136\u662f\u4ee5\u53e6\u4e00\u4e2a\u4eba\u7684\u8eab\u4efd\u5b58\u5728\uff0c\u6b63\u5f53\u4ed6\u8ff7\u60d1\u4e0d\u89e3\u7684\u65f6\u5019\uff0c\u5217\u8f66\u4e0a\u5ffd\u7136\u53d1\u751f\u7206\u70b8\u2026\u2026\n\u79d1\u7279\u53c8\u4e00\u6b21\u60ca\u9192\uff0c\u53d1\u73b0\u81ea\u5df1\u8eab\u5904\u4e00\u4e2a\u5bc6\u95ed\u7684\u592a\u7a7a\u4ed3\u91cc\uff0c\u6709\u4e00\u4f4d\u5973\u519b\u5b98\u53e4\u5fb7\u6e29\uff08\u7ef4\u62c9\u00b7\u6cd5\u7c73\u52a0 Vera Farmiga \u9970\uff09\u6b63\u5728\u901a\u8fc7\u89c6\u9891\u548c\u81ea\u5df1\u5bf9\u8bdd\uff0c\u5e76\u8981\u6c42\u81ea\u5df1\u62a5\u544a\u5217\u8f66\u4e0a\u53d1\u751f\u7684\u4e8b\u60c5\u3002\u4e00\u5934\u96fe\u6c34\u7684\u79d1\u7279\u8fd8\u6ca1\u641e\u660e\u767d\u662f\u600e\u4e48\u56de\u4e8b\u65f6\uff0c\u4ed6\u53c8\u4e00\u6b21\u88ab\u9001\u4e0a\u90a3\u8f86\u5217\u8f66\u3002\u8fd9\u6b21\u4e4b\u540e\uff0c\u79d1\u7279\u7ec8\u4e8e\u660e\u767d\u81ea\u5df1\u5728\u6267\u884c\u4e00\u4ef6\u4efb\u52a1\uff0c\u8d1f\u8d23\u8c03\u67e5\u829d\u52a0\u54e5\u706b\u8f66\u7206\u70b8\u6848\u627e\u5230\u6050\u6016\u4efd\u5b50\u5e76\u67e5\u51fa\u4ed6\u7684\u4e0b\u4e00\u4e2a\u76ee\u6807\u3002\u79d1\u7279\u88ab\u4e00\u6b21\u53c8\u4e00\u6b21\u7684\u9001\u4e0a\u90a3\u8f86\u9ad8\u901f\u5217\u8f66\uff0c\u6bcf\u6b21\u53ea\u6709\u516b\u5206\u949f\u7684\u65f6\u95f4\u8c03\u67e5\uff0c\u8c03\u67e5\u8fc7\u7a0b\u4e2d\uff0c\u79d1\u7279\u53d1\u73b0\u81ea\u5df1\u5df2\u5728\u4e00\u5468\u524d\u53bb\u4e16\uff0c\u539f\u6765\u4ed6\u6b63\u5728\u53c2\u4e0e\u662f\u4e00\u9879\u201c\u8111\u6ce2\u6e90\u4ee3\u7801\u201d\u7684\u79d8\u5bc6\u4efb\u52a1\uff0c\u8fd9\u9879\u4efb\u52a1\u901a\u8fc7\u5df2\u7ecf\u6b7b\u4ea1\u7684\u79d1\u7279\u5c1a\u672a\u5b8c\u5168\u6b7b\u4ea1\u7684\u8111\u7ec6\u80de\u5f71\u50cf\u6765\u8fd8\u539f\u4e8b\u4ef6\uff0c\u8c03\u67e5\u4e8b\u60c5\u7684\u771f\u76f8\u3002\u6700\u7ec8\uff0c\u79d1\u7279\u987a\u5229\u5b8c\u6210\u4e86\u4efb\u52a1\uff0c\u4f46\u662f\u4ed6\u5374\u51b3\u5b9a\u518d\u4e00\u6b21\u8fd4\u56de\u5217\u8f66\uff0c\u62ef\u6551\u5217\u8f66\u4e0a\u90a3\u4e9b\u65e0\u8f9c\u7684\u751f\u547d\u2026\u2026", "interest_cmt_earlier_tip_title": "\u53d1\u5e03\u4e8e\u4e0a\u6620\u524d", "has_linewatch": true, "comment_count": 174570, "forum_topic_count": 559, "ticket_promo_text": "", "webview_info": {}, "is_released": true, "vendors": [{"accessible": true, "labels": [], "click_trackings": ["https:\/\/frodo.douban.com\/rohirrim\/video_tracking\/click?subject_id=3075287&video_type=movie&video_id=822354&source=bilibili&user_id=None&douban_udid=None&platform=&location=vendor_section"], "book_type_cn": "", "payment_desc": "VIP\u514d\u8d39\u89c2\u770b", "id": "bilibili", "impression_trackings": ["https:\/\/frodo.douban.com\/rohirrim\/video_tracking\/impression?subject_id=3075287&video_type=movie&video_id=822354&source=bilibili&user_id=None&douban_udid=None&platform=&location=vendor_section"], "style": "medium", "app_uri": "bilibili:\/\/skynet.douban.com", "title": "\u54d4\u54e9\u54d4\u54e9", "app_bundle_id": "tv.danmaku.bili", "click_tracking": "", "bg_image": "", "is_ad": false, "impression_tracking": "", "promote_desc": "", "book_type": "", "icon": "https://img9.doubanio.com\/f\/frodo\/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc\/pics\/vendors\/bilibili.png", "grey_icon": "https://img2.doubanio.com\/f\/frodo\/306bfa7ea3d607e3525063ac6aea156b2ca163f5\/pics\/vendors\/bilibili_grey.png", "url": "https:\/\/m.bilibili.com\/bangumi\/play\/ss35535?bsource=doubanh5", "is_paid": true, "uri": "bilibili:\/\/pgc\/season\/35535?h5awaken=b3Blbl9hcHBfZnJvbV90eXBlPWg1Jm9wZW5fYXBwX2FkZGl0aW9uPSU3QiUyMmJzb3VyY2UlMjIlM0ElMjJkb3ViYW5kcSUyMiUyQyUyMml2a19mcm9tJTIyJTNBJTIyZG91YmFuZHElMjIlN0Q=", "episodes_info": "", "is_in_whitelist": false, "payments": [{"price": "", "description": "", "method": "VIP\u514d\u8d39\u89c2\u770b"}], "pre_release_desc": "", "subject_id": "3075287"}, {"accessible": false, "labels": [], "click_trackings": ["https:\/\/frodo.douban.com\/rohirrim\/video_tracking\/click?subject_id=3075287&video_type=movie&video_id=781915&source=iqiyi&user_id=None&douban_udid=None&platform=&location=vendor_section"], "book_type_cn": "", "payment_desc": "", "id": "iqiyi", "impression_trackings": ["https:\/\/frodo.douban.com\/rohirrim\/video_tracking\/impression?subject_id=3075287&video_type=movie&video_id=781915&source=iqiyi&user_id=None&douban_udid=None&platform=&location=vendor_section"], "style": "small", "app_uri": "iqiyi:\/\/skynet.douban.com", "title": "\u7231\u5947\u827a", "app_bundle_id": "com.qiyi.video", "click_tracking": "", "bg_image": "", "is_ad": false, "impression_tracking": "", "promote_desc": "", "book_type": "", "icon": "https://img9.doubanio.com\/f\/frodo\/634a77c4a77d80a2a4f49ed7aaf0bc076fec7d01\/pics\/vendors\/iqiyi.png", "grey_icon": "https://img2.doubanio.com\/f\/frodo\/4e4fdeaa34319e8ad48533489b7857dfea5bd31c\/pics\/vendors\/iqiyi_grey.png", "url": "http:\/\/www.iqiyi.com\/v_19rrhynlvs.html?vfm=m_331_dbdy&fv=4904d94982104144a1548dd9040df241", "is_paid": false, "uri": "iqiyi:\/\/mobile\/player?aid=246473700&tvid=246473700&ftype=27&subtype=333", "episodes_info": "", "is_in_whitelist": false, "payments": [{"price": "", "description": "", "method": ""}], "pre_release_desc": "", "subject_id": "3075287"}, {"accessible": false, "labels": [], "click_trackings": ["https:\/\/frodo.douban.com\/rohirrim\/video_tracking\/click?subject_id=3075287&video_type=movie&video_id=842326&source=youku&user_id=None&douban_udid=None&platform=&location=vendor_section"], "book_type_cn": "", "payment_desc": "", "id": "youku", "impression_trackings": ["https:\/\/frodo.douban.com\/rohirrim\/video_tracking\/impression?subject_id=3075287&video_type=movie&video_id=842326&source=youku&user_id=None&douban_udid=None&platform=&location=vendor_section"], "style": "small", "app_uri": "youku:\/\/skynet.douban.com", "title": "\u4f18\u9177\u89c6\u9891", "app_bundle_id": "com.youku.phone", "click_tracking": "", "bg_image": "", "is_ad": false, "impression_tracking": "", "promote_desc": "", "book_type": "", "icon": "https://img3.doubanio.com\/f\/frodo\/9f302f2ad003c8c607cb79b447aca789a01142b2\/pics\/vendors\/youku.png", "grey_icon": "https://img2.doubanio.com\/f\/frodo\/103c5a5c991f65bbc31290a0538cb7cbe5b81e31\/pics\/vendors\/youku_grey.png", "url": "http:\/\/v.youku.com\/v_show\/id_XMzA4Mjk1MTQ4.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "is_paid": false, "uri": "youku:\/\/play?vid=XMzA4Mjk1MTQ4&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "episodes_info": "", "is_in_whitelist": false, "payments": [{"price": "", "description": "", "method": ""}], "pre_release_desc": "", "subject_id": "3075287"}], "actors": [{"name": "\u6770\u514b\u00b7\u5409\u4f26\u54c8\u5c14"}, {"name": "\u7ef4\u62c9\u00b7\u6cd5\u7c73\u52a0"}, {"name": "\u7c73\u6b47\u5c14\u00b7\u83ab\u7eb3\u6c49"}, {"name": "\u6770\u5f17\u91cc\u00b7\u6000\u7279"}, {"name": "\u62c9\u585e\u5c14\u00b7\u76ae\u7279\u65af"}, {"name": "\u8a79\u59c6\u65af\u00b7A\u00b7\u4f0d\u5179"}, {"name": "\u8fc8\u514b\u5c14\u00b7\u963f\u767b"}, {"name": "\u4e54\u00b7\u67ef\u5e03\u767b "}, {"name": "\u5361\u65af\u00b7\u5b89\u74e6\u5c14"}], "interest": null, "subtype": "movie", "episodes_count": 0, "color_scheme": {"is_dark": true, "primary_color_light": "687072", "_base_color": [0.5416666666666665, 0.09090909090909091, 0.17254901960784313], "secondary_color": "f4f8f9", "_avg_color": [0.5555555555555557, 0.058252427184466084, 0.403921568627451], "primary_color_dark": "454a4c"}, "type": "movie", "linewatches": [{"url": "http:\/\/v.youku.com\/v_show\/id_XMzA4Mjk1MTQ4.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "source": {"literal": "youku", "pic": "https://img3.doubanio.com\/img\/files\/file-1432869267.png", "name": "\u4f18\u9177\u89c6\u9891"}, "source_uri": "youku:\/\/play?vid=XMzA4Mjk1MTQ4&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "free": false}], "info_url": "https:\/\/www.douban.com\/doubanapp\/\/h5\/movie\/3075287\/desc", "tags": [], "vendor_desc": "", "durations": ["93\u5206\u949f"], "cover": {"description": "", "author": {"loc": {"id": "108090", "name": "\u4e2d\u56fd\u9999\u6e2f", "uid": "hongkong"}, "kind": "user", "name": "\u4e00\u6735\u6f5b\u6c34\u4e91", "reg_time": "2011-01-22 15:21:49", "url": "https:\/\/www.douban.com\/people\/48951480\/", "uri": "douban:\/\/douban.com\/user\/48951480", "avatar": "https://img9.doubanio.com\/icon\/up48951480-104.jpg", "is_club": false, "type": "user", "id": "48951480", "uid": "SamsonOu"}, "url": "https:\/\/movie.douban.com\/photos\/photo\/988260245\/", "image": {"normal": {"url": "https:\/\/qnmob3.doubanio.com\/view\/photo\/photo\/public\/p988260245.jpg?imageView2\/2\/q\/80\/w\/600\/h\/3000\/format\/jpg", "width": 405, "height": 600, "size": 0}, "large": {"url": "https:\/\/qnmob3.doubanio.com\/view\/photo\/photo\/public\/p988260245.jpg?imageView2\/2\/q\/80\/w\/600\/h\/3000\/format\/jpg", "width": 405, "height": 600, "size": 0}, "raw": null, "small": {"url": "https://img9.doubanio.com\/view\/photo\/s\/public\/p988260245.jpg", "width": 405, "height": 600, "size": 0}, "primary_color": "DFDFDF", "is_animated": false}, "uri": "douban:\/\/douban.com\/photo\/988260245", "create_time": "2011-05-07 00:54:13", "position": 0, "owner_uri": "douban:\/\/douban.com\/movie\/3075287", "type": "photo", "id": "988260245", "sharing_url": "https:\/\/www.douban.com\/doubanapp\/dispatch?uri=\/photo\/988260245\/"}, "cover_url": "https://img9.doubanio.com\/view\/photo\/m_ratio_poster\/public\/p988260245.jpg", "trailers": [{"sharing_url": "https:\/\/www.douban.com\/doubanapp\/dispatch?uri=\/movie\/3075287\/trailer%3Ftrailer_id%3D104704%26trailer_type%3DA", "video_url": "https:\/\/vt1.doubanio.com\/202402201307\/95acb953014d0ca04b5fc295958c808f\/view\/movie\/M\/301040704.mp4", "title": "\u9999\u6e2f\u9884\u544a\u7247 (\u4e2d\u6587\u5b57\u5e55)", "type_name": "\u9884\u544a\u7247", "uri": "douban:\/\/douban.com\/movie\/3075287\/trailer?trailer_id=104704&trailer_type=A", "cover_url": "https://img3.doubanio.com\/img\/trailer\/medium\/1346111093.jpg", "term_num": 0, "n_comments": 6, "create_time": "2011-12-18", "file_size": 11278225, "runtime": "02:07", "type": "A", "id": "104704", "desc": ""}], "header_bg_color": "454a4c", "is_douban_intro": true, "ticket_vendor_icons": ["https:\/\/img9.doubanio.com\/view\/dale-online\/dale_ad\/public\/0589a62f2f2d7c2.jpg"], "honor_infos": [{"kind": "movie", "uri": "douban:\/\/douban.com\/subject_collection\/movie_top250", "rank": 205, "title": "\u8c46\u74e3\u7535\u5f71Top250"}], "sharing_url": "https:\/\/movie.douban.com\/subject\/3075287\/", "subject_collections": [{"is_follow": false, "title": "\u9ad8\u5206\u7ecf\u5178\u79d1\u5e7b\u7247\u699c", "id": "movie_scifi", "uri": "douban:\/\/douban.com\/subject_collection\/movie_scifi?type=rank&category=movie&rank_type=film_genre"}, {"is_follow": false, "title": "\u9ad8\u5206\u7ecf\u5178\u60ca\u609a\u7247\u699c", "id": "film_genre_33", "uri": "douban:\/\/douban.com\/subject_collection\/film_genre_33?type=rank&category=movie&rank_type=film_genre"}, {"is_follow": false, "title": "\u7f8e\u56fd\u79d1\u5e7b\u7247\u699c", "id": "ECDAO6XZI", "uri": "douban:\/\/douban.com\/subject_collection\/ECDAO6XZI?type=rank&category=movie&rank_type=film_genre"}], "wechat_timeline_share": "screenshot", "restrictive_icon_url": "", "rate_info": "", "release_date": null, "countries": ["\u7f8e\u56fd", "\u52a0\u62ff\u5927"], "original_title": "Source Code", "uri": "douban:\/\/douban.com\/movie\/3075287", "pre_playable_date": null, "episodes_info": "", "url": "https:\/\/movie.douban.com\/subject\/3075287\/", "directors": [{"name": "\u9093\u80af\u00b7\u743c\u65af"}], "is_show": false, "vendor_icons": ["https://img9.doubanio.com\/f\/frodo\/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc\/pics\/vendors\/bilibili.png", "https://img9.doubanio.com\/f\/frodo\/634a77c4a77d80a2a4f49ed7aaf0bc076fec7d01\/pics\/vendors\/iqiyi.png", "https://img3.doubanio.com\/f\/frodo\/9f302f2ad003c8c607cb79b447aca789a01142b2\/pics\/vendors\/youku.png"], "pre_release_desc": "", "video": {"sharing_url": "https:\/\/www.douban.com\/doubanapp\/dispatch?uri=\/movie\/3075287\/video%3Fvideo_id%3D102830%26video_type%3DA", "video_url": "https:\/\/sv1.doubanio.com\/202402201306\/e32e0e09c95f19beb89c15d02a51619e\/video\/2019\/M\/401020830.mp4", "title": "\u4e5d\u6b218\u5206\u949f\u7684\u7a7f\u8d8a\uff0c\u5728\u5e73\u884c\u4e16\u754c\u4e2d\u5f00\u59cb\uff01", "author": {"loc": {"id": "118318", "name": "\u6210\u90fd", "uid": "chengdu"}, "reg_time": "2017-12-22 20:11:50", "followed": false, "name": "\u5929\u874e\u5468\u76fc\u76fc", "in_blacklist": false, "url": "https:\/\/www.douban.com\/people\/171421590\/", "gender": "M", "uri": "douban:\/\/douban.com\/user\/171421590", "id": "171421590", "remark": "", "avatar": "https://img1.doubanio.com\/icon\/up171421590-9.jpg", "is_club": false, "type": "user", "kind": "user", "uid": "171421590"}, "uri": "douban:\/\/douban.com\/movie\/3075287\/video?video_id=102830&video_type=A", "cover_url": "https://img1.doubanio.com\/view\/photo\/photo\/public\/p2554652450.jpg?", "n_comments": 5, "create_time": "2019-04-26", "file_size": 0, "runtime": "09:00", "type": "A", "id": "102830", "desc": "\u300a\u6e90\u4ee3\u7801\u300b\u4ee5\u4e94\u5f69\u6591\u6593\u7684\u5f62\u5f0f\u5c55\u73b0\u51fa\u4e00\u4e2a\u672a\u77e5\u7684\u4e16\u754c\uff0c\u7ed9\u4e86\u6240\u6709\u4eba\u4e00\u6b21\u91cd\u65b0\u5f00\u59cb\u7684\u63d0\u793a\u3002"}, "aka": ["\u542f\u52a8\u539f\u59cb\u7801(\u53f0)", "\u5371\u673a\u89e3\u5bc6(\u6e2f)"], "is_restrictive": false, "null_rating_reason": "", "interest_cmt_earlier_tip_desc": "\u8be5\u77ed\u8bc4\u7684\u53d1\u5e03\u65f6\u95f4\u65e9\u4e8e\u516c\u5f00\u4e0a\u6620\u65f6\u95f4\uff0c\u4f5c\u8005\u53ef\u80fd\u901a\u8fc7\u5176\u4ed6\u6e20\u9053\u63d0\u524d\u89c2\u770b\uff0c\u8bf7\u8c28\u614e\u53c2\u8003\u3002\u5176\u8bc4\u5206\u5c06\u4e0d\u8ba1\u5165\u603b\u8bc4\u5206\u3002"} -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [versionize](https://github.com/versionize/versionize) for commit guidelines. 4 | 5 | 6 | ## [2.0.0](https://www.github.com/Libitum/jellyfin-plugin-douban/releases/tag/v2.0.0) (2024-02-21) 7 | 8 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Clients/FrodoAndroidClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Text.Json; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using System.Web; 12 | 13 | using Microsoft.Extensions.Logging; 14 | 15 | namespace Jellyfin.Plugin.Douban.Clients 16 | { 17 | /// 18 | /// Frodo is the secondary domain of API used by Douban APP. 19 | /// 20 | public sealed class FrodoAndroidClient : IDoubanClient 21 | { 22 | 23 | private const string BaseDoubanUrl = "https://frodo.douban.com"; 24 | 25 | /// API key to use when performing an API call. 26 | private const string ApiKey = "0dad551ec0f84ed02907ff5c42e8ec70"; 27 | 28 | /// Secret key for HMACSHA1 to generate signature. 29 | private const string SecretKey = "bf7dddc7c9cfe6f7"; 30 | 31 | private static readonly string[] UserAgents = { 32 | "api-client/1 com.douban.frodo/6.42.2(194) Android/22 product/shamu vendor/OPPO model/OPPO R11 Plus rom/android network/wifi platform/mobile nd/1", 33 | "api-client/1 com.douban.frodo/6.42.2(194) Android/23 product/meizu_MX6 vendor/Meizu model/MX6 rom/android network/wifi platform/mobile", 34 | "api-client/1 com.douban.frodo/6.32.0(180) Android/23 product/OnePlus3 vendor/One model/One rom/android network/wifi", 35 | "api-client/1 com.douban.frodo/6.32.0(180) Android/25 product/Google vendor/LGE model/Nexus 5 rom/android network/wifi platform/mobile nd/1", 36 | "api-client/1 com.douban.frodo/7.0.1(204) Android/28 product/hammerhead vendor/Xiaomi model/MI 10 rom/android network/wifi platform/mobile nd/1", 37 | "api-client/1 com.douban.frodo/6.32.0(180) Android/26 product/marlin vendor/Google model/Pixel XL rom/android network/wifi platform/mobile nd/1", 38 | "api-client/1 com.douban.frodo/7.0.1(204) Android/29 product/nitrogen vendor/Xiaomi model/MI MAX 3 rom/miui6 network/wifi platform/mobile nd/1", 39 | "api-client/1 com.douban.frodo/6.32.0(180) Android/22 product/R11 vendor/OPPO model/OPPO R11 rom/android network/wifi platform/mobile nd/1", 40 | }; 41 | 42 | private static readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1); 43 | 44 | private static readonly LRUCache _cache = new LRUCache(); 45 | 46 | private readonly Random _random = new Random(); 47 | 48 | private readonly IHttpClientFactory _httpClientFactory; 49 | private readonly ILogger _logger; 50 | 51 | private string _userAgent; 52 | private int _requestCount = 0; 53 | 54 | public FrodoAndroidClient(IHttpClientFactory httpClientFactory, ILogger logger) 55 | { 56 | this._httpClientFactory = httpClientFactory; 57 | this._logger = logger; 58 | 59 | this._userAgent = UserAgents[_random.Next(UserAgents.Length)]; 60 | } 61 | 62 | /// 63 | /// Gets one movie or tv item by doubanID. 64 | /// 65 | /// The subject ID in Douban. 66 | /// Subject type. 67 | /// Used to cancel the request. 68 | /// The subject of one item. 69 | public async Task GetSubject(string doubanID, DoubanType type, CancellationToken cancellationToken) 70 | { 71 | _logger.LogInformation($"Start to GetSubject by Id: {doubanID}"); 72 | 73 | string path = $"/api/v2/{type:G}/{doubanID}"; 74 | // Try to use cache firstly. 75 | if (_cache.TryGet(path, out Response.Subject subject)) 76 | { 77 | _logger.LogInformation($"Get subject {doubanID} from cache"); 78 | return subject; 79 | } 80 | 81 | Dictionary queryParams = new Dictionary(); 82 | var contentStream = await GetResponse(path, queryParams, cancellationToken); 83 | subject = await JsonSerializer.DeserializeAsync(contentStream); 84 | // Add it into cache 85 | _cache.Add(path, subject); 86 | 87 | _logger.LogTrace($"Finish doing GetSubject by Id: {doubanID}"); 88 | return subject; 89 | } 90 | 91 | /// 92 | /// Search in Douban by a search query. 93 | /// 94 | /// The content of search query. 95 | /// Used to cancel the request. 96 | /// The Search Result. 97 | public async Task Search(string name, CancellationToken cancellationToken) 98 | { 99 | return await Search(name, 5, cancellationToken); 100 | } 101 | 102 | public async Task Search(string name, int count, CancellationToken cancellationToken) 103 | { 104 | await _locker.WaitAsync(cancellationToken); 105 | 106 | // Change UserAgent for every search section. 107 | _userAgent = UserAgents[_random.Next(UserAgents.Length)]; 108 | ResetCounter(); 109 | 110 | try 111 | { 112 | _logger.LogInformation($"Start to Search by name: {name}, count: {count}"); 113 | 114 | await Task.Delay(_random.Next(4000, 10000), cancellationToken); 115 | 116 | const string path = "/api/v2/search/movie"; 117 | Dictionary queryParams = new Dictionary 118 | { 119 | { "q", name }, 120 | { "count", count.ToString() } 121 | }; 122 | var contentStream = await GetResponse(path, queryParams, cancellationToken); 123 | Response.SearchResult result = await JsonSerializer.DeserializeAsync(contentStream); 124 | 125 | _logger.LogTrace($"Finish doing Search by name: {name}, count: {count}"); 126 | 127 | return result; 128 | } 129 | finally 130 | { 131 | _locker.Release(); 132 | } 133 | } 134 | 135 | /// 136 | /// Generates signature for douban api 137 | /// 138 | /// Douban api path, e.g. /api/v2/search/movie 139 | /// Timestamp. 140 | /// Douban signature 141 | private static string Sign(string path, string ts) 142 | { 143 | string[] message = 144 | { 145 | "GET", 146 | path.Replace("/", "%2F"), 147 | ts 148 | }; 149 | string signMessage = String.Join('&', message); 150 | 151 | using var hmacsha1 = new HMACSHA1(Encoding.UTF8.GetBytes(SecretKey)); 152 | byte[] sign = hmacsha1.ComputeHash(Encoding.UTF8.GetBytes(signMessage)); 153 | 154 | return Convert.ToBase64String(sign); 155 | } 156 | 157 | /// 158 | /// Sends request to Douban Frodo and get the response. 159 | /// It generates the signature for douban api internally. 160 | /// 161 | /// Douban api path, e.g. /api/v2/search/movie 162 | /// Parameters for the request. 163 | /// Used to cancel the request. 164 | /// The HTTP content with the type of stream. 165 | private async Task GetResponse(string path, Dictionary queryParams, 166 | CancellationToken cancellationToken) 167 | { 168 | _logger.LogTrace($"Start to request path: {path}"); 169 | 170 | cancellationToken.ThrowIfCancellationRequested(); 171 | 172 | // Sign for the parameters. 173 | string ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); 174 | queryParams.Add("_ts", ts); 175 | queryParams.Add("_sig", Sign(path, ts)); 176 | queryParams.Add("apikey", ApiKey); 177 | 178 | // Generate the URL. 179 | string queryStr = string.Join('&', queryParams.Select(item => $"{item.Key}={HttpUtility.UrlEncode(item.Value)}")); 180 | string url = $"{BaseDoubanUrl}{path}?{queryStr}"; 181 | _logger.LogInformation($"Frodo request URL: {url}"); 182 | 183 | // Send request to Frodo API and get response. 184 | using HttpResponseMessage response = await GetAsync(url, cancellationToken); 185 | using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); 186 | 187 | _logger.LogTrace($"Finish doing request path: {path}"); 188 | return content; 189 | } 190 | 191 | 192 | /// 193 | /// Simply gets the response by HTTP without any other options. 194 | /// 195 | /// Request URL. 196 | /// Used to cancel the request. 197 | /// Simple Http Response. 198 | public async Task GetAsync(string url, CancellationToken cancellationToken) 199 | { 200 | 201 | cancellationToken.ThrowIfCancellationRequested(); 202 | 203 | // await Task.Delay(6000, cancellationToken); 204 | CheckCountAndSleep(); 205 | 206 | var httpClient = _httpClientFactory.CreateClient(); 207 | httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", _userAgent); 208 | 209 | HttpResponseMessage response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); 210 | response.EnsureSuccessStatusCode(); 211 | return response; 212 | } 213 | 214 | private void ResetCounter() 215 | { 216 | _requestCount = 0; 217 | } 218 | 219 | private void CheckCountAndSleep() 220 | { 221 | if (_requestCount > 5) 222 | { 223 | Task.Delay(_random.Next(3000, 7000)); 224 | _requestCount = 0; 225 | } 226 | _requestCount++; 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Clients/IDoubanClient.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Jellyfin.Plugin.Douban.Clients 6 | { 7 | public enum DoubanType 8 | { 9 | movie, 10 | tv 11 | } 12 | public interface IDoubanClient 13 | { 14 | /// 15 | /// Gets one movie or tv item by doubanID. 16 | /// 17 | /// The subject ID in Douban. 18 | /// Subject type. 19 | /// Used to cancel the request. 20 | /// The subject of one item. 21 | public Task GetSubject(string doubanID, DoubanType type, 22 | CancellationToken cancellationToken); 23 | 24 | /// 25 | /// Search in Douban by a search query. 26 | /// 27 | /// The content of search query. 28 | /// Used to cancel the request. 29 | /// The Search Result. 30 | public Task Search(string name, CancellationToken cancellationToken); 31 | 32 | /// 33 | /// Simply gets the response by HTTP without any other options. 34 | /// 35 | /// Request URL. 36 | /// Used to cancel the request. 37 | /// Simple Http Response. 38 | public Task GetAsync(string url, CancellationToken cancellationToken); 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Clients/Response/SearchResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Jellyfin.Plugin.Douban.Response 5 | { 6 | public class SearchResult { 7 | public SubjectList Subjects {get; set;} 8 | 9 | } 10 | public class SubjectList { 11 | public string Target_Name {get; set;} 12 | public List Items { get; set; } 13 | } 14 | 15 | public class SearchSubject 16 | { 17 | public SearchTarget Target { get; set; } 18 | public string Target_Type { get; set; } 19 | } 20 | 21 | public class SearchTarget 22 | { 23 | public string Id { get; set; } 24 | public string Cover_Url { get; set; } 25 | public string Year { get; set; } 26 | public string Title { get; set; } 27 | } 28 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Clients/Response/Subject.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Jellyfin.Plugin.Douban.Response 4 | { 5 | public class Subject 6 | { 7 | public string Id { get; set; } 8 | public string Title { get; set; } 9 | public string Original_Title { get; set; } 10 | public string Intro { get; set; } 11 | public string Summary { get; set; } 12 | public string Year { get; set; } 13 | public List Pubdate { get; set; } 14 | public Rating Rating { get; set; } 15 | public Image Pic { get; set; } 16 | public string Url { get; set; } 17 | public List Countries { get; set; } 18 | public Trailer Trailer { get; set; } 19 | public List Directors { get; set; } 20 | public List Actors { get; set; } 21 | public List Genres { get; set; } 22 | public string Subtype { get; set; } 23 | public bool Is_Tv { get; set; } 24 | } 25 | 26 | public class Rating 27 | { 28 | public float Value { get; set; } 29 | public float Star_Count { get; set; } 30 | } 31 | 32 | public class Crew 33 | { 34 | public string Name { get; set; } 35 | public string Url { get; set; } 36 | public string Id { get; set; } 37 | public Image Avatar { get; set; } 38 | public List Roles { get; set; } 39 | public string Title { get; set; } 40 | public string Abstract { get; set; } 41 | public string Type { get; set; } 42 | } 43 | 44 | public class Image 45 | { 46 | public string Large { get; set; } 47 | public string Normal { get; set; } 48 | } 49 | 50 | public class Trailer 51 | { 52 | public string Video_Url { get; set; } 53 | public string Title { get; set; } 54 | public string Subject_Title { get; set; } 55 | public string Runtime { get; set; } 56 | public string Cover_Url { get; set; } 57 | } 58 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Clients/WechatClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Net.Http.Json; 6 | using System.Text.Json; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using System.Web; 10 | 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace Jellyfin.Plugin.Douban.Clients 14 | { 15 | /// 16 | /// Mock as Douban Wechat micro-app cliend. 17 | /// 18 | public sealed class WechatClient : IDoubanClient 19 | { 20 | 21 | private const string BaseDoubanUrl = "https://frodo.douban.com"; 22 | /// API key to use when performing an API call. 23 | private const string ApiKey = "054022eaeae0b00e0fc068c0c0a2102a"; 24 | private const string UserAgent = "MicroMessenger/"; 25 | private const string Referer = "https://servicewechat.com/wx2f9b06c1de1ccfca/91/page-frame.html"; 26 | 27 | private readonly IHttpClientFactory _httpClientFactory; 28 | private readonly ILogger _logger; 29 | 30 | public WechatClient(IHttpClientFactory httpClientFactory, ILogger logger) 31 | { 32 | this._httpClientFactory = httpClientFactory; 33 | this._logger = logger; 34 | } 35 | 36 | /// 37 | /// Gets one movie or tv item by doubanID. 38 | /// 39 | /// The subject ID in Douban. 40 | /// Subject type. 41 | /// Used to cancel the request. 42 | /// The subject of one item. 43 | public async Task GetSubject(string doubanID, DoubanType type, CancellationToken cancellationToken) 44 | { 45 | _logger.LogInformation("Start to GetSubject by Id: {doubanID}", doubanID); 46 | 47 | string path = $"/api/v2/{type:G}/{doubanID}"; 48 | Dictionary queryParams = new Dictionary(); 49 | var content = await GetResponse(path, queryParams, cancellationToken); 50 | JsonSerializerOptions options = new(JsonSerializerDefaults.Web); 51 | Response.Subject subject = await content.ReadFromJsonAsync(options, cancellationToken); 52 | _logger.LogTrace("Finish doing GetSubject by Id: {doubanID}", doubanID); 53 | return subject; 54 | } 55 | 56 | /// 57 | /// Search in Douban by a search query. 58 | /// 59 | /// The content of search query. 60 | /// Used to cancel the request. 61 | /// The Search Result. 62 | public async Task Search(string name, CancellationToken cancellationToken) 63 | { 64 | return await Search(name, 5, cancellationToken); 65 | } 66 | 67 | public async Task Search(string name, int count, CancellationToken cancellationToken) 68 | { 69 | _logger.LogInformation($"Start to Search by name: {name}, count: {count}"); 70 | 71 | const string path = "/api/v2/search"; 72 | Dictionary queryParams = new Dictionary 73 | { 74 | { "q", name }, 75 | { "count", count.ToString() } 76 | }; 77 | var content = await GetResponse(path, queryParams, cancellationToken); 78 | JsonSerializerOptions options = new(JsonSerializerDefaults.Web); 79 | Response.SearchResult result = await content.ReadFromJsonAsync(options, cancellationToken); 80 | 81 | _logger.LogTrace($"Finish doing Search by name: {name}, count: {count}"); 82 | return result; 83 | } 84 | 85 | /// 86 | /// Sends request to Douban Frodo and get the response. 87 | /// It generates the signature for douban api internally. 88 | /// 89 | /// Douban api path, e.g. /api/v2/search/movie 90 | /// Parameters for the request. 91 | /// Used to cancel the request. 92 | /// The HTTP content with the type of stream. 93 | private async Task GetResponse(string path, Dictionary queryParams, 94 | CancellationToken cancellationToken) 95 | { 96 | _logger.LogTrace($"Start to request path: {path}"); 97 | 98 | cancellationToken.ThrowIfCancellationRequested(); 99 | 100 | queryParams.Add("apikey", ApiKey); 101 | 102 | // Generate the URL. 103 | string queryStr = string.Join('&', queryParams.Select(item => $"{item.Key}={HttpUtility.UrlEncode(item.Value)}")); 104 | string url = $"{BaseDoubanUrl}{path}?{queryStr}"; 105 | _logger.LogInformation($"Frodo request URL: {url}"); 106 | 107 | // Send request to Frodo API and get response. 108 | HttpResponseMessage response = await GetAsync(url, cancellationToken); 109 | _logger.LogTrace($"Finish doing request path: {path}"); 110 | return response.Content; 111 | } 112 | 113 | 114 | /// 115 | /// Simply gets the response by HTTP without any other options. 116 | /// 117 | /// Request URL. 118 | /// Used to cancel the request. 119 | /// Simple Http Response. 120 | public async Task GetAsync(string url, CancellationToken cancellationToken) 121 | { 122 | cancellationToken.ThrowIfCancellationRequested(); 123 | 124 | var httpClient = _httpClientFactory.CreateClient(); 125 | httpClient.Timeout = TimeSpan.FromSeconds(10); 126 | httpClient.DefaultRequestHeaders.Clear(); 127 | httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", UserAgent); 128 | httpClient.DefaultRequestHeaders.Add("Referer", Referer); 129 | 130 | HttpResponseMessage response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); 131 | response.EnsureSuccessStatusCode(); 132 | return response; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.Plugins; 2 | 3 | namespace Jellyfin.Plugin.Douban.Configuration 4 | { 5 | public class PluginConfiguration : BasePluginConfiguration 6 | { 7 | public string ApiKey { get; set; } 8 | public int MinRequestInternalMs { get; set; } 9 | public PluginConfiguration() 10 | { 11 | MinRequestInternalMs = 2000; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Configuration/configPage.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Template 6 | 7 | 8 |
9 |
10 |
11 |
12 |
13 | 14 | 15 |
Minimum request Internal 最小请求间隔,单位为毫秒
16 |
17 |
18 | 21 |
22 |
23 |
24 |
25 | 51 |
52 | 53 | 54 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/ExternalID.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.Douban.Providers; 2 | 3 | using MediaBrowser.Controller.Entities.Movies; 4 | using MediaBrowser.Controller.Entities.TV; 5 | using MediaBrowser.Controller.Providers; 6 | using MediaBrowser.Model.Entities; 7 | using MediaBrowser.Model.Providers; 8 | 9 | namespace Jellyfin.Plugin.Douban 10 | { 11 | public class DoubanExternalId : IExternalId 12 | { 13 | public string ProviderName => "Douban"; 14 | 15 | public string Key => BaseProvider.ProviderID; 16 | 17 | public ExternalIdMediaType? Type => null; 18 | 19 | public string UrlFormatString => "https://movie.douban.com/subject/{0}/"; 20 | 21 | public bool Supports(IHasProviderIds item) 22 | { 23 | return item is Movie || item is Series; 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Jellyfin.Plugin.Douban.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Jellyfin.Plugin.Douban 6 | 2.0.0 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/LRUCache.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | 3 | namespace Jellyfin.Plugin.Douban 4 | { 5 | public class LRUCache 6 | { 7 | private readonly int _capacity; 8 | 9 | private readonly OrderedDictionary _cache; 10 | private readonly object _lock = new object(); 11 | 12 | /// 13 | /// Create a LRUCache object. 14 | /// 15 | /// The size of the cache. Default is 20. 16 | public LRUCache(int capacity = 20) 17 | { 18 | _capacity = capacity; 19 | 20 | _cache = new OrderedDictionary(capacity); 21 | } 22 | 23 | /// 24 | /// Add a new object into the cache. 25 | /// It will delete least recently used one if the cache is full to the capacity. 26 | /// 27 | /// 28 | /// 29 | public void Add(string key, object value) 30 | { 31 | lock(_lock) 32 | { 33 | if (_cache.Contains(key)) 34 | { 35 | _cache.Remove(key); 36 | } 37 | 38 | if (_cache.Count >= _capacity) 39 | { 40 | _cache.RemoveAt(0); 41 | } 42 | 43 | _cache.Add(key, value); 44 | } 45 | } 46 | 47 | public bool TryGet(string key, out T value) 48 | { 49 | lock (_lock) 50 | { 51 | value = default; 52 | if (_cache.Contains(key)) 53 | { 54 | value = (T)_cache[key]; 55 | _cache.Remove(key); 56 | _cache.Add(key, value); 57 | return true; 58 | } 59 | 60 | return false; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | using Jellyfin.Plugin.Douban.Configuration; 5 | 6 | using MediaBrowser.Common.Configuration; 7 | using MediaBrowser.Common.Plugins; 8 | using MediaBrowser.Model.Plugins; 9 | using MediaBrowser.Model.Serialization; 10 | 11 | namespace Jellyfin.Plugin.Douban 12 | { 13 | public class Plugin : BasePlugin, IHasWebPages 14 | { 15 | public override string Name => "Douban"; 16 | public override Guid Id => Guid.Parse("e325b8d5-5f54-447f-a38a-a951b933d22c"); 17 | public static Plugin Instance { get; private set; } 18 | 19 | public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) 20 | { 21 | Instance = this; 22 | } 23 | 24 | public IEnumerable GetPages() 25 | { 26 | return new[] 27 | { 28 | new PluginPageInfo 29 | { 30 | Name = this.Name, 31 | EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace) 32 | } 33 | }; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Providers/BaseProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | using Microsoft.Extensions.Logging; 9 | 10 | using Jellyfin.Plugin.Douban.Clients; 11 | using Jellyfin.Plugin.Douban.Response; 12 | 13 | using MediaBrowser.Controller.Entities; 14 | using MediaBrowser.Controller.Entities.Movies; 15 | using MediaBrowser.Controller.Providers; 16 | using MediaBrowser.Model.Entities; 17 | 18 | namespace Jellyfin.Plugin.Douban.Providers 19 | { 20 | public abstract class BaseProvider 21 | { 22 | /// 23 | /// Used to store douban Id in Jellyfin system. 24 | /// 25 | public const string ProviderID = "DoubanID"; 26 | 27 | protected readonly ILogger _logger; 28 | 29 | protected readonly Configuration.PluginConfiguration _config; 30 | 31 | // All requests 32 | protected readonly IDoubanClient _doubanClient; 33 | 34 | protected BaseProvider(IHttpClientFactory httpClientFactory, ILogger logger) 35 | { 36 | this._logger = logger; 37 | this._config = Plugin.Instance == null ? 38 | new Configuration.PluginConfiguration() : 39 | Plugin.Instance.Configuration; 40 | 41 | this._doubanClient = new WechatClient(httpClientFactory, _logger); 42 | } 43 | 44 | public Task GetImageResponse(string url, 45 | CancellationToken cancellationToken) 46 | { 47 | _logger.LogInformation("GetImageResponse url: {url}", url); 48 | return _doubanClient.GetAsync(url, cancellationToken); 49 | } 50 | 51 | public async Task> Search(string name, 52 | CancellationToken cancellationToken) 53 | { 54 | DoubanType type = typeof(T) == typeof(Movie) ? DoubanType.movie : DoubanType.tv; 55 | 56 | _logger.LogInformation("Searching for sid of {type} named #{name}#", type, name); 57 | 58 | var searchResults = new List(); 59 | 60 | if (string.IsNullOrWhiteSpace(name)) 61 | { 62 | _logger.LogWarning("Search name is empty."); 63 | return searchResults; 64 | } 65 | 66 | name = name.Replace('.', ' '); 67 | 68 | try 69 | { 70 | var response = await _doubanClient.Search(name, cancellationToken); 71 | if (response.Subjects.Items.Count > 0) 72 | { 73 | searchResults = response.Subjects.Items.Where(item => item.Target_Type == type.ToString()) 74 | .Select(item => item.Target).ToList(); 75 | 76 | if (searchResults.Count == 0) 77 | { 78 | _logger.LogWarning("Seems like #{name}# genre is not {type}.", name, type); 79 | } 80 | } 81 | else 82 | { 83 | _logger.LogWarning("No results found for #{name}#.", name); 84 | } 85 | } 86 | catch (HttpRequestException e) 87 | { 88 | _logger.LogError("Search #{name}# error, got {e.StatusCode}.", name, e.StatusCode); 89 | throw; 90 | } 91 | 92 | _logger.LogInformation("Finish searching #{name}#, count: {searchResults.Count}", name, searchResults.Count); 93 | return searchResults; 94 | } 95 | 96 | protected async Task GetSubject(string sid, 97 | CancellationToken cancellationToken) where T : BaseItem 98 | { 99 | DoubanType type = typeof(T) == typeof(Movie) ? DoubanType.movie : DoubanType.tv; 100 | return await _doubanClient.GetSubject(sid, type, cancellationToken); 101 | } 102 | 103 | protected async Task> GetMetadata(string sid, CancellationToken cancellationToken) 104 | where T : BaseItem, new() 105 | { 106 | var result = new MetadataResult(); 107 | 108 | DoubanType type = typeof(T) == typeof(Movie) ? DoubanType.movie : DoubanType.tv; 109 | var subject = await _doubanClient.GetSubject(sid, type, cancellationToken); 110 | 111 | result.Item = TransMediaInfo(subject); 112 | result.Item.SetProviderId(ProviderID, sid); 113 | TransPersonInfo(subject.Directors, PersonType.Director).ForEach(result.AddPerson); 114 | TransPersonInfo(subject.Actors, PersonType.Actor).ForEach(result.AddPerson); 115 | 116 | result.QueriedById = true; 117 | result.HasMetadata = true; 118 | 119 | return result; 120 | } 121 | 122 | private static T TransMediaInfo(Subject data) where T : BaseItem, new() 123 | { 124 | var item = new T 125 | { 126 | Name = data.Title ?? data.Original_Title, 127 | OriginalTitle = data.Original_Title, 128 | CommunityRating = data.Rating?.Value, 129 | Overview = data.Intro, 130 | ProductionYear = int.Parse(data.Year), 131 | HomePageUrl = data.Url, 132 | ProductionLocations = data.Countries?.ToArray() 133 | }; 134 | 135 | if (data.Pubdate?.Count > 0 && !String.IsNullOrEmpty(data.Pubdate[0])) 136 | { 137 | string pubdate = data.Pubdate[0].Split('(', 2)[0]; 138 | if (DateTime.TryParse(pubdate, out DateTime dateValue)) 139 | { 140 | item.PremiereDate = dateValue; 141 | } 142 | } 143 | 144 | if (data.Trailer != null) 145 | { 146 | item.AddTrailerUrl(data.Trailer.Video_Url); 147 | } 148 | 149 | data.Genres.ForEach(item.AddGenre); 150 | 151 | return item; 152 | } 153 | 154 | private static List TransPersonInfo( 155 | List crewList, string personType) 156 | { 157 | var result = new List(); 158 | foreach (var crew in crewList) 159 | { 160 | var personInfo = new PersonInfo 161 | { 162 | Name = crew.Name, 163 | Type = personType, 164 | ImageUrl = crew.Avatar?.Large ?? "", 165 | Role = crew.Roles?.Count > 0 ? crew.Roles[0] : "" 166 | }; 167 | 168 | personInfo.SetProviderId(ProviderID, crew.Id); 169 | result.Add(personInfo); 170 | } 171 | return result; 172 | } 173 | } 174 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Providers/ImageProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Net.Http; 5 | using System.Text.RegularExpressions; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | using Microsoft.Extensions.Logging; 10 | 11 | using Jellyfin.Plugin.Douban.Clients; 12 | 13 | using MediaBrowser.Controller.Entities; 14 | using MediaBrowser.Controller.Entities.Movies; 15 | using MediaBrowser.Controller.Entities.TV; 16 | using MediaBrowser.Controller.Providers; 17 | using MediaBrowser.Model.Entities; 18 | using MediaBrowser.Model.Providers; 19 | 20 | namespace Jellyfin.Plugin.Douban.Providers 21 | { 22 | public class ImageProvider : BaseProvider, IRemoteImageProvider, IHasOrder 23 | { 24 | public string Name => "豆瓣刮削器"; 25 | public int Order => 3; 26 | 27 | public ImageProvider(IHttpClientFactory httpClientFactory, 28 | ILoggerFactory loggerFactory) : base(httpClientFactory, loggerFactory.CreateLogger()) 29 | { 30 | // empty 31 | } 32 | 33 | public async Task> GetImages(BaseItem item, 34 | CancellationToken cancellationToken) 35 | { 36 | _logger.LogInformation("GetImages for item: {item.Name}", item.Name); 37 | 38 | var list = new List(); 39 | var sid = item.GetProviderId(ProviderID); 40 | if (string.IsNullOrWhiteSpace(sid)) 41 | { 42 | _logger.LogWarning("Got images failed because the sid of #{item.Name}# is empty!", item.Name); 43 | return list; 44 | } 45 | 46 | var primaryList = await GetPrimary(sid, item is Movie ? "movie" : "tv", cancellationToken); 47 | list.AddRange(primaryList); 48 | 49 | // TODO(Libitum): Add backdrop back. 50 | // var backdropList = await GetBackdrop(sid, cancellationToken); 51 | // list.AddRange(backdropList); 52 | 53 | return list; 54 | } 55 | 56 | public bool Supports(BaseItem item) 57 | { 58 | return item is Movie || item is Series; 59 | } 60 | 61 | public IEnumerable GetSupportedImages(BaseItem item) 62 | { 63 | return new List 64 | { 65 | ImageType.Primary, 66 | ImageType.Backdrop 67 | }; 68 | } 69 | 70 | public async Task> GetPrimary(string sid, string type, 71 | CancellationToken cancellationToken) 72 | { 73 | var list = new List(); 74 | var item = await _doubanClient.GetSubject(sid, Enum.Parse(type), cancellationToken); 75 | list.Add(new RemoteImageInfo() 76 | { 77 | ProviderName = Name, 78 | Url = item.Pic.Large, 79 | Type = ImageType.Primary 80 | }); 81 | return list; 82 | } 83 | 84 | public async Task> GetBackdrop(string sid, 85 | CancellationToken cancellationToken) 86 | { 87 | var url = string.Format("https://movie.douban.com/subject/{0}/photos?" + 88 | "type=W&start=0&sortby=size&size=a&subtype=a", sid); 89 | 90 | var response = await _doubanClient.GetAsync(url, cancellationToken); 91 | var stream = await response.Content.ReadAsStreamAsync(cancellationToken); 92 | string content = new StreamReader(stream).ReadToEnd(); 93 | 94 | const String pattern = @"(?s)data-id=""(\d+)"".*?class=""prop"">\n\s*(\d+)x(\d+)"; 95 | Match match = Regex.Match(content, pattern); 96 | 97 | var list = new List(); 98 | while (match.Success) 99 | { 100 | string data_id = match.Groups[1].Value; 101 | string width = match.Groups[2].Value; 102 | string height = match.Groups[3].Value; 103 | _logger.LogInformation("Find backdrop id {0}, size {1}x{2}", data_id, width, height); 104 | 105 | if (float.Parse(width) > float.Parse(height) * 1.3) 106 | { 107 | // Just chose the Backdrop which width is larger than height 108 | list.Add(new RemoteImageInfo 109 | { 110 | ProviderName = Name, 111 | Url = string.Format("https://img9.doubanio.com/view/photo/l/public/p{0}.webp", data_id), 112 | Type = ImageType.Backdrop, 113 | }); 114 | } 115 | 116 | match = match.NextMatch(); 117 | } 118 | 119 | return list; 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Providers/MovieProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.Extensions.Logging; 8 | 9 | using MediaBrowser.Controller.Entities.Movies; 10 | using MediaBrowser.Controller.Providers; 11 | using MediaBrowser.Model.Entities; 12 | using MediaBrowser.Model.Providers; 13 | 14 | namespace Jellyfin.Plugin.Douban.Providers 15 | { 16 | public class MovieProvider : BaseProvider, IHasOrder, 17 | IRemoteMetadataProvider 18 | { 19 | public string Name => "豆瓣刮削器"; 20 | public int Order => 3; 21 | 22 | public MovieProvider(IHttpClientFactory httpClientFactory, 23 | ILoggerFactory loggerFactory) : base(httpClientFactory, loggerFactory.CreateLogger()) 24 | { 25 | // Empty 26 | } 27 | 28 | public async Task> GetMetadata(MovieInfo info, 29 | CancellationToken cancellationToken) 30 | { 31 | _logger.LogInformation("Getting metadata for #{info.Name}#", info.Name); 32 | 33 | string sid = info.GetProviderId(ProviderID); 34 | if (string.IsNullOrWhiteSpace(sid)) 35 | { 36 | var searchResults = await Search(info.Name, cancellationToken); 37 | sid = searchResults.FirstOrDefault()?.Id; 38 | } 39 | 40 | if (string.IsNullOrWhiteSpace(sid)) 41 | { 42 | _logger.LogWarning("No sid found for #{info.Name}#", info.Name); 43 | return new MetadataResult(); 44 | } 45 | 46 | var result = await GetMetadata(sid, cancellationToken); 47 | if (result.HasMetadata) 48 | { 49 | _logger.LogInformation("Get the metadata of #{info.Name}# successfully!", info.Name); 50 | info.SetProviderId(ProviderID, sid); 51 | } 52 | 53 | return result; 54 | } 55 | 56 | public async Task> GetSearchResults( 57 | MovieInfo info, CancellationToken cancellationToken) 58 | { 59 | _logger.LogInformation($"[DOUBAN] GetSearchResults \"{info.Name}\""); 60 | 61 | var results = new List(); 62 | 63 | var searchResults = new List(); 64 | 65 | string sid = info.GetProviderId(ProviderID); 66 | if (!string.IsNullOrEmpty(sid)) 67 | { 68 | var subject = await GetSubject(sid, cancellationToken); 69 | searchResults.Add(new Response.SearchTarget 70 | { 71 | Id = subject?.Id, 72 | Cover_Url = subject?.Pic?.Normal, 73 | Year = subject?.Year, 74 | Title = subject?.Title 75 | }); 76 | } 77 | else 78 | { 79 | searchResults = await Search(info.Name, cancellationToken); 80 | } 81 | 82 | foreach (Response.SearchTarget searchTarget in searchResults) 83 | { 84 | var searchResult = new RemoteSearchResult() 85 | { 86 | Name = searchTarget?.Title, 87 | ImageUrl = searchTarget?.Cover_Url, 88 | ProductionYear = int.Parse(searchTarget?.Year) 89 | }; 90 | searchResult.SetProviderId(ProviderID, searchTarget.Id); 91 | results.Add(searchResult); 92 | } 93 | 94 | return results; 95 | } 96 | 97 | } 98 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Douban/Providers/TVProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.Text.RegularExpressions; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using System.Web; 8 | 9 | using Microsoft.Extensions.Logging; 10 | 11 | using MediaBrowser.Controller.Entities.TV; 12 | using MediaBrowser.Controller.Providers; 13 | using MediaBrowser.Model.Entities; 14 | using MediaBrowser.Model.Providers; 15 | 16 | namespace Jellyfin.Plugin.Douban.Providers 17 | { 18 | public class TVProvider : BaseProvider, IHasOrder, 19 | IRemoteMetadataProvider, 20 | IRemoteMetadataProvider, 21 | IRemoteMetadataProvider 22 | { 23 | public string Name => "豆瓣刮削器"; 24 | public int Order => 3; 25 | 26 | public TVProvider(IHttpClientFactory httpClientFactory, 27 | ILoggerFactory loggerFactory) : base(httpClientFactory, loggerFactory.CreateLogger()) 28 | { 29 | // empty 30 | } 31 | 32 | #region series 33 | public async Task> GetMetadata(SeriesInfo info, 34 | CancellationToken cancellationToken) 35 | { 36 | _logger.LogInformation($"[DOUBAN] Getting series metadata for \"{info.Name}\""); 37 | 38 | var sid = info.GetProviderId(ProviderID); 39 | if (string.IsNullOrWhiteSpace(sid)) 40 | { 41 | var searchResults = await Search(info.Name, cancellationToken); 42 | sid = searchResults.FirstOrDefault()?.Id; 43 | } 44 | 45 | if (string.IsNullOrWhiteSpace(sid)) 46 | { 47 | _logger.LogWarning($"[DOUBAN] No sid found for \"{info.Name}\""); 48 | return new MetadataResult(); 49 | } 50 | 51 | var result = await GetMetadata(sid, cancellationToken); 52 | if (result.HasMetadata) 53 | { 54 | info.SetProviderId(ProviderID, sid); 55 | result.QueriedById = true; 56 | _logger.LogInformation($"[DOUBAN] Get series metadata of \"{info.Name}\" successfully!"); 57 | } 58 | 59 | return result; 60 | } 61 | 62 | public async Task> GetSearchResults( 63 | SeriesInfo info, CancellationToken cancellationToken) 64 | { 65 | _logger.LogInformation($"[DOUBAN] Searching series \"{info.Name}\""); 66 | 67 | var results = new List(); 68 | 69 | var searchResults = new List(); 70 | 71 | string sid = info.GetProviderId(ProviderID); 72 | if (!string.IsNullOrEmpty(sid)) 73 | { 74 | var subject = await GetSubject(sid, cancellationToken); 75 | searchResults.Add(new Response.SearchTarget 76 | { 77 | Id = subject?.Id, 78 | Cover_Url = subject?.Pic?.Normal, 79 | Year = subject?.Year, 80 | Title = subject?.Title 81 | }); 82 | } 83 | else 84 | { 85 | searchResults = await Search(info.Name, cancellationToken); 86 | } 87 | 88 | foreach (Response.SearchTarget searchTarget in searchResults) 89 | { 90 | var searchResult = new RemoteSearchResult() 91 | { 92 | Name = searchTarget?.Title, 93 | ImageUrl = searchTarget?.Cover_Url, 94 | ProductionYear = int.Parse(searchTarget?.Year) 95 | }; 96 | searchResult.SetProviderId(ProviderID, searchTarget.Id); 97 | results.Add(searchResult); 98 | } 99 | 100 | return results; 101 | } 102 | #endregion series 103 | 104 | #region season 105 | public async Task> GetMetadata(SeasonInfo info, 106 | CancellationToken cancellationToken) 107 | { 108 | _logger.LogInformation($"[DOUBAN] Getting season metadata for \"{info.Name}\""); 109 | var result = new MetadataResult(); 110 | 111 | info.SeriesProviderIds.TryGetValue(ProviderID, out string sid); 112 | if (string.IsNullOrEmpty(sid)) 113 | { 114 | _logger.LogInformation("No douban sid found, just skip"); 115 | return result; 116 | } 117 | 118 | if (string.IsNullOrWhiteSpace(sid)) 119 | { 120 | _logger.LogError($"[DOUBAN FRODO ERROR] No sid found for \"{info.Name}\""); 121 | return new MetadataResult(); 122 | } 123 | 124 | var subject = await GetSubject(sid, cancellationToken). 125 | ConfigureAwait(false); 126 | 127 | string pattern_name = @".* (?i)Season(?-i) (\d+)$"; 128 | Match match = Regex.Match(subject.Original_Title, pattern_name); 129 | if (match.Success) 130 | { 131 | result.Item = new Season 132 | { 133 | IndexNumber = int.Parse(match.Groups[1].Value), 134 | ProductionYear = int.Parse(subject.Year) 135 | }; 136 | result.HasMetadata = true; 137 | } 138 | return result; 139 | 140 | } 141 | 142 | public Task> GetSearchResults( 143 | SeasonInfo info, CancellationToken cancellationToken) 144 | { 145 | _logger.LogInformation("Douban:Search for season {0}", info.Name); 146 | // It's needless for season to do search 147 | return Task.FromResult>( 148 | new List()); 149 | } 150 | #endregion season 151 | 152 | #region episode 153 | public async Task> GetMetadata(EpisodeInfo info, 154 | CancellationToken cancellationToken) 155 | { 156 | _logger.LogInformation($"Douban:GetMetadata for episode {info.Name}"); 157 | var result = new MetadataResult(); 158 | 159 | if (info.IsMissingEpisode) 160 | { 161 | _logger.LogInformation("Do not support MissingEpisode"); 162 | return result; 163 | } 164 | 165 | var sid = info.GetProviderId(ProviderID); 166 | if (string.IsNullOrEmpty(sid)) 167 | { 168 | var searchResults = await Search(info.Name, cancellationToken); 169 | sid = searchResults.FirstOrDefault()?.Id; 170 | } 171 | 172 | if (!info.IndexNumber.HasValue) 173 | { 174 | _logger.LogInformation("No episode num found, please check " + 175 | "the format of file name"); 176 | return result; 177 | } 178 | // Start to get information from douban 179 | result.Item = new Episode 180 | { 181 | Name = info.Name, 182 | IndexNumber = info.IndexNumber, 183 | ParentIndexNumber = info.ParentIndexNumber 184 | }; 185 | result.Item.SetProviderId(ProviderID, sid); 186 | 187 | //var url = string.Format("https://movie.douban.com/subject/{0}" + 188 | // "/episode/{1}/", sid, info.IndexNumber); 189 | // TODO(Libitum) 190 | // string content = await _doubanAccessor.GetResponseWithDelay(url, cancellationToken); 191 | string content = ""; 192 | string pattern_name = "data-name=\\\"(.*?)\\\""; 193 | Match match = Regex.Match(content, pattern_name); 194 | if (match.Success) 195 | { 196 | var name = HttpUtility.HtmlDecode(match.Groups[1].Value); 197 | _logger.LogDebug("The name is {0}", name); 198 | result.Item.Name = name; 199 | } 200 | 201 | string pattern_desc = "data-desc=\\\"(.*?)\\\""; 202 | match = Regex.Match(content, pattern_desc); 203 | if (match.Success) 204 | { 205 | var desc = HttpUtility.HtmlDecode(match.Groups[1].Value); 206 | _logger.LogDebug("The desc is {0}", desc); 207 | result.Item.Overview = desc; 208 | } 209 | result.HasMetadata = true; 210 | 211 | return result; 212 | } 213 | 214 | 215 | public Task> GetSearchResults( 216 | EpisodeInfo info, CancellationToken cancellationToken) 217 | { 218 | _logger.LogInformation("Douban:Search for episode {0}", info.Name); 219 | // It's needless for season to do search 220 | return Task.FromResult>( 221 | new List()); 222 | } 223 | #endregion episode 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Libitum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Douban plugin for Jellyfin 2 | 3 | ## 0. 写在前面的话 4 | 5 | 这个项目是我在 19 年折腾 NAS 的时候心血来潮写的小玩具,当时还不会 c#,由于工作很忙,只能一点点边学边折腾。 6 | 7 | 时过境迁,没想到这么多年过去了,反而是工作清闲下来了(心酸)。朝花夕拾,权当纪念~ 8 | 9 | ## 1. 背景 10 | 11 | [Jellyfin](https://github.com/jellyfin/jellyfin) 是一个免费的多媒体数据管理软件。 12 | 详情请见[官网](https://jellyfin.media/) 13 | 14 | [Douban](https://www.douban.com/) 豆瓣就不用介绍了吧:-) 15 | 16 | 这个插件是一个 Jellyfin 的元数据提取插件,能够从豆瓣抓取电影和电视剧的元数据,包括评分、简介、 17 | 演员等相关信息。 18 | 19 | ## 2. 使用方式 20 | 21 | ### 通过插件仓库安装 22 | 23 | 1. 插件仓库地址:https://github.com/Libitum/jellyfin-plugin-douban/releases/latest/download/manifest.json 24 | 25 | TODO:截图介绍过程 26 | 27 | ### 手动安装 28 | 29 | 1. 从 Release 页面下载最新的版本,或者自行编译。 30 | 2. 把下载的文件解压,然后将 Douban 文件夹放到 Jellyfin 的 "plugins" 目录下。 31 | * 对于 Linux, plugins 目录在 "$HOME/.local/share/jellyfin/plugins" 32 | * 对于 Mac 系统, 在 "~/.local/share/jellyfin/plugins" 33 | * 对于 Docker, 在 Docker 中的 "/config/plugins" 目录下。 相应的宿主机目录请查阅自己 34 | 的目录映射配置 35 | * 对于 Windows 10, 如果使用管理员权限启动的话,在 "C:\ProgramData\Jellyfin\Server\plugins" 目录下。 36 | * 对于其他系统,如果你找不到位置,请提 issue 或者与我联系。 37 | 3. 重启 Jellyfin Service 38 | 39 | ## 3. 功能 40 | 41 | 1. 支持获取电影和电视剧类型的元数据; 42 | 2. 支持获取部分电影的背景图片,会在豆瓣海报中尝试寻找合适的图片; 43 | 3. 支持延迟请求,避免被豆瓣官方封禁; 44 | 4. 不支持把多季的电视剧合并成一个。比如:权力的游戏每一季都是分开的,不支持合并在一起。 45 | 46 | ## 4. 配置 47 | 48 | TODO 49 | 50 | -------------------------------------------------------------------------------- /assets/enable_advanced_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Libitum/jellyfin-plugin-douban/f52fe11308fd72ac351167e21a8299ba2c6019d9/assets/enable_advanced_settings.png -------------------------------------------------------------------------------- /assets/enable_douban_image_provider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Libitum/jellyfin-plugin-douban/f52fe11308fd72ac351167e21a8299ba2c6019d9/assets/enable_douban_image_provider.png -------------------------------------------------------------------------------- /assets/enable_douban_provider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Libitum/jellyfin-plugin-douban/f52fe11308fd72ac351167e21a8299ba2c6019d9/assets/enable_douban_provider.png -------------------------------------------------------------------------------- /assets/language_and_country.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Libitum/jellyfin-plugin-douban/f52fe11308fd72ac351167e21a8299ba2c6019d9/assets/language_and_country.png -------------------------------------------------------------------------------- /build.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "jellyfin-plugin-douban" 3 | guid: "e325b8d5-5f54-447f-a38a-a951b933d22c" 4 | version: "0.0.1" 5 | nicename: "Douban" 6 | description: "Scrape the meta data from Douban" 7 | overview: > 8 | TODO This is a longer description that can span more than one 9 | line and include details about your plugin. 10 | category: "Metadata" 11 | owner: "libitum" 12 | artifacts: 13 | - "Jellyfin.Plugin.Douban.dll" 14 | build_type: "dotnet" 15 | dotnet_configuration: "Release" 16 | dotnet_framework: ".net6.0" 17 | -------------------------------------------------------------------------------- /jellyfin-plugin-douban.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30717.126 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.Douban", "Jellyfin.Plugin.Douban\Jellyfin.Plugin.Douban.csproj", "{22F69FCD-9702-44FA-98B5-D7AF912C4B37}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.Douban.Tests", "Jellyfin.Plugin.Douban.Tests\Jellyfin.Plugin.Douban.Tests.csproj", "{593E2CB8-69CC-4C50-BAB6-17CEC810F095}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {22F69FCD-9702-44FA-98B5-D7AF912C4B37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {22F69FCD-9702-44FA-98B5-D7AF912C4B37}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {22F69FCD-9702-44FA-98B5-D7AF912C4B37}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {22F69FCD-9702-44FA-98B5-D7AF912C4B37}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {593E2CB8-69CC-4C50-BAB6-17CEC810F095}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {593E2CB8-69CC-4C50-BAB6-17CEC810F095}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {593E2CB8-69CC-4C50-BAB6-17CEC810F095}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {593E2CB8-69CC-4C50-BAB6-17CEC810F095}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {66FDFA0E-E866-4930-8FEF-A4417C39D030} 30 | EndGlobalSection 31 | GlobalSection(MonoDevelopProperties) = preSolution 32 | Policies = $0 33 | $0.DotNetNamingPolicy = $1 34 | $1.DirectoryNamespaceAssociation = PrefixedHierarchical 35 | $0.TextStylePolicy = $4 36 | $2.FileWidth = 80 37 | $2.TabsToSpaces = True 38 | $2.scope = text/x-csharp 39 | $0.CSharpFormattingPolicy = $3 40 | $3.scope = text/x-csharp 41 | $4.FileWidth = 80 42 | $4.TabsToSpaces = True 43 | $4.scope = text/plain 44 | $0.StandardHeader = $5 45 | $0.VersionControlPolicy = $6 46 | EndGlobalSection 47 | EndGlobal 48 | --------------------------------------------------------------------------------