追剧记录管理

于个人习惯,对于做过的事情总喜欢形成一个有效的记录,作为回顾和反思之用。当前,已经借助有效的软件实现了读书进度和笔记的管理,但是一直没有找到一个合适的APP或者说工具来管理追剧记录。

以前其实也尝试过各种办法,上一轮尝试的办法是借助OBSIDIAN的豆瓣插件,然后生成对应剧的文件,利用template在生成时定制笔记属性。在这个基础上,在后期实时的调整笔记属性的值以达到管理目的,生成总结之类的则借助于dataview实现。这样的优点是可以非常的方便的用dataview生成出非常直观的总结;缺点则是文件太多了,根本不易于管理原始文件,实际上每个文件的内容并不多。所以用了一段时间,也失去了维护的动力。

在探索各种收费与开源工具后,还是回到了OBSIDAIN。最根本的考虑是数据必须要在我自己手里,并且尽可能是通用格式。就目前的互联网趋势,一个成品APP的寿命周期太短了,封闭格式和数据云端非常有可能导致数字灾民。于是,回归本质,在OBSIDIAN不再考虑成品的插件,因为真的找不到合适的,在当前AI足够强大的背景下,考虑了一套逻辑,利用AI的支持,自己编写了一套方便的记录和管理方式。

本质上这套管理方式和以前的不大,只是对于我自己而言,它可以高度自定义了,不再有我不需要的垃圾数据,也简化了文件的管理。具体的管理方式是:每一年的追剧记录会保存在一个笔记文件里,这样可以极大的降低文件数量;对于需要增加的记录,直接使用quickadd调用我编写的代码实现从TMDB爬取数据,并追加到当年的追剧记录中;利用dataview调用外部代码实现解析当年数据并生产对应的视图,解耦笔记文件和总结文件的关联性。当年这样还是有一定困难,对于文件的解析复杂度非常高,靠人力几乎是做低质量的劳动,所以这一部分几乎全是AI代劳。

目前来说效果很不错,可以清晰的捋清追剧情况,总结界面对于剧状态也非常简洁,后期就算有特殊需求,可以非常简单的增加或者删除功能。

分享展示下效果:

这是原始的文件记录状态,可以非常清晰的修改,已预留了观后管填写位置,可以方便的展示到总结。

追剧记录管理-原始记录效果.png

这是海报墙展示,目前只展示了基本情况,适合作为分享用。

追剧记录管理.png

代码记录

quickadd部分

这部分主要是由quickadd获取capture,capture会调用quickadd的macro脚本。

首先是创建一个获取影视信息的脚本,代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
// addAnyMedia.js - 通用影视记录宏 (支持电影、电视剧、纪录片等)
module.exports = async (params) => {

    const { quickAddApi } = params;

    // 配置区 ==============================================
    const TMDB_API_KEY = '输入你的的TMDBkey';
    const LANGUAGE = "zh-CN";

    // 主流程 ==============================================
    try {
        // 1. 用户输入
        const mediaName = await quickAddApi.inputPrompt("请输入影视名称 (电影/剧集/纪录片...)");
        if (!mediaName) return "";

        // 2. 执行通用智能搜索 (核心)
        const searchResults = await universalSmartSearch(mediaName, TMDB_API_KEY, LANGUAGE);
        if (searchResults.length === 0) {
            return await handleNoResults(mediaName, quickAddApi);
        }

        // 3. 让用户选择搜索结果
        const choiceLabels = searchResults.map((item, idx) => {
            const year = getYear(item);
            const typeIcon = item.media_type === 'movie' ? '🎬' : (item.media_type === 'tv' ? '📺' : '🎭');
            const langFlag = (item.original_language === 'zh' || item.origin_country?.includes('CN')) ? '🇨🇳' : '🌐';
            return `${idx + 1}. ${typeIcon} ${langFlag} ${item.title || item.name} (${year}) | 评分: ⭐ ${(item.vote_average || 0).toFixed(1)}`;
        });
        choiceLabels.push(`⛔ 手动输入TMDB ID`);

        const selectedIndex = await quickAddApi.suggester(choiceLabels, [...searchResults.map((_, i) => i), -1]);
        if (selectedIndex === -1) {
            const manualId = await quickAddApi.inputPrompt("请输入TMDB ID (从网址获取):");
            if (!manualId) return "";
            throw new Error(`手动添加\n请使用ID ${manualId} 重新搜索或稍后配置。`);
            // return `## 手动添加\n请使用ID ${manualId} 重新搜索或稍后配置。\n---\n`;
        }
        const selectedItem = searchResults[selectedIndex];

        // 4. 根据媒体类型进行分支处理
        let finalMarkdown;
        if (selectedItem.media_type === 'movie') {
            finalMarkdown = await processMovie(selectedItem, TMDB_API_KEY, LANGUAGE, quickAddApi);
        } else if (selectedItem.media_type === 'tv') {
            finalMarkdown = await processTVShow(selectedItem, TMDB_API_KEY, LANGUAGE, quickAddApi);
        } else {
            // 处理其他类型(如纪录片),按电影流程处理
            finalMarkdown = await processMovie(selectedItem, TMDB_API_KEY, LANGUAGE, quickAddApi);
        }

        return finalMarkdown;

    } catch (error) {
        console.error("通用脚本出错:", error);
        throw new Error(`错误\n处理过程中出现错误: ${error.message}`);
        return `## 错误\n处理过程中出现错误: ${error.message}\n---\n`;
    }
};

// ===================== 核心功能函数 =====================

/**
 * 通用智能搜索函数 (支持所有类型)
 */
async function universalSmartSearch (query, apiKey, language) {
    console.log(`开始通用搜索: "${query}"`);
    const encodedQuery = encodeURIComponent(query.replace(/[::·•]/g, ' ').trim());

    // 使用 /search/multi 端点,这是支持所有类型的关键
    const searchUrl = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&query=${encodedQuery}&language=${language}&include_adult=false&page=1`;
    const response = await fetch(searchUrl);
    if (!response.ok) throw new Error(`搜索API失败: ${response.status}`);

    const data = await response.json();
    // 过滤掉“人物”(person)类型,只保留影视
    const mediaResults = (data.results || []).filter(item => item.media_type !== 'person');

    console.log(`找到 ${mediaResults.length} 个媒体结果`);
    return mediaResults;
}

/**
 * 处理电影类型
 */
async function processMovie (movieItem, apiKey, language, quickAddApi) {
    // 获取电影详情
    const detailUrl = `https://api.themoviedb.org/3/movie/${movieItem.id}?api_key=${apiKey}&language=${language}`;
    const detailRes = await fetch(detailUrl);
    const movieDetails = await detailRes.json();

    const today = new Date().toISOString().split('T')[0];
    // 使用 original 画质
    const posterUrl = movieDetails.poster_path ? `https://image.tmdb.org/t/p/original${movieDetails.poster_path}` : '';
    const tmdbLink = `https://www.themoviedb.org/movie/${movieDetails.id}`;

    return `## 🎬 [${movieDetails.title}](${tmdbLink}) 

${posterUrl ? `![封面|300](${posterUrl})` : ''}

**📋 电影信息**
- **类型**: ${(movieDetails.genres || []).map(g => g.name).join('、') || '未知'}
- **评分**: ⭐ ${movieDetails.vote_average?.toFixed(1) || '?'}/10 (${movieDetails.vote_count || 0}人)
- **片长**: ${movieDetails.runtime || '?'}分钟
- **上映日期**: ${movieDetails.release_date || '未知'}
- **剧情简介**:${movieDetails.overview || '暂无简介。'}

**🎯 我的观看记录**
- **记录日期**: ${today}
- **完成日期**: 
- **我的评分**: ⭐/10
- **观看平台**: 
- **观看状态**: 已看完

**💭 观后感**

---
`;
}

/**
 * 处理电视剧类型 (包含季数选择)
 */
async function processTVShow (tvItem, apiKey, language, quickAddApi) {
    // 获取剧集基本信息
    const showDetailUrl = `https://api.themoviedb.org/3/tv/${tvItem.id}?api_key=${apiKey}&language=${language}`;
    const showDetailRes = await fetch(showDetailUrl);
    const showDetails = await showDetailRes.json();

    // 1. 列出所有季让用户选择
    const availableSeasons = (showDetails.seasons || []).filter(s => s.season_number > 0);
    if (availableSeasons.length === 0) {
        throw new Error(`${showDetails.name}\n该剧暂无季数信息。`);
        return `## ${showDetails.name}\n该剧暂无季数信息。\n---\n`;
    }

    // 创建选择列表:显示字符串,但返回季节号
    const seasonChoices = availableSeasons.map(s =>
        `第${s.season_number}季: ${s.name || '未命名'} (${s.episode_count || 0}集)`
    );

    // 这里返回的是季节号(数字),不是显示的字符串
    const selectedSeasonNum = await quickAddApi.suggester(
        seasonChoices,
        availableSeasons.map(s => s.season_number)
    );

    // 如果用户取消选择
    if (selectedSeasonNum === null || selectedSeasonNum === undefined) return "";

    // 2. 获取选中季的详细信息
    let seasonDetails = availableSeasons.find(s => s.season_number === selectedSeasonNum);
    const seasonDetailUrl = `https://api.themoviedb.org/3/tv/${tvItem.id}/season/${selectedSeasonNum}?api_key=${apiKey}&language=${language}`;
    const seasonDetailRes = await fetch(seasonDetailUrl);
    if (seasonDetailRes.ok) {
        const detailed = await seasonDetailRes.json();
        seasonDetails = { ...seasonDetails, ...detailed };
    }

    const today = new Date().toISOString().split('T')[0];
    // 优先使用该季海报,否则使用剧集海报,均使用 original 画质
    const posterPath = seasonDetails.poster_path || showDetails.poster_path;
    const posterUrl = posterPath ? `https://image.tmdb.org/t/p/original${posterPath}` : '';
    const tmdbLink = `https://www.themoviedb.org/tv/${showDetails.id}/season/${selectedSeasonNum}`;

    // 生成剧集列表
    let episodesList = "";
    if (seasonDetails.episodes && seasonDetails.episodes.length > 0) {
        episodesList = "\n**单集列表**:\n";
        seasonDetails.episodes.slice(0, 10).forEach(ep => { // 最多显示10集
            episodesList += `- **第${ep.episode_number}集**《${ep.name || '未命名'}》\n`;
        });
        if (seasonDetails.episodes.length > 10) episodesList += `- ... 等共 ${seasonDetails.episodes.length} 集\n`;
    }

    return `## 📺 [${showDetails.name}${selectedSeasonNum === 1 ? '' : `(第${selectedSeasonNum}季)`}](${tmdbLink})
    
${posterUrl ? `![封面|300](${posterUrl})` : ''}

**📋 本季信息**

- **类型**: ${(showDetails.genres || []).map(g => g.name).join('、') || '未知'}
- **剧评分**: ⭐ ${showDetails.vote_average?.toFixed(1) || '?'}/10 (${showDetails.vote_count || 0}人)
- **季评分**: ⭐ ${seasonDetails.vote_average?.toFixed(1) || '?'}/10 (${seasonDetails.vote_count || 0}人)
- **季名**: ${seasonDetails.name || '未命名'}(第${selectedSeasonNum}季/共${showDetails.number_of_seasons || 0}季)
- **集数**: ${seasonDetails.episodes ? seasonDetails.episodes.length : seasonDetails.episode_count || '?'}- **播出年份**: ${seasonDetails.air_date ? seasonDetails.air_date.substring(0, 4) : '未知'}
- **本季简介**: ${seasonDetails.overview || '暂无简介'}

**🎯 我的观看记录**
- **记录日期**: ${today}
- **完成日期**: 
- **观看进度**: /${seasonDetails.episodes ? seasonDetails.episodes.length : seasonDetails.episode_count || '?'}- **我的评分**: ⭐/10
- **观看平台**: 
- **观看状态**: 已看完

**💭 本季观感**

---
`;
}

/**
 * 处理无搜索结果的情况
 */
async function handleNoResults (query, quickAddApi) {
    const action = await quickAddApi.suggester(
        ['换个名称再试', '前往TMDB网站查找ID', '放弃添加'],
        ['retry', 'find_id', 'cancel']
    );

    if (action === 'find_id') {
        quickAddApi.openUrl(`https://www.themoviedb.org/search?query=${encodeURIComponent(query)}`);
        throw new Error(`已为您打开TMDB搜索页面,找到后请使用ID添加。`);
        return `已为您打开TMDB搜索页面,找到后请使用ID添加。\n---\n`;
    }
    throw new Error(`未找到: ${query}\n尝试了通用搜索,未找到结果。`);
    return `## 未找到: ${query}\n尝试了通用搜索,未找到结果。\n---\n`;
}

/**
 * 辅助函数:从日期中提取年份
 */
function getYear (item) {
    const dateStr = item.release_date || item.first_air_date || item.air_date;
    return dateStr ? dateStr.substring(0, 4) : '年份未知';
}

接着创建一个quickadd的macro,指定这个脚本。 然后创建一个quickadd的capture,路径之类的可随意写,主要是支持format那需要写调用的macro。格式类似于{{macro:你的macro名称}}。 后续就可以直接使用quickadd获取影视剧信息咯。

dataview展示

调用方式很简单,我的影视是按年划分,每年的数据存在一个文件里,使用的渲染代码如下:

1
2
3
await dv.view("AAAA.scripts/dataview/showView_v2","追剧记录/追剧记录_2026.md")
// AAAA.scripts/dataview/showView_v2 是我的展示脚本路径
// 追剧记录/追剧记录_2026.md 是我需要渲染的年份数据

具体的代码如下:

   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
/**
 * ============================================
 * 影视记录可视化组件 v2.0
 * 功能:解析 Markdown 影视记录,生成可视化票根样式展示

 * ============================================
 */

// ------------------------------
// 常量与配置区
// ------------------------------

// 调试开关:控制图片匹配调试信息输出
const IMAGE_MATCH_DEBUG = true;

// 颜色缓存,避免重复计算
const COLOR_CACHE = {};
const COLOR_PROMISES = {};

// 预定义颜色方案
const COLOR_PALETTE = [
    'rgba(173, 216, 230, 0.15)', // 浅蓝色
    'rgba(144, 238, 144, 0.15)', // 浅绿色
    'rgba(255, 182, 193, 0.15)', // 浅粉色
    'rgba(221, 160, 221, 0.15)', // 浅紫色
    'rgba(255, 222, 173, 0.15)', // 浅橙色
    'rgba(240, 230, 140, 0.15)', // 浅黄色
    'rgba(175, 238, 238, 0.15)', // 浅青色
    'rgba(255, 228, 196, 0.15)', // 浅杏仁色
];

// 类型对应颜色
const TYPE_COLORS = {
    '电影': 'rgba(70, 130, 180, 0.15)', // 钢蓝色
    '电视剧': 'rgba(186, 85, 211, 0.15)', // 中紫色
};

// 状态对应颜色
const STATUS_COLORS = {
    '已弃剧': '#595959',
    '观看中': '#096dd9',
    '搁置中': '#d46b08',
    '想看': '#db19d2ff'
};

// ------------------------------
// 工具函数区
// ------------------------------

/**
 * 生成星级评分显示
 * @param {number} rating - 评分(0-10分制)
 * @returns {string} HTML字符串
 */
function generateStarRating(rating) {
    const starRating = Math.round((rating / 10) * 5 * 2) / 2;
    const fullStars = Math.floor(starRating);
    const hasHalfStar = starRating % 1 !== 0;
    const emptyStars = 5 - Math.ceil(starRating);

    let starsHTML = '';

    // 实心星
    for (let i = 0; i < fullStars; i++) {
        starsHTML += '<span style="color: #ff9900; font-size: 16px; text-shadow: 0 1px 2px rgba(0,0,0,0.1);">★</span>';
    }

    // 半星(使用渐变色实现半星效果)
    if (hasHalfStar) {
        starsHTML += '<span style="' +
            'display: inline-block;' +
            'width: 16px;' +
            'height: 16px;' +
            'font-size: 16px;' +
            'line-height: 1;' +
            'background: linear-gradient(90deg, #ff9900 50%, #999 50%);' +
            '-webkit-background-clip: text;' +
            '-webkit-text-fill-color: transparent;' +
            '">★</span>';
    }

    // 空心星
    for (let i = 0; i < emptyStars; i++) {
        starsHTML += '<span style="color: #999; font-size: 16px;">★</span>';
    }

    return starsHTML;
}

/**
 * 解析日期字符串,支持多种格式
 * @param {string} s - 日期字符串
 * @returns {Date|null} 解析后的日期对象
 */
function parseDateString(s) {
    if (!s) return null;
    
    try {
        // 尝试直接解析
        let d = new Date(s);
        if (!isNaN(d)) return d;
    } catch (e) { }

    try {
        // 替换常见分隔符,去掉中文日字
        let t = s.replace(/[年月.\.\/]/g, '-').replace(/日/g, '').trim();
        t = t.split(' ')[0]; // 移除时间部分
        let d2 = new Date(t);
        if (!isNaN(d2)) return d2;
    } catch (e) { }

    // 最后尝试从数字中抽取 YYYY MM DD
    const m = s.match(/(\d{4}).*?(\d{1,2}).*?(\d{1,2})/);
    if (m) {
        return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
    }
    return null;
}

/**
 * 从文件名中提取年份信息
 * @param {string} filePath - 文件路径
 * @returns {string} 年份字符串
 */
function extractYearFromFileName(filePath) {
    const fileName = filePath.split('/').pop();
    
    // 匹配 _2026.md 格式
    const yearMatch = fileName.match(/_(\d{4})\.md$/);
    if (yearMatch && yearMatch[1]) {
        return yearMatch[1];
    }

    // 匹配其他格式的四位数字
    const yearMatch2 = fileName.match(/(\d{4})/);
    if (yearMatch2 && yearMatch2[1]) {
        return yearMatch2[1];
    }

    // 默认返回当前年份
    return new Date().getFullYear().toString();
}

// ------------------------------
// 数据解析区
// ------------------------------

/**
 * 解析单个影视条目
 * @param {string} section - Markdown段落
 * @returns {object} 影视条目对象
 */
function parseMediaSection(section) {
    const lines = section.split('\n');
    const item = {
        title: '',
        link: '',
        cover: '',
        type: '',
        rating: '',
        genres: [],
        duration: '',
        releaseDate: '',
        description: '',
        recordDate: '',
        isTv: false,
        episodeInfo: '',
        finishDate: '',
        myRating: null,
        status: '',
        progress: null
    };

    for (let i = 0; i < lines.length; i++) {
        const line = lines[i].trim();
        parseLine(line, item);
    }
    
    return item;
}

/**
 * 解析单行文本,填充条目信息
 * @param {string} line - 文本行
 * @param {object} item - 影视条目对象
 */
function parseLine(line, item) {
    // 解析标题和链接
    const titleMatch = line.match(/^##\s*(🎬|📺)\s*\[([^\]]+)\]\(([^)]+)\)/);
    if (titleMatch) {
        item.title = titleMatch[2];
        item.link = titleMatch[3];
        item.type = titleMatch[1] === '🎬' ? '电影' : '电视剧';
        item.isTv = titleMatch[1] === '📺';
        return;
    }

    // 解析封面图片
    const coverMatch = line.match(/!\[[^\]]*\]\(([^)]+)\)/);
    if (coverMatch && !item.cover) {
        let coverPath = coverMatch[1];
        // 处理本地相对路径
        if (coverPath && !coverPath.startsWith('http') && !coverPath.startsWith('data:')) {
            coverPath = coverPath.replace(/^\[\[/, '').replace(/\]\]$/, '');
            // 导出模式下尝试获取base64
            if (typeof window !== 'undefined' && window.location.href.includes('export')) {
                coverPath = getImageAsBase64(coverPath);
            }
        }
        item.cover = coverPath;
        return;
    }

    // 解析类型
    const genreMatch = line.match(/\*\*类型\*\*:\s*(.+)/);
    if (genreMatch) {
        item.genres = genreMatch[1].split('、').map(g => g.trim());
        return;
    }

    // 解析评分
    const ratingMatch = line.match(/\*\*评分\*\*:\s*⭐\s*([\d.]+)\/10/);
    if (ratingMatch) {
        item.rating = ratingMatch[1];
        return;
    }

    // 解析片长/集数
    const durationMatch = line.match(/\*\*片长\*\*:\s*(.+)/);
    const episodeMatch = line.match(/\*\*集数\*\*:\s*(.+)/);
    if (durationMatch) {
        item.duration = durationMatch[1];
    } else if (episodeMatch) {
        item.episodeInfo = episodeMatch[1];
    }

    // 解析上映日期/播出年份
    const dateMatch = line.match(/\*\*上映日期\*\*:\s*(.+)/);
    const yearMatch = line.match(/\*\*播出年份\*\*:\s*(.+)/);
    if (dateMatch) {
        item.releaseDate = dateMatch[1];
    } else if (yearMatch) {
        item.releaseDate = yearMatch[1];
    }

    // 解析剧情简介
    const descMatch = line.match(/\*\*剧情简介\*\*:\s*(.+)/);
    const seasonDescMatch = line.match(/\*\*本季简介\*\*:\s*(.+)/);
    if (descMatch) {
        item.description = descMatch[1];
    } else if (seasonDescMatch) {
        item.description = seasonDescMatch[1];
    }

    // 解析记录日期
    const recordDateMatch = line.match(/\*\*记录日期\*\*:\s*(.+)/);
    if (recordDateMatch) {
        item.recordDate = recordDateMatch[1];
    }

    // 解析完成日期
    const finishDateMatch = line.match(/\*\*完成日期\*\*:\s*(.+)/);
    if (finishDateMatch) {
        item.finishDate = finishDateMatch[1];
    }

    // 解析我的评分
    const myRatingMatch = line.match(/\*\*我的评分\*\*:\s*⭐\s*([\d.]*)\/10/);
    if (myRatingMatch && myRatingMatch[1]) {
        item.myRating = parseFloat(myRatingMatch[1]);
    }

    // 解析观看状态
    const statusMatch = line.match(/\*\*观看状态\*\*:\s*(.+)/);
    if (statusMatch) {
        item.status = statusMatch[1];
    }

    // 解析观看进度
    const progressMatch = line.match(/\*\*观看进度\*\*:\s*(\d+)\/(\d+)/);
    if (progressMatch) {
        item.progress = {
            current: parseInt(progressMatch[1]),
            total: parseInt(progressMatch[2])
        };
    }
}

// ------------------------------
// 图片处理区
// ------------------------------

/**
 * 获取图片的base64数据(用于导出模式)
 * @param {string} imagePath - 图片路径
 * @returns {string} base64编码或原始路径
 */
function getImageAsBase64(imagePath) {
    try {
        const allImages = document.querySelectorAll('img');
        for (const img of allImages) {
            const src = img.src || '';
            const srcPath = imagePathFromUrl(src);
            const targetPath = imagePathFromUrl(imagePath);
            const srcBase = srcPath.split('/').pop();
            const targetBase = targetPath.split('/').pop();

            if (src === imagePath || srcPath === targetPath || src.includes(targetPath) || 
                srcPath.endsWith(targetPath) || (srcBase && targetBase && 
                (srcBase === targetBase || src.includes(targetBase)))) {
                if (src.startsWith('data:')) {
                    return src;
                }
                return convertImageToBase64(img);
            }
        }
    } catch (e) {
        console.log("获取 base64 图片失败:", e);
    }
    
    if (IMAGE_MATCH_DEBUG) debugListAllImages(imagePath);
    return imagePath; // 回退到原始路径
}

/**
 * 将图片转换为base64格式
 * @param {HTMLImageElement} img - 图片元素
 * @returns {Promise<string>} base64字符串
 */
function convertImageToBase64(img) {
    return new Promise((resolve) => {
        try {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            
            canvas.width = img.naturalWidth || img.width;
            canvas.height = img.naturalHeight || img.height;
            
            ctx.drawImage(img, 0, 0);
            const base64 = canvas.toDataURL('image/jpeg', 0.8);
            resolve(base64);
        } catch (e) {
            console.log("图片转换失败:", e);
            resolve(img.src); // 返回原始src
        }
    });
}

/**
 * 从URL中提取图片路径(移除查询参数和哈希)
 * @param {string} url - 完整URL
 * @returns {string} 清理后的路径
 */
function imagePathFromUrl(url) {
    if (!url) return '';
    return url.split('?')[0].split('#')[0];
}

/**
 * 获取URL的文件名(解码后)
 * @param {string} url - URL地址
 * @returns {string} 文件名
 */
function imageBasename(url) {
    if (!url) return '';
    try {
        const path = imagePathFromUrl(url);
        const parts = path.split('/');
        return decodeURIComponent(parts[parts.length - 1] || '');
    } catch (e) {
        return '';
    }
}

/**
 * 调试函数:列出页面上所有图片信息
 * @param {string} target - 目标图片路径
 */
function debugListAllImages(target) {
    if (!IMAGE_MATCH_DEBUG) return;
    
    try {
        console.group && console.group('Image Match Debug');
        const imgs = document.querySelectorAll('img');
        
        for (const im of imgs) {
            const src = im.currentSrc || im.src || '';
            const path = imagePathFromUrl(src);
            const base = imageBasename(src || path);
            console.log('IMG:', { src, path, base, alt: im.alt || '', title: im.title || '' });
        }
        
        if (target) {
            const targetPath = imagePathFromUrl(target);
            const targetBase = imageBasename(targetPath);
            console.log('TARGET:', { target, targetPath, targetBase });
        }
        
        console.groupEnd && console.groupEnd();
    } catch (e) {
        console.log('debugListAllImages error', e);
    }
}

// ------------------------------
// 颜色处理区
// ------------------------------

/**
 * 从图片中提取主色调
 * @param {string} imageUrl - 图片URL
 * @returns {Promise<string|null>} RGB颜色字符串
 */
async function getDominantColorFromImage(imageUrl) {
    if (!imageUrl) return null;

    // 处理data URL
    if (typeof imageUrl === 'string' && imageUrl.startsWith('data:image')) {
        return await extractColorFromBase64(imageUrl);
    }

    // 缓存检查
    if (COLOR_CACHE[imageUrl]) return COLOR_CACHE[imageUrl];
    if (COLOR_PROMISES[imageUrl]) return await COLOR_PROMISES[imageUrl];

    const task = (async () => {
        try {
            let bitmap = null;
            
            // 尝试多种方式获取图片
            bitmap = await loadImageBitmap(imageUrl);
            
            if (!bitmap) {
                const imgElement = await findImageElement(imageUrl);
                if (imgElement && imgElement.complete) {
                    try { bitmap = await createImageBitmap(imgElement); } catch (e) { bitmap = imgElement; }
                }
            }
            
            if (!bitmap) {
                bitmap = await loadImageViaImgTag(imageUrl);
            }
            
            if (!bitmap) return null;
            
            return await extractColorFromBitmap(bitmap);
        } catch (err) {
            console.log("提取颜色失败:", err);
            return null;
        }
    })();

    COLOR_PROMISES[imageUrl] = task;
    
    try {
        const color = await task;
        delete COLOR_PROMISES[imageUrl];
        if (color) COLOR_CACHE[imageUrl] = color;
        return color;
    } catch (e) {
        delete COLOR_PROMISES[imageUrl];
        return null;
    }
}

/**
 * 从base64图片中提取颜色
 * @param {string} base64 - base64编码的图片
 * @returns {Promise<string|null>} RGB颜色字符串
 */
async function extractColorFromBase64(base64) {
    if (!base64) return null;
    try {
        const img = new Image();
        img.crossOrigin = 'anonymous';
        await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = base64; });
        return await extractColorFromImageElement(img);
    } catch (e) { return null; }
}

/**
 * 从Image元素提取颜色
 * @param {HTMLImageElement} img - 图片元素
 * @returns {Promise<string|null>} RGB颜色字符串
 */
async function extractColorFromImageElement(img) {
    if (!img) return null;
    try {
        const maxSize = 100;
        let width = img.naturalWidth || img.width || maxSize;
        let height = img.naturalHeight || img.height || maxSize;
        
        // 等比例缩放
        if (width > height && width > maxSize) {
            height = Math.round((height * maxSize) / width);
            width = maxSize;
        } else if (height > maxSize) {
            width = Math.round((width * maxSize) / height);
            height = maxSize;
        }

        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);
        
        return extractColorFromCanvas(ctx, width, height);
    } catch (e) {
        return null;
    }
}

/**
 * 从Canvas上下文中提取颜色
 * @param {CanvasRenderingContext2D} ctx - 2D上下文
 * @param {number} width - 画布宽度
 * @param {number} height - 画布高度
 * @returns {string|null} RGB颜色字符串
 */
function extractColorFromCanvas(ctx, width, height) {
    const imageData = ctx.getImageData(0, 0, width, height);
    const data = imageData.data;
    const colorMap = {};
    let maxCount = 0;
    let dominantColor = null;
    const pixelCount = width * height;
    const sampleRate = 5;

    for (let i = 0; i < pixelCount; i += sampleRate) {
        const pi = i * 4;
        const a = data[pi + 3];
        if (a < 50) continue;
        
        const r = data[pi], g = data[pi + 1], b = data[pi + 2];
        if (r > 240 && g > 240 && b > 240) continue; // 跳过接近白色
        if (r < 20 && g < 20 && b < 20) continue;    // 跳过接近黑色
        
        const qr = Math.floor(r / 8) * 8;
        const qg = Math.floor(g / 8) * 8;
        const qb = Math.floor(b / 8) * 8;
        const key = `${qr},${qg},${qb}`;
        
        colorMap[key] = (colorMap[key] || 0) + 1;
        if (colorMap[key] > maxCount) {
            maxCount = colorMap[key];
            dominantColor = `rgb(${qr}, ${qg}, ${qb})`;
        }
    }

    return (dominantColor && maxCount > 3) ? dominantColor : null;
}

/**
 * 从标题生成稳定颜色
 * @param {string} title - 影视标题
 * @returns {string} RGBA颜色字符串
 */
function getColorFromTitle(title) {
    let hash = 0;
    for (let i = 0; i < title.length; i++) {
        hash = title.charCodeAt(i) + ((hash << 5) - hash);
    }
    const index = Math.abs(hash) % COLOR_PALETTE.length;
    return COLOR_PALETTE[index];
}

/**
 * 根据类型获取颜色
 * @param {string} type - 影视类型
 * @returns {string} RGBA颜色字符串
 */
function getColorByType(type) {
    return TYPE_COLORS[type] || 'rgba(169, 169, 169, 0.15)';
}

/**
 * 应用背景颜色到票根元素
 * @param {HTMLElement} leftContainer - 左侧容器
 * @param {HTMLElement} watchSection - 右侧容器
 * @param {string} color - 颜色值
 * @param {boolean} isDominant - 是否为主色调
 */
function applyBackgroundColor(leftContainer, watchSection, color, isDominant = false) {
    if (!color) return;

    let bgColor;
    if (isDominant) {
        bgColor = color.replace('rgb(', 'rgba(').replace(')', ', 0.1)');
    } else {
        bgColor = color;
    }

    leftContainer.style.background = bgColor;
    leftContainer.style.backgroundImage = `linear-gradient(90deg, ${bgColor}, transparent 50%)`;
    
    watchSection.style.background = bgColor;
    watchSection.style.backgroundImage = `linear-gradient(90deg, transparent, ${bgColor})`;
}

// ------------------------------
// 图片加载辅助函数
// ------------------------------

/**
 * 通过fetch和createImageBitmap加载图片
 * @param {string} imageUrl - 图片URL
 * @returns {Promise<ImageBitmap|null>} 图片位图
 */
async function loadImageBitmap(imageUrl) {
    if (!imageUrl.startsWith('http') && !imageUrl.startsWith('blob:')) {
        return null;
    }
    
    try {
        const resp = await fetch(imageUrl, { mode: 'cors' });
        if (resp.ok) {
            const blob = await resp.blob();
            return await createImageBitmap(blob);
        }
    } catch (e) {
        // 忽略错误,尝试其他方法
    }
    return null;
}

/**
 * 在页面中查找图片元素
 * @param {string} imageUrl - 目标图片URL
 * @param {number} retries - 重试次数
 * @param {number} delayMs - 重试间隔
 * @returns {Promise<HTMLImageElement|null>} 图片元素
 */
async function findImageElement(imageUrl, retries = 3, delayMs = 200) {
    for (let attempt = 0; attempt < retries; attempt++) {
        if (typeof document === 'undefined') return null;
        
        const imgs = document.querySelectorAll('img');
        const targetPath = imagePathFromUrl(imageUrl);
        const targetBase = imageBasename(targetPath);
        
        for (const img of imgs) {
            const src = img.currentSrc || img.src || '';
            if (!src) continue;
            
            if (matchImage(src, imageUrl, targetPath, targetBase)) {
                return img;
            }
        }
        
        // 重试前等待
        if (attempt < retries - 1) {
            await new Promise(res => setTimeout(res, delayMs));
        }
    }
    
    // 激进回退:选取最可能的候选图片
    return findFallbackImage(imageUrl);
}

/**
 * 匹配图片
 * @param {string} src - 图片源
 * @param {string} targetUrl - 目标URL
 * @param {string} targetPath - 目标路径
 * @param {string} targetBase - 目标文件名
 * @returns {boolean} 是否匹配
 */
function matchImage(src, targetUrl, targetPath, targetBase) {
    const srcPath = imagePathFromUrl(src);
    const srcBase = imageBasename(srcPath);
    
    // 多种匹配策略
    let decodedSrc = '';
    let decodedTarget = '';
    try { decodedSrc = decodeURIComponent(src); } catch (e) { }
    try { decodedTarget = decodeURIComponent(targetUrl); } catch (e) { }
    
    return (
        src === targetUrl ||
        (decodedSrc && decodedTarget && decodedSrc === decodedTarget) ||
        srcPath === targetPath ||
        src.includes(targetPath) ||
        src.endsWith(targetBase) ||
        srcPath.endsWith(targetBase) ||
        (srcBase && targetBase && srcBase === targetBase)
    );
}

/**
 * 寻找备选图片(回退策略)
 * @param {string} imageUrl - 目标图片URL
 * @returns {HTMLImageElement|null} 备选图片元素
 */
function findFallbackImage(imageUrl) {
    try {
        const imgs = document.querySelectorAll('img');
        const targetPath = imagePathFromUrl(imageUrl);
        const targetBase = imageBasename(targetPath);
        const targetBaseLower = (targetBase || '').toLowerCase();
        
        let candidate = null;
        
        // 1. 优先 data URI
        for (const img of imgs) {
            const src = img.currentSrc || img.src || '';
            if (src && src.startsWith('data:')) {
                candidate = img;
                break;
            }
        }
        
        // 2. 包含文件名或路径的图片
        if (!candidate && targetBaseLower) {
            for (const img of imgs) {
                const src = (img.currentSrc || img.src || '').toLowerCase();
                if (!src) continue;
                if (src.includes(targetBaseLower) || src.endsWith(targetBaseLower)) {
                    candidate = img;
                    break;
                }
            }
        }
        
        // 3. blob: 格式(Obsidian导出常用)
        if (!candidate) {
            for (const img of imgs) {
                const src = img.currentSrc || img.src || '';
                if (src && src.startsWith('blob:')) {
                    candidate = img;
                    break;
                }
            }
        }
        
        // 4. 选择较大的图片
        if (!candidate) {
            for (const img of imgs) {
                try {
                    if ((img.naturalWidth || img.width) >= 64) {
                        candidate = img;
                        break;
                    }
                } catch (e) { }
            }
        }
        
        if (candidate && IMAGE_MATCH_DEBUG) {
            console.warn('Image match fallback:', { 
                src: candidate.currentSrc || candidate.src, 
                alt: candidate.alt || '', 
                title: candidate.title || '' 
            });
        }
        
        return candidate;
    } catch (e) {
        return null;
    }
}

/**
 * 通过Image标签加载图片
 * @param {string} imageUrl - 图片URL
 * @returns {Promise<HTMLImageElement|null>} 图片元素
 */
async function loadImageViaImgTag(imageUrl) {
    try {
        const img = new Image();
        img.crossOrigin = 'anonymous';
        await new Promise((res, rej) => { 
            img.onload = res; 
            img.onerror = rej; 
            img.src = imageUrl; 
        });
        return img;
    } catch (e) {
        return null;
    }
}

/**
 * 从位图中提取颜色
 * @param {ImageBitmap|HTMLImageElement} bitmap - 位图或图片元素
 * @returns {Promise<string|null>} RGB颜色字符串
 */
async function extractColorFromBitmap(bitmap) {
    const maxSize = 100;
    const srcW = (bitmap.width || bitmap.naturalWidth || bitmap.width) || maxSize;
    const srcH = (bitmap.height || bitmap.naturalHeight || bitmap.height) || maxSize;
    let dstW = srcW, dstH = srcH;
    
    // 等比例缩放
    if (dstW > dstH && dstW > maxSize) {
        dstH = Math.round((dstH * maxSize) / dstW);
        dstW = maxSize;
    } else if (dstH > maxSize) {
        dstW = Math.round((dstW * maxSize) / dstH);
        dstH = maxSize;
    }

    let canvas, ctx;
    if (typeof OffscreenCanvas !== 'undefined') {
        canvas = new OffscreenCanvas(dstW, dstH);
        ctx = canvas.getContext('2d');
    } else {
        canvas = document.createElement('canvas');
        canvas.width = dstW;
        canvas.height = dstH;
        ctx = canvas.getContext('2d');
    }

    try {
        ctx.drawImage(bitmap, 0, 0, dstW, dstH);
    } catch (e) {
        return null;
    }

    return extractColorFromCanvas(ctx, dstW, dstH);
}

// ------------------------------
// 渲染函数区
// ------------------------------

/**
 * 创建票根样式列表项
 * @param {object} item - 影视条目对象
 * @param {HTMLElement} container - 父容器
 * @param {string} precomputedColor - 预计算颜色
 * @param {boolean} isDominant - 是否为主色调
 * @returns {HTMLElement} 票根元素
 */
function createTicketItem(item, container, precomputedColor = null, isDominant = false) {
    // 创建票根容器
    const ticket = container.createEl('div', {
        attr: {
            style: `
                display: flex;
                position: relative;
                background: var(--background-secondary);
                border-radius: 8px;
                overflow: hidden;
                box-shadow: 0 2px 12px rgba(0,0,0,0.08);
                transition: all 0.3s ease;
                cursor: pointer;
                height: 200px;
            `
        }
    });

    // 悬停效果
    ticket.addEventListener('mouseenter', () => {
        ticket.style.transform = 'translateY(-4px)';
        ticket.style.boxShadow = '0 8px 20px rgba(0,0,0,0.12)';
    });

    ticket.addEventListener('mouseleave', () => {
        ticket.style.transform = 'translateY(0)';
        ticket.style.boxShadow = '0 2px 12px rgba(0,0,0,0.08)';
    });

    // 创建左侧容器
    const leftContainer = createLeftContainer(ticket, item);
    
    // 创建右侧容器
    const watchSection = createRightContainer(ticket);
    
    // 添加圆孔装饰
    addTicketHoles(watchSection);
    
    // 填充左侧内容
    fillLeftContent(leftContainer, item);
    
    // 填充右侧内容
    fillRightContent(watchSection, item);
    
    // 应用背景颜色
    applyBackgroundToTicket(leftContainer, watchSection, item, precomputedColor, isDominant);
    
    return ticket;
}

/**
 * 创建左侧容器
 * @param {HTMLElement} ticket - 票根元素
 * @param {object} item - 影视条目
 * @returns {HTMLElement} 左侧容器
 */
function createLeftContainer(ticket, item) {
    const leftContainer = ticket.createEl('div', {
        attr: {
            style: `
                display: flex;
                width: 75%;
                height: 100%;
            `
        }
    });

    // 创建封面区域
    createCoverSection(leftContainer, item);
    
    // 创建信息区域
    const infoSection = leftContainer.createEl('div', {
        attr: {
            style: `
                flex: 1;
                padding: 12px 16px;
                display: flex;
                flex-direction: column;
                position: relative;
                height: 100%;
                box-sizing: border-box;
                overflow: hidden;
                background: transparent;
            `
        }
    });

    return leftContainer;
}

/**
 * 创建封面区域
 * @param {HTMLElement} leftContainer - 左侧容器
 * @param {object} item - 影视条目
 */
function createCoverSection(leftContainer, item) {
    const imageSection = leftContainer.createEl('div', {
        attr: {
            style: `
                width: 120px;
                flex-shrink: 0;
                position: relative;
                overflow: hidden;
                display: flex;
                align-items: center;
                justify-content: center;
                background: transparent;
                height: calc(100% - 16px);
                margin: 8px 0 8px 8px;
                border-radius: 8px;
            `
        }
    });

    if (item.cover) {
        createCoverImage(imageSection, item);
    } else {
        createCoverPlaceholder(imageSection, item);
    }
}

/**
 * 创建封面图片
 * @param {HTMLElement} imageSection - 图片容器
 * @param {object} item - 影视条目
 */
function createCoverImage(imageSection, item) {
    const imgElement = imageSection.createEl('img', {
        attr: {
            src: item.cover,
            style: `
                width: 100%;
                height: 100%;
                object-fit: cover;
                display: block;
                border-radius: 8px;
            `
        }
    });

    imgElement.onerror = function() {
        handleImageError(this, item, imageSection);
    };
}

/**
 * 处理图片加载错误
 * @param {HTMLImageElement} imgElement - 图片元素
 * @param {object} item - 影视条目
 * @param {HTMLElement} imageSection - 图片容器
 */
function handleImageError(imgElement, item, imageSection) {
    console.log("封面图片加载失败:", item.cover);
    
    // 尝试处理 Obsidian 格式链接
    if (item.cover.includes('[[') && item.cover.includes(']]')) {
        const obsidianPath = item.cover.replace(/^\[\[/, '').replace(/\]\]$/, '');
        imgElement.src = encodeURI(obsidianPath);
    } else {
        imgElement.style.display = 'none';
        createCoverPlaceholder(imageSection, item);
    }
}

/**
 * 创建封面占位符
 * @param {HTMLElement} imageSection - 图片容器
 * @param {object} item - 影视条目
 */
function createCoverPlaceholder(imageSection, item) {
    const placeholder = imageSection.createEl('div', {
        attr: {
            style: `
                width: 100%;
                height: 100%;
                background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
                display: flex;
                align-items: center;
                justify-content: center;
                color: var(--text-muted);
                border-radius: 8px;
                font-size: 12px;
                font-weight: 500;
            `
        }
    });
    
    placeholder.textContent = item.title ? item.title.charAt(0) : '无';
}

/**
 * 填充左侧内容
 * @param {HTMLElement} leftContainer - 左侧容器
 * @param {object} item - 影视条目
 */
function fillLeftContent(leftContainer, item) {
    const infoSection = leftContainer.querySelector('div:last-child');
    if (!infoSection) return;
    
    // 标题
    createTitle(infoSection, item);
    
    // 元信息行
    createMetaRow(infoSection, item);
    
    // 类型标签
    createGenreTags(infoSection, item);
    
    // 简介
    createDescription(infoSection, item);
}

/**
 * 创建标题
 * @param {HTMLElement} container - 容器元素
 * @param {object} item - 影视条目
 */
function createTitle(container, item) {
    container.createEl('a', {
        text: item.title,
        attr: {
            href: item.link,
            style: `
                font-size: 18px;
                font-weight: 700;
                color: rgba(0,0,0,0.9);
                text-decoration: none;
                margin-bottom: 6px;
                line-height: 1.5;
                height: 27px;
                display: flex;
                align-items: center;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            `
        }
    });
}

/**
 * 创建元信息行
 * @param {HTMLElement} container - 容器元素
 * @param {object} item - 影视条目
 */
function createMetaRow(container, item) {
    const metaRow = container.createEl('div', {
        attr: {
            style: `
                display: flex;
                align-items: center;
                gap: 8px;
                margin-bottom: 8px;
                font-size: 13px;
                color: rgba(0,0,0,0.7);
                white-space: nowrap;
                overflow: hidden;
                flex-wrap: nowrap;
            `
        }
    });

    // 类型图标
    metaRow.createEl('span', {
        text: item.type === '电影' ? '🎬 电影' : '📺 电视剧',
        attr: {
            style: 'display: flex; align-items: center; gap: 4px; flex-shrink: 0;'
        }
    });

    // 片长/集数
    if (item.duration) {
        metaRow.createEl('span', {
            text: `⏱️ ${item.duration}`,
            attr: {
                style: 'display: flex; align-items: center; gap: 4px; flex-shrink: 0;'
            }
        });
    } else if (item.episodeInfo) {
        metaRow.createEl('span', {
            text: `🎞️ ${item.episodeInfo}`,
            attr: {
                style: 'display: flex; align-items: center; gap: 4px; flex-shrink: 0;'
            }
        });
    }

    // 分隔符
    metaRow.createEl('span', {
        text: '·',
        attr: {
            style: 'opacity: 0.5; flex-shrink: 0;'
        }
    });

    // 上映信息
    if (item.releaseDate) {
        metaRow.createEl('span', {
            text: `🎉 ${item.releaseDate}`,
            attr: {
                style: 'display: flex; align-items: center; gap: 4px; flex-shrink: 0;'
            }
        });
    }

    // 弹性空间
    metaRow.createEl('div', {
        attr: {
            style: 'flex-grow: 1; flex-shrink: 1; min-width: 0;'
        }
    });
}

/**
 * 创建类型标签
 * @param {HTMLElement} container - 容器元素
 * @param {object} item - 影视条目
 */
function createGenreTags(container, item) {
    if (item.genres.length === 0) return;
    
    const genresContainer = container.createEl('div', {
        attr: {
            style: 'display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px;'
        }
    });

    item.genres.slice(0, 3).forEach(genre => {
        genresContainer.createEl('span', {
            text: genre,
            attr: {
                style: `
                    padding: 2px 8px;
                    background: rgba(0,0,0,0.05);
                    color: rgba(0,0,0,0.8);
                    border-radius: 4px;
                    font-size: 11px;
                    border: 1px solid rgba(0,0,0,0.1);
                    font-weight: 500;
                `
            }
        });
    });
}

/**
 * 创建简介
 * @param {HTMLElement} container - 容器元素
 * @param {object} item - 影视条目
 */
function createDescription(container, item) {
    if (!item.description || item.description.length === 0) return;
    
    const descContainer = container.createEl('div', {
        attr: {
            style: `
                padding-top: 8px;
                padding-right: 12px;
                border-top: 1px solid rgba(0,0,0,0.1);
                flex-shrink: 0;
                min-height: 0;
                overflow: hidden;
            `
        }
    });

    const maxLength = 1000;
    const displayDesc = item.description.length > maxLength
        ? item.description.substring(0, maxLength) + '...'
        : item.description;

    descContainer.createEl('div', {
        text: displayDesc,
        attr: {
            style: `
                font-size: 13px;
                color: rgba(0,0,0,0.7);
                line-height: 1.4;
                display: -webkit-box;
                -webkit-line-clamp: 5;
                -webkit-box-orient: vertical;
                overflow: hidden;
                text-overflow: ellipsis;
            `
        }
    });
}

/**
 * 创建右侧容器
 * @param {HTMLElement} ticket - 票根元素
 * @returns {HTMLElement} 右侧容器
 */
function createRightContainer(ticket) {
    const watchSection = ticket.createEl('div', {
        attr: {
            style: `
                width: 25%;
                padding: 8px 16px;
                border-left: 2px dashed rgba(0,0,0,0.12);
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: flex-start;
                position: relative;
                box-sizing: border-box;
                height: 100%;
                background: white;
            `
        }
    });
    
    return watchSection;
}

/**
 * 添加票根圆孔装饰
 * @param {HTMLElement} watchSection - 右侧容器
 */
function addTicketHoles(watchSection) {
    const circlePositions = ['25%', '50%', '75%'];

    // 整圆孔
    circlePositions.forEach((position) => {
        const hole = watchSection.createEl('div', {
            attr: {
                style: `
                    position: absolute;
                    left: -12px;
                    width: 24px;
                    height: 24px;
                    background: white;
                    border-radius: 50%;
                    z-index: 2;
                    box-sizing: border-box;
                    transform: translateY(-50%);
                `
            }
        });
        hole.style.top = position;
    });

    // 顶部半圆孔
    watchSection.createEl('div', {
        attr: {
            style: `
                position: absolute;
                left: -12px;
                top: 0;
                width: 24px;
                height: 12px;
                background: white;
                border-radius: 0 0 12px 12px;
                z-index: 2;
                box-sizing: border-box;
            `
        }
    });

    // 底部半圆孔
    watchSection.createEl('div', {
        attr: {
            style: `
                position: absolute;
                left: -12px;
                bottom: 0;
                width: 24px;
                height: 12px;
                background: white;
                border-radius: 12px 12px 0 0;
                z-index: 2;
                box-sizing: border-box;
            `
        }
    });
}

/**
 * 填充右侧内容
 * @param {HTMLElement} watchSection - 右侧容器
 * @param {object} item - 影视条目
 */
function fillRightContent(watchSection, item) {
    // 日期容器
    const dateContainer = watchSection.createEl('div', {
        attr: {
            style: 'display:flex; flex-direction:column; align-items:center; gap:2px; width:100%; box-sizing:border-box; height: auto;'
        }
    });

    // 完成日期
    if (item.finishDate) {
        createDateDisplay(dateContainer, item.finishDate);
    }

    // 评分显示
    if (item.myRating) {
        createRatingDisplay(watchSection, item.myRating);
    }

    // 状态标签
    if (item.status && item.status !== '已看完') {
        createStatusBadge(watchSection, item);
    }
}

/**
 * 创建日期显示
 * @param {HTMLElement} container - 容器元素
 * @param {string} dateString - 日期字符串
 */
function createDateDisplay(container, dateString) {
    const date = parseDateString(dateString);
    if (!date) {
        // 解析失败,显示原始字符串
        container.createEl('div', {
            text: dateString,
            attr: { 
                style: 'font-size:13px; width:100%; text-align:right; color: rgba(0,0,0,0.7); font-weight:600;' 
            }
        });
        return;
    }

    const year = date.getFullYear();
    const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
    const weekday = weekdays[date.getDay()];
    const month = date.getMonth() + 1;
    const day = date.getDate();

    // 年份
    container.createEl('div', {
        text: `${year}`,
        attr: {
            style: 'width:100%; text-align:right; font-size:18px; font-weight:700; color: rgba(0,0,0,0.85);'
        }
    });

    // 周几.月.日
    container.createEl('div', {
        text: `${weekday}.${month}.${day}`,
        attr: {
            style: 'font-size:13px; width:100%; text-align:right; color: rgba(0,0,0,0.7); font-weight:600;'
        }
    });
}

/**
 * 创建评分显示
 * @param {HTMLElement} container - 容器元素
 * @param {number} rating - 评分值
 */
function createRatingDisplay(container, rating) {
    const ratingContainer = container.createEl('div', {
        attr: {
            style: 'position:absolute; top:50%; left:0; transform:translateY(-50%); width:100%; text-align:center; z-index:3;'
        }
    });

    ratingContainer.innerHTML = generateStarRating(rating);
    
    ratingContainer.createEl('div', {
        text: `${rating.toFixed(1)}/10`,
        attr: {
            style: 'font-size: 12px; color: rgba(0,0,0,0.7); font-weight: 600; margin-top:4px;'
        }
    });
}

/**
 * 创建状态徽章
 * @param {HTMLElement} container - 容器元素
 * @param {object} item - 影视条目
 */
function createStatusBadge(container, item) {
    const statusColor = STATUS_COLORS[item.status] || '#096dd9';
    const hasRating = !!item.myRating;
    
    // 根据是否有评分调整位置
    const statusTop = hasRating ? '12px' : '50%';
    const statusTransform = hasRating ? 'translateX(-50%)' : 'translate(-50%, -50%)';
    
    container.createEl('div', {
        text: item.status,
        attr: {
            style: `
                position: absolute;
                top: ${statusTop};
                left: 50%;
                transform: ${statusTransform};
                font-size: 12px;
                padding: 6px 10px;
                border-radius: 10px;
                background: ${statusColor}15;
                color: ${statusColor};
                border: 1px solid ${statusColor}40;
                font-weight:700;
                white-space: nowrap;
                z-index:4;
            `
        }
    });
}

/**
 * 应用背景颜色到票根
 * @param {HTMLElement} leftContainer - 左侧容器
 * @param {HTMLElement} watchSection - 右侧容器
 * @param {object} item - 影视条目
 * @param {string} precomputedColor - 预计算颜色
 * @param {boolean} isDominant - 是否为主色调
 */
function applyBackgroundToTicket(leftContainer, watchSection, item, precomputedColor, isDominant) {
    if (precomputedColor) {
        applyBackgroundColor(leftContainer, watchSection, precomputedColor, isDominant);
        try { 
            leftContainer.closest('div').dataset.dominantColor = precomputedColor; 
            leftContainer.closest('div').style.setProperty('--dominant-color', precomputedColor); 
        } catch (e) { }
    } else {
        // 回退颜色
        let fallbackColor;
        if (item.type) {
            fallbackColor = getColorByType(item.type);
        } else if (item.title) {
            fallbackColor = getColorFromTitle(item.title);
        } else {
            fallbackColor = 'rgba(169, 169, 169, 0.15)';
        }
        applyBackgroundColor(leftContainer, watchSection, fallbackColor, false);
    }
}

/**
 * 计算条目颜色
 * @param {object} item - 影视条目
 * @returns {Promise<object>} 颜色信息对象
 */
async function computeItemColor(item) {
    // 优先从图片提取主色
    if (item.cover) {
        try {
            const dominant = await getDominantColorFromImage(item.cover);
            if (dominant) return { color: dominant, isDominant: true };
        } catch (e) { /* 忽略错误 */ }
    }
    
    // 回退颜色
    if (item.type) return { color: getColorByType(item.type), isDominant: false };
    if (item.title) return { color: getColorFromTitle(item.title), isDominant: false };
    
    return { color: 'rgba(169, 169, 169, 0.15)', isDominant: false };
}

// ------------------------------
// 主逻辑区
// ------------------------------

/**
 * 主函数:解析文件并生成可视化视图
 */
async function main() {
    // 获取文件内容
    const filePath = input;
    const content = await dv.io.load(filePath);
    const sections = content.split("---\n").filter(s => s.trim().length > 0);

    // 生成年份标题
    const year = extractYearFromFileName(filePath);
    const sectionTitle = `🎦追剧记录(${year}年)`;
    dv.header(2, sectionTitle);

    // 解析影视条目
    let mediaItems = [];
    for (let section of sections) {
        try {
            const item = parseMediaSection(section);
            if (item) {
                mediaItems.push(item);
            }
        } catch (e) {
            console.log("解析失败:", e);
        }
    }

    // 统计信息
    const totalItems = mediaItems.length;
    const moviesCount = mediaItems.filter(item => item.type === '电影').length;
    const tvCount = mediaItems.filter(item => item.type === '电视剧').length;
    
    dv.paragraph(`📊 统计:共 ${totalItems} 部作品(电影 ${moviesCount} 部 | 电视剧 ${tvCount} 部)`);

    // 生成票根列表
    if (mediaItems.length === 0) {
        dv.paragraph("未找到影视条目");
        return;
    }

    // 创建列表容器
    const container = dv.container;
    const list = container.createEl('div', {
        attr: {
            style: `
                display: flex;
                flex-direction: column;
                gap: 20px;
                padding: 20px 0;
                background: var(--background-primary);
            `
        }
    });

    // 显示加载提示
    const loader = list.createEl('div', {
        attr: {
            style: `
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 20px;
                color: var(--text-muted);
                font-size: 14px;
            `
        }
    });
    loader.createEl('div', { text: '正在生成视觉内容,请稍候…' });

    try {
        // 并行计算颜色
        const colorPromises = mediaItems.map(item => computeItemColor(item));
        const colorResults = await Promise.all(colorPromises);

        // 渲染所有票根
        mediaItems.forEach((item, idx) => {
            const colorInfo = colorResults[idx] || null;
            const color = colorInfo ? colorInfo.color : null;
            const isDominant = colorInfo ? !!colorInfo.isDominant : false;
            createTicketItem(item, list, color, isDominant);
        });
    } catch (e) {
        console.log('计算颜色时出错,使用回退颜色', e);
        // 发生错误时使用回退颜色渲染
        mediaItems.forEach((item) => {
            createTicketItem(item, list, null, false);
        });
    } finally {
        // 移除加载提示
        try { loader.remove(); } catch (e) { }
    }
}

// 执行主函数(添加错误处理)
try {
    await main();
} catch (error) {
    console.error("影视记录可视化组件执行失败:", error);
    dv.paragraph("❌ 渲染失败,请检查控制台日志");
}

// 初始化调试(如果启用)
if (IMAGE_MATCH_DEBUG) {
    try {
        debugListAllImages();
        setTimeout(() => { 
            try { debugListAllImages(); } catch (e) { } 
        }, 300);
    } catch (e) {
        console.log('初始化图片调试失败', e);
    }
}

如上,完成了整个的海报墙。

updatedupdated2026-01-222026-01-22
加载评论