From 07dc0f1059f85bcb68435b0116cc04ff76e52872 Mon Sep 17 00:00:00 2001 From: yvan Date: Thu, 17 Sep 2026 19:09:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=BA=93=E6=BA=90=E7=A0=81=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E8=BD=AC=E7=A0=81=E4=BC=98=E5=8C=96=20ultrafast=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 8 + .gitignore | 21 + Dockerfile | 19 + README.md | 440 ++++ _checkzip.py | 19 + _diff_report.txt | 47 + compose.yaml | 28 + go.mod | 25 + go.sum | 50 + internal/app/account_admin.go | 315 +++ internal/app/account_admin_test.go | 177 ++ internal/app/account_downloads.go | 35 + internal/app/account_downloads_test.go | 148 ++ internal/app/account_import.go | 112 + internal/app/account_password.go | 69 + internal/app/account_source_catalog.go | 6 + internal/app/account_sources.go | 383 ++++ .../app/account_sources_multisource_test.go | 172 ++ internal/app/account_sources_test.go | 68 + internal/app/account_store.go | 303 +++ internal/app/cli.go | 296 +++ internal/app/config_defaults.go | 158 ++ internal/app/config_runtime.go | 172 ++ internal/app/config_store.go | 360 +++ internal/app/desktop_picker.go | 226 ++ internal/app/desktop_picker_windows.go | 12 + internal/app/diagnostics.go | 134 ++ internal/app/diagnostics_test.go | 137 ++ internal/app/download_hls.go | 387 ++++ internal/app/download_progress.go | 151 ++ internal/app/download_quality.go | 82 + internal/app/download_quality_test.go | 228 ++ internal/app/downloader.go | 326 +++ internal/app/emby_api.go | 191 ++ internal/app/emby_files.go | 119 + internal/app/emby_posters_test.go | 393 ++++ internal/app/emby_sync.go | 511 +++++ internal/app/emby_sync_files.go | 164 ++ internal/app/emby_sync_posters.go | 208 ++ internal/app/emby_sync_test.go | 335 +++ internal/app/ffmpeg_install.go | 406 ++++ internal/app/ffmpeg_install_test.go | 309 +++ internal/app/ffmpeg_media.go | 22 + internal/app/ffmpeg_media_test.go | 91 + internal/app/ffmpeg_paths.go | 80 + internal/app/ffmpeg_probe_windows.go | 12 + internal/app/following_store.go | 147 ++ internal/app/helpers.go | 142 ++ internal/app/image_heif.go | 360 +++ internal/app/init.mp4 | Bin 0 -> 1354 bytes internal/app/library.go | 64 + internal/app/library_cache.go | 158 ++ internal/app/library_cover_metadata_test.go | 109 + internal/app/library_fetch.go | 191 ++ internal/app/library_metadata.go | 227 ++ internal/app/library_metadata_live_test.go | 122 ++ internal/app/library_metadata_queue.go | 189 ++ internal/app/library_metadata_queue_test.go | 265 +++ internal/app/library_metadata_test.go | 228 ++ internal/app/library_progress.go | 241 +++ internal/app/library_update_test.go | 322 +++ internal/app/media_merge.go | 226 ++ internal/app/media_playlist.go | 49 + internal/app/media_resume.go | 230 ++ internal/app/media_resume_integration_test.go | 168 ++ internal/app/media_resume_test.go | 343 +++ internal/app/models.go | 125 ++ internal/app/network_browser.go | 200 ++ internal/app/network_browser_test.go | 249 +++ internal/app/network_cdn.go | 192 ++ internal/app/network_diagnostics.go | 199 ++ internal/app/network_dns.go | 164 ++ internal/app/network_fetch.go | 60 + internal/app/network_images.go | 228 ++ internal/app/network_images_test.go | 170 ++ internal/app/network_limiter.go | 273 +++ internal/app/network_limiter_test.go | 78 + internal/app/network_proxy.go | 224 ++ internal/app/paths.go | 109 + internal/app/playback_activity.go | 30 + internal/app/playback_activity_test.go | 85 + internal/app/playback_browser_test.go | 171 ++ internal/app/playback_direct.go | 132 ++ internal/app/playback_gateway.go | 275 +++ internal/app/playback_gateway_test.go | 207 ++ internal/app/playback_generated.go | 260 +++ internal/app/playback_history_source.go | 17 + internal/app/playback_history_source_test.go | 23 + internal/app/playback_history_store.go | 215 ++ internal/app/playback_hls_types_test.go | 39 + internal/app/playback_media_info.go | 197 ++ internal/app/playback_native.go | 427 ++++ internal/app/playback_plan_test.go | 376 ++++ internal/app/playback_process.go | 66 + internal/app/playback_quality.go | 149 ++ internal/app/playback_quality_test.go | 75 + internal/app/playback_remux.go | 201 ++ internal/app/playback_remux_test.go | 219 ++ internal/app/playback_resources.go | 202 ++ internal/app/playback_run.go | 120 + internal/app/playback_stream.go | 353 +++ internal/app/provider_cover.go | 91 + internal/app/provider_cover_test.go | 76 + internal/app/provider_discovery.go | 146 ++ internal/app/provider_drama_metadata.go | 76 + internal/app/provider_hongguo.go | 360 +++ internal/app/provider_hongguo_app.go | 235 ++ internal/app/provider_hongguo_app_test.go | 118 + internal/app/provider_hongguo_catalog.go | 228 ++ internal/app/provider_hongguo_catalog_test.go | 210 ++ internal/app/provider_hongguo_comment_sign.go | 205 ++ .../app/provider_hongguo_comment_sign_test.go | 35 + internal/app/provider_hongguo_detail.go | 127 ++ internal/app/provider_hongguo_native_media.go | 108 + internal/app/provider_hongguo_playback.go | 201 ++ .../app/provider_hongguo_recommendations.go | 110 + .../provider_hongguo_recommendations_test.go | 164 ++ internal/app/provider_hongguo_search.go | 107 + internal/app/provider_hongguo_search_test.go | 175 ++ internal/app/provider_hongguo_sign.go | 47 + internal/app/provider_huangdou.go | 469 ++++ internal/app/provider_huangdou_detail.go | 67 + internal/app/provider_huangdou_vip.go | 81 + internal/app/provider_huangdou_vip_test.go | 103 + internal/app/provider_huangguo.go | 1700 +++++++++++++++ internal/app/provider_huangguo_cards.go | 94 + internal/app/provider_huangguo_cards_test.go | 80 + .../app/provider_huangguo_catalog_test.go | 61 + .../app/provider_huangguo_metadata_test.go | 132 ++ internal/app/provider_legacy.go | 348 +++ ...ovider_legacy_playback_integration_test.go | 94 + internal/app/provider_legacy_playback_test.go | 122 ++ internal/app/provider_legacy_session.go | 243 +++ internal/app/provider_legacy_test.go | 218 ++ internal/app/provider_media.go | 168 ++ internal/app/provider_rankings.go | 174 ++ internal/app/provider_rankings_loader.go | 39 + internal/app/provider_rankings_loader_test.go | 103 + internal/app/provider_rankings_parse.go | 175 ++ internal/app/provider_rankings_test.go | 325 +++ internal/app/provider_sort_details.go | 261 +++ internal/app/provider_sort_metadata.go | 46 + internal/app/ui_account.go | 295 +++ internal/app/ui_account_browser_test.go | 130 ++ internal/app/ui_account_test.go | 504 +++++ internal/app/ui_assets_test.go | 85 + internal/app/ui_cover_repair.go | 169 ++ internal/app/ui_cover_repair_test.go | 205 ++ internal/app/ui_drama_refresh.go | 191 ++ internal/app/ui_drama_refresh_test.go | 185 ++ internal/app/ui_emby.go | 352 +++ internal/app/ui_emby_cover.go | 117 + internal/app/ui_emby_media.go | 162 ++ internal/app/ui_emby_sync.go | 165 ++ internal/app/ui_emby_test.go | 163 ++ internal/app/ui_ffmpeg.go | 24 + internal/app/ui_following.go | 180 ++ internal/app/ui_following_test.go | 263 +++ internal/app/ui_image.go | 124 ++ internal/app/ui_image_cache.go | 127 ++ internal/app/ui_image_huangguo_test.go | 158 ++ internal/app/ui_library_search.go | 77 + internal/app/ui_library_sources_test.go | 112 + internal/app/ui_network.go | 13 + internal/app/ui_page.go | 43 + internal/app/ui_playback.go | 523 +++++ internal/app/ui_playback_collection.go | 182 ++ internal/app/ui_playback_danmaku.go | 205 ++ internal/app/ui_playback_danmaku_live_test.go | 63 + internal/app/ui_playback_danmaku_test.go | 282 +++ internal/app/ui_playback_history.go | 195 ++ internal/app/ui_playback_history_test.go | 401 ++++ internal/app/ui_playback_native.go | 144 ++ internal/app/ui_playback_native_test.go | 223 ++ internal/app/ui_playback_plan.go | 222 ++ internal/app/ui_playback_prefetch.go | 335 +++ .../ui_playback_prefetch_integration_test.go | 65 + internal/app/ui_playback_prefetch_test.go | 163 ++ .../app/ui_playback_proxy_integration_test.go | 148 ++ internal/app/ui_rankings.go | 109 + internal/app/ui_recommendations.go | 83 + internal/app/ui_request_origin_test.go | 100 + internal/app/ui_server.go | 1923 +++++++++++++++++ internal/app/ui_tasks.go | 237 ++ internal/app/ui_viewer.go | 350 +++ internal/app/ui_viewer_fixture_test.go | 30 + internal/app/ui_viewer_legacy.go | 247 +++ internal/app/ui_viewer_test.go | 394 ++++ internal/app/ui_vip_metadata.go | 47 + internal/app/ui_vip_metadata_test.go | 142 ++ internal/webui/account.js | 165 ++ internal/webui/app.css | 163 ++ internal/webui/card-viewport.js | 74 + internal/webui/cover-repair.js | 41 + internal/webui/details.js | 166 ++ internal/webui/downloads.css | 10 + internal/webui/downloads.js | 290 +++ internal/webui/drama-refresh.js | 39 + internal/webui/drama-refresh.test.cjs | 61 + internal/webui/embed.go | 9 + internal/webui/emby.js | 116 + internal/webui/following.js | 219 ++ internal/webui/history.css | 34 + internal/webui/history.js | 175 ++ internal/webui/hls.LICENSE.txt | 233 ++ internal/webui/hls.min.js | 4 + internal/webui/index.html | 307 +++ internal/webui/library-sort.js | 78 + internal/webui/library-sort.test.cjs | 84 + internal/webui/library-tools.css | 42 + internal/webui/library.css | 128 ++ internal/webui/library.js | 470 ++++ internal/webui/main.js | 82 + internal/webui/navigation.js | 110 + internal/webui/playback-settings.js | 41 + internal/webui/player-capabilities.js | 36 + internal/webui/player-capabilities.test.cjs | 35 + internal/webui/player-controls.js | 185 ++ internal/webui/player-danmaku.js | 282 +++ internal/webui/player-danmaku.test.cjs | 60 + internal/webui/player-media.js | 76 + internal/webui/player-media.test.cjs | 86 + internal/webui/player-mobile.js | 163 ++ internal/webui/player-orientation.js | 150 ++ internal/webui/player-session.js | 60 + internal/webui/player-session.test.cjs | 88 + internal/webui/player.css | 147 ++ internal/webui/player.js | 953 ++++++++ internal/webui/rankings.js | 252 +++ internal/webui/recommendations.js | 101 + internal/webui/settings.js | 65 + internal/webui/shell.js | 197 ++ internal/webui/theme.js | 23 + internal/webui/ui-core.js | 164 ++ internal/webui/users.js | 146 ++ internal/webui/viewer.js | 44 + internal/webui/vip-filter.js | 41 + internal/webui/vip-filter.test.cjs | 49 + main.go | 7 + scripts/build.bat | 10 + scripts/build.sh | 19 + scripts/docker-build.sh | 8 + scripts/docker-up.sh | 8 + scripts/update_from_zip.py | 189 ++ scripts/启动剧库.bat | 11 + scripts/启动剧库.command | 13 + scripts/启动剧库.sh | 8 + 247 files changed, 43242 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 _checkzip.py create mode 100644 _diff_report.txt create mode 100644 compose.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/account_admin.go create mode 100644 internal/app/account_admin_test.go create mode 100644 internal/app/account_downloads.go create mode 100644 internal/app/account_downloads_test.go create mode 100644 internal/app/account_import.go create mode 100644 internal/app/account_password.go create mode 100644 internal/app/account_source_catalog.go create mode 100644 internal/app/account_sources.go create mode 100644 internal/app/account_sources_multisource_test.go create mode 100644 internal/app/account_sources_test.go create mode 100644 internal/app/account_store.go create mode 100644 internal/app/cli.go create mode 100644 internal/app/config_defaults.go create mode 100644 internal/app/config_runtime.go create mode 100644 internal/app/config_store.go create mode 100644 internal/app/desktop_picker.go create mode 100644 internal/app/desktop_picker_windows.go create mode 100644 internal/app/diagnostics.go create mode 100644 internal/app/diagnostics_test.go create mode 100644 internal/app/download_hls.go create mode 100644 internal/app/download_progress.go create mode 100644 internal/app/download_quality.go create mode 100644 internal/app/download_quality_test.go create mode 100644 internal/app/downloader.go create mode 100644 internal/app/emby_api.go create mode 100644 internal/app/emby_files.go create mode 100644 internal/app/emby_posters_test.go create mode 100644 internal/app/emby_sync.go create mode 100644 internal/app/emby_sync_files.go create mode 100644 internal/app/emby_sync_posters.go create mode 100644 internal/app/emby_sync_test.go create mode 100644 internal/app/ffmpeg_install.go create mode 100644 internal/app/ffmpeg_install_test.go create mode 100644 internal/app/ffmpeg_media.go create mode 100644 internal/app/ffmpeg_media_test.go create mode 100644 internal/app/ffmpeg_paths.go create mode 100644 internal/app/ffmpeg_probe_windows.go create mode 100644 internal/app/following_store.go create mode 100644 internal/app/helpers.go create mode 100644 internal/app/image_heif.go create mode 100644 internal/app/init.mp4 create mode 100644 internal/app/library.go create mode 100644 internal/app/library_cache.go create mode 100644 internal/app/library_cover_metadata_test.go create mode 100644 internal/app/library_fetch.go create mode 100644 internal/app/library_metadata.go create mode 100644 internal/app/library_metadata_live_test.go create mode 100644 internal/app/library_metadata_queue.go create mode 100644 internal/app/library_metadata_queue_test.go create mode 100644 internal/app/library_metadata_test.go create mode 100644 internal/app/library_progress.go create mode 100644 internal/app/library_update_test.go create mode 100644 internal/app/media_merge.go create mode 100644 internal/app/media_playlist.go create mode 100644 internal/app/media_resume.go create mode 100644 internal/app/media_resume_integration_test.go create mode 100644 internal/app/media_resume_test.go create mode 100644 internal/app/models.go create mode 100644 internal/app/network_browser.go create mode 100644 internal/app/network_browser_test.go create mode 100644 internal/app/network_cdn.go create mode 100644 internal/app/network_diagnostics.go create mode 100644 internal/app/network_dns.go create mode 100644 internal/app/network_fetch.go create mode 100644 internal/app/network_images.go create mode 100644 internal/app/network_images_test.go create mode 100644 internal/app/network_limiter.go create mode 100644 internal/app/network_limiter_test.go create mode 100644 internal/app/network_proxy.go create mode 100644 internal/app/paths.go create mode 100644 internal/app/playback_activity.go create mode 100644 internal/app/playback_activity_test.go create mode 100644 internal/app/playback_browser_test.go create mode 100644 internal/app/playback_direct.go create mode 100644 internal/app/playback_gateway.go create mode 100644 internal/app/playback_gateway_test.go create mode 100644 internal/app/playback_generated.go create mode 100644 internal/app/playback_history_source.go create mode 100644 internal/app/playback_history_source_test.go create mode 100644 internal/app/playback_history_store.go create mode 100644 internal/app/playback_hls_types_test.go create mode 100644 internal/app/playback_media_info.go create mode 100644 internal/app/playback_native.go create mode 100644 internal/app/playback_plan_test.go create mode 100644 internal/app/playback_process.go create mode 100644 internal/app/playback_quality.go create mode 100644 internal/app/playback_quality_test.go create mode 100644 internal/app/playback_remux.go create mode 100644 internal/app/playback_remux_test.go create mode 100644 internal/app/playback_resources.go create mode 100644 internal/app/playback_run.go create mode 100644 internal/app/playback_stream.go create mode 100644 internal/app/provider_cover.go create mode 100644 internal/app/provider_cover_test.go create mode 100644 internal/app/provider_discovery.go create mode 100644 internal/app/provider_drama_metadata.go create mode 100644 internal/app/provider_hongguo.go create mode 100644 internal/app/provider_hongguo_app.go create mode 100644 internal/app/provider_hongguo_app_test.go create mode 100644 internal/app/provider_hongguo_catalog.go create mode 100644 internal/app/provider_hongguo_catalog_test.go create mode 100644 internal/app/provider_hongguo_comment_sign.go create mode 100644 internal/app/provider_hongguo_comment_sign_test.go create mode 100644 internal/app/provider_hongguo_detail.go create mode 100644 internal/app/provider_hongguo_native_media.go create mode 100644 internal/app/provider_hongguo_playback.go create mode 100644 internal/app/provider_hongguo_recommendations.go create mode 100644 internal/app/provider_hongguo_recommendations_test.go create mode 100644 internal/app/provider_hongguo_search.go create mode 100644 internal/app/provider_hongguo_search_test.go create mode 100644 internal/app/provider_hongguo_sign.go create mode 100644 internal/app/provider_huangdou.go create mode 100644 internal/app/provider_huangdou_detail.go create mode 100644 internal/app/provider_huangdou_vip.go create mode 100644 internal/app/provider_huangdou_vip_test.go create mode 100644 internal/app/provider_huangguo.go create mode 100644 internal/app/provider_huangguo_cards.go create mode 100644 internal/app/provider_huangguo_cards_test.go create mode 100644 internal/app/provider_huangguo_catalog_test.go create mode 100644 internal/app/provider_huangguo_metadata_test.go create mode 100644 internal/app/provider_legacy.go create mode 100644 internal/app/provider_legacy_playback_integration_test.go create mode 100644 internal/app/provider_legacy_playback_test.go create mode 100644 internal/app/provider_legacy_session.go create mode 100644 internal/app/provider_legacy_test.go create mode 100644 internal/app/provider_media.go create mode 100644 internal/app/provider_rankings.go create mode 100644 internal/app/provider_rankings_loader.go create mode 100644 internal/app/provider_rankings_loader_test.go create mode 100644 internal/app/provider_rankings_parse.go create mode 100644 internal/app/provider_rankings_test.go create mode 100644 internal/app/provider_sort_details.go create mode 100644 internal/app/provider_sort_metadata.go create mode 100644 internal/app/ui_account.go create mode 100644 internal/app/ui_account_browser_test.go create mode 100644 internal/app/ui_account_test.go create mode 100644 internal/app/ui_assets_test.go create mode 100644 internal/app/ui_cover_repair.go create mode 100644 internal/app/ui_cover_repair_test.go create mode 100644 internal/app/ui_drama_refresh.go create mode 100644 internal/app/ui_drama_refresh_test.go create mode 100644 internal/app/ui_emby.go create mode 100644 internal/app/ui_emby_cover.go create mode 100644 internal/app/ui_emby_media.go create mode 100644 internal/app/ui_emby_sync.go create mode 100644 internal/app/ui_emby_test.go create mode 100644 internal/app/ui_ffmpeg.go create mode 100644 internal/app/ui_following.go create mode 100644 internal/app/ui_following_test.go create mode 100644 internal/app/ui_image.go create mode 100644 internal/app/ui_image_cache.go create mode 100644 internal/app/ui_image_huangguo_test.go create mode 100644 internal/app/ui_library_search.go create mode 100644 internal/app/ui_library_sources_test.go create mode 100644 internal/app/ui_network.go create mode 100644 internal/app/ui_page.go create mode 100644 internal/app/ui_playback.go create mode 100644 internal/app/ui_playback_collection.go create mode 100644 internal/app/ui_playback_danmaku.go create mode 100644 internal/app/ui_playback_danmaku_live_test.go create mode 100644 internal/app/ui_playback_danmaku_test.go create mode 100644 internal/app/ui_playback_history.go create mode 100644 internal/app/ui_playback_history_test.go create mode 100644 internal/app/ui_playback_native.go create mode 100644 internal/app/ui_playback_native_test.go create mode 100644 internal/app/ui_playback_plan.go create mode 100644 internal/app/ui_playback_prefetch.go create mode 100644 internal/app/ui_playback_prefetch_integration_test.go create mode 100644 internal/app/ui_playback_prefetch_test.go create mode 100644 internal/app/ui_playback_proxy_integration_test.go create mode 100644 internal/app/ui_rankings.go create mode 100644 internal/app/ui_recommendations.go create mode 100644 internal/app/ui_request_origin_test.go create mode 100644 internal/app/ui_server.go create mode 100644 internal/app/ui_tasks.go create mode 100644 internal/app/ui_viewer.go create mode 100644 internal/app/ui_viewer_fixture_test.go create mode 100644 internal/app/ui_viewer_legacy.go create mode 100644 internal/app/ui_viewer_test.go create mode 100644 internal/app/ui_vip_metadata.go create mode 100644 internal/app/ui_vip_metadata_test.go create mode 100644 internal/webui/account.js create mode 100644 internal/webui/app.css create mode 100644 internal/webui/card-viewport.js create mode 100644 internal/webui/cover-repair.js create mode 100644 internal/webui/details.js create mode 100644 internal/webui/downloads.css create mode 100644 internal/webui/downloads.js create mode 100644 internal/webui/drama-refresh.js create mode 100644 internal/webui/drama-refresh.test.cjs create mode 100644 internal/webui/embed.go create mode 100644 internal/webui/emby.js create mode 100644 internal/webui/following.js create mode 100644 internal/webui/history.css create mode 100644 internal/webui/history.js create mode 100644 internal/webui/hls.LICENSE.txt create mode 100644 internal/webui/hls.min.js create mode 100644 internal/webui/index.html create mode 100644 internal/webui/library-sort.js create mode 100644 internal/webui/library-sort.test.cjs create mode 100644 internal/webui/library-tools.css create mode 100644 internal/webui/library.css create mode 100644 internal/webui/library.js create mode 100644 internal/webui/main.js create mode 100644 internal/webui/navigation.js create mode 100644 internal/webui/playback-settings.js create mode 100644 internal/webui/player-capabilities.js create mode 100644 internal/webui/player-capabilities.test.cjs create mode 100644 internal/webui/player-controls.js create mode 100644 internal/webui/player-danmaku.js create mode 100644 internal/webui/player-danmaku.test.cjs create mode 100644 internal/webui/player-media.js create mode 100644 internal/webui/player-media.test.cjs create mode 100644 internal/webui/player-mobile.js create mode 100644 internal/webui/player-orientation.js create mode 100644 internal/webui/player-session.js create mode 100644 internal/webui/player-session.test.cjs create mode 100644 internal/webui/player.css create mode 100644 internal/webui/player.js create mode 100644 internal/webui/rankings.js create mode 100644 internal/webui/recommendations.js create mode 100644 internal/webui/settings.js create mode 100644 internal/webui/shell.js create mode 100644 internal/webui/theme.js create mode 100644 internal/webui/ui-core.js create mode 100644 internal/webui/users.js create mode 100644 internal/webui/viewer.js create mode 100644 internal/webui/vip-filter.js create mode 100644 internal/webui/vip-filter.test.cjs create mode 100644 main.go create mode 100644 scripts/build.bat create mode 100644 scripts/build.sh create mode 100644 scripts/docker-build.sh create mode 100644 scripts/docker-up.sh create mode 100644 scripts/update_from_zip.py create mode 100644 scripts/启动剧库.bat create mode 100644 scripts/启动剧库.command create mode 100644 scripts/启动剧库.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd8b27c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** +!go.mod +!go.sum +!main.go +!internal/ +!internal/** +**/*_test.go +**/*.test.cjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dab7297 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.DS_Store +/data/ +/dist/ +/短剧下载/ +/config.json +/juku-settings.json +/settings.json +/library.json +/ui-state.json +/failed.json +/juku +/juku.exe +/bin/ffmpeg +/bin/ffmpeg.exe +/bin/ffprobe +/bin/ffprobe.exe +__pycache__/ +*.pyc +*.log +*.zip +*.tar diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..121cf4b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.26-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +COPY internal ./internal +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/juku . + +FROM debian:bookworm-slim +ENV TZ=Asia/Shanghai +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates ffmpeg tzdata tini curl && rm -rf /var/lib/apt/lists/* && groupadd --gid 1000 juku && useradd --uid 1000 --gid juku --no-create-home juku && mkdir -p /data /downloads && chown juku:juku /data /downloads +COPY --from=build /out/juku /usr/local/bin/juku +USER 1000:1000 +WORKDIR /data +VOLUME ["/data", "/downloads"] +EXPOSE 8998 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl --noproxy '*' --fail --silent --output /dev/null http://127.0.0.1:8998/ || exit 1 +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/juku"] +CMD ["-open=false", "-listen", "0.0.0.0:8998", "-data-dir", "/data", "-out", "/downloads", "-ffmpeg", "/usr/bin/ffmpeg"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7cf7c83 --- /dev/null +++ b/README.md @@ -0,0 +1,440 @@ +# 短剧库 + +多站源版,支持黄果、黄豆、红果。支持在线点播、观看记录、追剧清单、下载和 Emby 自动同步 / 导出。编译后的程序无需 Go、Node.js 或 Python;网页资源已经嵌入程序;兼容内容可免 FFmpeg 播放,需要解密、封装、转码或下载时自动准备 FFmpeg。 + +[启动](#启动与升级) · [账号](#账号与管理员) · [找剧](#剧库与追剧) · [播放](#在线播放) · [下载](#下载与合并) · [Docker](#docker-部署) · [反向代理](#反向代理) · [Emby](#emby-接入) · [故障排查](#常见问题) · [近期更新](#近期新增功能优化与修复) + +## 启动与升级 + +| 系统 | `dist/` 中的程序 | +| --- | --- | +| Windows x64 | 双击 `juku_windows_amd64.exe` | +| macOS Apple Silicon | `./juku_darwin_arm64` | +| macOS Intel | `./juku_darwin_amd64` | +| Linux x64 | `./juku_linux_amd64 -open=false` | + +macOS / Linux 在程序所在目录执行对应命令;若提示没有执行权限,先执行 `chmod +x 程序文件名`。也可使用 `scripts/` 内对应系统的“启动剧库”脚本。 + +源码运行需要 Go 1.24.1+,在项目根目录执行: + +```bash +go run . +``` + +默认监听 `0.0.0.0:8998`,本机访问 `http://127.0.0.1:8998`,其他设备访问 `http://运行程序电脑的局域网IP:8998`。防火墙需允许 TCP 8998;`0.0.0.0` 是监听地址。服务器启动可加 `-open=false`,仅本机使用可加 `-listen 127.0.0.1:8998`。 + +第一次启动自动创建配置及管理员,随机初始密码显示在启动控制台。默认允许匿名使用。管理员、登录和注册设置见下一节。 + +程序应放在可写目录。独立程序默认在自身目录保存数据;源码运行或项目内的 `dist/` 程序默认使用项目根目录。显式指定 `-data-dir` 或 `-config` 时,相对路径以启动命令的工作目录为准。 + +升级时退出旧程序、替换对应程序并重启,再刷新浏览器;源码修改后也需要重启 `go run .`。保留原 `data/` 和下载目录。Docker 升级需重新构建镜像并沿用原数据卷。 + +## 账号与管理员 + +首次默认创建管理员 `admin`,密码随机生成并只在首次启动时输出;首次登录必须改密,完成前不能进入剧库或操作业务接口。普通重启沿用原账号和密码。 + +可以自行指定管理员账号和密码,启动参数同样适用于可执行文件: + +```bash +go run . -admin-user owner -admin-password 'replace-with-your-own-password' +``` + +只有自行指定了密码才免除首次强制改密;仅修改管理员名称仍会生成随机密码。用户名为 2–32 个中文、字母、数字、下划线或短横线,大小写不敏感;密码为 10–128 个字符。环境变量 `JUKU_ADMIN_USER`、`JUKU_ADMIN_PASSWORD` 也可设置,显式启动参数优先。 + +登录管理员后,从顶部“更多 → 用户管理”打开独立管理页面,也可从账号弹窗进入。用户管理集中设置访问策略、创建用户、搜索用户和保存权限;账号弹窗保留登录、退出和个人改密。 + +| 设置或操作 | 默认与行为 | +| --- | --- | +| 必须登录才能使用 | 默认关闭。开启后未登录访问跳转 `/login`,业务接口也要求登录 | +| 允许注册账号 | 默认开启。关闭后隐藏注册入口并拒绝注册请求,只能由管理员建号 | +| 创建普通用户 | 设置用户名、初始密码、可用站源和是否仅在线观看,新用户即可登录 | +| 可用站源 | 既有账号默认全部可用;可为普通账号勾选黄果、黄豆、红果中的一个或多个,管理员始终拥有全部站源 | +| 仅在线观看 | 默认关闭。开启后隐藏下载页、批量下载、卡片与榜单下载、下载画质和 Emby 导出,并由服务端禁止相关接口 | +| 服务设置 | 代理、下载目录、并发、连接检测与 FFmpeg 管理仅管理员可修改 | + +访问开关保存后立即生效,重启保留。忘记管理员密码时,停止服务,用 `-admin-password` 指定新密码后重启;修改管理员名称仍保留原账号记录,修改凭据会撤销旧登录。显式密码参数或环境变量会在每次启动时重新应用,网页改密后应同步更新或移除旧值。 + +账号权限保存后,服务端对后续请求立即校验,当前登录无需退出;页面在下一次请求检测到权限变化时自动重载。站源限制同时覆盖剧库、搜索、榜单、推荐、播放、个人记录、任务和导出。仅在线观看账号可正常收藏和续播,原下载合集的观看记录会改走在线播放;已开始的共享下载仍由管理员或有下载权限的账号管理。 + +匿名访客和自行注册的新账号默认可用全部允许的站源及下载功能。需要严格按账号分配权限时,在“用户管理”开启“必须登录”,关闭“允许用户自行注册”,再由管理员建号。 + +观看记录、续播位置、收藏、手动已看和新集已读状态按账号保存,同一账号换设备登录后继续使用。匿名记录按浏览器 Cookie 隔离,不按 IP 区分;同一浏览器的多个标签页共用记录,其他浏览器、配置或设备分别保存。清除 Cookie 或换访问主机名会建立新的匿名身份;需要跨设备续播时使用账号。 + +注册时可选择接入当前浏览器的匿名记录;登录已有账号后也可手动接回,账号已有的同剧记录优先。退出登录会建立新的空匿名身份,账号记录保留,其他设备不受退出影响。改密会撤销该账号其他设备的登录。禁用 Cookie 时会提示开启,不会退回共享历史。 + +剧库、下载任务和视频文件由服务共享,展示和操作遵循当前账号权限。每个账号或匿名身份最多保存 500 部观看记录、1000 条追剧标记。 + +升级前的共享记录原样保留。需要接回时,查看 `data/viewer-legacy-code`,登录希望保留记录的账号,在“更多 → 代理 / 设置 → 个人记录”输入恢复码。旧记录只归属一个身份,原文件保留,恢复失败可重试。 + +## 剧库与追剧 + +主导航提供“剧库、追剧、下载”;仅在线观看账号隐藏“下载”。桌面使用顶栏,手机使用底部导航。“用户管理”和“代理 / 设置”统一收在顶部“更多”菜单,其中用户管理仅管理员可见。海报直接播放,剧名打开详情,卡片可直接收藏、下载;“批量管理”用于勾选和批量下载。主题可选浅色、深色或跟随系统。 + +“更新剧库”同时负责查新、继续加载历史目录和分批补齐旧资料,保留已有条目及分页位置;当前缓存数量不代表源站总量。新剧的附加文字资料异步补充,旧缓存由手动更新分批处理,缺失日期、热度或集数保持未知。 + +“更多 → 站源状态”中,每个可用站源都有独立的“更新”按钮,只检查该入口的目录和资料,不改变当前列表筛选。面板会显示更新进度、数量和错误;执行中的剧库任务会暂时禁用更新按钮,避免重复提交,完成后恢复。已有内容仍可使用,失败后可再次手动更新。 + +搜索框即时筛选本地剧库,点击联网搜索或按回车显式搜索红果。可按名称、上线日期、热度、播放量排序;季号使用自然顺序,未知值置后。榜单保留源站顺序和名次,兼容红果网页新旧数据格式与分页。红果推荐支持真人剧、漫剧、AI 剧,点击“继续推荐”翻页;它使用公开分类推荐,不提供红果官方账号登录或个性化推荐同步。 + +“追剧”包含想看、在看、已看。收藏和手动已看独立于真实播放进度;取消收藏不会删除历史。新集提示比较更新后的剧库集数与原基准,不会自动定时更新全库。“历史记录”支持搜索、单条删除和清空。追剧列表显示剧库中的海报,小屏也保留;缺失或加载失败时回退为剧名首字,封面地址修复后同步更新。 + +黄豆封面只显示简洁的“VIP”标记,详情和分集保留仅能试看的提示。剧库和黄豆榜单的 VIP 开关文字表示**当前状态**:“隐藏 VIP”时不显示已识别的 VIP,“显示 VIP”时展示 VIP;默认隐藏,记住当前浏览器的选择,隐藏时也从批量勾选中移除 VIP。 + +没有 VIP 字段的旧缓存会导致开关切换后数量暂时不变;筛选区显示待识别数量,未知条目暂保留,不猜测为免费或 VIP。点击 VIP 开关会优先按当前列表排队检查至多 40 部未知剧集,检查期间可继续切换;再次切换不会叠加正在处理的批次。批次完成后结果自动更新,剩余条目可再次操作开关或“更新剧库”分批补齐,点开一部剧可单独补充。免费分集不会因同剧含 VIP 分集而被标成 VIP。 + +多站源版首次默认展示红果,随后记住浏览器的站源筛选。黄果旧 API 可自动建立访客会话并从 HLS 获取密钥;黄豆或黄果付费分集仍依赖上游实际授权,试看不会当作正片。来源被拦截时保留缓存并显示具体原因。 + +点击剧名打开详情,或点击封面、播放按钮观看时,都会在后台更新当前剧集的资料,即使海报正常、排序字段已经齐全也会检查。可用的剧名、简介、集数、状态、分类、标签、热度、日期和海报地址会合并保存,并同步更新剧库、详情、追剧和榜单;站源没有提供的字段保留原值或未知。多站源版同时补充黄豆 VIP 状态。重复点击共享请求,成功或上游返回失败后 5 分钟内复用检查结果,客户端连接失败可在 30 秒后重试;资料失败不阻塞打开详情或播放。普通读取和页面重载不会主动扫描历史缓存。 + +海报缺失可通过“更新剧库”补齐历史地址;加载失败时显示占位,可在点开剧集补齐资料后重试。补齐过程只读取源站文字和地址字段。 封面不再依赖手填图床域名白名单,改为校验地址是否来自当前账号可访问的剧库记录;来源请求头按站源生成。重定向及 DNS 解析后的实际连接拒绝本机、内网和保留地址,正常公网图床换域名无需修改代码。 + +## 在线播放 + +剧库播放只观看,不创建下载任务。播放器支持每页 30 集、输入跳集、画质、倍速、暂停、自动下一集和全屏。实际播放后约每 10 秒保存进度,暂停、切集、关闭及进入后台时补记;再次打开按分集 ID 和位置续播,手动选集从该集开头播放。 + +| 操作 | 行为 | +| --- | --- | +| 空格 | 播放 / 暂停 | +| ← / → 短按 | 后退 / 快进 5 秒 | +| ↑ / ↓ | 调节音量;系统不允许网页调音量时使用设备音量键 | +| 播放时长按 → 或视频画面 | 临时 3 倍速,松开、失焦或暂停后恢复原速 | +| F / Esc | 网页播放器全屏 / 逐层返回 | +| 手机向上 / 向下滑动视频画面 | 切到下一集 / 上一集,到首尾集时提示边界 | + +手机播放器按视频实际宽高识别横竖屏,保留完整画面和原比例。横向视频在竖屏时提供“全屏观看”,顶部旋转按钮可随时手动切换;横屏采用红果 App 参考布局:顶部返回、剧名和更多,底部播放 / 暂停、下一集、时间、进度、倍速、画质与选集,选集和设置从右侧展开。选集打开时缩小左侧画面,保持视频可见。横屏播放时操作栏约 3 秒后收起,点画面显示或隐藏;返回按钮先回到竖屏,再返回剧库。切换方向不重开视频或会话,保留进度、暂停状态与倍速。 + +自动旋转默认开启,可在播放器“更多”中关闭。网页跟随屏幕方向变化;浏览器支持方向感应时,也能在屏幕未旋转时跟随手机姿态。需要授权的浏览器通过“更多 → 启用方向感应”开启;感应受 HTTPS、浏览器和设备权限限制,不可用时仍可手动旋转。竖屏点击画面播放 / 暂停;横竖屏都支持上下滑动切集和长按临时加速,横向滑动、多指、拖动进度或滚动选集不会误切集。桌面原生控件保留自身操作。 + +播放会话每 20 秒保活,请求超过 8 秒会结束等待并在 5 秒后重试;有效取流和观看进度上报也会延长会话。无活动 10 分钟后释放后台资源;切回页面、恢复网络或浏览器返回时立即检查,需要时自动重新连接同一剧、同一集和刚才的位置,并保留暂停、倍速、画质。短时间内最多自动恢复两次,持续失败会保留手动重试入口;登录失效或权限不足需要先处理账号状态。关闭播放器立即释放播放资源。 + +在线播放默认选当前可解析的最高兼容画质,选项来自本集实际返回的片源;桌面只有一档时隐藏选择器,手机显示实际可用选项。切换时保留进度、暂停状态和倍速。输出保留源分辨率,不把低画质片源放大。 + +默认优先播放源站的原始 MP4 / HLS。兼容的 MP4 直接交给视频元素,HLS 在 iOS 等支持的浏览器中走原生播放,其他支持 MSE 的浏览器使用程序内置的 hls.js,无需外部脚本 CDN。暂停、续播和跳转不因位置非零而自动启动视频转码;HLS 保留原始时长、初始化片段、密钥、字节范围及分片格式。 + +需要解密或换封装时优先复制视频码流;仅音频不兼容时只转为 AAC,视频转码作为最后回退。该类封装以整集连续处理生成真实分片索引,完成后提供完整时间轴和任意跳转,准备上限为 90 秒,首播可能需要等待。分片时长按实际关键帧生成,不再假定每片固定两秒。源站拒绝或网络失败只有限重试,不通过视频转码解决连接问题。 + +管理员在“更多 → 代理 / 设置 → 播放与资源限制”调整以下设置,保存后重启保留: + +| 设置 | 默认值与用途 | +| --- | --- | +| 优先原始 MP4 / HLS 播放 | 开启;关闭可回到原有兼容播放链路 | +| 允许尝试客户端直连的站源 | 全部关闭;按站源开启后,检查编码、源站鉴权、协议与跨域条件,通过才返回 302,客户端失败退回原样中转 | +| 播放会话 / 上游媒体连接上限 | 4 / 8;网页与 Emby 共用额度 | +| 解密 / 换封装、音频转码、视频转码并发 | 2 / 1 / 1;分别限制,可设为 0 禁用对应处理 | +| 下一集预备并发 | 1;设为 0 禁用后台预备 | +| 封装缓存 | 512 MB;关闭播放后释放,可按磁盘容量调整 | + +免转码减少 CPU 开销,原样中转仍消耗 VPS 视频流量;只有客户端成功直接取源站视频才能省下这部分流量。302 会向客户端提供源站临时地址,账号权限变更后已发出的源站地址可能在到期前继续有效,因此默认关闭。 + +“预缓存下一集”默认开启并记住开关。新的原始播放链路只提前解析下一集地址,不下载或转码视频,切集时复用;关闭原始播放优化后沿用旧版单集有界预缓存。预备不记观看历史、不创建下载任务,关闭播放器时释放,可按需要关闭。 + +红果文字弹幕默认开启,在播放设置中可关闭;按原始时间分段加载,暂停、倍速、跳转和切集保持同步,失败不影响播放。普通评论尚未接入,原生视频全屏可能无法显示网页弹幕。 + +从下载合集播放时,已有完整文件优先本地读取;未完成的分集只在播放到它时继续对应下载,同时在线缓冲。关闭播放器后已触发的下载继续,可在下载页暂停。网页和 Emby 默认合计最多四个活跃播放会话,管理员可调整。 + +| 播放入口 | 视频从哪里读取 | +| --- | --- | +| 下载页的合集 / 分集“播放” | 下载任务已完成且 MP4 文件仍存在时,直接读取服务所在电脑、VPS 或容器上的文件,不向源站取视频 | +| 下载合集中的未完成或缺失分集 | 地址可用时在线缓冲并继续对应下载;缺少有效地址时提示更新合集 | +| 剧库封面 / 播放按钮 | 默认在线播放;若该剧上次从下载合集观看且仍有权限,会尝试回到合集续播。不会仅因磁盘已有下载就自动切为本地播放 | + +本地文件仍由服务端通过网页播放接口传给浏览器,兼容 MP4 原样传输,仅在格式或编码需要时封装或转码;手机从 VPS 收流仍会使用 VPS 出站带宽。播放器“更多”中的说明可确认当前集显示“本集播放本地已完成文件”还是在线缓冲。 + + +### Artplayer 接入评估 + +已核对 [Artplayer 官方包 5.4.0](https://www.npmjs.com/package/artplayer/v/5.4.0) 与类型定义,许可为 MIT。它提供 `proxy` 复用现有视频元素、`customType` 接入自定义媒体管道、清晰度菜单、事件和插件接口;可随程序内嵌资源发布。 + +| 接入范围 | 工作量与处理方式 | +| --- | --- | +| 基础播放与控件 | 较低:复用当前视频元素和播放接口,接入暂停、倍速、音量和全屏 | +| 完整保留现有行为 | 中等:对接账号隔离续播、服务端画质切换、MSE / iOS HLS、会话恢复、预缓存、弹幕、手机旋转及上下滑动,避免两套控件重复处理事件 | +| 推进建议 | 先提供可选播放器,验证桌面与 iOS,再决定默认界面;后台取流、权限和媒体处理继续复用现有实现 | + +本节是可行性评估。当前发布版仍使用现有播放器;服务器转码负载、上游访问限制和原生全屏弹幕能力仍由对应链路决定。 + +## 下载与合并 + +仅对有下载权限的账号和允许匿名使用的访客显示;管理员可在“用户管理”给普通账号启用“仅在线观看”。 + +详情、批量栏、榜单和下载页的“新任务画质”共用当前浏览器的下载偏好,与在线播放画质独立。 + +| 下载画质 | 选择方式 | +| --- | --- | +| 最高可用(默认) | 每集选择当前可解析的最高兼容档位 | +| 指定档位 | 优先该档;缺档时选择不高于目标的最高档,若全部更高则保留其中最低档 | + +下载使用 `-c copy` 保留源音视频编码和分辨率,不重新编码成 720p,也不生成源站没有的 1080p。竖屏 720p 通常为 720×1280,1080p 为 1080×1920。画质选择随任务保存,暂停、重试、重启和补新集继续沿用;修改默认值只影响后续新任务,不重下已有视频。 + +下载页支持状态筛选、合集或分集批量暂停、继续、取消和重试。“更新本剧”只补新增或本地缺失分集。“清理任务”删除任务记录,保留已下载文件;运行中的任务先停止。暂停后继续会重下当前未完成分集,不是跨重启的字节级断点续传。 + +同名不同 ID 使用不同目录。下载先写 `.part.mp4`,校验成功后生成 `001.mp4` 等最终文件,未知时长或大小不虚报进度。下载完成与源站“剧情完结”是不同状态。 + +合并要求分集连续且文件存在,输出如 `剧名1-17.mp4`。原编码兼容时直接合并;编码、尺寸或音轨不一致时可使用兼容合并,保留画面比例并补边,转为 H.264 / AAC,需要额外时间与临时空间。“合并成功后删除分集文件”默认关闭,仅主动启用且输出校验成功才删除。 + +默认保存到运行程序电脑上的 `短剧下载/`。管理员可在下载页“更改目录”,保存后重启生效;已有合集的任务、补新集和合并继续使用原路径,不自动移动视频。也可用 `-out "./我的下载"` 或 `JUKU_OUTPUT_DIR`;持续携带 `-out` 会覆盖页面保存值。本机访问可调用系统文件夹选择器,远程或无桌面环境手动填写服务器路径。 + +## 代理与 FFmpeg + +管理员在“更多 → 代理 / 设置”操作,桌面和手机使用同一入口。代理与并发保存后立即生效,已开始的连接不强制中断。 + +| 设置 | 默认 | 范围或说明 | +| --- | --- | --- | +| 下载并发 | 2 | 1–32 | +| 剧库请求并发 | 2 | 1–16,同一来源的元数据请求串行 | +| 同站请求间隔 | 500 毫秒 | 100–60000 毫秒,不限制媒体分片 | +| 代理 | 自动 | 环境变量优先,再查 macOS / Windows 系统静态代理;没有则直连 | + +可选择直连或手动 HTTP、HTTPS、SOCKS5 / SOCKS5h 代理;Linux 自动模式读取环境变量,不解析 PAC。站点请求、媒体、密钥和 FFmpeg 内部转发使用应用的代理配置。修改系统代理后重新保存自动模式可刷新检测。 + +FFmpeg 自动查找顺序为 PATH、项目 `bin/`、程序目录和已验证的自动版本。不兼容的自动候选会跳过;显式 `-ffmpeg`、`JUKU_FFMPEG` 或 `download.ffmpeg` 路径有误时提示更正。可将 Windows 的 `ffmpeg.exe` 或 macOS / Linux 的 `ffmpeg` 放入 `bin/`,后者需可执行权限。 + +所有候选都检查 H.264/AAC 编码器并执行短合成转码,通过后才显示就绪。未找到可用版本时,后台下载固定发布 `eugeneware/ffmpeg-static b6.1.1`,校验 SHA-256,保存至 `bin/<系统>-<架构>/verified-b6.1.1/`。支持 macOS Intel / Apple Silicon、Windows x64、Linux x64;下载约 18–29 MiB,离线环境需预先提供完整 FFmpeg。 + +自动准备失败后可调整路径或代理,点击“重新检查 FFmpeg”。设置与日志显示最终使用路径。Windows 修改 PATH 后需新开终端重启。分发 FFmpeg 时保留构建包自带许可和必要源码信息,发布来源为 。 + +## Docker 部署 + +需要 Docker Engine/Desktop 和 Compose v2,在项目根目录执行: + +```bash +docker compose up -d --build +docker compose logs --tail=100 app +``` + +也可运行 `./scripts/docker-up.sh`。默认访问 `http://宿主机IP:8998`,日志可查看首次随机管理员密码。镜像本机构建,默认名称 `juku:local`;内置 FFmpeg、证书与时区,以 UID/GID 1000 运行。 + +可在 Compose 的 `.env` 设置: + +```dotenv +JUKU_PORT=8998 +JUKU_ADMIN_USER=admin +JUKU_ADMIN_PASSWORD=replace-with-your-own-password +``` + +省略密码变量则首次随机生成。显式密码每次启动生效,网页改密后同步更新或移除旧变量。`JUKU_BIND_ADDRESS=127.0.0.1` 可仅开放宿主本机;`JUKU_IMAGE` 更改镜像名。 + +配置、账号、记录、缓存和日志放在 `/data`,视频放在 `/downloads`;Compose 命名卷带 `juku` 项目前缀。`docker compose down` 保留卷,加 `-v` 会删除卷。升级再次执行 `docker compose up -d --build`,保留原卷。 + +NAS 或宿主目录映射可改为 `/srv/juku/data:/data`、`/srv/juku/downloads:/downloads`,提前创建目录并让 UID/GID 1000 可写,也可配置实际运行用户。网页中填写容器路径 `/downloads`,而非宿主路径;启动参数固定此挂载点。 + +容器内的 `127.0.0.1` 指向容器本身。使用宿主代理可设置 `JUKU_PROXY_URL=http://host.docker.internal:7897`,宿主代理需允许容器访问;也可填写容器可达的局域网代理。拉取基础镜像的代理在 Docker 自身配置,独立于应用代理。 + +`./scripts/docker-build.sh` 可单独构建,并接受 `--platform linux/arm64` 等 Docker 参数。构建上下文排除运行数据、视频、便携 FFmpeg 和 `dist/`。本次环境没有 Docker 引擎,未在真实容器运行新版;已有本地代理、FFmpeg 和合成媒体回归。 + +## 反向代理 + +建议使用独立域名的根路径,账号登录通过 HTTPS。Nginx 位置配置示例,`proxy_pass` 换成实际后端: + +```nginx +location / { + proxy_pass http://127.0.0.1:8998; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; +} +``` + +透传 Cookie、Set-Cookie、Host 和查询参数,不为登录、账号、个人记录及媒体接口设置公共缓存。保留 `X-Forwarded-Proto`,HTTPS 时账号 Cookie 才能正确附加 Secure。接口还返回 `X-Accel-Buffering: no`,提示及时转发媒体。 + +网页资源和 API 使用 `/assets/`、`/api/` 根路径,不能直接挂在任意子目录下。无需向公网映射 FFmpeg 使用的内部随机端口。403 查看来源与 Host,401 查看登录与 Cookie,410 查看会话是否过期,502/504 同时查看网关和应用日志。 + +## Emby 接入 + +支持自动同步到已有的 Emby 电视剧媒体库,**不需要先下载视频,也不必逐部下载 ZIP**。同步生成 STRM / NFO 文字文件;播放时有完整的本地分集就优先读取,否则重新解析在线播放地址,不把会过期的源站媒体地址写入文件。 + +1. 管理员登录后打开“更多 → Emby 同步”,开启自动同步。 +2. 填写媒体库输出目录和 **Emby 服务器能访问的剧库地址**,例如 `http://192.168.1.10:8998`。目录是程序所在电脑或容器的路径。 +3. 选择“我的追剧”和 / 或“下载完成的合集”,保存后自动加入这些剧集,后续按间隔检查并补入新集。无需手动操作每一部剧。 +4. 在 Emby 中创建或选择“电视剧”媒体库,将这个输出目录加入媒体库,并启用 NFO 元数据读取。展开“扫描与海报同步”,填写 Emby 地址与 API Key,程序会通知扫描,并让 Emby 获取和缓存海报。未填写时只生成媒体库文件,需要 Emby 自行监控或扫描。 + +| 设置 | 行为 | +| --- | --- | +| 自动同步 | 默认关闭;启用后由服务器持续执行,关闭网页不影响任务,重启后继续使用已保存设置 | +| 输出目录 | 默认是当前下载目录下的 `Emby` 子目录;Docker 默认 `/downloads/Emby` | +| 我的追剧 | 使用保存设置的管理员账号的“在看”和“想看”;不会收集其他用户的观看记录 | +| 下载完成的合集 | 自动纳入有下载完成记录的合集,并同步该剧可获取的分集;此操作不会发起视频下载 | +| 检查间隔 | 默认 60 分钟,可设 5–1440 分钟;新增收藏和下载完成会安排检查;每批最多 50 部,剩余内容一分钟后继续,总范围最多 1000 部 | +| Emby 服务器 / API Key | 可选。填入后通知扫描并自动同步海报,需要 Emby 管理权限;程序只提交海报 URL,由 Emby 获取和缓存。密钥仅保存在服务端,网页不回显 | +| 立即同步 | 重新检查已选范围;已有分集编号保持稳定,内容未变的文件不重写,也不重复通知扫描 | + +Docker 中需要让两个容器看到同一份导出文件。例如剧库的 `/downloads` 映射到宿主机 `/srv/juku/downloads`,Emby 挂载宿主机 `/srv/juku/downloads/Emby` 到自己的 `/media/short-dramas`,再将后者加入电视剧媒体库。容器内路径可以不同,共享的宿主目录必须相同;命名卷也可以共享。填写剧库地址时不要使用 Emby 容器无法访问的 `localhost`。两个程序都需要相应的目录读写 / 读取权限。 + +海报同步无需按剧名到 TMDB 匹配。程序使用自己的剧集标识和导出目录识别 Emby 条目,支持两端不同的 Docker 挂载路径;存在重复目录时停止该剧海报同步并提示,避免串图。首次扫描尚未完成时先显示“海报等待扫描”,随后自动补齐;每批最多处理 20 部海报,失败按 1、2、5、10、15 分钟退避重试,重启后继续。封面地址缺失时不阻塞分集同步,可更新剧集资料后再同步。 + +已有的手动海报或其他刮削器海报会保留;仅已确认由本程序同步的海报随源站封面地址更新,地址未变不会反复下载。用户在 Emby 中更换海报后,程序会保留该修改;需要改用剧库海报时,在 Emby 删除当前海报,再点“立即同步”。面板分别显示分集和海报状态。 + +自动同步和手动 ZIP 的 NFO 都包含签名海报 URL,导出目录不生成图片文件。Emby 自身不读取 NFO 里的海报 URL,因此自动补图需要上述 Emby 地址和 API Key;仅解压手动 ZIP 不会自动补图。海报 URL 不依赖网页登录 Cookie,会检查导出账号当前的站源及下载权限,并复用网页海报的代理、来源头和格式兼容处理。Emby 获取海报时需保持本程序运行;获取后由 Emby 缓存,是否将图片保存到媒体目录由 Emby 自身设置决定。 + +自动同步只维护自己生成并登记的剧集目录;同名的手动目录、其他文件以及暂时从站源消失的已有分集均保留。移出所有同步范围或关闭功能会停止后续更新,不自动删除已导入的文件。单部剧更新失败不覆盖旧文件,面板会列出失败原因;其他剧继续处理。它不自动下载新视频,也不回写 Emby 的用户或观看进度。 Emby 播放同样优先原始媒体与本地文件;若 Emby 服务端跟随 302 后再转发给终端,视频流量仍由 Emby 所在机器承担。播放期间保持本程序运行,需要媒体处理时由程序调用 FFmpeg。 + +手动导出仍可使用:打开剧集详情,点击“导出 Emby”,将 ZIP 解压到电视剧媒体库并扫描;这种方式需要有新集时重新导出。手动导出和自动同步都遵循账号权限。登录账号导出的链接绑定当前账号,自动同步链接绑定启用功能的管理员;链接不依赖网页登录 Cookie,每次播放和读取分片检查账号当前站源与下载权限。普通账号改为仅在线观看或收回相应站源后,对应链接失效。匿名导出和旧版 STRM 使用独立签名,开启必须登录不会撤销这类旧链接。 + +签名密钥为 `data/emby-key`,同步设置与进度为 `data/emby-sync.json`,备份时一并保留。剧库访问地址变更后,保存自动同步设置即可重写已选范围的链接,手动 ZIP 需要重新导出。需要撤销全部旧链接时,移走密钥文件并重启,再重新同步或导出。分享 STRM 即分享对应分集的访问权限。 + + +## 黄果视频连接 + +`huangguo-video` 默认使用 `tls-client v1.16.0` + `fhttp v0.6.9` 的 Chrome 150 指纹客户端,同时匹配 TLS、HTTP/2 和请求头。它在请求发送前选择客户端,继续使用已配置的自动 / 直连 / HTTP、HTTPS、SOCKS5 代理,以及原有请求间隔、并发限制、取消和退避;默认校验证书,关闭 HTTP/3。更换代理会重建对应连接与 Cookie 会话,已有退避仍等待原定时间,不因切换代理而跳过。 + +403 提示改为“Cloudflare 拒绝了当前请求”。仅凭该状态不能断言 IP 被封;程序也会识别返回 HTTP 200 的浏览器验证页,避免把它当成空剧库。“检测当前连接”中的黄果视频项使用同一客户端并校验解析结果;`network.browser_request` 日志记录来源、客户端、HTTP 状态、协议、响应类型与 Ray ID,不记录 Cookie 值或代理凭据。 + +2026-09-16 主程序接入后的纯文本验证:直连和本机 HTTP 代理分别取得列表、分类、详情三个 HTTP 200,均使用 HTTP/2,现有解析器分别读到 40 条、20 条记录和 2 集。此次没有获取源站图片,也没有以此代替媒体 CDN、播放或下载验证。站点策略变化时仍可能需要浏览器验证,失败后遵循退避,避免连续重试。 + +此客户端直接编译进多站源程序,无需 Python、浏览器或 CGO。源码最低 Go 版本为 1.24.1;Docker 构建使用 Go 1.26,并复制 `go.mod` 与 `go.sum` 安装锁定依赖。更新 Docker 部署需要重新构建镜像。 + +## 配置、数据与备份 + +```text +data/ +├── juku.json 代理、并发、下载目录等配置 +├── playback-settings.json 播放优化、直连站源与资源限制 +├── accounts.json 账号、站源与下载权限、登录状态、访问策略 +├── viewer-key 浏览器身份签名密钥 +├── viewers/ 各账号或匿名身份的观看记录和追剧清单 +├── library.json 剧库缓存与红果分页位置 +├── ui-state.json 下载、画质偏好、暂停和合并状态 +├── emby-key 首次导出后生成的签名密钥 +└── logs/app.log 自动轮转的诊断日志 +短剧下载/ 视频目录,可自定义 +``` + +备份整个 `data/` 和需要保留的视频,尤其不要漏掉 `accounts.json`、`viewer-key`、`viewers/` 与 `emby-key`。密码采用加盐派生存储,不保存明文。两项目各用自己的数据目录,不能因同步源码而混用账号、记录或配置。 + +配置优先级为:内置默认值 < `data/juku.json` < `JUKU_*` 环境变量 < 显式启动参数。常用配置示例: + +```json +{ + "version": 1, + "download": {"directory": "短剧下载", "concurrency": 2}, + "network": {"proxy": "auto", "requestConcurrency": 2, "requestIntervalMs": 500} +} +``` + +可选站源覆盖为 `sources.huangguo.aiURL`、`sources.huangguo.videoURL`、`sources.huangguo.legacyAPI`、`sources.huangdou.baseURL`、`sources.hongguo.baseURL`;黄果显式凭据优先,`aesKeyHex` 可留空以自动取钥。`download.ffmpeg` 可覆盖 FFmpeg 路径。通常使用页面设置即可,无需手改协议配置。 + +首次升级可导入旧配置及缓存,已有新数据不覆盖,原文件保留;损坏配置明确报错,不自动清空。分享程序时仅提供程序和本说明,避免包含自己的数据、配置、账号或视频。 + +## 命令行与编译 + +常用启动参数: + +| 参数 | 用途 | +| --- | --- | +| `-listen 地址`、`-open=false` | 调整监听地址、不自动打开浏览器 | +| `-admin-user`、`-admin-password` | 创建或更新管理员凭据 | +| `-data-dir 目录`、`-out 目录` | 数据目录、下载目录 | +| `-ffmpeg 路径`、`-c 数量` | FFmpeg、下载并发 | +| `-pages 数量` | 每频道扫描页数;红果为 App 每批页数 | +| `-config 文件` | 首次导入旧配置,日常无需指定 | +| `-help` | 查看完整参数 | + +默认启动网页,命令行模式需加 `-ui=false`: + +```bash +go run . -ui=false -mode list +go run . -ui=false -mode list -refresh +go run . -ui=false -mode list -more +go run . -ui=false -mode search -keyword 关键词 +go run . -ui=false -mode id -id 'hongguo:实际剧ID' +``` + +`-refresh` 更新缓存,`-more` 从红果分页位置继续并补一批资料,两者不能同时使用。`search` 先筛选本地剧库并下载匹配条目;联网搜索使用网页。`-mode all` 会下载当前剧库全部条目,先确认磁盘容量与范围。 + +```bash +go build -o dist/juku . +bash scripts/build.sh +``` + +构建脚本生成 macOS Intel / Apple Silicon、Linux amd64、Windows amd64 四个平台程序;Windows 的 `scripts\build.bat` 只构建 Windows x64。前端无需额外构建步骤,修改页面后必须重新编译程序。 + +## 常见问题 + +| 问题 | 当前处理与排查方法 | +| --- | --- | +| Docker 有封面和集数,却不能播放 | 已修复 FFmpeg 继承代理变量后,把程序内部 `127.0.0.1` 媒体请求错误送往外部代理的问题;应用访问源站仍使用所设代理。需要重建镜像。列表成功不代表视频 CDN 可达,其他原因结合日志判断 | +| 红果提示榜单格式或分页变化 | 已兼容新旧页面数据载体,修复真人剧、AI 剧及翻页;升级后重新加载榜单,不必清空剧库 | +| VPS 播放卡、CPU 高 | 默认原始 MP4/HLS 免转码,解密或换封装保留视频,仅必要时转码。查看 `playback.plan` 的处理方式和 CPU,在播放资源设置限制并发;CPU 低但缓冲需检查线路、代理和带宽,免转码中转仍使用 VPS 流量 | +| 下载只有 720p | 选择“最高可用”或指定 1080p,查看 `download.media` 中实际和可用画质。下载保留原码流;源站只提供兼容 720p 时不会生成真正的 1080p。此前在线播放的 720p 缩小上限也已移除 | +| Windows 报 `Unrecognized option 'preset'` | 查看实际选中 FFmpeg 路径。自动模式会跳过不兼容版本,显式路径需改为具备 libx264/AAC 的完整版本,或设为 `ffmpeg` 恢复自动查找;修改 PATH 后重开终端 | +| 中途 `unexpected EOF` | 已知长度和范围时自动有限续读,默认每个响应最多两次;资源变化、不支持 Range 或持续失败仍报错。点击“重试播放”,结合发生时间和集数查看日志 | +| 播放会话已过期 | 已补上心跳超时重试、前台 / 网络恢复检查和按原位置自动续接,并延长闲置回收时间;持续失败检查登录、权限和反代连接,更新程序后重新打开页面 | +| 登录后又跳回登录页 / 记录看似消失 | 保持同一访问地址,允许 Cookie;检查反代是否透传 Cookie 和 Set-Cookie。匿名身份清除 Cookie 后不会自动认领旧记录,同一账号登录可恢复账号记录 | +| 无法访问局域网服务 | 检查监听地址、宿主端口、防火墙和无线客户端隔离;远程设备不要访问自己的 `127.0.0.1` | +| 个别用户黄果海报返回 400,其他站源正常 | 已修复图床 `pic.tuafjz.cn` 被旧硬编码域名名单误拒绝的问题,并取消该名单;新域名使用剧库来源校验、正确来源头和格式处理。升级程序或重建镜像后刷新网页;旧缓存缺地址时点开剧集或更新剧库补齐,无需删除账号、观看记录。502 则查看 `cover.failed` 的上游错误 | +| 黄果视频返回 Cloudflare 403 | 使用已接入 Chrome 150 指纹客户端的新版程序或重建镜像,等待提示的退避结束再更新。查看 `network.browser_request` 的状态和 Ray ID,不把 403 直接当作 IP 封禁 | +| Emby 剧集有了但没有海报 | 在“扫描与海报同步”填写服务器地址和有管理权限的 API Key;查看逐剧海报状态,等待扫描或失败重试。目录必须加入电视剧媒体库,不能重复挂载;只解压 ZIP 无法靠 NFO 海报 URL 自动补图 | +| Emby 没有自动出现新剧 | 先在“更多 → Emby 同步”启用并选好范围;检查输出目录是否与 Emby 共享、扫描是否开启,面板显示同步与通知错误。仅手动导出 ZIP 不会自动更新 | +| 下载目录不能写入 | 填写服务所在电脑或容器的路径并检查运行用户权限;修改目录后重启,旧任务仍使用原路径 | + +诊断日志默认 `data/logs/app.log`,单份 5 MiB,另保留两份备份;Docker 内为 `/data/logs/app.log`。日志隐藏凭证和 URL 签名,排查时记录剧名、集数、时间、浏览器及相关文字日志即可。 + +```bash +tail -n 100 data/logs/app.log +docker compose logs --tail=100 app +docker compose exec app tail -n 100 /data/logs/app.log +docker stats --no-stream +``` + +Windows PowerShell 可用 `Get-Content .\data\logs\app.log -Tail 100`。`ffmpeg.failed` 是准备或能力失败,`media.failed` 是视频连接或读取失败,`playback.failed` 是播放过程失败;`playback.plan` 记录原始播放、换封装、只转音频及传输方式,`playback.mode` 记录旧兼容链路的封装或转码,`download.media` 记录请求档位、可用档位与实际画质。旧版未保存的日志无法补回。 + +## 近期新增功能、优化与修复 + +截至 2026-09-17,下列已实现内容已归入正式目录,并更新 `dist/` 程序。 + +| 类型 | 功能或问题 | 当前行为 | +| --- | --- | --- | +| 新增 | 原始 MP4 / HLS 播放 | 兼容片源跳过 FFmpeg,支持原生 HLS 和内置 hls.js;续播、暂停、跳转与画质切换保留状态 | +| 新增 | 条件式 302 直连 | 管理员按站源开启,默认关闭;检查通过后由客户端取源站,失败退回免转码中转 | +| 优化 | 解密、换封装与只转音频 | 连续处理整集,保留兼容视频;真实分片索引支持完整时间轴,视频转码作为最后回退 | +| 新增 | 播放资源限制 | 分开限制会话、媒体连接、换封装、音频、视频与预备任务,并限制封装缓存 | +| 修复 | HLS 分片格式 | 正确保留 MP4 初始化片段和 fMP4 / TS 分片扩展名及媒体类型,避免浏览器误判并触发额外封装 | +| 修复 | 红果榜单新格式 | 同时解析新旧页面数据载体,校验榜单与页码,恢复真人剧、AI 剧及翻页,保留真实名次 | +| 修复 | 封面域名限制 | 移除手填图床域名白名单,以剧库记录及站源权限校验;重定向和实际连接均限制为公网地址,保留代理配置 | +| 新增 | 站源单独更新 | 站源状态逐行提供更新按钮,按入口独立查新、续载和补资料;状态实时刷新,沿用账号权限及限流退避 | +| 修复 | Emby 表单勾选框 | 勾选框与第一行文字对齐,补充说明缩进到文字列,适配窄屏换行 | +| 修复 | 黄果海报来源与解密 | 修正 `pic.tuafjz.cn` 的 CDN 分类,按剧集站源发送来源头;加密封面不再依赖某个固定域名才能解密,签名查询参数完整保留 | +| 修复 | 黄果视频 Cloudflare 403 | 预先使用 Chrome 150 的 TLS / HTTP/2 / 请求头指纹;保留代理、限流、退避与证书验证,网络检测采用同一链路 | +| 新增 | Emby 海报自动同步 | 通过签名 URL 让 Emby 获取和缓存海报;支持扫描等待、失败退避、重启恢复,保留自定义海报,导出目录无需额外图片文件 | +| 新增 | Emby 自动同步 | 管理员可按追剧 / 已下载合集定时生成 STRM、NFO 并补集;无需下载视频,支持通知 Emby 扫描,重启继续,已有文件不重复写入 | +| 新增 | 管理员启动参数 | 默认 admin / 随机密码,首次强制改密;自设密码免首次强制改密,支持环境变量和重置 | +| 新增 | 登录与注册策略 | 管理员控制必须登录、允许注册及手动建号;默认匿名开放、注册开放 | +| 新增 | 独立用户管理 | 单独页面管理访问策略、创建和搜索账号,通过“更多”菜单进入 | +| 优化 | 顶部按钮 | “用户管理”和“代理 / 设置”统一移入“更多”,桌面、手机一致 | +| 新增 | 点开剧集补齐资料 | 剧名、封面和播放入口异步更新当前剧集,重复请求合并,保存缓存并更新页面 | +| 新增 | 账号权限 | 按账号分配黄果 / 黄豆 / 红果;支持仅在线观看,下载入口和接口同时受限,修改后当前登录生效 | +| 修复 | 黄豆 VIP 与快筛 | 文字对应当前显隐状态,默认隐藏;封面仅标 VIP,显示未知数量,切换时按批补齐旧缓存 | +| 新增 | 移动端沉浸播放 | 铺满视口、进度拖动、上下滑动切集、长按加速,竖屏底部面板与横屏右侧面板 | +| 新增 | 横屏与方向感应 | 按实际尺寸识别视频,“全屏观看”与手动旋转、自动跟随方向;参考红果 App 的横屏控制条和抽屉,切换不中断播放 | +| 修复 | 播放会话保活与恢复 | 心跳超时后重试,有效取流及进度上报保活;回前台 / 网络恢复后自动接续,保留分集、进度、暂停、倍速和画质 | +| 修复 | 追剧海报 | 追剧列表使用真实封面,失败回退首字,窄屏保留海报并同步地址修复 | +| 新增 | Emby 账号权限 | 新登录账号导出的链接绑定当前账号,站源及下载权限变更可撤销播放 | +| 新增 | 账号与匿名隔离 | 账号跨设备续播,匿名按 Cookie 独立保存,支持接回旧记录和匿名记录 | +| 修复 | Docker 播放代理 | FFmpeg 内部回环请求不再继承外部代理,应用代理继续生效 | +| 优化 | VPS 播放负载 | 原始播放减少 CPU,必要封装保留码流,独立限制处理任务;直连成功时进一步减少剧库服务器流量 | +| 新增 | 下载画质 | 默认最高可用,支持指定档位、缺档回退、任务持久化与实际画质日志 | +| 修复 | iPhone / iPad 与高画质 | 优先原生 HLS,MSE 失败有回退,保留完整时间轴和所选源分辨率 | +| 修复 | FFmpeg 发现与 preset 错误 | PATH 候选逐个能力检测,显式路径报错清楚,自动版本校验后安装 | +| 新增 | 播放快捷操作 | 键盘播放、跳转、音量、全屏,鼠标或触摸长按临时 3 倍速 | +| 优化 | 下一集预备 | 新播放链路仅预解析地址,避免占用视频流量和转码 CPU;切集复用,关闭播放器释放 | +| 修复 | 中途断流与反向代理 | 媒体范围校验后有限续读,修正同源请求判断,媒体禁缓存并及时转发 | +| 优化 | 大剧库与下载页面 | 仅挂载可见卡片,筛选仍覆盖全部结果;隐藏下载页减少渲染与轮询 | +| 新增 | 追剧与继续观看 | 想看 / 在看 / 已看、收藏撤销、手动已看、按真实分集进度续播 | +| 新增 | 红果分类推荐与弹幕 | 真人 / 漫剧 / AI 推荐分页,真实文字弹幕异步加载并同步播放 | +| 修复 | 目录、搜索、榜单和资料 | 续载分页、自然季号排序、真实字段保留,旧资料手动分批补齐 | +| 修复 | 海报地址 | 榜单入库保留地址,支持历史条目补齐与卡片 / 详情失败重试 | +| 新增 | Emby | STRM / NFO 导出、稳定签名 HLS,支持本地文件与在线解析 | +| 新增 | 自动诊断日志 | 记录播放、下载、FFmpeg 与媒体恢复,支持轮转及凭证脱敏 | +| 修复 | 黄果接入与文字资料 | 访客登录复用、自动取钥、异常剧名和嵌套集数解析;专属代码仅在多站源版 | +| 整理 | 发布与说明 | 临时实现合回正式目录,两版同步构建;使用说明统一为本 README | + +两版完整 Go 回归及相关并发检查通过,JavaScript 回归覆盖排序、资料补齐、播放器加载与取消、会话保活、弹幕等已有功能。账号权限、下载、Emby 自动同步与海报同步的模拟接口回归继续保留。四个平台的程序已交叉编译,macOS 程序在隔离数据目录验证启动、重启、内嵌资源和设置持久化。 + +本轮播放验证使用本机合成视频,覆盖 Chromium 原生 HLS、Chromium 内置 hls.js 路径和 WebKit:原始 MP4、TS / fMP4 HLS、画质切换、连续换封装、只转音频、302 直连及失败回退、会话恢复、本地文件和缓存释放。三秒关键帧间隔的样本生成实际 3 / 3 / 3 / 1 秒分片;CENC 解密与复制视频的逐帧校验通过。红果四种榜单的两页真实文字数据均成功解析,每页 20 条,名次连续。 + +封面使用模拟响应和本机合成图片验证地址登记、账号权限、新图床域名、黄果加密格式、签名参数、重定向、公网 DNS 校验、HTTP / HTTPS 代理与缓存。未请求、分析或处理源站图片;浏览器导航前拦截图片和外部请求。未在用户实际 Docker / VPS、Windows、iPhone 真机或 Emby 客户端运行新版,WebKit 与模拟接口检查不代表这些环境的实测结果。 diff --git a/_checkzip.py b/_checkzip.py new file mode 100644 index 0000000..576b486 --- /dev/null +++ b/_checkzip.py @@ -0,0 +1,19 @@ +import zipfile +from pathlib import Path + +zp = Path(r"C:\Users\Yvan\AppData\Local\Temp\短剧库-update.zip") +with zipfile.ZipFile(zp) as zf: + for info in zf.infolist(): + name = info.filename + if not info.flag_bits & 0x800: + try: + name = name.encode("cp437").decode("utf-8") + except Exception: + pass + if name.endswith("internal/app/playback_stream.go"): + body = zf.read(info).decode("utf-8", "replace") + print("ultrafast in zip:", "ultrafast" in body) + print("veryfast in zip:", "veryfast" in body) + start = body.find("func playbackEncodingArgs") + print(body[start:start + 420]) + break diff --git a/_diff_report.txt b/_diff_report.txt new file mode 100644 index 0000000..c79492d --- /dev/null +++ b/_diff_report.txt @@ -0,0 +1,47 @@ +下载最新压缩包:https://www.252035.xyz/短剧库.zip + + 65536/815309 字节 + 131072/815309 字节 + 196608/815309 字节 + 262144/815309 字节 + 327680/815309 字节 + 393216/815309 字节 + 458752/815309 字节 + 524288/815309 字节 + 589824/815309 字节 + 655360/815309 字节 + 720896/815309 字节 + 786432/815309 字节 + 815309/815309 字节 + 已保存到 C:\Users\Yvan\AppData\Local\Temp\短剧库-update.zip +检测到压缩包顶层目录:短剧库/(比对时已忽略) + +对比完成:压缩包 245 个文件,本地 246 个文件 + 新增 2 个 + + internal/app/emby_playback.go + + internal/app/emby_playback_test.go + 修改 18 个 + ~ .gitignore + ~ Dockerfile + ~ README.md + ~ compose.yaml + ~ internal/app/playback_generated.go + ~ internal/app/playback_plan_test.go + ~ internal/app/playback_resources.go + ~ internal/app/playback_stream.go + ~ internal/app/ui_emby.go + ~ internal/app/ui_emby_media.go + ~ internal/app/ui_playback.go + ~ internal/webui/index.html + ~ scripts/build.bat + ~ scripts/build.sh + ~ scripts/docker-build.sh + ~ scripts/启动剧库.bat + ~ scripts/启动剧库.command + ~ scripts/启动剧库.sh + 删除 2 个 + - _diff_report.txt + - internal/app/init.mp4 + 未变化 225 个 + +当前为预览模式。确认无误后追加 --apply 执行更新,然后 git add -A && git commit。 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1767018 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,28 @@ +name: juku +services: + app: + image: ${JUKU_IMAGE:-juku:local} + build: + context: . + ports: + - "${JUKU_BIND_ADDRESS:-0.0.0.0}:${JUKU_PORT:-8998}:8998" + environment: + TZ: ${TZ:-Asia/Shanghai} + JUKU_PROXY_URL: ${JUKU_PROXY_URL:-} + JUKU_ADMIN_USER: ${JUKU_ADMIN_USER:-} + JUKU_ADMIN_PASSWORD: ${JUKU_ADMIN_PASSWORD:-} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - data:/data + - downloads:/downloads + restart: unless-stopped + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +volumes: + data: + downloads: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d48cba --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module juku + +go 1.24.1 + +require ( + github.com/bogdanfinn/fhttp v0.6.9 + github.com/bogdanfinn/tls-client v1.16.0 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.10-utls // indirect + github.com/bogdanfinn/utls v1.7.8-barnius // indirect + github.com/bogdanfinn/websocket v1.5.6-barnius // indirect + github.com/cloudflare/circl v1.6.2 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f107aa5 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.9 h1:F9MsWR1K6JMRlXN4Q/MfjYmptr29nCdycQo+OPBG5U0= +github.com/bogdanfinn/fhttp v0.6.9/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk= +github.com/bogdanfinn/quic-go-utls v1.0.10-utls h1:oZN+J3OKMmWaVbI/sx4ULvHiAXbWJKVcvEFW4fkybMU= +github.com/bogdanfinn/quic-go-utls v1.0.10-utls/go.mod h1:bBLJ2Vy68Q7y6VfbG9czd825rTcQua7n+y12RM9Q588= +github.com/bogdanfinn/tls-client v1.16.0 h1:km3YLI6CMRLZnfrC+hGStePPryIicd3hoxFQvHB1itY= +github.com/bogdanfinn/tls-client v1.16.0/go.mod h1:TxbiXlpZhNN5xmxzsr3KhNAeqoEEPJafwgBbyp26Phc= +github.com/bogdanfinn/utls v1.7.8-barnius h1:wRT83mP4hmuMM1/lIpLKKhzaEuyEKjux7VSj7XHFXdg= +github.com/bogdanfinn/utls v1.7.8-barnius/go.mod h1:o5v09jf+9ayMEfcP+8sssNdSMm278cyhBV25H8jb5Lw= +github.com/bogdanfinn/websocket v1.5.6-barnius h1:QpdWVEZ9rKtcT7C2pU19uE2KQw2XevuKTDTzLxfDi/I= +github.com/bogdanfinn/websocket v1.5.6-barnius/go.mod h1:41/rXLDWpqJ5A4gOKSY/z4NLbW/hHZHneRcYM0kbpxA= +github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= +github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/account_admin.go b/internal/app/account_admin.go new file mode 100644 index 0000000..c9bbdd7 --- /dev/null +++ b/internal/app/account_admin.go @@ -0,0 +1,315 @@ +package app + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +type administratorBootstrap struct { + Username string + InitialPassword string + RequirePasswordChange bool +} + +func (manager *viewerManager) ensureAdministrator(cfg Config) (administratorBootstrap, error) { + store := manager.accountStore() + store.mu.Lock() + defer store.mu.Unlock() + if store.err != nil { + return administratorBootstrap{}, store.err + } + var previous string + var account accountRecord + for name, record := range store.state.Accounts { + if record.Admin { + previous, account = name, record + break + } + } + username := firstNonEmpty(cfg.adminUsername, "admin") + if previous != "" && !cfg.adminUserExplicit { + username = account.Username + } + name, display, err := normalizeAccountUsername(username) + if err != nil { + return administratorBootstrap{}, err + } + if existing, exists := store.state.Accounts[name]; exists && existing.ID != account.ID { + return administratorBootstrap{}, errors.New("管理员用户名已被普通账号占用,请通过 -admin-user 指定其他名称") + } + initial := "" + changed := previous == "" || previous != name || account.Username != display + if previous == "" { + account = accountRecord{ID: randomHex(32), Username: display, Admin: true, Algorithm: "pbkdf2-sha256", Iterations: accountPasswordIterations, CreatedAt: time.Now()} + if account.ID == "" { + return administratorBootstrap{}, errors.New("无法生成管理员身份") + } + password := cfg.adminPassword + if !cfg.adminPasswordExplicit { + password = randomHex(16) + if password == "" { + return administratorBootstrap{}, errors.New("无法生成管理员初始密码") + } + initial = password + account.RequirePasswordChange = true + } + account.Salt, account.PasswordHash, err = newAccountPassword(password) + } else if cfg.adminPasswordExplicit { + if err = validateAccountPassword(cfg.adminPassword); err == nil { + if !verifyAccountPassword(account, cfg.adminPassword) { + account.Salt, account.PasswordHash, err = newAccountPassword(cfg.adminPassword) + account.Iterations = accountPasswordIterations + changed = true + } + if account.RequirePasswordChange { + changed = true + } + account.RequirePasswordChange = false + } + } + if err != nil { + return administratorBootstrap{}, fmt.Errorf("管理员密码无效:%w", err) + } + if changed { + state := store.snapshotLocked() + delete(state.Accounts, previous) + account.Username = display + state.Accounts[name] = account + for key, session := range state.Sessions { + if session.Account == previous { + delete(state.Sessions, key) + } + } + if previous != "" && previous != name { + for guest, imported := range state.GuestImports { + if imported.Account == previous { + imported.Account = name + state.GuestImports[guest] = imported + } + } + } + if err = store.saveLocked(state); err != nil { + return administratorBootstrap{}, err + } + } + return administratorBootstrap{Username: account.Username, InitialPassword: initial, RequirePasswordChange: account.RequirePasswordChange}, nil +} + +func (store *accountStore) policy() (bool, bool) { + store.mu.Lock() + defer store.mu.Unlock() + return store.state.RequireLogin, store.state.AllowRegistration +} + +func accountAdminPath(path string) bool { + if strings.HasPrefix(path, "/api/ui/admin/") { + return true + } + switch path { + case "/api/ui/config", "/api/ui/network/check", "/api/ui/directory/pick", "/api/ui/ffmpeg": + return true + } + return false +} + +func (app *UIApp) withAccountAccess(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + path := request.URL.Path + if strings.HasPrefix(path, "/assets/") || strings.HasPrefix(path, "/api/emby/media/") || path == "/api/emby/stream.m3u8" || path == "/api/emby/segment.ts" || path == "/api/emby/cover" { + next.ServeHTTP(writer, request) + return + } + switch path { + case "/api/ui/viewer", "/api/ui/account/login", "/api/ui/account/register", "/api/ui/account/logout", "/api/ui/account/password": + next.ServeHTTP(writer, request) + return + } + manager := app.browserViewers() + store := manager.accountStore() + if manager.err != nil || store.err != nil { + writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", "账号数据无法读取,请检查数据目录或恢复备份") + return + } + requireLogin, _ := store.policy() + account, _, cookiePresent, err := manager.requestAccount(request) + if err != nil { + accountOperationError(writer, err) + return + } + if path == "/" || path == "/login" { + writer.Header().Set("Cache-Control", "private, no-store") + writer.Header().Add("Vary", "Cookie") + if path == "/" && (account.RequirePasswordChange || requireLogin && account.ID == "") { + http.Redirect(writer, request, "/login", http.StatusFound) + return + } + if path == "/login" && account.ID != "" && !account.RequirePasswordChange { + http.Redirect(writer, request, "/", http.StatusFound) + return + } + next.ServeHTTP(writer, request) + return + } + if cookiePresent && account.ID == "" && !accountAdminPath(path) { + writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "账号登录已失效,请重新登录或继续匿名使用") + return + } + request = request.WithContext(withSourceScope(request.Context(), account)) + sources := strings.Join(account.effectiveSources(), ",") + writer.Header().Set("X-Juku-Sources", sources) + writer.Header().Set("Cache-Control", "private, no-store") + writer.Header().Add("Vary", "Cookie") + if previous := request.Header.Get("X-Juku-Sources"); previous != "" && previous != sources { + writeViewerError(writer, http.StatusConflict, "sources_changed", "站源权限已更新,正在重新加载") + return + } + onlineOnly := strconv.FormatBool(account.onlineOnly()) + writer.Header().Set("X-Juku-Online-Only", onlineOnly) + if previous := request.Header.Get("X-Juku-Online-Only"); previous != "" && previous != onlineOnly { + writeViewerError(writer, http.StatusConflict, "permissions_changed", "账号权限已更新,正在重新加载") + return + } + if account.RequirePasswordChange { + writeViewerError(writer, http.StatusForbidden, "password_change_required", "请先修改管理员初始密码") + return + } + if requireLogin && account.ID == "" { + writeViewerError(writer, http.StatusUnauthorized, "login_required", "请登录后使用剧库") + return + } + if accountDownloadPath(path) && !requireDownload(writer, request) { + return + } + if accountAdminPath(path) { + if !account.Admin { + writeViewerError(writer, http.StatusForbidden, "admin_required", "此操作需要管理员账号") + return + } + if request.Method != http.MethodGet && request.Method != http.MethodHead { + if !playbackRequestAllowed(writer, request, http.MethodPost) { + return + } + if request.Header.Get("X-Juku-Viewer") == "" { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请从管理员面板操作"}) + return + } + } + } + next.ServeHTTP(writer, request) + }) +} + +func (app *UIApp) handleAdminSettings(writer http.ResponseWriter, request *http.Request) { + store := app.browserViewers().accountStore() + if request.Method == http.MethodGet { + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + requireLogin, allowRegistration := store.policy() + writeJSON(writer, http.StatusOK, map[string]bool{"requireLogin": requireLogin, "allowRegistration": allowRegistration}) + return + } + var input struct { + RequireLogin *bool `json:"requireLogin"` + AllowRegistration *bool `json:"allowRegistration"` + } + if !readAccountRequest(writer, request, &input) { + return + } + if input.RequireLogin == nil || input.AllowRegistration == nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择是否必须登录及是否允许注册"}) + return + } + store.mu.Lock() + state := store.snapshotLocked() + state.RequireLogin, state.AllowRegistration = *input.RequireLogin, *input.AllowRegistration + err := store.saveLocked(state) + store.mu.Unlock() + if err != nil { + accountOperationError(writer, err) + return + } + writeJSON(writer, http.StatusOK, map[string]bool{"requireLogin": *input.RequireLogin, "allowRegistration": *input.AllowRegistration}) +} + +func (app *UIApp) handleAdminAccounts(writer http.ResponseWriter, request *http.Request) { + store := app.browserViewers().accountStore() + if request.Method == http.MethodGet { + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + store.mu.Lock() + accounts := make([]accountPublic, 0, len(store.state.Accounts)) + for _, account := range store.state.Accounts { + accounts = append(accounts, account.public()) + } + store.mu.Unlock() + sort.Slice(accounts, func(i, j int) bool { + if accounts[i].Admin != accounts[j].Admin { + return accounts[i].Admin + } + return accounts[i].CreatedAt.Before(accounts[j].CreatedAt) + }) + writeJSON(writer, http.StatusOK, map[string]any{"data": accounts, "sourceChoices": accountSourceChoices}) + return + } + var input struct { + Username string `json:"username"` + Password string `json:"password"` + Sources []string `json:"sources"` + OnlineOnly bool `json:"onlineOnly"` + } + if !readAccountRequest(writer, request, &input) { + return + } + name, display, err := normalizeAccountUsername(input.Username) + if err == nil { + err = validateAccountPassword(input.Password) + } + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + sources, sourceErr := normalizeAccountSources(input.Sources) + if sourceErr != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": sourceErr.Error()}) + return + } + if !accountHashPermit(writer, request, store, name) { + return + } + defer func() { <-store.hashing }() + salt, hash, err := newAccountPassword(input.Password) + if err != nil { + accountOperationError(writer, err) + return + } + identifier := randomHex(32) + if identifier == "" { + accountOperationError(writer, errors.New("无法创建账号")) + return + } + store.mu.Lock() + defer store.mu.Unlock() + if _, exists := store.state.Accounts[name]; exists { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "该用户名已被使用"}) + return + } + if len(store.state.Accounts) >= accountLimit { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "此服务的账号数量已达上限"}) + return + } + account := accountRecord{OnlineOnly: input.OnlineOnly, Sources: sources, ID: identifier, Username: display, Salt: salt, PasswordHash: hash, Algorithm: "pbkdf2-sha256", Iterations: accountPasswordIterations, CreatedAt: time.Now()} + state := store.snapshotLocked() + state.Accounts[name] = account + if err = store.saveLocked(state); err != nil { + accountOperationError(writer, err) + return + } + writeJSON(writer, http.StatusCreated, map[string]any{"ok": true, "account": account.public()}) +} diff --git a/internal/app/account_admin_test.go b/internal/app/account_admin_test.go new file mode 100644 index 0000000..9236be5 --- /dev/null +++ b/internal/app/account_admin_test.go @@ -0,0 +1,177 @@ +package app + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func administratorFixture(t *testing.T) (*UIApp, *accountTestBrowser) { + t.Helper() + app := viewerTestApp(t) + app.cfg.adminUsername, app.cfg.adminPassword, app.cfg.adminPasswordExplicit = "admin", accountFixturePassword, true + if _, err := app.browserViewers().ensureAdministrator(app.cfg); err != nil { + t.Fatal(err) + } + admin := newAccountTestBrowser(t, app) + admin.login(t, "admin", accountFixturePassword) + return app, admin +} + +func TestAdministratorRandomBootstrapRequiresPasswordChange(t *testing.T) { + app := viewerTestApp(t) + manager := app.browserViewers() + initial, err := manager.ensureAdministrator(app.cfg) + if err != nil || initial.Username != "admin" || len(initial.InitialPassword) < 24 || !initial.RequirePasswordChange { + t.Fatal("random administrator bootstrap failed", err) + } + admin := newAccountTestBrowser(t, app) + admin.login(t, "admin", initial.InitialPassword) + for _, path := range []string{"/api/ui/dramas", "/api/ui/playback/history", "/api/ui/admin/settings", "/api/ui/config"} { + if result := admin.request(t, http.MethodGet, path, nil); result.Code != http.StatusForbidden || !strings.Contains(result.Body.String(), "password_change_required") { + t.Fatal("initial password bypassed change requirement", path, result.Code) + } + } + if result := admin.request(t, http.MethodGet, "/", nil); result.Code != http.StatusFound || result.Header().Get("Location") != "/login" { + t.Fatal("initial password did not redirect to change page", result.Code) + } + if result := admin.request(t, http.MethodPost, "/api/ui/account/password", map[string]string{"password": initial.InitialPassword, "newPassword": initial.InitialPassword}); result.Code != http.StatusBadRequest { + t.Fatal("initial password could be reused") + } + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/account/password", map[string]string{"password": initial.InitialPassword, "newPassword": accountFixturePassword})) + admin.refresh(t) + viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil)) + viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/dramas", nil)) + restarted := &UIApp{cfg: app.cfg} + after, err := restarted.browserViewers().ensureAdministrator(app.cfg) + if err != nil || after.InitialPassword != "" || after.RequirePasswordChange || after.Username != "admin" { + t.Fatal("restart reset administrator password", err) + } + admin.handler = restarted.routes() + admin.refresh(t) + viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil)) +} + +func TestAdministratorSpecifiedCredentialsAndStartupReset(t *testing.T) { + app := viewerTestApp(t) + app.cfg.adminUsername, app.cfg.adminPassword = "owner", accountFixturePassword + app.cfg.adminUserExplicit, app.cfg.adminPasswordExplicit = true, true + first, err := app.browserViewers().ensureAdministrator(app.cfg) + if err != nil || first.RequirePasswordChange || first.InitialPassword != "" || first.Username != "owner" { + t.Fatal("specified bootstrap requires change", err) + } + admin := newAccountTestBrowser(t, app) + admin.login(t, "owner", accountFixturePassword) + viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil)) + restarted := &UIApp{cfg: app.cfg} + defaults := app.cfg + defaults.adminUsername, defaults.adminPassword, defaults.adminUserExplicit, defaults.adminPasswordExplicit = "admin", "", false, false + after, err := restarted.browserViewers().ensureAdministrator(defaults) + if err != nil || after.Username != "owner" { + t.Fatal("default flags renamed existing administrator", err) + } + reset := app.cfg + reset.adminUsername, reset.adminPassword = "new-owner", "a-new-administrator-password" + oldID := app.browserViewers().accountStore().state.Accounts["owner"].viewerID() + if _, err = restarted.browserViewers().ensureAdministrator(reset); err != nil { + t.Fatal(err) + } + account := restarted.browserViewers().accountStore().state.Accounts["new-owner"] + if account.viewerID() != oldID || account.RequirePasswordChange { + t.Fatal("startup override lost records or kept forced change") + } + admin.handler = restarted.routes() + if result := admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil); result.Code != http.StatusForbidden { + t.Fatal("startup credential reset kept old administrator session", result.Code) + } + admin.refresh(t) + admin.login(t, "new-owner", reset.adminPassword) + viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil)) + encoded, _ := json.Marshal(reset) + if strings.Contains(string(encoded), reset.adminPassword) { + t.Fatal("administrator password serialized into normal config") + } +} + +func TestAdministratorPolicyEnforcedRegistrationManualUsersAndGuestIsolation(t *testing.T) { + app, admin := administratorFixture(t) + guest, other := newAccountTestBrowser(t, app), newAccountTestBrowser(t, app) + guest.watch(t, app, 1, 17) + other.watch(t, app, 2, 35) + policy := func(login, registration bool) { + t.Helper() + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": login, "allowRegistration": registration})) + } + policy(false, false) + if result := guest.request(t, http.MethodPost, "/api/ui/account/register", map[string]string{"username": "blocked", "password": accountFixturePassword}); result.Code != http.StatusForbidden { + t.Fatal("disabled public registration accepted", result.Code) + } + created := admin.request(t, http.MethodPost, "/api/ui/admin/accounts", map[string]string{"username": "friend", "password": accountFixturePassword}) + if created.Code != http.StatusCreated { + t.Fatal("administrator cannot create a user", created.Code, created.Body.String()) + } + member := newAccountTestBrowser(t, app) + member.login(t, "friend", accountFixturePassword) + if member.id == admin.id || len(member.history(t)) != 0 { + t.Fatal("created user inherited administrator identity") + } + for _, browser := range []*accountTestBrowser{guest, member} { + for _, path := range []string{"/api/ui/admin/accounts", "/api/ui/admin/settings", "/api/ui/config"} { + if result := browser.request(t, http.MethodGet, path, nil); result.Code != http.StatusForbidden { + t.Fatal("non-administrator accessed management", path, result.Code) + } + } + if result := browser.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": false, "allowRegistration": true}); result.Code != http.StatusForbidden { + t.Fatal("non-administrator changed access policy") + } + } + policy(true, false) + for _, path := range []string{"/api/ui/dramas", "/api/ui/tasks", "/api/ui/following", "/api/ui/playback/history"} { + if result := guest.request(t, http.MethodGet, path, nil); result.Code != http.StatusUnauthorized || !strings.Contains(result.Body.String(), "login_required") { + t.Fatal("anonymous API access bypassed login requirement", path, result.Code) + } + } + if result := guest.request(t, http.MethodGet, "/", nil); result.Code != http.StatusFound || result.Header().Get("Location") != "/login" { + t.Fatal("anonymous browser was not redirected") + } + viewerResultOK(t, guest.request(t, http.MethodGet, "/login", nil)) + viewerResultOK(t, guest.request(t, http.MethodGet, "/assets/main.js", nil)) + if state := guest.refresh(t); state["requireLogin"] != true || state["allowRegistration"] != false { + t.Fatal("login page cannot read access flags") + } + viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/dramas", nil)) + restarted := &UIApp{cfg: app.cfg} + required, registration := restarted.browserViewers().accountStore().policy() + if !required || registration { + t.Fatal("restart lost administrator policy") + } + policy(false, false) + if guest.history(t)[0].Position != 17 || other.history(t)[0].Position != 35 { + t.Fatal("toggling login requirement mixed or deleted guest histories") + } +} + +func TestAdministratorPolicyPersistenceFailureAndNameCollision(t *testing.T) { + app, admin := administratorFixture(t) + store := app.browserViewers().accountStore() + path := store.path + store.path = app.cfg.dataDirectory() + result := admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": false}) + if result.Code != http.StatusInternalServerError { + t.Fatal("failed policy save reported success") + } + if required, registration := store.policy(); required || !registration { + t.Fatal("failed policy save changed active policy") + } + store.path = path + other := viewerTestApp(t) + member := newAccountTestBrowser(t, other) + member.register(t, "admin") + if _, err := other.browserViewers().ensureAdministrator(other.cfg); err == nil { + t.Fatal("bootstrap promoted an existing ordinary account") + } + if other.browserViewers().accountStore().state.Accounts["admin"].Admin { + t.Fatal("name collision escalated privileges") + } +} diff --git a/internal/app/account_downloads.go b/internal/app/account_downloads.go new file mode 100644 index 0000000..1f5f212 --- /dev/null +++ b/internal/app/account_downloads.go @@ -0,0 +1,35 @@ +package app + +import ( + "context" + "net/http" + "strings" +) + +func (account accountRecord) onlineOnly() bool { + return account.OnlineOnly && !account.Admin +} + +func downloadAllowed(ctx context.Context) bool { + scope := sourceScope(ctx) + return scope == nil || !scope.OnlineOnly +} + +func accountDownloadPath(path string) bool { + if strings.HasPrefix(path, "/api/ui/tasks/") { + return true + } + switch path { + case "/api/ui/download", "/api/ui/tasks", "/api/ui/update", "/api/ui/merge", "/api/emby/export": + return true + } + return false +} + +func requireDownload(writer http.ResponseWriter, request *http.Request) bool { + if downloadAllowed(request.Context()) { + return true + } + writeViewerError(writer, http.StatusForbidden, "download_forbidden", "当前账号仅可在线观看,请联系管理员开通下载权限") + return false +} diff --git a/internal/app/account_downloads_test.go b/internal/app/account_downloads_test.go new file mode 100644 index 0000000..1a285a1 --- /dev/null +++ b/internal/app/account_downloads_test.go @@ -0,0 +1,148 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestOnlineOnlyAccountPersistsAndBlocksDownloadAPIs(t *testing.T) { + app, admin := administratorFixture(t) + created := admin.request(t, http.MethodPost, "/api/ui/admin/accounts", map[string]any{"username": "online-user", "password": accountFixturePassword, "sources": []string{sourceHongguo}, "onlineOnly": true}) + if created.Code != http.StatusCreated { + t.Fatal(created.Code, created.Body.String()) + } + member := newAccountTestBrowser(t, app) + member.login(t, "online-user", accountFixturePassword) + state := member.refresh(t) + if state["onlineOnly"] != true || state["account"].(map[string]any)["onlineOnly"] != true { + t.Fatal("online-only permission missing from viewer state") + } + for _, test := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/api/ui/tasks", nil}, + {http.MethodPost, "/api/ui/download", map[string]any{"ids": []string{historyFixtureDramaID}}}, + {http.MethodPost, "/api/ui/update", map[string]any{"ids": []string{historyFixtureDramaID}}}, + {http.MethodPost, "/api/ui/merge", map[string]any{"dramaIds": []string{historyFixtureDramaID}}}, + {http.MethodPost, "/api/ui/tasks/retry", map[string]any{"ids": []string{"existing-task"}}}, + {http.MethodPost, "/api/ui/tasks/resume", map[string]any{"ids": []string{"existing-task"}}}, + {http.MethodPost, "/api/ui/tasks/pause", map[string]any{"ids": []string{"existing-task"}}}, + {http.MethodPost, "/api/ui/tasks/cancel", map[string]any{"ids": []string{"existing-task"}}}, + {http.MethodPost, "/api/ui/tasks/clear", map[string]any{"all": true}}, + {http.MethodPost, "/api/emby/export", map[string]any{"dramaId": historyFixtureDramaID, "baseUrl": "http://local.test"}}, + {http.MethodPost, "/api/ui/playback/open", map[string]any{"taskId": "existing-task"}}, + } { + response := member.request(t, test.method, test.path, test.body) + if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "download_forbidden") { + t.Fatal("online-only request reached a download handler", test.path, response.Code, response.Body.String()) + } + } + session := member.watch(t, app, 1, 12) + app.closePlayback(session) + viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/following", nil)) + viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/dramas", nil)) + if response := member.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "online-user", "onlineOnly": false}); response.Code != http.StatusForbidden { + t.Fatal("ordinary user granted themselves downloads", response.Code) + } + if response := admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "admin", "onlineOnly": true}); response.Code != http.StatusBadRequest { + t.Fatal("administrator permissions were reduced", response.Code) + } + restored := &UIApp{cfg: app.cfg} + saved := restored.browserViewers().accountStore().state.Accounts["online-user"] + if !saved.onlineOnly() || len(saved.Sources) != 1 || saved.Sources[0] != sourceHongguo { + t.Fatal("permissions lost on restart") + } + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "online-user", "onlineOnly": false})) + if state := member.refresh(t); state["onlineOnly"] != false || len(state["sources"].([]any)) != 1 { + t.Fatal("download grant changed source permissions") + } + viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/tasks", nil)) + if (accountRecord{}).onlineOnly() || (accountRecord{Admin: true, OnlineOnly: true}).onlineOnly() { + t.Fatal("legacy or administrator download defaults changed") + } +} + +func TestOnlineOnlyRevocationRejectsStalePagesAndAccountExports(t *testing.T) { + app, admin := administratorFixture(t) + member := newAccountTestBrowser(t, app) + member.register(t, "download-revoked") + online := member.watch(t, app, 1, 3) + collection := member.watch(t, app, 1, 4) + app.playbackMu.Lock() + app.playbacks[collection].downloadIDs = []string{"download-one"} + app.playbackMu.Unlock() + account := app.browserViewers().accountStore().state.Accounts["download-revoked"] + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + chapter := "hongguo:7000000000000000001:1" + query := url.Values{"id": {historyFixtureDramaID}, "chapter": {chapter}, "account": {account.ID}, "key": {embyToken(key, historyFixtureDramaID, chapter, account.ID)}} + if result := member.request(t, http.MethodHead, "/api/emby/stream.m3u8?"+query.Encode(), nil); result.Code != http.StatusOK { + t.Fatal("permitted account export rejected", result.Code) + } + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "download-revoked", "onlineOnly": true})) + app.playbackMu.Lock() + keptOnline, keptCollection := app.playbacks[online] != nil, app.playbacks[collection] != nil + app.playbackMu.Unlock() + if !keptOnline || keptCollection { + t.Fatal("revocation did not distinguish online and download playback") + } + request := httptest.NewRequest(http.MethodGet, "http://shared.example.test/api/ui/dramas", nil) + request.Header.Set("X-Juku-Viewer", member.id) + request.Header.Set("X-Juku-Online-Only", "false") + for _, cookie := range member.cookies { + request.AddCookie(cookie) + } + recorder := httptest.NewRecorder() + member.handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "permissions_changed") { + t.Fatal("stale or forged download permission accepted", recorder.Code) + } + for _, endpoint := range []string{"stream.m3u8", "segment.ts"} { + if result := member.request(t, http.MethodGet, "/api/emby/"+endpoint+"?"+query.Encode(), nil); result.Code != http.StatusForbidden { + t.Fatal("revoked account export remains usable", endpoint, result.Code) + } + } + restored := &UIApp{cfg: app.cfg} + if !restored.browserViewers().accountStore().state.Accounts["download-revoked"].onlineOnly() { + t.Fatal("revocation lost after restart") + } + app.closePlayback(online) +} + +func TestOnlineOnlyCollectionHistoryResumesOnline(t *testing.T) { + app, admin := administratorFixture(t) + member := newAccountTestBrowser(t, app) + member.register(t, "collection-history") + sessionID := member.watch(t, app, 1, 8) + app.playbackMu.Lock() + session := app.playbacks[sessionID] + session.downloadIDs = []string{"download-one", "download-two", "download-three"} + app.playbackMu.Unlock() + progress := playbackHistoryProgress{Run: 1, Sequence: 2, Episode: 1, Position: 9, Duration: 90} + viewerResultOK(t, member.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": sessionID, "progress": progress})) + if history := member.history(t); len(history) != 1 || history[0].Mode != "collection" { + t.Fatal("missing collection history") + } + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "collection-history", "onlineOnly": true})) + response := member.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}) + viewerResultOK(t, response) + var opened struct { + Session string `json:"session"` + Mode string `json:"mode"` + InitialPosition float64 `json:"initialPosition"` + } + if err := json.Unmarshal(response.Body.Bytes(), &opened); err != nil { + t.Fatal(err) + } + if opened.Mode != "online" || opened.InitialPosition != 9 { + t.Fatal("collection history did not resume online", response.Body.String()) + } + app.closePlayback(opened.Session) +} diff --git a/internal/app/account_import.go b/internal/app/account_import.go new file mode 100644 index 0000000..0284978 --- /dev/null +++ b/internal/app/account_import.go @@ -0,0 +1,112 @@ +package app + +import ( + "errors" + "net/http" + "os" + "path/filepath" +) + +func (manager *viewerManager) guestImportAvailable(identity, account string) bool { + if identity == "" || account == "" { + return false + } + guest := viewerID(identity) + store := manager.accountStore() + store.mu.Lock() + imported, exists := store.state.GuestImports[guest] + store.mu.Unlock() + if exists && (imported.Completed || imported.Account != account) { + return false + } + for _, name := range []string{"playback-history.json", "following.json"} { + if info, err := os.Stat(filepath.Join(manager.directory, "viewers", guest, name)); err == nil && info.Mode().IsRegular() && info.Size() > 0 { + return true + } + } + manager.legacyMu.Lock() + defer manager.legacyMu.Unlock() + legacy, err := manager.legacyImport() + return err == nil && !legacy.Completed && legacy.Viewer == guest +} + +func (app *UIApp) handleAccountImport(writer http.ResponseWriter, request *http.Request) { + var input struct{} + if !readAccountRequest(writer, request, &input) { + return + } + manager := app.browserViewers() + account, name, _, _ := manager.requestAccount(request) + if account.ID == "" { + writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "请先登录,再接回此浏览器原有记录"}) + return + } + identity := manager.identity(request) + if identity == "" { + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) + return + } + guest := viewerID(identity) + manager.legacyMu.Lock() + defer manager.legacyMu.Unlock() + legacy, legacyErr := manager.legacyImport() + pendingLegacy := legacyErr == nil && !legacy.Completed && legacy.Viewer == guest + store := manager.accountStore() + store.mu.Lock() + defer store.mu.Unlock() + imported, exists := store.state.GuestImports[guest] + if exists && imported.Account != name { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "此浏览器原有记录已接入其他账号"}) + return + } + if exists && imported.Completed { + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) + return + } + if len(store.state.GuestImports) >= 10000 && !exists { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "此服务的浏览器记录接回数量已达上限"}) + return + } + source := manager.acquire(guest) + defer source.release() + target := requestViewer(writer, request) + if target == nil || target.id != account.viewerID() { + return + } + history, err := source.playbackHistory().list() + var following []followingEntry + if err == nil { + following, err = source.followingStore().list() + } + if err == nil { + _, err = target.playbackHistory().list() + } + if err == nil { + _, err = target.followingStore().list() + } + if err == nil && len(history) == 0 && len(following) == 0 && !pendingLegacy { + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) + return + } + if err == nil && !exists { + state := store.snapshotLocked() + state.GuestImports[guest] = accountGuestImport{Account: name} + err = store.saveLocked(state) + } + if err == nil { + err = target.followingStore().importLegacy(following) + } + if err == nil { + err = target.playbackHistory().importLegacy(history) + } + if err == nil { + state := store.snapshotLocked() + state.GuestImports[guest] = accountGuestImport{Account: name, Completed: true} + err = store.saveLocked(state) + } + if err != nil { + accountOperationError(writer, errors.New("记录尚未全部接回,可在当前账号重试;原始文件已保留:"+publicError(err).Error())) + return + } + writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "history": len(history), "following": len(following)}) +} diff --git a/internal/app/account_password.go b/internal/app/account_password.go new file mode 100644 index 0000000..caa1918 --- /dev/null +++ b/internal/app/account_password.go @@ -0,0 +1,69 @@ +package app + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "unicode" + "unicode/utf8" +) + +const accountPasswordIterations = 600000 + +func normalizeAccountUsername(name string) (string, string, error) { + name = strings.TrimSpace(name) + if !utf8.ValidString(name) || utf8.RuneCountInString(name) < 2 || utf8.RuneCountInString(name) > 32 { + return "", "", errors.New("用户名需为 2–32 个字,支持中文、字母、数字、下划线和短横线") + } + for _, char := range name { + if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' && char != '-' { + return "", "", errors.New("用户名只能包含中文、字母、数字、下划线和短横线") + } + } + return strings.ToLower(name), name, nil +} + +func validateAccountPassword(password string) error { + count := utf8.RuneCountInString(password) + if !utf8.ValidString(password) || count < 10 || count > 128 { + return errors.New("密码需为 10–128 个字符,可使用长短语") + } + return nil +} + +func accountPasswordKey(password string, salt []byte, iterations int) []byte { + mac := hmac.New(sha256.New, []byte(password)) + mac.Write(salt) + mac.Write([]byte{0, 0, 0, 1}) + block := mac.Sum(nil) + key := append([]byte(nil), block...) + for remaining := iterations - 1; remaining > 0; remaining-- { + mac.Reset() + mac.Write(block) + block = mac.Sum(block[:0]) + for index, value := range block { + key[index] ^= value + } + } + return key +} + +func newAccountPassword(password string) (string, string, error) { + if err := validateAccountPassword(password); err != nil { + return "", "", err + } + salt := randomHex(16) + if salt == "" { + return "", "", errors.New("无法生成密码盐,请重试") + } + decoded, _ := hex.DecodeString(salt) + return salt, hex.EncodeToString(accountPasswordKey(password, decoded, accountPasswordIterations)), nil +} + +func verifyAccountPassword(account accountRecord, password string) bool { + salt, _ := hex.DecodeString(account.Salt) + expected, _ := hex.DecodeString(account.PasswordHash) + return hmac.Equal(expected, accountPasswordKey(password, salt, account.Iterations)) +} diff --git a/internal/app/account_source_catalog.go b/internal/app/account_source_catalog.go new file mode 100644 index 0000000..060e220 --- /dev/null +++ b/internal/app/account_source_catalog.go @@ -0,0 +1,6 @@ +package app + +var accountSourceChoices = []accountSourceChoice{{ID: "huangguo", Name: "黄果"}, {ID: "huangdou", Name: "黄豆"}, {ID: "hongguo", Name: "红果"}} +var accountSourceAliases = map[string]string{"huangguo": "huangguo", "cloudfront": "huangguo", "huangguoai": "huangguo", "huangguo-video": "huangguo", "huangdou": "huangdou", "hongguo": "hongguo"} + +const accountLegacySource = "huangguo" diff --git a/internal/app/account_sources.go b/internal/app/account_sources.go new file mode 100644 index 0000000..6d3f662 --- /dev/null +++ b/internal/app/account_sources.go @@ -0,0 +1,383 @@ +package app + +import ( + "context" + "errors" + "net/http" + "strings" + "time" +) + +type accountSourceChoice struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type accountSourceScope struct { + OnlineOnly bool + AccountID string + Sources []string +} + +type accountSourceContextKey struct{} + +func allAccountSources() []string { + result := make([]string, 0, len(accountSourceChoices)) + for _, choice := range accountSourceChoices { + result = append(result, choice.ID) + } + return result +} + +func normalizeAccountSources(sources []string) ([]string, error) { + if sources == nil { + return nil, nil + } + if len(sources) == 0 || len(sources) > len(accountSourceChoices) { + return nil, errors.New("请至少选择一个可用站源") + } + selected := make(map[string]bool, len(sources)) + for _, source := range sources { + valid := false + for _, choice := range accountSourceChoices { + if source == choice.ID { + valid = true + break + } + } + if !valid || selected[source] { + return nil, errors.New("站源权限无效,请重新选择") + } + selected[source] = true + } + result := make([]string, 0, len(sources)) + for _, choice := range accountSourceChoices { + if selected[choice.ID] { + result = append(result, choice.ID) + } + } + return result, nil +} + +func (account accountRecord) effectiveSources() []string { + if account.Admin || account.Sources == nil { + return allAccountSources() + } + return append([]string{}, account.Sources...) +} + +func sourceScope(ctx context.Context) *accountSourceScope { + scope, _ := ctx.Value(accountSourceContextKey{}).(*accountSourceScope) + return scope +} + +func withSourceScope(ctx context.Context, account accountRecord) context.Context { + return context.WithValue(ctx, accountSourceContextKey{}, &accountSourceScope{OnlineOnly: account.onlineOnly(), AccountID: account.ID, Sources: account.effectiveSources()}) +} + +func sourceScopeRestricted(ctx context.Context) bool { + scope := sourceScope(ctx) + return scope != nil && len(scope.Sources) < len(accountSourceChoices) +} + +func accountSourceGroup(source string) string { + return accountSourceAliases[strings.ToLower(strings.TrimSpace(source))] +} + +func accountDramaSource(id string) string { + id = strings.TrimSpace(id) + if source, sourceID, ok := splitProviderDramaID(id); ok && sourceID != "" { + return accountSourceGroup(source) + } + if id == "" || strings.Contains(id, ":") { + return "" + } + return accountLegacySource +} + +func sourceAllowed(ctx context.Context, source string) bool { + scope := sourceScope(ctx) + if scope == nil { + return true + } + group := accountSourceGroup(source) + for _, allowed := range scope.Sources { + if allowed == group { + return true + } + } + return false +} + +func dramaAllowed(ctx context.Context, id, source string) bool { + if sourceScope(ctx) == nil { + return true + } + group := accountDramaSource(id) + return group != "" && (source == "" || accountSourceGroup(source) == group) && sourceAllowed(ctx, group) +} + +func taskSourceAllowed(ctx context.Context, task Task) bool { + return dramaAllowed(ctx, task.DramaID, task.Chapter.Source) +} + +func requireSource(writer http.ResponseWriter, ctx context.Context, source string) bool { + if sourceAllowed(ctx, source) { + return true + } + writeSourceDenied(writer) + return false +} + +func writeSourceDenied(writer http.ResponseWriter) { + writeViewerError(writer, http.StatusForbidden, "source_forbidden", "当前账号无权访问该站源,请联系管理员") +} + +func (app *UIApp) requireDramaSources(writer http.ResponseWriter, request *http.Request, ids []string) bool { + app.mu.Lock() + allowed := app.dramaSourcesAllowedLocked(request.Context(), ids) + app.mu.Unlock() + if !allowed { + writeSourceDenied(writer) + } + return allowed +} + +func (app *UIApp) dramaSourcesAllowedLocked(ctx context.Context, ids []string) bool { + wanted := make(map[string]bool, len(ids)) + for _, id := range ids { + if !dramaAllowed(ctx, id, "") { + return false + } + wanted[id] = true + } + for _, drama := range app.dramas { + if wanted[drama.ID] && !dramaAllowed(ctx, drama.ID, drama.Source) { + return false + } + } + return true +} + +func (app *UIApp) requireTaskSourcesLocked(writer http.ResponseWriter, request *http.Request, selection taskSelection) bool { + ctx := request.Context() + allowed := app.dramaSourcesAllowedLocked(ctx, selection.DramaIDs) + for _, id := range selection.IDs { + if task := app.tasks[id]; task != nil && (!dramaAllowed(ctx, task.DramaID, "") || !taskSourceAllowed(ctx, task.Source)) { + allowed = false + } + } + for _, id := range selection.idsLocked(app) { + if task := app.tasks[id]; task != nil && (!dramaAllowed(ctx, task.DramaID, "") || !taskSourceAllowed(ctx, task.Source)) { + allowed = false + } + } + if !allowed { + writeSourceDenied(writer) + } + return allowed +} + +func (app *UIApp) taskViewsForSourceLocked(ctx context.Context) []uiTaskView { + views := app.taskViewsLocked() + if sourceScope(ctx) == nil { + return views + } + result := make([]uiTaskView, 0, len(views)) + for _, view := range views { + task := app.tasks[view.ID] + if task != nil && dramaAllowed(ctx, view.DramaID, "") && taskSourceAllowed(ctx, task.Source) { + result = append(result, view) + } + } + return result +} + +func (app *UIApp) librarySnapshotForSourceLocked(ctx context.Context, revision uint64) map[string]any { + if !sourceScopeRestricted(ctx) { + return app.librarySnapshotLocked(revision) + } + response := app.librarySnapshotLocked(0) + dramas := make([]Drama, 0) + for _, drama := range app.dramas { + if dramaAllowed(ctx, drama.ID, drama.Source) { + dramas = append(dramas, drama) + } + } + sources := map[string]librarySourceState{} + loading := false + for source, state := range app.librarySources { + if sourceAllowed(ctx, source) { + sources[source] = state + loading = loading || state.Status == "loading" + } + } + remaining := sortMetadataRemaining(dramas) + for source := range remaining { + if source != "" && !sourceAllowed(ctx, source) { + delete(remaining, source) + } + } + more := map[string]bool{} + for source, count := range remaining { + more[source] = count > 0 + } + if sourceAllowed(ctx, sourceHongguo) { + more[sourceHongguo] = more[sourceHongguo] || hongguoCatalogHasMore(app.libraryApp) + } + for source, value := range more { + if source != "" { + more[""] = more[""] || value + } + } + response["data"], response["total"], response["sources"], response["error"] = dramas, len(dramas), sources, librarySourceErrors(sources) + response["loading"], response["loadingMore"] = loading, loading && app.libraryMore + response["loadingSource"] = "" + if sourceAllowed(ctx, app.libraryLoadingSource) { + response["loadingSource"] = app.libraryLoadingSource + } + response["metadataRemaining"], response["hasMoreBySource"], response["hasMore"] = remaining, more, more[""] + pending := 0 + for _, drama := range dramas { + if app.metadataPending[drama.ID] { + pending++ + } + } + response["metadata"] = map[string]any{"running": pending > 0, "checked": 0, "total": pending, "updated": 0, "failed": 0} + return response +} + +func (app *UIApp) imageSource(ctx context.Context, remote string) (string, bool) { + app.mu.Lock() + defer app.mu.Unlock() + for _, drama := range app.dramas { + if !dramaAllowed(ctx, drama.ID, drama.Source) { + continue + } + if address, valid := buildImageURL(bestDramaCover(drama)); valid && address == remote { + return dramaProvider(drama), true + } + } + return "", false +} + +func playbackSourceAllowed(ctx context.Context, session *playbackSession) bool { + if session == nil { + return false + } + if !downloadAllowed(ctx) && (len(session.downloadIDs) > 0 || session.accountID != "") { + return false + } + if session.dramaID != "" && !dramaAllowed(ctx, session.dramaID, "") { + return false + } + for _, task := range session.tasks { + if !taskSourceAllowed(ctx, task) { + return false + } + } + return true +} + +func (app *UIApp) closeForbiddenPlaybacks(account accountRecord) { + ctx := withSourceScope(context.Background(), account) + var ids []string + app.playbackMu.Lock() + for id, session := range app.playbacks { + if (session.accountID == account.ID || session.viewer != nil && session.viewer.id == account.viewerID()) && !playbackSourceAllowed(ctx, session) { + ids = append(ids, id) + } + } + app.playbackMu.Unlock() + for _, id := range ids { + app.closePlayback(id) + } +} + +func (app *UIApp) handleAdminAccountPermissions(writer http.ResponseWriter, request *http.Request) { + var input struct { + Username string `json:"username"` + Sources []string `json:"sources"` + OnlineOnly *bool `json:"onlineOnly"` + } + if !readAccountRequest(writer, request, &input) { + return + } + name, _, err := normalizeAccountUsername(input.Username) + sources, sourceErr := normalizeAccountSources(input.Sources) + if err != nil || sourceErr != nil || sources == nil && input.OnlineOnly == nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择账号及需要修改的权限"}) + return + } + store := app.browserViewers().accountStore() + store.mu.Lock() + account, exists := store.state.Accounts[name] + if !exists || account.Admin { + store.mu.Unlock() + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "只能修改现有普通账号的权限"}) + return + } + if input.Sources != nil { + account.Sources = sources + } + if input.OnlineOnly != nil { + account.OnlineOnly = *input.OnlineOnly + } + state := store.snapshotLocked() + state.Accounts[name] = account + err = store.saveLocked(state) + store.mu.Unlock() + if err != nil { + accountOperationError(writer, err) + return + } + app.closeForbiddenPlaybacks(account) + writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "account": account.public()}) +} + +func sourceRankingBoards(ctx context.Context) []rankingBoard { + result := make([]rankingBoard, 0, len(rankingBoards)) + for _, board := range rankingBoards { + if sourceAllowed(ctx, board.Source) { + result = append(result, board) + } + } + return result +} + +func (store *playbackHistoryStore) removeForSources(ctx context.Context, id string, all bool) error { + if !all || !sourceScopeRestricted(ctx) { + return store.remove(id, all) + } + store.mu.Lock() + if store.loadErr != nil { + err := store.loadErr + store.mu.Unlock() + return err + } + now := time.Now() + for id, entry := range store.entries { + if dramaAllowed(ctx, id, entry.Source) { + delete(store.entries, id) + store.deleted[id] = now + } + } + store.revision++ + store.mu.Unlock() + return store.flush() +} + +func (app *UIApp) embyAccountSourceAllowed(owner, dramaID string) bool { + store := app.browserViewers().accountStore() + store.mu.Lock() + defer store.mu.Unlock() + if store.err != nil { + return false + } + for _, account := range store.state.Accounts { + if account.ID == owner { + return !account.onlineOnly() && dramaAllowed(withSourceScope(context.Background(), account), dramaID, "") + } + } + return false +} diff --git a/internal/app/account_sources_multisource_test.go b/internal/app/account_sources_multisource_test.go new file mode 100644 index 0000000..1cd3fff --- /dev/null +++ b/internal/app/account_sources_multisource_test.go @@ -0,0 +1,172 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestSourcePermissionsFilterReadsAndRejectMixedActions(t *testing.T) { + app, admin := administratorFixture(t) + member := newAccountTestBrowser(t, app) + member.register(t, "limited-user") + member.watch(t, app, 1, 9) + hidden := "huangdou:hidden-series" + app.mu.Lock() + app.dramas = append(app.dramas, Drama{ID: hidden, Source: sourceHuangdou, Title: "restricted secret title", CoverURL: "https://tideember.cc/hidden-cover.jpg"}, Drama{ID: "cloudfront-legacy", Title: "legacy secret"}, Drama{ID: "hongguo:7999999999999999999", Source: sourceHuangdou, Title: "conflicting secret"}) + app.libraryAttempted = true + app.libraryRevision = 7 + app.librarySources = map[string]librarySourceState{sourceHongguo: {Count: 1, Status: "ready"}, sourceHuangdou: {Count: 1, Status: "failed", Error: "restricted secret error"}} + app.tasks = map[string]*UITask{ + "allowed-task": {ID: "allowed-task", DramaID: historyFixtureDramaID, DramaTitle: "allowed task", Status: uiStatusQueued, Source: Task{DramaID: historyFixtureDramaID, Chapter: Chapter{Source: sourceHongguo}}}, + "hidden-task": {ID: "hidden-task", DramaID: hidden, DramaTitle: "restricted secret task", Status: uiStatusQueued, Source: Task{DramaID: hidden, Chapter: Chapter{Source: sourceHuangdou}}}, + } + app.taskOrder = []string{"allowed-task", "hidden-task"} + app.merges = map[string]*UIMergeState{hidden: {DramaID: hidden, DramaTitle: "restricted secret merge"}} + app.mu.Unlock() + viewer := app.browserViewers().acquire(member.id) + defer viewer.release() + history := viewer.playbackHistory() + history.mu.Lock() + history.entries[hidden] = playbackHistoryEntry{DramaID: hidden, Source: sourceHuangdou, Title: "restricted secret history", Episode: "1", Index: 1, Total: 2, Position: 10, Duration: 50, WatchedAt: time.Now()} + history.mu.Unlock() + follows := viewer.followingStore() + follows.mu.Lock() + follows.entries[hidden] = followingEntry{DramaID: hidden, Source: sourceHuangdou, Title: "restricted secret following", Saved: true, AddedAt: time.Now(), UpdatedAt: time.Now()} + follows.mu.Unlock() + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "limited-user", "sources": []string{sourceHongguo}})) + for _, path := range []string{"/api/ui/dramas?revision=7", "/api/ui/tasks", "/api/ui/following", "/api/ui/playback/history", "/api/ui/rankings"} { + response := member.request(t, http.MethodGet, path, nil) + viewerResultOK(t, response) + if strings.Contains(response.Body.String(), "secret") || strings.Contains(response.Body.String(), "huangdou") || strings.Contains(response.Body.String(), "huangguo") { + t.Fatalf("hidden source leaked through %s: %s", path, response.Body.String()) + } + if path == "/api/ui/dramas?revision=7" { + var result struct { + Data []Drama `json:"data"` + Total int `json:"total"` + } + if json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Total != 1 || len(result.Data) != 1 { + t.Fatal("incorrect scoped catalogue", response.Body.String()) + } + } + } + for _, test := range []struct { + path string + body any + }{ + {"/api/ui/download", map[string]any{"ids": []string{historyFixtureDramaID, hidden}}}, + {"/api/ui/update", map[string]any{"ids": []string{hidden}}}, + {"/api/ui/download", map[string]any{"ids": []string{"cloudfront-legacy"}}}, + {"/api/ui/download", map[string]any{"ids": []string{"hongguo:7999999999999999999"}}}, + {"/api/ui/merge", map[string]any{"dramaIds": []string{hidden}}}, + {"/api/ui/playback/open", map[string]any{"dramaId": hidden}}, + {"/api/ui/playback/open", map[string]any{"taskId": "hidden-task"}}, + {"/api/ui/following", map[string]any{"dramaId": hidden, "saved": true}}, + {"/api/ui/playback/history/remove", map[string]any{"dramaId": hidden}}, + {"/api/ui/cover/repair", map[string]any{"dramaId": hidden}}, + {"/api/emby/export", map[string]any{"dramaId": hidden, "baseUrl": "http://local.test"}}, + } { + response := member.request(t, http.MethodPost, test.path, test.body) + if response.Code != http.StatusForbidden { + t.Fatalf("%s allowed forbidden source: %d %s", test.path, response.Code, response.Body.String()) + } + } + for _, action := range []string{"retry", "cancel", "pause", "resume", "clear"} { + response := member.request(t, http.MethodPost, "/api/ui/tasks/"+action, map[string]any{"ids": []string{"allowed-task", "hidden-task"}}) + if response.Code != http.StatusForbidden { + t.Fatal("mixed action accepted", action, response.Code, response.Body.String()) + } + } + if app.tasks["allowed-task"].Status != uiStatusQueued || app.tasks["hidden-task"].Status != uiStatusQueued { + t.Fatal("mixed forbidden batch partially mutated tasks") + } + for _, path := range []string{"/api/ui/dramas?update=1&source=huangdou", "/api/ui/image?url=" + url.QueryEscape("https://tideember.cc/hidden-cover.jpg")} { + if response := member.request(t, http.MethodGet, path, nil); response.Code != http.StatusForbidden { + t.Fatal("restricted read triggered upstream request", path, response.Code) + } + } + viewerResultOK(t, member.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]bool{"all": true})) + if _, exists := history.get(hidden); !exists { + t.Fatal("clear visible history deleted inaccessible records") + } + if _, exists := history.get(historyFixtureDramaID); exists { + t.Fatal("visible history was not cleared") + } + ctx := withSourceScope(context.Background(), accountRecord{Sources: []string{"huangguo"}}) + for _, source := range []string{"cloudfront", "huangguoai", "huangguo-video"} { + if !sourceAllowed(ctx, source) { + t.Fatal("source group excludes provider", source) + } + } +} + +func TestSourceRevocationClosesStreamsAndRejectsStalePagesAndExports(t *testing.T) { + app, admin := administratorFixture(t) + member := newAccountTestBrowser(t, app) + member.register(t, "revoked-user") + session := member.watch(t, app, 1, 9) + account := app.browserViewers().accountStore().state.Accounts["revoked-user"] + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + chapter := "hongguo:7000000000000000001:1" + query := url.Values{"id": {historyFixtureDramaID}, "chapter": {chapter}, "account": {account.ID}, "key": {embyToken(key, historyFixtureDramaID, chapter, account.ID)}} + if result := member.request(t, http.MethodHead, "/api/emby/stream.m3u8?"+query.Encode(), nil); result.Code != http.StatusOK { + t.Fatal("valid account export rejected", result.Code) + } + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "revoked-user", "sources": []string{sourceHuangdou}})) + app.playbackMu.Lock() + _, exists := app.playbacks[session] + app.playbackMu.Unlock() + if exists { + t.Fatal("permission revocation left playback session running") + } + if result := member.request(t, http.MethodPost, "/api/ui/playback/control", map[string]string{"session": session, "action": "heartbeat"}); result.Code != http.StatusGone { + t.Fatal("revoked playback still usable", result.Code) + } + for _, endpoint := range []string{"stream.m3u8", "segment.ts"} { + if result := member.request(t, http.MethodGet, "/api/emby/"+endpoint+"?"+query.Encode(), nil); result.Code != http.StatusForbidden { + t.Fatal("revoked export still usable", result.Code) + } + } + query.Del("account") + if result := member.request(t, http.MethodGet, "/api/emby/stream.m3u8?"+query.Encode(), nil); result.Code != http.StatusForbidden { + t.Fatal("stripping account bypasses export signature") + } + request := httptest.NewRequest(http.MethodGet, "http://shared.example.test/api/ui/dramas", nil) + for _, cookie := range member.cookies { + request.AddCookie(cookie) + } + request.Header.Set("X-Juku-Sources", strings.Join(allAccountSources(), ",")) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + if response.Code != http.StatusConflict || !strings.Contains(response.Body.String(), "sources_changed") { + t.Fatal("stale page kept old source state", response.Code) + } + for _, test := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/api/ui/search?q=test", nil}, + {http.MethodPost, "/api/ui/recommendations", map[string]any{"genre": "short_play", "offset": 0}}, + } { + if result := member.request(t, test.method, test.path, test.body); result.Code != http.StatusForbidden { + t.Fatal("restricted recommendations/search accepted", result.Code, result.Body.String()) + } + } + ctx := withSourceScope(context.Background(), account) + ctx = context.WithValue(ctx, viewerContextKey{}, app.browserViewers().acquire(member.id)) + defer contextViewer(ctx).release() + late := &playbackSession{dramaID: historyFixtureDramaID, viewer: contextViewer(ctx)} + ctx = withSourceScope(ctx, accountRecord{Sources: []string{sourceHuangdou}}) + if viewerOwnsPlayback(ctx, late) { + t.Fatal("late session bypasses current source scope") + } +} diff --git a/internal/app/account_sources_test.go b/internal/app/account_sources_test.go new file mode 100644 index 0000000..425177b --- /dev/null +++ b/internal/app/account_sources_test.go @@ -0,0 +1,68 @@ +package app + +import ( + "encoding/json" + "net/http" + "os" + "reflect" + "testing" +) + +func TestAccountSourceChoicesPersistAndRejectInvalidChanges(t *testing.T) { + app, admin := administratorFixture(t) + result := admin.request(t, http.MethodPost, "/api/ui/admin/accounts", map[string]any{"username": "source-user", "password": accountFixturePassword, "sources": []string{sourceHongguo}}) + if result.Code != http.StatusCreated { + t.Fatal(result.Code, result.Body.String()) + } + member := newAccountTestBrowser(t, app) + member.login(t, "source-user", accountFixturePassword) + if sources := member.refresh(t)["sources"]; !reflect.DeepEqual(sources, []any{sourceHongguo}) { + t.Fatal("incorrect account sources", sources) + } + for _, input := range []map[string]any{ + {"username": "source-user", "sources": []string{}}, + {"username": "source-user", "sources": []string{"unknown-source"}}, + {"username": "source-user", "sources": []string{sourceHongguo, sourceHongguo}}, + {"username": "admin", "sources": []string{sourceHongguo}}, + } { + if response := admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", input); response.Code != http.StatusBadRequest { + t.Fatal("invalid permission change accepted", response.Code) + } + } + if response := member.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "source-user", "sources": allAccountSources()}); response.Code != http.StatusForbidden { + t.Fatal("member can grant permissions", response.Code) + } + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "source-user", "sources": []string{sourceHongguo}})) + restored := &UIApp{cfg: app.cfg} + account := restored.browserViewers().accountStore().state.Accounts["source-user"] + if !reflect.DeepEqual(account.Sources, []string{sourceHongguo}) || restored.browserViewers().accountStore().err != nil { + t.Fatal("source permissions lost on restart") + } + store := app.browserViewers().accountStore() + store.mu.Lock() + snapshot := store.snapshotLocked() + item := snapshot.Accounts["source-user"] + item.Sources[0] = "unknown-source" + snapshot.Accounts["source-user"] = item + if store.state.Accounts["source-user"].Sources[0] != sourceHongguo { + t.Fatal("snapshot aliases live permissions") + } + encoded, err := json.Marshal(snapshot) + store.mu.Unlock() + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(store.path, encoded, 0600); err != nil { + t.Fatal(err) + } + corrupt := &UIApp{cfg: app.cfg} + if corrupt.browserViewers().accountStore().err == nil { + t.Fatal("invalid persisted permissions granted access") + } + if !reflect.DeepEqual((accountRecord{}).effectiveSources(), allAccountSources()) { + t.Fatal("legacy and anonymous access changed") + } + if !reflect.DeepEqual((accountRecord{Admin: true, Sources: []string{sourceHongguo}}).effectiveSources(), allAccountSources()) { + t.Fatal("administrator lost full access") + } +} diff --git a/internal/app/account_store.go b/internal/app/account_store.go new file mode 100644 index 0000000..f249945 --- /dev/null +++ b/internal/app/account_store.go @@ -0,0 +1,303 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +const accountLifetime = 30 * 24 * time.Hour +const accountLimit = 1000 +const accountSessionLimit = 16 + +var errAccountStore = errors.New("账号数据无法读取,原文件已保留,请检查数据目录或恢复备份") + +type accountRecord struct { + OnlineOnly bool `json:"onlineOnly,omitempty"` + Sources []string `json:"sources,omitempty"` + Admin bool `json:"admin,omitempty"` + RequirePasswordChange bool `json:"requirePasswordChange,omitempty"` + ID string `json:"id"` + Username string `json:"username"` + Salt string `json:"salt"` + PasswordHash string `json:"passwordHash"` + Iterations int `json:"iterations"` + Algorithm string `json:"algorithm"` + CreatedAt time.Time `json:"createdAt"` +} + +type accountPublic struct { + OnlineOnly bool `json:"onlineOnly"` + Sources []string `json:"sources"` + Admin bool `json:"admin"` + RequirePasswordChange bool `json:"requirePasswordChange"` + Username string `json:"username"` + CreatedAt time.Time `json:"createdAt"` +} + +func (account accountRecord) public() accountPublic { + return accountPublic{OnlineOnly: account.onlineOnly(), Sources: account.effectiveSources(), Username: account.Username, CreatedAt: account.CreatedAt, Admin: account.Admin, RequirePasswordChange: account.RequirePasswordChange} +} + +func (account accountRecord) viewerID() string { + return viewerID("account:" + account.ID) +} + +type accountSession struct { + Account string `json:"account"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type accountGuestImport struct { + Account string `json:"account"` + Completed bool `json:"completed"` +} + +type accountState struct { + RequireLogin bool `json:"requireLogin"` + AllowRegistration bool `json:"allowRegistration"` + Version int `json:"version"` + Accounts map[string]accountRecord `json:"accounts"` + Sessions map[string]accountSession `json:"sessions"` + GuestImports map[string]accountGuestImport `json:"guestImports,omitempty"` +} + +type accountAttempt struct { + count int + until time.Time +} + +type accountStore struct { + mu sync.Mutex + path string + state accountState + err error + attempts map[string]accountAttempt + hashing chan struct{} +} + +func validAccountHex(value string, size int) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == size && hex.EncodeToString(decoded) == value +} + +func (manager *viewerManager) accountStore() *accountStore { + manager.accountsOnce.Do(func() { + store := &accountStore{path: filepath.Join(manager.directory, "accounts.json"), + state: accountState{Version: 1, AllowRegistration: true, Accounts: map[string]accountRecord{}, Sessions: map[string]accountSession{}, GuestImports: map[string]accountGuestImport{}}, + attempts: map[string]accountAttempt{}, hashing: make(chan struct{}, 2)} + store.err = store.load() + manager.accounts = store + }) + return manager.accounts +} + +func (store *accountStore) load() error { + file, err := os.Open(store.path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return errAccountStore + } + defer file.Close() + state := accountState{AllowRegistration: true} + decoder := json.NewDecoder(io.LimitReader(file, 16<<20)) + if decoder.Decode(&state) != nil || decoder.Decode(new(any)) != io.EOF || state.Version != 1 || state.Accounts == nil || len(state.Accounts) > accountLimit || len(state.Sessions) > accountLimit*accountSessionLimit { + return errAccountStore + } + identifiers := make(map[string]bool, len(state.Accounts)) + adminCount := 0 + for name, account := range state.Accounts { + key, _, nameErr := normalizeAccountUsername(account.Username) + if nameErr != nil || key != name || !validAccountHex(account.ID, 32) || identifiers[account.ID] || !validAccountHex(account.Salt, 16) || !validAccountHex(account.PasswordHash, 32) || account.Algorithm != "pbkdf2-sha256" || account.Iterations < accountPasswordIterations || account.Iterations > 2000000 || account.CreatedAt.IsZero() { + return errAccountStore + } + sources, sourceErr := normalizeAccountSources(account.Sources) + if sourceErr != nil { + return errAccountStore + } + account.Sources = sources + state.Accounts[name] = account + if account.Admin { + adminCount++ + } + identifiers[account.ID] = true + } + if adminCount > 1 { + return errAccountStore + } + for token, session := range state.Sessions { + if !validAccountHex(token, 32) || state.Accounts[session.Account].ID == "" || session.CreatedAt.IsZero() || !session.ExpiresAt.After(session.CreatedAt) { + return errAccountStore + } + } + for guest, imported := range state.GuestImports { + if !validAccountHex(guest, 32) || state.Accounts[imported.Account].ID == "" { + return errAccountStore + } + } + if state.Sessions == nil { + state.Sessions = map[string]accountSession{} + } + if state.GuestImports == nil { + state.GuestImports = map[string]accountGuestImport{} + } + store.state = state + return nil +} + +func (store *accountStore) snapshotLocked() accountState { + state := accountState{Version: 1, RequireLogin: store.state.RequireLogin, AllowRegistration: store.state.AllowRegistration, Accounts: make(map[string]accountRecord, len(store.state.Accounts)), Sessions: make(map[string]accountSession, len(store.state.Sessions)), GuestImports: make(map[string]accountGuestImport, len(store.state.GuestImports))} + for key, value := range store.state.Accounts { + if value.Sources != nil { + value.Sources = append([]string{}, value.Sources...) + } + state.Accounts[key] = value + } + for key, value := range store.state.Sessions { + state.Sessions[key] = value + } + for key, value := range store.state.GuestImports { + state.GuestImports[key] = value + } + return state +} + +func (store *accountStore) saveLocked(state accountState) error { + if store.err != nil { + return store.err + } + data, err := json.Marshal(state) + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(store.path), ".accounts-*.json") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if err = os.Rename(file.Name(), store.path); err != nil { + return err + } + store.state = state + return nil +} + +func accountTokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (manager *viewerManager) accountCookieName() string { return manager.cookie + "_account" } + +func (store *accountStore) resolve(token string) (accountRecord, string, bool) { + if !validAccountHex(token, 32) { + return accountRecord{}, "", false + } + store.mu.Lock() + defer store.mu.Unlock() + session, found := store.state.Sessions[accountTokenHash(token)] + if !found || !session.ExpiresAt.After(time.Now()) { + return accountRecord{}, "", false + } + account, found := store.state.Accounts[session.Account] + return account, session.Account, found && store.err == nil +} + +func (manager *viewerManager) requestAccount(request *http.Request) (accountRecord, string, bool, error) { + cookie, err := request.Cookie(manager.accountCookieName()) + if err != nil || cookie.Value == "" { + return accountRecord{}, "", false, nil + } + store := manager.accountStore() + if store.err != nil { + return accountRecord{}, "", true, store.err + } + account, name, valid := store.resolve(cookie.Value) + if !valid { + return accountRecord{}, "", true, nil + } + return account, name, true, nil +} + +func (store *accountStore) allowAttempt(name, remote string, now time.Time) bool { + ip, _, err := net.SplitHostPort(remote) + if err != nil { + ip = remote + } + store.mu.Lock() + defer store.mu.Unlock() + for key, attempt := range store.attempts { + if !attempt.until.After(now) { + delete(store.attempts, key) + } + } + keys := []string{"name:" + name, "ip:" + ip} + for index, key := range keys { + limit := 10 + if index == 1 { + limit = 60 + } + if store.attempts[key].count >= limit || len(store.attempts) >= 4096 && store.attempts[key].count == 0 { + return false + } + } + for _, key := range keys { + attempt := store.attempts[key] + if attempt.count == 0 { + attempt.until = now.Add(time.Minute) + } + attempt.count++ + store.attempts[key] = attempt + } + return true +} + +func (state *accountState) addSession(name string, now time.Time) (string, error) { + token := randomHex(32) + if token == "" { + return "", errors.New("无法创建登录会话,请重试") + } + var current []string + for key, session := range state.Sessions { + if !session.ExpiresAt.After(now) { + delete(state.Sessions, key) + continue + } + if session.Account == name { + current = append(current, key) + } + } + sort.Slice(current, func(i, j int) bool { + return state.Sessions[current[i]].CreatedAt.Before(state.Sessions[current[j]].CreatedAt) + }) + for len(current) >= accountSessionLimit { + delete(state.Sessions, current[0]) + current = current[1:] + } + state.Sessions[accountTokenHash(token)] = accountSession{Account: name, CreatedAt: now, ExpiresAt: now.Add(accountLifetime)} + return token, nil +} diff --git a/internal/app/cli.go b/internal/app/cli.go new file mode 100644 index 0000000..35c6aa2 --- /dev/null +++ b/internal/app/cli.go @@ -0,0 +1,296 @@ +package app + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const defaultUIListenAddress = "0.0.0.0:8998" + +type boolOverride struct { + set bool + val bool +} + +func (b *boolOverride) String() string { return strconv.FormatBool(b.val) } + +func (b *boolOverride) Set(s string) error { + v, err := strconv.ParseBool(s) + if err != nil { + return err + } + b.set = true + b.val = v + return nil +} + +func openBrowser(rawURL string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", rawURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + default: + cmd = exec.Command("xdg-open", rawURL) + } + if err := cmd.Start(); err != nil { + fmt.Printf(" 自动打开浏览器失败,请手动访问:%s (%v)\n", rawURL, err) + } +} + +func normalizeListen(addr string) string { + addr = strings.TrimSpace(addr) + if addr == "" { + return defaultUIListenAddress + } + if strings.HasPrefix(addr, ":") { + return "0.0.0.0" + addr + } + return addr +} + +func publicURL(addr string) string { + if strings.HasPrefix(addr, "127.0.0.1:") || strings.HasPrefix(addr, "localhost:") { + return "http://" + addr + } + if strings.HasPrefix(addr, "0.0.0.0:") || strings.HasPrefix(addr, "[::]:") { + if _, port, ok := strings.Cut(addr, ":"); ok { + return "http://127.0.0.1:" + port + } + } + return "http://" + addr +} + +func usage() { + fmt.Print(`短剧库 - 在线追剧与下载管理 + +请仅下载你拥有权利或已获授权的视频内容。 + +用法: + 直接执行 go run . 或编译后的程序,默认启动浏览器管理界面 + +常用参数: + -ui=true|false 启动/关闭本地浏览器管理界面,默认 true + -listen 地址 UI 监听地址,默认 0.0.0.0:8998,支持局域网访问 + -open=true|false UI 模式是否自动打开浏览器,默认 true + -admin-user 用户名 管理员账号,首次默认 admin + -admin-password 密码 指定管理员密码;首次未指定时随机生成并输出,首次登录须修改 + -mode all|search|id|list CLI 模式,需配合 -ui=false,默认 all + -keyword 关键词 search 模式关键词 + -id 剧ID id 模式下载单部剧 + -out 目录 自定义下载目录,默认 ./短剧下载,支持相对或绝对路径 + -c 并发数 下载并发,默认 2,可在界面设置中修改 + -pages 页数 每频道扫描页数;红果为 App 每批页数,默认 20 + -data-dir 目录 配置、缓存和任务目录,默认 ./data + -config 文件 首次运行时导入旧版配置(可选) + -ffmpeg 路径 ffmpeg 路径,默认从 PATH 查找 + -refresh CLI 模式强制更新剧库,默认读取本地缓存 + -more CLI 模式从保存的红果 App 分页位置继续加载 + -insecure-tls=true|false 是否跳过 TLS 证书校验,默认 false + +示例: + juku + juku -open=false + juku -ui=false -mode all + juku -ui=false -mode search -keyword 女总裁 + juku -ui=false -mode id -id 6a928xxxxx + juku -ui=false -mode list +`) +} + +func Run() { + mode := flag.String("mode", "all", "all/search/id/list") + keyword := flag.String("keyword", "", "search keyword") + id := flag.String("id", "", "drama id") + out := flag.String("out", "", "output directory") + concurrency := flag.Int("c", 0, "download concurrency") + pages := flag.Int("pages", 0, "max pages per sort") + configFile := flag.String("config", "", "import a legacy config on first startup") + dataDir := flag.String("data-dir", "data", "configuration, cache and task directory") + ffmpeg := flag.String("ffmpeg", "", "ffmpeg path") + adminUser := flag.String("admin-user", "", "administrator username, default admin on first startup") + adminPassword := flag.String("admin-password", "", "administrator password; default random on first startup") + uiMode := flag.Bool("ui", true, "start local web UI") + listen := flag.String("listen", defaultUIListenAddress, "UI listen address; use 127.0.0.1:8998 for local-only access") + open := flag.Bool("open", true, "open browser automatically in UI mode") + refresh := flag.Bool("refresh", false, "refresh the CLI library instead of using local cache") + more := flag.Bool("more", false, "continue loading the Hongguo App catalog from its saved cursor") + var insecureTLS boolOverride + flag.Var(&insecureTLS, "insecure-tls", "skip TLS certificate verification") + help := flag.Bool("help", false, "show help") + flag.Parse() + if *help { + usage() + return + } + if *refresh && *more { + fmt.Fprintln(os.Stderr, "-refresh 和 -more 不能同时使用") + os.Exit(2) + } + + if !flagWasSet("data-dir") && !flagWasSet("config") { + if err := preparePortableWorkingDirectory(); err != nil { + fmt.Fprintln(os.Stderr, "初始化程序目录失败:", err) + os.Exit(1) + } + } + cfg, configErr := loadApplicationConfig(*dataDir, *configFile, *out) + if configErr != nil { + fmt.Fprintln(os.Stderr, "初始化配置失败:", publicError(configErr)) + os.Exit(2) + } + cfg.adminUsername = firstNonEmpty(*adminUser, os.Getenv("JUKU_ADMIN_USER"), "admin") + cfg.adminUserExplicit = flagWasSet("admin-user") || os.Getenv("JUKU_ADMIN_USER") != "" + cfg.adminPassword = os.Getenv("JUKU_ADMIN_PASSWORD") + if flagWasSet("admin-password") { + cfg.adminPassword = *adminPassword + } + cfg.adminPasswordExplicit = flagWasSet("admin-password") || os.Getenv("JUKU_ADMIN_PASSWORD") != "" + if *concurrency > 0 { + cfg.Concurrency = *concurrency + } + if *pages > 0 { + cfg.MaxPagesPerSort = *pages + } + if *ffmpeg != "" { + cfg.FFmpeg = *ffmpeg + } + if insecureTLS.set { + cfg.InsecureTLS = insecureTLS.val + } + if err := cfg.validate(); err != nil { + fmt.Fprintf(os.Stderr, "配置错误: %v\n", err) + os.Exit(2) + } + d := NewDownloader(cfg) + cfg = d.cfg + if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "创建输出目录失败: %v\n", err) + os.Exit(1) + } + _, ffmpegErr := exec.LookPath(portableFFmpeg(cfg.FFmpeg)) + fmt.Printf("输出目录: %s\n下载并发: %d\nFFmpeg 已找到(后台校验): %t\nTLS证书校验: %t\n", cfg.OutputDir, cfg.Concurrency, ffmpegErr == nil, !cfg.InsecureTLS) + d.recordDiagnostic(diagnosticEvent{Level: "info", Event: "app.started", Message: "短剧库已启动"}) + fmt.Printf("诊断日志: %s\n", d.diagnostics.path) + if ffmpegErr != nil { + if automaticFFmpeg(cfg.FFmpeg) { + fmt.Println("未找到 FFmpeg,将自动下载匹配当前系统的便携版本;如下载失败,请在界面中调整代理后重试。") + } else { + fmt.Println("指定的 FFmpeg 路径未找到,请更正 download.ffmpeg 或 -ffmpeg。") + } + } + + if *uiMode { + addr := normalizeListen(*listen) + app := NewUIApp(d) + pageURL := publicURL(addr) + fmt.Printf("监听地址: %s | 管理界面已启动: %s\n", addr, pageURL) + if *open { + go func() { + time.Sleep(500 * time.Millisecond) + openBrowser(pageURL) + }() + } + if err := app.ListenAndServe(addr); err != nil && !errors.Is(err, http.ErrServerClosed) { + d.recordDiagnostic(diagnosticEvent{Event: "app.failed", Message: err.Error()}) + fmt.Fprintf(os.Stderr, "UI 服务停止: %v\n", err) + os.Exit(1) + } + return + } + + ctx := context.Background() + var dramas []Drama + var err error + switch strings.ToLower(*mode) { + case "id": + if strings.TrimSpace(*id) == "" { + fmt.Fprintln(os.Stderr, "-mode id 需要 -id") + os.Exit(2) + } + dramas = []Drama{{ID: strings.TrimSpace(*id), Title: "短剧"}} + case "all", "search", "list": + if *more { + dramas, err = d.RefreshDramas(context.WithValue(ctx, libraryMoreKey{}, true), sourceHongguo) + } else if *refresh { + dramas, err = d.RefreshDramas(ctx, "") + } else { + dramas, err = d.GetAllDramas(ctx) + } + if err != nil { + fmt.Fprintf(os.Stderr, " 剧库部分来源失败,继续处理已获取的 %d 部:%v\n", len(dramas), err) + if len(dramas) == 0 { + os.Exit(1) + } + } + if strings.ToLower(*mode) == "search" { + kw := strings.TrimSpace(*keyword) + if kw == "" { + fmt.Fprintln(os.Stderr, "-mode search 需要 -keyword") + os.Exit(2) + } + var filtered []Drama + for _, dr := range dramas { + text := dr.DisplayTitle() + " " + dr.Desc + " " + dr.Intro + " " + dr.ChannelName + if strings.Contains(strings.ToLower(text), strings.ToLower(kw)) { + filtered = append(filtered, dr) + } + } + dramas = filtered + fmt.Printf(" 搜索命中:%d 部\n", len(dramas)) + } + if strings.ToLower(*mode) == "list" { + for i, dr := range dramas { + fmt.Printf("%04d [%s] %s %s\n", i+1, dr.ChannelName, dr.ID, dr.DisplayTitle()) + } + return + } + default: + usage() + os.Exit(2) + } + if len(dramas) == 0 { + fmt.Println("没有待下载短剧。") + return + } + + var tasks []Task + for i, dr := range dramas { + fmt.Printf(" [%d/%d] 获取章节:《%s》 %s\n", i+1, len(dramas), dr.DisplayTitle(), dr.ID) + dramaTasks, err := d.BuildDramaTasks(ctx, dr) + if err != nil { + fmt.Printf(" 获取章节失败:《%s》 %v\n", dr.DisplayTitle(), err) + continue + } + tasks = append(tasks, dramaTasks...) + } + fmt.Printf(" 待处理视频:%d 集\n", len(tasks)) + results := DownloadTasks(ctx, d, tasks) + var ok, fail int + for _, r := range results { + if r.OK { + ok++ + } else { + fail++ + } + } + if err := saveFailures(cfg.dataDirectory(), results); err != nil { + fmt.Printf(" 写入 failed.json 失败: %v\n", err) + } + fmt.Printf("\n 完成:成功/跳过 %d,失败 %d\n", ok, fail) + if fail > 0 { + fmt.Printf("失败详情已写入:%s\n", filepath.Join(cfg.dataDirectory(), "failed.json")) + os.Exit(1) + } +} diff --git a/internal/app/config_defaults.go b/internal/app/config_defaults.go new file mode 100644 index 0000000..2f8a8b5 --- /dev/null +++ b/internal/app/config_defaults.go @@ -0,0 +1,158 @@ +package app + +import ( + "crypto/aes" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" +) + +const ( + defaultAPIBase = "https://dr6skssi3nxbk.cloudfront.net" + defaultCDNURL = "https://sjljsla.lkkwip.cn" + userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1" + xUserAgent = "BuildID=com.abc.Butterfly;SysType=ios;DevID=00000000000000000000000000000;Ver=1.0.0;DevType=iPhone;DeviceBrand=APPLE;DeviceModel=iPhone;SystemName=iOS;SystemVersion=18.7;Terminal=1;IsH5=1;Sid=00000000000000000000000000000000" +) + +type Config struct { + adminUsername string + adminPassword string + adminUserExplicit bool + adminPasswordExplicit bool + settingsLoaded bool + dataDir string + outputDirSetting string + APIBase string `json:"apiBase"` + CDNURL string `json:"cdnURL"` + Token string `json:"token"` + AESKeyHex string `json:"aesKeyHex"` + InterfaceKey string `json:"interfaceKey"` + ParamKey string `json:"paramKey"` + ParamIV string `json:"paramIV"` + OutputDir string `json:"outputDir"` + FFmpeg string `json:"ffmpeg"` + Concurrency int `json:"concurrency"` + RequestConcurrency int `json:"requestConcurrency"` + RequestIntervalMS int `json:"requestIntervalMs"` + MaxPagesPerSort int `json:"maxPagesPerSort"` + PageSize int `json:"pageSize"` + Retries int `json:"retries"` + SkipBytes int64 `json:"skipBytes"` + InsecureTLS bool `json:"insecureTLS"` + ProxyURL string `json:"proxyURL,omitempty"` + HuangguoAIURL string `json:"huangguoAIURL,omitempty"` + HuangguoVideoURL string `json:"huangguoVideoURL,omitempty"` + HuangdouURL string `json:"huangdouURL,omitempty"` + HongguoURL string `json:"hongguoURL,omitempty"` +} + +func defaultConfig() Config { + return Config{ + APIBase: defaultAPIBase, + CDNURL: defaultCDNURL, + OutputDir: defaultOutputDir(), + FFmpeg: "ffmpeg", + Concurrency: 2, + RequestConcurrency: 2, + RequestIntervalMS: 500, + MaxPagesPerSort: 20, + PageSize: 50, + Retries: 3, + SkipBytes: 512 * 1024, + InsecureTLS: false, + } +} + +func defaultOutputDir() string { + return "短剧下载" +} + +func loadConfig(path string) Config { + cfg := defaultConfig() + if path != "" { + b, err := os.ReadFile(path) + if err != nil { + fmt.Printf(" 读取配置失败,使用默认配置: %v\n", err) + } else if err := json.Unmarshal(b, &cfg); err != nil { + fmt.Printf(" 解析配置失败,使用默认配置: %v\n", err) + cfg = defaultConfig() + } + } + applyConfigEnvironment(&cfg) + if cfg.APIBase == "" { + cfg.APIBase = defaultAPIBase + } + if cfg.CDNURL == "" { + cfg.CDNURL = defaultCDNURL + } + if cfg.OutputDir == "" { + cfg.OutputDir = defaultOutputDir() + } + if cfg.FFmpeg == "" { + cfg.FFmpeg = "ffmpeg" + } + if cfg.Concurrency <= 0 { + cfg.Concurrency = 2 + } + if cfg.RequestConcurrency <= 0 { + cfg.RequestConcurrency = 2 + } + if cfg.RequestIntervalMS <= 0 { + cfg.RequestIntervalMS = 500 + } + if cfg.MaxPagesPerSort <= 0 { + cfg.MaxPagesPerSort = 20 + } + if cfg.PageSize <= 0 || cfg.PageSize > 100 { + cfg.PageSize = 50 + } + if cfg.Retries <= 0 { + cfg.Retries = 3 + } + if cfg.SkipBytes <= 0 { + cfg.SkipBytes = 512 * 1024 + } + return cfg +} + +func applyConfigEnvironment(cfg *Config) { + setIf := func(dst *string, key string) { + if v := os.Getenv(key); v != "" { + *dst = v + } + } + setIf(&cfg.APIBase, "JUKU_API_BASE") + setIf(&cfg.CDNURL, "JUKU_CDN_URL") + setIf(&cfg.Token, "JUKU_TOKEN") + setIf(&cfg.AESKeyHex, "JUKU_AES_KEY_HEX") + setIf(&cfg.InterfaceKey, "JUKU_INTERFACE_KEY") + setIf(&cfg.ParamKey, "JUKU_PARAM_KEY") + setIf(&cfg.ParamIV, "JUKU_PARAM_IV") + setIf(&cfg.HuangguoAIURL, "JUKU_HUANGGUO_AI_URL") + setIf(&cfg.HuangguoVideoURL, "JUKU_HUANGGUO_VIDEO_URL") + setIf(&cfg.HuangdouURL, "JUKU_HUANGDOU_URL") + setIf(&cfg.HongguoURL, "JUKU_HONGGUO_URL") + setIf(&cfg.ProxyURL, "JUKU_PROXY_URL") + setIf(&cfg.OutputDir, "JUKU_OUTPUT_DIR") + setIf(&cfg.FFmpeg, "JUKU_FFMPEG") +} + +func (c Config) validate() error { + if _, err := configuredProxy(c.ProxyURL); err != nil { + return err + } + if c.ParamKey != "" && len(c.ParamKey) != aes.BlockSize || c.ParamIV != "" && len(c.ParamIV) != aes.BlockSize { + return fmt.Errorf("paramKey/paramIV 必须是 %d 字节", aes.BlockSize) + } + if key, err := hex.DecodeString(c.AESKeyHex); c.AESKeyHex != "" && (err != nil || len(key) != aes.BlockSize) { + return errors.New("aesKeyHex 必须是 16 字节 AES 密钥的 hex 编码") + } + for _, endpoint := range []string{c.APIBase, c.HuangguoAIURL, c.HuangguoVideoURL, c.HuangdouURL, c.HongguoURL} { + if endpoint != "" && !isProviderHTTPMediaURL(endpoint) { + return errors.New("站点地址必须是有效的 HTTP/HTTPS URL") + } + } + return nil +} diff --git a/internal/app/config_runtime.go b/internal/app/config_runtime.go new file mode 100644 index 0000000..6e2d091 --- /dev/null +++ b/internal/app/config_runtime.go @@ -0,0 +1,172 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +type runtimeSettings struct { + Concurrency int `json:"concurrency"` + RequestConcurrency int `json:"requestConcurrency"` + RequestIntervalMS int `json:"requestIntervalMs"` + ProxyURL *string `json:"proxyURL,omitempty"` + OutputDir *string `json:"outputDir,omitempty"` +} + +func (settings runtimeSettings) validate() error { + if settings.OutputDir != nil { + if _, err := normalizedOutputDirectory(*settings.OutputDir); err != nil { + return err + } + } + if settings.ProxyURL != nil { + if _, err := configuredProxy(*settings.ProxyURL); err != nil { + return err + } + } + if settings.Concurrency < 1 || settings.Concurrency > 32 { + return errors.New("下载并发范围为 1–32") + } + if settings.RequestConcurrency < 1 || settings.RequestConcurrency > 16 { + return errors.New("剧库请求并发范围为 1–16") + } + if settings.RequestIntervalMS < 100 || settings.RequestIntervalMS > 60000 { + return errors.New("同站请求间隔范围为 100–60000 毫秒") + } + return nil +} + +func runtimeSettingsPath(directory string) string { + return filepath.Join(directory, "juku.json") +} + +func loadRuntimeSettings(cfg *Config) { + if cfg.settingsLoaded { + return + } + cfg.settingsLoaded = true + document, err := readConfigDocument(runtimeSettingsPath(cfg.runtimeSettingsDirectory())) + if err == nil { + loaded, configErr := document.config() + if configErr == nil { + loaded.dataDir = cfg.dataDirectory() + loaded.settingsLoaded = true + *cfg = loaded + } + return + } + if !errors.Is(err, os.ErrNotExist) { + fmt.Printf("配置读取失败,保留当前设置: %v\n", err) + return + } + body, err := os.ReadFile(filepath.Join(cfg.OutputDir, "settings.json")) + if errors.Is(err, os.ErrNotExist) { + return + } + var settings runtimeSettings + if err == nil { + err = json.Unmarshal(body, &settings) + } + if err == nil { + err = settings.validate() + } + if err != nil { + fmt.Printf(" 界面并发设置读取失败,使用配置文件/默认值: %v\n", err) + return + } + applyRuntimeSettings(cfg, settings) +} + +func applyRuntimeSettings(cfg *Config, settings runtimeSettings) { + cfg.Concurrency = settings.Concurrency + cfg.RequestConcurrency = settings.RequestConcurrency + cfg.RequestIntervalMS = settings.RequestIntervalMS + if settings.ProxyURL != nil { + cfg.ProxyURL = *settings.ProxyURL + } + if settings.OutputDir != nil { + cfg.OutputDir, _ = normalizedOutputDirectory(*settings.OutputDir) + } +} + +func (a *UIApp) updateRuntimeSettings(writer http.ResponseWriter, request *http.Request) bool { + var settings runtimeSettings + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 4096)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&settings); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "无效的并发设置"}) + return false + } + if err := settings.validate(); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return false + } + a.settingsMu.Lock() + defer a.settingsMu.Unlock() + a.mu.Lock() + cfg := a.cfg + if settings.ProxyURL == nil { + value := a.cfg.ProxyURL + settings.ProxyURL = &value + } + if settings.OutputDir == nil { + value := firstNonEmpty(a.nextOutputDir, cfg.outputDirSetting, cfg.OutputDir) + settings.OutputDir = &value + } + a.mu.Unlock() + directory, err := normalizedOutputDirectory(*settings.OutputDir) + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return false + } + settings.OutputDir = &directory + if !sameDirectory(directory, cfg.OutputDir) { + if err := checkOutputDirectory(directory); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": a.redactError(err)}) + return false + } + } + if err := saveRuntimeSettings(cfg.runtimeSettingsDirectory(), settings); err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "设置保存失败: " + a.redactError(err)}) + return false + } + a.downloader.proxyRouter.configure(*settings.ProxyURL) + a.downloader.client.CloseIdleConnections() + a.mu.Lock() + a.cfg.Concurrency = settings.Concurrency + a.cfg.RequestConcurrency = settings.RequestConcurrency + a.cfg.RequestIntervalMS = settings.RequestIntervalMS + a.cfg.ProxyURL = *settings.ProxyURL + a.nextOutputDir = directory + a.downloader.limiter.configure(settings.RequestConcurrency, time.Duration(settings.RequestIntervalMS)*time.Millisecond) + a.ensureWorkersLocked() + a.cond.Broadcast() + a.mu.Unlock() + return true +} + +func saveRuntimeSettings(directory string, settings runtimeSettings) error { + if err := settings.validate(); err != nil { + return err + } + path := runtimeSettingsPath(directory) + document, err := readConfigDocument(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + document.Download.Concurrency = settings.Concurrency + document.Network.RequestConcurrency = settings.RequestConcurrency + document.Network.RequestIntervalMS = settings.RequestIntervalMS + if settings.OutputDir != nil { + document.Download.Directory = *settings.OutputDir + } + if settings.ProxyURL != nil { + document.Network.Proxy = *settings.ProxyURL + } + return writeConfigDocument(path, document) +} diff --git a/internal/app/config_store.go b/internal/app/config_store.go new file mode 100644 index 0000000..fda608e --- /dev/null +++ b/internal/app/config_store.go @@ -0,0 +1,360 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +type configDocument struct { + Version int `json:"version"` + Download downloadSettings `json:"download"` + Network networkSettings `json:"network"` + Sources *sourceSettings `json:"sources,omitempty"` + Advanced *advancedSettings `json:"advanced,omitempty"` +} + +type downloadSettings struct { + Directory string `json:"directory"` + Concurrency int `json:"concurrency"` + FFmpeg string `json:"ffmpeg,omitempty"` +} + +type networkSettings struct { + Proxy string `json:"proxy"` + RequestConcurrency int `json:"requestConcurrency"` + RequestIntervalMS int `json:"requestIntervalMs"` +} + +type sourceSettings struct { + Huangguo *huangguoSettings `json:"huangguo,omitempty"` + Huangdou *siteSettings `json:"huangdou,omitempty"` + Hongguo *siteSettings `json:"hongguo,omitempty"` +} + +type siteSettings struct { + BaseURL string `json:"baseURL,omitempty"` +} + +type huangguoSettings struct { + AIURL string `json:"aiURL,omitempty"` + VideoURL string `json:"videoURL,omitempty"` + LegacyAPI *legacyAPISettings `json:"legacyAPI,omitempty"` +} + +type legacyAPISettings struct { + APIBase string `json:"apiBase,omitempty"` + CDNURL string `json:"cdnURL,omitempty"` + Token string `json:"token,omitempty"` + AESKeyHex string `json:"aesKeyHex,omitempty"` + InterfaceKey string `json:"interfaceKey,omitempty"` + ParamKey string `json:"paramKey,omitempty"` + ParamIV string `json:"paramIV,omitempty"` +} + +type advancedSettings struct { + MaxPagesPerSort int `json:"maxPagesPerSort,omitempty"` + PageSize int `json:"pageSize,omitempty"` + Retries int `json:"retries,omitempty"` + SkipBytes int64 `json:"skipBytes,omitempty"` + InsecureTLS bool `json:"insecureTLS,omitempty"` +} + +func documentFromConfig(cfg Config) configDocument { + defaults := defaultConfig() + document := configDocument{ + Version: 1, + Download: downloadSettings{Directory: firstNonEmpty(cfg.outputDirSetting, cfg.OutputDir), Concurrency: cfg.Concurrency}, + Network: networkSettings{Proxy: firstNonEmpty(cfg.ProxyURL, "auto"), RequestConcurrency: cfg.RequestConcurrency, RequestIntervalMS: cfg.RequestIntervalMS}, + } + if cfg.FFmpeg != "" && cfg.FFmpeg != defaults.FFmpeg { + document.Download.FFmpeg = cfg.FFmpeg + } + sources := sourceSettings{} + if cfg.HuangdouURL != "" { + sources.Huangdou = &siteSettings{BaseURL: cfg.HuangdouURL} + } + if cfg.HongguoURL != "" { + sources.Hongguo = &siteSettings{BaseURL: cfg.HongguoURL} + } + huangguo := huangguoSettings{AIURL: cfg.HuangguoAIURL, VideoURL: cfg.HuangguoVideoURL} + legacy := legacyAPISettings{Token: cfg.Token, AESKeyHex: cfg.AESKeyHex, InterfaceKey: cfg.InterfaceKey, ParamKey: cfg.ParamKey, ParamIV: cfg.ParamIV} + if cfg.APIBase != defaults.APIBase { + legacy.APIBase = cfg.APIBase + } + if cfg.CDNURL != defaults.CDNURL { + legacy.CDNURL = cfg.CDNURL + } + if legacy != (legacyAPISettings{}) { + huangguo.LegacyAPI = &legacy + } + if huangguo.AIURL != "" || huangguo.VideoURL != "" || huangguo.LegacyAPI != nil { + sources.Huangguo = &huangguo + } + if sources.Huangguo != nil || sources.Huangdou != nil || sources.Hongguo != nil { + document.Sources = &sources + } + advanced := advancedSettings{InsecureTLS: cfg.InsecureTLS} + if cfg.MaxPagesPerSort != defaults.MaxPagesPerSort { + advanced.MaxPagesPerSort = cfg.MaxPagesPerSort + } + if cfg.PageSize != defaults.PageSize { + advanced.PageSize = cfg.PageSize + } + if cfg.Retries != defaults.Retries { + advanced.Retries = cfg.Retries + } + if cfg.SkipBytes != defaults.SkipBytes { + advanced.SkipBytes = cfg.SkipBytes + } + if advanced != (advancedSettings{}) { + document.Advanced = &advanced + } + return document +} + +func (document configDocument) config() (Config, error) { + if document.Version != 1 { + return Config{}, fmt.Errorf("不支持的配置版本 %d,请使用兼容的程序版本", document.Version) + } + cfg := defaultConfig() + cfg.OutputDir = document.Download.Directory + cfg.Concurrency = document.Download.Concurrency + cfg.FFmpeg = firstNonEmpty(document.Download.FFmpeg, cfg.FFmpeg) + cfg.ProxyURL = document.Network.Proxy + cfg.RequestConcurrency = document.Network.RequestConcurrency + cfg.RequestIntervalMS = document.Network.RequestIntervalMS + if sources := document.Sources; sources != nil { + if sources.Huangdou != nil { + cfg.HuangdouURL = sources.Huangdou.BaseURL + } + if sources.Hongguo != nil { + cfg.HongguoURL = sources.Hongguo.BaseURL + } + if source := sources.Huangguo; source != nil { + cfg.HuangguoAIURL, cfg.HuangguoVideoURL = source.AIURL, source.VideoURL + if legacy := source.LegacyAPI; legacy != nil { + cfg.APIBase = firstNonEmpty(legacy.APIBase, cfg.APIBase) + cfg.CDNURL = firstNonEmpty(legacy.CDNURL, cfg.CDNURL) + cfg.Token, cfg.AESKeyHex = legacy.Token, legacy.AESKeyHex + cfg.InterfaceKey, cfg.ParamKey, cfg.ParamIV = legacy.InterfaceKey, legacy.ParamKey, legacy.ParamIV + } + } + } + if advanced := document.Advanced; advanced != nil { + if advanced.MaxPagesPerSort > 0 { + cfg.MaxPagesPerSort = advanced.MaxPagesPerSort + } + if advanced.PageSize > 0 { + cfg.PageSize = advanced.PageSize + } + if advanced.Retries > 0 { + cfg.Retries = advanced.Retries + } + if advanced.SkipBytes > 0 { + cfg.SkipBytes = advanced.SkipBytes + } + cfg.InsecureTLS = advanced.InsecureTLS + } + settings := runtimeSettings{Concurrency: cfg.Concurrency, RequestConcurrency: cfg.RequestConcurrency, RequestIntervalMS: cfg.RequestIntervalMS, ProxyURL: &cfg.ProxyURL, OutputDir: &cfg.OutputDir} + if err := settings.validate(); err != nil { + return Config{}, err + } + if err := cfg.validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func readConfigDocument(path string) (configDocument, error) { + document := documentFromConfig(defaultConfig()) + file, err := os.Open(path) + if err != nil { + return document, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return document, err + } + if info.Size() > 1<<20 { + return document, errors.New("配置文件超过 1 MB") + } + decoder := json.NewDecoder(io.LimitReader(file, (1<<20)+1)) + content := &document + if err := decoder.Decode(&content); err != nil { + return document, fmt.Errorf("配置文件格式无效: %w", err) + } + if content == nil { + return document, errors.New("配置文件必须是 JSON 对象") + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return document, errors.New("配置文件含有多余内容") + } + if _, err := document.config(); err != nil { + return document, err + } + return document, nil +} + +func writeConfigDocument(path string, document configDocument) error { + if _, err := document.config(); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp") + if err != nil { + return err + } + defer os.Remove(file.Name()) + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + err = encoder.Encode(document) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return os.Rename(file.Name(), path) +} + +func loadApplicationConfig(dataDirectory, importPath, outputOverride string) (Config, error) { + dataDirectory, err := filepath.Abs(dataDirectory) + if err != nil { + return Config{}, err + } + path := runtimeSettingsPath(dataDirectory) + document, err := readConfigDocument(path) + if errors.Is(err, os.ErrNotExist) { + cfg, legacyDirectories, importErr := importLegacyConfig(firstNonEmpty(importPath, "config.json"), outputOverride) + if importErr != nil { + return Config{}, importErr + } + document = documentFromConfig(cfg) + if _, err := document.config(); err != nil { + return Config{}, err + } + if err := migrateLegacyData(dataDirectory, legacyDirectories); err != nil { + return Config{}, err + } + if err := writeConfigDocument(path, document); err != nil { + return Config{}, fmt.Errorf("首次生成配置失败: %w", err) + } + } else if err != nil { + return Config{}, fmt.Errorf("无法读取 %s,原文件未修改: %w", path, err) + } + cfg, err := document.config() + if err != nil { + return Config{}, err + } + cfg.settingsLoaded = true + cfg.dataDir = dataDirectory + applyConfigEnvironment(&cfg) + if outputOverride != "" { + cfg.OutputDir = outputOverride + } + return cfg, nil +} + +func importLegacyConfig(path, outputOverride string) (Config, []string, error) { + cfg := defaultConfig() + body, err := os.ReadFile(path) + if err == nil { + if err := json.Unmarshal(body, &cfg); err != nil { + return cfg, nil, fmt.Errorf("旧配置格式错误,未覆盖原文件: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return cfg, nil, err + } + if cfg.OutputDir == "" { + cfg.OutputDir = defaultOutputDir() + } + if cfg.RequestConcurrency <= 0 { + cfg.RequestConcurrency = 2 + } + if cfg.RequestIntervalMS <= 0 { + cfg.RequestIntervalMS = 500 + } + directories := []string{firstNonEmpty(outputOverride, cfg.OutputDir), cfg.OutputDir} + candidates := []string{filepath.Join(filepath.Dir(path), "juku-settings.json")} + for _, directory := range directories { + candidates = append(candidates, filepath.Join(directory, "settings.json")) + } + for _, candidate := range candidates { + body, err = os.ReadFile(candidate) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return cfg, nil, err + } + var settings runtimeSettings + if err := json.Unmarshal(body, &settings); err != nil { + return cfg, nil, fmt.Errorf("旧设置格式错误: %w", err) + } + if err := settings.validate(); err != nil { + return cfg, nil, err + } + applyRuntimeSettings(&cfg, settings) + break + } + return cfg, append(directories, cfg.OutputDir, filepath.Dir(path)), nil +} + +func migrateLegacyData(directory string, candidates []string) error { + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + for _, name := range []string{"library.json", "ui-state.json", "failed.json"} { + target := filepath.Join(directory, name) + if _, err := os.Stat(target); err == nil { + continue + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + for _, candidate := range candidates { + if sameDirectory(directory, candidate) { + continue + } + original := filepath.Join(candidate, name) + body, err := os.ReadFile(original) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if !json.Valid(body) { + return fmt.Errorf("旧数据 %s 格式无效,原文件未修改", original) + } + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + break + } + if err != nil { + return err + } + _, err = file.Write(body) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + _ = os.Remove(target) + return errors.Join(err, closeErr) + } + break + } + } + return nil +} diff --git a/internal/app/desktop_picker.go b/internal/app/desktop_picker.go new file mode 100644 index 0000000..f5b565b --- /dev/null +++ b/internal/app/desktop_picker.go @@ -0,0 +1,226 @@ +package app + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "mime" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + "unicode/utf16" +) + +const macDirectoryPickerScript = `ObjC.import('AppKit'); +function run(arguments) { + var application = $.NSApplication.sharedApplication; + application.setActivationPolicy($.NSApplicationActivationPolicyAccessory); + var panel = $.NSOpenPanel.openPanel; + panel.setTitle('选择下载文件夹'); + panel.setPrompt('选择'); + panel.setCanChooseFiles(false); + panel.setCanChooseDirectories(true); + panel.setAllowsMultipleSelection(false); + panel.setCanCreateDirectories(true); + panel.setDirectoryURL($.NSURL.fileURLWithPath(arguments[0])); + application.activateIgnoringOtherApps(true); + return panel.runModal === $.NSModalResponseOK ? ObjC.unwrap(panel.URL.path) : ''; +}` + +const windowsDirectoryPickerScript = `$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.Application]::EnableVisualStyles() +$dialog = New-Object System.Windows.Forms.FolderBrowserDialog +$owner = New-Object System.Windows.Forms.Form +try { + $dialog.Description = '选择下载文件夹' + $dialog.ShowNewFolderButton = $true + $dialog.SelectedPath = $env:JUKU_PICKER_START_DIRECTORY + $owner.ShowInTaskbar = $false + $owner.TopMost = $true + $owner.Opacity = 0 + $owner.Show() + $owner.Activate() + if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { + [Console]::Write($dialog.SelectedPath) + } +} finally { + $dialog.Dispose() + $owner.Dispose() +}` + +var configureDirectoryPickerProcess = func(command *exec.Cmd) {} + +func directoryPickerCommand(ctx context.Context, system, initial string) (*exec.Cmd, bool, error) { + var command *exec.Cmd + cancelExit := false + switch system { + case "darwin": + command = exec.CommandContext(ctx, "osascript", "-l", "JavaScript", "-e", macDirectoryPickerScript, initial) + case "windows": + encoded := utf16.Encode([]rune(windowsDirectoryPickerScript)) + body := make([]byte, len(encoded)*2) + for index, value := range encoded { + body[index*2], body[index*2+1] = byte(value), byte(value>>8) + } + command = exec.CommandContext(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-STA", "-EncodedCommand", base64.StdEncoding.EncodeToString(body)) + command.Env = append(os.Environ(), "JUKU_PICKER_START_DIRECTORY="+initial) + case "linux": + if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { + return nil, false, errors.New("当前没有桌面会话,请手动输入下载路径") + } + if executable, err := exec.LookPath("zenity"); err == nil { + command = exec.CommandContext(ctx, executable, "--file-selection", "--directory", "--title=选择下载文件夹", "--filename="+initial+string(os.PathSeparator)) + } else if executable, err := exec.LookPath("kdialog"); err == nil { + command = exec.CommandContext(ctx, executable, "--getexistingdirectory", initial, "--title", "选择下载文件夹") + } else { + return nil, false, errors.New("当前桌面没有 zenity 或 kdialog,请安装其一或手动输入下载路径") + } + cancelExit = true + default: + return nil, false, errors.New("当前系统暂不支持原生文件夹选择,请手动输入下载路径") + } + configureDirectoryPickerProcess(command) + return command, cancelExit, nil +} + +func nativeDirectoryPicker(ctx context.Context, initial string) (string, error) { + command, cancelExit, err := directoryPickerCommand(ctx, runtime.GOOS, initial) + if err != nil { + return "", err + } + output, err := command.Output() + if ctx.Err() != nil { + return "", ctx.Err() + } + if err != nil { + var exitError *exec.ExitError + if cancelExit && errors.As(err, &exitError) && exitError.ExitCode() == 1 && strings.TrimSpace(string(exitError.Stderr)) == "" { + return "", nil + } + return "", fmt.Errorf("系统文件夹选择框启动失败,请确认桌面会话可用,或手动输入路径: %w", err) + } + return strings.TrimRight(strings.TrimPrefix(string(output), "\ufeff"), "\r\n"), nil +} + +func pickerInitialDirectory(initial, fallback string) string { + for _, candidate := range []string{initial, fallback, "."} { + if candidate == "" { + continue + } + absolute, err := filepath.Abs(candidate) + if err != nil { + continue + } + for { + if info, err := os.Stat(absolute); err == nil && info.IsDir() { + return absolute + } + parent := filepath.Dir(absolute) + if parent == absolute { + break + } + absolute = parent + } + } + return "" +} + +func directorySelectionPath(absolute string) string { + if working, err := os.Getwd(); err == nil { + if relative, err := filepath.Rel(working, absolute); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) { + return relative + } + } + return absolute +} + +func localDirectoryPickerRequest(request *http.Request) bool { + remote, _, err := net.SplitHostPort(request.RemoteAddr) + if err != nil || !net.ParseIP(remote).IsLoopback() { + return false + } + host := (&url.URL{Host: request.Host}).Hostname() + if !strings.EqualFold(host, "localhost") && !net.ParseIP(host).IsLoopback() { + return false + } + if site := request.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" { + return false + } + if origin := request.Header.Get("Origin"); origin != "" { + parsed, err := url.Parse(origin) + if err != nil || parsed.User != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, request.Host) { + return false + } + } + return true +} + +func (a *UIApp) handleDirectoryPicker(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + if request.Method != http.MethodPost { + writer.Header().Set("Allow", http.MethodPost) + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if !localDirectoryPickerRequest(request) { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请在运行程序的电脑上使用 localhost 或 127.0.0.1 打开界面;远程访问请手动输入服务器路径"}) + return + } + contentType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type")) + if contentType != "application/json" { + writeJSON(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "请求必须使用 JSON"}) + return + } + var selection struct { + InitialPath string `json:"initialPath"` + } + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 16384)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&selection); err != nil || len(selection.InitialPath) > 8192 || strings.ContainsAny(selection.InitialPath, "\x00\r\n") { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "无效的初始目录"}) + return + } + if !a.directoryPickerMu.TryLock() { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "系统文件夹选择框已打开,请先完成或取消选择"}) + return + } + defer a.directoryPickerMu.Unlock() + a.mu.Lock() + output, picker := a.cfg.OutputDir, a.directoryPicker + a.mu.Unlock() + if picker == nil { + picker = nativeDirectoryPicker + } + ctx, cancel := context.WithTimeout(request.Context(), 5*time.Minute) + defer cancel() + path, err := picker(ctx, pickerInitialDirectory(selection.InitialPath, output)) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + writeJSON(writer, http.StatusGatewayTimeout, map[string]string{"error": "文件夹选择超时,请重试或手动输入路径"}) + } else { + writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": a.redactError(err)}) + } + return + } + if path == "" { + writeJSON(writer, http.StatusOK, map[string]bool{"canceled": true}) + return + } + info, err := os.Stat(path) + if err != nil || !filepath.IsAbs(path) || strings.ContainsAny(path, "\x00\r\n") || !info.IsDir() { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "系统未返回有效文件夹,请重新选择"}) + return + } + path = filepath.Clean(path) + writeJSON(writer, http.StatusOK, map[string]any{"path": path, "selectionPath": directorySelectionPath(path), "canceled": false}) +} diff --git a/internal/app/desktop_picker_windows.go b/internal/app/desktop_picker_windows.go new file mode 100644 index 0000000..4ad8e5b --- /dev/null +++ b/internal/app/desktop_picker_windows.go @@ -0,0 +1,12 @@ +package app + +import ( + "os/exec" + "syscall" +) + +func init() { + configureDirectoryPickerProcess = func(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} + } +} diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go new file mode 100644 index 0000000..98d868d --- /dev/null +++ b/internal/app/diagnostics.go @@ -0,0 +1,134 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +const diagnosticLogBytes = 5 * 1024 * 1024 +const diagnosticLogBackups = 2 + +type diagnosticEvent struct { + HTTPStatus int `json:"httpStatus,omitempty"` + Client string `json:"client,omitempty"` + Protocol string `json:"protocol,omitempty"` + CFRay string `json:"cfRay,omitempty"` + ResponseType string `json:"responseType,omitempty"` + RequestedQuality int `json:"requestedQuality,omitempty"` + Time time.Time `json:"time"` + Level string `json:"level"` + Event string `json:"event"` + Source string `json:"source,omitempty"` + DramaID string `json:"dramaId,omitempty"` + DramaTitle string `json:"dramaTitle,omitempty"` + Episode int `json:"episode,omitempty"` + Run uint64 `json:"run,omitempty"` + StartSeconds float64 `json:"startSeconds,omitempty"` + Host string `json:"host,omitempty"` + OffsetBytes int64 `json:"offsetBytes,omitempty"` + TotalBytes int64 `json:"totalBytes,omitempty"` + Attempt int `json:"attempt,omitempty"` + Quality int `json:"quality,omitempty"` + AvailableQualities []int `json:"availableQualities,omitempty"` + Message string `json:"message"` +} + +type diagnosticLog struct { + mu sync.Mutex + path string + limit int64 + warningOnce sync.Once +} + +func newDiagnosticLog(directory string) *diagnosticLog { + return &diagnosticLog{path: filepath.Join(directory, "logs", "app.log"), limit: diagnosticLogBytes} +} + +func (log *diagnosticLog) write(event diagnosticEvent) error { + event.Time = time.Now() + if event.Level == "" { + event.Level = "error" + } + data, err := json.Marshal(event) + if err != nil { + return err + } + data = append(data, '\n') + log.mu.Lock() + defer log.mu.Unlock() + if int64(len(data)) > log.limit { + return errors.New("单条诊断日志过大") + } + if err := os.MkdirAll(filepath.Dir(log.path), 0700); err != nil { + return err + } + info, err := os.Stat(log.path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if info != nil && info.Size()+int64(len(data)) > log.limit { + if err := log.rotate(); err != nil { + return err + } + } + file, err := os.OpenFile(log.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return err + } + _, writeErr := file.Write(data) + return errors.Join(writeErr, file.Close()) +} + +func (log *diagnosticLog) rotate() error { + oldest := log.path + "." + strconv.Itoa(diagnosticLogBackups) + if err := os.Remove(oldest); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + for index := diagnosticLogBackups; index > 0; index-- { + previous := log.path + if index > 1 { + previous += "." + strconv.Itoa(index-1) + } + if err := os.Rename(previous, log.path+"."+strconv.Itoa(index)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} + +func redactConfiguredString(cfg *Config, value string) string { + for _, secret := range []string{cfg.Token, cfg.AESKeyHex, cfg.InterfaceKey, cfg.ParamKey, cfg.ParamIV} { + if secret = strings.TrimSpace(secret); secret != "" { + value = strings.ReplaceAll(value, secret, "[redacted]") + } + } + return redactErrorString(value) +} + +func (downloader *Downloader) recordDiagnostic(event diagnosticEvent) { + if downloader == nil || downloader.diagnostics == nil { + return + } + event.Message = truncate(redactConfiguredString(&downloader.cfg, event.Message), 4096) + event.DramaTitle = truncate(redactConfiguredString(&downloader.cfg, event.DramaTitle), 256) + if err := downloader.diagnostics.write(event); err != nil { + downloader.diagnostics.warningOnce.Do(func() { + fmt.Fprintf(os.Stderr, "诊断日志保存失败:%v\n", publicError(err)) + }) + } +} + +func (downloader *Downloader) recordTaskFailure(event string, task Task, start float64, err error) { + if err == nil { + return + } + downloader.recordDiagnostic(diagnosticEvent{Event: event, Source: firstNonEmpty(task.Chapter.Source, sourceFromDramaID(task.DramaID)), + DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Index, StartSeconds: start, Message: err.Error()}) +} diff --git a/internal/app/diagnostics_test.go b/internal/app/diagnostics_test.go new file mode 100644 index 0000000..522d72f --- /dev/null +++ b/internal/app/diagnostics_test.go @@ -0,0 +1,137 @@ +package app + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +func readDiagnosticEvents(t *testing.T, path string) []diagnosticEvent { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var events []diagnosticEvent + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var event diagnosticEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil || event.Time.IsZero() || event.Event == "" { + t.Fatal("invalid diagnostic record", err) + } + events = append(events, event) + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + return events +} + +func TestDiagnosticLogConcurrentWritesAndRedaction(t *testing.T) { + log := newDiagnosticLog(t.TempDir()) + d := &Downloader{cfg: Config{Token: "fixture-token", AESKeyHex: "fixture-media-key"}, diagnostics: log} + var group sync.WaitGroup + for index := 0; index < 50; index++ { + group.Add(1) + go func(index int) { + defer group.Done() + d.recordDiagnostic(diagnosticEvent{Event: "fixture.failed", Episode: index + 1, Message: "fixture-token fixture-media-key https://user:fixture-password@media.invalid/video?signature=fixture-signature#fixture-fragment"}) + }(index) + } + group.Wait() + events := readDiagnosticEvents(t, log.path) + seen := make(map[int]bool) + for _, event := range events { + seen[event.Episode] = true + for _, secret := range []string{"fixture-token", "fixture-media-key", "fixture-password", "fixture-signature", "fixture-fragment"} { + if strings.Contains(event.Message, secret) { + t.Fatal("diagnostic log exposed a credential or signed URL parameter") + } + } + } + if len(events) != 50 || len(seen) != 50 { + t.Fatal("concurrent diagnostic events were lost or interleaved") + } + info, err := os.Stat(log.path) + if err != nil || runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 { + t.Fatal("new diagnostic file was not private", err) + } +} + +func TestDiagnosticLogRotationBoundsFiles(t *testing.T) { + log := newDiagnosticLog(t.TempDir()) + log.limit = 512 + for index := 0; index < 30; index++ { + if err := log.write(diagnosticEvent{Event: "fixture.rotate", Episode: index, Message: strings.Repeat("x", 100)}); err != nil { + t.Fatal(err) + } + } + files, err := os.ReadDir(filepath.Dir(log.path)) + if err != nil || len(files) != diagnosticLogBackups+1 { + t.Fatal("diagnostic rotation exceeded its file bound", err) + } + for _, file := range files { + info, err := file.Info() + if err != nil || info.Size() > log.limit || len(readDiagnosticEvents(t, filepath.Join(filepath.Dir(log.path), file.Name()))) == 0 { + t.Fatal("rotation kept an oversized or invalid file", err) + } + } + current := readDiagnosticEvents(t, log.path) + if current[len(current)-1].Episode != 29 { + t.Fatal("rotation discarded the latest event") + } + if err := newDiagnosticLog(filepath.Dir(filepath.Dir(log.path))).write(diagnosticEvent{Event: "fixture.restart", Message: "restart"}); err != nil { + t.Fatal("reopening diagnostics failed", err) + } +} + +func TestDiagnosticWriteFailureDoesNotReplacePlaybackError(t *testing.T) { + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, "logs"), []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + log := newDiagnosticLog(directory) + if err := log.write(diagnosticEvent{Event: "fixture.failed", Message: "failure"}); err == nil { + t.Fatal("unwritable diagnostics unexpectedly succeeded") + } + d := &Downloader{diagnostics: log} + d.recordTaskFailure("playback.failed", Task{}, 49, errors.New("unexpected EOF")) + data, err := os.ReadFile(filepath.Join(directory, "logs")) + if err != nil || string(data) != "keep" { + t.Fatal("diagnostics replaced an existing file", err) + } +} + +func TestPlaybackFailureLogIncludesEpisodeAndSkipsStoppedRun(t *testing.T) { + app, session := prefetchFixtureApp(t) + session.tasks[0] = Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "合成播放条目", Index: 1} + run, _, err := app.beginPlayback(viewerFixtureContext(app, context.Background()), "fixture", 1, 49, 0, 1, false) + if err != nil { + t.Fatal(err) + } + defer run.stop() + app.finishPlaybackRun(run, errors.New("unexpected EOF"), false) + app.finishPlaybackRun(run, context.Canceled, true) + events := readDiagnosticEvents(t, app.downloader.diagnostics.path) + failures := 0 + for _, event := range events { + if event.Event == "playback.failed" { + failures++ + if event.DramaID != run.task.DramaID || event.DramaTitle != run.task.DramaTitle || event.Episode != 1 || event.StartSeconds != 49 || event.Message != "unexpected EOF" { + t.Fatal("playback failure lost its diagnostic context", fmt.Sprint(event)) + } + } + } + if failures != 1 { + t.Fatal("stopping playback generated a false diagnostic failure") + } +} diff --git a/internal/app/download_hls.go b/internal/app/download_hls.go new file mode 100644 index 0000000..82b10ba --- /dev/null +++ b/internal/app/download_hls.go @@ -0,0 +1,387 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +type hlsAsset struct { + extension string + remote string + body []byte + playlist bool +} + +type hlsProxy struct { + client *http.Client + server *http.Server + base string + referer string + key []byte + mediaKey []byte + mu sync.Mutex + assets map[string]hlsAsset + assetIDs map[string]string + failure error + root string + retries int + host string + diagnostic func(diagnosticEvent) + query string + acquire func(context.Context) (func(), error) +} + +var hlsURIAttribute = regexp.MustCompile(`URI="([^"]+)"`) + +func (d *Downloader) newHLSProxy(ctx context.Context, media providerMedia, key []byte) (*hlsProxy, error) { + nonce := randomHex(16) + if nonce == "" { + return nil, errors.New("无法创建本地下载会话") + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("创建本地 HLS 转发失败: %w", err) + } + proxy := &hlsProxy{ + client: &http.Client{Transport: d.client.Transport}, + base: "http://" + listener.Addr().String() + "/" + nonce + "/", + referer: media.Referer, + key: key, + mediaKey: media.HLSKey, + assets: map[string]hlsAsset{}, + assetIDs: map[string]string{}, + retries: d.cfg.Retries, + diagnostic: d.recordDiagnostic, + } + if remote, err := url.Parse(media.URL); err == nil { + proxy.host = remote.Hostname() + } + proxy.root = proxy.addAsset(playbackRootAsset(media)) + proxy.server = &http.Server{ + Handler: proxy, + ReadHeaderTimeout: 5 * time.Second, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + go func() { + if err := proxy.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + proxy.recordError(err) + } + }() + return proxy, nil +} + +func playbackRootAsset(media providerMedia) hlsAsset { + asset := hlsAsset{remote: media.URL, body: []byte(media.Playlist), playlist: media.Playlist != ""} + if asset.playlist && !strings.Contains(media.Playlist, "#EXT-X-ENDLIST") && !strings.Contains(media.Playlist, "#EXT-X-STREAM-INF:") { + asset.body = nil + } + return asset +} + +func (proxy *hlsProxy) Close() { + if proxy.server != nil { + _ = proxy.server.Close() + } +} + +func (proxy *hlsProxy) Err() error { + proxy.mu.Lock() + defer proxy.mu.Unlock() + return proxy.failure +} + +func (proxy *hlsProxy) recordError(err error) { + if err == nil { + return + } + proxy.mu.Lock() + first := proxy.failure == nil + if proxy.failure == nil { + proxy.failure = publicError(err) + } + proxy.mu.Unlock() + if first && proxy.diagnostic != nil { + proxy.diagnostic(diagnosticEvent{Event: "media.failed", Host: proxy.host, Message: err.Error()}) + } +} + +func (proxy *hlsProxy) addAsset(asset hlsAsset) string { + proxy.mu.Lock() + defer proxy.mu.Unlock() + if existing := proxy.assetIDs[asset.remote]; existing != "" { + return existing + } + if len(proxy.assets) >= 16384 { + return "" + } + extension := asset.extension + if extension == "" { + remote, _ := url.Parse(asset.remote) + if remote != nil { + extension = strings.ToLower(path.Ext(remote.Path)) + } + switch extension { + case ".m3u8": + asset.playlist = true + case ".mp4", ".m4s", ".m4a", ".aac", ".mp3", ".vtt", ".key", ".ts": + default: + extension = ".ts" + } + } + if asset.playlist { + extension = ".m3u8" + } else if len(asset.body) > 0 { + extension = ".key" + } + asset.extension = extension + local := proxy.base + strconv.Itoa(len(proxy.assets)+1) + extension + if proxy.query != "" { + local += "?" + proxy.query + } + parsed, _ := url.Parse(local) + proxy.assets[parsed.Path] = asset + proxy.assetIDs[asset.remote] = local + return local +} + +func (proxy *hlsProxy) rewritePlaylist(raw, baseURL string) (string, error) { + base, err := url.Parse(baseURL) + if err != nil { + return "", err + } + rewrite := func(reference string, playlist, key, initialization bool) (string, error) { + parsed, err := url.Parse(reference) + if err != nil { + return "", err + } + remote := base.ResolveReference(parsed).String() + if !isProviderHTTPMediaURL(remote) { + return "", errors.New("HLS 包含非 HTTP/HTTPS 资源地址") + } + asset := hlsAsset{remote: remote, playlist: playlist || strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8")} + if key { + asset.extension = ".key" + } else if initialization { + asset.extension = ".mp4" + } + resolved, _ := url.Parse(remote) + if key && len(proxy.key) > 0 && strings.Contains(parsed.Path, "/api/app/vid/sec") { + asset.body = proxy.key + } + if key && len(proxy.mediaKey) == 16 && strings.HasSuffix(resolved.Path, "/enc.key") { + asset.body = proxy.mediaKey + } + local := proxy.addAsset(asset) + if local == "" { + return "", errors.New("HLS 资源数量超过限制") + } + return local, nil + } + lines := strings.Split(strings.TrimPrefix(raw, "\ufeff"), "\n") + nextPlaylist := false + for index, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF:") { + nextPlaylist = true + } + if strings.HasPrefix(trimmed, "#") { + var rewriteErr error + lines[index] = hlsURIAttribute.ReplaceAllStringFunc(line, func(attribute string) string { + reference := hlsURIAttribute.FindStringSubmatch(attribute)[1] + local, err := rewrite(reference, strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") || strings.HasPrefix(trimmed, "#EXT-X-I-FRAME-STREAM-INF:"), strings.HasPrefix(trimmed, "#EXT-X-KEY:") || strings.HasPrefix(trimmed, "#EXT-X-SESSION-KEY:"), strings.HasPrefix(trimmed, "#EXT-X-MAP:")) + if err != nil { + rewriteErr = err + return attribute + } + return `URI="` + local + `"` + }) + if rewriteErr != nil { + return "", rewriteErr + } + } else if trimmed != "" { + lines[index], err = rewrite(trimmed, nextPlaylist, false, false) + if err != nil { + return "", err + } + nextPlaylist = false + } + } + return strings.Join(lines, "\n"), nil +} + +func hlsAssetContentType(extension string) string { + switch extension { + case ".mp4", ".m4s": + return "video/mp4" + case ".ts": + return "video/mp2t" + case ".m4a": + return "audio/mp4" + case ".aac": + return "audio/aac" + case ".mp3": + return "audio/mpeg" + case ".vtt": + return "text/vtt" + case ".key": + return "application/octet-stream" + } + return "" +} + +func (proxy *hlsProxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet && request.Method != http.MethodHead { + writer.WriteHeader(http.StatusMethodNotAllowed) + return + } + proxy.mu.Lock() + asset, found := proxy.assets[request.URL.Path] + proxy.mu.Unlock() + if !found { + http.NotFound(writer, request) + return + } + body := asset.body + if len(body) == 0 { + if proxy.acquire != nil { + release, err := proxy.acquire(request.Context()) + if err != nil { + http.Error(writer, "媒体连接正在等待资源,请稍后重试", http.StatusServiceUnavailable) + return + } + defer release() + } + method := request.Method + if asset.playlist { + method = http.MethodGet + } + upstream, err := http.NewRequestWithContext(request.Context(), method, asset.remote, nil) + if err != nil { + proxy.fail(writer, request, err) + return + } + upstream.Header.Set("User-Agent", userAgent) + upstream.Header.Set("Referer", proxy.referer) + upstream.Header.Set("Accept-Encoding", "identity") + if origin, err := url.Parse(proxy.referer); err == nil && origin.Host != "" { + upstream.Header.Set("Origin", origin.Scheme+"://"+origin.Host) + } + for _, header := range []string{"Range", "If-Range"} { + if value := request.Header.Get(header); value != "" && !asset.playlist { + upstream.Header.Set(header, value) + } + } + response, err := proxy.fetch(upstream) + if err != nil { + proxy.fail(writer, request, err) + return + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + _ = response.Body.Close() + if response.StatusCode == http.StatusRequestedRangeNotSatisfiable { + writer.Header().Set("Content-Range", response.Header.Get("Content-Range")) + writer.WriteHeader(response.StatusCode) + return + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone { + proxy.recordError(fmt.Errorf("媒体源 HTTP %d", response.StatusCode)) + writer.WriteHeader(response.StatusCode) + return + } + proxy.fail(writer, request, fmt.Errorf("HLS 资源请求失败: %s HTTP %d", upstream.URL.Hostname(), response.StatusCode)) + return + } + if asset.playlist || strings.Contains(response.Header.Get("Content-Type"), "mpegurl") { + defer response.Body.Close() + body, err = io.ReadAll(io.LimitReader(response.Body, providerMaxBodyBytes+1)) + if err != nil || len(body) > providerMaxBodyBytes { + proxy.fail(writer, request, errors.New("读取 HLS 播放列表失败或内容过大")) + return + } + asset.playlist = true + if response.Request != nil { + asset.remote = response.Request.URL.String() + } + } else { + reader := proxy.mediaReader(upstream, response) + defer reader.Close() + for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Content-Encoding", "ETag", "Last-Modified"} { + if value := response.Header.Get(header); value != "" { + writer.Header().Set(header, value) + } + } + if contentType := hlsAssetContentType(asset.extension); contentType != "" { + writer.Header().Set("Content-Type", contentType) + } + writer.WriteHeader(response.StatusCode) + if request.Method == http.MethodHead { + return + } + if _, err := io.Copy(writer, reader); err != nil && reader.failure != nil && request.Context().Err() == nil { + proxy.recordError(reader.failure) + } + return + } + } + if asset.playlist { + if !bytes.HasPrefix(bytes.TrimSpace(bytes.TrimPrefix(body, []byte("\ufeff"))), []byte("#EXTM3U")) { + proxy.fail(writer, request, errors.New("上游没有返回有效的 HLS 播放列表")) + return + } + rewritten, err := proxy.rewritePlaylist(string(body), asset.remote) + if err != nil { + proxy.fail(writer, request, err) + return + } + body = []byte(rewritten) + writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + } else { + writer.Header().Set("Content-Type", "application/octet-stream") + } + http.ServeContent(writer, request, "media", time.Time{}, bytes.NewReader(body)) +} + +func (proxy *hlsProxy) fetch(request *http.Request) (*http.Response, error) { + attempts := mediaRequestAttempts(proxy.retries) + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 { + timer := time.NewTimer(time.Duration(attempt) * time.Second) + select { + case <-timer.C: + case <-request.Context().Done(): + timer.Stop() + return nil, request.Context().Err() + } + } + response, err := proxy.client.Do(request.Clone(request.Context())) + if err == nil { + return response, nil + } + if request.Context().Err() != nil { + return nil, request.Context().Err() + } + lastErr = err + } + return nil, fmt.Errorf("媒体资源连接失败(%s,已尝试 %d 次;可在代理设置中检测连接): %w", request.URL.Hostname(), attempts, publicError(lastErr)) +} + +func (proxy *hlsProxy) fail(writer http.ResponseWriter, request *http.Request, err error) { + if request.Context().Err() != nil { + return + } + proxy.recordError(err) + http.Error(writer, "上游媒体请求失败", http.StatusBadGateway) +} diff --git a/internal/app/download_progress.go b/internal/app/download_progress.go new file mode 100644 index 0000000..56fbb5d --- /dev/null +++ b/internal/app/download_progress.go @@ -0,0 +1,151 @@ +package app + +import ( + "math" + "os" + "strings" + "sync" + "time" +) + +type cappedStringWriter struct { + b strings.Builder + limit int +} + +func (w *cappedStringWriter) Write(p []byte) (int, error) { + if w.limit <= 0 { + return len(p), nil + } + remaining := w.limit - w.b.Len() + if remaining > 0 { + if len(p) > remaining { + _, _ = w.b.Write(p[:remaining]) + } else { + _, _ = w.b.Write(p) + } + } + return len(p), nil +} + +func (w *cappedStringWriter) String() string { return w.b.String() } + +type downloadProgressState struct { + mu sync.Mutex + callbackMu sync.Mutex + callback func(DownloadProgress) + partPath string + outPath string + totalBytes int64 + mediaTotal time.Duration + mediaDone time.Duration + started time.Time + lastSample time.Time + lastBytes int64 + speed float64 + lastReport time.Time + lastPercent int + lastPhase string +} + +func newDownloadProgressState(partPath, outPath string, totalBytes int64, callback func(DownloadProgress)) *downloadProgressState { + return &downloadProgressState{partPath: partPath, outPath: outPath, totalBytes: totalBytes, callback: callback, started: time.Now()} +} + +func (p *downloadProgressState) setMediaTotal(total time.Duration) { + p.mu.Lock() + p.mediaTotal = total + p.mu.Unlock() +} + +func (p *downloadProgressState) setMediaElapsed(elapsed time.Duration) { + if elapsed < 0 { + elapsed = 0 + } + p.mu.Lock() + if elapsed > p.mediaDone { + p.mediaDone = elapsed + } + p.mu.Unlock() +} + +func (p *downloadProgressState) snapshot(now time.Time, phase string) DownloadProgress { + p.mu.Lock() + defer p.mu.Unlock() + bytesDownloaded := int64(0) + if st, err := os.Stat(p.partPath); err == nil && !st.IsDir() { + bytesDownloaded = st.Size() + } else if phase == "completed" && p.outPath != "" { + if st, err := os.Stat(p.outPath); err == nil && !st.IsDir() { + bytesDownloaded = st.Size() + } + } + if !p.lastSample.IsZero() { + seconds := now.Sub(p.lastSample).Seconds() + if seconds > 0 && bytesDownloaded >= p.lastBytes { + p.speed = float64(bytesDownloaded-p.lastBytes) / seconds + } + } + p.lastSample = now + p.lastBytes = bytesDownloaded + if p.speed < 0 || math.IsNaN(p.speed) || math.IsInf(p.speed, 0) { + p.speed = 0 + } + elapsed := now.Sub(p.started) + if elapsed < 0 { + elapsed = 0 + } + mediaElapsed := p.mediaDone + mediaTotal := p.mediaTotal + percent := 0 + if mediaTotal > 0 { + percent = int(float64(mediaElapsed) / float64(mediaTotal) * 100) + } else if p.totalBytes > 0 { + percent = int(float64(bytesDownloaded) / float64(p.totalBytes) * 100) + } + if phase == "completed" { + percent = 100 + if mediaTotal > 0 { + mediaElapsed = mediaTotal + } + } + if percent < 0 { + percent = 0 + } + if phase != "completed" && percent >= 100 { + percent = 99 + } else if percent > 100 { + percent = 100 + } + remaining := time.Duration(0) + if mediaTotal > 0 && mediaElapsed > 0 { + remaining = time.Duration(float64(mediaTotal-mediaElapsed) * float64(elapsed) / float64(mediaElapsed)) + } else if p.totalBytes > 0 && p.speed > 0 && bytesDownloaded < p.totalBytes { + remaining = time.Duration(float64(p.totalBytes-bytesDownloaded) / p.speed * float64(time.Second)) + } + if remaining < 0 || math.IsNaN(float64(remaining)) || math.IsInf(float64(remaining), 0) { + remaining = 0 + } + return DownloadProgress{Percent: percent, DownloadedBytes: bytesDownloaded, TotalBytes: p.totalBytes, SpeedBytesPerSecond: p.speed, Elapsed: elapsed, Remaining: remaining, MediaElapsed: mediaElapsed, MediaTotal: mediaTotal, Phase: phase} +} + +func (p *downloadProgressState) report(phase string, force bool) { + if p.callback == nil { + return + } + now := time.Now() + p.callbackMu.Lock() + defer p.callbackMu.Unlock() + progress := p.snapshot(now, phase) + p.mu.Lock() + changed := force || progress.Percent != p.lastPercent || progress.Phase != p.lastPhase || now.Sub(p.lastReport) >= time.Second + if changed { + p.lastPercent = progress.Percent + p.lastPhase = progress.Phase + p.lastReport = now + } + p.mu.Unlock() + if changed { + p.callback(progress) + } +} diff --git a/internal/app/download_quality.go b/internal/app/download_quality.go new file mode 100644 index 0000000..c86d5dd --- /dev/null +++ b/internal/app/download_quality.go @@ -0,0 +1,82 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "net/http" +) + +func readDownloadRequest(writer http.ResponseWriter, request *http.Request) ([]string, *int, bool) { + var input struct { + IDs []string `json:"ids"` + Quality *int `json:"quality"` + } + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "下载参数无效"}) + return nil, nil, false + } + if decoder.Decode(new(any)) != io.EOF || input.Quality != nil && (*input.Quality < 0 || *input.Quality > 4320) { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "下载清晰度选项无效"}) + return nil, nil, false + } + ids, ok := cleanIDList(writer, input.IDs) + return ids, input.Quality, ok +} + +func preferredDownloadQuality(requested int, available []int) int { + best, lowest := 0, 0 + for _, quality := range available { + if quality <= 0 || quality > 4320 { + continue + } + if lowest == 0 || quality < lowest { + lowest = quality + } + if (requested <= 0 || quality <= requested) && quality > best { + best = quality + } + } + if best == 0 { + return lowest + } + return best +} + +func (downloader *Downloader) selectDownloadQuality(ctx context.Context, task Task, media providerMedia) (providerMedia, error) { + qualities := []int{media.Quality} + validVariants := make([]providerMedia, 0, len(media.Variants)+1) + for _, variant := range media.Variants { + if isProviderHTTPMediaURL(variant.URL) { + qualities = append(qualities, variant.Quality) + validVariants = append(validVariants, variant) + } + } + if isProviderHTTPMediaURL(media.URL) { + current := media + current.Variants = nil + validVariants = append(validVariants, current) + } + media.Variants = validVariants + if variants := playbackHLSVariants(media.Playlist); len(variants) > 0 { + qualities = nil + for _, variant := range variants { + qualities = append(qualities, variant.quality) + } + } + quality := preferredDownloadQuality(task.DownloadQuality, qualities) + selected, err := downloader.selectPlaybackQuality(context.WithValue(ctx, playbackQualityKey{}, quality), media) + if err != nil { + return providerMedia{}, err + } + var available []int + for _, option := range playbackQualityOptions(selected) { + available = append(available, option.Value) + } + downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "download.media", Source: sourceFromDramaID(task.DramaID), + DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Index, Quality: selected.Quality, RequestedQuality: task.DownloadQuality, AvailableQualities: available, + Message: "下载保留源媒体的音视频编码和分辨率"}) + return selected, nil +} diff --git a/internal/app/download_quality_test.go b/internal/app/download_quality_test.go new file mode 100644 index 0000000..6768705 --- /dev/null +++ b/internal/app/download_quality_test.go @@ -0,0 +1,228 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func TestDownloadQualityExplicitChoiceAndFallback(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + return rankingHTTPResponse(request, http.StatusOK, "#EXTM3U\n#EXTINF:2,\nsynthetic.ts\n#EXT-X-ENDLIST\n"), nil + }) + low := providerMedia{URL: "https://media.example.test/low.mp4", Quality: 720} + high := providerMedia{URL: "https://media.example.test/high.mp4", Quality: 1080} + media := high + media.Variants = []providerMedia{high, low, {URL: "file:///invalid", Quality: 2160}} + master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1080x1920\nhigh.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280\nlow.m3u8\n" + for _, input := range []struct{ requested, expected int }{{0, 1080}, {2160, 1080}, {1080, 1080}, {900, 720}, {720, 720}, {480, 720}} { + task := Task{DownloadQuality: input.requested} + for _, source := range []providerMedia{media, {URL: "https://media.example.test/master.m3u8", Playlist: master}} { + selected, err := d.selectDownloadQuality(context.Background(), task, source) + if err != nil || selected.Quality != input.expected { + t.Fatalf("requested %d: selected %d, expected %d: %v", input.requested, selected.Quality, input.expected, err) + } + } + } + events := readDiagnosticEvents(t, d.diagnostics.path) + last := events[len(events)-1] + if last.RequestedQuality != 480 || last.Quality != 720 { + t.Fatal("fallback log lost requested and actual quality", last) + } +} + +func TestDownloadQualityRequestValidation(t *testing.T) { + for _, input := range []struct { + body string + valid bool + quality int + }{ + {`{"ids":["hongguo:7000000000000000001"]}`, true, 0}, + {`{"ids":["hongguo:7000000000000000001"],"quality":720}`, true, 720}, + {`{"ids":["hongguo:7000000000000000001"],"quality":-1}`, false, 0}, + {`{"ids":["hongguo:7000000000000000001"],"quality":9999}`, false, 0}, + {`{"ids":["hongguo:7000000000000000001"],"quality":"720"}`, false, 0}, + {`{"ids":["hongguo:7000000000000000001"],"quality":720.5}`, false, 0}, + {`{"ids":[]}`, false, 0}, + {`{"ids":["hongguo:7000000000000000001"]} {}`, false, 0}, + } { + writer := httptest.NewRecorder() + _, quality, valid := readDownloadRequest(writer, httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(input.body))) + if valid != input.valid || valid && quality != nil && *quality != input.quality { + t.Fatal(input.body, valid, quality, writer.Body.String()) + } + } +} + +func downloadQualityApp(t *testing.T) *UIApp { + t.Helper() + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + t.Error("download task fixture attempted a network request", request.URL) + return nil, errors.New("fixture only") + }) + a := &UIApp{cfg: d.cfg, downloader: d, tasks: map[string]*UITask{}, statePath: filepath.Join(d.cfg.dataDirectory(), "ui-state.json")} + a.cond = sync.NewCond(&a.mu) + drama := Drama{ID: "hongguo:7000000000000000001", Source: sourceHongguo, SourceID: "7000000000000000001", Title: "无图画质任务"} + a.dramas = []Drama{drama} + client := d.hongguoClient() + client.details[drama.SourceID] = hongguoDetailEntry{Drama: drama, Chapters: []Chapter{ + {ID: "hongguo:7000000000000000001:7000000000000000002", Source: sourceHongguo, Title: "第 1 集", CurrentEpisode: rawEpisode(1)}, + }, ExpiresAt: time.Now().Add(time.Hour)} + return a +} + +func waitDownloadQualityParse(t *testing.T, a *UIApp) []uiTaskView { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + a.mu.Lock() + pending := len(a.parsingInFlight) > 0 + views := a.taskViewsLocked() + a.mu.Unlock() + if !pending { + return views + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("chapter parsing did not finish") + return nil +} + +func TestDownloadQualitySurvivesQueueRestartRetryAndUpdate(t *testing.T) { + a := downloadQualityApp(t) + body := `{"ids":["hongguo:7000000000000000001"],"quality":720}` + writer := httptest.NewRecorder() + a.handleDownload(writer, httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(body))) + if writer.Code != http.StatusAccepted { + t.Fatal(writer.Code, writer.Body.String()) + } + views := waitDownloadQualityParse(t, a) + if len(views) != 1 || views[0].DownloadQuality != 720 { + t.Fatal("queued task lost selected quality", views) + } + restarted := &UIApp{cfg: a.cfg, downloader: a.downloader, tasks: map[string]*UITask{}, statePath: a.statePath} + restarted.cond = sync.NewCond(&restarted.mu) + restarted.loadState() + task := restarted.tasks[views[0].ID] + if task == nil || task.Source.DownloadQuality != 720 { + t.Fatal("restart lost quality", task) + } + task.Status = uiStatusFailed + selection, _ := json.Marshal(map[string]any{"ids": []string{task.ID}}) + restarted.handleRetry(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/retry", bytes.NewReader(selection))) + if task.Status != uiStatusQueued || task.Source.DownloadQuality != 720 { + t.Fatal("retry changed quality", task) + } + restarted.handlePause(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/pause", bytes.NewReader(selection))) + restarted.handleResume(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/resume", bytes.NewReader(selection))) + if task.Status != uiStatusQueued || task.Source.DownloadQuality != 720 { + t.Fatal("pause/resume changed quality", task) + } + client := a.downloader.hongguoClient() + entry := client.details["7000000000000000001"] + entry.Chapters = append(entry.Chapters, Chapter{ID: "hongguo:7000000000000000001:7000000000000000003", Source: sourceHongguo, Title: "第 2 集", CurrentEpisode: rawEpisode(2)}) + client.details["7000000000000000001"] = entry + restarted.handleUpdate(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/update", strings.NewReader(`{"ids":["hongguo:7000000000000000001"]}`))) + views = waitDownloadQualityParse(t, restarted) + if len(views) != 2 || views[0].DownloadQuality != 720 || views[1].DownloadQuality != 720 { + t.Fatal("new episode update did not retain collection quality", views) + } +} + +func TestDownloadQualityFailedChapterParseKeepsPreference(t *testing.T) { + a := downloadQualityApp(t) + client := a.downloader.hongguoClient() + entry := client.details["7000000000000000001"] + empty := entry + empty.Chapters = nil + client.details["7000000000000000001"] = empty + a.handleDownload(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(`{"ids":["hongguo:7000000000000000001"],"quality":540}`))) + views := waitDownloadQualityParse(t, a) + if len(views) != 1 || views[0].Status != uiStatusFailed || views[0].DownloadQuality != 540 { + t.Fatal("chapter failure lost quality", views) + } + client.details["7000000000000000001"] = entry + selection, _ := json.Marshal(map[string]any{"ids": []string{views[0].ID}}) + a.handleRetry(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/retry", bytes.NewReader(selection))) + views = waitDownloadQualityParse(t, a) + if len(views) != 1 || views[0].Status != uiStatusQueued || views[0].DownloadQuality != 540 { + t.Fatal("reparsed chapter lost quality", views) + } +} + +func TestDownloadQualityKeepsHighestAvailableSource(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.String() != "https://media.example.test/high.m3u8" { + t.Fatal("download requested an unselected quality", request.URL) + } + return rankingHTTPResponse(request, http.StatusOK, "#EXTM3U\n#EXTINF:2.0,\nsynthetic.ts\n#EXT-X-ENDLIST\n"), nil + }) + task := Task{DramaID: "hongguo:7000000000000000001", Index: 1} + ctx := context.WithValue(context.Background(), playbackQualityKey{}, 720) + low := providerMedia{URL: "https://media.example.test/low.mp4", Quality: 720} + high := providerMedia{URL: "https://media.example.test/high.mp4", Quality: 1080} + media := low + media.Variants = []providerMedia{low, high} + selected, err := d.selectDownloadQuality(ctx, task, media) + if err != nil || selected.Quality != 1080 || selected.URL != high.URL { + t.Fatal("playback quality reduced the download quality", selected.Quality, err) + } + selected, err = d.selectDownloadQuality(ctx, task, low) + if err != nil || selected.Quality != 720 || selected.URL != low.URL { + t.Fatal("download invented a higher source quality", selected, err) + } + master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=9000000,RESOLUTION=720x1280\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1080x1920\nhigh.m3u8\n" + selected, err = d.selectDownloadQuality(ctx, task, providerMedia{URL: "https://media.example.test/master.m3u8", Playlist: master, Quality: 720}) + if err != nil || selected.Quality != 1080 || strings.Contains(selected.Playlist, "low.m3u8") || !strings.Contains(selected.Playlist, "high.m3u8") { + t.Fatal("download did not explicitly select the highest HLS resolution", selected, err) + } + events := readDiagnosticEvents(t, d.diagnostics.path) + last := events[len(events)-1] + if last.Event != "download.media" || last.Quality != 1080 || !reflect.DeepEqual(last.AvailableQualities, []int{1080, 720}) { + t.Fatal("download log did not retain the source quality evidence", last) + } +} + +func TestDownloadPreserves1080pPixelsAndPackets(t *testing.T) { + app, session := nativePlaybackFixture(t, "2", "1080x1920") + source := session.tasks[0].OutPath + body, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/fixture.mp4" { + t.Error("download requested an unrelated resource") + http.NotFound(writer, request) + return + } + http.ServeContent(writer, request, "fixture.mp4", time.Time{}, bytes.NewReader(body)) + })) + defer server.Close() + d := app.downloader + d.client, d.cfg.Retries, d.cfg.SkipBytes = server.Client(), 1, 1 + task := Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "合成原画下载样本", Index: 1, + OutPath: filepath.Join(t.TempDir(), "download.mp4"), Chapter: Chapter{ID: "hongguo:7000000000000000001:1", Source: sourceHongguo, VideoURL: server.URL + "/fixture.mp4"}} + ctx, cancel := context.WithTimeout(context.WithValue(context.Background(), playbackQualityKey{}, 720), 30*time.Second) + defer cancel() + if err := d.DownloadEpisode(ctx, task); err != nil { + t.Fatal(err) + } + info, err := probeMergeMedia(ctx, d.cfg.FFmpeg, task.OutPath) + if err != nil || info.width != 1080 || info.height != 1920 { + t.Fatal("download reduced the source resolution", info, err) + } + if !reflect.DeepEqual(playbackFixturePackets(t, d.cfg.FFmpeg, source), playbackFixturePackets(t, d.cfg.FFmpeg, task.OutPath)) { + t.Fatal("download re-encoded the original video or audio") + } +} diff --git a/internal/app/downloader.go b/internal/app/downloader.go new file mode 100644 index 0000000..63bc1ed --- /dev/null +++ b/internal/app/downloader.go @@ -0,0 +1,326 @@ +package app + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +type Downloader struct { + cfg Config + client *http.Client + huangdouDetails map[string]huangdouDetailEntry + huangdouDetailPending map[string]*huangdouDetailCall + providerMu sync.Mutex + providerHosts map[string]string + apiMu sync.Mutex + apiBase string + limiter *requestLimiter + proxyRouter *proxyRouter + ffmpegMu sync.Mutex + ffmpegInstaller *ffmpegInstaller + hongguoOnce sync.Once + hongguo *hongguoAppClient + legacyOnce sync.Once + legacy *legacyAPIClient + rankings rankingCache + diagnostics *diagnosticLog +} + +func NewDownloader(cfg Config) *Downloader { + if cfg.OutputDir == "" { + cfg.OutputDir = defaultOutputDir() + } + if cfg.FFmpeg == "" { + cfg.FFmpeg = "ffmpeg" + } + if cfg.Concurrency <= 0 { + cfg.Concurrency = 2 + } + if cfg.RequestConcurrency <= 0 { + cfg.RequestConcurrency = 2 + } + if cfg.RequestIntervalMS <= 0 { + cfg.RequestIntervalMS = 500 + } + loadRuntimeSettings(&cfg) + if absolute, err := filepath.Abs(cfg.dataDirectory()); err == nil { + cfg.dataDir = absolute + } + cfg.outputDirSetting = cfg.OutputDir + if absolute, err := filepath.Abs(cfg.OutputDir); err == nil { + cfg.OutputDir = absolute + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.InsecureTLS} + transport.MaxIdleConnsPerHost = 8 + transport.ResponseHeaderTimeout = 20 * time.Second + router := &proxyRouter{} + router.configure(cfg.ProxyURL) + transport.Proxy = router.proxy + resolver := newSafeDNSDialer(transport) + transport.DialContext = resolver.DialContext + downloader := &Downloader{cfg: cfg, providerHosts: map[string]string{}, limiter: newRequestLimiter(cfg.RequestConcurrency, time.Duration(cfg.RequestIntervalMS)*time.Millisecond), proxyRouter: router, diagnostics: newDiagnosticLog(cfg.dataDirectory())} + images := newImageTransport(newHuangguoBrowserTransport(newCDNTransport(transport, resolver), downloader), transport) + images.lookup = func(ctx context.Context, host string) ([]string, error) { + if protectedCDNHost(host) { + return resolver.lookup(ctx, host) + } + return net.DefaultResolver.LookupHost(ctx, host) + } + images.fallback = func(ctx context.Context, host string) ([]string, error) { + entry, err := resolver.lookupEntry(ctx, host, cdnAlternateSubnet) + return entry.addresses, err + } + downloader.client = &http.Client{Transport: images, Timeout: 45 * time.Second} + return downloader +} + +func (d *Downloader) DownloadEpisode(ctx context.Context, task Task) error { + return d.DownloadEpisodeWithProgress(ctx, task, nil) +} + +func (d *Downloader) DownloadEpisodeWithProgress(ctx context.Context, task Task, callback func(DownloadProgress)) (resultErr error) { + defer func() { + if ctx.Err() == nil { + d.recordTaskFailure("download.failed", task, 0, resultErr) + } + }() + if isHuangguoProviderSource(task.Chapter.Source) || task.Chapter.PageURL != "" || isProviderHTTPMediaURL(task.Chapter.VideoURL) || strings.HasPrefix(task.Chapter.VideoURL, "hongguo-cenc://") { + return d.downloadHuangguoProviderMediaWithProgress(ctx, task, callback) + } + if task.Chapter.VideoURL == "" { + return errors.New("chapter videoUrl is empty") + } + if ok, size := existingGood(task.OutPath, d.cfg.SkipBytes); ok { + fmt.Printf(" 已存在,跳过:%s (%.1f MB)\n", task.OutPath, float64(size)/1024/1024) + if callback != nil { + total := task.Chapter.MediaSize + if total <= 0 { + total = size + } + callback(DownloadProgress{Percent: 100, DownloadedBytes: size, TotalBytes: total, Phase: "completed"}) + } + return nil + } + partPath := strings.TrimSuffix(task.OutPath, filepath.Ext(task.OutPath)) + ".part.mp4" + _ = os.Remove(partPath) + defer os.Remove(partPath) + if callback != nil { + callback(DownloadProgress{Phase: "preparing"}) + } + ffmpeg, err := d.ensureFFmpeg(ctx) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(task.OutPath), 0o755); err != nil { + return err + } + progress := newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback) + progress.report("downloading", true) + var lastErr error + for attempt := 1; attempt <= d.cfg.Retries; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + func() { + media, key, resolveErr := d.resolvePlaybackMedia(ctx, task) + if resolveErr != nil { + lastErr = resolveErr + return + } + media, resolveErr = d.selectDownloadQuality(ctx, task, media) + if resolveErr != nil { + lastErr = resolveErr + return + } + progress.setMediaTotal(media.Duration) + progress.report("downloading", true) + proxy, err := d.newHLSProxy(ctx, media, key) + if err != nil { + lastErr = err + return + } + defer proxy.Close() + args := []string{ + "-hide_banner", "-loglevel", "error", "-nostats", + "-allowed_extensions", "ALL", + "-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy", + "-rw_timeout", "20000000", + "-i", proxy.root, + "-c", "copy", + "-progress", "pipe:1", + "-f", "mp4", + "-y", partPath, + } + cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + cmd := ffmpegMediaCommand(cmdCtx, ffmpeg, args...) + stdout, pipeErr := cmd.StdoutPipe() + if pipeErr != nil { + lastErr = pipeErr + return + } + stderr, pipeErr := cmd.StderrPipe() + if pipeErr != nil { + lastErr = pipeErr + return + } + if startErr := cmd.Start(); startErr != nil { + lastErr = startErr + return + } + stderrDone := make(chan string, 1) + go func() { + var stderrBuf cappedStringWriter + stderrBuf.limit = 64 * 1024 + _, _ = io.Copy(&stderrBuf, stderr) + stderrDone <- stderrBuf.String() + }() + progressDone := make(chan struct{}) + go func() { + defer close(progressDone) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch key { + case "out_time_us", "out_time_ms": + value = strings.TrimSpace(value) + if value == "" || value == "N/A" { + continue + } + if micros, parseErr := strconv.ParseInt(value, 10, 64); parseErr == nil && micros >= 0 { + progress.setMediaElapsed(time.Duration(micros) * time.Microsecond) + progress.report("downloading", false) + } + case "progress": + if strings.TrimSpace(value) == "end" { + progress.report("downloading", true) + } + } + } + }() + tickerDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + defer close(tickerDone) + for { + select { + case <-ticker.C: + progress.report("downloading", false) + case <-progressDone: + return + } + } + }() + waitErr := cmd.Wait() + <-progressDone + <-tickerDone + stderrText := <-stderrDone + if ctxErr := ctx.Err(); ctxErr != nil { + lastErr = ctxErr + return + } + if proxyErr := proxy.Err(); proxyErr != nil { + lastErr = proxyErr + return + } + if waitErr != nil { + lastErr = fmt.Errorf("ffmpeg failed: %v %s", waitErr, truncate(stderrText, 1000)) + _ = os.Remove(partPath) + return + } + st, statErr := os.Stat(partPath) + if statErr != nil { + lastErr = statErr + return + } + if st.Size() < 100*1024 { + lastErr = fmt.Errorf("downloaded file too small: %d bytes", st.Size()) + _ = os.Remove(partPath) + return + } + if renameErr := os.Rename(partPath, task.OutPath); renameErr != nil { + lastErr = renameErr + return + } + lastErr = nil + }() + if lastErr == nil { + progress.report("completed", true) + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + fmt.Printf(" 尝试 %d/%d 失败:%s:%v\n", attempt, d.cfg.Retries, filepath.Base(task.OutPath), lastErr) + if attempt < d.cfg.Retries { + select { + case <-time.After(time.Duration(attempt*2) * time.Second): + case <-ctx.Done(): + return ctx.Err() + } + } + } + return lastErr +} + +func DownloadTasks(ctx context.Context, d *Downloader, tasks []Task) []Result { + if len(tasks) == 0 { + return nil + } + jobs := make(chan Task) + results := make(chan Result) + var done int64 + workers := d.cfg.Concurrency + if workers > len(tasks) { + workers = len(tasks) + } + var wg sync.WaitGroup + for i := 1; i <= workers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for task := range jobs { + cur := atomic.AddInt64(&done, 1) + fmt.Printf(" [线程%d] [%d/%d] 《%s》 第%d/%d 集\n", id, cur, len(tasks), task.DramaTitle, task.Index, task.Total) + err := d.DownloadEpisode(ctx, task) + if err != nil { + results <- Result{Task: task, OK: false, Err: err.Error()} + } else { + results <- Result{Task: task, OK: true} + } + } + }(i) + } + go func() { + for _, task := range tasks { + jobs <- task + } + close(jobs) + wg.Wait() + close(results) + }() + var out []Result + for r := range results { + out = append(out, r) + } + return out +} diff --git a/internal/app/emby_api.go b/internal/app/emby_api.go new file mode 100644 index 0000000..abd4edf --- /dev/null +++ b/internal/app/emby_api.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" +) + +type embyAPIClient struct { + settings embySyncSettings + client *http.Client + transport *http.Transport +} + +type embyMediaItem struct { + ID string `json:"Id"` + Type string `json:"Type"` + Path string `json:"Path"` + ProviderIDs map[string]string `json:"ProviderIds"` + ImageTags map[string]string `json:"ImageTags"` +} + +type embyItemList struct { + Items []embyMediaItem `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} + +func newEmbyAPIClient(settings embySyncSettings) *embyAPIClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + return &embyAPIClient{settings: settings, transport: transport, + client: &http.Client{Transport: transport, Timeout: 95 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} + +func (client *embyAPIClient) close() { + client.transport.CloseIdleConnections() +} + +func (client *embyAPIClient) request(ctx context.Context, method, endpoint string, query url.Values, result any) error { + address := client.settings.ServerURL + endpoint + if len(query) > 0 { + address += "?" + query.Encode() + } + request, err := http.NewRequestWithContext(ctx, method, address, nil) + if err != nil { + return errors.New("Emby 请求地址无效") + } + request.Header.Set("X-Emby-Token", client.settings.APIKey) + request.Header.Set("Accept", "application/json") + if method == http.MethodPost && strings.HasSuffix(endpoint, "/RemoteImages/Download") { + request.Body = io.NopCloser(strings.NewReader("{}")) + request.ContentLength = 2 + request.Header.Set("Content-Type", "application/json") + } + response, err := client.client.Do(request) + if err != nil { + var failure *url.Error + if errors.As(err, &failure) { + err = failure.Err + } + return fmt.Errorf("Emby 连接失败:%w", publicError(err)) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("Emby HTTP %d", response.StatusCode) + } + if result == nil { + _, err = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return err + } + const limit = 4 << 20 + body, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return errors.New("Emby 响应读取失败") + } + if len(body) > limit || json.Unmarshal(body, result) != nil { + return errors.New("Emby 返回的剧集信息无效或过大") + } + return nil +} + +func (client *embyAPIClient) series(ctx context.Context) ([]embyMediaItem, error) { + var items []embyMediaItem + seen := make(map[string]bool) + for start := 0; start < 100000; { + query := url.Values{"Recursive": {"true"}, "IncludeItemTypes": {"Series"}, "Fields": {"Path,ProviderIds"}, + "EnableImages": {"true"}, "EnableImageTypes": {"Primary"}, "ImageTypeLimit": {"1"}, + "SortBy": {"SortName"}, "SortOrder": {"Ascending"}, "StartIndex": {strconv.Itoa(start)}, "Limit": {"200"}} + var page embyItemList + if err := client.request(ctx, http.MethodGet, "/Items", query, &page); err != nil { + return nil, err + } + added := 0 + for _, item := range page.Items { + if validEmbyItemID(item.ID) && item.Type == "Series" && !seen[item.ID] { + seen[item.ID] = true + items = append(items, item) + added++ + } + } + start += len(page.Items) + if len(page.Items) == 0 || page.TotalRecordCount > 0 && start >= page.TotalRecordCount || + page.TotalRecordCount == 0 && len(page.Items) < 200 { + return items, nil + } + if added == 0 { + return nil, errors.New("Emby 剧集分页未前进,请稍后重试") + } + } + return nil, errors.New("Emby 剧集列表超过 100000 部,暂时无法完成海报匹配") +} + +func validEmbyItemID(id string) bool { + if len(id) == 0 || len(id) > 128 { + return false + } + for _, char := range id { + if char != '-' && char != '_' && (char < '0' || char > '9') && (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') { + return false + } + } + return true +} + +func embyProviderID(item embyMediaItem, provider string) string { + for name, value := range item.ProviderIDs { + if strings.EqualFold(name, provider) { + return value + } + } + return "" +} + +func matchEmbySeries(items []embyMediaItem, folder, identity string) (embyMediaItem, error) { + var matches []embyMediaItem + for _, item := range items { + if item.Type != "Series" || !validEmbyItemID(item.ID) { + continue + } + providerID := embyProviderID(item, "juku") + if providerID != "" && providerID != identity { + continue + } + itemFolder := path.Base(strings.TrimRight(strings.ReplaceAll(item.Path, "\\", "/"), "/")) + if itemFolder != folder { + continue + } + matches = append(matches, item) + } + if len(matches) > 1 { + return embyMediaItem{}, errors.New("Emby 中存在多个相同导出目录,请移除重复的媒体库挂载后重试") + } + if len(matches) == 0 { + return embyMediaItem{}, nil + } + return matches[0], nil +} + +func (client *embyAPIClient) downloadPoster(ctx context.Context, id, address string) (string, bool, error) { + if !validEmbyItemID(id) { + return "", false, errors.New("Emby 剧集 ID 无效") + } + query := url.Values{"Type": {"Primary"}, "ImageUrl": {address}} + if err := client.request(ctx, http.MethodPost, "/Items/"+id+"/RemoteImages/Download", query, nil); err != nil { + return "", false, err + } + tag, err := client.posterTag(ctx, id) + return tag, true, err +} + +func (client *embyAPIClient) posterTag(ctx context.Context, id string) (string, error) { + var result embyItemList + if err := client.request(ctx, http.MethodGet, "/Items", url.Values{"Ids": {id}, "EnableImages": {"true"}}, &result); err != nil { + return "", err + } + for _, item := range result.Items { + if item.ID == id && item.ImageTags["Primary"] != "" { + return item.ImageTags["Primary"], nil + } + } + return "", errors.New("Emby 尚未返回海报状态,将自动重试") +} diff --git a/internal/app/emby_files.go b/internal/app/emby_files.go new file mode 100644 index 0000000..aea45cc --- /dev/null +++ b/internal/app/emby_files.go @@ -0,0 +1,119 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "net/url" + "sort" +) + +type embyChapter struct { + ID string `json:"id"` + Title string `json:"title"` + Number int `json:"number"` +} + +func embyFolderName(drama Drama) string { + folder := []rune(safeFilename(drama.DisplayTitle())) + for len(string(folder)) > 180 { + folder = folder[:len(folder)-1] + } + identity := sha256.Sum256([]byte(drama.ID)) + return string(folder) + " [" + hex.EncodeToString(identity[:8]) + "]" +} + +func embyChapterRecords(id string, chapters []Chapter, previous []embyChapter) ([]embyChapter, error) { + if len(chapters) == 0 || len(chapters) > 2000 || len(previous) > 2000 { + return nil, errors.New("可导出分集数无效") + } + byID := make(map[string]embyChapter, len(previous)+len(chapters)) + used := make(map[int]bool, len(previous)+len(chapters)) + for _, chapter := range previous { + if !validEmbyIdentity(id, chapter.ID) || chapter.Number < 1 || chapter.Number > 2000 || used[chapter.Number] || byID[chapter.ID].ID != "" { + return nil, errors.New("已有 Emby 分集清单无效,原文件已保留") + } + byID[chapter.ID], used[chapter.Number] = chapter, true + } + seen := make(map[string]bool, len(chapters)) + for index, chapter := range chapters { + if !validEmbyIdentity(id, chapter.ID) || seen[chapter.ID] { + return nil, errors.New("站源未提供唯一、稳定的分集 ID,暂不能导出") + } + seen[chapter.ID] = true + record := byID[chapter.ID] + if record.ID == "" { + number := index + 1 + for used[number] && number <= 2000 { + number++ + } + if number > 2000 { + return nil, errors.New("Emby 分集数超过 2000 集") + } + record = embyChapter{ID: chapter.ID, Number: number} + used[number] = true + } + record.Title = firstNonEmpty(chapter.Title, "第 "+chapter.EpisodeString(index+1)+" 集") + byID[chapter.ID] = record + } + result := make([]embyChapter, 0, len(byID)) + for _, chapter := range byID { + result = append(result, chapter) + } + sort.Slice(result, func(i, j int) bool { return result[i].Number < result[j].Number }) + return result, nil +} + +func writeEmbyFiles(drama Drama, chapters []embyChapter, base string, key []byte, owner string, write func(string, []byte) error) error { + title := drama.DisplayTitle() + type nfoValue struct { + Value string `xml:",chardata"` + Type string `xml:"type,attr,omitempty"` + Aspect string `xml:"aspect,attr,omitempty"` + } + var thumb *nfoValue + if cover := embyCoverURL(drama, base, key, owner); cover != "" { + thumb = &nfoValue{Value: cover, Aspect: "poster"} + } + show := struct { + XMLName xml.Name `xml:"tvshow"` + Title string `xml:"title"` + Plot string `xml:"plot,omitempty"` + Identity nfoValue `xml:"uniqueid"` + Thumb *nfoValue `xml:"thumb,omitempty"` + }{Title: title, Plot: firstNonEmpty(drama.Desc, drama.Intro), Identity: nfoValue{Value: embyMetadataID(key, drama.ID), Type: "juku"}, Thumb: thumb} + body, err := xml.MarshalIndent(show, "", " ") + if err != nil { + return err + } + if err = write("tvshow.nfo", append([]byte(xml.Header), body...)); err != nil { + return err + } + for _, chapter := range chapters { + query := url.Values{"id": {drama.ID}, "chapter": {chapter.ID}, "key": {embyToken(key, drama.ID, chapter.ID, owner)}} + if owner != "" { + query.Set("account", owner) + } + path := fmt.Sprintf("Season 01/S01E%03d", chapter.Number) + episode := struct { + XMLName xml.Name `xml:"episodedetails"` + Title string `xml:"title"` + Show string `xml:"showtitle"` + Season int `xml:"season"` + Episode int `xml:"episode"` + }{Title: chapter.Title, Show: title, Season: 1, Episode: chapter.Number} + body, err = xml.MarshalIndent(episode, "", " ") + if err != nil { + return err + } + if err = write(path+".nfo", append([]byte(xml.Header), body...)); err != nil { + return err + } + if err = write(path+".strm", []byte(base+"/api/emby/stream.m3u8?"+query.Encode()+"\n")); err != nil { + return err + } + } + return nil +} diff --git a/internal/app/emby_posters_test.go b/internal/app/emby_posters_test.go new file mode 100644 index 0000000..727cf92 --- /dev/null +++ b/internal/app/emby_posters_test.go @@ -0,0 +1,393 @@ +package app + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "encoding/xml" + "errors" + "image" + "image/png" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +const embyFixtureCover = "https://p3-shortvideo.byteimg.com/synthetic.png?signature=private-cover-token" + +type embyPosterTestTransport func(*http.Request) (*http.Response, error) + +func (transport embyPosterTestTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func embySyntheticPoster(t *testing.T) []byte { + t.Helper() + var data bytes.Buffer + if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func TestEmbyArchiveIncludesSignedPosterMetadataOnly(t *testing.T) { + drama := Drama{ID: historyFixtureDramaID, Title: "海报 <&>", CoverURL: embyFixtureCover} + key := bytes.Repeat([]byte{7}, 32) + body, err := buildEmbyArchive(drama, embySyncTestChapters(), "https://library.test/juku", key, "poster-owner") + if err != nil { + t.Fatal(err) + } + archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatal(err) + } + if len(archive.File) != 5 { + t.Fatal("export should contain only STRM and NFO files") + } + found := false + for _, file := range archive.File { + if !strings.HasSuffix(file.Name, ".strm") && !strings.HasSuffix(file.Name, ".nfo") { + t.Fatal("export created an artwork or media file", file.Name) + } + if !strings.HasSuffix(file.Name, "/tvshow.nfo") { + continue + } + reader, _ := file.Open() + var nfo struct { + Title string `xml:"title"` + Thumb struct { + URL string `xml:",chardata"` + Aspect string `xml:"aspect,attr"` + } `xml:"thumb"` + Identity struct { + Value string `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"uniqueid"` + } + err = xml.NewDecoder(reader).Decode(&nfo) + reader.Close() + if err != nil || nfo.Title != drama.Title || nfo.Thumb.Aspect != "poster" || nfo.Identity.Type != "juku" || nfo.Identity.Value != embyMetadataID(key, drama.ID) { + t.Fatal("invalid NFO metadata", err) + } + cover, err := url.Parse(nfo.Thumb.URL) + if err != nil || cover.Scheme != "https" || cover.Host != "library.test" || cover.Path != "/juku/api/emby/cover" { + t.Fatal("cover URL does not preserve the configured reverse proxy path") + } + query := cover.Query() + if query.Get("id") != drama.ID || query.Get("account") != "poster-owner" || + query.Get("key") != embyCoverToken(key, drama.ID, "poster-owner", query.Get("v")) || strings.Contains(nfo.Thumb.URL, "private-cover-token") { + t.Fatal("cover URL is not scoped to this account and drama") + } + found = true + } + if !found { + t.Fatal("missing tvshow.nfo") + } + drama.CoverURL = "https://untrusted.example.invalid/image" + if embyCoverURL(drama, "https://library.test", key, "poster-owner") != "" { + t.Fatal("untrusted source was included in the exported poster URL") + } +} + +func TestEmbyCoverWithoutCookiesChecksSignatureAndCurrentPermissions(t *testing.T) { + app, admin := administratorFixture(t) + member := newAccountTestBrowser(t, app) + member.register(t, "poster-viewer") + store := app.browserViewers().accountStore() + owner := store.state.Accounts["poster-viewer"].ID + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": true})) + app.dramas[0].CoverURL = embyFixtureCover + data := embySyntheticPoster(t) + var calls atomic.Int32 + app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.String() != embyFixtureCover || request.Header.Get("Referer") == "" || request.Header.Get("X-Emby-Token") != "" { + t.Error("cover request lost source headers or leaked Emby credentials") + } + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil + }) + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + address := embyCoverURL(app.dramas[0], "http://library.test", key, owner) + request := func(handler http.Handler, method, address string) *httptest.ResponseRecorder { + result := httptest.NewRecorder() + handler.ServeHTTP(result, httptest.NewRequest(method, address, nil)) + return result + } + for _, mutation := range []struct{ name, value string }{ + {"id", "hongguo:7000000000000000002"}, {"account", "another-account"}, {"v", strings.Repeat("0", 32)}, + {"key", embyToken(key, historyFixtureDramaID, "chapter-1", owner)}, {"key", "invalid"}, + } { + parsed, _ := url.Parse(address) + query := parsed.Query() + query.Set(mutation.name, mutation.value) + parsed.RawQuery = query.Encode() + if result := request(app.routes(), http.MethodGet, parsed.String()); result.Code != 403 { + t.Fatal("forged cover was accepted", mutation.name, result.Code) + } + } + if calls.Load() != 0 { + t.Fatal("invalid signatures reached the source") + } + result := request(app.routes(), http.MethodGet, address) + if result.Code != 200 || !bytes.Equal(result.Body.Bytes(), data) || result.Header().Get("Content-Type") != "image/png" || + result.Header().Get("Cache-Control") != "private, no-store" { + t.Fatal("signed cover was not usable without browser cookies", result.Code) + } + head := request(app.routes(), http.MethodHead, address) + if head.Code != 200 || head.Body.Len() != 0 || calls.Load() != 1 { + t.Fatal("cover HEAD/cache failed") + } + if result := request(app.routes(), http.MethodPost, address); result.Code != 405 { + t.Fatal("unsupported cover method accepted") + } + restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, dramas: app.dramas} + if result := request(restarted.routes(), http.MethodGet, address); result.Code != 200 { + t.Fatal("poster link did not survive restart", result.Code) + } + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "poster-viewer", "onlineOnly": true})) + before := calls.Load() + if result := request(app.routes(), http.MethodGet, address); result.Code != 403 || calls.Load() != before { + t.Fatal("online-only account retained poster export access") + } +} + +func TestEmbyPosterMatchesIdentityAndPortableFolderPaths(t *testing.T) { + folder, identity := "同名剧 [0123456789abcdef]", "fixture-provider-id" + for _, prefix := range []string{"/mnt/emby/", "D:\\Media\\Emby\\"} { + item, err := matchEmbySeries([]embyMediaItem{ + {ID: "other", Type: "Series", Path: prefix + "同名剧 [fedcba9876543210]"}, + {ID: "wanted", Type: "Series", Path: prefix + folder, ProviderIDs: map[string]string{"Juku": identity}}, + }, folder, identity) + if err != nil || item.ID != "wanted" { + t.Fatal("portable folder/identity match failed", item.ID, err) + } + } + items := []embyMediaItem{ + {ID: "first", Type: "Series", Path: "/one/" + folder}, + {ID: "second", Type: "Series", Path: "/two/" + folder}, + } + if _, err := matchEmbySeries(items, folder, identity); err == nil { + t.Fatal("ambiguous folders were matched by title") + } + items[0].ProviderIDs = map[string]string{"juku": "different-instance"} + items[1].ID = "../escape" + if item, err := matchEmbySeries(items, folder, identity); err != nil || item.ID != "" { + t.Fatal("wrong instance or unsafe item ID accepted") + } +} + +func TestEmbyPostersScanDelayRetryRestartAndCustomArtwork(t *testing.T) { + app, admin := administratorFixture(t) + manager := app.embySyncer() + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true})) + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": true})) + app.dramas[0].CoverURL = embyFixtureCover + data := embySyntheticPoster(t) + var imageRequests atomic.Int32 + app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) { + if request.URL.Host != "p3-shortvideo.byteimg.com" { + t.Error("unexpected external request") + return nil, errors.New("external requests forbidden") + } + imageRequests.Add(1) + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil + }) + library := httptest.NewServer(app.routes()) + defer library.Close() + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + folder := embyFolderName(app.dramas[0]) + var ready, failDownload, delayConfirmation atomic.Bool + var scans, uploads atomic.Int32 + var imageTag atomic.Value + imageTag.Store("") + item := func() embyMediaItem { + return embyMediaItem{ID: "series-1", Type: "Series", Path: "/container/shared/" + folder, + ProviderIDs: map[string]string{"juku": embyMetadataID(key, historyFixtureDramaID)}, ImageTags: map[string]string{"Primary": imageTag.Load().(string)}} + } + emby := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("X-Emby-Token") != "private-api-key" || strings.Contains(request.URL.RawQuery, "private-api-key") { + t.Error("API key missing from header or exposed in URL") + } + switch request.URL.Path { + case "/emby/Library/Refresh": + scans.Add(1) + writer.WriteHeader(204) + case "/emby/Items": + result := embyItemList{} + if ready.Load() && !(request.URL.Query().Get("Ids") != "" && delayConfirmation.Load()) { + result.Items, result.TotalRecordCount = []embyMediaItem{item()}, 1 + } + json.NewEncoder(writer).Encode(result) + case "/emby/Items/series-1/RemoteImages/Download": + uploads.Add(1) + if failDownload.Load() { + http.Error(writer, "sensitive upstream body private-api-key", 503) + return + } + if request.Method != http.MethodPost || request.URL.Query().Get("Type") != "Primary" || request.Header.Get("Content-Type") != "application/json" { + t.Error("invalid poster download request") + } + address := request.URL.Query().Get("ImageUrl") + if !strings.HasPrefix(address, library.URL+"/api/emby/cover?") { + t.Error("Emby was sent a source URL instead of the signed application URL") + writer.WriteHeader(400) + return + } + response, err := http.Get(address) + if err != nil { + t.Error(err) + writer.WriteHeader(502) + return + } + body, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || response.StatusCode != 200 || !bytes.Equal(body, data) { + t.Error("Emby could not fetch the synthetic cover without cookies", response.StatusCode) + writer.WriteHeader(502) + return + } + imageTag.Store("managed-" + request.URL.Query().Get("ImageUrl")) + writer.WriteHeader(204) + default: + t.Error("unexpected Emby request", request.URL.Path) + writer.WriteHeader(404) + } + })) + defer emby.Close() + manager.document.OwnerID = app.browserViewers().accountStore().state.Accounts["admin"].ID + manager.document.Settings = embySyncSettings{Enabled: true, Following: true, IntervalMinutes: 60, OutputDir: t.TempDir(), BaseURL: library.URL, ServerURL: emby.URL + "/emby", APIKey: "private-api-key"} + forcePoster := func() { + entry := manager.document.Entries[historyFixtureDramaID] + entry.Poster.CheckedAt, entry.Poster.RetryAt = time.Time{}, time.Time{} + manager.document.Entries[historyFixtureDramaID] = entry + } + run := func() embySyncEntry { + manager.runOnce(context.Background()) + return manager.document.Entries[historyFixtureDramaID] + } + first := run() + if first.Episodes != 3 || first.Error != "" || first.Poster.Status != "waiting" || scans.Load() != 1 || uploads.Load() != 0 || + manager.document.PostersPending != 1 || time.Until(manager.document.NextRunAt) > 2*time.Minute { + t.Fatal("first scan did not queue a durable poster retry", first.Poster.Status, manager.document.Error) + } + file := filepath.Join(manager.document.Settings.OutputDir, first.Folder, "Season 01", "S01E001.strm") + before, _ := os.Stat(file) + ready.Store(true) + forcePoster() + second := run() + after, _ := os.Stat(file) + if second.Poster.Status != "synced" || uploads.Load() != 1 || imageRequests.Load() != 1 || manager.document.WrittenFiles != 0 || + scans.Load() != 1 || !before.ModTime().Equal(after.ModTime()) { + t.Fatal("poster retry rewrote/refetched episodes or did not synchronize", second.Poster.Status, manager.document.Error) + } + forcePoster() + run() + if uploads.Load() != 1 { + t.Fatal("unchanged artwork was downloaded again") + } + restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, dramas: app.dramas} + manager = restarted.embySyncer() + if manager.loadErr != nil || manager.document.Entries[historyFixtureDramaID].Poster.ImageTag == "" { + t.Fatal("restart lost managed artwork state", manager.loadErr) + } + forcePoster() + run() + if uploads.Load() != 1 || scans.Load() != 1 { + t.Fatal("restart duplicated existing artwork or files") + } + setCover := func(address string) { + app.mu.Lock() + app.dramas = append([]Drama(nil), app.dramas...) + app.dramas[0].CoverURL = address + dramas := append([]Drama(nil), app.dramas...) + app.mu.Unlock() + restarted.mu.Lock() + restarted.dramas = dramas + restarted.mu.Unlock() + } + setCover(embyFixtureCover + "&revision=2") + failDownload.Store(true) + failed := run() + if failed.Error != "" || failed.Poster.Status != "failed" || manager.document.PostersFailed != 1 || + !strings.Contains(failed.Poster.Error, "HTTP 503") || time.Until(failed.Poster.RetryAt) < 30*time.Second { + t.Fatal("poster failure affected episode import or lost retry state", failed.Poster.Status, manager.document.Error) + } + serialized, _ := json.Marshal(manager.view()) + if bytes.Contains(serialized, []byte("private-api-key")) || bytes.Contains(serialized, []byte("private-cover-token")) { + t.Fatal("sync status exposed private URLs or credentials") + } + count := uploads.Load() + run() + if uploads.Load() != count { + t.Fatal("failed artwork ignored backoff") + } + failDownload.Store(false) + delayConfirmation.Store(true) + forcePoster() + unconfirmed := run() + if unconfirmed.Poster.SyncedSource == "" || unconfirmed.Poster.Status != "failed" { + t.Fatal("successful submission with delayed confirmation was lost") + } + count = uploads.Load() + delayConfirmation.Store(false) + imageTag.Store("custom-during-confirmation-failure") + forcePoster() + if confirmed := run(); confirmed.Poster.Status != "existing" || confirmed.Poster.ImageTag != "" || uploads.Load() != count { + t.Fatal("unconfirmed image ownership overwrote or adopted custom artwork") + } + imageTag.Store("user-custom-cover") + forcePoster() + if custom := run(); custom.Poster.Status != "existing" || uploads.Load() != count { + t.Fatal("custom artwork was overwritten") + } + setCover(embyFixtureCover + "&revision=3") + if custom := run(); custom.Poster.Status != "existing" || uploads.Load() != count { + t.Fatal("metadata update overwrote custom artwork") + } + imageTag.Store("") + forcePoster() + if repaired := run(); repaired.Poster.Status != "synced" || uploads.Load() != count+1 { + t.Fatal("missing artwork was not repaired") + } + err = filepath.WalkDir(manager.document.Settings.OutputDir, func(path string, entry os.DirEntry, err error) error { + if err == nil && !entry.IsDir() && !strings.HasSuffix(path, ".strm") && !strings.HasSuffix(path, ".nfo") && entry.Name() != ".juku-emby.json" { + t.Error("synchronization wrote an image into the export directory", entry.Name()) + } + return err + }) + if err != nil { + t.Fatal(err) + } +} + +func TestEmbyPosterAPIDoesNotForwardCredentialsOnRedirect(t *testing.T) { + var forwarded atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + forwarded.Add(1) + writer.WriteHeader(200) + })) + defer target.Close() + redirect := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, target.URL, http.StatusFound) + })) + defer redirect.Close() + client := newEmbyAPIClient(embySyncSettings{ServerURL: redirect.URL, APIKey: "private-api-key"}) + defer client.close() + if _, err := client.series(context.Background()); err == nil || !strings.Contains(err.Error(), "HTTP 302") || forwarded.Load() != 0 { + t.Fatal("Emby request followed a redirect with credentials", err) + } +} diff --git a/internal/app/emby_sync.go b/internal/app/emby_sync.go new file mode 100644 index 0000000..5fcae4c --- /dev/null +++ b/internal/app/emby_sync.go @@ -0,0 +1,511 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type embySyncSettings struct { + Enabled bool `json:"enabled"` + OutputDir string `json:"outputDir"` + BaseURL string `json:"baseUrl"` + IntervalMinutes int `json:"intervalMinutes"` + Following bool `json:"following"` + Downloads bool `json:"downloads"` + ServerURL string `json:"serverUrl"` + APIKey string `json:"apiKey,omitempty"` +} + +type embySyncEntry struct { + Folder string `json:"folder,omitempty"` + Title string `json:"title"` + Episodes int `json:"episodes"` + CheckedAt time.Time `json:"checkedAt"` + SyncedAt time.Time `json:"syncedAt"` + Error string `json:"error,omitempty"` + Poster embyPosterState `json:"poster"` +} + +type embySyncDocument struct { + Version int `json:"version"` + Settings embySyncSettings `json:"settings"` + OwnerID string `json:"ownerId"` + OwnerName string `json:"owner"` + Entries map[string]embySyncEntry `json:"entries"` + LastStartedAt time.Time `json:"lastStartedAt"` + LastFinishedAt time.Time `json:"lastFinishedAt"` + NextRunAt time.Time `json:"nextRunAt"` + WrittenFiles int `json:"writtenFiles"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Pending int `json:"pending"` + Error string `json:"error,omitempty"` + RefreshPending bool `json:"refreshPending"` + PostersSynced int `json:"postersSynced"` + PostersPending int `json:"postersPending"` + PostersFailed int `json:"postersFailed"` + PostersUpdated int `json:"postersUpdated"` + PosterChecked int `json:"-"` +} + +type embySyncManager struct { + app *UIApp + path string + mu sync.Mutex + document embySyncDocument + loadErr error + running bool + generation uint64 + wake chan struct{} + cancel context.CancelFunc + runCancel context.CancelFunc + done chan struct{} + closed bool +} + +func (app *UIApp) embySyncer() *embySyncManager { + app.embySyncOnce.Do(func() { + manager := &embySyncManager{app: app, path: filepath.Join(app.cfg.dataDirectory(), "emby-sync.json"), wake: make(chan struct{}, 1)} + manager.document = embySyncDocument{Version: 1, Settings: embySyncSettings{ + OutputDir: filepath.Join(app.cfg.OutputDir, "Emby"), IntervalMinutes: 60, Following: true, Downloads: true, + }, Entries: make(map[string]embySyncEntry)} + var stored embySyncDocument + err := readEmbyJSON(manager.path, &stored, 8<<20) + if err == nil { + if stored.Version != 1 || len(stored.Entries) > 10000 { + err = errors.New("Emby 同步记录格式无效") + } + if err == nil { + err = stored.Settings.normalize() + } + if err == nil { + if stored.Entries == nil { + stored.Entries = make(map[string]embySyncEntry) + } + for id, entry := range stored.Entries { + canonical, _, valid := playbackHistoryIdentity(id) + if !valid || canonical != id || !validEmbyFolder(entry.Folder) || entry.Episodes < 0 || entry.Episodes > 2000 { + err = errors.New("Emby 同步记录含无效的剧集或路径") + break + } + } + } + if err == nil { + manager.document = stored + } + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + manager.loadErr = err + } + app.embySync = manager + }) + return app.embySync +} + +func (settings *embySyncSettings) normalize() error { + if settings.IntervalMinutes < 5 || settings.IntervalMinutes > 1440 { + return errors.New("同步间隔范围为 5–1440 分钟") + } + if settings.Enabled && !settings.Following && !settings.Downloads { + return errors.New("请至少选择一种同步范围") + } + if len(settings.OutputDir) > 2048 || strings.TrimSpace(settings.OutputDir) == "" || strings.ContainsAny(settings.OutputDir, "\x00\r\n") { + return errors.New("请输入有效的 Emby 输出目录") + } + directory, err := normalizedOutputDirectory(settings.OutputDir) + if err == nil { + directory, err = filepath.Abs(directory) + } + if err != nil { + return errors.New("Emby 输出目录无效") + } + settings.OutputDir = directory + if settings.Enabled || settings.BaseURL != "" { + settings.BaseURL, err = embyBaseURL(settings.BaseURL) + if err != nil { + return err + } + } + if settings.ServerURL != "" { + settings.ServerURL, err = embyBaseURL(settings.ServerURL) + if err != nil { + return errors.New("请输入有效的 Emby 服务器 HTTP/HTTPS 地址") + } + if settings.Enabled && settings.APIKey == "" { + return errors.New("填写 Emby 服务器后,需要同时填写 API Key 才能通知扫描和同步海报") + } + } + if len(settings.APIKey) > 2048 || strings.ContainsAny(settings.APIKey, "\x00\r\n") { + return errors.New("Emby API Key 格式无效") + } + return nil +} + +func (manager *embySyncManager) start() { + manager.mu.Lock() + defer manager.mu.Unlock() + if manager.cancel != nil || manager.closed { + return + } + ctx, cancel := context.WithCancel(context.Background()) + manager.cancel, manager.done = cancel, make(chan struct{}) + manager.notify() + go manager.loop(ctx) +} + +func (manager *embySyncManager) stop() { + manager.mu.Lock() + manager.closed = true + cancel, done := manager.cancel, manager.done + manager.mu.Unlock() + if cancel != nil { + cancel() + <-done + } +} + +func (manager *embySyncManager) notify() { + select { + case manager.wake <- struct{}{}: + default: + } +} + +func (app *UIApp) notifyEmbySync() { + manager := app.embySyncer() + manager.mu.Lock() + enabled := manager.document.Settings.Enabled + manager.mu.Unlock() + if enabled { + manager.notify() + } +} + +func (manager *embySyncManager) loop(ctx context.Context) { + defer close(manager.done) + timer := time.NewTimer(time.Minute) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-manager.wake: + case <-timer.C: + } + if ctx.Err() != nil { + return + } + manager.runOnce(ctx) + manager.mu.Lock() + delay := time.Until(manager.document.NextRunAt) + if !manager.document.Settings.Enabled || delay < time.Minute { + delay = time.Minute + } + manager.mu.Unlock() + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(delay) + } +} + +func (manager *embySyncManager) owner(id string) (accountRecord, error) { + store := manager.app.browserViewers().accountStore() + store.mu.Lock() + defer store.mu.Unlock() + if store.err != nil { + return accountRecord{}, store.err + } + for _, account := range store.state.Accounts { + if account.ID == id && account.Admin && !account.RequirePasswordChange { + return account, nil + } + } + return accountRecord{}, errors.New("启用同步的管理员账号不可用,请重新登录管理员并保存同步设置") +} + +func (manager *embySyncManager) targets(ctx context.Context, config embySyncSettings, owner accountRecord) ([]Drama, error) { + selected := map[string]Drama{} + if config.Following { + viewer := manager.app.browserViewers().acquire(owner.viewerID()) + defer viewer.release() + entries, err := viewer.followingStore().list() + if err != nil { + return nil, err + } + history, err := viewer.playbackHistory().list() + if err != nil { + return nil, err + } + ids := make([]string, 0, len(entries)+len(history)) + for _, entry := range entries { + ids = append(ids, entry.DramaID) + } + for _, entry := range history { + ids = append(ids, entry.DramaID) + } + metadata := manager.app.followingMetadata(ids) + completed := map[string]bool{} + for _, entry := range entries { + completed[entry.DramaID] = entry.Completed && (entry.KnownEpisodes == 0 || metadata[entry.DramaID].KnownEpisodes <= entry.KnownEpisodes) + if entry.Saved && !completed[entry.DramaID] { + selected[entry.DramaID] = Drama{ID: entry.DramaID, Title: entry.Title, Source: entry.Source} + } + } + for _, entry := range history { + if !completed[entry.DramaID] && !(entry.Completed && entry.Index >= entry.Total && entry.Index >= metadata[entry.DramaID].KnownEpisodes) { + selected[entry.DramaID] = Drama{ID: entry.DramaID, Title: entry.Title, Source: entry.Source} + } + } + } + app := manager.app + app.mu.Lock() + if config.Downloads { + for _, task := range app.tasks { + if task != nil && task.Status == uiStatusSuccess && !isChapterPlaceholderTask(task) && taskSourceAllowed(ctx, task.Source) { + selected[task.DramaID] = Drama{ID: task.DramaID, Title: task.DramaTitle, Source: task.Source.Chapter.Source} + } + } + } + for _, drama := range app.dramas { + if _, exists := selected[drama.ID]; exists { + selected[drama.ID] = drama + } + } + app.mu.Unlock() + if len(selected) > 1000 { + return nil, errors.New("自动同步最多支持 1000 部,请缩小追剧或下载同步范围") + } + result := make([]Drama, 0, len(selected)) + for _, drama := range selected { + id, _, valid := playbackHistoryIdentity(drama.ID) + if valid && id == drama.ID && dramaAllowed(ctx, drama.ID, drama.Source) { + result = append(result, drama) + } + } + return result, nil +} + +func (manager *embySyncManager) runOnce(parent context.Context) { + manager.mu.Lock() + if manager.running || manager.loadErr != nil || !manager.document.Settings.Enabled || manager.closed { + manager.mu.Unlock() + return + } + previous := manager.document + document := manager.document + document.Entries = make(map[string]embySyncEntry, len(manager.document.Entries)) + for id, entry := range manager.document.Entries { + document.Entries[id] = entry + } + generation := manager.generation + ctx, cancel := context.WithTimeout(parent, 10*time.Minute) + manager.running, manager.runCancel = true, cancel + manager.document.LastStartedAt = time.Now() + document.LastStartedAt = manager.document.LastStartedAt + manager.mu.Unlock() + defer cancel() + document.WrittenFiles, document.Succeeded, document.Failed, document.Pending, document.Error = 0, 0, 0, 0, "" + document.PostersUpdated, document.PosterChecked = 0, 0 + document.NextRunAt = time.Now().Add(time.Duration(document.Settings.IntervalMinutes) * time.Minute) + err := manager.synchronize(ctx, &document) + if err != nil { + document.Error = manager.safeError(err, document.Settings.APIKey) + } + document.LastFinishedAt = time.Now() + if document.Pending > 0 || document.RefreshPending || time.Until(document.NextRunAt) < time.Minute { + document.NextRunAt = time.Now().Add(time.Minute) + } + if document.Succeeded == 0 && document.Failed == 0 && document.PosterChecked == 0 && err == nil && !previous.RefreshPending { + document.LastStartedAt, document.LastFinishedAt = previous.LastStartedAt, previous.LastFinishedAt + document.Succeeded, document.Failed, document.WrittenFiles, document.Error = previous.Succeeded, previous.Failed, previous.WrittenFiles, previous.Error + } + manager.mu.Lock() + manager.running, manager.runCancel = false, nil + if generation == manager.generation { + if err = writeEmbyJSON(manager.path, document, 0600); err != nil { + document.Error = "同步状态保存失败:" + manager.safeError(err, document.Settings.APIKey) + } + manager.document = document + } + manager.mu.Unlock() +} + +func (manager *embySyncManager) safeError(err error, key string) string { + if err == nil { + return "" + } + text := err.Error() + if key != "" { + text = strings.ReplaceAll(text, key, "[redacted]") + } + return truncate(manager.app.redactError(errors.New(text)), 1024) +} + +func (manager *embySyncManager) synchronize(ctx context.Context, document *embySyncDocument) error { + owner, err := manager.owner(document.OwnerID) + if err != nil { + return err + } + ctx = context.WithValue(withSourceScope(ctx, owner), backgroundCatalogKey{}, true) + targets, err := manager.targets(ctx, document.Settings, owner) + if err != nil { + return err + } + interval := time.Duration(document.Settings.IntervalMinutes) * time.Minute + defer func() { + next := time.Now().Add(interval) + for _, drama := range targets { + if due := document.Entries[drama.ID].CheckedAt.Add(interval); due.Before(next) { + next = due + } + if due := embyPosterDue(document.Entries[drama.ID].Poster, interval); !due.IsZero() && due.Before(next) { + next = due + } + } + document.NextRunAt = next + document.countPosters(targets) + }() + var key []byte + if len(targets) > 0 { + key, err = manager.app.embySigningKey(true) + if err != nil { + return err + } + } + due := make([]Drama, 0, len(targets)) + for _, drama := range targets { + if time.Since(document.Entries[drama.ID].CheckedAt) >= interval { + due = append(due, drama) + } + } + sort.Slice(due, func(i, j int) bool { + left, right := document.Entries[due[i].ID].CheckedAt, document.Entries[due[j].ID].CheckedAt + if left.Equal(right) { + return due[i].ID < due[j].ID + } + return left.Before(right) + }) + document.Pending = len(due) + if len(due) > 50 { + due = due[:50] + } + if len(due) > 0 { + for _, drama := range due { + if err := ctx.Err(); err != nil { + return err + } + entry := document.Entries[drama.ID] + entry.Title, entry.CheckedAt = drama.DisplayTitle(), time.Now() + dramaCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + title, chapters, fetchErr := manager.app.downloader.GetDramaChapters(dramaCtx, drama.ID) + if fetchErr == nil { + drama.Title = firstNonEmpty(title, drama.DisplayTitle()) + var written, episodes int + entry.Folder, written, episodes, fetchErr = syncEmbyDramaFiles(dramaCtx, document.Settings, drama, chapters, entry.Folder, key, owner.ID) + document.WrittenFiles += written + if written > 0 { + document.RefreshPending = document.Settings.ServerURL != "" + } + if fetchErr == nil { + entry.Title, entry.Episodes, entry.SyncedAt = drama.DisplayTitle(), episodes, time.Now() + } + } + cancel() + entry.Error = manager.safeError(fetchErr, document.Settings.APIKey) + if fetchErr == nil { + document.Succeeded++ + } else { + document.Failed++ + } + document.Entries[drama.ID] = entry + document.Pending-- + } + } + if document.WrittenFiles > 0 { + document.RefreshPending = document.Settings.ServerURL != "" + } + manager.preparePosters(document, targets, key) + if document.RefreshPending && document.Settings.ServerURL != "" { + if err := notifyEmbyLibrary(ctx, document.Settings); err != nil { + return fmt.Errorf("文件已保留,通知 Emby 扫描失败:%w", err) + } + document.RefreshPending = false + } + manager.synchronizePosters(ctx, document, targets, key) + document.countPosters(targets) + if document.Failed > 0 { + return fmt.Errorf("%d 部同步失败,已导入内容保留,可查看下方详情或稍后重试", document.Failed) + } + return document.posterError() +} + +func notifyEmbyLibrary(ctx context.Context, settings embySyncSettings) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, settings.ServerURL+"/Library/Refresh", nil) + if err != nil { + return err + } + request.Header.Set("X-Emby-Token", settings.APIKey) + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + response, err := client.Do(request) + if err != nil { + return publicError(err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("Emby HTTP %d", response.StatusCode) + } + _, err = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return err +} + +func readEmbyJSON(path string, target any, limit int64) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, limit+1)) + if err := decoder.Decode(target); err != nil { + return err + } + if decoder.Decode(new(any)) != io.EOF { + return errors.New("Emby 配置或清单格式无效") + } + info, err := file.Stat() + if err != nil { + return err + } + if info.Size() > limit { + return errors.New("Emby 配置或清单过大") + } + return nil +} + +func writeEmbyJSON(path string, value any, mode os.FileMode) error { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + _, err = writeEmbyFile(path, append(body, '\n'), mode) + return err +} diff --git a/internal/app/emby_sync_files.go b/internal/app/emby_sync_files.go new file mode 100644 index 0000000..af21fc2 --- /dev/null +++ b/internal/app/emby_sync_files.go @@ -0,0 +1,164 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +type embyFolderManifest struct { + Version int `json:"version"` + DramaID string `json:"dramaId"` + Chapters []embyChapter `json:"chapters"` +} + +func validEmbyFolder(folder string) bool { + return folder == "" || len(folder) <= 240 && folder != "." && folder != ".." && !strings.ContainsAny(folder, "/\\\x00\r\n") && filepath.Base(folder) == folder +} + +func ensureEmbyDirectory(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return os.Mkdir(path, 0755) + } + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("Emby 生成目录不是普通文件夹,已停止写入") + } + return nil +} + +func syncEmbyDramaFiles(ctx context.Context, settings embySyncSettings, drama Drama, chapters []Chapter, folder string, key []byte, owner string) (string, int, int, error) { + if !validEmbyFolder(folder) { + return folder, 0, 0, errors.New("Emby 剧集目录无效") + } + if folder == "" { + folder = embyFolderName(drama) + } + if err := os.MkdirAll(settings.OutputDir, 0755); err != nil { + return folder, 0, 0, err + } + root, err := filepath.EvalSymlinks(settings.OutputDir) + if err != nil { + return folder, 0, 0, err + } + target := filepath.Join(root, folder) + work := target + var previous []embyChapter + staged := false + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + work, err = os.MkdirTemp(root, ".juku-emby-") + if err != nil { + return folder, 0, 0, err + } + staged = true + defer os.RemoveAll(work) + if err = os.Chmod(work, 0755); err != nil { + return folder, 0, 0, err + } + } else { + if err != nil { + return folder, 0, 0, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return folder, 0, 0, errors.New("Emby 剧集目录不是普通文件夹") + } + manifestPath := filepath.Join(work, ".juku-emby.json") + info, err := os.Lstat(manifestPath) + if err != nil || !info.Mode().IsRegular() { + return folder, 0, 0, errors.New("同名目录不属于自动同步,原内容已保留;请使用单独的 Emby 输出目录") + } + var manifest embyFolderManifest + if err = readEmbyJSON(manifestPath, &manifest, 8<<20); err != nil { + return folder, 0, 0, err + } + if manifest.Version != 1 || manifest.DramaID != drama.ID { + return folder, 0, 0, errors.New("Emby 目录所属剧集不匹配,原内容已保留") + } + previous = manifest.Chapters + } + records, err := embyChapterRecords(drama.ID, chapters, previous) + if err != nil { + return folder, 0, 0, err + } + if err = ensureEmbyDirectory(filepath.Join(work, "Season 01")); err != nil { + return folder, 0, 0, err + } + written := 0 + err = writeEmbyFiles(drama, records, settings.BaseURL, key, owner, func(name string, body []byte) error { + if err := ctx.Err(); err != nil { + return err + } + changed, err := writeEmbyFile(filepath.Join(work, filepath.FromSlash(name)), body, 0644) + if changed { + written++ + } + return err + }) + if err == nil { + body, marshalErr := json.MarshalIndent(embyFolderManifest{Version: 1, DramaID: drama.ID, Chapters: records}, "", " ") + err = marshalErr + if err == nil { + _, err = writeEmbyFile(filepath.Join(work, ".juku-emby.json"), append(body, '\n'), 0600) + } + } + if err == nil { + err = ctx.Err() + } + if staged { + if err == nil { + err = os.Rename(work, target) + } + if err != nil { + written = 0 + } + } + return folder, written, len(records), err +} + +func writeEmbyFile(path string, body []byte, mode os.FileMode) (bool, error) { + info, err := os.Lstat(path) + if err == nil { + if !info.Mode().IsRegular() || info.Size() > 8<<20 { + return false, errors.New("Emby 目标文件不是普通文件或文件过大,已停止写入") + } + old, err := os.ReadFile(path) + if err != nil { + return false, err + } + if bytes.Equal(old, body) { + return false, nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + file, err := os.CreateTemp(filepath.Dir(path), ".juku-emby-write-") + if err != nil { + return false, err + } + defer os.Remove(file.Name()) + if err = file.Chmod(mode); err == nil { + _, err = file.Write(body) + } + if err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return false, err + } + if err = os.Rename(file.Name(), path); err != nil { + return false, fmt.Errorf("Emby 文件替换失败:%w", err) + } + return true, nil +} diff --git a/internal/app/emby_sync_posters.go b/internal/app/emby_sync_posters.go new file mode 100644 index 0000000..4ab74eb --- /dev/null +++ b/internal/app/emby_sync_posters.go @@ -0,0 +1,208 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sort" + "time" +) + +type embyPosterState struct { + SourceURL string `json:"sourceUrl,omitempty"` + Destination string `json:"destination,omitempty"` + ItemID string `json:"itemId,omitempty"` + ImageTag string `json:"imageTag,omitempty"` + SyncedSource string `json:"syncedSource,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + CheckedAt time.Time `json:"checkedAt"` + SyncedAt time.Time `json:"syncedAt"` + RetryAt time.Time `json:"retryAt"` + Failures int `json:"failures,omitempty"` +} + +func embyPosterDestination(settings embySyncSettings, key []byte) string { + digest := sha256.Sum256([]byte(settings.ServerURL + "\x00" + settings.OutputDir + "\x00" + embyMetadataID(key, "instance"))) + return hex.EncodeToString(digest[:]) +} + +func embyPosterSourceHash(remote string) string { + digest := sha256.Sum256([]byte(remote)) + return hex.EncodeToString(digest[:]) +} + +func embyPosterDue(poster embyPosterState, interval time.Duration) time.Time { + if poster.Status == "missing" || poster.Status == "unconfigured" || poster.SourceURL == "" { + return time.Time{} + } + if !poster.RetryAt.IsZero() { + return poster.RetryAt + } + if poster.CheckedAt.IsZero() { + return time.Now() + } + return poster.CheckedAt.Add(interval) +} + +func (manager *embySyncManager) preparePosters(document *embySyncDocument, targets []Drama, key []byte) { + destination := embyPosterDestination(document.Settings, key) + for _, drama := range targets { + entry := document.Entries[drama.ID] + if entry.Episodes == 0 { + continue + } + poster := entry.Poster + if poster.Destination != destination { + poster = embyPosterState{SourceURL: poster.SourceURL, Destination: destination} + } + remote := embyDramaCover(drama) + if remote == "" { + remote = embyDramaCover(Drama{CoverURL: poster.SourceURL}) + } + if poster.SourceURL != remote { + poster.SourceURL = remote + poster.CheckedAt, poster.RetryAt, poster.Failures = time.Time{}, time.Time{}, 0 + poster.Status, poster.Error = "pending", "" + } + if remote == "" { + poster.Status, poster.Error = "missing", "" + } else if document.Settings.ServerURL == "" { + poster.Status, poster.Error = "unconfigured", "" + } else if poster.Status == "" || poster.Status == "missing" || poster.Status == "unconfigured" { + poster.Status = "pending" + poster.CheckedAt, poster.RetryAt = time.Time{}, time.Time{} + } + entry.Poster = poster + document.Entries[drama.ID] = entry + } +} + +func (manager *embySyncManager) postponePoster(poster *embyPosterState, waiting bool, err error, key string) { + poster.Status = "failed" + if waiting { + poster.Status = "waiting" + } + poster.Error = manager.safeError(err, key) + poster.CheckedAt = time.Now() + if poster.Failures < 5 { + poster.Failures++ + } + delays := [...]time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute} + poster.RetryAt = time.Now().Add(delays[poster.Failures-1]) +} + +func (manager *embySyncManager) synchronizePosters(ctx context.Context, document *embySyncDocument, targets []Drama, key []byte) { + if document.Settings.ServerURL == "" { + return + } + interval := time.Duration(document.Settings.IntervalMinutes) * time.Minute + var due []string + for _, drama := range targets { + poster := document.Entries[drama.ID].Poster + if next := embyPosterDue(poster, interval); !next.IsZero() && !next.After(time.Now()) { + due = append(due, drama.ID) + } + } + sort.Slice(due, func(i, j int) bool { + left, right := document.Entries[due[i]].Poster.CheckedAt, document.Entries[due[j]].Poster.CheckedAt + if left.Equal(right) { + return due[i] < due[j] + } + return left.Before(right) + }) + if len(due) > 20 { + due = due[:20] + } + if len(due) == 0 { + return + } + client := newEmbyAPIClient(document.Settings) + defer client.close() + listCtx, cancel := context.WithTimeout(ctx, time.Minute) + items, listErr := client.series(listCtx) + cancel() + for _, id := range due { + if ctx.Err() != nil { + break + } + entry := document.Entries[id] + poster := entry.Poster + document.PosterChecked++ + if listErr != nil { + manager.postponePoster(&poster, false, listErr, document.Settings.APIKey) + } else { + item, err := matchEmbySeries(items, entry.Folder, embyMetadataID(key, id)) + if err != nil { + manager.postponePoster(&poster, false, err, document.Settings.APIKey) + } else if item.ID == "" { + manager.postponePoster(&poster, true, errors.New("等待 Emby 扫描此剧;请确认输出目录已加入电视剧媒体库,并启用 NFO 元数据读取"), document.Settings.APIKey) + } else { + manager.synchronizePoster(ctx, client, document, id, &poster, item, key) + } + } + entry.Poster = poster + document.Entries[id] = entry + } +} + +func (manager *embySyncManager) synchronizePoster(ctx context.Context, client *embyAPIClient, document *embySyncDocument, id string, poster *embyPosterState, item embyMediaItem, key []byte) { + currentTag := item.ImageTags["Primary"] + sourceHash := embyPosterSourceHash(poster.SourceURL) + sameItem := poster.ItemID == item.ID + managed := sameItem && poster.ImageTag != "" && currentTag == poster.ImageTag + if currentTag != "" && !managed { + poster.Status, poster.Error = "existing", "" + poster.ItemID, poster.ImageTag, poster.SyncedSource = item.ID, "", "" + poster.SyncedAt = time.Time{} + poster.CheckedAt, poster.RetryAt, poster.Failures = time.Now(), time.Time{}, 0 + return + } + if currentTag != "" && managed && poster.SyncedSource == sourceHash { + poster.Status, poster.Error, poster.ImageTag = "synced", "", currentTag + poster.CheckedAt, poster.RetryAt, poster.Failures = time.Now(), time.Time{}, 0 + return + } + address := embyCoverURL(Drama{ID: id, CoverURL: poster.SourceURL}, document.Settings.BaseURL, key, document.OwnerID) + if address == "" { + manager.postponePoster(poster, false, errors.New("海报地址无效,请更新剧集资料后重试"), document.Settings.APIKey) + return + } + requestCtx, cancel := context.WithTimeout(ctx, 95*time.Second) + defer cancel() + tag, uploaded, err := client.downloadPoster(requestCtx, item.ID, address) + if uploaded { + poster.ItemID, poster.SyncedSource, poster.ImageTag = item.ID, sourceHash, "" + poster.SyncedAt = time.Now() + document.PostersUpdated++ + } + if err != nil { + manager.postponePoster(poster, false, err, document.Settings.APIKey) + return + } + poster.ImageTag, poster.Status, poster.Error = tag, "synced", "" + poster.CheckedAt, poster.RetryAt, poster.Failures = time.Now(), time.Time{}, 0 +} + +func (document *embySyncDocument) countPosters(targets []Drama) { + document.PostersPending, document.PostersFailed, document.PostersSynced = 0, 0, 0 + for _, drama := range targets { + switch document.Entries[drama.ID].Poster.Status { + case "pending", "waiting": + document.PostersPending++ + case "failed": + document.PostersFailed++ + case "synced": + document.PostersSynced++ + } + } +} + +func (document *embySyncDocument) posterError() error { + if document.PostersFailed == 0 { + return nil + } + return fmt.Errorf("%d 部海报同步失败,分集文件保留,海报会自动重试;请查看下方详情", document.PostersFailed) +} diff --git a/internal/app/emby_sync_test.go b/internal/app/emby_sync_test.go new file mode 100644 index 0000000..f37f10e --- /dev/null +++ b/internal/app/emby_sync_test.go @@ -0,0 +1,335 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func embySyncTestChapters() []Chapter { + return []Chapter{{ID: "fixture-1", Title: "第一集"}, {ID: "fixture-2", Title: "第二集"}} +} + +func TestEmbySyncFilesIdempotentStableAndNonDestructive(t *testing.T) { + settings := embySyncSettings{OutputDir: t.TempDir(), BaseURL: "http://library.test:8998"} + drama := Drama{ID: historyFixtureDramaID, Title: "合成测试 <&>"} + key := make([]byte, 32) + chapters := embySyncTestChapters() + folder, written, total, err := syncEmbyDramaFiles(context.Background(), settings, drama, chapters, "", key, "fixture-owner") + if err != nil || written != 5 || total != 2 { + t.Fatal(folder, written, total, err) + } + first := filepath.Join(settings.OutputDir, folder, "Season 01", "S01E001.strm") + before, _ := os.Stat(first) + original, _ := os.ReadFile(first) + if !strings.Contains(string(original), "account=fixture-owner") { + t.Fatal("automated link not bound to owner") + } + _, written, _, err = syncEmbyDramaFiles(context.Background(), settings, drama, chapters, folder, key, "fixture-owner") + after, _ := os.Stat(first) + if err != nil || written != 0 || !before.ModTime().Equal(after.ModTime()) { + t.Fatal("unchanged export rewrote files", written, err) + } + note := filepath.Join(settings.OutputDir, folder, "my-notes.txt") + if err = os.WriteFile(note, []byte("user text"), 0600); err != nil { + t.Fatal(err) + } + chapters = []Chapter{chapters[1], chapters[0], {ID: "fixture-3", Title: "第三集"}} + drama.Title = "资料更新后的合成剧名" + same, written, total, err := syncEmbyDramaFiles(context.Background(), settings, drama, chapters, folder, key, "fixture-owner") + if err != nil || same != folder || total != 3 { + t.Fatal("renamed/reordered series duplicated or renumbered", same, total, err) + } + current, _ := os.ReadFile(first) + if string(current) != string(original) { + t.Fatal("existing episode link changed after source order changed") + } + if body, _ := os.ReadFile(note); string(body) != "user text" { + t.Fatal("unrelated file was changed") + } + _, _, total, err = syncEmbyDramaFiles(context.Background(), settings, drama, chapters[:1], folder, key, "fixture-owner") + if err != nil || total != 3 { + t.Fatal("temporarily shorter upstream deleted previous episodes", total, err) + } + marker := filepath.Join(settings.OutputDir, folder, ".juku-emby.json") + var manifest embyFolderManifest + if err = readEmbyJSON(marker, &manifest, 8<<20); err != nil || len(manifest.Chapters) != 3 { + t.Fatal(err) + } +} + +func TestEmbySyncRefusesUnmanagedFoldersAndSymlinks(t *testing.T) { + root := t.TempDir() + settings := embySyncSettings{OutputDir: root, BaseURL: "http://library.test"} + drama := Drama{ID: historyFixtureDramaID, Title: "独立目录"} + folder := embyFolderName(drama) + if err := os.Mkdir(filepath.Join(root, folder), 0755); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(root, folder, "sentinel.txt") + os.WriteFile(sentinel, []byte("preserved"), 0600) + if _, _, _, err := syncEmbyDramaFiles(context.Background(), settings, drama, embySyncTestChapters(), "", make([]byte, 32), "owner"); err == nil { + t.Fatal("unmanaged directory adopted without permission") + } + if data, _ := os.ReadFile(sentinel); string(data) != "preserved" { + t.Fatal("unmanaged directory modified") + } + settings.OutputDir = t.TempDir() + folder, _, _, err := syncEmbyDramaFiles(context.Background(), settings, drama, embySyncTestChapters(), "", make([]byte, 32), "owner") + if err != nil { + t.Fatal(err) + } + target := filepath.Join(settings.OutputDir, folder, "Season 01", "S01E001.strm") + if err = os.Remove(target); err != nil { + t.Fatal(err) + } + if err = os.Symlink(sentinel, target); err != nil { + t.Skip("symlinks unavailable") + } + if _, _, _, err = syncEmbyDramaFiles(context.Background(), settings, drama, embySyncTestChapters(), folder, make([]byte, 32), "owner"); err == nil { + t.Fatal("managed file symlink followed") + } + if data, _ := os.ReadFile(sentinel); string(data) != "preserved" { + t.Fatal("symlink target modified") + } + for _, folder := range []string{"../escape", "..", "/absolute", "nested/path", "nested\\path"} { + if _, _, _, err := syncEmbyDramaFiles(context.Background(), settings, drama, embySyncTestChapters(), folder, make([]byte, 32), "owner"); err == nil { + t.Fatal("unsafe folder accepted", folder) + } + } +} + +func waitEmbySync(t *testing.T, manager *embySyncManager, after time.Time) embySyncDocument { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + manager.mu.Lock() + done := !manager.running && manager.document.LastFinishedAt.After(after) + snapshot := manager.document + manager.mu.Unlock() + if done { + return snapshot + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("automatic Emby synchronization did not finish") + return embySyncDocument{} +} + +func TestEmbySyncAutomaticFollowedImportRefreshAndRestart(t *testing.T) { + app, admin := administratorFixture(t) + manager := app.embySyncer() + t.Cleanup(manager.stop) + ordinary := newAccountTestBrowser(t, app) + ordinary.register(t, "other-viewer") + otherID := "hongguo:7000000000000000002" + app.dramas = append(app.dramas, Drama{ID: otherID, Source: sourceHongguo, Title: "另一个用户的剧"}) + viewerResultOK(t, ordinary.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": otherID, "saved": true})) + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true})) + var scans atomic.Int32 + emby := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/emby/Library/Refresh" || r.Header.Get("X-Emby-Token") != "private-test-key" || r.URL.RawQuery != "" { + t.Error("invalid Emby refresh request") + } + scans.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer emby.Close() + settings := map[string]any{"enabled": true, "outputDir": t.TempDir(), "baseUrl": "http://library.test:8998", "intervalMinutes": 60, "following": true, "downloads": false, "serverUrl": emby.URL + "/emby", "apiKey": "private-test-key"} + started := time.Now() + response := admin.request(t, http.MethodPost, "/api/ui/admin/emby", settings) + viewerResultOK(t, response) + if strings.Contains(response.Body.String(), "private-test-key") { + t.Fatal("API response leaked the Emby key") + } + result := waitEmbySync(t, manager, started) + if result.Error != "" || result.Succeeded != 1 || len(result.Entries) != 1 || result.Entries[historyFixtureDramaID].Episodes != 3 || scans.Load() != 1 { + t.Fatal("automatic import failed or crossed accounts", result.Error, result.Succeeded, len(result.Entries), scans.Load()) + } + output := settings["outputDir"].(string) + entry := result.Entries[historyFixtureDramaID] + strm, err := os.ReadFile(filepath.Join(output, entry.Folder, "Season 01", "S01E001.strm")) + if err != nil { + t.Fatal(err) + } + link := strings.TrimSpace(string(strm)) + parsed, _ := url.Parse(link) + if parsed.Query().Get("account") == "" { + t.Fatal("automatic export created a guest link") + } + writer := httptest.NewRecorder() + app.handleEmbyStream(writer, httptest.NewRequest(http.MethodHead, link, nil)) + if writer.Code != 200 { + t.Fatal("automatic signed link unusable", writer.Code) + } + info, err := os.Stat(manager.path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("sync settings not saved privately") + } + manager.stop() + restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, dramas: app.dramas} + next := restarted.embySyncer() + if next.loadErr != nil || !next.document.Settings.Enabled || next.document.Settings.APIKey != "private-test-key" { + t.Fatal("restart lost configuration", next.loadErr) + } + for id, entry := range next.document.Entries { + entry.CheckedAt = time.Time{} + next.document.Entries[id] = entry + } + next.runOnce(context.Background()) + if next.document.WrittenFiles != 0 || scans.Load() != 1 || next.document.Error != "" { + t.Fatal("restart duplicated exports or scans", next.document.WrittenFiles, scans.Load(), next.document.Error) + } + if data, err := os.ReadFile(filepath.Join(output, entry.Folder, "Season 01", "S01E001.strm")); err != nil || string(data) != string(strm) { + t.Fatal("restart changed stable link") + } +} + +func TestEmbySyncAdminGateDisabledDefaultsAndValidation(t *testing.T) { + app, admin := administratorFixture(t) + manager := app.embySyncer() + t.Cleanup(manager.stop) + guest := newAccountTestBrowser(t, app) + member := newAccountTestBrowser(t, app) + member.register(t, "online-only-fixture") + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "online-only-fixture", "onlineOnly": true})) + for _, browser := range []*accountTestBrowser{guest, member} { + for _, method := range []string{http.MethodGet, http.MethodPost} { + response := browser.request(t, method, "/api/ui/admin/emby", map[string]any{}) + if response.Code != http.StatusForbidden { + t.Fatal("non-admin accessed server export settings", response.Code) + } + } + } + response := admin.request(t, http.MethodGet, "/api/ui/admin/emby", nil) + viewerResultOK(t, response) + if manager.document.Settings.Enabled { + t.Fatal("automatic export enabled by default") + } + if _, err := os.Stat(manager.path); !os.IsNotExist(err) { + t.Fatal("reading settings created an export job") + } + input := map[string]any{"enabled": true, "outputDir": t.TempDir(), "baseUrl": "file:///private/fixture", "intervalMinutes": 60, "following": true, "downloads": false} + if result := admin.request(t, http.MethodPost, "/api/ui/admin/emby", input); result.Code != http.StatusBadRequest { + t.Fatal("invalid playback address accepted", result.Code) + } + input["baseUrl"] = "http://library.test" + input["intervalMinutes"] = 1 + if result := admin.request(t, http.MethodPost, "/api/ui/admin/emby", input); result.Code != http.StatusBadRequest { + t.Fatal("unbounded refresh frequency accepted", result.Code) + } + input["intervalMinutes"] = 60 + input["enabled"] = false + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/emby", input)) + if result := admin.request(t, http.MethodPost, "/api/ui/admin/emby/sync", map[string]any{}); result.Code != http.StatusConflict { + t.Fatal("disabled sync executed", result.Code) + } +} + +func TestEmbySyncPartialFailurePreservesFilesAndRetriesScan(t *testing.T) { + app, admin := administratorFixture(t) + manager := app.embySyncer() + t.Cleanup(manager.stop) + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true})) + owner := app.browserViewers().accountStore().state.Accounts["admin"] + var scans atomic.Int32 + emby := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := scans.Add(1) + if count == 1 { + w.WriteHeader(503) + return + } + w.WriteHeader(204) + })) + defer emby.Close() + manager.document.Settings = embySyncSettings{Enabled: true, OutputDir: t.TempDir(), BaseURL: "http://library.test", IntervalMinutes: 60, Following: true, ServerURL: emby.URL, APIKey: "scan-secret"} + manager.document.OwnerID = owner.ID + manager.runOnce(context.Background()) + if !manager.document.RefreshPending || !strings.Contains(manager.document.Error, "HTTP 503") { + t.Fatal("failed scan was not retained for retry", manager.document.Error) + } + entry := manager.document.Entries[historyFixtureDramaID] + before, _ := os.ReadFile(filepath.Join(manager.document.Settings.OutputDir, entry.Folder, "Season 01", "S01E001.strm")) + manager.runOnce(context.Background()) + if manager.document.RefreshPending || scans.Load() != 2 || manager.document.WrittenFiles != 0 { + t.Fatal("scan retry rewrote files or was skipped") + } + client := app.downloader.hongguoClient() + broken := client.details["7000000000000000001"] + broken.Chapters = nil + client.details["7000000000000000001"] = broken + entry.CheckedAt = time.Time{} + manager.document.Entries[historyFixtureDramaID] = entry + manager.runOnce(context.Background()) + if manager.document.Failed != 1 || manager.document.Entries[historyFixtureDramaID].Episodes != 3 { + t.Fatal("upstream failure lost previous imported episodes") + } + after, _ := os.ReadFile(filepath.Join(manager.document.Settings.OutputDir, entry.Folder, "Season 01", "S01E001.strm")) + if string(before) != string(after) { + t.Fatal("failed source response overwrote completed export") + } + state, _ := json.Marshal(manager.view()) + if strings.Contains(string(state), "scan-secret") { + t.Fatal("status leaked API key") + } +} + +func TestEmbySyncDownloadedCollectionAndLocalPlaybackPriority(t *testing.T) { + app, _ := administratorFixture(t) + owner := app.browserViewers().accountStore().state.Accounts["admin"] + cached := app.downloader.hongguoClient().details["7000000000000000001"] + path := filepath.Join(t.TempDir(), "local-fixture.mp4") + if err := os.WriteFile(path, []byte("synthetic file identity only"), 0600); err != nil { + t.Fatal(err) + } + task := Task{DramaID: historyFixtureDramaID, DramaTitle: "下载完成的合成剧", Chapter: cached.Chapters[0], OutPath: path, Index: 1, Total: 3} + app.tasks = map[string]*UITask{"download-fixture": {ID: "download-fixture", DramaID: historyFixtureDramaID, DramaTitle: task.DramaTitle, Path: path, Status: uiStatusSuccess, Source: task}} + manager := app.embySyncer() + manager.document.OwnerID = owner.ID + manager.document.Settings = embySyncSettings{Enabled: true, OutputDir: t.TempDir(), BaseURL: "http://library.test", IntervalMinutes: 60, Downloads: true} + manager.runOnce(context.Background()) + if manager.document.Error != "" || manager.document.Entries[historyFixtureDramaID].Episodes != 3 { + t.Fatal("download collection not imported", manager.document.Error) + } + local, downloadID, err := app.embyTask(context.Background(), historyFixtureDramaID, task.Chapter.ID) + if err != nil || downloadID != "download-fixture" || local.OutPath != path { + t.Fatal("Emby did not prefer completed local file", downloadID, err) + } + firstRun := manager.document.LastFinishedAt + nextRun := manager.document.NextRunAt + manager.runOnce(context.Background()) + if manager.document.LastFinishedAt != firstRun || manager.document.NextRunAt.After(nextRun.Add(time.Second)) { + t.Fatal("unrelated notification postponed scheduled updates") + } +} + +func TestEmbySyncStartupDoesNotImmediatelyRepeatFailedScan(t *testing.T) { + app, admin := administratorFixture(t) + manager := app.embySyncer() + t.Cleanup(manager.stop) + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true})) + scans := make(chan struct{}, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { scans <- struct{}{}; w.WriteHeader(503) })) + defer server.Close() + started := time.Now() + input := map[string]any{"enabled": true, "outputDir": t.TempDir(), "baseUrl": "http://library.test", "intervalMinutes": 60, "following": true, "downloads": false, "serverUrl": server.URL, "apiKey": "fixture-scan-key"} + viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/emby", input)) + result := waitEmbySync(t, manager, started) + if result.Succeeded != 1 || !result.RefreshPending || !strings.Contains(result.Error, "HTTP 503") { + t.Fatal("failed scan status not preserved", result.Error) + } + <-scans + select { + case <-scans: + t.Fatal("startup and settings wake triggered duplicate immediate scan") + case <-time.After(500 * time.Millisecond): + } +} diff --git a/internal/app/ffmpeg_install.go b/internal/app/ffmpeg_install.go new file mode 100644 index 0000000..ffeeb9a --- /dev/null +++ b/internal/app/ffmpeg_install.go @@ -0,0 +1,406 @@ +package app + +import ( + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +const ffmpegReleaseURL = "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/" + +type ffmpegPackage struct { + platform string + archiveSize int64 + binarySize int64 + archiveSHA256 string + binarySHA256 string + licenseSHA256 string + readmeSHA256 string +} + +var ffmpegPackages = map[string]ffmpegPackage{ + "darwin/amd64": {"darwin-x64", 25296431, 78862176, + "929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106", "ebdddc936f61e14049a2d4b549a412b8a40deeff6540e58a9f2a2da9e6b18894", + "2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af", "e88a0325f8e5b75210355e37341824f074d3cd82def2125be54c914b62848a36"}, + "darwin/arm64": {"darwin-arm64", 19246198, 45568216, + "8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa", "a90e3db6a3fd35f6074b013f948b1aa45b31c6375489d39e572bea3f18336584", + "cb48bf09a11f5fb576cddb0431c8f5ed0a60157a9ec942adffc13907cbe083f2", "05ba4b92c96605434b1aaae3eedf5a2c280c9607bf78ffca9a5b536d9af2dc6a"}, + "linux/amd64": {"linux-x64", 29354986, 79826272, + "bfe8a8fc511530457b528c48d77b5737527b504a3797a9bc4866aeca69c2dffa", "e7e7fb30477f717e6f55f9180a70386c62677ef8a4d4d1a5d948f4098aa3eb99", + "8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903", "72f4b1b06d419d22ace6e7cc75f06826f90737345aa0b1736158929f4aacc537"}, + "windows/amd64": {"win32-x64", 29581307, 82797568, + "8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77", "04e1307997530f9cf2fe35cba2ca7e8875ca91da02f89d6c7243df819c94ad00", + "8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903", "a636a7183c58006351acbaf35303c0ed85c6e1320fd4e80de453ba6157de6311"}, +} + +type ffmpegInstallState struct { + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + Path string `json:"path,omitempty"` + DownloadedBytes int64 `json:"downloadedBytes"` + TotalBytes int64 `json:"totalBytes"` + Error string `json:"error,omitempty"` +} + +type ffmpegInstaller struct { + mu sync.Mutex + configured string + directory string + name string + client *http.Client + pack ffmpegPackage + state ffmpegInstallState + record func(diagnosticEvent) + done chan struct{} + cancel context.CancelFunc + lastError error + lastTry time.Time +} + +func (downloader *Downloader) ffmpegInstallation() *ffmpegInstaller { + downloader.ffmpegMu.Lock() + defer downloader.ffmpegMu.Unlock() + if downloader.ffmpegInstaller == nil { + directory, name := ffmpegManagedDirectory(), ffmpegExecutableName() + client := *downloader.client + client.Timeout = 0 + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if request.URL.Scheme != "https" || len(via) >= 10 { + return errors.New("FFmpeg 下载重定向不安全或次数过多") + } + return nil + } + installer := &ffmpegInstaller{ + configured: downloader.cfg.FFmpeg, directory: directory, name: name, client: &client, + pack: ffmpegPackages[runtime.GOOS+"/"+runtime.GOARCH], state: ffmpegInstallState{Status: "idle"}, record: downloader.recordDiagnostic, + } + downloader.ffmpegInstaller = installer + } + return downloader.ffmpegInstaller +} + +func (downloader *Downloader) ensureFFmpeg(ctx context.Context) (string, error) { + return downloader.ffmpegInstallation().ensure(ctx) +} + +func (installer *ffmpegInstaller) snapshot() ffmpegInstallState { + installer.mu.Lock() + defer installer.mu.Unlock() + return installer.state +} + +func (installer *ffmpegInstaller) update(status, detail string, downloaded, total int64) { + installer.mu.Lock() + installer.state.Status, installer.state.Detail = status, detail + installer.state.DownloadedBytes, installer.state.TotalBytes = downloaded, total + installer.mu.Unlock() +} + +func (installer *ffmpegInstaller) ensure(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + installer.mu.Lock() + if installer.state.Status == "ready" { + if path, err := exec.LookPath(installer.state.Path); err == nil { + installer.mu.Unlock() + return path, nil + } + installer.state.Status = "idle" + } + if installer.done == nil { + if installer.lastError != nil && time.Since(installer.lastTry) < 30*time.Second { + err := installer.lastError + installer.mu.Unlock() + return "", err + } + installer.done = make(chan struct{}) + installer.lastTry = time.Now() + installer.state = ffmpegInstallState{Status: "verifying", Detail: "正在检查 FFmpeg 的播放能力"} + installCtx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + installer.cancel = cancel + go installer.run(installCtx) + } + done := installer.done + installer.mu.Unlock() + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-done: + installer.mu.Lock() + defer installer.mu.Unlock() + return installer.state.Path, installer.lastError + } +} + +func (installer *ffmpegInstaller) run(ctx context.Context) { + path, err := installer.prepare(ctx) + err = publicError(err) + event := diagnosticEvent{Level: "info", Event: "ffmpeg.ready", Message: "FFmpeg 已就绪:" + path} + if err != nil { + event = diagnosticEvent{Event: "ffmpeg.failed", Message: err.Error()} + } + if installer.record != nil { + installer.record(event) + } + installer.mu.Lock() + defer installer.mu.Unlock() + installer.cancel() + installer.cancel = nil + installer.lastError = err + if err != nil { + installer.state.Status = "failed" + installer.state.Error = err.Error() + installer.state.Detail = "FFmpeg 准备失败,请检查路径、完整版本或代理" + } else { + installer.state = ffmpegInstallState{Status: "ready", Path: path, Detail: event.Message} + } + close(installer.done) + installer.done = nil +} + +func (installer *ffmpegInstaller) retry() { + installer.mu.Lock() + if installer.done == nil { + installer.lastError = nil + installer.state = ffmpegInstallState{Status: "idle"} + } + installer.mu.Unlock() +} + +func (installer *ffmpegInstaller) stop() { + installer.mu.Lock() + defer installer.mu.Unlock() + if installer.cancel != nil { + installer.cancel() + } +} + +func (installer *ffmpegInstaller) fetch(ctx context.Context, name, target, checksum string, limit int64, progress bool) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, ffmpegReleaseURL+name, nil) + if err != nil { + return err + } + request.Header.Set("User-Agent", "juku") + request.Header.Set("Accept-Encoding", "identity") + response, err := installer.client.Do(request) + if err != nil { + return publicError(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("FFmpeg 下载失败:HTTP %d,请检查代理设置后重试", response.StatusCode) + } + if response.ContentLength > limit { + return errors.New("FFmpeg 下载文件超过预期大小") + } + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + defer file.Close() + digest := sha256.New() + reader := io.TeeReader(io.LimitReader(response.Body, limit+1), digest) + var downloaded int64 + buffer := make([]byte, 128*1024) + lastReport := time.Time{} + for { + count, readErr := reader.Read(buffer) + if count > 0 { + downloaded += int64(count) + if downloaded > limit { + return errors.New("FFmpeg 下载文件超过预期大小") + } + if _, err := file.Write(buffer[:count]); err != nil { + return err + } + if progress && time.Since(lastReport) >= 200*time.Millisecond { + installer.update("downloading", "正在自动下载 FFmpeg", downloaded, installer.pack.archiveSize) + lastReport = time.Now() + } + } + if readErr != nil { + if readErr != io.EOF { + return readErr + } + break + } + } + if hex.EncodeToString(digest.Sum(nil)) != checksum { + return errors.New("FFmpeg 下载文件 SHA-256 校验失败,未安装或执行该文件") + } + return file.Close() +} + +func unpackFFmpeg(archive, output string, pack ffmpegPackage) error { + input, err := os.Open(archive) + if err != nil { + return err + } + defer input.Close() + reader, err := gzip.NewReader(input) + if err != nil { + return err + } + defer reader.Close() + file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + defer file.Close() + digest := sha256.New() + size, err := io.Copy(io.MultiWriter(file, digest), io.LimitReader(reader, pack.binarySize+1)) + if err != nil { + return err + } + if size != pack.binarySize || hex.EncodeToString(digest.Sum(nil)) != pack.binarySHA256 { + return errors.New("FFmpeg 可执行文件校验失败,未安装或执行该文件") + } + if err := file.Close(); err != nil { + return err + } + return os.Chmod(output, 0755) +} + +var configureFFmpegProbeProcess = func(command *exec.Cmd) {} + +func validateFFmpeg(ctx context.Context, path string) error { + checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + command := exec.CommandContext(checkCtx, path, "-hide_banner", "-encoders") + output := &cappedStringWriter{limit: 256 * 1024} + command.Stdout, command.Stderr = output, output + command.WaitDelay = time.Second + configureFFmpegProbeProcess(command) + if err := command.Run(); err != nil { + if checkCtx.Err() != nil { + return fmt.Errorf("FFmpeg 能力检查未完成:%w", checkCtx.Err()) + } + return fmt.Errorf("FFmpeg 无法启动:%w", err) + } + var video, audio bool + for _, line := range strings.Split(output.String(), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + video = video || fields[1] == "libx264" + audio = audio || fields[1] == "aac" + } + } + if !video || !audio { + return errors.New("FFmpeg 缺少 libx264 或 AAC 编码器,需要完整版本") + } + command = exec.CommandContext(checkCtx, path, + "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "1", "-filter_complex_threads", "1", + "-f", "lavfi", "-i", "color=c=black:s=16x16:r=30:d=0.1", + "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", + "-t", "0.1", "-map", "0:v:0", "-map", "1:a:0", + "-vf", "fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1", + "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-profile:v", "baseline", + "-pix_fmt", "yuv420p", "-crf", "23", "-threads", "1", + "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", + "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1") + output = &cappedStringWriter{limit: 16 * 1024} + command.Stdout, command.Stderr = io.Discard, output + command.WaitDelay = time.Second + configureFFmpegProbeProcess(command) + if err := command.Run(); err != nil { + if checkCtx.Err() != nil { + return fmt.Errorf("FFmpeg 播放能力检查未完成:%w", checkCtx.Err()) + } + return fmt.Errorf("FFmpeg 无法完成 H.264/AAC 播放转码(需要 preset 等编码选项):%w %s", err, truncate(strings.TrimSpace(output.String()), 1500)) + } + return nil +} + +func (installer *ffmpegInstaller) prepare(ctx context.Context) (string, error) { + candidates := ffmpegCandidates(installer.configured, installer.directory) + for _, path := range candidates { + installer.update("verifying", "正在检查 FFmpeg:"+path, 0, 0) + err := validateFFmpeg(ctx, path) + if err == nil { + return path, nil + } + if ctx.Err() != nil { + return "", ctx.Err() + } + if !automaticFFmpeg(installer.configured) { + return "", fmt.Errorf("指定的 FFmpeg 不可用(%s):%w;请更正 download.ffmpeg / -ffmpeg,或设为 ffmpeg 使用自动查找", path, err) + } + if installer.record != nil { + installer.record(diagnosticEvent{Level: "warn", Event: "ffmpeg.rejected", Message: "跳过不可用 FFmpeg:" + path + ":" + err.Error()}) + } + } + if !automaticFFmpeg(installer.configured) { + return "", fmt.Errorf("找不到指定的 FFmpeg(%s);请更正 download.ffmpeg / -ffmpeg,或设为 ffmpeg 使用自动查找", normalizedFFmpegSetting(installer.configured)) + } + detail := "未找到可用 FFmpeg,正在自动准备" + if len(candidates) > 0 { + detail = "现有 FFmpeg 未通过播放检查,正在自动准备兼容版本" + } + installer.update("downloading", detail, 0, installer.pack.archiveSize) + return installer.install(ctx) +} + +func (installer *ffmpegInstaller) install(ctx context.Context) (string, error) { + if !automaticFFmpeg(installer.configured) { + return "", errors.New("指定的 FFmpeg 不可用,请更正 -ffmpeg 路径,或恢复默认 ffmpeg 以启用自动下载") + } + if installer.pack.platform == "" { + return "", fmt.Errorf("暂不支持自动下载 %s/%s 的 FFmpeg,请将对应可执行文件放入 bin 目录", runtime.GOOS, runtime.GOARCH) + } + if err := os.MkdirAll(filepath.Dir(installer.directory), 0755); err != nil { + return "", fmt.Errorf("无法准备 FFmpeg 目录,请将程序移到可写目录:%w", err) + } + work, err := os.MkdirTemp(filepath.Dir(installer.directory), ".ffmpeg-install-*") + if err != nil { + return "", err + } + defer os.RemoveAll(work) + archive := filepath.Join(work, "download.gz") + pack := installer.pack + if err := installer.fetch(ctx, "ffmpeg-"+pack.platform+".gz", archive, pack.archiveSHA256, pack.archiveSize, true); err != nil { + return "", err + } + installer.update("verifying", "正在校验、解压 FFmpeg", pack.archiveSize, pack.archiveSize) + binary := filepath.Join(work, installer.name) + if err := unpackFFmpeg(archive, binary, pack); err != nil { + return "", err + } + if err := os.Remove(archive); err != nil { + return "", err + } + for _, document := range []struct{ remote, local, checksum string }{ + {pack.platform + ".LICENSE", "LICENSE.txt", pack.licenseSHA256}, + {pack.platform + ".README", "README.txt", pack.readmeSHA256}, + } { + if err := installer.fetch(ctx, document.remote, filepath.Join(work, document.local), document.checksum, 256*1024, false); err != nil { + return "", fmt.Errorf("下载 FFmpeg 许可与构建说明失败:%w", err) + } + } + if err := validateFFmpeg(ctx, binary); err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + if err := os.Rename(work, installer.directory); err != nil { + installed := filepath.Join(installer.directory, installer.name) + if validationErr := validateFFmpeg(ctx, installed); validationErr == nil { + return installed, nil + } + return "", fmt.Errorf("保存 FFmpeg 失败,请检查 bin 目录权限或已有文件:%w", err) + } + return filepath.Join(installer.directory, installer.name), nil +} diff --git a/internal/app/ffmpeg_install_test.go b/internal/app/ffmpeg_install_test.go new file mode 100644 index 0000000..ef4140e --- /dev/null +++ b/internal/app/ffmpeg_install_test.go @@ -0,0 +1,309 @@ +package app + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestMain(m *testing.M) { + if os.Getenv("JUKU_TEST_FFMPEG_HELPER") == "1" { + ffmpegTestHelper() + return + } + os.Exit(m.Run()) +} + +func ffmpegTestHelper() { + mode, _ := os.ReadFile(os.Args[0] + ".mode") + if log, err := os.OpenFile(os.Args[0]+".calls", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600); err == nil { + fmt.Fprintln(log, strings.Join(os.Args[1:], " ")) + _ = log.Close() + } + if string(mode) == "hang" { + time.Sleep(time.Minute) + } + if string(mode) == "slow" { + time.Sleep(100 * time.Millisecond) + } + if strings.Contains(strings.Join(os.Args[1:], " "), "-encoders") { + fmt.Println(" A..... aac AAC") + if string(mode) != "missing" { + fmt.Println(" V..... libx264 H.264") + } + return + } + if string(mode) == "preset" { + fmt.Fprintln(os.Stderr, "Unrecognized option 'preset'.\r\nError splitting the argument list: Option not found") + os.Exit(8) + } + fmt.Print("synthetic MP4 output") +} + +func ffmpegTestEnvironment(t *testing.T) string { + t.Helper() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(previous) }) + t.Setenv("PATH", "") + t.Setenv("JUKU_TEST_FFMPEG_HELPER", "1") + return root +} + +func ffmpegTestProgram(t *testing.T, directory, mode string) string { + t.Helper() + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatal(err) + } + program, err := os.Executable() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, ffmpegExecutableName()) + if err := os.Link(program, path); err != nil { + body, err := os.ReadFile(program) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(path+".mode", []byte(mode), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func ffmpegTestInstaller(t *testing.T, configured string) *ffmpegInstaller { + t.Helper() + return &ffmpegInstaller{ + configured: configured, name: ffmpegExecutableName(), directory: ffmpegManagedDirectory(), + client: &http.Client{Transport: rankingTransport(func(*http.Request) (*http.Response, error) { + t.Error("unexpected FFmpeg download") + return nil, errors.New("network forbidden") + })}, + } +} + +func TestFFmpegPATHPrecedesPortableAndKeepsAutomaticSetting(t *testing.T) { + root := ffmpegTestEnvironment(t) + ffmpegTestProgram(t, filepath.Join(root, "bin"), "preset") + ffmpegTestProgram(t, root, "preset") + full := ffmpegTestProgram(t, filepath.Join(root, "系统 PATH with spaces"), "good") + t.Setenv("PATH", filepath.Dir(full)) + d := NewDownloader(Config{FFmpeg: "ffmpeg", OutputDir: filepath.Join(root, "out"), dataDir: filepath.Join(root, "data"), settingsLoaded: true}) + if d.cfg.FFmpeg != "ffmpeg" { + t.Fatal("automatic discovery was frozen as an explicit portable path", d.cfg.FFmpeg) + } + if d.ffmpegInstallation().snapshot().Status == "ready" { + t.Fatal("unverified executable advertised as ready") + } + path, err := d.ensureFFmpeg(context.Background()) + if err != nil || path != full { + t.Fatal("PATH must take precedence over current-directory and bundled programs", path, err) + } + if !strings.Contains(d.ffmpegInstallation().snapshot().Detail, full) { + t.Fatal("the selected executable must be visible in status") + } +} + +func TestFFmpegSkipsIncompatiblePATHCandidates(t *testing.T) { + root := ffmpegTestEnvironment(t) + first := ffmpegTestProgram(t, filepath.Join(root, "minimal"), "missing") + second := ffmpegTestProgram(t, filepath.Join(root, "advertised-only"), "preset") + full := ffmpegTestProgram(t, filepath.Join(root, "full"), "good") + t.Setenv("PATH", strings.Join([]string{filepath.Dir(first), filepath.Dir(second), filepath.Dir(first), filepath.Dir(full)}, string(os.PathListSeparator))) + installer := ffmpegTestInstaller(t, "ffmpeg") + var events []diagnosticEvent + installer.record = func(event diagnosticEvent) { events = append(events, event) } + path, err := installer.ensure(context.Background()) + if err != nil || path != full { + t.Fatal("a PATH hit without working encoders must not mask a usable fallback", path, err) + } + if len(events) != 3 || events[0].Event != "ffmpeg.rejected" || !strings.Contains(events[1].Message, "preset") || events[2].Event != "ffmpeg.ready" || !strings.Contains(events[2].Message, full) { + t.Fatal("selection diagnostics must explain rejected candidates and the final path", events) + } + if candidates := ffmpegCandidates("ffmpeg", installer.directory); len(candidates) != 3 { + t.Fatal("duplicate PATH entries were not deduplicated", candidates) + } +} + +func TestFFmpegExplicitPathsRemainAuthoritative(t *testing.T) { + root := ffmpegTestEnvironment(t) + full := ffmpegTestProgram(t, filepath.Join(root, "full"), "good") + bad := ffmpegTestProgram(t, filepath.Join(root, "explicit"), "preset") + t.Setenv("PATH", filepath.Dir(full)) + installer := ffmpegTestInstaller(t, bad) + path, err := installer.ensure(context.Background()) + if err == nil || path != "" || !strings.Contains(err.Error(), "preset") || !strings.Contains(err.Error(), bad) { + t.Fatal("an invalid explicit path must report its failure without switching programs", path, err) + } + if installer.snapshot().Status != "failed" { + t.Fatal("invalid explicit program was marked ready") + } + if err := os.WriteFile(bad+".mode", []byte("good"), 0600); err != nil { + t.Fatal(err) + } + installer.retry() + path, err = installer.ensure(context.Background()) + if err != nil || path != bad { + t.Fatal("retry must probe a corrected explicit program", path, err) + } + installer = ffmpegTestInstaller(t, " \""+full+"\" ") + path, err = installer.ensure(context.Background()) + if err != nil || path != full { + t.Fatal("quoted paths with spaces were not resolved", path, err) + } + installer = ffmpegTestInstaller(t, filepath.Join(root, "missing", "ffmpeg.exe")) + if _, err := installer.ensure(context.Background()); err == nil || !strings.Contains(err.Error(), "找不到指定") { + t.Fatal("missing explicit path must not silently download", err) + } +} + +func TestFFmpegSharedProbeIsCachedAndCancelable(t *testing.T) { + root := ffmpegTestEnvironment(t) + program := ffmpegTestProgram(t, filepath.Join(root, "slow"), "slow") + installer := ffmpegTestInstaller(t, program) + var wait sync.WaitGroup + for index := 0; index < 8; index++ { + wait.Add(1) + go func() { + defer wait.Done() + if path, err := installer.ensure(context.Background()); err != nil || path != program { + t.Error(path, err) + } + }() + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := installer.ensure(ctx); !errors.Is(err, context.Canceled) { + t.Fatal("canceled waiter did not exit", err) + } + wait.Wait() + if _, err := installer.ensure(context.Background()); err != nil { + t.Fatal(err) + } + log, err := os.ReadFile(program + ".calls") + if err != nil || len(strings.Split(strings.TrimSpace(string(log)), "\n")) != 2 { + t.Fatal("concurrent users must share one encoder query and one real probe", string(log), err) + } + hanging := ffmpegTestProgram(t, filepath.Join(root, "hang"), "hang") + ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + start := time.Now() + if err := validateFFmpeg(ctx, hanging); err == nil || time.Since(start) > 3*time.Second { + t.Fatal("capability probe must honor cancellation", err, time.Since(start)) + } +} + +func ffmpegTestPackage(t *testing.T, installer *ffmpegInstaller) *atomic.Int32 { + t.Helper() + program, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary, err := os.ReadFile(program) + if err != nil { + t.Fatal(err) + } + var archive bytes.Buffer + writer, _ := gzip.NewWriterLevel(&archive, gzip.BestSpeed) + _, _ = writer.Write(binary) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + digest := func(body []byte) string { value := sha256.Sum256(body); return hex.EncodeToString(value[:]) } + document := []byte("synthetic test package documentation") + installer.pack = ffmpegPackage{platform: "fixture", archiveSize: int64(archive.Len()), binarySize: int64(len(binary)), archiveSHA256: digest(archive.Bytes()), binarySHA256: digest(binary), licenseSHA256: digest(document), readmeSHA256: digest(document)} + var requests atomic.Int32 + installer.client = &http.Client{Transport: rankingTransport(func(request *http.Request) (*http.Response, error) { + requests.Add(1) + body := document + if strings.HasSuffix(request.URL.Path, ".gz") { + body = archive.Bytes() + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(body)), ContentLength: int64(len(body)), Request: request}, nil + })} + return &requests +} + +func TestFFmpegAutomaticFallbackPreservesExistingPortableProgram(t *testing.T) { + root := ffmpegTestEnvironment(t) + old := ffmpegTestProgram(t, filepath.Join(root, "bin", runtime.GOOS+"-"+runtime.GOARCH), "missing") + info, err := os.Stat(old) + if err != nil { + t.Fatal(err) + } + installer := ffmpegTestInstaller(t, "ffmpeg") + requests := ffmpegTestPackage(t, installer) + path, err := installer.ensure(context.Background()) + if err != nil || path != filepath.Join(installer.directory, installer.name) || path == old { + t.Fatal("verified auto-download must be selected after rejecting the old portable program", path, err) + } + if requests.Load() != 3 { + t.Fatal("archive, license and build details must all be fetched", requests.Load()) + } + after, err := os.Stat(old) + if err != nil || !os.SameFile(info, after) { + t.Fatal("existing portable program was replaced", err) + } + restarted := ffmpegTestInstaller(t, "ffmpeg") + if next, err := restarted.ensure(context.Background()); err != nil || next != path { + t.Fatal("restart must reuse the verified installation without downloading", next, err) + } +} + +func TestFFmpegInstallationRejectsCorruptionAndInvalidDestination(t *testing.T) { + root := ffmpegTestEnvironment(t) + installer := ffmpegTestInstaller(t, "ffmpeg") + ffmpegTestPackage(t, installer) + installer.pack.archiveSHA256 = strings.Repeat("0", 64) + if _, err := installer.install(context.Background()); err == nil || !strings.Contains(err.Error(), "SHA-256") { + t.Fatal("corrupt download was accepted", err) + } + if _, err := os.Stat(installer.directory); !errors.Is(err, os.ErrNotExist) { + t.Fatal("a corrupt download must not create a usable installation", err) + } + ffmpegTestPackage(t, installer) + bad := ffmpegTestProgram(t, installer.directory, "preset") + if path, err := installer.install(context.Background()); err == nil || path != "" { + t.Fatal("an existing invalid destination must not turn an install failure into success", path, err) + } + if mode, err := os.ReadFile(bad + ".mode"); err != nil || string(mode) != "preset" { + t.Fatal("existing installation contents were changed", root, err) + } +} + +func TestFFmpegLocalPlaybackProbe(t *testing.T) { + program, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("local FFmpeg unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + if err := validateFFmpeg(ctx, program); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/ffmpeg_media.go b/internal/app/ffmpeg_media.go new file mode 100644 index 0000000..3915258 --- /dev/null +++ b/internal/app/ffmpeg_media.go @@ -0,0 +1,22 @@ +package app + +import ( + "context" + "os" + "os/exec" + "strings" +) + +func ffmpegMediaCommand(ctx context.Context, executable string, args ...string) *exec.Cmd { + command := exec.CommandContext(ctx, executable, args...) + command.Env = make([]string, 0, len(os.Environ())) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + switch strings.ToLower(key) { + case "http_proxy", "https_proxy", "all_proxy": + continue + } + command.Env = append(command.Env, entry) + } + return command +} diff --git a/internal/app/ffmpeg_media_test.go b/internal/app/ffmpeg_media_test.go new file mode 100644 index 0000000..1c42b3b --- /dev/null +++ b/internal/app/ffmpeg_media_test.go @@ -0,0 +1,91 @@ +package app + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestFFmpegLoopbackKeepsApplicationProxy(t *testing.T) { + fixture, session := nativePlaybackFixture(t, "3", "640x360") + video, err := os.ReadFile(session.tasks[0].OutPath) + if err != nil { + t.Fatal(err) + } + var inheritedRequests, mediaRequests atomic.Int32 + inheritedProxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + inheritedRequests.Add(1) + http.Error(writer, "loopback must not reach the inherited proxy", http.StatusBadGateway) + })) + t.Cleanup(inheritedProxy.Close) + applicationProxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Host != "media.example.invalid" || request.URL.Path != "/fixture.mp4" { + t.Error("application proxy received an unrelated request") + http.NotFound(writer, request) + return + } + mediaRequests.Add(1) + http.ServeContent(writer, request, "fixture.mp4", time.Time{}, bytes.NewReader(video)) + })) + t.Cleanup(applicationProxy.Close) + for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"} { + t.Setenv(key, inheritedProxy.URL) + } + for _, key := range []string{"no_proxy", "NO_PROXY"} { + t.Setenv(key, "") + } + for _, mode := range []string{"MSE", "MSE remux", "native HLS", "download"} { + t.Run(mode, func(t *testing.T) { + app, _ := prefetchFixtureApp(t) + root := t.TempDir() + app.downloader = NewDownloader(Config{FFmpeg: fixture.downloader.cfg.FFmpeg, ProxyURL: applicationProxy.URL, + OutputDir: filepath.Join(root, "out"), dataDir: filepath.Join(root, "data"), settingsLoaded: true, Retries: 1, SkipBytes: 1}) + app.cfg = app.downloader.cfg + task := Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "合成代理样本", Index: 1, + OutPath: filepath.Join(root, "out", "episode.mp4"), Chapter: Chapter{ID: "hongguo:7000000000000000001:1", Source: sourceHongguo, + VideoURL: "http://media.example.invalid/fixture.mp4"}} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + before := mediaRequests.Load() + switch mode { + case "MSE", "MSE remux": + ctx = context.WithValue(ctx, playbackRemuxKey{}, mode == "MSE remux") + writer := httptest.NewRecorder() + if err := app.streamPlayback(ctx, cancel, writer, task, "", 0, 1, func(float64) {}); err != nil { + t.Fatal("playback through the application proxy failed", err) + } + if !bytes.Contains(writer.Body.Bytes(), []byte("moof")) { + t.Fatal("playback did not return fragmented video") + } + case "native HLS": + cache := newPlaybackNative(app, ctx, task, "", 0, 0, false) + defer func() { + cache.Close() + cache.workers.Wait() + }() + cache.start() + body, err := cache.segment(ctx, 0) + if err != nil || len(body) < 188 || body[0] != 0x47 { + t.Fatal("native playback through the application proxy failed", err) + } + case "download": + if err := app.downloader.DownloadEpisode(ctx, task); err != nil { + t.Fatal("download through the application proxy failed", err) + } + info, err := os.Stat(task.OutPath) + if err != nil || info.Size() < 100*1024 { + t.Fatal("download did not produce the fixture", err) + } + } + if inheritedRequests.Load() != 0 || mediaRequests.Load() <= before { + t.Fatalf("incorrect proxy route: inherited=%d media=%d", inheritedRequests.Load(), mediaRequests.Load()) + } + }) + } +} diff --git a/internal/app/ffmpeg_paths.go b/internal/app/ffmpeg_paths.go new file mode 100644 index 0000000..4dd3318 --- /dev/null +++ b/internal/app/ffmpeg_paths.go @@ -0,0 +1,80 @@ +package app + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func normalizedFFmpegSetting(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + value = strings.TrimSpace(value[1 : len(value)-1]) + } + return value +} + +func automaticFFmpeg(value string) bool { + value = normalizedFFmpegSetting(value) + if runtime.GOOS == "windows" { + value = strings.ToLower(value) + } + return value == "" || value == "ffmpeg" || value == "ffmpeg.exe" +} + +func ffmpegExecutableName() string { + if runtime.GOOS == "windows" { + return "ffmpeg.exe" + } + return "ffmpeg" +} + +func ffmpegManagedDirectory() string { + directory := filepath.Join("bin", runtime.GOOS+"-"+runtime.GOARCH, "verified-b6.1.1") + if absolute, err := filepath.Abs(directory); err == nil { + return absolute + } + return directory +} + +func ffmpegCandidates(configured, managedDirectory string) []string { + var candidates []string + seen := make(map[string]bool) + add := func(candidate string) { + path, err := exec.LookPath(candidate) + if err != nil { + return + } + path, err = filepath.Abs(path) + if err != nil { + return + } + key := path + if runtime.GOOS == "windows" { + key = strings.ToLower(key) + } + if !seen[key] { + seen[key] = true + candidates = append(candidates, path) + } + } + configured = normalizedFFmpegSetting(configured) + if !automaticFFmpeg(configured) { + add(configured) + return candidates + } + name := ffmpegExecutableName() + for _, directory := range filepath.SplitList(os.Getenv("PATH")) { + if filepath.IsAbs(directory) { + add(filepath.Join(directory, name)) + } + } + for _, path := range []string{filepath.Join("bin", name), name, filepath.Join(managedDirectory, name), filepath.Join("bin", runtime.GOOS+"-"+runtime.GOARCH, name)} { + if absolute, err := filepath.Abs(path); err == nil { + add(absolute) + } + } + return candidates +} diff --git a/internal/app/ffmpeg_probe_windows.go b/internal/app/ffmpeg_probe_windows.go new file mode 100644 index 0000000..3979d58 --- /dev/null +++ b/internal/app/ffmpeg_probe_windows.go @@ -0,0 +1,12 @@ +package app + +import ( + "os/exec" + "syscall" +) + +func init() { + configureFFmpegProbeProcess = func(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} + } +} diff --git a/internal/app/following_store.go b/internal/app/following_store.go new file mode 100644 index 0000000..cb4639b --- /dev/null +++ b/internal/app/following_store.go @@ -0,0 +1,147 @@ +package app + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +const followingLimit = 1000 + +var errFollowingLimit = errors.New("追剧清单已达到 1000 部,请先移除不再需要的内容") +var errFollowingUnknown = errors.New("剧库中没有这部剧,请先搜索或更新剧库") + +type followingEntry struct { + DramaID string `json:"dramaId"` + Source string `json:"source"` + Title string `json:"title"` + Category string `json:"category,omitempty"` + Saved bool `json:"saved"` + Completed bool `json:"completed"` + KnownEpisodes int `json:"knownEpisodes"` + AddedAt time.Time `json:"addedAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type followingFile struct { + Version int `json:"version"` + Entries []followingEntry `json:"entries"` +} + +type followingStore struct { + mu sync.Mutex + path string + entries map[string]followingEntry + loadErr error +} + +func newFollowingStore(directory string) *followingStore { + store := &followingStore{path: filepath.Join(directory, "following.json"), entries: map[string]followingEntry{}} + file, err := os.Open(store.path) + if errors.Is(err, os.ErrNotExist) { + return store + } + if err != nil { + store.loadErr = err + return store + } + defer file.Close() + var saved followingFile + decoder := json.NewDecoder(io.LimitReader(file, 8<<20)) + if err = decoder.Decode(&saved); err == nil { + if decoder.Decode(new(any)) != io.EOF || saved.Version != 1 || len(saved.Entries) > followingLimit { + err = errors.New("追剧清单格式无效,原文件已保留") + } + } + if err != nil { + store.loadErr = err + return store + } + for _, entry := range saved.Entries { + id, source, valid := playbackHistoryIdentity(entry.DramaID) + if !valid || canonicalProviderSource(entry.Source) != source || len(entry.Title) > 4096 || len(entry.Category) > 512 || entry.KnownEpisodes < 0 || entry.KnownEpisodes > 10000 || entry.AddedAt.IsZero() || entry.UpdatedAt.IsZero() || !entry.Saved && !entry.Completed { + continue + } + entry.DramaID, entry.Source = id, source + if old, exists := store.entries[id]; !exists || entry.UpdatedAt.After(old.UpdatedAt) { + store.entries[id] = entry + } + } + return store +} + +func followingEntries(entries map[string]followingEntry) []followingEntry { + result := make([]followingEntry, 0, len(entries)) + for _, entry := range entries { + result = append(result, entry) + } + sort.Slice(result, func(i, j int) bool { + if result[i].UpdatedAt.Equal(result[j].UpdatedAt) { + return result[i].DramaID < result[j].DramaID + } + return result[i].UpdatedAt.After(result[j].UpdatedAt) + }) + return result +} + +func (store *followingStore) list() ([]followingEntry, error) { + store.mu.Lock() + defer store.mu.Unlock() + return followingEntries(store.entries), store.loadErr +} + +func (store *followingStore) update(id string, change func(followingEntry, bool) (followingEntry, error)) (followingEntry, error) { + store.mu.Lock() + defer store.mu.Unlock() + if store.loadErr != nil { + return followingEntry{}, store.loadErr + } + previous, exists := store.entries[id] + entry, err := change(previous, exists) + if err != nil { + return followingEntry{}, err + } + if !exists && (entry.Saved || entry.Completed) && len(store.entries) >= followingLimit { + return followingEntry{}, errFollowingLimit + } + next := make(map[string]followingEntry, len(store.entries)+1) + for key, value := range store.entries { + next[key] = value + } + if entry.Saved || entry.Completed { + next[id] = entry + } else { + delete(next, id) + } + if err = store.write(followingFile{Version: 1, Entries: followingEntries(next)}); err != nil { + return followingEntry{}, err + } + store.entries = next + return entry, nil +} + +func (store *followingStore) write(data followingFile) error { + if err := os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(store.path), ".following-*.json") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if err = json.NewEncoder(file).Encode(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(file.Name(), store.path) +} diff --git a/internal/app/helpers.go b/internal/app/helpers.go new file mode 100644 index 0000000..8cbcde7 --- /dev/null +++ b/internal/app/helpers.go @@ -0,0 +1,142 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var ( + badNameChars = regexp.MustCompile(`[\\/\?%\*:|"<>\x00-\x1f]+`) + spaceChars = regexp.MustCompile(`\s+`) + rePublicURL = regexp.MustCompile(`(?i)(?:https?|socks5h?)://[^\s"'<>]+`) +) + +func publicError(err error) error { + if err == nil { + return nil + } + return errors.New(redactErrorString(err.Error())) +} + +func redactErrorString(s string) string { + return rePublicURL.ReplaceAllStringFunc(s, func(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "[无效 URL 已隐藏]" + } + if u.Host == "" { + return raw + } + if u.User != nil { + u.User = url.User("redacted") + } + if u.RawQuery != "" { + u.RawQuery = "[redacted]" + } + if u.Fragment != "" { + u.Fragment = "redacted" + } + return u.String() + }) +} + +func saveFailures(outputDir string, results []Result) error { + var failed []Result + for _, r := range results { + if !r.OK { + failed = append(failed, r) + } + } + if len(failed) == 0 { + return nil + } + b, err := json.MarshalIndent(failed, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(outputDir, "failed.json"), b, 0o644) +} + +func existingGood(path string, skipBytes int64) (bool, int64) { + st, err := os.Stat(path) + if err != nil || st.IsDir() { + return false, 0 + } + return st.Size() >= skipBytes, st.Size() +} + +func safeFilename(name string) string { + name = strings.TrimSpace(name) + name = badNameChars.ReplaceAllString(name, "_") + name = strings.ReplaceAll(name, "..", "_") + name = spaceChars.ReplaceAllString(name, " ") + name = strings.Trim(name, " ._") + if name == "" { + return "未命名" + } + upper := strings.ToUpper(name) + reserved := map[string]bool{"CON": true, "PRN": true, "AUX": true, "NUL": true, "COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true, "COM6": true, "COM7": true, "COM8": true, "COM9": true, "LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true, "LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true} + if reserved[strings.SplitN(upper, ".", 2)[0]] { + name = "_" + name + } + runes := []rune(name) + if len(runes) > 100 { + name = string(runes[:100]) + } + return name +} + +func safeJoin(base string, elem ...string) (string, error) { + cleanBase, err := filepath.Abs(base) + if err != nil { + return "", err + } + parts := []string{cleanBase} + for _, e := range elem { + parts = append(parts, safeFilename(e)) + } + joined := filepath.Join(parts...) + abs, err := filepath.Abs(joined) + if err != nil { + return "", err + } + rel, err := filepath.Rel(cleanBase, abs) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", fmt.Errorf("unsafe path outside output dir: %s", abs) + } + return abs, nil +} + +func padEpisode(ep string) string { + ep = strings.TrimSpace(ep) + if n, err := strconv.Atoi(ep); err == nil { + return fmt.Sprintf("%03d", n) + } + return safeFilename(ep) +} + +func hashShort(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:])[:12] +} + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "..." +} diff --git a/internal/app/image_heif.go b/internal/app/image_heif.go new file mode 100644 index 0000000..96c7ea4 --- /dev/null +++ b/internal/app/image_heif.go @@ -0,0 +1,360 @@ +package app + +import ( + "encoding/binary" + "errors" +) + +const maxCoverBytes = 20 << 20 + +type heifBox struct { + kind string + data []byte +} + +type heifReader struct { + data []byte + err error +} + +func (reader *heifReader) take(size int) []byte { + if reader.err != nil || size < 0 || size > len(reader.data) { + reader.err = errors.New("HEIC 数据不完整") + return nil + } + value := reader.data[:size] + reader.data = reader.data[size:] + return value +} + +func (reader *heifReader) uint(size int) uint64 { + if size < 0 || size > 8 { + reader.err = errors.New("HEIC 字段长度无效") + return 0 + } + var value uint64 + for _, part := range reader.take(size) { + value = value<<8 | uint64(part) + } + return value +} + +func heifBoxes(data []byte) ([]heifBox, error) { + var boxes []heifBox + for len(data) > 0 { + if len(data) < 8 || len(boxes) >= 4096 { + return nil, errors.New("HEIC 容器结构无效") + } + size, header := uint64(binary.BigEndian.Uint32(data)), 8 + if size == 1 { + if len(data) < 16 { + return nil, errors.New("HEIC 容器头不完整") + } + size, header = binary.BigEndian.Uint64(data[8:]), 16 + } else if size == 0 { + size = uint64(len(data)) + } + if size < uint64(header) || size > uint64(len(data)) { + return nil, errors.New("HEIC 容器长度无效") + } + boxes = append(boxes, heifBox{kind: string(data[4:8]), data: data[header:int(size)]}) + data = data[int(size):] + } + return boxes, nil +} + +func heifBoxData(boxes []heifBox, kind string) []byte { + for _, box := range boxes { + if box.kind == kind { + return box.data + } + } + return nil +} + +func isHEICImage(data []byte) bool { + if len(data) < 16 || string(data[4:8]) != "ftyp" { + return false + } + size := uint64(binary.BigEndian.Uint32(data)) + if size < 16 || size > uint64(len(data)) || size > 256 { + return false + } + for offset := 8; offset+4 <= int(size); offset += 4 { + if offset == 12 { + continue + } + switch string(data[offset : offset+4]) { + case "heic", "heix", "hevc", "hevx": + return true + } + } + return false +} + +type heifImage struct { + data []byte + filters []string +} + +func extractHEICImage(data []byte) (heifImage, error) { + var image heifImage + if len(data) > maxCoverBytes || !isHEICImage(data) { + return image, errors.New("不是支持的 HEIC 封面") + } + boxes, err := heifBoxes(data) + if err != nil { + return image, err + } + meta := heifBoxData(boxes, "meta") + if len(meta) < 4 || meta[0] != 0 { + return image, errors.New("HEIC 缺少图片元数据") + } + children, err := heifBoxes(meta[4:]) + if err != nil { + return image, err + } + primary := &heifReader{data: heifBoxData(children, "pitm")} + version := primary.uint(1) + primary.take(3) + idSize := 2 + if version == 1 { + idSize = 4 + } + itemID := primary.uint(idSize) + if primary.err != nil || version > 1 || itemID == 0 { + return image, errors.New("HEIC 主图片标识无效") + } + if err := validateHEICItem(heifBoxData(children, "iinf"), itemID); err != nil { + return image, err + } + config, filters, err := heifItemProperties(heifBoxData(children, "iprp"), itemID) + if err != nil { + return image, err + } + item, err := heifItemData(heifBoxData(children, "iloc"), itemID, data, heifBoxData(children, "idat")) + if err != nil { + return image, err + } + image.data, err = heifAnnexB(config, item) + image.filters = filters + return image, err +} + +func validateHEICItem(data []byte, itemID uint64) error { + reader := &heifReader{data: data} + version := reader.uint(1) + reader.take(3) + countSize := 2 + if version == 1 { + countSize = 4 + } + count := reader.uint(countSize) + if reader.err != nil || version > 1 { + return errors.New("HEIC 图片信息无效") + } + boxes, err := heifBoxes(reader.data) + if err != nil || count != uint64(len(boxes)) { + return errors.New("HEIC 图片信息不完整") + } + for _, box := range boxes { + if box.kind != "infe" { + continue + } + item := &heifReader{data: box.data} + version := item.uint(1) + item.take(3) + idSize := 2 + if version == 3 { + idSize = 4 + } + id := item.uint(idSize) + protected := item.uint(2) + kind := string(item.take(4)) + if id == itemID { + if item.err != nil || (version != 2 && version != 3) || protected != 0 || kind != "hvc1" { + return errors.New("HEIC 封面不是独立的 HEVC 图片") + } + return nil + } + } + return errors.New("HEIC 主图片不存在") +} + +func heifItemProperties(data []byte, itemID uint64) ([]byte, []string, error) { + boxes, err := heifBoxes(data) + if err != nil { + return nil, nil, err + } + properties, err := heifBoxes(heifBoxData(boxes, "ipco")) + if err != nil { + return nil, nil, err + } + reader := &heifReader{data: heifBoxData(boxes, "ipma")} + version, flags := reader.uint(1), reader.uint(3) + count := reader.uint(4) + if version > 1 || count > 4096 || reader.err != nil { + return nil, nil, errors.New("HEIC 图片属性无效") + } + var config []byte + var filters []string + var width, height uint64 + for entry := uint64(0); entry < count && reader.err == nil; entry++ { + idSize := 2 + if version == 1 { + idSize = 4 + } + id := reader.uint(idSize) + associations := reader.uint(1) + for association := uint64(0); association < associations && reader.err == nil; association++ { + index := reader.uint(1) & 127 + if flags&1 != 0 { + index = index<<8 | reader.uint(1) + } + if id != itemID || index == 0 { + continue + } + if index > uint64(len(properties)) { + return nil, nil, errors.New("HEIC 属性索引越界") + } + property := properties[index-1] + switch property.kind { + case "hvcC": + config = property.data + case "ispe": + if len(property.data) != 12 { + return nil, nil, errors.New("HEIC 图片尺寸无效") + } + width = uint64(binary.BigEndian.Uint32(property.data[4:])) + height = uint64(binary.BigEndian.Uint32(property.data[8:])) + case "irot", "imir": + if len(property.data) != 1 { + return nil, nil, errors.New("HEIC 图片方向无效") + } + if property.kind == "imir" { + if property.data[0]&1 == 0 { + filters = append(filters, "hflip") + } else { + filters = append(filters, "vflip") + } + } else { + switch property.data[0] & 3 { + case 1: + filters = append(filters, "transpose=cclock") + case 2: + filters = append(filters, "hflip", "vflip") + case 3: + filters = append(filters, "transpose=clock") + } + } + } + } + } + if reader.err != nil || len(config) < 23 || width == 0 || height == 0 || width > 4096 || height > 4096 { + return nil, nil, errors.New("HEIC 编码信息或图片尺寸无效") + } + return config, filters, nil +} + +func heifItemData(data []byte, itemID uint64, file, inline []byte) ([]byte, error) { + reader := &heifReader{data: data} + version := reader.uint(1) + reader.take(3) + lengths, offsets := reader.uint(1), reader.uint(1) + offsetSize, lengthSize := int(lengths>>4), int(lengths&15) + baseSize, indexSize := int(offsets>>4), 0 + if version > 0 { + indexSize = int(offsets & 15) + } + countSize := 2 + if version == 2 { + countSize = 4 + } + count := reader.uint(countSize) + if reader.err != nil || version > 2 || count > 4096 || offsetSize > 8 || lengthSize > 8 || baseSize > 8 || indexSize > 8 { + return nil, errors.New("HEIC 图片位置无效") + } + var result []byte + for entry := uint64(0); entry < count && reader.err == nil; entry++ { + id := reader.uint(countSize) + method := uint64(0) + if version > 0 { + method = reader.uint(2) & 15 + } + reference, base := reader.uint(2), reader.uint(baseSize) + extents := reader.uint(2) + if extents > 4096 || (id == itemID && (reference != 0 || method > 1)) { + return nil, errors.New("HEIC 不支持外部图片引用") + } + for extent := uint64(0); extent < extents && reader.err == nil; extent++ { + reader.uint(indexSize) + offset, length := reader.uint(offsetSize), reader.uint(lengthSize) + if id != itemID || reader.err != nil { + continue + } + source := file + if method == 1 { + source = inline + } + if base > uint64(len(source)) || offset > uint64(len(source))-base { + return nil, errors.New("HEIC 图片偏移越界") + } + start := base + offset + if length == 0 { + length = uint64(len(source)) - start + } + if length > uint64(len(source))-start || length > uint64(maxCoverBytes-len(result)) { + return nil, errors.New("HEIC 图片长度越界") + } + result = append(result, source[start:start+length]...) + } + } + if reader.err != nil || len(result) == 0 { + return nil, errors.New("HEIC 图片数据缺失") + } + return result, nil +} + +func heifAnnexB(config, item []byte) ([]byte, error) { + if len(config) < 23 || config[0] != 1 { + return nil, errors.New("HEIC 解码配置无效") + } + var result []byte + appendNAL := func(data []byte) error { + if len(data) < 2 || len(data) > maxCoverBytes-4-len(result) { + return errors.New("HEIC 编码数据长度无效") + } + result = append(result, 0, 0, 0, 1) + result = append(result, data...) + return nil + } + reader := &heifReader{data: config[23:]} + for array := 0; array < int(config[22]) && reader.err == nil; array++ { + reader.uint(1) + count := reader.uint(2) + if count > 4096 { + return nil, errors.New("HEIC 解码配置过大") + } + for index := uint64(0); index < count && reader.err == nil; index++ { + size := reader.uint(2) + if err := appendNAL(reader.take(int(size))); err != nil { + return nil, err + } + } + } + if reader.err != nil || len(reader.data) != 0 || len(result) == 0 { + return nil, errors.New("HEIC 解码配置不完整") + } + reader = &heifReader{data: item} + lengthSize := int(config[21]&3) + 1 + for count := 0; len(reader.data) > 0; count++ { + size := reader.uint(lengthSize) + if reader.err != nil || size > uint64(len(reader.data)) || count >= 4096 { + return nil, errors.New("HEIC 图片编码不完整") + } + if err := appendNAL(reader.take(int(size))); err != nil { + return nil, err + } + } + return result, nil +} diff --git a/internal/app/init.mp4 b/internal/app/init.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..48987f2445a8add8d1119d3140759c3ec7d88521 GIT binary patch literal 1354 zcmb_cy>1gh5T3PtDF_lqazqLj1{sM0jKhy0ct=R z$3Vt6)&RVojG`0(LYYnlYDelnjKKi#SIUBc?utDuna80T?xCj>zKy|WdfR-n?hq^4 zPKZ>PL!$2DctQaBzR(KwB|lW!cYV(j;}a+!Z#~7Ku+B5{jRmCt=58<|(}uB7Up1 zDvCp#J)VxSct+fu*0YOOFhWR!)#s=`pStz!;0{0? z8Z`Y}zrUX`XiPty`GE9h%vFq<($i>d>BQc&_vix9K#tO;3J8s$t;U6cx`p;D$0mrJ zaa8))*Au#hDq0n0&Racn#U3uM>gL_jcDkipVG}<6dxyNsDBW=#qN4fC{nP`rh0`HR zd51KW?T~`|58VMD^X_5dsbZJsA84^e|A`RrC<;44AT1L|3pUBs8sKz(kwLN4~p z3=cit**BZtVF?kVt1e5NbI|u6N(>{J`Y$k7M<+%jJZdY#g}8CrPkB1a;B%qr3V=dT*`Y{|$0Ms&D`R literal 0 HcmV?d00001 diff --git a/internal/app/library.go b/internal/app/library.go new file mode 100644 index 0000000..3edcb84 --- /dev/null +++ b/internal/app/library.go @@ -0,0 +1,64 @@ +package app + +import ( + "fmt" + "sort" + "strings" +) + +type libraryLoadError struct { + failures map[string]error +} + +func (failure *libraryLoadError) Error() string { + var messages []string + for source, cause := range failure.failures { + messages = append(messages, fmt.Sprintf("%s 获取失败: %v", source, publicError(cause))) + } + sort.Strings(messages) + return strings.Join(messages, "\n") +} + +func dramaProvider(drama Drama) string { + if source := canonicalProviderSource(drama.Source); source != "" { + return source + } + if source, _, ok := splitProviderDramaID(drama.ID); ok { + return source + } + return "cloudfront" +} + +func mergeLoadedDramas(cached, fresh []Drama, loadErr error) []Drama { + return mergeSourceDramas(cached, fresh, loadErr, "") +} + +func matchesSourceFilter(provider, source string) bool { + if source == "" { + return true + } + if source == "huangguo" { + return provider == "cloudfront" || provider == sourceHuangguoAI || provider == sourceHuangguoVideo + } + return provider == source || provider == canonicalProviderSource(source) +} + +func mergeSourceDramas(cached, fresh []Drama, loadErr error, source string) []Drama { + merged := make([]Drama, 0, len(cached)+len(fresh)) + positions := make(map[string]int, cap(merged)) + for _, batch := range [][]Drama{cached, fresh} { + for _, drama := range batch { + if drama.ID == "" { + continue + } + if position, found := positions[drama.ID]; found { + merged[position] = mergeDramaMetadata(drama, merged[position]) + } else { + positions[drama.ID] = len(merged) + merged = append(merged, drama) + } + } + } + sort.SliceStable(merged, func(left, right int) bool { return merged[left].DisplayTitle() < merged[right].DisplayTitle() }) + return merged +} diff --git a/internal/app/library_cache.go b/internal/app/library_cache.go new file mode 100644 index 0000000..7af729b --- /dev/null +++ b/internal/app/library_cache.go @@ -0,0 +1,158 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +type libraryCache struct { + Dramas []Drama `json:"dramas"` + LoadedAt time.Time `json:"loadedAt"` + LastError string `json:"lastError,omitempty"` + Sources map[string]librarySourceState `json:"sources,omitempty"` + HongguoApp *hongguoCatalogState `json:"hongguoApp,omitempty"` +} + +func libraryCachePath(outputDir string) string { + return filepath.Join(outputDir, "library.json") +} + +func readLibraryCache(outputDir string) (libraryCache, error) { + var cache libraryCache + path := libraryCachePath(outputDir) + body, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + path = filepath.Join(outputDir, "ui-state.json") + body, err = os.ReadFile(path) + } + if err != nil { + return cache, err + } + if err := json.Unmarshal(body, &cache); err != nil { + return cache, fmt.Errorf("本地剧库缓存损坏 %s: %w", path, err) + } + return cache, nil +} + +func writeLibraryCache(outputDir string, cache libraryCache) error { + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return err + } + file, err := os.CreateTemp(outputDir, ".library-*.tmp") + if err != nil { + return err + } + defer os.Remove(file.Name()) + encoder := json.NewEncoder(file) + if err := encoder.Encode(cache); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), libraryCachePath(outputDir)) +} + +func (a *UIApp) loadLibrary() { + cache, err := readLibraryCache(a.cfg.dataDirectory()) + if err == nil { + a.downloader.restoreHongguoCatalog(cache.HongguoApp) + } + a.mu.Lock() + defer a.mu.Unlock() + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + a.libraryError = a.redactError(err) + } + return + } + if len(cache.Dramas) > 0 && (len(a.dramas) == 0 || !a.loadedAt.After(cache.LoadedAt)) { + a.dramas = cache.Dramas + a.normalizeDramaCovers(a.dramas) + a.loadedAt = cache.LoadedAt + a.libraryError = cache.LastError + } + a.librarySources = cache.Sources + if cache.HongguoApp != nil { + a.libraryApp = a.downloader.hongguoCatalogSnapshot() + } + for source, state := range a.librarySources { + if state.Status == "loading" { + state.Status = "partial" + state.Error = "上次更新中断,当前显示已保存数据,可手动刷新" + a.librarySources[source] = state + } + } + if sourceErrors := librarySourceErrors(a.librarySources); sourceErrors != "" { + a.libraryError = sourceErrors + } + a.libraryRevision = 1 + if len(a.dramas) > 0 { + if _, err := os.Stat(libraryCachePath(a.cfg.dataDirectory())); errors.Is(err, os.ErrNotExist) { + if err := writeLibraryCache(a.cfg.dataDirectory(), libraryCache{Dramas: a.dramas, LoadedAt: a.loadedAt, LastError: a.libraryError, Sources: a.librarySources, HongguoApp: a.libraryApp}); err != nil { + a.libraryError = "剧库缓存保存失败: " + a.redactError(err) + return + } + } + a.librarySaved = true + var pending []Drama + for _, drama := range a.dramas { + if drama.SortMetadata != nil && drama.SortMetadata.Pending { + pending = append(pending, drama) + } + } + a.enqueueSortMetadataLocked(pending, false) + fmt.Printf("已恢复本地剧库:%d 部,缓存:%s\n", len(a.dramas), libraryCachePath(a.cfg.dataDirectory())) + } +} + +func (d *Downloader) GetAllDramas(ctx context.Context) ([]Drama, error) { + cache, err := readLibraryCache(d.cfg.dataDirectory()) + if err == nil { + d.restoreHongguoCatalog(cache.HongguoApp) + } + if err == nil && len(cache.Dramas) > 0 { + fmt.Printf("使用本地剧库缓存:%d 部(%s),更新请使用 -refresh\n", len(cache.Dramas), cache.LoadedAt.Format("2006-01-02 15:04:05")) + if cache.LastError != "" { + return cache.Dramas, errors.New(cache.LastError) + } + return cache.Dramas, nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + fmt.Printf(" 读取本地剧库缓存失败: %v\n", err) + } + return d.RefreshDramas(ctx, "") +} + +func (d *Downloader) RefreshDramas(ctx context.Context, source string) ([]Drama, error) { + cached, _ := readLibraryCache(d.cfg.dataDirectory()) + d.restoreHongguoCatalog(cached.HongguoApp) + ctx = withKnownHongguoDramas(ctx, cached.Dramas) + fresh, loadErr := d.fetchAllDramas(ctx, source) + merged := mergeSourceDramas(cached.Dramas, fresh, loadErr, source) + if len(fresh) == 0 && len(merged) == 0 { + return nil, loadErr + } + cache := libraryCache{Dramas: merged, LoadedAt: time.Now(), HongguoApp: d.hongguoCatalogSnapshot()} + if len(fresh) == 0 { + cache.LoadedAt = cached.LoadedAt + } + if loadErr != nil { + cache.LastError = publicError(loadErr).Error() + } + if err := writeLibraryCache(d.cfg.dataDirectory(), cache); err != nil { + loadErr = errors.Join(loadErr, fmt.Errorf("剧库缓存保存失败: %w", err)) + } + fmt.Printf("本地剧库:%d 部,本次新增 %d 部\n", len(merged), len(merged)-len(cached.Dramas)) + return merged, loadErr +} diff --git a/internal/app/library_cover_metadata_test.go b/internal/app/library_cover_metadata_test.go new file mode 100644 index 0000000..e46d0dc --- /dev/null +++ b/internal/app/library_cover_metadata_test.go @@ -0,0 +1,109 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +const coverAddressFixture = "https://p3-reading-sign.fqnovelpic.com/synthetic-cover-address?fixture=1" + +func TestHongguoRankingKeepsCoverAddressForLibrary(t *testing.T) { + board, _ := findRankingBoard("hongguo-hot") + rows := rankingRows(1) + rows[0]["cover"] = coverAddressFixture + page, err := parseHongguoRanking(rankingFixture(board, 1, rows), board, 1) + if err != nil || bestDramaCover(page.Items[0].Drama) != coverAddressFixture || bestDramaCover(page.Items[1].Drama) != "" { + t.Fatal("ranking discarded its cover address or invented one", err) + } + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + t.Error("retaining an address must not fetch the image", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + }) + app := &UIApp{downloader: d, cfg: d.cfg, metadataClosed: true} + page.BoardID, page.Page, page.FetchedAt = board.ID, 1, time.Now() + app.acceptRankingDramas(page) + for _, drama := range app.dramas { + if drama.ID == page.Items[0].Drama.ID && drama.Cover != "/api/ui/image?url="+url.QueryEscape(coverAddressFixture) { + t.Fatal("ranking address bypassed the local cover route") + } + } + for _, invalid := range []any{"javascript:invalid", "https://example.invalid/cover", "http://p3-reading-sign.fqnovelpic.com/cover", map[string]any{"unexpected": "field"}} { + rows[0]["cover"] = invalid + result, err := parseHongguoRanking(rankingFixture(board, 1, rows), board, 1) + if err != nil || len(result.Items) != 2 || bestDramaCover(result.Items[0].Drama) != "" { + t.Fatal("invalid optional address broke the ranking or reached the library", err) + } + } +} + +func TestHistoricalMissingCoverWaitsForManualUpdate(t *testing.T) { + for _, found := range []bool{true, false} { + t.Run(fmt.Sprintf("address=%t", found), func(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.Path != "/novel/player/video_detail/v1/" || request.Context().Value(backgroundCatalogKey{}) != true { + t.Error("cover metadata used a non-background or non-text request", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + cover := "" + if found { + cover = coverAddressFixture + } + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":"7000000000000000001","series_title":"合成资料","series_cover":%q,"hot_score":999,"series_play_cnt":999}}}`, cover)), nil + }) + old := Drama{ID: "hongguo:7000000000000000001", Source: sourceHongguo, Title: "历史榜单条目", OnlineDate: "2026-09-01", Heat: "20019", Views: "0", + SortMetadata: &sortMetadataState{Version: sortMetadataVersion, CheckedAt: time.Now().Add(-time.Hour)}} + if err := writeLibraryCache(d.cfg.dataDirectory(), libraryCache{Dramas: []Drama{old}}); err != nil { + t.Fatal(err) + } + app := &UIApp{downloader: d, cfg: d.cfg} + t.Cleanup(app.stopSortMetadata) + app.loadLibrary() + app.handleDramas(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/ui/dramas", nil)) + app.mu.Lock() + if calls.Load() != 0 || app.metadataDone != nil || len(app.newSortMetadataDramasLocked([]Drama{old})) != 0 || sortMetadataRemaining(app.dramas)[sourceHongguo] != 1 { + app.mu.Unlock() + t.Fatal("ordinary loading migrated historical addresses or hid their pending update") + } + app.enqueueHistoricalMetadataLocked(sourceHongguo, []string{old.ID}) + app.mu.Unlock() + waitSortMetadataQueue(t, app) + cache, err := readLibraryCache(d.cfg.dataDirectory()) + if err != nil || len(cache.Dramas) != 1 { + t.Fatal("manual address repair was not persisted", err) + } + updated := cache.Dramas[0] + if calls.Load() != 1 || updated.SortMetadata == nil || !updated.SortMetadata.CoverChecked || needsSortMetadata(updated) || updated.Views != "0" || updated.Heat != old.Heat || updated.Title != old.Title { + t.Fatal("cover repair changed primary metadata or did not remember a completed check") + } + if found && (bestDramaCover(updated) != coverAddressFixture || !strings.HasPrefix(updated.Cover.(string), "/api/ui/image?url=")) || !found && bestDramaCover(updated) != "" { + t.Fatal("manual repair lost the address or invented a missing cover") + } + app.mu.Lock() + app.enqueueHistoricalMetadataLocked(sourceHongguo, []string{old.ID}) + app.mu.Unlock() + waitSortMetadataQueue(t, app) + if calls.Load() != 1 { + t.Fatal("known missing cover triggered repeated requests") + } + }) + } +} + +func TestHongguoCoverRepairValidatesDramaIdentity(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":"7000000000000000002","series_cover":%q}}}`, coverAddressFixture)), nil + }) + patch, err := d.fetchDramaSortMetadata(context.Background(), Drama{ID: "hongguo:7000000000000000001", OnlineDate: "2026-09-01", Heat: "1", Views: "0"}) + if err == nil || bestDramaCover(patch) != "" { + t.Fatal("cover repair accepted another drama's address") + } +} diff --git a/internal/app/library_fetch.go b/internal/app/library_fetch.go new file mode 100644 index 0000000..1a3bc3c --- /dev/null +++ b/internal/app/library_fetch.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func (d *Downloader) fetchAllDramas(ctx context.Context, sourceFilter string) ([]Drama, error) { + if more, _ := ctx.Value(libraryMoreKey{}).(bool); more { + return d.fetchMoreLibrary(ctx, sourceFilter) + } + fmt.Println("正在获取剧库列表...") + type result struct { + name string + items []Drama + err error + } + jobs := []struct { + name string + timeout time.Duration + fn func(context.Context) ([]Drama, error) + }{ + {name: "cloudfront", timeout: 10 * time.Minute, fn: d.fetchCloudFrontDramas}, + {name: sourceHuangguoAI, timeout: 10 * time.Minute, fn: d.fetchHuangguoAIDramas}, + {name: sourceHuangguoVideo, timeout: 10 * time.Minute, fn: d.fetchHuangguoVideoDramas}, + {name: sourceHuangdou, timeout: 10 * time.Minute, fn: d.fetchHuangdouDramas}, + {name: sourceHongguo, timeout: 10 * time.Minute, fn: d.fetchHongguoDramas}, + } + selectedJobs := jobs[:0] + for _, job := range jobs { + if matchesSourceFilter(job.name, sourceFilter) && sourceAllowed(ctx, job.name) { + selectedJobs = append(selectedJobs, job) + } + } + jobs = selectedJobs + ch := make(chan result, len(jobs)) + for _, job := range jobs { + job := job + go func() { + jobCtx, cancel := context.WithTimeout(ctx, job.timeout) + defer cancel() + items, err := job.fn(jobCtx) + ch <- result{name: job.name, items: items, err: err} + }() + } + seen := map[string]bool{} + var unique []Drama + failures := map[string]error{} + for range jobs { + res := <-ch + if len(res.items) == 0 && res.err == nil && !(res.name == sourceHongguo && hongguoCatalogInitialized(d.hongguoCatalogSnapshot())) { + res.err = errors.New("未返回可识别的视频数据") + } + if res.err != nil { + msg := fmt.Sprintf("%s 获取失败: %v", res.name, publicError(res.err)) + fmt.Printf("%s\n", msg) + failures[res.name] = res.err + } + reportLibraryProgress(ctx, res.name, res.items, res.err, true) + for _, item := range res.items { + if item.ID == "" || seen[item.ID] { + continue + } + seen[item.ID] = true + unique = append(unique, item) + } + } + sort.SliceStable(unique, func(i, j int) bool { return unique[i].DisplayTitle() < unique[j].DisplayTitle() }) + fmt.Printf("本次获取剧库:%d 部,结果将合并到本地缓存\n", len(unique)) + if len(failures) > 0 { + return unique, &libraryLoadError{failures: failures} + } + return unique, nil +} + +func (d *Downloader) GetDramaChapters(ctx context.Context, seriesID string) (string, []Chapter, error) { + if source, sourceID, ok := splitProviderDramaID(seriesID); ok { + return d.GetHuangguoChapters(ctx, source, sourceID) + } + var detail detailResponse + if err := d.fetchAPI(ctx, "/api/app/playlet/detail/"+url.PathEscape(seriesID), nil, &detail); err != nil { + return "", nil, err + } + chapters := detail.Chapters + if len(chapters) == 0 { + var list []Chapter + if err := d.fetchAPI(ctx, "/api/app/playlet-chapter/list/"+url.PathEscape(seriesID), nil, &list); err != nil { + return "", nil, err + } + chapters = list + } + title := detail.Title + if title == "" { + title = detail.Name + } + if title == "" { + title = "短剧" + } + return title, uniqueChapters(chapters), nil +} + +func uniqueChapters(chapters []Chapter) []Chapter { + seen := map[string]bool{} + var out []Chapter + for i, ch := range chapters { + key := ch.ID + if key == "" { + key = ch.VideoURL + } + if key == "" { + key = fmt.Sprintf("idx_%d", i) + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, ch) + } + return out +} + +func (d *Downloader) BuildDramaTasks(ctx context.Context, drama Drama) ([]Task, error) { + return d.buildDramaTasksInDirectory(ctx, drama, "") +} + +func (d *Downloader) buildDramaTasksInDirectory(ctx context.Context, drama Drama, existingDirectory string) ([]Task, error) { + var title string + var chapters []Chapter + var err error + if isHuangguoProviderSource(drama.Source) { + sourceID := strings.TrimSpace(drama.SourceID) + if sourceID == "" { + if _, parsedID, ok := splitProviderDramaID(drama.ID); ok { + sourceID = parsedID + } else { + sourceID = strings.TrimSpace(drama.ID) + } + } + title, chapters, err = d.GetHuangguoChapters(ctx, drama.Source, sourceID) + } else { + title, chapters, err = d.GetDramaChapters(ctx, drama.ID) + } + if err != nil { + return nil, err + } + if len(chapters) == 0 { + return nil, nil + } + drama = d.hongguoCachedDrama(drama) + safeDrama := safeFilename(title) + dramaDir, err := safeJoin(d.cfg.OutputDir, safeDrama) + if err != nil { + return nil, err + } + if existingDirectory != "" { + dramaDir = existingDirectory + } + markerPath := filepath.Join(dramaDir, ".drama-id") + if b, readErr := os.ReadFile(markerPath); readErr == nil && strings.TrimSpace(string(b)) != "" && strings.TrimSpace(string(b)) != drama.ID { + dramaDir, err = safeJoin(d.cfg.OutputDir, safeFilename(fmt.Sprintf("%s_%s", title, hashShort(drama.ID)))) + if err != nil { + return nil, err + } + markerPath = filepath.Join(dramaDir, ".drama-id") + } + if err := os.MkdirAll(dramaDir, 0o755); err != nil { + return nil, err + } + if drama.ID != "" { + _ = os.WriteFile(markerPath, []byte(drama.ID), 0o644) + } + var tasks []Task + for i, ch := range chapters { + ep := ch.EpisodeString(i + 1) + epNum := padEpisode(ep) + fileName := safeFilename(epNum + ".mp4") + outPath, err := safeJoin(dramaDir, fileName) + if err != nil { + return nil, err + } + tasks = append(tasks, Task{DramaID: drama.ID, DramaTitle: title, Chapter: ch, Index: i + 1, Total: len(chapters), OutPath: outPath, ReleaseStatus: dramaReleaseStatus(drama)}) + } + return tasks, nil +} diff --git a/internal/app/library_metadata.go b/internal/app/library_metadata.go new file mode 100644 index 0000000..87b93f9 --- /dev/null +++ b/internal/app/library_metadata.go @@ -0,0 +1,227 @@ +package app + +import ( + "context" + "errors" + "fmt" + "sort" + "sync" + "time" +) + +const sortMetadataVersion = 1 +const sortMetadataBatchSize = 40 +const sortMetadataRetryDelay = 5 * time.Minute + +type sortMetadataState struct { + VIPChecked bool `json:"vipChecked,omitempty"` + Version int `json:"version"` + CheckedAt time.Time `json:"checkedAt"` + Pending bool `json:"pending,omitempty"` + CoverChecked bool `json:"coverChecked,omitempty"` +} + +type libraryRowsKey struct{} +type libraryPriorityKey struct{} +type libraryMetadataProgressKey struct{} + +type libraryMetadataProgress struct { + Running bool `json:"running"` + Checked int `json:"checked"` + Total int `json:"total"` + Updated int `json:"updated"` + Failed int `json:"failed"` +} + +func supportsSortMetadata(drama Drama) bool { + switch dramaProvider(drama) { + case sourceHongguo, sourceHuangdou, sourceHuangguoAI, sourceHuangguoVideo: + return true + } + return false +} + +func needsSortMetadata(drama Drama) bool { + return supportsSortMetadata(drama) && (drama.SortMetadata == nil || drama.SortMetadata.Version != dramaSortMetadataVersion(drama) || needsHongguoCoverAddress(drama) || needsHuangdouVIPMetadata(drama)) +} + +func needsHongguoCoverAddress(drama Drama) bool { + return dramaProvider(drama) == sourceHongguo && bestDramaCover(drama) == "" && + (drama.SortMetadata == nil || !drama.SortMetadata.CoverChecked) +} + +func dramaSortMetadataVersion(drama Drama) int { + if dramaProvider(drama) == sourceHuangguoAI { + return huangguoMetadataVersion + } + return sortMetadataVersion +} + +func selectSortMetadataBatch(dramas []Drama, source string, priority []string, now time.Time) []Drama { + positions := make(map[string]int, len(priority)) + for index, id := range priority { + if _, found := positions[id]; !found { + positions[id] = index + } + } + var candidates []Drama + seen := map[string]bool{} + for _, drama := range dramas { + if seen[drama.ID] || !matchesSourceFilter(dramaProvider(drama), source) || !needsSortMetadata(drama) { + continue + } + seen[drama.ID] = true + if drama.SortMetadata != nil && drama.SortMetadata.Version == 0 && now.Sub(drama.SortMetadata.CheckedAt) < sortMetadataRetryDelay { + continue + } + candidates = append(candidates, drama) + } + sort.SliceStable(candidates, func(i, j int) bool { + left, leftOK := positions[candidates[i].ID] + right, rightOK := positions[candidates[j].ID] + if leftOK != rightOK { + return leftOK + } + if leftOK && left != right { + return left < right + } + var leftTime, rightTime time.Time + if candidates[i].SortMetadata != nil { + leftTime = candidates[i].SortMetadata.CheckedAt + } + if candidates[j].SortMetadata != nil { + rightTime = candidates[j].SortMetadata.CheckedAt + } + return leftTime.Before(rightTime) + }) + if len(candidates) > sortMetadataBatchSize { + candidates = candidates[:sortMetadataBatchSize] + } + return candidates +} + +func sortMetadataRemaining(dramas []Drama) map[string]int { + counts := map[string]int{"": 0, "hongguo": 0, "huangdou": 0, "huangguo": 0} + for _, drama := range dramas { + if !needsSortMetadata(drama) { + continue + } + source := dramaProvider(drama) + if source == sourceHuangguoAI || source == sourceHuangguoVideo { + source = "huangguo" + } + counts[source]++ + counts[""]++ + } + return counts +} + +func reportMetadataProgress(ctx context.Context, progress libraryMetadataProgress) { + if callback, ok := ctx.Value(libraryMetadataProgressKey{}).(func(libraryMetadataProgress)); ok { + callback(progress) + } +} + +func (d *Downloader) backfillSortMetadata(ctx context.Context, source string) ([]Drama, map[string]error) { + rows, _ := ctx.Value(libraryRowsKey{}).([]Drama) + priority, _ := ctx.Value(libraryPriorityKey{}).([]string) + batch := selectSortMetadataBatch(rows, source, priority, time.Now()) + progress := libraryMetadataProgress{Running: true, Total: len(batch)} + reportMetadataProgress(ctx, progress) + defer func() { progress.Running = false; reportMetadataProgress(ctx, progress) }() + failures := map[string]error{} + if len(batch) == 0 { + return nil, failures + } + workCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + type result struct { + patch Drama + previous Drama + err error + } + jobs := make(chan Drama, len(batch)) + results := make(chan result, len(batch)) + for _, drama := range batch { + jobs <- drama + } + close(jobs) + workers := d.cfg.RequestConcurrency + if workers < 1 { + workers = 1 + } + if workers > 3 { + workers = 3 + } + var group sync.WaitGroup + for worker := 0; worker < workers; worker++ { + group.Add(1) + go func() { + defer group.Done() + for drama := range jobs { + if workCtx.Err() != nil { + return + } + itemCtx, stop := context.WithTimeout(workCtx, 40*time.Second) + patch, err := d.fetchDramaSortMetadata(itemCtx, drama) + stop() + patch.SortMetadata = &sortMetadataState{CheckedAt: time.Now()} + if err == nil { + patch.SortMetadata.Version = dramaSortMetadataVersion(drama) + patch.SortMetadata.CoverChecked = dramaProvider(drama) == sourceHongguo + patch.SortMetadata.VIPChecked = dramaProvider(drama) == sourceHuangdou + } + results <- result{patch: patch, previous: drama, err: err} + } + }() + } + go func() { group.Wait(); close(results) }() + var patches []Drama + for result := range results { + patch := result.patch + provider := dramaProvider(result.previous) + if result.err != nil { + progress.Failed++ + if failures[provider] == nil { + failures[provider] = fmt.Errorf("部分历史资料未补齐,可稍后重试: %w", result.err) + } + } + if patch.VIP != nil && (result.previous.VIP == nil || *patch.VIP != *result.previous.VIP) || patch.OnlineDate != "" && patch.OnlineDate != result.previous.OnlineDate || patch.Heat != "" && patch.Heat != result.previous.Heat || patch.Views != "" && patch.Views != result.previous.Views || bestDramaCover(patch) != "" && bestDramaCover(patch) != bestDramaCover(result.previous) || huangguoContentChanged(result.previous, mergeDramaMetadata(patch, result.previous)) { + progress.Updated++ + } + patches = append(patches, patch) + progress.Checked++ + reportLibraryProgress(ctx, provider, []Drama{patch}, nil, false) + reportMetadataProgress(ctx, progress) + } + if progress.Checked < len(batch) { + for _, drama := range batch { + provider := dramaProvider(drama) + if failures[provider] == nil { + failures[provider] = errors.New("本批补齐已暂停,剩余条目可再次手动补齐") + } + } + } + return patches, failures +} + +func (d *Downloader) fetchMoreLibrary(ctx context.Context, source string) ([]Drama, error) { + patches, failures := d.backfillSortMetadata(ctx, source) + if matchesSourceFilter(sourceHongguo, source) { + items, err := d.fetchHongguoDramas(ctx) + if err != nil { + failures[sourceHongguo] = errors.Join(failures[sourceHongguo], err) + } + reportLibraryProgress(ctx, sourceHongguo, items, failures[sourceHongguo], true) + patches = mergeSourceDramas(patches, items, nil, sourceHongguo) + } + for _, provider := range []string{"cloudfront", sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou} { + if matchesSourceFilter(provider, source) { + reportLibraryProgress(ctx, provider, nil, failures[provider], true) + } + } + if len(failures) > 0 { + return patches, &libraryLoadError{failures: failures} + } + return patches, nil +} diff --git a/internal/app/library_metadata_live_test.go b/internal/app/library_metadata_live_test.go new file mode 100644 index 0000000..9f20dd8 --- /dev/null +++ b/internal/app/library_metadata_live_test.go @@ -0,0 +1,122 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + "time" +) + +func TestLiveHistoricalSortMetadata(t *testing.T) { + if os.Getenv("JUKU_LIVE_METADATA") != "1" { + t.Skip("set JUKU_LIVE_METADATA=1 for explicit text-metadata verification") + } + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.Retries = t.TempDir(), t.TempDir(), 1 + d := NewDownloader(cfg) + transport := d.client.Transport + d.client.Transport = rankingTransport(func(request *http.Request) (*http.Response, error) { + path := request.URL.Path + if !(strings.HasPrefix(path, "/search/") || path == "/detail" || strings.HasPrefix(path, "/detail/") || path == "/novel/player/video_detail/v1/" || path == "/api/drama/detail" || path == "/api/drama/list") { + t.Errorf("metadata verification refused a non-metadata path: %s", path) + return rankingHTTPResponse(request, 403, "blocked by test"), nil + } + return transport.RoundTrip(request) + }) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second) + defer cancel() + search, err := d.searchHongguoDramas(ctx, "聚宝仙盆") + if err != nil || len(search.Dramas) == 0 { + t.Fatalf("search: %v", err) + } + base := "聚宝仙盆之杂灵根才是真BOSS" + rows := []Drama{ + {ID: "hongguo:7498612570866076734", Source: sourceHongguo, Title: "聚宝仙盆"}, + {ID: "hongguo:7615465407347952664", Source: sourceHongguo, Title: base}, + {ID: "hongguo:7629943873896188990", Source: sourceHongguo, Title: base + "第二季"}, + {ID: "hongguo:7637096540321893400", Source: sourceHongguo, Title: base + "第三季"}, + {ID: "hongguo:7642925668568665150", Source: sourceHongguo, Title: base + "第四季"}, + {ID: "hongguo:7653639205888724030", Source: sourceHongguo, Title: base + "第五季"}, + {ID: "hongguo:7654979763613731864", Source: sourceHongguo, Title: base + "第六季"}, + {ID: "hongguo:7665257305725750297", Source: sourceHongguo, Title: base + "第七季"}, + {ID: "hongguo:7671115782083841048", Source: sourceHongguo, Title: base + "第八季"}, + {ID: "hongguo:7673807696302197784", Source: sourceHongguo, Title: base + "第九季"}, + {ID: "hongguo:7677914379060251673", Source: sourceHongguo, Title: base + "第十季"}, + {ID: "hongguo:7681903520873729048", Source: sourceHongguo, Title: base + "第十一季"}, + {ID: "huangguoai:453", Source: sourceHuangguoAI, Title: "黄果样本"}, + {ID: "huangdou:6a8928e0817f7dacc5972a24", Source: sourceHuangdou, Title: "黄豆样本"}, + } + seen := map[string]bool{} + for _, row := range rows { + seen[row.ID] = true + } + var searchIDs []string + for _, row := range search.Dramas { + searchIDs = append(searchIDs, row.ID) + if !seen[row.ID] { + seen[row.ID] = true + rows = append(rows, Drama{ID: row.ID, Source: row.Source, Title: row.Title}) + } + } + patches, failures := d.backfillSortMetadata(withKnownHongguoDramas(ctx, rows), "") + if len(failures) > 0 { + for source, err := range failures { + t.Errorf("%s: %v", source, err) + } + } + merged := mergeSourceDramas(rows, patches, nil, "") + type textRow struct { + ID string `json:"id"` + Source string `json:"source"` + Title string `json:"title"` + OnlineDate string `json:"onlineDate,omitempty"` + Heat string `json:"heat,omitempty"` + Views string `json:"views,omitempty"` + } + var report []textRow + counts := map[string]map[string]int{} + for _, row := range merged { + if counts[row.Source] == nil { + counts[row.Source] = map[string]int{} + } + count := counts[row.Source] + count["total"]++ + if row.OnlineDate != "" { + count["dates"]++ + } + if row.Heat != "" { + count["heat"]++ + } + if row.Views != "" { + count["views"]++ + } + if row.SortMetadata == nil || row.SortMetadata.Version != sortMetadataVersion { + t.Errorf("entry was not checked: %s", row.ID) + } + if address := bestDramaCover(row); address != "" && (row.Source != sourceHongguo || hongguoCoverAddress(address) != address) { + t.Fatal("metadata must retain only a validated cover address") + } + report = append(report, textRow{row.ID, row.Source, row.Title, row.OnlineDate, row.Heat, row.Views}) + } + for source, count := range counts { + t.Logf("%s: %v", source, count) + } + if count := counts[sourceHongguo]; count["dates"] == 0 || count["heat"] == 0 || count["views"] == 0 { + t.Error("Hongguo live sorting metadata is missing") + } + if count := counts[sourceHuangguoAI]; count["dates"] == 0 || count["views"] == 0 { + t.Error("Huangguo live sorting metadata is missing") + } + if count := counts[sourceHuangdou]; count["heat"] == 0 || count["views"] == 0 { + t.Error("Huangdou live sorting metadata is missing") + } + if path := os.Getenv("JUKU_SORT_METADATA_REPORT"); path != "" { + data, _ := json.MarshalIndent(map[string]any{"query": "聚宝仙盆", "rows": report, "counts": counts, "searchIDs": searchIDs}, "", " ") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/app/library_metadata_queue.go b/internal/app/library_metadata_queue.go new file mode 100644 index 0000000..1ddbc0a --- /dev/null +++ b/internal/app/library_metadata_queue.go @@ -0,0 +1,189 @@ +package app + +import ( + "context" + "time" +) + +func (a *UIApp) newSortMetadataDramasLocked(items []Drama) []Drama { + known := make(map[string]bool, len(a.dramas)) + for _, drama := range a.dramas { + known[drama.ID] = true + } + var fresh []Drama + for _, drama := range items { + if drama.ID != "" && !known[drama.ID] { + known[drama.ID] = true + if needsSortMetadata(drama) { + fresh = append(fresh, drama) + } + } + } + return fresh +} + +func (a *UIApp) enqueueHistoricalMetadataLocked(source string, priority []string) { + var available []Drama + for _, drama := range a.dramas { + if !a.metadataPending[drama.ID] { + available = append(available, drama) + } + } + a.enqueueSortMetadataLocked(selectSortMetadataBatch(available, source, priority, time.Now()), true) +} + +func (a *UIApp) enqueueSortMetadataLocked(items []Drama, prioritize bool) { + if a.metadataClosed || len(items) == 0 { + return + } + if a.metadataPending == nil { + a.metadataPending = map[string]bool{} + } + var added []string + for _, drama := range items { + if drama.ID == "" || !needsSortMetadata(drama) || a.metadataPending[drama.ID] { + continue + } + a.metadataPending[drama.ID] = true + added = append(added, drama.ID) + } + if len(added) == 0 { + return + } + + for index, drama := range a.dramas { + if !a.metadataPending[drama.ID] || drama.SortMetadata != nil && drama.SortMetadata.Pending { + continue + } + state := sortMetadataState{Pending: true} + if drama.SortMetadata != nil { + state = *drama.SortMetadata + state.Pending = true + } + a.dramas[index].SortMetadata = &state + } + if prioritize { + a.metadataQueue = append(added, a.metadataQueue...) + } else { + a.metadataQueue = append(a.metadataQueue, added...) + } + if !a.libraryMetadata.Running { + a.libraryMetadata = libraryMetadataProgress{Running: true} + } + a.libraryMetadata.Total += len(added) + a.libraryDirty = true + a.libraryRevision++ + if a.metadataDone == nil { + ctx, cancel := context.WithCancel(context.Background()) + a.metadataCancel = cancel + a.metadataDone = make(chan struct{}) + go a.runSortMetadataQueue(ctx, a.metadataDone) + } +} + +func (a *UIApp) runSortMetadataQueue(ctx context.Context, done chan struct{}) { + + ctx = context.WithValue(ctx, backgroundCatalogKey{}, true) + for { + a.mu.Lock() + if ctx.Err() != nil || len(a.metadataQueue) == 0 { + a.mu.Unlock() + a.persistLibrary() + a.mu.Lock() + + if ctx.Err() == nil && len(a.metadataQueue) > 0 { + a.mu.Unlock() + continue + } + a.libraryMetadata.Running = false + a.libraryRevision++ + a.metadataCancel() + a.metadataCancel = nil + a.metadataDone = nil + a.metadataQueue = nil + close(done) + a.mu.Unlock() + return + } + if a.libraryLoading != nil { + a.mu.Unlock() + timer := time.NewTimer(500 * time.Millisecond) + select { + case <-ctx.Done(): + case <-timer.C: + } + timer.Stop() + continue + } + id := a.metadataQueue[0] + a.metadataQueue = a.metadataQueue[1:] + var previous Drama + for _, drama := range a.dramas { + if drama.ID == id { + previous = drama + break + } + } + a.mu.Unlock() + itemCtx, cancel := context.WithTimeout(ctx, 40*time.Second) + patch, err := a.downloader.fetchDramaSortMetadata(itemCtx, previous) + cancel() + if err != nil && ctx.Err() == nil { + a.downloader.recordDiagnostic(diagnosticEvent{Event: "metadata.failed", Source: dramaProvider(previous), DramaID: previous.ID, DramaTitle: previous.DisplayTitle(), Message: err.Error()}) + } + patch.SortMetadata = &sortMetadataState{CheckedAt: time.Now()} + if err == nil { + patch.SortMetadata.Version = dramaSortMetadataVersion(previous) + patch.SortMetadata.CoverChecked = dramaProvider(previous) == sourceHongguo + patch.SortMetadata.VIPChecked = dramaProvider(previous) == sourceHuangdou + } + normalizeDramaCover(&patch) + a.mu.Lock() + if ctx.Err() != nil { + + a.mu.Unlock() + continue + } + delete(a.metadataPending, id) + for index, drama := range a.dramas { + if drama.ID != id { + continue + } + _, sourceID, _ := splitProviderDramaID(drama.ID) + if dramaProvider(drama) != sourceHuangguoAI || firstHuangguoTitle(sourceID, drama.Title, drama.Name) != "" { + patch.Title, patch.Name = drama.Title, drama.Name + } + updated := mergeDramaMetadata(patch, drama) + if patch.VIP != nil && (drama.VIP == nil || *patch.VIP != *drama.VIP) || patch.OnlineDate != "" && patch.OnlineDate != drama.OnlineDate || patch.Heat != "" && patch.Heat != drama.Heat || patch.Views != "" && patch.Views != drama.Views || bestDramaCover(patch) != "" && bestDramaCover(patch) != bestDramaCover(drama) || huangguoContentChanged(drama, updated) { + a.libraryMetadata.Updated++ + } + a.dramas[index] = updated + break + } + a.libraryMetadata.Checked++ + if err != nil { + a.libraryMetadata.Failed++ + } + a.libraryDirty = true + a.libraryRevision++ + persist := time.Since(a.libraryLastSave) >= 3*time.Second + if persist { + a.libraryLastSave = time.Now() + } + a.mu.Unlock() + if persist { + a.persistLibrary() + } + } +} + +func (a *UIApp) stopSortMetadata() { + a.mu.Lock() + a.metadataClosed = true + cancel, done := a.metadataCancel, a.metadataDone + a.mu.Unlock() + if cancel != nil { + cancel() + <-done + } +} diff --git a/internal/app/library_metadata_queue_test.go b/internal/app/library_metadata_queue_test.go new file mode 100644 index 0000000..11e4c4b --- /dev/null +++ b/internal/app/library_metadata_queue_test.go @@ -0,0 +1,265 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" +) + +func waitSortMetadataQueue(t *testing.T, app *UIApp) { + t.Helper() + app.mu.Lock() + done := app.metadataDone + app.mu.Unlock() + if done != nil { + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("metadata queue did not finish") + } + } +} + +func TestPrimaryCatalogReturnsBeforeOptionalMetadataForAllSources(t *testing.T) { + for _, source := range []string{sourceHongguo, sourceHuangdou, sourceHuangguoAI} { + t.Run(source, func(t *testing.T) { + id := "7615465407347952664" + started, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.Context().Value(backgroundCatalogKey{}) != true { + t.Error("optional detail did not use background priority") + } + once.Do(func() { close(started) }) + select { + case <-release: + case <-request.Context().Done(): + return nil, request.Context().Err() + } + switch request.URL.Path { + case "/detail": + if request.URL.Query().Get("series_id") != id { + t.Error("historical entry was automatically fetched") + } + return rankingHTTPResponse(request, 200, sortDetailFixture(id, "1773662280")), nil + case "/novel/player/video_detail/v1/": + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":%q,"series_title":"详情标题","hot_score":20019,"series_play_cnt":10019}}}`, id)), nil + case "/api/drama/detail": + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"id":%q,"name":"详情标题","issue_date":"2026-03-16","click":10019}}`, id)), nil + case "/detail/" + id + "/": + body := fmt.Sprintf(`
10019次播放
`, id) + return rankingHTTPResponse(request, 200, body), nil + default: + t.Errorf("unexpected metadata request: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + }) + old := Drama{ID: providerDramaID(source, "7498612570866076734"), Source: source, Title: "历史条目"} + fresh := Drama{ID: providerDramaID(source, id), Source: source, Title: "列表标题", Heat: "20019", Views: "0"} + if source == sourceHongguo { + fresh.Cover = coverAddressFixture + } + if source == sourceHuangguoAI { + fresh.Heat = "" + } + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}, librarySources: map[string]librarySourceState{}, libraryLoading: make(chan struct{})} + t.Cleanup(app.stopSortMetadata) + app.acceptLibraryProgress(source, []Drama{old, fresh}, nil, false) + app.mu.Lock() + if calls.Load() != 0 || len(app.dramas) != 2 || app.dramas[1].Heat != fresh.Heat || app.dramas[1].Views != "0" { + t.Error("primary fields did not return before optional requests") + } + close(app.libraryLoading) + app.libraryLoading = nil + app.mu.Unlock() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("new entry was not scheduled automatically") + } + + app.acceptLibraryProgress(source, []Drama{fresh, old}, nil, true) + writer := httptest.NewRecorder() + app.handleDramas(writer, httptest.NewRequest("GET", "/api/ui/dramas", nil)) + var snapshot struct { + Loading bool `json:"loading"` + Metadata libraryMetadataProgress `json:"metadata"` + } + if json.Unmarshal(writer.Body.Bytes(), &snapshot) != nil || snapshot.Loading || !snapshot.Metadata.Running || snapshot.Metadata.Total != 1 { + t.Fatal("optional metadata blocked the library or duplicated work") + } + close(release) + waitSortMetadataQueue(t, app) + cache, err := readLibraryCache(d.cfg.dataDirectory()) + if err != nil { + t.Fatal(err) + } + for _, row := range cache.Dramas { + if row.ID == old.ID && row.SortMetadata != nil { + t.Error("ordinary catalog refresh enrolled historical data") + } + wantViews := "10019次播放" + if source == sourceHongguo { + wantViews = "0" + } + if row.ID == fresh.ID && (row.OnlineDate != "2026-03-16" || row.Views != wantViews || row.Heat != fresh.Heat || row.Title != "列表标题" || row.SortMetadata == nil || row.SortMetadata.Version != dramaSortMetadataVersion(row) || row.SortMetadata.Pending) { + t.Errorf("asynchronous metadata was not merged correctly: %+v", row) + } + } + if calls.Load() != 1 { + t.Fatalf("duplicate detail requests: %d", calls.Load()) + } + }) + } +} + +func TestResumeOnlyPreviouslyQueuedMetadata(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.Path != "/api/drama/detail" { + t.Errorf("unexpected resource: %s", request.URL.Path) + } + return rankingHTTPResponse(request, 200, `{"data":{"id":"pending","name":"新条目","issue_date":"","click":0}}`), nil + }) + cache := libraryCache{Dramas: []Drama{ + {ID: "huangdou:old", Source: sourceHuangdou, Title: "历史条目"}, + {ID: "huangdou:pending", Source: sourceHuangdou, Title: "新条目", Heat: "0", SortMetadata: &sortMetadataState{Pending: true}}, + }} + if err := writeLibraryCache(d.cfg.dataDirectory(), cache); err != nil { + t.Fatal(err) + } + app := &UIApp{downloader: d, cfg: d.cfg} + t.Cleanup(app.stopSortMetadata) + app.loadLibrary() + waitSortMetadataQueue(t, app) + if calls.Load() != 1 { + t.Fatalf("resume requested %d resources, want one pending detail", calls.Load()) + } + app.mu.Lock() + defer app.mu.Unlock() + if app.dramas[0].SortMetadata != nil || app.dramas[1].OnlineDate != "" || app.dramas[1].Views != "0" || app.dramas[1].SortMetadata.Version != sortMetadataVersion { + t.Fatal("resume changed history, lost zero, or invented an empty source date") + } +} + +func TestPrimarySortFieldsNeedNoAdditionalInterfacesWhenComplete(t *testing.T) { + hongguo := hongguoDramaFromAny(map[string]any{"series_id": "7615465407347952664", "series_title": "列表剧", "series_cover": coverAddressFixture, "first_visible_time": "1773662280", "hot_score": "20019", "series_play_cnt": "0"}, "测试") + huangdouRow := map[string]any{"pay_type": "free", "id": "target", "name": "列表剧", "issue_date": "2026-03-16", "hot_rate": "20019", "click": 0} + huangdou := huangdouDramaFromMap(huangdouRow) + ranking, err := parseHuangdouRanking(map[string]any{"data": map[string]any{"list": []any{huangdouRow}}}, 1) + if err != nil { + t.Fatal(err) + } + huangguo, ok := dramaFromAIMap(map[string]any{"id": "453", "title": "列表剧", "episode_count": 12, "publish_date": "2026-03-16", "play_count": "0", "created_at": "2026-01-01"}, huangguoAIBaseURL, "测试") + if !ok { + t.Fatal("invalid primary list fixture") + } + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + t.Errorf("complete primary data triggered an optional request: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + }) + for _, drama := range []Drama{hongguo, huangdou, ranking.Items[0].Drama, huangguo} { + if drama.OnlineDate != "2026-03-16" || drama.Views != "0" || !hasCompleteSortMetadata(drama) { + t.Errorf("primary sort fields were lost: %+v", drama) + } + if _, err := d.fetchDramaSortMetadata(context.Background(), drama); err != nil { + t.Fatal(err) + } + } + creationOnly, _ := dramaFromAIMap(map[string]any{"id": "454", "title": "列表剧", "created_at": "2026-01-01"}, huangguoAIBaseURL, "测试") + if creationOnly.OnlineDate != "" || hasSortMetric("未知") || hasSortMetric("-1") { + t.Fatal("creation time or unknown metrics were misclassified as available sorting data") + } +} + +func TestBackgroundRequestsYieldToForeground(t *testing.T) { + limiter := newRequestLimiter(1, time.Nanosecond) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + request := httptest.NewRequest("GET", "https://hongguoduanju.com/search/test", nil) + release, err := limiter.acquire(ctx, request) + if err != nil { + t.Fatal(err) + } + background := make(chan func(), 1) + foreground := make(chan func(), 1) + go func() { + unlock, _ := limiter.acquire(context.WithValue(ctx, backgroundCatalogKey{}, true), request) + background <- unlock + }() + go func() { + unlock, _ := limiter.acquire(ctx, request) + foreground <- unlock + }() + + for { + limiter.mu.Lock() + waiting := limiter.foregroundWaiting + limiter.mu.Unlock() + if waiting > 0 { + break + } + if ctx.Err() != nil { + t.Fatal("foreground waiter did not register") + } + time.Sleep(time.Millisecond) + } + release() + select { + case unlock := <-foreground: + if unlock == nil { + t.Fatal("foreground timed out") + } + select { + case <-background: + t.Fatal("background ran alongside a waiting foreground request") + default: + } + unlock() + case <-background: + t.Fatal("optional metadata overtook foreground work") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + select { + case unlock := <-background: + if unlock == nil { + t.Fatal("background did not resume") + } + unlock() + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + +func TestOptionalDetailPermissionFailureDoesNotBlockForeground(t *testing.T) { + limiter := newRequestLimiter(1, time.Nanosecond) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + foreground := httptest.NewRequest("GET", "https://hongguoduanju.com/search/test", nil).WithContext(ctx) + background := foreground.WithContext(context.WithValue(ctx, backgroundCatalogKey{}, true)) + limiter.observe(background, &http.Response{StatusCode: http.StatusForbidden, Header: http.Header{}}) + if release, err := limiter.acquire(background.Context(), background); err == nil { + release() + t.Fatal("repeated forbidden details must cool down") + } + release, err := limiter.acquire(ctx, foreground) + if err != nil { + t.Fatal("an optional detail's permissions blocked public search", err) + } + release() + limiter.observe(background, &http.Response{StatusCode: http.StatusTooManyRequests, Header: http.Header{"Retry-After": {"60"}}}) + if release, err := limiter.acquire(ctx, foreground); err == nil { + release() + t.Fatal("site rate limits must still be respected") + } +} diff --git a/internal/app/library_metadata_test.go b/internal/app/library_metadata_test.go new file mode 100644 index 0000000..160a0e8 --- /dev/null +++ b/internal/app/library_metadata_test.go @@ -0,0 +1,228 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func sortDetailFixture(id, stamp string) string { + return `` +} + +func TestSortDetailDatesAndIdentity(t *testing.T) { + id := "7615465407347952664" + drama, err := parseHongguoSortDetail(sortDetailFixture(id, "1773662280"), id) + if err != nil || drama.OnlineDate != "2026-03-16" { + t.Fatalf("first-visible date: %q, %v", drama.OnlineDate, err) + } + if _, err = parseHongguoSortDetail(sortDetailFixture("7498612570866076734", "1773662280"), id); err == nil { + t.Fatal("accepted another drama's time") + } + drama, err = parseHongguoSortDetail(sortDetailFixture(id, "0"), id) + if err != nil || drama.OnlineDate != "" { + t.Fatal("creation time must not replace missing first-visible time") + } + for _, value := range []string{"", "0", "0001-01-01", "1970-01-01", "2101-01-01", "2026-02-31", "not a date"} { + if got := providerReleaseDate(value); got != "" { + t.Errorf("accepted date %q", value) + } + } + body := `
已发布 · 10001次播放 · 2026-08-11 上线
评论 2026-09-13
` + drama, err = parseHuangguoSortDetail(body, "https://huangguoai.com/detail/453/", Drama{ID: "huangguoai:453", Source: sourceHuangguoAI}) + if err != nil || drama.OnlineDate != "2026-08-11" || drama.Views != "10001次播放" { + t.Fatalf("wrong detail fields: date=%q views=%q err=%v", drama.OnlineDate, drama.Views, err) + } + if _, err = parseHuangguoSortDetail(body, "https://huangguoai.com/detail/100/", Drama{}); err == nil { + t.Fatal("accepted recommendations as the detail") + } + if normalizeViews("10019") != "10019次播放" { + t.Fatal("play counts must retain precision") + } +} + +func TestSortMetadataSelectionAndResume(t *testing.T) { + now := time.Now() + var rows []Drama + for index := 0; index < 60; index++ { + rows = append(rows, Drama{ID: fmt.Sprintf("hongguo:%019d", index+1), Source: sourceHongguo}) + } + rows[1].SortMetadata = &sortMetadataState{Version: sortMetadataVersion, CheckedAt: now, CoverChecked: true} + rows[2].SortMetadata = &sortMetadataState{CheckedAt: now} + rows = append(rows, Drama{ID: "huangdou:other", Source: sourceHuangdou}) + batch := selectSortMetadataBatch(rows, sourceHongguo, []string{rows[59].ID, rows[59].ID, rows[2].ID, "huangdou:other"}, now) + if len(batch) != sortMetadataBatchSize || batch[0].ID != rows[59].ID { + t.Fatal("did not prioritize filtered entries within the batch limit") + } + for _, row := range batch { + if row.ID == rows[1].ID || row.ID == rows[2].ID || row.Source != sourceHongguo { + t.Fatal("selected completed, cooling-down or other-source entries") + } + } + if got := selectSortMetadataBatch(rows, sourceHuangdou, nil, now); len(got) != 1 { + t.Fatal("source filter was not respected") + } + if remaining := sortMetadataRemaining(rows); remaining["hongguo"] != 59 || remaining["huangdou"] != 1 || remaining[""] != 60 { + t.Fatal(remaining) + } +} + +func TestManualBackfillPersistsPartialResultsAndContinuesAfterCatalogEnd(t *testing.T) { + first, bad, unknown := "7615465407347952664", "7498612570866076734", "7677914379060251673" + var calls atomic.Int32 + var fail atomic.Bool + fail.Store(true) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + switch request.URL.Path { + case "/detail": + id := request.URL.Query().Get("series_id") + stamp := "1773662280" + if id == unknown { + stamp = "0" + } + return rankingHTTPResponse(request, 200, sortDetailFixture(id, stamp)), nil + case "/novel/player/video_detail/v1/": + var body struct { + ID string `json:"series_id"` + } + json.NewDecoder(request.Body).Decode(&body) + if body.ID == bad && fail.Load() { + return rankingHTTPResponse(request, 503, "temporary failure"), nil + } + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":%q,"series_title":"测试剧","hot_score":20019,"series_play_cnt":10019}}}`, body.ID)), nil + default: + t.Errorf("backfill requested a non-detail resource: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + }) + client := d.hongguoClient() + client.mu.Lock() + for _, genre := range hongguoAppGenres { + client.state.Feeds[genre.key] = hongguoCatalogCursor{Initialized: true, Exhausted: true} + } + client.mu.Unlock() + a := &UIApp{downloader: d, cfg: d.cfg, libraryAttempted: true, librarySources: map[string]librarySourceState{}, libraryApp: d.hongguoCatalogSnapshot(), dramas: []Drama{ + {ID: "hongguo:" + first, Source: sourceHongguo, Title: "原有标题", Views: "0"}, + {ID: "hongguo:" + bad, Source: sourceHongguo, Title: "暂时失败", Heat: "800"}, + {ID: "hongguo:" + unknown, Source: sourceHongguo, Title: "站点日期未知"}, + {ID: "huangdou:preserve", Source: sourceHuangdou, Title: "保留另一站"}, + }} + t.Cleanup(a.stopSortMetadata) + plain := httptest.NewRecorder() + a.handleDramas(plain, httptest.NewRequest("GET", "/api/ui/dramas", nil)) + if calls.Load() != 0 { + t.Fatal("ordinary library reads must not backfill") + } + before := a.librarySnapshotLocked(0) + if before["hasMoreBySource"].(map[string]bool)[sourceHongguo] != true { + t.Fatal("backfill must remain available after the catalog is exhausted") + } + writer := httptest.NewRecorder() + a.handleDramas(writer, httptest.NewRequest("GET", "/api/ui/dramas?more=1&source=hongguo&priority=hongguo:"+first, nil)) + if writer.Code != 200 { + t.Fatal(writer.Body.String()) + } + waitSortMetadataQueue(t, a) + a.mu.Lock() + rows := append([]Drama(nil), a.dramas...) + progress := a.libraryMetadata + a.mu.Unlock() + if progress.Checked != 3 || progress.Failed != 1 { + t.Fatalf("unexpected progress: %+v", progress) + } + for _, row := range rows { + switch row.ID { + case "hongguo:" + first: + if row.OnlineDate != "2026-03-16" || row.Heat != "20019" || row.Views != "10019次播放" || row.SortMetadata.Version != sortMetadataVersion { + t.Fatal("fresh sorting fields were not merged") + } + case "hongguo:" + bad: + if row.OnlineDate == "" || row.Heat != "800" || row.SortMetadata.Version != 0 { + t.Fatal("partial success must survive a failed metrics request") + } + case "hongguo:" + unknown: + if row.OnlineDate != "" || row.SortMetadata.Version != sortMetadataVersion { + t.Fatal("a valid response with no date must not be retried forever or invent a date") + } + case "huangdou:preserve": + if row.SortMetadata != nil || row.Title != "保留另一站" { + t.Fatal("modified another source") + } + } + } + saved, err := readLibraryCache(d.cfg.dataDirectory()) + if err != nil { + t.Fatal(err) + } + if batch := selectSortMetadataBatch(saved.Dramas, sourceHongguo, nil, time.Now()); len(batch) != 0 { + t.Fatal("restart lost completed checks or failure cooldown") + } + for index, row := range saved.Dramas { + if row.ID == "hongguo:"+bad { + saved.Dramas[index].SortMetadata = &sortMetadataState{CheckedAt: time.Now().Add(-sortMetadataRetryDelay - time.Second)} + } + } + fail.Store(false) + previous := calls.Load() + patches, failures := d.backfillSortMetadata(withKnownHongguoDramas(context.Background(), saved.Dramas), sourceHongguo) + if len(failures) != 0 || len(patches) != 1 || patches[0].ID != "hongguo:"+bad || calls.Load()-previous != 1 { + t.Fatal("retry did not resume only the failed entry") + } +} + +func TestHuangdouHistoricalFieldsUseMatchingListItem(t *testing.T) { + var listCalls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/api/drama/detail": + return rankingHTTPResponse(request, 200, `{"data":{"id":"target","name":"测试剧","issue_date":"2026-08-11","click":0}}`), nil + case "/api/drama/list": + listCalls.Add(1) + body, _ := io.ReadAll(request.Body) + key, _ := huangdouKey(request.Header.Get("requestId")) + decoded, err := huangdouDecode(body, key) + if err != nil || mapString(nestedMap(decoded, "data"), "keywords") != "测试剧" { + t.Error("did not use the site's keyword lookup") + } + return rankingHTTPResponse(request, 200, `{"data":{"list":[{"id":"other","hot_rate":"999999"},{"id":"rp_target","hot_rate":"20019"}]}}`), nil + default: + t.Errorf("unexpected path: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + }) + patch, err := d.fetchDramaSortMetadata(context.Background(), Drama{ID: "huangdou:target", Source: sourceHuangdou}) + if err != nil || patch.OnlineDate != "2026-08-11" || patch.Heat != "20019" || patch.Views != "0" || listCalls.Load() != 1 { + t.Fatalf("wrong matched metadata: date=%q heat=%q views=%q err=%v", patch.OnlineDate, patch.Heat, patch.Views, err) + } + if patch.Cover != nil || patch.CoverURL != nil { + t.Fatal("historical metadata must not collect images") + } +} + +func TestManualMetadataPriorityValidation(t *testing.T) { + a := &UIApp{} + for _, path := range []string{ + "/api/ui/dramas?more=1&source=invalid", + "/api/ui/dramas?more=1&refresh=1", + "/api/ui/dramas?update=1&refresh=1", + "/api/ui/dramas?update=1&more=1", + "/api/ui/dramas?update=1&source=invalid", + "/api/ui/dramas?update=1&priority=" + strings.Repeat("hongguo:700001,", sortMetadataBatchSize), + "/api/ui/dramas?update=1&priority=hongguo:" + strings.Repeat("1", 121), + "/api/ui/dramas?more=1&priority=" + strings.Repeat("id,", sortMetadataBatchSize), + } { + writer := httptest.NewRecorder() + a.handleDramas(writer, httptest.NewRequest("GET", path, nil)) + if writer.Code != 400 { + t.Fatalf("invalid request was not rejected: %d", writer.Code) + } + } +} diff --git a/internal/app/library_progress.go b/internal/app/library_progress.go new file mode 100644 index 0000000..f8a7e6f --- /dev/null +++ b/internal/app/library_progress.go @@ -0,0 +1,241 @@ +package app + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +type librarySourceState struct { + Status string `json:"status"` + Count int `json:"count"` + Error string `json:"error,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type libraryProgressKey struct{} +type libraryProgressFunc func(string, []Drama, error, bool) + +type libraryLoadMode int + +const ( + libraryLoadRefresh libraryLoadMode = iota + libraryLoadMore + libraryLoadUpdate +) + +func reportLibraryProgress(ctx context.Context, source string, items []Drama, err error, done bool) { + if callback, ok := ctx.Value(libraryProgressKey{}).(libraryProgressFunc); ok && (done || len(items) > 0) { + callback(source, items, err, done) + } +} + +func (a *UIApp) startLibraryRefreshLocked(source string) { + a.startLibraryLoadLocked(source, libraryLoadRefresh, nil) +} + +func (a *UIApp) startLibraryLoadLocked(source string, mode libraryLoadMode, priority []string, scopes ...context.Context) { + base := context.Background() + if len(scopes) > 0 && sourceScope(scopes[0]) != nil { + base = context.WithValue(base, accountSourceContextKey{}, sourceScope(scopes[0])) + } + if mode == libraryLoadUpdate || mode == libraryLoadMore { + if source == "" && sourceScopeRestricted(base) { + for _, allowed := range sourceScope(base).Sources { + a.enqueueHistoricalMetadataLocked(allowed, priority) + } + } else { + a.enqueueHistoricalMetadataLocked(source, priority) + } + } + more := mode == libraryLoadMore + if more { + if !matchesSourceFilter(sourceHongguo, source) || !hongguoCatalogHasMore(a.libraryApp) { + return + } + source = sourceHongguo + } + previousCount := len(a.dramas) + a.libraryAttempted = true + a.libraryLoading = make(chan struct{}) + a.libraryMore = more + a.libraryLoadingSource = source + if a.librarySources == nil { + a.librarySources = map[string]librarySourceState{} + } + for _, provider := range []string{"cloudfront", sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou, sourceHongguo} { + if matchesSourceFilter(provider, source) && sourceAllowed(base, provider) { + state := a.librarySources[provider] + state.Status = "loading" + state.Error = "" + a.librarySources[provider] = state + } + } + a.libraryRevision++ + ctx, cancel := context.WithTimeout(base, 12*time.Minute) + a.libraryCancel = cancel + ctx = context.WithValue(ctx, libraryProgressKey{}, libraryProgressFunc(a.acceptLibraryProgress)) + ctx = context.WithValue(ctx, libraryMoreKey{}, more) + ctx = context.WithValue(ctx, libraryUpdateKey{}, mode == libraryLoadUpdate) + ctx = withKnownHongguoDramas(ctx, a.dramas) + go func() { + defer cancel() + if more { + items, err := a.downloader.fetchHongguoDramas(ctx) + reportLibraryProgress(ctx, sourceHongguo, items, err, true) + } else { + _, _ = a.downloader.fetchAllDramas(ctx, source) + } + a.mu.Lock() + a.libraryDirty = true + a.libraryRevision++ + a.mu.Unlock() + a.persistLibrary() + a.mu.Lock() + close(a.libraryLoading) + a.libraryLoading = nil + a.libraryCancel = nil + a.libraryRevision++ + count := len(a.dramas) + a.mu.Unlock() + fmt.Printf("本地剧库:%d 部,本次新增 %d 部\n", count, count-previousCount) + }() +} + +func (a *UIApp) acceptLibraryProgress(source string, items []Drama, loadErr error, done bool) { + a.mu.Lock() + items = append([]Drama(nil), items...) + a.normalizeDramaCovers(items) + newDramas := a.newSortMetadataDramasLocked(items) + if done { + if source == sourceHongguo { + a.libraryApp = a.downloader.hongguoCatalogSnapshot() + } + var mergeErr error + if loadErr != nil { + mergeErr = &libraryLoadError{failures: map[string]error{source: loadErr}} + } + a.dramas = mergeSourceDramas(a.dramas, items, mergeErr, source) + } else { + positions := make(map[string]int, len(a.dramas)) + for index, drama := range a.dramas { + positions[drama.ID] = index + } + for _, drama := range items { + if drama.ID == "" { + continue + } + if position, found := positions[drama.ID]; found { + a.dramas[position] = mergeDramaMetadata(drama, a.dramas[position]) + } else { + positions[drama.ID] = len(a.dramas) + a.dramas = append(a.dramas, drama) + } + } + } + a.enqueueSortMetadataLocked(newDramas, false) + state := a.librarySources[source] + state.Count = 0 + for _, drama := range a.dramas { + if dramaProvider(drama) == source { + state.Count++ + } + } + if len(items) > 0 { + a.loadedAt = time.Now() + state.UpdatedAt = a.loadedAt + } + if done { + state.Status = "ready" + if loadErr != nil { + state.Status = "failed" + if state.Count > 0 { + state.Status = "partial" + } + state.Error = a.redactError(loadErr) + } + } + a.librarySources[source] = state + a.libraryError = librarySourceErrors(a.librarySources) + a.libraryDirty = true + a.libraryRevision++ + persist := done || time.Since(a.libraryLastSave) >= 3*time.Second + if persist { + a.libraryLastSave = time.Now() + } + a.mu.Unlock() + if persist { + a.persistLibrary() + } +} + +func librarySourceErrors(sources map[string]librarySourceState) string { + var messages []string + for source, state := range sources { + if state.Error != "" { + messages = append(messages, fmt.Sprintf("%s 获取失败: %s", source, state.Error)) + } + } + sort.Strings(messages) + return strings.Join(messages, "\n") +} + +func cloneLibrarySources(sources map[string]librarySourceState) map[string]librarySourceState { + copy := make(map[string]librarySourceState, len(sources)) + for source, state := range sources { + copy[source] = state + } + return copy +} + +func (a *UIApp) persistLibrary() { + a.libraryWriteMu.Lock() + defer a.libraryWriteMu.Unlock() + a.mu.Lock() + cache := libraryCache{Dramas: append([]Drama{}, a.dramas...), LoadedAt: a.loadedAt, LastError: librarySourceErrors(a.librarySources), Sources: cloneLibrarySources(a.librarySources), HongguoApp: cloneHongguoCatalogState(a.libraryApp)} + revision := a.libraryRevision + a.mu.Unlock() + err := writeLibraryCache(a.cfg.dataDirectory(), cache) + a.mu.Lock() + defer a.mu.Unlock() + if err != nil { + a.libraryError = strings.TrimSpace(cache.LastError + "\n剧库缓存保存失败: " + a.redactError(err)) + a.libraryRevision++ + return + } + a.librarySaved = true + if revision == a.libraryRevision { + a.libraryDirty = false + } +} + +func (a *UIApp) librarySnapshotLocked(revision uint64) map[string]any { + remaining := sortMetadataRemaining(a.dramas) + var unqueued []Drama + for _, drama := range a.dramas { + if !a.metadataPending[drama.ID] { + unqueued = append(unqueued, drama) + } + } + more := map[string]bool{} + for source, count := range sortMetadataRemaining(unqueued) { + more[source] = count > 0 + } + more["hongguo"] = more["hongguo"] || hongguoCatalogHasMore(a.libraryApp) + more[""] = more[""] || more["hongguo"] + response := map[string]any{ + "revision": a.libraryRevision, "loadedAt": a.loadedAt, "loading": a.libraryLoading != nil, + "cached": a.librarySaved && !a.libraryDirty, "error": a.libraryError, + "sources": cloneLibrarySources(a.librarySources), "total": len(a.dramas), + "hasMore": more["hongguo"], "hasMoreBySource": more, "metadataRemaining": remaining, + "loadingMore": a.libraryMore && a.libraryLoading != nil, "loadingSource": a.libraryLoadingSource, "metadata": a.libraryMetadata, + } + if revision != 0 && revision == a.libraryRevision { + response["unchanged"] = true + } else { + response["data"] = append([]Drama{}, a.dramas...) + } + return response +} diff --git a/internal/app/library_update_test.go b/internal/app/library_update_test.go new file mode 100644 index 0000000..4cae049 --- /dev/null +++ b/internal/app/library_update_test.go @@ -0,0 +1,322 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestUnifiedUpdateChecksNewContentAndContinuesCatalog(t *testing.T) { + for _, test := range []struct { + name string + pages int + savedOffset int + savedEnd bool + knownOffsets []int + endAt int + wantOffsets []int + wantSaved int + wantEnd bool + resumeSession bool + }{ + {name: "first import runs one batch", pages: 2, endAt: -1, wantOffsets: []int{0, 18}, wantSaved: 36}, + {name: "known head resumes saved tail", pages: 2, savedOffset: 90, knownOffsets: []int{0}, endAt: -1, wantOffsets: []int{0, 90, 108}, wantSaved: 126, resumeSession: true}, + {name: "new head stops at overlap", pages: 4, savedOffset: 90, knownOffsets: []int{18}, endAt: -1, wantOffsets: []int{0, 18, 90, 108, 126, 144}, wantSaved: 162, resumeSession: true}, + {name: "large update continues next unread page", pages: 4, savedOffset: 180, endAt: -1, wantOffsets: []int{0, 18, 36, 54, 72, 90, 108}, wantSaved: 126}, + {name: "exhausted catalog still checks new content", pages: 4, savedOffset: 180, savedEnd: true, knownOffsets: []int{18}, endAt: -1, wantOffsets: []int{0, 18}, wantSaved: 180, wantEnd: true}, + {name: "continuation reaches end", pages: 2, savedOffset: 90, knownOffsets: []int{0}, endAt: 90, wantOffsets: []int{0, 90}, wantSaved: 108, wantEnd: true, resumeSession: true}, + } { + t.Run(test.name, func(t *testing.T) { + calls := map[string][]int{} + var known []Drama + for index := range hongguoAppGenres { + for _, offset := range test.knownOffsets { + known = append(known, Drama{ID: fmt.Sprintf("hongguo:%d", 790000+index*1000+offset), Source: sourceHongguo}) + } + } + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + var payload struct { + Offset int `json:"offset"` + Session string `json:"session_id"` + Select struct { + Genre []string `json:"genre"` + } `json:"select_items"` + } + if request.URL.Path != "/reading/distribution/category/landpage/v/" || json.NewDecoder(request.Body).Decode(&payload) != nil || len(payload.Select.Genre) != 1 { + t.Errorf("unexpected catalog request: %s", request.URL.Path) + return rankingHTTPResponse(request, 400, ""), nil + } + genre := payload.Select.Genre[0] + index := -1 + for position, candidate := range hongguoAppGenres { + if candidate.key == genre { + index = position + } + } + if index < 0 { + t.Errorf("unexpected genre: %s", genre) + return rankingHTTPResponse(request, 400, ""), nil + } + calls[genre] = append(calls[genre], payload.Offset) + if payload.Offset == 0 && payload.Session != "" { + t.Error("head check reused a tail session") + } + if test.resumeSession && payload.Offset == test.savedOffset && payload.Session != "saved-"+genre { + t.Error("continuation lost its saved session") + } + body, _ := json.Marshal(map[string]any{"data": map[string]any{ + "next_offset": payload.Offset + 18, "session_id": "fixture-" + genre, "has_more": payload.Offset != test.endAt, + "video_data": []any{map[string]any{ + "series_id": fmt.Sprint(790000 + index*1000 + payload.Offset), "series_title": "文字目录样本", + "first_visible_time": "1773662280", "hot_score": "20", "series_play_cnt": "0", + }}, + }}) + return rankingHTTPResponse(request, 200, string(body)), nil + }) + d.cfg.MaxPagesPerSort = test.pages + if test.savedOffset > 0 { + client := d.hongguoClient() + for _, genre := range hongguoAppGenres { + client.state.Feeds[genre.key] = hongguoCatalogCursor{ + Offset: test.savedOffset, SessionID: "saved-" + genre.key, Initialized: true, Exhausted: test.savedEnd, UpdatedAt: time.Now(), + } + } + } + ctx := withKnownHongguoDramas(context.WithValue(context.Background(), libraryUpdateKey{}, true), known) + items, err := d.fetchHongguoAppCatalog(ctx) + if err != nil || len(items) != len(test.wantOffsets)*len(hongguoAppGenres) { + t.Fatalf("catalog result: %d items, %v", len(items), err) + } + for _, genre := range hongguoAppGenres { + if !reflect.DeepEqual(calls[genre.key], test.wantOffsets) { + t.Errorf("%s requested %v, want %v", genre.key, calls[genre.key], test.wantOffsets) + } + cursor := d.hongguoCatalogSnapshot().Feeds[genre.key] + if !cursor.Initialized || cursor.Offset != test.wantSaved || cursor.Exhausted != test.wantEnd { + t.Errorf("wrong checkpoint for %s: %+v", genre.key, cursor) + } + } + }) + } +} + +func TestUnifiedUpdatePreservesHistoryAcrossRestart(t *testing.T) { + calls := map[string][]int{} + transport := func(request *http.Request) (*http.Response, error) { + var payload struct { + Offset int `json:"offset"` + Scene string `json:"req_scene"` + } + if request.URL.Path != "/reading/distribution/category/landpage/v/" || json.NewDecoder(request.Body).Decode(&payload) != nil { + t.Errorf("unexpected request: %s", request.URL.Path) + return rankingHTTPResponse(request, 400, ""), nil + } + calls[payload.Scene] = append(calls[payload.Scene], payload.Offset) + index := 0 + for position, genre := range hongguoAppGenres { + if genre.scene == payload.Scene { + index = position + } + } + body := fmt.Sprintf(`{"data":{"next_offset":%d,"has_more":%t,"session_id":"saved-session","video_data":[{"series_id":"%d","series_title":"文字样本","first_visible_time":"1773662280","hot_score":"20","series_play_cnt":"0"}]}}`, payload.Offset+18, payload.Offset == 0, 790000+index*1000+payload.Offset) + return rankingHTTPResponse(request, 200, body), nil + } + directory := t.TempDir() + for run, wantCount := range []int{3, 6, 6} { + d := rankingTestDownloader(t, transport) + d.cfg.dataDir, d.cfg.MaxPagesPerSort = directory, 1 + items, err := d.RefreshDramas(context.WithValue(context.Background(), libraryUpdateKey{}, true), sourceHongguo) + if err != nil || len(items) != wantCount { + t.Fatalf("run %d lost history: %d items, %v", run, len(items), err) + } + cache, err := readLibraryCache(directory) + if err != nil || len(cache.Dramas) != wantCount || hongguoCatalogHasMore(cache.HongguoApp) != (run == 0) { + t.Fatalf("run %d did not persist data and cursor together: %v", run, err) + } + for _, drama := range cache.Dramas { + if drama.OnlineDate != "2026-03-16" || drama.Heat != "20" || drama.Views != "0" { + t.Fatal("update lost available sorting fields") + } + } + } + for scene, offsets := range calls { + if !reflect.DeepEqual(offsets, []int{0, 0, 18, 0}) { + t.Errorf("%s skipped or repeated a continuation: %v", scene, offsets) + } + } +} + +func TestUnifiedUpdateReturnsImmediatelyAndDeduplicatesWork(t *testing.T) { + catalogStarted, catalogRelease := make(chan struct{}), make(chan struct{}) + detailStarted, detailRelease := make(chan string, 1), make(chan struct{}) + releaseCatalog := sync.OnceFunc(func() { close(catalogRelease) }) + releaseDetail := sync.OnceFunc(func() { close(detailRelease) }) + var catalogOnce sync.Once + var catalogCalls atomic.Int32 + var detailMu sync.Mutex + var detailIDs []string + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/reading/distribution/category/landpage/v/": + catalogCalls.Add(1) + catalogOnce.Do(func() { close(catalogStarted) }) + select { + case <-catalogRelease: + case <-request.Context().Done(): + return nil, request.Context().Err() + } + return rankingHTTPResponse(request, 200, `{"data":{"next_offset":18,"has_more":false,"video_data":[{"series_id":"790000","series_title":"已完成的目录条目","first_visible_time":"1773662280","hot_score":"20","series_play_cnt":"0"}]}}`), nil + case "/detail": + if request.Context().Value(backgroundCatalogKey{}) != true { + t.Error("historical metadata did not use background priority") + } + id := request.URL.Query().Get("series_id") + detailMu.Lock() + detailIDs = append(detailIDs, id) + if len(detailIDs) == 1 { + detailStarted <- id + } + detailMu.Unlock() + select { + case <-detailRelease: + case <-request.Context().Done(): + return nil, request.Context().Err() + } + return rankingHTTPResponse(request, 200, sortDetailFixture(id, "1773662280")), nil + default: + t.Errorf("unexpected resource: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + }) + d.cfg.MaxPagesPerSort = 1 + for _, genre := range hongguoAppGenres { + d.hongguoClient().state.Feeds[genre.key] = hongguoCatalogCursor{Initialized: true, Exhausted: true, Offset: 18} + } + app := &UIApp{downloader: d, cfg: d.cfg, libraryAttempted: true, libraryApp: d.hongguoCatalogSnapshot(), librarySources: map[string]librarySourceState{}} + app.dramas = append(app.dramas, Drama{ID: "hongguo:790000", Source: sourceHongguo, Title: "已完成的目录条目", Cover: coverAddressFixture, OnlineDate: "2026-03-16", Heat: "20", Views: "0", SortMetadata: &sortMetadataState{Version: sortMetadataVersion}}) + for index := 0; index < 60; index++ { + app.dramas = append(app.dramas, Drama{ID: fmt.Sprintf("hongguo:%d", 800000+index), Source: sourceHongguo, Title: "历史文字样本", Cover: coverAddressFixture, Heat: "20", Views: "0"}) + } + t.Cleanup(func() { + releaseCatalog() + releaseDetail() + app.mu.Lock() + done, cancel := app.libraryLoading, app.libraryCancel + app.mu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("catalog worker did not stop") + } + } + app.stopSortMetadata() + }) + type snapshot struct { + Accepted bool `json:"updateAccepted"` + Loading bool `json:"loading"` + Data []Drama `json:"data"` + Metadata libraryMetadataProgress `json:"metadata"` + } + read := func(path string) snapshot { + t.Helper() + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + writer := httptest.NewRecorder() + app.handleDramas(writer, httptest.NewRequest("GET", path, nil)) + done <- writer + }() + select { + case writer := <-done: + var result snapshot + if writer.Code != 200 || json.Unmarshal(writer.Body.Bytes(), &result) != nil { + t.Fatalf("invalid snapshot: %d %s", writer.Code, writer.Body.String()) + } + return result + case <-time.After(2 * time.Second): + t.Fatal("library response waited for a stalled upstream request") + return snapshot{} + } + } + if result := read("/api/ui/dramas"); result.Loading || result.Metadata.Total != 0 || catalogCalls.Load() != 0 { + t.Fatal("ordinary cache read scheduled remote work") + } + path := "/api/ui/dramas?update=1&source=hongguo&priority=hongguo:800059" + result := read(path) + if !result.Accepted || !result.Loading || result.Metadata.Total != 40 || len(result.Data) != 61 { + t.Fatalf("update did not immediately return cache and bounded progress: %+v", result.Metadata) + } + select { + case <-catalogStarted: + case <-time.After(2 * time.Second): + t.Fatal("update did not start the main catalog") + } + for _, request := range []string{path, path, "/api/ui/dramas?revision=1"} { + if result := read(request); result.Accepted || !result.Loading || result.Metadata.Total != 40 { + t.Fatal("repeated update or polling duplicated work") + } + } + select { + case <-detailStarted: + t.Fatal("metadata did not yield to the catalog") + default: + } + app.mu.Lock() + catalogDone := app.libraryLoading + app.mu.Unlock() + releaseCatalog() + select { + case <-catalogDone: + case <-time.After(2 * time.Second): + t.Fatal("catalog waited for optional metadata") + } + select { + case id := <-detailStarted: + if id != "800059" { + t.Fatalf("current filtered result was not prioritized: %s", id) + } + case <-time.After(2 * time.Second): + t.Fatal("historical metadata was not scheduled") + } + if result := read("/api/ui/dramas"); result.Loading || !result.Metadata.Running || result.Metadata.Checked != 0 || catalogCalls.Load() != 3 { + t.Fatal("optional metadata blocked the main response or catalog work was duplicated") + } + releaseDetail() + waitSortMetadataQueue(t, app) + result = read("/api/ui/dramas") + if result.Metadata.Checked != 40 || result.Metadata.Failed != 0 || result.Metadata.Running { + t.Fatalf("unexpected completion: %+v", result.Metadata) + } + untouched := 0 + for _, drama := range result.Data { + if strings.HasPrefix(drama.ID, "hongguo:800") && drama.SortMetadata == nil { + untouched++ + } + } + if untouched != 20 { + t.Fatalf("update migrated more than one historical batch: %d untouched", untouched) + } + detailMu.Lock() + defer detailMu.Unlock() + seen := map[string]bool{} + for _, id := range detailIDs { + if seen[id] { + t.Fatalf("duplicate historical request: %s", id) + } + seen[id] = true + } + if len(seen) != 40 { + t.Fatalf("unexpected detail request count: %d", len(seen)) + } +} diff --git a/internal/app/media_merge.go b/internal/app/media_merge.go new file mode 100644 index 0000000..f88d1b8 --- /dev/null +++ b/internal/app/media_merge.go @@ -0,0 +1,226 @@ +package app + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +type mergeMediaInfo struct { + signature string + width int + height int + audio bool + duration time.Duration +} + +func probeMergeMedia(ctx context.Context, ffmpeg, path string) (mergeMediaInfo, error) { + probeCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + command := exec.CommandContext(probeCtx, ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "info", + "-protocol_whitelist", "file,pipe", "-i", path, "-map", "0:v:0", "-map", "0:a:0?", + "-c", "copy", "-t", "0", "-f", "framehash", "-hash", "sha256", "pipe:1") + output := &cappedStringWriter{limit: 64 * 1024} + diagnostics := &cappedStringWriter{limit: 64 * 1024} + command.Stdout, command.Stderr = output, diagnostics + if err := command.Run(); err != nil { + if probeCtx.Err() != nil { + return mergeMediaInfo{}, probeCtx.Err() + } + return mergeMediaInfo{}, fmt.Errorf("检测 %s 失败:%w %s", filepath.Base(path), err, truncate(diagnostics.String(), 1000)) + } + var info mergeMediaInfo + var signature []string + for _, line := range strings.Split(output.String(), "\n") { + line = strings.TrimSpace(line) + for _, prefix := range []string{"#extradata ", "#tb ", "#media_type ", "#codec_id ", "#dimensions ", "#sar ", "#sample_rate ", "#channel_layout"} { + if strings.HasPrefix(line, prefix) { + signature = append(signature, line) + break + } + } + if strings.HasPrefix(line, "#dimensions 0:") { + _, _ = fmt.Sscanf(strings.TrimSpace(strings.TrimPrefix(line, "#dimensions 0:")), "%dx%d", &info.width, &info.height) + } + if line == "#media_type 1: audio" { + info.audio = true + } + } + if match := playbackDurationPattern.FindStringSubmatch(diagnostics.String()); len(match) == 4 { + hours, _ := strconv.ParseFloat(match[1], 64) + minutes, _ := strconv.ParseFloat(match[2], 64) + seconds, _ := strconv.ParseFloat(match[3], 64) + info.duration = time.Duration((hours*3600 + minutes*60 + seconds) * float64(time.Second)) + } + if info.width <= 0 || info.height <= 0 || info.duration <= 0 { + return mergeMediaInfo{}, fmt.Errorf("%s 缺少有效的视频尺寸或时长,无法安全合并", filepath.Base(path)) + } + info.signature = strings.Join(signature, "\n") + return info, nil +} + +func runMergeFFmpeg(ctx context.Context, ffmpeg string, args []string, progress func(time.Duration)) error { + command := exec.CommandContext(ctx, ffmpeg, append([]string{ + "-hide_banner", "-nostdin", "-nostats", "-loglevel", "error", "-progress", "pipe:1", + }, args...)...) + diagnostics := &cappedStringWriter{limit: 64 * 1024} + command.Stderr = diagnostics + output, err := command.StdoutPipe() + if err != nil { + return err + } + defer output.Close() + if err := command.Start(); err != nil { + return err + } + scanner := bufio.NewScanner(output) + for scanner.Scan() { + key, value, ok := strings.Cut(scanner.Text(), "=") + if ok && key == "out_time_us" && progress != nil { + if micros, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil && micros >= 0 { + progress(time.Duration(micros) * time.Microsecond) + } + } + } + err = command.Wait() + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return fmt.Errorf("FFmpeg 处理失败:%w %s", err, truncate(diagnostics.String(), 1500)) + } + return scanner.Err() +} + +func normalizedMergeArgs(input, output string, info mergeMediaInfo, width, height int, audio bool) []string { + args := []string{"-xerror", "-threads", "2", "-protocol_whitelist", "file,pipe", "-i", input} + if audio && !info.audio { + args = append(args, "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000") + } + args = append(args, "-map", "0:v:0") + if audio { + audioInput := "0:a:0" + if !info.audio { + audioInput = "1:a:0" + } + args = append(args, "-map", audioInput, "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", + "-af", "aresample=48000:async=1:first_pts=0,apad", "-shortest") + } else { + args = append(args, "-an") + } + filter := fmt.Sprintf("setpts=PTS-STARTPTS,fps=30,scale=%d:%d:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1", width, height, width, height) + return append(args, "-sn", "-dn", "-map_metadata", "-1", "-vf", filter, + "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p", + "-threads:v", "2", "-g", "60", "-sc_threshold", "0", "-video_track_timescale", "90000", + "-max_muxing_queue_size", "4096", "-t", strconv.FormatFloat(info.duration.Seconds(), 'f', 6, 64), "-f", "mp4", "-y", output) +} + +func mergeMediaFiles(ctx context.Context, ffmpeg string, paths []string, output string, report func(int, string)) (string, error) { + if len(paths) == 0 { + return "", fmt.Errorf("没有可合并的分集") + } + lastProgress, lastDetail := -1, "" + notify := func(progress int, detail string) { + if progress > 99 { + progress = 99 + } + if progress < lastProgress { + progress = lastProgress + } + if report != nil && (progress != lastProgress || detail != lastDetail) { + report(progress, detail) + } + lastProgress, lastDetail = progress, detail + } + metadata := make([]mergeMediaInfo, 0, len(paths)) + var duration time.Duration + var width, height int + var audio, normalize bool + for index, path := range paths { + notify(index*5/len(paths), fmt.Sprintf("检测分集编码 %d/%d", index+1, len(paths))) + info, err := probeMergeMedia(ctx, ffmpeg, path) + if err != nil { + return "", err + } + if index > 0 && info.signature != metadata[0].signature { + normalize = true + } + metadata = append(metadata, info) + duration += info.duration + if info.width > width { + width = info.width + } + if info.height > height { + height = info.height + } + audio = audio || info.audio + } + work, err := os.MkdirTemp(filepath.Dir(output), ".merge-*") + if err != nil { + return "", err + } + defer os.RemoveAll(work) + inputs := append([]string(nil), paths...) + method, start := "原编码快速合并", 5 + if normalize { + method, start = "兼容合并(H.264 / AAC)", 95 + width += width % 2 + height += height % 2 + var processed time.Duration + for index, path := range paths { + detail := fmt.Sprintf("编码不一致,统一格式 %d/%d", index+1, len(paths)) + notify(5+int(processed*90/duration), detail) + temporary := filepath.Join(work, fmt.Sprintf("%06d.mp4", index+1)) + err := runMergeFFmpeg(ctx, ffmpeg, normalizedMergeArgs(path, temporary, metadata[index], width, height, audio), func(elapsed time.Duration) { + if elapsed > metadata[index].duration { + elapsed = metadata[index].duration + } + notify(5+int((processed+elapsed)*90/duration), detail) + }) + if err != nil { + return "", fmt.Errorf("转换 %s 失败:%w;原分集已保留", filepath.Base(path), err) + } + processed += metadata[index].duration + inputs[index] = temporary + } + } + var manifest strings.Builder + for _, path := range inputs { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + fmt.Fprintf(&manifest, "file '%s'\n", strings.ReplaceAll(filepath.ToSlash(absolute), "'", "'\\''")) + } + list := filepath.Join(work, "inputs.ffconcat") + if err := os.WriteFile(list, []byte(manifest.String()), 0600); err != nil { + return "", err + } + notify(start, method) + err = runMergeFFmpeg(ctx, ffmpeg, []string{"-protocol_whitelist", "file,pipe", "-f", "concat", "-safe", "0", "-i", list, + "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn", "-c", "copy", "-movflags", "+faststart", "-y", output}, func(elapsed time.Duration) { + notify(start+int(elapsed*time.Duration(99-start)/duration), method) + }) + if err != nil { + return "", err + } + notify(99, "核对合并文件时长") + merged, err := probeMergeMedia(ctx, ffmpeg, output) + if err != nil { + return "", err + } + tolerance := duration / 100 + if tolerance < 2*time.Second { + tolerance = 2 * time.Second + } + if merged.duration < duration-tolerance || merged.duration > duration+tolerance { + return "", fmt.Errorf("合并文件时长不符(预计 %.1f 秒,实际 %.1f 秒),原分集已保留", duration.Seconds(), merged.duration.Seconds()) + } + return method, nil +} diff --git a/internal/app/media_playlist.go b/internal/app/media_playlist.go new file mode 100644 index 0000000..a3ded86 --- /dev/null +++ b/internal/app/media_playlist.go @@ -0,0 +1,49 @@ +package app + +import ( + "math" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +func m3u8Duration(raw string) time.Duration { + var total time.Duration + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "#EXTINF:") { + continue + } + value := strings.TrimSpace(strings.TrimPrefix(line, "#EXTINF:")) + if comma := strings.IndexByte(value, ','); comma >= 0 { + value = value[:comma] + } + seconds, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || seconds < 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) { + continue + } + total += time.Duration(seconds * float64(time.Second)) + } + return total +} + +func rewriteM3U8(raw, playlistURL string) string { + base, err := url.Parse(playlistURL) + lines := strings.Split(raw, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, "URI=") && strings.Contains(trimmed, "/api/app/vid/sec") { + lines[i] = regexp.MustCompile(`URI="[^"]+"`).ReplaceAllString(line, `URI="key.bin"`) + continue + } + if err == nil && trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") { + ref, refErr := url.Parse(trimmed) + if refErr == nil { + lines[i] = base.ResolveReference(ref).String() + } + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/app/media_resume.go b/internal/app/media_resume.go new file mode 100644 index 0000000..aed40fe --- /dev/null +++ b/internal/app/media_resume.go @@ -0,0 +1,230 @@ +package app + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +type mediaByteRange struct { + start int64 + end int64 + total int64 +} + +func parseMediaByteRange(value string) (mediaByteRange, bool) { + var result mediaByteRange + unit, value, ok := strings.Cut(strings.TrimSpace(value), " ") + if !ok || !strings.EqualFold(unit, "bytes") { + return result, false + } + span, total, ok := strings.Cut(value, "/") + if !ok { + return result, false + } + start, end, ok := strings.Cut(span, "-") + if !ok { + return result, false + } + for index, field := range []string{start, end, total} { + if field == "" || strings.IndexFunc(field, func(char rune) bool { return char < '0' || char > '9' }) >= 0 { + return result, false + } + number, err := strconv.ParseInt(field, 10, 64) + if err != nil { + return result, false + } + switch index { + case 0: + result.start = number + case 1: + result.end = number + case 2: + result.total = number + } + } + return result, result.start <= result.end && result.end < result.total +} + +func mediaRequestAttempts(retries int) int { + return min(3, max(1, retries)) +} + +func identityMediaResponse(response *http.Response) bool { + encoding := strings.TrimSpace(response.Header.Get("Content-Encoding")) + return !response.Uncompressed && (encoding == "" || strings.EqualFold(encoding, "identity")) && + !strings.HasPrefix(strings.ToLower(response.Header.Get("Content-Type")), "multipart/") +} + +type resumableMediaReader struct { + proxy *hlsProxy + request *http.Request + body io.ReadCloser + span mediaByteRange + length int64 + read int64 + etag string + modified string + validator string + resumable bool + attempts int + maxAttempts int + pending error + failure error + recovered bool +} + +func (proxy *hlsProxy) mediaReader(request *http.Request, response *http.Response) *resumableMediaReader { + reader := &resumableMediaReader{ + proxy: proxy, request: request.Clone(request.Context()), body: response.Body, + length: -1, maxAttempts: mediaRequestAttempts(proxy.retries) - 1, + etag: strings.TrimSpace(response.Header.Get("ETag")), + modified: strings.TrimSpace(response.Header.Get("Last-Modified")), + } + if response.Request != nil && response.Request.URL != nil { + reader.request.URL = response.Request.URL + } + if response.ContentLength > 0 { + reader.length = response.ContentLength + } + if response.StatusCode == http.StatusOK && reader.length > 0 && response.Header.Get("Content-Range") == "" { + reader.span = mediaByteRange{end: reader.length - 1, total: reader.length} + reader.resumable = true + } else if response.StatusCode == http.StatusPartialContent { + if span, valid := parseMediaByteRange(response.Header.Get("Content-Range")); valid { + length := span.end - span.start + 1 + if response.ContentLength < 0 || response.ContentLength == length { + reader.span, reader.length, reader.resumable = span, length, true + } + } + } + reader.resumable = reader.resumable && request.Method == http.MethodGet && identityMediaResponse(response) + if len(reader.etag) >= 2 && strings.HasPrefix(reader.etag, `"`) && strings.HasSuffix(reader.etag, `"`) { + reader.validator = reader.etag + } else if _, err := http.ParseTime(reader.modified); err == nil { + reader.validator = reader.modified + } + return reader +} + +func (reader *resumableMediaReader) Close() error { + return reader.body.Close() +} + +func (reader *resumableMediaReader) Read(buffer []byte) (int, error) { + if len(buffer) == 0 { + return 0, nil + } + for { + if err := reader.request.Context().Err(); err != nil { + return 0, err + } + if reader.failure != nil { + return 0, reader.failure + } + if reader.length >= 0 && reader.read == reader.length { + if reader.attempts > 0 && !reader.recovered && reader.proxy.diagnostic != nil { + reader.recovered = true + reader.proxy.diagnostic(diagnosticEvent{Level: "info", Event: "media.recovered", Host: reader.request.URL.Hostname(), + OffsetBytes: reader.span.start + reader.read, TotalBytes: reader.span.total, Attempt: reader.attempts, Message: "媒体续读完成"}) + } + return 0, io.EOF + } + if reader.pending != nil { + err := reader.pending + if errors.Is(err, io.EOF) { + if reader.length < 0 { + return 0, io.EOF + } + err = io.ErrUnexpectedEOF + } + if reader.resumable && reader.attempts < reader.maxAttempts { + if err = reader.resume(); err == nil { + reader.pending = nil + continue + } + } + if canceled := reader.request.Context().Err(); canceled != nil { + return 0, canceled + } + reader.failure = fmt.Errorf("媒体资源读取不完整(已读取 %d 字节,续读 %d 次): %w", reader.read, reader.attempts, err) + return 0, reader.failure + } + if reader.length >= 0 && int64(len(buffer)) > reader.length-reader.read { + buffer = buffer[:reader.length-reader.read] + } + count, err := reader.body.Read(buffer) + reader.read += int64(count) + reader.pending = err + if count > 0 || err == nil { + return count, nil + } + } +} + +func (reader *resumableMediaReader) resume() error { + _ = reader.body.Close() + reader.body = http.NoBody + var lastErr error + for reader.attempts < reader.maxAttempts { + ctx := reader.request.Context() + if reader.attempts > 0 { + timer := time.NewTimer(time.Duration(reader.attempts) * 250 * time.Millisecond) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } + if err := ctx.Err(); err != nil { + return err + } + request := reader.request.Clone(ctx) + request.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", reader.span.start+reader.read, reader.span.end)) + request.Header.Del("If-Range") + if reader.validator != "" { + request.Header.Set("If-Range", reader.validator) + } + reader.attempts++ + if reader.proxy.diagnostic != nil { + reader.proxy.diagnostic(diagnosticEvent{Level: "warn", Event: "media.resume", Host: reader.request.URL.Hostname(), + OffsetBytes: reader.span.start + reader.read, TotalBytes: reader.span.total, Attempt: reader.attempts, Message: "媒体连接中断,正在续读"}) + } + response, err := reader.proxy.client.Do(request) + if err != nil { + lastErr = err + continue + } + if err = reader.validateContinuation(response); err == nil { + reader.body = response.Body + return nil + } + _ = response.Body.Close() + lastErr = err + if response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests && response.StatusCode < 500 { + return err + } + } + return lastErr +} + +func (reader *resumableMediaReader) validateContinuation(response *http.Response) error { + if response.StatusCode != http.StatusPartialContent { + return fmt.Errorf("媒体续读未返回所需字节范围(HTTP %d)", response.StatusCode) + } + span, valid := parseMediaByteRange(response.Header.Get("Content-Range")) + if !valid || span.start != reader.span.start+reader.read || span.end != reader.span.end || span.total != reader.span.total || + response.ContentLength >= 0 && response.ContentLength != reader.length-reader.read { + return errors.New("媒体续读字节范围或长度不一致") + } + if !identityMediaResponse(response) || reader.etag != "" && strings.TrimSpace(response.Header.Get("ETag")) != reader.etag || + reader.modified != "" && strings.TrimSpace(response.Header.Get("Last-Modified")) != reader.modified { + return errors.New("媒体续读资源已变化") + } + return nil +} diff --git a/internal/app/media_resume_integration_test.go b/internal/app/media_resume_integration_test.go new file mode 100644 index 0000000..48c7ae9 --- /dev/null +++ b/internal/app/media_resume_integration_test.go @@ -0,0 +1,168 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +type mediaDisconnectWriter struct { + http.ResponseWriter + remaining int +} + +func (writer *mediaDisconnectWriter) Write(data []byte) (int, error) { + if len(data) > writer.remaining { + count, _ := writer.ResponseWriter.Write(data[:writer.remaining]) + writer.remaining = 0 + return count, io.ErrUnexpectedEOF + } + count, err := writer.ResponseWriter.Write(data) + writer.remaining -= count + return count, err +} + +func verifyResumedTimeline(t *testing.T, ctx context.Context, ffmpeg, input string) { + t.Helper() + command := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "warning", "-xerror", "-nostdin", "-i", input, + "-map", "0:v:0", "-map", "0:a:0", "-progress", "pipe:1", "-f", "null", "-") + var progress, diagnostic bytes.Buffer + command.Stdout, command.Stderr = &progress, &diagnostic + if err := command.Run(); err != nil { + t.Fatalf("decode resumed timeline: %v\n%s", err, diagnostic.String()) + } + values := make(map[string]string) + for _, line := range strings.Split(progress.String(), "\n") { + key, value, ok := strings.Cut(line, "=") + if ok { + values[key] = strings.TrimSpace(value) + } + } + frames, _ := strconv.Atoi(values["frame"]) + micros, _ := strconv.Atoi(values["out_time_us"]) + if values["progress"] != "end" || frames != 1800 || micros < 59_900_000 || micros > 60_200_000 { + t.Fatalf("interrupted media lost frames, audio or duration: %v\n%s", values, diagnostic.String()) + } +} + +func TestPlaybackAndDownloadResumeInterruptedMedia(t *testing.T) { + fixture, fixtureSession := nativePlaybackFixture(t, "60", "160x90") + video, err := os.ReadFile(fixtureSession.tasks[0].OutPath) + if err != nil { + t.Fatal(err) + } + ffmpeg := fixture.downloader.cfg.FFmpeg + for _, mode := range []string{"MSE", "native HLS", "prefetch", "download", "failed download"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + var interrupted atomic.Bool + var resumes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/fixture.mp4" { + t.Error("fixture requested an unrelated resource") + http.NotFound(writer, request) + return + } + writer.Header().Set("ETag", `"generated-media-v1"`) + if request.Header.Get("If-Range") == `"generated-media-v1"` { + resumes.Add(1) + } + if request.Method == http.MethodGet && (interrupted.CompareAndSwap(false, true) || mode == "failed download") { + writer = &mediaDisconnectWriter{ResponseWriter: writer, remaining: min(64*1024, len(video)/2)} + } + http.ServeContent(writer, request, "fixture.mp4", time.Time{}, bytes.NewReader(video)) + })) + t.Cleanup(server.Close) + app, session := prefetchFixtureApp(t) + d := app.downloader + d.cfg.FFmpeg, d.cfg.Retries, d.cfg.SkipBytes = ffmpeg, 3, 1 + d.client = server.Client() + app.cfg = d.cfg + for index := range session.tasks { + session.tasks[index] = Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "合成断流样本", Index: index + 1, Total: 3, + OutPath: filepath.Join(t.TempDir(), "episode.mp4"), + Chapter: Chapter{ID: fmt.Sprintf("hongguo:7000000000000000001:%d", index+1), Source: sourceHongguo, VideoURL: server.URL + "/fixture.mp4", CurrentEpisode: rawEpisode(index + 1)}} + } + var input string + switch mode { + case "native HLS": + opened := nativeOpenFixture(t, app, 1, 0, 1) + var address string + if err := json.Unmarshal(opened["url"], &address); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + app.registerPlaybackRoutes(mux) + local := httptest.NewServer(viewerFixtureHandler(app, mux)) + t.Cleanup(local.Close) + input = local.URL + address + case "download", "failed download": + var completed atomic.Bool + err := d.DownloadEpisodeWithProgress(ctx, session.tasks[0], func(progress DownloadProgress) { + if progress.Phase == "completed" && progress.Percent == 100 { + completed.Store(true) + } + }) + if mode == "failed download" { + _, fileErr := os.Stat(session.tasks[0].OutPath) + if err == nil || completed.Load() || !os.IsNotExist(fileErr) { + t.Fatal("incomplete media was marked as a completed download", err, fileErr) + } + return + } + if err != nil || !completed.Load() { + t.Fatal("interrupted download did not complete", err) + } + input = session.tasks[0].OutPath + default: + episode := 1 + if mode == "prefetch" { + episode = 2 + result := prefetchRequest(app, `{"session":"fixture","episode":2,"run":4,"version":1}`) + if result.Code != http.StatusAccepted { + t.Fatal("prefetch was not accepted", result.Body.String()) + } + select { + case <-session.prefetch.done: + case <-ctx.Done(): + t.Fatal("prefetch did not complete") + } + if session.prefetch.view().State != "ready" { + t.Fatal("recovered media was marked as a failed prefetch", session.prefetch.view()) + } + } + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/api/ui/playback/stream?session=fixture&episode=%d", episode), nil).WithContext(ctx) + app.handlePlaybackStream(writer, viewerFixtureRequest(app, request)) + state, _ := app.playbackStatus("fixture", false) + if writer.Code != http.StatusOK || state.State != "ended" || state.Error != "" || state.Duration != 60 { + t.Fatalf("recovered playback failed: HTTP=%d state=%+v", writer.Code, state) + } + if mode == "prefetch" && writer.Header().Get("X-Playback-Prefetched") != "1" { + t.Fatal("recovered prefetch was discarded") + } + input = filepath.Join(t.TempDir(), "resumed.mp4") + if err := os.WriteFile(input, writer.Body.Bytes(), 0600); err != nil { + t.Fatal(err) + } + } + verifyResumedTimeline(t, ctx, ffmpeg, input) + if !interrupted.Load() || resumes.Load() == 0 { + t.Fatal("integration did not exercise automatic byte continuation") + } + }) + } +} diff --git a/internal/app/media_resume_test.go b/internal/app/media_resume_test.go new file mode 100644 index 0000000..d61b3e8 --- /dev/null +++ b/internal/app/media_resume_test.go @@ -0,0 +1,343 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +func mediaProxyFixture(t *testing.T, handler http.HandlerFunc) (*hlsProxy, *http.Client) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + downloader := &Downloader{cfg: Config{Retries: 3}, client: server.Client()} + proxy, err := downloader.newHLSProxy(context.Background(), providerMedia{URL: server.URL + "/fixture.mp4", Referer: "https://example.invalid/"}, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(proxy.Close) + client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: 10 * time.Second} + t.Cleanup(client.CloseIdleConnections) + return proxy, client +} + +func TestMediaProxyResumesTruncatedResponse(t *testing.T) { + payload := bytes.Repeat([]byte("0123456789abcdef"), 64) + for _, test := range []struct { + name string + rangeHeader string + start int + end int + }{ + {name: "whole", start: 0, end: 1023}, + {name: "bounded seek", rangeHeader: "bytes=100-699", start: 100, end: 699}, + {name: "suffix seek", rangeHeader: "bytes=-129", start: 895, end: 1023}, + } { + t.Run(test.name, func(t *testing.T) { + var calls atomic.Int32 + proxy, client := mediaProxyFixture(t, func(writer http.ResponseWriter, request *http.Request) { + attempt := calls.Add(1) + if request.Header.Get("Accept-Encoding") != "identity" || request.Header.Get("Referer") != "https://example.invalid/" { + t.Error("media request lost source headers") + } + start, end, status := test.start, test.end, http.StatusOK + if test.rangeHeader != "" { + status = http.StatusPartialContent + } + if attempt == 1 { + if request.Header.Get("Range") != test.rangeHeader { + t.Error("initial seek range was changed") + } + } else { + start += 13 + status = http.StatusPartialContent + want := fmt.Sprintf("bytes=%d-%d", start, end) + if request.Header.Get("Range") != want || request.Header.Get("If-Range") != `"fixture-v1"` { + t.Errorf("unsafe continuation: Range=%q If-Range=%q, want %s", request.Header.Get("Range"), request.Header.Get("If-Range"), want) + } + } + writer.Header().Set("ETag", `"fixture-v1"`) + writer.Header().Set("Content-Type", "video/mp4") + writer.Header().Set("Content-Length", strconv.Itoa(end-start+1)) + if status == http.StatusPartialContent { + writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))) + } + writer.WriteHeader(status) + if attempt == 1 { + _, _ = writer.Write(payload[start : start+13]) + } else { + _, _ = writer.Write(payload[start : end+1]) + } + }) + request, _ := http.NewRequest(http.MethodGet, proxy.root, nil) + request.Header.Set("Range", test.rangeHeader) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil || !bytes.Equal(body, payload[test.start:test.end+1]) || calls.Load() != 2 || proxy.Err() != nil { + t.Fatalf("interrupted media did not resume exactly: bytes=%d calls=%d read=%v proxy=%v", len(body), calls.Load(), err, proxy.Err()) + } + wantStatus := http.StatusOK + if test.rangeHeader != "" { + wantStatus = http.StatusPartialContent + } + if response.StatusCode != wantStatus || response.ContentLength != int64(test.end-test.start+1) { + t.Fatal("continuation changed the local response headers") + } + }) + } +} + +func TestMediaProxyExhaustsBodyRetries(t *testing.T) { + payload := bytes.Repeat([]byte("0123456789"), 10) + var calls atomic.Int32 + proxy, client := mediaProxyFixture(t, func(writer http.ResponseWriter, request *http.Request) { + attempt := int(calls.Add(1)) + start := (attempt - 1) * 13 + writer.Header().Set("Content-Length", strconv.Itoa(len(payload)-start)) + writer.Header().Set("ETag", `"fixture-v1"`) + if attempt > 1 { + if request.Header.Get("Range") != fmt.Sprintf("bytes=%d-99", start) { + t.Error("retry did not preserve its cumulative offset") + } + writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-99/100", start)) + writer.WriteHeader(http.StatusPartialContent) + } + _, _ = writer.Write(payload[start : start+13]) + }) + response, err := client.Get(proxy.root) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err == nil || calls.Load() != 3 || !bytes.Equal(body, payload[:39]) || proxy.Err() == nil { + t.Fatalf("exhausted transfer was not bounded or reported: bytes=%d calls=%d read=%v proxy=%v", len(body), calls.Load(), err, proxy.Err()) + } +} + +func TestMediaProxyRejectsUnsafeContinuation(t *testing.T) { + for _, test := range []struct { + name, contentRange, etag, encoding string + status, length int + }{ + {name: "range ignored", status: 200, length: 100, etag: `"fixture-v1"`}, + {name: "wrong start", status: 206, length: 90, contentRange: "bytes 9-98/100", etag: `"fixture-v1"`}, + {name: "wrong end", status: 206, length: 89, contentRange: "bytes 10-98/100", etag: `"fixture-v1"`}, + {name: "changed length", status: 206, length: 90, contentRange: "bytes 10-99/101", etag: `"fixture-v1"`}, + {name: "changed object", status: 206, length: 90, contentRange: "bytes 10-99/100", etag: `"fixture-v2"`}, + {name: "missing validator", status: 206, length: 90, contentRange: "bytes 10-99/100"}, + {name: "encoded bytes", status: 206, length: 90, contentRange: "bytes 10-99/100", etag: `"fixture-v1"`, encoding: "gzip"}, + {name: "conflicting length", status: 206, length: 80, contentRange: "bytes 10-99/100", etag: `"fixture-v1"`}, + } { + t.Run(test.name, func(t *testing.T) { + var calls atomic.Int32 + proxy, client := mediaProxyFixture(t, func(writer http.ResponseWriter, request *http.Request) { + if calls.Add(1) == 1 { + writer.Header().Set("Content-Length", "100") + writer.Header().Set("ETag", `"fixture-v1"`) + _, _ = io.WriteString(writer, "0123456789") + return + } + writer.Header().Set("Content-Length", strconv.Itoa(test.length)) + writer.Header().Set("Content-Range", test.contentRange) + writer.Header().Set("ETag", test.etag) + writer.Header().Set("Content-Encoding", test.encoding) + writer.WriteHeader(test.status) + _, _ = io.WriteString(writer, strings.Repeat("x", test.length)) + }) + response, err := client.Get(proxy.root) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err == nil || string(body) != "0123456789" || calls.Load() != 2 || proxy.Err() == nil { + t.Fatalf("unverified bytes were appended or failure was lost: bytes=%d calls=%d read=%v proxy=%v", len(body), calls.Load(), err, proxy.Err()) + } + }) + } +} + +func TestMediaProxyCanceledRequestDoesNotRecordFailure(t *testing.T) { + proxy, _ := mediaProxyFixture(t, func(http.ResponseWriter, *http.Request) { + t.Error("canceled request reached upstream") + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodGet, proxy.root, nil).WithContext(ctx) + proxy.ServeHTTP(httptest.NewRecorder(), request) + if proxy.Err() != nil { + t.Fatal("stopping playback poisoned its media proxy", proxy.Err()) + } +} + +func TestMediaProxyCancelsDuringContinuation(t *testing.T) { + started := make(chan struct{}) + var calls atomic.Int32 + proxy, _ := mediaProxyFixture(t, func(writer http.ResponseWriter, request *http.Request) { + if calls.Add(1) == 1 { + writer.Header().Set("Content-Length", "100") + _, _ = io.WriteString(writer, "0123456789") + return + } + close(started) + <-request.Context().Done() + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + defer close(done) + proxy.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, proxy.root, nil).WithContext(ctx)) + }() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("continuation never started") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("continuation blocked playback cancellation") + } + if proxy.Err() != nil || calls.Load() != 2 { + t.Fatal("cancellation was recorded as an upstream failure", proxy.Err()) + } +} + +func TestMediaProxyRetriesTemporaryContinuationStatus(t *testing.T) { + const modified = "Mon, 14 Sep 2026 12:00:00 GMT" + var calls atomic.Int32 + proxy, client := mediaProxyFixture(t, func(writer http.ResponseWriter, request *http.Request) { + attempt := calls.Add(1) + if attempt == 2 { + writer.WriteHeader(http.StatusServiceUnavailable) + return + } + writer.Header().Set("Last-Modified", modified) + if attempt == 1 { + writer.Header().Set("Content-Length", "20") + _, _ = io.WriteString(writer, "0123456789") + return + } + if request.Header.Get("Range") != "bytes=10-19" || request.Header.Get("If-Range") != modified { + t.Error("date validator or retry offset was lost") + } + writer.Header().Set("Content-Length", "10") + writer.Header().Set("Content-Range", "bytes 10-19/20") + writer.WriteHeader(http.StatusPartialContent) + _, _ = io.WriteString(writer, "abcdefghij") + }) + response, err := client.Get(proxy.root) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil || string(body) != "0123456789abcdefghij" || calls.Load() != 3 || proxy.Err() != nil { + t.Fatal("temporary continuation failure was latched", err, proxy.Err()) + } +} + +func TestMediaProxyHeadAndUnknownLength(t *testing.T) { + for _, mode := range []string{"head", "chunked", "broken chunked"} { + t.Run(mode, func(t *testing.T) { + var calls atomic.Int32 + proxy, client := mediaProxyFixture(t, func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + if mode == "head" { + writer.Header().Set("Content-Length", "100") + return + } + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + _, _ = io.WriteString(writer, "unknown-length-fixture") + writer.(http.Flusher).Flush() + if mode == "broken chunked" { + connection, _, err := writer.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = connection.Close() + } + }) + method := http.MethodGet + if mode == "head" { + method = http.MethodHead + } + request, _ := http.NewRequest(method, proxy.root, nil) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if mode == "head" && len(body) != 0 || mode != "head" && string(body) != "unknown-length-fixture" || calls.Load() != 1 { + t.Fatal("HEAD or unknown-length response changed") + } + if (proxy.Err() != nil) != (mode == "broken chunked") { + t.Fatal("unverifiable truncation was hidden or a complete body was rejected", proxy.Err()) + } + }) + } +} + +type mediaFailureBody struct { + data []byte +} + +func (body *mediaFailureBody) Read(buffer []byte) (int, error) { + count := copy(buffer, body.data) + body.data = body.data[count:] + return count, io.ErrUnexpectedEOF +} + +func (*mediaFailureBody) Close() error { return nil } + +type mediaStoppedWriter struct{ header http.Header } + +func (writer *mediaStoppedWriter) Header() http.Header { return writer.header } +func (*mediaStoppedWriter) WriteHeader(int) {} +func (*mediaStoppedWriter) Write([]byte) (int, error) { return 0, errors.New("consumer stopped") } + +func TestMediaProxyConsumerStopDoesNotLatchPendingReadError(t *testing.T) { + proxy, _ := mediaProxyFixture(t, func(http.ResponseWriter, *http.Request) { + t.Error("fixture transport was bypassed") + }) + var calls int + proxy.client.Transport = rankingTransport(func(request *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Length": {"100"}}, ContentLength: 100, + Body: &mediaFailureBody{data: []byte("0123456789")}, Request: request}, nil + }) + proxy.ServeHTTP(&mediaStoppedWriter{header: make(http.Header)}, httptest.NewRequest(http.MethodGet, proxy.root, nil)) + if calls != 1 || proxy.Err() != nil { + t.Fatal("unused media bytes triggered a retry or false failure", proxy.Err()) + } +} + +func TestMediaReaderAcceptsExactlyCompletedBody(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "http://fixture.invalid/media", nil) + proxy := &hlsProxy{retries: 3} + reader := proxy.mediaReader(request, &http.Response{StatusCode: 200, ContentLength: 10, Header: make(http.Header), Body: &mediaFailureBody{data: []byte("0123456789")}}) + defer reader.Close() + body, err := io.ReadAll(reader) + if err != nil || string(body) != "0123456789" || reader.attempts != 0 { + t.Fatal("completed response was retried", err) + } +} diff --git a/internal/app/models.go b/internal/app/models.go new file mode 100644 index 0000000..06daf75 --- /dev/null +++ b/internal/app/models.go @@ -0,0 +1,125 @@ +package app + +import ( + "encoding/json" + "strconv" + "strings" + "time" +) + +type Drama struct { + VIP *bool `json:"vip,omitempty"` + ID string `json:"id"` + Source string `json:"source,omitempty"` + SourceID string `json:"sourceId,omitempty"` + Title string `json:"title"` + Name string `json:"name"` + Desc string `json:"desc"` + Intro string `json:"intro"` + Cover any `json:"cover"` + CoverURL any `json:"coverUrl"` + CoverURLSnake any `json:"cover_url"` + Image any `json:"image"` + ImageURL any `json:"imageUrl"` + ImageURLSnake any `json:"image_url"` + Img any `json:"img"` + Pic any `json:"pic"` + Picture any `json:"picture"` + Poster any `json:"poster"` + Thumb any `json:"thumb"` + Thumbnail any `json:"thumbnail"` + TotalEpisode any `json:"totalEpisode"` + TotalEpisodeSnake any `json:"total_episode"` + ChapterCount any `json:"chapterCount"` + ChapterCountSnake any `json:"chapter_count"` + EpisodeCount any `json:"episodeCount"` + EpisodeCountSnake any `json:"episode_count"` + Total any `json:"total"` + Episodes any `json:"episodes"` + Duration any `json:"duration"` + ChannelName string `json:"channelName"` + Category string `json:"category"` + CategoryName string `json:"categoryName"` + CategoryNameSnake string `json:"category_name"` + TypeName string `json:"typeName"` + TypeNameSnake string `json:"type_name"` + SortName string `json:"sortName"` + SortNameSnake string `json:"sort_name"` + Remark string `json:"remark,omitempty"` + Score string `json:"score,omitempty"` + Views string `json:"views,omitempty"` + Heat string `json:"heat,omitempty"` + OnlineDate string `json:"onlineDate,omitempty"` + Tags []string `json:"tags,omitempty"` + ReleaseStatus string `json:"releaseStatus,omitempty"` + SortMetadata *sortMetadataState `json:"sortMetadata,omitempty"` +} + +func (d Drama) DisplayTitle() string { + if strings.TrimSpace(d.Title) != "" { + return d.Title + } + if strings.TrimSpace(d.Name) != "" { + return d.Name + } + return "短剧" +} + +type Chapter struct { + VIP bool `json:"vip,omitempty"` + ID string `json:"id"` + Source string `json:"source,omitempty"` + Title string `json:"title"` + VideoURL string `json:"videoUrl"` + CurrentEpisode json.RawMessage `json:"currentEpisode"` + MediaSize int64 `json:"mediaSize"` + PageURL string `json:"pageUrl,omitempty"` + Referer string `json:"referer,omitempty"` +} + +func (c Chapter) EpisodeString(fallback int) string { + if len(c.CurrentEpisode) > 0 && string(c.CurrentEpisode) != "null" { + var s string + if err := json.Unmarshal(c.CurrentEpisode, &s); err == nil && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + var n int + if err := json.Unmarshal(c.CurrentEpisode, &n); err == nil && n > 0 { + return strconv.Itoa(n) + } + var f float64 + if err := json.Unmarshal(c.CurrentEpisode, &f); err == nil && f > 0 { + return strconv.Itoa(int(f)) + } + } + return strconv.Itoa(fallback) +} + +type Task struct { + DramaID string `json:"dramaId"` + DramaTitle string `json:"dramaTitle"` + Chapter Chapter `json:"chapter"` + Index int `json:"index"` + Total int `json:"total"` + OutPath string `json:"outPath"` + ReleaseStatus string `json:"releaseStatus,omitempty"` + DownloadQuality int `json:"downloadQuality,omitempty"` +} + +type Result struct { + Task Task `json:"task"` + OK bool `json:"ok"` + Err string `json:"err,omitempty"` +} + +type DownloadProgress struct { + Percent int + DownloadedBytes int64 + TotalBytes int64 + SpeedBytesPerSecond float64 + Elapsed time.Duration + Remaining time.Duration + MediaElapsed time.Duration + MediaTotal time.Duration + Phase string +} diff --git a/internal/app/network_browser.go b/internal/app/network_browser.go new file mode 100644 index 0000000..749743c --- /dev/null +++ b/internal/app/network_browser.go @@ -0,0 +1,200 @@ +package app + +import ( + "crypto/tls" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +const huangguoBrowserProfile = "Chrome 150" + +type browserHTTPClient interface { + Do(*fhttp.Request) (*fhttp.Response, error) + CloseIdleConnections() +} + +type huangguoBrowserTransport struct { + base http.RoundTripper + router *proxyRouter + host string + insecure bool + record func(diagnosticEvent) + mu sync.Mutex + clients map[string]browserHTTPClient + newClient func(string) (browserHTTPClient, error) +} + +func newHuangguoBrowserTransport(base http.RoundTripper, downloader *Downloader) *huangguoBrowserTransport { + configured, _ := url.Parse(downloader.providerBaseURL(sourceHuangguoVideo)) + transport := &huangguoBrowserTransport{ + base: base, router: downloader.proxyRouter, host: configured.Host, + insecure: downloader.cfg.InsecureTLS, record: downloader.recordDiagnostic, + clients: make(map[string]browserHTTPClient), + } + transport.newClient = transport.createClient + return transport +} + +func (transport *huangguoBrowserTransport) matches(request *http.Request) bool { + return (request.Method == http.MethodGet || request.Method == http.MethodHead) && + (request.URL.Scheme == "http" || request.URL.Scheme == "https") && + (strings.EqualFold(request.URL.Host, transport.host) || strings.EqualFold(request.URL.Hostname(), "huangguo.video")) +} + +func (transport *huangguoBrowserTransport) createClient(proxy string) (browserHTTPClient, error) { + idleTimeout := 90 * time.Second + options := []tlsclient.HttpClientOption{ + tlsclient.WithClientProfile(profiles.Chrome_150), + tlsclient.WithRandomTLSExtensionOrder(), + tlsclient.WithDisableHttp3(), + tlsclient.WithNotFollowRedirects(), + tlsclient.WithTimeoutSeconds(45), + tlsclient.WithProxyUrl(proxy), + tlsclient.WithCookieJar(tlsclient.NewCookieJar()), + tlsclient.WithTransportOptions(&tlsclient.TransportOptions{ + IdleConnTimeout: &idleTimeout, MaxIdleConns: 8, MaxIdleConnsPerHost: 4, + MaxResponseHeaderBytes: 1 << 20, + }), + } + if transport.insecure { + options = append(options, tlsclient.WithInsecureSkipVerify()) + } + return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) +} + +func (transport *huangguoBrowserTransport) client(request *http.Request) (browserHTTPClient, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + proxy, err := transport.router.proxy(request) + if err != nil { + return nil, err + } + proxyURL := "" + if proxy != nil { + proxyURL = proxy.String() + } + key := request.URL.Scheme + "://" + strings.ToLower(request.URL.Host) + "|" + proxyURL + if client := transport.clients[key]; client != nil { + return client, nil + } + client, err := transport.newClient(proxyURL) + if err != nil { + return nil, err + } + if len(transport.clients) >= 8 { + for key, old := range transport.clients { + old.CloseIdleConnections() + delete(transport.clients, key) + break + } + } + transport.clients[key] = client + return client, nil +} + +func huangguoBrowserHeaders(request *http.Request) fhttp.Header { + headers := fhttp.Header{ + "sec-ch-ua": {`"Chromium";v="150", "Google Chrome";v="150", "Not_A Brand";v="24"`}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"macOS"`}, + "upgrade-insecure-requests": {"1"}, + "user-agent": {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"}, + "accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"}, + "sec-fetch-site": {"same-origin"}, + "sec-fetch-mode": {"navigate"}, + "sec-fetch-user": {"?1"}, + "sec-fetch-dest": {"document"}, + "accept-language": {"zh-CN,zh;q=0.9"}, + fhttp.HeaderOrderKey: {"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "upgrade-insecure-requests", "user-agent", "accept", "sec-fetch-site", "sec-fetch-mode", "sec-fetch-user", "sec-fetch-dest", "referer", "accept-encoding", "accept-language", "cookie"}, + } + for key, values := range request.Header { + lower := strings.ToLower(key) + if _, fixed := headers[lower]; fixed || lower == "host" || lower == "connection" { + continue + } + headers[lower] = append([]string(nil), values...) + } + if referer := request.Header.Get("Referer"); referer == "" { + headers["sec-fetch-site"] = []string{"none"} + } else if parsed, err := url.Parse(referer); err == nil && !strings.EqualFold(parsed.Host, request.URL.Host) { + headers["sec-fetch-site"] = []string{"cross-site"} + } + return headers +} + +func standardBrowserHeaders(headers fhttp.Header) http.Header { + result := make(http.Header, len(headers)) + for key, values := range headers { + result[http.CanonicalHeaderKey(key)] = append([]string(nil), values...) + } + return result +} + +func (transport *huangguoBrowserTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if !transport.matches(request) { + return transport.base.RoundTrip(request) + } + client, err := transport.client(request) + if err != nil { + return nil, fmt.Errorf("黄果浏览器客户端初始化失败:%w", publicError(err)) + } + upstream, err := fhttp.NewRequestWithContext(request.Context(), request.Method, request.URL.String(), request.Body) + if err != nil { + return nil, err + } + upstream.Header = huangguoBrowserHeaders(request) + upstream.Host = request.Host + upstream.ContentLength = request.ContentLength + upstream.GetBody = request.GetBody + response, err := client.Do(upstream) + if err != nil { + if response != nil && response.Body != nil { + response.Body.Close() + } + return nil, publicError(err) + } + result := &http.Response{ + Status: response.Status, StatusCode: response.StatusCode, + Proto: response.Proto, ProtoMajor: response.ProtoMajor, ProtoMinor: response.ProtoMinor, + Header: standardBrowserHeaders(response.Header), Body: response.Body, + ContentLength: response.ContentLength, TransferEncoding: response.TransferEncoding, + Close: response.Close, Uncompressed: response.Uncompressed, + Trailer: standardBrowserHeaders(response.Trailer), Request: request, + } + if state := response.TLS; state != nil { + result.TLS = &tls.ConnectionState{Version: state.Version, HandshakeComplete: state.HandshakeComplete, + DidResume: state.DidResume, CipherSuite: state.CipherSuite, NegotiatedProtocol: state.NegotiatedProtocol, + ServerName: state.ServerName, PeerCertificates: state.PeerCertificates, VerifiedChains: state.VerifiedChains} + } + if transport.record != nil { + level := "info" + if result.StatusCode >= 400 || strings.EqualFold(result.Header.Get("Cf-Mitigated"), "challenge") { + level = "warning" + } + transport.record(diagnosticEvent{Event: "network.browser_request", Level: level, Source: sourceHuangguoVideo, + Host: request.URL.Hostname(), HTTPStatus: result.StatusCode, Client: huangguoBrowserProfile, + Protocol: result.Proto, CFRay: truncate(result.Header.Get("Cf-Ray"), 128), + ResponseType: truncate(result.Header.Get("Content-Type"), 128), Message: "黄果浏览器指纹请求完成"}) + } + return result, nil +} + +func (transport *huangguoBrowserTransport) CloseIdleConnections() { + transport.mu.Lock() + for key, client := range transport.clients { + client.CloseIdleConnections() + delete(transport.clients, key) + } + transport.mu.Unlock() + if closer, ok := transport.base.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } +} diff --git a/internal/app/network_browser_test.go b/internal/app/network_browser_test.go new file mode 100644 index 0000000..b8c55c5 --- /dev/null +++ b/internal/app/network_browser_test.go @@ -0,0 +1,249 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + fhttp "github.com/bogdanfinn/fhttp" +) + +type browserFixtureClient struct { + do func(*fhttp.Request) (*fhttp.Response, error) + closed atomic.Int32 +} + +func (client *browserFixtureClient) Do(request *fhttp.Request) (*fhttp.Response, error) { + return client.do(request) +} +func (client *browserFixtureClient) CloseIdleConnections() { client.closed.Add(1) } + +func TestHuangguoBrowserHTTP2CookiesAndNativeIsolation(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := requests.Add(1) + if r.ProtoMajor != 2 || !strings.Contains(r.UserAgent(), "Chrome/150.0.0.0") || r.Header.Get("Sec-Ch-Ua-Platform") != `"macOS"` { + t.Errorf("browser fingerprint headers or HTTP/2 missing: %s %s", r.Proto, r.UserAgent()) + } + _, err := r.Cookie("fixture_session") + if (count == 2) != (err == nil) { + t.Errorf("cookie session reuse/reset failed at request %d", count) + } + http.SetCookie(w, &http.Cookie{Name: "fixture_session", Value: "local-test-only", Path: "/", Secure: true}) + io.WriteString(w, "fixture browser page") + })) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "direct", 1 + cfg.HuangguoVideoURL, cfg.InsecureTLS = server.URL, true + downloader := NewDownloader(cfg) + defer downloader.client.CloseIdleConnections() + downloader.limiter = newRequestLimiter(2, time.Nanosecond) + transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport) + var nativeCalls atomic.Int32 + transport.base = rankingTransport(func(r *http.Request) (*http.Response, error) { + nativeCalls.Add(1) + return rankingHTTPResponse(r, 200, "native fixture"), nil + }) + for index := 0; index < 3; index++ { + if index == 2 { + downloader.client.CloseIdleConnections() + } + body, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "https://huangguo.video/") + if err != nil || body != "fixture browser page" { + t.Fatal(body, err) + } + } + if nativeCalls.Load() != 0 { + t.Fatal("browser request first used the native transport") + } + request, _ := http.NewRequest(http.MethodGet, "https://hongguoduanju.com/", nil) + response, err := downloader.client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if nativeCalls.Load() != 1 { + t.Fatal("unrelated source did not keep native transport") + } +} + +func TestHuangguoBrowserRespectsCertificatesAndCancellation(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "fixture") })) + defer server.Close() + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "direct", 1 + cfg.HuangguoVideoURL = server.URL + downloader := NewDownloader(cfg) + defer downloader.client.CloseIdleConnections() + if _, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", ""); err == nil { + t.Fatal("untrusted certificate was accepted by default") + } + blocking := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer blocking.Close() + cfg.HuangguoVideoURL = blocking.URL + downloader = NewDownloader(cfg) + defer downloader.client.CloseIdleConnections() + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + if _, err := downloader.fetchProviderText(ctx, "https://huangguo.video/videos", ""); err == nil { + t.Fatal("canceled browser body read succeeded") + } + downloader.limiter.mu.Lock() + active := downloader.limiter.active + downloader.limiter.mu.Unlock() + if active != 0 { + t.Fatal("browser timeout leaked a limiter permit") + } +} + +func TestHuangguoBrowserProxyResetAndBackoff(t *testing.T) { + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "http://proxy-one.invalid:8080", 3 + downloader := NewDownloader(cfg) + defer downloader.client.CloseIdleConnections() + downloader.limiter = newRequestLimiter(2, time.Nanosecond) + transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport) + var routes []string + var clients []*browserFixtureClient + calls := 0 + transport.newClient = func(proxy string) (browserHTTPClient, error) { + routes = append(routes, proxy) + client := &browserFixtureClient{do: func(r *fhttp.Request) (*fhttp.Response, error) { + calls++ + status, body := 403, "Cloudflare: Sorry, you have been blocked" + if len(routes) > 1 { + status, body = 200, "fixture success" + } + return &fhttp.Response{StatusCode: status, Header: fhttp.Header{"Cf-Ray": {"fixture-ray"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + }} + clients = append(clients, client) + return client, nil + } + for index := 0; index < 2; index++ { + _, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "") + if err == nil || !strings.Contains(err.Error(), "Cloudflare 拒绝了当前请求") { + t.Fatal("missing accurate block cause", err) + } + } + if calls != 1 || len(routes) != 1 { + t.Fatal("backoff did not prevent repeated requests", calls, len(routes)) + } + downloader.proxyRouter.configure("socks5://proxy-two.invalid:1080") + downloader.client.CloseIdleConnections() + if clients[0].closed.Load() == 0 { + t.Fatal("old proxy session not closed") + } + _, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "") + if err == nil || calls != 1 { + t.Fatal("proxy switch bypassed the existing backoff") + } + downloader.limiter.mu.Lock() + delete(downloader.limiter.backoffs, "huangguo.video") + downloader.limiter.mu.Unlock() + body, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "") + if err != nil || body != "fixture success" || len(routes) != 2 || routes[1] != "socks5://proxy-two.invalid:1080" { + t.Fatal("updated proxy was not used", routes, err) + } +} + +func TestHuangguoBrowserRedirectUsesNativePolicy(t *testing.T) { + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.ProxyURL = t.TempDir(), t.TempDir(), "direct" + downloader := NewDownloader(cfg) + defer downloader.client.CloseIdleConnections() + transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport) + transport.newClient = func(string) (browserHTTPClient, error) { + return &browserFixtureClient{do: func(r *fhttp.Request) (*fhttp.Response, error) { + return &fhttp.Response{StatusCode: 302, Header: fhttp.Header{"Location": {"https://redirect.invalid/fixture"}, "Set-Cookie": {"fixture_session=private; Path=/"}}, Body: io.NopCloser(strings.NewReader(""))}, nil + }}, nil + } + transport.base = rankingTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.Host != "redirect.invalid" || r.Header.Get("Cookie") != "" || r.Header.Get("Sec-Ch-Ua") != "" { + t.Error("redirect leaked fingerprint session headers") + } + return rankingHTTPResponse(r, 200, "redirected fixture"), nil + }) + body, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "") + if err != nil || body != "redirected fixture" { + t.Fatal(body, err) + } +} + +func TestHuangguoBrowserLivePages(t *testing.T) { + if os.Getenv("JUKU_HUANGGUO_BROWSER_PROBE") != "1" { + t.Skip("text-only live probe is opt-in") + } + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), firstNonEmpty(os.Getenv("JUKU_HUANGGUO_BROWSER_PROXY"), "direct"), 1 + cfg.RequestIntervalMS = 2000 + downloader := NewDownloader(cfg) + defer downloader.client.CloseIdleConnections() + var events []diagnosticEvent + transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport) + transport.record = func(event diagnosticEvent) { events = append(events, event) } + ctx, cancel := context.WithTimeout(context.Background(), 70*time.Second) + defer cancel() + urls := []string{"https://huangguo.video/videos", "https://huangguo.video/videos?category=1"} + counts := []int{} + detailURL := "" + for _, address := range urls { + body, err := downloader.fetchProviderText(ctx, address, "https://huangguo.video/") + if err != nil { + t.Fatal(err) + } + dramas := parseHuangguoVideoCards(body, address) + if len(dramas) == 0 { + t.Fatal("200 page contained no business records") + } + counts = append(counts, len(dramas)) + if detailURL == "" { + for _, drama := range dramas { + _, id, ok := splitProviderDramaID(drama.ID) + if ok && strings.HasPrefix(id, "series/") { + detailURL = "https://huangguo.video/" + id + break + } + } + } + } + if detailURL == "" { + t.Fatal("missing linked detail") + } + body, err := downloader.fetchProviderText(ctx, detailURL, "https://huangguo.video/") + if err != nil { + t.Fatal(err) + } + episodes := parseHuangguoVideoEpisodes(body, detailURL) + if len(episodes) == 0 { + t.Fatal("detail contained no episode links") + } + counts = append(counts, len(episodes)) + evidence := map[string]any{"time": time.Now().UTC(), "client": "tls-client v1.16.0 / Chrome 150", "counts": counts, "requests": events, "route": map[bool]string{true: "direct", false: "configured-proxy"}[cfg.ProxyURL == "direct"]} + if output := os.Getenv("JUKU_HUANGGUO_BROWSER_EVIDENCE"); output != "" { + data, _ := json.MarshalIndent(evidence, "", " ") + if err := os.WriteFile(filepath.Clean(output), data, 0600); err != nil { + t.Fatal(err) + } + } + for _, event := range events { + if event.HTTPStatus != 200 || event.Protocol != "HTTP/2.0" { + t.Fatal("unexpected live response", event.HTTPStatus, event.Protocol) + } + } + t.Logf("text-only list/category/detail counts: %v; responses=%d", counts, len(events)) +} diff --git a/internal/app/network_cdn.go b/internal/app/network_cdn.go new file mode 100644 index 0000000..2fda711 --- /dev/null +++ b/internal/app/network_cdn.go @@ -0,0 +1,192 @@ +package app + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "time" +) + +const cdnAlternateSubnet = "8.8.8.0/24" + +type cdnRoute struct { + address string + expires time.Time +} + +type cdnTransport struct { + base *http.Transport + resolver *safeDNSDialer + mu sync.Mutex + transports map[string]*http.Transport + preferred map[string]cdnRoute +} + +func newCDNTransport(base *http.Transport, resolver *safeDNSDialer) *cdnTransport { + return &cdnTransport{base: base, resolver: resolver, transports: map[string]*http.Transport{}, preferred: map[string]cdnRoute{}} +} + +func (transport *cdnTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.Scheme != "https" || !protectedCDNHost(request.URL.Hostname()) || (request.Method != http.MethodGet && request.Method != http.MethodHead) { + return transport.base.RoundTrip(request) + } + var proxyURL *url.URL + var err error + if transport.base.Proxy != nil { + proxyURL, err = transport.base.Proxy(request) + if err != nil { + return nil, err + } + } + routeKey := request.URL.Host + if proxyURL != nil { + routeKey += "|" + proxyURL.String() + } + transport.mu.Lock() + preferred := transport.preferred[routeKey] + transport.mu.Unlock() + tried := map[string]bool{} + var response *http.Response + if preferred.address != "" && time.Now().Before(preferred.expires) { + tried[preferred.address] = true + response, err = transport.roundTripAddress(request, proxyURL, routeKey, preferred.address) + if err == nil { + return response, nil + } + } else { + response, err = transport.base.RoundTrip(request) + if err == nil { + return response, nil + } + } + if request.Context().Err() != nil { + return nil, request.Context().Err() + } + lastErr := err + subnets := []string{cdnAlternateSubnet, ""} + for _, subnet := range subnets { + entry, lookupErr := transport.resolver.lookupEntry(request.Context(), request.URL.Hostname(), subnet) + if lookupErr != nil { + if request.Context().Err() != nil { + return nil, request.Context().Err() + } + continue + } + for _, address := range entry.addresses { + if tried[address] || len(tried) >= 4 { + continue + } + tried[address] = true + response, err = transport.roundTripAddress(request, proxyURL, routeKey, address) + if err == nil { + transport.mu.Lock() + transport.preferred[routeKey] = cdnRoute{address: address, expires: entry.expires} + transport.mu.Unlock() + return response, nil + } + if request.Context().Err() != nil { + return nil, request.Context().Err() + } + lastErr = err + } + } + return nil, fmt.Errorf("黄果 CDN %s 连接失败(已尝试安全 DNS 回退,未改变代理模式): %w", request.URL.Hostname(), publicError(lastErr)) +} + +func (transport *cdnTransport) roundTripAddress(request *http.Request, proxyURL *url.URL, routeKey, address string) (*http.Response, error) { + pinned := transport.pinnedTransport(request.URL.Hostname(), proxyURL, routeKey, address) + upstream := request.Clone(request.Context()) + upstream.Host = request.Host + if upstream.Host == "" { + upstream.Host = request.URL.Host + } + upstream.URL.Host = address + if port := request.URL.Port(); port != "" { + upstream.URL.Host = net.JoinHostPort(address, port) + } + response, err := pinned.RoundTrip(upstream) + if response != nil { + response.Request = request + } + return response, err +} + +func (transport *cdnTransport) pinnedTransport(host string, proxyURL *url.URL, routeKey, address string) *http.Transport { + transport.mu.Lock() + defer transport.mu.Unlock() + key := routeKey + "|" + address + if existing := transport.transports[key]; existing != nil { + return existing + } + pinned := transport.base.Clone() + if pinned.TLSClientConfig == nil { + pinned.TLSClientConfig = &tls.Config{} + } + pinned.TLSClientConfig.ServerName = host + pinned.Proxy = http.ProxyURL(proxyURL) + if proxyURL != nil && proxyURL.Scheme == "https" { + plainProxy := *proxyURL + plainProxy.Scheme = "http" + if plainProxy.Port() == "" { + plainProxy.Host = net.JoinHostPort(proxyURL.Hostname(), "443") + } + pinned.Proxy = http.ProxyURL(&plainProxy) + pinned.DialContext = transport.tlsProxyDialer(proxyURL.Hostname()) + } + if len(transport.transports) >= 32 { + for expiredKey, expiredTransport := range transport.transports { + expiredTransport.CloseIdleConnections() + delete(transport.transports, expiredKey) + break + } + } + transport.transports[key] = pinned + return pinned +} + +func (transport *cdnTransport) tlsProxyDialer(host string) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + dial := transport.base.DialContext + if dial == nil { + dialer := &net.Dialer{Timeout: 10 * time.Second} + dial = dialer.DialContext + } + connection, err := dial(ctx, network, address) + if err != nil { + return nil, err + } + config := &tls.Config{} + if transport.base.TLSClientConfig != nil { + config = transport.base.TLSClientConfig.Clone() + } + config.ServerName = host + config.NextProtos = []string{"http/1.1"} + secured := tls.Client(connection, config) + if transport.base.TLSHandshakeTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, transport.base.TLSHandshakeTimeout) + defer cancel() + } + if err := secured.HandshakeContext(ctx); err != nil { + connection.Close() + return nil, err + } + return secured, nil + } +} + +func (transport *cdnTransport) CloseIdleConnections() { + transport.base.CloseIdleConnections() + transport.resolver.client.CloseIdleConnections() + transport.mu.Lock() + defer transport.mu.Unlock() + for key, pinned := range transport.transports { + pinned.CloseIdleConnections() + delete(transport.transports, key) + } + transport.preferred = map[string]cdnRoute{} +} diff --git a/internal/app/network_diagnostics.go b/internal/app/network_diagnostics.go new file mode 100644 index 0000000..6a3776b --- /dev/null +++ b/internal/app/network_diagnostics.go @@ -0,0 +1,199 @@ +package app + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type networkCheckResult struct { + Name string `json:"name"` + Status int `json:"status,omitempty"` + Detail string `json:"detail,omitempty"` + Error string `json:"error,omitempty"` +} + +func (a *UIApp) networkCheckResults(ctx context.Context) []networkCheckResult { + targets := []struct{ name, endpoint string }{ + {"黄豆入口", a.downloader.providerBaseURL(sourceHuangdou) + "/home"}, + {"红果入口", a.downloader.providerBaseURL(sourceHongguo) + "/"}, + {"黄果 AI 入口", a.downloader.providerBaseURL(sourceHuangguoAI) + "/"}, + {"黄果 video 入口", a.downloader.providerBaseURL(sourceHuangguoVideo) + "/videos"}, + } + var mediaTask *Task + a.mu.Lock() + concurrency := a.cfg.RequestConcurrency + for _, drama := range a.dramas { + if dramaProvider(drama) == sourceHuangdou { + if endpoint, valid := buildImageURL(bestDramaCover(drama)); valid { + targets = append(targets, struct{ name, endpoint string }{"黄豆封面 CDN", endpoint}) + break + } + } + } + for index := len(a.taskOrder) - 1; index >= 0; index-- { + task := a.tasks[a.taskOrder[index]] + if task == nil || isChapterPlaceholderTask(task) { + continue + } + source := sourceFromDramaID(task.DramaID) + if source == sourceHuangguoAI || source == sourceHuangguoVideo { + copy := task.Source + mediaTask = © + if task.Status == uiStatusFailed { + break + } + } + } + a.mu.Unlock() + if concurrency < 1 { + concurrency = 1 + } + if concurrency > 2 { + concurrency = 2 + } + results := make([]networkCheckResult, len(targets)+1) + jobs := make(chan int) + var workers sync.WaitGroup + for worker := 0; worker < concurrency; worker++ { + workers.Add(1) + go func() { + defer workers.Done() + for index := range jobs { + if index == len(targets) { + results[index] = a.checkHuangguoMedia(ctx, mediaTask) + continue + } + target := targets[index] + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + if providerSourceForURL(target.endpoint) == sourceHuangguoVideo || target.name == "黄果 video 入口" { + body, err := a.downloader.fetchProviderText(probeCtx, target.endpoint, a.downloader.providerBaseURL(sourceHuangguoVideo)+"/") + result := networkCheckResult{Name: target.name, Error: a.redactError(err)} + if err == nil { + result.Status = http.StatusOK + count := len(parseHuangguoVideoCards(body, target.endpoint)) + result.Detail = fmt.Sprintf("%s 浏览器指纹,解析到 %d 部剧", huangguoBrowserProfile, count) + if count == 0 { + result.Error = "响应没有可识别的剧集内容" + } + } + results[index] = result + cancel() + continue + } + status, err := a.checkNetworkResource(probeCtx, http.MethodGet, target.endpoint, imageReferer(target.endpoint), false) + cancel() + results[index] = networkCheckResult{Name: target.name, Status: status, Error: a.redactError(err)} + } + }() + } + for index := range results { + jobs <- index + } + close(jobs) + workers.Wait() + return results +} + +func (a *UIApp) checkNetworkResource(ctx context.Context, method, endpoint, referer string, key bool) (int, error) { + upstream, err := http.NewRequestWithContext(ctx, method, endpoint, nil) + if err != nil { + return 0, err + } + upstream.Header.Set("User-Agent", userAgent) + upstream.Header.Set("Referer", referer) + response, err := a.downloader.client.Do(upstream) + if err != nil { + return 0, publicError(err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return response.StatusCode, fmt.Errorf("%s HTTP %d", upstream.URL.Hostname(), response.StatusCode) + } + if key { + body, err := io.ReadAll(io.LimitReader(response.Body, 17)) + if err != nil { + return response.StatusCode, err + } + if len(body) != 16 { + return response.StatusCode, fmt.Errorf("密钥响应不是有效的 16 字节 AES-128 密钥") + } + } else { + if _, err := io.Copy(io.Discard, io.LimitReader(response.Body, 32768)); err != nil { + return response.StatusCode, err + } + } + return response.StatusCode, nil +} + +func (a *UIApp) checkHuangguoMedia(ctx context.Context, task *Task) networkCheckResult { + result := networkCheckResult{Name: "黄果实际媒体"} + if task == nil { + result.Detail = "暂无黄果分集任务,入口可达不代表媒体可下载" + return result + } + ctx, cancel := context.WithTimeout(ctx, 40*time.Second) + defer cancel() + media, err := a.downloader.resolveProviderMedia(ctx, *task) + if err != nil { + result.Error = "播放地址解析失败: " + a.redactError(err) + return result + } + for depth := 0; depth < 3; depth++ { + variant := selectBestM3U8Variant(media.Playlist, media.URL) + if variant == "" { + break + } + media.URL = variant + media.Playlist, err = a.downloader.fetchProviderText(ctx, variant, media.Referer) + if err != nil { + result.Error = "播放列表失败: " + a.redactError(err) + return result + } + } + base, _ := url.Parse(media.URL) + segment := media.URL + keyChecked := false + for _, line := range strings.Split(media.Playlist, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#EXT-X-KEY:") && !strings.Contains(line, "METHOD=NONE") { + match := hlsURIAttribute.FindStringSubmatch(line) + if len(match) == 2 && !keyChecked { + reference, parseErr := url.Parse(match[1]) + if parseErr == nil { + result.Status, err = a.checkNetworkResource(ctx, http.MethodGet, base.ResolveReference(reference).String(), media.Referer, true) + } else { + err = parseErr + } + if err != nil { + result.Error = "视频密钥失败: " + a.redactError(err) + return result + } + keyChecked = true + } + } else if line != "" && !strings.HasPrefix(line, "#") { + reference, parseErr := url.Parse(line) + if parseErr != nil { + result.Error = a.redactError(parseErr) + return result + } + segment = base.ResolveReference(reference).String() + break + } + } + result.Status, err = a.checkNetworkResource(ctx, http.MethodHead, segment, media.Referer, false) + if err != nil { + result.Error = "媒体分片检测失败: " + a.redactError(err) + return result + } + result.Detail = "播放地址、首个媒体资源可达(不等于整集下载验证)" + if keyChecked { + result.Detail = "播放列表、16 字节密钥、首个分片均可达(不等于整集下载验证)" + } + return result +} diff --git a/internal/app/network_dns.go b/internal/app/network_dns.go new file mode 100644 index 0000000..fe38bd7 --- /dev/null +++ b/internal/app/network_dns.go @@ -0,0 +1,164 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type dnsCacheEntry struct { + addresses []string + expires time.Time +} + +type safeDNSDialer struct { + client *http.Client + dialer net.Dialer + mu sync.Mutex + cache map[string]dnsCacheEntry + inFlight map[string]chan struct{} + endpoints []string +} + +func newSafeDNSDialer(transport *http.Transport) *safeDNSDialer { + lookupTransport := transport.Clone() + lookupTransport.TLSClientConfig = nil + return &safeDNSDialer{ + client: &http.Client{Transport: lookupTransport, Timeout: 4 * time.Second}, + dialer: net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}, + cache: map[string]dnsCacheEntry{}, inFlight: map[string]chan struct{}{}, + endpoints: []string{"https://dns.alidns.com/resolve", "https://dns.google/resolve"}, + } +} + +func protectedCDNHost(host string) bool { + host = strings.ToLower(host) + return isHuangguoImageCDNHost(host) || strings.HasSuffix(host, ".lkkwip.cn") +} + +func (resolver *safeDNSDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || !protectedCDNHost(host) { + return resolver.dialer.DialContext(ctx, network, address) + } + addresses, err := resolver.lookup(ctx, host) + if err != nil { + return nil, fmt.Errorf("%s 安全 DNS 解析失败,可在网络设置中启用可用代理: %w", host, err) + } + var lastErr error + for _, resolved := range addresses { + connection, err := resolver.dialer.DialContext(ctx, network, net.JoinHostPort(resolved, port)) + if err == nil { + return connection, nil + } + lastErr = err + } + return nil, fmt.Errorf("%s 的 CDN 无法直连,请检查代理配置: %w", host, lastErr) +} + +func (resolver *safeDNSDialer) lookup(ctx context.Context, host string) ([]string, error) { + entry, err := resolver.lookupEntry(ctx, host, "") + return entry.addresses, err +} + +func (resolver *safeDNSDialer) lookupEntry(ctx context.Context, host, subnet string) (dnsCacheEntry, error) { + cacheKey := host + "|" + subnet + for { + resolver.mu.Lock() + if entry, found := resolver.cache[cacheKey]; found && time.Now().Before(entry.expires) { + resolver.mu.Unlock() + return entry, nil + } + if pending := resolver.inFlight[cacheKey]; pending != nil { + resolver.mu.Unlock() + select { + case <-pending: + continue + case <-ctx.Done(): + return dnsCacheEntry{}, ctx.Err() + } + } + resolver.inFlight[cacheKey] = make(chan struct{}) + resolver.mu.Unlock() + break + } + entry, err := resolver.query(ctx, host, subnet) + resolver.mu.Lock() + if err == nil { + resolver.cache[cacheKey] = entry + } + close(resolver.inFlight[cacheKey]) + delete(resolver.inFlight, cacheKey) + resolver.mu.Unlock() + return entry, err +} + +func (resolver *safeDNSDialer) query(ctx context.Context, host, subnet string) (dnsCacheEntry, error) { + var lastErr error + for _, endpoint := range resolver.endpoints { + lookupURL, err := url.Parse(endpoint) + if err != nil { + return dnsCacheEntry{}, err + } + query := lookupURL.Query() + query.Set("name", host) + query.Set("type", "A") + if subnet != "" { + query.Set("edns_client_subnet", subnet) + } + lookupURL.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL.String(), nil) + if err != nil { + return dnsCacheEntry{}, err + } + request.Header.Set("Accept", "application/dns-json") + response, err := resolver.client.Do(request) + if err != nil { + lastErr = publicError(err) + continue + } + var payload struct { + Status int `json:"Status"` + Answer []struct { + Type int `json:"type"` + TTL int `json:"TTL"` + Data string `json:"data"` + } `json:"Answer"` + } + err = json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&payload) + response.Body.Close() + if response.StatusCode != http.StatusOK || err != nil || payload.Status != 0 { + lastErr = errors.New("DoH 服务没有返回有效 DNS 记录") + continue + } + entry := dnsCacheEntry{} + ttl := 300 + for _, answer := range payload.Answer { + address := net.ParseIP(answer.Data) + if answer.Type != 1 || address == nil || address.To4() == nil || !address.IsGlobalUnicast() || address.IsPrivate() { + continue + } + entry.addresses = append(entry.addresses, address.String()) + if answer.TTL < ttl { + ttl = answer.TTL + } + } + if len(entry.addresses) > 0 { + if ttl < 1 { + ttl = 1 + } + entry.expires = time.Now().Add(time.Duration(ttl) * time.Second) + return entry, nil + } + lastErr = errors.New("DoH 未返回公网 IPv4 地址") + } + return dnsCacheEntry{}, lastErr +} diff --git a/internal/app/network_fetch.go b/internal/app/network_fetch.go new file mode 100644 index 0000000..54c9eff --- /dev/null +++ b/internal/app/network_fetch.go @@ -0,0 +1,60 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +func mustHost(raw string) string { + u, err := url.Parse(raw) + if err == nil && u.Host != "" { + return u.Host + } + return strings.TrimPrefix(strings.TrimPrefix(raw, "https://"), "http://") +} + +func (d *Downloader) fetchRaw(ctx context.Context, rawURL string) (string, error) { + var lastErr error + for attempt := 1; attempt <= d.cfg.Retries; attempt++ { + if attempt > 1 { + select { + case <-time.After(time.Duration(attempt) * time.Second): + case <-ctx.Done(): + return "", ctx.Err() + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Referer", "https://d2pypzndaqisk.cloudfront.net/") + req.Header.Set("Origin", "https://d2pypzndaqisk.cloudfront.net") + req.Header.Set("User-Agent", userAgent) + resp, err := d.doCatalogRequest(req) + if err != nil { + lastErr = publicError(err) + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1)) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if len(body) > providerMaxBodyBytes { + return "", errors.New("播放列表响应过大") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) + continue + } + return string(body), nil + } + return "", lastErr +} diff --git a/internal/app/network_images.go b/internal/app/network_images.go new file mode 100644 index 0000000..74b10ba --- /dev/null +++ b/internal/app/network_images.go @@ -0,0 +1,228 @@ +package app + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "sync" + "time" +) + +type coverRequestKey struct{} +type coverSourceKey struct{} + +var nonPublicImageNetworks = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("64:ff9b::/96"), netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), netip.MustParsePrefix("2001::/32"), + netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), +} + +func publicImageAddress(value string) bool { + address, err := netip.ParseAddr(value) + if err != nil || address.Zone() != "" { + return false + } + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() { + return false + } + for _, network := range nonPublicImageNetworks { + if network.Contains(address) { + return false + } + } + return true +} + +func validImageURL(remote *url.URL) bool { + if remote == nil || remote.Scheme != "https" || remote.Opaque != "" || remote.User != nil || + (remote.Port() != "" && remote.Port() != "443") { + return false + } + host := strings.ToLower(strings.TrimSuffix(remote.Hostname(), ".")) + if _, err := netip.ParseAddr(host); err == nil { + return publicImageAddress(host) + } + if len(host) > 253 || !strings.Contains(host, ".") || strings.ContainsAny(host, ":%\\") { + return false + } + for _, suffix := range []string{"localhost", "local", "internal", "lan", "home", "invalid", "test", "example"} { + if host == suffix || strings.HasSuffix(host, "."+suffix) { + return false + } + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, ch := range label { + if ch != '-' && (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') { + return false + } + } + } + return true +} + +type imageTransport struct { + base http.RoundTripper + standard *http.Transport + lookup func(context.Context, string) ([]string, error) + fallback func(context.Context, string) ([]string, error) + mu sync.Mutex + transports map[string]*http.Transport +} + +func newImageTransport(base http.RoundTripper, standard *http.Transport) *imageTransport { + return &imageTransport{base: base, standard: standard, lookup: net.DefaultResolver.LookupHost, transports: map[string]*http.Transport{}} +} + +func (transport *imageTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.Context().Value(coverRequestKey{}) != true { + return transport.base.RoundTrip(request) + } + if !validImageURL(request.URL) || request.Method != http.MethodGet { + return nil, errors.New("封面地址不受支持") + } + host := strings.TrimSuffix(request.URL.Hostname(), ".") + addresses, err := transport.lookup(request.Context(), host) + if err != nil && transport.fallback != nil && request.Context().Err() == nil { + addresses, err = transport.fallback(request.Context(), host) + } + if err != nil { + return nil, fmt.Errorf("封面域名解析失败: %w", err) + } + var proxyURL *url.URL + if transport.standard.Proxy != nil { + proxyURL, err = transport.standard.Proxy(request) + if err != nil { + return nil, err + } + } + tried := map[string]bool{} + lastErr := errors.New("封面没有可用的公网地址") + for attempt := 0; attempt < 2; attempt++ { + for _, address := range addresses { + if !publicImageAddress(address) { + return nil, errors.New("封面地址指向本机、内网或保留网络") + } + } + for _, address := range addresses { + if tried[address] || len(tried) >= 4 { + continue + } + tried[address] = true + pinned := transport.pinned(host, address, proxyURL) + upstream := request.Clone(request.Context()) + upstream.Host = request.URL.Host + upstream.URL.Host = net.JoinHostPort(address, "443") + response, roundTripErr := pinned.RoundTrip(upstream) + if response != nil { + response.Request = request + } + if roundTripErr == nil { + return response, nil + } + lastErr = roundTripErr + if request.Context().Err() != nil { + return nil, request.Context().Err() + } + } + if attempt != 0 || transport.fallback == nil || len(tried) >= 4 { + break + } + addresses, err = transport.fallback(request.Context(), host) + if err != nil { + break + } + } + return nil, fmt.Errorf("封面 CDN 连接失败: %w", lastErr) +} + +func (transport *imageTransport) pinned(host, address string, proxyURL *url.URL) *http.Transport { + key := host + "|" + address + if proxyURL != nil { + key += "|" + proxyURL.String() + } + transport.mu.Lock() + defer transport.mu.Unlock() + if existing := transport.transports[key]; existing != nil { + return existing + } + pinned := transport.standard.Clone() + if pinned.TLSClientConfig == nil { + pinned.TLSClientConfig = &tls.Config{} + } + pinned.TLSClientConfig.ServerName = host + pinned.Proxy = http.ProxyURL(proxyURL) + if proxyURL != nil && proxyURL.Scheme == "https" { + plain := *proxyURL + plain.Scheme = "http" + if plain.Port() == "" { + plain.Host = net.JoinHostPort(plain.Hostname(), "443") + } + pinned.Proxy = http.ProxyURL(&plain) + pinned.DialContext = transport.tlsProxyDialer(proxyURL.Hostname()) + } + if len(transport.transports) >= 32 { + for key, stale := range transport.transports { + stale.CloseIdleConnections() + delete(transport.transports, key) + break + } + } + transport.transports[key] = pinned + return pinned +} + +func (transport *imageTransport) tlsProxyDialer(host string) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + dial := transport.standard.DialContext + if dial == nil { + dialer := &net.Dialer{Timeout: 10 * time.Second} + dial = dialer.DialContext + } + connection, err := dial(ctx, network, address) + if err != nil { + return nil, err + } + config := &tls.Config{} + if transport.standard.TLSClientConfig != nil { + config = transport.standard.TLSClientConfig.Clone() + } + config.ServerName, config.NextProtos = host, []string{"http/1.1"} + secured := tls.Client(connection, config) + if transport.standard.TLSHandshakeTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, transport.standard.TLSHandshakeTimeout) + defer cancel() + } + if err := secured.HandshakeContext(ctx); err != nil { + connection.Close() + return nil, err + } + return secured, nil + } +} + +func (transport *imageTransport) CloseIdleConnections() { + if closer, ok := transport.base.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } + transport.mu.Lock() + defer transport.mu.Unlock() + for key, pinned := range transport.transports { + pinned.CloseIdleConnections() + delete(transport.transports, key) + } +} diff --git a/internal/app/network_images_test.go b/internal/app/network_images_test.go new file mode 100644 index 0000000..230c53f --- /dev/null +++ b/internal/app/network_images_test.go @@ -0,0 +1,170 @@ +package app + +import ( + "context" + "crypto/tls" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" +) + +func TestImageURLAcceptsPublicCDNsWithoutDomainList(t *testing.T) { + for _, address := range []string{"https://new-covers.example.org/a?token=%2F%2b%3D", "https://another.example.com/cover", "https://93.184.216.34/image", "https://[2606:4700:4700::1111]/image"} { + parsed, err := url.Parse(address) + if err != nil || !validImageURL(parsed) { + t.Fatal("public cover URL required a hard-coded domain", address, err) + } + } + for _, address := range []string{ + "http://example.org/a", "file:///etc/passwd", "https://user@example.org/a", "https://example.org:8443/a", + "https://localhost/a", "https://app.localhost/a", "https://router.local/a", "https://private.lan/a", + "https://127.0.0.1/a", "https://10.1.2.3/a", "https://172.16.0.1/a", "https://192.168.1.1/a", + "https://169.254.169.254/a", "https://100.100.100.200/a", "https://0.0.0.0/a", "https://255.255.255.255/a", + "https://[::1]/a", "https://[::ffff:127.0.0.1]/a", "https://[fc00::1]/a", "https://[fe80::1%25en0]/a", + "https://[64:ff9b::a00:1]/a", "https://example.invalid/a", "https://empty../a", + } { + parsed, err := url.Parse(address) + if err == nil && validImageURL(parsed) { + t.Fatal("unsafe cover URL was accepted", address) + } + } +} + +func TestImageTransportRejectsPrivateDNSBeforeAnyConnection(t *testing.T) { + for _, addresses := range [][]string{{"127.0.0.1"}, {"10.0.0.1"}, {"100.100.100.200"}, {"::ffff:192.168.1.1"}, {"93.184.216.34", "169.254.169.254"}} { + t.Run(strings.Join(addresses, "/"), func(t *testing.T) { + var calls atomic.Int32 + standard := http.DefaultTransport.(*http.Transport).Clone() + standard.DialContext = func(context.Context, string, string) (net.Conn, error) { + calls.Add(1) + return nil, errors.New("must not connect") + } + transport := newImageTransport(standard, standard) + transport.lookup = func(context.Context, string) ([]string, error) { return addresses, nil } + transport.fallback = func(context.Context, string) ([]string, error) { + calls.Add(1) + return []string{"93.184.216.34"}, nil + } + request := httptest.NewRequest(http.MethodGet, "https://rebound.example.org/cover", nil) + request = request.WithContext(context.WithValue(request.Context(), coverRequestKey{}, true)) + if _, err := transport.RoundTrip(request); err == nil || calls.Load() != 0 { + t.Fatal("private DNS was fetched or retried as a public destination", calls.Load(), err) + } + }) + } +} + +func TestImageTransportPinsDNSPreservesSNIAndProxy(t *testing.T) { + for _, mode := range []string{"direct", "http-proxy", "https-proxy"} { + t.Run(mode, func(t *testing.T) { + var targetCalls atomic.Int32 + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetCalls.Add(1) + if r.Host != "new-covers.example.org" || r.TLS.ServerName != "new-covers.example.org" || r.URL.RawQuery != "signature=%2F%2b%3D" || r.Header.Get("Referer") != "https://source.example.org/" { + t.Error("pinned image request changed the hostname, TLS name, query or source headers") + } + io.WriteString(w, "synthetic transport fixture") + })) + defer target.Close() + var mu sync.Mutex + var dials, connects []string + proxyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + connects = append(connects, r.Host) + mu.Unlock() + if r.Method != http.MethodConnect || r.Host != "93.184.216.34:443" { + http.Error(w, "unexpected CONNECT destination", http.StatusBadRequest) + return + } + upstream, err := net.Dial("tcp", target.Listener.Addr().String()) + if err != nil { + t.Error(err) + return + } + connection, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + upstream.Close() + t.Error(err) + return + } + buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") + buffer.Flush() + go func() { io.Copy(upstream, buffer); upstream.Close() }() + io.Copy(connection, upstream) + connection.Close() + }) + standard := http.DefaultTransport.(*http.Transport).Clone() + standard.Proxy = nil + standard.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + var proxyAddress string + if mode != "direct" { + var proxy *httptest.Server + if mode == "https-proxy" { + proxy = httptest.NewTLSServer(proxyHandler) + } else { + proxy = httptest.NewServer(proxyHandler) + } + defer proxy.Close() + proxyURL, _ := url.Parse(proxy.URL) + proxyAddress = proxyURL.Host + standard.Proxy = http.ProxyURL(proxyURL) + } + standard.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + mu.Lock() + dials = append(dials, address) + mu.Unlock() + if mode == "direct" && address == "93.184.216.34:443" { + address = target.Listener.Addr().String() + } else if mode == "direct" || address != proxyAddress { + return nil, errors.New("request bypassed its pinned address or configured proxy") + } + return (&net.Dialer{}).DialContext(ctx, network, address) + } + transport := newImageTransport(standard, standard) + defer transport.CloseIdleConnections() + transport.lookup = func(ctx context.Context, host string) ([]string, error) { + if host != "new-covers.example.org" { + t.Error("unexpected hostname", host) + } + return []string{"93.184.216.34"}, nil + } + request, _ := http.NewRequestWithContext(context.WithValue(context.Background(), coverRequestKey{}, true), http.MethodGet, "https://new-covers.example.org/cover?signature=%2F%2b%3D", nil) + request.Header.Set("Referer", "https://source.example.org/") + response, err := (&http.Client{Transport: transport}).Do(request) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || string(body) != "synthetic transport fixture" || response.Request.URL.String() != request.URL.String() { + t.Fatal("pinned response lost the original address", err) + } + mu.Lock() + defer mu.Unlock() + if targetCalls.Load() != 1 || len(dials) != 1 || mode == "direct" && len(connects) != 0 || mode != "direct" && !reflect.DeepEqual(connects, []string{"93.184.216.34:443"}) { + t.Fatal("image request did not follow the expected pinned route", dials, connects, targetCalls.Load()) + } + }) + } +} + +func TestImageSourceRequiresRegisteredCoverForEveryViewer(t *testing.T) { + address := "https://new-covers.example.org/cover?token=fixture" + app := &UIApp{dramas: []Drama{{ID: "hongguo:7000000000000000001", Source: sourceHongguo, CoverURL: address}}} + for _, ctx := range []context.Context{context.Background(), withSourceScope(context.Background(), accountRecord{Admin: true}), withSourceScope(context.Background(), accountRecord{Sources: []string{sourceHongguo}})} { + if source, allowed := app.imageSource(ctx, address); !allowed || source != sourceHongguo { + t.Fatal("registered cover was rejected") + } + if _, allowed := app.imageSource(ctx, address+"-forged"); allowed { + t.Fatal("unregistered cover was accepted") + } + } +} diff --git a/internal/app/network_limiter.go b/internal/app/network_limiter.go new file mode 100644 index 0000000..cfbc1d2 --- /dev/null +++ b/internal/app/network_limiter.go @@ -0,0 +1,273 @@ +package app + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +type requestGate struct { + busy bool + lastStart time.Time +} + +type requestBackoff struct { + host string + until time.Time + status int + reason string +} + +func (backoff *requestBackoff) Error() string { + reason := "" + if backoff.reason != "" { + reason = ":" + backoff.reason + } + return fmt.Sprintf("%s HTTP %d%s,已暂停该域名请求,%s 后可重试", backoff.host, backoff.status, reason, backoff.until.Format("15:04:05")) +} + +func catalogResponseBlockReason(response *http.Response, body []byte) string { + if len(body) > 64*1024 { + body = body[:64*1024] + } + page := strings.ToLower(string(body)) + reason := "" + if strings.Contains(page, "cloudflare") && (strings.Contains(page, "sorry, you have been blocked") || strings.Contains(page, "you are unable to access")) { + reason = "Cloudflare 拒绝了当前请求" + } else if strings.EqualFold(response.Header.Get("Cf-Mitigated"), "challenge") || strings.Contains(page, "_cf_chl_opt") || strings.Contains(page, "just a moment") && strings.Contains(page, "cloudflare") { + reason = "站点要求浏览器验证,当前请求无法通过" + } + return reason +} + +func (d *Downloader) catalogResponseError(request *http.Request, response *http.Response, body []byte) error { + reason := catalogResponseBlockReason(response, body) + if d.limiter != nil && reason != "" && response.StatusCode >= 200 && response.StatusCode < 300 { + d.limiter.observeBlock(request, response) + } + + host := strings.ToLower(request.URL.Hostname()) + if d.limiter != nil && (response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusTooManyRequests || reason != "") { + d.limiter.mu.Lock() + backoffs := d.limiter.backoffs + if background, _ := request.Context().Value(backgroundCatalogKey{}).(bool); background && response.StatusCode != http.StatusTooManyRequests { + backoffs = d.limiter.backgroundBackoffs + } + if previous := backoffs[host]; previous != nil && previous.status == response.StatusCode { + updated := *previous + if reason != "" { + updated.reason = reason + } + backoffs[host] = &updated + d.limiter.mu.Unlock() + return &updated + } + d.limiter.mu.Unlock() + } + if reason != "" { + return fmt.Errorf("%s HTTP %d:%s", host, response.StatusCode, reason) + } + return fmt.Errorf("%s HTTP %d", host, response.StatusCode) +} + +type requestLimiter struct { + concurrency int + active int + foregroundWaiting int + lastForeground time.Time + interval time.Duration + mu sync.Mutex + gates map[string]*requestGate + backoffs map[string]*requestBackoff + backgroundBackoffs map[string]*requestBackoff + changed chan struct{} +} + +type backgroundCatalogKey struct{} + +func newRequestLimiter(concurrency int, interval time.Duration) *requestLimiter { + if concurrency < 1 { + concurrency = 1 + } + return &requestLimiter{concurrency: concurrency, interval: interval, gates: map[string]*requestGate{}, backoffs: map[string]*requestBackoff{}, backgroundBackoffs: map[string]*requestBackoff{}, changed: make(chan struct{})} +} + +func (limiter *requestLimiter) configure(concurrency int, interval time.Duration) { + limiter.mu.Lock() + defer limiter.mu.Unlock() + limiter.concurrency = concurrency + limiter.interval = interval + limiter.notifyLocked() +} + +func (limiter *requestLimiter) notifyLocked() { + close(limiter.changed) + limiter.changed = make(chan struct{}) +} + +func (limiter *requestLimiter) acquire(ctx context.Context, request *http.Request) (func(), error) { + background, _ := ctx.Value(backgroundCatalogKey{}).(bool) + host := strings.ToLower(request.URL.Hostname()) + source := providerSourceForURL(request.URL.String()) + if source == "" { + source = host + if strings.HasSuffix(host, ".cloudfront.net") { + source = "cloudfront" + } + } + limiter.mu.Lock() + if !background { + limiter.foregroundWaiting++ + defer func() { + limiter.mu.Lock() + limiter.foregroundWaiting-- + limiter.notifyLocked() + limiter.mu.Unlock() + }() + } + gate := limiter.gates[source] + if gate == nil { + gate = &requestGate{} + limiter.gates[source] = gate + } + limiter.mu.Unlock() + for { + if err := ctx.Err(); err != nil { + return nil, err + } + limiter.mu.Lock() + if backoff := limiter.backoffs[host]; backoff != nil && time.Now().Before(backoff.until) { + limiter.mu.Unlock() + return nil, backoff + } + if backoff := limiter.backgroundBackoffs[host]; background && backoff != nil && time.Now().Before(backoff.until) { + limiter.mu.Unlock() + return nil, backoff + } + delay := time.Until(gate.lastStart.Add(limiter.interval)) + if background { + + if idleDelay := time.Until(limiter.lastForeground.Add(limiter.interval)); idleDelay > delay { + delay = idleDelay + } + } + lowPriorityReady := !background || limiter.foregroundWaiting == 0 && limiter.active == 0 + if lowPriorityReady && !gate.busy && limiter.active < limiter.concurrency && delay <= 0 { + gate.busy = true + gate.lastStart = time.Now() + limiter.active++ + limiter.mu.Unlock() + break + } + changed := limiter.changed + limiter.mu.Unlock() + var timer *time.Timer + var deadline <-chan time.Time + if delay > 0 { + timer = time.NewTimer(delay) + deadline = timer.C + } + select { + case <-changed: + case <-deadline: + case <-ctx.Done(): + } + if timer != nil { + timer.Stop() + } + } + var once sync.Once + return func() { + once.Do(func() { + limiter.mu.Lock() + limiter.active-- + gate.busy = false + if !background { + limiter.lastForeground = time.Now() + } + limiter.notifyLocked() + limiter.mu.Unlock() + }) + }, nil +} + +func (limiter *requestLimiter) observe(request *http.Request, response *http.Response) { + if response.StatusCode != http.StatusForbidden && response.StatusCode != http.StatusTooManyRequests && !strings.EqualFold(response.Header.Get("Cf-Mitigated"), "challenge") { + return + } + limiter.observeBlock(request, response) +} + +func (limiter *requestLimiter) observeBlock(request *http.Request, response *http.Response) { + delay := time.Minute + if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 { + delay = time.Duration(seconds) * time.Second + } else if until, err := http.ParseTime(response.Header.Get("Retry-After")); err == nil && time.Until(until) > 0 { + delay = time.Until(until) + } + if delay > 30*time.Minute { + delay = 30 * time.Minute + } + host := strings.ToLower(request.URL.Hostname()) + limiter.mu.Lock() + backoffs := limiter.backoffs + if background, _ := request.Context().Value(backgroundCatalogKey{}).(bool); background && response.StatusCode != http.StatusTooManyRequests { + + backoffs = limiter.backgroundBackoffs + } + backoffs[host] = &requestBackoff{host: host, until: time.Now().Add(delay), status: response.StatusCode} + limiter.notifyLocked() + limiter.mu.Unlock() +} + +type limitedResponseBody struct { + io.ReadCloser + release func() +} + +func (body *limitedResponseBody) Close() error { + err := body.ReadCloser.Close() + body.release() + return err +} + +func (d *Downloader) doCatalogRequest(request *http.Request) (*http.Response, error) { + return d.doCatalogRequestWithTimeout(request, 0) +} + +func (d *Downloader) doCatalogRequestWithTimeout(request *http.Request, timeout time.Duration) (*http.Response, error) { + return d.doPreparedCatalogRequest(request, timeout, nil) +} + +func (d *Downloader) doPreparedCatalogRequest(request *http.Request, timeout time.Duration, prepare func(*http.Request)) (*http.Response, error) { + if background, _ := request.Context().Value(backgroundCatalogKey{}).(bool); background && (timeout <= 0 || timeout > 8*time.Second) { + timeout = 8 * time.Second + } + release, err := d.limiter.acquire(request.Context(), request) + if err != nil { + return nil, err + } + cancel := func() {} + if timeout > 0 { + var ctx context.Context + ctx, cancel = context.WithTimeout(request.Context(), timeout) + request = request.WithContext(ctx) + } + if prepare != nil { + prepare(request) + } + response, err := d.client.Do(request) + if err != nil { + cancel() + release() + return nil, err + } + d.limiter.observe(request, response) + response.Body = &limitedResponseBody{ReadCloser: response.Body, release: func() { cancel(); release() }} + return response, nil +} diff --git a/internal/app/network_limiter_test.go b/internal/app/network_limiter_test.go new file mode 100644 index 0000000..7d19c84 --- /dev/null +++ b/internal/app/network_limiter_test.go @@ -0,0 +1,78 @@ +package app + +import ( + "context" + "errors" + "net/http" + "strings" + "sync/atomic" + "testing" +) + +func TestCatalogBlockReasonSurvivesCooldown(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return rankingHTTPResponse(request, 403, `<title>Attention Required! | CloudflareSorry, you have been blocked. sensitive-ray-id`), nil + }) + for i := 0; i < 2; i++ { + _, err := d.fetchProviderText(context.Background(), "https://blocked.example/text", "") + var backoff *requestBackoff + if !errors.As(err, &backoff) || !strings.Contains(err.Error(), "Cloudflare 拒绝了当前请求") || strings.Contains(err.Error(), "sensitive-ray-id") { + t.Fatal("block reason was lost or raw page was exposed", err) + } + } + if calls.Load() != 1 { + t.Fatal("cooldown repeated an upstream request") + } +} + +func TestCatalogBackgroundBackoffStaysSeparate(t *testing.T) { + for _, status := range []int{403, 429} { + t.Run(http.StatusText(status), func(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if optional, _ := request.Context().Value(backgroundCatalogKey{}).(bool); optional { + return rankingHTTPResponse(request, status, "optional unavailable"), nil + } + return rankingHTTPResponse(request, 200, "foreground ready"), nil + }) + optional := context.WithValue(context.Background(), backgroundCatalogKey{}, true) + if _, err := d.fetchProviderText(optional, "https://catalog.example/detail", ""); err == nil { + t.Fatal("missing optional failure") + } + body, err := d.fetchProviderText(context.Background(), "https://catalog.example/list", "") + if status == 403 && (err != nil || body != "foreground ready" || calls.Load() != 2) { + t.Fatal("optional 403 blocked normal catalog", err) + } + if status == 429 && (err == nil || calls.Load() != 1) { + t.Fatal("site-wide rate limit was ignored") + } + }) + } +} + +func TestCatalogChallenge200StopsWithoutPublishingSuccess(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + response := rankingHTTPResponse(request, 200, "Just a moment...Cloudflare fixture") + response.Header.Set("Cf-Mitigated", "challenge") + return response, nil + }) + for index := 0; index < 2; index++ { + body, err := d.fetchProviderText(context.Background(), "https://challenge.example/text", "") + var backoff *requestBackoff + if body != "" || !errors.As(err, &backoff) || !strings.Contains(err.Error(), "浏览器验证") { + t.Fatal("challenge response treated as successful content", err) + } + } + if calls.Load() != 1 { + t.Fatal("challenge repeated requests during cooldown") + } + response := &http.Response{StatusCode: 200, Header: make(http.Header)} + if catalogResponseBlockReason(response, []byte("Local fixtureJust a moment, Cloudflare text in a description")) != "" { + t.Fatal("ordinary description was mistaken for a challenge") + } +} diff --git a/internal/app/network_proxy.go b/internal/app/network_proxy.go new file mode 100644 index 0000000..fdbf8e1 --- /dev/null +++ b/internal/app/network_proxy.go @@ -0,0 +1,224 @@ +package app + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" +) + +type systemProxy struct { + http string + https string + bypass []string +} + +type proxyRouter struct { + mu sync.RWMutex + resolve func(*http.Request) (*url.URL, error) + description string +} + +func (router *proxyRouter) configure(raw string) { + resolve, description := networkProxy(raw) + router.mu.Lock() + router.resolve = resolve + router.description = description + router.mu.Unlock() +} + +func (router *proxyRouter) proxy(request *http.Request) (*url.URL, error) { + router.mu.RLock() + resolve := router.resolve + router.mu.RUnlock() + if resolve == nil { + return nil, nil + } + return resolve(request) +} + +func (router *proxyRouter) summary() string { + router.mu.RLock() + defer router.mu.RUnlock() + return router.description +} + +func publicProxyConfig(raw string) (string, string, bool) { + if raw == "" || raw == "auto" { + return "auto", "", false + } + if raw == "direct" { + return "direct", "", false + } + endpoint, err := url.Parse(raw) + if err != nil { + return "manual", "", false + } + hasAuth := endpoint.User != nil + endpoint.User = nil + return "manual", endpoint.String(), hasAuth +} + +func configuredProxy(raw string) (*url.URL, error) { + if raw == "" || raw == "auto" || raw == "direct" { + return nil, nil + } + endpoint, err := url.Parse(raw) + if err != nil || endpoint.Hostname() == "" || endpoint.Scheme != "http" && endpoint.Scheme != "https" && endpoint.Scheme != "socks5" && endpoint.Scheme != "socks5h" || endpoint.RawQuery != "" || endpoint.Fragment != "" || endpoint.Path != "" && endpoint.Path != "/" { + return nil, errors.New("proxyURL 必须是 auto、direct 或 HTTP/HTTPS/SOCKS5 代理地址") + } + return endpoint, nil +} + +func networkProxy(raw string) (func(*http.Request) (*url.URL, error), string) { + if raw == "direct" { + return nil, "直连(手动设置)" + } + if endpoint, err := configuredProxy(raw); err != nil { + return func(*http.Request) (*url.URL, error) { return nil, err }, "代理配置无效" + } else if endpoint != nil { + return func(request *http.Request) (*url.URL, error) { + if proxyBypassed(request.URL.Hostname(), nil) { + return nil, nil + } + return endpoint, nil + }, "手动代理 " + endpoint.Scheme + "://" + endpoint.Host + } + system := discoverSystemProxy() + description := "直连(未发现代理)" + if system.http != "" || system.https != "" { + description = "系统代理 " + firstNonEmpty(system.https, system.http) + } + if firstNonEmpty(os.Getenv("HTTPS_PROXY"), os.Getenv("https_proxy"), os.Getenv("HTTP_PROXY"), os.Getenv("http_proxy")) != "" { + description = "环境变量代理" + } + return func(request *http.Request) (*url.URL, error) { + if proxyBypassed(request.URL.Hostname(), system.bypass) { + return nil, nil + } + environment := "HTTP_PROXY" + remote := system.http + if request.URL.Scheme == "https" { + environment = "HTTPS_PROXY" + remote = system.https + } + if firstNonEmpty(os.Getenv(environment), os.Getenv(strings.ToLower(environment))) != "" { + return http.ProxyFromEnvironment(request) + } + if proxyBypassed(request.URL.Hostname(), strings.Split(firstNonEmpty(os.Getenv("NO_PROXY"), os.Getenv("no_proxy")), ",")) || remote == "" { + return nil, nil + } + return url.Parse(remote) + }, description +} + +func proxyBypassed(host string, exceptions []string) bool { + host = strings.ToLower(host) + address := net.ParseIP(host) + if host == "localhost" || address != nil && address.IsLoopback() { + return true + } + for _, entry := range exceptions { + entry = strings.ToLower(strings.TrimSpace(entry)) + if entry == "*" || entry == "" && !strings.Contains(host, ".") { + return true + } + if _, network, err := net.ParseCIDR(entry); err == nil && address != nil && network.Contains(address) { + return true + } + if strings.HasPrefix(entry, "*.") || strings.HasPrefix(entry, ".") { + suffix := strings.TrimPrefix(strings.TrimPrefix(entry, "*"), ".") + if host == suffix || strings.HasSuffix(host, "."+suffix) { + return true + } + } else if entry != "" && host == entry { + return true + } + } + return false +} + +func discoverSystemProxy() systemProxy { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + switch runtime.GOOS { + case "darwin": + body, err := exec.CommandContext(ctx, "scutil", "--proxy").Output() + if err == nil { + return parseMacSystemProxy(string(body)) + } + case "windows": + body, err := exec.CommandContext(ctx, "reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`).Output() + if err == nil { + return parseWindowsSystemProxy(string(body)) + } + } + return systemProxy{} +} + +func parseMacSystemProxy(raw string) systemProxy { + values := map[string]string{} + result := systemProxy{} + for _, line := range strings.Split(raw, "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), " : ") + if !ok { + continue + } + values[key] = value + if key != "" && key[0] >= '0' && key[0] <= '9' { + result.bypass = append(result.bypass, value) + } + } + for _, scheme := range []string{"HTTP", "HTTPS"} { + if values[scheme+"Enable"] != "1" || values[scheme+"Proxy"] == "" || values[scheme+"Port"] == "" { + continue + } + endpoint := "http://" + net.JoinHostPort(values[scheme+"Proxy"], values[scheme+"Port"]) + if scheme == "HTTP" { + result.http = endpoint + } else { + result.https = endpoint + } + } + return result +} + +func parseWindowsSystemProxy(raw string) systemProxy { + values := map[string]string{} + for _, line := range strings.Split(raw, "\n") { + parts := strings.Fields(line) + if len(parts) >= 3 { + values[parts[0]] = strings.Join(parts[2:], " ") + } + } + if values["ProxyEnable"] != "0x1" { + return systemProxy{} + } + result := systemProxy{bypass: strings.Split(values["ProxyOverride"], ";")} + for _, entry := range strings.Split(values["ProxyServer"], ";") { + scheme, endpoint, assigned := strings.Cut(entry, "=") + if !assigned { + endpoint = entry + } + if endpoint == "" { + continue + } + if !strings.Contains(endpoint, "://") { + endpoint = "http://" + endpoint + } + if !assigned || scheme == "http" { + result.http = endpoint + } + if !assigned || scheme == "https" { + result.https = endpoint + } + } + return result +} diff --git a/internal/app/paths.go b/internal/app/paths.go new file mode 100644 index 0000000..3a1e240 --- /dev/null +++ b/internal/app/paths.go @@ -0,0 +1,109 @@ +package app + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +func portableFFmpeg(configured string) string { + if candidates := ffmpegCandidates(configured, ffmpegManagedDirectory()); len(candidates) > 0 { + return candidates[0] + } + if automaticFFmpeg(configured) { + return ffmpegExecutableName() + } + return normalizedFFmpegSetting(configured) +} + +func normalizedOutputDirectory(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New("下载目录不能为空") + } + if strings.ContainsAny(raw, "\x00\r\n") { + return "", errors.New("下载目录包含无效字符") + } + if strings.Contains(raw, "://") { + return "", errors.New("下载目录必须是本机文件夹路径,不是网址") + } + if runtime.GOOS != "windows" && (strings.HasPrefix(raw, `\\`) || len(raw) > 1 && raw[1] == ':') { + return "", errors.New("当前系统不支持 Windows 盘符或 UNC 路径") + } + return filepath.Clean(raw), nil +} + +func checkOutputDirectory(directory string) error { + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("无法创建下载目录: %w", err) + } + file, err := os.CreateTemp(directory, ".juku-write-check-*") + if err != nil { + return fmt.Errorf("下载目录不可写: %w", err) + } + name := file.Name() + closeErr := file.Close() + removeErr := os.Remove(name) + if closeErr != nil { + return closeErr + } + return removeErr +} + +func sameDirectory(first, second string) bool { + firstAbsolute, firstErr := filepath.Abs(first) + secondAbsolute, secondErr := filepath.Abs(second) + if firstErr != nil || secondErr != nil { + return first == second + } + if runtime.GOOS == "windows" { + return strings.EqualFold(firstAbsolute, secondAbsolute) + } + return firstAbsolute == secondAbsolute +} + +func (cfg Config) runtimeSettingsDirectory() string { + return cfg.dataDirectory() +} + +func (cfg Config) dataDirectory() string { + return firstNonEmpty(cfg.dataDir, "data") +} + +func flagWasSet(name string) bool { + found := false + flag.Visit(func(option *flag.Flag) { + if option.Name == name { + found = true + } + }) + return found +} + +func isProjectDirectory(directory string) bool { + body, err := os.ReadFile(filepath.Join(directory, "go.mod")) + fields := strings.Fields(string(body)) + return err == nil && len(fields) >= 2 && fields[0] == "module" && fields[1] == "juku" +} + +func preparePortableWorkingDirectory() error { + if isProjectDirectory(".") { + return nil + } + executable, err := os.Executable() + if err != nil { + return err + } + directory := filepath.Dir(executable) + if filepath.Base(directory) == "dist" && isProjectDirectory(filepath.Dir(directory)) { + directory = filepath.Dir(directory) + } + if strings.HasPrefix(filepath.Base(filepath.Dir(directory)), "go-build") || strings.HasPrefix(filepath.Base(directory), "go-build") { + return nil + } + return os.Chdir(directory) +} diff --git a/internal/app/playback_activity.go b/internal/app/playback_activity.go new file mode 100644 index 0000000..0094045 --- /dev/null +++ b/internal/app/playback_activity.go @@ -0,0 +1,30 @@ +package app + +import ( + "net/http" + "time" +) + +type playbackActivityWriter struct { + http.ResponseWriter + app *UIApp + run *playbackRunContext + nextTouch time.Time +} + +func (writer *playbackActivityWriter) Unwrap() http.ResponseWriter { + return writer.ResponseWriter +} + +func (writer *playbackActivityWriter) Write(data []byte) (int, error) { + count, err := writer.ResponseWriter.Write(data) + if count > 0 && err == nil && time.Now().After(writer.nextTouch) { + writer.app.playbackMu.Lock() + if session := writer.app.playbacks[writer.run.id]; session == writer.run.session && session.run == writer.run.run && writer.run.ctx.Err() == nil { + writer.app.touchPlaybackLocked(session) + } + writer.app.playbackMu.Unlock() + writer.nextTouch = time.Now().Add(20 * time.Second) + } + return count, err +} diff --git a/internal/app/playback_activity_test.go b/internal/app/playback_activity_test.go new file mode 100644 index 0000000..e88e14d --- /dev/null +++ b/internal/app/playback_activity_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestPlaybackActiveStreamRenewsSessionAndPreservesFlush(t *testing.T) { + app, session := historyFixtureApp(t) + session.expires = time.Now().Add(-time.Second) + recorder := httptest.NewRecorder() + writer := &playbackActivityWriter{ResponseWriter: recorder, app: app, run: &playbackRunContext{id: session.id, session: session, run: session.run, ctx: context.Background()}} + if _, err := writer.Write([]byte("synthetic media bytes")); err != nil { + t.Fatal(err) + } + if err := http.NewResponseController(writer).Flush(); err != nil || !recorder.Flushed { + t.Fatal("streaming flush was lost", err) + } + app.expirePlayback(session.id) + if _, exists := app.playbackStatus(session.id, false); !exists || time.Until(session.expires) < 9*time.Minute { + t.Fatal("an active stream expired without a browser timer") + } +} + +func TestPlaybackOldOrCanceledStreamCannotRenewSession(t *testing.T) { + for _, stopped := range []bool{false, true} { + app, session := historyFixtureApp(t) + before := time.Now().Add(time.Minute) + session.expires = before + ctx, cancel := context.WithCancel(context.Background()) + run := &playbackRunContext{id: session.id, session: session, run: session.run, ctx: ctx} + if stopped { + cancel() + } else { + session.run++ + } + writer := &playbackActivityWriter{ResponseWriter: httptest.NewRecorder(), app: app, run: run} + _, _ = writer.Write([]byte("late bytes")) + cancel() + if !session.expires.Equal(before) { + t.Fatal("superseded stream extended the current session") + } + } +} + +func TestPlaybackValidProgressKeepsSessionAliveAndInvalidProgressDoesNot(t *testing.T) { + app, session := historyFixtureApp(t) + before := time.Now().Add(time.Minute) + session.expires = before + invalid := historyProgress(1, 20) + invalid.Run = 99 + if _, _, err := app.recordPlaybackProgress(session.viewer, session.id, invalid); err != nil { + t.Fatal(err) + } + if !session.expires.Equal(before) { + t.Fatal("unknown playback progress extended the session") + } + if _, saved, err := app.recordPlaybackProgress(session.viewer, session.id, historyProgress(2, 24)); err != nil || !saved { + t.Fatal(saved, err) + } + if time.Until(session.expires) < 9*time.Minute { + t.Fatal("valid viewing progress did not renew the session") + } +} + +func TestPlaybackIdleExpiryStillReleasesStreamAndSlot(t *testing.T) { + app, session := historyFixtureApp(t) + ctx, cancel := context.WithCancel(context.Background()) + session.cancel = cancel + session.expires = time.Now().Add(-time.Second) + app.expirePlayback(session.id) + if ctx.Err() == nil { + t.Fatal("idle expiry left the media process alive") + } + if _, exists := app.playbackStatus(session.id, false); exists { + t.Fatal("idle expiry retained a playback slot") + } + writer := historyJSONRequest(t, app, app.handlePlaybackControl, "/api/ui/playback/control", map[string]string{"session": session.id, "action": "heartbeat"}) + if writer.Code != http.StatusGone { + t.Fatal("expired sessions must be recoverable by the client", writer.Code) + } +} diff --git a/internal/app/playback_browser_test.go b/internal/app/playback_browser_test.go new file mode 100644 index 0000000..c9c13f3 --- /dev/null +++ b/internal/app/playback_browser_test.go @@ -0,0 +1,171 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestPlaybackBrowserFixture(t *testing.T) { + root := os.Getenv("JUKU_PLAYBACK_BROWSER_ROOT") + if root == "" { + t.Skip("isolated browser fixture is opt-in") + } + mediaDir := os.Getenv("JUKU_PLAYBACK_BROWSER_MEDIA") + if mediaDir == "" { + t.Fatal("synthetic fixture directory required") + } + if _, err := os.Stat(filepath.Join(mediaDir, "original.mp4")); err != nil { + t.Fatal(err) + } + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + processLog := filepath.Join(root, "ffmpeg-calls.txt") + wrapper := filepath.Join(root, "ffmpeg-fixture") + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } + if err := os.WriteFile(wrapper, []byte("#!/bin/sh\nprintf '%s\\n' \"$1\" >> "+quote(processLog)+"\nexec "+quote(ffmpeg)+" \"$@\"\n"), 0700); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var blockClient atomic.Bool + var requests []map[string]string + files := http.FileServer(http.Dir(mediaDir)) + mediaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, map[string]string{"path": r.URL.Path, "method": r.Method, "range": r.Header.Get("Range")}) + mu.Unlock() + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" || r.Header.Get("X-Juku-Viewer") != "" { + t.Error("site credentials reached the synthetic media origin") + } + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges") + if blockClient.Load() && r.Header.Get("Sec-Fetch-Site") != "" { + w.WriteHeader(http.StatusForbidden) + return + } + if r.URL.Path == "/redirect.m3u8" { + http.Redirect(w, r, "/ts/index.m3u8", http.StatusFound) + return + } + if r.URL.Path == "/denied.mp4" { + w.WriteHeader(http.StatusForbidden) + return + } + files.ServeHTTP(w, r) + })) + defer mediaServer.Close() + mediaOrigin := strings.Replace(mediaServer.URL, "127.0.0.1", "localhost", 1) + app := viewerTestApp(t) + app.loadedAt = time.Now() + app.tasks = map[string]*UITask{} + app.cond = sync.NewCond(&app.mu) + app.statePath = filepath.Join(app.cfg.dataDirectory(), "ui-state.json") + app.downloader.cfg.FFmpeg, app.cfg.FFmpeg = wrapper, wrapper + app.downloader.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: wrapper}} + transport := mediaServer.Client().Transport + app.downloader.client = &http.Client{Transport: rankingTransport(func(request *http.Request) (*http.Response, error) { + if !strings.HasPrefix(request.URL.String(), mediaOrigin+"/") { + t.Error("playback fixture attempted an external request", request.URL.Host) + return nil, errors.New("external requests forbidden") + } + return transport.RoundTrip(request) + })} + app.cfg.adminUsername, app.cfg.adminPassword, app.cfg.adminPasswordExplicit = "admin", accountFixturePassword, true + if err := app.prepareBrowserViewers(); err != nil { + t.Fatal(err) + } + client := app.downloader.hongguoClient() + entry := client.details["7000000000000000001"] + entry.Chapters = nil + for index, path := range []string{"/original.mp4", "/redirect.m3u8", "/fmp4/index.m3u8", "/qualities/master.m3u8", "/ac3.mp4", "/denied.mp4", "/original.mp4"} { + entry.Chapters = append(entry.Chapters, Chapter{ID: providerChapterID(sourceHongguo, "7000000000000000001", fmt.Sprint(index+1)), Source: sourceHongguo, CurrentEpisode: rawEpisode(index + 1), VideoURL: mediaOrigin + path}) + } + entry.Drama.Title, entry.Drama.TotalEpisode, entry.Drama.ReleaseStatus = "本机合成播放样本", len(entry.Chapters), "finished" + client.details["7000000000000000001"] = entry + app.dramas = []Drama{entry.Drama} + task := Task{DramaID: entry.Drama.ID, DramaTitle: entry.Drama.Title, Index: 1, Total: 1, OutPath: filepath.Join(mediaDir, "original.mp4"), Chapter: entry.Chapters[0]} + app.taskOrder = []string{"local-fixture"} + app.tasks["local-fixture"] = &UITask{ID: "local-fixture", DramaID: task.DramaID, DramaTitle: task.DramaTitle, Status: uiStatusSuccess, Path: task.OutPath, Source: task} + t.Cleanup(app.embySyncer().stop) + routes := app.routes() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/fixture/status": + mu.Lock() + snapshot := append([]map[string]string(nil), requests...) + mu.Unlock() + body, _ := os.ReadFile(processLog) + count := 0 + if len(body) > 0 { + count = len(strings.Split(strings.TrimSpace(string(body)), "\n")) + } + resources := app.mediaResources() + resources.mu.Lock() + active := make(map[string]int) + for kind, value := range resources.active { + active[kind] = value + } + used := resources.cacheBytes + resources.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"ffmpegCalls": count, "requests": snapshot, "active": active, "cacheBytes": used}) + return + case "/fixture/direct-access": + var input struct { + Blocked bool `json:"blocked"` + } + if json.NewDecoder(r.Body).Decode(&input) != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + blockClient.Store(input.Blocked) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + return + case "/fixture/expire": + app.closePlaybacks() + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + return + case "/api/ui/cover/repair": + writeJSON(w, http.StatusOK, map[string]any{"dramaId": historyFixtureDramaID, "retryAfter": 300}) + return + case "/api/ui/image": + t.Error("playback fixture must never request source images") + http.NotFound(w, r) + return + } + routes.ServeHTTP(w, r) + })) + defer server.Close() + ready, _ := json.Marshal(map[string]string{"url": server.URL, "mediaOrigin": mediaOrigin, "adminPassword": accountFixturePassword}) + if err := os.WriteFile(filepath.Join(root, "ready.json"), ready, 0600); err != nil { + t.Fatal(err) + } + deadline := time.NewTimer(8 * time.Minute) + defer deadline.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if _, err := os.Stat(filepath.Join(root, "stop")); err == nil { + return + } + case <-deadline.C: + t.Fatal("isolated playback browser fixture timed out") + } + } +} diff --git a/internal/app/playback_direct.go b/internal/app/playback_direct.go new file mode 100644 index 0000000..8ce87fe --- /dev/null +++ b/internal/app/playback_direct.go @@ -0,0 +1,132 @@ +package app + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +func (app *UIApp) allowPlaybackDirect(ctx context.Context, media *playbackMediaSession, source, origin string) bool { + allowed := false + for _, selected := range app.mediaResources().snapshot().DirectSources { + allowed = allowed || selected == accountSourceGroup(source) + } + if !allowed || media.local != "" || len(media.key) != 0 || len(media.media.HLSKey) != 0 || len(media.media.CENCKey) != 0 { + return false + } + parsed, err := url.Parse(media.media.URL) + page, pageErr := url.Parse(origin) + if err != nil || pageErr != nil || page.Host == "" || parsed.User != nil || !isProviderHTTPMediaURL(parsed.String()) || page.Scheme == "https" && parsed.Scheme != "https" { + return false + } + if media.plan.Player == "mp4" && (!media.info.MP4 || !media.info.Range) { + return false + } + if media.plan.Player == "hls" && len(media.media.Variants) > 0 { + return false + } + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + client := &http.Client{Transport: app.downloader.client.Transport, CheckRedirect: func(request *http.Request, previous []*http.Request) error { + if len(previous) >= 5 || request.URL.User != nil || page.Scheme == "https" && request.URL.Scheme != "https" { + return http.ErrUseLastResponse + } + return nil + }} + seen := make(map[string]bool) + manifests := 0 + var check func(string, bool) bool + check = func(address string, playlist bool) bool { + if seen[address] { + return true + } + if len(seen) >= 256 || ctx.Err() != nil { + return false + } + seen[address] = true + remote, err := url.Parse(address) + if err != nil || remote.User != nil || !isProviderHTTPMediaURL(address) || page.Scheme == "https" && remote.Scheme != "https" { + return false + } + release, err := app.mediaResources().acquire(ctx, "media", false) + if err != nil { + return false + } + method := http.MethodHead + if playlist || media.plan.Player == "mp4" { + method = http.MethodGet + } + request, err := http.NewRequestWithContext(ctx, method, address, nil) + if err != nil { + release() + return false + } + request.Header.Set("Origin", origin) + request.Header.Set("Accept-Encoding", "identity") + if !playlist && method == http.MethodGet { + request.Header.Set("Range", "bytes=0-0") + } + response, err := client.Do(request) + if err != nil { + release() + return false + } + valid := response.StatusCode >= 200 && response.StatusCode < 300 + if media.plan.Player == "mp4" { + valid = response.StatusCode == http.StatusPartialContent && strings.HasPrefix(response.Header.Get("Content-Range"), "bytes 0-0/") + } + if media.plan.Player == "hls" { + cors := response.Header.Get("Access-Control-Allow-Origin") + valid = valid && (cors == "*" || cors == origin) + } + var body []byte + if valid && playlist { + manifests++ + body, err = io.ReadAll(io.LimitReader(response.Body, 256*1024+1)) + valid = err == nil && len(body) <= 256*1024 && manifests <= 8 && strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(string(body), "\ufeff")), "#EXTM3U") + } + response.Body.Close() + release() + if !valid || !playlist { + return valid + } + base := response.Request.URL + nextPlaylist := false + resolve := func(reference string, childPlaylist bool) bool { + child, err := url.Parse(reference) + return err == nil && check(base.ResolveReference(child).String(), childPlaylist) + } + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#EXT-X-STREAM-INF:") { + nextPlaylist = true + continue + } + if !strings.HasPrefix(line, "#") { + if !resolve(line, nextPlaylist) { + return false + } + nextPlaylist = false + continue + } + for _, tag := range []string{"#EXT-X-MEDIA:", "#EXT-X-I-FRAME-STREAM-INF:", "#EXT-X-KEY:", "#EXT-X-SESSION-KEY:", "#EXT-X-MAP:"} { + if !strings.HasPrefix(line, tag) { + continue + } + for _, match := range hlsURIAttribute.FindAllStringSubmatch(line, -1) { + if !resolve(match[1], tag == "#EXT-X-MEDIA:" || tag == "#EXT-X-I-FRAME-STREAM-INF:") { + return false + } + } + } + } + return true + } + return check(media.media.URL, media.plan.Player == "hls") +} diff --git a/internal/app/playback_gateway.go b/internal/app/playback_gateway.go new file mode 100644 index 0000000..9ca0a37 --- /dev/null +++ b/internal/app/playback_gateway.go @@ -0,0 +1,275 @@ +package app + +import ( + "context" + "errors" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +type playbackResponseURLs struct{ values sync.Map } +type playbackResponseURLsKey struct{} + +func rememberPlaybackResponseURL(ctx context.Context, original string, response *http.Response) { + if urls, _ := ctx.Value(playbackResponseURLsKey{}).(*playbackResponseURLs); urls != nil && response.Request != nil && response.Request.URL != nil { + urls.values.Store(original, response.Request.URL.String()) + } +} + +type playbackMediaPlan struct { + URL string `json:"url"` + Run uint64 `json:"run"` + Delivery string `json:"delivery"` + Processing string `json:"processing"` + Player string `json:"player"` + MIME string `json:"mime"` + Reason string `json:"reason"` + Source string `json:"source"` + Duration float64 `json:"duration"` + Quality int `json:"quality"` + Qualities []playbackQualityOption `json:"qualities"` + DirectURL string `json:"directURL,omitempty"` + SeekEnd float64 `json:"seekEnd"` +} + +type playbackMediaSession struct { + ctx context.Context + cancel context.CancelFunc + media providerMedia + key []byte + local string + proxy *hlsProxy + plan playbackMediaPlan + info playbackMediaInfo + generated *playbackGenerated + bytes atomic.Int64 + failed atomic.Bool + started time.Time + closeOnce sync.Once +} + +func (media *playbackMediaSession) Close() { + media.closeOnce.Do(func() { + media.cancel() + if media.generated != nil { + media.generated.Close() + } + if media.proxy != nil { + media.proxy.Close() + } + }) +} + +func (app *UIApp) resolveMediaSession(ctx, preparation context.Context, task Task, downloadID string, quality int, prefetched ...*playbackPrefetch) (*playbackMediaSession, error) { + ctx, cancel := context.WithCancel(context.WithValue(ctx, playbackQualityKey{}, quality)) + result := &playbackMediaSession{ctx: ctx, cancel: cancel, started: time.Now()} + success := false + defer func() { + if !success { + result.Close() + } + }() + if downloadID != "" { + var err error + task, result.local, err = app.playbackCollectionTask(downloadID) + if err != nil { + return nil, err + } + } + usedPrefetch := false + if result.local == "" && len(prefetched) > 0 && prefetched[0] != nil && prefetched[0].planOnly { + cache := prefetched[0] + select { + case <-cache.done: + case <-preparation.Done(): + return nil, preparation.Err() + } + cache.mu.Lock() + if cache.resolved != nil && cache.err == nil && cache.resolved.local == "" { + result.media, result.key, usedPrefetch = cache.resolved.media, cache.resolved.key, true + } + cache.mu.Unlock() + } + if result.local == "" && !usedPrefetch { + urls := &playbackResponseURLs{} + resolution := context.WithValue(context.WithValue(preparation, playbackQualityKey{}, quality), playbackResponseURLsKey{}, urls) + var err error + result.media, result.key, err = app.downloader.resolvePlaybackMedia(resolution, task) + if err != nil { + return nil, err + } + if final, ok := urls.values.Load(result.media.URL); ok { + result.media.URL = final.(string) + } + result.media, err = app.downloader.selectPlaybackQuality(resolution, result.media) + if err != nil { + return nil, err + } + if len(result.media.CENCKey) != 0 && (len(result.media.CENCKey) != 16 || result.media.Playlist != "") { + return nil, errors.New("播放密钥或媒体格式无效") + } + } + result.plan = playbackMediaPlan{Delivery: "proxy", Processing: "original", Player: "mp4", Source: "online", + MIME: "video/mp4", Reason: "compatible_original", Duration: result.media.Duration.Seconds(), + Quality: result.media.Quality, Qualities: playbackQualityOptions(result.media)} + if result.local != "" { + result.plan.Delivery, result.plan.Source = "local", "local" + } + if result.media.Playlist != "" { + result.plan.Player, result.plan.MIME = "hls", "application/vnd.apple.mpegurl" + } + result.plan.SeekEnd = result.plan.Duration + success = true + return result, nil +} + +func (app *UIApp) attachMediaGateway(media *playbackMediaSession, base, query string) { + proxy := &hlsProxy{client: &http.Client{Transport: app.downloader.client.Transport}, base: base, referer: media.media.Referer, + key: media.key, mediaKey: media.media.HLSKey, assets: map[string]hlsAsset{}, assetIDs: map[string]string{}, + retries: app.downloader.cfg.Retries, diagnostic: app.downloader.recordDiagnostic, query: query, + acquire: func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) }} + if remote, err := url.Parse(media.media.URL); err == nil { + proxy.host = remote.Hostname() + } + proxy.root = proxy.addAsset(playbackRootAsset(media.media)) + if media.plan.Player == "mp4" { + proxy.root = base + "file.mp4" + if query != "" { + proxy.root += "?" + query + } + proxy.assets = map[string]hlsAsset{base + "file.mp4": {remote: media.media.URL}} + proxy.assetIDs = map[string]string{media.media.URL: proxy.root} + } + media.proxy = proxy + media.plan.URL = proxy.root + if media.local != "" { + media.plan.URL = base + "file.mp4" + if query != "" { + media.plan.URL += "?" + query + } + } +} + +type playbackCountingWriter struct { + http.ResponseWriter + media *playbackMediaSession + activity func() +} + +func (writer *playbackCountingWriter) Unwrap() http.ResponseWriter { return writer.ResponseWriter } +func (writer *playbackCountingWriter) WriteHeader(status int) { + if status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusNotFound || status == http.StatusGone || status == http.StatusTooManyRequests || status >= 500 { + writer.media.failed.Store(true) + } + writer.ResponseWriter.WriteHeader(status) +} +func (writer *playbackCountingWriter) Write(body []byte) (int, error) { + n, err := writer.ResponseWriter.Write(body) + writer.media.bytes.Add(int64(n)) + if n > 0 && writer.activity != nil { + writer.activity() + } + return n, err +} + +func (app *UIApp) serveMediaSession(writer http.ResponseWriter, request *http.Request, media *playbackMediaSession) { + if request.Method != http.MethodGet && request.Method != http.MethodHead { + writer.Header().Set("Allow", "GET, HEAD") + writer.WriteHeader(http.StatusMethodNotAllowed) + return + } + if media.ctx.Err() != nil { + writer.WriteHeader(http.StatusGone) + return + } + ctx, cancel := context.WithCancel(request.Context()) + defer cancel() + idle := time.AfterFunc(60*time.Second, cancel) + defer idle.Stop() + stop := context.AfterFunc(media.ctx, cancel) + defer stop() + request = request.WithContext(ctx) + writer.Header().Set("Cache-Control", "private, no-store, no-transform") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Accel-Buffering", "no") + controller := http.NewResponseController(writer) + interrupted := make(chan struct{}) + interrupt := context.AfterFunc(ctx, func() { _ = controller.SetWriteDeadline(time.Now()); close(interrupted) }) + defer func() { + if !interrupt() { + <-interrupted + } + _ = controller.SetWriteDeadline(time.Time{}) + }() + writer = &playbackCountingWriter{ResponseWriter: writer, media: media, activity: func() { idle.Reset(60 * time.Second) }} + if media.plan.Delivery == "redirect" { + if request.URL.Path != strings.Split(media.plan.URL, "?")[0] { + http.NotFound(writer, request) + return + } + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("Location", media.media.URL) + writer.WriteHeader(http.StatusFound) + return + } + if media.generated != nil { + media.generated.ServeHTTP(writer, request) + return + } + if media.local != "" { + if request.URL.Path != strings.Split(media.plan.URL, "?")[0] { + http.NotFound(writer, request) + return + } + file, err := os.Open(media.local) + if err != nil { + http.NotFound(writer, request) + return + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + http.NotFound(writer, request) + return + } + writer.Header().Set("Content-Type", "video/mp4") + http.ServeContent(writer, request, "file.mp4", info.ModTime(), file) + return + } + if len(request.Header.Get("Range")) > 256 || strings.Contains(request.Header.Get("Range"), ",") { + writer.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + media.proxy.ServeHTTP(writer, request) +} + +func (app *UIApp) handlePlaybackMediaAsset(writer http.ResponseWriter, request *http.Request) { + if !playbackRequestAllowed(writer, request, request.Method) { + return + } + parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/api/ui/playback/media/"), "/") + if len(parts) != 3 { + http.NotFound(writer, request) + return + } + run, err := strconv.ParseUint(parts[1], 10, 64) + app.playbackMu.Lock() + session := app.playbacks[parts[0]] + if err != nil || !viewerOwnsPlayback(request.Context(), session) || session.run != run || session.media == nil { + app.playbackMu.Unlock() + writer.WriteHeader(http.StatusGone) + return + } + media := session.media + active := &playbackRunContext{id: session.id, run: session.run, session: session, ctx: media.ctx} + app.touchPlaybackLocked(session) + app.playbackMu.Unlock() + writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: active} + app.serveMediaSession(writer, request, media) +} diff --git a/internal/app/playback_gateway_test.go b/internal/app/playback_gateway_test.go new file mode 100644 index 0000000..414c940 --- /dev/null +++ b/internal/app/playback_gateway_test.go @@ -0,0 +1,207 @@ +package app + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestPlaybackDirectHLSChecksCORSKeysAndScheme(t *testing.T) { + app, _ := prefetchFixtureApp(t) + var cors atomic.Bool + cors.Store(true) + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" || r.Header.Get("Referer") != "" || r.Header.Get("X-Juku-Viewer") != "" { + t.Error("direct eligibility depended on server credentials") + } + if r.URL.Path != "/part.ts" || cors.Load() { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + if r.URL.Path == "/index.m3u8" { + io.WriteString(w, "#EXTM3U\n#EXT-X-TARGETDURATION:3\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXT-X-KEY:METHOD=AES-128,URI=\"public.key\"\n#EXTINF:3,\npart.ts\n#EXT-X-ENDLIST\n") + } + })) + defer upstream.Close() + app.downloader.client = upstream.Client() + app.mediaResources().settings.DirectSources = []string{sourceHongguo} + media := &playbackMediaSession{media: providerMedia{URL: upstream.URL + "/index.m3u8"}, plan: playbackMediaPlan{Player: "hls"}} + if !app.allowPlaybackDirect(context.Background(), media, sourceHongguo, "http://library.test") || requests.Load() != 4 { + t.Fatal("public HLS resources were not all checked", requests.Load()) + } + cors.Store(false) + if app.allowPlaybackDirect(context.Background(), media, sourceHongguo, "http://library.test") { + t.Fatal("segment without CORS was allowed") + } + cors.Store(true) + before := requests.Load() + if app.allowPlaybackDirect(context.Background(), media, sourceHongguo, "https://library.test") { + t.Fatal("HTTPS page was allowed to redirect to HTTP media") + } + media.key = make([]byte, 16) + if app.allowPlaybackDirect(context.Background(), media, sourceHongguo, "http://library.test") || requests.Load() != before { + t.Fatal("server-only key was treated as public") + } + media.key = nil + for source, group := range accountSourceAliases { + app.mediaResources().settings.DirectSources = []string{group} + if !app.allowPlaybackDirect(context.Background(), media, source, "http://library.test") { + t.Fatal("source alias ignored the administrator setting", source) + } + } +} + +func TestPlaybackOriginalHLSReloadsGrowingPlaylist(t *testing.T) { + app, _ := prefetchFixtureApp(t) + var sequence atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next := sequence.Add(1) + fmt.Fprintf(w, "#EXTM3U\n#EXT-X-TARGETDURATION:3\n#EXT-X-MEDIA-SEQUENCE:%d\n#EXTINF:3,\npart-%d.ts\n", next, next) + })) + defer upstream.Close() + app.downloader.client = upstream.Client() + ctx, cancel := context.WithCancel(context.Background()) + media := &playbackMediaSession{ctx: ctx, cancel: cancel, media: providerMedia{URL: upstream.URL + "/live.m3u8", Playlist: "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:3,\nold.ts\n"}, plan: playbackMediaPlan{Player: "hls"}} + defer media.Close() + app.attachMediaGateway(media, "/media/", "") + for expected := 1; expected <= 2; expected++ { + writer := httptest.NewRecorder() + app.serveMediaSession(writer, httptest.NewRequest(http.MethodGet, "http://localhost"+media.plan.URL, nil), media) + if writer.Code != 200 || !strings.Contains(writer.Body.String(), fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", expected)) || strings.Contains(writer.Body.String(), "#EXT-X-ENDLIST") { + t.Fatal("growing HLS was frozen or declared complete", writer.Code, writer.Body.String()) + } + } +} + +func TestPlaybackPlanRefusalAndMetadataOnlyPrefetch(t *testing.T) { + app, fixture := nativePlaybackFixture(t, "3", "160x90") + body, err := os.ReadFile(fixture.tasks[0].OutPath) + if err != nil { + t.Fatal(err) + } + var refused atomic.Bool + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if refused.Load() { + w.WriteHeader(http.StatusForbidden) + return + } + http.ServeContent(w, r, "fixture.mp4", time.Time{}, bytes.NewReader(body)) + })) + defer upstream.Close() + app.downloader.client = upstream.Client() + plannedFixture(t, app, 1, 1, "auto") + fixture.downloadIDs = nil + fixture.tasks[1].Chapter.Source = sourceHongguo + fixture.tasks[1].Chapter.VideoURL = upstream.URL + "/next.mp4" + result := prefetchRequest(app, fmt.Sprintf(`{"session":"fixture","episode":2,"run":%d,"version":1}`, fixture.run)) + if result.Code != http.StatusAccepted { + t.Fatal(result.Code, result.Body.String()) + } + cache := fixture.prefetch + select { + case <-cache.done: + case <-time.After(3 * time.Second): + t.Fatal("address prefetch did not finish") + } + if view := cache.view(); view.State != "resolved" || requests.Load() != 0 || app.downloader.ffmpegInstaller != nil { + t.Fatal("address prefetch transferred media or encoded video", view, requests.Load()) + } + fixture.tasks[1].Chapter.VideoURL = "" + plan := plannedFixture(t, app, 2, 2, "auto") + if plan.Processing != "original" || fixture.media.media.URL != upstream.URL+"/next.mp4" { + t.Fatal("prepared address was not reused with a nonzero resume position", plan) + } + refused.Store(true) + writer := httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil))) + state, _ := app.playbackStatus("fixture", false) + if writer.Code != 403 || !state.MediaFailure || app.downloader.ffmpegInstaller != nil { + t.Fatal("upstream refusal was hidden or started FFmpeg", writer.Code, state) + } +} + +func TestPlaybackPlanConvertsOnlyUnsupportedAudio(t *testing.T) { + app, fixture := nativePlaybackFixture(t, "3", "160x90") + ffmpeg := app.downloader.cfg.FFmpeg + original := fixture.tasks[0].OutPath + input := filepath.Join(t.TempDir(), "ac3.mp4") + cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-i", original, "-c:v", "copy", "-c:a", "ac3", input) + if body, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("AC3 fixture: %v %s", err, body) + } + fixture.tasks[0].OutPath = input + app.tasks["one"].Path, app.tasks["one"].Source.OutPath = input, input + app.mediaResources().settings.MaxVideoTranscodes = 0 + plan := plannedFixture(t, app, 1, 1, "auto") + if plan.Processing != "audio" || plan.Player != "hls" || plan.Reason != "audio_codec_unsupported" { + t.Fatal("unsupported audio did not select audio-only processing", plan) + } + media := fixture.media + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.serveMediaSession(w, r, media) })) + defer server.Close() + if !reflect.DeepEqual(playbackDecodedFrames(t, ffmpeg, original), playbackDecodedFrames(t, ffmpeg, server.URL+plan.URL)) { + t.Fatal("audio compatibility conversion changed video frames") + } + other, err := app.resolveMediaSession(context.Background(), context.Background(), fixture.tasks[1], "two", 0) + if err != nil { + t.Fatal(err) + } + defer other.Close() + if err := app.planMedia(context.Background(), other, playbackClientCapabilities{MP4: true, NativeHLS: true, Video: []string{"hevc"}, Audio: []string{"aac"}}, "auto", sourceHongguo, "", "/media/", ""); err != nil || other.plan.Player != "legacy" || other.plan.Reason != "video_codec_unsupported" { + t.Fatal("unsupported video did not choose the final compatibility fallback", other.plan, err) + } +} + +func TestPlaybackGeneratedCacheQuotaAndCleanup(t *testing.T) { + app, _ := prefetchFixtureApp(t) + resources := app.mediaResources() + resources.settings.CacheMB = 64 + directory := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + generated := &playbackGenerated{ctx: ctx, cancel: cancel, directory: directory, resources: resources, done: make(chan struct{})} + close(generated.done) + defer generated.Close() + for index := 0; index < 2; index++ { + file, err := os.Create(filepath.Join(directory, fmt.Sprintf("part-%d.m4s", index))) + if err != nil { + t.Fatal(err) + } + err = file.Truncate(40 << 20) + file.Close() + if err != nil { + t.Fatal(err) + } + err = generated.accountCache() + if (err == nil) != (index == 0) { + t.Fatal("cache quota did not enforce the combined size", index, err) + } + } + generated.Close() + deadline := time.Now().Add(3 * time.Second) + for { + resources.mu.Lock() + used := resources.cacheBytes + resources.mu.Unlock() + if used == 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("closed cache retained its quota", used) + } + time.Sleep(time.Millisecond) + } +} diff --git a/internal/app/playback_generated.go b/internal/app/playback_generated.go new file mode 100644 index 0000000..48b5936 --- /dev/null +++ b/internal/app/playback_generated.go @@ -0,0 +1,260 @@ +package app + +import ( + "context" + "errors" + "fmt" + "math" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +type playbackSegmentIndex struct { + Name string + Start float64 + Duration float64 +} + +type playbackGenerated struct { + ctx context.Context + cancel context.CancelFunc + directory string + base string + query string + playlist string + segments []playbackSegmentIndex + files map[string]bool + bytes int64 + resources *playbackResources + done chan struct{} + once sync.Once + mu sync.RWMutex +} + +func playbackGeneratedArgs(media providerMedia, input, directory, processing string) []string { + args := playbackInputArgs(media, input, 0) + if processing == "audio" { + args = append(args, "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2") + } else { + args = append(args, "-c", "copy") + } + return append(args, "-avoid_negative_ts", "make_zero", "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_playlist_type", "vod", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4", + "-hls_flags", "temp_file", "-hls_segment_filename", filepath.Join(directory, "%06d.m4s"), filepath.Join(directory, "index.m3u8")) +} + +func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string) error { + ffmpeg, err := app.downloader.ensureFFmpeg(ctx) + if err != nil { + return err + } + release, err := app.mediaResources().acquire(ctx, processing, false) + if err != nil { + return err + } + defer release() + directory, err := os.MkdirTemp("", "juku-playback-media-") + if err != nil { + return err + } + jobCtx, cancel := context.WithCancel(media.ctx) + stop := context.AfterFunc(ctx, cancel) + defer stop() + generated := &playbackGenerated{ctx: jobCtx, cancel: cancel, directory: directory, base: base, query: query, + resources: app.mediaResources(), done: make(chan struct{}), files: make(map[string]bool)} + media.generated = generated + defer close(generated.done) + input := media.local + var proxy *hlsProxy + if input == "" { + proxy, err = app.downloader.newHLSProxy(jobCtx, media.media, media.key) + if err != nil { + return err + } + defer proxy.Close() + proxy.acquire = func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) } + input = proxy.root + } + command := ffmpegMediaCommand(jobCtx, ffmpeg, playbackGeneratedArgs(media.media, input, directory, processing)...) + log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})} + command.Stderr = log + if err := command.Start(); err != nil { + return fmt.Errorf("无法启动媒体封装:%w", err) + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case err := <-done: + if err != nil { + return playbackStreamError(jobCtx, proxy, log, media.media, err, nil) + } + if err = generated.accountCache(); err != nil { + return err + } + body, err := os.ReadFile(filepath.Join(directory, "index.m3u8")) + if err != nil { + return err + } + if err := generated.index(string(body)); err != nil { + return err + } + media.plan.URL = generated.assetURL("index.m3u8") + media.plan.Player, media.plan.MIME, media.plan.Processing = "hls", "application/vnd.apple.mpegurl", processing + media.plan.Duration = generated.segments[len(generated.segments)-1].Start + generated.segments[len(generated.segments)-1].Duration + media.plan.SeekEnd = media.plan.Duration + return nil + case <-ticker.C: + if err := generated.accountCache(); err != nil { + cancel() + <-done + return err + } + case <-ctx.Done(): + cancel() + <-done + return ctx.Err() + } + } +} + +func (generated *playbackGenerated) accountCache() error { + entries, err := os.ReadDir(generated.directory) + if err != nil { + return err + } + var size int64 + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + if !info.Mode().IsRegular() || info.Size() > 64<<20 { + return errors.New("媒体分片超出缓存限制") + } + size += info.Size() + } + resources := generated.resources + resources.mu.Lock() + defer resources.mu.Unlock() + if resources.cacheBytes+size-generated.bytes > int64(resources.settings.CacheMB)<<20 { + return errors.New("媒体临时缓存已达上限,请关闭其他播放后重试") + } + resources.cacheBytes += size - generated.bytes + generated.bytes = size + return nil +} + +func (generated *playbackGenerated) assetURL(name string) string { + address := generated.base + name + if generated.query != "" { + address += "?" + generated.query + } + return address +} + +func (generated *playbackGenerated) index(playlist string) error { + if len(playlist) > 2<<20 || !strings.HasPrefix(playlist, "#EXTM3U") || !strings.Contains(playlist, "#EXT-X-ENDLIST") { + return errors.New("媒体封装尚未完成,不能生成完整播放索引") + } + lines := strings.Split(playlist, "\n") + var start, duration, longest float64 + validName := func(name string) bool { + return name != "" && filepath.Base(name) == name && !strings.ContainsAny(name, "\\?\x00") && (strings.HasSuffix(name, ".mp4") || strings.HasSuffix(name, ".m4s")) + } + for i, line := range lines { + if strings.HasPrefix(line, "#EXTINF:") { + value, _, _ := strings.Cut(strings.TrimPrefix(line, "#EXTINF:"), ",") + var err error + duration, err = strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(duration) || math.IsInf(duration, 0) || duration <= 0 || duration > 3600 { + return errors.New("媒体分片时长无效") + } + longest = math.Max(longest, duration) + } else if strings.HasPrefix(line, "#EXT-X-MAP:") { + matches := hlsURIAttribute.FindStringSubmatch(line) + if len(matches) != 2 || !validName(matches[1]) { + return errors.New("媒体初始化片段无效") + } + generated.files[matches[1]] = true + lines[i] = strings.Replace(line, matches[0], `URI="`+generated.assetURL(matches[1])+`"`, 1) + } else if line != "" && !strings.HasPrefix(line, "#") { + if duration <= 0 || !validName(line) || len(generated.segments) >= 16384 || start+duration > 24*60*60 { + return errors.New("媒体分片索引无效") + } + generated.files[line] = true + generated.segments = append(generated.segments, playbackSegmentIndex{Name: line, Start: start, Duration: duration}) + start += duration + duration = 0 + lines[i] = generated.assetURL(line) + } + } + if len(generated.segments) == 0 || duration != 0 { + return errors.New("媒体播放索引没有完整分片") + } + for i, line := range lines { + if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") { + lines[i] = "#EXT-X-TARGETDURATION:" + strconv.Itoa(int(math.Ceil(longest))) + } + } + for name := range generated.files { + info, err := os.Stat(filepath.Join(generated.directory, name)) + if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 { + return errors.New("媒体分片未完整生成") + } + } + generated.playlist = strings.Join(lines, "\n") + return nil +} + +func (generated *playbackGenerated) Close() { + generated.once.Do(func() { + generated.cancel() + go func() { + <-generated.done + generated.mu.Lock() + defer generated.mu.Unlock() + _ = os.RemoveAll(generated.directory) + generated.resources.mu.Lock() + generated.resources.cacheBytes -= generated.bytes + generated.resources.mu.Unlock() + }() + }) +} + +func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + generated.mu.RLock() + defer generated.mu.RUnlock() + if generated.ctx.Err() != nil { + writer.WriteHeader(http.StatusGone) + return + } + name := strings.TrimPrefix(request.URL.Path, generated.base) + if name == "index.m3u8" { + writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + http.ServeContent(writer, request, name, time.Time{}, strings.NewReader(generated.playlist)) + return + } + if !generated.files[name] { + http.NotFound(writer, request) + return + } + file, err := os.Open(filepath.Join(generated.directory, name)) + if err != nil { + http.NotFound(writer, request) + return + } + defer file.Close() + writer.Header().Set("Content-Type", "video/mp4") + http.ServeContent(writer, request, name, time.Time{}, file) +} diff --git a/internal/app/playback_history_source.go b/internal/app/playback_history_source.go new file mode 100644 index 0000000..555cf9c --- /dev/null +++ b/internal/app/playback_history_source.go @@ -0,0 +1,17 @@ +package app + +import "strings" + +func playbackHistoryIdentity(id string) (string, string, bool) { + id = strings.TrimSpace(id) + if id == "" || len(id) > 512 || strings.ContainsAny(id, "\x00\r\n\t /\\?#") { + return "", "", false + } + if source, sourceID, ok := splitProviderDramaID(id); ok { + return providerDramaID(source, sourceID), source, true + } + if !strings.Contains(id, ":") { + return id, "cloudfront", true + } + return "", "", false +} diff --git a/internal/app/playback_history_source_test.go b/internal/app/playback_history_source_test.go new file mode 100644 index 0000000..aa20f98 --- /dev/null +++ b/internal/app/playback_history_source_test.go @@ -0,0 +1,23 @@ +package app + +import "testing" + +func TestPlaybackHistorySourceIdentity(t *testing.T) { + for _, input := range []struct{ id, source string }{ + {"hongguo:7000000000000000001", "hongguo"}, + {"huangdou:123", "huangdou"}, + {"huangguoai:456", "huangguoai"}, + {"huangguo-video:789", "huangguo-video"}, + {"legacy-123", "cloudfront"}, + } { + id, source, valid := playbackHistoryIdentity(input.id) + if !valid || id != input.id || source != input.source { + t.Fatal(input, id, source, valid) + } + } + for _, invalid := range []string{"", "unsupported:123", "../data", "https://example.invalid", "123\n456"} { + if _, _, valid := playbackHistoryIdentity(invalid); valid { + t.Fatal("invalid history identity accepted", invalid) + } + } +} diff --git a/internal/app/playback_history_store.go b/internal/app/playback_history_store.go new file mode 100644 index 0000000..bcdd7ec --- /dev/null +++ b/internal/app/playback_history_store.go @@ -0,0 +1,215 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +const playbackHistoryLimit = 500 + +type playbackHistoryEntry struct { + DramaID string `json:"dramaId"` + Title string `json:"title"` + Source string `json:"source"` + ChapterID string `json:"chapterId,omitempty"` + Episode string `json:"episode"` + Index int `json:"index"` + Total int `json:"total"` + Position float64 `json:"position"` + Duration float64 `json:"duration"` + Completed bool `json:"completed"` + Mode string `json:"mode"` + TaskID string `json:"taskId,omitempty"` + WatchedAt time.Time `json:"watchedAt"` + sessionID string + sessionOpened time.Time +} + +type playbackHistoryFile struct { + Version int `json:"version"` + Entries []playbackHistoryEntry `json:"entries"` +} + +type playbackHistoryStore struct { + mu sync.Mutex + writeMu sync.Mutex + path string + entries map[string]playbackHistoryEntry + deleted map[string]time.Time + cleared time.Time + revision uint64 + saved uint64 + loadErr error +} + +func newPlaybackHistoryStore(directory string) *playbackHistoryStore { + store := &playbackHistoryStore{path: filepath.Join(directory, "playback-history.json"), entries: make(map[string]playbackHistoryEntry), deleted: make(map[string]time.Time)} + file, err := os.Open(store.path) + if errors.Is(err, os.ErrNotExist) { + return store + } + if err != nil { + store.loadErr = err + return store + } + defer file.Close() + var saved playbackHistoryFile + decoder := json.NewDecoder(io.LimitReader(file, 8<<20)) + if err = decoder.Decode(&saved); err == nil { + if decoder.Decode(new(any)) != io.EOF || saved.Version != 1 { + err = errors.New("观看记录格式无效") + } + } + if err != nil { + store.loadErr = err + return store + } + for _, entry := range saved.Entries { + id, source, valid := playbackHistoryIdentity(entry.DramaID) + if !valid || entry.Source != "" && canonicalProviderSource(entry.Source) != source || entry.Index < 1 || entry.Total < entry.Index || len(entry.Title) > 4096 || len(entry.ChapterID) > 2048 || len(entry.Episode) > 128 || !validPlaybackHistoryTime(entry.Position) || !validPlaybackHistoryTime(entry.Duration) || entry.WatchedAt.IsZero() { + continue + } + entry.DramaID, entry.Source = id, source + if entry.Mode != "collection" { + entry.Mode, entry.TaskID = "online", "" + } + if current, exists := store.entries[id]; !exists || entry.WatchedAt.After(current.WatchedAt) { + store.entries[id] = entry + } + } + store.trimLocked() + return store +} + +func validPlaybackHistoryTime(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 && value <= 24*60*60 +} + +func (store *playbackHistoryStore) list() ([]playbackHistoryEntry, error) { + store.mu.Lock() + defer store.mu.Unlock() + return store.listLocked(), store.loadErr +} + +func (store *playbackHistoryStore) listLocked() []playbackHistoryEntry { + entries := make([]playbackHistoryEntry, 0, len(store.entries)) + for _, entry := range store.entries { + entries = append(entries, entry) + } + sort.Slice(entries, func(left, right int) bool { + if entries[left].WatchedAt.Equal(entries[right].WatchedAt) { + return entries[left].DramaID < entries[right].DramaID + } + return entries[left].WatchedAt.After(entries[right].WatchedAt) + }) + return entries +} + +func (store *playbackHistoryStore) get(id string) (playbackHistoryEntry, bool) { + id, _, valid := playbackHistoryIdentity(id) + if !valid { + return playbackHistoryEntry{}, false + } + store.mu.Lock() + defer store.mu.Unlock() + entry, ok := store.entries[id] + return entry, ok +} + +func (store *playbackHistoryStore) record(entry playbackHistoryEntry, opened time.Time) (bool, error) { + store.mu.Lock() + defer store.mu.Unlock() + if store.loadErr != nil { + return false, store.loadErr + } + if !opened.After(store.cleared) || !opened.After(store.deleted[entry.DramaID]) { + return false, nil + } + if current, ok := store.entries[entry.DramaID]; ok && current.sessionOpened.After(opened) { + return false, nil + } + if current, ok := store.entries[entry.DramaID]; ok && current.sessionID == entry.sessionID && current.ChapterID == entry.ChapterID && current.Episode == entry.Episode && current.Position == entry.Position && current.Completed == entry.Completed { + return true, nil + } + store.entries[entry.DramaID] = entry + store.revision++ + store.trimLocked() + return true, nil +} + +func (store *playbackHistoryStore) trimLocked() { + if len(store.entries) <= playbackHistoryLimit { + return + } + for _, entry := range store.listLocked()[playbackHistoryLimit:] { + delete(store.entries, entry.DramaID) + } +} + +func (store *playbackHistoryStore) remove(id string, all bool) error { + store.mu.Lock() + if store.loadErr != nil { + err := store.loadErr + store.mu.Unlock() + return err + } + if all { + store.entries = make(map[string]playbackHistoryEntry) + store.deleted = make(map[string]time.Time) + store.cleared = time.Now() + } else { + delete(store.entries, id) + store.deleted[id] = time.Now() + } + store.revision++ + store.mu.Unlock() + return store.flush() +} + +func (store *playbackHistoryStore) flush() error { + store.writeMu.Lock() + defer store.writeMu.Unlock() + store.mu.Lock() + if store.loadErr != nil || store.revision == store.saved { + err := store.loadErr + store.mu.Unlock() + return err + } + revision := store.revision + data := playbackHistoryFile{Version: 1, Entries: store.listLocked()} + store.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(store.path), ".playback-history-*.json") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if err = json.NewEncoder(file).Encode(data); err != nil { + file.Close() + return err + } + if err = file.Sync(); err != nil { + file.Close() + return err + } + if err = file.Close(); err != nil { + return err + } + if err = os.Rename(file.Name(), store.path); err != nil { + return fmt.Errorf("保存观看记录失败:%w", err) + } + store.mu.Lock() + store.saved = revision + store.mu.Unlock() + return nil +} diff --git a/internal/app/playback_hls_types_test.go b/internal/app/playback_hls_types_test.go new file mode 100644 index 0000000..a4f61a4 --- /dev/null +++ b/internal/app/playback_hls_types_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHLSGatewayPreservesMediaTypes(t *testing.T) { + proxy := &hlsProxy{base: "/media/", assets: map[string]hlsAsset{}, assetIDs: map[string]string{}, client: &http.Client{Transport: rankingTransport(func(request *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: io.NopCloser(strings.NewReader("synthetic bytes")), Request: request}, nil + })}} + playlist, err := proxy.rewritePlaylist("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXT-X-KEY:METHOD=AES-128,URI=\"secret\"\n#EXTINF:3,\npart.m4s\n#EXTINF:3,\npart.ts\n#EXT-X-ENDLIST\n", "https://media.example.org/index.m3u8") + if err != nil { + t.Fatal(err) + } + var addresses []string + for _, match := range hlsURIAttribute.FindAllStringSubmatch(playlist, -1) { + addresses = append(addresses, match[1]) + } + for _, line := range strings.Split(playlist, "\n") { + if strings.HasPrefix(line, "/media/") { + addresses = append(addresses, line) + } + } + expected := []struct{ extension, contentType string }{{".mp4", "video/mp4"}, {".key", "application/octet-stream"}, {".m4s", "video/mp4"}, {".ts", "video/mp2t"}} + if len(addresses) != len(expected) { + t.Fatal(playlist) + } + for index, address := range addresses { + writer := httptest.NewRecorder() + proxy.ServeHTTP(writer, httptest.NewRequest(http.MethodGet, "http://localhost"+address, nil)) + if !strings.HasSuffix(address, expected[index].extension) || writer.Header().Get("Content-Type") != expected[index].contentType || writer.Code != 200 || writer.Body.String() != "synthetic bytes" { + t.Fatal("HLS asset was mislabeled or changed", address, writer.Code, writer.Header()) + } + } +} diff --git a/internal/app/playback_media_info.go b/internal/app/playback_media_info.go new file mode 100644 index 0000000..bff2d1e --- /dev/null +++ b/internal/app/playback_media_info.go @@ -0,0 +1,197 @@ +package app + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +type playbackMediaInfo struct { + Video string + Audio string + MIME string + Duration float64 + MP4 bool + Range bool +} + +func playbackMoovInfo(body []byte) playbackMediaInfo { + info := playbackMediaInfo{MP4: true} + mvhd := playbackMP4Child(body, "mvhd") + if len(mvhd) >= 20 && mvhd[0] == 0 { + scale := binary.BigEndian.Uint32(mvhd[12:16]) + if scale > 0 { + info.Duration = float64(binary.BigEndian.Uint32(mvhd[16:20])) / float64(scale) + } + } else if len(mvhd) >= 32 && mvhd[0] == 1 { + scale := binary.BigEndian.Uint32(mvhd[20:24]) + if scale > 0 { + info.Duration = float64(binary.BigEndian.Uint64(mvhd[24:32])) / float64(scale) + } + } + tracks, valid := playbackMP4Boxes(body) + if !valid { + return playbackMediaInfo{} + } + var codecs []string + for _, track := range tracks { + if track.kind != "trak" { + continue + } + stsd := playbackMP4Child(track.body, "mdia", "minf", "stbl", "stsd") + if len(stsd) < 8 { + continue + } + entries, ok := playbackMP4Boxes(stsd[8:]) + if !ok || len(entries) != 1 { + continue + } + entry := entries[0] + kind := entry.kind + if kind == "encv" && len(entry.body) >= 78 { + kind = string(playbackMP4Child(entry.body[78:], "sinf", "frma")) + } + if kind == "enca" && len(entry.body) >= 28 { + kind = string(playbackMP4Child(entry.body[28:], "sinf", "frma")) + } + switch kind { + case "avc1", "avc3": + if len(entry.body) < 78 { + continue + } + avcc := playbackMP4Child(entry.body[78:], "avcC") + if len(avcc) < 4 { + continue + } + info.Video = "h264" + codecs = append(codecs, fmt.Sprintf("%s.%02X%02X%02X", kind, avcc[1], avcc[2], avcc[3])) + case "hvc1", "hev1": + info.Video = "hevc" + codecs = append(codecs, kind) + case "av01": + info.Video = "av1" + codecs = append(codecs, "av01") + case "vp09": + info.Video = "vp9" + codecs = append(codecs, "vp09") + case "mp4a": + if len(entry.body) >= 28 && playbackAACLC(playbackMP4Child(entry.body[28:], "esds")) { + info.Audio = "aac" + codecs = append(codecs, "mp4a.40.2") + } else { + info.Audio = "unknown" + } + case "ac-3": + info.Audio = "ac3" + codecs = append(codecs, kind) + case "ec-3": + info.Audio = "eac3" + codecs = append(codecs, kind) + case "Opus": + info.Audio = "opus" + codecs = append(codecs, "opus") + } + } + if info.Video != "" && info.Audio != "unknown" { + info.MIME = `video/mp4; codecs="` + strings.Join(codecs, ", ") + `"` + } + return info +} + +func (app *UIApp) inspectPlaybackMedia(ctx context.Context, media *playbackMediaSession) { + if media.plan.Player == "hls" { + return + } + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + var file *os.File + if media.local != "" { + var err error + file, err = os.Open(media.local) + if err != nil { + return + } + defer file.Close() + } + read := func(offset, length int64) ([]byte, error) { + if offset < 0 || length < 1 || length > 2<<20 { + return nil, errors.New("媒体索引读取范围无效") + } + if file != nil { + body := make([]byte, length) + _, err := file.ReadAt(body, offset) + media.info.Range = true + return body, err + } + release, err := app.mediaResources().acquire(ctx, "media", false) + if err != nil { + return nil, err + } + defer release() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, media.media.URL, nil) + if err != nil { + return nil, err + } + request.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, offset+length-1)) + request.Header.Set("Accept-Encoding", "identity") + request.Header.Set("User-Agent", userAgent) + request.Header.Set("Referer", media.media.Referer) + response, err := (&http.Client{Transport: app.downloader.client.Transport}).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode == http.StatusPartialContent { + if !strings.HasPrefix(response.Header.Get("Content-Range"), "bytes "+strconv.FormatInt(offset, 10)+"-") { + return nil, errors.New("媒体 Range 响应无效") + } + media.info.Range = true + } else if response.StatusCode != http.StatusOK || offset != 0 { + return nil, errors.New("媒体不支持索引范围读取") + } + body := make([]byte, length) + _, err = io.ReadFull(response.Body, body) + return body, err + } + var offset int64 + for boxes := 0; boxes < 16; boxes++ { + header, err := read(offset, 16) + if err != nil { + return + } + size, headerSize := uint64(binary.BigEndian.Uint32(header)), uint64(8) + if size == 1 { + size, headerSize = binary.BigEndian.Uint64(header[8:16]), 16 + } + if size < headerSize || size > 1<<40 { + return + } + if string(header[4:8]) == "ftyp" { + media.info.MP4 = true + } + if string(header[4:8]) == "moov" { + body, err := read(offset+int64(headerSize), int64(size-headerSize)) + if err != nil { + return + } + info := playbackMoovInfo(body) + info.Range = media.info.Range + media.info = info + if info.Duration > 0 && info.Duration < 24*60*60 { + media.plan.Duration, media.plan.SeekEnd = info.Duration, info.Duration + } + if info.MIME != "" { + media.plan.MIME = info.MIME + } + return + } + offset += int64(size) + } +} diff --git a/internal/app/playback_native.go b/internal/app/playback_native.go new file mode 100644 index 0000000..1a4dcb5 --- /dev/null +++ b/internal/app/playback_native.go @@ -0,0 +1,427 @@ +package app + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "sync" + "time" +) + +const playbackNativeSegmentSeconds = 2 +const playbackNativeBatchSegments = 12 +const playbackNativeCacheBytes = 96 * 1024 * 1024 +const playbackNativeSegmentBytes = 16 * 1024 * 1024 + +type playbackNativeJob struct { + start int + end int + ctx context.Context + cancel context.CancelFunc + done chan struct{} +} + +type playbackNative struct { + ctx context.Context + cancel context.CancelFunc + app *UIApp + task Task + downloadID string + quality int + offset float64 + background bool + once sync.Once + workers sync.WaitGroup + mu sync.Mutex + changed chan struct{} + firstBatch chan struct{} + batchOnce sync.Once + closed bool + prepared bool + directory string + media providerMedia + input string + proxy *hlsProxy + ffmpeg string + duration float64 + segments map[int][]byte + bytes int + wanted int + job *playbackNativeJob + err error +} + +func newPlaybackNative(app *UIApp, parent context.Context, task Task, downloadID string, quality int, offset float64, background bool) *playbackNative { + ctx := context.WithValue(parent, playbackQualityKey{}, quality) + ctx, cancel := context.WithCancel(ctx) + return &playbackNative{ctx: ctx, cancel: cancel, app: app, task: task, downloadID: downloadID, quality: quality, offset: offset, background: background, + changed: make(chan struct{}), firstBatch: make(chan struct{}), segments: make(map[int][]byte), wanted: int(offset / playbackNativeSegmentSeconds)} +} + +func (cache *playbackNative) notifyLocked() { + close(cache.changed) + cache.changed = make(chan struct{}) +} + +func (cache *playbackNative) start() { + cache.once.Do(func() { + cache.mu.Lock() + if cache.closed { + cache.mu.Unlock() + return + } + cache.workers.Add(1) + cache.mu.Unlock() + go func() { + defer cache.workers.Done() + media, input, proxy, err := cache.app.preparePlaybackMedia(cache.ctx, cache.task, cache.downloadID) + var directory, ffmpeg string + if err == nil { + ffmpeg, err = cache.app.downloader.ensureFFmpeg(cache.ctx) + } + if err == nil { + directory, err = os.MkdirTemp("", "juku-playback-hls-") + } + if err == nil { + cache.app.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "playback.mode", Source: sourceFromDramaID(cache.task.DramaID), + DramaID: cache.task.DramaID, DramaTitle: cache.task.DramaTitle, Episode: cache.task.Index, StartSeconds: cache.offset, Message: "native HLS transcode H.264/AAC"}) + } + cache.mu.Lock() + cache.media, cache.input, cache.proxy = media, input, proxy + cache.ffmpeg, cache.directory = ffmpeg, directory + cache.duration = media.Duration.Seconds() + cache.prepared = true + if err == nil && cache.duration > 0 && cache.offset >= cache.duration { + err = errors.New("播放位置超过本集时长") + } + if err != nil { + cache.err = err + cache.batchOnce.Do(func() { close(cache.firstBatch) }) + } else if !cache.closed { + cache.startJobLocked(cache.wanted) + } + cache.notifyLocked() + cache.mu.Unlock() + }() + }) +} + +func (cache *playbackNative) Close() { + cache.mu.Lock() + if cache.closed { + cache.mu.Unlock() + return + } + cache.closed = true + cache.cancel() + if cache.job != nil { + cache.job.cancel() + } + cache.segments = nil + cache.bytes = 0 + cache.notifyLocked() + cache.mu.Unlock() + go func() { + cache.workers.Wait() + cache.mu.Lock() + proxy, directory := cache.proxy, cache.directory + cache.mu.Unlock() + if proxy != nil { + proxy.Close() + } + if directory != "" { + _ = os.RemoveAll(directory) + } + }() +} + +func (cache *playbackNative) segmentCountLocked() int { + if cache.duration <= 0 || math.IsNaN(cache.duration) || math.IsInf(cache.duration, 0) || cache.duration > 24*60*60 { + return 0 + } + return int(math.Ceil(cache.duration / playbackNativeSegmentSeconds)) +} + +func (cache *playbackNative) startJobLocked(index int) { + if cache.closed || !cache.prepared || cache.err != nil { + return + } + previous := cache.job + if previous != nil { + select { + case <-previous.done: + default: + if index >= previous.start && index < previous.end { + return + } + } + previous.cancel() + } + ctx, cancel := context.WithCancel(cache.ctx) + job := &playbackNativeJob{start: index, end: index + playbackNativeBatchSegments, ctx: ctx, cancel: cancel, done: make(chan struct{})} + if count := cache.segmentCountLocked(); count > 0 && job.end > count { + job.end = count + } + cache.job = job + cache.workers.Add(1) + go func() { + defer cache.workers.Done() + defer cancel() + var resultErr error + defer func() { + failed := false + cache.mu.Lock() + close(job.done) + if cache.job == job && !cache.closed { + if resultErr != nil && cache.ctx.Err() == nil { + cache.err = resultErr + failed = true + } + cache.batchOnce.Do(func() { close(cache.firstBatch) }) + cache.notifyLocked() + cache.prefillLocked(cache.wanted) + } + cache.mu.Unlock() + if failed { + cache.app.downloader.recordTaskFailure("playback.native_failed", cache.task, float64(job.start*playbackNativeSegmentSeconds), resultErr) + } + }() + if previous != nil { + select { + case <-previous.done: + case <-ctx.Done(): + return + } + } + resultErr = cache.render(job) + }() +} + +func playbackNativeArgs(media providerMedia, input, directory string, index, end int) []string { + offset := float64(index * playbackNativeSegmentSeconds) + args := playbackEncodingArgs(media, input, offset) + for i := 0; i+1 < len(args); i++ { + if args[i] == "-g" || args[i] == "-keyint_min" { + args[i+1] = strconv.Itoa(playbackNativeSegmentSeconds * 30) + } + } + return append(args, "-t", strconv.Itoa((end-index)*playbackNativeSegmentSeconds), + "-output_ts_offset", strconv.FormatFloat(offset, 'f', 3, 64), + "-f", "hls", "-hls_time", strconv.Itoa(playbackNativeSegmentSeconds), "-hls_list_size", "0", "-hls_segment_type", "mpegts", + "-hls_segment_options", "mpegts_flags=+initial_discontinuity", + "-hls_flags", "independent_segments+temp_file", "-start_number", strconv.Itoa(index), + "-hls_segment_filename", filepath.Join(directory, "%06d.ts"), filepath.Join(directory, "index.m3u8")) +} + +func (cache *playbackNative) render(job *playbackNativeJob) error { + if err := job.ctx.Err(); err != nil { + return err + } + directory, err := os.MkdirTemp(cache.directory, "batch-") + if err != nil { + return err + } + defer os.RemoveAll(directory) + args := playbackNativeArgs(cache.media, cache.input, directory, job.start, job.end) + cache.mu.Lock() + background := cache.background + cache.mu.Unlock() + if background { + for i := 0; i+1 < len(args); i++ { + if args[i] == "-threads" { + args[i+1] = "1" + } + } + } + release, err := cache.app.mediaResources().acquire(job.ctx, "video", background) + if err != nil { + return err + } + defer release() + command := ffmpegMediaCommand(job.ctx, cache.ffmpeg, args...) + log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})} + command.Stderr = log + if err := command.Start(); err != nil { + return fmt.Errorf("无法启动在线播放转码:%w", err) + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + ticker := time.NewTicker(40 * time.Millisecond) + defer ticker.Stop() + next := job.start + collect := func() error { + duration, _ := log.snapshot() + cache.mu.Lock() + if duration > 0 && cache.duration <= 0 { + cache.duration = duration + } + cache.mu.Unlock() + for next < job.end { + path := filepath.Join(directory, fmt.Sprintf("%06d.ts", next)) + info, err := os.Stat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if info.Size() <= 0 || info.Size() > playbackNativeSegmentBytes { + return errors.New("播放分片大小异常") + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + _ = os.Remove(path) + cache.mu.Lock() + if cache.job != job || cache.closed { + cache.mu.Unlock() + return context.Canceled + } + cache.bytes -= len(cache.segments[next]) + cache.segments[next] = body + cache.bytes += len(body) + cache.evictLocked(next) + cache.notifyLocked() + cache.mu.Unlock() + next++ + } + return nil + } + for { + select { + case err := <-done: + if err != nil { + return playbackStreamError(job.ctx, cache.proxy, log, cache.media, err, nil) + } + if err := collect(); err != nil { + return err + } + cache.mu.Lock() + count := cache.segmentCountLocked() + cache.mu.Unlock() + if count == 0 || next <= job.start { + return errors.New("未取得有效的播放时长或视频分片") + } + if next < job.end && next < count { + return errors.New("视频分片未完整生成,请重试播放") + } + return nil + case <-ticker.C: + if err := collect(); err != nil { + job.cancel() + <-done + return err + } + case <-job.ctx.Done(): + <-done + return job.ctx.Err() + } + } +} + +func (cache *playbackNative) evictLocked(keep int) { + for cache.bytes > playbackNativeCacheBytes { + victim, distance := -1, -1 + for index := range cache.segments { + if index == keep || index == cache.wanted { + continue + } + delta := index - cache.wanted + if delta < 0 { + delta = -delta + } + if delta > distance { + victim, distance = index, delta + } + } + if victim < 0 { + return + } + cache.bytes -= len(cache.segments[victim]) + delete(cache.segments, victim) + } +} + +func (cache *playbackNative) segment(ctx context.Context, index int) ([]byte, error) { + for { + if err := ctx.Err(); err != nil { + return nil, err + } + cache.mu.Lock() + if cache.closed || cache.ctx.Err() != nil { + cache.mu.Unlock() + return nil, errors.New("播放已停止,请重新打开本集") + } + if index < 0 || cache.segmentCountLocked() > 0 && index >= cache.segmentCountLocked() { + cache.mu.Unlock() + return nil, errors.New("播放分片不存在") + } + cache.wanted = index + if body := cache.segments[index]; len(body) > 0 { + cache.prefillLocked(index) + cache.mu.Unlock() + return body, nil + } + if cache.err != nil { + err := cache.err + cache.mu.Unlock() + return nil, err + } + cache.startJobLocked(index) + changed := cache.changed + cache.mu.Unlock() + select { + case <-changed: + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +func (cache *playbackNative) prefillLocked(index int) { + job := cache.job + if cache.background || job == nil || cache.err != nil || cache.closed || index < job.end-3 || job.end >= cache.segmentCountLocked() { + return + } + select { + case <-job.done: + if len(cache.segments[job.end]) == 0 { + cache.startJobLocked(job.end) + } + default: + } +} + +func (cache *playbackNative) metadata() (providerMedia, float64, string) { + cache.mu.Lock() + defer cache.mu.Unlock() + source := "online" + if cache.proxy == nil { + source = "local" + } + return cache.media, cache.duration, source +} + +func (cache *playbackNative) state() (string, error) { + cache.mu.Lock() + defer cache.mu.Unlock() + if cache.err != nil { + return "failed", cache.err + } + if cache.closed || cache.ctx.Err() != nil { + return "stopped", nil + } + count := cache.segmentCountLocked() + if count > 0 && len(cache.segments[count-1]) > 0 { + return "ended", nil + } + if len(cache.segments) > 0 { + return "streaming", nil + } + return "buffering", nil +} diff --git a/internal/app/playback_plan_test.go b/internal/app/playback_plan_test.go new file mode 100644 index 0000000..a021019 --- /dev/null +++ b/internal/app/playback_plan_test.go @@ -0,0 +1,376 @@ +package app + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" +) + +func plannedFixture(t *testing.T, app *UIApp, episode, version int, mode string) playbackMediaPlan { + t.Helper() + body, _ := json.Marshal(map[string]any{"session": "fixture", "episode": episode, "version": version, "start": 1.25, "mode": mode, + "client": playbackClientCapabilities{MP4: true, NativeHLS: true, HlsJS: true, Video: []string{"h264"}, Audio: []string{"aac"}}}) + request := httptest.NewRequest(http.MethodPost, "http://localhost/api/ui/playback/plan", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Origin", "http://localhost") + writer := httptest.NewRecorder() + app.handlePlaybackPlan(writer, viewerFixtureRequest(app, request)) + if writer.Code != http.StatusOK { + t.Fatalf("plan: %d %s", writer.Code, writer.Body.String()) + } + var plan playbackMediaPlan + if err := json.Unmarshal(writer.Body.Bytes(), &plan); err != nil { + t.Fatal(err) + } + return plan +} + +func TestPlaybackPlanLocalMP4WithoutFFmpegAndVersionIsolation(t *testing.T) { + app, session := nativePlaybackFixture(t, "4", "320x180") + app.downloader.cfg.FFmpeg = filepath.Join(t.TempDir(), "unavailable-ffmpeg") + plan := plannedFixture(t, app, 1, 1, "auto") + if plan.Player != "mp4" || plan.Delivery != "local" || plan.Processing != "original" || plan.Duration < 3.9 || plan.Duration > 4.1 { + t.Fatalf("unexpected plan: %+v", plan) + } + body, err := os.ReadFile(session.tasks[0].OutPath) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + method, span string + code, length int + }{{"GET", "bytes=0-15", 206, 16}, {"HEAD", "", 200, 0}, {"GET", "bytes=-16", 206, 16}, {"GET", "bytes=999999999-", 416, -1}} { + request := httptest.NewRequest(test.method, "http://localhost"+plan.URL, nil) + request.Header.Set("Range", test.span) + writer := httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request)) + if writer.Code != test.code || test.length >= 0 && writer.Body.Len() != test.length { + t.Fatalf("media %s %s: %d %d", test.method, test.span, writer.Code, writer.Body.Len()) + } + if test.span == "bytes=-16" && !bytes.Equal(writer.Body.Bytes(), body[len(body)-16:]) { + t.Fatal("tail range changed bytes") + } + } + if app.downloader.ffmpegInstaller != nil { + t.Fatal("original playback initialized FFmpeg") + } + other := app.browserViewers().acquire(viewerID("another-fixture-viewer")) + defer other.release() + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil) + app.handlePlaybackMediaAsset(writer, request.WithContext(context.WithValue(request.Context(), viewerContextKey{}, other))) + if writer.Code != http.StatusGone { + t.Fatal("another viewer obtained media", writer.Code) + } + old := session.media + plannedFixture(t, app, 2, 2, "auto") + writer = httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request)) + if writer.Code != http.StatusGone || old.ctx.Err() == nil { + t.Fatal("switching episodes kept the previous media active") + } +} + +func TestPlaybackPlanRemoteRangeRedirectAndFallback(t *testing.T) { + app, session := nativePlaybackFixture(t, "4", "320x180") + body, _ := os.ReadFile(session.tasks[0].OutPath) + var mediaRequests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + mediaRequests.Add(1) + if request.Header.Get("X-Juku-Viewer") != "" || request.Header.Get("Cookie") != "" || request.Header.Get("Authorization") != "" { + t.Error("site credentials forwarded upstream") + } + writer.Header().Set("Content-Type", "video/mp4") + writer.Header().Set("ETag", `"fixture-v1"`) + if request.URL.Path == "/no-range.mp4" { + writer.Write(body) + return + } + http.ServeContent(writer, request, "fixture.mp4", time.Time{}, bytes.NewReader(body)) + })) + defer upstream.Close() + app.downloader.client = upstream.Client() + session.downloadIDs = nil + session.tasks[0].Chapter.Source, session.tasks[0].Chapter.VideoURL = sourceHongguo, upstream.URL+"/fixture.mp4" + app.mediaResources().settings.DirectSources = []string{sourceHongguo} + plan := plannedFixture(t, app, 1, 1, "auto") + if plan.Delivery != "redirect" { + t.Fatalf("eligible MP4 did not redirect: %+v", plan) + } + before := mediaRequests.Load() + writer := httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil))) + if writer.Code != http.StatusFound || writer.Header().Get("Location") != upstream.URL+"/fixture.mp4" || writer.Body.Len() != 0 || mediaRequests.Load() != before { + t.Fatal("redirect transferred media through the app") + } + plan = plannedFixture(t, app, 1, 2, "proxy") + if plan.Delivery != "proxy" || plan.Processing != "original" { + t.Fatal("direct failure selected encoding") + } + for _, span := range []string{"bytes=0-15", "bytes=-16", "bytes=999999999-"} { + request := httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil) + request.Header.Set("Range", span) + writer = httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request)) + if span == "bytes=999999999-" { + if writer.Code != 416 || writer.Header().Get("Content-Range") == "" { + t.Fatal("range error was hidden", writer.Code) + } + } else if writer.Code != 206 || writer.Body.Len() != 16 { + t.Fatal("remote range corrupted", writer.Code, writer.Body.Len()) + } + } + session.tasks[0].Chapter.VideoURL = upstream.URL + "/no-range.mp4" + plan = plannedFixture(t, app, 1, 3, "auto") + request := httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil) + request.Header.Set("Range", "bytes=9-15") + writer = httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request)) + if plan.Delivery != "proxy" || writer.Code != 200 || writer.Header().Get("Accept-Ranges") != "" || !bytes.Equal(writer.Body.Bytes(), body) { + t.Fatal("proxy invented unsupported ranges") + } + if app.downloader.ffmpegInstaller != nil { + t.Fatal("raw remote media invoked FFmpeg") + } +} + +func playbackDecodedFrames(t *testing.T, ffmpeg, address string) []string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-i", address, "-map", "0:v:0", "-an", "-fps_mode", "passthrough", "-f", "framemd5", "-").Output() + if err != nil { + t.Fatal("decode fixture", err) + } + var frames []string + for _, line := range strings.Split(string(output), "\n") { + if line != "" && !strings.HasPrefix(line, "#") { + fields := strings.Split(line, ",") + frames = append(frames, strings.TrimSpace(fields[len(fields)-1])) + } + } + return frames +} + +func TestPlaybackGeneratedPreservesLongGOPAndAudioOnlyVideo(t *testing.T) { + app, fixture := nativePlaybackFixture(t, "10", "320x180") + ffmpeg := app.downloader.cfg.FFmpeg + source := filepath.Join(t.TempDir(), "gop3.mp4") + command := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-i", fixture.tasks[0].OutPath, "-c:v", "libx264", "-preset", "veryfast", "-g", "90", "-keyint_min", "90", "-sc_threshold", "0", "-threads", "1", "-c:a", "copy", source) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("fixture GOP: %v %s", err, output) + } + original := playbackDecodedFrames(t, ffmpeg, source) + for _, mode := range []string{"remux", "audio", "cenc"} { + t.Run(mode, func(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + media := &playbackMediaSession{ctx: parent, cancel: cancel, local: source, plan: playbackMediaPlan{Delivery: "local"}} + processing := mode + if mode == "cenc" { + processing = "remux" + key := "000102030405060708090a0b0c0d0e0f" + media.media.CENCKey, _ = hex.DecodeString(key) + media.local = filepath.Join(t.TempDir(), "encrypted.mp4") + cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-i", source, "-c", "copy", "-encryption_scheme", "cenc-aes-ctr", "-encryption_key", key, "-encryption_kid", "101112131415161718191a1b1c1d1e1f", media.local) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("CENC fixture: %v %s", err, output) + } + } + ctx, stop := context.WithTimeout(parent, 30*time.Second) + defer stop() + if err := app.generatePlayback(ctx, media, processing, "/media/", ""); err != nil { + media.Close() + t.Fatal(err) + } + defer media.Close() + stop() + if media.generated.ctx.Err() != nil { + t.Fatal("completed media inherited its preparation timeout") + } + parts := media.generated.segments + if len(parts) != 4 || parts[0].Duration < 2.99 || parts[0].Duration > 3.01 || parts[1].Start != parts[0].Duration || !strings.Contains(media.generated.playlist, "#EXT-X-TARGETDURATION:3") { + t.Fatalf("invented fixed two-second timeline: %+v", parts) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.serveMediaSession(w, r, media) })) + defer server.Close() + copied := playbackDecodedFrames(t, ffmpeg, server.URL+media.plan.URL) + if len(original) != 300 || !reflect.DeepEqual(original, copied) { + t.Fatalf("video changed during %s: %d / %d frames", mode, len(original), len(copied)) + } + directory := media.generated.directory + media.Close() + deadline := time.Now().Add(3 * time.Second) + for { + if _, err := os.Stat(directory); os.IsNotExist(err) { + break + } + if time.Now().After(deadline) { + t.Fatal("generated media survived close") + } + time.Sleep(10 * time.Millisecond) + } + }) + } + app.mediaResources().mu.Lock() + defer app.mediaResources().mu.Unlock() + if app.mediaResources().cacheBytes != 0 { + t.Fatal("cache budget was not released") + } +} + +func TestPlaybackOriginalHLSURIHeadersAndByteRanges(t *testing.T) { + app, fixture := nativePlaybackFixture(t, "2", "160x90") + var segmentRequests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "" || r.Header.Get("X-Juku-Viewer") != "" { + t.Error("viewer leaked to HLS origin") + } + switch r.URL.Path { + case "/redirect.m3u8": + http.Redirect(w, r, "/real/variant.m3u8", 302) + case "/real/variant.m3u8": + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + io.WriteString(w, "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:17\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXT-X-KEY:METHOD=AES-128,URI=\"enc.key\",IV=0x00000000000000000000000000000011\n#EXTINF:3.123,\n#EXT-X-BYTERANGE:188@0\npart.bin\n#EXT-X-DISCONTINUITY\n#EXTINF:2.5,\n#EXT-X-BYTERANGE:188@188\npart.bin\n#EXT-X-ENDLIST\n") + case "/real/enc.key": + w.Write(bytes.Repeat([]byte{7}, 16)) + case "/real/init.mp4": + w.Write([]byte("synthetic-init")) + case "/real/part.bin": + segmentRequests.Add(1) + http.ServeContent(w, r, "part", time.Time{}, bytes.NewReader(bytes.Repeat([]byte{0x47}, 376))) + default: + t.Error("wrong redirected HLS base", r.URL.Path) + w.WriteHeader(404) + } + })) + defer upstream.Close() + app.downloader.client = upstream.Client() + fixture.downloadIDs = nil + fixture.tasks[0].Chapter.Source, fixture.tasks[0].Chapter.VideoURL = sourceHongguo, upstream.URL+"/redirect.m3u8" + plan := plannedFixture(t, app, 1, 1, "auto") + if plan.Player != "hls" || plan.Processing != "original" || app.downloader.ffmpegInstaller != nil { + t.Fatalf("original HLS was processed: %+v", plan) + } + writer := httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, httptest.NewRequest("GET", "http://localhost"+plan.URL, nil))) + playlist := writer.Body.String() + for _, expected := range []string{"#EXT-X-MEDIA-SEQUENCE:17", "#EXTINF:3.123,", "#EXT-X-BYTERANGE:188@188", "#EXT-X-DISCONTINUITY", "#EXT-X-ENDLIST", "IV=0x00000000000000000000000000000011"} { + if !strings.Contains(playlist, expected) { + t.Fatal("lost HLS semantics", expected, playlist) + } + } + if strings.Contains(playlist, upstream.URL) { + t.Fatal("gateway exposed origin URLs") + } + for _, line := range strings.Split(playlist, "\n") { + if !strings.HasPrefix(line, "/api/") { + continue + } + request := httptest.NewRequest("GET", "http://localhost"+line, nil) + request.Header.Set("Range", "bytes=188-375") + part := httptest.NewRecorder() + app.handlePlaybackMediaAsset(part, viewerFixtureRequest(app, request)) + if part.Code != 206 || part.Body.Len() != 188 { + t.Fatal("HLS byte range changed", part.Code, part.Body.String()) + } + } + if segmentRequests.Load() != 2 { + t.Fatal("unexpected HLS media fetch count", segmentRequests.Load()) + } +} + +func TestPlaybackResourceBudgetsCancellationAndPersistence(t *testing.T) { + app, admin := administratorFixture(t) + settings := defaultPlaybackSettings() + settings.MaxVideoTranscodes, settings.MaxMediaRequests = 0, 1 + response := admin.request(t, http.MethodPost, "/api/ui/admin/playback", settings) + if response.Code != 200 { + t.Fatal(response.Code, response.Body.String()) + } + restarted := &UIApp{cfg: app.cfg} + if restarted.mediaResources().snapshot().MaxVideoTranscodes != 0 { + t.Fatal("disabled video processing did not persist") + } + resources := app.mediaResources() + if _, err := resources.acquire(context.Background(), "video", false); err == nil { + t.Fatal("disabled video processing was admitted") + } + release, err := resources.acquire(context.Background(), "media", false) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { _, err := resources.acquire(ctx, "media", false); done <- err }() + cancel() + if err := <-done; err != context.Canceled { + t.Fatal("queued media was not canceled", err) + } + if _, err := resources.acquire(context.Background(), "media", true); err == nil { + t.Fatal("background work displaced foreground playback") + } + release() + release() + resources.mu.Lock() + if resources.active["media"] != 0 || resources.waiting["media"] != 0 { + t.Fatal("resource lease leaked") + } + resources.mu.Unlock() + guest := newAccountTestBrowser(t, app) + if result := guest.request(t, http.MethodPost, "/api/ui/admin/playback", defaultPlaybackSettings()); result.Code != 403 { + t.Fatal("guest changed playback budgets", result.Code) + } +} + +func TestEmbyOriginalMP4SignedRangeWithoutCookies(t *testing.T) { + app, fixture := nativePlaybackFixture(t, "4", "320x180") + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + task := fixture.tasks[0] + query := url.Values{"id": {task.DramaID}, "chapter": {task.Chapter.ID}, "key": {embyToken(key, task.DramaID, task.Chapter.ID)}} + request := httptest.NewRequest("GET", "http://localhost/api/emby/stream.m3u8?"+query.Encode(), nil) + writer := httptest.NewRecorder() + app.handleEmbyStream(writer, request) + if writer.Code != 302 || !strings.Contains(writer.Header().Get("Location"), "file.mp4") { + t.Fatal("Emby did not receive original MP4", writer.Code, writer.Body.String()) + } + address := writer.Header().Get("Location") + part := httptest.NewRequest("GET", "http://localhost"+address, nil) + part.Header.Set("Range", "bytes=0-15") + writer = httptest.NewRecorder() + app.routes().ServeHTTP(writer, part) + if writer.Code != 206 || writer.Body.Len() != 16 || app.downloader.ffmpegInstaller != nil { + t.Fatal("Emby original media was not served without FFmpeg", writer.Code, writer.Body.String()) + } + parsed, _ := url.Parse(address) + changed := parsed.Query() + changed.Set("chapter", "forged-episode") + parsed.RawQuery = changed.Encode() + writer = httptest.NewRecorder() + app.routes().ServeHTTP(writer, httptest.NewRequest("GET", "http://localhost"+parsed.String(), nil)) + if writer.Code != 403 { + t.Fatal("Emby resource accepted a changed signed identity") + } + first := address + writer = httptest.NewRecorder() + app.handleEmbyStream(writer, request) + if writer.Header().Get("Location") != first { + t.Fatal("repeated Emby playback did not share prepared media") + } +} diff --git a/internal/app/playback_process.go b/internal/app/playback_process.go new file mode 100644 index 0000000..c54ac3a --- /dev/null +++ b/internal/app/playback_process.go @@ -0,0 +1,66 @@ +package app + +import ( + "context" + "fmt" + "io" + "os/exec" +) + +type playbackProcess struct { + command *exec.Cmd + cancel context.CancelFunc + stdout io.ReadCloser + log *playbackLog + waited bool + release func() +} + +func (app *UIApp) startBudgetedPlaybackProcess(ctx context.Context, ffmpeg string, args []string, kind string) (*playbackProcess, error) { + optional, _ := ctx.Value(playbackPrefetchKey{}).(bool) + release, err := app.mediaResources().acquire(ctx, kind, optional) + if err != nil { + return nil, err + } + process, err := startPlaybackProcess(ctx, ffmpeg, args) + if err != nil { + release() + return nil, err + } + process.release = release + return process, nil +} + +func startPlaybackProcess(ctx context.Context, ffmpeg string, args []string) (*playbackProcess, error) { + ctx, cancel := context.WithCancel(ctx) + command := ffmpegMediaCommand(ctx, ffmpeg, args...) + stdout, err := command.StdoutPipe() + if err != nil { + cancel() + return nil, err + } + log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})} + command.Stderr = log + if err := command.Start(); err != nil { + cancel() + _ = stdout.Close() + return nil, fmt.Errorf("无法启动在线播放处理:%w", err) + } + return &playbackProcess{command: command, cancel: cancel, stdout: stdout, log: log}, nil +} + +func (process *playbackProcess) Wait() error { + process.waited = true + if process.release != nil { + defer process.release() + } + return process.command.Wait() +} + +func (process *playbackProcess) Close() { + process.cancel() + _ = process.stdout.Close() + if !process.waited { + _ = process.Wait() + } +} diff --git a/internal/app/playback_quality.go b/internal/app/playback_quality.go new file mode 100644 index 0000000..c3da87b --- /dev/null +++ b/internal/app/playback_quality.go @@ -0,0 +1,149 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" +) + +type playbackQualityKey struct{} + +type playbackQualityOption struct { + Value int `json:"value"` + Label string `json:"label"` +} + +var playbackHLSResolution = regexp.MustCompile(`(?:^|,)RESOLUTION=(\d+)x(\d+)(?:,|$)`) + +func parsePlaybackQuality(value string) (int, error) { + if value == "" || value == "auto" { + return 0, nil + } + quality, err := strconv.Atoi(value) + if err != nil || quality < 0 || quality > 4320 { + return 0, errors.New("清晰度选项无效") + } + return quality, nil +} + +func playbackQualityOptions(media providerMedia) []playbackQualityOption { + options := make([]playbackQualityOption, 0, len(media.Variants)) + seen := make(map[int]bool) + for _, variant := range media.Variants { + if variant.Quality <= 0 || variant.Quality > 4320 || seen[variant.Quality] { + continue + } + seen[variant.Quality] = true + options = append(options, playbackQualityOption{Value: variant.Quality, Label: strconv.Itoa(variant.Quality) + "p"}) + } + sort.Slice(options, func(i, j int) bool { return options[i].Value > options[j].Value }) + return options +} + +func setPlaybackQualityHeaders(header http.Header, media providerMedia) { + options, _ := json.Marshal(playbackQualityOptions(media)) + header.Set("X-Playback-Qualities", string(options)) + header.Set("X-Playback-Quality", strconv.Itoa(media.Quality)) +} + +func (downloader *Downloader) selectPlaybackQuality(ctx context.Context, media providerMedia) (providerMedia, error) { + requested, _ := ctx.Value(playbackQualityKey{}).(int) + if requested > 0 { + for _, variant := range media.Variants { + if variant.Quality == requested { + variant.Variants = media.Variants + media = variant + break + } + } + } + lines := strings.Split(media.Playlist, "\n") + variants := playbackHLSVariants(media.Playlist) + if len(variants) == 0 { + return media, nil + } + sort.SliceStable(variants, func(i, j int) bool { return variants[i].quality > variants[j].quality }) + selected := variants[0] + media.Variants = nil + for _, variant := range variants { + media.Variants = append(media.Variants, providerMedia{Quality: variant.quality}) + if variant.quality == requested { + selected = variant + } + } + base, err := url.Parse(media.URL) + if err != nil { + return providerMedia{}, err + } + reference, err := url.Parse(selected.uri) + if err != nil { + return providerMedia{}, err + } + address := base.ResolveReference(reference).String() + if !isProviderHTTPMediaURL(address) { + return providerMedia{}, errors.New("清晰度播放列表地址无效") + } + playlist, err := downloader.fetchProviderText(ctx, address, media.Referer) + if err != nil { + return providerMedia{}, err + } + if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(playlist, "\ufeff")), "#EXTM3U") { + return providerMedia{}, errors.New("站点未返回所选清晰度的播放列表") + } + filtered := make([]string, 0, len(lines)) + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, "#EXT-X-STREAM-INF:") && i+1 < len(lines) { + if i == selected.line { + filtered = append(filtered, lines[i], lines[i+1]) + } + i++ + continue + } + if !strings.HasPrefix(line, "#EXT-X-I-FRAME-STREAM-INF:") { + filtered = append(filtered, lines[i]) + } + } + media.Playlist, media.Quality = strings.Join(filtered, "\n"), selected.quality + if duration := m3u8Duration(playlist); duration > 0 { + media.Duration = duration + } + return media, nil +} + +type playbackHLSVariant struct { + line int + quality int + uri string +} + +func playbackHLSVariants(playlist string) []playbackHLSVariant { + lines := strings.Split(playlist, "\n") + var variants []playbackHLSVariant + for i := 0; i+1 < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(line, "#EXT-X-STREAM-INF:") { + continue + } + match := playbackHLSResolution.FindStringSubmatch(strings.TrimPrefix(line, "#EXT-X-STREAM-INF:")) + if len(match) != 3 { + continue + } + width, _ := strconv.Atoi(match[1]) + height, _ := strconv.Atoi(match[2]) + if width < height { + height = width + } + uri := strings.TrimSpace(lines[i+1]) + if height > 0 && height <= 4320 && uri != "" && !strings.HasPrefix(uri, "#") { + variants = append(variants, playbackHLSVariant{line: i, quality: height, uri: uri}) + } + } + return variants +} diff --git a/internal/app/playback_quality_test.go b/internal/app/playback_quality_test.go new file mode 100644 index 0000000..5fc72fc --- /dev/null +++ b/internal/app/playback_quality_test.go @@ -0,0 +1,75 @@ +package app + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" +) + +func TestPlaybackQualityUsesActualCompatibleSourceVariants(t *testing.T) { + model := map[string]any{"video_duration": "12.5", "video_list": []any{ + map[string]any{"main_url": "https://media.example.test/unsupported.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "2160p"}}, + map[string]any{"main_url": "https://media.example.test/high.mp4", "video_meta": map[string]any{"codec_type": "bytevc1", "definition": "1080p", "vwidth": 1080, "vheight": 1920}}, + map[string]any{"main_url": "https://media.example.test/low-hevc.mp4", "video_meta": map[string]any{"codec_type": "hevc", "definition": "720p"}}, + map[string]any{"main_url": "https://media.example.test/low-h264.mp4", "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}}, + }} + media, err := selectHongguoAppMedia(model) + if err != nil || media.Quality != 1080 || len(media.Variants) != 2 { + t.Fatalf("compatible quality discovery: %+v %v", playbackQualityOptions(media), err) + } + d := rankingTestDownloader(t, func(*http.Request) (*http.Response, error) { + t.Error("known quality selection performed an unnecessary upstream request") + return nil, errors.New("no extra requests allowed") + }) + selected, err := d.selectPlaybackQuality(context.WithValue(context.Background(), playbackQualityKey{}, 720), media) + if err != nil || selected.Quality != 720 || !strings.HasSuffix(selected.URL, "/low-h264.mp4") || selected.Duration != 12500*time.Millisecond || len(playbackQualityOptions(selected)) != 2 { + t.Fatal("selection lost source quality, codec preference or metadata", err) + } + selected, err = d.selectPlaybackQuality(context.WithValue(context.Background(), playbackQualityKey{}, 480), media) + if err != nil || selected.Quality != 1080 || len(playbackQualityOptions(selected)) != 2 { + t.Fatal("invented an unavailable quality", err) + } + if options := playbackQualityOptions(providerMedia{}); len(options) != 0 { + t.Fatal("invented qualities for an unknown single stream") + } +} + +func TestPlaybackQualitySelectsHLSVariantAndKeepsSeparateAudio(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.String() != "https://media.example.test/video/low/index.m3u8" { + t.Errorf("unexpected request: %s", request.URL.Path) + return nil, errors.New("only a text variant playlist is allowed") + } + return rankingHTTPResponse(request, http.StatusOK, "#EXTM3U\n#EXTINF:6.25,\ngenerated.ts\n#EXT-X-ENDLIST\n"), nil + }) + master := "#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"main\",DEFAULT=YES,URI=\"audio.m3u8\"\n#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=720x1280,AUDIO=\"audio\"\nlow/index.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1080x1920,AUDIO=\"audio\"\nhigh/index.m3u8\n" + media, err := d.selectPlaybackQuality(context.WithValue(context.Background(), playbackQualityKey{}, 720), providerMedia{URL: "https://media.example.test/video/master.m3u8", Playlist: master, Referer: "https://source.example.test/"}) + if err != nil || media.Quality != 720 || media.Duration != 6250*time.Millisecond || len(playbackQualityOptions(media)) != 2 { + t.Fatal("HLS quality selection lost metadata", err) + } + if !strings.Contains(media.Playlist, "audio.m3u8") || !strings.Contains(media.Playlist, "low/index.m3u8") || strings.Contains(media.Playlist, "high/index.m3u8") { + t.Fatal("HLS selection lost audio or kept an unselected variant") + } +} + +func TestPlaybackRunRejectsStaleRequestsAndDifferentQualityPrefetch(t *testing.T) { + app, session := prefetchFixtureApp(t) + session.streamVersion = 5 + old := newPlaybackPrefetch(2, session.run) + old.quality = 720 + session.prefetch = old + if _, status, err := app.beginPlayback(viewerFixtureContext(app, context.Background()), "fixture", 2, 0, 720, 4, false); err == nil || status != http.StatusConflict || session.prefetch != old { + t.Fatal("stale request replaced the current playback") + } + run, _, err := app.beginPlayback(viewerFixtureContext(app, context.Background()), "fixture", 2, 0, 1080, 6, false) + if err != nil { + t.Fatal(err) + } + defer run.stop() + if run.cache != nil || old.ctx.Err() == nil || session.quality != 1080 { + t.Fatal("reused a prefetch with a different quality") + } +} diff --git a/internal/app/playback_remux.go b/internal/app/playback_remux.go new file mode 100644 index 0000000..919d551 --- /dev/null +++ b/internal/app/playback_remux.go @@ -0,0 +1,201 @@ +package app + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "strings" +) + +type playbackRemuxKey struct{} + +type playbackMP4Box struct { + kind string + body []byte +} + +const playbackInitLimit = 256 * 1024 + +func readPlaybackMP4Init(reader io.Reader) ([]byte, string, error) { + var initial []byte + for len(initial) < playbackInitLimit { + header := make([]byte, 8) + if _, err := io.ReadFull(reader, header); err != nil { + return nil, "", err + } + size := uint64(binary.BigEndian.Uint32(header)) + kind := string(header[4:8]) + if size == 1 { + extended := make([]byte, 8) + if _, err := io.ReadFull(reader, extended); err != nil { + return nil, "", err + } + size = binary.BigEndian.Uint64(extended) + header = append(header, extended...) + } + if size < uint64(len(header)) || size > uint64(playbackInitLimit-len(initial)) || kind != "ftyp" && kind != "free" && kind != "moov" { + return nil, "", errors.New("播放初始化数据无效或过大") + } + body := make([]byte, int(size)-len(header)) + if _, err := io.ReadFull(reader, body); err != nil { + return nil, "", err + } + initial = append(initial, header...) + initial = append(initial, body...) + if kind == "moov" { + return initial, playbackRemuxMIME(body), nil + } + } + return nil, "", errors.New("播放初始化数据过大") +} + +func playbackMP4Boxes(data []byte) ([]playbackMP4Box, bool) { + var boxes []playbackMP4Box + for len(data) > 0 { + if len(data) < 8 { + return nil, false + } + size, header := uint64(binary.BigEndian.Uint32(data)), 8 + if size == 1 { + if len(data) < 16 { + return nil, false + } + size, header = binary.BigEndian.Uint64(data[8:16]), 16 + } + if size < uint64(header) || size > uint64(len(data)) { + return nil, false + } + boxes = append(boxes, playbackMP4Box{kind: string(data[4:8]), body: data[header:int(size)]}) + data = data[int(size):] + } + return boxes, true +} + +func playbackMP4Child(data []byte, path ...string) []byte { + for _, kind := range path { + boxes, valid := playbackMP4Boxes(data) + if !valid { + return nil + } + data = nil + for _, box := range boxes { + if box.kind == kind { + if data != nil { + return nil + } + data = box.body + } + } + if data == nil { + return nil + } + } + return data +} + +func playbackRemuxMIME(moov []byte) string { + boxes, valid := playbackMP4Boxes(moov) + if !valid { + return "" + } + var video string + audio := false + for _, track := range boxes { + if track.kind != "trak" { + continue + } + stsd := playbackMP4Child(track.body, "mdia", "minf", "stbl", "stsd") + if len(stsd) < 8 || binary.BigEndian.Uint32(stsd[4:8]) != 1 { + return "" + } + entries, valid := playbackMP4Boxes(stsd[8:]) + if !valid || len(entries) != 1 { + return "" + } + entry := entries[0] + switch entry.kind { + case "avc1": + if video != "" || len(entry.body) < 78 { + return "" + } + width, height := int(binary.BigEndian.Uint16(entry.body[24:26])), int(binary.BigEndian.Uint16(entry.body[26:28])) + if width == 0 || height == 0 || width > 1920 || height > 1920 || width*height > 1920*1080 { + return "" + } + avcc := playbackMP4Child(entry.body[78:], "avcC") + if len(avcc) < 7 || avcc[0] != 1 || avcc[1] != 66 && avcc[1] != 77 && avcc[1] != 100 || avcc[3] == 0 || avcc[3] > 42 { + return "" + } + video = fmt.Sprintf("avc1.%02X%02X%02X", avcc[1], avcc[2], avcc[3]) + case "mp4a": + if audio || len(entry.body) < 28 || binary.BigEndian.Uint16(entry.body[8:10]) != 0 { + return "" + } + channels := binary.BigEndian.Uint16(entry.body[16:18]) + if channels < 1 || channels > 2 || !playbackAACLC(playbackMP4Child(entry.body[28:], "esds")) { + return "" + } + audio = true + default: + return "" + } + } + if video == "" { + return "" + } + codecs := []string{video} + if audio { + codecs = append(codecs, "mp4a.40.2") + } + return `video/mp4; codecs="` + strings.Join(codecs, ", ") + `"` +} + +func playbackMP4Descriptor(data []byte, tag byte) []byte { + if len(data) < 2 || data[0] != tag { + return nil + } + length := 0 + for i := 1; i < len(data) && i <= 4; i++ { + length = length<<7 | int(data[i]&0x7f) + if data[i]&0x80 == 0 { + if length > len(data)-i-1 { + return nil + } + return data[i+1 : i+1+length] + } + } + return nil +} + +func playbackAACLC(esds []byte) bool { + if len(esds) < 4 { + return false + } + es := playbackMP4Descriptor(esds[4:], 3) + if len(es) < 3 { + return false + } + flags, offset := es[2], 3 + if flags&0x80 != 0 { + offset += 2 + } + if flags&0x40 != 0 { + if offset >= len(es) { + return false + } + offset += 1 + int(es[offset]) + } + if flags&0x20 != 0 { + offset += 2 + } + if offset >= len(es) { + return false + } + decoder := playbackMP4Descriptor(es[offset:], 4) + if len(decoder) < 13 || decoder[0] != 0x40 || decoder[1]>>2 != 5 { + return false + } + config := playbackMP4Descriptor(decoder[13:], 5) + return len(config) >= 2 && config[0]>>3 == 2 +} diff --git a/internal/app/playback_remux_test.go b/internal/app/playback_remux_test.go new file mode 100644 index 0000000..62e0b1d --- /dev/null +++ b/internal/app/playback_remux_test.go @@ -0,0 +1,219 @@ +package app + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func playbackFixturePackets(t *testing.T, ffmpeg, path string) map[string][]string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + command := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, + "-map", "0:v:0", "-map", "0:a:0?", "-c", "copy", "-f", "framehash", "-hash", "sha256", "pipe:1") + output, err := command.Output() + if err != nil { + t.Fatal("read fixture packet hashes", err) + } + packets := make(map[string][]string) + for _, line := range strings.Split(string(output), "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Split(line, ",") + if len(fields) < 6 { + t.Fatal("unexpected packet hash output", line) + } + stream := strings.TrimSpace(fields[0]) + packets[stream] = append(packets[stream], strings.TrimSpace(fields[4])+":"+strings.TrimSpace(fields[5])) + } + if len(packets["0"]) == 0 { + t.Fatal("fixture has no video packets") + } + return packets +} + +func TestPlaybackRemuxPreservesPacketsAndSeekFallback(t *testing.T) { + app, session := nativePlaybackFixture(t, "4", "320x180") + ffmpeg := app.downloader.cfg.FFmpeg + source := session.tasks[0].OutPath + high := filepath.Join(t.TempDir(), "high-profile.mp4") + command := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", source, + "-c:v", "libx264", "-preset", "medium", "-profile:v", "high", "-threads", "1", "-c:a", "copy", high) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generate high profile fixture: %v %s", err, output) + } + session.tasks[0].OutPath, app.tasks["one"].Path = high, high + sourcePackets := playbackFixturePackets(t, ffmpeg, high) + for _, query := range []string{"&remux=1", "&remux=1&start=1.250", "&remux=0"} { + t.Run(query, func(t *testing.T) { + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/stream?session=fixture&episode=1"+query, nil) + app.handlePlaybackStream(writer, viewerFixtureRequest(app, request)) + if writer.Code != http.StatusOK || !bytes.Contains(writer.Body.Bytes(), []byte("moof")) { + t.Fatal("playback failed", writer.Code, writer.Body.String()) + } + path := filepath.Join(t.TempDir(), "playback.mp4") + if err := os.WriteFile(path, writer.Body.Bytes(), 0600); err != nil { + t.Fatal(err) + } + packets := playbackFixturePackets(t, ffmpeg, path) + if query == "&remux=1" { + if writer.Header().Get("X-Playback-Mode") != "remux" || !strings.Contains(writer.Header().Get("X-Playback-MIME"), "avc1.6400") { + t.Fatal("compatible high profile was not remuxed with its real codec", writer.Header()) + } + if !reflect.DeepEqual(sourcePackets, packets) { + t.Fatalf("remux changed compressed media packets: video %d/%d audio %d/%d", len(sourcePackets["0"]), len(packets["0"]), len(sourcePackets["1"]), len(packets["1"])) + } + } else if writer.Header().Get("X-Playback-Mode") != "transcode" || !strings.Contains(writer.Header().Get("X-Playback-MIME"), "avc1.42C0") { + t.Fatal("precise seek or compatibility request did not use baseline H.264", writer.Header()) + } + }) + } +} + +func TestPlaybackRemuxUnsupportedCodecFallsBack(t *testing.T) { + app, session := nativePlaybackFixture(t, "2", "160x90") + path := filepath.Join(t.TempDir(), "mpeg4.mp4") + command := exec.Command(app.downloader.cfg.FFmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", session.tasks[0].OutPath, + "-c:v", "mpeg4", "-q:v", "4", "-threads", "1", "-c:a", "copy", path) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generate incompatible fixture: %v %s", err, output) + } + session.tasks[0].OutPath, app.tasks["one"].Path = path, path + writer := httptest.NewRecorder() + app.handlePlaybackStream(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/stream?session=fixture&episode=1&remux=1", nil))) + if writer.Code != http.StatusOK || writer.Header().Get("X-Playback-Mode") != "transcode" || !bytes.Contains(writer.Body.Bytes(), []byte("moof")) { + t.Fatal("unsupported copy codec did not fall back before sending the response", writer.Code, writer.Header()) + } +} + +func TestPlaybackRemuxPrefetchAndCompatibilitySwitch(t *testing.T) { + app, session := nativePlaybackFixture(t, "2", "160x90") + request := func(episode int) *httptest.ResponseRecorder { + writer := httptest.NewRecorder() + address := fmt.Sprintf("http://localhost/api/ui/playback/stream?session=fixture&episode=%d&remux=1", episode) + app.handlePlaybackStream(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, address, nil))) + return writer + } + if writer := request(1); writer.Code != http.StatusOK || writer.Header().Get("X-Playback-Mode") != "remux" { + t.Fatal("initial playback did not use remux", writer.Code, writer.Header()) + } + for _, episode := range []int{2, 3} { + response := prefetchRequest(app, fmt.Sprintf(`{"session":"fixture","episode":%d,"run":%d,"version":1,"remux":true}`, episode, session.run)) + if response.Code != http.StatusAccepted { + t.Fatal("remux prefetch was not accepted", response.Body.String()) + } + cache := session.prefetch + select { + case <-cache.done: + case <-time.After(10 * time.Second): + t.Fatal("remux prefetch did not complete") + } + if cache.view().State != "ready" || !cache.remux { + t.Fatal("remux prefetch failed", cache.err) + } + if episode == 2 { + writer := request(episode) + if writer.Code != http.StatusOK || writer.Header().Get("X-Playback-Prefetched") != "1" || writer.Header().Get("X-Playback-Mode") != "remux" { + t.Fatal("remux prefetch lost mode or restarted encoding", writer.Code, writer.Header()) + } + } else { + run, status, err := app.beginPlayback(viewerFixtureContext(app, context.Background()), "fixture", episode, 0, 0, 0, false) + if err != nil || status != http.StatusOK { + t.Fatal("compatibility switch failed", err) + } + defer run.stop() + if run.cache != nil || cache.ctx.Err() == nil { + t.Fatal("a compatibility retry reused incompatible copied media") + } + } + } +} + +func TestPlaybackRemuxCENCUsesDecryptedPackets(t *testing.T) { + app, session := nativePlaybackFixture(t, "2", "160x90") + ffmpeg, source := app.downloader.cfg.FFmpeg, session.tasks[0].OutPath + path := filepath.Join(t.TempDir(), "encrypted.mp4") + key := "000102030405060708090a0b0c0d0e0f" + command := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", source, "-c", "copy", + "-encryption_scheme", "cenc-aes-ctr", "-encryption_key", key, "-encryption_kid", "101112131415161718191a1b1c1d1e1f", path) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generate encrypted fixture: %v %s", err, output) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + decodedKey, _ := hex.DecodeString(key) + process, err := startPlaybackProcess(ctx, ffmpeg, playbackRemuxArgs(providerMedia{CENCKey: decodedKey}, path)) + if err != nil { + t.Fatal(err) + } + defer process.Close() + initial, mime, err := readPlaybackMP4Init(process.stdout) + if err != nil || mime == "" { + t.Fatal("encrypted fixture did not produce clear initialization data", mime, err) + } + rest, err := io.ReadAll(process.stdout) + if err != nil { + t.Fatal(err) + } + if err := process.Wait(); err != nil { + _, detail := process.log.snapshot() + t.Fatal("CENC remux failed", err, detail) + } + clear := filepath.Join(t.TempDir(), "clear.mp4") + if err := os.WriteFile(clear, append(initial, rest...), 0600); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(playbackFixturePackets(t, ffmpeg, source), playbackFixturePackets(t, ffmpeg, clear)) { + t.Fatal("CENC remux changed media packets") + } +} + +func TestPlaybackMP4InitBoundsAndCodecValidation(t *testing.T) { + _, session := nativePlaybackFixture(t, "1", "160x90") + body, err := os.ReadFile(session.tasks[0].OutPath) + if err != nil { + t.Fatal(err) + } + _, mime, err := readPlaybackMP4Init(bytes.NewReader(body)) + if err != nil || mime == "" { + t.Fatal("valid fixture was rejected", err) + } + for _, change := range []string{"high10", "level", "hevc", "truncated", "oversized"} { + t.Run(change, func(t *testing.T) { + data := bytes.Clone(body) + avcc := bytes.Index(data, []byte("avcC")) + switch change { + case "high10": + data[avcc+5] = 110 + case "level": + data[avcc+7] = 51 + case "hevc": + index := bytes.LastIndex(data[:avcc], []byte("avc1")) + copy(data[index:index+4], "hev1") + case "truncated": + data = data[:avcc+7] + case "oversized": + binary.BigEndian.PutUint32(data[:4], playbackInitLimit+1) + } + _, mime, _ := readPlaybackMP4Init(bytes.NewReader(data)) + if mime != "" { + t.Fatal("unsafe initialization was accepted", change, mime) + } + }) + } +} diff --git a/internal/app/playback_resources.go b/internal/app/playback_resources.go new file mode 100644 index 0000000..ecb5e41 --- /dev/null +++ b/internal/app/playback_resources.go @@ -0,0 +1,202 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "sync" +) + +type playbackSettings struct { + Enabled bool `json:"enabled"` + DirectSources []string `json:"directSources"` + MaxSessions int `json:"maxSessions"` + MaxMediaRequests int `json:"maxMediaRequests"` + MaxRemuxJobs int `json:"maxRemuxJobs"` + MaxAudioTranscodes int `json:"maxAudioTranscodes"` + MaxVideoTranscodes int `json:"maxVideoTranscodes"` + MaxPrefetchJobs int `json:"maxPrefetchJobs"` + CacheMB int `json:"cacheMB"` +} + +type playbackResources struct { + mu sync.Mutex + saveMu sync.Mutex + settings playbackSettings + active map[string]int + waiting map[string]int + changed chan struct{} + path string + cacheBytes int64 +} + +func defaultPlaybackSettings() playbackSettings { + return playbackSettings{Enabled: true, DirectSources: []string{}, MaxSessions: 4, MaxMediaRequests: 8, + MaxRemuxJobs: 2, MaxAudioTranscodes: 1, MaxVideoTranscodes: 1, MaxPrefetchJobs: 1, CacheMB: 512} +} + +func (settings playbackSettings) validate() error { + if settings.MaxSessions < 1 || settings.MaxSessions > 64 || settings.MaxMediaRequests < 1 || settings.MaxMediaRequests > 64 || + settings.MaxRemuxJobs < 0 || settings.MaxRemuxJobs > 8 || settings.MaxAudioTranscodes < 0 || settings.MaxAudioTranscodes > 8 || + settings.MaxVideoTranscodes < 0 || settings.MaxVideoTranscodes > 8 || settings.MaxPrefetchJobs < 0 || settings.MaxPrefetchJobs > 4 || + settings.CacheMB < 64 || settings.CacheMB > 8192 { + return errors.New("播放资源设置超出允许范围") + } + seen := make(map[string]bool) + for _, source := range settings.DirectSources { + valid := false + for _, choice := range accountSourceChoices { + valid = valid || source == choice.ID + } + if !valid || seen[source] { + return errors.New("直连站源选项无效") + } + seen[source] = true + } + return nil +} + +func (app *UIApp) mediaResources() *playbackResources { + app.playbackResourcesOnce.Do(func() { + resources := &playbackResources{settings: defaultPlaybackSettings(), active: make(map[string]int), waiting: make(map[string]int), + changed: make(chan struct{}), path: filepath.Join(app.cfg.dataDirectory(), "playback-settings.json")} + if body, err := os.ReadFile(resources.path); err == nil { + settings := defaultPlaybackSettings() + if len(body) <= 16384 && json.Unmarshal(body, &settings) == nil && settings.validate() == nil { + resources.settings = settings + } + } + app.playbackResources = resources + }) + return app.playbackResources +} + +func (resources *playbackResources) snapshot() playbackSettings { + resources.mu.Lock() + defer resources.mu.Unlock() + settings := resources.settings + settings.DirectSources = append([]string{}, settings.DirectSources...) + return settings +} + +func (settings playbackSettings) limit(kind string) int { + switch kind { + case "media": + return settings.MaxMediaRequests + case "remux": + return settings.MaxRemuxJobs + case "audio": + return settings.MaxAudioTranscodes + case "video": + return settings.MaxVideoTranscodes + case "prefetch": + return settings.MaxPrefetchJobs + } + return 0 +} + +func (resources *playbackResources) notifyLocked() { + close(resources.changed) + resources.changed = make(chan struct{}) +} + +func (resources *playbackResources) acquire(ctx context.Context, kind string, optional bool) (func(), error) { + resources.mu.Lock() + if resources.waiting[kind] >= 128 { + resources.mu.Unlock() + return nil, errors.New("播放资源等待队列已满,请稍后重试") + } + resources.waiting[kind]++ + defer func() { + resources.mu.Lock() + resources.waiting[kind]-- + resources.notifyLocked() + resources.mu.Unlock() + }() + for { + limit := resources.settings.limit(kind) + if err := ctx.Err(); err != nil { + resources.mu.Unlock() + return nil, err + } + if limit == 0 { + resources.mu.Unlock() + return nil, fmt.Errorf("管理员已禁用此类播放处理(%s)", kind) + } + if resources.active[kind] < limit && (!optional || resources.waiting[kind] == 1) { + resources.active[kind]++ + resources.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + resources.mu.Lock() + resources.active[kind]-- + resources.notifyLocked() + resources.mu.Unlock() + }) + }, nil + } + if optional { + resources.mu.Unlock() + return nil, errors.New("前台播放正在使用资源,已跳过本次预缓存") + } + changed := resources.changed + resources.mu.Unlock() + select { + case <-changed: + case <-ctx.Done(): + return nil, ctx.Err() + } + resources.mu.Lock() + } +} + +func (app *UIApp) handlePlaybackSettings(writer http.ResponseWriter, request *http.Request) { + resources := app.mediaResources() + if request.Method == http.MethodGet { + writeJSON(writer, http.StatusOK, map[string]any{"settings": resources.snapshot(), "sources": accountSourceChoices}) + return + } + var settings playbackSettings + if !readPlaybackRequest(writer, request, &settings) { + return + } + if err := settings.validate(); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + resources.saveMu.Lock() + defer resources.saveMu.Unlock() + body, _ := json.MarshalIndent(settings, "", " ") + err := os.MkdirAll(filepath.Dir(resources.path), 0700) + if err == nil { + var file *os.File + file, err = os.CreateTemp(filepath.Dir(resources.path), ".playback-settings-*") + if err == nil { + defer os.Remove(file.Name()) + _, err = file.Write(body) + if syncErr := file.Sync(); err == nil { + err = syncErr + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(file.Name(), resources.path) + } + } + } + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "播放设置保存失败"}) + return + } + resources.mu.Lock() + resources.settings = settings + resources.notifyLocked() + resources.mu.Unlock() + writeJSON(writer, http.StatusOK, map[string]any{"settings": settings, "sources": accountSourceChoices}) +} diff --git a/internal/app/playback_run.go b/internal/app/playback_run.go new file mode 100644 index 0000000..e697c02 --- /dev/null +++ b/internal/app/playback_run.go @@ -0,0 +1,120 @@ +package app + +import ( + "context" + "errors" + "net/http" +) + +type playbackRunContext struct { + id string + index int + run uint64 + session *playbackSession + task Task + downloadID string + ctx context.Context + cancel context.CancelFunc + stop context.CancelFunc + cache *playbackPrefetch + offset float64 +} + +func (app *UIApp) beginPlayback(parent context.Context, id string, index int, offset float64, quality int, version uint64, native bool) (*playbackRunContext, int, error) { + app.playbackMu.Lock() + session := app.playbacks[id] + if !viewerOwnsPlayback(parent, session) { + app.playbackMu.Unlock() + return nil, http.StatusGone, errors.New("播放会话已过期,请重新打开本剧") + } + if index < 1 || index > len(session.tasks) { + app.playbackMu.Unlock() + return nil, http.StatusBadRequest, errors.New("分集不存在") + } + if version > 0 && version <= session.streamVersion { + app.playbackMu.Unlock() + return nil, http.StatusConflict, errors.New("播放请求已更新") + } + downloadID := "" + if len(session.downloadIDs) > 0 { + if !session.prepared[index] { + app.playbackMu.Unlock() + return nil, http.StatusConflict, errors.New("请先选择要播放的分集") + } + downloadID = session.downloadIDs[index-1] + } + previousCancel := session.cancel + cache := session.prefetch + session.prefetch, session.native, session.media = nil, nil, nil + remux, _ := parent.Value(playbackRemuxKey{}).(bool) + if cache != nil && (offset != 0 && !cache.planOnly || cache.episode != index || cache.fromRun != session.run || cache.quality != quality || (cache.native != nil) != native || !native && cache.remux != remux) { + cache.cancel() + cache = nil + } + ctx := context.WithValue(parent, playbackQualityKey{}, quality) + ctx, cancel := context.WithCancel(ctx) + stop := func() { + cancel() + if cache != nil { + cache.cancel() + } + } + session.cancel = stop + session.run++ + session.currentIndex, session.quality = index, quality + if version > 0 { + session.streamVersion = version + } + if session.historyRuns == nil { + session.historyRuns = make(map[uint64]playbackHistoryRun) + } + session.historyRuns[session.run] = playbackHistoryRun{episode: index} + for oldRun := range session.historyRuns { + if session.run > oldRun && session.run-oldRun > 8 { + delete(session.historyRuns, oldRun) + } + } + session.prefetchVersion = 0 + session.state, session.error, session.duration = "buffering", "", 0 + app.touchPlaybackLocked(session) + run := &playbackRunContext{id: id, index: index, run: session.run, session: session, task: session.tasks[index-1], downloadID: downloadID, ctx: ctx, cancel: cancel, stop: stop, cache: cache, offset: offset} + app.playbackMu.Unlock() + if previousCancel != nil { + previousCancel() + } + app.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "playback.started", Source: sourceFromDramaID(run.task.DramaID), + DramaID: run.task.DramaID, DramaTitle: run.task.DramaTitle, Episode: index, Run: run.run, StartSeconds: offset, Message: "开始播放"}) + return run, http.StatusOK, nil +} + +func (app *UIApp) playbackRunReady(run *playbackRunContext, duration float64) { + app.playbackMu.Lock() + if current := app.playbacks[run.id]; current == run.session && current.run == run.run { + current.state, current.duration = "streaming", duration + current.historyRuns[run.run] = playbackHistoryRun{episode: run.index, duration: duration} + } + app.playbackMu.Unlock() +} + +func (app *UIApp) finishPlaybackRun(run *playbackRunContext, err error, stopped bool) { + failed := false + app.playbackMu.Lock() + if current := app.playbacks[run.id]; current == run.session && current.run == run.run { + if current.native == nil { + current.cancel = nil + } + current.state = "ended" + if err != nil { + current.state, current.error = "failed", app.redactError(err) + if stopped { + current.state = "stopped" + } else { + failed = true + } + } + } + app.playbackMu.Unlock() + if failed { + app.downloader.recordTaskFailure("playback.failed", run.task, run.offset, err) + } +} diff --git a/internal/app/playback_stream.go b/internal/app/playback_stream.go new file mode 100644 index 0000000..34e0a9d --- /dev/null +++ b/internal/app/playback_stream.go @@ -0,0 +1,353 @@ +package app + +import ( + "context" + "crypto/aes" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +var playbackDurationPattern = regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`) +var playbackLevelPattern = regexp.MustCompile(`profile Constrained Baseline, level (\d+)\.(\d+)`) + +type playbackLog struct { + mu sync.Mutex + text cappedStringWriter + duration float64 + mime string + ready chan struct{} + once sync.Once +} + +func (log *playbackLog) Write(data []byte) (int, error) { + log.mu.Lock() + defer log.mu.Unlock() + _, _ = log.text.Write(data) + if log.mime == "" { + if match := playbackLevelPattern.FindStringSubmatch(log.text.String()); len(match) == 3 { + major, _ := strconv.Atoi(match[1]) + minor, _ := strconv.Atoi(match[2]) + log.mime = fmt.Sprintf(`video/mp4; codecs="avc1.42C0%02X, mp4a.40.2"`, major*10+minor) + } + } + if log.duration == 0 { + if match := playbackDurationPattern.FindStringSubmatch(log.text.String()); len(match) == 4 { + hours, _ := strconv.ParseFloat(match[1], 64) + minutes, _ := strconv.ParseFloat(match[2], 64) + seconds, _ := strconv.ParseFloat(match[3], 64) + log.duration = hours*3600 + minutes*60 + seconds + } + } + if log.mime != "" || strings.Contains(log.text.String(), "Output #0") { + log.once.Do(func() { close(log.ready) }) + } + return len(data), nil +} + +func (log *playbackLog) snapshot() (float64, string) { + log.mu.Lock() + defer log.mu.Unlock() + return log.duration, log.text.String() +} + +func (log *playbackLog) mimeType() string { + log.mu.Lock() + defer log.mu.Unlock() + return firstNonEmpty(log.mime, playbackMIME) +} + +func (downloader *Downloader) resolvePlaybackMedia(ctx context.Context, task Task) (providerMedia, []byte, error) { + if isHuangguoProviderSource(task.Chapter.Source) || task.Chapter.PageURL != "" || isProviderHTTPMediaURL(task.Chapter.VideoURL) || strings.HasPrefix(task.Chapter.VideoURL, "hongguo-cenc://") { + media, err := downloader.resolveProviderMedia(ctx, task) + return media, nil, err + } + if task.Chapter.VideoURL == "" { + return providerMedia{}, nil, errors.New("此分集没有可用的播放地址") + } + var key []byte + if downloader.cfg.AESKeyHex != "" { + var err error + key, err = hex.DecodeString(downloader.cfg.AESKeyHex) + if err != nil || len(key) != aes.BlockSize { + return providerMedia{}, nil, errors.New("aesKeyHex 必须是 16 字节 AES 密钥的 hex 编码") + } + } + base, err := downloader.apiEndpoint(ctx) + if err != nil { + return providerMedia{}, nil, err + } + access, err := downloader.legacyCredentials(ctx) + if err != nil { + return providerMedia{}, nil, err + } + mediaURL := fmt.Sprintf("%s/api/app/vid/h5/m3u8/%s?token=%s&c=%s", base, strings.TrimLeft(task.Chapter.VideoURL, "/"), url.QueryEscape(access.Token), url.QueryEscape(downloader.cfg.CDNURL)) + playlist, err := downloader.fetchRaw(ctx, mediaURL) + if err != nil { + return providerMedia{}, nil, err + } + if !strings.HasPrefix(strings.TrimSpace(playlist), "#EXTM3U") { + return providerMedia{}, nil, errors.New("站点未返回有效的播放列表") + } + return providerMedia{URL: mediaURL, Playlist: playlist, Duration: m3u8Duration(playlist), Referer: legacyFrontendURL + "/"}, key, nil +} + +func playbackInputArgs(media providerMedia, input string, offset float64) []string { + args := []string{"-hide_banner", "-loglevel", "info", "-nostats", "-nostdin", "-threads", "2", "-filter_threads", "1", "-filter_complex_threads", "1"} + if isProviderHTTPMediaURL(input) { + args = append(args, "-rw_timeout", "20000000", "-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy") + } else { + args = append(args, "-protocol_whitelist", "file,pipe") + } + if media.Playlist != "" { + args = append(args, "-allowed_extensions", "ALL") + } + if len(media.CENCKey) > 0 { + args = append(args, "-decryption_key", hex.EncodeToString(media.CENCKey)) + } + if offset > 0 { + args = append(args, "-ss", strconv.FormatFloat(offset, 'f', 3, 64)) + } + return append(args, "-i", input, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn", "-map_metadata", "-1") +} + +func playbackEncodingArgs(media providerMedia, input string, offset float64) []string { + return append(playbackInputArgs(media, input, offset), + "-vf", "fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", + "-pix_fmt", "yuv420p", "-crf", "22", "-maxrate", "12000k", "-bufsize", "24000k", "-threads", "4", + "-g", "30", "-keyint_min", "30", "-sc_threshold", "0", + "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2") +} + +func playbackFFmpegArgs(media providerMedia, input string, offset float64) []string { + return append(playbackEncodingArgs(media, input, offset), "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-frag_duration", "1000000", "-f", "mp4", "pipe:1") +} + +func playbackRemuxArgs(media providerMedia, input string) []string { + return append(playbackInputArgs(media, input, 0), "-c", "copy", "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-frag_duration", "1000000", "-f", "mp4", "pipe:1") +} + +func (app *UIApp) preparePlaybackMedia(ctx context.Context, task Task, downloadID string) (providerMedia, string, *hlsProxy, error) { + var input string + if downloadID != "" { + var err error + task, input, err = app.playbackCollectionTask(downloadID) + if err != nil { + return providerMedia{}, "", nil, err + } + } + var media providerMedia + var proxy *hlsProxy + if input == "" { + var key []byte + var err error + media, key, err = app.downloader.resolvePlaybackMedia(ctx, task) + if err != nil { + return providerMedia{}, "", nil, fmt.Errorf("播放地址解析失败:%w", err) + } + media, err = app.downloader.selectPlaybackQuality(ctx, media) + if err != nil { + return providerMedia{}, "", nil, fmt.Errorf("清晰度解析失败:%w", err) + } + if len(media.CENCKey) != 0 && (len(media.CENCKey) != aes.BlockSize || media.Playlist != "") { + return providerMedia{}, "", nil, errors.New("播放密钥或媒体格式无效") + } + proxy, err = app.downloader.newHLSProxy(ctx, media, key) + if err != nil { + return providerMedia{}, "", nil, err + } + proxy.acquire = func(request context.Context) (func(), error) { + optional, _ := ctx.Value(playbackPrefetchKey{}).(bool) + return app.mediaResources().acquire(request, "media", optional) + } + input = proxy.root + } + return media, input, proxy, nil +} + +func (app *UIApp) streamPlayback(ctx context.Context, cancel context.CancelFunc, writer http.ResponseWriter, task Task, downloadID string, offset float64, run uint64, ready func(float64)) (resultErr error) { + started := false + defer func() { + if resultErr != nil && !started { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": app.redactError(resultErr)}) + } + }() + media, input, proxy, err := app.preparePlaybackMedia(ctx, task, downloadID) + if err != nil { + return err + } + if proxy != nil { + defer proxy.Close() + } + ffmpeg, err := app.downloader.ensureFFmpeg(ctx) + if err != nil { + return err + } + remux, _ := ctx.Value(playbackRemuxKey{}).(bool) + remux = remux && offset == 0 + var process *playbackProcess + buffer := make([]byte, 64*1024) + var count int + var readErr error + for { + args := playbackFFmpegArgs(media, input, offset) + if remux { + args = playbackRemuxArgs(media, input) + } + if optional, _ := ctx.Value(playbackPrefetchKey{}).(bool); optional { + for index := 0; index+1 < len(args); index++ { + if args[index] == "-threads" { + args[index+1] = "1" + } + } + } + kind := "video" + if remux { + kind = "remux" + } + process, err = app.startBudgetedPlaybackProcess(ctx, ffmpeg, args, kind) + if err != nil { + return err + } + if !remux { + count, readErr = process.stdout.Read(buffer) + break + } + initial, mime, initErr := readPlaybackMP4Init(process.stdout) + if initErr == nil && mime != "" { + if len(initial) > len(buffer) { + buffer = make([]byte, len(initial)) + } + count = copy(buffer, initial) + process.log.mu.Lock() + process.log.mime = mime + process.log.mu.Unlock() + break + } + process.Close() + if ctx.Err() != nil || proxy != nil && proxy.Err() != nil { + return playbackStreamError(ctx, proxy, process.log, media, nil, initErr) + } + remux = false + } + defer cancel() + defer process.Close() + log := process.log + if count == 0 { + waitErr := process.Wait() + return playbackStreamError(ctx, proxy, log, media, waitErr, readErr) + } + select { + case <-log.ready: + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + duration, _ := log.snapshot() + if duration <= 0 { + duration = media.Duration.Seconds() + } + if duration > 0 && offset >= duration { + return errors.New("播放位置超过本集时长") + } + ready(duration) + writer.Header().Set("Content-Type", "video/mp4") + writer.Header().Set("Content-Disposition", "inline") + writer.Header().Set("X-Playback-Duration", strconv.FormatFloat(duration, 'f', 3, 64)) + writer.Header().Set("X-Playback-Run", strconv.FormatUint(run, 10)) + writer.Header().Set("X-Playback-MIME", log.mimeType()) + mode := "transcode" + if remux { + mode = "remux" + } + writer.Header().Set("X-Playback-Mode", mode) + app.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "playback.mode", Source: sourceFromDramaID(task.DramaID), + DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Index, Run: run, StartSeconds: offset, + Message: "MSE " + mode + " " + log.mimeType()}) + setPlaybackQualityHeaders(writer.Header(), media) + if proxy == nil { + writer.Header().Set("X-Playback-Source", "local") + } else { + writer.Header().Set("X-Playback-Source", "online") + } + controller := http.NewResponseController(writer) + interrupted := make(chan struct{}) + stopInterrupt := context.AfterFunc(ctx, func() { + _ = controller.SetWriteDeadline(time.Now()) + close(interrupted) + }) + defer func() { + if !stopInterrupt() { + <-interrupted + } + _ = controller.SetWriteDeadline(time.Time{}) + }() + started = true + for count > 0 { + if _, err := writer.Write(buffer[:count]); err != nil { + return err + } + if err := controller.Flush(); err != nil { + return err + } + count, readErr = process.stdout.Read(buffer) + } + if readErr != nil && readErr != io.EOF { + cancel() + } + waitErr := process.Wait() + if waitErr != nil || readErr != nil && readErr != io.EOF { + return playbackStreamError(ctx, proxy, log, media, waitErr, readErr) + } + if proxy != nil { + if err := proxy.Err(); err != nil { + return fmt.Errorf("读取在线媒体失败:%w", err) + } + } + return nil +} + +func playbackStreamError(ctx context.Context, proxy *hlsProxy, log *playbackLog, media providerMedia, commandErr, readErr error) error { + if ctx.Err() != nil { + return errors.New("播放请求已停止或准备超时,请重试") + } + if proxy != nil { + if err := proxy.Err(); err != nil { + return fmt.Errorf("读取在线媒体失败:%w", err) + } + } + _, detail := log.snapshot() + if strings.Contains(detail, "Unknown encoder") { + return errors.New("此 FFmpeg 缺少 libx264 或 AAC 编码器,请安装完整版本后重试") + } + lines := strings.Split(strings.TrimSpace(detail), "\n") + if len(lines) > 8 { + lines = lines[len(lines)-8:] + } + detail = strings.Join(lines, "\n") + if len(media.CENCKey) > 0 { + detail = strings.ReplaceAll(detail, hex.EncodeToString(media.CENCKey), "[redacted]") + } + if detail == "" { + detail = fmt.Sprint(firstNonNilError(commandErr, readErr, errors.New("没有收到可播放的视频数据"))) + } + return publicError(fmt.Errorf("在线播放转码失败:%s", detail)) +} + +func firstNonNilError(candidates ...error) error { + for _, candidate := range candidates { + if candidate != nil { + return candidate + } + } + return nil +} diff --git a/internal/app/provider_cover.go b/internal/app/provider_cover.go new file mode 100644 index 0000000..2aeb7e9 --- /dev/null +++ b/internal/app/provider_cover.go @@ -0,0 +1,91 @@ +package app + +import ( + "context" + "errors" + "net/http" + "net/url" + "regexp" + "strings" +) + +var coverMetaTag = regexp.MustCompile(`(?is)]*>`) + +func (d *Downloader) fetchDramaCoverAddress(ctx context.Context, drama Drama) (string, error) { + source, id, ok := splitProviderDramaID(drama.ID) + if !ok { + return "", errors.New("无效的剧集 ID") + } + switch source { + case sourceHongguo: + if !hongguoNumericID.MatchString(id) { + return "", errors.New("无效的红果剧集 ID") + } + result, err := d.hongguoAppRequest(ctx, http.MethodPost, "/novel/player/video_detail/v1/", nil, map[string]any{"series_id": id}) + if err != nil { + return "", err + } + row := nestedMap(result, "data", "video_data") + if mapString(row, "series_id_str", "series_id") != id { + return "", errors.New("红果详情返回了其他剧集") + } + return hongguoCoverAddress(mapString(row, "series_cover", "cover")), nil + case sourceHuangdou: + if !rankingSourceID.MatchString(id) { + return "", errors.New("无效的黄豆剧集 ID") + } + var decoded any + if err := newHuangdouAPIClient(d).call(ctx, "/drama/detail", map[string]any{"id": id}, &decoded); err != nil { + return "", err + } + row := huangdouDataMap(decoded) + if strings.TrimPrefix(mapString(row, "id", "drama_id"), "rp_") != id { + return "", errors.New("黄豆详情返回了其他剧集") + } + return providerCoverAddress(bestDramaCover(huangdouDramaFromMap(row)), ""), nil + case sourceHuangguoAI, sourceHuangguoVideo: + pageURL, err := huangguoSortDetailURL(source, id) + if err != nil { + return "", err + } + body, err := d.fetchProviderText(ctx, pageURL, providerRefererForURL(pageURL, "")) + if err != nil { + return "", err + } + patch, err := parseHuangguoSortDetail(body, pageURL, Drama{ID: drama.ID, Source: source, SourceID: id}) + return bestDramaCover(patch), err + } + return "", errors.New("该站源暂无封面补齐接口") +} + +func providerCoverAddress(value any, pageURL string) string { + switch item := value.(type) { + case []any: + for _, value := range item { + if address := providerCoverAddress(value, pageURL); address != "" { + return address + } + } + case map[string]any: + for _, key := range []string{"url", "contentUrl", "thumbnailUrl"} { + if address := providerCoverAddress(item[key], pageURL); address != "" { + return address + } + } + case string: + if strings.TrimSpace(item) == "" { + return "" + } + address, err := url.Parse(strings.TrimSpace(item)) + if err != nil { + return "" + } + if base, err := url.Parse(pageURL); err == nil && base.IsAbs() { + address = base.ResolveReference(address) + } + if validImageURL(address) { + return address.String() + } + } + return "" +} diff --git a/internal/app/provider_cover_test.go b/internal/app/provider_cover_test.go new file mode 100644 index 0000000..c7c0d1e --- /dev/null +++ b/internal/app/provider_cover_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" +) + +func TestHuangdouCoverClickUsesMatchingDetail(t *testing.T) { + for _, matched := range []bool{true, false} { + t.Run(fmt.Sprint(matched), func(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/api/drama/detail" { + t.Error("cover lookup requested something other than textual details", request.URL.Path) + } + id := "rp_target" + if !matched { + id = "other" + } + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"id":%q,"name":"虚构样本","img_y":"https://tideember.cc/synthetic-cover"}}`, id)), nil + }) + address, err := d.fetchDramaCoverAddress(context.Background(), Drama{ID: "huangdou:target", Source: sourceHuangdou}) + if matched && (err != nil || address != "https://tideember.cc/synthetic-cover") || !matched && (err == nil || address != "") { + t.Fatal("cover did not belong to the requested drama", address, err) + } + }) + } +} + +func TestHuangguoCoverClickUsesScopedPageMetadata(t *testing.T) { + for _, test := range []struct { + name, metadata, extra, want string + invalid bool + }{ + {"image object", `"image":{"url":"/synthetic-cover?fixture=1"}`, "", "https://huangguoai.com/synthetic-cover?fixture=1", false}, + {"thumbnail list", `"thumbnailUrl":["javascript:invalid","/synthetic-thumbnail"]`, "", "https://huangguoai.com/synthetic-thumbnail", false}, + {"alternate CDN", `"image":"https://pic.tuafjz.cn/upload/synthetic.jpg?auth_key=fixture-0-0-test"`, "", "https://pic.tuafjz.cn/upload/synthetic.jpg?auth_key=fixture-0-0-test", false}, + {"open graph", `"image":""`, ``, "https://huangguoai.com/synthetic-og?x=1&y=2", false}, + {"new CDN", `"image":"https://new-covers.example.org/cover"`, "", "https://new-covers.example.org/cover", false}, + {"reserved domain", `"image":"https://example.invalid/cover"`, "", "", false}, + {"no cover", `"image":""`, "", "", false}, + {"another drama", `"image":"/synthetic-cover"`, "", "", true}, + } { + t.Run(test.name, func(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/detail/501/" { + t.Error("only textual detail pages should be requested", request.URL.Path) + } + id := "501" + if test.invalid { + id = "502" + } + body := fmt.Sprintf(``, id, test.metadata) + test.extra + return rankingHTTPResponse(request, 200, body), nil + }) + address, err := d.fetchDramaCoverAddress(context.Background(), Drama{ID: "huangguoai:501", Source: sourceHuangguoAI}) + if address != test.want || (err != nil) != test.invalid { + t.Fatal("wrong scoped cover address", address, err) + } + }) + } + page := `` + if patch, err := parseHuangguoSortDetail(page, "https://huangguoai.com/detail/501/", Drama{Source: sourceHuangguoAI, SourceID: "501"}); err == nil || bestDramaCover(patch) != "" { + t.Fatal("accepted another site's identity") + } + video := strings.ReplaceAll(page, "https://example.invalid/detail/501/", "https://huangguo.video/series/501") + if patch, err := parseHuangguoSortDetail(video, "https://huangguo.video/series/501", Drama{Source: sourceHuangguoVideo, SourceID: "series/501"}); err != nil || bestDramaCover(patch) == "" { + t.Fatal("video source did not retain its matching cover address", err) + } + mirror := strings.ReplaceAll(page, "https://example.invalid/detail/501/", "https://thu.ediayikma.cc/detail/501/") + if patch, err := parseHuangguoSortDetail(mirror, "https://huangguoai.com/detail/501/", Drama{Source: sourceHuangguoAI, SourceID: "501"}); err != nil || bestDramaCover(patch) == "" { + t.Fatal("known mirror lost its matching cover address", err) + } +} diff --git a/internal/app/provider_discovery.go b/internal/app/provider_discovery.go new file mode 100644 index 0000000..5d12f46 --- /dev/null +++ b/internal/app/provider_discovery.go @@ -0,0 +1,146 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const legacyFrontendURL = "https://d2pypzndaqisk.cloudfront.net" + +var apiFallbackHosts = []string{ + defaultAPIBase, + "https://d18ka9rqpfd3lo.cloudfront.net", + "https://d37n0wjehmw08f.cloudfront.net", +} + +func (d *Downloader) apiEndpoint(ctx context.Context) (string, error) { + d.apiMu.Lock() + defer d.apiMu.Unlock() + if d.apiBase != "" { + return d.apiBase, nil + } + configured := strings.TrimRight(firstNonEmpty(d.cfg.APIBase, defaultAPIBase), "/") + parsed, err := url.Parse(configured) + if err != nil || !isProviderHTTPMediaURL(configured) { + return "", errors.New("API 地址无效") + } + if !strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".cloudfront.net") { + d.apiBase = configured + return configured, nil + } + candidates := append([]string{configured}, apiFallbackHosts...) + seen := map[string]bool{} + var lastErr error + for pass := 0; pass < 2; pass++ { + if pass == 1 { + candidates, err = d.discoverAPIHosts(ctx) + if err != nil { + lastErr = err + break + } + } + for _, candidate := range candidates { + if seen[candidate] { + continue + } + seen[candidate] = true + if err := ctx.Err(); err != nil { + return "", err + } + if err := d.probeAPIHost(ctx, candidate); err != nil { + lastErr = err + continue + } + d.apiBase = candidate + return candidate, nil + } + } + return "", fmt.Errorf("黄果原 API 的候选域名均不可用: %w", publicError(lastErr)) +} + +func (d *Downloader) resetAPIEndpoint(failed string) { + d.apiMu.Lock() + if d.apiBase == failed { + d.apiBase = "" + } + d.apiMu.Unlock() +} + +func (d *Downloader) probeAPIHost(ctx context.Context, host string) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/api/app/ping/check", nil) + if err != nil { + return err + } + request.Header.Set("User-Agent", userAgent) + response, err := d.doCatalogRequestWithTimeout(request, 4*time.Second) + if err != nil { + return publicError(err) + } + defer response.Body.Close() + var envelope struct { + Code int `json:"code"` + } + if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&envelope) != nil || envelope.Code != 200 { + return fmt.Errorf("%s API 健康检查失败 (HTTP %d)", request.URL.Hostname(), response.StatusCode) + } + return nil +} + +func (d *Downloader) discoverAPIHosts(ctx context.Context) ([]string, error) { + body, err := d.fetchLegacyFrontendScript(ctx) + if err != nil { + return nil, err + } + if hosts := parseAPIHosts(body); len(hosts) > 0 { + return hosts, nil + } + return nil, errors.New("官网前端未提供可识别的 API 地址,请更新 apiBase 配置") +} + +func (d *Downloader) fetchLegacyFrontendScript(ctx context.Context) (string, error) { + page, err := d.fetchProviderText(ctx, legacyFrontendURL+"/", legacyFrontendURL+"/") + if err != nil { + return "", err + } + scripts := regexp.MustCompile(`(?i)]*\bsrc=["']([^"']+)["']`).FindAllStringSubmatch(page, -1) + for _, script := range scripts { + if !strings.Contains(script[1], "/main-") || !strings.HasSuffix(script[1], ".js") { + continue + } + assetURL := resolveProviderURL(legacyFrontendURL+"/", script[1]) + asset, err := url.Parse(assetURL) + if err != nil || asset.Hostname() != mustHost(legacyFrontendURL) { + continue + } + body, err := d.fetchProviderText(ctx, assetURL, legacyFrontendURL+"/") + if err != nil { + return "", err + } + return body, nil + } + return "", errors.New("黄果官网未提供可识别的接口脚本") +} + +func parseAPIHosts(script string) []string { + matches := regexp.MustCompile(`https://[a-z0-9]+\.cloudfront\.net`).FindAllString(script, -1) + seen := map[string]bool{} + var hosts []string + for _, host := range matches { + if host != legacyFrontendURL && !seen[host] { + seen[host] = true + hosts = append(hosts, host) + if len(hosts) == 8 { + break + } + } + } + return hosts +} diff --git a/internal/app/provider_drama_metadata.go b/internal/app/provider_drama_metadata.go new file mode 100644 index 0000000..dca7fb7 --- /dev/null +++ b/internal/app/provider_drama_metadata.go @@ -0,0 +1,76 @@ +package app + +import ( + "context" + "errors" + "net/url" + "strings" +) + +func (d *Downloader) fetchDramaMetadata(ctx context.Context, drama Drama) (Drama, error) { + source, id, valid := splitProviderDramaID(drama.ID) + if !valid { + return Drama{}, errors.New("无效的剧集 ID") + } + switch source { + case sourceHongguo: + return d.fetchHongguoDramaMetadata(ctx, drama, id) + case sourceHuangdou: + row, err := d.huangdouDetail(ctx, id) + if err != nil { + return Drama{}, err + } + patch := huangdouDramaFromMap(row) + patch.Title = mapString(row, "name", "title", "t") + patch.Name = patch.Title + patch.CategoryName = mapString(row, "category", "category_name", "categoryName") + return patch, nil + case sourceHuangguoAI, sourceHuangguoVideo: + pageURL, err := huangguoSortDetailURL(source, id) + if err != nil { + return Drama{}, err + } + body, err := d.fetchProviderText(ctx, pageURL, providerRefererForURL(pageURL, "")) + if err != nil { + return Drama{}, err + } + patch, err := parseHuangguoSortDetail(body, pageURL, Drama{ID: drama.ID, Source: source, SourceID: id}) + if err == nil { + patch.Desc = extractDescription(body) + patch.Intro = patch.Desc + patch.Tags = extractTags(body) + } + return patch, err + } + return Drama{}, errors.New("该站源暂无资料补齐接口") +} + +func (d *Downloader) fetchHongguoDramaMetadata(ctx context.Context, drama Drama, id string) (Drama, error) { + if !hongguoNumericID.MatchString(id) { + return Drama{}, errors.New("无效的红果剧集 ID") + } + entry, appErr := d.hongguoAppDetail(ctx, id) + patch := entry.Drama + if patch.ID == drama.ID { + appErr = nil + if patch.Title == id { + patch.Title, patch.Name = "", "" + } + if patch.CategoryName == "短剧" { + patch.CategoryName = "" + } + if patch.OnlineDate != "" || drama.OnlineDate != "" { + return patch, nil + } + } + body, webErr := d.fetchProviderText(ctx, hongguoBaseURL+"/detail?series_id="+url.QueryEscape(id), hongguoBaseURL+"/") + if webErr == nil { + var web Drama + web, webErr = parseHongguoSortDetail(body, id) + patch = mergeDramaMetadata(patch, web) + } + if patch.ID == drama.ID && strings.TrimSpace(patch.DisplayTitle()) != "" && webErr == nil { + return patch, nil + } + return patch, errors.Join(appErr, webErr) +} diff --git a/internal/app/provider_hongguo.go b/internal/app/provider_hongguo.go new file mode 100644 index 0000000..d50ea38 --- /dev/null +++ b/internal/app/provider_hongguo.go @@ -0,0 +1,360 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "time" +) + +const hongguoBaseURL = "https://hongguoduanju.com" + +func (d *Downloader) fetchHongguoDramas(ctx context.Context) ([]Drama, error) { + initialized := hongguoCatalogInitialized(d.hongguoCatalogSnapshot()) + dramas, appErr := d.fetchHongguoAppCatalog(ctx) + if len(dramas) > 0 || appErr == nil && initialized { + return dramas, appErr + } + if ctx.Err() != nil { + return dramas, ctx.Err() + } + webDramas, webErr := d.fetchHongguoWebDramas(ctx) + if len(webDramas) > 0 && webErr == nil { + return webDramas, nil + } + return webDramas, errors.Join(appErr, webErr) +} + +func (d *Downloader) fetchHongguoWebDramas(ctx context.Context) ([]Drama, error) { + routes := []struct { + path string + name string + }{ + {path: "real-drama", name: "真人剧"}, + {path: "comic-drama", name: "漫剧"}, + {path: "ai-drama", name: "AI剧"}, + {path: "comic", name: "动漫"}, + } + type routeResult struct { + dramas []Drama + err error + } + results := make(chan routeResult, len(routes)) + for _, route := range routes { + route := route + go func() { + dramas, err := d.fetchHongguoRoute(ctx, route.path, route.name) + results <- routeResult{dramas: dramas, err: err} + }() + } + seen := map[string]bool{} + var out []Drama + var failures []error + for range routes { + result := <-results + if result.err != nil { + failures = append(failures, result.err) + } + for _, drama := range result.dramas { + if drama.ID != "" && !seen[drama.ID] { + seen[drama.ID] = true + out = append(out, drama) + } + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayTitle() < out[j].DisplayTitle() }) + return out, errors.Join(failures...) +} + +func (d *Downloader) fetchHongguoRoute(ctx context.Context, route, category string) ([]Drama, error) { + pages := d.cfg.MaxPagesPerSort + if pages < 1 { + pages = 1 + } + var dramas []Drama + seen := map[string]bool{} + for page := 1; page <= pages; page++ { + items, totalPages, err := d.fetchHongguoCategoryPage(ctx, route+"?page="+strconv.Itoa(page), category) + if err != nil { + return dramas, fmt.Errorf("%s: %w", category, err) + } + if totalPages > pages { + pages = totalPages + if pages > 500 { + pages = 500 + } + } + reportLibraryProgress(ctx, sourceHongguo, items, nil, false) + added := 0 + for _, drama := range items { + if !seen[drama.ID] { + seen[drama.ID] = true + dramas = append(dramas, drama) + added++ + } + } + if added == 0 || len(items) < 24 { + break + } + } + return dramas, nil +} + +func (d *Downloader) fetchHongguoCategory(ctx context.Context, route, category string) ([]Drama, error) { + items, _, err := d.fetchHongguoCategoryPage(ctx, route, category) + return items, err +} + +func (d *Downloader) fetchHongguoCategoryPage(ctx context.Context, route, category string) ([]Drama, int, error) { + body, err := d.fetchProviderText(ctx, hongguoBaseURL+"/category/"+route, hongguoBaseURL+"/") + if err != nil { + return nil, 0, err + } + data := parseRouterData(body) + page := routerLoaderMap(data, "category_page", "category_$") + if len(page) == 0 || page["isSuccess"] == false { + return nil, 0, errors.New("红果分类数据不可用,可能是页面结构或访问权限变化") + } + items := anyList(page["recommendList"]) + out := make([]Drama, 0, len(items)) + for _, item := range items { + if dr := hongguoDramaFromAny(item, category); dr.ID != "" { + out = append(out, dr) + } + } + pages, _ := strconv.Atoi(mapString(nestedMap(page, "pagination"), "totalPages")) + return out, pages, nil +} + +func (d *Downloader) fetchHongguoChapters(ctx context.Context, sourceID string) (string, []Chapter, error) { + sourceID = strings.TrimPrefix(strings.TrimSpace(sourceID), "hg-series-v1:") + if sourceID == "" { + return "", nil, fmt.Errorf("empty hongguo sourceID") + } + entry, appErr := d.hongguoAppDetail(ctx, sourceID) + if appErr == nil { + return entry.Drama.DisplayTitle(), append([]Chapter(nil), entry.Chapters...), nil + } + if ctx.Err() != nil { + return "", nil, ctx.Err() + } + title, chapters, webErr := d.fetchHongguoWebChapters(ctx, sourceID) + if webErr == nil { + return title, chapters, nil + } + return "", nil, fmt.Errorf("App 详情失败: %v;网页详情失败: %w", publicError(appErr), webErr) +} + +func (d *Downloader) fetchHongguoWebChapters(ctx context.Context, sourceID string) (string, []Chapter, error) { + body, err := d.fetchProviderText(ctx, hongguoBaseURL+"/detail?series_id="+url.QueryEscape(sourceID), hongguoBaseURL+"/") + if err != nil { + return "", nil, err + } + data := parseRouterData(body) + page := routerLoaderMap(data, "detail_page", "detail_") + detail, _ := page["seriesDetail"].(map[string]any) + if len(detail) == 0 { + return "", nil, fmt.Errorf("hongguo detail is empty") + } + title := firstNonEmpty(mapString(detail, "series_name", "series_title", "name"), sourceID) + vids := anyList(detail["vid_list"]) + chapters := make([]Chapter, 0, len(vids)) + for i, v := range vids { + vid := strings.TrimSpace(fmt.Sprint(v)) + if vid == "" || vid == "" { + continue + } + idx := i + 1 + chapters = append(chapters, Chapter{ID: providerChapterID(sourceHongguo, sourceID, vid), Source: sourceHongguo, Title: fmt.Sprintf("第%d集", idx), VideoURL: "hongguo-cenc://" + vid, CurrentEpisode: rawEpisode(idx)}) + } + if len(chapters) == 0 { + return title, nil, errors.New("红果详情没有返回剧集 ID") + } + return title, chapters, nil +} + +func parseRouterData(raw string) map[string]any { + idx := regexp.MustCompile(`(?s)(?:window\.)?_ROUTER_DATA\s*=\s*`).FindStringIndex(raw) + if idx == nil { + return nil + } + var data map[string]any + dec := json.NewDecoder(strings.NewReader(raw[idx[1]:])) + dec.UseNumber() + if err := dec.Decode(&data); err != nil { + return nil + } + return data +} + +func (d *Downloader) resolveHongguoMedia(ctx context.Context, task Task) (providerMedia, error) { + source, seriesID, ok := splitProviderDramaID(task.DramaID) + if !ok || source != sourceHongguo { + return providerMedia{}, fmt.Errorf("红果章节缺少剧 ID,请刷新剧库后重新加入队列") + } + videoID := strings.TrimPrefix(task.Chapter.VideoURL, "hongguo-cenc://") + if !hongguoNumericID.MatchString(videoID) || !hongguoNumericID.MatchString(seriesID) { + return providerMedia{}, fmt.Errorf("红果章节 ID 无效,请重新获取章节") + } + media, nativeErr := d.resolveHongguoAppMedia(ctx, videoID) + if nativeErr == nil { + return media, nil + } + if err := ctx.Err(); err != nil { + return providerMedia{}, err + } + media, pageErr := d.resolveHongguoWebMedia(ctx, seriesID, videoID) + if pageErr == nil { + return media, nil + } + if err := ctx.Err(); err != nil { + return providerMedia{}, err + } + media, apiErr := d.resolveHongguoPlaybackAPI(ctx, seriesID, videoID) + if apiErr == nil { + return media, nil + } + return providerMedia{}, fmt.Errorf("红果 App 取流失败:%v;网页取流失败:%v;备用取流失败:%w", publicError(nativeErr), publicError(pageErr), apiErr) +} + +func (d *Downloader) resolveHongguoWebMedia(ctx context.Context, seriesID, videoID string) (providerMedia, error) { + pageURL := hongguoBaseURL + "/player/" + url.PathEscape(seriesID) + "/" + url.PathEscape(videoID) + body, err := d.fetchProviderText(ctx, pageURL, hongguoBaseURL+"/") + if err != nil { + return providerMedia{}, err + } + page := routerLoaderMap(parseRouterData(body), "player_", "player_page") + if mapString(page, "vid") != videoID || mapString(page, "series_id") != seriesID { + return providerMedia{}, fmt.Errorf("红果未返回所请求的剧集,可能仅允许网页试看;请在站点确认该集的访问权限") + } + info, _ := page["video_player_info"].(map[string]any) + mediaURL := mapString(info, "main_url") + if !isProviderHTTPMediaURL(mediaURL) { + return providerMedia{}, fmt.Errorf("红果该集未提供公开播放地址,可能需要登录或 App 授权;不会将试看集冒充该集下载") + } + duration, _ := strconv.ParseFloat(mapString(info, "duration"), 64) + return providerMedia{URL: mediaURL, Referer: d.providerBaseURL(sourceHongguo) + "/", Duration: time.Duration(duration * float64(time.Second))}, nil +} + +func hongguoCoverAddress(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || len(value) > 8192 { + continue + } + parsed, err := url.Parse(value) + if err == nil && validImageURL(parsed) { + return value + } + } + return "" +} + +func hongguoDramaFromAny(v any, category string) Drama { + m, ok := v.(map[string]any) + if !ok { + return Drama{} + } + vd, _ := m["video_data"].(map[string]any) + if len(vd) == 0 { + vd = m + } + sourceID := firstNonEmpty(mapString(vd, "series_id_str", "series_id"), mapString(m, "series_id_str", "series_id"), mapString(vd, "keyword"), mapString(m, "keyword")) + if !hongguoNumericID.MatchString(sourceID) { + return Drama{} + } + title := firstNonEmpty(mapString(vd, "series_title", "series_name", "title"), mapString(m, "series_name", "name"), sourceID) + cover := firstNonEmpty(mapString(vd, "series_cover", "cover"), mapString(m, "series_cover")) + intro := firstNonEmpty(mapString(vd, "series_intro", "video_desc"), mapString(m, "series_intro")) + count := firstNonEmpty(mapString(vd, "episode_cnt"), mapString(m, "episode_cnt")) + remark := firstNonEmpty(mapString(vd, "episode_right_text"), mapString(m, "episode_right_text")) + releaseStatus := releaseStatusFromRemark(remark) + if mapString(vd, "series_status") == "1" { + releaseStatus = "finished" + } else if mapString(vd, "series_status") == "0" { + releaseStatus = "ongoing" + } + if remark == "" && count != "" { + remark = "共" + count + "集" + } + tags := mapStringSlice(vd, "tags") + for _, value := range anyList(vd["category_list"]) { + item, _ := value.(map[string]any) + if name := mapString(item, "name"); name != "" && !slices.Contains(tags, name) { + tags = append(tags, name) + } + } + var categories []struct { + Name string `json:"name"` + } + if json.Unmarshal([]byte(mapString(vd, "category_schema")), &categories) == nil { + for _, item := range categories { + if item.Name != "" && !slices.Contains(tags, item.Name) { + tags = append(tags, item.Name) + } + } + } + genre := mapString(vd, "category_name", "categoryName", "category") + if genre == "" && len(tags) > 0 { + genre = tags[0] + } + return Drama{ID: providerDramaID(sourceHongguo, sourceID), Source: sourceHongguo, SourceID: sourceID, Title: title, Name: title, Desc: intro, Intro: intro, Cover: cover, CoverURL: cover, CategoryName: firstNonEmpty(genre, category), ChannelName: "红果", Remark: remark, TotalEpisode: count, EpisodeCount: count, Tags: tags, ReleaseStatus: releaseStatus, Score: mapString(vd, "score"), Views: mapString(vd, "series_play_cnt", "play_cnt"), Heat: hongguoHeat(vd), OnlineDate: hongguoOnlineDate(vd, time.Now())} +} + +func nestedMap(v any, keys ...string) map[string]any { + cur, _ := v.(map[string]any) + for _, key := range keys { + if cur == nil { + return nil + } + cur, _ = cur[key].(map[string]any) + } + return cur +} +func routerLoaderMap(data map[string]any, names ...string) map[string]any { + loader, _ := data["loaderData"].(map[string]any) + for _, name := range names { + if page, _ := loader[name].(map[string]any); len(page) > 0 { + return page + } + } + for key, value := range loader { + for _, name := range names { + if strings.TrimSuffix(name, "$") != "" && strings.HasPrefix(key, strings.TrimSuffix(name, "$")) { + if page, _ := value.(map[string]any); len(page) > 0 { + return page + } + } + } + } + return nil +} + +func anyList(v any) []any { + switch x := v.(type) { + case []any: + return x + case map[string]any: + for _, key := range []string{"list", "items", "data"} { + if out := anyList(x[key]); len(out) > 0 { + return out + } + } + } + return nil +} + +func cloneValues(v url.Values) url.Values { + out := url.Values{} + for key, values := range v { + out[key] = append([]string(nil), values...) + } + return out +} diff --git a/internal/app/provider_hongguo_app.go b/internal/app/provider_hongguo_app.go new file mode 100644 index 0000000..9e6afeb --- /dev/null +++ b/internal/app/provider_hongguo_app.go @@ -0,0 +1,235 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const hongguoAppBaseURL = "https://api5-normal-sinfonlineb.fqnovel.com" +const hongguoAppUserAgent = "com.phoenix.read/73532 (Linux; U; Android 16; zh_CN; 25053RT47C; Build/BP2A.250605.031.A3; Cronet/TTNetVersion:04657795 2026-01-23 QuicVersion:c67e9834 2025-09-08)" + +type hongguoCatalogCursor struct { + Offset int `json:"offset"` + SessionID string `json:"sessionId,omitempty"` + LastID string `json:"lastId,omitempty"` + PageSignature string `json:"pageSignature,omitempty"` + Initialized bool `json:"initialized"` + Exhausted bool `json:"exhausted"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type hongguoCatalogState struct { + Version int `json:"version"` + DeviceID string `json:"deviceId"` + InstallID string `json:"installId"` + Feeds map[string]hongguoCatalogCursor `json:"feeds"` +} + +type hongguoAppClient struct { + mu sync.Mutex + catalogMu sync.Mutex + baseURL string + state hongguoCatalogState + details map[string]hongguoDetailEntry + pending map[string]*hongguoDetailCall + searches map[string]hongguoSearchEntry + searchPending map[string]*hongguoSearchCall + danmaku map[string]hongguoDanmakuCacheEntry + danmakuPending map[string]*hongguoDanmakuCall +} + +func (downloader *Downloader) hongguoClient() *hongguoAppClient { + downloader.hongguoOnce.Do(func() { + downloader.hongguo = &hongguoAppClient{ + baseURL: hongguoAppBaseURL, + state: hongguoCatalogState{Version: 1, DeviceID: newHongguoDeviceID(), InstallID: newHongguoDeviceID(), Feeds: map[string]hongguoCatalogCursor{}}, + details: map[string]hongguoDetailEntry{}, pending: map[string]*hongguoDetailCall{}, + searches: map[string]hongguoSearchEntry{}, searchPending: map[string]*hongguoSearchCall{}, + } + }) + return downloader.hongguo +} + +func cloneHongguoCatalogState(state *hongguoCatalogState) *hongguoCatalogState { + if state == nil { + return nil + } + cloned := *state + cloned.Feeds = make(map[string]hongguoCatalogCursor, len(state.Feeds)) + for name, cursor := range state.Feeds { + cloned.Feeds[name] = cursor + } + return &cloned +} + +func (downloader *Downloader) restoreHongguoCatalog(state *hongguoCatalogState) { + if state == nil || state.Version != 1 { + return + } + client := downloader.hongguoClient() + client.mu.Lock() + defer client.mu.Unlock() + if len(client.state.Feeds) != 0 { + return + } + cloned := cloneHongguoCatalogState(state) + if !hongguoNumericID.MatchString(cloned.DeviceID) || !hongguoNumericID.MatchString(cloned.InstallID) { + return + } + for name, cursor := range cloned.Feeds { + known := false + for _, genre := range hongguoAppGenres { + known = known || name == genre.key + } + if !known || cursor.Offset < 0 || cursor.Offset > 1_000_000 || len(cursor.SessionID) > 4096 { + delete(cloned.Feeds, name) + } + } + client.state = *cloned +} + +func (downloader *Downloader) hongguoCatalogSnapshot() *hongguoCatalogState { + client := downloader.hongguoClient() + client.mu.Lock() + defer client.mu.Unlock() + return cloneHongguoCatalogState(&client.state) +} + +func hongguoCatalogInitialized(state *hongguoCatalogState) bool { + if state != nil && state.Version == 1 { + for _, cursor := range state.Feeds { + if cursor.Initialized { + return true + } + } + } + return false +} + +func hongguoCatalogHasMore(state *hongguoCatalogState) bool { + for _, genre := range hongguoAppGenres { + if state == nil || !state.Feeds[genre.key].Exhausted { + return true + } + } + return false +} + +func (downloader *Downloader) hongguoAppRequest(ctx context.Context, method, path string, extra url.Values, payload any) (map[string]any, error) { + client := downloader.hongguoClient() + client.mu.Lock() + base, deviceID, installID := client.baseURL, client.state.DeviceID, client.state.InstallID + client.mu.Unlock() + query := url.Values{ + "aid": {"8662"}, "app_name": {"novelread"}, "version_code": {"73532"}, "version_name": {"7.3.5.32"}, + "manifest_version_code": {"73532"}, "update_version_code": {"73532"}, "channel": {"update_64"}, + "device_platform": {"android"}, "os": {"android"}, "ssmix": {"a"}, "device_type": {"25053RT47C"}, + "device_brand": {"Redmi"}, "language": {"zh"}, "os_api": {"36"}, "os_version": {"16"}, + "resolution": {"1280*2772"}, "dpi": {"520"}, "ac": {"wifi"}, "device_id": {deviceID}, "iid": {installID}, + } + for key, values := range extra { + query[key] = append([]string(nil), values...) + } + var body []byte + var err error + if payload != nil { + body, err = json.Marshal(payload) + if err != nil { + return nil, err + } + } + attempts := downloader.cfg.Retries + if attempts < 1 { + attempts = 1 + } + if attempts > 3 { + attempts = 3 + } + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 { + select { + case <-time.After(time.Duration(attempt) * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + request, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(base, "/")+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", hongguoAppUserAgent) + request.Header.Set("Accept", "application/json") + request.Header.Set("X-XS-From-Web", "0") + request.Header.Set("Sdk-Version", "2") + comment, _ := ctx.Value(hongguoCommentKey{}).(bool) + var commentNonce hongguoCommentNonce + if comment { + commentNonce, err = newHongguoCommentNonce() + if err != nil { + return nil, errors.New("无法准备红果文字请求") + } + request.Header.Set("Comment-Source", "601") + request.Header.Set("Server-Channel", "1000") + } + if payload != nil { + request.Header.Set("Content-Type", "application/json; charset=utf-8") + } + response, err := downloader.doPreparedCatalogRequest(request, 20*time.Second, func(prepared *http.Request) { + now := time.Now() + query.Set("_rticket", strconv.FormatInt(now.UnixMilli(), 10)) + prepared.URL.RawQuery = query.Encode() + if comment { + signHongguoCommentRequest(prepared, commentNonce, now) + } else { + signHongguoRequest(prepared, body, now) + } + }) + if err != nil { + lastErr = err + var backoff *requestBackoff + if errors.As(err, &backoff) || ctx.Err() != nil { + return nil, err + } + continue + } + content, readErr := io.ReadAll(io.LimitReader(response.Body, providerMaxBodyBytes+1)) + response.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if response.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("红果 App 接口 HTTP %d", response.StatusCode) + if response.StatusCode >= 400 && response.StatusCode < 500 { + return nil, lastErr + } + continue + } + if len(content) == 0 || len(content) > providerMaxBodyBytes { + return nil, errors.New("红果 App 接口未返回有效数据") + } + var result map[string]any + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil || result == nil { + return nil, errors.New("红果 App 接口返回格式异常") + } + code := firstNonEmpty(mapString(result, "code", "Code", "status_code"), mapString(nestedMap(result, "BaseResp"), "StatusCode")) + if code != "" && code != "0" { + return nil, fmt.Errorf("红果 App 接口暂不可用(%s)", truncate(code, 20)) + } + return result, nil + } + return nil, lastErr +} diff --git a/internal/app/provider_hongguo_app_test.go b/internal/app/provider_hongguo_app_test.go new file mode 100644 index 0000000..2793620 --- /dev/null +++ b/internal/app/provider_hongguo_app_test.go @@ -0,0 +1,118 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "testing" +) + +func TestHongguoCatalogContinuesAfterRestartWithoutReplacingHistory(t *testing.T) { + calls := map[string][]int{} + transport := func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/reading/distribution/category/landpage/v/" { + t.Errorf("unexpected catalog request: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + var payload struct { + Offset int `json:"offset"` + Select struct { + Genre []string `json:"genre"` + } `json:"select_items"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil || len(payload.Select.Genre) != 1 { + t.Errorf("invalid catalog payload: %v", err) + return rankingHTTPResponse(request, 400, ""), nil + } + genre := payload.Select.Genre[0] + index := -1 + for position, candidate := range hongguoAppGenres { + if candidate.key == genre { + index = position + } + } + if index < 0 || payload.Offset != 0 && payload.Offset != 18 { + t.Errorf("unknown feed or incorrect continuation: %s %d", genre, payload.Offset) + return rankingHTTPResponse(request, 400, ""), nil + } + calls[genre] = append(calls[genre], payload.Offset) + id := strconv.Itoa(700000 + index*100 + payload.Offset) + body, _ := json.Marshal(map[string]any{"data": map[string]any{ + "next_offset": payload.Offset + 18, "session_id": "fixture-" + genre, "has_more": payload.Offset == 0, + "video_data": []any{map[string]any{ + "series_id": id, "series_title": fmt.Sprintf("目录剧 %s", id), "series_cover": coverAddressFixture, "first_visible_time": "1773662280", "hot_score": "20019", "series_play_cnt": "0", + }}, + }}) + return rankingHTTPResponse(request, 200, string(body)), nil + } + first := rankingTestDownloader(t, transport) + first.cfg.MaxPagesPerSort = 1 + initial, err := first.RefreshDramas(context.Background(), sourceHongguo) + if err != nil || len(initial) != len(hongguoAppGenres) { + t.Fatalf("first catalog batch: %d, %v", len(initial), err) + } + cache, err := readLibraryCache(first.cfg.dataDirectory()) + if err != nil || !hongguoCatalogInitialized(cache.HongguoApp) || !hongguoCatalogHasMore(cache.HongguoApp) { + t.Fatalf("catalog checkpoint was not persisted: %v", err) + } + second := rankingTestDownloader(t, transport) + second.cfg.dataDir = first.cfg.dataDirectory() + second.cfg.MaxPagesPerSort = 1 + continued, err := second.RefreshDramas(context.WithValue(context.Background(), libraryMoreKey{}, true), sourceHongguo) + if err != nil || len(continued) != 2*len(initial) || hongguoCatalogHasMore(second.hongguoCatalogSnapshot()) { + t.Fatalf("continuation lost history or pagination: %d, %v", len(continued), err) + } + for _, genre := range hongguoAppGenres { + offsets := calls[genre.key] + if len(offsets) != 2 || offsets[0] != 0 || offsets[1] != 18 { + t.Errorf("feed %s restarted instead of continuing: %v", genre.key, offsets) + } + } + for _, old := range initial { + found := false + for _, drama := range continued { + if drama.ID == old.ID { + found = true + if drama.OnlineDate != old.OnlineDate || drama.Heat != old.Heat || drama.Views != "0" { + t.Error("continuation lost primary sort fields") + } + } + } + if !found { + t.Errorf("lost cached drama %s", old.ID) + } + } +} + +func TestHongguoAppDetailAndCompatibleMediaMetadata(t *testing.T) { + detail := map[string]any{ + "series_id": "700001", "series_title": "详情测试", "episode_cnt": 2, + "video_list": []any{ + map[string]any{"vid": "800002", "vid_index": 2, "series_id": "700001"}, + map[string]any{"vid": "800001", "vid_index": 1, "series_id": "700001"}, + }, + } + result := map[string]any{"data": map[string]any{"video_data": detail}} + entry, err := parseHongguoAppDetail(result, "700001") + if err != nil || len(entry.Chapters) != 2 || entry.Chapters[0].VideoURL != "hongguo-cenc://800001" { + t.Fatalf("complete detail did not preserve episode order: %v", err) + } + if _, err := parseHongguoAppDetail(result, "700002"); err == nil { + t.Fatal("accepted another drama's episodes") + } + detail["episode_cnt"] = 3 + if _, err := parseHongguoAppDetail(result, "700001"); err == nil { + t.Fatal("accepted an incomplete episode list") + } + model := map[string]any{"video_list": []any{ + map[string]any{"main_url": "https://media.example.test/high.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "2160p"}}, + map[string]any{"main_url": "https://media.example.test/low.mp4", "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}}, + map[string]any{"main_url": "https://media.example.test/compatible.mp4", "video_meta": map[string]any{"codec_type": "hevc", "definition": "1080p"}}, + }} + media, err := selectHongguoAppMedia(model) + if err != nil || media.URL != "https://media.example.test/compatible.mp4" { + t.Fatalf("wrong compatible media choice: %v", err) + } +} diff --git a/internal/app/provider_hongguo_catalog.go b/internal/app/provider_hongguo_catalog.go new file mode 100644 index 0000000..52c05f4 --- /dev/null +++ b/internal/app/provider_hongguo_catalog.go @@ -0,0 +1,228 @@ +package app + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +var hongguoAppGenres = []struct { + key string + scene string + name string +}{ + {key: "short_play", scene: "default", name: "真人剧"}, + {key: "comic_series", scene: "comic_series", name: "漫剧"}, + {key: "ai_series", scene: "ai_series", name: "AI剧"}, +} + +type libraryMoreKey struct{} +type libraryUpdateKey struct{} +type libraryKnownKey struct{} + +func withKnownHongguoDramas(ctx context.Context, dramas []Drama) context.Context { + known := make(map[string]bool, len(dramas)) + for _, drama := range dramas { + known[drama.ID] = true + } + ctx = context.WithValue(ctx, libraryRowsKey{}, append([]Drama(nil), dramas...)) + return context.WithValue(ctx, libraryKnownKey{}, known) +} + +func (downloader *Downloader) fetchHongguoAppCatalog(ctx context.Context) ([]Drama, error) { + client := downloader.hongguoClient() + client.catalogMu.Lock() + defer client.catalogMu.Unlock() + more, _ := ctx.Value(libraryMoreKey{}).(bool) + update, _ := ctx.Value(libraryUpdateKey{}).(bool) + known, _ := ctx.Value(libraryKnownKey{}).(map[string]bool) + state := downloader.hongguoCatalogSnapshot() + pageLimit := downloader.cfg.MaxPagesPerSort + if pageLimit < 1 { + pageLimit = defaultConfig().MaxPagesPerSort + } + if pageLimit > 200 { + pageLimit = 200 + } + headLimit := min(pageLimit, 3) + roundLimit := pageLimit + if update { + roundLimit += headLimit + } + type feedScan struct { + cursor hongguoCatalogCursor + tail hongguoCatalogCursor + head bool + done bool + pages int + } + scans := make([]feedScan, len(hongguoAppGenres)) + for index, genre := range hongguoAppGenres { + cursor := state.Feeds[genre.key] + if !more && cursor.Initialized { + scans[index].head = true + scans[index].tail = cursor + } else { + scans[index].cursor = cursor + scans[index].done = cursor.Exhausted + } + } + seen := map[string]int{} + var dramas []Drama + var failures []error + for round := 0; round < roundLimit; round++ { + active := false + for index, genre := range hongguoAppGenres { + scan := &scans[index] + if scan.done || !scan.head && scan.pages >= pageLimit { + continue + } + if err := ctx.Err(); err != nil { + return dramas, err + } + active = true + cursor := scan.cursor + if time.Since(cursor.UpdatedAt) > 30*time.Minute { + cursor.SessionID = "" + } + payload := map[string]any{ + "req_scene": genre.scene, "offset": cursor.Offset, "limit": 18, + "req_type": "only_content", "need_selector_panel": false, "client_req_type": 3, + "session_id": cursor.SessionID, "filter_ids": "", + "select_items": map[string]any{ + "genre": []string{genre.key}, "sort": []string{"online_time"}, "gender": []string{}, + "category_dim_theme": []string{}, "category_dim_role": []string{}, "category_dim_epoch": []string{}, + "online_time": []string{}, "creation_status": []string{}, + }, + } + if cursor.Offset > 0 { + payload["client_req_type"] = 2 + } + var items []Drama + var nextCursor hongguoCatalogCursor + var pageErr error + newItems := 0 + for attempt := 0; attempt < 2; attempt++ { + result, err := downloader.hongguoAppRequest(ctx, http.MethodPost, "/reading/distribution/category/landpage/v/", nil, payload) + retryable := err == nil || cursor.SessionID != "" + items = nil + if err == nil { + items, nextCursor, err = parseHongguoCatalogPage(result, cursor, genre.name) + } + newItems = 0 + for _, drama := range items { + if !known[drama.ID] { + newItems++ + } + if position, exists := seen[drama.ID]; exists { + dramas[position] = mergeDramaMetadata(drama, dramas[position]) + } else { + seen[drama.ID] = len(dramas) + dramas = append(dramas, drama) + } + } + pageErr = err + if err == nil { + break + } + reportLibraryProgress(ctx, sourceHongguo, items, nil, false) + var backoff *requestBackoff + if attempt > 0 || !retryable || errors.As(err, &backoff) || ctx.Err() != nil { + break + } + payload["session_id"] = "" + } + if err := ctx.Err(); err != nil { + return dramas, err + } + if pageErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", genre.name, pageErr)) + scan.done = true + continue + } + cursor = nextCursor + scan.pages++ + scan.cursor = cursor + scan.done = cursor.Exhausted + checkpoint := cursor + if scan.head && newItems == 0 && !cursor.Exhausted { + scan.done = true + if scan.tail.Exhausted || scan.tail.Offset >= cursor.Offset { + checkpoint = scan.tail + } + } + if scan.head && (scan.done || scan.pages >= headLimit) { + scan.done = true + if update && !checkpoint.Exhausted { + + scan.cursor = checkpoint + scan.head, scan.done, scan.pages = false, false, 0 + } + } + client.mu.Lock() + client.state.Feeds[genre.key] = checkpoint + client.mu.Unlock() + reportLibraryProgress(ctx, sourceHongguo, items, nil, false) + } + if !active { + break + } + } + sort.SliceStable(dramas, func(left, right int) bool { return dramas[left].DisplayTitle() < dramas[right].DisplayTitle() }) + return dramas, errors.Join(failures...) +} + +func parseHongguoCatalogPage(result map[string]any, cursor hongguoCatalogCursor, category string) ([]Drama, hongguoCatalogCursor, error) { + data := nestedMap(result, "data") + rows, valid := data["video_data"].([]any) + if !valid { + return nil, cursor, errors.New("App 分类数据格式异常") + } + items := make([]Drama, 0, len(rows)) + ids := make([]string, 0, len(rows)) + seen := map[string]bool{} + for _, row := range rows { + drama := hongguoDramaFromAny(row, category) + if drama.ID != "" { + items = append(items, drama) + if !seen[drama.ID] { + seen[drama.ID] = true + ids = append(ids, drama.ID) + } + } + } + if len(rows) > 0 && len(items) == 0 { + return items, cursor, errors.New("App 分类未返回可识别的剧集") + } + next, parseErr := strconv.Atoi(mapString(data, "next_offset")) + hasMore, paginationOK := data["has_more"].(bool) + if !paginationOK || hasMore && parseErr != nil { + return items, cursor, errors.New("App 分页标记无效,已保留上次位置") + } + if parseErr != nil { + next = cursor.Offset + len(rows) + } + lastID, signature := "", "" + if len(items) > 0 { + lastID = items[len(items)-1].ID + sort.Strings(ids) + signature = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(ids, "\n")))) + } + if hasMore && (len(items) == 0 || next <= cursor.Offset || next > 1_000_000 || signature == cursor.PageSignature) { + return items, cursor, errors.New("App 分页未前进,已保留上次位置") + } + cursor.Exhausted = !hasMore + cursor.Initialized = true + cursor.Offset = next + cursor.SessionID = mapString(data, "session_id") + cursor.LastID = lastID + cursor.PageSignature = signature + cursor.UpdatedAt = time.Now() + return items, cursor, nil +} diff --git a/internal/app/provider_hongguo_catalog_test.go b/internal/app/provider_hongguo_catalog_test.go new file mode 100644 index 0000000..49a9ddb --- /dev/null +++ b/internal/app/provider_hongguo_catalog_test.go @@ -0,0 +1,210 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +type hongguoCatalogRequestFixture struct { + Offset int `json:"offset"` + Session string `json:"session_id"` + Scene string `json:"req_scene"` +} + +func hongguoCatalogRequestForTest(t *testing.T, request *http.Request) hongguoCatalogRequestFixture { + t.Helper() + var payload hongguoCatalogRequestFixture + if request.URL.Path != "/reading/distribution/category/landpage/v/" || json.NewDecoder(request.Body).Decode(&payload) != nil { + t.Errorf("unexpected catalog request: %s", request.URL.Path) + } + return payload +} + +func hongguoCatalogResponseForTest(request *http.Request, next any, more any, session string, ids ...string) *http.Response { + rows := make([]any, 0, len(ids)) + for _, id := range ids { + rows = append(rows, map[string]any{ + "series_id": id, "series_title": "文字目录样本 " + id, + "first_visible_time": "1773662280", "hot_score": "20", "series_play_cnt": "0", + }) + } + body, _ := json.Marshal(map[string]any{"data": map[string]any{ + "next_offset": next, "has_more": more, "session_id": session, "video_data": rows, + }}) + return rankingHTTPResponse(request, http.StatusOK, string(body)) +} + +func TestHongguoCatalogAllowsOverlappingPages(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(map[bool]string{false: "new cursor", true: "legacy cursor"}[legacy], func(t *testing.T) { + var offsets []int + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + payload := hongguoCatalogRequestForTest(t, request) + offsets = append(offsets, payload.Offset) + switch payload.Offset { + case 0: + return hongguoCatalogResponseForTest(request, 18, true, "fixture", "700001", "700002"), nil + case 18: + return hongguoCatalogResponseForTest(request, 36, true, "fixture", "700003", "700002"), nil + case 36: + return hongguoCatalogResponseForTest(request, 54, false, "fixture", "700004"), nil + default: + t.Errorf("skipped a page: %d", payload.Offset) + return rankingHTTPResponse(request, 400, ""), nil + } + }) + d.cfg.MaxPagesPerSort = 3 + for _, genre := range hongguoAppGenres[:2] { + d.hongguoClient().state.Feeds[genre.key] = hongguoCatalogCursor{Initialized: true, Exhausted: true} + } + wantOffsets, wantCount := []int{0, 18, 36}, 4 + if legacy { + d.hongguoClient().state.Feeds["ai_series"] = hongguoCatalogCursor{Offset: 18, LastID: "hongguo:700002", Initialized: true} + wantOffsets, wantCount = []int{18, 36}, 3 + } + items, err := d.fetchHongguoAppCatalog(context.WithValue(context.Background(), libraryMoreKey{}, true)) + if err != nil || len(items) != wantCount || !reflect.DeepEqual(offsets, wantOffsets) { + t.Fatalf("overlap stopped valid pagination: items=%d offsets=%v error=%v", len(items), offsets, err) + } + cursor := d.hongguoCatalogSnapshot().Feeds["ai_series"] + if cursor.Offset != 54 || !cursor.Exhausted { + t.Fatalf("wrong final checkpoint: %+v", cursor) + } + }) + } +} + +func TestHongguoCatalogRecoversStalledPageAfterRestart(t *testing.T) { + for _, test := range []struct { + name string + next any + more any + ids []string + }{ + {name: "unchanged offset", next: 18, more: true, ids: []string{"700003"}}, + {name: "regressed offset", next: 0, more: true, ids: []string{"700003"}}, + {name: "missing offset", more: true, ids: []string{"700003"}}, + {name: "missing continuation flag", next: 36, ids: []string{"700003"}}, + {name: "empty page", next: 36, more: true}, + {name: "same page", next: 36, more: true, ids: []string{"700001", "700002"}}, + {name: "same page reordered", next: 36, more: true, ids: []string{"700002", "700001"}}, + } { + t.Run(test.name, func(t *testing.T) { + var calls []hongguoCatalogRequestFixture + transport := func(request *http.Request) (*http.Response, error) { + payload := hongguoCatalogRequestForTest(t, request) + if payload.Scene != "ai_series" { + return hongguoCatalogResponseForTest(request, 0, false, ""), nil + } + calls = append(calls, payload) + switch len(calls) { + case 1: + return hongguoCatalogResponseForTest(request, 18, true, "expired-session", "700001", "700002"), nil + case 2: + return hongguoCatalogResponseForTest(request, test.next, test.more, "expired-session", test.ids...), nil + case 3: + return hongguoCatalogResponseForTest(request, 36, true, "new-session", "700004"), nil + default: + t.Error("retried more than once") + return rankingHTTPResponse(request, 400, ""), nil + } + } + first := rankingTestDownloader(t, transport) + first.cfg.MaxPagesPerSort = 1 + initial, err := first.fetchHongguoAppCatalog(context.Background()) + if err != nil || len(initial) != 2 { + t.Fatalf("initial batch failed: %v", err) + } + if err := writeLibraryCache(first.cfg.dataDirectory(), libraryCache{Dramas: initial, HongguoApp: first.hongguoCatalogSnapshot()}); err != nil { + t.Fatal(err) + } + cache, err := readLibraryCache(first.cfg.dataDirectory()) + if err != nil { + t.Fatal(err) + } + second := rankingTestDownloader(t, transport) + second.cfg.MaxPagesPerSort = 1 + second.restoreHongguoCatalog(cache.HongguoApp) + items, err := second.fetchHongguoAppCatalog(context.WithValue(context.Background(), libraryMoreKey{}, true)) + if err != nil || len(calls) != 3 { + t.Fatalf("did not recover at the saved position: calls=%+v error=%v", calls, err) + } + if calls[1].Offset != 18 || calls[1].Session != "expired-session" || calls[2].Offset != 18 || calls[2].Session != "" { + t.Fatalf("recovery skipped a page or reused the bad session: %+v", calls) + } + cursor := second.hongguoCatalogSnapshot().Feeds["ai_series"] + if cursor.Offset != 36 || cursor.SessionID != "new-session" || cursor.Exhausted { + t.Fatalf("recovery did not save the valid checkpoint: %+v", cursor) + } + ids := map[string]bool{} + for _, drama := range mergeSourceDramas(cache.Dramas, items, nil, sourceHongguo) { + ids[drama.ID] = true + if drama.OnlineDate != "2026-03-16" || drama.Heat != "20" || drama.Views != "0" { + t.Error("recovery lost primary sorting fields") + } + } + for _, id := range append([]string{"700001", "700002", "700004"}, test.ids...) { + if !ids["hongguo:"+id] { + t.Errorf("recovery discarded a valid item from an earlier attempt: %s", id) + } + } + }) + } +} + +func TestHongguoCatalogStopsPersistentStallWithoutLosingCheckpoint(t *testing.T) { + for _, session := range []string{"expired-session", ""} { + t.Run(map[bool]string{false: "with session", true: "without session"}[session == ""], func(t *testing.T) { + calls := map[string]int{} + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + payload := hongguoCatalogRequestForTest(t, request) + calls[payload.Scene]++ + if payload.Scene == "ai_series" { + if payload.Offset != 18 || calls[payload.Scene] > 2 { + t.Errorf("stall skipped pages or retried without a bound: %+v", payload) + } + return hongguoCatalogResponseForTest(request, 18, true, "bad-session", "700003"), nil + } + return hongguoCatalogResponseForTest(request, 18, false, "", "700004"), nil + }) + d.cfg.MaxPagesPerSort = 5 + saved := hongguoCatalogCursor{Offset: 18, SessionID: session, LastID: "hongguo:700002", Initialized: true, UpdatedAt: time.Now()} + d.hongguoClient().state.Feeds["ai_series"] = saved + items, err := d.fetchHongguoAppCatalog(context.WithValue(context.Background(), libraryMoreKey{}, true)) + if err == nil || !strings.Contains(err.Error(), "AI剧: App 分页未前进") || calls["ai_series"] != 2 || len(items) != 2 { + t.Fatalf("persistent failure did not preserve partial results: calls=%v items=%d error=%v", calls, len(items), err) + } + state := d.hongguoCatalogSnapshot() + if state.Feeds["ai_series"] != saved || !hongguoCatalogHasMore(state) { + t.Fatalf("persistent failure changed the saved position: %+v", state.Feeds["ai_series"]) + } + for _, genre := range hongguoAppGenres[:2] { + if !state.Feeds[genre.key].Exhausted { + t.Errorf("one stalled feed prevented %s from finishing", genre.name) + } + } + }) + } +} + +func TestHongguoCatalogDoesNotRetryAfterCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := 0 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls++ + cancel() + return nil, context.Canceled + }) + d.hongguoClient().state.Feeds[hongguoAppGenres[0].key] = hongguoCatalogCursor{SessionID: "fixture", UpdatedAt: time.Now()} + _, err := d.fetchHongguoAppCatalog(ctx) + if !errors.Is(err, context.Canceled) || calls != 1 { + t.Fatalf("cancelled catalog was retried: calls=%d error=%v", calls, err) + } +} diff --git a/internal/app/provider_hongguo_comment_sign.go b/internal/app/provider_hongguo_comment_sign.go new file mode 100644 index 0000000..c63d965 --- /dev/null +++ b/internal/app/provider_hongguo_comment_sign.go @@ -0,0 +1,205 @@ +package app + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "math/bits" + "net/http" + "strconv" + "time" +) + +type hongguoCommentKey struct{} +type hongguoCommentNonce [11]byte + +func newHongguoCommentNonce() (hongguoCommentNonce, error) { + var nonce hongguoCommentNonce + _, err := rand.Read(nonce[:]) + return nonce, err +} + +func signHongguoCommentRequest(request *http.Request, nonce hongguoCommentNonce, now time.Time) { + stamp := uint32(now.Unix()) + query := request.URL.RawQuery + head2, head3 := (nonce[0]%7+1)<<5, 0xe0|nonce[1]&15 + seed := make([]byte, 20) + hash := md5.Sum([]byte(query)) + copy(seed, hash[:4]) + copy(seed[12:16], []byte{0, 1, 7, 4}) + binary.BigEndian.PutUint32(seed[16:], stamp) + key := [8]byte{0x4a, 0, 0x16, head3, 0x47, 0x6c, 0, head2} + var box [256]byte + for i := range box { + box[i] = byte(i) + } + j := byte(0) + for i := range box { + j += box[i] + key[i&7] + box[i] = box[j] + } + j = 0 + for offset := range seed { + i := byte(offset + 1) + j += box[i] + box[i] = box[j] + seed[offset] ^= box[box[i]+box[j]] + } + for i := range seed { + seed[i] = bits.Reverse8(bits.RotateLeft8(seed[i], 4)^seed[(i+1)%len(seed)]) ^ 0xeb + } + request.Header.Set("X-Gorgon", hex.EncodeToString(append([]byte{0x84, 4, head2, head3, 0, 0}, seed...))) + request.Header.Set("X-Khronos", strconv.FormatUint(uint64(stamp), 10)) + request.Header.Set("X-SS-Req-Ticket", strconv.FormatInt(now.UnixMilli(), 10)) + request.Header.Set("X-SS-STUB", "") + request.Header.Set("X-Argus", hongguoCommentArgus(query, stamp, nonce)) + request.Header.Set("X-Ladon", hongguoCommentLadon(request.URL.Query().Get("aid"), stamp, nonce[2:6])) +} + +func hongguoCommentPad(data []byte) []byte { + padding := 16 - len(data)%16 + out := append([]byte(nil), data...) + for i := 0; i < padding; i++ { + out = append(out, byte(padding)) + } + return out +} + +func hongguoCommentLadon(aid string, timestamp uint32, prefix []byte) string { + hash := md5.Sum(append(append([]byte(nil), prefix...), aid...)) + digest := []byte(hex.EncodeToString(hash[:])) + left, right := binary.LittleEndian.Uint64(digest), binary.LittleEndian.Uint64(digest[8:]) + queue := []uint64{binary.LittleEndian.Uint64(digest[16:]), binary.LittleEndian.Uint64(digest[24:])} + var keys [34]uint64 + for i := range keys { + keys[i] = left + mixed := (bits.RotateLeft64(right, -8) + left) ^ uint64(i) + queue = append(queue, mixed) + left = mixed ^ bits.RotateLeft64(left, 3) + right, queue = queue[0], queue[1:] + } + data := hongguoCommentPad([]byte(strconv.FormatUint(uint64(timestamp), 10) + "-1611921764-3019")) + for offset := 0; offset < len(data); offset += 16 { + left, right := binary.LittleEndian.Uint64(data[offset:]), binary.LittleEndian.Uint64(data[offset+8:]) + for _, key := range keys { + right = key ^ (left + bits.RotateLeft64(right, -8)) + left = right ^ bits.RotateLeft64(left, 3) + } + binary.LittleEndian.PutUint64(data[offset:], left) + binary.LittleEndian.PutUint64(data[offset+8:], right) + } + return base64.StdEncoding.EncodeToString(append(append([]byte(nil), prefix...), data...)) +} + +func hongguoCommentArgus(query string, timestamp uint32, nonce hongguoCommentNonce) string { + key, _ := hex.DecodeString("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c") + var message []byte + varint := func(field, value uint64) { + message = binary.AppendUvarint(message, field*8) + message = binary.AppendUvarint(message, value) + } + bytesField := func(field uint64, value []byte) { + message = binary.AppendUvarint(message, field*8+2) + message = binary.AppendUvarint(message, uint64(len(value))) + message = append(message, value...) + } + varint(1, 0x20200929*2) + varint(2, 2) + varint(3, uint64(binary.LittleEndian.Uint32(nonce[6:10]))) + bytesField(4, []byte("3019")) + bytesField(6, []byte("1611921764")) + bytesField(7, []byte("6.8.1.32")) + bytesField(8, []byte("v04.07.01-ml-android")) + varint(9, 135135744) + bytesField(10, make([]byte, 8)) + varint(12, uint64(timestamp)*2) + bodyHash, queryHash := hongguoSM3(make([]byte, 16)), hongguoSM3([]byte(query)) + bytesField(13, bodyHash[:6]) + bytesField(14, queryHash[:6]) + nested := []byte{8, 2 + (nonce[10]%49)*2} + for _, field := range []uint64{2, 3, 4} { + nested = binary.AppendUvarint(nested, field*8) + nested = binary.AppendUvarint(nested, 1388734) + } + bytesField(15, nested) + bytesField(20, []byte("none")) + varint(21, 738) + data := hongguoCommentPad(message) + seed := append(append(append([]byte(nil), key...), 0x3c, 0xcc, 0x56, 0x7b), key...) + derived := hongguoSM3(seed) + var rounds [72]uint64 + for i := 0; i < 4; i++ { + rounds[i] = binary.LittleEndian.Uint64(derived[i*8:]) + } + for i := 4; i < len(rounds); i++ { + mixed := bits.RotateLeft64(rounds[i-1], -3) ^ rounds[i-3] + mixed ^= bits.RotateLeft64(mixed, -1) + rounds[i] = ^rounds[i-4] ^ mixed ^ (uint64(0x3dc94c3a046d678b)>>uint((i-4)%62))&1 ^ 3 + } + for offset := 0; offset < len(data); offset += 16 { + left, right := binary.LittleEndian.Uint64(data[offset:]), binary.LittleEndian.Uint64(data[offset+8:]) + for _, round := range rounds { + left, right = right, left^(bits.RotateLeft64(right, 1)&bits.RotateLeft64(right, 8))^bits.RotateLeft64(right, 2)^round + } + binary.LittleEndian.PutUint64(data[offset:], left) + binary.LittleEndian.PutUint64(data[offset+8:], right) + } + xor := []byte{0xd0, 0x4f, 0xfd, 0xff, 0xd0, 0x4f, 0xfd, 0xff} + for i := range data { + data[i] ^= xor[i%8] + } + data = append(xor, data...) + for left, right := 0, len(data)-1; left < right; left, right = left+1, right-1 { + data[left], data[right] = data[right], data[left] + } + container := append([]byte{0xa6, 0xe7, 0x83, 0xee, 0x70, 0x01, 0x10, 0x09, 0x18}, data...) + container = hongguoCommentPad(append(container, 0x56, 0x7b)) + aesKey, iv := md5.Sum(key[:16]), md5.Sum(key[16:]) + block, _ := aes.NewCipher(aesKey[:]) + cipher.NewCBCEncrypter(block, iv[:]).CryptBlocks(container, container) + return base64.StdEncoding.EncodeToString(append([]byte{0x3c, 0xcc}, container...)) +} + +func hongguoSM3(input []byte) [32]byte { + length := uint64(len(input)) * 8 + data := append(append([]byte(nil), input...), 0x80) + for len(data)%64 != 56 { + data = append(data, 0) + } + data = binary.BigEndian.AppendUint64(data, length) + state := [8]uint32{0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600, 0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e} + for offset := 0; offset < len(data); offset += 64 { + var words [68]uint32 + for i := 0; i < 16; i++ { + words[i] = binary.BigEndian.Uint32(data[offset+i*4:]) + } + for i := 16; i < len(words); i++ { + v := words[i-16] ^ words[i-9] ^ bits.RotateLeft32(words[i-3], 15) + words[i] = v ^ bits.RotateLeft32(v, 15) ^ bits.RotateLeft32(v, 23) ^ bits.RotateLeft32(words[i-13], 7) ^ words[i-6] + } + a, b, c, d, e, f, g, h := state[0], state[1], state[2], state[3], state[4], state[5], state[6], state[7] + for i := 0; i < 64; i++ { + t, ff, gg := uint32(0x79cc4519), a^b^c, e^f^g + if i >= 16 { + t, ff, gg = 0x7a879d8a, (a&b)|(a&c)|(b&c), (e&f)|(^e&g) + } + ss1 := bits.RotateLeft32(bits.RotateLeft32(a, 12)+e+bits.RotateLeft32(t, i), 7) + tt1 := ff + d + (ss1 ^ bits.RotateLeft32(a, 12)) + (words[i] ^ words[i+4]) + tt2 := gg + h + ss1 + words[i] + a, b, c, d = tt1, a, bits.RotateLeft32(b, 9), c + e, f, g, h = tt2^bits.RotateLeft32(tt2, 9)^bits.RotateLeft32(tt2, 17), e, bits.RotateLeft32(f, 19), g + } + for i, value := range [8]uint32{a, b, c, d, e, f, g, h} { + state[i] ^= value + } + } + var result [32]byte + for i, value := range state { + binary.BigEndian.PutUint32(result[i*4:], value) + } + return result +} diff --git a/internal/app/provider_hongguo_comment_sign_test.go b/internal/app/provider_hongguo_comment_sign_test.go new file mode 100644 index 0000000..c929dee --- /dev/null +++ b/internal/app/provider_hongguo_comment_sign_test.go @@ -0,0 +1,35 @@ +package app + +import ( + "encoding/hex" + "net/http/httptest" + "testing" + "time" +) + +func TestHongguoCommentSignatureVector(t *testing.T) { + request := httptest.NewRequest("POST", "https://api5-normal-sinfonlineb.fqnovel.com/novel/commentapi/comment/list/700002/v1/?aid=8662&device_id=700003", nil) + nonce := hongguoCommentNonce{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} + signHongguoCommentRequest(request, nonce, time.Unix(1773662280, 0)) + expected := map[string]string{ + "X-Argus": "PMxPj+JImxTKEfQuAsVmWpqWbk6ptNvvFsIJbNFMxOQxhVihWhCZbh3JphV/tvUG7ABmRtDIxS2tVq7M9EF+LUTvnjlIF4oXQZYJsihMmASmjnLwRmXqU6nLasLwya7gsESxF+40XOiFB33K2lzJU0NY2CgrA2G58uGjG0TG0GtLwpE3RXTEMO0GUwaKqaCFx4yb01vcH+N+obpbQAuycKGF", + "X-Gorgon": "840440e20000e5534737ff447dece3898d8546b77f54303100d8", + "X-Khronos": "1773662280", + "X-Ladon": "AwQFBllQi0qfAHVP9XxZtrYsmLNnfrwAzFAlv0Cqi9J16ndL", + } + for key, want := range expected { + if got := request.Header.Get(key); got != want { + t.Errorf("%s: got %q, want %q", key, got, want) + } + } + if request.Header.Get("X-SS-STUB") != "" { + t.Fatal("comment protocol requires an empty body stub") + } +} + +func TestHongguoSM3KnownVector(t *testing.T) { + hash := hongguoSM3([]byte("abc")) + if got := hex.EncodeToString(hash[:]); got != "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0" { + t.Fatal(got) + } +} diff --git a/internal/app/provider_hongguo_detail.go b/internal/app/provider_hongguo_detail.go new file mode 100644 index 0000000..616f0a1 --- /dev/null +++ b/internal/app/provider_hongguo_detail.go @@ -0,0 +1,127 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "time" +) + +type hongguoDetailEntry struct { + Drama Drama + Chapters []Chapter + ExpiresAt time.Time +} + +type hongguoDetailCall struct { + done chan struct{} + entry hongguoDetailEntry + err error +} + +func (downloader *Downloader) hongguoAppDetail(ctx context.Context, seriesID string) (hongguoDetailEntry, error) { + if !hongguoNumericID.MatchString(seriesID) { + return hongguoDetailEntry{}, errors.New("红果剧集 ID 无效") + } + client := downloader.hongguoClient() + client.mu.Lock() + if cached, found := client.details[seriesID]; found && time.Now().Before(cached.ExpiresAt) { + client.mu.Unlock() + return cached, nil + } + if pending := client.pending[seriesID]; pending != nil { + client.mu.Unlock() + select { + case <-ctx.Done(): + return hongguoDetailEntry{}, ctx.Err() + case <-pending.done: + if (errors.Is(pending.err, context.Canceled) || errors.Is(pending.err, context.DeadlineExceeded)) && ctx.Err() == nil { + return downloader.hongguoAppDetail(ctx, seriesID) + } + return pending.entry, pending.err + } + } + pending := &hongguoDetailCall{done: make(chan struct{})} + client.pending[seriesID] = pending + client.mu.Unlock() + result, err := downloader.hongguoAppRequest(ctx, http.MethodPost, "/novel/player/video_detail/v1/", nil, map[string]any{"series_id": seriesID}) + var entry hongguoDetailEntry + if err == nil { + entry, err = parseHongguoAppDetail(result, seriesID) + } + client.mu.Lock() + if err == nil { + if len(client.details) >= 128 { + client.details = map[string]hongguoDetailEntry{} + } + entry.ExpiresAt = time.Now().Add(5 * time.Minute) + client.details[seriesID] = entry + } + pending.entry, pending.err = entry, err + delete(client.pending, seriesID) + close(pending.done) + client.mu.Unlock() + return entry, err +} + +func parseHongguoAppDetail(result map[string]any, seriesID string) (hongguoDetailEntry, error) { + detail := nestedMap(result, "data", "video_data") + returnedID := mapString(detail, "series_id_str", "series_id") + if returnedID != seriesID { + return hongguoDetailEntry{}, errors.New("红果 App 未返回所请求的剧集") + } + entry := hongguoDetailEntry{Drama: hongguoDramaFromAny(detail, "短剧")} + seen := map[string]bool{} + episodes := map[int]bool{} + for _, row := range anyList(detail["video_list"]) { + video, _ := row.(map[string]any) + videoID := mapString(video, "vid") + index, err := strconv.Atoi(mapString(video, "vid_index")) + if err != nil || index < 1 || !hongguoNumericID.MatchString(videoID) { + return entry, errors.New("红果 App 分集编号或视频 ID 无效") + } + if identifier := mapString(video, "series_id"); identifier != "" && identifier != seriesID { + return entry, errors.New("红果 App 返回了其他剧集的分集") + } + if seen[videoID] || episodes[index] { + return entry, errors.New("红果 App 返回了重复分集") + } + seen[videoID], episodes[index] = true, true + entry.Chapters = append(entry.Chapters, Chapter{ + ID: providerChapterID(sourceHongguo, seriesID, videoID), Source: sourceHongguo, + Title: fmt.Sprintf("第%d集", index), VideoURL: "hongguo-cenc://" + videoID, CurrentEpisode: rawEpisode(index), + }) + } + sort.Slice(entry.Chapters, func(left, right int) bool { + leftIndex, _ := strconv.Atoi(entry.Chapters[left].EpisodeString(left + 1)) + rightIndex, _ := strconv.Atoi(entry.Chapters[right].EpisodeString(right + 1)) + return leftIndex < rightIndex + }) + total, _ := strconv.Atoi(mapString(detail, "episode_cnt")) + if len(entry.Chapters) == 0 || total > len(entry.Chapters) { + return entry, errors.New("红果 App 未返回完整分集,已尝试其他详情入口") + } + for index, chapter := range entry.Chapters { + if chapter.EpisodeString(index+1) != strconv.Itoa(index+1) { + return entry, errors.New("红果 App 分集列表不连续") + } + } + return entry, nil +} + +func (downloader *Downloader) hongguoCachedDrama(drama Drama) Drama { + source, seriesID, valid := splitProviderDramaID(drama.ID) + if !valid || source != sourceHongguo { + return drama + } + client := downloader.hongguoClient() + client.mu.Lock() + defer client.mu.Unlock() + if cached, found := client.details[seriesID]; found && time.Now().Before(cached.ExpiresAt) { + return mergeDramaMetadata(cached.Drama, drama) + } + return drama +} diff --git a/internal/app/provider_hongguo_native_media.go b/internal/app/provider_hongguo_native_media.go new file mode 100644 index 0000000..120bf47 --- /dev/null +++ b/internal/app/provider_hongguo_native_media.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +func (downloader *Downloader) resolveHongguoAppMedia(ctx context.Context, videoID string) (providerMedia, error) { + if !hongguoNumericID.MatchString(videoID) { + return providerMedia{}, errors.New("红果视频 ID 无效") + } + payload := map[string]any{ + "video_id": videoID, "content_type": 1, + "biz_param": map[string]any{"need_all_video_definition": true, "video_platform": 3}, + } + result, err := downloader.hongguoAppRequest(ctx, http.MethodPost, "/novel/player/video_model/v1/", nil, payload) + if err != nil { + return providerMedia{}, err + } + data := nestedMap(result, "data") + model, _ := data["video_model"].(map[string]any) + if encoded, ok := data["video_model"].(string); ok { + decoder := json.NewDecoder(strings.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&model); err != nil { + return providerMedia{}, errors.New("红果 App 播放信息格式异常") + } + } + return selectHongguoAppMedia(model) +} + +func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { + variants := anyList(model["video_list"]) + if rows, ok := model["video_list"].(map[string]any); ok && len(variants) == 0 { + keys := make([]string, 0, len(rows)) + for key := range rows { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + variants = append(variants, rows[key]) + } + } + duration, _ := strconv.ParseFloat(mapString(model, "video_duration", "duration"), 64) + var selected providerMedia + bestQuality := -1 + var keyErr error + choices := make(map[int]providerMedia) + scores := make(map[int]int) + for _, row := range variants { + variant, _ := row.(map[string]any) + meta := nestedMap(variant, "video_meta") + codec := strings.ToLower(mapString(meta, "codec_type")) + if codec == "bytevc2" || strings.Contains(strings.ToLower(mapString(variant, "gear_des_key")), "bytevc2") { + continue + } + address := mapString(variant, "main_url") + if len(address) > 8192 || !isProviderHTTPMediaURL(address) { + continue + } + media := providerMedia{URL: address, Referer: "https://novel.snssdk.com/", Duration: time.Duration(duration * float64(time.Second))} + encryption := nestedMap(variant, "encrypt_info") + spade := mapString(encryption, "spade_a") + if spade != "" || encryption["encrypt"] == true || mapString(encryption, "encryption_method") == "cenc-aes-ctr" { + var err error + media.CENCKey, err = hongguoContentKey(spade) + if err != nil { + keyErr = err + continue + } + } + height, _ := strconv.Atoi(mapString(meta, "vheight")) + if definition, err := strconv.Atoi(hongguoQualityNumber.FindString(mapString(meta, "definition"))); err == nil && definition > 0 { + height = definition + } else if width, _ := strconv.Atoi(mapString(meta, "vwidth")); width > 0 && (height == 0 || width < height) { + height = width + } + media.Quality = height + quality := height * 10 + if codec == "h264" || codec == "avc1" { + quality++ + } + if previous, exists := scores[height]; !exists || quality > previous { + choices[height], scores[height] = media, quality + } + if selected.URL == "" || quality > bestQuality { + selected, bestQuality = media, quality + } + } + if selected.URL != "" { + for _, media := range choices { + selected.Variants = append(selected.Variants, media) + } + sort.Slice(selected.Variants, func(i, j int) bool { return selected.Variants[i].Quality > selected.Variants[j].Quality }) + return selected, nil + } + if keyErr != nil { + return providerMedia{}, fmt.Errorf("红果 App 媒体密钥不可用: %w", keyErr) + } + return providerMedia{}, errors.New("红果 App 未返回兼容的媒体,已跳过不支持的编码") +} diff --git a/internal/app/provider_hongguo_playback.go b/internal/app/provider_hongguo_playback.go new file mode 100644 index 0000000..22bfc83 --- /dev/null +++ b/internal/app/provider_hongguo_playback.go @@ -0,0 +1,201 @@ +package app + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/bits" + "net/url" + "regexp" + "strconv" + "strings" +) + +const hongguoPlaybackAPI = "https://djapi.999888456.xyz/api/hongguo/play" + +var hongguoNumericID = regexp.MustCompile(`^[0-9]{1,32}$`) +var hongguoQualityNumber = regexp.MustCompile(`[0-9]+`) + +type hongguoPlaybackReference struct { + ContentType int `json:"content_type"` + FromVideoID string `json:"from_video_id"` + SeriesID string `json:"series_id"` + VideoID string `json:"vid"` + VideoPlatform int `json:"video_platform"` +} + +type hongguoPlaybackResponse struct { + Parse json.RawMessage `json:"parse"` + JX json.RawMessage `json:"jx"` + KeyURLs []struct { + Name string `json:"name"` + URL string `json:"src"` + KeyID string `json:"kid"` + SpadeA string `json:"spade_a"` + } `json:"key_urls"` +} + +func (d *Downloader) resolveHongguoPlaybackAPI(ctx context.Context, seriesID, videoID string) (providerMedia, error) { + if !hongguoNumericID.MatchString(seriesID) || !hongguoNumericID.MatchString(videoID) { + return providerMedia{}, errors.New("红果播放请求缺少有效的剧集 ID") + } + reference, err := json.Marshal(hongguoPlaybackReference{ContentType: 1004, SeriesID: seriesID, VideoID: videoID, VideoPlatform: 3}) + if err != nil { + return providerMedia{}, err + } + query := url.Values{"id": {base64.StdEncoding.EncodeToString(reference)}} + body, err := d.fetchProviderText(ctx, hongguoPlaybackAPI+"?"+query.Encode(), hongguoBaseURL+"/") + if err != nil { + return providerMedia{}, err + } + decoded, err := decodeHongguoPlaybackResponse(body) + if err != nil { + return providerMedia{}, err + } + var response hongguoPlaybackResponse + if err := json.Unmarshal(decoded, &response); err != nil { + return providerMedia{}, errors.New("红果备用播放接口返回了无效数据") + } + for _, flag := range []json.RawMessage{response.Parse, response.JX} { + switch strings.TrimSpace(string(flag)) { + case "", "null", "false", "0", `"0"`, `""`: + default: + return providerMedia{}, errors.New("红果备用播放接口没有返回直接媒体地址") + } + } + var selected providerMedia + bestQuality := -1 + var keyErr error + var variants []providerMedia + for _, option := range response.KeyURLs { + mediaURL := strings.TrimSpace(option.URL) + if len(mediaURL) > 8192 || !isProviderHTTPMediaURL(mediaURL) { + continue + } + keyID, err := hex.DecodeString(strings.TrimSpace(option.KeyID)) + if err != nil || len(keyID) != aes.BlockSize { + keyErr = errors.New("红果媒体密钥标识无效") + continue + } + key, err := hongguoContentKey(option.SpadeA) + if err != nil { + keyErr = err + continue + } + quality, _ := strconv.Atoi(hongguoQualityNumber.FindString(option.Name)) + media := providerMedia{URL: mediaURL, Referer: "https://novel.snssdk.com/", CENCKey: key, Quality: quality} + variants = append(variants, media) + if selected.URL == "" || quality > bestQuality { + selected = media + bestQuality = quality + } + } + if selected.URL != "" { + selected.Variants = variants + return selected, nil + } + if keyErr != nil { + return providerMedia{}, keyErr + } + return providerMedia{}, errors.New("红果备用播放接口未返回该集可用的媒体和密钥,请稍后重试或确认该集是否仍可访问") +} + +func decodeHongguoPlaybackResponse(body string) ([]byte, error) { + text := strings.TrimSpace(body) + if !strings.HasPrefix(text, "v2.") { + return []byte(text), nil + } + parts := strings.SplitN(text, ".", 3) + if len(parts) != 3 || len(parts[1]) <= 4 || len(parts[1]) > 1028 { + return nil, errors.New("红果备用接口响应密钥无效") + } + encoded, err := hex.DecodeString(parts[1][4:]) + if err != nil || len(encoded) < 32 { + return nil, errors.New("红果备用接口响应密钥无效") + } + mask := [...]byte{104, 64, 70, 166, 190, 168, 143, 130, 225, 254, 251, 217, 196, 34, 45, 60, 29, 20, 103, 105} + material := make([]byte, len(encoded)) + for index, current := range encoded { + previous := byte(109) + if index > 0 { + previous = encoded[index-1] + } + slot := index % len(mask) + salt := mask[slot] ^ byte(90+13*slot) ^ 85 + shifted := byte(int(current) + 215 - 11*index) + material[index] = previous ^ salt ^ bits.RotateLeft8(shifted, 3) + } + ciphertext, err := decodeHongguoBase64(parts[2]) + if err != nil || len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return nil, errors.New("红果备用接口加密响应无效") + } + block, err := aes.NewCipher(material[:16]) + if err != nil { + return nil, err + } + plain := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, material[16:32]).CryptBlocks(plain, ciphertext) + unpadded, err := pkcs7Unpad(plain, aes.BlockSize) + if err != nil { + return nil, errors.New("红果备用接口响应解密失败") + } + return unpadded, nil +} + +func decodeHongguoBase64(value string) ([]byte, error) { + value = strings.TrimSpace(value) + decoded, err := base64.StdEncoding.Strict().DecodeString(value) + if err == nil { + return decoded, nil + } + return base64.RawStdEncoding.Strict().DecodeString(value) +} + +func hongguoContentKey(value string) ([]byte, error) { + if len(value) > 1024 { + return nil, errors.New("红果媒体密钥数据过长") + } + raw, err := decodeHongguoBase64(value) + if err != nil || len(raw) < 3 { + return nil, errors.New("红果媒体密钥编码无效") + } + tagLength := int(raw[0]^raw[1]^raw[2]) - 48 + contentLength := len(raw) - tagLength - 1 + if tagLength < 1 || contentLength < 33 || contentLength >= len(raw) { + return nil, errors.New("红果媒体密钥结构无效") + } + seed := raw[len(raw)-tagLength-2] ^ raw[len(raw)-tagLength-1] + tag := make([]byte, tagLength) + for index := range tag { + tag[index] = raw[len(raw)-tagLength+index] ^ seed + } + if string(tag) == "app_v2" || string(tag) == "web_v2" { + return nil, errors.New("红果媒体密钥版本暂不支持") + } + decoded := make([]byte, contentLength) + previousEven, previousOdd := byte(250), byte(85) + for index, current := range raw[1 : 1+contentLength] { + previous := previousEven + if index%2 == 0 { + previousEven = current + } else { + previous = previousOdd + previousOdd = current + } + decoded[index] = byte(int(previous^current) - 21 - bits.OnesCount(uint(index))) + } + padding, err := strconv.ParseUint(string(decoded[:1]), 36, 8) + if err != nil || contentLength-int(padding)-1 != 32 { + return nil, errors.New("红果媒体密钥内容无效") + } + key, err := hex.DecodeString(string(decoded[1:33])) + if err != nil || len(key) != aes.BlockSize { + return nil, fmt.Errorf("红果媒体密钥不是有效的 AES-128 密钥") + } + return key, nil +} diff --git a/internal/app/provider_hongguo_recommendations.go b/internal/app/provider_hongguo_recommendations.go new file mode 100644 index 0000000..079c7b8 --- /dev/null +++ b/internal/app/provider_hongguo_recommendations.go @@ -0,0 +1,110 @@ +package app + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" +) + +type hongguoRecommendationQuery struct { + Genre string `json:"genre"` + Offset int `json:"offset"` + SessionID string `json:"sessionId"` + Seen []string `json:"seen"` +} + +type hongguoRecommendationPage struct { + Dramas []Drama `json:"data"` + NextOffset int `json:"nextOffset"` + SessionID string `json:"sessionId"` + HasMore bool `json:"hasMore"` + Saved bool `json:"saved"` +} + +func (query hongguoRecommendationQuery) validate() error { + known := false + for _, genre := range hongguoAppGenres { + known = known || query.Genre == genre.key + } + if !known || query.Offset < 0 || query.Offset > 1_000_000 || len(query.SessionID) > 4096 || strings.ContainsAny(query.SessionID, "\r\n\x00") || len(query.Seen) > 540 { + return errors.New("推荐分类或分页参数无效,请重新获取") + } + for _, id := range query.Seen { + if !hongguoNumericID.MatchString(strings.TrimPrefix(id, "hongguo:")) { + return errors.New("推荐分页含无效剧集 ID") + } + } + return nil +} + +func (downloader *Downloader) fetchHongguoRecommendations(ctx context.Context, query hongguoRecommendationQuery) (hongguoRecommendationPage, error) { + page := hongguoRecommendationPage{Dramas: []Drama{}} + if err := query.validate(); err != nil { + return page, err + } + scene, category := "", "" + for _, genre := range hongguoAppGenres { + if genre.key == query.Genre { + scene, category = genre.scene, genre.name + } + } + seen := make(map[string]bool, len(query.Seen)) + filterIDs := make([]string, 0, len(query.Seen)) + for _, id := range query.Seen { + id = strings.TrimPrefix(id, "hongguo:") + if !seen[id] { + filterIDs = append(filterIDs, id) + seen[id] = true + } + } + payload := map[string]any{ + "req_scene": scene, "offset": query.Offset, "limit": 18, + "req_type": "only_content", "need_selector_panel": false, "client_req_type": 3, + "session_id": query.SessionID, "filter_ids": strings.Join(filterIDs, ","), + "select_items": map[string]any{ + "genre": []string{query.Genre}, "sort": []string{}, "gender": []string{}, + "category_dim_theme": []string{}, "category_dim_role": []string{}, "category_dim_epoch": []string{}, + "online_time": []string{}, "creation_status": []string{}, + }, + } + if query.Offset > 0 { + payload["client_req_type"] = 2 + } + result, err := downloader.hongguoAppRequest(ctx, http.MethodPost, "/reading/distribution/category/landpage/v/", nil, payload) + if err != nil { + return page, err + } + data := nestedMap(result, "data") + rows, valid := data["video_data"].([]any) + hasMore, paginationOK := data["has_more"].(bool) + next, parseErr := strconv.Atoi(mapString(data, "next_offset")) + if !valid || !paginationOK || hasMore && parseErr != nil { + return page, errors.New("红果推荐数据格式异常,已保留上次位置") + } + validRows := 0 + for _, row := range rows { + drama := hongguoDramaFromAny(row, category) + if drama.ID != "" { + validRows++ + } + id := strings.TrimPrefix(drama.ID, "hongguo:") + if drama.ID == "" || seen[id] { + continue + } + seen[id] = true + page.Dramas = append(page.Dramas, drama) + } + if len(rows) > 0 && validRows == 0 { + return hongguoRecommendationPage{}, errors.New("红果推荐未返回可识别的剧集") + } + if hasMore && (next <= query.Offset || next > 1_000_000 || len(page.Dramas) == 0) { + return hongguoRecommendationPage{}, errors.New("红果推荐分页未前进,可重试或重新获取") + } + page.NextOffset, page.HasMore, page.SessionID = next, hasMore, mapString(data, "session_id") + if len(page.SessionID) > 4096 || strings.ContainsAny(page.SessionID, "\r\n\x00") { + return hongguoRecommendationPage{}, errors.New("红果推荐分页标记无效") + } + return page, ctx.Err() +} diff --git a/internal/app/provider_hongguo_recommendations_test.go b/internal/app/provider_hongguo_recommendations_test.go new file mode 100644 index 0000000..2994882 --- /dev/null +++ b/internal/app/provider_hongguo_recommendations_test.go @@ -0,0 +1,164 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func recommendationFixture(ids []string, next int, more bool) string { + rows := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + rows = append(rows, map[string]any{"series_id": id, "title": "测试推荐 " + id, "hot_score_data": map[string]any{"score": 20}}) + } + body, _ := json.Marshal(map[string]any{"data": map[string]any{"video_data": rows, "has_more": more, "next_offset": next, "session_id": "anonymous-page"}}) + return string(body) +} + +func TestHongguoRecommendationsKeepUpstreamOrderAndIndependentCursor(t *testing.T) { + calls := 0 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/reading/distribution/category/landpage/v/" || request.Method != http.MethodPost { + t.Fatal("unexpected recommendation endpoint") + } + var query struct { + Offset int `json:"offset"` + Session string `json:"session_id"` + Filter string `json:"filter_ids"` + Type int `json:"client_req_type"` + Select map[string][]string `json:"select_items"` + } + if err := json.NewDecoder(request.Body).Decode(&query); err != nil { + t.Fatal(err) + } + if values, ok := query.Select["sort"]; !ok || values == nil || len(values) != 0 { + t.Fatal("recommendations must use the upstream default order") + } + calls++ + if calls == 1 { + if query.Offset != 0 || query.Type != 3 || query.Session != "" { + t.Fatal("invalid first-page request") + } + return rankingHTTPResponse(request, 200, recommendationFixture([]string{"3", "1", "3"}, 18, true)), nil + } + if query.Offset != 18 || query.Type != 2 || query.Session != "anonymous-page" || query.Filter != "3,1" { + t.Fatal("cursor and exclusion IDs were not forwarded") + } + return rankingHTTPResponse(request, 200, recommendationFixture([]string{"1", "8", "5", "8"}, 36, false)), nil + }) + client := d.hongguoClient() + client.state.Feeds["short_play"] = hongguoCatalogCursor{Offset: 90, SessionID: "catalog-session", Initialized: true} + before := d.hongguoCatalogSnapshot() + first, err := d.fetchHongguoRecommendations(context.Background(), hongguoRecommendationQuery{Genre: "short_play"}) + if err != nil { + t.Fatal(err) + } + if len(first.Dramas) != 2 || first.Dramas[0].ID != "hongguo:3" || first.Dramas[1].ID != "hongguo:1" || first.Dramas[0].Heat != "20" { + t.Fatal("first page order, duplicate removal, or direct metadata incorrect") + } + second, err := d.fetchHongguoRecommendations(context.Background(), hongguoRecommendationQuery{Genre: "short_play", Offset: first.NextOffset, SessionID: first.SessionID, Seen: []string{"hongguo:3", "hongguo:1"}}) + if err != nil { + t.Fatal(err) + } + if len(second.Dramas) != 2 || second.Dramas[0].ID != "hongguo:8" || second.Dramas[1].ID != "hongguo:5" || second.HasMore { + t.Fatal("cross-page duplicates, order, or end marker incorrect") + } + if !reflect.DeepEqual(before, d.hongguoCatalogSnapshot()) { + t.Fatal("recommendations modified the catalog cursor") + } +} + +func TestHongguoRecommendationGenresAndBadPagination(t *testing.T) { + for _, genre := range hongguoAppGenres { + t.Run(genre.key, func(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + var payload map[string]any + json.NewDecoder(request.Body).Decode(&payload) + if payload["req_scene"] != genre.scene || nestedMap(payload, "select_items")["genre"].([]any)[0] != genre.key { + t.Fatal("incorrect genre scene") + } + return rankingHTTPResponse(request, 200, recommendationFixture([]string{"7"}, 18, true)), nil + }) + page, err := d.fetchHongguoRecommendations(context.Background(), hongguoRecommendationQuery{Genre: genre.key}) + if err != nil || len(page.Dramas) != 1 { + t.Fatalf("%v", err) + } + }) + } + for name, body := range map[string]string{ + "unchanged offset": recommendationFixture([]string{"9"}, 18, true), + "empty page": recommendationFixture(nil, 36, true), + "only duplicates": recommendationFixture([]string{"1"}, 36, true), + "invalid id": recommendationFixture([]string{"other:2"}, 36, false), + "missing marker": `{"data":{"video_data":[],"next_offset":36}}`, + "invalid marker": `{"data":{"video_data":[],"next_offset":"broken","has_more":true}}`, + } { + t.Run(name, func(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + return rankingHTTPResponse(request, 200, body), nil + }) + _, err := d.fetchHongguoRecommendations(context.Background(), hongguoRecommendationQuery{Genre: "short_play", Offset: 18, Seen: []string{"hongguo:1"}}) + if err == nil { + t.Fatal("invalid pagination must not advance") + } + }) + } +} + +func TestRecommendationAPIValidationAndCancellation(t *testing.T) { + calls := 0 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { calls++; return nil, context.Canceled }) + app := &UIApp{downloader: d, cfg: d.cfg} + for _, body := range []string{`{"genre":"unknown"}`, `{"genre":"short_play","offset":-1}`, `{"genre":"short_play","seen":["huangdou:1"]}`, `{"genre":"short_play"} {}`, `null`, fmt.Sprintf(`{"genre":"short_play","sessionId":%q}`, strings.Repeat("x", 4097))} { + writer := httptest.NewRecorder() + app.routes().ServeHTTP(writer, httptest.NewRequest(http.MethodPost, "/api/ui/recommendations", strings.NewReader(body))) + if writer.Code != http.StatusBadRequest { + t.Fatalf("invalid body accepted: %d", writer.Code) + } + } + if calls != 0 { + t.Fatal("bad input reached upstream") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := d.fetchHongguoRecommendations(ctx, hongguoRecommendationQuery{Genre: "short_play"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation not propagated: %v", err) + } + if len(app.dramas) != 0 || app.libraryRevision != 0 { + t.Fatal("canceled request changed library") + } +} + +func TestRecommendationAPIPersistsNewDramasAndKeepsKnownMetadata(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + return rankingHTTPResponse(request, 200, recommendationFixture([]string{"2", "1"}, 18, true)), nil + }) + app := &UIApp{downloader: d, cfg: d.cfg, metadataClosed: true, dramas: []Drama{{ID: "hongguo:1", Source: sourceHongguo, Title: "旧标题", Views: "0", OnlineDate: "2026-09-01"}}} + writer := httptest.NewRecorder() + app.routes().ServeHTTP(writer, httptest.NewRequest(http.MethodPost, "/api/ui/recommendations", strings.NewReader(`{"genre":"short_play"}`))) + if writer.Code != http.StatusOK { + t.Fatal(writer.Body.String()) + } + var result hongguoRecommendationPage + if err := json.Unmarshal(writer.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Dramas) != 2 || result.Dramas[0].ID != "hongguo:2" || result.Dramas[1].Views != "0" || result.Dramas[1].OnlineDate != "2026-09-01" || !result.Saved { + t.Fatal("response order or merged metadata is incorrect") + } + if result.Dramas[1].SortMetadata != nil { + t.Fatal("reading recommendations must not migrate historical metadata") + } + restored := &UIApp{downloader: d, cfg: d.cfg} + restored.loadLibrary() + if len(restored.dramas) != 2 { + t.Fatal("recommended dramas were not persisted for play and download") + } +} diff --git a/internal/app/provider_hongguo_search.go b/internal/app/provider_hongguo_search.go new file mode 100644 index 0000000..47edfdf --- /dev/null +++ b/internal/app/provider_hongguo_search.go @@ -0,0 +1,107 @@ +package app + +import ( + "context" + "errors" + "net/url" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +type hongguoSearchEntry struct { + Dramas []Drama + Total int + ExpiresAt time.Time +} + +type hongguoSearchCall struct { + done chan struct{} + entry hongguoSearchEntry + err error +} + +func hongguoSearchKeyword(keyword string) (string, error) { + keyword = strings.TrimSpace(keyword) + if keyword == "" || !utf8.ValidString(keyword) || utf8.RuneCountInString(keyword) > 80 || strings.IndexFunc(keyword, unicode.IsControl) >= 0 { + return "", errors.New("请输入 1 至 80 个字符的搜索词") + } + return keyword, nil +} + +func (downloader *Downloader) searchHongguoDramas(ctx context.Context, keyword string) (hongguoSearchEntry, error) { + keyword, err := hongguoSearchKeyword(keyword) + if err != nil { + return hongguoSearchEntry{}, err + } + client := downloader.hongguoClient() + client.mu.Lock() + if cached, found := client.searches[keyword]; found && time.Now().Before(cached.ExpiresAt) { + client.mu.Unlock() + return cached, nil + } + if pending := client.searchPending[keyword]; pending != nil { + client.mu.Unlock() + select { + case <-ctx.Done(): + return hongguoSearchEntry{}, ctx.Err() + case <-pending.done: + if (errors.Is(pending.err, context.Canceled) || errors.Is(pending.err, context.DeadlineExceeded)) && ctx.Err() == nil { + return downloader.searchHongguoDramas(ctx, keyword) + } + return pending.entry, pending.err + } + } + pending := &hongguoSearchCall{done: make(chan struct{})} + client.searchPending[keyword] = pending + client.mu.Unlock() + entry, err := downloader.fetchHongguoSearch(ctx, keyword) + client.mu.Lock() + if err == nil { + if len(client.searches) >= 64 { + client.searches = map[string]hongguoSearchEntry{} + } + entry.ExpiresAt = time.Now().Add(5 * time.Minute) + client.searches[keyword] = entry + } + pending.entry, pending.err = entry, err + delete(client.searchPending, keyword) + close(pending.done) + client.mu.Unlock() + return entry, err +} + +func (downloader *Downloader) fetchHongguoSearch(ctx context.Context, keyword string) (hongguoSearchEntry, error) { + pageURL := hongguoBaseURL + "/search/" + url.PathEscape(keyword) + body, err := downloader.fetchProviderText(ctx, pageURL, hongguoBaseURL+"/") + if err != nil { + return hongguoSearchEntry{}, err + } + page := routerLoaderMap(parseRouterData(body), "search_(keyword)/page", "search_") + rows, valid := page["searchList"].([]any) + if page["isSuccess"] != true || !valid || mapString(page, "query") != keyword { + return hongguoSearchEntry{}, errors.New("红果搜索未返回有效结果") + } + entry := hongguoSearchEntry{Dramas: make([]Drama, 0, len(rows))} + seen := make(map[string]bool, len(rows)) + for _, row := range rows { + if len(nestedMap(row, "video_data")) == 0 { + continue + } + drama := hongguoDramaFromAny(row, "短剧") + if drama.ID != "" && !seen[drama.ID] { + seen[drama.ID] = true + entry.Dramas = append(entry.Dramas, drama) + } + } + if len(rows) > 0 && len(entry.Dramas) == 0 { + return hongguoSearchEntry{}, errors.New("红果搜索结果中没有可识别的剧集") + } + entry.Total, _ = strconv.Atoi(mapString(page, "totalCount")) + if entry.Total < len(entry.Dramas) { + entry.Total = len(entry.Dramas) + } + return entry, nil +} diff --git a/internal/app/provider_hongguo_search_test.go b/internal/app/provider_hongguo_search_test.go new file mode 100644 index 0000000..6df9548 --- /dev/null +++ b/internal/app/provider_hongguo_search_test.go @@ -0,0 +1,175 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" +) + +const searchSortFixture = `` + +func TestHongguoSearchSortMetadata(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Host != "hongguoduanju.com" || request.URL.Path != "/search/测试" { + t.Fatalf("unexpected request: %s", request.URL.Path) + } + return rankingHTTPResponse(request, http.StatusOK, searchSortFixture), nil + }) + result, err := d.fetchHongguoSearch(context.Background(), "测试") + if err != nil { + t.Fatal(err) + } + if len(result.Dramas) != 3 || result.Total != 30 { + t.Fatalf("wrong search result size: %d / %d", len(result.Dramas), result.Total) + } + for index, want := range []string{"20019", "0", ""} { + drama := result.Dramas[index] + if drama.Heat != want { + t.Errorf("result %d heat = %q, want %q", index, drama.Heat, want) + } + if drama.Views != "" || drama.OnlineDate != "" { + t.Errorf("heat and create_time must not be relabeled as plays or a release date") + } + } +} + +func TestHongguoHeatTextFallback(t *testing.T) { + for _, test := range []struct { + name string + hot map[string]any + want string + }{ + {"precise numeric heat", map[string]any{"score": json.Number("47093735"), "text": "4709万热度"}, "47093735"}, + {"zero is valid", map[string]any{"score": 0, "text": "未知"}, "0"}, + {"text only", map[string]any{"text": "1.2亿热度"}, "1.2亿热度"}, + {"invalid numeric heat", map[string]any{"score": "-1", "text": "5万热度"}, "5万热度"}, + {"missing heat", nil, ""}, + } { + t.Run(test.name, func(t *testing.T) { + row := map[string]any{"series_id": "7000000000000000001", "score": "9.8", "hot_score_data": test.hot} + if got := hongguoDramaFromAny(row, "短剧").Heat; got != test.want { + t.Fatalf("heat = %q, want %q", got, test.want) + } + }) + } +} + +func TestLibrarySearchReturnsMergedSortMetadata(t *testing.T) { + var calls atomic.Int32 + release := make(chan struct{}) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path == "/search/测试" { + calls.Add(1) + return rankingHTTPResponse(request, http.StatusOK, searchSortFixture), nil + } + select { + case <-release: + case <-request.Context().Done(): + return nil, request.Context().Err() + } + switch request.URL.Path { + case "/detail": + if request.URL.Query().Get("series_id") != "7000000000000000002" { + t.Error("search automatically enrolled an existing cache entry") + } + return rankingHTTPResponse(request, 200, sortDetailFixture("7000000000000000002", "1773662280")), nil + case "/novel/player/video_detail/v1/": + return rankingHTTPResponse(request, 200, `{"data":{"video_data":{"series_id_str":"7000000000000000002","hot_score":0,"series_play_cnt":0}}}`), nil + default: + t.Errorf("unexpected search metadata request: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + }) + a := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{ + {ID: "hongguo:7000000000000000001", Source: sourceHongguo, Title: "旧标题", Heat: "100", Views: "320次播放", OnlineDate: "2026-09-01"}, + {ID: "hongguo:7000000000000000003", Source: sourceHongguo, Title: "已知剧", Heat: "800", Views: "0"}, + {ID: "huangdou:existing", Source: sourceHuangdou, Title: "保留其他站源"}, + }} + t.Cleanup(a.stopSortMetadata) + for attempt := 0; attempt < 2; attempt++ { + writer := httptest.NewRecorder() + a.handleLibrarySearch(writer, httptest.NewRequest(http.MethodGet, "/api/ui/search?q=测试", nil)) + if writer.Code != http.StatusOK { + t.Fatal(writer.Body.String()) + } + var result struct { + Data []Drama `json:"data"` + Saved bool `json:"saved"` + } + if err := json.Unmarshal(writer.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Data) != 3 || len(a.dramas) != 4 || !result.Saved { + t.Fatal("search did not merge and persist all matching dramas") + } + first := result.Data[0] + if first.Heat != "20019" || first.Views != "320次播放" || first.OnlineDate != "2026-09-01" || first.Title != "测试甲" { + t.Errorf("search must return fresh heat together with previously known sort metadata: heat=%q views=%q date=%q title=%q", first.Heat, first.Views, first.OnlineDate, first.Title) + } + if result.Data[2].Heat != "800" || result.Data[2].Views != "0" { + t.Error("missing search fields must not erase previously known metrics or zero") + } + for _, drama := range result.Data { + for _, cached := range a.dramas { + if cached.ID == drama.ID && (cached.Heat != drama.Heat || cached.Views != drama.Views || cached.OnlineDate != drama.OnlineDate) { + t.Error("search response and library snapshot disagree about sort metadata") + } + } + } + } + if calls.Load() != 1 { + t.Fatal("repeated searches should reuse upstream metadata") + } + close(release) + waitSortMetadataQueue(t, a) + a.mu.Lock() + defer a.mu.Unlock() + for _, row := range a.dramas { + if row.ID == "hongguo:7000000000000000002" && (row.OnlineDate != "2026-03-16" || row.Heat != "0" || row.Views != "0" || row.SortMetadata.Version != sortMetadataVersion) { + t.Fatal("new search entry was not completed asynchronously") + } + if row.ID == "hongguo:7000000000000000003" && row.SortMetadata != nil { + t.Fatal("existing search entry must wait for manual backfill") + } + } +} + +func TestLiveHongguoSearchSortMetadata(t *testing.T) { + if os.Getenv("JUKU_LIVE_SEARCH") != "1" { + t.Skip("set JUKU_LIVE_SEARCH=1 for a metadata-only search request") + } + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.Retries = t.TempDir(), t.TempDir(), 1 + d := NewDownloader(cfg) + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + result, err := d.searchHongguoDramas(ctx, "重生") + if err != nil || len(result.Dramas) == 0 { + t.Fatalf("live search returned no entries: %v", err) + } + heat, dates, views := 0, 0, 0 + for _, drama := range result.Dramas { + if drama.Heat != "" { + heat++ + } + if drama.OnlineDate != "" { + dates++ + } + if drama.Views != "" { + views++ + } + } + if heat == 0 { + t.Fatal("live search heat was not retained") + } + t.Logf("entries=%d, total=%d, with heat=%d, release date=%d, plays=%d", len(result.Dramas), result.Total, heat, dates, views) +} diff --git a/internal/app/provider_hongguo_sign.go b/internal/app/provider_hongguo_sign.go new file mode 100644 index 0000000..01bece1 --- /dev/null +++ b/internal/app/provider_hongguo_sign.go @@ -0,0 +1,47 @@ +package app + +import ( + "crypto/md5" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "math/bits" + "net/http" + "strconv" + "time" +) + +func newHongguoDeviceID() string { + var random [8]byte + if _, err := rand.Read(random[:]); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + return strconv.FormatUint(1_000_000_000_000_000_000+binary.BigEndian.Uint64(random[:])%8_000_000_000_000_000_000, 10) +} + +func signHongguoRequest(request *http.Request, body []byte, now time.Time) { + timestamp := uint32(now.Unix()) + queryHash := md5.Sum([]byte(request.URL.RawQuery)) + var payload [20]byte + copy(payload[:4], queryHash[:4]) + if body != nil { + bodyHash := md5.Sum(body) + copy(payload[4:8], bodyHash[:4]) + request.Header.Set("X-SS-STUB", fmt.Sprintf("%X", bodyHash)) + } + copy(payload[12:16], []byte{0, 6, 11, 28}) + binary.BigEndian.PutUint32(payload[16:], timestamp) + key := [...]byte{0x44, 0xb9, 0xb9, 0xd9, 0xa4, 0xae, 0xf9, 0xfc, 0xa4, 0x93, 0xaa, 0x75, 0x7c, 0xa3, 0xc2, 0xc4, 0xa4, 0x96, 0x93, 0x8f} + for index := range payload { + payload[index] ^= key[index] + } + for index := range payload { + mixed := bits.RotateLeft8(payload[index], 4) ^ payload[(index+1)%len(payload)] + payload[index] = bits.Reverse8(mixed) ^ 0xff ^ byte(len(payload)) + } + signature := append([]byte{0x84, 0x04, 0x40, 0x1c, 0, 0}, payload[:]...) + request.Header.Set("X-Khronos", strconv.FormatUint(uint64(timestamp), 10)) + request.Header.Set("X-Gorgon", hex.EncodeToString(signature)) + request.Header.Set("X-SS-Req-Ticket", strconv.FormatInt(now.UnixMilli(), 10)) +} diff --git a/internal/app/provider_huangdou.go b/internal/app/provider_huangdou.go new file mode 100644 index 0000000..d3bb309 --- /dev/null +++ b/internal/app/provider_huangdou.go @@ -0,0 +1,469 @@ +package app + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + huangdouBaseURL = "https://tideember.cc" + huangdouVersion = "2.0.0" + huangdouDeviceType = "web" + huangdouPlatformKey = "7961beb44246e3012ce228d6b5ced05a" +) + +type huangdouAPIClient struct { + d *Downloader + host string + sessionID string + deviceID string +} + +func newHuangdouAPIClient(d *Downloader) *huangdouAPIClient { + id := randomHex(16) + if id == "" { + id = strconv.FormatInt(time.Now().UnixNano(), 16) + } + host := d.providerBaseURL(sourceHuangdou) + d.providerMu.Lock() + if preferred := d.providerHosts[sourceHuangdou]; preferred != "" { + host = preferred + } + d.providerMu.Unlock() + return &huangdouAPIClient{d: d, host: host, sessionID: id, deviceID: id} +} + +func (d *Downloader) fetchHuangdouDramas(ctx context.Context) ([]Drama, error) { + client := newHuangdouAPIClient(d) + pages := d.cfg.MaxPagesPerSort + if pages <= 0 { + pages = 1 + } + pageSize := d.cfg.PageSize + if pageSize <= 0 || pageSize > 100 { + pageSize = 50 + } + seen := map[string]bool{} + var out []Drama + var lastErr error + for page := 1; page <= pages; page++ { + var decoded any + if err := client.call(ctx, "/drama/list", map[string]any{"page": strconv.Itoa(page), "page_size": strconv.Itoa(pageSize)}, &decoded); err != nil { + lastErr = err + break + } + items := huangdouList(decoded) + if len(items) == 0 { + break + } + added := 0 + var batch []Drama + for _, item := range items { + dr := huangdouDramaFromMap(item) + if dr.ID == "" || seen[dr.ID] { + continue + } + seen[dr.ID] = true + out = append(out, dr) + batch = append(batch, dr) + added++ + } + reportLibraryProgress(ctx, sourceHuangdou, batch, nil, false) + if added == 0 || len(items) < pageSize { + break + } + } + if len(out) == 0 && lastErr == nil { + lastErr = errors.New("黄豆未返回剧库数据,请检查站点访问权限或接口变化") + } + return out, lastErr +} + +func (d *Downloader) fetchHuangdouChapters(ctx context.Context, sourceID string) (string, []Chapter, error) { + sourceID = strings.TrimPrefix(strings.TrimSpace(sourceID), "rp_") + data, err := d.huangdouDetail(ctx, sourceID) + if err != nil { + return "", nil, err + } + title := firstNonEmpty(mapString(data, "name", "title", "t"), sourceID) + episodes := huangdouList(data["episodes"]) + count := atoiDefault(firstNonEmpty(mapString(data, "episode_count"), mapString(data, "free_episodes")), len(episodes)) + if count <= 0 { + count = len(episodes) + } + if count <= 0 || count > 10000 { + return title, nil, fmt.Errorf("黄豆返回了无效集数: %d", count) + } + chapters := make([]Chapter, 0, count) + if len(episodes) > 0 { + for i, ep := range episodes { + seq := atoiDefault(firstNonEmpty(mapString(ep, "seq"), mapString(ep, "episode"), mapString(ep, "ep")), i+1) + if seq <= 0 { + seq = i + 1 + } + titleText := firstNonEmpty(mapString(ep, "name", "title"), fmt.Sprintf("第%d集", seq)) + chapters = append(chapters, Chapter{VIP: huangdouEpisodeVIP(data, ep, seq), ID: providerChapterID(sourceHuangdou, sourceID, strconv.Itoa(seq)), Source: sourceHuangdou, Title: titleText, CurrentEpisode: rawEpisode(seq)}) + } + } else { + for i := 1; i <= count; i++ { + chapters = append(chapters, Chapter{VIP: huangdouEpisodeVIP(data, nil, i), ID: providerChapterID(sourceHuangdou, sourceID, strconv.Itoa(i)), Source: sourceHuangdou, Title: fmt.Sprintf("第%d集", i), CurrentEpisode: rawEpisode(i)}) + } + } + sortProviderChapters(chapters) + return title, uniqueChapters(chapters), nil +} + +func (d *Downloader) resolveHuangdouPlayURL(ctx context.Context, client *huangdouAPIClient, sourceID string, seq int) (string, error) { + media, err := d.resolveHuangdouPlayback(ctx, client, sourceID, seq) + return media.URL, err +} + +func (d *Downloader) resolveHuangdouPlayback(ctx context.Context, client *huangdouAPIClient, sourceID string, seq int) (providerMedia, error) { + var decoded any + if err := client.call(ctx, "/drama/play", map[string]any{"id": sourceID, "seq": strconv.Itoa(seq)}, &decoded); err != nil { + return providerMedia{}, err + } + data := huangdouDataMap(decoded) + media := firstNonEmpty(mapString(data, "m3u8"), mapString(data, "url"), mapString(data, "play_url"), mapString(data, "playUrl")) + if huangdouPreviewOnly(data, media, client.host) { + return providerMedia{}, &huangdouAPIError{code: "preview", message: "黄豆仅提供试看,未取得该集正片;不会把试看内容当作完整分集"} + } + if media == "" { + media = fmt.Sprintf("%s/api/drama/hls/%s/%d/play.m3u8?line=free", client.host, url.PathEscape(sourceID), seq) + playlist, err := d.fetchProviderText(ctx, media, client.host+"/home") + if err != nil || !strings.HasPrefix(strings.TrimSpace(playlist), "#EXTM3U") { + return providerMedia{}, errors.New("黄豆未提供有效的播放地址,备用播放列表也不可用") + } + } + media = resolveProviderURL(client.host+"/", media) + if !isProviderHTTPMediaURL(media) { + return providerMedia{}, errors.New("黄豆播放地址不是 HTTP/HTTPS URL") + } + result := providerMedia{URL: media, Referer: client.host + "/home"} + if rawKey := mapString(data, "hls_key"); rawKey != "" { + key, err := hex.DecodeString(rawKey) + if err != nil || len(key) != aes.BlockSize { + return providerMedia{}, errors.New("黄豆返回了无效的 HLS 密钥") + } + result.HLSKey = key + } + if duration, err := strconv.ParseFloat(mapString(data, "duration"), 64); err == nil && duration > 0 { + result.Duration = time.Duration(duration * float64(time.Second)) + } + return result, nil +} + +func (c *huangdouAPIClient) call(ctx context.Context, path string, data map[string]any, out *any) error { + retries := c.d.cfg.Retries + if retries < 1 { + retries = 1 + } + var lastErr error + for attempt := 0; attempt < retries; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + if attempt > 0 { + select { + case <-time.After(time.Duration(attempt) * time.Second): + case <-ctx.Done(): + return ctx.Err() + } + } + lastErr = c.callOnce(ctx, path, data, out) + if lastErr == nil { + return nil + } + var apiErr *huangdouAPIError + if errors.As(lastErr, &apiErr) { + return lastErr + } + if c.host == huangdouBaseURL && c.d.cfg.HuangdouURL == "" { + c.host = "https://xqjurgek.top" + } + } + return lastErr +} + +type huangdouAPIError struct { + code string + message string +} + +func (failure *huangdouAPIError) Error() string { + if failure.code != "preview" && isHuangdouAccessError(failure) { + return failure.message + "(站点权限限制,已停止自动重试;切换代理不会解锁)" + } + return failure.message +} + +func isHuangdouAccessError(err error) bool { + var failure *huangdouAPIError + if !errors.As(err, &failure) { + return false + } + switch failure.code { + case "813004", "813005", "813006", "813103", "preview": + return true + default: + return false + } +} + +func huangdouPreviewOnly(data map[string]any, media, host string) bool { + switch strings.ToLower(mapString(data, "is_preview", "isPreview")) { + case "true", "1", "y": + return true + } + if preview := mapString(data, "preview_m3u8", "preview_url"); preview != "" && + (media == "" || resolveProviderURL(host+"/", media) == resolveProviderURL(host+"/", preview)) { + return true + } + parsed, err := url.Parse(media) + if err != nil { + return false + } + path := strings.ToLower(parsed.Path) + return path == "preview.mp4" || path == "preview.m3u8" || strings.HasSuffix(path, "/preview.mp4") || strings.HasSuffix(path, "/preview.m3u8") +} + +func (c *huangdouAPIClient) callOnce(ctx context.Context, path string, data map[string]any, out *any) error { + path = "/" + strings.TrimLeft(path, "/") + rid := uuidLike() + key, err := huangdouKey(rid) + if err != nil { + return err + } + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + return err + } + plain, err := json.Marshal(map[string]any{"token": "", "deviceId": c.deviceID, "data": data}) + if err != nil { + return err + } + var gz bytes.Buffer + zw := gzip.NewWriter(&gz) + if _, err := zw.Write(plain); err != nil { + return err + } + if err := zw.Close(); err != nil { + return err + } + cipherText, err := aesCBCEncrypt(gz.Bytes(), key, iv) + if err != nil { + return err + } + body := append(append([]byte{}, iv...), cipherText...) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.host, "/")+"/api"+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", c.host) + req.Header.Set("Referer", c.host+"/home") + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("version", huangdouVersion) + req.Header.Set("deviceType", huangdouDeviceType) + req.Header.Set("requestId", rid) + req.Header.Set("sessionId", c.sessionID) + resp, err := c.d.doPreparedCatalogRequest(req, 20*time.Second, func(prepared *http.Request) { + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + signRaw := "Dart|" + c.sessionID + "|" + rid + "|" + timestamp + "|" + path + signSum := sha256.Sum256([]byte(signRaw)) + prepared.Header.Set("time", timestamp) + prepared.Header.Set("sign", hex.EncodeToString(signSum[:])+"-"+timestamp) + }) + if err != nil { + return err + } + defer resp.Body.Close() + blob, err := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1)) + if err != nil { + return err + } + if len(blob) > providerMaxBodyBytes { + return fmt.Errorf("huangdou response exceeds %d bytes", providerMaxBodyBytes) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("huangdou HTTP %d: %s", resp.StatusCode, truncate(string(blob), 200)) + } + decoded, err := huangdouDecode(blob, key) + if err != nil { + return err + } + if envelope, ok := decoded.(map[string]any); ok { + if status := mapString(envelope, "status"); status != "" && status != "y" { + return &huangdouAPIError{code: mapString(envelope, "errorCode", "error_code", "code"), message: "黄豆接口拒绝请求: " + firstNonEmpty(mapString(envelope, "msg", "message", "error"), status)} + } + } + c.d.providerMu.Lock() + c.d.providerHosts[sourceHuangdou] = c.host + c.d.providerMu.Unlock() + if out != nil { + *out = decoded + } + return nil +} + +func huangdouKey(rid string) ([]byte, error) { + clean := strings.ReplaceAll(rid, "-", "") + b, err := hex.DecodeString(clean) + if err != nil { + return nil, err + } + mac := hmac.New(sha256.New, []byte(huangdouPlatformKey)) + _, _ = mac.Write(b) + return mac.Sum(nil), nil +} + +func huangdouDecode(blob, key []byte) (any, error) { + if len(blob) < aes.BlockSize*2 { + var direct any + if err := json.Unmarshal(blob, &direct); err == nil { + return direct, nil + } + return nil, fmt.Errorf("huangdou response too short") + } + plain, err := aesCBCDecrypt(blob[aes.BlockSize:], key, blob[:aes.BlockSize]) + if err != nil { + var direct any + if jsonErr := json.Unmarshal(blob, &direct); jsonErr == nil { + return direct, nil + } + return nil, err + } + if len(plain) >= 2 && plain[0] == 0x1f && plain[1] == 0x8b { + zr, err := gzip.NewReader(bytes.NewReader(plain)) + if err != nil { + return nil, err + } + plain, err = io.ReadAll(io.LimitReader(zr, providerMaxBodyBytes+1)) + _ = zr.Close() + if err != nil { + return nil, err + } + if len(plain) > providerMaxBodyBytes { + return nil, errors.New("黄豆解压后响应过大") + } + } + var decoded any + decoder := json.NewDecoder(bytes.NewReader(plain)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return nil, err + } + return decoded, nil +} + +func aesCBCEncrypt(plain, key, iv []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padded := pkcs7Pad(plain, block.BlockSize()) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) + return out, nil +} + +func aesCBCDecrypt(cipherText, key, iv []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(cipherText)%block.BlockSize() != 0 { + return nil, fmt.Errorf("huangdou ciphertext is not block aligned") + } + out := make([]byte, len(cipherText)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, cipherText) + return pkcs7Unpad(out, block.BlockSize()) +} + +func huangdouDramaFromMap(item map[string]any) Drama { + sourceID := strings.TrimPrefix(firstNonEmpty(mapString(item, "id"), mapString(item, "drama_id")), "rp_") + if sourceID == "" { + return Drama{} + } + title := firstNonEmpty(mapString(item, "name", "title", "t"), sourceID) + cover := firstNonEmpty(mapString(item, "img_y", "img_x", "img", "cover", "pic")) + remark := firstNonEmpty(mapString(item, "update_label"), mapString(item, "corner")) + releaseStatus := releaseStatusFromRemark(remark) + if remark == "" { + if eps := mapString(item, "episode_count"); eps != "" { + remark = "全" + eps + "集" + } + } + category := firstNonEmpty(mapString(item, "category", "category_name", "categoryName"), "未分类") + return Drama{VIP: huangdouVIPFlag(item), ID: providerDramaID(sourceHuangdou, sourceID), Source: sourceHuangdou, SourceID: sourceID, Title: title, Name: title, Desc: firstNonEmpty(mapString(item, "description"), mapString(item, "summary")), Intro: firstNonEmpty(mapString(item, "description"), mapString(item, "summary")), Cover: cover, CoverURL: cover, CategoryName: category, ChannelName: "tideember.cc", Remark: remark, TotalEpisode: mapString(item, "episode_count"), EpisodeCount: mapString(item, "episode_count"), Tags: mapStringSlice(item, "tags"), ReleaseStatus: releaseStatus, Heat: mapString(item, "hot_rate"), Views: normalizeViews(mapString(item, "click")), OnlineDate: providerReleaseDate(mapString(item, "issue_date"))} +} + +func huangdouDataMap(v any) map[string]any { + m, _ := v.(map[string]any) + if data, ok := m["data"].(map[string]any); ok { + return data + } + return m +} + +func huangdouList(v any) []map[string]any { + switch x := v.(type) { + case []any: + out := make([]map[string]any, 0, len(x)) + for _, item := range x { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out + case []map[string]any: + return x + case map[string]any: + for _, key := range []string{"list", "items", "data"} { + if list := huangdouList(x[key]); len(list) > 0 { + return list + } + } + } + return nil +} + +func atoiDefault(s string, fallback int) int { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + return fallback +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "" + } + return hex.EncodeToString(b) +} + +func uuidLike() string { + raw := randomHex(16) + if len(raw) != 32 { + return raw + } + return raw[:8] + "-" + raw[8:12] + "-" + raw[12:16] + "-" + raw[16:20] + "-" + raw[20:] +} diff --git a/internal/app/provider_huangdou_detail.go b/internal/app/provider_huangdou_detail.go new file mode 100644 index 0000000..58f28f8 --- /dev/null +++ b/internal/app/provider_huangdou_detail.go @@ -0,0 +1,67 @@ +package app + +import ( + "context" + "errors" + "strings" + "time" +) + +type huangdouDetailEntry struct { + row map[string]any + expiresAt time.Time +} + +type huangdouDetailCall struct { + done chan struct{} + row map[string]any + err error +} + +func (d *Downloader) huangdouDetail(ctx context.Context, id string) (map[string]any, error) { + if !rankingSourceID.MatchString(id) { + return nil, errors.New("无效的黄豆剧集 ID") + } + d.providerMu.Lock() + if cached, found := d.huangdouDetails[id]; found && time.Now().Before(cached.expiresAt) { + d.providerMu.Unlock() + return cached.row, nil + } + if pending := d.huangdouDetailPending[id]; pending != nil { + d.providerMu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-pending.done: + if (errors.Is(pending.err, context.Canceled) || errors.Is(pending.err, context.DeadlineExceeded)) && ctx.Err() == nil { + return d.huangdouDetail(ctx, id) + } + return pending.row, pending.err + } + } + if d.huangdouDetailPending == nil { + d.huangdouDetailPending = make(map[string]*huangdouDetailCall) + } + call := &huangdouDetailCall{done: make(chan struct{})} + d.huangdouDetailPending[id] = call + d.providerMu.Unlock() + var decoded any + err := newHuangdouAPIClient(d).call(ctx, "/drama/detail", map[string]any{"id": id}, &decoded) + row := huangdouDataMap(decoded) + if err == nil && strings.TrimPrefix(mapString(row, "id", "drama_id"), "rp_") != id { + row = nil + err = errors.New("黄豆详情返回了其他剧集") + } + d.providerMu.Lock() + if err == nil { + if d.huangdouDetails == nil || len(d.huangdouDetails) >= 128 { + d.huangdouDetails = make(map[string]huangdouDetailEntry) + } + d.huangdouDetails[id] = huangdouDetailEntry{row: row, expiresAt: time.Now().Add(dramaRefreshRetryDelay)} + } + call.row, call.err = row, err + delete(d.huangdouDetailPending, id) + close(call.done) + d.providerMu.Unlock() + return row, err +} diff --git a/internal/app/provider_huangdou_vip.go b/internal/app/provider_huangdou_vip.go new file mode 100644 index 0000000..fdba83f --- /dev/null +++ b/internal/app/provider_huangdou_vip.go @@ -0,0 +1,81 @@ +package app + +import ( + "fmt" + "strconv" + "strings" +) + +func huangdouVIPFlag(item map[string]any) *bool { + var known *bool + for _, key := range []string{"is_vip", "isVip"} { + value := strings.ToLower(mapString(item, key)) + if value == "true" || value == "1" || value == "y" { + result := true + return &result + } + if value == "false" || value == "0" || value == "n" { + result := false + known = &result + } + } + switch strings.ToLower(mapString(item, "pay_type")) { + case "vip": + result := true + return &result + case "free": + result := false + known = &result + } + for _, episode := range huangdouVIPEpisodes(item) { + if episode > 0 { + result := true + return &result + } + } + for _, episode := range huangdouList(item["episodes"]) { + if strings.EqualFold(mapString(episode, "type"), "vip") { + result := true + return &result + } + } + return known +} + +func huangdouVIPEpisodes(item map[string]any) []int { + var result []int + if values, ok := item["vip_episodes"].([]any); ok { + for _, value := range values { + if index, err := strconv.Atoi(fmt.Sprint(value)); err == nil && index > 0 && index <= 10000 { + result = append(result, index) + } + } + } + return result +} + +func huangdouEpisodeVIP(detail, episode map[string]any, seq int) bool { + switch strings.ToLower(mapString(episode, "type")) { + case "vip": + return true + case "free": + return false + } + for _, index := range huangdouVIPEpisodes(detail) { + if index == seq { + return true + } + } + if flag := huangdouVIPFlag(episode); flag != nil { + return *flag + } + if strings.EqualFold(mapString(detail, "pay_type"), "vip") { + free, err := strconv.Atoi(mapString(detail, "free_episodes")) + return err == nil && free >= 0 && seq > free + } + return false +} + +func needsHuangdouVIPMetadata(drama Drama) bool { + return dramaProvider(drama) == sourceHuangdou && drama.VIP == nil && (drama.SortMetadata == nil || !drama.SortMetadata.VIPChecked) +} diff --git a/internal/app/provider_huangdou_vip_test.go b/internal/app/provider_huangdou_vip_test.go new file mode 100644 index 0000000..5086c06 --- /dev/null +++ b/internal/app/provider_huangdou_vip_test.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestHuangdouVIPKnownFreeAndUnknownRemainDistinct(t *testing.T) { + for _, test := range []struct { + body string + want int + }{ + {`{"pay_type":"vip"}`, 1}, + {`{"pay_type":"free"}`, 0}, + {`{"is_vip":"y"}`, 1}, + {`{"is_vip":false}`, 0}, + {`{"vip_episodes":[3,"4"]}`, 1}, + {`{"pay_type":"free","vip_episodes":[3]}`, 1}, + {`{"episodes":[{"type":"free"},{"type":"vip"}]}`, 1}, + {`{"name":"VIP 是标题文字"}`, -1}, + {`{"pay_type":"unknown","vip_episodes":[0,-1,"invalid"]}`, -1}, + {`{"vip_episodes":[]}`, -1}, + } { + var row map[string]any + if err := json.Unmarshal([]byte(test.body), &row); err != nil { + t.Fatal(err) + } + row["id"], row["name"] = "fixture", "无图 VIP 样本" + drama := huangdouDramaFromMap(row) + actual := -1 + if drama.VIP != nil { + actual = 0 + if *drama.VIP { + actual = 1 + } + } + if actual != test.want { + t.Fatalf("%s: VIP %d, want %d", test.body, actual, test.want) + } + ranking, err := parseHuangdouRanking(map[string]any{"list": []any{row}}, 1) + if err != nil { + t.Fatal(err) + } + ranked := ranking.Items[0].Drama + if (ranked.VIP == nil) != (drama.VIP == nil) || ranked.VIP != nil && *ranked.VIP != *drama.VIP { + t.Fatal("ranking lost VIP metadata") + } + } + paid, free := true, false + previous := Drama{ID: "huangdou:fixture", VIP: &paid} + if value := mergeDramaMetadata(Drama{}, previous).VIP; value == nil || !*value { + t.Fatal("incomplete metadata erased VIP flag") + } + if value := mergeDramaMetadata(Drama{VIP: &free}, previous).VIP; value == nil || *value { + t.Fatal("fresh free metadata did not replace VIP flag") + } +} + +func TestHuangdouVIPEpisodesPreserveFreeEpisodesAndPreviewRefusal(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/api/drama/detail" { + t.Fatal("unexpected upstream path", request.URL.Path) + } + return rankingHTTPResponse(request, 200, `{"data":{"id":"fixture","name":"无图分集样本","pay_type":"vip","free_episodes":1,"episode_count":4,"vip_episodes":[3],"episodes":[{"seq":1,"type":"free"},{"seq":2,"type":"vip"},{"seq":3},{"seq":4,"type":"free"}]}}`), nil + }) + _, chapters, err := d.fetchHuangdouChapters(context.Background(), "fixture") + if err != nil || len(chapters) != 4 { + t.Fatal(err, len(chapters)) + } + for i, want := range []bool{false, true, true, false} { + if chapters[i].VIP != want { + t.Fatal("incorrect episode restriction", i+1, chapters[i].VIP) + } + } + if !huangdouPreviewOnly(map[string]any{"is_preview": true}, "https://example.invalid/preview.mp4", "https://example.invalid") { + t.Fatal("preview mistaken for a complete episode") + } +} + +func TestHuangdouVIPBackfillOnlyWhenInformationMissing(t *testing.T) { + calls := 0 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls++ + if request.URL.Path != "/api/drama/detail" { + t.Fatal("unexpected upstream path", request.URL.Path) + } + return rankingHTTPResponse(request, 200, `{"data":{"id":"fixture","name":"无图样本","pay_type":"vip","issue_date":"2026-03-16","click":0,"hot_rate":"123"}}`), nil + }) + cached := Drama{ID: "huangdou:fixture", Source: sourceHuangdou, OnlineDate: "2026-03-16", Views: "0", Heat: "123", SortMetadata: &sortMetadataState{Version: sortMetadataVersion}} + if !needsSortMetadata(cached) { + t.Fatal("manual update cannot backfill old VIP information") + } + patch, err := d.fetchDramaSortMetadata(context.Background(), cached) + if err != nil || patch.VIP == nil || !*patch.VIP || calls != 1 { + t.Fatal("VIP backfill failed", err, calls) + } + cached = mergeDramaMetadata(patch, cached) + if _, err = d.fetchDramaSortMetadata(context.Background(), cached); err != nil || calls != 1 { + t.Fatal("known VIP caused unnecessary detail request") + } +} diff --git a/internal/app/provider_huangguo.go b/internal/app/provider_huangguo.go new file mode 100644 index 0000000..a34dd63 --- /dev/null +++ b/internal/app/provider_huangguo.go @@ -0,0 +1,1700 @@ +package app + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const ( + sourceHuangguoAI = "huangguoai" + sourceHuangguoVideo = "huangguo-video" + sourceHuangdou = "huangdou" + sourceHongguo = "hongguo" + + providerMaxBodyBytes = 20 * 1024 * 1024 + providerTimeout = 12 * time.Second +) + +var ( + huangguoAIBaseURL = "https://huangguoai.com" + huangguoAIBaseMirrors = []string{"https://huangguoai.com", "https://ttvoij.ediayikma.cc", "https://thu.ediayikma.cc", "https://pku.ediayikma.cc", "https://fdu.ediayikma.cc", "https://thu.agdkczeyx.cc"} + huangguoVideoBaseURL = "https://huangguo.video" + + huangguoAISlugs = []string{"recommend", "newest", "ai-duanju", "ai-manju", "ai-huanlian", "ai-mogai", "ranks/hot"} + huangguoAITypeNames = map[string]string{"recommend": "精选推荐", "newest": "最近上新", "ai-duanju": "AI成人短剧", "ai-manju": "AI成人漫剧", "ai-huanlian": "AI换脸", "ai-mogai": "AI魔改", "ranks/hot": "排行榜"} + + reDetailHref = regexp.MustCompile(`(?is)]*href=["']([^"']*/detail/([^"'/?#]+)[^"']*)["'][^>]*>.*?`) + reDramaCardStart = regexp.MustCompile(`(?is)]*class=["'][^"']*\bhg-drama-card\b[^"']*["'][^>]*>`) + reVideoCard = regexp.MustCompile(`(?is)]*class=["'][^"']*\bvideo-card\b[^"']*["'][^>]*>.*?`) + reAnyArticle = regexp.MustCompile(`(?is)]*>.*?`) + reHuangguoLink = regexp.MustCompile(`(?is)]*href=["']([^"']*/(?:series|video)/([^"'/?#]+)[^"']*)["'][^>]*>.*?`) + reAIEpisodeLink = regexp.MustCompile(`(?is)]*href=["']([^"']*/video/[^"']+)["'][^>]*>.*?`) + reTitleTag = regexp.MustCompile(`(?is)]*>(.*?)`) + reH1Tag = regexp.MustCompile(`(?is)]*>(.*?)`) + reDescMeta = regexp.MustCompile(`(?is)]*(?:name|property)=["'](?:description|og:description)["'][^>]*content=["']([^"']+)["'][^>]*>`) + reEpisodeNumber = regexp.MustCompile(`(?i)(?:第\s*0*(\d+)\s*(?:集|话|期)|(?:更新至|共|全)\s*0*(\d+)\s*(?:集|话|期)|(?:episode|ep)\s*#?\s*0*(\d+))`) + reResolution = regexp.MustCompile(`(?i)RESOLUTION\s*=\s*(\d+)x(\d+)`) + reBandwidth = regexp.MustCompile(`(?i)(?:AVERAGE-)?BANDWIDTH\s*=\s*(\d+)`) + reDataHLS = regexp.MustCompile(`(?is)data-hls=["']([^"']+)["']`) + reDataPlaySrc = regexp.MustCompile(`(?is)data-play-src=["']([^"']+)["']`) + reMediaFieldValue = regexp.MustCompile(`(?is)["']?(?:videoSrc|videoUrl|playUrl|src|url)["']?\s*[:=]\s*["']([^"']+\.(?:m3u8|mp4)(?:[^"']*)?)["']`) + reDateText = regexp.MustCompile(`\d{4}-\d{1,2}-\d{1,2}`) + reViewsText = regexp.MustCompile(`(?i)[0-9]+(?:\.[0-9]+)?\s*[w万]?\s*次播放`) + reScoreText = regexp.MustCompile(`[0-9]+(?:\.[0-9]+)?\s*分`) +) + +type providerEpisode struct { + Key string + Title string + URL string + Index int + HLS string +} + +func providerDramaID(source, sourceID string) string { + return source + ":" + strings.TrimSpace(sourceID) +} + +func providerChapterID(source, sourceID, chapterKey string) string { + return source + ":" + strings.TrimSpace(sourceID) + ":" + strings.TrimSpace(chapterKey) +} + +func splitProviderDramaID(id string) (source, sourceID string, ok bool) { + source, sourceID, ok = strings.Cut(strings.TrimSpace(id), ":") + source = canonicalProviderSource(source) + if !ok || strings.TrimSpace(sourceID) == "" || !isHuangguoProviderSource(source) { + return "", "", false + } + return source, strings.TrimSpace(sourceID), true +} + +func isHuangguoProviderSource(source string) bool { + switch canonicalProviderSource(source) { + case sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou, sourceHongguo: + return true + default: + return false + } +} + +func canonicalProviderSource(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case "huangguo", "huangguoai", "huangguoai.com": + return sourceHuangguoAI + case "huangguo-video", "huangguo.video": + return sourceHuangguoVideo + case "huangdou", "tideember.cc", "xqjurgek.top": + return sourceHuangdou + case "hongguo", "hongguoduanju.com": + return sourceHongguo + default: + return strings.TrimSpace(source) + } +} + +func (d *Downloader) fetchHuangguoProviders(ctx context.Context, seen map[string]bool) []Drama { + var out []Drama + providers := []struct { + name string + fn func(context.Context) ([]Drama, error) + }{ + {name: sourceHuangguoAI, fn: d.fetchHuangguoAIDramas}, + {name: sourceHuangguoVideo, fn: d.fetchHuangguoVideoDramas}, + {name: sourceHuangdou, fn: d.fetchHuangdouDramas}, + {name: sourceHongguo, fn: d.fetchHongguoDramas}, + } + for _, provider := range providers { + dramas, err := provider.fn(ctx) + if err != nil { + fmt.Printf(" provider[%s] 获取失败: %v\n", provider.name, err) + continue + } + for _, dr := range dramas { + if dr.ID == "" || seen[dr.ID] { + continue + } + seen[dr.ID] = true + out = append(out, dr) + } + } + return out +} + +func (d *Downloader) fetchHuangguoAIDramas(ctx context.Context) ([]Drama, error) { + type aiPage struct { + url string + referer string + category string + jsonAPI bool + } + maxPages := d.cfg.MaxPagesPerSort + if maxPages <= 0 { + maxPages = 1 + } + pages := []aiPage{{url: huangguoAIBaseURL + "/", referer: huangguoAIBaseURL + "/", category: "首页"}} + for _, slug := range huangguoAISlugs { + categoryURL := huangguoAIListURL(slug, 1) + category := huangguoAITypeName(slug) + pages = append(pages, aiPage{url: categoryURL, referer: huangguoAIBaseURL + "/", category: category}) + + if !strings.HasPrefix(slug, "ai-") { + continue + } + for page := 1; page <= maxPages; page++ { + apiURL := fmt.Sprintf("%s/api/videos/category/%s?sort=hot&page=%d&size=24", strings.TrimRight(huangguoAIBaseURL, "/"), url.PathEscape(slug), page) + pages = append(pages, aiPage{url: apiURL, referer: categoryURL, category: category, jsonAPI: true}) + } + } + type result struct { + items []Drama + err error + } + ch := make(chan result, len(pages)) + semaphore := make(chan struct{}, 6) + for _, page := range pages { + page := page + go func() { + select { + case semaphore <- struct{}{}: + defer func() { <-semaphore }() + case <-ctx.Done(): + ch <- result{err: ctx.Err()} + return + } + pageCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + body, err := d.fetchProviderText(pageCtx, page.url, page.referer) + if err != nil { + ch <- result{err: fmt.Errorf("%s: %w", page.url, err)} + return + } + if page.jsonAPI { + ch <- result{items: parseHuangguoAIJSONCards([]byte(body), page.url, page.category)} + return + } + ch <- result{items: parseHuangguoAIDramaCards(body, page.url, page.category)} + }() + } + index := map[string]int{} + var out []Drama + var lastErr error + for range pages { + res := <-ch + if res.err != nil { + lastErr = res.err + continue + } + reportLibraryProgress(ctx, sourceHuangguoAI, res.items, nil, false) + for _, dr := range res.items { + if dr.ID == "" { + continue + } + if pos, ok := index[dr.ID]; ok { + out[pos] = mergeDramaMetadata(out[pos], dr) + continue + } + index[dr.ID] = len(out) + out = append(out, dr) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayTitle() < out[j].DisplayTitle() }) + if len(out) == 0 && lastErr == nil { + lastErr = fmt.Errorf("黄果页面未解析到视频,可能是站点结构改变或访问验证") + } + return out, lastErr +} + +func mergeDramaMetadata(base, extra Drama) Drama { + if base.VIP == nil && extra.VIP != nil { + value := *extra.VIP + base.VIP = &value + } + if extra.SortMetadata != nil && (base.SortMetadata == nil || extra.SortMetadata.CheckedAt.After(base.SortMetadata.CheckedAt)) { + base.SortMetadata = extra.SortMetadata + } + if strings.TrimSpace(base.ID) == "" { + base.ID = extra.ID + } + if strings.TrimSpace(base.Source) == "" { + base.Source = extra.Source + } + if strings.TrimSpace(base.SourceID) == "" { + base.SourceID = extra.SourceID + } + if strings.TrimSpace(base.Title) == "" { + base.Title = extra.Title + } + if strings.TrimSpace(base.Name) == "" { + base.Name = extra.Name + } + if strings.TrimSpace(base.Desc) == "" { + base.Desc = extra.Desc + } + if strings.TrimSpace(base.Intro) == "" { + base.Intro = extra.Intro + } + if coverPathFromAny(base.Cover) == "" { + base.Cover = extra.Cover + } + if coverPathFromAny(base.CoverURL) == "" { + base.CoverURL = extra.CoverURL + } + if valueEmpty(base.TotalEpisode) { + base.TotalEpisode = extra.TotalEpisode + } + if valueEmpty(base.EpisodeCount) { + base.EpisodeCount = extra.EpisodeCount + } + if strings.TrimSpace(base.ChannelName) == "" { + base.ChannelName = extra.ChannelName + } + if strings.TrimSpace(base.CategoryName) == "" || (base.CategoryName == "首页" && extra.CategoryName != "") { + base.CategoryName = extra.CategoryName + } + if strings.TrimSpace(base.Remark) == "" || base.Remark == "在线观看" { + base.Remark = extra.Remark + } + if strings.TrimSpace(base.Score) == "" { + base.Score = extra.Score + } + if strings.TrimSpace(base.Views) == "" { + base.Views = extra.Views + } + if strings.TrimSpace(base.Heat) == "" { + base.Heat = extra.Heat + } + if strings.TrimSpace(base.OnlineDate) == "" { + base.OnlineDate = extra.OnlineDate + } + if len(base.Tags) == 0 { + base.Tags = extra.Tags + } + if base.ReleaseStatus == "" || base.ReleaseStatus == "unknown" { + base.ReleaseStatus = extra.ReleaseStatus + } + if dramaProvider(base) == sourceHuangguoAI && base.ID == extra.ID { + _, sourceID, _ := splitProviderDramaID(base.ID) + if title := firstHuangguoTitle(sourceID, base.Title, base.Name, extra.Title, extra.Name); title != "" { + base.Title, base.Name = title, title + } + } + return base +} + +func valueEmpty(v any) bool { + if v == nil { + return true + } + s := strings.TrimSpace(fmt.Sprint(v)) + return s == "" || s == "0" || s == "" +} + +func huangguoAIListURL(slug string, page int) string { + base := strings.TrimRight(huangguoAIBaseURL, "/") + if page < 1 { + page = 1 + } + switch slug { + case "recommend": + return fmt.Sprintf("%s/recommend/%d/", base, page) + case "newest": + return fmt.Sprintf("%s/newest/%d/", base, page) + case "ranks/hot": + return base + "/ranks/hot/" + default: + return fmt.Sprintf("%s/%s/", base, strings.Trim(slug, "/")) + } +} + +func huangguoAITypeName(slug string) string { + if name := huangguoAITypeNames[slug]; name != "" { + return name + } + return slug +} + +func (d *Downloader) fetchHuangguoVideoDramas(ctx context.Context) ([]Drama, error) { + seen := map[string]bool{} + var out []Drama + var lastErr error + add := func(items []Drama) { + reportLibraryProgress(ctx, sourceHuangguoVideo, items, nil, false) + for _, dr := range items { + if dr.ID == "" || seen[dr.ID] { + continue + } + seen[dr.ID] = true + out = append(out, dr) + } + } + pages := []string{strings.TrimRight(huangguoVideoBaseURL, "/") + "/videos"} + for category := 1; category <= 4; category++ { + pages = append(pages, fmt.Sprintf("%s/videos?category=%d", strings.TrimRight(huangguoVideoBaseURL, "/"), category)) + } + for _, pageURL := range pages { + body, err := d.fetchProviderText(ctx, pageURL, huangguoVideoBaseURL+"/") + if err != nil { + lastErr = err + var backoff *requestBackoff + if errors.As(err, &backoff) || ctx.Err() != nil { + break + } + continue + } + items := parseHuangguoVideoCards(body, pageURL) + if len(items) == 0 && len(out) == 0 { + lastErr = errors.New("黄果视频页面没有可识别的剧集内容,请检查站点是否返回验证页或页面结构已变化") + break + } + add(items) + } + if len(out) == 0 && lastErr != nil { + return nil, lastErr + } + sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayTitle() < out[j].DisplayTitle() }) + return out, lastErr +} + +func (d *Downloader) GetHuangguoChapters(ctx context.Context, source, sourceID string) (string, []Chapter, error) { + switch canonicalProviderSource(source) { + case sourceHuangguoAI: + return d.fetchHuangguoAIChapters(ctx, sourceID) + case sourceHuangguoVideo: + return d.fetchHuangguoVideoChapters(ctx, sourceID) + case sourceHuangdou: + return d.fetchHuangdouChapters(ctx, sourceID) + case sourceHongguo: + return d.fetchHongguoChapters(ctx, sourceID) + default: + return "", nil, fmt.Errorf("unsupported provider source: %s", source) + } +} + +func (d *Downloader) fetchHuangguoAIChapters(ctx context.Context, sourceID string) (string, []Chapter, error) { + sourceID = strings.Trim(strings.TrimSpace(sourceID), "/") + sourceID = strings.TrimPrefix(sourceID, "detail/") + if sourceID == "" { + return "", nil, fmt.Errorf("empty huangguoai sourceID") + } + detailURL := strings.TrimRight(huangguoAIBaseURL, "/") + "/detail/" + url.PathEscape(sourceID) + "/" + body, err := d.fetchProviderText(ctx, detailURL, huangguoAIBaseURL+"/") + if err != nil { + return "", nil, err + } + title := firstNonEmpty(extractPageTitle(body), titleNearDetail(body, sourceID), "短剧") + episodes := parseHuangguoAIEpisodes(body, detailURL, sourceID) + if len(episodes) == 0 { + if media := parseAIVideoURL(body, detailURL); media != "" { + episodes = []providerEpisode{{Key: "1", Title: title, URL: detailURL, Index: 1, HLS: media}} + } + } + if len(episodes) == 0 { + return title, nil, nil + } + var chapters []Chapter + for _, ep := range episodes { + mediaURL := ep.HLS + idx := ep.Index + if idx <= 0 { + idx = len(chapters) + 1 + } + chapterTitle := strings.TrimSpace(ep.Title) + if chapterTitle == "" { + chapterTitle = fmt.Sprintf("第%d集", idx) + } + key := ep.Key + if key == "" { + key = strconv.Itoa(idx) + } + chapters = append(chapters, Chapter{ID: providerChapterID(sourceHuangguoAI, sourceID, key), Source: sourceHuangguoAI, Title: chapterTitle, VideoURL: mediaURL, PageURL: ep.URL, CurrentEpisode: rawEpisode(idx)}) + } + sortProviderChapters(chapters) + return title, uniqueChapters(chapters), nil +} + +func (d *Downloader) fetchHuangguoVideoChapters(ctx context.Context, sourceID string) (string, []Chapter, error) { + sourceID = strings.Trim(strings.TrimSpace(sourceID), "/") + if sourceID == "" { + return "", nil, fmt.Errorf("empty huangguo-video sourceID") + } + detailPath := sourceID + if !strings.HasPrefix(detailPath, "series/") && !strings.HasPrefix(detailPath, "video/") { + detailPath = "series/" + detailPath + } + detailURL := strings.TrimRight(huangguoVideoBaseURL, "/") + "/" + detailPath + body, err := d.fetchProviderText(ctx, detailURL, huangguoVideoBaseURL+"/") + if err != nil { + return "", nil, err + } + title := firstNonEmpty(extractPageTitle(body), "短剧") + var episodes []providerEpisode + if strings.HasPrefix(detailPath, "video/") { + if hls := parseDataHLS(body, detailURL); hls != "" { + episodes = []providerEpisode{{Key: "1", Title: title, URL: detailURL, Index: 1, HLS: hls}} + } + } else { + episodes = parseHuangguoVideoEpisodes(body, detailURL) + } + if len(episodes) == 0 { + if hls := parseDataHLS(body, detailURL); hls != "" { + episodes = []providerEpisode{{Key: "1", Title: title, URL: detailURL, Index: 1, HLS: hls}} + } + } + var chapters []Chapter + for _, ep := range episodes { + hlsURL := ep.HLS + idx := ep.Index + if idx <= 0 { + idx = len(chapters) + 1 + } + chapterTitle := strings.TrimSpace(ep.Title) + if chapterTitle == "" { + chapterTitle = fmt.Sprintf("第%d集", idx) + } + key := ep.Key + if key == "" { + key = strconv.Itoa(idx) + } + chapters = append(chapters, Chapter{ID: providerChapterID(sourceHuangguoVideo, sourceID, key), Source: sourceHuangguoVideo, Title: chapterTitle, VideoURL: hlsURL, PageURL: ep.URL, CurrentEpisode: rawEpisode(idx)}) + } + sortProviderChapters(chapters) + return title, uniqueChapters(chapters), nil +} +func (d *Downloader) fetchProviderText(ctx context.Context, rawURL, referer string) (string, error) { + retries := d.cfg.Retries + if retries <= 0 { + retries = 3 + } + if referer == "" { + referer = rawURL + } + candidates := d.providerURLCandidates(rawURL) + var lastErr error + tried := 0 + for _, candidate := range candidates { + if err := ctx.Err(); err != nil { + return "", err + } + attempts := retries + if len(candidates) > 1 { + attempts = 1 + } + for attempt := 1; attempt <= attempts; attempt++ { + tried++ + if attempt > 1 { + select { + case <-time.After(time.Duration(attempt) * time.Second): + case <-ctx.Done(): + return "", ctx.Err() + } + } + timeout := providerTimeout + if len(candidates) > 1 { + timeout = 5 * time.Second + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", userAgent) + if providerSourceForURL(rawURL) != "" { + req.Header.Set("Referer", providerRefererForURL(candidate, referer)) + } else { + req.Header.Set("Referer", referer) + } + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") + resp, err := d.doCatalogRequestWithTimeout(req, timeout) + if err != nil { + lastErr = err + var backoff *requestBackoff + if errors.As(err, &backoff) { + break + } + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1)) + closeErr := resp.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if closeErr != nil { + lastErr = closeErr + continue + } + if len(body) > providerMaxBodyBytes { + lastErr = fmt.Errorf("response exceeds %d bytes", providerMaxBodyBytes) + continue + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || catalogResponseBlockReason(resp, body) != "" { + lastErr = d.catalogResponseError(req, resp, body) + if catalogResponseBlockReason(resp, body) != "" || resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 408 { + break + } + continue + } + if source := providerSourceForURL(rawURL); source != "" { + effectiveURL := req.URL + if resp.Request != nil { + effectiveURL = resp.Request.URL + } + d.providerMu.Lock() + d.providerHosts[source] = effectiveURL.Scheme + "://" + effectiveURL.Host + d.providerMu.Unlock() + } + rememberPlaybackResponseURL(ctx, rawURL, resp) + return string(body), nil + } + } + if len(candidates) > 1 && lastErr != nil { + return "", fmt.Errorf("黄果来源请求失败:已尝试 %d 个域名,最后错误:%w", tried, lastErr) + } + return "", lastErr +} + +func providerURLCandidates(rawURL string) []string { + u, err := url.Parse(rawURL) + if err != nil || u.Hostname() != "huangguoai.com" { + return []string{rawURL} + } + out := make([]string, 0, len(huangguoAIBaseMirrors)+8) + seen := map[string]bool{} + add := func(candidate string) { + if candidate == "" || seen[candidate] { + return + } + seen[candidate] = true + out = append(out, candidate) + } + for i, mirror := range huangguoAIBaseMirrors { + if i == 1 { + for _, generated := range generatedHuangguoAIMirrors(rawURL, 8) { + add(rehostProviderURL(u, generated)) + } + } + add(rehostProviderURL(u, mirror)) + } + if len(out) == 0 { + return []string{rawURL} + } + return out +} + +func rehostProviderURL(u *url.URL, mirror string) string { + mu, err := url.Parse(mirror) + if err != nil || mu.Host == "" { + return "" + } + copyURL := *u + copyURL.Scheme = mu.Scheme + copyURL.Host = mu.Host + return copyURL.String() +} + +func generatedHuangguoAIMirrors(seed string, n int) []string { + if n <= 0 { + return nil + } + out := make([]string, 0, n) + seen := map[string]bool{} + for i := 0; len(out) < n && i < n*4; i++ { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", seed, i))) + hexText := fmt.Sprintf("%x", sum[:]) + sub := strings.TrimLeft(hexText[:6], "0123456789") + if len(sub) < 4 { + sub = hexText[6:12] + } + if len(sub) > 6 { + sub = sub[:6] + } + if len(sub) < 3 || seen[sub] { + continue + } + seen[sub] = true + out = append(out, "https://"+sub+".ediayikma.cc") + } + return out +} + +func providerRefererForURL(candidate, fallback string) string { + if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" { + return u.Scheme + "://" + u.Host + "/" + } + return fallback +} + +func parseHuangguoAIDramaCards(rawHTML, pageURL, category string) []Drama { + positions := map[string]int{} + var out []Drama + add := func(dr Drama) { + if dr.ID == "" || dr.SourceID == "" { + return + } + if position, found := positions[dr.SourceID]; found { + out[position] = mergeDramaMetadata(out[position], dr) + return + } + positions[dr.SourceID] = len(out) + out = append(out, dr) + } + markup := huangguoNonContent.ReplaceAllString(rawHTML, "") + for _, block := range splitHuangguoAICardBlocks(markup) { + sourceID := cleanID(firstNonEmpty(extractAttr(block, "data-track-id"), detailIDFromString(extractAttr(block, "href")))) + if sourceID == "" { + if m := reDetailHref.FindStringSubmatch(block); len(m) > 2 { + sourceID = cleanID(m[2]) + } + } + if sourceID == "" { + continue + } + title := huangguoCardTitle(block, sourceID) + cover := resolveProviderURL(pageURL, firstNonEmpty(extractAttr(block, "data-src"), extractAttr(block, "data-original"), extractAttr(block, "src"))) + desc := firstNonEmpty(extractByClassText(block, "hg-drama-card__desc"), extractDescription(block)) + score := firstNonEmpty(extractByClassText(block, "hg-drama-card__score"), firstMatchText(reScoreText, block)) + episodeBlock := huangguoClassBlock(block, "hg-drama-card__episode") + episode := firstNonEmpty(extractAttr(episodeBlock, "data-ep-base"), cleanText(episodeBlock)) + badge := extractByClassText(block, "hg-drama-card__badge") + remark := strings.TrimSpace(strings.Join(nonEmptyStrings(badge, episode), " ")) + if remark == "" { + remark = "在线观看" + } + views := normalizeViews(firstNonEmpty(firstMatchText(reViewsText, block), extractByClassText(block, "hg-drama-card__views"), extractByClassText(block, "hg-drama-card__play"))) + online := normalizeDate(firstMatchText(reDateText, block)) + tags := extractTags(block) + typeName := firstNonEmpty(extractAttr(block, "data-track-type-name"), category) + if title == "" { + title = sourceID + } + dr := Drama{ID: providerDramaID(sourceHuangguoAI, sourceID), Source: sourceHuangguoAI, SourceID: sourceID, Title: title, Name: title, Desc: desc, Intro: desc, Cover: cover, CoverURL: cover, CategoryName: typeName, ChannelName: "huangguoai.com", Remark: remark, Score: score, Views: views, OnlineDate: online, Tags: tags} + if n := episodeIndex(episode, 0); n > 0 { + dr.TotalEpisode = n + dr.EpisodeCount = n + } + add(dr) + } + for _, m := range reDetailHref.FindAllStringSubmatchIndex(markup, -1) { + if len(m) < 6 || m[4] < 0 || m[5] < 0 { + continue + } + sourceID := cleanID(markup[m[4]:m[5]]) + block := markup[m[0]:m[1]] + title := firstHuangguoTitle(sourceID, huangguoCardTitle(block, sourceID), cleanText(block)) + if title == "" { + continue + } + cover := resolveProviderURL(pageURL, extractAttr(block, "data-src", "data-original", "src")) + add(Drama{ID: providerDramaID(sourceHuangguoAI, sourceID), Source: sourceHuangguoAI, SourceID: sourceID, Title: title, Name: title, Cover: cover, CoverURL: cover, CategoryName: category, ChannelName: "huangguoai.com"}) + } + for _, dr := range parseHuangguoAIJSONLDDramas(rawHTML, category) { + add(dr) + } + return out +} + +func splitHuangguoAICardBlocks(raw string) []string { + starts := reDramaCardStart.FindAllStringIndex(raw, -1) + blocks := make([]string, 0, len(starts)) + for i, m := range starts { + end := len(raw) + if i+1 < len(starts) { + end = starts[i+1][0] + } + blocks = append(blocks, huangguoElementBlock(raw[:end], m[0], m[1], "div")) + } + return blocks +} + +func parseHuangguoAIJSONLDDramas(raw, category string) []Drama { + var out []Drama + seen := map[string]bool{} + for _, block := range regexp.MustCompile(`(?is)]*type=["']application/ld\+json["'][^>]*>(.*?)`).FindAllStringSubmatch(raw, -1) { + if len(block) < 2 { + continue + } + var value any + if json.Unmarshal([]byte(html.UnescapeString(block[1])), &value) != nil { + continue + } + var walk func(any) + walk = func(v any) { + switch x := v.(type) { + case []any: + for _, item := range x { + walk(item) + } + case map[string]any: + name, _ := x["name"].(string) + itemURL, _ := x["url"].(string) + id := detailIDFromString(itemURL) + if id != "" && !huangguoTitleNeedsRepair(name, id) && !seen[id] { + seen[id] = true + title := cleanText(name) + out = append(out, Drama{ID: providerDramaID(sourceHuangguoAI, id), Source: sourceHuangguoAI, SourceID: id, Title: title, Name: title, CategoryName: category, ChannelName: "huangguoai.com"}) + } + for _, child := range x { + walk(child) + } + } + } + walk(value) + } + return out +} + +func parseHuangguoAIJSONCards(raw []byte, pageURL, category string) []Drama { + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil + } + seen := map[string]bool{} + var out []Drama + var walk func(any) + walk = func(v any) { + switch x := v.(type) { + case []any: + for _, item := range x { + walk(item) + } + case map[string]any: + if dr, ok := dramaFromAIMap(x, pageURL, category); ok { + if !seen[dr.SourceID] { + seen[dr.SourceID] = true + out = append(out, dr) + } + return + } + for _, child := range x { + walk(child) + } + } + } + walk(decoded) + return out +} + +func dramaFromAIMap(m map[string]any, pageURL, category string) (Drama, bool) { + sourceID := firstNonEmpty(mapString(m, "id"), mapString(m, "videoId"), mapString(m, "video_id"), mapString(m, "vid"), mapString(m, "slug")) + if sourceID == "" { + for _, v := range m { + if s, ok := v.(string); ok { + if id := detailIDFromString(s); id != "" { + sourceID = id + break + } + } + } + } + if detailID := detailIDFromString(sourceID); detailID != "" { + sourceID = detailID + } + sourceID = cleanID(sourceID) + if sourceID == "" { + return Drama{}, false + } + title := firstNonEmpty(mapString(m, "title"), mapString(m, "name"), mapString(m, "videoTitle"), mapString(m, "video_title"), mapString(m, "videoName"), mapString(m, "video_name")) + if huangguoTitleNeedsRepair(title, sourceID) { + return Drama{}, false + } + cover := resolveProviderURL(pageURL, firstNonEmpty(mapString(m, "cover"), mapString(m, "coverUrl"), mapString(m, "cover_url"), mapString(m, "coverImage"), mapString(m, "cover_image"), mapString(m, "image"), mapString(m, "imageUrl"), mapString(m, "thumbnail"), mapString(m, "poster"), mapString(m, "posterUrl"))) + desc := firstNonEmpty(mapString(m, "desc"), mapString(m, "description"), mapString(m, "intro"), mapString(m, "summary")) + eps := huangguoMapEpisodes(m) + remark := firstNonEmpty(mapString(m, "vod_remarks"), mapString(m, "remark"), mapString(m, "remarks")) + if remark == "" && eps != "" { + if strings.EqualFold(mapString(m, "is_finished"), "true") || mapString(m, "is_finished") == "1" { + remark = "全" + eps + "集" + } else { + remark = "更新至 " + eps + " 集" + } + } + score := firstNonEmpty(mapString(m, "score"), mapString(m, "rating")) + if score != "" && !strings.Contains(score, "分") { + score += "分" + } + views := normalizeViews(firstNonEmpty(mapString(m, "views"), mapString(m, "view_count"), mapString(m, "viewCount"), mapString(m, "view_num"), mapString(m, "viewNum"), mapString(m, "views_text"), mapString(m, "view_count_text"), mapString(m, "play_count"), mapString(m, "playCount"), mapString(m, "play_num"), mapString(m, "playNum"), mapString(m, "play_count_text"), mapString(m, "hits"), mapString(m, "hit_count"), mapString(m, "plays"), mapString(m, "play"))) + online := providerReleaseDate(firstNonEmpty(mapString(m, "onlineDate"), mapString(m, "online_date"), mapString(m, "online_time"), mapString(m, "onlineTime"), mapString(m, "publish_date"), mapString(m, "publishDate"), mapString(m, "publish_time"), mapString(m, "publishTime"), mapString(m, "release_date"), mapString(m, "releaseDate"))) + tags := mapStringSlice(m, "tags", "tag", "categories", "category") + return Drama{ID: providerDramaID(sourceHuangguoAI, sourceID), Source: sourceHuangguoAI, SourceID: sourceID, Title: title, Name: title, Desc: desc, Intro: desc, Cover: cover, CoverURL: cover, TotalEpisode: eps, EpisodeCount: eps, CategoryName: category, ChannelName: "huangguoai.com", Remark: remark, Score: score, Views: views, OnlineDate: online, Tags: tags}, true +} + +func parseHuangguoVideoCards(rawHTML, pageURL string) []Drama { + blocks := reVideoCard.FindAllString(rawHTML, -1) + if len(blocks) == 0 { + for _, block := range reAnyArticle.FindAllString(rawHTML, -1) { + if strings.Contains(strings.ToLower(block), "video-card") { + blocks = append(blocks, block) + } + } + } + seen := map[string]bool{} + var out []Drama + for _, block := range blocks { + link := reHuangguoLink.FindStringSubmatch(block) + if len(link) < 3 { + continue + } + path, sourceID := huangguoVideoSourceID(link[1]) + if sourceID == "" || seen[sourceID] { + continue + } + seen[sourceID] = true + title := firstNonEmpty(extractClosestAttr(block, 0, len(block), "title", "alt", "aria-label"), cleanText(block)) + cover := resolveProviderURL(pageURL, extractClosestAttr(block, 0, len(block), "data-src", "data-original", "src")) + category := "video" + if strings.HasPrefix(path, "series/") { + category = "series" + } + desc := firstNonEmpty(extractClosestAttr(block, 0, len(block), "data-description", "description"), extractDescription(block)) + out = append(out, Drama{ID: providerDramaID(sourceHuangguoVideo, sourceID), Source: sourceHuangguoVideo, SourceID: sourceID, Title: title, Name: title, Desc: desc, Intro: desc, Cover: cover, CoverURL: cover, CategoryName: category, ChannelName: "huangguo.video", Remark: firstNonEmpty(extractByClassText(block, "bg-black/55"), extractByClassText(block, "text-gold-dim"))}) + } + return out +} + +func parseHuangguoAIEpisodes(rawHTML, pageURL, sourceID string) []providerEpisode { + matches := reAIEpisodeLink.FindAllStringSubmatchIndex(rawHTML, -1) + seen := map[string]bool{} + var episodes []providerEpisode + usedIndex := map[int]bool{} + for _, m := range matches { + if len(m) < 4 || m[2] < 0 || m[3] < 0 { + continue + } + href := rawHTML[m[2]:m[3]] + fullURL := resolveProviderURL(pageURL, href) + linkHTML := rawHTML[m[0]:m[1]] + title := firstNonEmpty(extractAttr(linkHTML, "title", "aria-label"), cleanText(linkHTML)) + key := aiEpisodeKey(href, sourceID) + if key == "" { + key = fmt.Sprintf("auto-%d", len(episodes)+1) + } + if seen[key] || seen[fullURL] { + continue + } + seen[key] = true + seen[fullURL] = true + idx := episodeIndex(title, 0) + if idx <= 0 { + idx = len(episodes) + 1 + } + idx = nextUnusedEpisodeIndex(idx, usedIndex) + usedIndex[idx] = true + episodes = append(episodes, providerEpisode{Key: key, Title: title, URL: fullURL, Index: idx}) + } + sortProviderEpisodes(episodes) + return episodes +} + +func parseHuangguoVideoEpisodes(rawHTML, pageURL string) []providerEpisode { + blocks := reVideoCard.FindAllString(rawHTML, -1) + if len(blocks) == 0 { + blocks = reHuangguoLink.FindAllString(rawHTML, -1) + } + seen := map[string]bool{} + var episodes []providerEpisode + for _, block := range blocks { + link := reHuangguoLink.FindStringSubmatch(block) + if len(link) < 2 { + continue + } + path, key := huangguoVideoSourceID(link[1]) + if key == "" || !strings.HasPrefix(path, "video/") { + continue + } + fullURL := resolveProviderURL(pageURL, link[1]) + if seen[key] || seen[fullURL] { + continue + } + seen[key] = true + seen[fullURL] = true + title := firstNonEmpty(extractAttr(block, "title"), extractAttr(block, "alt"), cleanText(block)) + episodes = append(episodes, providerEpisode{Key: key, Title: title, URL: fullURL, Index: episodeIndex(title, len(episodes)+1), HLS: parseDataHLS(block, pageURL)}) + } + sortProviderEpisodes(episodes) + return episodes +} + +func parseAIVideoURL(rawHTML, pageURL string) string { + if media := parseAIVideoInitialData(rawHTML, pageURL); media != "" { + return media + } + if m := reDataPlaySrc.FindStringSubmatch(rawHTML); len(m) > 1 { + if media := normalizeProviderMediaURL(pageURL, m[1]); media != "" { + return media + } + } + for _, m := range reMediaFieldValue.FindAllStringSubmatch(rawHTML, -1) { + if len(m) > 1 { + if media := normalizeProviderMediaURL(pageURL, m[1]); media != "" { + return media + } + } + } + return "" +} + +func parseAIVideoInitialData(rawHTML, pageURL string) string { + re := regexp.MustCompile(`(?is)]*id=["']videoInitialData["'][^>]*>(.*?)`) + m := re.FindStringSubmatch(rawHTML) + if len(m) < 2 { + return "" + } + var data map[string]any + if err := json.Unmarshal([]byte(html.UnescapeString(m[1])), &data); err != nil { + return "" + } + if media := normalizeProviderMediaURL(pageURL, mapString(data, "videoSrc", "videoUrl", "playUrl")); media != "" { + return media + } + eps, _ := data["epPlaySrcs"].(map[string]any) + if len(eps) == 0 { + return "" + } + preferred := firstNonEmpty(mapString(data, "ep", "episode")) + if m := regexp.MustCompile(`/ep-(\d+)(?:/|$)`).FindStringSubmatch(pageURL); len(m) > 1 { + preferred = m[1] + } + if preferred != "" { + if media := normalizeProviderMediaURL(pageURL, fmt.Sprint(eps[preferred])); media != "" { + return media + } + } + keys := make([]string, 0, len(eps)) + for key := range eps { + keys = append(keys, key) + } + sort.SliceStable(keys, func(i, j int) bool { return episodeIndex(keys[i], i+1) < episodeIndex(keys[j], j+1) }) + for _, key := range keys { + if media := normalizeProviderMediaURL(pageURL, fmt.Sprint(eps[key])); media != "" { + return media + } + } + return "" +} + +func parseDataHLS(rawHTML, pageURL string) string { + if m := reDataHLS.FindStringSubmatch(rawHTML); len(m) > 1 { + return normalizeProviderMediaURL(pageURL, m[1]) + } + return "" +} + +func (d *Downloader) resolveHuangguoVideoHLS(ctx context.Context, hlsURL, referer string) string { + if strings.HasSuffix(strings.ToLower(strings.SplitN(hlsURL, "?", 2)[0]), ".mp4") { + return hlsURL + } + body, err := d.fetchProviderText(ctx, hlsURL, referer) + if err != nil { + return hlsURL + } + if best := selectBestM3U8Variant(body, hlsURL); best != "" { + return best + } + return hlsURL +} + +func selectBestM3U8Variant(master, masterURL string) string { + playlistURL, err := url.Parse(masterURL) + if err != nil { + return "" + } + lines := strings.Split(master, "\n") + type variant struct { + url string + height int + bandwidth int + order int + } + var variants []variant + pending := variant{} + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") { + pending = variant{} + if m := reResolution.FindStringSubmatch(trimmed); len(m) > 2 { + pending.height, _ = strconv.Atoi(m[2]) + } + if m := reBandwidth.FindStringSubmatch(trimmed); len(m) > 1 { + pending.bandwidth, _ = strconv.Atoi(m[1]) + } + continue + } + if strings.HasPrefix(trimmed, "#") { + continue + } + if pending.height > 0 || pending.bandwidth > 0 { + reference, err := url.Parse(trimmed) + if err != nil { + pending = variant{} + continue + } + pending.url = playlistURL.ResolveReference(reference).String() + if !isProviderHTTPMediaURL(pending.url) { + pending = variant{} + continue + } + pending.order = len(variants) + variants = append(variants, pending) + pending = variant{} + } + } + if len(variants) == 0 { + return "" + } + sort.SliceStable(variants, func(i, j int) bool { + if variants[i].height != variants[j].height { + return variants[i].height > variants[j].height + } + if variants[i].bandwidth != variants[j].bandwidth { + return variants[i].bandwidth > variants[j].bandwidth + } + return variants[i].order < variants[j].order + }) + return variants[0].url +} + +func sortProviderEpisodes(episodes []providerEpisode) { + sort.SliceStable(episodes, func(i, j int) bool { + if episodes[i].Index != episodes[j].Index { + return episodes[i].Index < episodes[j].Index + } + return episodes[i].Key < episodes[j].Key + }) +} + +func sortProviderChapters(chapters []Chapter) { + sort.SliceStable(chapters, func(i, j int) bool { + ei := chapterEpisodeNumber(chapters[i], i+1) + ej := chapterEpisodeNumber(chapters[j], j+1) + if ei != ej { + return ei < ej + } + return chapters[i].ID < chapters[j].ID + }) +} + +func chapterEpisodeNumber(ch Chapter, fallback int) int { + if len(ch.CurrentEpisode) > 0 && string(ch.CurrentEpisode) != "null" { + var n int + if err := json.Unmarshal(ch.CurrentEpisode, &n); err == nil && n > 0 { + return n + } + var s string + if err := json.Unmarshal(ch.CurrentEpisode, &s); err == nil { + return episodeIndex(s, fallback) + } + } + return episodeIndex(ch.Title, fallback) +} + +func rawEpisode(n int) json.RawMessage { + if n <= 0 { + n = 1 + } + return json.RawMessage(strconv.Itoa(n)) +} + +func episodeIndex(s string, fallback int) int { + if m := reEpisodeNumber.FindStringSubmatch(s); len(m) > 1 { + for _, group := range m[1:] { + if group == "" { + continue + } + if n, err := strconv.Atoi(group); err == nil && n > 0 { + return n + } + } + } + return fallback +} + +func nextUnusedEpisodeIndex(preferred int, used map[int]bool) int { + if preferred <= 0 { + preferred = 1 + } + if !used[preferred] { + return preferred + } + for i := 1; ; i++ { + if !used[i] { + return i + } + } +} +func aiEpisodeKey(href, sourceID string) string { + u, err := url.Parse(html.UnescapeString(href)) + path := href + if err == nil { + path = u.Path + } + path = strings.Trim(strings.TrimPrefix(path, "/video/"), "/") + parts := strings.Split(path, "/") + if len(parts) > 1 && parts[0] == sourceID { + return cleanID(parts[len(parts)-1]) + } + if len(parts) == 1 && parts[0] == sourceID { + return "" + } + return cleanID(strings.Join(parts, "-")) +} + +func huangguoVideoSourceID(href string) (path, sourceID string) { + u, err := url.Parse(html.UnescapeString(href)) + if err == nil { + path = strings.Trim(u.Path, "/") + } else { + path = strings.Trim(href, "/") + } + parts := strings.Split(path, "/") + for i := 0; i+1 < len(parts); i++ { + if parts[i] == "series" || parts[i] == "video" { + code := cleanID(parts[i+1]) + if code == "" { + return "", "" + } + return parts[i] + "/" + code, parts[i] + "/" + code + } + } + return "", "" +} + +func detailIDFromString(s string) string { + u, err := url.Parse(html.UnescapeString(s)) + path := s + if err == nil { + path = u.Path + } + parts := strings.Split(strings.Trim(path, "/"), "/") + for i := 0; i+1 < len(parts); i++ { + if parts[i] == "detail" { + return cleanID(parts[i+1]) + } + } + return "" +} + +func cleanID(s string) string { + s = strings.TrimSpace(html.UnescapeString(s)) + s = strings.Trim(s, " /\t\r\n\"'") + if q := strings.IndexAny(s, "?#"); q >= 0 { + s = s[:q] + } + return s +} + +func extractPageTitle(rawHTML string) string { + if m := reH1Tag.FindStringSubmatch(rawHTML); len(m) > 1 { + if t := cleanText(m[1]); t != "" { + return t + } + } + if m := regexp.MustCompile(`(?is)]*property=["']og:title["'][^>]*content=["']([^"']+)["'][^>]*>`).FindStringSubmatch(rawHTML); len(m) > 1 { + if t := strings.TrimSpace(html.UnescapeString(m[1])); t != "" { + return t + } + } + if m := reTitleTag.FindStringSubmatch(rawHTML); len(m) > 1 { + return cleanText(m[1]) + } + return "" +} + +func extractDescription(rawHTML string) string { + if m := reDescMeta.FindStringSubmatch(rawHTML); len(m) > 1 { + return strings.TrimSpace(html.UnescapeString(m[1])) + } + return "" +} + +func extractAttr(block string, names ...string) string { + for _, name := range names { + re := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']+)["']`) + if m := re.FindStringSubmatch(block); len(m) > 1 { + return strings.TrimSpace(html.UnescapeString(m[1])) + } + } + return "" +} + +func extractByClassText(raw, className string) string { + if className == "" { + return "" + } + re := regexp.MustCompile(`(?is)<[^>]+class=["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)]+>`) + if m := re.FindStringSubmatch(raw); len(m) > 1 { + return cleanText(m[1]) + } + return "" +} + +func firstMatchText(re *regexp.Regexp, raw string) string { + if m := re.FindString(raw); m != "" { + return strings.TrimSpace(m) + } + return "" +} + +func extractTags(raw string) []string { + re := regexp.MustCompile(`(?is)<[^>]+class=["'][^"']*hg-tag[^"']*["'][^>]*>(.*?)]+>`) + seen := map[string]bool{} + var out []string + for _, m := range re.FindAllStringSubmatch(raw, -1) { + if len(m) < 2 { + continue + } + v := cleanText(m[1]) + if v != "" && !seen[v] { + seen[v] = true + out = append(out, v) + } + } + return out +} + +func normalizeDate(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + matches := reDateText.FindAllString(s, -1) + for _, m := range matches { + parts := strings.Split(m, "-") + if len(parts) != 3 { + continue + } + y, yErr := strconv.Atoi(parts[0]) + mo, moErr := strconv.Atoi(parts[1]) + d, dErr := strconv.Atoi(parts[2]) + if yErr == nil && moErr == nil && dErr == nil && y >= 2000 && y <= 2100 && mo >= 1 && mo <= 12 && d >= 1 && d <= 31 { + return fmt.Sprintf("%04d-%02d-%02d", y, mo, d) + } + } + return "" +} + +func normalizeViews(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if strings.Contains(s, "次播放") { + return s + } + if strings.ContainsAny(strings.ToLower(s), "w万") { + return s + "次播放" + } + n, err := strconv.ParseFloat(s, 64) + if err != nil || n <= 0 { + return s + } + return strconv.FormatFloat(n, 'f', -1, 64) + "次播放" +} + +func cleanText(s string) string { + s = regexp.MustCompile(`(?is)`).ReplaceAllString(s, " ") + s = regexp.MustCompile(`(?is)`).ReplaceAllString(s, " ") + s = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(s, " ") + s = html.UnescapeString(s) + s = spaceChars.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} + +func contextBlock(s string, start, end, padding int) string { + if start < 0 { + start = 0 + } + if end < start { + end = start + } + lo := start - padding + if lo < 0 { + lo = 0 + } + hi := end + padding + if hi > len(s) { + hi = len(s) + } + return s[lo:hi] +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func nonEmptyStrings(values ...string) []string { + var out []string + for _, v := range values { + if strings.TrimSpace(v) != "" { + out = append(out, strings.TrimSpace(v)) + } + } + return out +} + +func mapString(m map[string]any, keys ...string) string { + for _, key := range keys { + if v, ok := m[key]; ok { + switch x := v.(type) { + case string: + if text := strings.TrimSpace(x); text != "" { + return text + } + case bool: + return strconv.FormatBool(x) + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'f', -1, 64) + case int: + return strconv.Itoa(x) + case json.Number: + return x.String() + } + } + } + return "" +} + +func mapStringSlice(m map[string]any, keys ...string) []string { + seen := map[string]bool{} + var out []string + add := func(s string) { + s = strings.TrimSpace(s) + if s != "" && !seen[s] { + seen[s] = true + out = append(out, s) + } + } + for _, key := range keys { + v, ok := m[key] + if !ok { + continue + } + switch x := v.(type) { + case string: + for _, part := range strings.FieldsFunc(x, func(r rune) bool { return r == ',' || r == '/' || r == ',' || r == '、' }) { + add(part) + } + case []any: + for _, item := range x { + add(fmt.Sprint(item)) + } + } + } + return out +} + +func extractClosestAttr(raw string, start, end int, names ...string) string { + if start < 0 { + start = 0 + } + if end < start { + end = start + } + if end > len(raw) { + end = len(raw) + } + for _, value := range []string{raw[start:end], contextBlock(raw, start, end, 600)} { + if value == "" { + continue + } + if attr := extractAttr(value, names...); attr != "" { + return attr + } + } + return "" +} + +func titleNearDetail(raw, sourceID string) string { + needle := "/detail/" + sourceID + idx := strings.Index(raw, needle) + if idx < 0 { + return "" + } + return extractByClassText(contextBlock(raw, idx, idx+len(needle), 1800), "hg-drama-card__title") +} + +func resolveProviderURL(baseURL, ref string) string { + ref = strings.TrimSpace(html.UnescapeString(ref)) + if ref == "" { + return "" + } + ref = strings.ReplaceAll(ref, `\/`, `/`) + if strings.HasPrefix(ref, "//") { + return "https:" + ref + } + u, err := url.Parse(ref) + if err != nil { + return ref + } + if u.IsAbs() { + if u.Scheme == "http" { + u.Scheme = "https" + } + return u.String() + } + base, err := url.Parse(baseURL) + if err != nil { + return ref + } + resolved := base.ResolveReference(u) + if resolved.Scheme == "http" { + resolved.Scheme = "https" + } + return resolved.String() +} + +func normalizeProviderMediaURL(pageURL, raw string) string { + raw = strings.TrimSpace(html.UnescapeString(raw)) + raw = strings.Trim(raw, " \t\r\n\"'") + if raw == "" { + return "" + } + if unquoted, err := strconv.Unquote("\"" + strings.ReplaceAll(raw, "\"", "\\\"") + "\""); err == nil { + raw = unquoted + } + raw = strings.ReplaceAll(raw, `\/`, `/`) + resolved := resolveProviderURL(pageURL, raw) + if !strings.Contains(strings.ToLower(resolved), ".m3u8") && !strings.Contains(strings.ToLower(resolved), ".mp4") { + return "" + } + return resolved +} + +func isHuangguoProviderChapter(ch Chapter) bool { + return isHuangguoProviderSource(ch.Source) && isProviderHTTPMediaURL(ch.VideoURL) +} + +func isProviderHTTPMediaURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Hostname() != "" && parsed.User == nil +} + +func (d *Downloader) downloadHuangguoProviderMediaWithProgress(ctx context.Context, task Task, callback func(DownloadProgress)) error { + if ok, size := existingGood(task.OutPath, d.cfg.SkipBytes); ok { + if callback != nil { + callback(DownloadProgress{Percent: 100, DownloadedBytes: size, TotalBytes: size, Phase: "completed"}) + } + return nil + } + if callback != nil { + callback(DownloadProgress{Phase: "preparing"}) + } + ffmpeg, err := d.ensureFFmpeg(ctx) + if err != nil { + return err + } + partPath := strings.TrimSuffix(task.OutPath, filepath.Ext(task.OutPath)) + ".part.mp4" + _ = os.Remove(partPath) + defer os.Remove(partPath) + if err := os.MkdirAll(filepath.Dir(task.OutPath), 0o755); err != nil { + return err + } + progress := newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback) + progress.report("resolving", true) + retries := d.cfg.Retries + if retries <= 0 { + retries = 3 + } + var lastErr error + for attempt := 1; attempt <= retries; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + if attempt > 1 { + _ = os.Remove(partPath) + progress = newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback) + progress.report("retrying", true) + select { + case <-time.After(time.Duration(attempt*2) * time.Second): + case <-ctx.Done(): + return ctx.Err() + } + } + progress.report("resolving", true) + media, err := d.resolveProviderMedia(ctx, task) + if err != nil { + lastErr = publicError(err) + if isHuangdouAccessError(err) { + return lastErr + } + continue + } + media, err = d.selectDownloadQuality(ctx, task, media) + if err != nil { + lastErr = publicError(err) + continue + } + if len(media.CENCKey) != 0 && (len(media.CENCKey) != 16 || media.Playlist != "") { + return errors.New("CENC 媒体的密钥或格式无效") + } + progress.setMediaTotal(media.Duration) + proxy, err := d.newHLSProxy(ctx, media, nil) + if err != nil { + return err + } + inputURL := proxy.root + origin, _ := url.Parse(media.Referer) + headers := fmt.Sprintf("User-Agent: %s\r\nReferer: %s\r\n", userAgent, media.Referer) + if origin != nil && origin.Host != "" { + headers += "Origin: " + origin.Scheme + "://" + origin.Host + "\r\n" + } + cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Minute) + args := []string{ + "-hide_banner", "-loglevel", "error", "-nostats", + "-rw_timeout", "20000000", + "-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy", + "-headers", headers, + } + if media.Playlist != "" { + args = append(args, "-allowed_extensions", "ALL") + } + if len(media.CENCKey) > 0 { + args = append(args, "-decryption_key", hex.EncodeToString(media.CENCKey)) + } + args = append(args, + "-i", inputURL, + "-c", "copy", + "-progress", "pipe:1", + "-f", "mp4", + "-y", partPath, + ) + cmd := ffmpegMediaCommand(cmdCtx, ffmpeg, args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + if proxy != nil { + proxy.Close() + } + cancel() + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + if proxy != nil { + proxy.Close() + } + cancel() + return err + } + if err := cmd.Start(); err != nil { + if proxy != nil { + proxy.Close() + } + cancel() + lastErr = err + continue + } + progress.report("downloading", true) + stderrDone := make(chan string, 1) + go func() { + var b cappedStringWriter + b.limit = 64 * 1024 + _, _ = io.Copy(&b, stderr) + stderrDone <- b.String() + }() + progressDone := make(chan struct{}) + go func() { + defer close(progressDone) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + key, val, ok := strings.Cut(scanner.Text(), "=") + if ok && (key == "out_time_us" || key == "out_time_ms") { + if micros, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil && micros >= 0 { + progress.setMediaElapsed(time.Duration(micros) * time.Microsecond) + progress.report("downloading", false) + } + } + } + }() + tickerDone := make(chan struct{}) + go func() { + defer close(tickerDone) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + progress.report("downloading", false) + case <-progressDone: + return + } + } + }() + waitErr := cmd.Wait() + <-progressDone + <-tickerDone + stderrText := <-stderrDone + commandErr := cmdCtx.Err() + cancel() + var proxyErr error + if proxy != nil { + proxyErr = proxy.Err() + proxy.Close() + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if commandErr != nil { + lastErr = commandErr + _ = os.Remove(partPath) + continue + } + if proxyErr != nil { + lastErr = proxyErr + continue + } + if waitErr != nil { + lastErr = fmt.Errorf("ffmpeg failed: %v %s", waitErr, truncate(stderrText, 1000)) + _ = os.Remove(partPath) + continue + } + st, err := os.Stat(partPath) + if err != nil { + lastErr = err + continue + } + if st.Size() < 100*1024 { + lastErr = fmt.Errorf("downloaded file too small: %d bytes", st.Size()) + _ = os.Remove(partPath) + continue + } + if err := os.Rename(partPath, task.OutPath); err != nil { + lastErr = err + continue + } + progress.report("completed", true) + return nil + } + return lastErr +} + +func providerReferer(source string) string { + if source == sourceHuangguoVideo { + return huangguoVideoBaseURL + "/" + } + return huangguoAIBaseURL + "/" +} diff --git a/internal/app/provider_huangguo_cards.go b/internal/app/provider_huangguo_cards.go new file mode 100644 index 0000000..7f4c7bb --- /dev/null +++ b/internal/app/provider_huangguo_cards.go @@ -0,0 +1,94 @@ +package app + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const huangguoMetadataVersion = 2 + +var huangguoHTMLTag = regexp.MustCompile(`(?is)<(/?)([a-z][a-z0-9:-]*)\b(?:[^<>"']|"[^"]*"|'[^']*')*>`) +var huangguoNonContent = regexp.MustCompile(`(?is)|]*>.*?|]*>.*?`) +var huangguoTitleFragment = regexp.MustCompile(`(?i)(?:\b(?:class|type|title|aria-label)\s*=|(?:data|ata)-search-input|hg-search(?:__|-))`) + +func huangguoElementBlock(raw string, start, end int, tag string) string { + depth := 1 + for _, match := range huangguoHTMLTag.FindAllStringSubmatchIndex(raw[end:], -1) { + if !strings.EqualFold(raw[end+match[4]:end+match[5]], tag) { + continue + } + if match[3] > match[2] { + depth-- + } else if !strings.HasSuffix(strings.TrimSpace(raw[end+match[0]:end+match[1]]), "/>") { + depth++ + } + if depth == 0 { + return raw[start : end+match[1]] + } + } + return raw[start:end] +} + +func huangguoClassBlock(raw, className string) string { + for _, match := range huangguoHTMLTag.FindAllStringSubmatchIndex(raw, -1) { + if match[3] > match[2] { + continue + } + for _, class := range strings.Fields(extractAttr(raw[match[0]:match[1]], "class")) { + if class == className { + return huangguoElementBlock(raw, match[0], match[1], raw[match[4]:match[5]]) + } + } + } + return "" +} + +func huangguoClassText(raw, className string) string { + return cleanText(huangguoClassBlock(raw, className)) +} + +func huangguoTitleNeedsRepair(title, sourceID string) bool { + title = strings.TrimSpace(title) + return title == "" || title == sourceID || title == "短剧" || title == "搜索" || + huangguoTitleFragment.MatchString(title) || strings.Contains(title, "搜索记录") && strings.Contains(title, "暂无搜索记录") +} + +func firstHuangguoTitle(sourceID string, values ...string) string { + for _, title := range values { + if !huangguoTitleNeedsRepair(title, sourceID) { + return strings.TrimSpace(title) + } + } + return "" +} + +func huangguoCardTitle(block, sourceID string) string { + return firstHuangguoTitle(sourceID, + extractAttr(block, "data-track-title"), huangguoClassText(block, "hg-drama-card__title"), + extractAttr(block, "data-search-hot"), huangguoClassText(block, "hg-search-suggest__hot-title"), + extractAttr(block, "title"), extractAttr(block, "alt"), extractAttr(block, "aria-label")) +} + +func huangguoNeedsContentMetadata(drama Drama) bool { + if dramaProvider(drama) != sourceHuangguoAI { + return false + } + _, sourceID, _ := splitProviderDramaID(drama.ID) + return huangguoTitleNeedsRepair(drama.DisplayTitle(), sourceID) || valueEmpty(drama.TotalEpisode) && valueEmpty(drama.EpisodeCount) +} + +func huangguoContentChanged(previous, updated Drama) bool { + return dramaProvider(previous) == sourceHuangguoAI && (previous.Title != updated.Title || previous.Name != updated.Name || + fmt.Sprint(previous.TotalEpisode) != fmt.Sprint(updated.TotalEpisode) || fmt.Sprint(previous.EpisodeCount) != fmt.Sprint(updated.EpisodeCount)) +} + +func huangguoMapEpisodes(row map[string]any) string { + for _, key := range []string{"episode_count", "episodeCount", "total_episodes", "totalEpisodes", "totalEpisode", "total_episode", "episodes", "total"} { + if count, err := strconv.Atoi(mapString(row, key)); err == nil && count > 0 { + return strconv.Itoa(count) + } + } + return "" +} diff --git a/internal/app/provider_huangguo_cards_test.go b/internal/app/provider_huangguo_cards_test.go new file mode 100644 index 0000000..850ffaa --- /dev/null +++ b/internal/app/provider_huangguo_cards_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "fmt" + "testing" +) + +func TestHuangguoCardsKeepSearchSuggestionsInsideTheirLinks(t *testing.T) { + body := `
搜索记录 清除 暂无搜索记录
+1搜索建议甲2775.8w +2搜索建议乙123.4w
+ + +
搜索记录 清除 暂无搜索记录
` + items := parseHuangguoAIDramaCards(body, huangguoAIBaseURL+"/newest/", "最近上新") + want := map[string]string{"12": "搜索建议甲", "117": "搜索建议乙", "501": "新剧样本", "502": "旧剧样本"} + if len(items) != len(want) { + t.Fatalf("got %d dramas, want %d", len(items), len(want)) + } + for _, item := range items { + if item.Title != want[item.SourceID] || item.Name != want[item.SourceID] { + t.Errorf("%s got title %q and name %q", item.ID, item.Title, item.Name) + } + switch item.SourceID { + case "501": + if fmt.Sprint(item.TotalEpisode) != "12" || fmt.Sprint(item.EpisodeCount) != "12" || item.Remark != "新上架 更新至12集" { + t.Errorf("new card lost its count: count=%v remark=%q", item.TotalEpisode, item.Remark) + } + case "502": + if fmt.Sprint(item.TotalEpisode) != "8" { + t.Errorf("nested episode text lost its count: %v", item.TotalEpisode) + } + } + } +} + +func TestHuangguoCardsDoNotBorrowTitlesFromNeighborsOrFooter(t *testing.T) { + body := `
+

隔壁剧名

+
+

页脚文字

搜索记录 清除 暂无搜索记录
+` + items := parseHuangguoAIDramaCards(body, huangguoAIBaseURL, "测试") + want := map[string]string{"501": "结构化剧名", "502": "隔壁剧名", "503": "503"} + if len(items) != len(want) { + t.Fatalf("got %d dramas, want %d", len(items), len(want)) + } + for _, item := range items { + if item.Title != want[item.SourceID] { + t.Errorf("%s borrowed a title: %q", item.ID, item.Title) + } + } +} + +func TestHuangguoNestedDetailMetaKeepsCountsAndViewsScoped(t *testing.T) { + for _, entry := range []struct { + name string + meta string + count string + }{ + {"new", `8.7分1小时前更新至 12 集 · 4.1w次播放`, "12"}, + {"finished", `9.9分已完结 · 共 21 集 · 4.1w次播放`, "21"}, + {"unknown", `8.7分新上架 · 1小时前 · 4.1w次播放`, ""}, + } { + t.Run(entry.name, func(t *testing.T) { + body := `
` + entry.meta + `
全99集900w次播放
` + item, err := parseHuangguoSortDetail(body, huangguoAIBaseURL+"/detail/453/", Drama{ID: "huangguoai:453", Source: sourceHuangguoAI, SourceID: "453"}) + if err != nil || item.Title != "详情剧名" || item.Views != "4.1w次播放" || item.OnlineDate != "2026-09-14" { + t.Fatalf("wrong detail metadata: title=%q views=%q date=%q error=%v", item.Title, item.Views, item.OnlineDate, err) + } + if entry.count == "" { + if !valueEmpty(item.TotalEpisode) || !valueEmpty(item.EpisodeCount) { + t.Fatal("invented a count from relative time or recommendations") + } + } else if fmt.Sprint(item.TotalEpisode) != entry.count || fmt.Sprint(item.EpisodeCount) != entry.count { + t.Fatalf("got count %v, want %s", item.TotalEpisode, entry.count) + } + }) + } +} diff --git a/internal/app/provider_huangguo_catalog_test.go b/internal/app/provider_huangguo_catalog_test.go new file mode 100644 index 0000000..d21325f --- /dev/null +++ b/internal/app/provider_huangguo_catalog_test.go @@ -0,0 +1,61 @@ +package app + +import ( + "context" + "net/http" + "strings" + "sync/atomic" + "testing" +) + +func TestHuangguoVideoStopsOnCloudflareBlock(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return rankingHTTPResponse(request, 403, "Cloudflare: Sorry, you have been blocked"), nil + }) + items, err := d.fetchHuangguoVideoDramas(context.Background()) + if len(items) != 0 || err == nil || !strings.Contains(err.Error(), "Cloudflare 拒绝了当前请求") || calls.Load() != 1 { + t.Fatal("blocked catalog lost its cause or repeated requests", err) + } +} + +func TestHuangguoAICatalogUsesAvailableCategoryAPIs(t *testing.T) { + var APIs atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Path, "/api/videos/category/") { + APIs.Add(1) + if !strings.Contains(request.URL.Path, "/category/ai-") { + t.Error("HTML recommendation page was requested through a nonexistent category API") + } + return rankingHTTPResponse(request, 200, `{"data":{"items":[{"id":"123","title":"无图目录样本"}],"pagination":{"pages":1}}}`), nil + } + return rankingHTTPResponse(request, 200, "local text fixture"), nil + }) + d.cfg.MaxPagesPerSort = 1 + items, err := d.fetchHuangguoAIDramas(context.Background()) + if err != nil || len(items) != 1 || APIs.Load() != 4 { + t.Fatalf("catalog paths: items=%d APIs=%d error=%v", len(items), APIs.Load(), err) + } +} + +func TestHuangguoVideoValidListAllowsEmptyCategories(t *testing.T) { + for _, validList := range []bool{true, false} { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + body := "
当前分类暂无内容
" + if validList && request.URL.RawQuery == "" { + body = `` + } + return rankingHTTPResponse(request, 200, body), nil + }) + items, err := d.fetchHuangguoVideoDramas(context.Background()) + if validList && (err != nil || len(items) != 1 || calls.Load() != 5) { + t.Fatal("empty category rejected a valid catalog", err, len(items), calls.Load()) + } + if !validList && (err == nil || len(items) != 0 || calls.Load() != 1) { + t.Fatal("empty main response was reported as a successful catalog", err, len(items), calls.Load()) + } + } +} diff --git a/internal/app/provider_huangguo_metadata_test.go b/internal/app/provider_huangguo_metadata_test.go new file mode 100644 index 0000000..9e0fa94 --- /dev/null +++ b/internal/app/provider_huangguo_metadata_test.go @@ -0,0 +1,132 @@ +package app + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestHuangguoHistoryRepairsOnlyAfterManualUpdate(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.Context().Value(backgroundCatalogKey{}) != true || !strings.HasPrefix(request.URL.Path, "/detail/") { + t.Errorf("unexpected foreground or non-detail request: %s", request.URL.Path) + } + id := strings.Trim(strings.TrimPrefix(request.URL.Path, "/detail/"), "/") + if id != "501" && id != "502" && id != "503" { + t.Errorf("unexpected historical ID: %s", id) + } + episodes := "更新至12集" + if id == "503" { + episodes = "新上架" + } + body := fmt.Sprintf(`
8.7分%s · 100次播放
`, id, id, episodes) + return rankingHTTPResponse(request, 200, body), nil + }) + oldState := &sortMetadataState{Version: sortMetadataVersion, CheckedAt: time.Now()} + rows := []Drama{ + {ID: "huangguoai:501", Source: sourceHuangguoAI, Title: `class=“hg-search__submit” type=“submit” title=“搜索” aria-label=“搜索”> 搜索记录 清除 暂无搜索记录`, OnlineDate: "2026-09-14", Views: "100次播放", TotalEpisode: 12, SortMetadata: oldState}, + {ID: "huangguoai:502", Source: sourceHuangguoAI, Title: "ata-search-input> 搜索记录 清除 暂无搜索记录", OnlineDate: "2026-09-14", Views: "100次播放", SortMetadata: oldState}, + {ID: "huangguoai:503", Source: sourceHuangguoAI, Title: "保留正确的列表剧名", Remark: "新上架", OnlineDate: "2026-09-14", Views: "100次播放", SortMetadata: oldState}, + {ID: "huangguoai:504", Source: sourceHuangguoAI, Title: "已确认源站没有集数", SortMetadata: &sortMetadataState{Version: huangguoMetadataVersion}}, + {ID: "hongguo:7615465407347952664", Source: sourceHongguo, Title: "保留红果", SortMetadata: &sortMetadataState{Version: sortMetadataVersion, CoverChecked: true}}, + } + if err := writeLibraryCache(d.cfg.dataDirectory(), libraryCache{Dramas: rows, LoadedAt: time.Now()}); err != nil { + t.Fatal(err) + } + app := &UIApp{cfg: d.cfg, downloader: d} + t.Cleanup(app.stopSortMetadata) + app.loadLibrary() + plain := httptest.NewRecorder() + app.handleDramas(plain, httptest.NewRequest(http.MethodGet, "/api/ui/dramas", nil)) + if calls.Load() != 0 || app.dramas[0].Title != rows[0].Title || app.metadataDone != nil { + t.Fatal("ordinary cache loading or reading triggered a historical migration") + } + writer := httptest.NewRecorder() + app.handleDramas(writer, httptest.NewRequest(http.MethodGet, "/api/ui/dramas?more=1&source=huangguo", nil)) + if writer.Code != http.StatusOK { + t.Fatal(writer.Body.String()) + } + waitSortMetadataQueue(t, app) + if calls.Load() != 3 { + t.Fatalf("got %d detail requests, want 3", calls.Load()) + } + saved, err := readLibraryCache(d.cfg.dataDirectory()) + if err != nil { + t.Fatal(err) + } + for _, row := range saved.Dramas { + switch row.ID { + case "huangguoai:501", "huangguoai:502": + if row.Title != "正确剧名"+strings.TrimPrefix(row.ID, "huangguoai:") || row.Name != row.Title || fmt.Sprint(row.TotalEpisode) != "12" || fmt.Sprint(row.EpisodeCount) != "12" { + t.Errorf("historical content was not repaired: title=%q name=%q count=%v", row.Title, row.Name, row.TotalEpisode) + } + case "huangguoai:503": + if row.Title != "保留正确的列表剧名" || !valueEmpty(row.TotalEpisode) || row.Remark != "新上架" { + t.Error("overwrote a valid title or invented an unavailable episode count") + } + case "hongguo:7615465407347952664": + if row.Title != "保留红果" || row.SortMetadata.Version != sortMetadataVersion { + t.Error("Huangguo repair touched another source") + } + } + if needsSortMetadata(row) || row.SortMetadata.Pending { + t.Errorf("completed metadata would be requested again: %s", row.ID) + } + } + if app.libraryMetadata.Failed != 0 || app.libraryMetadata.Updated != 3 { + t.Fatalf("unexpected repair progress: %+v", app.libraryMetadata) + } + app.mu.Lock() + app.enqueueHistoricalMetadataLocked("huangguo", nil) + app.mu.Unlock() + waitSortMetadataQueue(t, app) + if calls.Load() != 3 { + t.Fatal("missing source counts were retried indefinitely") + } +} + +func TestHuangguoCatalogMergesRepairTitlesWithoutRegressingGoodData(t *testing.T) { + broken := Drama{ID: "huangguoai:501", Source: sourceHuangguoAI, SourceID: "501", Title: "ata-search-input> 搜索记录 清除 暂无搜索记录", Name: "搜索", Remark: "新上架"} + correct := Drama{ID: broken.ID, Source: sourceHuangguoAI, SourceID: broken.SourceID, Title: "正确剧名", Name: "正确剧名", TotalEpisode: 12, EpisodeCount: 12} + for _, pair := range [][2]Drama{{broken, correct}, {correct, broken}} { + result := mergeLoadedDramas([]Drama{pair[0]}, []Drama{pair[1]}, nil) + if len(result) != 1 || result[0].Title != correct.Title || result[0].Name != correct.Name || fmt.Sprint(result[0].TotalEpisode) != "12" { + t.Fatal("a catalog update kept or restored a corrupted title") + } + } +} + +func TestHuangguoJSONCountsUseAvailableEpisodeFields(t *testing.T) { + for _, row := range []map[string]any{ + {"id": "501", "title": "测试剧", "episode_count": 12, "total_episodes": 0, "remark": "新上架"}, + {"id": "501", "title": "测试剧", "episode_count": 0, "total_episodes": 12, "is_finished": true}, + } { + drama, ok := dramaFromAIMap(row, huangguoAIBaseURL, "测试") + if !ok || fmt.Sprint(drama.TotalEpisode) != "12" || fmt.Sprint(drama.EpisodeCount) != "12" { + t.Fatal("available episode counts were dropped") + } + } + for _, row := range []map[string]any{ + {"id": "501", "title": "测试剧", "episode_count": 0, "total_episodes": 0, "total_collect": 12}, + {"id": "501", "title": "测试剧", "episode_count": "1小时前"}, + } { + drama, ok := dramaFromAIMap(row, huangguoAIBaseURL, "测试") + if !ok || !valueEmpty(drama.TotalEpisode) || !valueEmpty(drama.EpisodeCount) { + t.Fatal("invented episode counts from zero, time, or collections") + } + } +} + +func TestHuangguoJSONCardsDoNotTreatAuthorsAsDramas(t *testing.T) { + body := `{"data":{"items":[{"id":501,"title":"真实剧名","episode_count":12,"author":{"id":156291,"name":"作者名称"}},{"id":501,"title":"真实剧名","episode_count":12,"author":{"id":156292,"name":"另一作者"}}]}}` + items := parseHuangguoAIJSONCards([]byte(body), huangguoAIBaseURL, "测试") + if len(items) != 1 || items[0].ID != "huangguoai:501" || items[0].Title != "真实剧名" { + t.Fatal("nested author metadata was imported as additional dramas") + } +} diff --git a/internal/app/provider_legacy.go b/internal/app/provider_legacy.go new file mode 100644 index 0000000..8299ac5 --- /dev/null +++ b/internal/app/provider_legacy.go @@ -0,0 +1,348 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type Tab struct { + ID string + Name string +} + +type legacyTabList []Tab + +func (tabs *legacyTabList) UnmarshalJSON(raw []byte) error { + raw = bytes.TrimSpace(raw) + if len(raw) > 0 && raw[0] == '[' { + return json.Unmarshal(raw, (*[]Tab)(tabs)) + } + var wrapped struct { + List []Tab `json:"list"` + } + if err := json.Unmarshal(raw, &wrapped); err != nil { + return err + } + *tabs = wrapped.List + return nil +} + +type apiEnvelope struct { + Code json.RawMessage `json:"code"` + Msg string `json:"msg"` + Hash json.RawMessage `json:"hash"` + Data json.RawMessage `json:"data"` +} + +func apiHashEnabled(raw json.RawMessage) bool { + if len(raw) == 0 || string(raw) == "null" { + return false + } + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + return b + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + s = strings.TrimSpace(strings.ToLower(s)) + return s != "" && s != "false" && s != "0" && s != "null" + } + return true +} + +type listResponse struct { + List []Drama `json:"list"` +} + +type detailResponse struct { + Title string `json:"title"` + Name string `json:"name"` + Chapters []Chapter `json:"chapters"` +} + +func pkcs7Pad(src []byte, blockSize int) []byte { + pad := blockSize - len(src)%blockSize + return append(src, bytes.Repeat([]byte{byte(pad)}, pad)...) +} + +func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) { + if len(src) == 0 || len(src)%blockSize != 0 { + return nil, errors.New("invalid pkcs7 length") + } + pad := int(src[len(src)-1]) + if pad == 0 || pad > blockSize || pad > len(src) { + return nil, errors.New("invalid pkcs7 padding") + } + for _, v := range src[len(src)-pad:] { + if int(v) != pad { + return nil, errors.New("invalid pkcs7 padding bytes") + } + } + return src[:len(src)-pad], nil +} + +func encryptParam(obj any, key, iv []byte) (string, error) { + plain, err := json.Marshal(obj) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + padded := pkcs7Pad(plain, block.BlockSize()) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) + return base64.StdEncoding.EncodeToString(out), nil +} + +func sha256Bytes(b []byte) []byte { + h := sha256.Sum256(b) + return h[:] +} + +func decryptResponse(dataStr string, interfaceKey []byte) ([]byte, error) { + o, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataStr)) + if err != nil { + return nil, err + } + if len(o) < 12 { + return nil, errors.New("encrypted payload too short") + } + i := o[:12] + r := append(append([]byte{}, interfaceKey...), i...) + l := len(r) >> 1 + s := sha256Bytes(r)[8:24] + c := append(append([]byte{}, s...), r[:l]...) + a := append(append([]byte{}, r[l:]...), s...) + d := sha256Bytes(c) + f := sha256Bytes(a) + m := append(append(append([]byte{}, d[:8]...), f[8:24]...), d[24:]...) + p := append(append(append([]byte{}, f[:4]...), d[12:20]...), f[28:]...) + g := o[12:] + block, err := aes.NewCipher(m) + if err != nil { + return nil, err + } + if len(g)%block.BlockSize() != 0 { + return nil, errors.New("ciphertext is not block aligned") + } + plain := make([]byte, len(g)) + cipher.NewCBCDecrypter(block, p).CryptBlocks(plain, g) + return pkcs7Unpad(plain, block.BlockSize()) +} + +func (d *Downloader) fetchAPI(ctx context.Context, apiPath string, params any, out any) error { + for attempt := 0; attempt < 2; attempt++ { + access, err := d.legacyCredentials(ctx) + if err != nil { + return err + } + payload, err := d.legacyRequest(ctx, http.MethodGet, apiPath, params, access) + var apiErr *legacyAPIError + if attempt == 0 && access.Anonymous && errors.As(err, &apiErr) && apiErr.code == "5005" { + d.invalidateLegacyToken(access) + continue + } + if err != nil { + return err + } + return json.Unmarshal(payload, out) + } + return errors.New("黄果访客登录已失效,请稍后重试") +} + +type legacyAPIError struct{ code, message string } + +func (err *legacyAPIError) Error() string { + return fmt.Sprintf("黄果 API 错误 %s: %s", err.code, err.message) +} + +func (d *Downloader) legacyRequest(ctx context.Context, method, apiPath string, params any, access legacyAccess) ([]byte, error) { + var lastErr error + attempts := d.cfg.Retries + if attempts < 1 { + attempts = 1 + } + for attempt := 1; attempt <= attempts; attempt++ { + if attempt > 1 { + select { + case <-time.After(time.Duration(attempt) * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + fullPath := apiPath + var requestBody io.Reader + if params != nil { + enc, err := encryptParam(params, []byte(access.ParamKey), []byte(access.ParamIV)) + if err != nil { + return nil, err + } + if method == http.MethodGet { + sep := "?" + if strings.Contains(fullPath, "?") { + sep = "&" + } + fullPath += sep + "data=" + url.QueryEscape(enc) + } else { + body, _ := json.Marshal(map[string]string{"data": enc}) + requestBody = bytes.NewReader(body) + } + } + base, err := d.apiEndpoint(ctx) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, base+fullPath, requestBody) + if err != nil { + return nil, err + } + if access.Token != "" { + req.Header.Set("Authorization", access.Token) + } + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("temp", "test") + req.Header.Set("X-User-Agent", access.userAgent()) + req.Header.Set("Origin", legacyFrontendURL) + req.Header.Set("Referer", legacyFrontendURL+"/") + req.Header.Set("User-Agent", userAgent) + + resp, err := d.doCatalogRequest(req) + if err != nil { + lastErr = publicError(err) + var backoff *requestBackoff + if errors.As(err, &backoff) || ctx.Err() != nil { + return nil, lastErr + } + d.resetAPIEndpoint(base) + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1)) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if len(body) > providerMaxBodyBytes { + return nil, errors.New("API 响应过大") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + lastErr = d.catalogResponseError(req, resp, body) + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return nil, lastErr + } + continue + } + var env apiEnvelope + if err := json.Unmarshal(body, &env); err != nil { + lastErr = err + continue + } + if code := strings.Trim(string(env.Code), `"`); code != "" && code != "null" && code != "200" && code != "0" { + message := firstNonEmpty(env.Msg, "请检查访问配置或刷新登录凭证") + if access.Token != "" { + message = strings.ReplaceAll(message, access.Token, "[已隐藏]") + } + return nil, &legacyAPIError{code: truncate(code, 20), message: truncate(message, 160)} + } + payload := env.Data + if apiHashEnabled(env.Hash) && len(env.Data) > 0 { + var encrypted string + if err := json.Unmarshal(env.Data, &encrypted); err != nil { + lastErr = err + continue + } + plain, err := decryptResponse(encrypted, []byte(access.InterfaceKey)) + if err != nil { + lastErr = err + continue + } + payload = plain + } + if len(payload) == 0 || string(payload) == "null" { + return nil, errors.New("黄果 API 未返回数据,请检查登录凭证或接口是否变更") + } + return payload, nil + } + return nil, lastErr +} + +func (d *Downloader) fetchCloudFrontDramas(ctx context.Context) ([]Drama, error) { + var activeTabs legacyTabList + if err := d.fetchAPI(ctx, "/api/app/playlet-tab/all", nil, &activeTabs); err != nil { + return nil, err + } + if len(activeTabs) == 0 { + return nil, errors.New("黄果 API 未返回可用分类") + } + sortTypes := []string{"0", "1", "6"} + seen := map[string]bool{} + var unique []Drama + var errs []string + for _, sortType := range sortTypes { + for _, tab := range activeTabs { + if tab.ID == "" { + continue + } + for page := 1; page <= d.cfg.MaxPagesPerSort; page++ { + if err := ctx.Err(); err != nil { + return unique, err + } + var lr listResponse + err := d.fetchAPI(ctx, "/api/app/playlet/home/tab/"+tab.ID, map[string]string{ + "pageNumber": strconv.Itoa(page), + "pageSize": strconv.Itoa(d.cfg.PageSize), + "tabSortType": sortType, + }, &lr) + if err != nil { + msg := fmt.Sprintf("频道[%s] 排序[%s] 第%d页失败: %v", tab.Name, sortType, page, err) + fmt.Printf(" %s\n", msg) + errs = append(errs, msg) + break + } + if len(lr.List) == 0 { + break + } + added := 0 + var batch []Drama + for _, item := range lr.List { + if item.ID == "" || seen[item.ID] { + continue + } + item.ChannelName = tab.Name + item.Source = "cloudfront" + item.CategoryName = firstNonEmpty(item.CategoryName, item.CategoryNameSnake, item.Category, tab.Name) + seen[item.ID] = true + unique = append(unique, item) + batch = append(batch, item) + added++ + } + reportLibraryProgress(ctx, "cloudfront", batch, nil, false) + if added == 0 || len(lr.List) < d.cfg.PageSize { + break + } + } + } + } + if len(errs) > 0 { + return unique, errors.New(strings.Join(errs, "\n")) + } + return unique, nil +} diff --git a/internal/app/provider_legacy_playback_integration_test.go b/internal/app/provider_legacy_playback_integration_test.go new file mode 100644 index 0000000..e34b597 --- /dev/null +++ b/internal/app/provider_legacy_playback_integration_test.go @@ -0,0 +1,94 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestLegacyPlaybackAndDownloadSourceKeyLocalFFmpeg(t *testing.T) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("local FFmpeg is unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=30:duration=3", "-an", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18", "-threads", "1", "-f", "mpegts", "pipe:1") + var log bytes.Buffer + command.Stderr = &log + segment, err := command.Output() + if err != nil { + t.Fatalf("generate local video fixture: %v %s", err, log.String()) + } + sourceKey := []byte("0123456789abcdef") + block, err := aes.NewCipher(sourceKey) + if err != nil { + t.Fatal(err) + } + segment = pkcs7Pad(segment, aes.BlockSize) + cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(segment, segment) + playlist := "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:3\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-KEY:METHOD=AES-128,URI=\"/api/app/vid/sec\",IV=0x00000000000000000000000000000000\n#EXTINF:3.0,\n/fixture.ts\n#EXT-X-ENDLIST\n" + var keyRequests, segmentRequests atomic.Int32 + d := legacyFixtureDownloader(t, func(request *http.Request) (*http.Response, error) { + var body []byte + switch request.URL.Path { + case "/api/app/vid/h5/m3u8/generated-video": + return rankingHTTPResponse(request, http.StatusOK, playlist), nil + case "/api/app/vid/sec": + keyRequests.Add(1) + body = sourceKey + case "/fixture.ts": + segmentRequests.Add(1) + body = segment + default: + t.Errorf("unexpected request: %s", request.URL.Path) + return nil, errors.New("only generated fixture resources are allowed") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body)), ContentLength: int64(len(body)), Header: http.Header{"Content-Type": {"application/octet-stream"}}, Request: request}, nil + }) + d.cfg.FFmpeg, d.cfg.Retries = ffmpeg, 1 + d.cfg.Token = "own-fixture-token" + d.cfg.InterfaceKey, d.cfg.ParamKey, d.cfg.ParamIV = fixtureLegacyProtocol.InterfaceKey, fixtureLegacyProtocol.ParamKey, fixtureLegacyProtocol.ParamIV + task := Task{DramaID: "legacy-drama", Chapter: Chapter{ID: "episode-1", VideoURL: "generated-video"}, OutPath: filepath.Join(t.TempDir(), "download.mp4")} + app := &UIApp{downloader: d} + writer := httptest.NewRecorder() + streamCtx, streamCancel := context.WithCancel(ctx) + defer streamCancel() + var duration float64 + err = app.streamPlayback(streamCtx, streamCancel, writer, task, "", 0, 1, func(value float64) { duration = value }) + if err != nil || duration <= 0 || writer.Code != http.StatusOK || writer.Header().Get("X-Playback-Source") != "online" || !bytes.Contains(writer.Body.Bytes(), []byte("moof")) { + t.Fatalf("source key playback failed: %v, HTTP=%d, duration=%f", err, writer.Code, duration) + } + if keyRequests.Load() == 0 || segmentRequests.Load() == 0 { + t.Fatal("playback did not fetch the source key and generated segment") + } + keyRequests.Store(0) + segmentRequests.Store(0) + var completed atomic.Bool + err = d.DownloadEpisodeWithProgress(ctx, task, func(progress DownloadProgress) { + if progress.Phase == "completed" && progress.Percent == 100 { + completed.Store(true) + } + }) + if err != nil { + t.Fatal("source key download failed", err) + } + output, err := os.ReadFile(task.OutPath) + if err != nil || len(output) < 100*1024 || !bytes.Contains(output, []byte("ftyp")) || !bytes.Contains(output, []byte("moov")) || !completed.Load() { + t.Fatalf("download did not finish as an MP4: %v, bytes=%d", err, len(output)) + } + if keyRequests.Load() == 0 || segmentRequests.Load() == 0 || d.cfg.AESKeyHex != "" { + t.Fatal("download did not use the source-managed key") + } +} diff --git a/internal/app/provider_legacy_playback_test.go b/internal/app/provider_legacy_playback_test.go new file mode 100644 index 0000000..4426e16 --- /dev/null +++ b/internal/app/provider_legacy_playback_test.go @@ -0,0 +1,122 @@ +package app + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestLegacyPlaybackUsesSourceKeyWithoutMandatoryConfiguration(t *testing.T) { + for _, test := range []struct { + name string + override string + encrypted bool + wantSource bool + }{ + {name: "source key", encrypted: true, wantSource: true}, + {name: "explicit override", override: hex.EncodeToString([]byte("fedcba9876543210")), encrypted: true}, + {name: "unencrypted playlist"}, + } { + t.Run(test.name, func(t *testing.T) { + var keyRequests atomic.Int32 + sourceKey := []byte("0123456789abcdef") + playlist := "#EXTM3U\n#EXT-X-VERSION:3\n" + if test.encrypted { + playlist += "#EXT-X-KEY:METHOD=AES-128,URI=\"/api/app/vid/sec\"\n" + } + playlist += "#EXTINF:4.5,\nsegment.ts\n#EXT-X-ENDLIST\n" + d := legacyFixtureDownloader(t, func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/api/app/vid/h5/m3u8/opaque-video": + if request.URL.Query().Get("token") != "own-fixture-token" || request.URL.Query().Get("c") != defaultCDNURL { + t.Error("playlist lost its session or CDN selection") + } + return rankingHTTPResponse(request, http.StatusOK, playlist), nil + case "/api/app/vid/sec": + keyRequests.Add(1) + if request.Header.Get("Referer") != legacyFrontendURL+"/" || request.Header.Get("Origin") != legacyFrontendURL { + t.Error("key request lost the source headers") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(sourceKey)), ContentLength: int64(len(sourceKey)), Header: http.Header{"Content-Type": {"application/vnd.apple.keynote"}}, Request: request}, nil + default: + t.Errorf("unexpected resource: %s", request.URL.Path) + return nil, errors.New("only playlist and key fixtures are allowed") + } + }) + d.cfg.Token, d.cfg.AESKeyHex = "own-fixture-token", test.override + d.cfg.InterfaceKey, d.cfg.ParamKey, d.cfg.ParamIV = fixtureLegacyProtocol.InterfaceKey, fixtureLegacyProtocol.ParamKey, fixtureLegacyProtocol.ParamIV + task := Task{DramaID: "legacy-drama", Chapter: Chapter{ID: "chapter-1", VideoURL: "opaque-video"}} + media, key, err := d.resolvePlaybackMedia(context.Background(), task) + if err != nil || media.Playlist != playlist || media.Duration != 4500*time.Millisecond { + t.Fatalf("could not resolve the source playlist: %v", err) + } + if test.override == "" && len(key) != 0 { + t.Fatal("invented a local override for a source-managed key") + } + proxy, err := d.newHLSProxy(context.Background(), media, key) + if err != nil { + t.Fatal(err) + } + t.Cleanup(proxy.Close) + client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: 3 * time.Second} + t.Cleanup(client.CloseIdleConnections) + read := func(address string) []byte { + t.Helper() + response, err := client.Get(address) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 16*1024)) + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("proxy response: HTTP %d, error=%v", response.StatusCode, err) + } + return body + } + rewritten := string(read(proxy.root)) + match := hlsURIAttribute.FindStringSubmatch(rewritten) + if test.encrypted { + if len(match) != 2 || !strings.HasPrefix(match[1], proxy.base) { + t.Fatal("key URI was not safely forwarded through the local proxy") + } + want := sourceKey + if test.override != "" { + want, _ = hex.DecodeString(test.override) + } + if !bytes.Equal(read(match[1]), want) { + t.Fatal("player received a different AES key") + } + } else if len(match) != 0 { + t.Fatal("added a key to an unencrypted playlist") + } + wantRequests := int32(0) + if test.wantSource { + wantRequests = 1 + } + if keyRequests.Load() != wantRequests || d.cfg.AESKeyHex != test.override { + t.Fatal("key resolution ignored the explicit configuration or changed it") + } + }) + } +} + +func TestLegacyPlaybackRejectsInvalidExplicitKey(t *testing.T) { + d := legacyFixtureDownloader(t, func(request *http.Request) (*http.Response, error) { + t.Error("invalid explicit configuration should fail before requesting the source") + return nil, errors.New("unexpected request") + }) + for _, value := range []string{"bad-key", "0123", strings.Repeat("0", 64)} { + d.cfg.AESKeyHex = value + _, _, err := d.resolvePlaybackMedia(context.Background(), Task{Chapter: Chapter{VideoURL: "opaque-video"}}) + if err == nil || !strings.Contains(err.Error(), "aesKeyHex") || strings.Contains(err.Error(), value) { + t.Fatal("invalid explicit key was silently ignored or leaked") + } + } +} diff --git a/internal/app/provider_legacy_session.go b/internal/app/provider_legacy_session.go new file mode 100644 index 0000000..561b073 --- /dev/null +++ b/internal/app/provider_legacy_session.go @@ -0,0 +1,243 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +type legacyProtocol struct { + InterfaceKey string `json:"interfaceKey"` + ParamKey string `json:"paramKey"` + ParamIV string `json:"paramIV"` +} + +type legacySessionState struct { + Version int `json:"version"` + Scope string `json:"scope"` + DeviceID string `json:"deviceId"` + Token string `json:"token"` + Protocol legacyProtocol `json:"protocol"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type legacyAccess struct { + legacyProtocol + Token string + DeviceID string + Anonymous bool +} + +type legacyAPIClient struct { + mu sync.Mutex + loaded bool + state legacySessionState + access *legacyAccess + pending chan struct{} + err error + retryAt time.Time +} + +func (d *Downloader) legacyClient() *legacyAPIClient { + d.legacyOnce.Do(func() { d.legacy = &legacyAPIClient{} }) + return d.legacy +} + +func (d *Downloader) legacyCredentials(ctx context.Context) (legacyAccess, error) { + client := d.legacyClient() + for { + if err := ctx.Err(); err != nil { + return legacyAccess{}, err + } + client.mu.Lock() + if client.access != nil { + access := *client.access + client.mu.Unlock() + return access, nil + } + if pending := client.pending; pending != nil { + client.mu.Unlock() + select { + case <-pending: + continue + case <-ctx.Done(): + return legacyAccess{}, ctx.Err() + } + } + if client.err != nil && time.Now().Before(client.retryAt) { + err := client.err + client.mu.Unlock() + return legacyAccess{}, err + } + if !client.loaded { + client.state = d.loadLegacySession() + client.loaded = true + } + state := client.state + pending := make(chan struct{}) + client.pending = pending + client.mu.Unlock() + + access, updated, err := d.bootstrapLegacySession(ctx, state) + client.mu.Lock() + client.state = updated + client.err = err + client.retryAt = time.Time{} + if err == nil { + client.access = &access + } else if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + client.retryAt = time.Now().Add(time.Minute) + } + client.pending = nil + close(pending) + client.mu.Unlock() + return access, err + } +} + +func (d *Downloader) legacySessionScope() string { + return strings.TrimRight(firstNonEmpty(d.cfg.APIBase, defaultAPIBase), "/") +} + +func (d *Downloader) loadLegacySession() legacySessionState { + empty := legacySessionState{Version: 1, Scope: d.legacySessionScope()} + file, err := os.Open(filepath.Join(d.cfg.dataDirectory(), "huangguo-session.json")) + if err != nil { + return empty + } + defer file.Close() + var state legacySessionState + if json.NewDecoder(io.LimitReader(file, 16*1024)).Decode(&state) != nil || state.Version != 1 || state.Scope != empty.Scope || + !regexp.MustCompile(`^[A-Fa-f0-9]{16}[0-9]{13}$`).MatchString(state.DeviceID) || !validLegacyToken(state.Token) { + return empty + } + return state +} + +func validLegacyToken(token string) bool { + return len(token) <= 4096 && !strings.ContainsAny(token, "\r\n\t ") +} + +func (d *Downloader) saveLegacySession(state legacySessionState) error { + directory := d.cfg.dataDirectory() + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + file, err := os.CreateTemp(directory, ".huangguo-session-*.tmp") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if err := json.NewEncoder(file).Encode(state); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), filepath.Join(directory, "huangguo-session.json")) +} + +func (d *Downloader) bootstrapLegacySession(ctx context.Context, state legacySessionState) (legacyAccess, legacySessionState, error) { + access := legacyAccess{Token: d.cfg.Token, Anonymous: d.cfg.Token == ""} + protocol := state.Protocol + needsProtocol := d.cfg.InterfaceKey == "" || d.cfg.ParamKey == "" || d.cfg.ParamIV == "" + if needsProtocol && (!validLegacyProtocol(protocol) || time.Since(state.UpdatedAt) > 24*time.Hour) { + script, err := d.fetchLegacyFrontendScript(ctx) + if err != nil { + return access, state, fmt.Errorf("获取黄果官网协议失败: %w", publicError(err)) + } + protocol, err = parseLegacyProtocol(script) + if err != nil { + return access, state, err + } + state.Protocol, state.UpdatedAt = protocol, time.Now() + } + access.legacyProtocol = legacyProtocol{ + InterfaceKey: firstNonEmpty(d.cfg.InterfaceKey, protocol.InterfaceKey), + ParamKey: firstNonEmpty(d.cfg.ParamKey, protocol.ParamKey), + ParamIV: firstNonEmpty(d.cfg.ParamIV, protocol.ParamIV), + } + if !validLegacyProtocol(access.legacyProtocol) || !validLegacyToken(access.Token) { + return access, state, errors.New("黄果 API 协议配置无效,请检查 sources.huangguo.legacyAPI") + } + if access.Anonymous { + if state.DeviceID == "" { + prefix := strings.ToUpper(randomHex(8)) + if prefix == "" { + return access, state, errors.New("无法生成黄果访客标识") + } + state.DeviceID = prefix + strconv.FormatInt(time.Now().UnixMilli(), 10) + } + access.DeviceID, access.Token = state.DeviceID, state.Token + if access.Token == "" { + payload, err := d.legacyRequest(ctx, http.MethodPost, "/api/app/mine/login/h5", map[string]any{ + "devID": access.DeviceID, "sysType": "ios", "isAppStore": false, + }, access) + if err != nil { + return access, state, fmt.Errorf("黄果访客登录失败: %w", err) + } + var login struct { + Token string `json:"token"` + } + if json.Unmarshal(payload, &login) != nil || login.Token == "" || !validLegacyToken(login.Token) { + return access, state, errors.New("黄果访客登录未返回有效令牌") + } + access.Token, state.Token = login.Token, login.Token + } + } + if access.Anonymous { + if err := d.saveLegacySession(state); err != nil { + fmt.Println("黄果访客会话未能保存,本次会话仍可使用") + } + } + return access, state, nil +} + +func validLegacyProtocol(protocol legacyProtocol) bool { + return len(protocol.InterfaceKey) > 0 && len(protocol.InterfaceKey) <= 512 && len(protocol.ParamKey) == 16 && len(protocol.ParamIV) == 16 +} + +func parseLegacyProtocol(script string) (legacyProtocol, error) { + value := func(name string) string { + pattern := `["']` + regexp.QuoteMeta(name) + `["']\s*[:,]\s*["']([^"'\\\r\n]+)["']` + match := regexp.MustCompile(pattern).FindStringSubmatch(script) + if len(match) == 2 { + return match[1] + } + return "" + } + protocol := legacyProtocol{InterfaceKey: value("interfaceKey"), ParamKey: value("parameterKey"), ParamIV: value("parameterIv")} + if !validLegacyProtocol(protocol) { + return legacyProtocol{}, errors.New("黄果官网协议已变化,无法识别当前接口参数") + } + return protocol, nil +} + +func (d *Downloader) invalidateLegacyToken(access legacyAccess) { + client := d.legacyClient() + client.mu.Lock() + defer client.mu.Unlock() + if access.Anonymous && client.access != nil && client.access.Token == access.Token { + client.state.Token = "" + client.access = nil + client.err = nil + } +} + +func (access legacyAccess) userAgent() string { + if access.DeviceID == "" { + return xUserAgent + } + return strings.Replace(xUserAgent, "DevID=00000000000000000000000000000", "DevID="+access.DeviceID, 1) +} diff --git a/internal/app/provider_legacy_test.go b/internal/app/provider_legacy_test.go new file mode 100644 index 0000000..3e688f6 --- /dev/null +++ b/internal/app/provider_legacy_test.go @@ -0,0 +1,218 @@ +package app + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +var fixtureLegacyProtocol = legacyProtocol{InterfaceKey: "fixture-interface", ParamKey: "0123456789abcdef", ParamIV: "fedcba9876543210"} + +func legacyFixtureDownloader(t *testing.T, transport rankingTransport) *Downloader { + t.Helper() + d := rankingTestDownloader(t, transport) + d.cfg.APIBase = "https://legacy.example.test" + d.cfg.MaxPagesPerSort, d.cfg.PageSize = 1, 3 + return d +} + +func legacyFixtureResponse(request *http.Request, payload any) *http.Response { + body, _ := json.Marshal(map[string]any{"code": 200, "hash": false, "data": payload}) + return rankingHTTPResponse(request, 200, string(body)) +} + +func legacyFixtureLogin(t *testing.T, request *http.Request) { + t.Helper() + var body struct { + Data string `json:"data"` + } + if request.Method != http.MethodPost || request.Header.Get("Authorization") != "" || json.NewDecoder(request.Body).Decode(&body) != nil { + t.Error("anonymous login must use an encrypted POST without another user's token") + return + } + encrypted, err := base64.StdEncoding.DecodeString(body.Data) + if err != nil || len(encrypted) == 0 || len(encrypted)%16 != 0 { + t.Error("invalid encrypted login") + return + } + block, _ := aes.NewCipher([]byte(fixtureLegacyProtocol.ParamKey)) + cipher.NewCBCDecrypter(block, []byte(fixtureLegacyProtocol.ParamIV)).CryptBlocks(encrypted, encrypted) + plain, err := pkcs7Unpad(encrypted, 16) + var fields struct { + Device string `json:"devID"` + System string `json:"sysType"` + Store bool `json:"isAppStore"` + } + if err != nil || json.Unmarshal(plain, &fields) != nil || len(fields.Device) != 29 || fields.System != "ios" || fields.Store || + !strings.Contains(request.Header.Get("X-User-Agent"), "DevID="+fields.Device+";") { + t.Error("login must carry a fresh consistent H5 device identity") + } +} + +func TestLegacyAnonymousCatalogAndSessionReuse(t *testing.T) { + var loginCalls, scriptCalls atomic.Int32 + transport := rankingTransport(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/": + return rankingHTTPResponse(request, 200, ``), nil + case "/js/main-fixture.js": + scriptCalls.Add(1) + return rankingHTTPResponse(request, 200, `t(F,"interfaceKey","fixture-interface");t(F,"parameterKey","0123456789abcdef");t(F,"parameterIv","fedcba9876543210")`), nil + case "/api/app/mine/login/h5": + loginCalls.Add(1) + legacyFixtureLogin(t, request) + return legacyFixtureResponse(request, map[string]string{"token": "own-anonymous-session"}), nil + case "/api/app/playlet-tab/all": + if request.Header.Get("Authorization") != "own-anonymous-session" { + t.Error("catalog lost its session") + } + return legacyFixtureResponse(request, map[string]any{"list": []Tab{{ID: "current-tab", Name: "当前分类"}}}), nil + case "/api/app/playlet/home/tab/current-tab": + return legacyFixtureResponse(request, map[string]any{"list": []Drama{{ID: "current-drama", Title: "无图剧库样本", TotalEpisode: 2}}}), nil + case "/api/app/playlet/detail/current-drama": + return legacyFixtureResponse(request, map[string]any{"title": "无图剧库样本", "chapters": []Chapter{{ID: "episode-1", VideoURL: "opaque-video-id"}}}), nil + default: + t.Errorf("unexpected request blocked: %s", request.URL.Path) + return nil, errors.New("unexpected endpoint") + } + }) + d := legacyFixtureDownloader(t, transport) + items, err := d.fetchCloudFrontDramas(context.Background()) + if err != nil || len(items) != 1 || items[0].Source != "cloudfront" || items[0].ChannelName != "当前分类" { + t.Fatalf("catalog: count=%d error=%v", len(items), err) + } + _, chapters, err := d.GetDramaChapters(context.Background(), "current-drama") + if err != nil || len(chapters) != 1 { + t.Fatalf("chapters: count=%d error=%v", len(chapters), err) + } + if d.cfg.Token != "" || d.cfg.AESKeyHex != "" || d.cfg.InterfaceKey != "" { + t.Fatal("automatic session changed user configuration") + } + info, err := os.Stat(filepath.Join(d.cfg.dataDirectory(), "huangguo-session.json")) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("anonymous session must be private", err) + } + restarted := NewDownloader(d.cfg) + restarted.client = &http.Client{Transport: transport} + restarted.limiter = newRequestLimiter(8, time.Nanosecond) + if _, err := restarted.legacyCredentials(context.Background()); err != nil { + t.Fatal(err) + } + if loginCalls.Load() != 1 || scriptCalls.Load() != 1 { + t.Fatal("restart did not reuse the anonymous session and public protocol") + } +} + +func TestLegacyLoginWaiterCancellationAndSingleFlight(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + var logins atomic.Int32 + d := legacyFixtureDownloader(t, func(request *http.Request) (*http.Response, error) { + logins.Add(1) + close(started) + select { + case <-release: + case <-request.Context().Done(): + return nil, request.Context().Err() + } + return legacyFixtureResponse(request, map[string]string{"token": "shared-own-session"}), nil + }) + d.cfg.InterfaceKey, d.cfg.ParamKey, d.cfg.ParamIV = fixtureLegacyProtocol.InterfaceKey, fixtureLegacyProtocol.ParamKey, fixtureLegacyProtocol.ParamIV + var group sync.WaitGroup + group.Add(1) + go func() { + defer group.Done() + if _, err := d.legacyCredentials(context.Background()); err != nil { + t.Error(err) + } + }() + <-started + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := d.legacyCredentials(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("a waiting caller cannot be canceled", err) + } + for i := 0; i < 8; i++ { + group.Add(1) + go func() { + defer group.Done() + if _, err := d.legacyCredentials(context.Background()); err != nil { + t.Error(err) + } + }() + } + close(release) + group.Wait() + if logins.Load() != 1 { + t.Fatal("concurrent callers created multiple anonymous logins") + } +} + +func TestLegacyExpiredAnonymousSessionRefreshesOnce(t *testing.T) { + var logins atomic.Int32 + d := legacyFixtureDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path == "/api/app/mine/login/h5" { + logins.Add(1) + return legacyFixtureResponse(request, map[string]string{"token": "renewed-session"}), nil + } + if request.Header.Get("Authorization") == "expired-session" { + return rankingHTTPResponse(request, 200, `{"code":5005,"msg":"no token"}`), nil + } + return legacyFixtureResponse(request, []Tab{{ID: "valid", Name: "当前分类"}}), nil + }) + state := legacySessionState{Version: 1, Scope: d.legacySessionScope(), DeviceID: "0123456789ABCDEF1789290000000", Token: "expired-session", Protocol: fixtureLegacyProtocol, UpdatedAt: time.Now()} + if err := d.saveLegacySession(state); err != nil { + t.Fatal(err) + } + var tabs legacyTabList + if err := d.fetchAPI(context.Background(), "/api/app/playlet-tab/all", nil, &tabs); err != nil || len(tabs) != 1 { + t.Fatal("anonymous recovery failed", err) + } + if logins.Load() != 1 { + t.Fatal("session refresh did not stay bounded") + } +} + +func TestLegacyExplicitTokenIsPreservedWithoutMediaKey(t *testing.T) { + var calls atomic.Int32 + d := legacyFixtureDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.Path != "/api/app/playlet-tab/all" || request.Header.Get("Authorization") != "configured-session" { + t.Error("explicit configuration was replaced or sent to another endpoint") + } + return rankingHTTPResponse(request, 200, `{"code":5005,"msg":"expired configured session"}`), nil + }) + d.cfg.Token = "configured-session" + d.cfg.InterfaceKey, d.cfg.ParamKey, d.cfg.ParamIV = fixtureLegacyProtocol.InterfaceKey, fixtureLegacyProtocol.ParamKey, fixtureLegacyProtocol.ParamIV + var tabs legacyTabList + if err := d.fetchAPI(context.Background(), "/api/app/playlet-tab/all", nil, &tabs); err == nil { + t.Fatal("expired explicit token was ignored") + } + if calls.Load() != 1 || d.cfg.Token != "configured-session" { + t.Fatal("explicit token was silently renewed") + } +} + +func TestLegacyPublicProtocolValidation(t *testing.T) { + for _, script := range []string{"", `t(F,"parameterKey","short")`, `t(F,"interfaceKey","fixture");t(F,"parameterKey","short");t(F,"parameterIv","fedcba9876543210")`} { + if _, err := parseLegacyProtocol(script); err == nil { + t.Fatal("accepted incomplete or invalid public protocol") + } + } + var tabs legacyTabList + for _, raw := range []string{`[{"id":"a","name":"甲"}]`, `{"list":[{"id":"a","name":"甲"}],"count":1}`} { + if json.Unmarshal([]byte(raw), &tabs) != nil || len(tabs) != 1 || tabs[0].ID != "a" { + t.Fatal("tab response format not supported") + } + } +} diff --git a/internal/app/provider_media.go b/internal/app/provider_media.go new file mode 100644 index 0000000..d1886ee --- /dev/null +++ b/internal/app/provider_media.go @@ -0,0 +1,168 @@ +package app + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +type providerMedia struct { + URL string + Referer string + Duration time.Duration + Playlist string + HLSKey []byte + CENCKey []byte + Quality int + Variants []providerMedia +} + +func (d *Downloader) providerBaseURL(source string) string { + source = canonicalProviderSource(source) + var configured, fallback string + switch source { + case sourceHuangguoAI: + configured, fallback = d.cfg.HuangguoAIURL, huangguoAIBaseURL + case sourceHuangguoVideo: + configured, fallback = d.cfg.HuangguoVideoURL, huangguoVideoBaseURL + case sourceHuangdou: + configured, fallback = d.cfg.HuangdouURL, huangdouBaseURL + case sourceHongguo: + configured, fallback = d.cfg.HongguoURL, hongguoBaseURL + default: + fallback = "https://d2pypzndaqisk.cloudfront.net" + } + return strings.TrimRight(firstNonEmpty(configured, fallback), "/") +} + +func providerSourceForURL(raw string) string { + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + host := strings.ToLower(parsed.Hostname()) + switch { + case host == "huangguoai.com" || strings.HasSuffix(host, ".ediayikma.cc") || strings.HasSuffix(host, ".agdkczeyx.cc"): + return sourceHuangguoAI + case host == "huangguo.video": + return sourceHuangguoVideo + case host == "tideember.cc" || host == "xqjurgek.top": + return sourceHuangdou + case host == "hongguoduanju.com" || host == "www.hongguoduanju.com": + return sourceHongguo + default: + return "" + } +} + +func (d *Downloader) providerURLCandidates(raw string) []string { + source := providerSourceForURL(raw) + if source == "" { + return []string{raw} + } + parsed, _ := url.Parse(raw) + d.providerMu.Lock() + preferred := d.providerHosts[source] + d.providerMu.Unlock() + var candidates []string + seen := map[string]bool{} + add := func(candidate string) { + if candidate != "" && !seen[candidate] { + seen[candidate] = true + candidates = append(candidates, candidate) + } + } + add(rehostProviderURL(parsed, preferred)) + configured := d.providerBaseURL(source) + add(rehostProviderURL(parsed, configured)) + if providerSourceForURL(configured) == "" { + return candidates + } + for _, candidate := range providerURLCandidates(raw) { + add(candidate) + } + return candidates +} + +func (d *Downloader) resolveProviderMedia(ctx context.Context, task Task) (providerMedia, error) { + chapter := task.Chapter + chapter.Source = canonicalProviderSource(chapter.Source) + if chapter.Source == "" { + chapter.Source = sourceFromDramaID(task.DramaID) + } + if strings.HasPrefix(chapter.VideoURL, "hongguo-cenc://") { + return d.resolveHongguoMedia(ctx, task) + } + if chapter.PageURL == "" && (chapter.Source == sourceHuangguoAI || chapter.Source == sourceHuangguoVideo) { + if source, sourceID, valid := splitProviderDramaID(task.DramaID); valid { + _, chapters, err := d.GetHuangguoChapters(ctx, source, sourceID) + if err != nil { + return providerMedia{}, fmt.Errorf("刷新旧任务播放地址失败: %w", err) + } + matched := false + for _, fresh := range chapters { + if fresh.ID == chapter.ID { + chapter = fresh + matched = true + break + } + } + if !matched { + return providerMedia{}, fmt.Errorf("原章节已变化,请更新该合集后重新下载") + } + } + } + media := providerMedia{URL: chapter.VideoURL, Referer: firstNonEmpty(chapter.Referer, d.providerBaseURL(chapter.Source)+"/")} + if chapter.Source == sourceHuangdou { + _, sourceID, valid := splitProviderDramaID(task.DramaID) + if valid { + sequence, err := strconv.Atoi(chapter.EpisodeString(task.Index)) + if err != nil || sequence < 1 { + return providerMedia{}, fmt.Errorf("黄豆集数无效") + } + client := newHuangdouAPIClient(d) + media, err = d.resolveHuangdouPlayback(ctx, client, sourceID, sequence) + if err != nil { + return providerMedia{}, err + } + media.Referer = client.host + "/home" + } + } + if chapter.PageURL != "" && (chapter.Source == sourceHuangguoAI || chapter.Source == sourceHuangguoVideo) { + body, err := d.fetchProviderText(ctx, chapter.PageURL, media.Referer) + if err != nil { + return providerMedia{}, err + } + if chapter.Source == sourceHuangguoAI { + media.URL = parseAIVideoURL(body, chapter.PageURL) + } else { + media.URL = parseDataHLS(body, chapter.PageURL) + } + d.providerMu.Lock() + if preferred := d.providerHosts[chapter.Source]; preferred != "" { + media.Referer = preferred + "/" + } + d.providerMu.Unlock() + } + if !isProviderHTTPMediaURL(media.URL) { + return providerMedia{}, fmt.Errorf("%s 未返回有效播放地址,请刷新章节或确认站点访问权限", chapter.Source) + } + parsed, _ := url.Parse(media.URL) + if strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8") { + playlist, err := d.fetchProviderText(ctx, media.URL, media.Referer) + if err != nil { + return providerMedia{}, fmt.Errorf("获取播放列表失败: %w", err) + } + if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(playlist, "\ufeff")), "#EXTM3U") { + return providerMedia{}, fmt.Errorf("站点未返回有效 M3U8,可能需要登录或链接已失效") + } + if duration := m3u8Duration(playlist); duration > 0 { + media.Duration = duration + } + media.Playlist = playlist + } + return media, nil +} diff --git a/internal/app/provider_rankings.go b/internal/app/provider_rankings.go new file mode 100644 index 0000000..afad267 --- /dev/null +++ b/internal/app/provider_rankings.go @@ -0,0 +1,174 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strconv" + "sync" + "time" +) + +const rankingCacheTTL = 5 * time.Minute + +type rankingBoard struct { + ID string `json:"id"` + Source string `json:"source"` + Name string `json:"name"` + Description string `json:"description"` + path string + upstreamKey string +} + +var rankingBoards = []rankingBoard{ + {ID: "hongguo-hot", Source: sourceHongguo, Name: "总热播榜", Description: "红果观看、互动等综合热度;每日更新。", path: "hot-drama", upstreamKey: "hongguo"}, + {ID: "hongguo-real", Source: sourceHongguo, Name: "真人剧榜", Description: "红果真人剧热播榜;每日更新。", path: "hot-real-drama", upstreamKey: "real"}, + {ID: "hongguo-comic", Source: sourceHongguo, Name: "漫剧榜", Description: "红果漫剧热播榜;每日更新。", path: "hot-comic-drama", upstreamKey: "comic"}, + {ID: "hongguo-ai", Source: sourceHongguo, Name: "AI剧榜", Description: "红果 AI 剧热播榜;每日更新。", path: "hot-ai-drama", upstreamKey: "ai"}, + {ID: "huangdou-all", Source: sourceHuangdou, Name: "总榜", Description: "黄豆短剧总榜,保留站点返回的顺序。", upstreamKey: "all"}, + {ID: "huangdou-mogai", Source: sourceHuangdou, Name: "魔改榜", Description: "黄豆站点魔改榜。", upstreamKey: "mogai"}, + {ID: "huangdou-search", Source: sourceHuangdou, Name: "搜索榜", Description: "黄豆站点搜索榜。", upstreamKey: "search"}, + {ID: "huangdou-favorite", Source: sourceHuangdou, Name: "收藏榜", Description: "黄豆站点收藏榜。", upstreamKey: "favorite"}, + {ID: "huangdou-finish", Source: sourceHuangdou, Name: "完结榜", Description: "黄豆站点完结榜。", upstreamKey: "finish"}, + {ID: "huangguo-hot", Source: "huangguo", Name: "热播榜", Description: "黄果站点热播 TOP 20;每日更新。", path: "hot"}, + {ID: "huangguo-recommend", Source: "huangguo", Name: "推荐榜", Description: "黄果站点推荐 TOP 20。", path: "recommend"}, + {ID: "huangguo-potential", Source: "huangguo", Name: "潜力榜", Description: "黄果站点潜力 TOP 20。", path: "potential"}, +} + +type rankingItem struct { + Rank int `json:"rank"` + Drama Drama `json:"drama"` + Metric string `json:"metric,omitempty"` +} + +type rankingPage struct { + BoardID string `json:"boardId"` + Page int `json:"page"` + Items []rankingItem `json:"items"` + HasMore bool `json:"hasMore"` + TotalPages int `json:"totalPages,omitempty"` + UpdatedText string `json:"updatedText,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + Stale bool `json:"stale,omitempty"` + Warning string `json:"warning,omitempty"` +} + +type rankingCall struct { + done chan struct{} + page rankingPage + err error +} + +type rankingCache struct { + mu sync.Mutex + pages map[string]rankingPage + pending map[string]*rankingCall +} + +func findRankingBoard(id string) (rankingBoard, bool) { + for _, board := range rankingBoards { + if board.ID == id { + return board, true + } + } + return rankingBoard{}, false +} + +func cloneRankingPage(page rankingPage) rankingPage { + page.Items = append([]rankingItem{}, page.Items...) + return page +} + +func (d *Downloader) loadRankingPage(ctx context.Context, board rankingBoard, page int, refresh bool) (rankingPage, error) { + key := board.ID + ":" + strconv.Itoa(page) + cache := &d.rankings + cache.mu.Lock() + if cache.pages == nil { + cache.pages = map[string]rankingPage{} + cache.pending = map[string]*rankingCall{} + } + cached, exists := cache.pages[key] + if exists && !refresh && time.Since(cached.FetchedAt) < rankingCacheTTL { + cache.mu.Unlock() + return cloneRankingPage(cached), nil + } + if pending := cache.pending[key]; pending != nil { + cache.mu.Unlock() + select { + case <-ctx.Done(): + return rankingPage{}, ctx.Err() + case <-pending.done: + if (errors.Is(pending.err, context.Canceled) || errors.Is(pending.err, context.DeadlineExceeded)) && ctx.Err() == nil { + return d.loadRankingPage(ctx, board, page, refresh) + } + return cloneRankingPage(pending.page), pending.err + } + } + pending := &rankingCall{done: make(chan struct{})} + cache.pending[key] = pending + cache.mu.Unlock() + + result, err := d.fetchRankingPage(ctx, board, page) + cache.mu.Lock() + defer cache.mu.Unlock() + if err == nil { + result.BoardID, result.Page, result.FetchedAt = board.ID, page, time.Now() + if refresh && page == 1 { + for cachedKey, old := range cache.pages { + if old.BoardID == board.ID { + delete(cache.pages, cachedKey) + } + } + } + if len(cache.pages) >= 128 { + oldestKey, oldest := "", time.Now() + for cachedKey, old := range cache.pages { + if old.FetchedAt.Before(oldest) { + oldestKey, oldest = cachedKey, old.FetchedAt + } + } + delete(cache.pages, oldestKey) + } + cache.pages[key] = cloneRankingPage(result) + } else if exists && ctx.Err() == nil && time.Since(cached.FetchedAt) < 24*time.Hour { + result = cloneRankingPage(cached) + result.Stale = true + result.Warning = "站点暂不可用,显示上次取得的榜单,请稍后刷新。" + err = nil + } + pending.page, pending.err = cloneRankingPage(result), err + delete(cache.pending, key) + close(pending.done) + return result, err +} + +func (d *Downloader) fetchRankingPage(ctx context.Context, board rankingBoard, page int) (rankingPage, error) { + switch board.Source { + case sourceHongguo: + pageURL := hongguoBaseURL + "/rank/" + board.path + "?page=" + strconv.Itoa(page) + body, err := d.fetchProviderText(ctx, pageURL, hongguoBaseURL+"/") + if err != nil { + return rankingPage{}, err + } + return parseHongguoRanking(body, board, page) + case sourceHuangdou: + var decoded any + err := newHuangdouAPIClient(d).call(ctx, "/drama/rank", map[string]any{"tab": board.upstreamKey, "page": strconv.Itoa(page)}, &decoded) + if err != nil { + return rankingPage{}, err + } + return parseHuangdouRanking(decoded, page) + case "huangguo": + if page != 1 { + return rankingPage{}, errors.New("该榜单只有一页") + } + pageURL := huangguoAIBaseURL + "/ranks/" + board.path + "/" + body, err := d.fetchProviderText(ctx, pageURL, huangguoAIBaseURL+"/") + if err != nil { + return rankingPage{}, err + } + return parseHuangguoRanking(body, board) + default: + return rankingPage{}, fmt.Errorf("不支持的榜单站源 %s", board.Source) + } +} diff --git a/internal/app/provider_rankings_loader.go b/internal/app/provider_rankings_loader.go new file mode 100644 index 0000000..ab7c685 --- /dev/null +++ b/internal/app/provider_rankings_loader.go @@ -0,0 +1,39 @@ +package app + +import "encoding/json" + +func parseHongguoMergeLoader(body, loaderKey string) (hongguoRankingContent, bool) { + for _, tag := range rankingScriptTags.FindAllString(body, -1) { + if extractAttr(tag, "data-fn-name") != "mergeLoaderData" || extractAttr(tag, "data-script-src") != "modern-run-window-fn" { + continue + } + var args []json.RawMessage + if json.Unmarshal([]byte(extractAttr(tag, "data-fn-args")), &args) != nil || len(args) != 2 { + continue + } + var route string + if json.Unmarshal(args[0], &route) != nil || route != loaderKey { + continue + } + var fields []struct { + Key string `json:"key"` + Name string `json:"routerDataFnName"` + Args []json.RawMessage `json:"routerDataFnArgs"` + } + if json.Unmarshal(args[1], &fields) != nil { + continue + } + for _, field := range fields { + if field.Key != "content" || field.Name != "p" { + continue + } + var content hongguoRankingContent + var raw string + if len(field.Args) != 1 || json.Unmarshal(field.Args[0], &raw) != nil || json.Unmarshal([]byte(raw), &content) != nil { + return hongguoRankingContent{}, true + } + return content, true + } + } + return hongguoRankingContent{}, false +} diff --git a/internal/app/provider_rankings_loader_test.go b/internal/app/provider_rankings_loader_test.go new file mode 100644 index 0000000..5456760 --- /dev/null +++ b/internal/app/provider_rankings_loader_test.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "encoding/json" + "html" + "os" + "reflect" + "strings" + "testing" + "time" +) + +func rankingMergedFixture(board rankingBoard, page int, rows []map[string]any) string { + key := "rank_" + board.path + "/page" + base := rankingFixture(board, page, rows) + base = strings.Split(base, "")[0] + "" + content, _ := json.Marshal(map[string]any{"isSuccess": true, "rankList": rows, "pagination": map[string]int{"pageNum": page, "totalPages": 2}}) + args, _ := json.Marshal([]any{key, []any{map[string]any{"key": "content", "routerDataFnName": "p", "routerDataFnArgs": []string{string(content)}}}}) + return base + `` +} + +func TestHongguoRankingMergeLoaderFormatsPreserveMetadata(t *testing.T) { + for _, board := range rankingBoards { + if board.Source != sourceHongguo { + continue + } + for page := 1; page <= 2; page++ { + rows := rankingRows(page) + rows[0]["title"] = `甲 & <乙> "丙" '丁' / 戊` + rows[0]["scoreText"] = "评分9.0" + rows[0]["tags"] = []string{"剧情", "成长"} + rows[0]["description"] = "文字简介 & 内容" + legacy, err := parseHongguoRanking(rankingFixture(board, page, rows), board, page) + if err != nil { + t.Fatal(err) + } + merged, err := parseHongguoRanking(rankingMergedFixture(board, page, rows), board, page) + if err != nil || !reflect.DeepEqual(merged, legacy) { + t.Fatal("merged and streamed formats differ", board.ID, page, err) + } + if merged.Items[0].Drama.TotalEpisode != 2 || merged.Items[0].Drama.Score != "9.0" || merged.Items[0].Drama.Title != rows[0]["title"] { + t.Fatal("lost episode, score or escaped text metadata") + } + } + } +} + +func TestHongguoRankingMergeLoaderRejectsWrongRouteAndInvalidContent(t *testing.T) { + board, _ := findRankingBoard("hongguo-hot") + body := rankingMergedFixture(board, 1, rankingRows(1)) + for _, value := range []string{ + strings.Replace(body, `data-fn-name="mergeLoaderData"`, `data-fn-name="unrelated"`, 1), + strings.Replace(body, `data-script-src="modern-run-window-fn"`, `data-script-src="unrelated"`, 1), + strings.ReplaceAll(body, `hot-drama/page`, `hot-ai-drama/page`), + strings.ReplaceAll(body, `routerDataFnArgs`, `unrelatedArgs`), + strings.ReplaceAll(body, `isSuccess`, `unknownStatus`), + } { + if _, err := parseHongguoRanking(value, board, 1); err == nil { + t.Fatal("unrelated or invalid merge payload was accepted") + } + } + if _, err := parseHongguoRanking(body, board, 2); err == nil { + t.Fatal("repeated page was accepted") + } + rows := rankingRows(1) + rows[1]["id"], rows[1]["seriesId"] = rows[0]["id"], rows[0]["seriesId"] + if _, err := parseHongguoRanking(rankingMergedFixture(board, 1, rows), board, 1); err == nil { + t.Fatal("duplicate merge rows were accepted") + } + deferred := `` + if _, err := parseHongguoRanking(deferred+rankingFixture(board, 1, rankingRows(1)), board, 1); err != nil { + t.Fatal("deferred merge prevented streamed content", err) + } +} + +func TestLiveHongguoRankingFormats(t *testing.T) { + if os.Getenv("JUKU_LIVE_HONGGUO_RANKING") != "1" { + t.Skip("metadata-only live ranking check is opt-in") + } + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.Retries = t.TempDir(), t.TempDir(), 1 + downloader := NewDownloader(cfg) + for _, board := range rankingBoards { + if board.Source != sourceHongguo { + continue + } + t.Run(board.ID, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) + defer cancel() + for page := 1; page <= 2; page++ { + result, err := downloader.loadRankingPage(ctx, board, page, true) + if err != nil || len(result.Items) == 0 || result.Items[0].Rank != (page-1)*20+1 { + t.Fatalf("page %d failed: %v", page, err) + } + t.Logf("page %d: %d entries, rank %d–%d", page, len(result.Items), result.Items[0].Rank, result.Items[len(result.Items)-1].Rank) + if !result.HasMore { + break + } + } + }) + } +} diff --git a/internal/app/provider_rankings_parse.go b/internal/app/provider_rankings_parse.go new file mode 100644 index 0000000..c188f19 --- /dev/null +++ b/internal/app/provider_rankings_parse.go @@ -0,0 +1,175 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +var rankingScriptTags = regexp.MustCompile(`(?is)]*>`) +var rankingJSONLD = regexp.MustCompile(`(?is)]*type=["']application/ld\+json["'][^>]*>(.*?)`) +var rankingSourceID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,100}$`) + +type hongguoRankingContent struct { + Success bool `json:"isSuccess"` + Rows []struct { + ID string `json:"id"` + SeriesID string `json:"seriesId"` + Rank int `json:"rank"` + Title string `json:"title"` + Heat string `json:"heatText"` + Score string `json:"scoreText"` + Tags []string `json:"tags"` + Description string `json:"description"` + EpisodeIDs []string `json:"episodeVids"` + Cover any `json:"cover"` + } `json:"rankList"` + Pagination struct { + Page int `json:"pageNum"` + TotalPages int `json:"totalPages"` + } `json:"pagination"` +} + +func parseHongguoRanking(body string, board rankingBoard, page int) (rankingPage, error) { + failure := errors.New("红果榜单格式或分页已变化,请稍后重试") + loaderKey := "rank_" + board.path + "/page" + loader := nestedMap(parseRouterData(body), "loaderData", loaderKey) + if mapString(loader, "rankKey") != board.upstreamKey || mapString(loader, "pageNum") != strconv.Itoa(page) { + return rankingPage{}, failure + } + var content hongguoRankingContent + if inline := nestedMap(loader, "content"); len(inline) > 0 { + raw, err := json.Marshal(inline) + if err != nil || json.Unmarshal(raw, &content) != nil { + return rankingPage{}, failure + } + } else if merged, found := parseHongguoMergeLoader(body, loaderKey); found { + content = merged + } else { + for _, tag := range rankingScriptTags.FindAllString(body, -1) { + if extractAttr(tag, "data-fn-name") != "r" || extractAttr(tag, "data-script-src") != "modern-run-router-data-fn" { + continue + } + var args []json.RawMessage + if json.Unmarshal([]byte(extractAttr(tag, "data-fn-args")), &args) != nil || len(args) != 3 { + continue + } + var route, field string + if json.Unmarshal(args[0], &route) != nil || json.Unmarshal(args[1], &field) != nil || route != loaderKey || field != "content" { + continue + } + if json.Unmarshal(args[2], &content) != nil { + return rankingPage{}, failure + } + break + } + } + if !content.Success || content.Rows == nil || content.Pagination.Page != page || content.Pagination.TotalPages < page || content.Pagination.TotalPages > 500 { + return rankingPage{}, failure + } + result := rankingPage{Items: make([]rankingItem, 0, len(content.Rows)), TotalPages: content.Pagination.TotalPages, HasMore: page < content.Pagination.TotalPages, UpdatedText: mapString(loader, "updatedText")} + seen := map[string]bool{} + previous := (page - 1) * 20 + for _, row := range content.Rows { + id := firstNonEmpty(row.SeriesID, row.ID) + if !hongguoNumericID.MatchString(id) || row.ID != "" && row.SeriesID != "" && row.ID != row.SeriesID || strings.TrimSpace(row.Title) == "" || row.Rank <= previous || row.Rank > page*20 || seen[id] { + return rankingPage{}, failure + } + seen[id], previous = true, row.Rank + drama := Drama{ID: providerDramaID(sourceHongguo, id), Source: sourceHongguo, SourceID: id, Title: row.Title, Name: row.Title, Desc: row.Description, Intro: row.Description, Tags: row.Tags, Heat: row.Heat, Score: strings.TrimPrefix(row.Score, "评分"), ChannelName: "红果"} + if cover := hongguoCoverAddress(coverPathFromAny(row.Cover)); cover != "" { + drama.Cover, drama.CoverURL = cover, cover + } + if len(row.Tags) > 0 { + drama.CategoryName = row.Tags[0] + } + if len(row.EpisodeIDs) > 0 { + drama.TotalEpisode = len(row.EpisodeIDs) + } + result.Items = append(result.Items, rankingItem{Rank: row.Rank, Drama: drama, Metric: row.Heat}) + } + if len(result.Items) == 0 && result.HasMore { + return rankingPage{}, failure + } + return result, nil +} + +func parseHuangdouRanking(decoded any, page int) (rankingPage, error) { + data := huangdouDataMap(decoded) + rows, valid := data["list"].([]any) + if !valid { + return rankingPage{}, errors.New("黄豆未返回有效榜单") + } + if len(rows) > 20 { + return rankingPage{}, errors.New("黄豆榜单分页格式已变化") + } + result := rankingPage{Items: make([]rankingItem, 0, len(rows)), HasMore: len(rows) == 20} + seen := map[string]bool{} + for index, value := range rows { + row, valid := value.(map[string]any) + id := strings.TrimPrefix(firstNonEmpty(mapString(row, "id"), mapString(row, "drama_id")), "rp_") + title := mapString(row, "name", "title", "t") + if !valid || !rankingSourceID.MatchString(id) || strings.TrimSpace(title) == "" || seen[id] { + return rankingPage{}, errors.New("黄豆榜单包含无效或重复条目") + } + seen[id] = true + remark := firstNonEmpty(mapString(row, "update_label"), mapString(row, "corner")) + drama := Drama{VIP: huangdouVIPFlag(row), ID: providerDramaID(sourceHuangdou, id), Source: sourceHuangdou, SourceID: id, Title: title, Name: title, ChannelName: "黄豆", CategoryName: mapString(row, "category"), TotalEpisode: mapString(row, "episode_count"), Remark: remark, ReleaseStatus: releaseStatusFromRemark(remark), Heat: mapString(row, "hot_rate"), Views: normalizeViews(mapString(row, "click")), OnlineDate: providerReleaseDate(mapString(row, "issue_date"))} + metric := "" + if drama.Heat != "" { + metric = drama.Heat + "热度" + } + + result.Items = append(result.Items, rankingItem{Rank: (page-1)*20 + index + 1, Drama: drama, Metric: metric}) + } + return result, nil +} + +func parseHuangguoRanking(body string, board rankingBoard) (rankingPage, error) { + failure := errors.New("黄果未返回有效榜单,可能是页面结构变化") + for _, match := range rankingJSONLD.FindAllStringSubmatch(body, -1) { + var schema struct { + Graph []struct { + Type string `json:"@type"` + ID string `json:"@id"` + Items []struct { + Position int `json:"position"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"itemListElement"` + } `json:"@graph"` + } + if json.Unmarshal([]byte(match[1]), &schema) != nil { + continue + } + for _, graph := range schema.Graph { + if graph.Type != "ItemList" || !strings.HasSuffix(graph.ID, "/ranks/"+board.path+"/#itemlist") { + continue + } + if len(graph.Items) == 0 || len(graph.Items) > 100 { + return rankingPage{}, failure + } + result := rankingPage{Items: make([]rankingItem, 0, len(graph.Items)), TotalPages: 1} + seen, previous := map[string]bool{}, 0 + for _, item := range graph.Items { + parsed, err := url.Parse(item.URL) + if err != nil || !strings.HasPrefix(parsed.Path, "/detail/") { + return rankingPage{}, failure + } + id := strings.TrimSuffix(strings.TrimPrefix(parsed.Path, "/detail/"), "/") + if !rankingSourceID.MatchString(id) || strings.TrimSpace(item.Name) == "" || item.Position <= previous || seen[id] { + return rankingPage{}, failure + } + seen[id], previous = true, item.Position + drama := Drama{ID: providerDramaID(sourceHuangguoAI, id), Source: sourceHuangguoAI, SourceID: id, Title: item.Name, Name: item.Name} + result.Items = append(result.Items, rankingItem{Rank: item.Position, Drama: drama}) + } + return result, nil + } + } + return rankingPage{}, fmt.Errorf("%s: %w", board.Name, failure) +} diff --git a/internal/app/provider_rankings_test.go b/internal/app/provider_rankings_test.go new file mode 100644 index 0000000..dbbba95 --- /dev/null +++ b/internal/app/provider_rankings_test.go @@ -0,0 +1,325 @@ +package app + +import ( + "context" + "encoding/json" + "html" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func rankingFixture(board rankingBoard, page int, rows []map[string]any) string { + key := "rank_" + board.path + "/page" + loader, _ := json.Marshal(map[string]any{"loaderData": map[string]any{key: map[string]any{"rankKey": board.upstreamKey, "pageNum": page, "updatedText": "9月13日已更新", "content": map[string]any{}}}}) + args, _ := json.Marshal([]any{key, "content", map[string]any{"isSuccess": true, "rankList": rows, "pagination": map[string]int{"pageNum": page, "totalPages": 2}}}) + return "" +} + +func rankingRows(page int) []map[string]any { + return []map[string]any{ + {"id": "7681903520873729048", "seriesId": "7681903520873729048", "rank": (page-1)*20 + 1, "title": "甲 & <乙> \"丙\"", "heatText": "1.2亿热度", "episodeVids": []string{"1111111111111111111", "2222222222222222222"}}, + {"id": "7680883072278989849", "seriesId": "7680883072278989849", "rank": (page-1)*20 + 3, "title": "另一部", "heatText": "9000万热度"}, + } +} + +func TestHongguoRankingStreamAndValidation(t *testing.T) { + board, _ := findRankingBoard("hongguo-hot") + result, err := parseHongguoRanking(rankingFixture(board, 2, rankingRows(2)), board, 2) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 2 || result.Items[0].Rank != 21 || result.Items[1].Rank != 23 || result.HasMore || result.Items[0].Drama.Title != "甲 & <乙> \"丙\"" { + t.Fatalf("streamed data lost its rank, title or pagination: %+v", result) + } + if result.Items[0].Drama.Views != "" || result.Items[0].Drama.Heat != "1.2亿热度" || result.Items[0].Drama.Cover != nil { + t.Fatal("heat must stay separate from views, and ranking metadata must have no images") + } + for _, test := range []struct { + name string + change func([]map[string]any) + }{ + {"duplicate ID", func(rows []map[string]any) { rows[1]["id"], rows[1]["seriesId"] = rows[0]["id"], rows[0]["seriesId"] }}, + {"duplicate rank", func(rows []map[string]any) { rows[1]["rank"] = 1 }}, + {"wrong page ranks", func(rows []map[string]any) { rows[0]["rank"] = 21 }}, + {"mismatched ID", func(rows []map[string]any) { rows[0]["id"] = "7777777777777777777" }}, + {"missing title", func(rows []map[string]any) { rows[0]["title"] = "" }}, + } { + t.Run(test.name, func(t *testing.T) { + rows := rankingRows(1) + test.change(rows) + if _, err := parseHongguoRanking(rankingFixture(board, 1, rows), board, 1); err == nil { + t.Fatal("accepted invalid upstream ranks") + } + }) + } + other, _ := findRankingBoard("hongguo-ai") + if _, err := parseHongguoRanking(rankingFixture(other, 1, rankingRows(1)), board, 1); err == nil { + t.Fatal("accepted a different board") + } + if _, err := parseHongguoRanking(rankingFixture(board, 1, rankingRows(1)), board, 2); err == nil { + t.Fatal("accepted a repeated first page") + } + if _, err := parseHongguoRanking(rankingFixture(board, 1, []map[string]any{}), board, 1); err == nil { + t.Fatal("empty page with hasMore must be treated as a failure") + } +} + +func TestHuangdouRankingPreservesOrderAndMetrics(t *testing.T) { + rows := []any{ + map[string]any{"id": "rp_first", "name": "低热度先返回", "hot_rate": "20", "click": "100"}, + map[string]any{"id": "second", "name": "高热度后返回", "hot_rate": "1000", "click": "2"}, + } + result, err := parseHuangdouRanking(map[string]any{"data": map[string]any{"list": rows}}, 2) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 2 || result.Items[0].Rank != 21 || result.Items[0].Drama.ID != "huangdou:first" || result.Items[0].Drama.Heat != "20" || result.Items[0].Drama.Views != "100次播放" || result.HasMore { + t.Fatalf("wrong rank or source metric: %+v", result) + } + if _, err := parseHuangdouRanking(map[string]any{"error": "not a list"}, 1); err == nil { + t.Fatal("an upstream error cannot become an empty successful board") + } + result, err = parseHuangdouRanking(map[string]any{"list": []any{}}, 1) + if err != nil || len(result.Items) != 0 || result.HasMore { + t.Fatal("explicitly empty lists must be supported") + } +} + +func TestHuangguoRankingStructuredData(t *testing.T) { + board, _ := findRankingBoard("huangguo-potential") + body := `` + result, err := parseHuangguoRanking(body, board) + if err != nil || len(result.Items) != 2 || result.Items[1].Rank != 3 || result.Items[0].Drama.ID != "huangguoai:12" || result.HasMore { + t.Fatalf("could not preserve structured site ranks: %+v %v", result, err) + } + other, _ := findRankingBoard("huangguo-hot") + if _, err := parseHuangguoRanking(body, other); err == nil { + t.Fatal("accepted a different chart's structured data") + } + if _, err := parseHuangguoRanking(strings.ReplaceAll(body, "/detail/34/", "/detail/12/"), board); err == nil { + t.Fatal("accepted duplicate IDs") + } +} + +func TestHongguoReleaseLabelsUseSourceMeaning(t *testing.T) { + now := time.Date(2026, 9, 12, 17, 30, 0, 0, time.UTC) + for _, test := range []struct{ label, expected string }{ + {"今日上新", "2026-09-13"}, {"昨日上新", "2026-09-12"}, {"2026-08-21上新", "2026-08-21"}, + {"新剧", ""}, {"2026-02-31上新", ""}, {"都市", ""}, + } { + row := map[string]any{"sub_title_list": []any{map[string]any{"content": test.label}}} + if got := hongguoOnlineDate(row, now); got != test.expected { + t.Errorf("%q: got %q, want %q", test.label, got, test.expected) + } + } +} + +type rankingTransport func(*http.Request) (*http.Response, error) + +func (transport rankingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func rankingTestDownloader(t *testing.T, transport rankingTransport) *Downloader { + t.Helper() + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "direct", 1 + d := NewDownloader(cfg) + d.client = &http.Client{Transport: transport} + d.limiter = newRequestLimiter(8, time.Nanosecond) + return d +} + +func rankingHTTPResponse(request *http.Request, status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": {"text/html; charset=utf-8"}}, Request: request} +} + +func TestRankingCacheAndStaleRecovery(t *testing.T) { + board, _ := findRankingBoard("hongguo-hot") + var calls atomic.Int32 + var fail atomic.Bool + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if fail.Load() { + return rankingHTTPResponse(request, 503, "temporarily unavailable"), nil + } + page, _ := strconv.Atoi(request.URL.Query().Get("page")) + return rankingHTTPResponse(request, 200, rankingFixture(board, page, rankingRows(page))), nil + }) + ctx := context.Background() + first, err := d.loadRankingPage(ctx, board, 1, false) + if err != nil { + t.Fatal(err) + } + first.Items[0].Rank = 999 + again, err := d.loadRankingPage(ctx, board, 1, false) + if err != nil || again.Items[0].Rank != 1 || calls.Load() != 1 { + t.Fatal("a fresh page was not safely reused") + } + if _, err = d.loadRankingPage(ctx, board, 2, false); err != nil { + t.Fatal(err) + } + if _, err = d.loadRankingPage(ctx, board, 1, true); err != nil { + t.Fatal(err) + } + if _, found := d.rankings.pages[board.ID+":2"]; found { + t.Fatal("refresh must invalidate older continuation pages") + } + previous := d.rankings.pages[board.ID+":1"].FetchedAt + fail.Store(true) + stale, err := d.loadRankingPage(ctx, board, 1, true) + if err != nil || !stale.Stale || stale.Warning == "" || !stale.FetchedAt.Equal(previous) { + t.Fatalf("last valid page must retain its original freshness: %+v %v", stale, err) + } + fail.Store(false) + fresh, err := d.loadRankingPage(ctx, board, 1, true) + if err != nil || fresh.Stale || fresh.Warning != "" { + t.Fatal("a later successful refresh must clear the stale state") + } +} + +func TestRankingConcurrentRequestsShareFetch(t *testing.T) { + board, _ := findRankingBoard("hongguo-hot") + var calls atomic.Int32 + started, release := make(chan struct{}), make(chan struct{}) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if calls.Add(1) == 1 { + close(started) + } + <-release + return rankingHTTPResponse(request, 200, rankingFixture(board, 1, rankingRows(1))), nil + }) + var group sync.WaitGroup + for index := 0; index < 8; index++ { + group.Add(1) + go func() { + defer group.Done() + result, err := d.loadRankingPage(context.Background(), board, 1, false) + if err != nil || len(result.Items) != 2 { + t.Errorf("concurrent request failed: %v", err) + } + }() + } + <-started + close(release) + group.Wait() + if calls.Load() != 1 { + t.Fatalf("got %d upstream requests, want one", calls.Load()) + } +} + +func TestRankingHandlerRegistersPlayableMetadataOnce(t *testing.T) { + board, _ := findRankingBoard("hongguo-hot") + var calls atomic.Int32 + release := make(chan struct{}) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path == "/rank/hot-drama" { + calls.Add(1) + return rankingHTTPResponse(request, 200, rankingFixture(board, 1, rankingRows(1))), nil + } + select { + case <-release: + case <-request.Context().Done(): + return nil, request.Context().Err() + } + switch request.URL.Path { + case "/detail": + if request.URL.Query().Get("series_id") != "7680883072278989849" { + t.Error("ranking automatically enrolled an existing cache entry") + } + return rankingHTTPResponse(request, 200, sortDetailFixture("7680883072278989849", "1773662280")), nil + case "/novel/player/video_detail/v1/": + return rankingHTTPResponse(request, 200, `{"data":{"video_data":{"series_id_str":"7680883072278989849","hot_score":90000001,"series_play_cnt":10019}}}`), nil + default: + t.Errorf("unexpected ranking metadata request: %s", request.URL.Path) + return rankingHTTPResponse(request, 404, ""), nil + } + }) + a := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{{ID: "hongguo:7681903520873729048", Source: sourceHongguo, Title: "旧标题", OnlineDate: "2026-09-01"}, {ID: "huangdou:existing", Source: sourceHuangdou, Title: "保留其他站源"}}} + t.Cleanup(a.stopSortMetadata) + for _, test := range []struct { + method, path string + status int + }{ + {"GET", "/api/ui/rankings", 200}, {"POST", "/api/ui/rankings", 405}, + {"GET", "/api/ui/rankings?board=missing", 400}, {"GET", "/api/ui/rankings?board=hongguo-hot&page=0", 400}, + {"GET", "/api/ui/rankings?board=hongguo-hot&page=1.5", 400}, {"GET", "/api/ui/rankings?board=huangguo-hot&page=2", 400}, + } { + writer := httptest.NewRecorder() + a.handleRankings(writer, httptest.NewRequest(test.method, test.path, nil)) + if writer.Code != test.status { + t.Fatalf("%s returned %d, want %d", test.path, writer.Code, test.status) + } + } + if calls.Load() != 0 { + t.Fatal("listing available boards and rejecting invalid inputs must not fetch upstream") + } + var firstRevision uint64 + for index := 0; index < 2; index++ { + writer := httptest.NewRecorder() + a.handleRankings(writer, httptest.NewRequest("GET", "/api/ui/rankings?board=hongguo-hot", nil)) + if writer.Code != 200 { + t.Fatal(writer.Body.String()) + } + if index == 0 { + firstRevision = a.libraryRevision + } + } + if len(a.dramas) != 3 || a.libraryRevision != firstRevision || !a.librarySaved || calls.Load() != 1 { + t.Fatal("rankings must merge into the existing library once, including previously unseen dramas") + } + for _, drama := range a.dramas { + if drama.ID == "hongguo:7681903520873729048" && (drama.OnlineDate != "2026-09-01" || drama.Heat != "1.2亿热度") { + t.Fatal("ranking enrichment lost prior metadata") + } + } + close(release) + waitSortMetadataQueue(t, a) + a.mu.Lock() + defer a.mu.Unlock() + for _, drama := range a.dramas { + if drama.ID == "hongguo:7680883072278989849" && (drama.OnlineDate != "2026-03-16" || drama.Heat != "90000001" || drama.Views != "10019次播放" || drama.SortMetadata.Version != sortMetadataVersion) { + t.Fatal("new ranking entry was not completed asynchronously") + } + } +} + +func TestLiveRankingsMetadata(t *testing.T) { + if os.Getenv("JUKU_LIVE_RANKINGS") != "1" { + t.Skip("set JUKU_LIVE_RANKINGS=1 for explicit metadata-only network verification") + } + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.Retries = t.TempDir(), t.TempDir(), 1 + d := NewDownloader(cfg) + for _, board := range rankingBoards { + t.Run(board.ID, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + result, err := d.loadRankingPage(ctx, board, 1, true) + if err != nil || len(result.Items) == 0 { + t.Fatalf("no live ranking entries: %v", err) + } + for _, item := range result.Items { + if address := bestDramaCover(item.Drama); address != "" && (item.Drama.Source != sourceHongguo || hongguoCoverAddress(address) != address) { + t.Fatal("ranking metadata must retain only a validated cover address") + } + } + t.Logf("%s: %d entries, ranks %d–%d, more=%v", board.Name, len(result.Items), result.Items[0].Rank, result.Items[len(result.Items)-1].Rank, result.HasMore) + if (board.ID == "hongguo-hot" || board.ID == "huangdou-all") && result.HasMore { + next, err := d.loadRankingPage(ctx, board, 2, true) + if err != nil || len(next.Items) == 0 || next.Items[0].Rank != 21 || next.Items[0].Drama.ID == result.Items[0].Drama.ID { + t.Fatalf("live pagination did not advance: %v", err) + } + t.Logf("page 2: %d entries, first rank %d", len(next.Items), next.Items[0].Rank) + } + }) + } +} diff --git a/internal/app/provider_sort_details.go b/internal/app/provider_sort_details.go new file mode 100644 index 0000000..32cf2b7 --- /dev/null +++ b/internal/app/provider_sort_details.go @@ -0,0 +1,261 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +var sortMetadataMetric = regexp.MustCompile(`(?i)^\d+(?:\.\d+)?(?:亿|万|千|w|k|m|b)?\+?(?:次播放|次观看|人看过|热度|播放|观看|次)?\+?$`) + +func hasSortMetric(value string) bool { + return sortMetadataMetric.MatchString(strings.NewReplacer(",", "", ",", "", " ", "", "\t", "").Replace(value)) +} + +func hasCompleteSortMetadata(drama Drama) bool { + if providerReleaseDate(drama.OnlineDate) == "" || !hasSortMetric(drama.Views) { + return false + } + switch dramaProvider(drama) { + case sourceHongguo, sourceHuangdou: + return hasSortMetric(drama.Heat) + case sourceHuangguoAI, sourceHuangguoVideo: + + return true + } + return false +} + +func providerReleaseDate(value string) string { + value = strings.TrimSpace(value) + if stamp, err := time.Parse(time.RFC3339Nano, value); err == nil { + value = stamp.In(providerChinaTime).Format("2006-01-02") + } + date := normalizeDate(value) + if parsed, err := time.Parse("2006-01-02", date); err != nil || parsed.Year() < 2000 || parsed.Year() > 2100 { + return "" + } + return date +} + +func providerTimestampDate(value string) string { + stamp, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || stamp <= 0 { + return "" + } + if stamp > 100_000_000_000 { + stamp /= 1000 + } + date := time.Unix(stamp, 0).In(providerChinaTime) + if date.Year() < 2000 || date.Year() > 2100 { + return "" + } + return date.Format("2006-01-02") +} + +func (d *Downloader) fetchDramaSortMetadata(ctx context.Context, drama Drama) (Drama, error) { + source, id, ok := splitProviderDramaID(drama.ID) + patch := Drama{ID: drama.ID, Source: source, SourceID: id} + if !ok { + return patch, errors.New("无效的剧集 ID") + } + needCover := needsHongguoCoverAddress(drama) + if hasCompleteSortMetadata(drama) && !huangguoNeedsContentMetadata(drama) && !needCover && !needsHuangdouVIPMetadata(drama) { + return patch, nil + } + switch source { + case sourceHongguo: + if !hongguoNumericID.MatchString(id) { + return patch, errors.New("无效的红果剧集 ID") + } + var failures []error + if providerReleaseDate(drama.OnlineDate) == "" { + body, err := d.fetchProviderText(ctx, hongguoBaseURL+"/detail?series_id="+url.QueryEscape(id), hongguoBaseURL+"/") + if err == nil { + var web Drama + web, err = parseHongguoSortDetail(body, id) + patch = mergeDramaMetadata(web, patch) + } + if err != nil { + failures = append(failures, err) + } + } + needMetrics := !hasSortMetric(drama.Heat) || !hasSortMetric(drama.Views) + if !needMetrics && (!needCover || bestDramaCover(patch) != "") { + return patch, errors.Join(failures...) + } + result, appErr := d.hongguoAppRequest(ctx, http.MethodPost, "/novel/player/video_detail/v1/", nil, map[string]any{"series_id": id}) + if appErr == nil { + row := nestedMap(result, "data", "video_data") + if mapString(row, "series_id_str", "series_id") != id { + appErr = errors.New("红果详情返回了其他剧集") + } else { + patch.Title = mapString(row, "series_title", "series_name") + if needMetrics { + patch.Heat = hongguoHeat(row) + patch.Views = normalizeViews(mapString(row, "series_play_cnt", "play_cnt")) + } + if needCover { + if cover := hongguoCoverAddress(mapString(row, "series_cover", "cover")); cover != "" { + patch.Cover, patch.CoverURL = cover, cover + } + } + } + } + if appErr != nil { + failures = append(failures, appErr) + } + return patch, errors.Join(failures...) + case sourceHuangdou: + if !rankingSourceID.MatchString(id) { + return patch, errors.New("无效的黄豆剧集 ID") + } + row, err := d.huangdouDetail(ctx, id) + if err != nil { + return patch, err + } + patch.VIP = huangdouVIPFlag(row) + patch.Title = mapString(row, "name", "title") + patch.OnlineDate = providerReleaseDate(mapString(row, "issue_date")) + patch.Views = normalizeViews(mapString(row, "click")) + patch.Heat = mapString(row, "hot_rate") + if !hasSortMetric(patch.Heat) && !hasSortMetric(drama.Heat) { + + keyword := firstNonEmpty(patch.Title, drama.DisplayTitle()) + var decoded any + if err := newHuangdouAPIClient(d).call(ctx, "/drama/list", map[string]any{"keywords": keyword, "page": "1", "page_size": "50"}, &decoded); err != nil { + return patch, err + } + matched := false + for _, item := range huangdouList(decoded) { + if strings.TrimPrefix(mapString(item, "id", "drama_id"), "rp_") == id { + matched = true + patch.Heat = mapString(item, "hot_rate") + break + } + } + if !matched { + return patch, errors.New("黄豆热度查询未返回所请求剧集,可稍后重试") + } + } + return patch, nil + case sourceHuangguoAI, sourceHuangguoVideo: + pageURL, err := huangguoSortDetailURL(source, id) + if err != nil { + return patch, err + } + body, err := d.fetchProviderText(ctx, pageURL, providerRefererForURL(pageURL, "")) + if err != nil { + return patch, err + } + return parseHuangguoSortDetail(body, pageURL, patch) + } + return patch, errors.New("该站源暂无资料补齐接口") +} + +func huangguoSortDetailURL(source, id string) (string, error) { + if source == sourceHuangguoVideo { + parts := strings.Split(id, "/") + if len(parts) == 1 { + parts = []string{"series", id} + } + if len(parts) != 2 || (parts[0] != "series" && parts[0] != "video") || !rankingSourceID.MatchString(parts[1]) { + return "", errors.New("无效的黄果剧集 ID") + } + return huangguoVideoBaseURL + "/" + parts[0] + "/" + url.PathEscape(parts[1]), nil + } + if source != sourceHuangguoAI || !rankingSourceID.MatchString(id) { + return "", errors.New("无效的黄果剧集 ID") + } + return huangguoAIBaseURL + "/detail/" + url.PathEscape(id) + "/", nil +} + +func parseHongguoSortDetail(body, id string) (Drama, error) { + row := nestedMap(routerLoaderMap(parseRouterData(body), "detail_page", "detail_"), "seriesDetail") + if mapString(row, "series_id", "series_id_str") != id || mapString(row, "series_name", "series_title") == "" { + return Drama{}, errors.New("红果网页详情与请求剧集不符") + } + patch := hongguoDramaFromAny(row, "") + patch.Title = mapString(row, "series_name", "series_title") + patch.Name = patch.Title + patch.OnlineDate = providerTimestampDate(mapString(row, "first_visible_time")) + if cover := hongguoCoverAddress(mapString(row, "series_cover", "cover")); cover != "" { + patch.Cover, patch.CoverURL = cover, cover + } + return patch, nil +} + +func parseHuangguoSortDetail(body, pageURL string, patch Drama) (Drama, error) { + type entry struct { + Type string `json:"@type"` + ID string `json:"@id"` + URL string `json:"url"` + Name string `json:"name"` + Published string `json:"datePublished"` + Uploaded string `json:"uploadDate"` + Image any `json:"image"` + Thumbnail any `json:"thumbnailUrl"` + } + expected, _ := url.Parse(pageURL) + matched := false + for _, block := range rankingJSONLD.FindAllStringSubmatch(body, -1) { + var graph struct { + Graph []entry `json:"@graph"` + entry + } + if json.Unmarshal([]byte(block[1]), &graph) != nil { + continue + } + for _, row := range append(graph.Graph, graph.entry) { + if row.Type != "WebPage" && row.Type != "VideoObject" && row.Type != "TVSeries" && row.Type != "Movie" { + continue + } + actual, err := url.Parse(firstNonEmpty(row.URL, row.ID)) + if err != nil || actual.Host != "" && !strings.EqualFold(actual.Host, expected.Host) && providerSourceForURL(actual.String()) != patch.Source || strings.TrimRight(actual.Path, "/") != strings.TrimRight(expected.Path, "/") || row.Name == "" || patch.Source == sourceHuangguoAI && huangguoTitleNeedsRepair(row.Name, patch.SourceID) { + continue + } + matched = true + patch.Title = row.Name + if address := firstNonEmpty(providerCoverAddress(row.Image, pageURL), providerCoverAddress(row.Thumbnail, pageURL)); address != "" { + patch.Cover, patch.CoverURL = address, address + } + if date := providerReleaseDate(firstNonEmpty(row.Uploaded, row.Published)); date != "" && (patch.OnlineDate == "" || row.Type == "VideoObject") { + patch.OnlineDate = date + } + } + } + if !matched { + return patch, fmt.Errorf("黄果详情没有返回所请求剧集的元数据") + } + if bestDramaCover(patch) == "" { + for _, tag := range coverMetaTag.FindAllString(body, -1) { + if strings.ToLower(extractAttr(tag, "property", "name")) == "og:image" { + if address := providerCoverAddress(extractAttr(tag, "content"), pageURL); address != "" { + patch.Cover, patch.CoverURL = address, address + break + } + } + } + } + markup := huangguoNonContent.ReplaceAllString(body, "") + metaBlock := huangguoClassBlock(markup, "hg-web-detail__meta") + meta := cleanText(metaBlock) + patch.Views = normalizeViews(firstMatchText(reViewsText, meta)) + if patch.OnlineDate == "" && strings.Contains(meta, "上线") { + patch.OnlineDate = providerReleaseDate(firstMatchText(reDateText, meta)) + } + if patch.Source == sourceHuangguoAI { + episode := firstNonEmpty(extractAttr(metaBlock, "data-ep-base"), meta) + if count := episodeIndex(episode, 0); count > 0 { + patch.TotalEpisode, patch.EpisodeCount = count, count + } + } + return patch, nil +} diff --git a/internal/app/provider_sort_metadata.go b/internal/app/provider_sort_metadata.go new file mode 100644 index 0000000..e1aceed --- /dev/null +++ b/internal/app/provider_sort_metadata.go @@ -0,0 +1,46 @@ +package app + +import ( + "math" + "strconv" + "strings" + "time" +) + +var providerChinaTime = time.FixedZone("CST", 8*60*60) + +func hongguoHeat(row map[string]any) string { + hot := nestedMap(row, "hot_score_data") + + if score := firstNonEmpty(mapString(hot, "score"), mapString(row, "hot_score")); score != "" { + value, err := strconv.ParseFloat(score, 64) + if err == nil && value >= 0 && !math.IsInf(value, 0) && !math.IsNaN(value) { + return score + } + } + return mapString(hot, "text") +} + +func hongguoOnlineDate(row map[string]any, now time.Time) string { + if date := providerTimestampDate(mapString(row, "first_visible_time")); date != "" { + return date + } + for _, value := range anyList(row["sub_title_list"]) { + label := mapString(nestedMap(value), "content") + day := now.In(providerChinaTime) + switch label { + case "今日上新": + return day.Format("2006-01-02") + case "昨日上新": + return day.AddDate(0, 0, -1).Format("2006-01-02") + default: + if strings.HasSuffix(label, "上新") { + date := normalizeDate(label) + if _, err := time.Parse("2006-01-02", date); err == nil { + return date + } + } + } + } + return "" +} diff --git a/internal/app/ui_account.go b/internal/app/ui_account.go new file mode 100644 index 0000000..e17d649 --- /dev/null +++ b/internal/app/ui_account.go @@ -0,0 +1,295 @@ +package app + +import ( + "encoding/hex" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strings" + "time" +) + +func readAccountRequest(writer http.ResponseWriter, request *http.Request, input any) bool { + if !playbackRequestAllowed(writer, request, http.MethodPost) { + return false + } + if request.Header.Get("X-Juku-Viewer") == "" { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请从剧库账号面板操作"}) + return false + } + contentType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type")) + if contentType != "application/json" { + writeJSON(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "账号请求必须使用 JSON"}) + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 4096)) + decoder.DisallowUnknownFields() + if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "账号请求无效"}) + return false + } + return requestViewer(writer, request) != nil +} + +func accountOperationError(writer http.ResponseWriter, err error) { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "账号操作未完成:" + publicError(err).Error()}) +} + +func accountHashPermit(writer http.ResponseWriter, request *http.Request, store *accountStore, name string) bool { + if store.err != nil { + accountOperationError(writer, store.err) + return false + } + if !store.allowAttempt(name, request.RemoteAddr, time.Now()) { + writer.Header().Set("Retry-After", "60") + writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "账号尝试次数较多,请一分钟后重试"}) + return false + } + select { + case store.hashing <- struct{}{}: + return true + default: + writer.Header().Set("Retry-After", "2") + writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "正在处理其他登录请求,请稍后重试"}) + return false + } +} + +func accountSecureRequest(request *http.Request) bool { + return request.TLS != nil || strings.EqualFold(request.Header.Get("X-Forwarded-Proto"), "https") +} + +func (manager *viewerManager) setAccountCookie(writer http.ResponseWriter, request *http.Request, token string) { + maxAge, expires := int(accountLifetime/time.Second), time.Now().Add(accountLifetime) + if token == "" { + maxAge, expires = -1, time.Unix(1, 0) + } + http.SetCookie(writer, &http.Cookie{Name: manager.accountCookieName(), Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: accountSecureRequest(request), MaxAge: maxAge, Expires: expires}) +} + +func (manager *viewerManager) setGuestCookie(writer http.ResponseWriter, request *http.Request, identity string) { + expires := time.Now().Add(viewerLifetime) + http.SetCookie(writer, &http.Cookie{Name: manager.cookie, Value: manager.token(identity, expires), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: accountSecureRequest(request), MaxAge: int(viewerLifetime / time.Second), Expires: expires}) +} + +func (app *UIApp) handleAccountRegister(writer http.ResponseWriter, request *http.Request) { + var input struct { + Username string `json:"username"` + Password string `json:"password"` + } + if !readAccountRequest(writer, request, &input) { + return + } + manager := app.browserViewers() + if account, _, _, _ := manager.requestAccount(request); account.ID != "" { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "请先退出当前账号再注册"}) + return + } + name, display, err := normalizeAccountUsername(input.Username) + if err == nil { + err = validateAccountPassword(input.Password) + } + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + store := manager.accountStore() + if _, allowed := store.policy(); !allowed { + writeJSON(writer, http.StatusForbidden, map[string]string{"code": "registration_disabled", "error": "管理员已关闭注册,请联系管理员创建账号"}) + return + } + if !accountHashPermit(writer, request, store, name) { + return + } + defer func() { <-store.hashing }() + salt, hash, err := newAccountPassword(input.Password) + if err != nil { + accountOperationError(writer, err) + return + } + identifier := randomHex(32) + if identifier == "" { + accountOperationError(writer, errors.New("无法创建账号,请重试")) + return + } + now := time.Now() + account := accountRecord{ID: identifier, Username: display, Salt: salt, PasswordHash: hash, Algorithm: "pbkdf2-sha256", Iterations: accountPasswordIterations, CreatedAt: now} + store.mu.Lock() + defer store.mu.Unlock() + if !store.state.AllowRegistration { + writeJSON(writer, http.StatusForbidden, map[string]string{"code": "registration_disabled", "error": "管理员已关闭注册,请联系管理员创建账号"}) + return + } + if _, exists := store.state.Accounts[name]; exists { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "该用户名已被使用"}) + return + } + if len(store.state.Accounts) >= accountLimit { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "此服务的账号数量已达上限"}) + return + } + state := store.snapshotLocked() + state.Accounts[name] = account + token, err := state.addSession(name, now) + if err == nil { + err = store.saveLocked(state) + } + if err != nil { + accountOperationError(writer, err) + return + } + manager.setAccountCookie(writer, request, token) + writeJSON(writer, http.StatusCreated, map[string]any{"ok": true, "account": account.public()}) +} + +func (app *UIApp) handleAccountLogin(writer http.ResponseWriter, request *http.Request) { + var input struct { + Username string `json:"username"` + Password string `json:"password"` + } + if !readAccountRequest(writer, request, &input) { + return + } + manager := app.browserViewers() + if account, _, _, _ := manager.requestAccount(request); account.ID != "" { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "请先退出当前账号再切换账号"}) + return + } + name, _, nameErr := normalizeAccountUsername(input.Username) + if nameErr != nil || validateAccountPassword(input.Password) != nil { + writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "用户名或密码不正确"}) + return + } + store := manager.accountStore() + if !accountHashPermit(writer, request, store, name) { + return + } + defer func() { <-store.hashing }() + store.mu.Lock() + account, found := store.state.Accounts[name] + store.mu.Unlock() + if !found { + dummySalt := manager.signature("account-password-dummy") + account = accountRecord{Salt: hex.EncodeToString(dummySalt[:16]), PasswordHash: strings.Repeat("0", 64), Iterations: accountPasswordIterations} + } + valid := verifyAccountPassword(account, input.Password) + store.mu.Lock() + defer store.mu.Unlock() + current := store.state.Accounts[name] + if !valid || !found || current.ID != account.ID || current.PasswordHash != account.PasswordHash || current.Salt != account.Salt { + writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "用户名或密码不正确"}) + return + } + state := store.snapshotLocked() + token, err := state.addSession(name, time.Now()) + if err == nil { + err = store.saveLocked(state) + } + if err != nil { + accountOperationError(writer, err) + return + } + manager.setAccountCookie(writer, request, token) + writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "account": account.public()}) +} + +func (app *UIApp) handleAccountLogout(writer http.ResponseWriter, request *http.Request) { + var input struct{} + if !readAccountRequest(writer, request, &input) { + return + } + manager := app.browserViewers() + guest := randomHex(32) + if guest == "" { + accountOperationError(writer, errors.New("无法建立新的浏览器身份,请重试")) + return + } + if cookie, err := request.Cookie(manager.accountCookieName()); err == nil { + store := manager.accountStore() + store.mu.Lock() + state := store.snapshotLocked() + delete(state.Sessions, accountTokenHash(cookie.Value)) + err = store.saveLocked(state) + store.mu.Unlock() + if err != nil { + accountOperationError(writer, err) + return + } + } + manager.setAccountCookie(writer, request, "") + manager.setGuestCookie(writer, request, guest) + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} + +func (app *UIApp) handleAccountPassword(writer http.ResponseWriter, request *http.Request) { + var input struct { + Password string `json:"password"` + NewPassword string `json:"newPassword"` + } + if !readAccountRequest(writer, request, &input) { + return + } + manager := app.browserViewers() + account, name, _, _ := manager.requestAccount(request) + if account.ID == "" { + writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "请先登录账号"}) + return + } + if account.RequirePasswordChange && input.NewPassword == input.Password { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "新密码不能与随机初始密码相同"}) + return + } + if err := validateAccountPassword(input.NewPassword); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if validateAccountPassword(input.Password) != nil { + writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "当前密码不正确"}) + return + } + store := manager.accountStore() + if !accountHashPermit(writer, request, store, name) { + return + } + defer func() { <-store.hashing }() + if !verifyAccountPassword(account, input.Password) { + writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "当前密码不正确"}) + return + } + salt, hash, err := newAccountPassword(input.NewPassword) + if err != nil { + accountOperationError(writer, err) + return + } + cookie, _ := request.Cookie(manager.accountCookieName()) + store.mu.Lock() + defer store.mu.Unlock() + current := store.state.Accounts[name] + if current.PasswordHash != account.PasswordHash || current.Salt != account.Salt || cookie == nil || store.state.Sessions[accountTokenHash(cookie.Value)].Account != name { + writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "账号状态已变化,请重新登录") + return + } + state := store.snapshotLocked() + account.Salt, account.PasswordHash, account.Iterations = salt, hash, accountPasswordIterations + account.RequirePasswordChange = false + state.Accounts[name] = account + for key, session := range state.Sessions { + if session.Account == name { + delete(state.Sessions, key) + } + } + token, err := state.addSession(name, time.Now()) + if err == nil { + err = store.saveLocked(state) + } + if err != nil { + accountOperationError(writer, err) + return + } + manager.setAccountCookie(writer, request, token) + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/internal/app/ui_account_browser_test.go b/internal/app/ui_account_browser_test.go new file mode 100644 index 0000000..b422453 --- /dev/null +++ b/internal/app/ui_account_browser_test.go @@ -0,0 +1,130 @@ +package app + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func TestAccountBrowserFixture(t *testing.T) { + root := os.Getenv("JUKU_ACCOUNT_BROWSER_ROOT") + if root == "" { + t.Skip("browser fixture is opt-in") + } + media, err := os.ReadFile(os.Getenv("JUKU_ACCOUNT_BROWSER_MEDIA")) + if err != nil { + t.Fatal(err) + } + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Fatal(err) + } + mediaServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/synthetic.mp4" { + t.Error("fixture attempted an unrelated media request", request.URL.Path) + http.NotFound(writer, request) + return + } + http.ServeContent(writer, request, "synthetic.mp4", time.Time{}, bytes.NewReader(media)) + })) + defer mediaServer.Close() + app := viewerTestApp(t) + t.Cleanup(app.embySyncer().stop) + app.loadedAt = time.Now() + app.tasks = map[string]*UITask{} + app.cond = sync.NewCond(&app.mu) + app.statePath = filepath.Join(app.cfg.dataDirectory(), "ui-state.json") + app.downloader.cfg.FFmpeg = ffmpeg + app.cfg.FFmpeg = ffmpeg + app.downloader.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: ffmpeg}} + transport := mediaServer.Client().Transport + app.downloader.client = &http.Client{Transport: rankingTransport(func(request *http.Request) (*http.Response, error) { + if !strings.HasPrefix(request.URL.String(), mediaServer.URL+"/") { + t.Error("browser fixture attempted an external request", request.URL.Host) + return nil, errors.New("external requests forbidden") + } + return transport.RoundTrip(request) + })} + app.cfg.adminUsername, app.cfg.adminPassword, app.cfg.adminPasswordExplicit = "admin", accountFixturePassword, true + adminPassword := accountFixturePassword + if os.Getenv("JUKU_ACCOUNT_BROWSER_RANDOM_ADMIN") == "1" { + app.cfg.adminPassword, app.cfg.adminPasswordExplicit = "", false + bootstrap, err := app.browserViewers().ensureAdministrator(app.cfg) + if err != nil { + t.Fatal(err) + } + adminPassword = bootstrap.InitialPassword + } + if err := app.prepareBrowserViewers(); err != nil { + t.Fatal(err) + } + client := app.downloader.hongguoClient() + entry := client.details["7000000000000000001"] + if os.Getenv("JUKU_ACCOUNT_BROWSER_MOBILE") == "1" { + entry.Chapters = nil + for index := 1; index <= 65; index++ { + entry.Chapters = append(entry.Chapters, Chapter{ID: providerChapterID(sourceHongguo, "7000000000000000001", strconv.Itoa(index)), Source: sourceHongguo, CurrentEpisode: rawEpisode(index)}) + } + app.dramas[0].TotalEpisode = 65 + app.dramas[0].ReleaseStatus = "finished" + } + for index := range entry.Chapters { + entry.Chapters[index].VideoURL = mediaServer.URL + "/synthetic.mp4" + } + client.details["7000000000000000001"] = entry + routes := app.routes() + var requestsMu sync.Mutex + var nativeRequests []map[string]string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/ui/cover/repair": + writeJSON(writer, http.StatusOK, map[string]any{"dramaId": historyFixtureDramaID, "retryAfter": 300}) + return + case "/api/ui/image": + t.Error("browser fixture must never request images") + http.NotFound(writer, request) + return + } + if strings.HasPrefix(request.URL.Path, "/api/ui/playback/hls/") { + account, _, _, _ := app.browserViewers().requestAccount(request) + requestsMu.Lock() + nativeRequests = append(nativeRequests, map[string]string{"path": request.URL.Path, "account": account.Username}) + requestsMu.Unlock() + } + routes.ServeHTTP(writer, request) + })) + defer func() { + server.Close() + requestsMu.Lock() + data, err := json.Marshal(nativeRequests) + requestsMu.Unlock() + if err == nil { + err = os.WriteFile(filepath.Join(root, "native-requests.json"), data, 0600) + } + if err != nil { + t.Error(err) + } + }() + ready, _ := json.Marshal(map[string]string{"url": server.URL, "adminPassword": adminPassword}) + if err = os.WriteFile(filepath.Join(root, "ready.json"), ready, 0600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(4 * time.Minute) + for time.Now().Before(deadline) { + if _, err := os.Stat(filepath.Join(root, "stop")); err == nil { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("browser fixture timed out") +} diff --git a/internal/app/ui_account_test.go b/internal/app/ui_account_test.go new file mode 100644 index 0000000..29e97d8 --- /dev/null +++ b/internal/app/ui_account_test.go @@ -0,0 +1,504 @@ +package app + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const accountFixturePassword = "long-fixture-password-123" + +type accountTestBrowser struct { + handler http.Handler + cookies map[string]*http.Cookie + id string + secure bool +} + +func (browser *accountTestBrowser) request(t *testing.T, method, path string, input any) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + if input != nil { + if err := json.NewEncoder(&body).Encode(input); err != nil { + t.Fatal(err) + } + } + request := httptest.NewRequest(method, "http://shared.example.test"+path, &body) + request.RemoteAddr = "192.168.1.10:1234" + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.Header.Set("Content-Type", "application/json") + if browser.id != "" { + request.Header.Set("X-Juku-Viewer", browser.id) + } + if browser.secure { + request.Header.Set("X-Forwarded-Proto", "https") + } + for _, cookie := range browser.cookies { + request.AddCookie(cookie) + } + writer := httptest.NewRecorder() + browser.handler.ServeHTTP(writer, request) + for _, cookie := range writer.Result().Cookies() { + if cookie.MaxAge < 0 { + delete(browser.cookies, cookie.Name) + } else { + browser.cookies[cookie.Name] = cookie + } + } + return writer +} + +func (browser *accountTestBrowser) refresh(t *testing.T) map[string]any { + t.Helper() + result := browser.request(t, http.MethodGet, "/api/ui/viewer", nil) + viewerResultOK(t, result) + var state map[string]any + if err := json.Unmarshal(result.Body.Bytes(), &state); err != nil { + t.Fatal(err) + } + if state["ready"] != true { + result = browser.request(t, http.MethodGet, "/api/ui/viewer?confirm=1", nil) + viewerResultOK(t, result) + if err := json.Unmarshal(result.Body.Bytes(), &state); err != nil { + t.Fatal(err) + } + } + browser.id, _ = state["id"].(string) + if len(browser.id) != 64 { + t.Fatal("missing viewer identity", state) + } + return state +} + +func newAccountTestBrowser(t *testing.T, app *UIApp) *accountTestBrowser { + t.Helper() + browser := &accountTestBrowser{handler: app.routes(), cookies: map[string]*http.Cookie{}} + browser.refresh(t) + return browser +} + +func (browser *accountTestBrowser) register(t *testing.T, name string) { + t.Helper() + result := browser.request(t, http.MethodPost, "/api/ui/account/register", map[string]string{"username": name, "password": accountFixturePassword}) + if result.Code != http.StatusCreated { + t.Fatal(result.Code, result.Body.String()) + } + if browser.refresh(t)["account"] == nil { + t.Fatal("registration did not sign in") + } +} + +func (browser *accountTestBrowser) login(t *testing.T, name, password string) { + t.Helper() + viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/account/login", map[string]string{"username": name, "password": password})) + if browser.refresh(t)["account"] == nil { + t.Fatal("login not confirmed") + } +} + +func (browser *accountTestBrowser) copy() *accountTestBrowser { + result := &accountTestBrowser{handler: browser.handler, cookies: map[string]*http.Cookie{}, id: browser.id, secure: browser.secure} + for name, cookie := range browser.cookies { + copy := *cookie + result.cookies[name] = © + } + return result +} + +func (browser *accountTestBrowser) history(t *testing.T) []playbackHistoryEntry { + t.Helper() + result := browser.request(t, http.MethodGet, "/api/ui/playback/history", nil) + viewerResultOK(t, result) + var response struct { + Data []playbackHistoryEntry `json:"data"` + } + if err := json.Unmarshal(result.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Header().Get("Cache-Control"), "no-store") || !strings.Contains(result.Header().Get("Vary"), "Cookie") { + t.Fatal("account history could be cached") + } + return response.Data +} + +func (browser *accountTestBrowser) following(t *testing.T) []followingView { + t.Helper() + result := browser.request(t, http.MethodGet, "/api/ui/following", nil) + viewerResultOK(t, result) + var response struct { + Data []followingView `json:"data"` + } + if err := json.Unmarshal(result.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response.Data +} + +func (browser *accountTestBrowser) watch(t *testing.T, app *UIApp, episode int, position float64) string { + t.Helper() + result := browser.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID}) + viewerResultOK(t, result) + var opened struct { + Session string `json:"session"` + } + if err := json.Unmarshal(result.Body.Bytes(), &opened); err != nil || opened.Session == "" { + t.Fatal("missing playback session", err) + } + app.playbackMu.Lock() + session := app.playbacks[opened.Session] + session.run, session.currentIndex, session.state = 1, episode, "ended" + session.historyRuns = map[uint64]playbackHistoryRun{1: {episode: episode, duration: 90}} + app.playbackMu.Unlock() + progress := playbackHistoryProgress{Run: 1, Sequence: 1, Episode: episode, Position: position, Duration: 90} + viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": opened.Session, "progress": progress})) + return opened.Session +} + +func TestAccountPasswordMatchesPBKDF2SHA256Vectors(t *testing.T) { + for _, vector := range []struct { + iterations int + want string + }{ + {1, "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"}, + {2, "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43"}, + {4096, "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a"}, + } { + if actual := hex.EncodeToString(accountPasswordKey("password", []byte("salt"), vector.iterations)); actual != vector.want { + t.Fatal(vector.iterations, actual) + } + } + for _, name := range []string{"x", "with spaces", "../path", "name@host", strings.Repeat("长", 33)} { + if _, _, err := normalizeAccountUsername(name); err == nil { + t.Fatal("invalid account name accepted", name) + } + } + if name, display, err := normalizeAccountUsername(" Alice_中文 "); err != nil || name != "alice_中文" || display != "Alice_中文" { + t.Fatal(name, display, err) + } +} + +func TestAccountCrossDeviceHistoryFollowingAndRestart(t *testing.T) { + app := viewerTestApp(t) + alice, bob := newAccountTestBrowser(t, app), newAccountTestBrowser(t, app) + alice.register(t, "Alice") + bob.register(t, "Bob") + if alice.id == bob.id { + t.Fatal("different accounts share a record identity") + } + aliceSession := alice.watch(t, app, 1, 26) + bob.watch(t, app, 2, 49) + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true})) + phone := newAccountTestBrowser(t, app) + phone.login(t, "aLiCe", accountFixturePassword) + if phone.id != alice.id || len(phone.history(t)) != 1 || phone.history(t)[0].Position != 26 || len(phone.following(t)) != 1 || len(bob.following(t)) != 0 || bob.history(t)[0].Index != 2 { + t.Fatal("cross-device login did not preserve account isolation") + } + resumed := phone.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}) + viewerResultOK(t, resumed) + var resume struct { + Index int `json:"initialIndex"` + Position float64 `json:"initialPosition"` + } + if err := json.Unmarshal(resumed.Body.Bytes(), &resume); err != nil || resume.Index != 1 || resume.Position != 26 { + t.Fatal("cross-device resume lost position", resume, err) + } + for _, path := range []string{"/api/ui/playback/status?session=" + aliceSession, "/api/ui/playback/hls/index.m3u8?run=1&session=" + aliceSession, "/api/ui/playback/hls/segment.ts?run=1&segment=0&session=" + aliceSession} { + if result := bob.request(t, http.MethodGet, path, nil); result.Code != http.StatusGone { + t.Fatal("other account accessed a playback session", path, result.Code) + } + } + stale := bob.copy() + stale.id = alice.id + if result := stale.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true}); result.Code != http.StatusConflict { + t.Fatal("stale account tab could write", result.Code) + } + restarted := &UIApp{cfg: app.cfg} + phone.handler, bob.handler = restarted.routes(), restarted.routes() + if phone.refresh(t)["account"] == nil || phone.history(t)[0].Position != 26 || bob.history(t)[0].Position != 49 || len(phone.following(t)) != 1 { + t.Fatal("restart lost account sessions or records") + } + contents, err := os.ReadFile(app.browserViewers().accountStore().path) + if err != nil || bytes.Contains(contents, []byte(accountFixturePassword)) { + t.Fatal("plaintext password stored", err) + } + for _, cookie := range alice.cookies { + if bytes.Contains(contents, []byte(cookie.Value)) { + t.Fatal("bearer token stored in plaintext") + } + } + store := app.browserViewers().accountStore() + if store.state.Accounts["alice"].Salt == store.state.Accounts["bob"].Salt { + t.Fatal("accounts reused password salt") + } +} + +func TestAccountGuestImportPrivateRetryableAndIdempotent(t *testing.T) { + app := viewerTestApp(t) + alice := newAccountTestBrowser(t, app) + guestID, guestCookie := alice.id, alice.cookies[app.browserViewers().cookie] + alice.watch(t, app, 1, 31) + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true})) + originals := map[string][]byte{} + for _, name := range []string{"playback-history.json", "following.json"} { + data, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), "viewers", guestID, name)) + if err != nil { + t.Fatal(err) + } + originals[name] = data + } + alice.register(t, "Alice") + if alice.id == guestID || len(alice.history(t)) != 0 { + t.Fatal("guest records were silently shared with the account") + } + if alice.refresh(t)["guestImportAvailable"] != true { + t.Fatal("guest import not offered") + } + viewer := app.browserViewers().acquire(alice.id) + defer viewer.release() + history := viewer.playbackHistory() + path := history.path + history.path = app.cfg.dataDirectory() + failed := alice.request(t, http.MethodPost, "/api/ui/account/import", map[string]any{}) + if failed.Code != http.StatusInternalServerError { + t.Fatal("failed record write claimed success", failed.Code) + } + history.path = path + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/account/import", map[string]any{})) + if len(alice.history(t)) != 1 || alice.history(t)[0].Position != 31 || len(alice.following(t)) != 1 { + t.Fatal("guest import lost records") + } + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]any{"all": true})) + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": false})) + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/account/import", map[string]any{})) + if len(alice.history(t)) != 0 || len(alice.following(t)) != 0 { + t.Fatal("repeated import resurrected deleted account records") + } + bob := newAccountTestBrowser(t, app) + bob.register(t, "Bob") + bob.cookies[guestCookie.Name] = guestCookie + if result := bob.request(t, http.MethodPost, "/api/ui/account/import", map[string]any{}); result.Code != http.StatusConflict || len(bob.history(t)) != 0 { + t.Fatal("another account claimed the same guest records", result.Code) + } + for name, original := range originals { + data, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), "viewers", guestID, name)) + if err != nil || !bytes.Equal(data, original) { + t.Fatal("guest import altered the original records", name, err) + } + } +} + +func TestAccountContinuesGuestLegacyRecovery(t *testing.T) { + for _, failedStore := range []string{"following", "history"} { + t.Run(failedStore, func(t *testing.T) { + app := viewerTestApp(t) + originals := viewerLegacyFixture(t, app) + alice, bob := newAccountTestBrowser(t, app), newAccountTestBrowser(t, app) + guest := alice.copy() + manager := app.browserViewers() + viewer := manager.acquire(guest.id) + defer viewer.release() + path := &viewer.playbackHistory().path + if failedStore == "following" { + path = &viewer.followingStore().path + } + originalPath := *path + *path = app.cfg.dataDirectory() + input := map[string]string{"code": manager.legacyCode()} + if result := guest.request(t, http.MethodPost, "/api/ui/viewer/legacy", input); result.Code != http.StatusInternalServerError { + t.Fatal("legacy recovery did not exercise a partial disk failure", result.Code) + } + *path = originalPath + alice.register(t, "Alice") + if state := alice.refresh(t); state["legacyAvailable"] != false || state["guestImportAvailable"] != true { + t.Fatal("guest recovery ownership was lost or silently assigned", state) + } + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/account/import", map[string]any{})) + if alice.refresh(t)["legacyAvailable"] != true { + t.Fatal("account cannot continue the imported guest's unfinished recovery") + } + for _, other := range []*accountTestBrowser{guest, bob} { + if result := other.request(t, http.MethodPost, "/api/ui/viewer/legacy", input); result.Code != http.StatusConflict { + t.Fatal("legacy recovery could be reclaimed outside its account", result.Code) + } + } + phone := newAccountTestBrowser(t, app) + phone.login(t, "Alice", accountFixturePassword) + viewerResultOK(t, phone.request(t, http.MethodPost, "/api/ui/viewer/legacy", input)) + if entries := phone.history(t); len(entries) != 1 || entries[0].Position != 26 || len(phone.following(t)) != 1 || len(bob.history(t)) != 0 { + t.Fatal("continued recovery lost or shared records", entries) + } + state, err := manager.legacyImport() + if err != nil || !state.Completed || state.Viewer != phone.id { + t.Fatal("completed recovery did not persist account ownership", state, err) + } + viewerResultOK(t, phone.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]any{"all": true})) + viewerResultOK(t, phone.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": false})) + viewerResultOK(t, phone.request(t, http.MethodPost, "/api/ui/viewer/legacy", input)) + if len(phone.history(t)) != 0 || len(phone.following(t)) != 0 { + t.Fatal("continued recovery resurrected deleted records") + } + for name, before := range originals { + after, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), name)) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("continued recovery altered the original shared file", name, err) + } + } + }) + } +} + +func TestAccountLogoutPasswordRotationAndSecureProxyCookie(t *testing.T) { + app := viewerTestApp(t) + alice := newAccountTestBrowser(t, app) + alice.secure = true + alice.register(t, "Alice") + accountCookie := alice.cookies[app.browserViewers().accountCookieName()] + if accountCookie == nil || !accountCookie.HttpOnly || !accountCookie.Secure || accountCookie.SameSite != http.SameSiteLaxMode || accountCookie.Domain != "" || accountCookie.Path != "/" || accountCookie.MaxAge != int(accountLifetime/time.Second) { + t.Fatal("reverse proxy login cookie is unsafe") + } + alice.watch(t, app, 2, 37) + phone := newAccountTestBrowser(t, app) + phone.login(t, "Alice", accountFixturePassword) + revoked := alice.copy() + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/account/logout", map[string]any{})) + alice.refresh(t) + if len(alice.history(t)) != 0 || alice.id == phone.id || phone.history(t)[0].Position != 37 { + t.Fatal("logout leaked or erased account history") + } + if result := revoked.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusUnauthorized { + t.Fatal("logout cookie remained usable", result.Code) + } + oldPhone := phone.copy() + newPassword := "a-different-long-password-456" + viewerResultOK(t, phone.request(t, http.MethodPost, "/api/ui/account/password", map[string]string{"password": accountFixturePassword, "newPassword": newPassword})) + phone.refresh(t) + if phone.history(t)[0].Position != 37 { + t.Fatal("password change lost records") + } + if result := oldPhone.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusUnauthorized { + t.Fatal("password rotation kept an old login alive", result.Code) + } + if result := alice.request(t, http.MethodPost, "/api/ui/account/login", map[string]string{"username": "Alice", "password": accountFixturePassword}); result.Code != http.StatusUnauthorized { + t.Fatal("old password still works") + } + alice.login(t, "Alice", newPassword) + if alice.history(t)[0].Position != 37 { + t.Fatal("new password did not restore history") + } +} + +func TestAccountAuthenticationRejectsCrossSiteAndMissingGuard(t *testing.T) { + app := viewerTestApp(t) + browser := newAccountTestBrowser(t, app) + for _, input := range []struct { + site, contentType, guard string + status int + }{ + {"cross-site", "application/json", browser.id, http.StatusForbidden}, + {"same-site", "application/json", browser.id, http.StatusForbidden}, + {"same-origin", "text/plain", browser.id, http.StatusUnsupportedMediaType}, + {"same-origin", "application/json", "", http.StatusForbidden}, + {"same-origin", "application/json", strings.Repeat("f", 64), http.StatusConflict}, + } { + request := httptest.NewRequest(http.MethodPost, "http://shared.example.test/api/ui/account/register", strings.NewReader(`{"username":"Alice","password":"long-fixture-password-123"}`)) + request.Header.Set("Sec-Fetch-Site", input.site) + request.Header.Set("Content-Type", input.contentType) + request.Header.Set("X-Juku-Viewer", input.guard) + for _, cookie := range browser.cookies { + request.AddCookie(cookie) + } + writer := httptest.NewRecorder() + browser.handler.ServeHTTP(writer, request) + if writer.Code != input.status { + t.Fatal(input, writer.Code, writer.Body.String()) + } + } + if len(app.browserViewers().accountStore().state.Accounts) != 0 { + t.Fatal("invalid registration created an account") + } +} + +func TestAccountLimitsExpiryAndCorruptionFailClosed(t *testing.T) { + app := viewerTestApp(t) + manager := app.browserViewers() + store := manager.accountStore() + now := time.Now() + for index := 0; index < 10; index++ { + if !store.allowAttempt("alice", "192.168.1.10:1234", now) { + t.Fatal("premature rate limit") + } + } + if store.allowAttempt("alice", "192.168.1.11:1234", now) || !store.allowAttempt("alice", "192.168.1.10:1234", now.Add(time.Minute)) { + t.Fatal("account rate limit or recovery failed") + } + state := accountState{Version: 1, Accounts: map[string]accountRecord{}, Sessions: map[string]accountSession{}} + var first, last string + for index := 0; index < accountSessionLimit+1; index++ { + token, err := state.addSession("alice", now.Add(time.Duration(index)*time.Second)) + if err != nil { + t.Fatal(err) + } + if first == "" { + first = token + } + last = token + } + if len(state.Sessions) != accountSessionLimit || state.Sessions[accountTokenHash(first)].Account != "" || state.Sessions[accountTokenHash(last)].Account != "alice" { + t.Fatal("login sessions are not bounded") + } + browser := newAccountTestBrowser(t, app) + browser.register(t, "Bob") + store.mu.Lock() + for token, session := range store.state.Sessions { + session.ExpiresAt = now.Add(-time.Second) + store.state.Sessions[token] = session + } + store.mu.Unlock() + if result := browser.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusUnauthorized { + t.Fatal("expired account token accepted", result.Code) + } + broken := []byte(`{"version":999,"accounts":{}}`) + if err := os.WriteFile(store.path, broken, 0600); err != nil { + t.Fatal(err) + } + restarted := &UIApp{cfg: app.cfg} + browser.handler = restarted.routes() + if result := browser.request(t, http.MethodGet, "/api/ui/viewer", nil); result.Code != http.StatusServiceUnavailable { + t.Fatal("broken account data was silently reset", result.Code) + } + actual, _ := os.ReadFile(store.path) + if !bytes.Equal(actual, broken) { + t.Fatal("broken account data was overwritten") + } +} + +func TestAccountFailedPersistenceKeepsLoginAndPasswordUnchanged(t *testing.T) { + app := viewerTestApp(t) + browser := newAccountTestBrowser(t, app) + browser.register(t, "Alice") + store := app.browserViewers().accountStore() + path := store.path + store.path = app.cfg.dataDirectory() + for _, operation := range []struct { + path string + input any + }{ + {"/api/ui/account/logout", map[string]any{}}, + {"/api/ui/account/password", map[string]string{"password": accountFixturePassword, "newPassword": "another-long-password-456"}}, + } { + if result := browser.request(t, http.MethodPost, operation.path, operation.input); result.Code != http.StatusInternalServerError { + t.Fatal("failed disk save reported success", operation.path, result.Code) + } + viewerResultOK(t, browser.request(t, http.MethodGet, "/api/ui/playback/history", nil)) + } + store.path = path + phone := newAccountTestBrowser(t, app) + phone.login(t, "Alice", accountFixturePassword) +} diff --git a/internal/app/ui_assets_test.go b/internal/app/ui_assets_test.go new file mode 100644 index 0000000..c822907 --- /dev/null +++ b/internal/app/ui_assets_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" +) + +func TestEmbeddedInterfaceServesModulesWithoutExternalBuild(t *testing.T) { + app := &UIApp{cfg: Config{dataDir: t.TempDir()}} + router := app.routes() + check := func(path string) *httptest.ResponseRecorder { + t.Helper() + writer := httptest.NewRecorder() + router.ServeHTTP(writer, httptest.NewRequest(http.MethodGet, "http://localhost"+path, nil)) + if writer.Code != http.StatusOK { + t.Fatalf("embedded asset %s: %d %s", path, writer.Code, writer.Body.String()) + } + return writer + } + page := check("/") + if !strings.Contains(page.Body.String(), `短剧库`) || !strings.Contains(page.Header().Get("Content-Type"), "text/html") { + t.Fatal("new embedded page was not served") + } + references := regexp.MustCompile(`(?:src|href)="(/assets/[a-z0-9.-]+\.(?:js|css))(?:\?v=[a-z0-9-]+)?"`) + imports := regexp.MustCompile(`from '\./([a-z-]+\.js)'`) + seen := map[string]bool{} + var inspect func(string) + inspect = func(path string) { + if seen[path] { + return + } + seen[path] = true + asset := check(path) + contentType := asset.Header().Get("Content-Type") + if strings.HasSuffix(path, ".css") { + if !strings.Contains(contentType, "text/css") { + t.Error(path, contentType) + } + return + } + if !strings.Contains(contentType, "javascript") { + t.Error(path, contentType) + } + for _, imported := range imports.FindAllStringSubmatch(asset.Body.String(), -1) { + inspect("/assets/" + imported[1]) + } + } + for _, reference := range references.FindAllStringSubmatch(page.Body.String(), -1) { + inspect(reference[1]) + } + for _, path := range []string{"/assets/main.js", "/assets/following.js", "/assets/library.js", "/assets/card-viewport.js", "/assets/cover-repair.js", "/assets/downloads.js", "/assets/navigation.js", "/assets/player.js", "/assets/player-media.js", "/assets/hls.min.js", "/assets/playback-settings.js", "/assets/app.css"} { + if !seen[path] { + t.Error("module was not reachable from the embedded page", path) + } + } +} + +func TestEmbeddedAssetsRevalidateAfterProgramUpgrade(t *testing.T) { + router := (&UIApp{}).routes() + first := httptest.NewRecorder() + router.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "http://localhost/assets/player.js", nil)) + tag := first.Header().Get("ETag") + if first.Code != http.StatusOK || tag == "" || first.Header().Get("Cache-Control") != "no-cache" { + t.Fatal("scripts must revalidate against the running program") + } + for _, value := range []string{tag, "W/" + tag, `"old", ` + tag} { + request := httptest.NewRequest(http.MethodGet, "http://localhost/assets/player.js", nil) + request.Header.Set("If-None-Match", value) + cached := httptest.NewRecorder() + router.ServeHTTP(cached, request) + if cached.Code != http.StatusNotModified || cached.Body.Len() != 0 { + t.Fatal("unchanged scripts should reuse cached content") + } + } + request := httptest.NewRequest(http.MethodGet, "http://localhost/assets/player.js", nil) + request.Header.Set("If-None-Match", `"previous-build"`) + updated := httptest.NewRecorder() + router.ServeHTTP(updated, request) + if updated.Code != http.StatusOK || updated.Body.String() != first.Body.String() { + t.Fatal("an old build tag must receive the current player") + } +} diff --git a/internal/app/ui_cover_repair.go b/internal/app/ui_cover_repair.go new file mode 100644 index 0000000..45582ca --- /dev/null +++ b/internal/app/ui_cover_repair.go @@ -0,0 +1,169 @@ +package app + +import ( + "context" + "errors" + "net/http" + "strings" + "time" +) + +const coverRepairRetryDelay = 5 * time.Minute + +type coverRepairResult struct { + DramaID string `json:"dramaId"` + Cover string `json:"cover,omitempty"` + RetryAfter int `json:"retryAfter"` +} + +type coverRepairCall struct { + done chan struct{} + result coverRepairResult + err error + retryAt time.Time +} + +func (a *UIApp) handleCoverRepair(w http.ResponseWriter, r *http.Request) { + var input struct { + DramaID string `json:"dramaId"` + Cover string `json:"cover"` + } + if !readPlaybackRequest(w, r, &input) { + return + } + if !a.requireDramaSources(w, r, []string{input.DramaID}) { + return + } + source, id, valid := splitProviderDramaID(input.DramaID) + if !valid || len(input.DramaID) > 512 || len(input.Cover) > 8192 || !supportsSortMetadata(Drama{Source: source}) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "封面补齐参数无效"}) + return + } + input.DramaID = providerDramaID(source, id) + a.mu.Lock() + var drama Drama + for _, row := range a.dramas { + if row.ID == input.DramaID && dramaProvider(row) == source { + drama = row + break + } + } + a.mu.Unlock() + if drama.ID == "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "剧库中没有该剧集"}) + return + } + result, err := a.repairDramaCover(r.Context(), drama, input.Cover) + if r.Context().Err() != nil { + return + } + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "封面暂未补齐,可稍后再次点开剧集"}) + return + } + writeJSON(w, http.StatusOK, result) +} + +func currentCoverResult(drama Drama) coverRepairResult { + result := coverRepairResult{DramaID: drama.ID, RetryAfter: int(coverRepairRetryDelay.Seconds())} + if address, ok := buildImageURL(bestDramaCover(drama)); ok { + patch := Drama{CoverURL: address} + normalizeDramaCover(&patch) + result.Cover, _ = patch.Cover.(string) + } + return result +} + +func (a *UIApp) repairDramaCover(ctx context.Context, drama Drama, observed string) (coverRepairResult, error) { + previous := bestDramaCover(drama) + if previous != coverPathFromAny(observed) { + return currentCoverResult(drama), nil + } + now := time.Now() + a.mu.Lock() + if call := a.coverRepairs[drama.ID]; call != nil && (call.retryAt.IsZero() || now.Before(call.retryAt)) { + if !call.retryAt.IsZero() && call.result.Cover != "" && previous != coverPathFromAny(call.result.Cover) { + a.mu.Unlock() + return currentCoverResult(drama), nil + } + a.mu.Unlock() + select { + case <-ctx.Done(): + return coverRepairResult{}, ctx.Err() + case <-call.done: + return call.result, call.err + } + } + pending := 0 + for id, call := range a.coverRepairs { + if call.retryAt.IsZero() { + pending++ + } else if !now.Before(call.retryAt) { + delete(a.coverRepairs, id) + } + } + if pending >= 8 || len(a.coverRepairs) >= 256 { + a.mu.Unlock() + return coverRepairResult{}, errors.New("封面补齐请求过多") + } + if a.coverRepairs == nil { + a.coverRepairs = make(map[string]*coverRepairCall) + a.coverRepairSlots = make(chan struct{}, 1) + } + call := &coverRepairCall{done: make(chan struct{})} + a.coverRepairs[drama.ID] = call + slots := a.coverRepairSlots + a.mu.Unlock() + work, cancel := context.WithTimeout(context.WithValue(ctx, backgroundCatalogKey{}, true), 30*time.Second) + defer cancel() + var address string + var err error + select { + case <-work.Done(): + err = work.Err() + case slots <- struct{}{}: + address, err = a.downloader.fetchDramaCoverAddress(work, drama) + <-slots + } + result := coverRepairResult{DramaID: drama.ID, RetryAfter: int(coverRepairRetryDelay.Seconds())} + changed := false + if err == nil && strings.TrimSpace(address) != "" { + a.mu.Lock() + for index, current := range a.dramas { + if current.ID != drama.ID { + continue + } + if bestDramaCover(current) == previous { + patch := Drama{CoverURL: address} + normalizeDramaCover(&patch) + current.CoverURL, current.Cover = address, patch.Cover + changed = bestDramaCover(current) != previous + if changed { + a.dramas[index] = current + a.libraryDirty = true + a.libraryRevision++ + } + } + result = currentCoverResult(current) + break + } + a.mu.Unlock() + } + if changed { + a.persistLibrary() + a.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "cover.metadata_updated", Source: dramaProvider(drama), DramaID: drama.ID, DramaTitle: drama.DisplayTitle()}) + } else if err != nil && ctx.Err() == nil { + a.downloader.recordDiagnostic(diagnosticEvent{Event: "cover.metadata_failed", Source: dramaProvider(drama), DramaID: drama.ID, DramaTitle: drama.DisplayTitle(), Message: a.redactError(err)}) + } + a.mu.Lock() + call.result, call.err = result, err + call.retryAt = time.Now().Add(coverRepairRetryDelay) + if ctx.Err() != nil { + delete(a.coverRepairs, drama.ID) + result, err = coverRepairResult{}, ctx.Err() + call.result, call.err = result, err + } + close(call.done) + a.mu.Unlock() + return result, err +} diff --git a/internal/app/ui_cover_repair_test.go b/internal/app/ui_cover_repair_test.go new file mode 100644 index 0000000..6228cfb --- /dev/null +++ b/internal/app/ui_cover_repair_test.go @@ -0,0 +1,205 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func coverRepairFixture() Drama { + return Drama{ID: "hongguo:7000000000000000001", Source: sourceHongguo, SourceID: "7000000000000000001", Title: "无图合成剧", Heat: "200", Views: "0", OnlineDate: "2026-09-01", + SortMetadata: &sortMetadataState{Version: sortMetadataVersion, CoverChecked: true, CheckedAt: time.Now().Add(-time.Hour)}} +} + +func coverRepairRequest(app *UIApp, id, cover string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"dramaId": id, "cover": cover}) + request := httptest.NewRequest(http.MethodPost, "/api/ui/cover/repair", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + app.routes().ServeHTTP(writer, request) + return writer +} + +func TestCoverClickRepairIsolatedCoalescedAndPersisted(t *testing.T) { + for _, broken := range []bool{false, true} { + t.Run(fmt.Sprintf("broken=%t", broken), func(t *testing.T) { + var calls atomic.Int32 + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/novel/player/video_detail/v1/" || request.Context().Value(backgroundCatalogKey{}) != true { + t.Error("cover repair must only request text metadata at background priority", request.URL.Path) + } + if calls.Add(1) == 1 { + close(entered) + } + <-release + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":"7000000000000000001","series_cover":%q,"hot_score":9999,"series_title":"不得覆盖标题"}}}`, coverAddressFixture)), nil + }) + old := coverRepairFixture() + if broken { + old.Cover, old.CoverURL = coverAddressFixture+"&expired=1", coverAddressFixture+"&expired=1" + normalizeDramaCover(&old) + } + untouched := coverRepairFixture() + untouched.ID = "hongguo:7000000000000000002" + untouched.SourceID = "7000000000000000002" + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old, untouched}, libraryRevision: 10} + observed, _ := old.Cover.(string) + writers := make(chan *httptest.ResponseRecorder, 12) + go func() { writers <- coverRepairRequest(app, old.ID, observed) }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("repair did not start") + } + read := make(chan *httptest.ResponseRecorder, 1) + go func() { + writer := httptest.NewRecorder() + app.handleDramas(writer, httptest.NewRequest(http.MethodGet, "/api/ui/dramas", nil)) + read <- writer + }() + select { + case writer := <-read: + if writer.Code != http.StatusOK { + t.Fatal(writer.Body.String()) + } + case <-time.After(time.Second): + t.Fatal("cover lookup blocked the library") + } + for index := 1; index < 12; index++ { + go func() { writers <- coverRepairRequest(app, old.ID, observed) }() + } + unblock() + for index := 0; index < 12; index++ { + writer := <-writers + var result coverRepairResult + if writer.Code != http.StatusOK || json.Unmarshal(writer.Body.Bytes(), &result) != nil || coverPathFromAny(result.Cover) != coverAddressFixture { + t.Fatal("click did not receive the repaired address", writer.Code, writer.Body.String()) + } + } + if calls.Load() != 1 { + t.Fatal("repeated clicks duplicated the lookup", calls.Load()) + } + cache, err := readLibraryCache(d.cfg.dataDirectory()) + if err != nil || len(cache.Dramas) != 2 { + t.Fatal("repaired cover was not persisted", err) + } + updated := cache.Dramas[0] + savedOther, _ := json.Marshal(cache.Dramas[1]) + originalOther, _ := json.Marshal(untouched) + if bestDramaCover(updated) != coverAddressFixture || updated.Title != old.Title || updated.Heat != old.Heat || updated.Views != old.Views || updated.SortMetadata.Version != old.SortMetadata.Version || string(savedOther) != string(originalOther) { + t.Fatal("repair lost the cover or modified unrelated metadata", updated) + } + app.mu.Lock() + if app.libraryRevision != 11 || app.metadataDone != nil { + t.Error("single cover click migrated historical metadata or missed the revision") + } + app.mu.Unlock() + }) + } +} + +func TestCoverClickRepairMissingInvalidAndRetry(t *testing.T) { + for _, result := range []struct{ name, id, address string }{ + {"missing", "7000000000000000001", ""}, + {"wrong drama", "7000000000000000002", coverAddressFixture}, + {"invalid address", "7000000000000000001", "https://example.invalid/not-a-cover"}, + } { + t.Run(result.name, func(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":%q,"series_cover":%q}}}`, result.id, result.address)), nil + }) + old := coverRepairFixture() + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}} + for index := 0; index < 2; index++ { + coverRepairRequest(app, old.ID, "") + } + if calls.Load() != 1 || !reflect.DeepEqual(app.dramas[0], old) { + t.Fatal("failed repair changed metadata or bypassed the cooldown") + } + app.mu.Lock() + app.coverRepairs[old.ID].retryAt = time.Now().Add(-time.Second) + app.mu.Unlock() + coverRepairRequest(app, old.ID, "") + if calls.Load() != 2 { + t.Fatal("a later click could not retry") + } + }) + } +} + +func TestCoverClickRepairDoesNotOverwriteNewerCover(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + close(entered) + <-release + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":"7000000000000000001","series_cover":%q}}}`, coverAddressFixture)), nil + }) + old := coverRepairFixture() + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}} + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- coverRepairRequest(app, old.ID, "") }() + <-entered + newer := coverAddressFixture + "&newer=1" + app.mu.Lock() + app.dramas[0].CoverURL = newer + normalizeDramaCover(&app.dramas[0]) + app.mu.Unlock() + close(release) + writer := <-done + var result coverRepairResult + if json.Unmarshal(writer.Body.Bytes(), &result) != nil || coverPathFromAny(result.Cover) != newer || bestDramaCover(app.dramas[0]) != newer { + t.Fatal("late repair overwrote a newer cover", writer.Body.String()) + } + app.mu.Lock() + app.dramas[0].CoverURL = newer + "&latest=1" + normalizeDramaCover(&app.dramas[0]) + current := app.dramas[0] + app.mu.Unlock() + writer = coverRepairRequest(app, old.ID, current.Cover.(string)) + if json.Unmarshal(writer.Body.Bytes(), &result) != nil || coverPathFromAny(result.Cover) != bestDramaCover(current) { + t.Fatal("cached repair returned an obsolete cover", writer.Body.String()) + } +} + +func TestCoverClickRepairValidationAndCancellation(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + <-request.Context().Done() + return nil, request.Context().Err() + }) + old := coverRepairFixture() + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}} + for _, input := range []struct{ id, cover string }{ + {"outside:1", ""}, {"hongguo:7000000000000000002", ""}, {old.ID, "https://example.invalid/arbitrary"}, + } { + coverRepairRequest(app, input.id, input.cover) + } + if calls.Load() != 0 { + t.Fatal("unrecognized IDs or unrelated addresses triggered upstream requests") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := app.repairDramaCover(ctx, old, ""); err == nil { + t.Fatal("canceled lookup succeeded") + } + app.mu.Lock() + defer app.mu.Unlock() + if len(app.coverRepairs) != 0 { + t.Fatal("cancellation left an unretryable pending lookup") + } +} diff --git a/internal/app/ui_drama_refresh.go b/internal/app/ui_drama_refresh.go new file mode 100644 index 0000000..06e057f --- /dev/null +++ b/internal/app/ui_drama_refresh.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "errors" + "math" + "net/http" + "reflect" + "time" +) + +const dramaRefreshRetryDelay = 5 * time.Minute + +type dramaRefreshResult struct { + DramaID string `json:"dramaId"` + Drama Drama `json:"drama"` + RetryAfter int `json:"retryAfter"` + Warning string `json:"warning,omitempty"` +} + +type dramaRefreshCall struct { + done chan struct{} + err error + retryAt time.Time +} + +func (a *UIApp) handleDramaRefresh(w http.ResponseWriter, r *http.Request) { + var input struct { + DramaID string `json:"dramaId"` + } + if !readPlaybackRequest(w, r, &input) || !a.requireDramaSources(w, r, []string{input.DramaID}) { + return + } + source, id, valid := splitProviderDramaID(input.DramaID) + if !valid || len(input.DramaID) > 512 || !supportsSortMetadata(Drama{Source: source}) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "剧集资料更新参数无效"}) + return + } + input.DramaID = providerDramaID(source, id) + a.mu.Lock() + drama := a.dramaForRefreshLocked(input.DramaID) + a.mu.Unlock() + if drama.ID == "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "剧库中没有该剧集"}) + return + } + result, err := a.refreshDramaMetadata(r.Context(), drama) + if r.Context().Err() != nil { + return + } + if err != nil { + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "资料更新繁忙,可稍后重新打开剧集"}) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (a *UIApp) dramaForRefreshLocked(id string) Drama { + for _, drama := range a.dramas { + if drama.ID == id { + return drama + } + } + return Drama{} +} + +func (a *UIApp) dramaRefreshResultLocked(id string, call *dramaRefreshCall) dramaRefreshResult { + result := dramaRefreshResult{DramaID: id, Drama: a.dramaForRefreshLocked(id), RetryAfter: max(1, int(math.Ceil(time.Until(call.retryAt).Seconds())))} + if call.err != nil { + result.Warning = "部分资料暂未更新,已保留原有信息,可稍后再次打开剧集重试" + } + return result +} + +func (a *UIApp) refreshDramaMetadata(ctx context.Context, drama Drama) (dramaRefreshResult, error) { + if err := ctx.Err(); err != nil { + return dramaRefreshResult{}, err + } + now := time.Now() + a.mu.Lock() + if call := a.dramaRefreshes[drama.ID]; call != nil && (call.retryAt.IsZero() || now.Before(call.retryAt)) { + a.mu.Unlock() + select { + case <-ctx.Done(): + return dramaRefreshResult{}, ctx.Err() + case <-call.done: + if errors.Is(call.err, context.Canceled) && ctx.Err() == nil { + return a.refreshDramaMetadata(ctx, drama) + } + a.mu.Lock() + result := a.dramaRefreshResultLocked(drama.ID, call) + a.mu.Unlock() + return result, nil + } + } + pending := 0 + for id, call := range a.dramaRefreshes { + if call.retryAt.IsZero() { + pending++ + } else if !now.Before(call.retryAt) { + delete(a.dramaRefreshes, id) + } + } + if pending >= 8 || len(a.dramaRefreshes) >= 256 { + a.mu.Unlock() + return dramaRefreshResult{}, errors.New("剧集资料更新请求过多") + } + if a.dramaRefreshes == nil { + a.dramaRefreshes = make(map[string]*dramaRefreshCall) + a.dramaRefreshSlots = make(chan struct{}, 1) + } + call := &dramaRefreshCall{done: make(chan struct{})} + a.dramaRefreshes[drama.ID] = call + slots := a.dramaRefreshSlots + a.mu.Unlock() + work, cancel := context.WithTimeout(context.WithValue(ctx, backgroundCatalogKey{}, true), 35*time.Second) + defer cancel() + var patch Drama + var err error + select { + case <-work.Done(): + err = work.Err() + case slots <- struct{}{}: + patch, err = a.downloader.fetchDramaMetadata(work, drama) + <-slots + } + if patch.ID != "" && patch.ID != drama.ID { + patch = Drama{} + err = errors.New("详情返回了其他剧集") + } + changed := false + a.mu.Lock() + if ctx.Err() == nil && patch.ID == drama.ID { + for index, current := range a.dramas { + if current.ID != drama.ID { + continue + } + if err == nil { + patch.SortMetadata = completedDramaMetadata(current) + } + updated := mergeRefreshedDrama(patch, current, drama) + if !reflect.DeepEqual(updated, current) { + a.dramas[index] = updated + a.libraryDirty = true + a.libraryRevision++ + changed = true + } + break + } + } + a.mu.Unlock() + if changed { + a.persistLibrary() + } + if err != nil && ctx.Err() == nil { + a.downloader.recordDiagnostic(diagnosticEvent{Event: "drama.metadata_failed", Source: dramaProvider(drama), DramaID: drama.ID, Message: a.redactError(err)}) + } + a.mu.Lock() + call.err, call.retryAt = err, time.Now().Add(dramaRefreshRetryDelay) + if ctx.Err() != nil { + call.err = ctx.Err() + delete(a.dramaRefreshes, drama.ID) + } + result := a.dramaRefreshResultLocked(drama.ID, call) + close(call.done) + a.mu.Unlock() + return result, ctx.Err() +} + +func mergeRefreshedDrama(patch, current, previous Drama) Drama { + updated := mergeDramaMetadata(patch, current) + if current.SortMetadata != nil && (previous.SortMetadata == nil || current.SortMetadata.CheckedAt.After(previous.SortMetadata.CheckedAt)) { + updated = mergeDramaMetadata(current, patch) + } + if current.Title != previous.Title || current.Name != previous.Name { + updated.Title, updated.Name = current.Title, current.Name + } + if bestDramaCover(current) != bestDramaCover(previous) { + updated.Cover, updated.CoverURL = current.Cover, current.CoverURL + } + normalizeDramaCover(&updated) + return updated +} + +func completedDramaMetadata(drama Drama) *sortMetadataState { + state := sortMetadataState{Version: dramaSortMetadataVersion(drama), CheckedAt: time.Now(), CoverChecked: dramaProvider(drama) == sourceHongguo, VIPChecked: dramaProvider(drama) == sourceHuangdou} + if drama.SortMetadata != nil { + state.Pending = drama.SortMetadata.Pending + } + return &state +} diff --git a/internal/app/ui_drama_refresh_test.go b/internal/app/ui_drama_refresh_test.go new file mode 100644 index 0000000..d253444 --- /dev/null +++ b/internal/app/ui_drama_refresh_test.go @@ -0,0 +1,185 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func dramaRefreshFixture() Drama { + drama := coverRepairFixture() + drama.SortMetadata.CheckedAt = drama.SortMetadata.CheckedAt.Round(0) + return drama +} + +func dramaRefreshRequest(app *UIApp, id string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"dramaId": id}) + request := httptest.NewRequest(http.MethodPost, "/api/ui/dramas/refresh", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + app.routes().ServeHTTP(writer, request) + return writer +} + +func refreshMetadataFixture(id string) string { + return fmt.Sprintf(`{"data":{"video_data":{"series_id_str":%q,"series_title":"更新后的合成剧名","series_intro":"补齐的简介","episode_cnt":4,"series_status":0,"series_cover":%q,"hot_score":900,"series_play_cnt":1200,"category_name":"合成分类","tags":["合成标签"]}}}`, id, coverAddressFixture) +} + +func TestDramaRefreshWithHealthyCoverCoalescesPersistsAndDoesNotBlockReads(t *testing.T) { + var calls atomic.Int32 + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/novel/player/video_detail/v1/" || request.Context().Value(backgroundCatalogKey{}) != true { + t.Error("clicked drama must use only background text metadata", request.URL.Path) + } + if calls.Add(1) == 1 { + close(entered) + } + <-release + return rankingHTTPResponse(request, 200, refreshMetadataFixture("7000000000000000001")), nil + }) + old := dramaRefreshFixture() + old.CoverURL = coverAddressFixture + old.TotalEpisode = "2" + normalizeDramaCover(&old) + other := dramaRefreshFixture() + other.ID, other.SourceID = "hongguo:7000000000000000002", "7000000000000000002" + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old, other}, libraryRevision: 20} + const requests = 10 + writers := make(chan *httptest.ResponseRecorder, requests) + go func() { writers <- dramaRefreshRequest(app, old.ID) }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("complete sorting fields and a healthy cover prevented a detail refresh") + } + read := make(chan *httptest.ResponseRecorder, 1) + go func() { + writer := httptest.NewRecorder() + app.handleDramas(writer, httptest.NewRequest(http.MethodGet, "/api/ui/dramas", nil)) + read <- writer + }() + select { + case writer := <-read: + if writer.Code != http.StatusOK { + t.Fatal(writer.Code) + } + case <-time.After(time.Second): + t.Fatal("metadata lookup blocked a normal library read") + } + for i := 1; i < requests; i++ { + go func() { writers <- dramaRefreshRequest(app, old.ID) }() + } + unblock() + for i := 0; i < requests; i++ { + writer := <-writers + var result dramaRefreshResult + if writer.Code != http.StatusOK || json.Unmarshal(writer.Body.Bytes(), &result) != nil || result.DramaID != old.ID || result.Warning != "" || result.RetryAfter < 299 || result.Drama.Desc != "补齐的简介" { + t.Fatal("metadata response is incomplete", writer.Code, writer.Body.String()) + } + } + if calls.Load() != 1 { + t.Fatal("repeated title and cover clicks duplicated the detail lookup", calls.Load()) + } + cache, err := readLibraryCache(d.cfg.dataDirectory()) + if err != nil || len(cache.Dramas) != 2 { + t.Fatal("refreshed drama was not persisted", err) + } + updated := cache.Dramas[0] + if updated.Title != "更新后的合成剧名" || updated.Intro != "补齐的简介" || fmt.Sprint(updated.TotalEpisode) != "4" || updated.Heat != "900" || updated.ReleaseStatus != "ongoing" || len(updated.Tags) != 1 || updated.CategoryName != "合成分类" || updated.OnlineDate != old.OnlineDate || !updated.SortMetadata.CheckedAt.After(old.SortMetadata.CheckedAt) { + t.Fatal("click did not reconcile full drama metadata", updated) + } + if !reflect.DeepEqual(cache.Dramas[1], other) || app.metadataDone != nil || app.libraryRevision != 21 { + t.Fatal("one click changed an unrelated drama or queued the entire historical library") + } +} + +func TestDramaRefreshPreservesNewerConcurrentMetadata(t *testing.T) { + previous := dramaRefreshFixture() + current := previous + current.Title = "稍后取得的新标题" + current.Heat = "5000" + current.CoverURL = coverAddressFixture + "¤t=1" + current.SortMetadata = &sortMetadataState{Version: sortMetadataVersion, CheckedAt: time.Now()} + normalizeDramaCover(¤t) + patch := Drama{ID: previous.ID, Title: "迟到的旧标题", Heat: "1", Desc: "此前缺失的简介", CoverURL: coverAddressFixture, SortMetadata: completedDramaMetadata(previous)} + updated := mergeRefreshedDrama(patch, current, previous) + if updated.Title != current.Title || updated.Heat != current.Heat || bestDramaCover(updated) != bestDramaCover(current) || updated.Desc != patch.Desc { + t.Fatal("late click response overwrote newer metadata or failed to fill a missing field", updated) + } +} + +func TestDramaRefreshFailureValidationPermissionsAndRetry(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.Path == "/detail" { + return rankingHTTPResponse(request, 200, sortDetailFixture("7000000000000000002", "1773662280")), nil + } + return rankingHTTPResponse(request, 200, refreshMetadataFixture("7000000000000000002")), nil + }) + old := dramaRefreshFixture() + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}} + for _, id := range []string{"unknown:1", "hongguo:7000000000000000003"} { + if response := dramaRefreshRequest(app, id); response.Code < 400 { + t.Fatal("invalid or missing drama accepted", response.Code) + } + } + request := httptest.NewRequest(http.MethodPost, "/api/ui/dramas/refresh", strings.NewReader(fmt.Sprintf(`{"dramaId":%q}`, old.ID))) + request.Header.Set("Content-Type", "application/json") + request = request.WithContext(withSourceScope(request.Context(), accountRecord{Sources: []string{}})) + writer := httptest.NewRecorder() + app.handleDramaRefresh(writer, request) + if writer.Code != http.StatusForbidden || calls.Load() != 0 { + t.Fatal("source permission check happened after an upstream request", writer.Code, calls.Load()) + } + for i := 0; i < 2; i++ { + response := dramaRefreshRequest(app, old.ID) + var result dramaRefreshResult + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Warning == "" || !reflect.DeepEqual(result.Drama, old) { + t.Fatal("failed lookup erased usable metadata or lost its warning", response.Body.String()) + } + } + if calls.Load() != 2 || !reflect.DeepEqual(app.dramas[0], old) { + t.Fatal("failed refresh bypassed cooldown or changed existing metadata", calls.Load()) + } + app.mu.Lock() + app.dramaRefreshes[old.ID].retryAt = time.Now().Add(-time.Second) + app.mu.Unlock() + dramaRefreshRequest(app, old.ID) + if calls.Load() != 4 { + t.Fatal("expired failure could not be retried", calls.Load()) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := app.refreshDramaMetadata(ctx, old); err == nil || calls.Load() != 4 { + t.Fatal("canceled refresh still contacted upstream") + } +} + +func TestOnlineOnlyAccountMayRefreshAllowedDramaMetadata(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + return rankingHTTPResponse(request, 200, refreshMetadataFixture("7000000000000000001")), nil + }) + old := dramaRefreshFixture() + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}} + request := httptest.NewRequest(http.MethodPost, "/api/ui/dramas/refresh", strings.NewReader(fmt.Sprintf(`{"dramaId":%q}`, old.ID))) + request.Header.Set("Content-Type", "application/json") + request = request.WithContext(withSourceScope(request.Context(), accountRecord{Sources: []string{sourceHongguo}, OnlineOnly: true})) + writer := httptest.NewRecorder() + app.handleDramaRefresh(writer, request) + if writer.Code != http.StatusOK || app.dramas[0].Desc == "" { + t.Fatal("online-only permission blocked viewing metadata", writer.Code, writer.Body.String()) + } +} diff --git a/internal/app/ui_emby.go b/internal/app/ui_emby.go new file mode 100644 index 0000000..ed69378 --- /dev/null +++ b/internal/app/ui_emby.go @@ -0,0 +1,352 @@ +package app + +import ( + "archive/zip" + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +func (app *UIApp) embySigningKey(create bool) ([]byte, error) { + app.embyMu.Lock() + defer app.embyMu.Unlock() + if len(app.embyKey) == 32 { + return app.embyKey, nil + } + path := filepath.Join(app.cfg.dataDirectory(), "emby-key") + body, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) && create { + if err = os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + body = make([]byte, 32) + if _, err = rand.Read(body); err != nil { + return nil, err + } + file, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(openErr, os.ErrExist) { + body, err = os.ReadFile(path) + } else if openErr != nil { + return nil, openErr + } else { + _, err = file.Write(body) + if syncErr := file.Sync(); err == nil { + err = syncErr + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + } + } + if err != nil { + return nil, err + } + if len(body) != 32 { + return nil, errors.New("Emby 链接密钥无效") + } + app.embyKey = body + return body, nil +} + +func embyToken(key []byte, dramaID, chapterID string, accounts ...string) string { + mac := hmac.New(sha256.New, key) + payload := "emby-v1\x00" + dramaID + "\x00" + chapterID + if len(accounts) > 0 && accounts[0] != "" { + payload = "emby-v2\x00" + accounts[0] + "\x00" + dramaID + "\x00" + chapterID + } + mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func validEmbyIdentity(dramaID, chapterID string) bool { + canonical, _, ok := playbackHistoryIdentity(dramaID) + return ok && canonical == dramaID && len(dramaID) <= 256 && chapterID != "" && len(chapterID) <= 512 && !strings.ContainsAny(chapterID, "\x00\r\n") +} + +func (app *UIApp) authorizeEmby(writer http.ResponseWriter, request *http.Request) (string, string, bool) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("X-Content-Type-Options", "nosniff") + if request.Method != http.MethodGet && request.Method != http.MethodHead { + writer.Header().Set("Allow", "GET, HEAD") + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "请求方法不支持"}) + return "", "", false + } + query := request.URL.Query() + id, chapter := query.Get("id"), query.Get("chapter") + supplied, err := hex.DecodeString(query.Get("key")) + key, keyErr := app.embySigningKey(false) + if err != nil || len(supplied) != 32 || keyErr != nil || !validEmbyIdentity(id, chapter) { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "Emby 播放链接无效,请重新导出"}) + return "", "", false + } + owner := query.Get("account") + expected, _ := hex.DecodeString(embyToken(key, id, chapter, owner)) + if !hmac.Equal(expected, supplied) { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "Emby 播放链接无效,请重新导出"}) + return "", "", false + } + if owner != "" && !app.embyAccountSourceAllowed(owner, id) { + writeViewerError(writer, http.StatusForbidden, "export_forbidden", "当前账号无权使用此 Emby 链接,请联系管理员") + return "", "", false + } + return id, chapter, true +} + +func embyBaseURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || len(raw) > 2048 || parsed == nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || strings.ContainsAny(raw, "\x00\r\n") { + return "", errors.New("请输入 Emby 能访问的剧库 HTTP/HTTPS 地址") + } + return strings.TrimRight(parsed.String(), "/"), nil +} + +func (app *UIApp) handleEmbyExport(writer http.ResponseWriter, request *http.Request) { + var input struct { + DramaID string `json:"dramaId"` + BaseURL string `json:"baseUrl"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + if !app.requireDramaSources(writer, request, []string{input.DramaID}) { + return + } + base, err := embyBaseURL(input.BaseURL) + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + app.mu.Lock() + var drama Drama + for _, item := range app.dramas { + if item.ID == input.DramaID { + drama = item + break + } + } + app.mu.Unlock() + if drama.ID == "" { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请先在剧库中找到此剧"}) + return + } + ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) + defer cancel() + title, chapters, err := app.downloader.GetDramaChapters(ctx, drama.ID) + if err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "读取分集失败:" + app.redactError(err)}) + return + } + if len(chapters) == 0 || len(chapters) > 2000 { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "可导出分集数无效"}) + return + } + for _, chapter := range chapters { + if !validEmbyIdentity(drama.ID, chapter.ID) { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "此剧未提供稳定分集 ID,暂不能导出"}) + return + } + } + key, err := app.embySigningKey(true) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法保存 Emby 链接密钥"}) + return + } + drama.Title = firstNonEmpty(title, drama.DisplayTitle()) + owner := "" + if scope := sourceScope(request.Context()); scope != nil { + owner = scope.AccountID + } + body, err := buildEmbyArchive(drama, chapters, base, key, owner) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "生成 Emby 分集文件失败"}) + return + } + writer.Header().Set("Content-Type", "application/zip") + writer.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": safeFilename(drama.DisplayTitle()) + "-Emby.zip"})) + writer.Header().Set("Content-Length", strconv.Itoa(len(body))) + writer.Write(body) +} + +func buildEmbyArchive(drama Drama, chapters []Chapter, base string, key []byte, accounts ...string) ([]byte, error) { + owner := "" + if len(accounts) > 0 { + owner = accounts[0] + } + records, err := embyChapterRecords(drama.ID, chapters, nil) + if err != nil { + return nil, err + } + var buffer bytes.Buffer + archive := zip.NewWriter(&buffer) + prefix := embyFolderName(drama) + "/" + err = writeEmbyFiles(drama, records, base, key, owner, func(name string, body []byte) error { + file, err := archive.Create(prefix + name) + if err != nil { + return err + } + _, err = file.Write(body) + return err + }) + if err != nil { + archive.Close() + return nil, err + } + if err = archive.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func (app *UIApp) embyTask(ctx context.Context, id, chapterID string) (Task, string, error) { + app.mu.Lock() + for _, candidate := range app.tasks { + if candidate.DramaID == id && candidate.Source.Chapter.ID == chapterID && isPlayableDownloadTask(candidate) { + task, downloadID := candidate.Source, candidate.ID + app.mu.Unlock() + return task, downloadID, nil + } + } + app.mu.Unlock() + title, chapters, err := app.downloader.GetDramaChapters(ctx, id) + if err != nil { + return Task{}, "", err + } + for index, chapter := range chapters { + if chapter.ID == chapterID { + return Task{DramaID: id, DramaTitle: title, Chapter: chapter, Index: index + 1, Total: len(chapters)}, "", nil + } + } + return Task{}, "", errors.New("此分集已不可用,请更新剧库并重新导出") +} + +func (app *UIApp) handleEmbyLegacyStream(writer http.ResponseWriter, request *http.Request) { + id, chapter, ok := app.authorizeEmby(writer, request) + if !ok { + return + } + if request.Method == http.MethodHead { + writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + return + } + sessionID := randomHex(24) + if sessionID == "" { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法创建播放会话"}) + return + } + ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) + defer cancel() + app.playbackMu.Lock() + if len(app.playbacks) >= app.mediaResources().snapshot().MaxSessions { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "同时播放数量已达上限,请稍后再试"}) + return + } + if app.playbacks == nil { + app.playbacks = make(map[string]*playbackSession) + } + session := &playbackSession{accountID: request.URL.Query().Get("account"), dramaID: id, id: sessionID, run: 1, state: "opening", expires: time.Now().Add(playbackIdleTimeout), cancel: cancel} + session.timer = time.AfterFunc(playbackIdleTimeout, func() { app.expirePlayback(sessionID) }) + app.playbacks[sessionID] = session + app.playbackMu.Unlock() + ready := false + defer func() { + if !ready { + app.closePlayback(sessionID) + } + }() + task, downloadID, err := app.embyTask(ctx, id, chapter) + if err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "解析 Emby 分集失败:" + app.redactError(err)}) + return + } + cache := newPlaybackNative(app, context.Background(), task, downloadID, 0, 0, false) + app.playbackMu.Lock() + if app.playbacks[sessionID] != session || ctx.Err() != nil { + app.playbackMu.Unlock() + cache.Close() + return + } + session.tasks = []Task{task} + session.native = cache + session.cancel = cache.Close + app.playbackMu.Unlock() + cache.start() + if _, err = cache.segment(ctx, 0); err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "准备 Emby 播放失败:" + app.redactError(err)}) + return + } + _, duration, _ := cache.metadata() + if duration <= 0 || duration > 24*60*60 || math.IsNaN(duration) || math.IsInf(duration, 0) { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "未取得有效播放时长"}) + return + } + var playlist strings.Builder + fmt.Fprintf(&playlist, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n", playbackNativeSegmentSeconds) + for part, count := 0, int(math.Ceil(duration/playbackNativeSegmentSeconds)); part < count; part++ { + query := url.Values{"id": {id}, "chapter": {chapter}, "key": {request.URL.Query().Get("key")}, "session": {sessionID}, "segment": {strconv.Itoa(part)}} + if owner := request.URL.Query().Get("account"); owner != "" { + query.Set("account", owner) + } + fmt.Fprintf(&playlist, "#EXTINF:%.6f,\nsegment.ts?%s\n", math.Min(playbackNativeSegmentSeconds, duration-float64(part*playbackNativeSegmentSeconds)), query.Encode()) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + app.playbackMu.Lock() + if app.playbacks[sessionID] == session { + session.state = "streaming" + session.duration = duration + app.touchPlaybackLocked(session) + ready = true + } + app.playbackMu.Unlock() + if !ready { + return + } + writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + http.ServeContent(writer, request, "index.m3u8", time.Time{}, strings.NewReader(playlist.String())) +} + +func (app *UIApp) handleEmbySegment(writer http.ResponseWriter, request *http.Request) { + id, chapter, ok := app.authorizeEmby(writer, request) + if !ok { + return + } + query := request.URL.Query() + part, err := strconv.Atoi(query.Get("segment")) + if err != nil || part < 0 { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "分片编号无效"}) + return + } + app.playbackMu.Lock() + session := app.playbacks[query.Get("session")] + if session == nil || session.native == nil || len(session.tasks) != 1 || session.tasks[0].DramaID != id || session.tasks[0].Chapter.ID != chapter { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusGone, map[string]string{"error": "播放已过期,请重新播放"}) + return + } + cache := session.native + app.touchPlaybackLocked(session) + app.playbackMu.Unlock() + ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) + defer cancel() + body, err := cache.segment(ctx, part) + if err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "读取 Emby 分片失败:" + app.redactError(err)}) + return + } + writer.Header().Set("Content-Type", "video/mp2t") + http.ServeContent(writer, request, "segment.ts", time.Time{}, bytes.NewReader(body)) +} diff --git a/internal/app/ui_emby_cover.go b/internal/app/ui_emby_cover.go new file mode 100644 index 0000000..51dd7a6 --- /dev/null +++ b/internal/app/ui_emby_cover.go @@ -0,0 +1,117 @@ +package app + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + "strings" + "time" +) + +func embyMetadataID(key []byte, dramaID string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte("emby-metadata-v1\x00" + dramaID)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func embyCoverToken(key []byte, dramaID, owner, version string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte("emby-cover-v1\x00" + owner + "\x00" + dramaID + "\x00" + version)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func embyDramaCover(drama Drama) string { + raw := bestDramaCover(drama) + if raw == "" || len(raw) > 4096 { + return "" + } + remote, ok := buildImageURL(raw) + if !ok { + return "" + } + return remote +} + +func embyCoverURL(drama Drama, base string, key []byte, owner string) string { + remote := embyDramaCover(drama) + if remote == "" { + return "" + } + digest := sha256.Sum256([]byte(remote)) + version := hex.EncodeToString(digest[:16]) + query := url.Values{"id": {drama.ID}, "v": {version}, "key": {embyCoverToken(key, drama.ID, owner, version)}} + if owner != "" { + query.Set("account", owner) + } + return base + "/api/emby/cover?" + query.Encode() +} + +func (app *UIApp) embyCoverSource(id string) string { + app.mu.Lock() + for _, drama := range app.dramas { + if drama.ID == id { + remote := embyDramaCover(drama) + if remote != "" { + app.mu.Unlock() + return remote + } + break + } + } + app.mu.Unlock() + manager := app.embySyncer() + manager.mu.Lock() + remote := manager.document.Entries[id].Poster.SourceURL + manager.mu.Unlock() + if remote != "" { + if normalized, ok := buildImageURL(remote); ok { + return normalized + } + } + return "" +} + +func (app *UIApp) handleEmbyCover(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "private, no-store") + writer.Header().Set("X-Content-Type-Options", "nosniff") + if request.Method != http.MethodGet && request.Method != http.MethodHead { + writer.Header().Set("Allow", "GET, HEAD") + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "请求方法不支持"}) + return + } + query := request.URL.Query() + id, owner, version := query.Get("id"), query.Get("account"), query.Get("v") + canonical, _, valid := playbackHistoryIdentity(id) + supplied, err := hex.DecodeString(query.Get("key")) + versionBytes, versionErr := hex.DecodeString(version) + key, keyErr := app.embySigningKey(false) + if !valid || id != canonical || len(id) > 256 || len(owner) > 256 || strings.ContainsAny(owner, "\x00\r\n") || + err != nil || len(supplied) != 32 || versionErr != nil || len(versionBytes) != 16 || keyErr != nil { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "Emby 海报链接无效,请重新同步或导出"}) + return + } + expected, _ := hex.DecodeString(embyCoverToken(key, id, owner, version)) + if !hmac.Equal(expected, supplied) || owner != "" && !app.embyAccountSourceAllowed(owner, id) { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "Emby 海报链接无效或账号权限已变更"}) + return + } + remote := app.embyCoverSource(id) + if remote == "" { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "此剧暂无可用海报"}) + return + } + source, _, _ := splitProviderDramaID(id) + ctx, cancel := context.WithTimeout(context.WithValue(request.Context(), coverSourceKey{}, source), 90*time.Second) + defer cancel() + data, err := app.loadCoverImage(ctx, remote, decodeImageBytes) + if err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "读取海报失败:" + app.redactError(err)}) + return + } + writer.Header().Set("Content-Type", imageContentType(data)) + http.ServeContent(writer, request, "cover", time.Time{}, bytes.NewReader(data)) +} diff --git a/internal/app/ui_emby_media.go b/internal/app/ui_emby_media.go new file mode 100644 index 0000000..3462606 --- /dev/null +++ b/internal/app/ui_emby_media.go @@ -0,0 +1,162 @@ +package app + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "time" +) + +func (app *UIApp) handleEmbyStream(writer http.ResponseWriter, request *http.Request) { + if !app.mediaResources().snapshot().Enabled { + app.handleEmbyLegacyStream(writer, request) + return + } + id, chapter, ok := app.authorizeEmby(writer, request) + if !ok { + return + } + if request.Method == http.MethodHead { + writer.Header().Set("Content-Type", "application/octet-stream") + return + } + owner := request.URL.Query().Get("account") + app.playbackMu.Lock() + var session *playbackSession + for _, candidate := range app.playbacks { + if candidate.viewer == nil && candidate.accountID == owner && candidate.mediaReady != nil && candidate.dramaID == id && len(candidate.tasks) == 1 && candidate.tasks[0].Chapter.ID == chapter && candidate.mediaError == nil && (candidate.media == nil || !candidate.media.failed.Load() && candidate.media.ctx.Err() == nil && (candidate.media.proxy == nil || candidate.media.proxy.Err() == nil)) { + session = candidate + break + } + } + if session == nil { + if len(app.playbacks) >= app.mediaResources().snapshot().MaxSessions { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "同时播放数量已达上限,请稍后再试"}) + return + } + sessionID := randomHex(24) + if sessionID == "" { + app.playbackMu.Unlock() + writer.WriteHeader(http.StatusInternalServerError) + return + } + parent, cancel := context.WithCancel(context.Background()) + session = &playbackSession{id: sessionID, accountID: owner, dramaID: id, run: 1, state: "opening", mediaReady: make(chan struct{}), cancel: cancel, + tasks: []Task{{DramaID: id, Chapter: Chapter{ID: chapter}}}, expires: time.Now().Add(playbackIdleTimeout)} + if app.playbacks == nil { + app.playbacks = make(map[string]*playbackSession) + } + app.playbacks[sessionID] = session + session.timer = time.AfterFunc(playbackIdleTimeout, func() { app.expirePlayback(sessionID) }) + query := url.Values{"id": {id}, "chapter": {chapter}, "key": {request.URL.Query().Get("key")}} + if owner != "" { + query.Set("account", owner) + } + origin := request.Header.Get("Origin") + go app.prepareEmbyMedia(parent, session, query.Encode(), origin) + } + session.mediaWaiters++ + app.touchPlaybackLocked(session) + app.playbackMu.Unlock() + defer func() { + app.playbackMu.Lock() + session.mediaWaiters-- + var cancel context.CancelFunc + if session.mediaWaiters == 0 && session.state == "opening" && app.playbacks[session.id] == session { + delete(app.playbacks, session.id) + session.timer.Stop() + cancel = session.cancel + } + app.playbackMu.Unlock() + if cancel != nil { + cancel() + } + }() + select { + case <-session.mediaReady: + case <-request.Context().Done(): + return + } + app.playbackMu.Lock() + media, err := session.media, session.mediaError + app.playbackMu.Unlock() + if err != nil || media == nil { + if err == nil { + err = errors.New("播放会话已结束") + } + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "准备 Emby 播放失败:" + app.redactError(err)}) + return + } + if media.plan.Player == "legacy" { + app.closePlayback(session.id) + app.handleEmbyLegacyStream(writer, request) + return + } + writer.Header().Set("Location", media.plan.URL) + writer.Header().Set("Cache-Control", "private, no-store") + writer.WriteHeader(http.StatusFound) +} + +func (app *UIApp) prepareEmbyMedia(parent context.Context, session *playbackSession, query, origin string) { + ctx, cancel := context.WithTimeout(parent, 90*time.Second) + defer cancel() + task, downloadID, err := app.embyTask(ctx, session.dramaID, session.tasks[0].Chapter.ID) + var media *playbackMediaSession + if err == nil { + media, err = app.resolveMediaSession(parent, ctx, task, downloadID, 0) + } + if err == nil { + base := "/api/emby/media/" + session.id + "/" + err = app.planMedia(ctx, media, playbackClientCapabilities{MP4: true, NativeHLS: true}, "auto", sourceFromDramaID(task.DramaID), origin, base, query) + } + app.playbackMu.Lock() + if err == nil && (app.playbacks[session.id] != session || ctx.Err() != nil) { + err = context.Canceled + } + if err == nil { + previous := session.cancel + session.cancel = func() { previous(); media.Close() } + session.tasks, session.media, session.state = []Task{task}, media, "streaming" + session.duration = media.plan.Duration + media.plan.Run = session.run + app.touchPlaybackLocked(session) + } else { + session.mediaError, session.state = err, "failed" + } + close(session.mediaReady) + app.playbackMu.Unlock() + if err != nil { + if media != nil { + media.Close() + } + app.closePlayback(session.id) + } +} + +func (app *UIApp) handleEmbyMediaAsset(writer http.ResponseWriter, request *http.Request) { + id, chapter, ok := app.authorizeEmby(writer, request) + if !ok { + return + } + parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/api/emby/media/"), "/") + if len(parts) != 2 { + http.NotFound(writer, request) + return + } + app.playbackMu.Lock() + session := app.playbacks[parts[0]] + if session == nil || session.viewer != nil || session.media == nil || session.accountID != request.URL.Query().Get("account") || len(session.tasks) != 1 || session.tasks[0].DramaID != id || session.tasks[0].Chapter.ID != chapter { + app.playbackMu.Unlock() + writer.WriteHeader(http.StatusGone) + return + } + media := session.media + active := &playbackRunContext{id: session.id, run: session.run, session: session, ctx: media.ctx} + app.touchPlaybackLocked(session) + app.playbackMu.Unlock() + writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: active} + app.serveMediaSession(writer, request, media) +} diff --git a/internal/app/ui_emby_sync.go b/internal/app/ui_emby_sync.go new file mode 100644 index 0000000..0825937 --- /dev/null +++ b/internal/app/ui_emby_sync.go @@ -0,0 +1,165 @@ +package app + +import ( + "errors" + "net/http" + "sort" + "time" +) + +func (manager *embySyncManager) view() map[string]any { + manager.mu.Lock() + defer manager.mu.Unlock() + settings := manager.document.Settings + hasKey := settings.APIKey != "" + settings.APIKey = "" + entries := make([]map[string]any, 0, len(manager.document.Entries)) + totalEpisodes := 0 + for id, entry := range manager.document.Entries { + totalEpisodes += entry.Episodes + entries = append(entries, map[string]any{"dramaId": id, "title": entry.Title, "episodes": entry.Episodes, + "folder": entry.Folder, "syncedAt": entry.SyncedAt, "checkedAt": entry.CheckedAt, "error": entry.Error, + "posterStatus": entry.Poster.Status, "posterError": entry.Poster.Error, "posterSyncedAt": entry.Poster.SyncedAt}) + } + sort.Slice(entries, func(i, j int) bool { + left, right := entries[i]["checkedAt"].(time.Time), entries[j]["checkedAt"].(time.Time) + if left.Equal(right) { + return entries[i]["dramaId"].(string) < entries[j]["dramaId"].(string) + } + return left.After(right) + }) + message := manager.document.Error + if manager.loadErr != nil { + message = "同步配置读取失败,原文件已保留:" + manager.safeError(manager.loadErr, manager.document.Settings.APIKey) + } + return map[string]any{"settings": settings, "hasAPIKey": hasKey, "owner": manager.document.OwnerName, + "running": manager.running, "lastStartedAt": manager.document.LastStartedAt, "lastFinishedAt": manager.document.LastFinishedAt, + "nextRunAt": manager.document.NextRunAt, "writtenFiles": manager.document.WrittenFiles, "succeeded": manager.document.Succeeded, + "failed": manager.document.Failed, "pending": manager.document.Pending, "error": message, "entries": entries, "episodes": totalEpisodes, + "postersSynced": manager.document.PostersSynced, "postersPending": manager.document.PostersPending, + "postersFailed": manager.document.PostersFailed, "postersUpdated": manager.document.PostersUpdated} +} + +func (app *UIApp) handleEmbySyncSettings(writer http.ResponseWriter, request *http.Request) { + manager := app.embySyncer() + if request.Method == http.MethodGet { + if playbackRequestAllowed(writer, request, http.MethodGet) { + writeJSON(writer, http.StatusOK, manager.view()) + } + return + } + var input struct { + Enabled bool `json:"enabled"` + OutputDir string `json:"outputDir"` + BaseURL string `json:"baseUrl"` + IntervalMinutes int `json:"intervalMinutes"` + Following bool `json:"following"` + Downloads bool `json:"downloads"` + ServerURL string `json:"serverUrl"` + APIKey *string `json:"apiKey"` + } + if !readAccountRequest(writer, request, &input) { + return + } + account, _, _, err := app.browserViewers().requestAccount(request) + if err != nil || !account.Admin || account.RequirePasswordChange { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请使用管理员账号保存同步设置"}) + return + } + manager.mu.Lock() + if manager.loadErr != nil { + manager.mu.Unlock() + writeJSON(writer, http.StatusConflict, map[string]string{"error": "已有 Emby 配置无法读取,请恢复备份后再保存"}) + return + } + settings := embySyncSettings{Enabled: input.Enabled, OutputDir: input.OutputDir, BaseURL: input.BaseURL, + IntervalMinutes: input.IntervalMinutes, Following: input.Following, Downloads: input.Downloads, ServerURL: input.ServerURL} + if settings.ServerURL != "" { + if normalized, err := embyBaseURL(settings.ServerURL); err == nil { + settings.ServerURL = normalized + } + } + if input.APIKey != nil { + settings.APIKey = *input.APIKey + } else if settings.ServerURL == manager.document.Settings.ServerURL { + settings.APIKey = manager.document.Settings.APIKey + } + if err = settings.normalize(); err != nil { + manager.mu.Unlock() + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if manager.closed { + manager.mu.Unlock() + writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "服务正在停止"}) + return + } + if settings.Enabled { + if err = checkOutputDirectory(settings.OutputDir); err != nil { + manager.mu.Unlock() + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "无法使用此 Emby 输出目录:" + manager.safeError(err, settings.APIKey)}) + return + } + } + document := manager.document + document.Settings, document.OwnerID, document.OwnerName = settings, account.ID, account.Username + document.Entries = make(map[string]embySyncEntry, len(manager.document.Entries)) + for id, entry := range manager.document.Entries { + entry.CheckedAt = time.Time{} + entry.Poster.CheckedAt, entry.Poster.RetryAt, entry.Poster.Failures = time.Time{}, time.Time{}, 0 + document.Entries[id] = entry + } + document.NextRunAt = time.Now() + document.Error = "" + if settings.ServerURL == "" { + document.RefreshPending = false + } + if err = writeEmbyJSON(manager.path, document, 0600); err == nil { + manager.document = document + manager.generation++ + manager.notify() + if manager.runCancel != nil { + manager.runCancel() + } + } + manager.mu.Unlock() + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "同步设置保存失败:" + manager.safeError(err, settings.APIKey)}) + return + } + manager.start() + writeJSON(writer, http.StatusOK, manager.view()) +} + +func (app *UIApp) handleEmbySyncNow(writer http.ResponseWriter, request *http.Request) { + var input struct{} + if !readAccountRequest(writer, request, &input) { + return + } + manager := app.embySyncer() + manager.mu.Lock() + var err error + if manager.loadErr != nil { + err = errors.New("同步配置无法读取,请先恢复备份") + } else if !manager.document.Settings.Enabled { + err = errors.New("请先启用并保存 Emby 自动同步") + } else if manager.closed { + err = errors.New("服务正在停止") + } + if err == nil && !manager.running { + for id, entry := range manager.document.Entries { + entry.CheckedAt = time.Time{} + entry.Poster.CheckedAt, entry.Poster.RetryAt, entry.Poster.Failures = time.Time{}, time.Time{}, 0 + manager.document.Entries[id] = entry + } + manager.document.NextRunAt = time.Now() + manager.notify() + } + manager.mu.Unlock() + if err != nil { + writeJSON(writer, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + manager.start() + writeJSON(writer, http.StatusAccepted, manager.view()) +} diff --git a/internal/app/ui_emby_test.go b/internal/app/ui_emby_test.go new file mode 100644 index 0000000..88e9994 --- /dev/null +++ b/internal/app/ui_emby_test.go @@ -0,0 +1,163 @@ +package app + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestEmbyExportPersistentSignedLinksAndSafeArchive(t *testing.T) { + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + t.Fatal("Emby export must use the cached text fixture") + return nil, nil + }) + id := "hongguo:7000000000000000001" + chapters := []Chapter{{ID: id + ":1", Source: sourceHongguo, Title: "第一集"}, {ID: id + ":2", Source: sourceHongguo, Title: "第二集"}} + d.hongguoClient().details["7000000000000000001"] = hongguoDetailEntry{Drama: Drama{ID: id, Title: "../测试剧 <&>"}, Chapters: chapters, ExpiresAt: time.Now().Add(time.Minute)} + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{{ID: id, Source: sourceHongguo, Title: "测试", Desc: "纯文字 <简介>"}}} + body, _ := json.Marshal(map[string]string{"dramaId": id, "baseUrl": "http://library.test:8998"}) + req := httptest.NewRequest(http.MethodPost, "http://localhost/api/emby/export", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + result := httptest.NewRecorder() + app.routes().ServeHTTP(result, req) + if result.Code != 200 || result.Header().Get("Content-Type") != "application/zip" { + t.Fatal(result.Code, result.Body.String()) + } + archive, err := zip.NewReader(bytes.NewReader(result.Body.Bytes()), int64(result.Body.Len())) + if err != nil { + t.Fatal(err) + } + if len(archive.File) != 5 { + t.Fatal("missing STRM or NFO files") + } + links := []string{} + for _, file := range archive.File { + if strings.HasPrefix(file.Name, "/") || strings.Contains(file.Name, "..") || strings.Contains(file.Name, "\\") { + t.Fatal("unsafe archive path", file.Name) + } + reader, _ := file.Open() + content, _ := io.ReadAll(reader) + reader.Close() + if strings.HasSuffix(file.Name, ".strm") { + links = append(links, strings.TrimSpace(string(content))) + } + if strings.HasSuffix(file.Name, "tvshow.nfo") && !bytes.Contains(content, []byte("<&>")) { + t.Fatal("NFO XML not escaped") + } + } + if len(links) != 2 || links[0] == links[1] { + t.Fatal("wrong per-episode links") + } + info, err := os.Stat(filepath.Join(d.cfg.dataDirectory(), "emby-key")) + if err != nil || info.Size() != 32 || info.Mode().Perm() != 0o600 { + t.Fatal("persistent signing key not saved privately") + } + restarted := &UIApp{cfg: d.cfg} + for _, link := range links { + request := httptest.NewRequest(http.MethodHead, link, nil) + writer := httptest.NewRecorder() + restarted.handleEmbyStream(writer, request) + if writer.Code != 200 || writer.Body.Len() != 0 { + t.Fatal("exported URL did not survive restart") + } + parsed, _ := url.Parse(link) + query := parsed.Query() + query.Set("chapter", id+":3") + parsed.RawQuery = query.Encode() + writer = httptest.NewRecorder() + restarted.handleEmbyStream(writer, httptest.NewRequest(http.MethodGet, parsed.String(), nil)) + if writer.Code != 403 { + t.Fatal("signed link accepted a different episode") + } + } +} + +func TestEmbyRejectsInvalidURLAndSignature(t *testing.T) { + for _, raw := range []string{"", "file:///tmp/local", "http://user:pass@example.test", "http://example.test/?key=secret", "http://example.test/#fragment"} { + if _, err := embyBaseURL(raw); err == nil { + t.Fatal("invalid public base URL accepted", raw) + } + } + app := &UIApp{cfg: Config{dataDir: t.TempDir()}} + writer := httptest.NewRecorder() + app.handleEmbyStream(writer, httptest.NewRequest(http.MethodGet, "http://localhost/api/emby/stream.m3u8?id=hongguo:1&chapter=1&key=invalid", nil)) + if writer.Code != 403 { + t.Fatal("unsigned stream allowed") + } + if _, err := os.Stat(filepath.Join(app.cfg.dataDirectory(), "emby-key")); !os.IsNotExist(err) { + t.Fatal("unsigned request created a key") + } +} + +func TestEmbyHLSRangePlaybackAndExpiry(t *testing.T) { + app, fixture := nativePlaybackFixture(t, "4", "320x180") + app.mediaResources().settings.Enabled = false + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + task := fixture.tasks[0] + query := url.Values{"id": {task.DramaID}, "chapter": {task.Chapter.ID}, "key": {embyToken(key, task.DramaID, task.Chapter.ID)}} + writer := httptest.NewRecorder() + app.handleEmbyStream(writer, httptest.NewRequest(http.MethodGet, "http://localhost/api/emby/stream.m3u8?"+query.Encode(), nil)) + if writer.Code != 200 || strings.Count(writer.Body.String(), "#EXTINF:") != 2 || !strings.Contains(writer.Body.String(), "#EXT-X-ENDLIST") { + t.Fatal("invalid complete Emby HLS timeline", writer.Code, writer.Body.String()) + } + var segment string + for _, line := range strings.Split(writer.Body.String(), "\n") { + if strings.HasPrefix(line, "segment.ts?") { + segment = line + break + } + } + partURL := "http://localhost/api/emby/" + segment + request := httptest.NewRequest(http.MethodGet, partURL, nil) + request.Header.Set("Range", "bytes=0-187") + part := httptest.NewRecorder() + app.handleEmbySegment(part, request) + if part.Code != 206 || part.Body.Len() != 188 || part.Body.Bytes()[0] != 0x47 { + t.Fatal("Emby byte range did not return TS media") + } + part = httptest.NewRecorder() + app.handleEmbySegment(part, httptest.NewRequest(http.MethodHead, partURL, nil)) + if part.Code != 200 || part.Body.Len() != 0 { + t.Fatal("Emby segment HEAD failed") + } + parsed, _ := url.Parse(partURL) + sessionID := parsed.Query().Get("session") + app.playbackMu.Lock() + active := app.playbacks[sessionID] + app.playbackMu.Unlock() + if active == nil || active.native == nil { + t.Fatal("missing Emby playback cache") + } + app.closePlayback(sessionID) + part = httptest.NewRecorder() + app.handleEmbySegment(part, httptest.NewRequest(http.MethodGet, partURL, nil)) + if part.Code != 410 { + t.Fatal("expired HLS session stayed usable") + } + probe, err := exec.LookPath("ffprobe") + if err != nil { + t.Skip("ffprobe unavailable") + } + server := httptest.NewServer(app.routes()) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, probe, "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", server.URL+"/api/emby/stream.m3u8?"+query.Encode()).CombinedOutput() + if err != nil || !strings.Contains(string(output), "4.000000") { + t.Fatalf("standard HLS client could not probe stream: %v %s", err, output) + } +} diff --git a/internal/app/ui_ffmpeg.go b/internal/app/ui_ffmpeg.go new file mode 100644 index 0000000..41926a1 --- /dev/null +++ b/internal/app/ui_ffmpeg.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + "net/http" +) + +func (app *UIApp) handleFFmpeg(writer http.ResponseWriter, request *http.Request) { + installer := app.downloader.ffmpegInstallation() + switch request.Method { + case http.MethodGet: + writeJSON(writer, http.StatusOK, installer.snapshot()) + case http.MethodPost: + var input struct{} + if !readPlaybackRequest(writer, request, &input) { + return + } + installer.retry() + go func() { _, _ = installer.ensure(context.Background()) }() + writeJSON(writer, http.StatusAccepted, map[string]bool{"ok": true}) + default: + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } +} diff --git a/internal/app/ui_following.go b/internal/app/ui_following.go new file mode 100644 index 0000000..6274170 --- /dev/null +++ b/internal/app/ui_following.go @@ -0,0 +1,180 @@ +package app + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type followingView struct { + followingEntry + TotalEpisode int `json:"totalEpisode"` + NewEpisodes int `json:"newEpisodes"` +} + +func followingEpisodeCount(drama Drama) int { + for _, value := range []any{drama.TotalEpisode, drama.TotalEpisodeSnake, drama.ChapterCount, drama.ChapterCountSnake, drama.EpisodeCount, drama.EpisodeCountSnake, drama.Total, drama.Episodes} { + count, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(value))) + if err == nil && count > 0 && count <= 10000 { + return count + } + } + return 0 +} + +func followingDramaEntry(drama Drama) followingEntry { + id, source, valid := playbackHistoryIdentity(drama.ID) + if !valid { + return followingEntry{} + } + return followingEntry{DramaID: id, Source: source, Title: followingText(drama.DisplayTitle(), 4096), Category: followingText(firstNonEmpty(drama.CategoryName, drama.CategoryNameSnake, drama.Category, drama.TypeName, drama.SortName), 512), KnownEpisodes: followingEpisodeCount(drama)} +} + +func followingText(value string, limit int) string { + value = strings.ToValidUTF8(strings.TrimSpace(value), "") + if len(value) > limit { + value = value[:limit] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + } + return value +} + +func (app *UIApp) followingMetadata(ids []string) map[string]followingEntry { + result := make(map[string]followingEntry, len(ids)) + if len(ids) == 0 { + return result + } + wanted := make(map[string]bool, len(ids)) + for _, id := range ids { + wanted[id] = true + } + selected := make([]Drama, 0, len(ids)) + app.mu.Lock() + for _, drama := range app.dramas { + if wanted[drama.ID] { + selected = append(selected, drama) + } + } + app.mu.Unlock() + for _, drama := range selected { + entry := followingDramaEntry(drama) + if entry.DramaID != "" { + result[entry.DramaID] = entry + } + } + return result +} + +func (app *UIApp) handleFollowing(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodPost { + app.handleFollowingUpdate(writer, request) + return + } + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + viewer := requestViewer(writer, request) + if viewer == nil { + return + } + entries, err := viewer.followingStore().list() + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法读取追剧清单:" + publicError(err).Error()}) + return + } + ids := make([]string, 0, len(entries)) + for _, entry := range entries { + ids = append(ids, entry.DramaID) + } + known := app.followingMetadata(ids) + result := make([]followingView, 0, len(entries)) + for _, entry := range entries { + if !dramaAllowed(request.Context(), entry.DramaID, entry.Source) { + continue + } + view := followingView{followingEntry: entry, TotalEpisode: entry.KnownEpisodes} + if current, exists := known[entry.DramaID]; exists { + view.Title = current.Title + view.Category = current.Category + if current.KnownEpisodes > 0 { + view.TotalEpisode = current.KnownEpisodes + if entry.KnownEpisodes > 0 && current.KnownEpisodes > entry.KnownEpisodes { + view.NewEpisodes = current.KnownEpisodes - entry.KnownEpisodes + } + } + } + result = append(result, view) + } + writeJSON(writer, http.StatusOK, map[string]any{"data": result, "limit": followingLimit}) +} + +func (app *UIApp) handleFollowingUpdate(writer http.ResponseWriter, request *http.Request) { + var input struct { + DramaID string `json:"dramaId"` + Saved *bool `json:"saved"` + Completed *bool `json:"completed"` + Acknowledge bool `json:"acknowledge"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + id, _, valid := playbackHistoryIdentity(input.DramaID) + if !valid || input.Saved == nil && input.Completed == nil && !input.Acknowledge { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "追剧操作或剧集 ID 无效"}) + return + } + viewer := requestViewer(writer, request) + if viewer == nil { + return + } + if !app.requireDramaSources(writer, request, []string{id}) { + return + } + metadata := app.followingMetadata([]string{id})[id] + if metadata.DramaID == "" { + if history, exists := viewer.playbackHistory().get(id); exists { + metadata = followingEntry{DramaID: id, Source: history.Source, Title: followingText(history.Title, 4096), KnownEpisodes: history.Total} + } + } + entry, err := viewer.followingStore().update(id, func(previous followingEntry, exists bool) (followingEntry, error) { + if !exists { + if metadata.DramaID == "" { + return followingEntry{}, errFollowingUnknown + } + previous = metadata + previous.AddedAt = time.Now() + } + if metadata.DramaID != "" { + previous.Title, previous.Category = metadata.Title, metadata.Category + if metadata.KnownEpisodes > 0 && (input.Acknowledge || input.Completed != nil && *input.Completed || previous.KnownEpisodes == 0) { + previous.KnownEpisodes = metadata.KnownEpisodes + } + } + if input.Saved != nil { + previous.Saved = *input.Saved + } + if input.Completed != nil { + previous.Completed = *input.Completed + } + previous.UpdatedAt = time.Now() + return previous, nil + }) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, errFollowingUnknown) { + status = http.StatusNotFound + } else if errors.Is(err, errFollowingLimit) { + status = http.StatusConflict + } + writeJSON(writer, status, map[string]string{"error": "追剧操作未保存:" + publicError(err).Error()}) + return + } + app.notifyEmbySync() + writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "entry": entry}) +} diff --git a/internal/app/ui_following_test.go b/internal/app/ui_following_test.go new file mode 100644 index 0000000..b1b000b --- /dev/null +++ b/internal/app/ui_following_test.go @@ -0,0 +1,263 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" +) + +func followingFixture(t *testing.T) *UIApp { + t.Helper() + return &UIApp{cfg: Config{dataDir: t.TempDir()}, dramas: []Drama{ + {ID: historyFixtureDramaID, Title: "无图测试小剧场", TotalEpisode: 3}, + {ID: "huangdou:7000000000000000001", Title: "无图测试小剧场", TotalEpisode: 5}, + {ID: "huangguoai:10001", Title: "未提供集数的本地测试"}, + }} +} + +func followingListForTest(t *testing.T, app *UIApp) []followingView { + t.Helper() + writer := httptest.NewRecorder() + app.handleFollowing(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/following", nil))) + if writer.Code != http.StatusOK { + t.Fatal(writer.Code, writer.Body.String()) + } + var response struct { + Data []followingView `json:"data"` + } + if err := json.Unmarshal(writer.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response.Data +} + +func followingChangeForTest(t *testing.T, app *UIApp, input map[string]any) { + t.Helper() + writer := historyJSONRequest(t, app, app.handleFollowing, "/api/ui/following", input) + if writer.Code != http.StatusOK { + t.Fatal(writer.Code, writer.Body.String()) + } +} + +func TestFollowingPersistsSeparatelyFromPlaybackAndSameNames(t *testing.T) { + app, session := historyFixtureApp(t) + app.dramas = followingFixture(t).dramas + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 24)); err != nil || !saved { + t.Fatal(saved, err) + } + before, err := os.ReadFile(fixtureViewer(app).playbackHistory().path) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{historyFixtureDramaID, app.dramas[1].ID} { + followingChangeForTest(t, app, map[string]any{"dramaId": id, "saved": true}) + } + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "completed": true}) + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "saved": false}) + restarted := &UIApp{cfg: app.cfg} + entries := followingListForTest(t, restarted) + if len(entries) != 2 { + t.Fatal("same-name dramas from different sources were combined", entries) + } + for _, entry := range entries { + if entry.DramaID == historyFixtureDramaID && (entry.Saved || !entry.Completed || entry.KnownEpisodes != 3) { + t.Fatal("unfollow changed manual completion", entry) + } + } + followingChangeForTest(t, restarted, map[string]any{"dramaId": historyFixtureDramaID, "completed": false}) + if entries := followingListForTest(t, restarted); len(entries) != 1 || entries[0].DramaID != app.dramas[1].ID { + t.Fatal("removing manual completion did not clean the entry", entries) + } + after, _ := os.ReadFile(fixtureViewer(app).playbackHistory().path) + if !bytes.Equal(before, after) { + t.Fatal("following or manual completion modified genuine playback history") + } + if other := followingListForTest(t, followingFixture(t)); len(other) != 0 { + t.Fatal("separate instances shared following data", other) + } +} + +func TestFollowingNewEpisodesRequireKnownCountAndExplicitAcknowledgement(t *testing.T) { + app := followingFixture(t) + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "saved": true}) + app.dramas[0].TotalEpisode = "6" + before, _ := os.ReadFile(fixtureViewer(app).followingStore().path) + entry := followingListForTest(t, app)[0] + if entry.NewEpisodes != 3 || entry.TotalEpisode != 6 || entry.KnownEpisodes != 3 { + t.Fatal("catalog update was not reported", entry) + } + after, _ := os.ReadFile(fixtureViewer(app).followingStore().path) + if !bytes.Equal(before, after) { + t.Fatal("reading following silently acknowledged updates") + } + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "acknowledge": true}) + entry = followingListForTest(t, app)[0] + if entry.NewEpisodes != 0 || entry.KnownEpisodes != 6 || entry.Completed { + t.Fatal("acknowledging updates changed completion", entry) + } + app.dramas[0].TotalEpisode = 2 + if entry := followingListForTest(t, app)[0]; entry.NewEpisodes != 0 { + t.Fatal("catalog reduction produced an update", entry) + } + unknownID := app.dramas[2].ID + followingChangeForTest(t, app, map[string]any{"dramaId": unknownID, "saved": true}) + app.dramas[2].TotalEpisode = 20 + for _, entry := range followingListForTest(t, app) { + if entry.DramaID == unknownID && (entry.NewEpisodes != 0 || entry.TotalEpisode != 20) { + t.Fatal("unknown original episode count fabricated new episodes", entry) + } + } +} + +func TestFollowingAcceptsHistoryOnlyMetadataAndBoundsStoredText(t *testing.T) { + app, session := historyFixtureApp(t) + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 15)); err != nil || !saved { + t.Fatal(saved, err) + } + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "saved": true}) + if entry := followingListForTest(t, app)[0]; entry.Title != session.tasks[0].DramaTitle || entry.Source != "hongguo" { + t.Fatal("known history was not usable without the catalog", entry) + } + app.dramas = []Drama{{ID: historyFixtureDramaID, Title: strings.Repeat("剧", 2000), CategoryName: strings.Repeat("分类", 1000)}} + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "completed": true}) + entries, err := newFollowingStore(fixtureViewer(app).directory).list() + if err != nil || len(entries) != 1 || len(entries[0].Title) > 4096 || len(entries[0].Category) > 512 || !utf8.ValidString(entries[0].Title) || !utf8.ValidString(entries[0].Category) { + t.Fatal("long cached metadata could not survive restart", entries, err) + } +} + +func TestFollowingRejectsCrossSiteUnknownAndMalformedRequests(t *testing.T) { + app := followingFixture(t) + for _, test := range []struct { + name, method, contentType, origin, body string + status int + }{ + {"cross-site read", "GET", "", "https://unrelated.invalid", "", 403}, + {"cross-site write", "POST", "application/json", "https://unrelated.invalid", `{"dramaId":"` + historyFixtureDramaID + `","saved":true}`, 403}, + {"wrong type", "POST", "text/plain", "", `{}`, 415}, + {"unknown ID", "POST", "application/json", "", `{"dramaId":"hongguo:7000000000000000999","saved":true}`, 404}, + {"invalid ID", "POST", "application/json", "", `{"dramaId":"../file","saved":true}`, 400}, + {"no change", "POST", "application/json", "", `{"dramaId":"` + historyFixtureDramaID + `"}`, 400}, + {"client metadata", "POST", "application/json", "", `{"dramaId":"` + historyFixtureDramaID + `","saved":true,"title":"untrusted"}`, 400}, + {"trailing input", "POST", "application/json", "", `{"dramaId":"` + historyFixtureDramaID + `","saved":true}{}`, 400}, + {"method", "DELETE", "", "", "", 405}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(test.method, "http://localhost/api/ui/following", strings.NewReader(test.body)) + request.Header.Set("Content-Type", test.contentType) + request.Header.Set("Origin", test.origin) + writer := httptest.NewRecorder() + app.handleFollowing(writer, viewerFixtureRequest(app, request)) + if writer.Code != test.status { + t.Fatal(writer.Code, writer.Body.String()) + } + }) + } + if entries := followingListForTest(t, app); len(entries) != 0 { + t.Fatal("invalid requests changed following", entries) + } +} + +func TestFollowingConcurrentUpdatesSurviveRestart(t *testing.T) { + app := followingFixture(t) + app.dramas = nil + for index := 0; index < 24; index++ { + app.dramas = append(app.dramas, Drama{ID: fmt.Sprintf("hongguo:%d", int64(7000000000000000000)+int64(index)), Title: "并发无图测试"}) + } + var wait sync.WaitGroup + for _, drama := range app.dramas { + for _, field := range []string{"saved", "completed"} { + wait.Add(1) + go func(id, field string) { + defer wait.Done() + body, _ := json.Marshal(map[string]any{"dramaId": id, field: true}) + request := httptest.NewRequest("POST", "http://localhost/api/ui/following", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + app.handleFollowing(writer, viewerFixtureRequest(app, request)) + if writer.Code != http.StatusOK { + t.Error(writer.Code, writer.Body.String()) + } + }(drama.ID, field) + } + } + wait.Wait() + entries, err := newFollowingStore(fixtureViewer(app).directory).list() + if err != nil || len(entries) != len(app.dramas) { + t.Fatal("concurrent entries were lost", len(entries), err) + } + for _, entry := range entries { + if !entry.Saved || !entry.Completed { + t.Fatal("concurrent independent flags overwrote one another", entry) + } + } +} + +func TestFollowingWriteFailureRollsBackAndCorruptFilesArePreserved(t *testing.T) { + app := followingFixture(t) + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "saved": true}) + store := fixtureViewer(app).followingStore() + path := store.path + before, _ := os.ReadFile(path) + store.path = app.cfg.dataDirectory() + writer := historyJSONRequest(t, app, app.handleFollowing, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": false}) + if writer.Code != http.StatusInternalServerError || !followingListForTest(t, app)[0].Saved { + t.Fatal("failed write was reported as a successful mutation", writer.Code, writer.Body.String()) + } + store.path = path + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("write failure changed the stored file") + } + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "saved": false}) + for _, broken := range []string{`{"version":1,"entries":`, `{"version":99,"entries":[]}`, `{"version":1,"entries":[]} trailing`} { + if err := os.WriteFile(path, []byte(broken), 0o600); err != nil { + t.Fatal(err) + } + restarted := &UIApp{cfg: app.cfg, dramas: app.dramas} + writer := historyJSONRequest(t, restarted, restarted.handleFollowing, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true}) + if writer.Code != http.StatusInternalServerError { + t.Fatal("invalid storage was silently replaced", writer.Code) + } + actual, _ := os.ReadFile(path) + if string(actual) != broken { + t.Fatal("corrupt original file was not preserved") + } + } +} + +func TestFollowingLoadRejectsSourceMismatchAndLimitPreservesExistingEntries(t *testing.T) { + app := followingFixture(t) + entry := followingEntry{DramaID: historyFixtureDramaID, Source: "huangdou", Title: "无图测试", Saved: true, AddedAt: time.Now(), UpdatedAt: time.Now()} + body, _ := json.Marshal(followingFile{Version: 1, Entries: []followingEntry{entry}}) + if err := os.MkdirAll(fixtureViewer(app).directory, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixtureViewer(app).directory, "following.json"), body, 0o600); err != nil { + t.Fatal(err) + } + if entries := followingListForTest(t, app); len(entries) != 0 { + t.Fatal("mismatched source identity was restored", entries) + } + store := fixtureViewer(app).followingStore() + for index := 0; index < followingLimit; index++ { + entry.DramaID = fmt.Sprintf("hongguo:%d", int64(7000000000000100000)+int64(index)) + entry.Source = "hongguo" + store.entries[entry.DramaID] = entry + } + writer := historyJSONRequest(t, app, app.handleFollowing, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true}) + if writer.Code != http.StatusConflict || len(store.entries) != followingLimit { + t.Fatal("full following store silently evicted entries", writer.Code, len(store.entries)) + } + followingChangeForTest(t, app, map[string]any{"dramaId": entry.DramaID, "saved": false}) + followingChangeForTest(t, app, map[string]any{"dramaId": historyFixtureDramaID, "saved": true}) +} diff --git a/internal/app/ui_image.go b/internal/app/ui_image.go new file mode 100644 index 0000000..0363388 --- /dev/null +++ b/internal/app/ui_image.go @@ -0,0 +1,124 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "image/jpeg" + "io" + "net/http" + "os/exec" + "strings" + "time" +) + +func isHongguoImageHost(host string) bool { + host = strings.ToLower(host) + return host == "hongguoduanju.com" || host == "www.hongguoduanju.com" || + strings.HasSuffix(host, ".byteimg.com") || strings.HasSuffix(host, ".fqnovelpic.com") +} + +func (app *UIApp) loadCoverImage(ctx context.Context, remoteURL string, decode func([]byte, string) []byte) ([]byte, error) { + source, _ := ctx.Value(coverSourceKey{}).(string) + referer := sourceCoverReferer(source, remoteURL) + return app.coverImages.load(ctx, remoteURL+"\x00"+referer, func(ctx context.Context) ([]byte, error) { + ctx = context.WithValue(ctx, coverRequestKey{}, true) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteURL, nil) + if err != nil { + return nil, err + } + if !validImageURL(request.URL) { + return nil, errors.New("封面地址不受支持") + } + request.Header.Set("Referer", referer) + request.Header.Set("User-Agent", userAgent) + request.Header.Set("Accept", "image/webp,image/jpeg,image/png,image/gif,*/*;q=0.5") + client := *app.downloader.client + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if len(via) >= 5 || !validImageURL(request.URL) { + return errors.New("封面重定向地址不受支持") + } + request.Header.Set("Referer", referer) + return nil + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("封面上游 HTTP %d", response.StatusCode) + } + if response.ContentLength > maxCoverBytes { + return nil, errors.New("封面文件过大") + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxCoverBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxCoverBytes { + return nil, errors.New("封面文件过大") + } + if decode != nil { + data = decode(data, remoteURL) + } + if isKnownImage(data) { + return data, nil + } + if isHEICImage(data) { + return app.downloader.convertHEICCover(ctx, data) + } + return nil, errors.New("封面内容无效,请检查代理或站点是否需要验证") + }) +} + +type coverOutput struct { + buffer bytes.Buffer +} + +func (output *coverOutput) Write(data []byte) (int, error) { + if len(data) > (4<<20)-output.buffer.Len() { + return 0, errors.New("封面转换结果过大") + } + return output.buffer.Write(data) +} + +func (output *coverOutput) Bytes() []byte { return output.buffer.Bytes() } + +func (downloader *Downloader) convertHEICCover(ctx context.Context, data []byte) ([]byte, error) { + image, err := extractHEICImage(data) + if err != nil { + return nil, err + } + ffmpeg, err := downloader.ensureFFmpeg(ctx) + if err != nil { + return nil, fmt.Errorf("封面转换需要 FFmpeg:%w", err) + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + filters := append(image.filters, "scale=w='min(800,iw)':h='min(800,ih)':force_original_aspect_ratio=decrease") + command := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", + "-max_alloc", "67108864", "-protocol_whitelist", "pipe", "-threads", "1", "-f", "hevc", "-i", "pipe:0", + "-frames:v", "1", "-an", "-sn", "-vf", strings.Join(filters, ","), "-filter_threads", "1", + "-threads", "1", "-c:v", "mjpeg", "-pix_fmt", "yuvj420p", "-q:v", "3", "-f", "image2pipe", "pipe:1") + command.Stdin = bytes.NewReader(image.data) + var output coverOutput + stderr := &cappedStringWriter{limit: 4096} + command.Stdout, command.Stderr = &output, stderr + if err := command.Run(); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = err.Error() + } + return nil, fmt.Errorf("HEIC 封面转换失败:%s", detail) + } + config, err := jpeg.DecodeConfig(bytes.NewReader(output.Bytes())) + if err != nil || config.Width <= 0 || config.Height <= 0 || config.Width > 800 || config.Height > 800 { + return nil, errors.New("HEIC 封面转换结果无效") + } + return output.Bytes(), nil +} diff --git a/internal/app/ui_image_cache.go b/internal/app/ui_image_cache.go new file mode 100644 index 0000000..7db8cc1 --- /dev/null +++ b/internal/app/ui_image_cache.go @@ -0,0 +1,127 @@ +package app + +import ( + "context" + "errors" + "sync" + "time" +) + +const coverCacheLimit = 32 << 20 + +type coverCacheEntry struct { + data []byte + expires time.Time + lastUsed time.Time +} + +type coverImageJob struct { + done chan struct{} + cancel context.CancelFunc + waiters int + finished bool + data []byte + err error +} + +type coverImageCache struct { + mu sync.Mutex + entries map[string]*coverCacheEntry + pending map[string]*coverImageJob + slots chan struct{} + size int +} + +func (cache *coverImageCache) load(ctx context.Context, key string, fetch func(context.Context) ([]byte, error)) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + cache.mu.Lock() + if entry := cache.entries[key]; entry != nil && time.Now().Before(entry.expires) { + entry.lastUsed = time.Now() + cache.mu.Unlock() + return entry.data, nil + } + if cache.pending == nil { + cache.entries = make(map[string]*coverCacheEntry) + cache.pending = make(map[string]*coverImageJob) + cache.slots = make(chan struct{}, 4) + } + job := cache.pending[key] + if job == nil { + if len(cache.pending) >= 128 { + cache.mu.Unlock() + return nil, errors.New("封面请求过多,请稍后重试") + } + workCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + job = &coverImageJob{done: make(chan struct{}), cancel: cancel} + cache.pending[key] = job + go cache.run(workCtx, key, job, fetch) + } + job.waiters++ + cache.mu.Unlock() + select { + case <-ctx.Done(): + case <-job.done: + } + cache.mu.Lock() + job.waiters-- + if !job.finished && job.waiters == 0 { + job.cancel() + if cache.pending[key] == job { + delete(cache.pending, key) + } + } + data, err := job.data, job.err + cache.mu.Unlock() + if ctx.Err() != nil { + return nil, ctx.Err() + } + return data, err +} + +func (cache *coverImageCache) run(ctx context.Context, key string, job *coverImageJob, fetch func(context.Context) ([]byte, error)) { + defer job.cancel() + var data []byte + var err error + select { + case cache.slots <- struct{}{}: + data, err = fetch(ctx) + <-cache.slots + case <-ctx.Done(): + err = ctx.Err() + } + cache.mu.Lock() + defer cache.mu.Unlock() + job.data, job.err, job.finished = data, err, true + if cache.pending[key] == job { + delete(cache.pending, key) + if err == nil && ctx.Err() == nil && len(data) > 0 && len(data) <= 4<<20 { + cache.store(key, data) + } + } + close(job.done) +} + +func (cache *coverImageCache) store(key string, data []byte) { + now := time.Now() + for name, entry := range cache.entries { + if name == key || !now.Before(entry.expires) { + cache.size -= len(entry.data) + delete(cache.entries, name) + } + } + for cache.size+len(data) > coverCacheLimit || len(cache.entries) >= 512 { + oldest := "" + var lastUsed time.Time + for name, entry := range cache.entries { + if oldest == "" || entry.lastUsed.Before(lastUsed) { + oldest, lastUsed = name, entry.lastUsed + } + } + cache.size -= len(cache.entries[oldest].data) + delete(cache.entries, oldest) + } + cache.entries[key] = &coverCacheEntry{data: data, expires: now.Add(24 * time.Hour), lastUsed: now} + cache.size += len(data) +} diff --git a/internal/app/ui_image_huangguo_test.go b/internal/app/ui_image_huangguo_test.go new file mode 100644 index 0000000..75017ef --- /dev/null +++ b/internal/app/ui_image_huangguo_test.go @@ -0,0 +1,158 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "image" + "image/png" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" +) + +func syntheticHuangguoCover(t *testing.T) ([]byte, []byte) { + t.Helper() + var output bytes.Buffer + if err := png.Encode(&output, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil { + t.Fatal(err) + } + plain := output.Bytes() + padding := aes.BlockSize - len(plain)%aes.BlockSize + padded := append(append([]byte{}, plain...), bytes.Repeat([]byte{byte(padding)}, padding)...) + encrypted := make([]byte, len(padded)) + block, err := aes.NewCipher([]byte("f5d965df75336270")) + if err != nil { + t.Fatal(err) + } + cipher.NewCBCEncrypter(block, []byte("97b60394abc2fbe1")).CryptBlocks(encrypted, padded) + return plain, encrypted +} + +func TestHuangguoImageProxyAcceptsKnownCDNAliases(t *testing.T) { + plain, encrypted := syntheticHuangguoCover(t) + for _, host := range []string{"pic.zdmhyg.cn", "pic.tuafjz.cn", "PIC.TUAFJZ.CN", "new-covers.example.org"} { + for _, payload := range []struct { + name string + data []byte + }{{"plain", plain}, {"encrypted", encrypted}} { + t.Run(host+"/"+payload.name, func(t *testing.T) { + address := "https://" + host + "/upload/synthetic.jpg?auth_key=fixture-0-0-test&signature=%2F%2b%3D" + var calls atomic.Int32 + downloader := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.String() != address || request.Header.Get("Referer") != "https://huangguoai.com/" || request.Header.Get("User-Agent") == "" { + t.Error("cover request changed the signed address or omitted source headers") + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(payload.data)), Request: request}, nil + }) + app := &UIApp{downloader: downloader, cfg: downloader.cfg, dramas: []Drama{{ID: "huangguoai:501", Source: sourceHuangguoAI, CoverURL: address}}} + for range 2 { + request := httptest.NewRequest(http.MethodGet, "/api/ui/image?url="+url.QueryEscape(address), nil) + request = request.WithContext(withSourceScope(context.Background(), accountRecord{Sources: []string{"huangguo"}})) + writer := httptest.NewRecorder() + app.handleImage(writer, request) + if writer.Code != http.StatusOK || !bytes.Equal(writer.Body.Bytes(), plain) || writer.Header().Get("Content-Type") != "image/png" { + t.Fatalf("known Huangguo cover was rejected or decoded incorrectly: status=%d body=%q", writer.Code, writer.Body.String()) + } + if writer.Header().Get("Cache-Control") != "private, no-store" { + t.Fatal("restricted-source cover became publicly cacheable") + } + } + if calls.Load() != 1 { + t.Fatal("repeat cover requests bypassed the cache", calls.Load()) + } + if host != "new-covers.example.org" && !protectedCDNHost(host) { + t.Fatal("known cover CDN lost its existing DNS and proxy fallback") + } + }) + } + } +} + +func TestHuangguoImageProxyValidatesRedirects(t *testing.T) { + plain, _ := syntheticHuangguoCover(t) + for _, test := range []struct { + target string + status int + calls int32 + }{ + {"https://pic.tuafjz.cn/upload/synthetic.jpg?auth_key=fixture", http.StatusOK, 2}, + {"https://new-covers.example.org/cover", http.StatusOK, 2}, + {"https://pic.tuafjz.cn.example.invalid/cover", http.StatusBadGateway, 1}, + {"https://127.0.0.1/cover", http.StatusBadGateway, 1}, + } { + t.Run(test.target, func(t *testing.T) { + var calls atomic.Int32 + downloader := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + count := calls.Add(1) + if count == 1 { + return &http.Response{StatusCode: http.StatusFound, Header: http.Header{"Location": {test.target}}, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil + } + if test.calls != 2 || request.URL.String() != test.target || request.Header.Get("Referer") != "https://huangguoai.com/" { + t.Error("redirect requested a forbidden host or lost the correct referer") + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(plain)), Request: request}, nil + }) + app := &UIApp{downloader: downloader, cfg: downloader.cfg, dramas: []Drama{{ID: "huangguoai:501", Source: sourceHuangguoAI, CoverURL: "https://pic.zdmhyg.cn/upload/synthetic.jpg"}}} + request := httptest.NewRequest(http.MethodGet, "/api/ui/image?url="+url.QueryEscape("https://pic.zdmhyg.cn/upload/synthetic.jpg"), nil) + writer := httptest.NewRecorder() + app.handleImage(writer, request) + if writer.Code != test.status || calls.Load() != test.calls { + t.Fatalf("unexpected redirect result: status=%d calls=%d", writer.Code, calls.Load()) + } + }) + } +} + +func TestHuangguoImageProxyRetainsURLAndSourceRestrictions(t *testing.T) { + var calls atomic.Int32 + downloader := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return rankingHTTPResponse(request, http.StatusOK, "forbidden request"), nil + }) + address := "https://pic.tuafjz.cn/upload/synthetic.jpg?auth_key=fixture" + app := &UIApp{downloader: downloader, cfg: downloader.cfg, dramas: []Drama{{ID: "huangguoai:501", Source: sourceHuangguoAI, CoverURL: address}}} + for _, target := range []string{ + "https://pic.tuafjz.cn.example.invalid/cover", + "https://pic.tuafjz.cn@127.0.0.1/cover", "https://user@pic.tuafjz.cn/cover", "https://pic.tuafjz.cn:8443/cover", + "http://pic.tuafjz.cn/cover", "https://127.0.0.1/cover", "https://[::1]/cover", + } { + writer := httptest.NewRecorder() + app.handleImage(writer, httptest.NewRequest(http.MethodGet, "/api/ui/image?url="+url.QueryEscape(target), nil)) + if writer.Code != http.StatusBadRequest { + t.Errorf("unsupported image URL was accepted: %s (%d)", target, writer.Code) + } + } + for _, target := range []string{"https://tuafjz.cn/cover", "https://other.tuafjz.cn/cover", "https://new-covers.example.org/unregistered"} { + writer := httptest.NewRecorder() + app.handleImage(writer, httptest.NewRequest(http.MethodGet, "/api/ui/image?url="+url.QueryEscape(target), nil)) + if writer.Code != http.StatusForbidden { + t.Errorf("unregistered cover was accepted: %s (%d)", target, writer.Code) + } + } + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/ui/image?url="+url.QueryEscape(address), nil) + request = request.WithContext(withSourceScope(context.Background(), accountRecord{Sources: []string{sourceHongguo}})) + app.handleImage(writer, request) + if writer.Code != http.StatusForbidden || calls.Load() != 0 { + t.Fatalf("source-restricted request was not denied before fetching: status=%d calls=%d", writer.Code, calls.Load()) + } +} + +func TestCoverRefererPreservesHuangdouMirrors(t *testing.T) { + for address, want := range map[string]string{ + "https://tideember.cc/cover": "https://tideember.cc/home", + "https://xqjurgek.top/cover": "https://xqjurgek.top/home", + "https://new-covers.example.org/cover": huangdouBaseURL + "/home", + } { + if referer := sourceCoverReferer(sourceHuangdou, address); referer != want { + t.Fatal(address, referer, want) + } + } +} diff --git a/internal/app/ui_library_search.go b/internal/app/ui_library_search.go new file mode 100644 index 0000000..c051e3e --- /dev/null +++ b/internal/app/ui_library_search.go @@ -0,0 +1,77 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "time" +) + +func (app *UIApp) handleLibrarySearch(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + keyword, err := hongguoSearchKeyword(request.URL.Query().Get("q")) + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if !requireSource(writer, request.Context(), sourceHongguo) { + return + } + ctx, cancel := context.WithTimeout(request.Context(), 45*time.Second) + defer cancel() + result, err := app.downloader.searchHongguoDramas(ctx, keyword) + if err != nil { + if request.Context().Err() != nil { + return + } + fmt.Printf("红果联网搜索失败: %v\n", publicError(err)) + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "联网搜索暂不可用,请稍后重试;本地筛选仍可使用"}) + return + } + if ctx.Err() != nil { + return + } + dramas := append([]Drama{}, result.Dramas...) + if len(dramas) > 0 { + positions := make(map[string]int, len(dramas)) + for index, drama := range dramas { + positions[drama.ID] = index + } + app.mu.Lock() + app.normalizeDramaCovers(dramas) + newDramas := app.newSortMetadataDramasLocked(dramas) + app.dramas = mergeSourceDramas(app.dramas, dramas, nil, sourceHongguo) + app.enqueueSortMetadataLocked(newDramas, false) + app.loadedAt = time.Now() + if app.librarySources == nil { + app.librarySources = map[string]librarySourceState{} + } + state := app.librarySources[sourceHongguo] + state.Count = 0 + for _, drama := range app.dramas { + if dramaProvider(drama) == sourceHongguo { + state.Count++ + } + if index, found := positions[drama.ID]; found { + + dramas[index] = drama + } + } + state.UpdatedAt = app.loadedAt + if state.Status == "" { + state.Status = "ready" + } + app.librarySources[sourceHongguo] = state + app.libraryDirty = true + app.libraryRevision++ + app.mu.Unlock() + app.persistLibrary() + } + app.mu.Lock() + saved := app.librarySaved && !app.libraryDirty + app.mu.Unlock() + writeJSON(writer, http.StatusOK, map[string]any{"query": keyword, "data": dramas, "total": result.Total, "saved": saved}) +} diff --git a/internal/app/ui_library_sources_test.go b/internal/app/ui_library_sources_test.go new file mode 100644 index 0000000..ad2b513 --- /dev/null +++ b/internal/app/ui_library_sources_test.go @@ -0,0 +1,112 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestLibraryUpdateTargetsIndividualHuangguoEntrypoints(t *testing.T) { + for _, source := range []string{"cloudfront", sourceHuangguoAI, sourceHuangguoVideo} { + t.Run(source, func(t *testing.T) { + started := make(chan struct{}, 8) + downloader := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if request.Context().Err() == nil { + select { + case started <- struct{}{}: + default: + } + } + <-request.Context().Done() + return nil, request.Context().Err() + }) + app := &UIApp{downloader: downloader, cfg: downloader.cfg, libraryAttempted: true, librarySources: map[string]librarySourceState{}} + for _, provider := range []string{"cloudfront", sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou, sourceHongguo} { + app.librarySources[provider] = librarySourceState{Status: "cached", Count: 7} + } + t.Cleanup(func() { + app.mu.Lock() + done, cancel := app.libraryLoading, app.libraryCancel + app.mu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("source update did not stop") + } + } + app.stopSortMetadata() + }) + read := func() struct { + Accepted bool `json:"updateAccepted"` + Loading bool `json:"loading"` + Source string `json:"loadingSource"` + } { + t.Helper() + request := httptest.NewRequest(http.MethodGet, "/api/ui/dramas?update=1&source="+source, nil) + request = request.WithContext(withSourceScope(context.Background(), accountRecord{Sources: []string{"huangguo"}})) + writer := httptest.NewRecorder() + app.handleDramas(writer, request) + var result struct { + Accepted bool `json:"updateAccepted"` + Loading bool `json:"loading"` + Source string `json:"loadingSource"` + } + if writer.Code != http.StatusOK || json.Unmarshal(writer.Body.Bytes(), &result) != nil { + t.Fatalf("individual source update rejected: %d %s", writer.Code, writer.Body.String()) + } + return result + } + if result := read(); !result.Accepted || !result.Loading || result.Source != source { + t.Fatalf("update was not scoped to the requested entrypoint: %+v", result) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("source update never dispatched a textual request") + } + if result := read(); result.Accepted || !result.Loading || result.Source != source { + t.Fatalf("busy update was reported as newly accepted: %+v", result) + } + app.mu.Lock() + defer app.mu.Unlock() + for provider, state := range app.librarySources { + want := "cached" + if provider == source { + want = "loading" + } + if state.Status != want || state.Count != 7 { + t.Errorf("update changed another entrypoint: %s %+v", provider, state) + } + } + }) + } +} + +func TestIndividualSourceUpdatesRequireSourcePermission(t *testing.T) { + var calls atomic.Int32 + downloader := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return rankingHTTPResponse(request, http.StatusServiceUnavailable, "text fixture"), nil + }) + app := &UIApp{downloader: downloader, cfg: downloader.cfg, libraryAttempted: true} + for _, source := range []string{"cloudfront", sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou} { + request := httptest.NewRequest(http.MethodGet, "/api/ui/dramas?update=1&source="+source, nil) + request = request.WithContext(withSourceScope(context.Background(), accountRecord{Sources: []string{sourceHongguo}})) + writer := httptest.NewRecorder() + app.handleDramas(writer, request) + if writer.Code != http.StatusForbidden { + t.Errorf("restricted source %s returned %d instead of 403", source, writer.Code) + } + } + if calls.Load() != 0 || app.libraryLoading != nil { + t.Fatal("unauthorized source update triggered background work") + } +} diff --git a/internal/app/ui_network.go b/internal/app/ui_network.go new file mode 100644 index 0000000..84bc7d6 --- /dev/null +++ b/internal/app/ui_network.go @@ -0,0 +1,13 @@ +package app + +import ( + "net/http" +) + +func (a *UIApp) handleNetworkCheck(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + writeJSON(writer, http.StatusOK, map[string]any{"data": a.networkCheckResults(request.Context())}) +} diff --git a/internal/app/ui_page.go b/internal/app/ui_page.go new file mode 100644 index 0000000..d0f8027 --- /dev/null +++ b/internal/app/ui_page.go @@ -0,0 +1,43 @@ +package app + +import ( + "crypto/sha256" + "fmt" + "net/http" + "strings" + + "juku/internal/webui" +) + +var uiHTML = webui.HTML + +func webAssets() http.Handler { + tags := map[string]string{} + entries, _ := webui.Assets.ReadDir(".") + for _, entry := range entries { + if entry.IsDir() { + continue + } + body, err := webui.Assets.ReadFile(entry.Name()) + if err == nil { + tags["/assets/"+entry.Name()] = fmt.Sprintf(`"%x"`, sha256.Sum256(body)) + } + } + files := http.StripPrefix("/assets/", http.FileServer(http.FS(webui.Assets))) + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "no-cache") + if tag := tags[request.URL.Path]; tag != "" { + writer.Header().Set("ETag", tag) + if request.Method == http.MethodGet || request.Method == http.MethodHead { + for _, candidate := range strings.Split(request.Header.Get("If-None-Match"), ",") { + candidate = strings.TrimPrefix(strings.TrimSpace(candidate), "W/") + if candidate == tag || candidate == "*" { + writer.WriteHeader(http.StatusNotModified) + return + } + } + } + } + files.ServeHTTP(writer, request) + }) +} diff --git a/internal/app/ui_playback.go b/internal/app/ui_playback.go new file mode 100644 index 0000000..0d2e1d4 --- /dev/null +++ b/internal/app/ui_playback.go @@ -0,0 +1,523 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "io" + "math" + "mime" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const playbackIdleTimeout = 10 * time.Minute +const playbackSessionLimit = 4 +const playbackMIME = `video/mp4; codecs="avc1.42C01F, mp4a.40.2"` + +type playbackSession struct { + accountID string + dramaID string + id string + viewer *viewerRecords + tasks []Task + downloadIDs []string + prepared map[int]bool + expires time.Time + timer *time.Timer + cancel context.CancelFunc + run uint64 + state string + error string + duration float64 + currentIndex int + prefetchVersion uint64 + prefetch *playbackPrefetch + native *playbackNative + media *playbackMediaSession + mediaReady chan struct{} + mediaError error + mediaWaiters int + quality int + streamVersion uint64 + openedAt time.Time + historySequence uint64 + historyRuns map[uint64]playbackHistoryRun +} + +type playbackEpisode struct { + VIP bool `json:"vip,omitempty"` + Index int `json:"index"` + Episode string `json:"episode"` + Title string `json:"title"` + TaskID string `json:"taskId,omitempty"` + Danmaku bool `json:"danmaku,omitempty"` + ChapterID string `json:"chapterId,omitempty"` + Number int `json:"number"` + Total int `json:"total"` +} + +type playbackView struct { + State string `json:"state"` + Error string `json:"error,omitempty"` + Run uint64 `json:"run"` + Duration float64 `json:"duration"` + Prefetch *playbackPrefetchView `json:"prefetch,omitempty"` + Plan *playbackMediaPlan `json:"plan,omitempty"` + MediaBytes int64 `json:"mediaBytes,omitempty"` + MediaFailure bool `json:"mediaFailure,omitempty"` +} + +func (app *UIApp) registerPlaybackRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/ui/admin/playback", app.handlePlaybackSettings) + mux.HandleFunc("/api/ui/playback/plan", app.handlePlaybackPlan) + mux.HandleFunc("/api/ui/playback/media/", app.handlePlaybackMediaAsset) + mux.HandleFunc("/api/emby/media/", app.handleEmbyMediaAsset) + mux.HandleFunc("/api/ui/playback/open", app.handlePlaybackOpen) + mux.HandleFunc("/api/ui/playback/prepare", app.handlePlaybackPrepare) + mux.HandleFunc("/api/ui/playback/stream", app.handlePlaybackStream) + mux.HandleFunc("/api/ui/playback/hls/open", app.handlePlaybackNativeOpen) + mux.HandleFunc("/api/ui/playback/hls/index.m3u8", app.handlePlaybackNativeAsset) + mux.HandleFunc("/api/ui/playback/hls/segment.ts", app.handlePlaybackNativeAsset) + mux.HandleFunc("/api/ui/playback/control", app.handlePlaybackControl) + mux.HandleFunc("/api/ui/playback/status", app.handlePlaybackStatus) + mux.HandleFunc("/api/ui/playback/prefetch", app.handlePlaybackPrefetch) + mux.HandleFunc("/api/ui/playback/danmaku", app.handlePlaybackDanmaku) + mux.HandleFunc("/api/ui/playback/history", app.handlePlaybackHistory) + mux.HandleFunc("/api/ui/playback/history/remove", app.handlePlaybackHistoryRemove) + mux.HandleFunc("/api/ui/playback/progress", app.handlePlaybackProgress) +} + +func playbackRequestAllowed(writer http.ResponseWriter, request *http.Request, method string) bool { + writer.Header().Set("Cache-Control", "private, no-store, no-transform") + writer.Header().Set("X-Accel-Buffering", "no") + writer.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + writer.Header().Set("X-Content-Type-Options", "nosniff") + if request.Method != method { + writer.Header().Set("Allow", method) + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "请求方法不支持"}) + return false + } + switch request.Header.Get("Sec-Fetch-Site") { + case "same-origin", "none": + return true + case "": + default: + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请从剧库页面发起播放"}) + return false + } + if origin := request.Header.Get("Origin"); origin != "" { + parsed, err := url.Parse(origin) + if err != nil || parsed.User != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || !originHostMatches(parsed, request.Host) { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "不允许跨站播放请求"}) + return false + } + } + return true +} + +func originHostMatches(origin *url.URL, host string) bool { + port := ":80" + if origin.Scheme == "https" { + port = ":443" + } + return strings.EqualFold(strings.TrimSuffix(origin.Host, port), strings.TrimSuffix(host, port)) +} + +func readPlaybackRequest(writer http.ResponseWriter, request *http.Request, destination any) bool { + if !playbackRequestAllowed(writer, request, http.MethodPost) { + return false + } + contentType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type")) + if contentType != "application/json" { + writeJSON(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "请求必须使用 JSON"}) + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 4096)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放请求无效"}) + return false + } + if err := decoder.Decode(new(any)); err != io.EOF { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放请求包含多余数据"}) + return false + } + return true +} + +func (app *UIApp) handlePlaybackOpen(writer http.ResponseWriter, request *http.Request) { + var input struct { + DramaID string `json:"dramaId"` + TaskID string `json:"taskId"` + Resume bool `json:"resume"` + FromHistory bool `json:"fromHistory"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + if input.TaskID != "" && !requireDownload(writer, request) { + return + } + if input.TaskID != "" && input.DramaID != "" { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择剧库播放或合集播放"}) + return + } + viewer := requestViewer(writer, request) + if viewer == nil { + return + } + if input.DramaID != "" && !app.requireDramaSources(writer, request, []string{input.DramaID}) { + return + } + openedAt := time.Now() + history := viewer.playbackHistory() + previous, hasPrevious := history.get(input.DramaID) + if input.FromHistory && (!input.Resume || !hasPrevious || input.TaskID != "") { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "此观看记录已移除,请从剧库重新选择"}) + return + } + var drama Drama + var title string + var tasks []Task + var downloadIDs []string + initialIndex := 1 + var collectionErr error + app.mu.Lock() + if input.FromHistory && previous.Mode == "collection" && downloadAllowed(request.Context()) { + candidate := app.tasks[previous.TaskID] + if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID { + input.TaskID = candidate.ID + } else { + for _, taskID := range app.taskOrder { + candidate = app.tasks[taskID] + if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID { + input.TaskID = candidate.ID + break + } + } + } + } + if input.TaskID != "" { + if !app.requireTaskSourcesLocked(writer, request, taskSelection{IDs: []string{input.TaskID}}) { + app.mu.Unlock() + return + } + title, tasks, downloadIDs, initialIndex, collectionErr = app.collectionPlaybackTasksLocked(input.TaskID) + } else { + for _, candidate := range app.dramas { + if candidate.ID == input.DramaID && input.DramaID != "" { + drama = candidate + break + } + } + } + app.mu.Unlock() + if input.TaskID != "" && len(tasks) > 0 && input.Resume { + previous, hasPrevious = history.get(tasks[0].DramaID) + } + if input.TaskID == "" && drama.ID == "" && input.Resume && hasPrevious { + drama = Drama{ID: previous.DramaID, Source: previous.Source, Title: previous.Title} + } + if collectionErr != nil { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": app.redactError(collectionErr)}) + return + } + if input.TaskID == "" && drama.ID == "" { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "此短剧不在当前剧库中,请刷新剧库后重试"}) + return + } + if drama.ID != "" && !dramaAllowed(request.Context(), drama.ID, drama.Source) { + writeSourceDenied(writer) + return + } + for _, task := range tasks { + if !taskSourceAllowed(request.Context(), task) { + writeSourceDenied(writer) + return + } + } + sessionDramaID := drama.ID + if len(tasks) > 0 { + sessionDramaID = tasks[0].DramaID + } + ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) + defer cancel() + id := randomHex(24) + if id == "" { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法创建播放会话"}) + return + } + app.playbackMu.Lock() + if len(app.playbacks) >= app.mediaResources().snapshot().MaxSessions { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "同时播放数量已达上限,请稍后再试"}) + return + } + if app.playbacks == nil { + app.playbacks = make(map[string]*playbackSession) + } + session := &playbackSession{downloadIDs: downloadIDs, dramaID: sessionDramaID, id: id, viewer: viewer, expires: time.Now().Add(playbackIdleTimeout), state: "opening", cancel: cancel, openedAt: openedAt, historyRuns: make(map[uint64]playbackHistoryRun)} + viewer.retain() + app.playbacks[id] = session + session.timer = time.AfterFunc(playbackIdleTimeout, func() { app.expirePlayback(id) }) + app.playbackMu.Unlock() + ready := false + defer func() { + if !ready { + app.closePlayback(id) + } + }() + if input.TaskID == "" { + fetchedTitle, chapters, err := app.downloader.GetDramaChapters(ctx, drama.ID) + if err == nil && len(chapters) == 0 { + err = errors.New("站点未返回可播放的分集") + } + if err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "获取选集失败:" + app.redactError(err)}) + return + } + title = firstNonEmpty(fetchedTitle, drama.DisplayTitle()) + for index, chapter := range chapters { + tasks = append(tasks, Task{DramaID: drama.ID, DramaTitle: title, Chapter: chapter, Index: index + 1, Total: len(chapters)}) + } + } + for _, task := range tasks { + if !taskSourceAllowed(request.Context(), task) { + writeSourceDenied(writer) + return + } + } + episodes := make([]playbackEpisode, 0, len(tasks)) + for index, task := range tasks { + number := task.Index + if number < 1 { + number = index + 1 + } + total := task.Total + if total < len(tasks) { + total = len(tasks) + } + if total < number { + total = number + } + episode := playbackEpisode{VIP: task.Chapter.VIP, Index: index + 1, Episode: task.Chapter.EpisodeString(number), Title: task.Chapter.Title, ChapterID: task.Chapter.ID, Number: number, Total: total} + _, _, episode.Danmaku = hongguoPlaybackIDs(task) + if len(downloadIDs) > 0 { + episode.TaskID = downloadIDs[index] + } + episodes = append(episodes, episode) + } + app.playbackMu.Lock() + if app.playbacks[id] == session && ctx.Err() == nil { + session.tasks = tasks + session.downloadIDs = downloadIDs + session.prepared = make(map[int]bool) + session.cancel = nil + session.state = "ready" + app.touchPlaybackLocked(session) + ready = true + } + app.playbackMu.Unlock() + if !ready { + writeJSON(writer, http.StatusRequestTimeout, map[string]string{"error": "播放准备超时,请重新打开本剧"}) + return + } + mode := "online" + if len(downloadIDs) > 0 { + mode = "collection" + } + initialPosition, resumePaused, resumeMessage := float64(0), false, "" + if input.Resume && hasPrevious { + initialIndex, initialPosition, resumePaused, resumeMessage = playbackHistoryResume(previous, tasks, initialIndex) + } + dramaID, source, _ := playbackHistoryIdentity(tasks[0].DramaID) + writeJSON(writer, http.StatusOK, map[string]any{"session": id, "dramaId": dramaID, "source": source, "title": title, "releaseStatus": dramaReleaseStatus(drama), "vip": drama.VIP, "episodes": episodes, "mimeType": playbackMIME, "mode": mode, "initialIndex": initialIndex, "initialPosition": initialPosition, "resumePaused": resumePaused, "resumeMessage": resumeMessage}) +} + +func (app *UIApp) touchPlaybackLocked(session *playbackSession) { + session.expires = time.Now().Add(playbackIdleTimeout) + if session.timer != nil { + session.timer.Reset(playbackIdleTimeout) + } +} + +func (app *UIApp) expirePlayback(id string) { + app.playbackMu.Lock() + session := app.playbacks[id] + if session != nil && time.Now().Before(session.expires) { + session.timer.Reset(time.Until(session.expires)) + app.playbackMu.Unlock() + return + } + if session != nil { + delete(app.playbacks, id) + } + app.playbackMu.Unlock() + if session != nil && session.cancel != nil { + session.cancel() + } + if session != nil && session.prefetch != nil { + session.prefetch.cancel() + } + if session != nil { + session.viewer.release() + } +} + +func (app *UIApp) closePlayback(id string) { + app.playbackMu.Lock() + session := app.playbacks[id] + if session != nil { + delete(app.playbacks, id) + session.timer.Stop() + } + app.playbackMu.Unlock() + if session != nil && session.cancel != nil { + session.cancel() + } + if session != nil && session.prefetch != nil { + session.prefetch.cancel() + } + if session != nil { + session.viewer.release() + } +} + +func (app *UIApp) closePlaybacks() { + app.playbackMu.Lock() + sessions := app.playbacks + app.playbacks = nil + for _, session := range sessions { + session.timer.Stop() + } + app.playbackMu.Unlock() + for _, session := range sessions { + session.viewer.release() + if session.cancel != nil { + session.cancel() + } + if session.prefetch != nil { + session.prefetch.cancel() + } + } +} + +func (app *UIApp) playbackStatus(id string, touch bool) (playbackView, bool) { + app.playbackMu.Lock() + defer app.playbackMu.Unlock() + session := app.playbacks[id] + if session == nil { + return playbackView{}, false + } + if touch { + app.touchPlaybackLocked(session) + } + view := playbackView{State: session.state, Error: session.error, Run: session.run, Duration: session.duration} + if session.native != nil { + state, err := session.native.state() + view.State = state + if err != nil { + view.Error = app.redactError(err) + } + } + if session.media != nil { + plan := session.media.plan + view.Plan = &plan + view.MediaBytes = session.media.bytes.Load() + view.MediaFailure = session.media.failed.Load() || session.media.proxy != nil && session.media.proxy.Err() != nil + } + if session.prefetch != nil { + view.Prefetch = session.prefetch.view() + } + return view, true +} + +func (app *UIApp) handlePlaybackControl(writer http.ResponseWriter, request *http.Request) { + var input struct { + Session string `json:"session"` + Action string `json:"action"` + Progress *playbackHistoryProgress `json:"progress,omitempty"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + if !app.requirePlaybackOwner(writer, request, input.Session) { + return + } + if input.Action == "close" { + var progressError string + if input.Progress != nil { + if _, _, err := app.recordPlaybackProgress(contextViewer(request.Context()), input.Session, *input.Progress); err != nil { + progressError = publicError(err).Error() + } + } + app.closePlayback(input.Session) + writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "historyError": progressError}) + return + } + if input.Action != "heartbeat" { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "不支持此播放操作"}) + return + } + if state, ok := app.playbackStatus(input.Session, true); ok { + writeJSON(writer, http.StatusOK, state) + } else { + writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期,请重新打开本剧"}) + } +} + +func (app *UIApp) handlePlaybackStatus(writer http.ResponseWriter, request *http.Request) { + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + if !app.requirePlaybackOwner(writer, request, request.URL.Query().Get("session")) { + return + } + if state, ok := app.playbackStatus(request.URL.Query().Get("session"), false); ok { + writeJSON(writer, http.StatusOK, state) + } else { + writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期,请重新打开本剧"}) + } +} + +func (app *UIApp) handlePlaybackStream(writer http.ResponseWriter, request *http.Request) { + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + query := request.URL.Query() + index, indexErr := strconv.Atoi(query.Get("episode")) + offset, offsetErr := strconv.ParseFloat(firstNonEmpty(query.Get("start"), "0"), 64) + if indexErr != nil || index < 1 || offsetErr != nil || math.IsNaN(offset) || math.IsInf(offset, 0) || offset < 0 || offset > 24*60*60 { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "集数或播放位置无效"}) + return + } + quality, qualityErr := parsePlaybackQuality(query.Get("quality")) + version, versionErr := strconv.ParseUint(firstNonEmpty(query.Get("version"), "0"), 10, 64) + if qualityErr != nil || versionErr != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "清晰度或播放请求编号无效"}) + return + } + ctx := context.WithValue(request.Context(), playbackRemuxKey{}, query.Get("remux") == "1") + run, status, err := app.beginPlayback(ctx, query.Get("session"), index, offset, quality, version, false) + if err != nil { + writeJSON(writer, status, map[string]string{"error": err.Error()}) + return + } + defer run.stop() + writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: run} + startupTimer := time.AfterFunc(90*time.Second, run.cancel) + defer startupTimer.Stop() + ready := func(duration float64) { + startupTimer.Stop() + app.playbackRunReady(run, duration) + } + used, err := app.servePrefetchedPlayback(run.ctx, writer, run.cache, run.run, ready) + if !used { + err = app.streamPlayback(run.ctx, run.cancel, writer, run.task, run.downloadID, offset, run.run, ready) + } + app.finishPlaybackRun(run, err, request.Context().Err() != nil) +} diff --git a/internal/app/ui_playback_collection.go b/internal/app/ui_playback_collection.go new file mode 100644 index 0000000..76775f9 --- /dev/null +++ b/internal/app/ui_playback_collection.go @@ -0,0 +1,182 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func isPlayableDownloadTask(task *UITask) bool { + return task != nil && !task.RemoveRequested && !isChapterPlaceholderTask(task) && + (hasCollectionPlaybackMedia(task.Source) || task.Status == uiStatusSuccess && firstNonEmpty(task.Path, task.Source.OutPath) != "") +} + +func hasCollectionPlaybackMedia(task Task) bool { + if task.Chapter.VideoURL != "" || task.Chapter.PageURL != "" { + return true + } + source, _, valid := splitProviderDramaID(task.DramaID) + return valid && (source == sourceHuangdou || source == sourceHuangguoAI || source == sourceHuangguoVideo) +} + +func (app *UIApp) collectionPlaybackTasksLocked(selectedID string) (string, []Task, []string, int, error) { + selected := app.tasks[selectedID] + if !isPlayableDownloadTask(selected) { + return "", nil, nil, 0, errors.New("此分集不存在或尚未完成章节解析,请更新合集后再播放") + } + var candidates []*UITask + for _, taskID := range app.taskOrder { + task := app.tasks[taskID] + if isPlayableDownloadTask(task) && task.DramaID == selected.DramaID { + candidates = append(candidates, task) + } + } + sort.SliceStable(candidates, func(left, right int) bool { + return playbackEpisodeNumber(candidates[left]) < playbackEpisodeNumber(candidates[right]) + }) + var tasks []Task + var ids []string + initialIndex := 0 + for index, candidate := range candidates { + task := candidate.Source + if task.Index <= 0 { + task.Index = playbackEpisodeNumber(candidate) + } + tasks = append(tasks, task) + ids = append(ids, candidate.ID) + if candidate.ID == selectedID { + initialIndex = index + 1 + } + } + if initialIndex == 0 { + return "", nil, nil, 0, errors.New("合集任务记录已变化,请刷新页面后再播放") + } + return firstNonEmpty(selected.DramaTitle, selected.Source.DramaTitle, "短剧"), tasks, ids, initialIndex, nil +} + +func playbackEpisodeNumber(task *UITask) int { + if task.Source.Index > 0 { + return task.Source.Index + } + return episodeNumber(firstNonEmpty(task.Episode, task.Source.Chapter.EpisodeString(1))) +} + +func completedPlaybackPath(task *UITask) string { + if task.Status != uiStatusSuccess { + return "" + } + path := firstNonEmpty(task.Path, task.Source.OutPath) + if !strings.EqualFold(filepath.Ext(path), ".mp4") || strings.HasSuffix(strings.ToLower(path), ".part.mp4") { + return "" + } + absolute, err := filepath.Abs(path) + if err != nil { + return "" + } + info, err := os.Stat(absolute) + if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 { + return "" + } + return absolute +} + +func (app *UIApp) playbackCollectionTask(id string) (Task, string, error) { + app.mu.Lock() + defer app.mu.Unlock() + task := app.tasks[id] + if !isPlayableDownloadTask(task) { + return Task{}, "", errors.New("分集任务已被清理或正在移除,请重新打开合集") + } + return task.Source, completedPlaybackPath(task), nil +} + +type collectionPlaybackPreparation struct { + Source string `json:"source"` + DownloadStatus string `json:"downloadStatus"` + Queued bool `json:"queued"` +} + +func (app *UIApp) prepareCollectionDownload(ctx context.Context, id string) (collectionPlaybackPreparation, error) { + app.mu.Lock() + defer app.mu.Unlock() + task := app.tasks[id] + if !isPlayableDownloadTask(task) { + return collectionPlaybackPreparation{}, errors.New("分集任务已被清理或正在移除,请重新打开合集") + } + if err := ctx.Err(); err != nil { + return collectionPlaybackPreparation{}, err + } + if completedPlaybackPath(task) != "" { + return collectionPlaybackPreparation{Source: "local", DownloadStatus: task.Status}, nil + } + if task.CancelRequested || task.PauseRequested { + return collectionPlaybackPreparation{}, errors.New("此分集的下载正在停止,请稍后重新播放") + } + if task.Status == uiStatusRunning || task.Status == uiStatusQueued { + return collectionPlaybackPreparation{Source: "online", DownloadStatus: task.Status}, nil + } + if task.Source.OutPath == "" || !hasCollectionPlaybackMedia(task.Source) { + return collectionPlaybackPreparation{}, errors.New("本地分集不可用,且任务缺少下载地址,请更新该合集") + } + previous := *task + task.Status = uiStatusQueued + task.Phase = "queued" + task.Progress = 0 + task.Error = "" + task.CancelRequested = false + task.PauseRequested = false + task.DownloadedBytes = 0 + task.ElapsedSeconds = 0 + task.MediaElapsedSeconds = 0 + task.SpeedBytesPerSecond = 0 + task.RemainingSeconds = 0 + task.UpdatedAt = time.Now() + if err := app.saveStateLocked(); err != nil { + *task = previous + return collectionPlaybackPreparation{}, fmt.Errorf("无法保存自动下载任务:%w", err) + } + app.cond.Broadcast() + return collectionPlaybackPreparation{Source: "online", DownloadStatus: uiStatusQueued, Queued: true}, nil +} + +func (app *UIApp) handlePlaybackPrepare(writer http.ResponseWriter, request *http.Request) { + var input struct { + Session string `json:"session"` + Episode int `json:"episode"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + app.playbackMu.Lock() + session := app.playbacks[input.Session] + if !viewerOwnsPlayback(request.Context(), session) { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期,请重新打开合集"}) + return + } + if input.Episode < 1 || input.Episode > len(session.downloadIDs) { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择下载合集中的有效分集"}) + return + } + id := session.downloadIDs[input.Episode-1] + app.touchPlaybackLocked(session) + app.playbackMu.Unlock() + result, err := app.prepareCollectionDownload(request.Context(), id) + if err != nil { + writeJSON(writer, http.StatusConflict, map[string]string{"error": app.redactError(err)}) + return + } + app.playbackMu.Lock() + if app.playbacks[input.Session] == session { + session.prepared[input.Episode] = true + } + app.playbackMu.Unlock() + writeJSON(writer, http.StatusOK, result) +} diff --git a/internal/app/ui_playback_danmaku.go b/internal/app/ui_playback_danmaku.go new file mode 100644 index 0000000..f7f791e --- /dev/null +++ b/internal/app/ui_playback_danmaku.go @@ -0,0 +1,205 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + "unicode" +) + +const danmakuWindowMS = 30_000 +const danmakuMaxDurationMS = 24 * 60 * 60 * 1000 + +type hongguoDanmakuItem struct { + ID string `json:"id"` + Text string `json:"text"` + TimeMS int64 `json:"timeMs"` +} + +type hongguoDanmakuPage struct { + Items []hongguoDanmakuItem `json:"items"` + StartMS int64 `json:"startMs"` + NextMS int64 `json:"nextMs"` + Total int64 `json:"total"` +} + +type hongguoDanmakuCacheEntry struct { + page hongguoDanmakuPage + err error + expires time.Time +} + +type hongguoDanmakuCall struct { + done chan struct{} + entry hongguoDanmakuCacheEntry +} + +func hongguoPlaybackIDs(task Task) (string, string, bool) { + source, seriesID, valid := splitProviderDramaID(task.DramaID) + if !valid || source != sourceHongguo || task.Chapter.Source != sourceHongguo || !strings.HasPrefix(task.Chapter.VideoURL, "hongguo-cenc://") { + return "", "", false + } + videoID := strings.TrimPrefix(task.Chapter.VideoURL, "hongguo-cenc://") + return seriesID, videoID, hongguoNumericID.MatchString(seriesID) && hongguoNumericID.MatchString(videoID) +} + +func (app *UIApp) handlePlaybackDanmaku(writer http.ResponseWriter, request *http.Request) { + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + query := request.URL.Query() + index, indexErr := strconv.Atoi(query.Get("episode")) + start, startErr := strconv.ParseInt(query.Get("start"), 10, 64) + duration, durationErr := strconv.ParseInt(query.Get("duration"), 10, 64) + if indexErr != nil || index < 1 || startErr != nil || durationErr != nil || start < 0 || duration <= 0 || duration > danmakuMaxDurationMS || start >= duration { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "弹幕分集或时间范围无效"}) + return + } + app.playbackMu.Lock() + session := app.playbacks[query.Get("session")] + if !viewerOwnsPlayback(request.Context(), session) { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期"}) + return + } + if index > len(session.tasks) { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "分集不存在"}) + return + } + seriesID, videoID, supported := hongguoPlaybackIDs(session.tasks[index-1]) + app.playbackMu.Unlock() + if !supported { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "本集暂不支持弹幕"}) + return + } + + ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second) + defer cancel() + page, err := app.downloader.hongguoDanmaku(ctx, seriesID, videoID, start, duration) + if err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "弹幕暂不可用,可稍后重新开启弹幕重试"}) + return + } + writeJSON(writer, http.StatusOK, page) +} + +func (d *Downloader) hongguoDanmaku(ctx context.Context, seriesID, videoID string, start, duration int64) (hongguoDanmakuPage, error) { + if !hongguoNumericID.MatchString(seriesID) || !hongguoNumericID.MatchString(videoID) || start < 0 || duration <= start || duration > danmakuMaxDurationMS { + return hongguoDanmakuPage{}, errors.New("弹幕请求参数无效") + } + key := fmt.Sprintf("%s:%s:%d:%d", seriesID, videoID, start, duration) + client := d.hongguoClient() + client.mu.Lock() + if entry, found := client.danmaku[key]; found && time.Now().Before(entry.expires) { + client.mu.Unlock() + return cloneDanmakuPage(entry.page), entry.err + } + if pending := client.danmakuPending[key]; pending != nil { + client.mu.Unlock() + select { + case <-ctx.Done(): + return hongguoDanmakuPage{}, ctx.Err() + case <-pending.done: + if ctx.Err() == nil && (errors.Is(pending.entry.err, context.Canceled) || errors.Is(pending.entry.err, context.DeadlineExceeded)) { + return d.hongguoDanmaku(ctx, seriesID, videoID, start, duration) + } + return cloneDanmakuPage(pending.entry.page), pending.entry.err + } + } + if client.danmakuPending == nil { + client.danmakuPending = map[string]*hongguoDanmakuCall{} + } + if len(client.danmakuPending) >= 16 { + client.mu.Unlock() + return hongguoDanmakuPage{}, errors.New("弹幕请求繁忙") + } + pending := &hongguoDanmakuCall{done: make(chan struct{})} + client.danmakuPending[key] = pending + client.mu.Unlock() + ctx = context.WithValue(ctx, backgroundCatalogKey{}, true) + ctx = context.WithValue(ctx, hongguoCommentKey{}, true) + body := map[string]any{ + "comment_source": 601, "server_channel": 1000, "group_id": videoID, "group_type": 30, + "comment_type": 20, "sort": 1, "count": 90, "cursor": "", "aid": 8662, "compliance_status": 0, + "business_param": map[string]any{"book_id": seriesID, "start_offset_time": start, "playlet_item_duration": duration, "need_danmaku_guide_type": []int{1, 3, 4, 2}}, + } + result, err := d.hongguoAppRequest(ctx, http.MethodPost, "/novel/commentapi/comment/list/"+videoID+"/v1/", nil, body) + var page hongguoDanmakuPage + if err == nil { + page, err = parseHongguoDanmaku(result, videoID, start, duration) + } + entry := hongguoDanmakuCacheEntry{page: page, err: err, expires: time.Now().Add(5 * time.Minute)} + if err != nil { + entry.expires = time.Now().Add(15 * time.Second) + } + client.mu.Lock() + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + if client.danmaku == nil || len(client.danmaku) >= 128 { + client.danmaku = map[string]hongguoDanmakuCacheEntry{} + } + client.danmaku[key] = entry + } + pending.entry = entry + delete(client.danmakuPending, key) + close(pending.done) + client.mu.Unlock() + return cloneDanmakuPage(page), err +} + +func cloneDanmakuPage(page hongguoDanmakuPage) hongguoDanmakuPage { + page.Items = append([]hongguoDanmakuItem{}, page.Items...) + return page +} + +func parseHongguoDanmaku(result map[string]any, videoID string, start, duration int64) (hongguoDanmakuPage, error) { + data := nestedMap(result, "data") + rows, valid := data["data_list"].([]any) + next, err := strconv.ParseInt(mapString(nestedMap(data, "extra"), "next_query_danmaku_list_time"), 10, 64) + if !valid || err != nil || next <= start || next > danmakuMaxDurationMS { + return hongguoDanmakuPage{}, errors.New("红果弹幕时间段格式无效") + } + page := hongguoDanmakuPage{Items: []hongguoDanmakuItem{}, StartMS: start, NextMS: min(next, duration)} + var cursor struct { + Total int64 `json:"danmaku_count"` + } + if raw := mapString(nestedMap(data, "common_list_info"), "cursor"); len(raw) <= 8192 && json.Unmarshal([]byte(raw), &cursor) == nil && cursor.Total >= 0 { + page.Total = cursor.Total + } + seen := map[string]bool{} + for _, raw := range rows { + row, _ := raw.(map[string]any) + comment := nestedMap(row, "comment") + common := nestedMap(comment, "common") + if mapString(common, "group_id") != videoID || mapString(common, "status") != "1" { + continue + } + position, err := strconv.ParseInt(mapString(nestedMap(comment, "expand"), "offset_time"), 10, 64) + text := strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { + return ' ' + } + return r + }, mapString(nestedMap(common, "content"), "text"))) + id := mapString(comment, "comment_id") + if err != nil || position < start || position >= page.NextMS || text == "" || id == "" || len(id) > 120 || seen[id] { + continue + } + if runes := []rune(text); len(runes) > 180 { + text = string(runes[:180]) + "…" + } + seen[id] = true + page.Items = append(page.Items, hongguoDanmakuItem{ID: id, Text: text, TimeMS: position}) + if len(page.Items) == 90 { + break + } + } + sort.SliceStable(page.Items, func(i, j int) bool { return page.Items[i].TimeMS < page.Items[j].TimeMS }) + return page, nil +} diff --git a/internal/app/ui_playback_danmaku_live_test.go b/internal/app/ui_playback_danmaku_live_test.go new file mode 100644 index 0000000..7441ffc --- /dev/null +++ b/internal/app/ui_playback_danmaku_live_test.go @@ -0,0 +1,63 @@ +package app + +import ( + "context" + "errors" + "net/http" + "os" + "strings" + "testing" + "time" +) + +func TestLiveHongguoPlaybackDanmaku(t *testing.T) { + if os.Getenv("JUKU_LIVE_DANMAKU") != "1" { + t.Skip("set JUKU_LIVE_DANMAKU=1 for explicit text-only danmaku verification") + } + cfg := defaultConfig() + cfg.dataDir, cfg.OutputDir, cfg.Retries = t.TempDir(), t.TempDir(), 1 + d := NewDownloader(cfg) + transport := d.client.Transport + const series = "7615465407347952664" + const video = "7615470191303986238" + d.client.CheckRedirect = func(*http.Request, []*http.Request) error { return errors.New("test blocks redirects") } + d.client.Transport = rankingTransport(func(request *http.Request) (*http.Response, error) { + host := request.URL.Hostname() + if request.Method != http.MethodPost || request.URL.Scheme != "https" || + (host != "api5-normal-sinfonlinea.fqnovel.com" && host != "api5-normal-sinfonlineb.fqnovel.com") || + request.URL.Path != "/novel/commentapi/comment/list/"+video+"/v1/" { + return nil, errors.New("test blocks non-danmaku requests") + } + response, err := transport.RoundTrip(request) + if err == nil && !strings.Contains(strings.ToLower(response.Header.Get("Content-Type")), "json") { + response.Body.Close() + return nil, errors.New("test blocks non-JSON responses") + } + return response, err + }) + ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) + defer cancel() + count := 0 + for _, start := range []int64{0, 30000, 150000} { + page, err := d.hongguoDanmaku(ctx, series, video, start, 160000) + if err != nil { + t.Fatalf("window %d: %v", start, err) + } + if page.StartMS != start || page.NextMS <= start || page.NextMS > 160000 { + t.Fatalf("invalid window: %+v", page) + } + for _, item := range page.Items { + if item.Text == "" || item.ID == "" || item.TimeMS < 0 || item.TimeMS >= 160000 { + t.Fatal("invalid item") + } + if item.TimeMS < start || item.TimeMS >= page.NextMS { + t.Fatalf("source returned an item outside the requested window: %d, want %d–%d", item.TimeMS, start, page.NextMS) + } + } + count += len(page.Items) + t.Logf("window %d → %d ms: %d text items, source episode count %d", start, page.NextMS, len(page.Items), page.Total) + } + if count == 0 { + t.Fatal("known sample returned no real danmaku") + } +} diff --git a/internal/app/ui_playback_danmaku_test.go b/internal/app/ui_playback_danmaku_test.go new file mode 100644 index 0000000..7fdedb3 --- /dev/null +++ b/internal/app/ui_playback_danmaku_test.go @@ -0,0 +1,282 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const danmakuTestSeries = "7615465407347952664" +const danmakuTestVideo = "7615470191303986238" + +func danmakuTestRow(video, id, text string, offset any) map[string]any { + return map[string]any{"comment": map[string]any{ + "comment_id": id, "common": map[string]any{"group_id": video, "status": 1, "content": map[string]any{"text": text}}, + "expand": map[string]any{"offset_time": offset}, + }} +} + +func danmakuTestResult(next int, rows []any) map[string]any { + return map[string]any{"code": 0, "data": map[string]any{ + "data_list": rows, "extra": map[string]any{"next_query_danmaku_list_time": next}, + "common_list_info": map[string]any{"cursor": `{"danmaku_count":250}`, "has_more": false, "total": len(rows)}, + }} +} + +func danmakuTestJSON(next int, rows ...any) string { + if rows == nil { + rows = []any{} + } + raw, _ := json.Marshal(danmakuTestResult(next, rows)) + return string(raw) +} + +func TestHongguoDanmakuPreservesSourceTimeline(t *testing.T) { + row := func(id string, offset any) map[string]any { + return danmakuTestRow(danmakuTestVideo, id, "文字", offset) + } + rows := []any{row("later", 15000), danmakuTestRow(danmakuTestVideo, "zero", "文字\n后一行", 0), row("zero", 1), + row("missing", nil), row("negative", -1), row("past-end", 160000), row("decimal", 1.2), + danmakuTestRow("999999999", "wrong-episode", "串集", 0), nil, row("hidden", 5)} + nestedMap(rows[len(rows)-1].(map[string]any), "comment", "common")["status"] = 2 + page, err := parseHongguoDanmaku(danmakuTestResult(30000, rows), danmakuTestVideo, 0, 160000) + if err != nil || len(page.Items) != 2 || page.Items[0].ID != "zero" || page.Items[0].TimeMS != 0 || page.Items[1].TimeMS != 15000 { + t.Fatalf("source timeline or episode identity lost: %+v %v", page, err) + } + if page.Items[0].Text != "文字 后一行" || page.Total != 250 || page.NextMS != 30000 { + t.Fatalf("text or source count was changed: %+v", page) + } + + empty, err := parseHongguoDanmaku(danmakuTestResult(60000, []any{}), danmakuTestVideo, 30000, 160000) + if err != nil || empty.Items == nil || len(empty.Items) != 0 || empty.NextMS != 60000 || empty.Total != 250 { + t.Fatalf("empty window lost continuation: %+v %v", empty, err) + } + last, err := parseHongguoDanmaku(danmakuTestResult(180000, []any{}), danmakuTestVideo, 150000, 160000) + if err != nil || last.NextMS != 160000 { + t.Fatal("last window must end at episode duration") + } +} + +func TestHongguoDanmakuRejectsMalformedWindowsAndBoundsText(t *testing.T) { + for _, result := range []map[string]any{nil, {"data": map[string]any{"data_list": []any{}}}, danmakuTestResult(0, []any{}), danmakuTestResult(danmakuMaxDurationMS+1, []any{})} { + if _, err := parseHongguoDanmaku(result, danmakuTestVideo, 0, 160000); err == nil { + t.Fatal("malformed response became a successful empty page") + } + } + rows := []any{} + for i := 0; i < 110; i++ { + rows = append(rows, danmakuTestRow(danmakuTestVideo, strconv.Itoa(i), strings.Repeat("长", 500), i)) + } + page, err := parseHongguoDanmaku(danmakuTestResult(30000, rows), danmakuTestVideo, 0, 160000) + if err != nil || len(page.Items) != 90 || len([]rune(page.Items[0].Text)) != 181 { + t.Fatal("unbounded danmaku") + } +} + +func TestHongguoDanmakuCacheAndConcurrentRequests(t *testing.T) { + var calls atomic.Int32 + started, release := make(chan struct{}), make(chan struct{}) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + count := calls.Add(1) + if request.URL.Path == "/novel/player/video_detail/v1/" { + if request.Header.Get("X-Argus") != "" || request.Header.Get("X-Ladon") != "" || request.Header.Get("Comment-Source") != "" { + t.Error("comment signatures leaked into the regular player request") + } + return rankingHTTPResponse(request, 200, `{"code":0}`), nil + } + if request.URL.Path != "/novel/commentapi/comment/list/"+danmakuTestVideo+"/v1/" || request.Method != http.MethodPost { + t.Error("wrong danmaku path") + } + if request.Context().Value(backgroundCatalogKey{}) != true || request.Header.Get("Comment-Source") != "601" || request.Header.Get("Server-Channel") != "1000" || request.Header.Get("Cookie") != "" { + t.Error("danmaku must be anonymous and low priority") + } + for _, name := range []string{"X-Argus", "X-Ladon", "X-Gorgon", "X-Khronos"} { + if request.Header.Get(name) == "" { + t.Errorf("missing comment signature: %s", name) + } + } + var body struct { + GroupID string `json:"group_id"` + Type int `json:"comment_type"` + Business struct { + BookID string `json:"book_id"` + Start int `json:"start_offset_time"` + Duration int `json:"playlet_item_duration"` + } `json:"business_param"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.GroupID != danmakuTestVideo || body.Type != 20 || body.Business.BookID != danmakuTestSeries || body.Business.Duration != 160000 { + t.Error("wrong source episode request") + } + if count == 1 { + close(started) + <-release + } + return rankingHTTPResponse(request, 200, danmakuTestJSON(body.Business.Start+30000, danmakuTestRow(danmakuTestVideo, "first", "原弹幕", body.Business.Start))), nil + }) + results := make(chan error, 8) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + page, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000) + if err == nil && (len(page.Items) != 1 || page.Items[0].Text != "原弹幕") { + err = errors.New("wrong shared page") + } + if len(page.Items) > 0 { + page.Items[0].Text = "caller mutation" + } + results <- err + }() + } + <-started + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := d.hongguoDanmaku(canceled, danmakuTestSeries, danmakuTestVideo, 0, 160000); !errors.Is(err, context.Canceled) { + t.Error("a waiting reader could not cancel") + } + close(release) + wg.Wait() + close(results) + for err := range results { + if err != nil { + t.Error(err) + } + } + if calls.Load() != 1 { + t.Fatal("concurrent identical windows were not coalesced") + } + page, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000) + if err != nil || page.Items[0].Text != "原弹幕" || calls.Load() != 1 { + t.Fatal("cache was not isolated from callers") + } + if _, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 30000, 160000); err != nil || calls.Load() != 2 { + t.Fatal("next window reused the wrong cached page") + } + if _, err := d.hongguoAppRequest(context.Background(), http.MethodPost, "/novel/player/video_detail/v1/", nil, map[string]string{"series_id": danmakuTestSeries}); err != nil { + t.Fatal(err) + } +} + +func TestHongguoDanmakuCanceledLoadCanBeRetried(t *testing.T) { + var calls atomic.Int32 + started := make(chan struct{}) + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + if calls.Add(1) == 1 { + close(started) + <-request.Context().Done() + return nil, request.Context().Err() + } + return rankingHTTPResponse(request, 200, danmakuTestJSON(30000)), nil + }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { _, err := d.hongguoDanmaku(ctx, danmakuTestSeries, danmakuTestVideo, 0, 160000); done <- err }() + <-started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if _, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000); err != nil || calls.Load() != 2 { + t.Fatal("canceled optional load poisoned the cache") + } +} + +func TestPlaybackDanmakuValidationAndStreamIsolation(t *testing.T) { + started := make(chan struct{}) + var calls, streamCanceled atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + close(started) + <-request.Context().Done() + return nil, request.Context().Err() + }) + task := Task{DramaID: "hongguo:" + danmakuTestSeries, Chapter: Chapter{Source: sourceHongguo, VideoURL: "hongguo-cenc://" + danmakuTestVideo}} + timer := time.NewTimer(time.Hour) + defer timer.Stop() + session := &playbackSession{id: "current", tasks: []Task{task}, state: "streaming", run: 7, duration: 160, timer: timer, cancel: func() { streamCanceled.Add(1) }} + app := &UIApp{cfg: d.cfg, downloader: d, playbacks: map[string]*playbackSession{"current": session}} + session.viewer = fixtureViewer(app) + query := "?session=current&episode=1&start=0&duration=160000" + for _, tc := range []struct { + query, method, origin string + status int + }{ + {query, http.MethodPost, "", 405}, {query, http.MethodGet, "https://other.example", 403}, + {strings.Replace(query, "episode=1", "episode=0", 1), http.MethodGet, "", 400}, + {strings.Replace(query, "episode=1", "episode=2", 1), http.MethodGet, "", 400}, + {strings.Replace(query, "start=0", "start=-1", 1), http.MethodGet, "", 400}, + {strings.Replace(query, "duration=160000", "duration=9999999999", 1), http.MethodGet, "", 400}, + {strings.Replace(query, "current", "expired", 1), http.MethodGet, "", 410}, + } { + request := httptest.NewRequest(tc.method, "http://localhost/api/ui/playback/danmaku"+tc.query, nil) + request.Header.Set("Origin", tc.origin) + writer := httptest.NewRecorder() + app.handlePlaybackDanmaku(writer, viewerFixtureRequest(app, request)) + if writer.Code != tc.status { + t.Errorf("%s: got %d, want %d", tc.query, writer.Code, tc.status) + } + } + for _, video := range []string{"https://example.invalid/video.mp4", "hongguo-cenc://invalid", "hongguo-cenc://123?url=https://example.invalid"} { + bad := task + bad.Chapter.VideoURL = video + if _, _, ok := hongguoPlaybackIDs(bad); ok { + t.Fatal("accepted an arbitrary source URL") + } + } + bad := task + bad.Chapter.Source = "other" + app.playbacks["unsupported"] = &playbackSession{viewer: session.viewer, tasks: []Task{bad}} + writer := httptest.NewRecorder() + app.handlePlaybackDanmaku(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/danmaku"+strings.Replace(query, "current", "unsupported", 1), nil))) + if writer.Code != 404 || calls.Load() != 0 { + t.Fatal("invalid requests reached the source") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + defer close(done) + app.handlePlaybackDanmaku(httptest.NewRecorder(), viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/danmaku"+query, nil).WithContext(ctx))) + }() + <-started + view, found := app.playbackStatus("current", false) + if !found || view.State != "streaming" || view.Run != 7 || view.Duration != 160 || streamCanceled.Load() != 0 { + t.Fatal("waiting for danmaku changed or blocked playback") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("optional request did not cancel promptly") + } + if streamCanceled.Load() != 0 || session.state != "streaming" || session.error != "" { + t.Fatal("danmaku cancellation affected the stream") + } +} + +func TestHongguoDanmakuFailureBackoff(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + return nil, fmt.Errorf("optional service offline") + }) + for i := 0; i < 3; i++ { + if _, err := d.hongguoDanmaku(context.Background(), danmakuTestSeries, danmakuTestVideo, 0, 160000); err == nil { + t.Fatal("failure masked as empty success") + } + } + if calls.Load() != 1 { + t.Fatal("an unavailable optional service was hammered") + } +} diff --git a/internal/app/ui_playback_history.go b/internal/app/ui_playback_history.go new file mode 100644 index 0000000..93da972 --- /dev/null +++ b/internal/app/ui_playback_history.go @@ -0,0 +1,195 @@ +package app + +import ( + "errors" + "math" + "net/http" + "time" +) + +type playbackHistoryProgress struct { + Run uint64 `json:"run"` + Sequence uint64 `json:"sequence"` + Episode int `json:"episode"` + Position float64 `json:"position"` + Duration float64 `json:"duration"` + Completed bool `json:"completed"` +} + +type playbackHistoryRun struct { + episode int + duration float64 +} + +func (app *UIApp) handlePlaybackHistory(writer http.ResponseWriter, request *http.Request) { + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + viewer := requestViewer(writer, request) + if viewer == nil { + return + } + entries, err := viewer.playbackHistory().list() + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法读取观看记录:" + publicError(err).Error()}) + return + } + visible := make([]playbackHistoryEntry, 0, len(entries)) + for _, entry := range entries { + if dramaAllowed(request.Context(), entry.DramaID, entry.Source) { + visible = append(visible, entry) + } + } + writeJSON(writer, http.StatusOK, map[string]any{"data": visible, "limit": playbackHistoryLimit}) +} + +func (app *UIApp) handlePlaybackHistoryRemove(writer http.ResponseWriter, request *http.Request) { + var input struct { + DramaID string `json:"dramaId"` + All bool `json:"all"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + id, _, valid := playbackHistoryIdentity(input.DramaID) + if input.All && input.DramaID != "" || !input.All && !valid { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择要删除的观看记录"}) + return + } + viewer := requestViewer(writer, request) + if viewer == nil { + return + } + if !input.All && !app.requireDramaSources(writer, request, []string{id}) { + return + } + if err := viewer.playbackHistory().removeForSources(request.Context(), id, input.All); err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "删除观看记录未能保存:" + publicError(err).Error()}) + return + } + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} + +func (app *UIApp) handlePlaybackProgress(writer http.ResponseWriter, request *http.Request) { + var input struct { + Session string `json:"session"` + Progress playbackHistoryProgress `json:"progress"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + if !app.requirePlaybackOwner(writer, request, input.Session) { + return + } + entry, saved, err := app.recordPlaybackProgress(contextViewer(request.Context()), input.Session, input.Progress) + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": publicError(err).Error()}) + return + } + writeJSON(writer, http.StatusOK, map[string]any{"saved": saved, "entry": entry}) +} + +func (app *UIApp) recordPlaybackProgress(viewer *viewerRecords, id string, progress playbackHistoryProgress) (playbackHistoryEntry, bool, error) { + if progress.Run == 0 || progress.Sequence == 0 || !validPlaybackHistoryTime(progress.Position) || !validPlaybackHistoryTime(progress.Duration) { + return playbackHistoryEntry{}, false, errors.New("观看进度无效") + } + if viewer == nil { + return playbackHistoryEntry{}, false, errors.New("浏览器身份无效") + } + store := viewer.playbackHistory() + app.playbackMu.Lock() + session := app.playbacks[id] + if session == nil || progress.Episode < 1 || progress.Episode > len(session.tasks) { + app.playbackMu.Unlock() + return playbackHistoryEntry{}, false, nil + } + if session.viewer != viewer { + app.playbackMu.Unlock() + return playbackHistoryEntry{}, false, errors.New("播放会话已过期,请重新打开本剧") + } + run, known := session.historyRuns[progress.Run] + if !known || run.episode != progress.Episode || progress.Sequence <= session.historySequence { + app.playbackMu.Unlock() + return playbackHistoryEntry{}, false, nil + } + task := session.tasks[progress.Episode-1] + dramaID, source, valid := playbackHistoryIdentity(task.DramaID) + if !valid { + app.playbackMu.Unlock() + return playbackHistoryEntry{}, false, errors.New("观看记录的站源或剧集 ID 无效") + } + duration := progress.Duration + if run.duration > 0 && validPlaybackHistoryTime(run.duration) { + duration = run.duration + } + position := progress.Position + if duration > 0 { + if position > duration+2 { + app.playbackMu.Unlock() + return playbackHistoryEntry{}, false, errors.New("观看位置超过分集时长") + } + position = math.Min(position, duration) + } + completed := progress.Completed && duration > 0 && position >= math.Max(0, duration-2) + index := task.Index + if index < 1 { + index = progress.Episode + } + total := task.Total + if total < index { + total = index + } + if total < len(session.tasks) { + total = len(session.tasks) + } + entry := playbackHistoryEntry{DramaID: dramaID, Source: source, Title: task.DramaTitle, ChapterID: task.Chapter.ID, Episode: task.Chapter.EpisodeString(index), Index: index, Total: total, Position: math.Round(position*1000) / 1000, Duration: duration, Completed: completed, Mode: "online", WatchedAt: time.Now(), sessionID: id, sessionOpened: session.openedAt} + if len(session.downloadIDs) >= progress.Episode { + entry.Mode, entry.TaskID = "collection", session.downloadIDs[progress.Episode-1] + } + saved, err := store.record(entry, session.openedAt) + if err == nil { + session.historySequence = progress.Sequence + app.touchPlaybackLocked(session) + } + app.playbackMu.Unlock() + if err == nil && saved { + err = store.flush() + } + return entry, saved && err == nil, err +} + +func playbackHistoryResume(entry playbackHistoryEntry, tasks []Task, fallback int) (int, float64, bool, string) { + matched := -1 + for index, task := range tasks { + if entry.ChapterID != "" && entry.ChapterID == task.Chapter.ID { + matched = index + break + } + } + if matched < 0 { + for index, task := range tasks { + if task.Chapter.EpisodeString(task.Index) == entry.Episode { + matched = index + break + } + } + } + if matched < 0 { + return fallback, 0, false, "当前选集中没有上次观看的分集,请手动选集" + } + if entry.Completed { + if matched+1 < len(tasks) { + return matched + 2, 0, false, "已接续到下一集" + } + message := "已看至最新,可选集重看" + if entry.Index < entry.Total { + message = "已看完此合集当前可播放的分集,可更新合集或从剧库继续观看" + } + return matched + 1, 0, true, message + } + position := entry.Position + if entry.Duration > 0 && position >= entry.Duration { + position = math.Max(0, entry.Duration-1) + } + return matched + 1, position, false, "已恢复上次观看进度" +} diff --git a/internal/app/ui_playback_history_test.go b/internal/app/ui_playback_history_test.go new file mode 100644 index 0000000..9e7a106 --- /dev/null +++ b/internal/app/ui_playback_history_test.go @@ -0,0 +1,401 @@ +package app + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +const historyFixtureDramaID = "hongguo:7000000000000000001" + +func historyFixtureApp(t *testing.T) (*UIApp, *playbackSession) { + t.Helper() + app := &UIApp{cfg: Config{dataDir: t.TempDir()}, playbacks: make(map[string]*playbackSession)} + session := &playbackSession{id: "history-fixture", viewer: fixtureViewer(app), openedAt: time.Now().Add(-time.Minute), run: 1, currentIndex: 1, state: "ended", expires: time.Now().Add(time.Hour), historyRuns: map[uint64]playbackHistoryRun{1: {episode: 1, duration: 90}}} + for index := 1; index <= 3; index++ { + session.tasks = append(session.tasks, Task{DramaID: historyFixtureDramaID, DramaTitle: "本地无图续播测试", Index: index, Total: 3, Chapter: Chapter{ID: fmt.Sprintf("hongguo:7000000000000000001:%d", index), CurrentEpisode: rawEpisode(index)}}) + } + session.timer = time.AfterFunc(time.Hour, func() {}) + session.viewer.retain() + app.playbacks[session.id] = session + t.Cleanup(app.closePlaybacks) + return app, session +} + +func historyProgress(sequence uint64, position float64) playbackHistoryProgress { + return playbackHistoryProgress{Run: 1, Sequence: sequence, Episode: 1, Position: position, Duration: 90} +} + +func historyJSONRequest(t *testing.T, app *UIApp, handler http.HandlerFunc, path string, input any) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "http://localhost"+path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + handler(writer, viewerFixtureRequest(app, request)) + return writer +} + +func TestPlaybackHistoryPersistsAndSharesOnlineCollectionProgress(t *testing.T) { + app, session := historyFixtureApp(t) + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 23.75)); err != nil || !saved { + t.Fatal("initial progress was not saved", saved, err) + } + app.playbackMu.Lock() + session.downloadIDs = []string{"download-one", "download-two", "download-three"} + app.playbackMu.Unlock() + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 41.25)); err != nil || !saved { + t.Fatal("collection progress was not saved", saved, err) + } + restarted := newPlaybackHistoryStore(fixtureViewer(app).directory) + entries, err := restarted.list() + if err != nil || len(entries) != 1 || entries[0].Position != 41.25 || entries[0].Mode != "collection" || entries[0].TaskID != "download-one" || entries[0].DramaID != historyFixtureDramaID { + t.Fatalf("restart lost or duplicated progress: %+v %v", entries, err) + } + info, err := os.Stat(restarted.path) + if err != nil || runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatal("history file permissions", info, err) + } + body, _ := os.ReadFile(restarted.path) + for _, field := range []string{"videoUrl", "cover", "poster", "sessionID", "sessionOpened"} { + if bytes.Contains(body, []byte(field)) { + t.Fatalf("unexpected runtime/media field persisted: %s", field) + } + } +} + +func TestPlaybackHistoryRejectsPrefetchAndOutOfOrderProgress(t *testing.T) { + app, session := historyFixtureApp(t) + preloaded := historyProgress(1, 30) + preloaded.Episode = 2 + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, preloaded); err != nil || saved { + t.Fatal("an unplayed episode became history", saved, err) + } + if _, err := os.Stat(fixtureViewer(app).playbackHistory().path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("prefetch created a history file", err) + } + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 45)); err != nil || !saved { + t.Fatal(saved, err) + } + app.playbackMu.Lock() + session.run, session.currentIndex = 2, 2 + session.historyRuns[2] = playbackHistoryRun{episode: 2, duration: 90} + app.playbackMu.Unlock() + next := historyProgress(3, 12) + next.Run, next.Episode = 2, 2 + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, next); err != nil || !saved { + t.Fatal(saved, err) + } + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 80)); err != nil || saved { + t.Fatal("late previous-episode report overwrote progress", saved, err) + } + entry, _ := fixtureViewer(app).playbackHistory().get(historyFixtureDramaID) + if entry.Episode != "2" || entry.Position != 12 { + t.Fatalf("latest episode was not kept: %+v", entry) + } +} + +func TestPlaybackHistoryCloseSavesBeforeSessionRelease(t *testing.T) { + app, session := historyFixtureApp(t) + writer := historyJSONRequest(t, app, app.handlePlaybackControl, "/api/ui/playback/control", map[string]any{"session": session.id, "action": "close", "progress": historyProgress(1, 63.125)}) + if writer.Code != 200 || strings.Contains(writer.Body.String(), `"historyError":"观看`) { + t.Fatal(writer.Code, writer.Body.String()) + } + if _, exists := app.playbackStatus(session.id, false); exists { + t.Fatal("closing history retained the playback session") + } + entry, found := newPlaybackHistoryStore(fixtureViewer(app).directory).get(historyFixtureDramaID) + if !found || entry.Position != 63.125 { + t.Fatalf("close snapshot was lost: %+v", entry) + } +} + +func TestPlaybackHistoryDeleteAndClearBlockLateAutosaves(t *testing.T) { + for _, all := range []bool{false, true} { + t.Run(fmt.Sprint(all), func(t *testing.T) { + app, session := historyFixtureApp(t) + if _, _, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 20)); err != nil { + t.Fatal(err) + } + input := map[string]any{"dramaId": historyFixtureDramaID} + if all { + input = map[string]any{"all": true} + } + writer := historyJSONRequest(t, app, app.handlePlaybackHistoryRemove, "/api/ui/playback/history/remove", input) + if writer.Code != 200 { + t.Fatal(writer.Body.String()) + } + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 25)); err != nil || saved { + t.Fatal("deleted history was resurrected", saved, err) + } + if entries, _ := newPlaybackHistoryStore(fixtureViewer(app).directory).list(); len(entries) != 0 { + t.Fatal("deleted history was persisted again") + } + app.playbackMu.Lock() + session.openedAt = time.Now().Add(time.Millisecond) + app.playbackMu.Unlock() + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(3, 5)); err != nil || !saved { + t.Fatal("explicit new viewing could not create history", saved, err) + } + }) + } +} + +func TestPlaybackHistoryNewSessionWinsOverLateOldClose(t *testing.T) { + app, old := historyFixtureApp(t) + newer := *old + newer.id, newer.openedAt, newer.timer = "new-viewing", time.Now(), time.AfterFunc(time.Hour, func() {}) + newer.viewer.retain() + app.playbacks[newer.id] = &newer + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), newer.id, historyProgress(1, 8)); err != nil || !saved { + t.Fatal(saved, err) + } + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), old.id, historyProgress(8, 60)); err != nil || saved { + t.Fatal("older window replaced the new viewing", saved, err) + } + entry, _ := fixtureViewer(app).playbackHistory().get(historyFixtureDramaID) + if entry.Position != 8 { + t.Fatal(entry) + } +} + +func TestPlaybackHistoryResumeUsesChapterIdentityAndActualCompletion(t *testing.T) { + _, session := historyFixtureApp(t) + entry := playbackHistoryEntry{ChapterID: session.tasks[1].Chapter.ID, Episode: "2", Index: 2, Total: 3, Position: 42.5, Duration: 90} + index, offset, paused, _ := playbackHistoryResume(entry, session.tasks, 1) + if index != 2 || offset != 42.5 || paused { + t.Fatal("normal resume", index, offset, paused) + } + partial := []Task{session.tasks[1], session.tasks[2]} + index, offset, _, _ = playbackHistoryResume(entry, partial, 1) + if index != 1 || offset != 42.5 { + t.Fatal("partial collection used a global array index", index, offset) + } + entry.Position, entry.Completed = 89, false + index, offset, _, _ = playbackHistoryResume(entry, session.tasks, 1) + if index != 2 || offset != 89 { + t.Fatal("near-end viewing skipped unwatched content", index, offset) + } + entry.Completed = true + index, offset, paused, _ = playbackHistoryResume(entry, session.tasks, 1) + if index != 3 || offset != 0 || paused { + t.Fatal("completed episode did not advance", index, offset, paused) + } + entry.ChapterID, entry.Episode, entry.Index = session.tasks[2].Chapter.ID, "3", 3 + index, offset, paused, message := playbackHistoryResume(entry, session.tasks, 1) + if index != 3 || offset != 0 || !paused || !strings.Contains(message, "已看至最新") { + t.Fatal("completed latest episode replayed automatically", index, offset, paused, message) + } + entry.ChapterID, entry.Episode, entry.Completed = "removed", "99", false + index, offset, _, message = playbackHistoryResume(entry, session.tasks, 1) + if index != 1 || offset != 0 || !strings.Contains(message, "没有上次观看") { + t.Fatal("missing chapter reused an unrelated offset", index, offset, message) + } +} + +func TestPlaybackHistoryStorageDoesNotHoldPlaybackLock(t *testing.T) { + app, session := historyFixtureApp(t) + store := fixtureViewer(app).playbackHistory() + store.writeMu.Lock() + done := make(chan error, 1) + go func() { + _, _, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 10)) + done <- err + }() + deadline := time.After(time.Second) + for { + if _, exists := store.get(historyFixtureDramaID); exists { + break + } + select { + case <-deadline: + store.writeMu.Unlock() + t.Fatal("history did not reach storage") + case <-time.After(time.Millisecond): + } + } + playback := make(chan bool, 1) + go func() { _, exists := app.playbackStatus(session.id, false); playback <- exists }() + select { + case exists := <-playback: + if !exists { + t.Error("playback disappeared") + } + case <-time.After(time.Second): + t.Error("slow history storage blocked playback") + } + store.writeMu.Unlock() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestPlaybackHistoryWriteFailureAndCorruptFilePreservation(t *testing.T) { + app, session := historyFixtureApp(t) + store := fixtureViewer(app).playbackHistory() + originalPath := store.path + store.path = app.cfg.dataDirectory() + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 12)); err == nil || saved { + t.Fatal("storage failure was silently reported as saved", saved, err) + } + if _, exists := app.playbackStatus(session.id, false); !exists { + t.Fatal("history storage failure ended playback") + } + store.path = originalPath + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 12)); err != nil || !saved { + t.Fatal("failed write could not be retried", saved, err) + } + broken := []byte(`{"version":1,"entries":`) + if err := os.WriteFile(originalPath, broken, 0o600); err != nil { + t.Fatal(err) + } + restarted := newPlaybackHistoryStore(fixtureViewer(app).directory) + if _, err := restarted.list(); err == nil { + t.Fatal("corrupt history was reported as an empty success") + } + if _, err := restarted.record(playbackHistoryEntry{DramaID: historyFixtureDramaID}, time.Now()); err == nil { + t.Fatal("corrupt history was overwritten") + } + actual, _ := os.ReadFile(originalPath) + if !bytes.Equal(actual, broken) { + t.Fatal("unreadable existing history changed") + } +} + +func TestPlaybackHistoryRecentLimitAndIsolatedDirectories(t *testing.T) { + directory := t.TempDir() + store := newPlaybackHistoryStore(directory) + opened := time.Now() + for index := 0; index < playbackHistoryLimit+2; index++ { + entry := playbackHistoryEntry{DramaID: fmt.Sprintf("hongguo:%d", int64(7000000000000000000)+int64(index)), Title: "本地测试", Episode: "1", Index: 1, Total: 1, WatchedAt: opened.Add(time.Duration(index) * time.Second)} + if _, err := store.record(entry, opened); err != nil { + t.Fatal(err) + } + } + if err := store.flush(); err != nil { + t.Fatal(err) + } + entries, _ := newPlaybackHistoryStore(directory).list() + if len(entries) != playbackHistoryLimit || !entries[0].WatchedAt.After(entries[len(entries)-1].WatchedAt) { + t.Fatal("recent-history limit/order", len(entries)) + } + other := newPlaybackHistoryStore(filepath.Join(directory, "another-project")) + if entries, _ := other.list(); len(entries) != 0 { + t.Fatal("different data directories shared history") + } +} + +func TestPlaybackHistoryLoadRejectsMismatchedSource(t *testing.T) { + directory := t.TempDir() + entry := playbackHistoryEntry{DramaID: historyFixtureDramaID, Source: "unrelated-provider", Episode: "1", Index: 1, Total: 3, WatchedAt: time.Now()} + body, err := json.Marshal(playbackHistoryFile{Version: 1, Entries: []playbackHistoryEntry{entry}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "playback-history.json"), body, 0o600); err != nil { + t.Fatal(err) + } + entries, err := newPlaybackHistoryStore(directory).list() + if err != nil || len(entries) != 0 { + t.Fatal("history restored an unrelated source", entries, err) + } +} + +func TestPlaybackHistoryRejectsCrossSiteAndInvalidProgress(t *testing.T) { + app, session := historyFixtureApp(t) + request := httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/history", nil) + request.Header.Set("Origin", "https://unrelated.invalid") + writer := httptest.NewRecorder() + app.handlePlaybackHistory(writer, viewerFixtureRequest(app, request)) + if writer.Code != http.StatusForbidden { + t.Fatal("cross-site history was allowed", writer.Code) + } + for _, position := range []float64{-1, 91 * 60 * 60} { + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, position)); err == nil || saved { + t.Fatal("invalid position", position, saved, err) + } + } + progress := historyProgress(1, 20) + progress.Completed = true + if entry, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, progress); err != nil || !saved || entry.Completed { + t.Fatal("early completion flag skipped content", entry.Completed, saved, err) + } +} + +func TestPlaybackHistoryOpenResumesAndHonorsExplicitEpisode(t *testing.T) { + app, session := historyFixtureApp(t) + program, err := os.Executable() + if err != nil { + t.Fatal(err) + } + app.downloader = rankingTestDownloader(t, func(*http.Request) (*http.Response, error) { + t.Error("history/open requested an external resource") + return nil, errors.New("external request forbidden") + }) + app.downloader.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: program}} + client := app.downloader.hongguoClient() + chapters := make([]Chapter, len(session.tasks)) + app.tasks = make(map[string]*UITask) + for index, task := range session.tasks { + task.Chapter.VideoURL = "hongguo-cenc://9000000000000000001" + chapters[index] = task.Chapter + id := fmt.Sprintf("download-%d", index+1) + app.tasks[id] = &UITask{ID: id, DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Chapter.EpisodeString(task.Index), Source: task} + app.taskOrder = append(app.taskOrder, id) + } + client.details["7000000000000000001"] = hongguoDetailEntry{Drama: Drama{ID: historyFixtureDramaID, Title: "本地无图续播测试"}, Chapters: chapters, ExpiresAt: time.Now().Add(time.Hour)} + session.historyRuns[2] = playbackHistoryRun{episode: 2, duration: 90} + progress := historyProgress(1, 32.5) + progress.Episode, progress.Run = 2, 2 + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, progress); err != nil || !saved { + t.Fatal(saved, err) + } + app.closePlayback(session.id) + fixtureViewer(app).history = nil + fixtureViewer(app).historyOnce = sync.Once{} + check := func(input map[string]any, index int, position float64, mode string) { + t.Helper() + writer := historyJSONRequest(t, app, app.handlePlaybackOpen, "/api/ui/playback/open", input) + if writer.Code != 200 { + t.Fatal(writer.Code, writer.Body.String()) + } + var view struct { + Session string `json:"session"` + DramaID string `json:"dramaId"` + InitialIndex int `json:"initialIndex"` + InitialPosition float64 `json:"initialPosition"` + Mode string `json:"mode"` + } + if err := json.Unmarshal(writer.Body.Bytes(), &view); err != nil { + t.Fatal(err) + } + defer app.closePlayback(view.Session) + if view.DramaID != historyFixtureDramaID || view.InitialIndex != index || view.InitialPosition != position || view.Mode != mode { + t.Fatalf("unexpected playback selection: %+v", view) + } + } + check(map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}, 2, 32.5, "online") + check(map[string]any{"taskId": "download-1", "resume": true}, 2, 32.5, "collection") + check(map[string]any{"taskId": "download-3", "resume": false}, 3, 0, "collection") + entry, _ := fixtureViewer(app).playbackHistory().get(historyFixtureDramaID) + entry.Mode, entry.TaskID, entry.Completed, entry.Position = "collection", "download-2", true, 90 + if _, err := fixtureViewer(app).playbackHistory().record(entry, time.Now()); err != nil { + t.Fatal(err) + } + check(map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}, 3, 0, "collection") + app.tasks = nil + check(map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}, 3, 0, "online") +} diff --git a/internal/app/ui_playback_native.go b/internal/app/ui_playback_native.go new file mode 100644 index 0000000..ad06015 --- /dev/null +++ b/internal/app/ui_playback_native.go @@ -0,0 +1,144 @@ +package app + +import ( + "bytes" + "context" + "fmt" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func (app *UIApp) handlePlaybackNativeOpen(writer http.ResponseWriter, request *http.Request) { + var input struct { + Session string `json:"session"` + Episode int `json:"episode"` + Start float64 `json:"start"` + Quality int `json:"quality"` + Version uint64 `json:"version"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + if input.Episode < 1 || math.IsNaN(input.Start) || math.IsInf(input.Start, 0) || input.Start < 0 || input.Start > 24*60*60 || input.Quality < 0 || input.Quality > 4320 { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "集数、播放位置或清晰度无效"}) + return + } + if request.Context().Err() != nil { + return + } + run, status, err := app.beginPlayback(context.WithoutCancel(request.Context()), input.Session, input.Episode, input.Start, input.Quality, input.Version, true) + if err != nil { + writeJSON(writer, status, map[string]string{"error": err.Error()}) + return + } + var cache *playbackNative + if run.cache != nil && run.cache.view().State != "failed" { + cache = run.cache.native + } + prefetched := cache != nil + if cache == nil { + cache = newPlaybackNative(app, run.ctx, run.task, run.downloadID, input.Quality, input.Start, false) + } else { + cache.mu.Lock() + cache.background = false + cache.mu.Unlock() + } + stop := func() { run.stop(); cache.Close() } + app.playbackMu.Lock() + if app.playbacks[run.id] != run.session || run.session.run != run.run || run.ctx.Err() != nil { + app.playbackMu.Unlock() + stop() + writeJSON(writer, http.StatusConflict, map[string]string{"error": "播放请求已更新"}) + return + } + run.session.native, run.session.cancel = cache, stop + app.playbackMu.Unlock() + cache.start() + startup, cancel := context.WithTimeout(request.Context(), 90*time.Second) + defer cancel() + _, err = cache.segment(startup, int(input.Start/playbackNativeSegmentSeconds)) + if err != nil { + stop() + app.finishPlaybackRun(run, err, request.Context().Err() != nil) + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": app.redactError(err)}) + return + } + media, duration, source := cache.metadata() + if duration <= 0 || duration > 24*60*60 || math.IsNaN(duration) || math.IsInf(duration, 0) || input.Start >= duration { + stop() + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "未取得有效播放时长或播放位置已超出本集"}) + return + } + app.playbackRunReady(run, duration) + query := url.Values{"session": {run.id}, "run": {strconv.FormatUint(run.run, 10)}} + writeJSON(writer, http.StatusOK, map[string]any{ + "url": "/api/ui/playback/hls/index.m3u8?" + query.Encode(), "run": run.run, "duration": duration, "source": source, + "quality": media.Quality, "qualities": playbackQualityOptions(media), "prefetched": prefetched, + }) +} + +func (app *UIApp) handlePlaybackNativeAsset(writer http.ResponseWriter, request *http.Request) { + method := http.MethodGet + if request.Method == http.MethodHead { + method = http.MethodHead + } + if !playbackRequestAllowed(writer, request, method) { + return + } + query := request.URL.Query() + run, runErr := strconv.ParseUint(query.Get("run"), 10, 64) + if runErr != nil || run == 0 { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放请求编号无效"}) + return + } + app.playbackMu.Lock() + session := app.playbacks[query.Get("session")] + if !viewerOwnsPlayback(request.Context(), session) || session.run != run || session.native == nil { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusGone, map[string]string{"error": "此播放流已结束,请重新选择分集"}) + return + } + cache := session.native + app.touchPlaybackLocked(session) + app.playbackMu.Unlock() + if strings.HasSuffix(request.URL.Path, "/index.m3u8") { + _, duration, _ := cache.metadata() + if duration <= 0 || duration > 24*60*60 || math.IsNaN(duration) || math.IsInf(duration, 0) { + writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "播放列表尚未就绪"}) + return + } + var playlist strings.Builder + fmt.Fprintf(&playlist, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n", playbackNativeSegmentSeconds) + if cache.offset > 0 { + fmt.Fprintf(&playlist, "#EXT-X-START:TIME-OFFSET=%.3f,PRECISE=YES\n", cache.offset) + } + for index, count := 0, int(math.Ceil(duration/playbackNativeSegmentSeconds)); index < count; index++ { + length := math.Min(playbackNativeSegmentSeconds, duration-float64(index*playbackNativeSegmentSeconds)) + asset := url.Values{"session": {session.id}, "run": {strconv.FormatUint(run, 10)}, "segment": {strconv.Itoa(index)}} + fmt.Fprintf(&playlist, "#EXTINF:%.6f,\nsegment.ts?%s\n", length, asset.Encode()) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + http.ServeContent(writer, request, "index.m3u8", time.Time{}, strings.NewReader(playlist.String())) + return + } + index, err := strconv.Atoi(query.Get("segment")) + _, duration, _ := cache.metadata() + if err != nil || index < 0 || duration <= 0 || index >= int(math.Ceil(duration/playbackNativeSegmentSeconds)) { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放分片编号无效"}) + return + } + ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) + defer cancel() + body, err := cache.segment(ctx, index) + if err != nil { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": app.redactError(err)}) + return + } + writer.Header().Set("Content-Type", "video/mp2t") + http.ServeContent(writer, request, "segment.ts", time.Time{}, bytes.NewReader(body)) +} diff --git a/internal/app/ui_playback_native_test.go b/internal/app/ui_playback_native_test.go new file mode 100644 index 0000000..fe31b38 --- /dev/null +++ b/internal/app/ui_playback_native_test.go @@ -0,0 +1,223 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func nativePlaybackFixture(t *testing.T, duration, size string) (*UIApp, *playbackSession) { + t.Helper() + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("local FFmpeg is unavailable") + } + video := filepath.Join(t.TempDir(), "generated.mp4") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size="+size+":rate=30:duration="+duration, "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", "-t", duration, "-c:v", "libx264", "-preset", "ultrafast", "-threads", "1", "-c:a", "aac", "-movflags", "+faststart", video) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generate local fixture: %v %s", err, output) + } + app, session := prefetchFixtureApp(t) + app.downloader.cfg.FFmpeg = ffmpeg + app.cfg = app.downloader.cfg + app.tasks = make(map[string]*UITask) + session.downloadIDs = []string{"one", "two", "three"} + session.prepared = make(map[int]bool) + for index, id := range session.downloadIDs { + task := Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "本地生成播放样本", Index: index + 1, Total: 3, OutPath: video, Chapter: Chapter{ID: "hongguo:7000000000000000001:" + strconv.Itoa(index+1), CurrentEpisode: rawEpisode(index + 1)}} + session.tasks[index] = task + session.prepared[index+1] = true + app.tasks[id] = &UITask{ID: id, DramaID: task.DramaID, Status: uiStatusSuccess, Path: video, Source: task} + } + return app, session +} + +func nativeOpenFixture(t *testing.T, app *UIApp, episode int, offset float64, version int) map[string]json.RawMessage { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "http://localhost/api/ui/playback/hls/open", strings.NewReader(fmt.Sprintf(`{"session":"fixture","episode":%d,"start":%f,"version":%d}`, episode, offset, version))) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + app.handlePlaybackNativeOpen(writer, viewerFixtureRequest(app, request)) + if writer.Code != http.StatusOK { + t.Fatalf("native playback open: %d %s", writer.Code, writer.Body.String()) + } + var result map[string]json.RawMessage + if err := json.Unmarshal(writer.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result +} + +func TestPlaybackNativeHLSSeeksRangesPrefetchAndCleanup(t *testing.T) { + app, session := nativePlaybackFixture(t, "30", "320x180") + opened := nativeOpenFixture(t, app, 1, 0, 1) + var address string + if json.Unmarshal(opened["url"], &address) != nil || !strings.Contains(address, "index.m3u8") { + t.Fatal("native route did not return an HLS address") + } + cache := session.native + writer := httptest.NewRecorder() + app.handlePlaybackNativeAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+address, nil))) + if writer.Code != http.StatusOK || strings.Count(writer.Body.String(), "#EXTINF:") != 15 || !strings.Contains(writer.Body.String(), "#EXT-X-ENDLIST") || strings.Contains(writer.Body.String(), "generated.mp4") { + t.Fatal("native playlist is not a complete, private VOD timeline", writer.Body.String()) + } + read := func(segment int, method, header string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, fmt.Sprintf("http://localhost/api/ui/playback/hls/segment.ts?session=fixture&run=%d&segment=%d", session.run, segment), nil) + if header != "" { + request.Header.Set("Range", header) + } + result := httptest.NewRecorder() + app.handlePlaybackNativeAsset(result, viewerFixtureRequest(app, request)) + return result + } + first := read(0, http.MethodGet, "bytes=0-187") + if first.Code != http.StatusPartialContent || first.Body.Len() != 188 || first.Body.Bytes()[0] != 0x47 || first.Header().Get("Content-Type") != "video/mp2t" { + t.Fatal("HLS byte ranges did not return a transport-stream packet", first.Code, first.Body.Len()) + } + last := read(14, http.MethodGet, "") + if last.Code != http.StatusOK || last.Body.Len() < 188 || read(0, http.MethodHead, "").Body.Len() != 0 { + t.Fatal("seeking across encoding batches or HEAD failed", last.Code, last.Body.String()) + } + state, _ := app.playbackStatus("fixture", false) + if state.State != "ended" || state.Duration < 29.9 || state.Duration > 30.1 { + t.Fatalf("native run lost its full duration: %+v", state) + } + result := prefetchRequest(app, fmt.Sprintf(`{"session":"fixture","episode":2,"run":%d,"version":1}`, session.run)) + if result.Code != http.StatusAccepted { + t.Fatal("native next episode was not accepted", result.Body.String()) + } + prefetch := session.prefetch + select { + case <-prefetch.done: + case <-time.After(30 * time.Second): + t.Fatal("native prefetch did not finish its bounded batch") + } + if prefetch.view().State != "partial" { + t.Fatal("native prefetch must describe partial caching accurately", prefetch.view()) + } + opened = nativeOpenFixture(t, app, 2, 0, 2) + if string(opened["prefetched"]) != "true" || session.native != prefetch.native { + t.Fatal("native playback did not reuse the prepared next episode") + } + writer = httptest.NewRecorder() + app.handlePlaybackNativeAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+address, nil))) + if writer.Code != http.StatusGone || cache.ctx.Err() == nil { + t.Fatal("previous HLS run stayed active after switching episodes") + } + active := session.native + active.mu.Lock() + directory := active.directory + active.mu.Unlock() + app.closePlayback("fixture") + active.workers.Wait() + deadline := time.Now().Add(3 * time.Second) + for { + _, err := os.Stat(directory) + if os.IsNotExist(err) { + break + } + if time.Now().After(deadline) { + t.Fatal("native temporary files survived session close") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestPlaybackKeeps1080pAndNativeResumeTimeline(t *testing.T) { + app, session := nativePlaybackFixture(t, "4", "1080x1920") + writer := httptest.NewRecorder() + app.handlePlaybackStream(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/stream?session=fixture&episode=1", nil))) + if writer.Code != http.StatusOK || !bytes.Contains(writer.Body.Bytes(), []byte("moof")) { + t.Fatal("generated 1080p stream failed", writer.Code) + } + path := filepath.Join(t.TempDir(), "encoded.mp4") + if err := os.WriteFile(path, writer.Body.Bytes(), 0600); err != nil { + t.Fatal(err) + } + command := exec.Command(app.downloader.cfg.FFmpeg, "-hide_banner", "-i", path, "-t", "0", "-f", "null", "-") + output, err := command.CombinedOutput() + if err != nil || !strings.Contains(string(output), "1080x1920") || writer.Header().Get("X-Playback-MIME") != `video/mp4; codecs="avc1.42C028, mp4a.40.2"` { + t.Fatalf("1080p was downscaled or codec metadata was wrong: %v %s %s", err, writer.Header().Get("X-Playback-MIME"), output) + } + opened := nativeOpenFixture(t, app, 1, 2.5, 1) + if string(opened["duration"]) != "4" { + t.Fatal("native resume lost the full episode duration", string(opened["duration"])) + } + var address string + _ = json.Unmarshal(opened["url"], &address) + writer = httptest.NewRecorder() + app.handlePlaybackNativeAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+address, nil))) + if !strings.Contains(writer.Body.String(), "TIME-OFFSET=2.500") || strings.Count(writer.Body.String(), "#EXTINF:") != 2 || session.historyRuns[session.run].duration != 4 { + t.Fatal("native seek or history used a shortened timeline") + } +} + +func TestPlaybackNativeDecodesAcrossBatches(t *testing.T) { + app, session := nativePlaybackFixture(t, "50.1", "320x180") + opened := nativeOpenFixture(t, app, 1, 0, 1) + var address string + if err := json.Unmarshal(opened["url"], &address); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + app.registerPlaybackRoutes(mux) + server := httptest.NewServer(viewerFixtureHandler(app, mux)) + defer server.Close() + defer app.closePlayback("fixture") + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + command := exec.CommandContext(ctx, app.downloader.cfg.FFmpeg, "-hide_banner", "-loglevel", "warning", "-xerror", "-nostdin", "-i", server.URL+address, "-map", "0:v:0", "-map", "0:a:0", "-progress", "pipe:1", "-f", "null", "-") + var progress, diagnostic bytes.Buffer + command.Stdout, command.Stderr = &progress, &diagnostic + if err := command.Run(); err != nil { + t.Fatalf("decode complete HLS timeline: %v\n%s\n%s", err, diagnostic.String(), progress.String()) + } + values := make(map[string]string) + for _, line := range strings.Split(progress.String(), "\n") { + key, value, ok := strings.Cut(line, "=") + if ok { + values[key] = value + } + } + frames, _ := strconv.Atoi(strings.TrimSpace(values["frame"])) + micros, _ := strconv.Atoi(values["out_time_us"]) + if values["progress"] != "end" || frames != 1503 || micros < 50_000_000 || micros > 50_250_000 { + t.Fatalf("HLS batches lost frames or their shared timeline: %v\n%s", values, diagnostic.String()) + } + if state, err := session.native.state(); state != "ended" || err != nil { + t.Fatalf("complete HLS decode left an invalid state: %s %v", state, err) + } +} + +func TestPlaybackNativeBoundsMemoryAndCancelsWaiters(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cache := &playbackNative{ctx: ctx, cancel: cancel, changed: make(chan struct{}), segments: make(map[int][]byte), wanted: 40} + defer cache.Close() + data := make([]byte, 2*1024*1024) + for index := 0; index < 70; index++ { + cache.segments[index] = data + cache.bytes += len(data) + } + cache.evictLocked(69) + if cache.bytes > playbackNativeCacheBytes || len(cache.segments[40]) == 0 || len(cache.segments[69]) == 0 { + t.Fatal("native cache exceeded its bound or evicted the current segment") + } + canceled, stop := context.WithCancel(context.Background()) + stop() + if _, err := cache.segment(canceled, 100); err != context.Canceled || cache.job != nil { + t.Fatal("canceled native request started more work", err) + } +} diff --git a/internal/app/ui_playback_plan.go b/internal/app/ui_playback_plan.go new file mode 100644 index 0000000..ad2a84c --- /dev/null +++ b/internal/app/ui_playback_plan.go @@ -0,0 +1,222 @@ +package app + +import ( + "context" + "math" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +type playbackClientCapabilities struct { + MP4 bool `json:"mp4"` + NativeHLS bool `json:"nativeHls"` + HlsJS bool `json:"hlsjs"` + Video []string `json:"video"` + Audio []string `json:"audio"` +} + +func playbackCodecAllowed(choices []string, codec string) bool { + if len(choices) == 0 || codec == "" { + return true + } + for _, choice := range choices { + if choice == codec { + return true + } + } + return false +} + +var playbackProbeVideo = regexp.MustCompile(`Video: ([a-zA-Z0-9_]+)`) +var playbackProbeAudio = regexp.MustCompile(`Audio: ([a-zA-Z0-9_]+)`) + +func (app *UIApp) probePlaybackCodecs(ctx context.Context, media *playbackMediaSession) error { + if media.info.Video != "" && media.info.Audio != "unknown" { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + ffmpeg, err := app.downloader.ensureFFmpeg(ctx) + if err != nil { + return err + } + release, err := app.mediaResources().acquire(ctx, "remux", false) + if err != nil { + return err + } + defer release() + input := media.local + var proxy *hlsProxy + if input == "" { + proxy, err = app.downloader.newHLSProxy(ctx, media.media, media.key) + if err != nil { + return err + } + defer proxy.Close() + proxy.acquire = func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) } + input = proxy.root + } + args := playbackInputArgs(media.media, input, 0) + for index, value := range args { + if value == "-i" { + args = append(args[:index], append([]string{"-probesize", "2097152", "-analyzeduration", "2000000"}, args[index:]...)...) + break + } + } + args = append(args, "-c", "copy", "-t", "0", "-f", "null", "-") + command := ffmpegMediaCommand(ctx, ffmpeg, args...) + log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})} + command.Stderr = log + err = command.Run() + if ctx.Err() != nil { + return ctx.Err() + } + duration, body := log.snapshot() + if value := playbackProbeVideo.FindStringSubmatch(body); len(value) == 2 { + media.info.Video = value[1] + } + if value := playbackProbeAudio.FindStringSubmatch(body); len(value) == 2 { + media.info.Audio = value[1] + } + if proxy != nil && proxy.Err() != nil || media.info.Video == "" && err != nil { + return playbackStreamError(ctx, proxy, log, media.media, err, nil) + } + if media.plan.Duration <= 0 && duration > 0 { + media.plan.Duration, media.plan.SeekEnd = duration, duration + } + return nil +} + +func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, client playbackClientCapabilities, mode, source, origin, base, query string) error { + app.attachMediaGateway(media, base, query) + app.inspectPlaybackMedia(ctx, media) + if mode == "legacy" { + media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "client_decode_failed" + return nil + } + convert := len(media.media.CENCKey) > 0 || mode == "compatible" || mode == "audio" + if !playbackCodecAllowed(client.Video, media.info.Video) { + convert = true + } + if !playbackCodecAllowed(client.Audio, media.info.Audio) { + convert = true + } + if convert { + if err := app.probePlaybackCodecs(ctx, media); err != nil { + return err + } + if !playbackCodecAllowed(client.Video, media.info.Video) || media.info.Video == "" { + media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "video_codec_unsupported" + return nil + } + processing := "remux" + media.plan.Reason = "container_compatibility" + if len(media.media.CENCKey) > 0 { + media.plan.Reason = "server_decryption_required" + } + if mode == "audio" || !playbackCodecAllowed(client.Audio, media.info.Audio) { + processing, media.plan.Reason = "audio", "audio_codec_unsupported" + } + if !client.NativeHLS && !client.HlsJS { + media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "hls_player_unavailable" + return nil + } + if err := app.generatePlayback(ctx, media, processing, base, query); err != nil { + return err + } + } else if media.plan.Player == "hls" && !client.NativeHLS && !client.HlsJS || media.plan.Player == "mp4" && !client.MP4 { + media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "original_player_unavailable" + return nil + } + if mode == "auto" && media.plan.Processing == "original" && app.allowPlaybackDirect(ctx, media, source, origin) { + media.plan.Delivery, media.plan.Reason = "redirect", "direct_probe_passed" + } else if mode == "proxy" { + media.plan.Reason = "client_direct_access_failed" + } + return nil +} + +func (app *UIApp) handlePlaybackPlan(writer http.ResponseWriter, request *http.Request) { + var input struct { + Session string `json:"session"` + Episode int `json:"episode"` + Start float64 `json:"start"` + Quality int `json:"quality"` + Version uint64 `json:"version"` + Mode string `json:"mode"` + Client playbackClientCapabilities `json:"client"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + if input.Episode < 1 || math.IsNaN(input.Start) || math.IsInf(input.Start, 0) || input.Start < 0 || input.Start > 24*60*60 || input.Quality < 0 || input.Quality > 4320 || len(input.Client.Video) > 16 || len(input.Client.Audio) > 16 { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "集数、播放位置或媒体能力无效"}) + return + } + if input.Mode == "" { + input.Mode = "auto" + } + if input.Mode != "auto" && input.Mode != "proxy" && input.Mode != "compatible" && input.Mode != "audio" && input.Mode != "legacy" { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放模式无效"}) + return + } + if !app.requirePlaybackOwner(writer, request, input.Session) { + return + } + if !app.mediaResources().snapshot().Enabled { + writeJSON(writer, http.StatusOK, playbackMediaPlan{Player: "legacy", Reason: "optimization_disabled"}) + return + } + run, status, err := app.beginPlayback(context.WithoutCancel(request.Context()), input.Session, input.Episode, input.Start, input.Quality, input.Version, false) + if err != nil { + writeJSON(writer, status, map[string]string{"error": err.Error()}) + return + } + ready := false + var media *playbackMediaSession + defer func() { + if !ready { + run.stop() + if media != nil { + media.Close() + } + } + }() + ctx, cancel := context.WithTimeout(run.ctx, 90*time.Second) + defer cancel() + stop := context.AfterFunc(request.Context(), cancel) + defer stop() + media, err = app.resolveMediaSession(run.ctx, ctx, run.task, run.downloadID, input.Quality, run.cache) + if err == nil { + base := "/api/ui/playback/media/" + run.id + "/" + strconv.FormatUint(run.run, 10) + "/" + err = app.planMedia(ctx, media, input.Client, input.Mode, sourceFromDramaID(run.task.DramaID), request.Header.Get("Origin"), base, "") + } + if err != nil { + app.finishPlaybackRun(run, err, request.Context().Err() != nil) + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "准备播放失败:" + app.redactError(err)}) + return + } + media.plan.Run = run.run + if media.plan.Player == "legacy" { + writeJSON(writer, http.StatusOK, media.plan) + return + } + app.playbackMu.Lock() + if app.playbacks[run.id] == run.session && run.session.run == run.run && ctx.Err() == nil { + run.session.media = media + run.session.cancel = func() { run.stop(); media.Close() } + ready = true + } + app.playbackMu.Unlock() + if !ready { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "播放请求已更新"}) + return + } + app.playbackRunReady(run, media.plan.Duration) + app.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "playback.plan", Source: sourceFromDramaID(run.task.DramaID), DramaID: run.task.DramaID, + Episode: input.Episode, Run: run.run, Quality: input.Quality, Message: strings.Join([]string{media.plan.Delivery, media.plan.Processing, media.plan.Player, media.plan.Reason}, " / ")}) + writeJSON(writer, http.StatusOK, media.plan) +} diff --git a/internal/app/ui_playback_prefetch.go b/internal/app/ui_playback_prefetch.go new file mode 100644 index 0000000..f723204 --- /dev/null +++ b/internal/app/ui_playback_prefetch.go @@ -0,0 +1,335 @@ +package app + +import ( + "context" + "net/http" + "strconv" + "sync" + "time" +) + +const playbackPrefetchChunkSize = 64 * 1024 +const playbackPrefetchChunks = 512 + +type playbackPrefetchKey struct{} + +type playbackPrefetch struct { + episode int + fromRun uint64 + quality int + remux bool + planOnly bool + resolved *playbackMediaSession + native *playbackNative + ctx context.Context + cancel context.CancelFunc + chunks chan []byte + ready chan struct{} + done chan struct{} + once sync.Once + headers http.Header + status int + mu sync.Mutex + header http.Header + err error +} + +type playbackPrefetchView struct { + Episode int `json:"episode"` + State string `json:"state"` +} + +func newPlaybackPrefetch(episode int, run uint64) *playbackPrefetch { + ctx := context.WithValue(context.Background(), backgroundCatalogKey{}, true) + ctx = context.WithValue(ctx, playbackPrefetchKey{}, true) + ctx, cancel := context.WithCancel(ctx) + return &playbackPrefetch{episode: episode, fromRun: run, ctx: ctx, cancel: cancel, + chunks: make(chan []byte, playbackPrefetchChunks), ready: make(chan struct{}), done: make(chan struct{}), headers: make(http.Header)} +} + +func (cache *playbackPrefetch) Header() http.Header { return cache.headers } +func (cache *playbackPrefetch) WriteHeader(status int) { cache.status = status } +func (cache *playbackPrefetch) Flush() {} +func (cache *playbackPrefetch) SetWriteDeadline(time.Time) error { return nil } + +func (cache *playbackPrefetch) Write(data []byte) (int, error) { + if cache.status == 0 { + cache.status = http.StatusOK + } + if cache.status != http.StatusOK { + return len(data), nil + } + cache.once.Do(func() { + cache.mu.Lock() + cache.header = cache.headers.Clone() + cache.mu.Unlock() + close(cache.ready) + }) + written := 0 + for len(data) > 0 { + size := len(data) + if size > playbackPrefetchChunkSize { + size = playbackPrefetchChunkSize + } + chunk := append([]byte(nil), data[:size]...) + select { + case cache.chunks <- chunk: + data = data[size:] + written += size + case <-cache.ctx.Done(): + return written, cache.ctx.Err() + } + } + return written, nil +} + +func (cache *playbackPrefetch) finish(err error) { + cache.mu.Lock() + cache.err = err + cache.mu.Unlock() + close(cache.chunks) + close(cache.done) + cache.once.Do(func() { close(cache.ready) }) +} + +func (cache *playbackPrefetch) view() *playbackPrefetchView { + cache.mu.Lock() + resolved := cache.resolved != nil && cache.err == nil + cache.mu.Unlock() + if resolved { + return &playbackPrefetchView{Episode: cache.episode, State: "resolved"} + } + if cache.native != nil { + state, _ := cache.native.state() + switch state { + case "ended": + state = "ready" + case "failed", "stopped": + state = "failed" + case "streaming": + state = "buffering" + select { + case <-cache.native.firstBatch: + state = "partial" + default: + } + default: + state = "preparing" + } + return &playbackPrefetchView{Episode: cache.episode, State: state} + } + cache.mu.Lock() + defer cache.mu.Unlock() + state := "preparing" + if cache.err != nil { + state = "failed" + } else if cache.header != nil { + state = "buffering" + select { + case <-cache.done: + state = "ready" + default: + } + } + return &playbackPrefetchView{Episode: cache.episode, State: state} +} + +func (cache *playbackPrefetch) serve(ctx context.Context, writer http.ResponseWriter, run uint64, ready func(float64)) (bool, error) { + select { + case <-cache.ready: + case <-ctx.Done(): + return true, ctx.Err() + } + cache.mu.Lock() + header, failed := cache.header, cache.err + cache.mu.Unlock() + if failed != nil || header == nil { + return false, nil + } + var first []byte + select { + case chunk, ok := <-cache.chunks: + if !ok { + return false, nil + } + first = chunk + case <-ctx.Done(): + return true, ctx.Err() + } + for _, name := range []string{"Content-Type", "Content-Disposition", "X-Playback-Duration", "X-Playback-Source", "X-Playback-MIME", "X-Playback-Mode", "X-Playback-Quality", "X-Playback-Qualities"} { + if value := header.Get(name); value != "" { + writer.Header().Set(name, value) + } + } + writer.Header().Set("X-Playback-Run", strconv.FormatUint(run, 10)) + writer.Header().Set("X-Playback-Prefetched", "1") + duration, _ := strconv.ParseFloat(header.Get("X-Playback-Duration"), 64) + ready(duration) + controller := http.NewResponseController(writer) + interrupted := make(chan struct{}) + stopInterrupt := context.AfterFunc(ctx, func() { _ = controller.SetWriteDeadline(time.Now()); close(interrupted) }) + defer func() { + if !stopInterrupt() { + <-interrupted + } + _ = controller.SetWriteDeadline(time.Time{}) + }() + for { + if _, err := writer.Write(first); err != nil { + return true, err + } + if err := controller.Flush(); err != nil { + return true, err + } + select { + case chunk, ok := <-cache.chunks: + if !ok { + <-cache.done + cache.mu.Lock() + err := cache.err + cache.mu.Unlock() + return true, err + } + first = chunk + case <-ctx.Done(): + return true, ctx.Err() + } + } +} + +func (app *UIApp) handlePlaybackPrefetch(writer http.ResponseWriter, request *http.Request) { + var input struct { + Session string `json:"session"` + Episode int `json:"episode"` + Run uint64 `json:"run"` + Version uint64 `json:"version"` + Cancel bool `json:"cancel"` + Remux bool `json:"remux"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + app.playbackMu.Lock() + session := app.playbacks[input.Session] + if !viewerOwnsPlayback(request.Context(), session) { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期"}) + return + } + if input.Run != session.run || input.Version == 0 || input.Version < session.prefetchVersion { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusConflict, map[string]string{"error": "播放分集已变化"}) + return + } + if !input.Cancel && (input.Episode < 1 || input.Episode != session.currentIndex+1 || input.Episode > len(session.tasks)) { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "只能预缓存当前集的下一集"}) + return + } + state := session.state + if session.native != nil { + state, _ = session.native.state() + } + if !input.Cancel && state != "ended" && session.media == nil { + app.playbackMu.Unlock() + writeJSON(writer, http.StatusConflict, map[string]string{"error": "当前集尚未缓冲完成"}) + return + } + session.prefetchVersion = input.Version + app.touchPlaybackLocked(session) + previous := session.prefetch + if input.Cancel { + session.prefetch = nil + app.playbackMu.Unlock() + if previous != nil { + previous.cancel() + } + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) + return + } + remux := input.Remux && session.native == nil + if previous != nil && previous.episode == input.Episode && previous.fromRun == input.Run && previous.remux == remux { + view := previous.view() + app.playbackMu.Unlock() + writeJSON(writer, http.StatusOK, view) + return + } + cache := newPlaybackPrefetch(input.Episode, input.Run) + cache.quality, cache.remux = session.quality, remux + cache.planOnly = session.media != nil + cache.ctx = context.WithValue(cache.ctx, playbackQualityKey{}, cache.quality) + cache.ctx = context.WithValue(cache.ctx, playbackRemuxKey{}, remux) + session.prefetch = cache + task := session.tasks[input.Episode-1] + downloadID := "" + if len(session.downloadIDs) > 0 { + downloadID = session.downloadIDs[input.Episode-1] + } + if session.native != nil { + cache.native = newPlaybackNative(app, cache.ctx, task, downloadID, cache.quality, 0, true) + cancel := cache.cancel + cache.cancel = func() { cancel(); cache.native.Close() } + } + app.playbackMu.Unlock() + if previous != nil { + previous.cancel() + } + go func() { + release, err := app.mediaResources().acquire(cache.ctx, "prefetch", true) + if err != nil { + cache.cancel() + cache.finish(err) + return + } + defer release() + startup := time.AfterFunc(60*time.Second, cache.cancel) + defer startup.Stop() + if cache.planOnly { + defer cache.cancel() + resolved, err := app.resolveMediaSession(cache.ctx, cache.ctx, task, downloadID, cache.quality) + if err == nil { + cache.mu.Lock() + cache.resolved = resolved + cache.mu.Unlock() + resolved.Close() + } + cache.finish(err) + return + } + if cache.native != nil { + cache.native.start() + _, err := cache.native.segment(cache.ctx, 0) + startup.Stop() + if err == nil { + select { + case <-cache.native.firstBatch: + _, err = cache.native.state() + case <-cache.ctx.Done(): + err = cache.ctx.Err() + } + } + if err != nil { + cache.cancel() + } + cache.finish(err) + return + } + defer cache.cancel() + err = app.streamPlayback(cache.ctx, cache.cancel, cache, task, downloadID, 0, 0, func(float64) { startup.Stop() }) + cache.finish(err) + }() + writeJSON(writer, http.StatusAccepted, &playbackPrefetchView{Episode: input.Episode, State: "preparing"}) +} + +func (app *UIApp) servePrefetchedPlayback(ctx context.Context, writer http.ResponseWriter, cache *playbackPrefetch, run uint64, ready func(float64)) (bool, error) { + if cache == nil { + return false, nil + } + used, err := cache.serve(ctx, writer, run, ready) + if !used { + cache.cancel() + } + return used, err +} + +var _ http.ResponseWriter = (*playbackPrefetch)(nil) diff --git a/internal/app/ui_playback_prefetch_integration_test.go b/internal/app/ui_playback_prefetch_integration_test.go new file mode 100644 index 0000000..7d6b843 --- /dev/null +++ b/internal/app/ui_playback_prefetch_integration_test.go @@ -0,0 +1,65 @@ +package app + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestPlaybackPrefetchLocalFFmpeg(t *testing.T) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("local FFmpeg is unavailable") + } + directory := t.TempDir() + video := filepath.Join(directory, "generated-fixture.mp4") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + command := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=160x90:r=30:d=1", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", "-t", "1", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-movflags", "+faststart", video) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generate local fixture: %v %s", err, output) + } + app, session := prefetchFixtureApp(t) + app.downloader.cfg.FFmpeg = ffmpeg + task := Task{DramaID: "hongguo:1000000000000000001", Index: 2, OutPath: video} + session.tasks[1] = task + session.downloadIDs = []string{"one", "two", "three"} + session.prepared = map[int]bool{} + app.tasks = map[string]*UITask{"two": {ID: "two", DramaID: task.DramaID, Status: uiStatusSuccess, Path: video, Source: task}} + result := prefetchRequest(app, `{"session":"fixture","episode":2,"run":4,"version":1}`) + if result.Code != http.StatusAccepted { + t.Fatalf("prefetch not accepted: %d %s", result.Code, result.Body.String()) + } + cache := session.prefetch + select { + case <-cache.done: + case <-time.After(15 * time.Second): + t.Fatal("local prefetch did not complete") + } + if cache.view().State != "ready" { + t.Fatal("local FFmpeg prefetch failed", cache.err) + } + if session.prepared[2] || app.tasks["two"].Status != uiStatusSuccess { + t.Fatal("prefetch changed collection download state") + } + request := httptest.NewRequest(http.MethodPost, "http://localhost/api/ui/playback/prepare", bytes.NewBufferString(`{"session":"fixture","episode":2}`)) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + app.handlePlaybackPrepare(writer, viewerFixtureRequest(app, request)) + if writer.Code != 200 { + t.Fatal("collection preparation failed", writer.Body.String()) + } + writer = httptest.NewRecorder() + started := time.Now() + app.handlePlaybackStream(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/stream?session=fixture&episode=2", nil))) + if writer.Code != 200 || writer.Header().Get("X-Playback-Prefetched") != "1" || writer.Header().Get("X-Playback-Source") != "local" || + !bytes.Contains(writer.Body.Bytes(), []byte("ftyp")) || !bytes.Contains(writer.Body.Bytes(), []byte("moof")) { + t.Fatal("cached playback did not return the generated fragmented MP4") + } + t.Logf("cached local episode: %d bytes served in %s", writer.Body.Len(), time.Since(started)) +} diff --git a/internal/app/ui_playback_prefetch_test.go b/internal/app/ui_playback_prefetch_test.go new file mode 100644 index 0000000..540efeb --- /dev/null +++ b/internal/app/ui_playback_prefetch_test.go @@ -0,0 +1,163 @@ +package app + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func prefetchFixtureApp(t *testing.T) (*UIApp, *playbackSession) { + t.Helper() + timer := time.AfterFunc(time.Hour, func() {}) + t.Cleanup(func() { timer.Stop() }) + session := &playbackSession{id: "fixture", run: 4, currentIndex: 1, state: "ended", timer: timer, + tasks: []Task{{Index: 1}, {Index: 2}, {Index: 3}}} + app := &UIApp{playbacks: map[string]*playbackSession{"fixture": session}, downloader: rankingTestDownloader(t, func(*http.Request) (*http.Response, error) { + t.Error("cache hit unexpectedly requested an upstream resource") + return nil, errors.New("unexpected network request") + })} + app.cfg = app.downloader.cfg + session.viewer = fixtureViewer(app) + session.viewer.retain() + t.Cleanup(app.closePlaybacks) + return app, session +} + +func prefetchRequest(app *UIApp, body string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodPost, "http://localhost/api/ui/playback/prefetch", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + app.handlePlaybackPrefetch(writer, viewerFixtureRequest(app, request)) + return writer +} + +func TestPlaybackPrefetchReusesEncodedBytesAndRun(t *testing.T) { + app, session := prefetchFixtureApp(t) + cache := newPlaybackPrefetch(2, session.run) + session.prefetch = cache + cache.Header().Set("Content-Type", "video/mp4") + cache.Header().Set("X-Playback-Duration", "12.500") + cache.Header().Set("X-Playback-Source", "online") + fixture := bytes.Repeat([]byte("locally-generated-encoded-fixture"), 4000) + if _, err := cache.Write(fixture); err != nil { + t.Fatal(err) + } + cache.finish(nil) + writer := httptest.NewRecorder() + app.handlePlaybackStream(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/stream?session=fixture&episode=2", nil))) + if writer.Code != 200 || !bytes.Equal(writer.Body.Bytes(), fixture) || writer.Header().Get("X-Playback-Prefetched") != "1" || writer.Header().Get("X-Playback-Run") != "5" { + t.Fatal("cached episode was restarted or corrupted") + } + view, _ := app.playbackStatus("fixture", false) + if view.State != "ended" || view.Run != 5 || view.Duration != 12.5 || session.currentIndex != 2 || session.prefetch != nil { + t.Fatal("cached stream did not become the active episode") + } +} + +func TestPlaybackPrefetchBoundsBufferAndCancelsProducer(t *testing.T) { + cache := newPlaybackPrefetch(2, 1) + cache.chunks = make(chan []byte, 2) + finished := make(chan error, 1) + go func() { + _, err := cache.Write(make([]byte, 4*playbackPrefetchChunkSize)) + cache.finish(err) + finished <- err + }() + deadline := time.After(time.Second) + for len(cache.chunks) < 2 { + select { + case <-deadline: + t.Fatal("producer did not fill the bounded cache") + default: + time.Sleep(time.Millisecond) + } + } + select { + case <-finished: + t.Fatal("producer exceeded its buffer capacity") + default: + } + cache.cancel() + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatal("producer ignored cancellation", err) + } + case <-time.After(time.Second): + t.Fatal("canceled producer stayed blocked") + } + if len(cache.chunks) > 2 { + t.Fatal("unbounded cache") + } +} + +func TestPlaybackPrefetchFailureFallsBackWithoutLeakingJSON(t *testing.T) { + cache := newPlaybackPrefetch(2, 1) + defer cache.cancel() + cache.WriteHeader(http.StatusBadGateway) + _, _ = cache.Write([]byte(`{"error":"fixture failed"}`)) + cache.finish(errors.New("fixture failed")) + writer := httptest.NewRecorder() + used, err := cache.serve(context.Background(), writer, 2, func(float64) { t.Error("failed cache became ready") }) + if used || err != nil || writer.Body.Len() != 0 || writer.Header().Get("Content-Type") != "" { + t.Fatal("failed prefetch must leave the response available for normal playback") + } +} + +func TestPlaybackPrefetchValidatesEpisodeAndStaleCommands(t *testing.T) { + app, session := prefetchFixtureApp(t) + for _, body := range []string{ + `{"session":"fixture","episode":3,"run":4,"version":1}`, + `{"session":"fixture","episode":0,"run":4,"version":1}`, + `{"session":"fixture","episode":2,"run":3,"version":1}`, + `{"session":"fixture","episode":2,"run":4,"version":0}`, + } { + if result := prefetchRequest(app, body); result.Code < 400 { + t.Fatal("accepted an invalid prefetch request", body) + } + } + session.state = "streaming" + if result := prefetchRequest(app, `{"session":"fixture","episode":2,"run":4,"version":1}`); result.Code != 409 { + t.Fatal("prefetch competed with a current stream") + } + session.state = "ended" + cache := newPlaybackPrefetch(2, 4) + session.prefetch = cache + if result := prefetchRequest(app, `{"session":"fixture","cancel":true,"run":4,"version":3}`); result.Code != 200 { + t.Fatal("could not turn prefetch off") + } + if cache.ctx.Err() == nil || session.prefetch != nil { + t.Fatal("disabled prefetch kept its producer") + } + if result := prefetchRequest(app, `{"session":"fixture","episode":2,"run":4,"version":2}`); result.Code != 409 { + t.Fatal("late start undid a newer cancellation") + } + cache.finish(context.Canceled) +} + +func TestPlaybackCloseAndExpiryCancelPrefetch(t *testing.T) { + for _, mode := range []string{"close", "expire", "all"} { + t.Run(mode, func(t *testing.T) { + app, session := prefetchFixtureApp(t) + cache := newPlaybackPrefetch(2, 4) + session.prefetch = cache + switch mode { + case "close": + app.closePlayback("fixture") + case "expire": + app.expirePlayback("fixture") + case "all": + app.closePlaybacks() + } + if cache.ctx.Err() == nil { + t.Fatal("closed session retained its prefetch") + } + cache.finish(context.Canceled) + }) + } +} diff --git a/internal/app/ui_playback_proxy_integration_test.go b/internal/app/ui_playback_proxy_integration_test.go new file mode 100644 index 0000000..50bc8b5 --- /dev/null +++ b/internal/app/ui_playback_proxy_integration_test.go @@ -0,0 +1,148 @@ +package app + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestPlaybackNginxFlushesMediaAndPreservesCookies(t *testing.T) { + nginx, err := exec.LookPath("nginx") + if err != nil || runtime.GOOS == "windows" { + t.Skip("local Nginx is unavailable") + } + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/ready" { + writer.WriteHeader(http.StatusNoContent) + return + } + cookie, err := request.Cookie("synthetic_viewer") + if err != nil || cookie.Value != "local-fixture" { + http.Error(writer, "missing fixture identity", http.StatusUnauthorized) + return + } + if request.URL.Path == "/stream" && !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + http.SetCookie(writer, &http.Cookie{Name: "synthetic_seen", Value: "yes", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) + writer.Header().Set("Content-Type", "video/mp4") + _, _ = io.WriteString(writer, "synthetic-init") + writer.(http.Flusher).Flush() + select { + case <-request.Context().Done(): + case <-time.After(5 * time.Second): + } + })) + defer upstream.Close() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + _ = listener.Close() + directory := t.TempDir() + configuration := fmt.Sprintf(`worker_processes 1; +error_log stderr; +pid "%[1]s/nginx.pid"; +lock_file "%[1]s/nginx.lock"; +events { worker_connections 32; } +http { + access_log off; + client_body_temp_path "%[1]s/client_temp"; + proxy_temp_path "%[1]s/proxy_temp"; + fastcgi_temp_path "%[1]s/fastcgi_temp"; + uwsgi_temp_path "%[1]s/uwsgi_temp"; + scgi_temp_path "%[1]s/scgi_temp"; + server { + listen %[2]s; + location / { + proxy_pass %[3]s; + proxy_set_header Host $http_host; + proxy_http_version 1.1; + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + } +} +`, directory, address, upstream.URL) + path := filepath.Join(directory, "nginx.conf") + if err := os.WriteFile(path, []byte(configuration), 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, nginx, "-e", "stderr", "-p", directory+string(os.PathSeparator), "-c", path, "-g", "daemon off; master_process off;") + output := &cappedStringWriter{limit: 8192} + command.Stderr = output + command.WaitDelay = time.Second + if err := command.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + var exitErr error + go func() { exitErr = command.Wait(); close(done) }() + defer func() { cancel(); <-done }() + client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second} + defer client.CloseIdleConnections() + base := "http://" + address + deadline := time.Now().Add(3 * time.Second) + for { + response, err := client.Get(base + "/ready") + if err == nil { + response.Body.Close() + break + } + select { + case <-done: + if strings.Contains(output.String(), "sysctlbyname") && strings.Contains(output.String(), "Operation not permitted") { + t.Skip("local Nginx cannot read TCP settings in the sandbox; run this integration test with system access") + } + t.Fatal("Nginx fixture exited", exitErr, output.String()) + default: + } + if time.Now().After(deadline) { + t.Fatal("Nginx fixture did not start", err) + } + time.Sleep(20 * time.Millisecond) + } + for _, route := range []string{"buffered", "stream"} { + t.Run(route, func(t *testing.T) { + request, _ := http.NewRequest(http.MethodGet, base+"/"+route, nil) + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.Header.Set("Origin", "https://library.example.invalid") + request.AddCookie(&http.Cookie{Name: "synthetic_viewer", Value: "local-fixture"}) + started := time.Now() + response, err := client.Do(request) + var body []byte + if err == nil { + defer response.Body.Close() + body = make([]byte, len("synthetic-init")) + _, err = io.ReadFull(response.Body, body) + } + if route == "buffered" { + if err == nil { + t.Logf("default buffering also forwarded this fixture in %s; buffering alone does not establish a deployment failure", time.Since(started)) + } + return + } + if err != nil || string(body) != "synthetic-init" || response.StatusCode != http.StatusOK { + t.Fatal("Nginx buffered or rejected media initialization", err, string(body)) + } + if !strings.Contains(response.Header.Get("Cache-Control"), "no-store") || !strings.Contains(response.Header.Get("Set-Cookie"), "synthetic_seen=yes") { + t.Fatal("proxy lost privacy or browser identity headers", response.Header) + } + t.Logf("first media bytes arrived through Nginx in %s before upstream completion", time.Since(started)) + }) + } +} diff --git a/internal/app/ui_rankings.go b/internal/app/ui_rankings.go new file mode 100644 index 0000000..0d04291 --- /dev/null +++ b/internal/app/ui_rankings.go @@ -0,0 +1,109 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" +) + +func (a *UIApp) handleRankings(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + id := request.URL.Query().Get("board") + if id == "" { + writeJSON(writer, http.StatusOK, map[string]any{"boards": sourceRankingBoards(request.Context())}) + return + } + board, found := findRankingBoard(id) + pageText := firstNonEmpty(request.URL.Query().Get("page"), "1") + page, err := strconv.Atoi(pageText) + if !found || err != nil || page < 1 || page > 500 || board.Source == "huangguo" && page != 1 { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "榜单或页码无效"}) + return + } + if !requireSource(writer, request.Context(), board.Source) { + return + } + ctx, cancel := context.WithTimeout(request.Context(), 40*time.Second) + defer cancel() + result, err := a.downloader.loadRankingPage(ctx, board, page, request.URL.Query().Get("refresh") == "1") + if err != nil { + if request.Context().Err() != nil { + return + } + fmt.Printf("%s榜单获取失败: %v\n", board.Source, publicError(err)) + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "站点榜单暂不可用,请稍后重试"}) + return + } + if ctx.Err() != nil { + return + } + a.acceptRankingDramas(result) + a.mu.Lock() + saved := a.librarySaved && !a.libraryDirty + a.mu.Unlock() + writeJSON(writer, http.StatusOK, struct { + rankingPage + Saved bool `json:"saved"` + }{result, saved}) +} + +func (a *UIApp) acceptRankingDramas(page rankingPage) { + if len(page.Items) == 0 { + return + } + key := page.BoardID + ":" + strconv.Itoa(page.Page) + a.mu.Lock() + if a.rankingApplied == nil { + a.rankingApplied = map[string]time.Time{} + } + if a.rankingApplied[key].Equal(page.FetchedAt) { + a.mu.Unlock() + return + } + if len(a.rankingApplied) >= 256 { + a.rankingApplied = map[string]time.Time{} + } + a.rankingApplied[key] = page.FetchedAt + fresh := make([]Drama, 0, len(page.Items)) + for _, item := range page.Items { + fresh = append(fresh, item.Drama) + } + a.normalizeDramaCovers(fresh) + newDramas := a.newSortMetadataDramasLocked(fresh) + if page.Stale { + a.dramas = mergeLoadedDramas(fresh, a.dramas, nil) + } else { + a.dramas = mergeLoadedDramas(a.dramas, fresh, nil) + } + a.enqueueSortMetadataLocked(newDramas, false) + if a.librarySources == nil { + a.librarySources = map[string]librarySourceState{} + } + source := dramaProvider(fresh[0]) + state := a.librarySources[source] + state.Count = 0 + for _, drama := range a.dramas { + if dramaProvider(drama) == source { + state.Count++ + } + } + if state.Status == "" { + state.Status = "ready" + } + if page.FetchedAt.After(state.UpdatedAt) { + state.UpdatedAt = page.FetchedAt + } + a.librarySources[source] = state + if page.FetchedAt.After(a.loadedAt) { + a.loadedAt = page.FetchedAt + } + a.libraryDirty = true + a.libraryRevision++ + a.mu.Unlock() + a.persistLibrary() +} diff --git a/internal/app/ui_recommendations.go b/internal/app/ui_recommendations.go new file mode 100644 index 0000000..d5871a1 --- /dev/null +++ b/internal/app/ui_recommendations.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +func (app *UIApp) handleRecommendations(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + if request.Method != http.MethodPost { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var query hongguoRecommendationQuery + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 32768)) + if decoder.Decode(&query) != nil || decoder.Decode(new(any)) != io.EOF || query.validate() != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "推荐分类或分页参数无效,请重新获取"}) + return + } + if !requireSource(writer, request.Context(), sourceHongguo) { + return + } + ctx, cancel := context.WithTimeout(request.Context(), 45*time.Second) + defer cancel() + result, err := app.downloader.fetchHongguoRecommendations(ctx, query) + if request.Context().Err() != nil { + return + } + if err != nil { + fmt.Printf("红果分类推荐读取失败: %v\n", publicError(err)) + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "推荐暂不可用,已保留当前内容,可重试或重新获取"}) + return + } + result.Dramas = app.mergeRecommendedDramas(result.Dramas) + app.mu.Lock() + result.Saved = app.librarySaved && !app.libraryDirty + app.mu.Unlock() + writeJSON(writer, http.StatusOK, result) +} + +func (app *UIApp) mergeRecommendedDramas(items []Drama) []Drama { + dramas := append([]Drama{}, items...) + if len(dramas) == 0 { + return dramas + } + positions := make(map[string]int, len(dramas)) + for index, drama := range dramas { + positions[drama.ID] = index + } + app.mu.Lock() + app.normalizeDramaCovers(dramas) + newDramas := app.newSortMetadataDramasLocked(dramas) + app.dramas = mergeSourceDramas(app.dramas, dramas, nil, sourceHongguo) + app.enqueueSortMetadataLocked(newDramas, false) + app.loadedAt = time.Now() + if app.librarySources == nil { + app.librarySources = map[string]librarySourceState{} + } + state := app.librarySources[sourceHongguo] + state.Count = 0 + for _, drama := range app.dramas { + if dramaProvider(drama) == sourceHongguo { + state.Count++ + } + if index, found := positions[drama.ID]; found { + dramas[index] = drama + } + } + state.UpdatedAt = app.loadedAt + if state.Status == "" { + state.Status = "ready" + } + app.librarySources[sourceHongguo] = state + app.libraryDirty = true + app.libraryRevision++ + app.mu.Unlock() + app.persistLibrary() + return dramas +} diff --git a/internal/app/ui_request_origin_test.go b/internal/app/ui_request_origin_test.go new file mode 100644 index 0000000..c69998c --- /dev/null +++ b/internal/app/ui_request_origin_test.go @@ -0,0 +1,100 @@ +package app + +import ( + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strings" + "testing" + "time" +) + +func TestPlaybackRequestOriginPolicy(t *testing.T) { + for _, test := range []struct { + name, method, host, site, origin, forwarded string + want int + }{ + {"proxy same origin", "POST", "127.0.0.1:8998", "same-origin", "https://library.example.invalid:9443", "", 204}, + {"direct navigation", "POST", "127.0.0.1:8998", "none", "https://library.example.invalid", "", 204}, + {"direct older browser", "POST", "library.example.invalid:8998", "", "http://library.example.invalid:8998", "", 204}, + {"LAN IP direct", "POST", "192.168.1.5:8998", "", "http://192.168.1.5:8998", "", 204}, + {"LAN IP proxy same origin", "POST", "127.0.0.1:8998", "same-origin", "http://192.168.1.5:8998", "", 204}, + {"default https port", "POST", "library.example.invalid:443", "", "https://library.example.invalid", "", 204}, + {"default http port", "POST", "library.example.invalid:80", "", "http://library.example.invalid", "", 204}, + {"ipv6 default port", "POST", "[::1]:80", "", "http://[::1]", "", 204}, + {"case insensitive host", "POST", "LIBRARY.EXAMPLE.INVALID:8998", "", "http://library.example.invalid:8998", "", 204}, + {"API client", "POST", "127.0.0.1:8998", "", "", "", 204}, + {"cross site", "POST", "library.example.invalid", "cross-site", "https://library.example.invalid", "", 403}, + {"same site is not same origin", "POST", "library.example.invalid", "same-site", "https://library.example.invalid", "", 403}, + {"unrecognized fetch metadata", "POST", "library.example.invalid", "unrecognized", "https://library.example.invalid", "", 403}, + {"foreign older browser", "POST", "library.example.invalid", "", "https://other.example.invalid", "", 403}, + {"untrusted forwarded host", "POST", "library.example.invalid", "", "https://other.example.invalid", "other.example.invalid", 403}, + {"different port", "POST", "library.example.invalid:8998", "", "http://library.example.invalid:8999", "", 403}, + {"default port wrong scheme", "POST", "library.example.invalid:443", "", "http://library.example.invalid", "", 403}, + {"opaque origin", "POST", "library.example.invalid", "", "null", "", 403}, + {"credentials in origin", "POST", "library.example.invalid", "", "https://user@library.example.invalid", "", 403}, + {"wrong method", "GET", "127.0.0.1:8998", "same-origin", "", "", 405}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(test.method, "http://localhost/api/ui/playback/open", nil) + request.Host = test.host + request.Header.Set("Sec-Fetch-Site", test.site) + request.Header.Set("Origin", test.origin) + request.Header.Set("X-Forwarded-Host", test.forwarded) + request.Header.Set("Forwarded", "host="+test.forwarded+";proto=https") + writer := httptest.NewRecorder() + if playbackRequestAllowed(writer, request, http.MethodPost) { + writer.WriteHeader(http.StatusNoContent) + } + if writer.Code != test.want { + t.Fatalf("got %d, want %d: %s", writer.Code, test.want, writer.Body.String()) + } + }) + } +} + +func TestPlaybackRequestThroughHostRewritingProxy(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var input struct { + DramaID string `json:"dramaId"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + writer.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + proxy := httputil.NewSingleHostReverseProxy(target) + director := proxy.Director + proxy.Director = func(request *http.Request) { + director(request) + request.Header.Set("X-Forwarded-Host", request.Host) + request.Host = target.Host + } + public := httptest.NewServer(proxy) + defer public.Close() + client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: 3 * time.Second} + defer client.CloseIdleConnections() + for _, site := range []string{"same-origin", "cross-site", "same-site"} { + request, _ := http.NewRequest(http.MethodPost, public.URL+"/api/ui/playback/open", strings.NewReader(`{"dramaId":"hongguo:7000000000000000001"}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Origin", "https://library.example.invalid:9443") + request.Header.Set("Sec-Fetch-Site", site) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(response.Body) + response.Body.Close() + want := http.StatusForbidden + if site == "same-origin" { + want = http.StatusNoContent + } + if response.StatusCode != want { + t.Fatalf("proxy %s got %d, want %d: %s", site, response.StatusCode, want, body) + } + } +} diff --git a/internal/app/ui_server.go b/internal/app/ui_server.go new file mode 100644 index 0000000..4ccf02f --- /dev/null +++ b/internal/app/ui_server.go @@ -0,0 +1,1923 @@ +package app + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + uiStatusQueued = "queued" + uiStatusParsing = "parsing" + uiStatusRunning = "running" + uiStatusSuccess = "success" + uiStatusFailed = "failed" + uiStatusCanceled = "canceled" + uiStatusPaused = "paused" +) + +type UIApp struct { + downloader *Downloader + cfg Config + mu sync.Mutex + dramas []Drama + selected map[string]bool + tasks map[string]*UITask + taskOrder []string + merges map[string]*UIMergeState + lastError string + loadedAt time.Time + libraryLoading chan struct{} + librarySaved bool + libraryDirty bool + libraryAttempted bool + libraryRevision uint64 + librarySources map[string]librarySourceState + libraryError string + libraryCancel context.CancelFunc + libraryWriteMu sync.Mutex + libraryLastSave time.Time + libraryApp *hongguoCatalogState + libraryMore bool + libraryLoadingSource string + libraryMetadata libraryMetadataProgress + metadataQueue []string + metadataPending map[string]bool + metadataCancel context.CancelFunc + metadataDone chan struct{} + metadataClosed bool + rankingApplied map[string]time.Time + + cond *sync.Cond + statePath string + address string + runningCancels map[string]context.CancelFunc + mergeMu sync.Mutex + parsingInFlight map[string]bool + parsingCancels map[string]context.CancelFunc + closed bool + workerCount int + activeDownloads int + settingsMu sync.Mutex + nextOutputDir string + directoryPickerMu sync.Mutex + directoryPicker func(context.Context, string) (string, error) + playbackMu sync.Mutex + playbacks map[string]*playbackSession + playbackPrefetchSlots chan struct{} + playbackResourcesOnce sync.Once + playbackResources *playbackResources + viewersOnce sync.Once + viewers *viewerManager + embyMu sync.Mutex + embyKey []byte + embySyncOnce sync.Once + embySync *embySyncManager + dramaRefreshes map[string]*dramaRefreshCall + dramaRefreshSlots chan struct{} + coverImages coverImageCache + coverRepairs map[string]*coverRepairCall + coverRepairSlots chan struct{} +} +type uiState struct { + Dramas []Drama `json:"dramas,omitempty"` + LoadedAt time.Time `json:"loadedAt,omitempty"` + LastError string `json:"lastError,omitempty"` + TaskOrder []string `json:"taskOrder"` + Tasks []*UITask `json:"tasks"` + MergeStates map[string]*UIMergeState `json:"merges,omitempty"` +} + +type UIMergeState struct { + DramaID string `json:"dramaId"` + DramaTitle string `json:"dramaTitle,omitempty"` + Status string `json:"status"` + Progress int `json:"progress"` + Merged int `json:"merged"` + StartEpisode int `json:"startEpisode,omitempty"` + EndEpisode int `json:"endEpisode,omitempty"` + Total int `json:"total"` + OutputPath string `json:"outputPath,omitempty"` + Detail string `json:"detail,omitempty"` + Error string `json:"error,omitempty"` + Skipped bool `json:"skipped,omitempty"` + DeleteEpisodes bool `json:"deleteEpisodes,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type uiMergeResult struct { + DramaID string `json:"dramaId"` + DramaTitle string `json:"dramaTitle,omitempty"` + OK bool `json:"ok"` + Skipped bool `json:"skipped,omitempty"` + Merged int `json:"merged"` + StartEpisode int `json:"startEpisode,omitempty"` + EndEpisode int `json:"endEpisode,omitempty"` + OutputPath string `json:"outputPath,omitempty"` + Detail string `json:"detail,omitempty"` + Error string `json:"error,omitempty"` +} + +type UITask struct { + ID string `json:"id"` + DramaID string `json:"dramaId"` + DramaTitle string `json:"dramaTitle"` + Episode string `json:"episode"` + Title string `json:"title"` + Status string `json:"status"` + Progress int `json:"progress"` + Error string `json:"error,omitempty"` + Path string `json:"path"` + Attempt int `json:"attempt"` + DownloadedBytes int64 `json:"downloadedBytes"` + TotalBytes int64 `json:"totalBytes"` + SpeedBytesPerSecond float64 `json:"speedBytesPerSecond"` + ElapsedSeconds int64 `json:"elapsedSeconds"` + RemainingSeconds int64 `json:"remainingSeconds"` + MediaElapsedSeconds int64 `json:"mediaElapsedSeconds"` + MediaTotalSeconds int64 `json:"mediaTotalSeconds"` + Phase string `json:"phase"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CancelRequested bool `json:"cancelRequested,omitempty"` + RemoveRequested bool `json:"removeRequested,omitempty"` + PauseRequested bool `json:"pauseRequested,omitempty"` + Source Task `json:"task"` +} + +type uiTaskView struct { + DownloadQuality int `json:"downloadQuality"` + ID string `json:"id"` + DramaID string `json:"dramaId"` + DramaTitle string `json:"dramaTitle"` + Episode string `json:"episode"` + Title string `json:"title"` + Index int `json:"index"` + Total int `json:"total"` + Status string `json:"status"` + Progress int `json:"progress"` + Error string `json:"error,omitempty"` + Path string `json:"path"` + Attempt int `json:"attempt"` + DownloadedBytes int64 `json:"downloadedBytes"` + TotalBytes int64 `json:"totalBytes"` + SpeedBytesPerSecond float64 `json:"speedBytesPerSecond"` + ElapsedSeconds int64 `json:"elapsedSeconds"` + RemainingSeconds int64 `json:"remainingSeconds"` + MediaElapsedSeconds int64 `json:"mediaElapsedSeconds"` + MediaTotalSeconds int64 `json:"mediaTotalSeconds"` + Phase string `json:"phase"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CancelRequested bool `json:"cancelRequested,omitempty"` + RemoveRequested bool `json:"removeRequested,omitempty"` + PauseRequested bool `json:"pauseRequested,omitempty"` + ReleaseStatus string `json:"releaseStatus,omitempty"` + Playable bool `json:"playable"` +} + +func NewUIApp(d *Downloader) *UIApp { + cfg := d.cfg + if cfg.OutputDir == "" { + cfg.OutputDir = defaultOutputDir() + } + if cfg.Concurrency <= 0 { + cfg.Concurrency = 1 + } + a := &UIApp{ + downloader: d, + cfg: cfg, + selected: map[string]bool{}, + tasks: map[string]*UITask{}, + merges: map[string]*UIMergeState{}, + statePath: filepath.Join(cfg.dataDirectory(), "ui-state.json"), + runningCancels: map[string]context.CancelFunc{}, + parsingInFlight: map[string]bool{}, + parsingCancels: map[string]context.CancelFunc{}, + nextOutputDir: cfg.outputDirSetting, + } + a.cond = sync.NewCond(&a.mu) + a.loadState() + a.loadLibrary() + a.startWorkers() + return a +} + +func (a *UIApp) ListenAndServe(addr string) error { + if err := a.prepareBrowserViewers(); err != nil { + return err + } + defer a.stopSortMetadata() + a.embySyncer().start() + defer a.embySyncer().stop() + installer := a.downloader.ffmpegInstallation() + defer installer.stop() + go func() { + path, err := installer.ensure(context.Background()) + if err != nil { + fmt.Printf("FFmpeg 准备失败:%v\n", publicError(err)) + } else { + fmt.Printf("FFmpeg 已就绪:%s\n", path) + } + }() + a.mu.Lock() + a.address = addr + _ = a.saveStateLocked() + a.mu.Unlock() + + defer a.closePlaybacks() + + server := &http.Server{Addr: addr, Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second} + return server.ListenAndServe() +} + +func (a *UIApp) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", a.handleIndex) + mux.Handle("/assets/", webAssets()) + mux.HandleFunc("/api/ui/viewer", a.handleViewer) + mux.HandleFunc("/api/ui/viewer/legacy", a.handleViewerLegacy) + mux.HandleFunc("/api/ui/account/register", a.handleAccountRegister) + mux.HandleFunc("/api/ui/account/login", a.handleAccountLogin) + mux.HandleFunc("/api/ui/account/logout", a.handleAccountLogout) + mux.HandleFunc("/api/ui/account/password", a.handleAccountPassword) + mux.HandleFunc("/api/ui/account/import", a.handleAccountImport) + mux.HandleFunc("/api/ui/admin/settings", a.handleAdminSettings) + mux.HandleFunc("/api/ui/admin/accounts", a.handleAdminAccounts) + mux.HandleFunc("/api/ui/admin/accounts/sources", a.handleAdminAccountPermissions) + mux.HandleFunc("/api/ui/admin/accounts/permissions", a.handleAdminAccountPermissions) + mux.HandleFunc("/api/ui/dramas", a.handleDramas) + mux.HandleFunc("/api/ui/cover/repair", a.handleCoverRepair) + mux.HandleFunc("/api/ui/dramas/refresh", a.handleDramaRefresh) + mux.HandleFunc("/api/ui/vip/metadata", a.handleVIPMetadata) + mux.HandleFunc("/api/ui/search", a.handleLibrarySearch) + mux.HandleFunc("/api/ui/following", a.handleFollowing) + mux.HandleFunc("/api/ui/rankings", a.handleRankings) + mux.HandleFunc("/api/ui/recommendations", a.handleRecommendations) + mux.HandleFunc("/api/ui/download", a.handleDownload) + mux.HandleFunc("/api/ui/tasks", a.handleTasks) + mux.HandleFunc("/api/ui/update", a.handleUpdate) + mux.HandleFunc("/api/ui/tasks/retry", a.handleRetry) + mux.HandleFunc("/api/ui/tasks/cancel", a.handleCancel) + mux.HandleFunc("/api/ui/tasks/pause", a.handlePause) + mux.HandleFunc("/api/ui/tasks/resume", a.handleResume) + mux.HandleFunc("/api/ui/tasks/clear", a.handleClear) + mux.HandleFunc("/api/ui/merge", a.handleMerge) + mux.HandleFunc("/api/ui/ffmpeg", a.handleFFmpeg) + mux.HandleFunc("/api/ui/config", a.handleConfig) + mux.HandleFunc("/api/ui/network/check", a.handleNetworkCheck) + mux.HandleFunc("/api/ui/directory/pick", a.handleDirectoryPicker) + mux.HandleFunc("/api/ui/image", a.handleImage) + mux.HandleFunc("/api/ui/admin/emby", a.handleEmbySyncSettings) + mux.HandleFunc("/api/ui/admin/emby/sync", a.handleEmbySyncNow) + mux.HandleFunc("/api/emby/export", a.handleEmbyExport) + mux.HandleFunc("/api/emby/cover", a.handleEmbyCover) + mux.HandleFunc("/api/emby/stream.m3u8", a.handleEmbyStream) + mux.HandleFunc("/api/emby/segment.ts", a.handleEmbySegment) + a.registerPlaybackRoutes(mux) + return a.withAccountAccess(a.withBrowserViewer(mux)) +} + +func (a *UIApp) startWorkers() { + a.mu.Lock() + defer a.mu.Unlock() + a.ensureWorkersLocked() +} + +func (a *UIApp) ensureWorkersLocked() { + for a.workerCount < a.cfg.Concurrency { + a.workerCount++ + go a.worker() + } +} + +func (a *UIApp) worker() { + for { + a.mu.Lock() + var task *UITask + for task == nil && !a.closed { + if a.activeDownloads >= a.cfg.Concurrency { + a.cond.Wait() + continue + } + for _, id := range a.taskOrder { + candidate := a.tasks[id] + if candidate != nil && candidate.Status == uiStatusQueued { + task = candidate + break + } + } + if task == nil && !a.closed { + a.cond.Wait() + } + } + if a.closed { + a.mu.Unlock() + return + } + + now := time.Now() + a.activeDownloads++ + task.Status = uiStatusRunning + task.Progress = 0 + task.Error = "" + task.CancelRequested = false + task.PauseRequested = false + task.SpeedBytesPerSecond = 0 + task.DownloadedBytes = 0 + task.ElapsedSeconds = 0 + task.MediaElapsedSeconds = 0 + task.RemainingSeconds = 0 + task.Phase = "starting" + task.Attempt++ + task.UpdatedAt = now + taskID := task.ID + downloadTask := task.Source + ctx, cancel := context.WithTimeout(context.Background(), uiTaskTimeout(downloadTask)) + a.runningCancels[taskID] = cancel + _ = a.saveStateLocked() + a.mu.Unlock() + + err := a.downloader.DownloadEpisodeWithProgress(ctx, downloadTask, func(p DownloadProgress) { + a.mu.Lock() + current := a.tasks[taskID] + if current != nil && current.Status == uiStatusRunning { + current.Progress = p.Percent + current.DownloadedBytes = p.DownloadedBytes + current.TotalBytes = p.TotalBytes + current.SpeedBytesPerSecond = p.SpeedBytesPerSecond + current.ElapsedSeconds = int64(p.Elapsed / time.Second) + current.RemainingSeconds = int64(p.Remaining / time.Second) + current.MediaElapsedSeconds = int64(p.MediaElapsed / time.Second) + current.MediaTotalSeconds = int64(p.MediaTotal / time.Second) + current.Phase = p.Phase + current.UpdatedAt = time.Now() + } + a.mu.Unlock() + }) + cancel() + + a.mu.Lock() + a.activeDownloads-- + delete(a.runningCancels, taskID) + current := a.tasks[taskID] + if current != nil && current.Status == uiStatusRunning { + current.UpdatedAt = time.Now() + current.RemainingSeconds = 0 + current.SpeedBytesPerSecond = 0 + if current.PauseRequested && !current.RemoveRequested { + current.Status = uiStatusPaused + current.PauseRequested = false + current.CancelRequested = false + current.Error = "" + current.Phase = "paused" + } else if current.CancelRequested || errors.Is(err, context.Canceled) { + current.Status = uiStatusCanceled + current.Progress = 0 + current.Error = "已取消" + current.Phase = "canceled" + } else if err != nil { + current.Status = uiStatusFailed + current.Progress = 0 + if errors.Is(err, context.DeadlineExceeded) { + current.Error = "下载任务超时,已自动失败,可稍后重试" + } else { + current.Error = a.redactError(err) + } + current.Phase = "failed" + } else { + current.Status = uiStatusSuccess + current.Progress = 100 + current.Error = "" + current.Phase = "completed" + if st, statErr := os.Stat(current.Path); statErr == nil && !st.IsDir() { + current.DownloadedBytes = st.Size() + if current.TotalBytes <= 0 { + current.TotalBytes = st.Size() + } + } + } + if current.RemoveRequested { + a.removeTaskLocked(taskID) + } + _ = a.saveStateLocked() + } + a.mu.Unlock() + a.cond.Broadcast() + if err == nil { + a.notifyEmbySync() + } + } +} + +func (a *UIApp) migrateTaskPathLocked(task *UITask) bool { + if task == nil { + return false + } + if task.Path != "" || task.Source.OutPath != "" { + return false + } + index := task.Source.Index + if index <= 0 { + index = episodeNumber(task.Episode) + } + if index <= 0 { + return false + } + title := strings.TrimSpace(task.DramaTitle) + if title == "" { + title = strings.TrimSpace(task.Source.DramaTitle) + } + if title == "" { + return false + } + dramaID := strings.TrimSpace(task.DramaID) + if dramaID == "" { + dramaID = strings.TrimSpace(task.Source.DramaID) + } + dramaDir := filepath.Join(a.cfg.OutputDir, safeFilename(title)) + markerPath := filepath.Join(dramaDir, ".drama-id") + if marker, err := os.ReadFile(markerPath); err == nil && strings.TrimSpace(string(marker)) != "" && dramaID != "" && strings.TrimSpace(string(marker)) != dramaID { + dramaDir = filepath.Join(a.cfg.OutputDir, safeFilename(title+"_"+hashShort(dramaID))) + markerPath = filepath.Join(dramaDir, ".drama-id") + } + if err := os.MkdirAll(dramaDir, 0o755); err != nil { + return false + } + if dramaID != "" { + _ = os.WriteFile(markerPath, []byte(dramaID), 0o644) + } + newPath := filepath.Join(dramaDir, fmt.Sprintf("%03d.mp4", index)) + task.Path = newPath + task.Source.OutPath = newPath + task.DramaID = dramaID + task.Source.DramaID = dramaID + task.DramaTitle = title + task.Source.DramaTitle = title + return true +} + +func episodeNumber(value string) int { + value = strings.TrimSpace(value) + if n, err := strconv.Atoi(value); err == nil && n > 0 { + return n + } + return 0 +} + +func isInvalidPersistedProviderTask(task *UITask) bool { + if task == nil { + return false + } + providerTask := isHuangguoProviderSource(task.Source.Chapter.Source) + if !providerTask { + _, _, providerTask = splitProviderDramaID(task.DramaID) + } + if !providerTask { + _, _, providerTask = splitProviderDramaID(task.Source.DramaID) + } + if !providerTask { + return false + } + total := task.Source.Total + if total <= 0 || total > 300 { + return false + } + ep := episodeNumber(task.Episode) + if ep <= 0 { + ep = episodeNumber(task.Source.Chapter.EpisodeString(task.Source.Index)) + } + return ep > total +} + +func (a *UIApp) loadState() { + b, err := os.ReadFile(a.statePath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + a.lastError = a.redactString(err.Error()) + } + return + } + var state uiState + if err := json.Unmarshal(b, &state); err != nil { + a.lastError = a.redactString(err.Error()) + return + } + + a.mu.Lock() + defer a.mu.Unlock() + a.dramas = append([]Drama(nil), state.Dramas...) + a.normalizeDramaCovers(a.dramas) + a.loadedAt = state.LoadedAt + a.lastError = a.redactString(state.LastError) + if state.MergeStates != nil { + a.merges = state.MergeStates + } + seen := map[string]bool{} + changed := false + for _, task := range state.Tasks { + if task == nil || task.RemoveRequested { + changed = true + continue + } + if task.PauseRequested { + task.Status = uiStatusPaused + task.Phase = "paused" + task.PauseRequested = false + task.CancelRequested = false + changed = true + } + if task.Status == uiStatusRunning || task.Status == "" { + task.Status = uiStatusQueued + task.Progress = 0 + task.CancelRequested = false + task.SpeedBytesPerSecond = 0 + task.RemainingSeconds = 0 + task.Phase = "queued" + task.UpdatedAt = time.Now() + changed = true + } + if task.Status == uiStatusParsing || task.Phase == "parsing" { + task.Status = uiStatusFailed + task.Title = "章节获取失败" + task.Progress = 0 + task.Error = "上次章节解析中断,请点击重试重新获取章节" + task.Phase = "failed" + source := firstNonEmpty(task.Source.Chapter.Source, sourceFromDramaID(task.DramaID), sourceFromDramaID(task.Source.DramaID), "unknown") + sourceID := firstNonEmpty(task.Source.DramaID, task.DramaID) + if _, parsedID, ok := splitProviderDramaID(sourceID); ok { + sourceID = parsedID + } + task.Source.Chapter.Source = source + task.Source.Chapter.ID = providerChapterID(source, sourceID, "fetch-failed") + task.Source.Chapter.Title = "章节处理中" + task.UpdatedAt = time.Now() + changed = true + } + if task.Path == "" { + task.Path = task.Source.OutPath + changed = true + } + if task.Source.OutPath == "" { + task.Source.OutPath = task.Path + changed = true + } + if isInvalidPersistedProviderTask(task) { + changed = true + continue + } + if !isFetchFailedPlaceholderTask(task) { + if a.migrateTaskPathLocked(task) { + changed = true + } + } + if newID := uiTaskID(task.Path); newID != "" && task.ID != newID { + task.ID = newID + changed = true + } + if task.Status == uiStatusRunning || task.Status == "" { + task.Status = uiStatusQueued + task.Progress = 0 + task.CancelRequested = false + task.SpeedBytesPerSecond = 0 + task.RemainingSeconds = 0 + task.Phase = "queued" + task.UpdatedAt = time.Now() + changed = true + } + task.Error = a.redactString(task.Error) + if task.CreatedAt.IsZero() { + task.CreatedAt = time.Now() + changed = true + } + if task.UpdatedAt.IsZero() { + task.UpdatedAt = task.CreatedAt + changed = true + } + if task.ID != "" && !seen[task.ID] { + seen[task.ID] = true + a.tasks[task.ID] = task + } + } + for _, id := range state.TaskOrder { + if a.tasks[id] != nil && seen[id] { + a.taskOrder = append(a.taskOrder, id) + seen[id] = false + } + } + ids := make([]string, 0, len(a.tasks)) + for id := range a.tasks { + if seen[id] { + ids = append(ids, id) + } + } + sort.Strings(ids) + a.taskOrder = append(a.taskOrder, ids...) + if changed { + _ = a.saveStateLocked() + } +} + +func (a *UIApp) saveStateLocked() error { + directory := filepath.Dir(a.statePath) + if err := os.MkdirAll(directory, 0o755); err != nil { + a.lastError = a.redactString(err.Error()) + return err + } + state := uiState{ + LoadedAt: a.loadedAt, + LastError: a.lastError, + TaskOrder: append([]string(nil), a.taskOrder...), + Tasks: make([]*UITask, 0, len(a.tasks)), + MergeStates: cloneMergeStates(a.merges), + } + if !a.librarySaved { + state.Dramas = append([]Drama(nil), a.dramas...) + } + for _, id := range a.taskOrder { + if task := a.tasks[id]; task != nil { + state.Tasks = append(state.Tasks, task) + } + } + tmp, err := os.CreateTemp(directory, ".ui-state-*.tmp") + if err != nil { + a.lastError = a.redactString(err.Error()) + return err + } + tmpName := tmp.Name() + enc := json.NewEncoder(tmp) + enc.SetIndent("", " ") + if err := enc.Encode(state); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + a.lastError = a.redactString(err.Error()) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + a.lastError = a.redactString(err.Error()) + return err + } + if err := os.Rename(tmpName, a.statePath); err != nil { + _ = os.Remove(tmpName) + a.lastError = a.redactString(err.Error()) + return err + } + return nil +} + +func (a *UIApp) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/login" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(uiHTML)) +} + +func (a *UIApp) handleDramas(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + refresh := r.URL.Query().Get("refresh") == "1" + more := r.URL.Query().Get("more") == "1" + update := r.URL.Query().Get("update") == "1" + if refresh && more || update && (refresh || more) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "更新操作不能同时指定多个模式"}) + return + } + source := r.URL.Query().Get("source") + switch source { + case "", "huangguo", "cloudfront", sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou, sourceHongguo: + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不支持刷新该站源"}) + return + } + if source != "" && !requireSource(w, r.Context(), source) { + return + } + var priority []string + if raw := r.URL.Query().Get("priority"); (update || more) && raw != "" { + priority = strings.Split(raw, ",") + if len(raw) > 6000 || len(priority) > sortMetadataBatchSize { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "优先补齐条目过多"}) + return + } + for _, id := range priority { + if len(id) > 120 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "无效的剧集 ID"}) + return + } + } + } + if !a.requireDramaSources(w, r, priority) { + return + } + a.mu.Lock() + updateAccepted := update && a.libraryLoading == nil + if a.libraryLoading == nil && (update || refresh || more || len(a.dramas) == 0 && !a.libraryAttempted) { + mode := libraryLoadRefresh + if update { + mode = libraryLoadUpdate + } else if more { + mode = libraryLoadMore + } + a.startLibraryLoadLocked(source, mode, priority, r.Context()) + } + revision, _ := strconv.ParseUint(r.URL.Query().Get("revision"), 10, 64) + resp := a.librarySnapshotForSourceLocked(r.Context(), revision) + if update { + resp["updateAccepted"] = updateAccepted + } + a.mu.Unlock() + writeJSON(w, http.StatusOK, resp) +} + +func (a *UIApp) normalizeDramaCovers(dramas []Drama) { + for i := range dramas { + normalizeDramaCover(&dramas[i]) + } +} + +func normalizeDramaCover(drama *Drama) { + drama.OnlineDate = normalizeDate(drama.OnlineDate) + drama.Views = normalizeViews(drama.Views) + if p := bestDramaCover(*drama); p != "" { + drama.Cover = "/api/ui/image?url=" + url.QueryEscape(p) + } +} + +func bestDramaCover(dr Drama) string { + for _, v := range []any{dr.CoverURL, dr.CoverURLSnake, dr.Cover, dr.ImageURL, dr.ImageURLSnake, dr.Image, dr.Img, dr.Pic, dr.Picture, dr.Poster, dr.Thumb, dr.Thumbnail} { + if p := coverPathFromAny(v); p != "" { + return p + } + } + return "" +} + +func coverPathFromAny(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + s := strings.TrimSpace(x) + if s == "" { + return "" + } + if strings.HasPrefix(s, "/api/ui/image") { + if u, err := url.Parse(s); err == nil { + if raw := strings.TrimSpace(u.Query().Get("url")); raw != "" { + return raw + } + } + } + if u, err := url.Parse(s); err == nil && u.IsAbs() { + return u.String() + } + return strings.TrimLeft(s, "/") + case map[string]any: + for _, key := range []string{"url", "src", "path", "cover", "coverUrl", "cover_url", "image", "pic", "poster"} { + if s := coverPathFromAny(x[key]); s != "" { + return s + } + } + } + return "" +} + +func (a *UIApp) handleImage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + imgPath := strings.TrimSpace(r.URL.Query().Get("url")) + if imgPath == "" { + http.Error(w, "missing url", http.StatusBadRequest) + return + } + remoteURL, ok := buildImageURL(imgPath) + if !ok { + http.Error(w, "invalid image url", http.StatusBadRequest) + return + } + source, allowed := a.imageSource(r.Context(), remoteURL) + if !allowed { + writeSourceDenied(w) + return + } + ctx, cancel := context.WithTimeout(context.WithValue(r.Context(), coverSourceKey{}, source), 90*time.Second) + defer cancel() + buf, err := a.loadCoverImage(ctx, remoteURL, decodeImageBytes) + if err != nil { + if r.Context().Err() == nil { + remote, _ := url.Parse(remoteURL) + a.downloader.recordDiagnostic(diagnosticEvent{Event: "cover.failed", Host: remote.Hostname(), Message: a.redactError(err)}) + } + http.Error(w, a.redactError(err), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", imageContentType(buf)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, max-age=86400") + if sourceScopeRestricted(r.Context()) { + w.Header().Set("Cache-Control", "private, no-store") + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf) +} + +func buildImageURL(imgPath string) (string, bool) { + imgPath = strings.TrimSpace(imgPath) + if imgPath == "" { + return "", false + } + if u, err := url.Parse(imgPath); err == nil && u.IsAbs() { + if !validImageURL(u) { + return "", false + } + return u.String(), true + } + imgPath = strings.TrimLeft(imgPath, "/") + if imgPath == "" || strings.Contains(imgPath, "..") || strings.Contains(imgPath, "://") { + return "", false + } + if strings.HasPrefix(imgPath, "upload_01/") || strings.HasPrefix(imgPath, "upload/") { + return "https://pic.zdmhyg.cn/" + imgPath, true + } + return "https://zzzznnn.lkkwip.cn/" + imgPath, true +} + +func isHuangguoImageCDNHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + return host == "pic.tuafjz.cn" || strings.HasSuffix(host, ".zdmhyg.cn") +} + +func sourceCoverReferer(source, remoteURL string) string { + switch source { + case sourceHongguo: + return hongguoBaseURL + "/" + case sourceHuangguoAI: + return "https://huangguoai.com/" + case sourceHuangguoVideo: + return "https://huangguo.video/" + case sourceHuangdou: + if remote, err := url.Parse(remoteURL); err == nil { + host := strings.ToLower(remote.Hostname()) + if host == "tideember.cc" || host == "xqjurgek.top" { + return remote.Scheme + "://" + remote.Host + "/home" + } + } + return huangdouBaseURL + "/home" + } + return imageReferer(remoteURL) +} + +func imageReferer(remoteURL string) string { + if u, err := url.Parse(remoteURL); err == nil { + if isHuangguoImageCDNHost(u.Hostname()) { + return "https://huangguoai.com/" + } + switch strings.ToLower(u.Hostname()) { + case "huangguoai.com", "www.huangguoai.com": + return "https://huangguoai.com/" + case "huangguo.video", "cdn.huangguo.video": + return "https://huangguo.video/" + case "tideember.cc", "xqjurgek.top": + return u.Scheme + "://" + u.Host + "/home" + case "d3rorc0p4i1kyz.cloudfront.net": + return huangdouBaseURL + "/home" + } + if isHongguoImageHost(u.Hostname()) { + return hongguoBaseURL + "/" + } + } + return "https://d2pypzndaqisk.cloudfront.net/" +} + +func decodeImageBytes(buf []byte, remoteURL string) []byte { + if isKnownImage(buf) { + return buf + } + if decoded := decryptHuangguoImage(buf); isKnownImage(decoded) || isHEICImage(decoded) { + return decoded + } + copyBuf := append([]byte(nil), buf...) + decryptImageHeader(copyBuf) + if isKnownImage(copyBuf) { + return copyBuf + } + return buf +} + +func decryptHuangguoImage(buf []byte) []byte { + if len(buf) == 0 || len(buf)%aes.BlockSize != 0 { + return nil + } + block, err := aes.NewCipher([]byte("f5d965df75336270")) + if err != nil { + return nil + } + out := make([]byte, len(buf)) + cipher.NewCBCDecrypter(block, []byte("97b60394abc2fbe1")).CryptBlocks(out, buf) + if len(out) > 0 { + pad := int(out[len(out)-1]) + if pad > 0 && pad <= aes.BlockSize && pad <= len(out) { + valid := true + for _, b := range out[len(out)-pad:] { + if int(b) != pad { + valid = false + break + } + } + if valid { + out = out[:len(out)-pad] + } + } + } + return out +} + +func decryptImageHeader(buf []byte) { + key := []byte("2019ysapp7527") + limit := len(buf) + if limit > 100 { + limit = 100 + } + for i := 0; i < limit; i++ { + buf[i] ^= key[i%len(key)] + } +} + +func imageContentType(buf []byte) string { + if len(buf) >= 12 && buf[0] == 'R' && buf[1] == 'I' && buf[2] == 'F' && buf[3] == 'F' && buf[8] == 'W' && buf[9] == 'E' && buf[10] == 'B' && buf[11] == 'P' { + return "image/webp" + } + if len(buf) >= 8 && buf[0] == 0x89 && buf[1] == 0x50 && buf[2] == 0x4e && buf[3] == 0x47 { + return "image/png" + } + if len(buf) >= 3 && buf[0] == 0x47 && buf[1] == 0x49 && buf[2] == 0x46 { + return "image/gif" + } + if len(buf) >= 2 && buf[0] == 0xff && buf[1] == 0xd8 { + return "image/jpeg" + } + return http.DetectContentType(buf) +} + +func isKnownImage(buf []byte) bool { + return imageContentType(buf) == "image/jpeg" || imageContentType(buf) == "image/png" || imageContentType(buf) == "image/gif" || imageContentType(buf) == "image/webp" +} + +func (a *UIApp) handleDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + ids, quality, ok := readDownloadRequest(w, r) + if !ok { + return + } + if !a.requireDramaSources(w, r, ids) { + return + } + views := a.enqueueDramasAsync(ids, false, quality) + writeJSON(w, http.StatusAccepted, map[string]any{"data": views, "pending": true}) +} + +func (a *UIApp) handleUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + ids, quality, ok := readDownloadRequest(w, r) + if !ok { + return + } + if !a.requireDramaSources(w, r, ids) { + return + } + views := a.enqueueDramasAsync(ids, true, quality) + writeJSON(w, http.StatusAccepted, map[string]any{"data": views, "pending": true}) +} + +func (a *UIApp) enqueueDramasAsync(ids []string, updateOnly bool, requestedQuality *int) []uiTaskView { + a.mu.Lock() + if a.parsingInFlight == nil { + a.parsingInFlight = map[string]bool{} + } + dramaByID := make(map[string]Drama, len(a.dramas)) + for _, dr := range a.dramas { + if strings.TrimSpace(dr.ID) != "" { + dramaByID[dr.ID] = dr + } + } + views := make([]uiTaskView, 0, len(ids)) + dramas := make([]Drama, 0, len(ids)) + for _, id := range ids { + drama, found := dramaByID[id] + if !found { + drama = Drama{ID: id, Title: "短剧"} + } + dramaKey := placeholderDramaKey(drama) + if a.parsingInFlight[dramaKey] { + if view, ok := a.existingParsingPlaceholderViewLocked(drama); ok { + views = append(views, view) + } + continue + } + quality := 0 + if updateOnly { + for _, taskID := range a.taskOrder { + if task := a.tasks[taskID]; task != nil && task.DramaID == drama.ID { + quality = task.Source.DownloadQuality + break + } + } + } + if requestedQuality != nil { + quality = *requestedQuality + } + dramas = append(dramas, drama) + a.parsingInFlight[dramaKey] = true + if view, changed := a.addParsingPlaceholderTaskLocked(drama, quality); view.ID != "" { + views = append(views, view) + if changed { + a.cond.Broadcast() + } + } + } + _ = a.saveStateLocked() + a.mu.Unlock() + for _, drama := range dramas { + a.startDramaParse(drama, updateOnly) + } + return views +} + +func placeholderDramaKey(drama Drama) string { + if id := strings.TrimSpace(drama.ID); id != "" { + return id + } + return strings.Join([]string{strings.TrimSpace(drama.Source), strings.TrimSpace(drama.SourceID), drama.DisplayTitle()}, "|") +} + +func (a *UIApp) existingParsingPlaceholderViewLocked(drama Drama) (uiTaskView, bool) { + task := a.placeholderTask(drama, "parsing") + id := uiTaskID(task.OutPath) + if existing := a.tasks[id]; existing != nil && existing.Status == uiStatusParsing && existing.Phase == "parsing" { + return taskToView(existing), true + } + return uiTaskView{}, false +} + +func (a *UIApp) markParsingFinished(dramas []Drama) { + a.mu.Lock() + defer a.mu.Unlock() + if a.parsingInFlight == nil { + return + } + for _, drama := range dramas { + delete(a.parsingInFlight, placeholderDramaKey(drama)) + delete(a.parsingCancels, placeholderDramaKey(drama)) + } +} + +func (a *UIApp) enqueueDramas(ctx context.Context, dramas []Drama, updateOnly bool, quality int) ([]uiTaskView, []uiTaskView, string) { + views := []uiTaskView{} + failedViews := []uiTaskView{} + respErr := "" + for _, drama := range dramas { + a.mu.Lock() + existingDirectory := "" + for _, id := range a.taskOrder { + if task := a.tasks[id]; task != nil && task.DramaID == drama.ID && !isChapterPlaceholderTask(task) && task.Path != "" { + existingDirectory = filepath.Dir(task.Path) + break + } + } + a.mu.Unlock() + built, err := a.downloader.buildDramaTasksInDirectory(ctx, drama, existingDirectory) + if err != nil { + msg := fmt.Sprintf("《%s》加入失败:%s", drama.DisplayTitle(), a.redactError(err)) + if respErr != "" { + respErr += "\n" + } + respErr += msg + a.mu.Lock() + if a.finishCanceledParseLocked(drama) { + a.mu.Unlock() + continue + } + a.lastError = respErr + a.removeParsingPlaceholderTaskLocked(drama) + if view, changed := a.addFailedPlaceholderTaskLocked(drama, err, quality); view.ID != "" { + failedViews = append(failedViews, view) + if changed { + a.cond.Broadcast() + } + } + _ = a.saveStateLocked() + a.mu.Unlock() + continue + } + if len(built) == 0 { + msg := fmt.Sprintf("《%s》没有可下载章节", drama.DisplayTitle()) + if respErr != "" { + respErr += "\n" + } + respErr += msg + a.mu.Lock() + if a.finishCanceledParseLocked(drama) { + a.mu.Unlock() + continue + } + a.lastError = respErr + a.removeParsingPlaceholderTaskLocked(drama) + if view, changed := a.addFailedPlaceholderTaskLocked(drama, errors.New("没有可下载章节"), quality); view.ID != "" { + failedViews = append(failedViews, view) + if changed { + a.cond.Broadcast() + } + } + _ = a.saveStateLocked() + a.mu.Unlock() + continue + } + + a.mu.Lock() + if a.finishCanceledParseLocked(drama) { + a.mu.Unlock() + continue + } + a.removeParsingPlaceholderTaskLocked(drama) + a.removeFailedPlaceholderTaskLocked(drama) + pathIndex := a.pathIndexLocked() + now := time.Now() + changed := true + for _, task := range built { + task.DownloadQuality = quality + if existing := pathIndex[task.OutPath]; existing != nil { + task.DownloadQuality = existing.Source.DownloadQuality + if existing.Status != uiStatusRunning { + existing.Source = task + } + if existing.Status == uiStatusSuccess { + if fileOK, _ := existingGood(existing.Path, a.cfg.SkipBytes); fileOK { + views = append(views, taskToView(existing)) + continue + } + if updateOnly { + existing.Status = uiStatusQueued + existing.Progress = 0 + existing.Error = "" + existing.Phase = "queued" + existing.DownloadedBytes = 0 + existing.TotalBytes = task.Chapter.MediaSize + existing.UpdatedAt = now + views = append(views, taskToView(existing)) + changed = true + continue + } + } + views = append(views, taskToView(existing)) + continue + } + uiTask := newUITask(task, now) + if ok, size := existingGood(task.OutPath, a.cfg.SkipBytes); ok { + uiTask.Status = uiStatusSuccess + uiTask.Progress = 100 + uiTask.DownloadedBytes = size + if uiTask.TotalBytes <= 0 { + uiTask.TotalBytes = size + } + uiTask.Phase = "completed" + } + a.tasks[uiTask.ID] = uiTask + a.taskOrder = append(a.taskOrder, uiTask.ID) + pathIndex[uiTask.Path] = uiTask + views = append(views, taskToView(uiTask)) + changed = true + } + if changed { + _ = a.saveStateLocked() + a.cond.Broadcast() + } + a.mu.Unlock() + } + return views, failedViews, respErr +} + +func (a *UIApp) addParsingPlaceholderTaskLocked(drama Drama, quality int) (uiTaskView, bool) { + task := a.placeholderTask(drama, "parsing") + task.DownloadQuality = quality + uiTask := newUITask(task, time.Now()) + uiTask.Status = uiStatusParsing + uiTask.Progress = 0 + uiTask.Phase = "parsing" + uiTask.Title = "正在解析章节" + if existing := a.tasks[uiTask.ID]; existing != nil { + existing.Source = task + existing.Status = uiStatusParsing + existing.CancelRequested = false + existing.PauseRequested = false + existing.RemoveRequested = false + existing.Progress = 0 + existing.Error = "" + existing.Phase = "parsing" + existing.Title = "正在解析章节" + existing.UpdatedAt = uiTask.UpdatedAt + return taskToView(existing), false + } + a.tasks[uiTask.ID] = uiTask + a.taskOrder = append(a.taskOrder, uiTask.ID) + return taskToView(uiTask), true +} + +func (a *UIApp) removeParsingPlaceholderTaskLocked(drama Drama) { + task := a.placeholderTask(drama, "parsing") + id := uiTaskID(task.OutPath) + if id == "" { + return + } + if existing := a.tasks[id]; existing == nil || existing.Phase != "parsing" { + return + } + delete(a.tasks, id) + for i, taskID := range a.taskOrder { + if taskID == id { + a.taskOrder = append(a.taskOrder[:i], a.taskOrder[i+1:]...) + break + } + } +} + +func (a *UIApp) placeholderTask(drama Drama, suffix string) Task { + title := drama.DisplayTitle() + if title == "" { + title = "短剧" + } + dramaID := strings.TrimSpace(drama.ID) + if dramaID == "" { + dramaID = title + } + dramaDir, err := safeJoin(a.cfg.OutputDir, safeFilename(title)) + if err != nil { + dramaDir = filepath.Join(a.cfg.OutputDir, safeFilename(title)) + } + outPath := filepath.Join(dramaDir, ".placeholder-"+hashShort(dramaID)+".txt") + chapterSource := firstNonEmpty(drama.Source, sourceFromDramaID(dramaID), "unknown") + return Task{DramaID: dramaID, DramaTitle: title, Chapter: Chapter{ID: providerChapterID(chapterSource, firstNonEmpty(drama.SourceID, dramaID), suffix), Source: chapterSource, Title: "章节处理中", CurrentEpisode: rawEpisode(1)}, Index: 1, Total: 1, OutPath: outPath} +} + +func sourceFromDramaID(id string) string { + if source, _, ok := splitProviderDramaID(id); ok { + return source + } + return "" +} + +func (a *UIApp) addFailedPlaceholderTaskLocked(drama Drama, cause error, quality int) (uiTaskView, bool) { + task := a.placeholderTask(drama, "fetch-failed") + task.DownloadQuality = quality + uiTask := newUITask(task, time.Now()) + uiTask.Title = "章节获取失败" + uiTask.Status = uiStatusFailed + uiTask.Progress = 0 + uiTask.Error = a.redactError(cause) + uiTask.Phase = "failed" + if existing := a.tasks[uiTask.ID]; existing != nil { + existing.Source = task + existing.Status = uiStatusFailed + existing.Title = "章节获取失败" + existing.Progress = 0 + existing.Error = uiTask.Error + existing.Phase = "failed" + existing.UpdatedAt = uiTask.UpdatedAt + return taskToView(existing), false + } + a.tasks[uiTask.ID] = uiTask + a.taskOrder = append(a.taskOrder, uiTask.ID) + return taskToView(uiTask), true +} + +func (a *UIApp) removeFailedPlaceholderTaskLocked(drama Drama) { + task := a.placeholderTask(drama, "fetch-failed") + id := uiTaskID(task.OutPath) + if id == "" { + return + } + if existing := a.tasks[id]; existing == nil || !isFetchFailedPlaceholderTask(existing) { + return + } + delete(a.tasks, id) + for i, taskID := range a.taskOrder { + if taskID == id { + a.taskOrder = append(a.taskOrder[:i], a.taskOrder[i+1:]...) + break + } + } +} + +func isFetchFailedPlaceholderTask(task *UITask) bool { + return task != nil && task.Phase == "failed" && task.Title == "章节获取失败" && strings.Contains(task.Source.Chapter.ID, ":fetch-failed") +} + +func dramaFromPlaceholderTask(task *UITask) Drama { + if task == nil { + return Drama{} + } + drama := Drama{ID: task.DramaID, Title: task.DramaTitle, Name: task.DramaTitle} + drama.Source = firstNonEmpty(task.Source.Chapter.Source, sourceFromDramaID(task.DramaID)) + if _, sourceID, ok := splitProviderDramaID(task.DramaID); ok { + drama.SourceID = sourceID + } else if task.Source.Chapter.ID != "" { + parts := strings.Split(task.Source.Chapter.ID, ":") + if len(parts) >= 2 { + drama.SourceID = parts[1] + } + } + return drama +} + +func (a *UIApp) handleTasks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.mu.Lock() + views := a.taskViewsForSourceLocked(r.Context()) + merges := cloneMergeStates(a.merges) + for id := range merges { + if !dramaAllowed(r.Context(), id, "") { + delete(merges, id) + } + } + a.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"data": views, "merges": merges, "ffmpeg": a.downloader.ffmpegInstallation().snapshot()}) +} + +func (a *UIApp) handleRetry(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + selection, ok := readTaskSelection(w, r) + if !ok { + return + } + a.mu.Lock() + if !a.requireTaskSourcesLocked(w, r, selection) { + a.mu.Unlock() + return + } + if a.parsingInFlight == nil { + a.parsingInFlight = map[string]bool{} + } + now := time.Now() + changed := false + var reparses []Drama + for _, id := range selection.idsLocked(a) { + task := a.tasks[id] + if task == nil || task.RemoveRequested || task.Status != uiStatusFailed && task.Status != uiStatusCanceled { + continue + } + if isChapterPlaceholderTask(task) { + drama := dramaFromPlaceholderTask(task) + dramaKey := placeholderDramaKey(drama) + if a.parsingInFlight[dramaKey] { + continue + } + a.parsingInFlight[dramaKey] = true + a.removeFailedPlaceholderTaskLocked(drama) + if _, placeholderChanged := a.addParsingPlaceholderTaskLocked(drama, task.Source.DownloadQuality); placeholderChanged { + changed = true + } + reparses = append(reparses, drama) + changed = true + continue + } + task.Status = uiStatusQueued + task.Progress = 0 + task.Error = "" + task.CancelRequested = false + task.PauseRequested = false + task.DownloadedBytes = 0 + task.ElapsedSeconds = 0 + task.MediaElapsedSeconds = 0 + task.SpeedBytesPerSecond = 0 + task.RemainingSeconds = 0 + task.Phase = "queued" + task.UpdatedAt = now + changed = true + } + if changed { + _ = a.saveStateLocked() + a.cond.Broadcast() + } + views := a.taskViewsForSourceLocked(r.Context()) + a.mu.Unlock() + for _, drama := range reparses { + a.startDramaParse(drama, true) + } + writeJSON(w, http.StatusOK, map[string]any{"data": views, "pending": len(reparses) > 0}) +} + +func (a *UIApp) handleCancel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + selection, ok := readTaskSelection(w, r) + if !ok { + return + } + a.mu.Lock() + if !a.requireTaskSourcesLocked(w, r, selection) { + a.mu.Unlock() + return + } + now := time.Now() + changed := false + for _, id := range selection.idsLocked(a) { + task := a.tasks[id] + if task == nil { + continue + } + task.PauseRequested = false + switch task.Status { + case uiStatusParsing: + task.CancelRequested = true + if cancel := a.parsingCancels[task.DramaID]; cancel != nil { + cancel() + } + changed = true + case uiStatusQueued, uiStatusPaused: + task.Status = uiStatusCanceled + task.Progress = 0 + task.Error = "已取消" + task.CancelRequested = false + task.RemainingSeconds = 0 + task.SpeedBytesPerSecond = 0 + task.Phase = "canceled" + task.UpdatedAt = now + changed = true + case uiStatusRunning: + task.CancelRequested = true + task.UpdatedAt = now + if cancel := a.runningCancels[id]; cancel != nil { + cancel() + } + changed = true + } + } + if changed { + _ = a.saveStateLocked() + } + views := a.taskViewsForSourceLocked(r.Context()) + a.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"data": views}) +} + +func (a *UIApp) handleClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + ids, ok := readIDsRequest(w, r) + if !ok { + return + } + a.mu.Lock() + if !a.requireTaskSourcesLocked(w, r, taskSelection{IDs: ids}) { + a.mu.Unlock() + return + } + removed, pending := 0, 0 + for _, id := range ids { + task := a.tasks[id] + if task == nil { + continue + } + if task.Status == uiStatusRunning || task.Status == uiStatusParsing { + task.RemoveRequested = true + task.CancelRequested = true + if cancel := a.runningCancels[id]; cancel != nil { + cancel() + } + if task.Status == uiStatusParsing { + if cancel := a.parsingCancels[task.DramaID]; cancel != nil { + cancel() + } + } + pending++ + } else { + a.removeTaskLocked(id) + removed++ + } + } + _ = a.saveStateLocked() + views := a.taskViewsForSourceLocked(r.Context()) + a.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"data": views, "removed": removed, "pending": pending}) +} + +func (a *UIApp) handleMerge(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + ids, deleteEpisodes, ok := readMergeRequest(w, r) + if !ok { + return + } + + if !a.requireDramaSources(w, r, ids) { + return + } + a.mergeMu.Lock() + defer a.mergeMu.Unlock() + + a.mu.Lock() + if !a.requireTaskSourcesLocked(w, r, taskSelection{DramaIDs: ids}) { + a.mu.Unlock() + return + } + byDrama := map[string][]*UITask{} + for _, id := range a.taskOrder { + task := a.tasks[id] + if task == nil || task.Status != uiStatusSuccess { + continue + } + byDrama[task.DramaID] = append(byDrama[task.DramaID], cloneUITask(task)) + } + a.mu.Unlock() + + results := make([]uiMergeResult, 0, len(ids)) + for _, dramaID := range ids { + items := byDrama[dramaID] + res := a.mergeDrama(r.Context(), dramaID, items, deleteEpisodes) + results = append(results, res) + } + writeJSON(w, http.StatusOK, map[string]any{"data": results}) +} + +func (a *UIApp) mergeDrama(ctx context.Context, dramaID string, tasks []*UITask, deleteEpisodes bool) uiMergeResult { + res := uiMergeResult{DramaID: dramaID} + if len(tasks) == 0 { + res.Error = "没有已完成的分集可合并" + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } + sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].Source.Index < tasks[j].Source.Index }) + valid := make([]*UITask, 0, len(tasks)) + seenEp := map[int]bool{} + startEp, endEp := 0, 0 + for _, task := range tasks { + if task == nil || task.Path == "" { + continue + } + ep := task.Source.Index + if ep <= 0 { + ep = episodeNumber(task.Episode) + } + if ep <= 0 || seenEp[ep] { + continue + } + if st, err := os.Stat(task.Path); err == nil && !st.IsDir() && st.Size() > 0 { + seenEp[ep] = true + valid = append(valid, task) + if startEp == 0 || ep < startEp { + startEp = ep + } + if ep > endEp { + endEp = ep + } + } + } + if len(valid) == 0 { + res.Error = "已完成任务的文件不存在" + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } + for ep := startEp; ep <= endEp; ep++ { + if !seenEp[ep] { + res.DramaTitle = valid[0].DramaTitle + res.StartEpisode = startEp + res.EndEpisode = endEp + res.Merged = len(valid) + res.Error = fmt.Sprintf("缺少第%03d集,未执行合并", ep) + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } + } + res.DramaTitle = valid[0].DramaTitle + res.Merged = len(valid) + res.StartEpisode = startEp + res.EndEpisode = endEp + dir := filepath.Dir(valid[0].Path) + outPath := filepath.Join(dir, mergeOutputName(res.DramaTitle, startEp, endEp)) + res.OutputPath = outPath + if ok, _ := existingGood(outPath, a.cfg.SkipBytes); ok { + res.OK = true + res.Skipped = true + a.setMergeState(res, "success", 100, true, deleteEpisodes) + return res + } + + paths := make([]string, 0, len(valid)) + for _, task := range valid { + paths = append(paths, task.Path) + } + partPath := filepath.Join(dir, "."+strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath))+".part.mp4") + _ = os.Remove(partPath) + defer os.Remove(partPath) + cmdCtx, cancel := context.WithTimeout(ctx, 2*time.Hour) + defer cancel() + res.Detail = "正在准备 FFmpeg" + a.setMergeState(res, "running", 0, false, deleteEpisodes) + ffmpeg, err := a.downloader.ensureFFmpeg(cmdCtx) + if err != nil { + res.Error = a.redactError(err) + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } + method, err := mergeMediaFiles(cmdCtx, ffmpeg, paths, partPath, func(progress int, detail string) { + res.Detail = detail + a.setMergeState(res, "running", progress, false, deleteEpisodes) + }) + if err != nil { + res.Error = a.redactError(err) + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } + res.Detail = method + if ok, _ := existingGood(partPath, a.cfg.SkipBytes); !ok { + res.Error = "合并输出文件无效" + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } + if err := os.Rename(partPath, outPath); err != nil { + res.Error = a.redactError(err) + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } + res.OK = true + if deleteEpisodes { + for _, task := range valid { + _ = os.Remove(task.Path) + } + } + a.setMergeState(res, "success", 100, false, deleteEpisodes) + return res +} + +func cloneMergeStates(in map[string]*UIMergeState) map[string]*UIMergeState { + out := make(map[string]*UIMergeState, len(in)) + for id, state := range in { + if state == nil { + continue + } + copyState := *state + out[id] = ©State + } + return out +} + +func mergeOutputName(title string, start, end int) string { + if start <= 0 { + start = 1 + } + if end < start { + end = start + } + return fmt.Sprintf("%s%d-%d.mp4", safeFilename(title), start, end) +} + +func (a *UIApp) setMergeState(res uiMergeResult, status string, progress int, skipped, deleteEpisodes bool) { + state := &UIMergeState{ + DramaID: res.DramaID, DramaTitle: res.DramaTitle, Status: status, Progress: progress, + Merged: res.Merged, StartEpisode: res.StartEpisode, EndEpisode: res.EndEpisode, + Total: res.Merged, OutputPath: res.OutputPath, Detail: res.Detail, Error: res.Error, Skipped: skipped, + DeleteEpisodes: deleteEpisodes, UpdatedAt: time.Now(), + } + a.mu.Lock() + if a.merges == nil { + a.merges = map[string]*UIMergeState{} + } + a.merges[res.DramaID] = state + _ = a.saveStateLocked() + a.mu.Unlock() +} + +func readMergeRequest(w http.ResponseWriter, r *http.Request) ([]string, bool, bool) { + var req struct { + DramaIDs []string `json:"dramaIds"` + DeleteEpisodes bool `json:"deleteEpisodes"` + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()}) + return nil, false, false + } + ids, ok := cleanIDList(w, req.DramaIDs) + return ids, req.DeleteEpisodes, ok +} + +func (a *UIApp) handleConfig(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + if !a.updateRuntimeSettings(w, r) { + return + } + } else if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.mu.Lock() + addr := a.address + cfg := a.cfg + nextOutputDir := firstNonEmpty(a.nextOutputDir, cfg.outputDirSetting, cfg.OutputDir) + a.mu.Unlock() + proxyMode, proxyURL, proxyHasAuth := publicProxyConfig(cfg.ProxyURL) + writeJSON(w, http.StatusOK, map[string]any{"outputDir": cfg.OutputDir, "outputDirSetting": nextOutputDir, "restartRequired": !sameDirectory(cfg.OutputDir, nextOutputDir), "dataDir": cfg.dataDirectory(), "concurrency": cfg.Concurrency, "ffmpeg": cfg.FFmpeg, "address": addr, "libraryCachePath": libraryCachePath(cfg.dataDirectory()), "requestConcurrency": cfg.RequestConcurrency, "requestIntervalMs": cfg.RequestIntervalMS, "network": a.downloader.proxyRouter.summary(), "proxyMode": proxyMode, "proxyURL": proxyURL, "proxyHasAuth": proxyHasAuth}) +} + +func readIDsRequest(w http.ResponseWriter, r *http.Request) ([]string, bool) { + var req struct { + IDs []string `json:"ids"` + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()}) + return nil, false + } + return cleanIDList(w, req.IDs) +} + +func readDramaIDsRequest(w http.ResponseWriter, r *http.Request) ([]string, bool) { + var req struct { + DramaIDs []string `json:"dramaIds"` + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()}) + return nil, false + } + return cleanIDList(w, req.DramaIDs) +} + +func cleanIDList(w http.ResponseWriter, in []string) ([]string, bool) { + seen := map[string]bool{} + ids := make([]string, 0, len(in)) + for _, id := range in { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + if len(ids) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ids is required"}) + return nil, false + } + return ids, true +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func uiTaskTimeout(task Task) time.Duration { + total := task.Total + if total <= 0 { + total = 1 + } + + timeout := time.Duration(20+total*3) * time.Minute + if timeout < 30*time.Minute { + return 30 * time.Minute + } + if timeout > 2*time.Hour { + return 2 * time.Hour + } + return timeout +} + +func newUITask(task Task, now time.Time) *UITask { + episode := task.Chapter.EpisodeString(task.Index) + title := strings.TrimSpace(task.Chapter.Title) + if title == "" || strings.TrimSpace(title) == strings.TrimSpace(episode) { + title = "第" + padEpisode(episode) + "集" + } + return &UITask{ + ID: uiTaskID(task.OutPath), DramaID: task.DramaID, DramaTitle: task.DramaTitle, + Episode: episode, Title: title, Status: uiStatusQueued, Progress: 0, + TotalBytes: task.Chapter.MediaSize, Phase: "queued", Path: task.OutPath, + CreatedAt: now, UpdatedAt: now, Source: task, + } +} + +func uiTaskID(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + sum := sha256.Sum256([]byte(path)) + return hex.EncodeToString(sum[:])[:16] +} + +func cloneUITask(task *UITask) *UITask { + if task == nil { + return nil + } + cp := *task + return &cp +} + +func (a *UIApp) pathIndexLocked() map[string]*UITask { + out := make(map[string]*UITask, len(a.tasks)) + for _, task := range a.tasks { + if task == nil { + continue + } + if task.Path != "" { + out[task.Path] = task + } + if task.Source.OutPath != "" { + out[task.Source.OutPath] = task + } + } + return out +} + +func (a *UIApp) taskViewsLocked() []uiTaskView { + views := make([]uiTaskView, 0, len(a.taskOrder)) + releases := make(map[string]string, len(a.dramas)) + for _, drama := range a.dramas { + releases[drama.ID] = dramaReleaseStatus(drama) + } + for _, id := range a.taskOrder { + if task := a.tasks[id]; task != nil { + view := taskToView(task) + if status := releases[task.DramaID]; status != "" { + view.ReleaseStatus = status + } + views = append(views, view) + } + } + return views +} + +func taskToView(task *UITask) uiTaskView { + return uiTaskView{ + ID: task.ID, DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Episode, Title: task.Title, + Index: task.Source.Index, Total: task.Source.Total, Status: task.Status, Progress: task.Progress, Error: task.Error, + Path: task.Path, Attempt: task.Attempt, DownloadedBytes: task.DownloadedBytes, TotalBytes: task.TotalBytes, + SpeedBytesPerSecond: task.SpeedBytesPerSecond, ElapsedSeconds: task.ElapsedSeconds, RemainingSeconds: task.RemainingSeconds, + MediaElapsedSeconds: task.MediaElapsedSeconds, MediaTotalSeconds: task.MediaTotalSeconds, Phase: task.Phase, + CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, CancelRequested: task.CancelRequested, + RemoveRequested: task.RemoveRequested, PauseRequested: task.PauseRequested, ReleaseStatus: task.Source.ReleaseStatus, + Playable: isPlayableDownloadTask(task), DownloadQuality: task.Source.DownloadQuality, + } +} + +func (a *UIApp) redactError(err error) string { + if err == nil { + return "" + } + return a.redactString(err.Error()) +} + +func (a *UIApp) redactString(s string) string { + return redactConfiguredString(&a.cfg, s) +} diff --git a/internal/app/ui_tasks.go b/internal/app/ui_tasks.go new file mode 100644 index 0000000..e62494d --- /dev/null +++ b/internal/app/ui_tasks.go @@ -0,0 +1,237 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + "strings" + "time" +) + +var finishedEpisodeRemark = regexp.MustCompile(`(?:全\s*\d+\s*集|\d+\s*集全|已完结|大结局)`) + +func releaseStatusFromRemark(remark string) string { + if strings.Contains(remark, "未完结") || strings.Contains(remark, "更新至") || strings.Contains(remark, "连载") { + return "ongoing" + } + if finishedEpisodeRemark.MatchString(remark) || strings.Contains(remark, "完结") { + return "finished" + } + return "unknown" +} + +func dramaReleaseStatus(drama Drama) string { + switch drama.ReleaseStatus { + case "finished", "ongoing", "unknown": + return drama.ReleaseStatus + default: + return releaseStatusFromRemark(drama.Remark) + } +} + +func (a *UIApp) removeTaskLocked(id string) { + task := a.tasks[id] + delete(a.tasks, id) + order := a.taskOrder[:0] + groupRemains := false + for _, taskID := range a.taskOrder { + if current := a.tasks[taskID]; current != nil { + order = append(order, taskID) + if task != nil && current.DramaID == task.DramaID { + groupRemains = true + } + } + } + a.taskOrder = order + if task != nil && !groupRemains { + delete(a.merges, task.DramaID) + } +} + +func (a *UIApp) startDramaParse(drama Drama, updateOnly bool) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + a.mu.Lock() + if a.parsingCancels == nil { + a.parsingCancels = map[string]context.CancelFunc{} + } + a.parsingCancels[placeholderDramaKey(drama)] = cancel + if a.finishCanceledParseLocked(drama) { + a.mu.Unlock() + cancel() + a.markParsingFinished([]Drama{drama}) + return + } + quality := 0 + if task := a.tasks[uiTaskID(a.placeholderTask(drama, "parsing").OutPath)]; task != nil { + quality = task.Source.DownloadQuality + } + a.mu.Unlock() + go func() { + defer cancel() + defer a.markParsingFinished([]Drama{drama}) + _, _, _ = a.enqueueDramas(ctx, []Drama{drama}, updateOnly, quality) + }() +} + +func (a *UIApp) finishCanceledParseLocked(drama Drama) bool { + id := uiTaskID(a.placeholderTask(drama, "parsing").OutPath) + task := a.tasks[id] + if task == nil || !task.CancelRequested && !task.RemoveRequested && !task.PauseRequested { + return false + } + if task.RemoveRequested { + a.removeTaskLocked(id) + } else if task.PauseRequested { + task.Status = uiStatusPaused + task.Phase = "paused" + task.PauseRequested = false + task.CancelRequested = false + task.Error = "" + } else { + task.Status = uiStatusCanceled + task.Phase = "canceled" + task.CancelRequested = false + task.Error = "章节解析已取消" + task.UpdatedAt = time.Now() + } + _ = a.saveStateLocked() + return true +} + +func isChapterPlaceholderTask(task *UITask) bool { + return task != nil && (strings.Contains(task.Source.Chapter.ID, ":fetch-failed") || strings.Contains(task.Source.Chapter.ID, ":parsing")) +} + +type taskSelection struct { + IDs []string `json:"ids"` + DramaIDs []string `json:"dramaIds"` +} + +func readTaskSelection(writer http.ResponseWriter, request *http.Request) (taskSelection, bool) { + var selection taskSelection + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&selection); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "无效的任务选择"}) + return selection, false + } + if _, valid := cleanIDList(writer, append(append([]string{}, selection.IDs...), selection.DramaIDs...)); !valid { + return selection, false + } + return selection, true +} + +func (selection taskSelection) idsLocked(app *UIApp) []string { + groups := map[string]bool{} + selected := map[string]bool{} + for _, id := range selection.DramaIDs { + groups[strings.TrimSpace(id)] = true + } + for _, id := range selection.IDs { + selected[strings.TrimSpace(id)] = true + } + var ids []string + for _, id := range app.taskOrder { + task := app.tasks[id] + if task != nil && (selected[id] || groups[task.DramaID]) { + ids = append(ids, id) + } + } + return ids +} + +func (a *UIApp) handlePause(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + selection, ok := readTaskSelection(writer, request) + if !ok { + return + } + a.mu.Lock() + if !a.requireTaskSourcesLocked(writer, request, selection) { + a.mu.Unlock() + return + } + for _, id := range selection.idsLocked(a) { + task := a.tasks[id] + if task == nil || task.RemoveRequested || task.CancelRequested { + continue + } + switch task.Status { + case uiStatusQueued: + task.Status = uiStatusPaused + task.Phase = "paused" + case uiStatusRunning, uiStatusParsing: + task.PauseRequested = true + if cancel := a.runningCancels[id]; cancel != nil { + cancel() + } + if task.Status == uiStatusParsing { + if cancel := a.parsingCancels[task.DramaID]; cancel != nil { + cancel() + } + } + } + task.UpdatedAt = time.Now() + } + _ = a.saveStateLocked() + views := a.taskViewsForSourceLocked(request.Context()) + a.mu.Unlock() + writeJSON(writer, http.StatusOK, map[string]any{"data": views}) +} + +func (a *UIApp) handleResume(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + selection, ok := readTaskSelection(writer, request) + if !ok { + return + } + a.mu.Lock() + if !a.requireTaskSourcesLocked(writer, request, selection) { + a.mu.Unlock() + return + } + if a.parsingInFlight == nil { + a.parsingInFlight = map[string]bool{} + } + var reparses []Drama + for _, id := range selection.idsLocked(a) { + task := a.tasks[id] + if task == nil || task.Status != uiStatusPaused || task.RemoveRequested { + continue + } + if isChapterPlaceholderTask(task) { + drama := dramaFromPlaceholderTask(task) + if a.parsingInFlight[drama.ID] { + continue + } + a.removeTaskLocked(id) + a.parsingInFlight[drama.ID] = true + a.addParsingPlaceholderTaskLocked(drama, task.Source.DownloadQuality) + reparses = append(reparses, drama) + continue + } + task.Status = uiStatusQueued + task.Phase = "queued" + task.Progress = 0 + task.DownloadedBytes = 0 + task.SpeedBytesPerSecond = 0 + task.PauseRequested = false + task.CancelRequested = false + task.UpdatedAt = time.Now() + } + _ = a.saveStateLocked() + a.cond.Broadcast() + views := a.taskViewsForSourceLocked(request.Context()) + a.mu.Unlock() + for _, drama := range reparses { + a.startDramaParse(drama, true) + } + writeJSON(writer, http.StatusOK, map[string]any{"data": views}) +} diff --git a/internal/app/ui_viewer.go b/internal/app/ui_viewer.go new file mode 100644 index 0000000..c2706ae --- /dev/null +++ b/internal/app/ui_viewer.go @@ -0,0 +1,350 @@ +package app + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +const viewerLifetime = 365 * 24 * time.Hour +const viewerIdleCacheLimit = 64 + +type viewerContextKey struct{} + +type viewerRecords struct { + id string + directory string + manager *viewerManager + refs int + used time.Time + historyOnce sync.Once + history *playbackHistoryStore + followingOnce sync.Once + following *followingStore +} + +type viewerManager struct { + directory string + key []byte + cookie string + err error + mu sync.Mutex + viewers map[string]*viewerRecords + legacyMu sync.Mutex + accountsOnce sync.Once + accounts *accountStore +} + +func (app *UIApp) browserViewers() *viewerManager { + app.viewersOnce.Do(func() { + manager := &viewerManager{directory: app.cfg.dataDirectory(), viewers: make(map[string]*viewerRecords)} + manager.key, manager.err = loadViewerKey(manager.directory) + if manager.err == nil { + sum := sha256.Sum256(manager.key) + manager.cookie = "juku_viewer_" + hex.EncodeToString(sum[:8]) + } + app.viewers = manager + }) + return app.viewers +} + +func loadViewerKey(directory string) ([]byte, error) { + path := filepath.Join(directory, "viewer-key") + key, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + key = make([]byte, 32) + if _, err = rand.Read(key); err != nil { + return nil, err + } + if err = os.MkdirAll(directory, 0o700); err != nil { + return nil, err + } + if err = writeNewViewerFile(path, key); errors.Is(err, os.ErrExist) { + key, err = os.ReadFile(path) + } + } + if err != nil { + return nil, err + } + if len(key) != 32 { + return nil, errors.New("浏览器身份密钥损坏,请恢复数据目录中的 viewer-key 备份") + } + return key, nil +} + +func writeNewViewerFile(path string, data []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + os.Remove(path) + } + return err +} + +func (manager *viewerManager) signature(value string) []byte { + mac := hmac.New(sha256.New, manager.key) + mac.Write([]byte(manager.cookie + "|" + value)) + return mac.Sum(nil) +} + +func (manager *viewerManager) token(identity string, expires time.Time) string { + value := "1." + identity + "." + strconv.FormatInt(expires.Unix(), 10) + return value + "." + hex.EncodeToString(manager.signature(value)) +} + +func (manager *viewerManager) identity(request *http.Request) string { + cookie, err := request.Cookie(manager.cookie) + if err != nil || len(cookie.Value) > 180 { + return "" + } + parts := strings.Split(cookie.Value, ".") + if len(parts) != 4 || parts[0] != "1" || len(parts[1]) != 64 || len(parts[3]) != 64 { + return "" + } + identity, identityErr := hex.DecodeString(parts[1]) + signature, signatureErr := hex.DecodeString(parts[3]) + expires, expiryErr := strconv.ParseInt(parts[2], 10, 64) + if identityErr != nil || len(identity) != 32 || signatureErr != nil || expiryErr != nil || expires <= time.Now().Unix() || !hmac.Equal(signature, manager.signature(strings.Join(parts[:3], "."))) { + return "" + } + return parts[1] +} + +func viewerID(identity string) string { + sum := sha256.Sum256([]byte(identity)) + return hex.EncodeToString(sum[:]) +} + +func (manager *viewerManager) acquire(id string) *viewerRecords { + manager.mu.Lock() + defer manager.mu.Unlock() + viewer := manager.viewers[id] + if viewer == nil { + viewer = &viewerRecords{id: id, directory: filepath.Join(manager.directory, "viewers", id), manager: manager} + manager.viewers[id] = viewer + } + viewer.refs++ + viewer.used = time.Now() + return viewer +} + +func (viewer *viewerRecords) retain() { + if viewer == nil { + return + } + viewer.manager.mu.Lock() + viewer.refs++ + viewer.manager.mu.Unlock() +} + +func (viewer *viewerRecords) release() { + if viewer == nil { + return + } + manager := viewer.manager + manager.mu.Lock() + defer manager.mu.Unlock() + viewer.refs-- + viewer.used = time.Now() + for len(manager.viewers) > viewerIdleCacheLimit { + var oldest *viewerRecords + for _, candidate := range manager.viewers { + if candidate.refs == 0 && (oldest == nil || candidate.used.Before(oldest.used)) { + oldest = candidate + } + } + if oldest == nil { + break + } + delete(manager.viewers, oldest.id) + } +} + +func (viewer *viewerRecords) playbackHistory() *playbackHistoryStore { + viewer.historyOnce.Do(func() { viewer.history = newPlaybackHistoryStore(viewer.directory) }) + return viewer.history +} + +func (viewer *viewerRecords) followingStore() *followingStore { + viewer.followingOnce.Do(func() { viewer.following = newFollowingStore(viewer.directory) }) + return viewer.following +} + +func contextViewer(ctx context.Context) *viewerRecords { + viewer, _ := ctx.Value(viewerContextKey{}).(*viewerRecords) + return viewer +} + +func requestViewer(writer http.ResponseWriter, request *http.Request) *viewerRecords { + viewer := contextViewer(request.Context()) + if viewer == nil { + writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "请刷新页面,恢复此浏览器的独立观看记录") + } + return viewer +} + +func viewerOwnsPlayback(ctx context.Context, session *playbackSession) bool { + viewer := contextViewer(ctx) + return viewer != nil && session != nil && session.viewer == viewer && playbackSourceAllowed(ctx, session) +} + +func writeViewerError(writer http.ResponseWriter, status int, code, message string) { + writer.Header().Set("Cache-Control", "private, no-store") + writer.Header().Add("Vary", "Cookie") + writeJSON(writer, status, map[string]string{"code": code, "error": message}) +} + +func (app *UIApp) withBrowserViewer(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + path := request.URL.Path + if path != "/api/ui/following" && !strings.HasPrefix(path, "/api/ui/playback/") && !strings.HasPrefix(path, "/api/ui/account/") && path != "/api/ui/viewer/legacy" && !accountAdminPath(path) { + next.ServeHTTP(writer, request) + return + } + writer.Header().Set("Cache-Control", "private, no-store") + writer.Header().Add("Vary", "Cookie") + manager := app.browserViewers() + if manager.err != nil { + writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", "无法读取浏览器身份:"+publicError(manager.err).Error()) + return + } + account, _, accountCookie, accountErr := manager.requestAccount(request) + if accountErr != nil { + writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", accountErr.Error()) + return + } + identity := manager.identity(request) + if account.ID == "" && (identity == "" || accountCookie) { + writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "请刷新页面,恢复此浏览器的独立观看记录") + return + } + id := viewerID(identity) + if account.ID != "" { + id = account.viewerID() + } + if expected := request.Header.Get("X-Juku-Viewer"); expected != "" && expected != id { + writeViewerError(writer, http.StatusConflict, "viewer_changed", "浏览器身份已变化,请刷新页面后继续") + return + } + viewer := manager.acquire(id) + defer viewer.release() + next.ServeHTTP(writer, request.WithContext(context.WithValue(request.Context(), viewerContextKey{}, viewer))) + }) +} + +func (app *UIApp) handleViewer(writer http.ResponseWriter, request *http.Request) { + if !playbackRequestAllowed(writer, request, http.MethodGet) { + return + } + writer.Header().Set("Cache-Control", "private, no-store") + writer.Header().Add("Vary", "Cookie") + manager := app.browserViewers() + if manager.err != nil { + writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", "无法保存浏览器身份:"+publicError(manager.err).Error()) + return + } + if err := manager.accountStore().err; err != nil { + writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", err.Error()) + return + } + account, accountName, accountCookie, err := manager.requestAccount(request) + if err != nil { + accountOperationError(writer, err) + return + } + accountExpired := accountCookie && account.ID == "" + if accountExpired { + manager.setAccountCookie(writer, request, "") + } + identity := manager.identity(request) + confirmed := identity != "" || account.ID != "" + if identity == "" { + if !confirmed && request.URL.Query().Get("confirm") == "1" { + writeViewerError(writer, http.StatusUnauthorized, "viewer_cookies_disabled", "请允许此网站保存 Cookie,再重试;观看记录需要用它识别当前浏览器") + return + } + identity = randomHex(32) + if identity == "" { + writeViewerError(writer, http.StatusInternalServerError, "viewer_unavailable", "无法建立浏览器身份,请重试") + return + } + } + manager.setGuestCookie(writer, request, identity) + if !confirmed { + writeJSON(writer, http.StatusOK, map[string]bool{"ready": false}) + return + } + id := viewerID(identity) + if account.ID != "" { + id = account.viewerID() + } + available, err := manager.legacyAvailable(id) + requireLogin, allowRegistration := manager.accountStore().policy() + result := map[string]any{"onlineOnly": account.onlineOnly(), "sources": account.effectiveSources(), "sourceChoices": accountSourceChoices, "ready": true, "id": id, "legacyAvailable": available, "accountExpired": accountExpired, "requireLogin": requireLogin, "allowRegistration": allowRegistration} + if account.ID != "" { + result["account"] = account.public() + result["guestImportAvailable"] = manager.guestImportAvailable(identity, accountName) + } + if err != nil { + result["legacyError"] = "旧版记录恢复暂不可用:" + publicError(err).Error() + } + writeJSON(writer, http.StatusOK, result) +} + +func (app *UIApp) prepareBrowserViewers() error { + manager := app.browserViewers() + if manager.err != nil { + return fmt.Errorf("初始化独立观看记录失败:%w", manager.err) + } + if err := manager.accountStore().err; err != nil { + return err + } + admin, err := manager.ensureAdministrator(app.cfg) + if err != nil { + return err + } + fmt.Printf("管理员账号: %s\n", admin.Username) + if admin.InitialPassword != "" { + fmt.Printf("管理员随机初始密码: %s\n首次登录必须修改密码。此密码仅在首次创建时输出,请妥善保存。\n", admin.InitialPassword) + } else if admin.RequirePasswordChange { + fmt.Println("管理员尚未修改初始密码;若已遗失,可用 -admin-password 指定新密码后启动。") + } + if available, err := manager.prepareLegacyCode(); err != nil { + fmt.Printf("旧版观看记录已保留,恢复码准备失败:%v\n", publicError(err)) + } else if available { + fmt.Printf("旧版观看记录已保留。可在“代理 / 设置 → 个人记录”输入 %s 中的恢复码,接回当前浏览器。\n", filepath.Join(manager.directory, "viewer-legacy-code")) + } + return nil +} + +func (app *UIApp) requirePlaybackOwner(writer http.ResponseWriter, request *http.Request, id string) bool { + if requestViewer(writer, request) == nil { + return false + } + app.playbackMu.Lock() + allowed := viewerOwnsPlayback(request.Context(), app.playbacks[id]) + app.playbackMu.Unlock() + if !allowed { + writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期,请重新打开本剧"}) + } + return allowed +} diff --git a/internal/app/ui_viewer_fixture_test.go b/internal/app/ui_viewer_fixture_test.go new file mode 100644 index 0000000..9519a2e --- /dev/null +++ b/internal/app/ui_viewer_fixture_test.go @@ -0,0 +1,30 @@ +package app + +import ( + "context" + "net/http" +) + +func fixtureViewer(app *UIApp) *viewerRecords { + manager := app.browserViewers() + if manager.err != nil { + panic(manager.err) + } + viewer := manager.acquire(viewerID("local-synthetic-viewer")) + viewer.release() + return viewer +} + +func viewerFixtureRequest(app *UIApp, request *http.Request) *http.Request { + return request.WithContext(viewerFixtureContext(app, request.Context())) +} + +func viewerFixtureContext(app *UIApp, parent context.Context) context.Context { + return context.WithValue(parent, viewerContextKey{}, fixtureViewer(app)) +} + +func viewerFixtureHandler(app *UIApp, next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + next.ServeHTTP(writer, viewerFixtureRequest(app, request)) + }) +} diff --git a/internal/app/ui_viewer_legacy.go b/internal/app/ui_viewer_legacy.go new file mode 100644 index 0000000..cb19f51 --- /dev/null +++ b/internal/app/ui_viewer_legacy.go @@ -0,0 +1,247 @@ +package app + +import ( + "crypto/hmac" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +type viewerLegacyImport struct { + Version int `json:"version"` + Viewer string `json:"viewer"` + Completed bool `json:"completed"` +} + +func (manager *viewerManager) legacyImport() (viewerLegacyImport, error) { + var state viewerLegacyImport + file, err := os.Open(filepath.Join(manager.directory, "viewer-legacy-import.json")) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return state, err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 4096)) + if err = decoder.Decode(&state); err != nil { + return state, err + } + id, idErr := hex.DecodeString(state.Viewer) + if state.Version != 1 || idErr != nil || len(id) != 32 || decoder.Decode(new(any)) != io.EOF { + return state, errors.New("旧版记录恢复状态无效,原文件已保留") + } + return state, nil +} + +func (manager *viewerManager) hasLegacyRecords() (bool, error) { + found := false + for _, name := range []string{"playback-history.json", "following.json"} { + info, err := os.Stat(filepath.Join(manager.directory, name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return false, errors.New("旧版记录路径不是普通文件") + } + found = true + } + return found, nil +} + +func (manager *viewerManager) legacyOwner(id string) string { + store := manager.accountStore() + store.mu.Lock() + defer store.mu.Unlock() + if imported, exists := store.state.GuestImports[id]; exists { + if account := store.state.Accounts[imported.Account]; account.ID != "" { + return account.viewerID() + } + } + return id +} + +func (manager *viewerManager) legacyAvailable(id string) (bool, error) { + manager.legacyMu.Lock() + defer manager.legacyMu.Unlock() + state, err := manager.legacyImport() + if err != nil || state.Completed || state.Viewer != "" && manager.legacyOwner(state.Viewer) != id { + return false, err + } + return manager.hasLegacyRecords() +} + +func (manager *viewerManager) legacyCode() string { + return hex.EncodeToString(manager.signature("legacy-records")) +} + +func (manager *viewerManager) prepareLegacyCode() (bool, error) { + manager.legacyMu.Lock() + defer manager.legacyMu.Unlock() + state, err := manager.legacyImport() + if err != nil || state.Completed { + return false, err + } + available, err := manager.hasLegacyRecords() + if err != nil || !available { + return false, err + } + err = writeNewViewerFile(filepath.Join(manager.directory, "viewer-legacy-code"), []byte(manager.legacyCode()+"\n")) + if errors.Is(err, os.ErrExist) { + err = nil + } + return true, err +} + +func (manager *viewerManager) saveLegacyImport(state viewerLegacyImport, create bool) error { + data, err := json.Marshal(state) + if err != nil { + return err + } + path := filepath.Join(manager.directory, "viewer-legacy-import.json") + if create { + return writeNewViewerFile(path, data) + } + file, err := os.CreateTemp(manager.directory, ".viewer-legacy-*.json") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(file.Name(), path) +} + +func (app *UIApp) handleViewerLegacy(writer http.ResponseWriter, request *http.Request) { + var input struct { + Code string `json:"code"` + } + if !readPlaybackRequest(writer, request, &input) { + return + } + viewer := requestViewer(writer, request) + if viewer == nil { + return + } + manager := viewer.manager + code := strings.TrimSpace(input.Code) + if len(code) != 64 || !hmac.Equal([]byte(code), []byte(manager.legacyCode())) { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "恢复码不正确,请从服务所在电脑的数据目录获取"}) + return + } + manager.legacyMu.Lock() + defer manager.legacyMu.Unlock() + state, err := manager.legacyImport() + if err == nil && state.Viewer != "" && manager.legacyOwner(state.Viewer) != viewer.id { + writeJSON(writer, http.StatusConflict, map[string]string{"error": "旧版记录已经归属其他账号或浏览器,不能重复领取"}) + return + } + if err == nil && state.Completed { + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) + return + } + if err == nil { + var available bool + available, err = manager.hasLegacyRecords() + if err == nil && !available { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "没有待恢复的旧版记录"}) + return + } + } + var history []playbackHistoryEntry + var following []followingEntry + if err == nil { + history, err = newPlaybackHistoryStore(manager.directory).list() + } + if err == nil { + following, err = newFollowingStore(manager.directory).list() + } + if err == nil { + _, err = viewer.playbackHistory().list() + } + if err == nil { + _, err = viewer.followingStore().list() + } + if err == nil && state.Viewer != viewer.id { + create := state.Viewer == "" + state = viewerLegacyImport{Version: 1, Viewer: viewer.id} + err = manager.saveLegacyImport(state, create) + } + if err == nil { + err = viewer.followingStore().importLegacy(following) + } + if err == nil { + err = viewer.playbackHistory().importLegacy(history) + } + if err == nil { + state.Completed = true + err = manager.saveLegacyImport(state, false) + } + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "旧版记录尚未全部恢复,可在当前账号或浏览器重试:" + publicError(err).Error()}) + return + } + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} + +func (store *playbackHistoryStore) importLegacy(entries []playbackHistoryEntry) error { + store.mu.Lock() + if store.loadErr != nil { + err := store.loadErr + store.mu.Unlock() + return err + } + for _, entry := range entries { + if _, exists := store.entries[entry.DramaID]; exists || !entry.WatchedAt.After(store.cleared) || !entry.WatchedAt.After(store.deleted[entry.DramaID]) { + continue + } + store.entries[entry.DramaID] = entry + store.revision++ + } + store.trimLocked() + store.mu.Unlock() + return store.flush() +} + +func (store *followingStore) importLegacy(entries []followingEntry) error { + store.mu.Lock() + defer store.mu.Unlock() + if store.loadErr != nil { + return store.loadErr + } + next := make(map[string]followingEntry, len(store.entries)) + for id, entry := range store.entries { + next[id] = entry + } + for _, entry := range entries { + if _, exists := next[entry.DramaID]; !exists { + next[entry.DramaID] = entry + } + } + if len(next) > followingLimit { + return errFollowingLimit + } + if len(next) == len(store.entries) { + return nil + } + if err := store.write(followingFile{Version: 1, Entries: followingEntries(next)}); err != nil { + return err + } + store.entries = next + return nil +} diff --git a/internal/app/ui_viewer_test.go b/internal/app/ui_viewer_test.go new file mode 100644 index 0000000..2899ca1 --- /dev/null +++ b/internal/app/ui_viewer_test.go @@ -0,0 +1,394 @@ +package app + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +type viewerTestBrowser struct { + handler http.Handler + cookie *http.Cookie + id string +} + +func viewerTestApp(t *testing.T) *UIApp { + t.Helper() + d := rankingTestDownloader(t, func(*http.Request) (*http.Response, error) { + t.Error("viewer tests must not request upstream resources") + return nil, errors.New("external requests forbidden") + }) + program, err := os.Executable() + if err != nil { + t.Fatal(err) + } + d.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: program}} + drama := Drama{ID: historyFixtureDramaID, Title: "多人无图测试", Source: sourceHongguo, TotalEpisode: 3} + chapters := []Chapter{} + for index := 1; index <= 3; index++ { + chapters = append(chapters, Chapter{ID: fmt.Sprintf("hongguo:7000000000000000001:%d", index), CurrentEpisode: rawEpisode(index), VideoURL: "hongguo-cenc://9000000000000000001"}) + } + d.hongguoClient().details["7000000000000000001"] = hongguoDetailEntry{Drama: drama, Chapters: chapters, ExpiresAt: time.Now().Add(time.Hour)} + app := &UIApp{cfg: d.cfg, downloader: d, dramas: []Drama{drama}} + t.Cleanup(app.closePlaybacks) + return app +} + +func (browser *viewerTestBrowser) request(t *testing.T, method, path string, input any) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + if input != nil { + if err := json.NewEncoder(&body).Encode(input); err != nil { + t.Fatal(err) + } + } + request := httptest.NewRequest(method, "http://shared.example.test"+path, &body) + request.RemoteAddr = "192.168.1.10:1234" + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.Header.Set("Content-Type", "application/json") + if browser.cookie != nil { + request.AddCookie(browser.cookie) + } + if browser.id != "" { + request.Header.Set("X-Juku-Viewer", browser.id) + } + writer := httptest.NewRecorder() + browser.handler.ServeHTTP(writer, request) + return writer +} + +func newViewerTestBrowser(t *testing.T, app *UIApp) *viewerTestBrowser { + t.Helper() + browser := &viewerTestBrowser{handler: app.routes()} + first := browser.request(t, http.MethodGet, "/api/ui/viewer", nil) + cookies := first.Result().Cookies() + if first.Code != http.StatusOK || len(cookies) != 1 { + t.Fatal("could not establish a browser identity", first.Code, first.Body.String()) + } + browser.cookie = cookies[0] + confirmed := browser.request(t, http.MethodGet, "/api/ui/viewer?confirm=1", nil) + var result struct { + ID string `json:"id"` + Ready bool `json:"ready"` + } + if err := json.Unmarshal(confirmed.Body.Bytes(), &result); err != nil || !result.Ready || len(result.ID) != 64 { + t.Fatal("browser identity was not confirmed", confirmed.Code, err, confirmed.Body.String()) + } + browser.id = result.ID + return browser +} + +func viewerResultOK(t *testing.T, result *httptest.ResponseRecorder) { + t.Helper() + if result.Code != http.StatusOK { + t.Fatal(result.Code, result.Body.String()) + } +} + +func (browser *viewerTestBrowser) open(t *testing.T, app *UIApp) string { + t.Helper() + result := browser.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID, "resume": true}) + viewerResultOK(t, result) + var opened struct { + Session string `json:"session"` + } + if err := json.Unmarshal(result.Body.Bytes(), &opened); err != nil || opened.Session == "" { + t.Fatal("missing playback session", err) + } + app.playbackMu.Lock() + session := app.playbacks[opened.Session] + session.run, session.currentIndex, session.state = 1, 1, "ended" + session.historyRuns = map[uint64]playbackHistoryRun{1: {episode: 1, duration: 90}, 2: {episode: 2, duration: 90}} + app.playbackMu.Unlock() + return opened.Session +} + +func (browser *viewerTestBrowser) history(t *testing.T) []playbackHistoryEntry { + t.Helper() + result := browser.request(t, http.MethodGet, "/api/ui/playback/history", nil) + viewerResultOK(t, result) + if !strings.Contains(result.Header().Get("Cache-Control"), "no-store") || !strings.Contains(result.Header().Get("Vary"), "Cookie") { + t.Fatal("personal history could be cached across browsers", result.Header()) + } + var response struct { + Data []playbackHistoryEntry `json:"data"` + } + if err := json.Unmarshal(result.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response.Data +} + +func (browser *viewerTestBrowser) following(t *testing.T) []followingView { + t.Helper() + result := browser.request(t, http.MethodGet, "/api/ui/following", nil) + viewerResultOK(t, result) + var response struct { + Data []followingView `json:"data"` + } + if err := json.Unmarshal(result.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response.Data +} + +func TestViewerIdentityCookiesRestartAndInstanceSeparation(t *testing.T) { + app := viewerTestApp(t) + alice := newViewerTestBrowser(t, app) + bob := newViewerTestBrowser(t, app) + if alice.id == bob.id || alice.cookie.Value == bob.cookie.Value { + t.Fatal("two browsers with the same LAN address share an identity") + } + cookie := alice.cookie + if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge <= 0 || cookie.Expires.Before(time.Now().Add(300*24*time.Hour)) { + t.Fatal("unexpected browser cookie properties", cookie.Name) + } + if _, err := os.Stat(filepath.Join(app.cfg.dataDirectory(), "viewers")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("anonymous page visits created personal record files", err) + } + unauthenticated := &viewerTestBrowser{handler: app.routes()} + for _, path := range []string{"/api/ui/following", "/api/ui/playback/history", "/api/ui/playback/status?session=anything", "/api/ui/viewer?confirm=1"} { + if result := unauthenticated.request(t, http.MethodGet, path, nil); result.Code != http.StatusUnauthorized { + t.Fatal("missing identity was accepted", path, result.Code) + } + } + restarted := &UIApp{cfg: app.cfg} + alice.handler = restarted.routes() + viewerResultOK(t, alice.request(t, http.MethodGet, "/api/ui/playback/history", nil)) + other := &UIApp{cfg: Config{dataDir: t.TempDir()}} + alice.handler = other.routes() + if result := alice.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusUnauthorized || other.browserViewers().cookie == app.browserViewers().cookie { + t.Fatal("two deployments shared a cookie identity", result.Code) + } + alice.handler = app.routes() + for _, value := range []string{cookie.Value + "x", strings.Replace(cookie.Value, ".", ".invalid.", 1), app.browserViewers().token(strings.Repeat("a", 64), time.Now().Add(-time.Hour)), "../../playback-history.json"} { + forged := *cookie + forged.Value = value + alice.cookie = &forged + if result := alice.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusUnauthorized { + t.Fatal("invalid or expired cookie reached personal records", result.Code) + } + } + alice.cookie, alice.id = cookie, bob.id + if result := alice.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true}); result.Code != http.StatusConflict { + t.Fatal("an old tab silently wrote into a changed browser identity", result.Code) + } + if len(bob.following(t)) != 0 { + t.Fatal("client-supplied viewer id changed another browser's records") + } +} + +func TestViewerHistoryFollowingConcurrentProgressAndResume(t *testing.T) { + app := viewerTestApp(t) + alice, bob := newViewerTestBrowser(t, app), newViewerTestBrowser(t, app) + aliceSession, bobSession := alice.open(t, app), bob.open(t, app) + var done sync.WaitGroup + for index, browser := range []*viewerTestBrowser{alice, bob} { + done.Add(1) + go func(index int, browser *viewerTestBrowser) { + defer done.Done() + session := []string{aliceSession, bobSession}[index] + for sequence := uint64(1); sequence <= 8; sequence++ { + progress := playbackHistoryProgress{Run: uint64(index + 1), Episode: index + 1, Sequence: sequence, Position: float64(index*40) + float64(sequence), Duration: 90} + viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": session, "progress": progress})) + } + }(index, browser) + } + done.Wait() + for index, browser := range []*viewerTestBrowser{alice, bob} { + entries := browser.history(t) + if len(entries) != 1 || entries[0].Index != index+1 || entries[0].Position != float64(index*40+8) { + t.Fatal("simultaneous playback mixed progress", index, entries) + } + viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true, "completed": index == 1})) + result := browser.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}) + viewerResultOK(t, result) + var resumed struct { + Session string `json:"session"` + Index int `json:"initialIndex"` + Position float64 `json:"initialPosition"` + } + if err := json.Unmarshal(result.Body.Bytes(), &resumed); err != nil || resumed.Index != index+1 || resumed.Position != float64(index*40+8) { + t.Fatal("resume used another browser's progress", resumed, err) + } + app.closePlayback(resumed.Session) + } + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": false})) + if len(alice.following(t)) != 0 || len(bob.following(t)) != 1 || !bob.following(t)[0].Completed { + t.Fatal("following changes affected another browser") + } + for _, input := range []map[string]any{{"dramaId": historyFixtureDramaID}, {"all": true}} { + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/playback/history/remove", input)) + late := alice.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": aliceSession, "progress": historyProgress(99, 77)}) + viewerResultOK(t, late) + if len(alice.history(t)) != 0 || len(bob.history(t)) != 1 || bob.history(t)[0].Position != 48 || !strings.Contains(late.Body.String(), `"saved":false`) { + t.Fatal("deletion, clearing or late autosave crossed browser boundaries") + } + } + restarted := &UIApp{cfg: app.cfg} + alice.handler, bob.handler = restarted.routes(), restarted.routes() + if len(alice.history(t)) != 0 || bob.history(t)[0].Position != 48 || !bob.following(t)[0].Completed { + t.Fatal("restart lost browser-specific records") + } +} + +func TestViewerPlaybackEndpointsRejectOtherBrowsersAndBeaconSavesOwner(t *testing.T) { + app := viewerTestApp(t) + alice, bob := newViewerTestBrowser(t, app), newViewerTestBrowser(t, app) + sessionID := alice.open(t, app) + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/api/ui/playback/status?session=" + sessionID, nil}, + {http.MethodGet, "/api/ui/playback/stream?episode=1&session=" + sessionID, nil}, + {http.MethodGet, "/api/ui/playback/hls/index.m3u8?run=1&session=" + sessionID, nil}, + {http.MethodHead, "/api/ui/playback/hls/segment.ts?run=1&segment=0&session=" + sessionID, nil}, + {http.MethodGet, "/api/ui/playback/danmaku?episode=1&start=0&duration=90000&session=" + sessionID, nil}, + {http.MethodPost, "/api/ui/playback/hls/open", map[string]any{"session": sessionID, "episode": 1}}, + {http.MethodPost, "/api/ui/playback/prepare", map[string]any{"session": sessionID, "episode": 1}}, + {http.MethodPost, "/api/ui/playback/prefetch", map[string]any{"session": sessionID, "episode": 2, "run": 1, "version": 1}}, + {http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": sessionID, "progress": historyProgress(1, 55)}}, + {http.MethodPost, "/api/ui/playback/control", map[string]any{"session": sessionID, "action": "heartbeat"}}, + {http.MethodPost, "/api/ui/playback/control", map[string]any{"session": sessionID, "action": "close", "progress": historyProgress(1, 55)}}, + } { + result := bob.request(t, tc.method, tc.path, tc.body) + if result.Code != http.StatusGone { + t.Error("another browser accessed a playback session", tc.path, result.Code, result.Body.String()) + } + } + if state, ok := app.playbackStatus(sessionID, false); !ok || state.Run != 1 || state.State != "ended" || len(alice.history(t)) != 0 || len(bob.history(t)) != 0 { + t.Fatal("unauthorized playback requests changed progress or state", state) + } + alice.id = "" + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/playback/control", map[string]any{"session": sessionID, "action": "close", "progress": historyProgress(1, 31)})) + if entries := alice.history(t); len(entries) != 1 || entries[0].Position != 31 { + t.Fatal("cookie-only close beacon lost the owner's final progress", entries) + } + if _, ok := app.playbackStatus(sessionID, false); ok || len(bob.history(t)) != 0 { + t.Fatal("close beacon affected another browser or kept the session") + } +} + +func viewerLegacyFixture(t *testing.T, app *UIApp) map[string][]byte { + t.Helper() + entry := playbackHistoryEntry{DramaID: historyFixtureDramaID, Source: sourceHongguo, Title: "升级前的无图记录", ChapterID: "one", Index: 1, Total: 3, Episode: "1", Position: 26, Duration: 90, Mode: "online", WatchedAt: time.Now().Add(-time.Hour)} + history := newPlaybackHistoryStore(app.cfg.dataDirectory()) + if _, err := history.record(entry, time.Now()); err != nil { + t.Fatal(err) + } + if err := history.flush(); err != nil { + t.Fatal(err) + } + following := newFollowingStore(app.cfg.dataDirectory()) + if _, err := following.update(historyFixtureDramaID, func(followingEntry, bool) (followingEntry, error) { + return followingEntry{DramaID: historyFixtureDramaID, Source: sourceHongguo, Title: entry.Title, Saved: true, KnownEpisodes: 3, AddedAt: entry.WatchedAt, UpdatedAt: entry.WatchedAt}, nil + }); err != nil { + t.Fatal(err) + } + originals := make(map[string][]byte) + for _, name := range []string{"playback-history.json", "following.json"} { + body, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), name)) + if err != nil { + t.Fatal(err) + } + originals[name] = body + } + if err := app.prepareBrowserViewers(); err != nil { + t.Fatal(err) + } + return originals +} + +func TestViewerLegacyImportPrivateRetryableAndNonDestructive(t *testing.T) { + app := viewerTestApp(t) + originals := viewerLegacyFixture(t, app) + alice, bob := newViewerTestBrowser(t, app), newViewerTestBrowser(t, app) + if len(alice.history(t)) != 0 || len(bob.history(t)) != 0 || len(bob.following(t)) != 0 { + t.Fatal("old shared records were assigned to a visitor automatically") + } + if result := bob.request(t, http.MethodPost, "/api/ui/viewer/legacy", map[string]any{"code": "invalid"}); result.Code != http.StatusForbidden { + t.Fatal("legacy records did not require the server's recovery code", result.Code) + } + code, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), "viewer-legacy-code")) + if err != nil { + t.Fatal(err) + } + input := map[string]any{"code": strings.TrimSpace(string(code))} + manager := app.browserViewers() + viewer := manager.acquire(alice.id) + defer viewer.release() + store := viewer.playbackHistory() + path := store.path + store.path = app.cfg.dataDirectory() + if result := alice.request(t, http.MethodPost, "/api/ui/viewer/legacy", input); result.Code != http.StatusInternalServerError { + t.Fatal("failed disk write was reported as a completed import", result.Code) + } + if result := bob.request(t, http.MethodPost, "/api/ui/viewer/legacy", input); result.Code != http.StatusConflict { + t.Fatal("partial import could be claimed by a different browser", result.Code) + } + store.path = path + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/viewer/legacy", input)) + if entries := alice.history(t); len(entries) != 1 || entries[0].Position != 26 || len(alice.following(t)) != 1 || len(bob.history(t)) != 0 || len(bob.following(t)) != 0 { + t.Fatal("legacy records were lost or shared during import", entries) + } + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]any{"all": true})) + viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/viewer/legacy", input)) + if len(alice.history(t)) != 0 { + t.Fatal("reusing a consumed code resurrected deleted history") + } + for name, before := range originals { + after, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), name)) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("import altered an original shared file", name, err) + } + } +} + +func TestViewerCacheKeepsActiveSessionsAndKeyCorruptionFailsClosed(t *testing.T) { + app := viewerTestApp(t) + browser := newViewerTestBrowser(t, app) + session := browser.open(t, app) + viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": session, "progress": historyProgress(1, 12)})) + viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]any{"all": true})) + for index := 0; index < viewerIdleCacheLimit+20; index++ { + other := newViewerTestBrowser(t, app) + if len(other.history(t)) != 0 { + t.Fatal("new browser has someone else's records") + } + } + manager := app.browserViewers() + manager.mu.Lock() + count := len(manager.viewers) + manager.mu.Unlock() + if count > viewerIdleCacheLimit { + t.Fatal("inactive viewer memory is unbounded", count) + } + viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": session, "progress": historyProgress(2, 42)})) + if len(browser.history(t)) != 0 { + t.Fatal("cache eviction lost an active player's deletion barrier") + } + path := filepath.Join(app.cfg.dataDirectory(), "viewer-key") + broken := []byte("broken-key") + if err := os.WriteFile(path, broken, 0600); err != nil { + t.Fatal(err) + } + restarted := &UIApp{cfg: app.cfg} + browser.handler = restarted.routes() + if result := browser.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusServiceUnavailable { + t.Fatal("corrupt identity key was silently replaced", result.Code) + } + actual, _ := os.ReadFile(path) + if !bytes.Equal(actual, broken) { + t.Fatal("corrupt identity key was overwritten") + } +} diff --git a/internal/app/ui_vip_metadata.go b/internal/app/ui_vip_metadata.go new file mode 100644 index 0000000..b33776a --- /dev/null +++ b/internal/app/ui_vip_metadata.go @@ -0,0 +1,47 @@ +package app + +import ( + "net/http" + "time" +) + +func (a *UIApp) handleVIPMetadata(w http.ResponseWriter, r *http.Request) { + var input struct { + Priority []string `json:"priority"` + } + if !readPlaybackRequest(w, r, &input) || !requireSource(w, r.Context(), sourceHuangdou) { + return + } + if len(input.Priority) > sortMetadataBatchSize { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "VIP 状态优先条目过多"}) + return + } + for _, id := range input.Priority { + source, _, ok := splitProviderDramaID(id) + if !ok || source != sourceHuangdou || len(id) > 512 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "VIP 状态优先条目无效"}) + return + } + } + a.mu.Lock() + var candidates []Drama + pending := 0 + for _, drama := range a.dramas { + if !needsHuangdouVIPMetadata(drama) { + continue + } + if a.metadataPending[drama.ID] { + pending++ + } else { + candidates = append(candidates, drama) + } + } + batch := selectSortMetadataBatch(candidates, sourceHuangdou, input.Priority, time.Now()) + if pending > 0 || a.metadataClosed { + batch = nil + } + a.enqueueSortMetadataLocked(batch, true) + result := map[string]any{"queued": len(batch), "pending": pending + len(batch), "remaining": len(candidates) + pending} + a.mu.Unlock() + writeJSON(w, http.StatusAccepted, result) +} diff --git a/internal/app/ui_vip_metadata_test.go b/internal/app/ui_vip_metadata_test.go new file mode 100644 index 0000000..bd263ee --- /dev/null +++ b/internal/app/ui_vip_metadata_test.go @@ -0,0 +1,142 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func vipMetadataRequest(app *UIApp, priority []string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]any{"priority": priority}) + request := httptest.NewRequest(http.MethodPost, "/api/ui/vip/metadata", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + writer := httptest.NewRecorder() + app.routes().ServeHTTP(writer, request) + return writer +} + +func huangdouRequestedID(t *testing.T, request *http.Request) string { + t.Helper() + body, err := io.ReadAll(request.Body) + if err != nil { + t.Error(err) + return "" + } + key, err := huangdouKey(request.Header.Get("requestId")) + if err != nil { + t.Error(err) + return "" + } + decoded, err := huangdouDecode(body, key) + if err != nil { + t.Error(err) + return "" + } + return mapString(nestedMap(decoded, "data"), "id") +} + +func TestVIPToggleBackfillsBoundedBatchWithoutReadMigration(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.Path != "/api/drama/detail" || request.Context().Value(backgroundCatalogKey{}) != true { + t.Error("VIP check contacted an unrelated resource or blocked foreground work", request.URL.Path) + } + id := huangdouRequestedID(t, request) + pay := "free" + if id == "row-44" { + pay = "vip" + } else if id == "row-0" { + pay = "" + } + return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"id":%q,"name":"合成剧","pay_type":%q,"hot_rate":"100","click":0,"issue_date":"2026-09-01"}}`, id, pay)), nil + }) + app := &UIApp{downloader: d, cfg: d.cfg, libraryLoading: make(chan struct{})} + t.Cleanup(app.stopSortMetadata) + for i := 0; i < 45; i++ { + app.dramas = append(app.dramas, Drama{ID: fmt.Sprintf("huangdou:row-%d", i), Source: sourceHuangdou, Title: "历史样本", Heat: "100", Views: "0", OnlineDate: "2026-09-01", SortMetadata: &sortMetadataState{Version: 1, CheckedAt: time.Now().Add(-time.Hour)}}) + } + writer := httptest.NewRecorder() + app.handleDramas(writer, httptest.NewRequest(http.MethodGet, "/api/ui/dramas", nil)) + if calls.Load() != 0 || len(app.metadataPending) != 0 { + t.Fatal("ordinary list read migrated old VIP metadata") + } + for i := 0; i < 2; i++ { + response := vipMetadataRequest(app, []string{"huangdou:row-44"}) + var result struct{ Queued, Pending, Remaining int } + if response.Code != http.StatusAccepted || json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Pending != 40 || result.Remaining != 45 || i == 0 && result.Queued != 40 || i == 1 && result.Queued != 0 { + t.Fatal("VIP toggle failed to bound or deduplicate its batch", response.Code, response.Body.String()) + } + } + app.mu.Lock() + if app.metadataQueue[0] != "huangdou:row-44" { + t.Error("visible VIP candidates were not prioritized") + } + close(app.libraryLoading) + app.libraryLoading = nil + app.mu.Unlock() + waitSortMetadataQueue(t, app) + if calls.Load() != 40 { + t.Fatal("VIP click updated more than one bounded batch", calls.Load()) + } + cache, err := readLibraryCache(d.cfg.dataDirectory()) + if err != nil { + t.Fatal(err) + } + if cache.Dramas[44].VIP == nil || !*cache.Dramas[44].VIP || cache.Dramas[1].VIP == nil || *cache.Dramas[1].VIP || cache.Dramas[0].VIP != nil || !cache.Dramas[0].SortMetadata.VIPChecked { + t.Fatal("VIP backfill did not distinguish paid, free, and unavailable metadata") + } + if app.libraryMetadata.Updated != 39 || needsHuangdouVIPMetadata(cache.Dramas[0]) { + t.Fatal("VIP progress or unknown classification causes repeated work", app.libraryMetadata) + } +} + +func TestHuangdouClickUpdatesFullMetadataAndPlaybackReusesDetail(t *testing.T) { + var calls atomic.Int32 + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.URL.Path != "/api/drama/detail" { + t.Error("refresh requested unrelated content", request.URL.Path) + } + return rankingHTTPResponse(request, 200, `{"data":{"id":"fixture","name":"新剧名","description":"新增简介","pay_type":"vip","episode_count":3,"free_episodes":1,"hot_rate":900,"issue_date":"2026-09-16","episodes":[{"seq":1,"type":"free"},{"seq":2,"type":"vip"},{"seq":3,"type":"vip"}]}}`), nil + }) + old := Drama{ID: "huangdou:fixture", Source: sourceHuangdou, Title: "旧剧名", TotalEpisode: "1", Heat: "100", Views: "0", OnlineDate: "2026-09-01", CategoryName: "保留分类", CoverURL: coverAddressFixture, SortMetadata: &sortMetadataState{Version: 1, VIPChecked: true}} + app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}} + response := dramaRefreshRequest(app, old.ID) + var result dramaRefreshResult + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Warning != "" || result.Drama.VIP == nil || !*result.Drama.VIP || result.Drama.Intro != "新增简介" || fmt.Sprint(result.Drama.TotalEpisode) != "3" || result.Drama.CategoryName != "保留分类" || bestDramaCover(result.Drama) != coverAddressFixture { + t.Fatal("click failed to supplement old checked metadata", response.Body.String()) + } + _, chapters, err := d.fetchHuangdouChapters(httptest.NewRequest("GET", "/", nil).Context(), "fixture") + if err != nil || len(chapters) != 3 || chapters[0].VIP || !chapters[1].VIP || calls.Load() != 1 { + t.Fatal("playback duplicated detail request or lost episode VIP flags", err, calls.Load()) + } +} + +func TestVIPMetadataRejectsForbiddenSourceAndInvalidPriority(t *testing.T) { + app := &UIApp{} + request := httptest.NewRequest(http.MethodPost, "/api/ui/vip/metadata", strings.NewReader(`{"priority":[]}`)) + request.Header.Set("Content-Type", "application/json") + request = request.WithContext(withSourceScope(request.Context(), accountRecord{Sources: []string{sourceHongguo}})) + writer := httptest.NewRecorder() + app.handleVIPMetadata(writer, request) + if writer.Code != http.StatusForbidden || len(app.metadataQueue) != 0 { + t.Fatal("Hongguo-only account requested Huangdou metadata") + } + for _, priority := range [][]string{{"hongguo:7000000000000000001"}, make([]string, 41)} { + body, _ := json.Marshal(map[string]any{"priority": priority}) + request = httptest.NewRequest(http.MethodPost, "/api/ui/vip/metadata", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + writer = httptest.NewRecorder() + app.handleVIPMetadata(writer, request) + if writer.Code != http.StatusBadRequest || len(app.metadataQueue) != 0 { + t.Fatal("invalid VIP metadata batch was queued", writer.Code) + } + } +} diff --git a/internal/webui/account.js b/internal/webui/account.js new file mode 100644 index 0000000..c5191be --- /dev/null +++ b/internal/webui/account.js @@ -0,0 +1,165 @@ +import { $, post, setMessage } from './ui-core.js'; +import { initializeViewer, announceViewerChange } from './viewer.js'; + +export function createAccount(app) { + let mode = 'login', busy = false; + const panel = $('accountPanel'); + + function setMode(value) { + if (busy) return; + mode = value === 'register' && app.viewer?.allowRegistration !== false ? 'register' : 'login'; + const registering = mode === 'register'; + document.querySelectorAll('[data-account-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.accountMode === mode))); + $('accountConfirmRow').hidden = !registering; + $('accountPasswordConfirm').required = registering; + $('accountImportOnRegisterRow').hidden = !registering; + $('accountPassword').autocomplete = registering ? 'new-password' : 'current-password'; + $('accountSubmitBtn').textContent = registering ? '注册并登录' : '登录'; + $('accountStatus').textContent = ''; + } + + function setBusy(value) { + busy = value; + panel.querySelectorAll('input, button:not([data-close])').forEach(control => {control.disabled = value;}); + panel.setAttribute('aria-busy', String(value)); + } + + function finish(message, error = false) { + try { sessionStorage.setItem('juku.account.flash', JSON.stringify({message, error})); } catch (_) {} + announceViewerChange(); + location.reload(); + } + + async function authenticate(event) { + event.preventDefault(); + if (busy) return; + const registering = mode === 'register'; + if (registering && $('accountPassword').value !== $('accountPasswordConfirm').value) { + $('accountStatus').textContent = '两次输入的密码不一致'; + return; + } + const importRecords = registering && $('accountImportOnRegister').checked; + const username = $('accountUsername').value.trim(), password = $('accountPassword').value; + setBusy(true); + $('accountStatus').textContent = registering ? '正在创建账号…' : '正在登录…'; + try { + await post('/api/ui/account/' + mode, {username, password}); + $('accountPassword').value = ''; + $('accountPasswordConfirm').value = ''; + app.viewer = await initializeViewer(); + if (!app.viewer.account) throw new Error('登录 Cookie 未保存,请允许此网站保存 Cookie 后重试'); + let message = registering ? '账号已创建,观看记录将跟随账号保存。' : '已登录 ' + app.viewer.account.username + ',个人记录已加载。'; + if (importRecords && app.viewer.guestImportAvailable) { + try { + await post('/api/ui/account/import', {}); + message = '账号已创建,此浏览器原有记录已接入账号。'; + } catch (error) { + finish('账号已创建;' + error.message + '。可在账号面板重试接回记录。', true); + return; + } + } + finish(message); + } catch (error) { + $('accountStatus').textContent = error.message; + if (app.viewer?.account) finish('账号已登录,请刷新后继续。', true); + } finally { setBusy(false); } + } + + async function logout() { + if (busy) return; + setBusy(true); + $('accountStatus').textContent = '正在退出…'; + try { + await post('/api/ui/account/logout', {}); + finish('已退出账号。账号记录已保留,下次登录可继续。'); + } catch (error) { $('accountStatus').textContent = error.message; } + finally { setBusy(false); } + } + + async function changePassword(event) { + event.preventDefault(); + if (busy) return; + if ($('accountNewPassword').value !== $('accountNewPasswordConfirm').value) { + $('accountStatus').textContent = '两次输入的新密码不一致'; + return; + } + const password = $('accountCurrentPassword').value, newPassword = $('accountNewPassword').value; + setBusy(true); + $('accountStatus').textContent = '正在修改密码…'; + try { + await post('/api/ui/account/password', {password, newPassword}); + $('accountPasswordForm').reset(); + finish('密码已修改,其他设备的登录已失效。'); + } catch (error) { $('accountStatus').textContent = error.message; } + finally { setBusy(false); } + } + + async function importGuest() { + if (busy) return; + setBusy(true); + $('accountStatus').textContent = '正在接回此浏览器原有记录…'; + try { + await post('/api/ui/account/import', {}); + finish('此浏览器原有记录已接入账号,原始文件仍保留。'); + } catch (error) { $('accountStatus').textContent = error.message; } + finally { setBusy(false); } + } + + function open() { + $('headerMenu').open = false; + window.JukuDialogs.open(panel); + } + + function init() { + const account = app.viewer?.account; + const forcedPassword = Boolean(account?.requirePasswordChange); + const loginPage = location.pathname === '/login' || forcedPassword || app.viewer?.requireLogin && !account; + $('accountGuest').hidden = Boolean(account); + $('accountMember').hidden = !account; + $('accountLabel').textContent = account?.username || '登录'; + $('openAccountBtn').setAttribute('aria-label', account ? '账号:' + account.username : '登录或注册账号'); + $('openAccountBtn').title = account ? '当前账号:' + account.username : '登录后跨设备接着看'; + $('accountName').textContent = account?.username || ''; + $('accountGuestImportSection').hidden = !app.viewer?.guestImportAvailable; + $('accountManageUsersBtn').hidden = !account?.admin || forcedPassword; + $('accountRegistrationDisabled').hidden = app.viewer?.allowRegistration !== false; + document.querySelector('[data-account-mode="register"]').hidden = app.viewer?.allowRegistration === false; + $('accountForcedPasswordHint').hidden = !forcedPassword; + if (forcedPassword) { + $('accountPasswordDetails').open = true; + $('accountPasswordDetails').querySelector('summary').hidden = true; + $('accountGuestImportSection').hidden = true; + } + $('viewerIdentityLabel').textContent = account ? '账号:' + account.username : '当前浏览器'; + $('viewerRecordsHint').textContent = account ? '观看进度和追剧清单跟随账号保存,换设备登录同一账号即可继续。可用站源和下载权限由管理员设置。' : '观看进度和追剧清单保存在当前浏览器身份下。点击顶栏的账号图标注册或登录,可跨设备继续观看。'; + const searchLabel = account ? '搜索我的观看记录' : '搜索本浏览器的观看记录'; + $('historySearch').placeholder = searchLabel; + $('historySearch').setAttribute('aria-label', searchLabel); + $('openAccountBtn').addEventListener('click', open); + document.querySelectorAll('[data-account-open]').forEach(button => button.addEventListener('click', open)); + document.querySelectorAll('[data-account-mode]').forEach(button => button.addEventListener('click', () => setMode(button.dataset.accountMode))); + $('accountForm').addEventListener('submit', authenticate); + $('accountLogoutBtn').addEventListener('click', logout); + $('accountPasswordForm').addEventListener('submit', changePassword); + $('accountImportGuestBtn').addEventListener('click', importGuest); + panel.addEventListener('close', () => panel.querySelectorAll('input[type=password]').forEach(input => {input.value = '';})); + setMode('login'); + if (loginPage) { + document.body.classList.add('account-page'); + panel.setAttribute('open', ''); + panel.querySelector('[data-close]').hidden = true; + $('accountTitle').textContent = forcedPassword ? '修改初始密码' : '登录剧库'; + $('accountAnonymousLink').hidden = app.viewer?.requireLogin || Boolean(account); + document.documentElement.dataset.ready = 'true'; + } + try { + const flash = JSON.parse(sessionStorage.getItem('juku.account.flash') || 'null'); + sessionStorage.removeItem('juku.account.flash'); + if (flash?.message) setMessage(flash.message, flash.error); + else if (app.viewer?.accountExpired) setMessage('登录已过期,请重新登录账号接着看。'); + } catch (_) {} + return Boolean(loginPage); + } + + return {init}; +} diff --git a/internal/webui/app.css b/internal/webui/app.css new file mode 100644 index 0000000..47519bf --- /dev/null +++ b/internal/webui/app.css @@ -0,0 +1,163 @@ +:root{color-scheme:light;--bg:#f7f7f2;--panel:#fffefb;--soft:#edeee8;--text:#242a27;--muted:#626b63;--line:#dde2d9;--line-strong:#9aa99d;--brand:#315e52;--on-brand:#fff;--brand-text:#355f52;--tint:#e7eee8;--poster:#e7e9e1;--poster-ink:#5e6b5e;--danger:#a34242;--danger-bg:#f7e9e6;--success:#376348;--success-bg:#e7efe6;--warning:#7c5b29;--warning-bg:#f5eddf;--shadow:0 12px 40px #1b302019;--radius:10px;--control-radius:7px} +:root[data-theme=dark]{color-scheme:dark;--bg:#161b19;--panel:#1e2622;--soft:#29332d;--text:#e9efe9;--muted:#a4b1a8;--line:#354239;--line-strong:#687e6f;--brand:#b5d5c1;--on-brand:#1b3023;--brand-text:#b5d5c1;--tint:#2b3e32;--poster:#29372e;--poster-ink:#b3c4b5;--danger:#efb0aa;--danger-bg:#3e2b29;--success:#b0cfb4;--success-bg:#2a3d2d;--warning:#ddc397;--warning-bg:#3b3426;--shadow:0 12px 40px #0005} +*{box-sizing:border-box} +html,body{height:100%;margin:0} +body{display:flex;flex-direction:column;height:100dvh;overflow:hidden;background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased} +button,input,select,textarea{font:inherit;color:inherit} +button,input,select,summary{-webkit-tap-highlight-color:transparent} +button,summary{cursor:pointer;touch-action:manipulation} +button,input,select{border:1px solid var(--line);border-radius:var(--control-radius);background:var(--panel);max-width:100%} +button,select,input:not([type=checkbox]){min-height:34px} +button{padding:6px 12px;background:var(--brand);color:var(--on-brand);border-color:var(--brand);font-weight:550;font-size:13px;line-height:1.4} +button.secondary{background:var(--panel);color:var(--text);border-color:var(--line)} +button.subtle{background:var(--tint);color:var(--brand-text);border-color:transparent} +button.quiet{background:transparent;border-color:transparent;color:var(--muted)} +button.danger{background:var(--danger);border-color:var(--danger);color:var(--panel)} +button:disabled{opacity:.45;cursor:default} +button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible,a:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--brand-text);outline-offset:3px} +input,select{padding:6px 10px;min-width:0} +input[type=search]{font-size:14px} +input[type=checkbox]{accent-color:var(--brand);width:17px;height:17px;flex:none;margin:0;cursor:pointer} +select{font-size:13px} +input::placeholder{color:var(--muted);opacity:1} +a{color:var(--brand-text)} +h1,h2,h3,p{margin:0} +h1{font-size:23px;font-weight:650;line-height:1.4;letter-spacing:-.025em} +h2{font-size:16px;font-weight:650;line-height:1.5} +h3{font-size:14px;font-weight:600} +svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:none} +[hidden]{display:none!important} +.small,.statusbar{font-size:12px;line-height:1.55;color:var(--muted)} +.error{color:var(--danger);font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere} +.error:empty{display:none} +.row,.toolbar,.heading,.section-heading{display:flex;align-items:center;gap:8px} +.heading,.section-heading{justify-content:space-between} +.toolbar{flex-wrap:wrap} +.section-heading{margin-bottom:10px} +.section-heading .small{margin-left:6px;font-weight:400} +.spacer{flex:1;min-width:0} +.icon-button{width:34px;min-width:34px;height:34px;padding:0;display:inline-flex;align-items:center;justify-content:center} +.button-content{display:inline-flex;align-items:center;justify-content:center;gap:6px} +.empty{grid-column:1/-1;text-align:center;padding:42px 18px;color:var(--muted);border:1px dashed var(--line-strong);border-radius:var(--radius);background:var(--panel)} +.empty strong{display:block;font-size:16px;color:var(--text);margin-bottom:6px} +.empty button{margin-top:14px} +.tag,.pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:4px;background:var(--soft);color:var(--muted);font-size:11px;white-space:nowrap} +.tags{display:flex;flex-wrap:wrap;gap:5px} +.progress{height:3px;border-radius:2px;overflow:hidden;background:var(--soft);margin:9px 0 6px} +.progress>span{display:block;height:100%;background:var(--brand);border-radius:2px} +.notice{margin-top:8px;overflow-wrap:anywhere} +.notice:empty{display:none} +.skip-link{position:fixed;left:16px;top:-100px;z-index:30;padding:8px 14px;background:var(--panel);border-radius:7px} +.skip-link:focus{top:8px} +.app-header{flex:none;background:var(--panel);border-bottom:1px solid var(--line);padding:0 max(24px,calc((100vw - 1840px)/2));height:54px;display:flex;align-items:center;gap:36px;z-index:5} +.brand{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--text);white-space:nowrap} +.brand-mark{display:grid;place-items:center;width:29px;height:29px;background:var(--brand);border-radius:8px;color:var(--on-brand)} +.brand-mark svg{width:21px;height:21px;stroke-width:1.6} +.brand-name{font-size:18px;font-weight:650;letter-spacing:.01em} +.header-actions{display:flex;gap:6px;align-items:center;margin-left:auto} +.header-actions #refreshBtn{background:var(--tint);border-color:transparent;color:var(--brand-text)} +.header-actions #refreshBtn[aria-busy=true]{opacity:1;cursor:progress} +#refreshBtnLabel{display:inline-block;width:4.25em;flex:none;text-align:center;white-space:nowrap} +#refreshBtn[aria-busy=true] svg{animation:library-refresh-turn .9s linear infinite;transform-origin:50% 50%} +@keyframes library-refresh-turn{to{transform:rotate(360deg)}} +@media(prefers-reduced-motion:reduce){#refreshBtn[aria-busy=true] svg{animation:none}} +.desktop-nav{display:flex;align-self:stretch;gap:10px} +.nav-button{display:flex;align-items:center;justify-content:center;gap:7px;background:none;color:var(--muted);border:0;border-radius:0;padding:0 13px;position:relative;white-space:nowrap;font-weight:500} +.nav-button[aria-current=page]{color:var(--brand-text);font-weight:650} +.desktop-nav .nav-button[aria-current=page]:after{position:absolute;content:"";bottom:-1px;left:12px;right:12px;height:2px;border-radius:2px;background:var(--brand)} +.nav-count{font-size:10px;padding:0 4px;min-width:16px;border-radius:4px;background:var(--soft);color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums} +.mobile-nav{display:none} +.workspace{flex:1;min-height:0;overflow:auto;overscroll-behavior-y:contain;scrollbar-gutter:stable;padding:14px max(24px,calc((100% - 1840px)/2)) 28px;scroll-behavior:auto} +.modal-open .workspace{overflow:hidden} +.workspace:focus{outline:none} +.page-view{min-width:0} +.page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px} +.page-heading p{color:var(--muted);font-size:12px;margin-top:3px} +.catalog-layout{display:grid;grid-template-columns:minmax(0,1fr);align-items:start;gap:20px} +.catalog-layout.with-activity{grid-template-columns:minmax(0,1fr) 254px} +.activity-panel{position:sticky;top:0;border:1px solid var(--line);background:var(--panel);border-radius:var(--radius);padding:13px} +.activity-panel h2{font-size:14px} +.activity-item{padding:11px 0;border-bottom:1px solid var(--line)} +.activity-item:last-child{border:0} +.activity-item h3{font-size:13px} +.activity-panel>.quiet{width:100%;margin-top:8px} +.menu-list{display:grid;gap:3px} +.menu-list button{display:flex;align-items:center;gap:10px;text-align:left;background:transparent;border-color:transparent;color:var(--text);border-radius:6px;min-height:38px} +.menu-list button svg:last-child{margin-left:auto;width:14px} +.action-menu{position:relative} +.action-menu>summary{list-style:none;padding:6px 11px;border:1px solid var(--line);border-radius:var(--control-radius);font-size:13px;min-height:34px;display:flex;align-items:center;justify-content:center;white-space:nowrap;background:var(--panel)} +.action-menu>summary::-webkit-details-marker{display:none} +.action-popover{position:absolute;right:0;top:calc(100% + 6px);z-index:8;width:230px;max-width:calc(100vw - 24px);padding:6px;border:1px solid var(--line);border-radius:10px;background:var(--panel);box-shadow:var(--shadow);display:grid;gap:3px} +.action-menu:not([open])>.action-popover{display:none} +.action-popover button{text-align:left} +.action-popover button.secondary{border-color:transparent;background:transparent} +.action-popover label{display:flex;align-items:center;gap:8px;min-height:38px;padding:7px;font-size:12px} +.action-popover p{margin:4px 0} +.header-menu>summary{padding:0;width:34px;height:34px;border-color:transparent;background:transparent;color:var(--muted)} +.header-menu .action-popover{width:242px} +.header-menu .small{padding:8px 10px 4px;border-top:1px solid var(--line);font-size:11px} +dialog{color:var(--text);background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);width:min(520px,calc(100vw - 32px));max-height:90dvh;overflow:auto;padding:0} +dialog::backdrop{background:#10251d70} +dialog .dialog-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--panel);z-index:2} +dialog .dialog-heading h2{font-size:17px} +.dialog-body{padding:18px} +.dialog-actions{display:flex;gap:8px;margin-top:16px} +.dialog-actions>button{flex:1} +.dialog-form{display:grid;gap:16px} +.field{display:grid;gap:6px;font-size:13px;font-weight:600} +.field select{width:100%} +.settings-dialog{width:min(690px,calc(100vw - 32px));padding:18px} +.settings-dialog>.heading{padding-bottom:12px;border-bottom:1px solid var(--line);margin-bottom:16px} +.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0} +.settings-grid label{display:grid;gap:5px;font-size:12px} +.settings-grid .wide{grid-column:1/-1} +.directory-input{display:flex;gap:8px;align-items:end} +.directory-input label{display:grid;gap:5px;flex:1} +.directory-input input{width:100%} +.directory-input button{white-space:nowrap} +.appearance-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:14px;margin-bottom:16px;border-bottom:1px solid var(--line)} +.settings-dialog .statusbar{overflow-wrap:anywhere;margin-top:14px} +.settings-dialog .settings-body>.small{margin-top:8px} +.toast{position:fixed;z-index:30;left:50%;bottom:20px;transform:translateX(-50%);padding:9px 14px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:var(--shadow);max-width:min(640px,calc(100vw - 24px));font-size:13px;display:flex;align-items:center;gap:12px} +.toast:empty{display:none} +.toast button{background:var(--tint);color:var(--brand-text);border:0;white-space:nowrap;padding:6px 10px} +.source-status-row{padding:12px 0;border-bottom:1px solid var(--line)} +.source-status-row p{margin-top:4px;overflow-wrap:anywhere} +.source-status-row:first-child{padding-top:0} +.source-status-heading h3{min-width:0;overflow-wrap:anywhere}.source-status-actions{display:flex;align-items:center;gap:8px;flex:none}.source-status-actions .tag{white-space:nowrap}.source-status-actions .source-update{font-size:12px;padding:6px 10px}.source-status-row p:empty{display:none} +.skeleton-card{aspect-ratio:3/5;border-radius:8px;background:var(--soft)} +@media(hover:hover){button:not(:disabled):hover{filter:brightness(.96)}button.secondary:not(:disabled):hover{border-color:var(--line-strong)}button.quiet:hover,.action-popover button.secondary:hover,.menu-list button:hover,.header-menu>summary:hover{background:var(--soft);color:var(--text);filter:none}.nav-button:hover{color:var(--brand-text)}.action-menu>summary:hover{border-color:var(--line-strong)}} +@media(max-width:1100px){.app-header{gap:20px;padding:0 20px}.desktop-nav{gap:2px}.nav-button{padding-left:11px;padding-right:11px}.workspace{padding-left:20px;padding-right:20px}.catalog-layout.with-activity{grid-template-columns:minmax(0,1fr)}.activity-panel,.activity-toggle{display:none!important}} +@media(max-width:700px){ + .app-header{padding:0 12px;height:50px;gap:8px}.brand-mark{width:27px;height:27px}.brand-name{font-size:17px}.brand{gap:7px}.desktop-nav{display:none}.header-actions{gap:3px}.header-actions button{font-size:12px;padding:6px 9px}.header-actions svg{width:16px}.workspace{padding:8px 14px 20px}.mobile-nav{display:flex;justify-content:space-around;flex:none;background:var(--panel);border-top:1px solid var(--line);padding:3px 10px calc(3px + env(safe-area-inset-bottom));z-index:5}.mobile-nav .nav-button{flex:1;min-height:47px;flex-direction:column;font-size:10px;gap:1px;padding:3px 8px}.mobile-nav svg{width:20px;height:20px}.mobile-nav .nav-count{position:absolute;top:1px;left:calc(50% + 7px);padding:0 4px}.mobile-nav .nav-button[aria-current=page] svg{stroke-width:2}.page-heading{margin-bottom:14px;gap:10px}.page-heading h1{font-size:21px}.section-heading{gap:6px}.section-heading button{font-size:12px;padding:5px 7px}.section-heading .small{font-size:11px;margin-left:4px}.toolbar{gap:6px}input[type=search],input[type=text],input[type=url],input[type=password]{font-size:16px}dialog{width:100%;max-width:100%;max-height:90dvh;margin:auto 0 0;border-radius:14px 14px 0 0;padding-bottom:env(safe-area-inset-bottom)}dialog .dialog-heading{padding:10px 14px}.dialog-body{padding:16px 14px}.settings-dialog{width:100%;max-width:100%;padding:16px 14px calc(18px + env(safe-area-inset-bottom))}.settings-grid{gap:12px}.directory-input{flex-wrap:wrap}.directory-input label{min-width:100%}.directory-input button{margin-left:auto}.toast{bottom:calc(66px + env(safe-area-inset-bottom))}.header-menu .action-popover{max-width:calc(100vw - 24px)} +} +@media(max-width:355px){.workspace{padding-left:10px;padding-right:10px}.app-header{padding-left:10px;padding-right:10px}.brand-name{font-size:16px}.header-actions button{font-size:11px;padding:6px 7px}.header-actions .button-content svg{width:14px;height:14px}.settings-grid{grid-template-columns:minmax(0,1fr)}.settings-grid .wide{grid-column:auto}} +@media(pointer:coarse){button,select,input:not([type=checkbox]),.action-menu>summary{min-height:40px}.icon-button,.header-menu>summary{width:40px;min-width:40px;height:40px}.header-actions #refreshBtn{padding-top:7px;padding-bottom:7px}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition:none!important}} + +.viewer-notice {display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; margin: 12px 20px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); font-size: 13px;} +.viewer-notice[hidden], #legacyImportSection[hidden] {display: none;} +.viewer-settings {margin-top: 16px; padding: 14px 0 0; border-top: 1px solid var(--line);} +.viewer-settings h3 {display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin: 0 0 6px; font-size: 14px;} +.viewer-settings p {margin: 6px 0; line-height: 1.6;} +.viewer-import-row {display: flex; align-items: end; gap: 8px; margin-top: 10px;} +.viewer-import-row label {flex: 1; min-width: 0;} +.viewer-import-row input {display: block; width: 100%; margin-top: 4px;} +.viewer-import-row button {flex: 0 0 auto;} + +.header-actions #openAccountBtn{display:inline-flex;align-items:center;gap:5px;max-width:124px;color:var(--muted)}.account-label{max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.account-dialog{width:min(400px,calc(100vw - 32px))}.account-dialog .dialog-body{padding-top:14px}.account-intro{margin:0 0 14px;font-size:14px}.account-tabs{margin-bottom:16px;width:100%}.account-tabs button{flex:1}.account-form{display:grid;gap:13px}.account-form>label:not(.account-check){display:grid;gap:6px;font-size:12px;color:var(--muted)}.account-form input{width:100%;min-width:0}.account-form .account-check{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}.account-check input{width:auto;flex:none}.account-form>button{justify-self:start;min-width:92px}.account-note{margin:14px 0 0;line-height:1.7}.account-member-heading{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:12px}.account-member-heading>div{min-width:0;display:grid;gap:3px}.account-member-heading strong{font-size:19px;overflow-wrap:anywhere}.account-member-heading button{flex:none}.account-password{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}.account-password>summary{cursor:pointer;font-size:13px}.account-password[open]>summary{margin-bottom:13px}.account-import{margin-top:14px}.account-status{line-height:1.7;overflow-wrap:anywhere;margin:12px 0 0}.account-status:empty{display:none}.account-dialog [hidden]{display:none!important} +@media(max-width:700px){.header-actions #openAccountBtn{width:32px;min-width:32px;justify-content:center;padding:6px}.account-label{display:none}.account-dialog{width:100%}.account-form input{font-size:16px}} + +.account-admin{margin-top:20px;border-top:1px solid var(--line);padding-top:4px}.account-admin h3{font-size:14px;margin:16px 0 12px}.account-check small{display:block;color:var(--muted);font-size:11px;line-height:1.6;margin-top:2px}.admin-account-list{list-style:none;margin:15px 0 0;padding:0;max-height:180px;overflow:auto}.admin-account-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:7px 0;border-top:1px solid var(--line);font-size:12px}.admin-account-row>span:first-child{overflow-wrap:anywhere;min-width:0}.account-initial-note{font-size:13px;line-height:1.7;color:var(--brand-text);padding:10px;background:var(--tint);border-radius:7px}.account-anonymous-link{display:block;margin-top:16px;font-size:13px;text-align:center;color:var(--muted)}.account-page{overflow:auto;min-height:100dvh}.account-page .workspace,.account-page .desktop-nav,.account-page .header-actions,.account-page .mobile-nav,.account-page .skip-link{display:none!important}.account-page .app-header{justify-content:center;flex:none;border:0;background:transparent;margin:20px 0 6px;height:auto;padding:15px}.account-page #accountPanel{position:relative;inset:auto;display:block;flex:none;margin:0 auto 32px;width:min(400px,calc(100vw - 28px));max-height:none;border-radius:12px;box-shadow:var(--shadow)}.account-page #accountPanel .dialog-heading{padding-right:20px}.account-page #messageText{position:fixed}.account-page #accountPanel[open]{animation:none} + +.account-source-fieldset{border:0;padding:0;margin:0;min-width:0}.account-source-fieldset legend{font-size:12px;color:var(--muted);margin-bottom:8px}.account-source-choices{display:flex;gap:8px 16px;flex-wrap:wrap}.account-source-choice{display:flex;align-items:center;gap:6px;font-size:13px;min-height:32px;cursor:pointer}.account-source-choice input{width:16px;height:16px;margin:0;accent-color:var(--brand)}.admin-account-list{max-height:330px}.admin-account-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;padding:12px 0}.admin-account-heading{grid-column:1/-1;display:flex;justify-content:space-between;gap:8px;align-items:center;overflow-wrap:anywhere}.admin-account-row>button{font-size:12px;padding:6px 9px;align-self:end}.admin-account-row .account-source-choices{gap:5px 12px} + +.users-page{max-width:1080px;margin:0 auto}.users-settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-card{min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px}.admin-card h2{font-size:15px;margin-bottom:14px}.admin-access-hint{line-height:1.8;margin:16px 0 0}.admin-users-card{margin-top:16px}.admin-list-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:10px}.admin-list-heading h2{margin:0}.admin-list-heading input{width:min(260px,45%);min-width:0}.users-page .admin-account-list{max-height:none;overflow:visible;margin:0}.users-page .admin-account-row{grid-template-columns:minmax(120px,.8fr) minmax(0,1.5fr) auto;gap:16px;padding:14px 0}.users-page .admin-account-heading{grid-column:auto;align-items:flex-start;flex-direction:column;gap:4px}.admin-account-permissions{display:grid;gap:7px;min-width:0}.admin-account-permissions .account-check{display:flex;align-items:center;gap:6px;font-size:12px;min-height:30px}.admin-account-permissions .account-check input{margin:0;width:16px;height:16px;accent-color:var(--brand)}.users-page .admin-account-row>button{align-self:center}.account-manage-users{margin-top:16px}.online-only [data-download-only],.online-only [data-page=downloads],.online-only [data-go=downloads]{display:none!important} +@media(max-width:700px){.users-settings-grid{grid-template-columns:1fr;gap:12px}.admin-card{padding:13px}.users-page .admin-account-row{grid-template-columns:minmax(0,1fr) auto;gap:8px 12px}.users-page .admin-account-heading{grid-column:1/-1;flex-direction:row;align-items:center}.users-page .admin-account-heading strong{max-width:65%}.users-page .admin-account-row>button{align-self:end}.admin-list-heading{flex-wrap:wrap;gap:10px}.admin-list-heading input{width:100%}.admin-account-permissions .account-source-choices{gap:4px 10px}} + +.menu-list button[aria-current=page]{color:var(--brand-text);background:var(--soft)} +.emby-form .account-check{display:flex;align-items:flex-start;gap:8px;line-height:1.5;cursor:pointer}.emby-form .account-check>input[type=checkbox]{width:17px;height:17px;flex:0 0 17px;margin:2px 0 0;padding:0}.emby-form .account-check>span{display:block;flex:1;min-width:0} + +.emby-dialog{width:min(780px,calc(100vw - 24px))}.emby-form{display:grid;gap:14px}.emby-form .settings-grid{margin:0}.emby-refresh-settings{border:1px solid var(--line);border-radius:10px;padding:12px}.emby-refresh-settings>summary{cursor:pointer;font-size:13px}.emby-refresh-settings[open]>.settings-grid{margin-top:12px}.emby-status{border-top:1px solid var(--line);padding-top:14px}.emby-entries{list-style:none;padding:0;margin:12px 0;max-height:320px;overflow:auto}.emby-entry{display:flex;flex-wrap:wrap;gap:4px 12px;padding:10px 0;border-bottom:1px solid var(--line)}.emby-entry>strong{font-size:13px;overflow-wrap:anywhere}.emby-entry>span{margin-left:auto}.emby-entry>p{flex-basis:100%;margin:0;font-size:12px;overflow-wrap:anywhere}.emby-dialog input{min-width:0}.emby-dialog .settings-grid .wide{grid-column:1/-1}.emby-dialog .account-check{margin:0}.emby-dialog .account-source-fieldset{display:grid;gap:10px}@media(max-width:600px){.emby-dialog{max-height:calc(100dvh - 24px)}.emby-entry>span{margin-left:0}.emby-form .settings-grid{grid-template-columns:minmax(0,1fr)}} + +.playback-settings{margin-top:16px}.playback-settings summary{cursor:pointer;font-weight:600}.playback-option,.playback-direct-sources label{display:inline-flex;align-items:center;gap:8px;margin:12px 0}.playback-option input[type=checkbox],.playback-direct-sources input[type=checkbox]{width:16px;height:16px;flex:0 0 16px;margin:0}.playback-direct-sources{display:flex;flex-wrap:wrap;gap:4px 18px} diff --git a/internal/webui/card-viewport.js b/internal/webui/card-viewport.js new file mode 100644 index 0000000..8dd1f79 --- /dev/null +++ b/internal/webui/card-viewport.js @@ -0,0 +1,74 @@ +import { element, reconcileChildren, withFocus } from './ui-core.js'; + +export function createCardViewport({container, scroller, create, update, key, rendered}) { + let items = [], emptyNode = null, frame = 0, revision = 0, drawnRevision = -1; + let columns = 1, rowHeight = 0, measuredWidth = 0, first = -1, last = -1; + let mounted = new Map(); + const before = element('div', 'cards-spacer'), after = element('div', 'cards-spacer'); + before.setAttribute('aria-hidden', 'true'); + after.setAttribute('aria-hidden', 'true'); + container.setAttribute('role', 'list'); + + function schedule() { + if (!frame) frame = requestAnimationFrame(() => {frame = 0; draw();}); + } + + function draw() { + const width = container.clientWidth; + if (!width || !scroller.clientHeight) return; + const style = getComputedStyle(container); + const tracks = style.gridTemplateColumns.split(/\s+/).filter(value => /^\d/.test(value)); + const nextColumns = Math.max(1, tracks.length); + if (width !== measuredWidth || columns !== nextColumns) { + measuredWidth = width; + columns = nextColumns; + rowHeight = 0; + first = -1; + } + const gap = parseFloat(style.rowGap) || 0; + const step = (rowHeight || (parseFloat(tracks[0]) || width) * 4 / 3 + 60) + gap; + const top = Math.max(0, scroller.getBoundingClientRect().top - container.getBoundingClientRect().top); + const totalRows = Math.ceil(items.length / columns); + const startRow = Math.max(0, Math.min(Math.max(0, totalRows - 1), Math.floor(top / step) - 1)); + const endRow = Math.min(totalRows, Math.ceil((top + scroller.clientHeight) / step) + 1); + const start = Math.min(items.length, startRow * columns), end = Math.min(items.length, endRow * columns); + if (first === start && last === end && drawnRevision === revision) return; + const nextMounted = new Map(), nodes = [], changed = []; + if (startRow > 0 && items.length) {before.style.height = Math.max(0, startRow * step - gap) + 'px'; nodes.push(before);} + for (let index = start; index < end; index++) { + const item = items[index], renderKey = key(item); + let card = mounted.get(item.id); + if (!card || card.renderKey !== renderKey) {card = create(item); changed.push(card);} + else if (drawnRevision !== revision) {update(card, item); changed.push(card);} + card.setAttribute('role', 'listitem'); + card.setAttribute('aria-posinset', String(index + 1)); + card.setAttribute('aria-setsize', String(items.length)); + nextMounted.set(item.id, card); + nodes.push(card); + } + if (endRow < totalRows) {after.style.height = Math.max(0, (totalRows - endRow) * step - gap) + 'px'; nodes.push(after);} + if (!items.length && emptyNode) nodes.push(emptyNode); + const active = document.activeElement?.closest?.('.card'); + if (active && container.contains(active) && !nextMounted.has(active.dataset.dramaId)) scroller.focus({preventScroll: true}); + withFocus(container, () => reconcileChildren(container, nodes)); + mounted = nextMounted; + first = start; + last = end; + drawnRevision = revision; + container.setAttribute('aria-busy', 'false'); + if (!rowHeight && mounted.size) { + rowHeight = mounted.values().next().value.getBoundingClientRect().height; + first = -1; + schedule(); + } + if (changed.length) rendered(changed); + } + + scroller.addEventListener('scroll', schedule, {passive: true}); + window.addEventListener('resize', schedule); + if (typeof ResizeObserver === 'function') new ResizeObserver(schedule).observe(container); + return { + setItems(next, placeholder = null) {items = next; emptyNode = placeholder; revision++; container.setAttribute('aria-busy', 'true'); schedule();}, + refresh() {first = -1; schedule();} + }; +} diff --git a/internal/webui/cover-repair.js b/internal/webui/cover-repair.js new file mode 100644 index 0000000..0670484 --- /dev/null +++ b/internal/webui/cover-repair.js @@ -0,0 +1,41 @@ +import { coverURL } from './ui-core.js'; + +export function createCoverRepair({get, post, apply}) { + const failures = new Map(), touched = new Map(), pending = new Set(), retryAt = new Map(), repaired = new Map(); + + async function repair(id) { + const drama = get(id); + if (!drama) return; + const now = Date.now(), observed = coverURL(drama); + if (observed && failures.get(id) !== observed || pending.has(id) || now < (retryAt.get(id) || 0)) return; + pending.add(id); + retryAt.set(id, now + 300000); + try { + const result = await post('/api/ui/cover/repair', {dramaId: id, cover: observed}); + retryAt.set(id, Date.now() + Math.max(300, Number(result.retryAfter) || 0) * 1000); + if (result.dramaId !== id || typeof result.cover !== 'string' || !result.cover.startsWith('/api/ui/image?') || !get(id) || coverURL(get(id)) !== observed) return; + failures.delete(id); + if (result.cover !== observed) repaired.set(id, {previous: observed, address: result.cover, expires: retryAt.get(id)}); + apply(id, result.cover, observed); + } catch {} + finally {pending.delete(id);} + } + + return { + updated(id) {failures.delete(id); retryAt.set(id, Date.now() + 300000); touched.set(id, Date.now());}, + reconcile(drama) { + const latest = repaired.get(drama.id); + if (!latest) return drama; + if (Date.now() >= latest.expires) {repaired.delete(drama.id); return drama;} + if (coverURL(drama) === latest.previous) {drama.cover = latest.address; drama.coverUrl = latest.address;} + return drama; + }, + touch(id) {touched.set(id, Date.now()); void repair(id);}, + failed(id, address) { + if (!get(id) || coverURL(get(id)) !== address) return; + failures.set(id, address); + if (Date.now() - (touched.get(id) || 0) < 60000) void repair(id); + }, + loaded(id, address) {if (failures.get(id) === address) failures.delete(id);} + }; +} diff --git a/internal/webui/details.js b/internal/webui/details.js new file mode 100644 index 0000000..91c3a30 --- /dev/null +++ b/internal/webui/details.js @@ -0,0 +1,166 @@ +import { $, element, button, icon, coverURL, withFocus, dramaTitle, sourceKey, sourceLabel, categoryName, episodeCount, firstNonEmpty, tagsText, releaseText, setMessage } from './ui-core.js'; + +export function createDetails(app) { + let currentID = ''; + const metadataMessages = new Map(); + let poster; + const exporting = new Set(); + const panel = $('detailPanel'); + const content = $('detailContent'); + + function renderCover(drama) { + const address = coverURL(drama), title = dramaTitle(drama); + if (poster?.id === drama.id && poster.address === address) { + if (poster.image) poster.image.alt = title + ' 海报'; + return poster.node; + } + const node = element('div', 'detail-cover'); + const status = element('span', 'detail-cover-status', address ? '加载海报…' : '暂无海报'); + status.setAttribute('role', 'status'); + node.appendChild(status); + const current = {id: drama.id, address, node, failed: false}; + poster = current; + if (!address) return node; + const image = element('img'); + current.image = image; + image.alt = title + ' 海报'; + image.decoding = 'async'; + image.hidden = true; + image.addEventListener('load', () => { + if (poster !== current) return; + current.failed = false; + image.hidden = false; + status.hidden = true; + app.library.coverLoaded(drama.id, address); + }); + image.addEventListener('error', () => { + if (poster !== current) return; + current.failed = true; + image.hidden = true; + status.hidden = false; + status.textContent = '海报加载失败'; + app.library.coverFailed(drama.id, address); + }); + current.retry = () => { + if (!current.failed) return; + current.failed = false; + status.textContent = '加载海报…'; + image.src = address; + }; + node.appendChild(image); + image.src = address; + return node; + } + + function refreshCover(id, retry = false) { + if (!panel.open || currentID !== id) return; + const drama = app.library.get(id); + if (!drama) return; + const previous = poster?.node; + const next = renderCover(drama); + if (previous !== next) previous?.replaceWith(next); + if (retry) poster?.retry?.(); + } + + function render() { + if (!currentID) return; + const saved = app.following.get(currentID); + const history = window.JukuHistory.get(currentID); + const known = app.library.get(currentID); + const drama = known || {id: currentID, title: saved?.title || history?.title || '短剧', source: saved?.source || history?.source, totalEpisode: saved?.totalEpisode || history?.total, categoryName: saved?.category}; + const title = dramaTitle(drama); + withFocus(content, () => { + content.replaceChildren(); + const top = element('div', 'detail-top'); + const heading = element('div', 'spacer'); + const name = element('h1', '', title); + name.id = 'detailTitle'; + heading.append(name, element('p', 'detail-meta', sourceLabel(sourceKey(drama)) + ' · ' + categoryName(drama))); + const tags = element('div', 'tags detail-tags'); + tagsText(drama).slice(0, 10).forEach(tag => tags.appendChild(element('span', 'tag', tag))); + if (drama.vip === true) tags.prepend(element('span', 'tag vip-tag', 'VIP · 仅试看')); + heading.appendChild(tags); + top.append(renderCover(drama), heading); + content.appendChild(top); + const metadataStatus = element('p', 'small', metadataMessages.get(currentID) || ''); + metadataStatus.id = 'detailMetadataStatus'; + metadataStatus.setAttribute('role', 'status'); + metadataStatus.hidden = !metadataStatus.textContent; + content.appendChild(metadataStatus); + if (drama.vip === true) content.appendChild(element('p', 'notice vip-notice', '此剧含 VIP 分集,站源仅提供试看;试看内容不会作为完整分集下载。')); + if (history) content.appendChild(element('p', 'detail-progress', window.JukuHistory.progressText(history))); + if (saved?.newEpisodes > 0) content.appendChild(element('p', 'following-update notice', '剧库新增 ' + saved.newEpisodes + ' 集')); + const actions = element('div', 'detail-actions'); + const play = button(history ? '继续观看' : '开始观看', () => app.play(currentID, title), false, 'primary-action button-content'); + play.prepend(icon('play')); + play.dataset.focusKey = 'detail-play'; + const bookmark = button(saved?.saved ? '已收藏' : '加入想看', () => app.following.toggleSaved(currentID), app.following.busy(currentID), 'secondary'); + bookmark.id = 'detailSaveBtn'; + bookmark.dataset.focusKey = 'detail-save'; + bookmark.setAttribute('aria-pressed', String(Boolean(saved?.saved))); + actions.append(play, bookmark); + content.appendChild(actions); + content.appendChild(element('p', 'detail-description', firstNonEmpty(drama.desc, drama.intro) || '站源暂未提供简介。')); + const facts = element('dl', 'detail-facts'); + const values = [['集数', episodeCount(drama) ? episodeCount(drama) + ' 集' : '暂未提供'], ['状态', releaseText(drama.releaseStatus)], ['上线时间', drama.onlineDate], ['站点热度', drama.heat], ['播放量', drama.views], ['站点评分', drama.score]]; + for (const [label, value] of values) { + if (!value) continue; + const item = element('div'); + item.append(element('dt', '', label), element('dd', '', value)); + facts.appendChild(item); + } + content.appendChild(facts); + const secondary = element('div', 'detail-secondary'); + const download = button('加入下载', () => app.downloads.enqueue([currentID]), !known, 'secondary'); + download.id = 'detailDownloadBtn'; + download.dataset.focusKey = 'detail-download'; + if (!known) download.title = '更新剧库找到此剧后可加入下载'; + const watched = button(saved?.completed ? '取消已看标记' : '标为已看', () => app.following.setCompleted(currentID, !saved?.completed), app.following.busy(currentID), 'secondary'); + watched.id = 'detailCompletedBtn'; + watched.dataset.focusKey = 'detail-completed'; + const emby = button(exporting.has(currentID) ? '导出中…' : '导出 Emby', () => exportEmby(currentID, title), !known || exporting.has(currentID), 'secondary'); + emby.id = 'detailEmbyBtn'; + emby.dataset.focusKey = 'detail-emby'; + emby.title = '导出 STRM 分集文件,用于现有 Emby 电视剧媒体库'; + if (app.viewer?.onlineOnly) secondary.appendChild(watched); + else secondary.append(app.downloads.qualityControl(), download, watched, emby); + actions.after(secondary); + content.appendChild(element('p', 'small notice', '手动标记用于整理清单,实际播放进度仍自动保存。')); + }); + } + + async function exportEmby(id, title) { + if (exporting.has(id)) return; + exporting.add(id); + render(); + try { + const response = await fetch('/api/emby/export', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({dramaId: id, baseUrl: location.origin})}); + if (!response.ok) {const result = await response.json(); throw new Error(result.error || '导出失败');} + const address = URL.createObjectURL(await response.blob()); + const link = document.createElement('a'); + link.href = address; + link.download = title + '-Emby.zip'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(address), 60000); + setMessage('已导出 Emby 分集;解压到 Emby 电视剧媒体库后扫描即可。'); + } catch (error) {setMessage('Emby 导出失败:' + error.message, true);} + finally {exporting.delete(id); if (panel.open && currentID === id) render();} + } + + function open(id) { + currentID = id; + render(); + window.JukuDialogs.open(panel); + app.library.refreshDrama(id); + } + + function metadataStatus(id, text) { + metadataMessages.set(id, text); + const status = $('detailMetadataStatus'); + if (panel.open && currentID === id && status) {status.textContent = text; status.hidden = !text;} + } + + return {open, metadataStatus, refreshCover, retryCover: () => refreshCover(currentID, true), refresh: () => {if (panel.open) render();}}; +} diff --git a/internal/webui/downloads.css b/internal/webui/downloads.css new file mode 100644 index 0000000..ef2c683 --- /dev/null +++ b/internal/webui/downloads.css @@ -0,0 +1,10 @@ +.download-summary{display:flex;gap:0;margin-bottom:14px;border:1px solid var(--line);border-radius:var(--radius);background:var(--panel)}.summary-tile{flex:1;min-width:0;padding:12px 16px}.summary-tile strong{display:block;font-size:22px;font-weight:550;font-variant-numeric:tabular-nums;line-height:1.4;margin-bottom:4px}.summary-tile>span{font-size:12px;color:var(--muted)}.download-filters{display:grid;grid-template-columns:minmax(140px,1fr) 150px 150px;gap:8px;margin-bottom:12px}.download-selection{margin-bottom:12px}.groups{display:flex;flex-direction:column;gap:12px}.group{border:1px solid var(--line);border-radius:var(--radius);background:var(--panel);min-width:0}.group-head{padding:14px}.group-heading{display:flex;gap:12px;align-items:center}.selection-target{display:none;align-items:center;justify-content:center;width:44px;min-width:44px;height:44px;cursor:pointer;flex:none}.download-batch .selection-target{display:flex}.group-heading>div{flex:1;min-width:0}.group-title{font-size:16px;font-weight:600;overflow-wrap:anywhere;line-height:1.5;margin-bottom:5px}.group-heading .small{font-size:12px}.group-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:11px}.group-actions button{font-size:13px}.group-actions button:disabled{display:none}.group-actions .collection-play-button{background:var(--brand);color:var(--on-brand);border-color:var(--brand)}.group-actions .action-popover{left:0;right:auto}.group-actions .action-menu button:disabled{display:block}.episode-list{display:none}.group.open .episode-list{display:block}.episode{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;padding:12px 14px;border-top:1px solid var(--line)}.download-batch .episode{grid-template-columns:44px minmax(0,1fr)}.episode-main{min-width:0}.episode-heading{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.episode-path{overflow-wrap:anywhere;font-size:12px;margin-top:7px;max-height:42px;overflow:auto}.episode .error{font-size:12px;max-height:100px;overflow:auto;margin-top:8px}.episode-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px}.episode-actions button{font-size:13px}.running,.parsing{background:var(--warning-bg);color:var(--warning)}.success{background:var(--success-bg);color:var(--success)}.failed{background:var(--danger-bg);color:var(--danger)}.queued{background:var(--tint);color:var(--brand-text)}.paused,.canceled{background:var(--soft);color:var(--muted)}.download-location{padding:14px;margin-top:16px;border-radius:11px;background:var(--soft);font:13px/1.6 ui-monospace,SFMono-Regular,monospace;overflow-wrap:anywhere} +@media(max-width:700px){.download-summary{margin-bottom:18px}.summary-tile{padding:12px 11px}.summary-tile strong{font-size:20px}.summary-tile>span{font-size:11px}.download-filters{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.download-filters>input{grid-column:1/-1}.download-filters select{font-size:13px}.download-selection button,.download-selection summary{font-size:12px;padding:8px 10px}.group-head{padding:14px}.group-title{font-size:15px}.group-actions{gap:6px;margin-top:9px}.group-actions button{font-size:12px;padding:8px 11px}.group-actions .action-menu>summary{font-size:12px;padding:8px 11px}.episode{padding:14px}.episode-actions button{font-size:12px}.group-heading .small{line-height:1.8}.download-selection .action-popover{right:auto;left:0}} +@media(max-width:355px){.summary-tile{padding:11px 9px}.summary-tile strong{font-size:18px}.summary-tile>span{font-size:11px}} + +.summary-tile+.summary-tile{border-left:1px solid var(--line)}.group-actions>.secondary:not(.collection-play-button){background:transparent}.group-actions .action-menu>summary{color:var(--muted)} + +.download-destination{display:flex;align-items:center;gap:7px;margin:-5px 0 14px;min-width:0;color:var(--muted)}.download-destination>svg{width:15px;height:15px}.download-destination>.small{white-space:nowrap}.download-path{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.download-summary{box-shadow:none}.summary-tile{display:flex;align-items:baseline;gap:10px}.summary-tile strong{margin:0}.summary-tile>span{white-space:nowrap} +@media(max-width:700px){.summary-tile{display:block;padding:10px}.summary-tile strong{font-size:20px}.download-destination{margin-top:-4px;gap:5px}.download-path{font-size:11px}.group-actions{gap:5px}.group-head{padding:12px}.group-actions button,.group-actions .action-menu>summary{padding:6px 9px}} + +.download-quality{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);white-space:nowrap}.download-quality select{width:auto;max-width:150px;min-height:30px;padding:5px 8px;font-size:12px}.detail-secondary{align-items:center}.download-selection .download-quality{margin-right:4px} diff --git a/internal/webui/downloads.js b/internal/webui/downloads.js new file mode 100644 index 0000000..d4cc2c7 --- /dev/null +++ b/internal/webui/downloads.js @@ -0,0 +1,290 @@ +import { reconcileChildren, withFocus, readPreference, savePreference } from './ui-core.js'; +import { $, element, empty, button, setMessage, api, post, valueText, firstNonEmpty, dramaTitle, watchLabel, historySuffix, normalizeSource, sourceKey, sourceLabel, categoryName, episodeCount, coverURL, tagsText, dramaSearchText, rebuildOptions, number, formatBytes, formatTime, statusText, releaseText, phaseText, progressText, progressBar, episodeLabel, groupStats } from './ui-core.js'; + +export function createDownloads(app) { + let tasks = [], visibleGroups = [], mergeStates = {}, taskRequest = false, ffmpegStatus = ''; + const selectedTasks = new Set(), openGroups = new Set(), groupsEl = $('groups'); + let pollTimer, pollPromise, batch = false, taskSignature = '', ffmpegSignature = ''; + let hasActiveTasks = false, polling = false, scheduledDelay = 0; + const qualityOptions = [0, 2160, 1440, 1080, 720, 540, 480, 360]; + const storedQuality = number(readPreference('downloadQuality', 0)); + let downloadQuality = qualityOptions.includes(storedQuality) ? storedQuality : 0; + const qualityLabel = value => value ? value + 'p 优先' : '最高可用'; + + function setupQualitySelect(select) { + select.dataset.downloadQuality = ''; + for (const quality of qualityOptions) { + const option = element('option', '', qualityLabel(quality)); + option.value = String(quality); + select.appendChild(option); + } + select.value = String(downloadQuality); + select.title = '用于新加入的下载任务;缺少所选档位时取不高于该档位的最高画质,全部更高时取最低可用画质。保留源文件,不压缩。'; + select.addEventListener('change', () => { + downloadQuality = number(select.value); + savePreference('downloadQuality', downloadQuality); + document.querySelectorAll('[data-download-quality]').forEach(node => {node.value = String(downloadQuality);}); + }); + } + + function qualityControl() { + const label = element('label', 'download-quality', '下载画质'); + const select = element('select'); + select.setAttribute('aria-label', '本次下载清晰度'); + select.dataset.focusKey = 'detail-download-quality'; + setupQualitySelect(select); + label.appendChild(select); + return label; + } +function buildGroups(){const byID=new Map();tasks.forEach(task=>{const id=task.dramaId||task.dramaTitle||task.id;let group=byID.get(id);if(!group){group={id,title:task.dramaTitle||'短剧',tasks:[],release:'unknown'};byID.set(id,group);}group.tasks.push(task);if(task.releaseStatus)group.release=task.releaseStatus;});return Array.from(byID.values());} + +function groupMatches(group){const keyword=$('taskSearch').value.trim().toLowerCase();if(keyword&&!group.title.toLowerCase().includes(keyword))return false;const release=$('releaseStatus').value;if(release&&group.release!==release)return false;const status=$('taskStatus').value;const stats=groupStats(group);if(status==='completed')return stats.success===group.tasks.length;if(status==='unfinished')return stats.success!==group.tasks.length;if(status==='failed')return stats.failed>0;if(status==='running')return stats.running+stats.parsing+stats.queued>0;if(status==='paused')return stats.paused>0;if(status==='canceled')return stats.canceled>0;return true;} + +function selectGroup(group, checked){group.tasks.forEach(task=>{if(checked)selectedTasks.add(task.id);else selectedTasks.delete(task.id);});renderTasks(tasks);} + +function selectedTaskList(){return tasks.filter(task=>selectedTasks.has(task.id));} + +function selectedGroupIDs(){return Array.from(new Set(selectedTaskList().map(task=>task.dramaId)));} + +function updateTaskSelection(){const visible=new Set(visibleGroups.flatMap(group=>group.tasks.map(task=>task.id)));const hidden=selectedTaskList().filter(task=>!visible.has(task.id)).length;$('taskSelection').textContent='已选 '+selectedTasks.size+' 集 / '+selectedGroupIDs().length+' 部'+(hidden?'(隐藏 '+hidden+' 集)':'');for(const id of ['pauseSelectedBtn','resumeSelectedBtn','cancelSelectedBtn','retrySelectedBtn','updateSelectedBtn','mergeSelectedBtn','clearTasksBtn'])$(id).disabled=selectedTasks.size===0;} + +function renderTasks(next) { + if (app.viewer?.onlineOnly) return; + if (tasks !== next) taskSignature = ''; + tasks = next; + if (selectedTasks.size) { + const existing = new Set(tasks.map(task => task.id)); + selectedTasks.forEach(id => {if (!existing.has(id)) selectedTasks.delete(id);}); + } + let active = 0, completed = 0, written = 0; + for (const task of tasks) { + if (['running', 'queued', 'parsing'].includes(task.status)) active++; + if (task.status === 'success') completed++; + written += number(task.downloadedBytes); + } + const setText = (node, value) => {if (node.textContent !== String(value)) node.textContent = value;}; + setText($('downloadActiveCount'), active); + setText($('downloadCompleteCount'), completed); + setText($('downloadWrittenBytes'), formatBytes(written)); + document.querySelectorAll('[data-download-count]').forEach(node => {setText(node, active); if (node.hidden !== !active) node.hidden = !active;}); + hasActiveTasks = active > 0; + const showActivity = !$('activityPanel').hidden && !$('libraryPage').hidden; + if ($('downloadsPage').hidden && !showActivity) return; + const all = buildGroups(); + if (!$('downloadsPage').hidden) { + visibleGroups = all.filter(groupMatches); + setText($('taskCount'), all.length ? visibleGroups.length + ' 部' : ''); + const previous = new Map(Array.from(groupsEl.querySelectorAll('.group'), node => [node.dataset.dramaId, node])); + withFocus(groupsEl, () => { + const nodes = []; + for (const group of visibleGroups) { + const old = previous.get(group.id); + const key = JSON.stringify([group, mergeStates[group.id], openGroups.has(group.id), batch, group.tasks.map(task => selectedTasks.has(task.id)), historySuffix(group.id)]); + if (old?.renderKey === key) {nodes.push(old); continue;} + const menuOpen = old?.querySelector('.action-menu')?.open; + const node = renderGroup(group); + node.renderKey = key; + if (menuOpen) node.querySelector('.action-menu').open = true; + nodes.push(node); + } + if (!visibleGroups.length) { + const empty = element('div', 'empty'); + empty.append(element('strong', '', tasks.length ? '没有匹配的下载合集' : '还没有下载任务'), element('p', '', tasks.length ? '试试其他筛选条件。' : '在剧集详情加入下载,也可以在剧库批量选择。')); + if (!tasks.length) empty.appendChild(button('去剧库看看', () => window.JukuDialogs.navigate('library'), false, 'secondary')); + nodes.push(empty); + } + reconcileChildren(groupsEl, nodes); + }); + updateTaskSelection(); + } + if (showActivity) renderActivity(all); + +} + +function renderGroup(group) { + const stats = groupStats(group); + const wrap = element('article', 'group' + (openGroups.has(group.id) ? ' open' : '')); + wrap.dataset.dramaId = group.id; + const head = element('div', 'group-head'); + const heading = element('div', 'group-heading'); + const checkbox = element('input'); + checkbox.type = 'checkbox'; + checkbox.setAttribute('aria-label', '选择合集 ' + group.title); + checkbox.dataset.focusKey = 'group-select-' + group.id; + const selectedCount = group.tasks.filter(task => selectedTasks.has(task.id)).length; + checkbox.checked = selectedCount === group.tasks.length; + checkbox.indeterminate = selectedCount > 0 && !checkbox.checked; + checkbox.addEventListener('change', () => selectGroup(group, checkbox.checked)); + const info = element('div'); + info.appendChild(element('h2', 'group-title', group.title)); + const statuses = [releaseText(group.release), '共 ' + group.tasks.length + ' 集', '完成 ' + stats.success]; + const qualities = [...new Set(group.tasks.map(task => number(task.downloadQuality)))]; + statuses.push('画质:' + (qualities.length === 1 ? qualityLabel(qualities[0]) : '按分集设置')); + for (const [key, label] of [['running','下载中'],['queued','排队'],['parsing','解析中'],['paused','暂停'],['failed','失败'],['canceled','已取消']]) if (stats[key]) statuses.push(label + ' ' + stats[key]); + const meta = element('p', 'small', statuses.join(' · ')); + meta.appendChild(element('span', 'group-watch-progress', historySuffix(group.id))); + info.appendChild(meta); + const selection = element('label', 'selection-target'); + selection.appendChild(checkbox); + heading.append(selection, info); + head.append(heading, progressBar(stats.percent), element('p', 'small', stats.percent + '% · 已写入 ' + formatBytes(stats.bytes) + (stats.speed > 0 ? ' · ' + formatBytes(stats.speed) + '/s' : ''))); + const merge = mergeStates[group.id]; + if (merge) head.appendChild(element('p', merge.error ? 'error notice' : 'small notice', '合并:' + ({running:'进行中', success:'完成', failed:'失败'}[merge.status] || merge.status) + ' ' + (merge.progress || 0) + '%' + (merge.detail ? ' · ' + merge.detail : '') + (merge.error ? ' · ' + merge.error : ''))); + const actions = element('div', 'group-actions'); + function action(label, key, callback, disabled = false, className = 'secondary') { + const node = button(label, callback, disabled, className); + node.dataset.focusKey = group.id + '-' + key; + return node; + } + const playable = group.tasks.filter(task => task.playable).sort((left, right) => number(left.index) - number(right.index))[0]; + actions.appendChild(action(watchLabel(group.id, '播放'), 'play', () => window.dramaPlayer.openCollection(playable.id, group.title, true), !playable, 'secondary collection-play-button')); + const toggle = action(openGroups.has(group.id) ? '收起分集' : '查看分集', 'episodes', () => { + if (openGroups.has(group.id)) openGroups.delete(group.id); else openGroups.add(group.id); + renderTasks(tasks); + }); + toggle.setAttribute('aria-expanded', String(openGroups.has(group.id))); + actions.appendChild(toggle); + if (stats.running + stats.queued + stats.parsing) actions.appendChild(action('暂停', 'pause', () => taskAction('pause', [], [group.id]))); + if (stats.paused) actions.appendChild(action('继续', 'resume', () => taskAction('resume', [], [group.id]))); + if (stats.failed + stats.canceled) actions.appendChild(action('重试失败', 'retry', () => taskAction('retry', [], [group.id]))); + const menu = element('details', 'action-menu'); + const summary = element('summary', '', '更多操作'); + summary.dataset.focusKey = group.id + '-more'; + const popover = element('div', 'action-popover'); + popover.append(action('更新本剧', 'update', () => updateGroups([group.id])), action('合并已完成分集', 'merge', () => mergeGroups([group.id]), !stats.success), action('取消本剧下载', 'cancel', () => taskAction('cancel', [], [group.id]), !stats.running && !stats.queued && !stats.parsing && !stats.paused)); + menu.append(summary, popover); + actions.appendChild(menu); + head.appendChild(actions); + const list = element('div', 'episode-list'); + if (openGroups.has(group.id)) for (const task of group.tasks) { + const episode = renderEpisode(task); + episode.querySelectorAll('button,input').forEach((control, index) => control.dataset.focusKey = 'task-' + task.id + '-' + index); + list.appendChild(episode); + } + wrap.append(head, list); + return wrap; +} + +function renderEpisode(task){ + const row=element('div','episode');const checkbox=element('input');checkbox.type='checkbox';checkbox.checked=selectedTasks.has(task.id);checkbox.setAttribute('aria-label','选择 '+episodeLabel(task));checkbox.addEventListener('change',()=>{if(checkbox.checked)selectedTasks.add(task.id);else selectedTasks.delete(task.id);renderTasks(tasks);});const selection=element('label','selection-target');selection.appendChild(checkbox);row.appendChild(selection);const content=element('div','episode-main');const heading=element('div','episode-heading');heading.appendChild(element('span','pill '+task.status,phaseText(task)));heading.appendChild(element('strong','',task.status==='parsing'||task.title==='章节获取失败'?task.title:episodeLabel(task)));content.appendChild(heading);content.appendChild(element('div','small episode-path',task.path||''));if(task.error)content.appendChild(element('div','error',task.error));content.appendChild(progressBar(task.progress));content.appendChild(element('div','small',progressText(task)+' · 已写入 '+formatBytes(task.downloadedBytes)+(number(task.totalBytes)?' / '+formatBytes(task.totalBytes):'')+' · '+(number(task.speedBytesPerSecond)>0?formatBytes(task.speedBytesPerSecond)+'/s':'速度 —')));content.appendChild(element('div','small','已用 '+formatTime(task.elapsedSeconds)+' · 剩余 '+formatTime(task.remainingSeconds)+' · 尝试 '+(task.attempt||0))); + const actions=element('div','episode-actions');if(task.playable)actions.appendChild(button('播放',()=>window.dramaPlayer.openCollection(task.id,task.dramaTitle),false,'secondary episode-play-button'));if(['running','queued','parsing'].includes(task.status))actions.appendChild(button('暂停',()=>taskAction('pause',[task.id]),task.pauseRequested));if(task.status==='paused')actions.appendChild(button('继续',()=>taskAction('resume',[task.id])));if(['running','queued','parsing','paused'].includes(task.status))actions.appendChild(button('取消',()=>taskAction('cancel',[task.id]),task.cancelRequested));if(['failed','canceled'].includes(task.status))actions.appendChild(button('重试',()=>taskAction('retry',[task.id])));content.appendChild(actions);row.appendChild(content);return row; + } + +async function retryFFmpeg(){try{await post('/api/ui/ffmpeg',{});setMessage('已重新检查 FFmpeg,准备进度会自动更新');await pollTasks();}catch(error){setMessage(error.message,true);}} + +function renderFFmpeg(state){if(!state)return;const retryCovers=ffmpegStatus&&ffmpegStatus!=='ready'&&state.status==='ready';ffmpegStatus=state.status;let text=state.detail||'首次使用时自动准备 FFmpeg';if(state.status==='downloading'&&number(state.totalBytes)>0)text+=' · '+formatBytes(state.downloadedBytes)+' / '+formatBytes(state.totalBytes);if(state.error)text+=':'+state.error;for(const id of ['ffmpegNotice','ffmpegSettingsStatus']){const target=$(id);empty(target);target.appendChild(element('span',state.status==='failed'?'error':'',text));if(state.status==='failed'&&app.viewer?.account?.admin)target.appendChild(button('重新检查 FFmpeg',retryFFmpeg));if(id==='ffmpegNotice')target.hidden=!['downloading','verifying','failed'].includes(state.status);}if(retryCovers)app.library.retryCovers();window.dramaPlayer?.updateDependency(state,text);} + +async function pollTasks() { + if (app.viewer?.onlineOnly) return; + if (taskRequest) return pollPromise; + taskRequest = true; + pollPromise = (async () => { + try { + const result = await api('/api/ui/tasks'); + const signature = JSON.stringify([result.data || [], result.merges || {}]); + if (signature !== taskSignature) { + mergeStates = result.merges || {}; + renderTasks(result.data || []); + taskSignature = signature; + } + const nextFFmpeg = JSON.stringify(result.ffmpeg || null); + if (nextFFmpeg !== ffmpegSignature) {ffmpegSignature = nextFFmpeg; renderFFmpeg(result.ffmpeg);} + $('taskError').textContent = ''; + } catch (error) { + $('taskError').textContent = '任务刷新失败:' + error.message; + } finally {taskRequest = false; schedule();} + })(); + return pollPromise; +} + +async function updateGroups(ids){if(!ids.length){setMessage('请先勾选下载合集',true);return;}try{await post('/api/ui/update',{ids});setMessage('已提交 '+ids.length+' 部更新检查,已有文件保留,只补充新增或缺失分集');await pollTasks();}catch(error){$('taskError').textContent='更新失败:'+error.message;}} + +async function taskAction(action,ids,dramaIds=[]){const count=dramaIds.length||ids.length;const unit=dramaIds.length?' 部合集':' 个任务';if(!count){setMessage('所选任务中没有可执行此操作的项',true);return;}if(action==='cancel'&&!confirm('取消所选 '+count+unit+'?已完成文件保留,可稍后重试。'))return;try{const result=await post('/api/ui/tasks/'+action,dramaIds.length?{dramaIds}:{ids});$('taskError').textContent='';if(result.data)renderTasks(result.data);setMessage('已提交'+({pause:'暂停',resume:'继续',cancel:'取消',retry:'重试'}[action]||action)+':'+count+unit);await pollTasks();}catch(error){$('taskError').textContent='操作失败:'+error.message;}} + +async function clearTasks(){const ids=selectedTaskList().map(task=>task.id);if(!ids.length){setMessage('请先勾选需要清理的任务',true);return;}if(!confirm('清理勾选的 '+ids.length+' 个任务记录?进行中的任务会先停止;已下载视频不会删除。'))return;try{const result=await post('/api/ui/tasks/clear',{ids});ids.forEach(id=>selectedTasks.delete(id));renderTasks(result.data||[]);setMessage('已清理 '+(result.removed||0)+' 个任务'+(result.pending?',另有 '+result.pending+' 个正在停止后清理':''));}catch(error){$('taskError').textContent='清理失败:'+error.message;}} + +async function mergeGroups(ids){if(!ids.length){setMessage('请先勾选下载合集',true);return;}const deleteEpisodes=$('deleteEpisodesAfterMerge').checked;if(deleteEpisodes&&!confirm('合并成功后删除 '+ids.length+' 个合集已合并的分集文件,确认继续?'))return;setMessage('正在合并 '+ids.length+' 部,下载区会显示进度');try{const result=await post('/api/ui/merge',{dramaIds:ids,deleteEpisodes});const items=result.data||[];const failed=items.filter(item=>!item.ok);setMessage('合并完成:成功 '+(items.length-failed.length)+',失败 '+failed.length,failed.length>0);await pollTasks();}catch(error){$('taskError').textContent='合并失败:'+error.message;}} + + +function renderActivity(all) { + const ordered = all.slice().sort((left, right) => { + const a = groupStats(left), b = groupStats(right); + return Boolean(b.running + b.queued + b.parsing) - Boolean(a.running + a.queued + a.parsing); + }); + const fragment = document.createDocumentFragment(); + for (const group of ordered.slice(0, 3)) { + const stats = groupStats(group), row = element('div', 'activity-item'); + row.append(element('h3', '', group.title), element('p', 'small', stats.success + ' / ' + group.tasks.length + ' 集已完成'), progressBar(stats.percent)); + fragment.appendChild(row); + } + if (!ordered.length) fragment.appendChild(element('p', 'small', '暂无下载任务,可在剧集卡片上直接加入下载。')); + $('activityTasks').replaceChildren(fragment); +} + +async function enqueue(ids) { + if (app.viewer?.onlineOnly) {setMessage('当前账号仅可在线观看', true); return false;} + if (!ids.length) return false; + try { + const quality = downloadQuality; + await post('/api/ui/download', {ids, quality}); + setMessage('已加入下载队列 · ' + ids.length + ' 部 · ' + qualityLabel(quality), false, {label: '查看下载', run: () => window.JukuDialogs.navigate('downloads')}); + await pollTasks(); + return true; + } catch (error) {setMessage('加入下载失败:' + error.message, true); return false;} +} + +function persistFilters() { + savePreference('downloadFilters', {search: $('taskSearch').value, status: $('taskStatus').value, release: $('releaseStatus').value}); +} + +function toggleBatch() { + batch = !batch; + if (!batch) selectedTasks.clear(); + document.body.classList.toggle('download-batch', batch); + $('downloadBatchControls').hidden = !batch; + $('downloadBatchBtn').setAttribute('aria-pressed', String(batch)); + $('downloadBatchBtn').textContent = batch ? '退出批量' : '批量管理'; + renderTasks(tasks); +} + +function schedule(restart = true) { + if (app.viewer?.onlineOnly) return; + const active = hasActiveTasks || ['idle', 'downloading', 'verifying'].includes(ffmpegStatus) || Object.values(mergeStates).some(state => state.status === 'running'); + const delay = document.hidden ? 30000 : active || !$('downloadsPage').hidden ? 2000 : 15000; + if (!restart && polling && pollTimer && delay === scheduledDelay) return; + clearTimeout(pollTimer); + pollTimer = 0; + if (!polling) return; + scheduledDelay = delay; + pollTimer = setTimeout(() => {pollTimer = 0; pollTasks();}, delay); +} + +function init() { + if (app.viewer?.onlineOnly) return; + document.querySelectorAll('[data-download-quality]').forEach(setupQualitySelect); + const stored = readPreference('downloadFilters', {}), saved = stored && typeof stored === 'object' ? stored : {}; + $('taskSearch').value = typeof saved.search === 'string' ? saved.search.slice(0, 80) : ''; + for (const [id, value] of [['taskStatus', saved.status], ['releaseStatus', saved.release]]) { + $(id).value = Array.from($(id).options).some(option => option.value === value) ? value : ''; + } + for (const id of ['taskStatus', 'releaseStatus']) $(id).addEventListener('change', () => {persistFilters(); renderTasks(tasks);}); + $('taskSearch').addEventListener('input', () => {persistFilters(); renderTasks(tasks);}); + $('downloadBatchBtn').addEventListener('click', toggleBatch); + $('selectGroupsBtn').addEventListener('click', () => {visibleGroups.forEach(group => group.tasks.forEach(task => selectedTasks.add(task.id))); renderTasks(tasks);}); + $('invertGroupsBtn').addEventListener('click', () => {visibleGroups.forEach(group => {const checked = !group.tasks.every(task => selectedTasks.has(task.id)); group.tasks.forEach(task => checked ? selectedTasks.add(task.id) : selectedTasks.delete(task.id));}); renderTasks(tasks);}); + $('deselectGroupsBtn').addEventListener('click', () => {selectedTasks.clear(); renderTasks(tasks);}); + for (const [id, action, statuses] of [['pauseSelectedBtn','pause',['running','queued','parsing']],['resumeSelectedBtn','resume',['paused']],['cancelSelectedBtn','cancel',['running','queued','parsing','paused']],['retrySelectedBtn','retry',['failed','canceled']]]) $(id).addEventListener('click', () => taskAction(action, selectedTaskList().filter(task => statuses.includes(task.status)).map(task => task.id))); + $('updateSelectedBtn').addEventListener('click', () => updateGroups(selectedGroupIDs())); + $('mergeSelectedBtn').addEventListener('click', () => mergeGroups(selectedGroupIDs())); + $('clearTasksBtn').addEventListener('click', clearTasks); + window.addEventListener('downloadsChanged', pollTasks); + document.addEventListener('visibilitychange', () => {if (!document.hidden) pollTasks(); schedule();}); + window.addEventListener('pagehide', () => {polling = false; clearTimeout(pollTimer);}); + window.addEventListener('pageshow', event => {polling = true; if (event.persisted) pollTasks(); else schedule();}); + polling = true; + return pollTasks(); +} + +return {init, refresh: pollTasks, render: () => {renderTasks(tasks); schedule(false);}, enqueue, qualityControl, quality: () => downloadQuality, all: () => tasks}; + +} diff --git a/internal/webui/drama-refresh.js b/internal/webui/drama-refresh.js new file mode 100644 index 0000000..6d24916 --- /dev/null +++ b/internal/webui/drama-refresh.js @@ -0,0 +1,39 @@ +export function createDramaRefresh({post, apply, failure, now = Date.now}) { + const pending = new Map(), retryAt = new Map(), latest = new Map(); + const checkedAt = drama => Date.parse(drama?.sortMetadata?.checkedAt) || 0; + + function reconcile(drama) { + const recent = latest.get(drama.id); + if (!recent) return drama; + if (now() >= recent.expires || checkedAt(drama) > checkedAt(recent.drama)) { + latest.delete(drama.id); + return drama; + } + return {...drama, ...recent.drama}; + } + + function refresh(id) { + if (pending.has(id)) return pending.get(id); + if (now() < (retryAt.get(id) || 0)) return Promise.resolve(); + retryAt.set(id, now() + 300000); + const work = (async () => { + await Promise.resolve(); + try { + const result = await post('/api/ui/dramas/refresh', {dramaId: id}); + if (result.dramaId !== id || result.drama?.id !== id) throw new Error('剧集资料响应不匹配'); + const expires = now() + Math.max(1, Number(result.retryAfter) || 300) * 1000; + retryAt.set(id, expires); + if (latest.size >= 256 && !latest.has(id)) latest.delete(latest.keys().next().value); + latest.set(id, {drama: result.drama, expires}); + apply(result.drama, result.warning || ''); + } catch (error) { + retryAt.set(id, now() + 30000); + failure?.(id, error); + } finally {pending.delete(id);} + })(); + pending.set(id, work); + return work; + } + + return {refresh, reconcile}; +} diff --git a/internal/webui/drama-refresh.test.cjs b/internal/webui/drama-refresh.test.cjs new file mode 100644 index 0000000..9ae88df --- /dev/null +++ b/internal/webui/drama-refresh.test.cjs @@ -0,0 +1,61 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const source = fs.readFileSync(path.join(__dirname, 'drama-refresh.js'), 'utf8'); +const modulePromise = import('data:text/javascript;base64,' + Buffer.from(source).toString('base64')); +const id = 'hongguo:7000000000000000001'; +const row = (title, checkedAt) => ({id, title, sortMetadata: {checkedAt}}); + +test('title and cover refresh share one request and respect the server cooldown', async () => { + const {createDramaRefresh} = await modulePromise; + let timestamp = 1000, calls = 0, finish; + const applied = []; + const refresh = createDramaRefresh({now: () => timestamp, apply: drama => applied.push(drama), post: async (url, body) => { + calls++; + assert.equal(url, '/api/ui/dramas/refresh'); + assert.equal(body.dramaId, id); + return new Promise(resolve => {finish = resolve;}); + }}); + const first = refresh.refresh(id), second = refresh.refresh(id); + assert.equal(first, second); + await Promise.resolve(); + assert.equal(calls, 1); + finish({dramaId: id, drama: row('已更新', '2026-09-16T02:00:00Z'), retryAfter: 20}); + await first; + await refresh.refresh(id); + assert.equal(calls, 1); + assert.equal(applied.length, 1); + timestamp += 20001; + const retry = refresh.refresh(id); + await Promise.resolve(); + assert.equal(calls, 2); + finish({dramaId: id, drama: row('再次更新', '2026-09-16T03:00:00Z'), retryAfter: 300}); + await retry; +}); + +test('a stale list response cannot erase clicked-drama metadata; a newer update wins', async () => { + const {createDramaRefresh} = await modulePromise; + const updated = row('已补齐的标题', '2026-09-16T03:00:00Z'); + const refresh = createDramaRefresh({now: () => 1000, apply() {}, post: async () => ({dramaId: id, drama: updated})}); + await refresh.refresh(id); + assert.equal(refresh.reconcile(row('旧标题', '2026-09-16T02:00:00Z')).title, updated.title); + const newer = row('服务器的新标题', '2026-09-16T04:00:00Z'); + assert.equal(refresh.reconcile(newer).title, newer.title); +}); + +test('failed or mismatched metadata never mutates the drama and can be retried', async () => { + const {createDramaRefresh} = await modulePromise; + for (const broken of [() => {throw new Error('offline');}, async () => ({dramaId: 'other', drama: {id: 'other'}})]) { + let timestamp = 0, failures = 0, calls = 0; + const refresh = createDramaRefresh({now: () => timestamp, post: (...args) => {calls++; return broken(...args);}, apply() {assert.fail('invalid data applied');}, failure() {failures++;}}); + await refresh.refresh(id); + assert.equal(failures, 1); + await refresh.refresh(id); + assert.equal(calls, 1); + timestamp = 30001; + await refresh.refresh(id); + assert.equal(calls, 2); + assert.equal(failures, 2); + } +}); diff --git a/internal/webui/embed.go b/internal/webui/embed.go new file mode 100644 index 0000000..17a6ec4 --- /dev/null +++ b/internal/webui/embed.go @@ -0,0 +1,9 @@ +package webui + +import "embed" + +//go:embed index.html +var HTML string + +//go:embed *.js *.css *.txt +var Assets embed.FS diff --git a/internal/webui/emby.js b/internal/webui/emby.js new file mode 100644 index 0000000..6b7dfbe --- /dev/null +++ b/internal/webui/emby.js @@ -0,0 +1,116 @@ +import { $, element } from './ui-core.js'; + +export function createEmby(app) { + let state, timer, loading = false, saving = false; + const panel = $('embyPanel'); + const date = value => value && !value.startsWith('0001') ? new Date(value).toLocaleString() : '尚未同步'; + const posterStates = {pending: '海报待同步', waiting: '海报等待扫描', synced: '海报已同步', existing: '保留已有海报', missing: '暂无海报地址', unconfigured: '海报未连接 Emby', failed: '海报同步失败,将重试'}; + + function fields() { + const settings = state.settings; + $('embyEnabled').checked = settings.enabled; + $('embyOutputDir').value = settings.outputDir; + $('embyBaseURL').value = settings.baseUrl || location.origin; + $('embyInterval').value = settings.intervalMinutes; + $('embyFollowing').checked = settings.following; + $('embyDownloads').checked = settings.downloads; + $('embyServerURL').value = settings.serverUrl || ''; + $('embyAPIKey').value = ''; + $('embyAPIKey').placeholder = state.hasAPIKey ? '已保存,留空保留' : '在 Emby 控制台创建 API Key'; + $('embyBaseURL').required = settings.enabled; + } + + function render() { + if (!state) return; + const entries = state.entries || []; + const imported = entries.filter(entry => entry.episodes > 0).length; + $('embyRunBtn').disabled = saving || !state.settings.enabled || state.running; + $('embyRunBtn').textContent = state.running ? '正在同步…' : '立即同步'; + $('embyOwner').textContent = state.owner ? '追剧账号:' + state.owner : '保存后使用当前管理员的追剧清单。'; + $('embySummary').textContent = '已导入 ' + imported + ' 部 · ' + (state.episodes || 0) + ' 集' + (state.pending ? ' · 待处理 ' + state.pending + ' 部' : ''); + $('embyPosterSummary').textContent = state.settings.serverUrl ? '海报已同步 ' + (state.postersSynced || 0) + ' 部' + (state.postersPending ? ' · 等待 ' + state.postersPending + ' 部' : '') + (state.postersFailed ? ' · 失败待重试 ' + state.postersFailed + ' 部' : '') : '填写 Emby 服务器地址和 API Key 后可自动同步海报。'; + $('embySchedule').textContent = state.running ? '正在更新分集文件和海报状态…' : state.settings.enabled ? '最近完成:' + date(state.lastFinishedAt) + ' · 下次检查:' + date(state.nextRunAt) : '自动同步已关闭,已导入的文件保留。'; + $('embyResult').textContent = state.error || (state.lastFinishedAt && !state.lastFinishedAt.startsWith('0001') ? '最近一轮:成功 ' + state.succeeded + ' 部,失败 ' + state.failed + ' 部,更新 ' + state.writtenFiles + ' 个文件。' : ''); + $('embyResult').classList.toggle('error', Boolean(state.error)); + const fragment = document.createDocumentFragment(); + for (const entry of entries.slice(0, 100)) { + const row = element('li', 'emby-entry'); + row.append(element('strong', '', entry.title || entry.dramaId), element('span', 'small', entry.episodes + ' 集 · ' + date(entry.syncedAt))); + if (posterStates[entry.posterStatus]) row.append(element('p', 'small', posterStates[entry.posterStatus])); + if (entry.error) row.append(element('p', 'error', entry.error)); + if (entry.posterError) row.append(element('p', entry.posterStatus === 'failed' ? 'error' : 'small', entry.posterError)); + fragment.appendChild(row); + } + if (entries.length > 100) fragment.appendChild(element('li', 'small', '显示最近处理的 100 部,其余内容仍会自动同步。')); + $('embyEntries').replaceChildren(fragment); + $('embyEmpty').hidden = entries.length > 0; + } + + function poll(delay) { + clearTimeout(timer); + if (panel.open) timer = setTimeout(() => load(false), delay ?? (state?.running ? 1500 : 10000)); + } + + async function load(fill = true) { + if (loading) return; + loading = true; + try { + state = await app.api('/api/ui/admin/emby'); + if (fill) fields(); + render(); + } catch (error) { + $('embyFeedback').textContent = '读取失败:' + error.message; + } finally { + loading = false; + poll(); + } + } + + async function save(event) { + event.preventDefault(); + if (saving || !state) return; + saving = true; + $('embySaveBtn').disabled = true; + const input = {enabled: $('embyEnabled').checked, outputDir: $('embyOutputDir').value.trim(), baseUrl: $('embyBaseURL').value.trim(), intervalMinutes: Number($('embyInterval').value), following: $('embyFollowing').checked, downloads: $('embyDownloads').checked, serverUrl: $('embyServerURL').value.trim()}; + if ($('embyAPIKey').value) input.apiKey = $('embyAPIKey').value; + try { + state = await app.post('/api/ui/admin/emby', input); + fields(); + $('embyFeedback').textContent = input.enabled ? '设置已保存,正在安排同步。Emby 媒体库请使用上方的输出目录。' : '设置已保存,自动同步已关闭。'; + } catch (error) { + $('embyFeedback').textContent = '保存失败:' + error.message; + } finally { + saving = false; + $('embySaveBtn').disabled = false; + render(); + poll(500); + } + } + + async function run() { + $('embyRunBtn').disabled = true; + try { + state = await app.post('/api/ui/admin/emby/sync', {}); + $('embyFeedback').textContent = '同步已安排,关闭此窗口也会继续。'; + render(); + } catch (error) { + $('embyFeedback').textContent = error.message; + render(); + } + poll(500); + } + + function init() { + $('embyForm').addEventListener('submit', save); + $('embyEnabled').addEventListener('change', () => {$('embyBaseURL').required = $('embyEnabled').checked;}); + $('embyRunBtn').addEventListener('click', run); + $('openEmbyBtn').addEventListener('click', () => { + if (!app.viewer?.account?.admin) return; + $('embyFeedback').textContent = ''; + window.JukuDialogs.open('embyPanel'); + load(); + }); + panel.addEventListener('close', () => clearTimeout(timer)); + } + return {init}; +} diff --git a/internal/webui/following.js b/internal/webui/following.js new file mode 100644 index 0000000..6f20ca8 --- /dev/null +++ b/internal/webui/following.js @@ -0,0 +1,219 @@ +import { $, element, button, icon, initial, withFocus, dramaTitle, sourceKey, sourceLabel, episodeCount, coverURL, number, setMessage } from './ui-core.js'; + +export function createFollowing(app) { + const entries = new Map(), pending = new Set(); + let tab = 'watching', loaded = false, loading = false, generation = 0, requestID = 0, visibleLimit = 100; + let lastRefresh = 0, refreshPromise; + + function get(id) { + const entry = entries.get(id); + if (!entry) return undefined; + const drama = app.library?.get(id); + const total = number(drama && episodeCount(drama)) || entry.totalEpisode || entry.knownEpisodes || 0; + return {...entry, title: drama ? dramaTitle(drama) : entry.title, totalEpisode: total, newEpisodes: entry.knownEpisodes > 0 ? Math.max(0, total - entry.knownEpisodes) : 0}; + } + + function records() { + const history = new Map((window.JukuHistory.list?.() || []).map(entry => [entry.dramaId, entry])); + return Array.from(new Set([...history.keys(), ...entries.keys()])).map(id => { + const entry = get(id), watched = history.get(id), drama = app.library?.get(id); + const total = number(drama && episodeCount(drama)) || entry?.totalEpisode || watched?.total || 0; + const completed = Boolean(entry?.completed && !entry.newEpisodes || watched?.completed && watched.index >= Math.max(total, watched.total)); + return {id, cover: drama ? coverURL(drama) : '', title: drama ? dramaTitle(drama) : entry?.title || watched?.title || '短剧', source: drama ? sourceKey(drama) : entry?.source || watched?.source, history: watched, entry, total, completed, watching: !completed && Boolean(watched || entry?.completed), saved: Boolean(entry?.saved), newEpisodes: entry?.newEpisodes || 0}; + }).sort((left, right) => new Date(right.history?.watchedAt || right.entry?.updatedAt || 0) - new Date(left.history?.watchedAt || left.entry?.updatedAt || 0)); + } + + function renderContinue(all) { + const list = all.filter(entry => entry.watching).slice(0, 6); + $('continueSection').hidden = !list.length; + withFocus($('continueList'), () => { + $('continueList').replaceChildren(); + for (const record of list) { + const control = button('', () => app.play(record.id, record.title), false, 'continue-card'); + control.dataset.focusKey = 'continue-' + record.id; + control.setAttribute('aria-label', '继续观看 ' + record.title); + control.title = record.title + (record.history ? ' · ' + window.JukuHistory.progressText(record.history) : ' · 有新集'); + const identity = element('span', 'continue-initial', initial(record.title)); + identity.setAttribute('aria-hidden', 'true'); + const content = element('span', 'continue-content'); + content.append(element('strong', '', record.title), element('span', 'small', record.history?.index > 0 ? '第 ' + record.history.index + ' 集' : '接着看')); + const progress = element('span', 'progress'); + const fill = element('span'); + const history = record.history; + fill.style.width = history?.duration > 0 ? Math.min(100, Math.max(0, history.position / history.duration * 100)) + '%' : '0%'; + progress.appendChild(fill); + content.appendChild(progress); + const play = icon('play'); + play.classList.add('continue-play'); + control.append(identity, content, play); + $('continueList').appendChild(control); + } + }); + } + + function renderCover(record) { + const poster = element('span', 'following-cover'); + poster.setAttribute('aria-hidden', 'true'); + const fallback = element('span', '', initial(record.title)); + if (record.cover) { + const image = element('img'); + image.alt = ''; + image.loading = 'lazy'; + image.decoding = 'async'; + image.addEventListener('error', () => {image.replaceWith(fallback); app.library?.coverFailed(record.id, record.cover);}); + image.addEventListener('load', () => app.library?.coverLoaded(record.id, record.cover)); + image.src = record.cover; + poster.retryCover = () => {if (fallback.isConnected) {image.src = record.cover; fallback.replaceWith(image);}}; + poster.appendChild(image); + } else poster.appendChild(fallback); + return poster; + } + + function refreshCover(id) { + const drama = app.library?.get(id); + if (!drama) return; + for (const row of $('followingList').querySelectorAll('.following-row')) { + if (row.dataset.dramaId === id) row.querySelector('.following-cover')?.replaceWith(renderCover({id, title: dramaTitle(drama), cover: coverURL(drama)})); + } + } + + function render() { + const all = records(); + const lists = {watching: all.filter(entry => entry.watching), saved: all.filter(entry => entry.saved && !entry.watching && !entry.completed), completed: all.filter(entry => entry.completed)}; + $('watchingCount').textContent = lists.watching.length; + $('savedCount').textContent = loaded ? lists.saved.length : '—'; + $('completedCount').textContent = lists.completed.length; + document.querySelectorAll('[data-follow-count]').forEach(node => {node.textContent = lists.watching.length; node.hidden = !lists.watching.length;}); + document.querySelectorAll('[data-follow-tab]').forEach(node => node.setAttribute('aria-pressed', String(node.dataset.followTab === tab))); + renderContinue(all); + if ($('followingPage').hidden) return; + const keyword = $('followingSearch').value.trim().toLocaleLowerCase(); + const list = lists[tab].filter(entry => (entry.title + ' ' + sourceLabel(entry.source)).toLocaleLowerCase().includes(keyword)); + withFocus($('followingList'), () => { + const fragment = document.createDocumentFragment(); + for (const record of list.slice(0, visibleLimit)) { + const row = element('article', 'following-row'); + row.dataset.dramaId = record.id; + const identity = renderCover(record); + const description = element('div', 'following-description'); + const name = button(record.title, () => app.details.open(record.id), false, 'following-title'); + name.dataset.focusKey = 'following-title-' + record.id; + const progress = record.entry?.completed ? '手动标为已看' : record.history ? window.JukuHistory.progressText(record.history) : '尚未开始'; + description.append(name, element('p', 'small', [sourceLabel(record.source), progress, record.total ? record.total + ' 集' : '集数待补充'].join(' · '))); + if (record.newEpisodes) description.appendChild(element('p', 'following-update', '剧库新增 ' + record.newEpisodes + ' 集')); + const actions = element('div', 'following-actions'); + const play = button(record.completed ? '重看' : record.watching ? '继续观看' : '开始看', () => app.play(record.id, record.title), false, record.watching ? '' : 'subtle'); + play.dataset.focusKey = 'following-play-' + record.id; + const save = saveButton(record.id, record.title); + actions.append(play, save); + row.append(identity, description, actions); + fragment.appendChild(row); + } + if (list.length > visibleLimit) fragment.appendChild(button('显示更多 · 还有 ' + (list.length - visibleLimit) + ' 部', () => {visibleLimit += 100; render();}, false, 'secondary')); + if (!list.length) { + const empty = element('div', 'empty'); + const title = loading ? '正在读取追剧清单…' : !loaded && tab === 'saved' ? '追剧清单暂未读取' : keyword ? '没有匹配的剧集' : {watching: '还没有正在观看的剧集', saved: '先收藏一部想看的剧', completed: '还没有已看完的剧集'}[tab]; + empty.append(element('strong', '', title), element('p', '', keyword ? '试试其他关键词。' : tab === 'saved' ? '在剧集卡片或详情中点击收藏,就能在这里找到。' : '观看进度会自动保存,也可以在详情里手动标记已看。')); + empty.appendChild(button('去剧库看看', () => window.JukuDialogs.navigate('library'), false, 'secondary')); + fragment.appendChild(empty); + } + $('followingList').replaceChildren(fragment); + }); + } + + function changed() { + render(); + app.library?.refreshFollowing(); + app.details?.refresh(); + } + + async function refresh() { + if (loading) return refreshPromise; + const request = ++requestID, version = generation; + loading = true; + $('followingError').textContent = ''; + $('retryFollowingBtn').hidden = true; + render(); + refreshPromise = (async () => { + try { + const result = await app.api('/api/ui/following'); + if (request !== requestID || generation !== version) return; + entries.clear(); + for (const entry of result.data || []) if (entry.dramaId) entries.set(entry.dramaId, entry); + loaded = true; + lastRefresh = Date.now(); + changed(); + } catch (error) { + $('followingError').textContent = '读取追剧清单失败:' + error.message; + $('retryFollowingBtn').hidden = false; + } finally { + loading = false; + render(); + } + })(); + return refreshPromise; + } + + async function change(id, values, message, undo) { + if (pending.has(id)) return false; + if (!loaded) {await refresh(); if (!loaded) {setMessage('请先重试读取追剧清单', true); return false;}} + pending.add(id); + generation++; + changed(); + try { + const result = await app.post('/api/ui/following', {dramaId: id, ...values}); + generation++; + if (result.entry?.saved || result.entry?.completed) entries.set(id, result.entry); + else entries.delete(id); + $('followingError').textContent = ''; + if (message) setMessage(message, false, undo ? {label: '撤销', run: () => change(id, undo, '已撤销')} : null); + return true; + } catch (error) { + $('followingError').textContent = error.message; + setMessage(error.message, true); + return false; + } finally { + pending.delete(id); + changed(); + } + } + + async function toggleSaved(id) { + if (!loaded) {await refresh(); if (!loaded) {setMessage('追剧清单尚未读取,请重试', true); return;}} + const value = !entries.get(id)?.saved; + return change(id, {saved: value}, value ? '已加入追剧清单' : '已取消收藏', {saved: !value}); + } + + function setCompleted(id, value) { + return change(id, {completed: value}, value ? '已标为已看,播放进度仍保留' : '已取消手动已看标记', {completed: !value}); + } + + function saveButton(id, title) { + const saved = get(id)?.saved; + const control = button('', () => toggleSaved(id), pending.has(id), 'secondary icon-button save-button'); + control.appendChild(icon('bookmark')); + control.dataset.focusKey = 'save-' + id; + control.dataset.saveId = id; + control.setAttribute('aria-pressed', String(Boolean(saved))); + control.setAttribute('aria-label', (saved ? '取消收藏 ' : '收藏 ') + title); + control.title = saved ? '取消收藏' : '加入想看'; + return control; + } + + function showTab(value) { + if (!['watching', 'saved', 'completed'].includes(value)) return; + tab = value; + visibleLimit = 100; + render(); + } + + function init() { + document.querySelectorAll('[data-follow-tab]').forEach(control => control.addEventListener('click', () => showTab(control.dataset.followTab))); + $('followingSearch').addEventListener('input', () => {visibleLimit = 100; render();}); + $('retryFollowingBtn').addEventListener('click', refresh); + document.addEventListener('visibilitychange', () => {if (!document.hidden && Date.now() - lastRefresh > 30000) refresh();}); + return refresh(); + } + + return {init, render, refresh, refreshCover, retryCovers: () => $('followingList').querySelectorAll('.following-cover').forEach(poster => poster.retryCover?.()), get, toggleSaved, setCompleted, saveButton, showTab, busy: id => pending.has(id), acknowledge: id => {if (get(id)?.newEpisodes) change(id, {acknowledge: true});}}; +} diff --git a/internal/webui/history.css b/internal/webui/history.css new file mode 100644 index 0000000..67b0080 --- /dev/null +++ b/internal/webui/history.css @@ -0,0 +1,34 @@ +.history-dialog{color:var(--text);background:var(--panel);border:1px solid var(--line);border-radius:12px;width:min(680px,calc(100vw - 20px));height:min(620px,82dvh);max-height:calc(100dvh - 20px);padding:0;overflow:hidden} +.history-dialog::backdrop{background:#10151080} +.history-dialog[open]{display:flex;flex-direction:column} +.history-header{padding:16px 18px 14px;border-bottom:1px solid var(--line);flex:none} +.history-header .heading{flex-wrap:nowrap} +.history-header h2{font-size:18px;white-space:nowrap} +.history-heading-actions{display:flex;gap:6px;flex:none} +.history-dialog button{font-size:12px;padding:5px 8px;white-space:nowrap} +#historySearch{width:100%;margin-top:12px;font-size:16px;padding:7px 9px} +.history-content{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding:0 18px} +.history-list{list-style:none;margin:0;padding:0} +.history-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--line)} +.history-description{min-width:0} +.history-title{font-weight:600;line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow-wrap:anywhere} +.history-meta{font-size:12px;line-height:1.6;color:var(--muted);margin-top:3px;overflow-wrap:anywhere} +.history-actions{display:flex;align-items:center;gap:5px} +.history-actions .history-remove{min-width:44px;background:transparent;border-color:transparent;font-size:19px;line-height:20px;padding:3px 7px;color:var(--muted)} +.history-remove:hover{color:var(--danger)} +.history-footer{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:12px 18px;border-top:1px solid var(--line);flex:none} +.history-footer .small{font-size:12px} +.history-empty{padding:32px 8px;text-align:center;color:var(--muted);font-size:12px} +#historyError{font-size:12px;margin-top:7px} +#historyError:empty,#playbackHistoryStatus:empty{display:none} +#playbackHistoryStatus{font-size:12px;margin-top:5px;color:var(--muted)} +.history-dialog [hidden]{display:none!important} +@media(max-width:600px){ + .history-header{padding:10px} + .history-header h2{font-size:16px} + .history-content{padding:0 10px} + .history-row{gap:7px;padding:10px 0} + .history-actions{gap:3px} + .history-actions button{padding:5px 6px;font-size:12px} + .history-footer{padding:8px 10px} +} diff --git a/internal/webui/history.js b/internal/webui/history.js new file mode 100644 index 0000000..0b01b98 --- /dev/null +++ b/internal/webui/history.js @@ -0,0 +1,175 @@ +(() => { + 'use strict'; + const entries = new Map(); + let options, generation = 0, requestID = 0, busy = false, removing = false, limit = 500; + const node = id => document.getElementById(id); + const dialog = node('historyPanel'); + const list = node('historyList'); + const get = id => entries.get(id); + + function element(tag, className, text) { + const result = document.createElement(tag); + if (className) result.className = className; + if (text !== undefined) result.textContent = text; + return result; + } + + function clock(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)); + const minutes = Math.floor(seconds / 60); + return (minutes >= 60 ? Math.floor(minutes / 60) + ':' + String(minutes % 60).padStart(2, '0') : String(minutes).padStart(2, '0')) + ':' + String(seconds % 60).padStart(2, '0'); + } + + function progressText(id) { + const entry = typeof id === 'object' ? id : get(id); + if (!entry) return ''; + if (entry.completed) return entry.index >= entry.total ? '已看至最新' : '第' + entry.episode + '集已看完'; + return '看到第' + entry.episode + '集 ' + clock(entry.position); + } + + function watchedTime(value) { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return ''; + const now = new Date(), elapsed = Math.max(0, now - date); + if (elapsed < 60000) return '刚刚'; + if (elapsed < 3600000) return Math.floor(elapsed / 60000) + '分钟前'; + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + if (date >= yesterday) return (date >= today ? '今天 ' : '昨天 ') + date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false}); + return date.toLocaleDateString('zh-CN', {year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'numeric', day: 'numeric'}); + } + + function controls() { + node('clearHistoryBtn').disabled = busy || removing || !entries.size; + node('historyContent').setAttribute('aria-busy', String(busy || removing)); + node('historySummary').textContent = '按最近观看排序 · 自动保存最近 ' + limit + ' 部'; + } + + function render() { + controls(); + node('historyCount').textContent = entries.size ? entries.size + ' 部' : ''; + if (!dialog.open) return; + const keyword = node('historySearch').value.trim().toLocaleLowerCase(); + const ordered = Array.from(entries.values()).sort((left, right) => new Date(right.watchedAt) - new Date(left.watchedAt)); + const visible = ordered.filter(entry => !keyword || (entry.title + ' ' + options.sourceLabel(entry.source)).toLocaleLowerCase().includes(keyword)); + const fragment = document.createDocumentFragment(); + for (const entry of visible) { + const row = element('li', 'history-row'); + row.dataset.dramaId = entry.dramaId; + const content = element('div', 'history-description'); + const title = element('div', 'history-title', entry.title || '短剧'); + title.title = entry.title; + const meta = element('div', 'history-meta', [options.sourceLabel(entry.source), progressText(entry), watchedTime(entry.watchedAt)].filter(Boolean).join(' · ')); + meta.title = new Date(entry.watchedAt).toLocaleString(); + content.append(title, meta); + const actions = element('div', 'history-actions'); + const resume = element('button', 'secondary', '继续观看'); + resume.setAttribute('aria-label', '继续观看 ' + entry.title); + resume.addEventListener('click', () => {dialog.close(); options.play(entry.dramaId, entry.title);}); + const remove = element('button', 'secondary history-remove', '×'); + remove.title = '删除此记录'; + remove.setAttribute('aria-label', '删除 ' + entry.title + ' 的观看记录'); + remove.disabled = removing; + remove.addEventListener('click', () => removeEntries(entry.dramaId)); + actions.append(resume, remove); + row.append(content, actions); + fragment.appendChild(row); + } + if (!visible.length) fragment.appendChild(element('li', 'history-empty', busy ? '正在读取观看记录…' : keyword ? '没有匹配的观看记录' : '还没有观看记录,播放后会自动保存')); + list.replaceChildren(fragment); + } + + function changed() { + options?.onChanged(); + render(); + } + + async function refresh() { + if (!options) return; + const request = ++requestID, version = generation; + busy = true; + node('historyError').textContent = ''; + node('retryHistoryBtn').hidden = true; + render(); + try { + const result = await options.api('/api/ui/playback/history'); + if (request !== requestID || version !== generation) return; + entries.clear(); + for (const entry of result.data || []) if (entry.dramaId) entries.set(entry.dramaId, entry); + limit = result.limit || 500; + changed(); + } catch (error) { + if (request !== requestID) return; + node('historyError').textContent = '读取观看记录失败:' + error.message; + node('retryHistoryBtn').hidden = false; + } finally { + if (request === requestID) {busy = false; render();} + } + } + + function remember(entry) { + if (!entry?.dramaId) return; + entries.set(entry.dramaId, entry); + generation++; + if (entries.size > limit) { + const oldest = Array.from(entries.values()).sort((left, right) => new Date(right.watchedAt) - new Date(left.watchedAt)); + for (const entry of oldest.slice(limit)) entries.delete(entry.dramaId); + } + changed(); + } + + async function save(session, progress, entry) { + remember(entry); + const version = generation; + const result = await options.post('/api/ui/playback/progress', {session, progress}); + if (version === generation) { + if (result.saved && result.entry?.dramaId) { + entries.set(result.entry.dramaId, result.entry); + changed(); + } else if (!result.saved) { + refresh(); + } + } + } + + async function removeEntries(id) { + if (removing || !id && !confirm('清空全部观看记录?已下载的视频和下载任务会保留。')) return; + removing = true; + node('historyError').textContent = ''; + render(); + try { + await options.post('/api/ui/playback/history/remove', id ? {dramaId: id} : {all: true}); + generation++; + if (id) entries.delete(id); else entries.clear(); + changed(); + node('historySearch').focus(); + } catch (error) { + node('historyError').textContent = error.message; + } finally { + removing = false; + render(); + } + } + + function init(configuration) { + options = configuration; + node('openHistoryBtn').addEventListener('click', () => { + node('historySearch').value = ''; + dialog.showModal(); + node('historyContent').scrollTop = 0; + refresh(); + }); + node('closeHistoryBtn').addEventListener('click', () => dialog.close()); + node('clearHistoryBtn').addEventListener('click', () => removeEntries('')); + node('retryHistoryBtn').addEventListener('click', refresh); + node('historySearch').addEventListener('input', render); + refresh(); + } + + function reportError(message) { + node('historyError').textContent = '观看进度暂未保存:' + message; + options?.onError(node('historyError').textContent); + } + + window.JukuHistory = {init, get, list: () => Array.from(entries.values()), progressText, remember, save, refresh, reportError}; +})(); diff --git a/internal/webui/hls.LICENSE.txt b/internal/webui/hls.LICENSE.txt new file mode 100644 index 0000000..4dcd28b --- /dev/null +++ b/internal/webui/hls.LICENSE.txt @@ -0,0 +1,233 @@ +hls.js 1.7.3 — https://github.com/video-dev/hls.js/tree/v1.7.3 + +Copyright (c) 2017 Dailymotion (http://www.dailymotion.com) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project +are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls) + +That work is also covered by the Apache 2 License, following copyright: +Copyright (c) 2013-2015 Brightcove + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/internal/webui/hls.min.js b/internal/webui/hls.min.js new file mode 100644 index 0000000..161cb34 --- /dev/null +++ b/internal/webui/hls.min.js @@ -0,0 +1,4 @@ +!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,i=Array(t);r=e.length?{done:!0}:{done:!1,value:e[n++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function o(e,t,r){return(t=y(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t3?(n=g===i)&&(l=a[(o=a[4])?5:(o=3,3)],a[4]=a[5]=e):a[0]<=c&&((n=r<2&&ci||i>g)&&(a[4]=r,a[5]=i,f.n=g,o=0))}if(n||r>1)return s;throw h=!0,i}return function(n,d,g){if(u>1)throw TypeError("Generator is already running");for(h&&1===d&&c(d,g),o=d,l=g;(t=o<2?e:l)||!h;){a||(o?o<3?(o>1&&(f.n=-1),c(o,l)):f.n=l:f.v=l);try{if(u=2,a){if(o||(n="next"),t=a[n]){if(!(t=t.call(a,l)))throw TypeError("iterator result is not an object");if(!t.done)return t;l=t.value,o<2&&(o=0)}else 1===o&&(t=a.return)&&t.call(a),o<2&&(l=TypeError("The iterator does not provide a '"+n+"' method"),o=1);a=e}else if((t=(h=f.n<0)?l:r.call(i,f))!==s)break}catch(t){a=e,o=1,l=t}finally{u=1}}return{value:t,done:h}}}(r,n,a),!0),u}var s={};function o(){}function l(){}function u(){}t=Object.getPrototypeOf;var d=[][i]?t(t([][i]())):(m(t={},i,(function(){return this})),t),h=u.prototype=o.prototype=Object.create(d);function f(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,u):(e.__proto__=u,m(e,n,"GeneratorFunction")),e.prototype=Object.create(h),e}return l.prototype=u,m(h,"constructor",u),m(u,"constructor",l),l.displayName="GeneratorFunction",m(u,n,"GeneratorFunction"),m(h),m(h,n,"Generator"),m(h,i,(function(){return this})),m(h,"toString",(function(){return"[object Generator]"})),(v=function(){return{w:a,m:f}})()}function m(e,t,r,i){var n=Object.defineProperty;try{n({},"",{})}catch(e){n=0}m=function(e,t,r,i){function a(t,r){m(e,t,(function(e){return this._invoke(t,r,e)}))}t?n?n(e,t,{value:r,enumerable:!i,configurable:!i,writable:!i}):e[t]=r:(a("next",0),a("throw",1),a("return",2))},m(e,t,r,i)}function p(e,t){return p=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},p(e,t)}function y(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function E(e){var t="function"==typeof Map?new Map:void 0;return E=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(h())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&p(n,r.prototype),n}(e,arguments,u(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),p(r,e)},E(e)}function T(e){return e instanceof ArrayBuffer||"undefined"!=typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer}var S="utf-16",A="utf-16be",I="utf-16le",L="utf-8";function R(e,t){if(e<0)return-R(-e,t);var r=Math.pow(10,t);if(Math.abs(e*r%1-.5)1||1===i&&null!=(t=this.levelkeys[r[0]])&&t.encrypted)return!0}return!1}},{key:"programDateTime",get:function(){return null===this._programDateTime&&this.rawProgramDateTime&&(this.programDateTime=Date.parse(this.rawProgramDateTime)),this._programDateTime},set:function(e){x(e)?this._programDateTime=e:this._programDateTime=this.rawProgramDateTime=null}},{key:"ref",get:function(){return W(this)?(this._ref||(this._ref={base:this.base,start:this.start,duration:this.duration,sn:this.sn,programDateTime:this.programDateTime}),this._ref):null}}])}(j),Q=function(e){function t(t,r,i,n,a){var s;(s=e.call(this,i)||this).fragOffset=0,s.duration=0,s.independent=!1,s.relurl=void 0,s.fragment=void 0,s.index=void 0,s.gap=!1,s.duration=t.decimalFloatingPoint("DURATION"),s.gap=t.bool("GAP"),s.independent=t.bool("INDEPENDENT"),s.relurl=t.enumeratedString("URI"),s.fragment=r,s.index=n;var o=t.enumeratedString("BYTERANGE");return o&&s.setByteRange(o,a),a&&(s.fragOffset=a.fragOffset+a.duration),s}return d(t,e),a(t,[{key:"start",get:function(){return this.fragment.start+this.fragOffset}},{key:"end",get:function(){return this.start+this.duration}},{key:"loaded",get:function(){var e=this.elementaryStreams;return!!(e.audio||e.video||e.audiovideo||this.fragment.type===H.SUBTITLE&&this.stats.loading.end>0)}}])}(j);function $(e,t){var r=Object.getPrototypeOf(e);if(r){var i=Object.getOwnPropertyDescriptor(r,t);return i||$(r,t)}}function Z(e){switch(e.details){case B.FRAG_LOAD_TIMEOUT:case B.KEY_LOAD_TIMEOUT:case B.LEVEL_LOAD_TIMEOUT:case B.MANIFEST_LOAD_TIMEOUT:case B.AUDIO_TRACK_LOAD_TIMEOUT:case B.SUBTITLE_TRACK_LOAD_TIMEOUT:case B.ASSET_LIST_LOAD_TIMEOUT:return!0}return!1}function J(e){return e.details.startsWith("key")}function ee(e){return J(e)&&!!e.frag&&!e.frag.decryptdata}function te(e,t){var r=Z(t);return e.default[(r?"timeout":"error")+"Retry"]}function re(e,t){var r="linear"===e.backoff?1:Math.pow(2,t);return Math.min(r*e.retryDelayMs,e.maxRetryDelayMs)}function ie(e){return c(c({},e),{errorRetry:null,timeoutRetry:null})}function ne(e,t,r,i){if(!e)return!1;var n=null==i?void 0:i.code,a=t499)}(n)||!!r);return e.shouldRetry?e.shouldRetry(e,t,r,i,a):a}function ae(e){return 0===e&&!1===navigator.onLine}function se(e,t){return t>0&&e.loadError>0&&0!==e.loadErrorTime&&self.performance.now()-e.loadErrorTime>t}var oe=function(){function e(e,t,r){void 0===t&&(t=0),void 0===r&&(r=0),this.halfLife=void 0,this.alpha_=void 0,this.estimate_=void 0,this.totalWeight_=void 0,this.halfLife=e,this.alpha_=e?Math.exp(Math.log(.5)/e):0,this.estimate_=t,this.totalWeight_=r}var t=e.prototype;return t.sample=function(e,t){var r=Math.pow(this.alpha_,e);this.estimate_=t*(1-r)+r*this.estimate_,this.totalWeight_+=e},t.getTotalWeight=function(){return this.totalWeight_},t.getEstimate=function(){if(this.alpha_){var e=1-Math.pow(this.alpha_,this.totalWeight_);if(e)return this.estimate_/e}return this.estimate_},e}(),le=function(){function e(e,t,r,i){void 0===i&&(i=100),this.defaultEstimate_=void 0,this.minWeight_=void 0,this.minDelayMs_=void 0,this.slow_=void 0,this.fast_=void 0,this.defaultTTFB_=void 0,this.ttfb_=void 0,this.defaultEstimate_=r,this.minWeight_=.001,this.minDelayMs_=50,this.slow_=new oe(e),this.fast_=new oe(t),this.defaultTTFB_=i,this.ttfb_=new oe(e)}var t=e.prototype;return t.update=function(e,t){var r=this.slow_,i=this.fast_,n=this.ttfb_;r.halfLife!==e&&(this.slow_=new oe(e,r.getEstimate(),r.getTotalWeight())),i.halfLife!==t&&(this.fast_=new oe(t,i.getEstimate(),i.getTotalWeight())),n.halfLife!==e&&(this.ttfb_=new oe(e,n.getEstimate(),n.getTotalWeight()))},t.sample=function(e,t){if(!(t<=0)){var r=Math.min(this.minDelayMs_,t/1e5),i=8*t,n=Math.max(e,r)/1e3,a=i/n;this.fast_.sample(n,a),this.slow_.sample(n,a)}},t.sampleTTFB=function(e){var t=e/1e3,r=Math.sqrt(2)*Math.exp(-Math.pow(t,2)/2);this.ttfb_.sample(r,Math.max(e,5))},t.canEstimate=function(){return this.fast_.getTotalWeight()>=this.minWeight_},t.getEstimate=function(){return this.canEstimate()?Math.min(this.fast_.getEstimate(),this.slow_.getEstimate()):this.defaultEstimate_},t.getEstimateTTFB=function(){return this.ttfb_.getTotalWeight()>=this.minWeight_?this.ttfb_.getEstimate():this.defaultTTFB_},t.destroy=function(){},a(e,[{key:"defaultEstimate",get:function(){return this.defaultEstimate_}}])}(),ue=function(e,t){this.trace=void 0,this.debug=void 0,this.log=void 0,this.warn=void 0,this.info=void 0,this.error=void 0;var r="["+e+"]:";this.trace=de,this.debug=t.debug.bind(null,r),this.log=t.log.bind(null,r),this.warn=t.warn.bind(null,r),this.info=t.info.bind(null,r),this.error=t.error.bind(null,r)},de=function(){},he={trace:de,debug:de,log:de,warn:de,info:de,error:de};function fe(){return l({},he)}function ce(e,t,r){return t[e]?t[e].bind(t):function(e,t){var r=self.console[e];return r?r.bind(self.console,(t?"["+t+"] ":"")+"["+e+"] >"):de}(e,r)}var ge=fe();function ve(e,t,r){var i=fe();if("object"==typeof console&&!0===e||"object"==typeof e){var n=["debug","log","info","warn","error"];n.forEach((function(t){i[t]=ce(t,e,r)}));try{i.log('Debug logs enabled for "'+t+'" in hls.js version 1.7.3')}catch(e){return fe()}n.forEach((function(t){ge[t]=ce(t,e)}))}else l(ge,i);return i}var me=ge;function pe(e){if(void 0===e&&(e=!0),"undefined"!=typeof self)return(e||!self.MediaSource)&&self.ManagedMediaSource||self.MediaSource||self.WebKitMediaSource}function ye(e,t){var r=Object.keys(e),i=Object.keys(t),n=r.length,a=i.length;return!n||!a||n===a&&!r.some((function(e){return-1===i.indexOf(e)}))}function Ee(e,t){return t+10<=e.length&&73===e[t]&&68===e[t+1]&&51===e[t+2]&&e[t+3]<255&&e[t+4]<255&&e[t+6]<128&&e[t+7]<128&&e[t+8]<128&&e[t+9]<128}function Te(e,t){var r=0;return r=(127&e[t])<<21,r|=(127&e[t+1])<<14,r|=(127&e[t+2])<<7,r|=127&e[t+3]}function Se(e,t){return Ee(e,t)&&Te(e,t+6)+10<=e.length-t}function Ae(e,t){return t+10<=e.length&&51===e[t]&&68===e[t+1]&&73===e[t+2]&&e[t+3]<255&&e[t+4]<255&&e[t+6]<128&&e[t+7]<128&&e[t+8]<128&&e[t+9]<128}function Ie(e,t){for(var r=t,i=0;Ee(e,t);)i+=10,i+=Te(e,t+6),Ae(e,t+10)&&(i+=10),t+=i;if(i>0)return e.subarray(r,r+i)}function Le(e,t,r){return void 0===t&&(t=0),void 0===r&&(r=1/0),function(e,t,r,i){var n,a=T(n=e)?n:n.buffer,s=1;"BYTES_PER_ELEMENT"in i&&(s=i.BYTES_PER_ELEMENT);var o,l=(o=e)&&T(o.buffer)&&void 0!==o.byteLength&&void 0!==o.byteOffset?e.byteOffset:0,u=(l+e.byteLength)/s,d=(l+t)/s,h=Math.floor(Math.max(0,Math.min(d,u)));return new i(a,h,Math.floor(Math.min(h+Math.max(r,0),u))-h)}(e,t,r,Uint8Array)}function Re(e,t){void 0===t&&(t=!1);var r=t?e.indexOf(0):e.length,i=function(e,t){var r;void 0===t&&(t={}),r=T(e)?new DataView(e):new DataView(e.buffer,e.byteOffset,e.byteLength);var i=0,n=t.encoding;if(!n){var a=r.getUint8(0),s=r.getUint8(1);239==a&&187==s&&191==r.getUint8(2)?(n=L,i=3):254==a&&255==s?(n=A,i=2):255==a&&254==s?(n=I,i=2):n=L}if("undefined"!=typeof TextDecoder)return new TextDecoder(n).decode(r);for(var o,l=r.byteLength,u=n!==A,d="";i=194&&o<=223)if(i+1=128&&h<=191?(o=(31&o)<<6|63&h,i+=2):i++}else i++;else if(o>=224&&o<=239)if(i+2<=l-1){var f=r.getUint8(i+1),c=r.getUint8(i+2);f>=128&&f<=191&&c>=128&&c<=191?(o=(15&o)<<12|(63&f)<<6|63&c,i+=3):i++}else i++;else if(o>=240&&o<=244)if(i+3<=l-1){var g=r.getUint8(i+1),v=r.getUint8(i+2),m=r.getUint8(i+3);g>=128&&g<=191&&v>=128&&v<=191&&m>=128&&m<=191?(o=(7&o)<<18|(63&g)<<12|(63&v)<<6|63&m,i+=4):i++}else i++;else i++;break;case A:case S:case I:o=r.getUint16(i,u),i+=2}d+=String.fromCodePoint(o)}return d}(new DataView(e.buffer,e.byteOffset,r),{encoding:L});return t?i:i.replace(/\0/g,"")}function be(e){var t={key:e.type,description:"",data:"",mimeType:null,pictureType:null};if(!(e.size<2))if(3===e.data[0]){var r=e.data.subarray(1).indexOf(0);if(-1!==r){var i=Re(Le(e.data,1,r)),n=e.data[2+r],a=e.data.subarray(3+r).indexOf(0);if(-1!==a){var s,o,l=Re(Le(e.data,3+r,a));return s="--\x3e"===i?Re(Le(e.data,4+r+a)):T(o=e.data.subarray(4+r+a))?o:0==o.byteOffset&&o.byteLength==o.buffer.byteLength?o.buffer:new Uint8Array(o).buffer,t.mimeType=i,t.pictureType=n,t.description=l,t.data=s,t}}}else console.log("Ignore frame with unrecognized character encoding")}function ke(e){return"PRIV"===e.type?function(e){if(!(e.size<2)){var t=Re(e.data,!0),r=new Uint8Array(e.data.subarray(t.length+1));return{key:e.type,info:t,data:r.buffer}}}(e):"W"===e.type[0]?function(e){if("WXXX"===e.type){if(e.size<2)return;var t=1,r=Re(e.data.subarray(t),!0);t+=r.length+1;var i=Re(e.data.subarray(t));return{key:e.type,info:r,data:i}}var n=Re(e.data);return{key:e.type,info:"",data:n}}(e):"APIC"===e.type?be(e):function(e){if(!(e.size<2)){if("TXXX"===e.type){var t=1,r=e.data,i=Re(r.subarray(t),!0);t+=i.length+1;var n=Re(r.subarray(t));return{key:e.type,info:i,data:n}}var a=Re(e.data.subarray(1));return{key:e.type,info:"",data:a}}}(e)}function De(e){var t=String.fromCharCode(e[0],e[1],e[2],e[3]),r=Te(e,4);return{type:t,size:r,data:e.subarray(10,10+r)}}var Pe=10,_e=10;function Ce(e){for(var t=0,r=[];Ee(e,t);){var i=Te(e,t+6);e[t+5]>>6&1&&(t+=Pe);for(var n=(t+=Pe)+i;t+_e>24,e[t+1]=r>>16&255,e[t+2]=r>>8&255,e[t+3]=255&r}function ze(e,t,r){var i=t.byteOffset-e.byteOffset-8,n=8+r,a=t.byteLength+8-n;a<8||(Xe(e,i,n),Xe(e,i+n,a),Xe(e,i+n+4,Ke.free))}function Qe(e,t){return[e.byteOffset-t-8,e.byteOffset-t+e.byteLength]}function $e(e,t){t.sort((function(e,t){return e[0]-t[0]}));for(var r=t.reduce((function(e,t){var r=t[0];return e+t[1]-r}),0),i=new Uint8Array(e.byteLength-r),n=0,a=0,s=0;s1?f+g:n;if(c&&e[f+4]===o&&e[f+5]===l&&e[f+6]===u&&e[f+7]===d){var m=Math.min(v,n);r===h?a.push(e.subarray(f+8,m)):Je(e,t,r+1,f+8,m,a)}f=v}}function et(e){var t=[],r=e[0],i=8,n=Ye(e,i);i+=4;var a=0,s=0;0===r?(a=Ye(e,i),s=Ye(e,i+4),i+=8):(a=je(e,i),s=je(e,i+8),i+=16),i+=2;var o=e.length+s,l=He(e,i);i+=2;for(var u=0;u>>31)return me.warn("SIDX has hierarchical references (not supported)"),null;var c=Ye(e,d);d+=4,t.push({referenceSize:f,subsegmentDuration:c,info:{duration:c/n,start:o,end:o+f-1}}),o+=f,i=d+=4}return{earliestPresentationTime:a,timescale:n,version:r,referencesCount:l,references:t}}function tt(e){for(var t=[],r=Ze(e,["moov","trak"]),i=0;i3&&(a+="."+at(u[1])+at(u[2])+at(u[3]),t=it("avc1"===l?"dva1":"dvav",i));break;case"mp4a":var d=Ze(r,[n])[0],h=Ze(d.subarray(28),["esds"])[0];if(h&&h.length>7){var f=4;if(3!==h[f++])break;f=nt(h,f),f+=2;var c=h[f++];if(128&c&&(f+=2),64&c&&(f+=h[f++]),4!==h[f++])break;f=nt(h,f);var g=h[f++];if(64!==g)break;if(a+="."+at(g),f+=12,5!==h[f++])break;f=nt(h,f);var v=h[f++],m=(248&v)>>3;31===m&&(m+=1+((7&v)<<3)+((224&h[f])>>5)),a+="."+m}break;case"hvc1":case"hev1":var p=Ze(i,["hvcC"])[0];if(p&&p.length>12){var y=p[1],E=["","A","B","C"][y>>6],T=31&y,S=Ye(p,2),A=(32&y)>>5?"H":"L",I=p[12],L=p.subarray(6,12);a+="."+E+T,a+="."+function(e){for(var t=0,r=0;r<32;r++)t|=(e>>r&1)<<31-r;return t>>>0}(S).toString(16).toUpperCase(),a+="."+A+I;for(var R="",b=L.length;b--;){var k=L[b];(k||R)&&(R="."+k.toString(16).toUpperCase()+R)}a+=R}t=it("hev1"==l?"dvhe":"dvh1",i);break;case"dvh1":case"dvhe":case"dvav":case"dva1":case"dav1":a=it(a,i)||a;break;case"vp09":var D=Ze(i,["vpcC"])[0];if(D&&D.length>6){var P=D[4],_=D[5],C=D[6]>>4&15;a+="."+st(P)+"."+st(_)+"."+st(C)}break;case"av01":var w=Ze(i,["av1C"])[0];if(w&&w.length>2){var O=w[1]>>>5,M=31&w[1],x=w[2]>>>7?"H":"M",F=(64&w[2])>>6,N=(32&w[2])>>5,U=2===O&&F?N?12:10:F?10:8,B=(16&w[2])>>4,G=(8&w[2])>>3,K=(4&w[2])>>2,V=3&w[2];a+="."+O+"."+st(M)+x+"."+st(U)+"."+B+"."+G+K+V+"."+st(1)+"."+st(1)+"."+st(1)+".0",t=it("dav1",i)}}return{codec:a,encrypted:s,supplemental:t}}function it(e,t){var r=Ze(t,["dvvC"]),i=r.length?r[0]:Ze(t,["dvcC"])[0];if(i){var n=i[2]>>1&127,a=i[2]<<5&32|i[3]>>3&31;return e+"."+st(n)+"."+st(a)}}function nt(e,t){for(var r=t+5;128&e[t++]&&t0;a||(n=Ze(i,["encv"])),n.forEach((function(e){Ze(a?e.subarray(28):e.subarray(78),["sinf"]).forEach((function(e){var r=lt(e);r&&t(r,a)}))}))}}))}function lt(e){var t=Ze(e,["schm"])[0];if(t){var r=Ue(t.subarray(4,8));if("cbcs"===r||"cenc"===r){var i=Ze(e,["schi","tenc"])[0];if(i)return i}}}function ut(e){return"undefined"==typeof TextDecoder?Re(e):(Be||(Be=new TextDecoder("utf-8"))).decode(e).replace(/\0/g,"")}function dt(e,t,r,i,n){for(var a={},s=!1,o=function(e){for(var t=e.byteLength,r=[],i=0;i1?i+a:t;if(n&&109===e[i+4]&&111===e[i+5]&&111===e[i+6]&&102===e[i+7])for(var o=Math.min(s,t),l=i+8;l1?l+d:o;if(u&&116===e[l+4]&&114===e[l+5]&&97===e[l+6]&&102===e[l+7]){for(var f=Math.min(h,o),c=void 0,g=void 0,v=[],m=l+8;m1?m+y:f;if(p&&116===e[m+4]){var T=Math.min(E,f);102===e[m+5]&&104===e[m+6]&&100===e[m+7]?g||(g=e.subarray(m+8,T)):102===e[m+5]&&100===e[m+6]&&116===e[m+7]?c||(c=e.subarray(m+8,T)):114===e[m+5]&&117===e[m+6]&&110===e[m+7]&&v.push(e.subarray(m+8,T))}m=E}r.push({moofOffset:e.byteOffset+i,tfdt:c,tfhd:g,truns:v})}l=h}i=s}return r}(e),l=e.byteLength,u=0;up.ptsMax)&&(p.ptsMax=ae+w),X&&(X[$]={cts:te,duration:w,flags:J,size:Q})}_+=w,C+=w}!C&&I&&(C+=I*F)}else{for(var se,oe,le=_,ue=q,de=W,he=-1,fe=0,ce=0,ge=null!=(se=null==n?void 0:n.timeOffset)?se:0,ve=I/(null!=(oe=null==n?void 0:n.timescale)?oe:1),me=!0===(null==n?void 0:n.isHEVCFlavor),pe=me?2:1,ye=0;ye0){var Ae=q;do{var Ie=0===e[Ae]?e[Ae+1]<<16|e[Ae+2]<<8|e[Ae+3]:Ye(e,Ae),Le=e[Ae+=4],Re=me?Le>>1&63:31&Le;(me?39===Re||40===Re:6===Re)&>(e.subarray(Ae,Ae+Ie),pe,ge,n.samples),Ae+=Ie}while(Ael);De++)ce=De+1,ke+=8;if(T&&(n.timeOffset=ge),-1!==he&&fe<=l&&void 0===p.keyFrameIndex&&(p.keyFrameIndex=he,p.keyFrameStart=le+he*I),ce){(!x(p.ptsMin)||lep.ptsMax)&&(p.ptsMax=Pe)}var _e=I*F;_=le+_e,C+=_e}}p.duration+=C,p.duration&&(s=!0)}}if(!s){for(var Ce=1/0,we=0,Oe=Ze(e,["sidx"]),Me=0;Me>1&63;return 39===r||40===r}return 6==(31&t)}function gt(e,t,r,i){var n=vt(e),a=0;a+=t;for(var s=0,o=0,l=0;a=n.length)break;s+=l=n[a++]}while(255===l);o=0;do{if(a>=n.length)break;o+=l=n[a++]}while(255===l);var u=n.length-a,d=a;if(ou){me.error("Malformed SEI payload. "+o+" is too small, only "+u+" bytes left to parse.");break}if(4===s){if(181===n[d++]){var h=He(n,d);if(d+=2,49===h){var f=Ye(n,d);if(d+=4,1195456820===f){var c=n[d++];if(3===c){var g=n[d++],v=64&g,m=v?2+3*(31&g):0,p=new Uint8Array(m);if(v){p[0]=g;for(var y=1;y16){for(var E=[],T=0;T<16;T++){var S=n[d++].toString(16);E.push(1==S.length?"0"+S:S),3!==T&&5!==T&&7!==T&&9!==T||E.push("-")}for(var A=o-16,I=new Uint8Array(A),L=0;L0&&new DataView(a.buffer).setUint32(0,r.byteLength,!1),function(e){for(var t=arguments.length,r=new Array(t>1?t-1:0),i=1;i>24&255,o[1]=a>>16&255,o[2]=a>>8&255,o[3]=255&a,o.set(e,4),s=0,a=8;s>>24;if(0!==n&&1!==n)return{offset:r,size:t};var a,s=e.buffer,o=xe(new Uint8Array(s,r+12,16)),l=null,u=0;if(0===n)u=28;else{var d=e.getUint32(28);if(!d||i<32+16*d)return{offset:r,size:t};l=[];for(var h=0;h>3==2)if(32===a){for(var l=new Uint8Array(16),u=0;u<16;u++)l[u]=parseInt(String.fromCharCode(e[r+2*u],e[r+2*u+1]),16);t.push(l)}else 16===a&&t.push(e.subarray(r,r+16));r+=a}else r+=1===n?8:4;else for(;128&e[r++];);}return t}(a)),{version:n,systemId:o,kids:l,data:a,offset:r,size:t})}var Et,Tt,St,At=Lt("userAgent"),It=Lt("vendor");function Lt(e){try{return navigator[e]||""}catch(e){}return""}function Rt(){if(void 0===Et){var e=At.match(/Chrome\/(\d+)/i);Et=e?parseInt(e[1]):0}return Et}function bt(){return/\(Windows.+Firefox\//i.test(At)}var kt={audio:{a3ds:1,"ac-3":.95,"ac-4":1,alac:.9,alaw:1,dra1:1,"dts+":1,"dts-":1,dtsc:1,dtse:1,dtsh:1,"ec-3":.9,enca:1,fLaC:.9,flac:.9,FLAC:.9,g719:1,g726:1,m4ae:1,mha1:1,mha2:1,mhm1:1,mhm2:1,mlpa:1,mp4a:1,"raw ":1,Opus:1,opus:1,samr:1,sawb:1,sawp:1,sevc:1,sqcp:1,ssmv:1,twos:1,ulaw:1},video:{avc1:1,avc2:1,avc3:1,avc4:1,avcp:1,av01:.8,dav1:.8,drac:1,dva1:1,dvav:1,dvh1:.7,dvhe:.7,encv:1,hev1:.75,hvc1:.75,mjp2:1,mp4v:1,mvc1:1,mvc2:1,mvc3:1,mvc4:1,resv:1,rv60:1,s263:1,svc1:1,svc2:1,"vc-1":1,vp08:1,vp09:.9},text:{stpp:1,wvtt:1},image:{mjpg:1}};function Dt(e,t){var r=kt[t];return!!r&&!!r[e.slice(0,4)]}function Pt(e,t,r){return void 0===r&&(r=!0),!e.split(",").some((function(e){return!_t(e,t,r)}))}function _t(e,t,r){var i;void 0===r&&(r=!0);var n=pe(r);return null!=(i=null==n?void 0:n.isTypeSupported(Ct(e,t)))&&i}function Ct(e,t){return t+"/mp4;codecs="+e}function wt(e){if(e){var t=e.substring(0,4);return kt.video[t]}return 2}function Ot(e){var t=bt();return e.split(",").reduce((function(e,r){var i=t&&ft(r)?9:kt.video[r];return i?(2*i+e)/(e?3:2):(kt.audio[r]+e)/(e?2:1)}),0)}var Mt={},xt=/flac|opus|mp4a\.40\.34/i;function Ft(e,t){return void 0===t&&(t=!0),e.replace(xt,(function(e){return function(e,t){if(void 0===t&&(t=!0),Mt[e])return Mt[e];for(var r={flac:["flac","fLaC","FLAC"],opus:["opus","Opus"],"mp4a.40.34":["mp3"]}[e],i=0;i4||-1!==["ac-3","ec-3","alac","fLaC","Opus"].indexOf(e))&&(Ut(e,"audio")||Ut(e,"video")))return e;if(t){var r=t.split(",");if(r.length>1){if(e)for(var i=r.length;i--;)if(r[i].substring(0,4)===e.substring(0,4))return r[i];return r[0]}}return t||e}function Ut(e,t){return Dt(e,t)&&_t(e,t)}function Bt(e){if(e.startsWith("av01.")){for(var t=e.split("."),r=["0","111","01","01","01","0"],i=t.length;i>4&&i<10;i++)t[i]=r[i-4];return t.join(".")}return e}function Gt(e){var t=pe(e)||{isTypeSupported:function(){return!1}};return{mpeg:t.isTypeSupported("audio/mpeg"),mp3:t.isTypeSupported('audio/mp4; codecs="mp3"'),ac3:t.isTypeSupported('audio/mp4; codecs="ac-3"')}}function Kt(e){return e.replace(/^.+codecs=["']?([^"']+).*$/,"$1")}var Vt={supported:!1,smooth:!1,powerEfficient:!1},Ht={supported:!0,configurations:[],decodingInfoResults:[{supported:!0,powerEfficient:!0,smooth:!0}]};function Yt(e,t){return{supported:!1,configurations:t,decodingInfoResults:[Vt],error:e}}function jt(e,t,r,i){void 0===i&&(i={});var n=e.videoCodec;if(!n&&!e.audioCodec||!r)return Promise.resolve(Ht);for(var a=[],s=function(e){var t,r=null==(t=e.videoCodec)?void 0:t.split(","),i=qt(e),n=e.width||640,a=e.height||480,s=e.frameRate||30,o=e.videoRange.toLowerCase();return r?r.map((function(e){var t={contentType:Ct(Bt(e),"video"),width:n,height:a,bitrate:i,framerate:s};return"sdr"!==o&&(t.transferFunction=o),t})):[]}(e),o=s.length,l=function(e,t,r){var i,n=null==(i=e.audioCodec)?void 0:i.split(","),a=qt(e);return n&&e.audioGroups?e.audioGroups.reduce((function(e,i){var s,o=i?null==(s=t.groups[i])?void 0:s.tracks:null;return o?o.reduce((function(e,t){if(t.groupId===i){var s=parseFloat(t.channels||"");n.forEach((function(t){var i={contentType:Ct(t,"audio"),bitrate:r?Wt(t,a):a};s&&(i.channels=""+s),e.push(i)}))}return e}),e):e}),[]):[]}(e,t,o>0),u=l.length,d=o||1*u||1;d--;){var h={type:"media-source"};if(o&&(h.video=s[d%o]),u){h.audio=l[d%u];var f=h.audio.bitrate;h.video&&f&&(h.video.bitrate-=f)}a.push(h)}if(n){var c=navigator.userAgent;if(n.split(",").some((function(e){return ft(e)}))&&bt())return Promise.resolve(Yt(new Error("Overriding Windows Firefox HEVC MediaCapabilities result based on user-agent string: ("+c+")"),a))}return Promise.all(a.map((function(e){var t,n,a,s,o=(n="",a=(t=e).audio,(s=t.video)&&(n+=Kt(s.contentType)+"_r"+s.height+"x"+s.width+"f"+Math.ceil(s.framerate)+(s.transferFunction||"sd")+"_"+Math.ceil(s.bitrate/1e5)),a&&(n+=(s?"_":"")+Kt(a.contentType)+"_c"+a.channels),n);return i[o]||(i[o]=r.decodingInfo(e))}))).then((function(e){return{supported:!e.some((function(e){return!e.supported})),configurations:a,decodingInfoResults:e}})).catch((function(e){return{supported:!1,configurations:a,decodingInfoResults:[],error:e}}))}function Wt(e,t){if(t<=1)return 1;var r=128e3;return"ec-3"===e?r=768e3:"ac-3"===e&&(r=64e4),Math.min(t/2,r)}function qt(e){return 1e3*Math.ceil(Math.max(.9*e.bitrate,e.averageBitrate)/1e3)||1}function Xt(e,t,r){var i=encodeURIComponent(t),n=i+"="+encodeURIComponent(r),a=!1,s=[];e.search.substring(1).split("&").forEach((function(e){e===i||e.startsWith(i+"=")?a||(s.push(n),a=!0):e&&s.push(e)})),a||s.push(n),e.search=s.join("&")}var zt=["NONE","TYPE-0","TYPE-1",null],Qt=["SDR","PQ","HLG"],$t="",Zt="YES",Jt="v2";function er(e){var t=e.canSkipUntil,r=e.canSkipDateRanges,i=e.age;return t&&i-1;i--)if(r(e[i]))return i;for(var n=t+1;n-1&&v!==g,p=!!e||m;if(p||!l.paused&&l.playbackRate&&l.readyState){var y=s.mainForwardBufferInfo;if(p||null!==y){var E=r.bwEstimator.getEstimateTTFB(),T=Math.abs(l.playbackRate);if(!(f<=Math.max(E,h/(2*T)*1e3))){var S=y?y.len/T:0,A=d.loading.first?d.loading.first-d.loading.start:-1,I=d.loaded&&A>-1,L=r.getBwEstimate(),R=s.levels,b=R[g],k=Math.max(d.loaded,Math.round(h*(n.bitrate||b.averageBitrate)/8)),D=I?f-A:f;D<1&&I&&(D=Math.min(f,8*d.loaded/L));var P=I?1e3*d.loaded/D:0,_=E/1e3,C=P?(k-d.loaded)/P:8*k/L+_;if(!(C<=S)){var w,O=P?8*P:L,M=!0===(null==(t=(null==e?void 0:e.details)||s.latestLevelDetails)?void 0:t.live),F=s.config.abrBandWidthUpFactor,N=Number.POSITIVE_INFINITY;for(w=g-1;w>c;w--){var U=R[w].maxBitrate,B=!R[w].details||M;if((N=r.getTimeToLoadFrag(_,O,h*U,B))=C||N>10*h)){I?r.bwEstimator.sample(f-Math.min(E,A),d.loaded):r.bwEstimator.sampleTTFB(f);var K=R[w].maxBitrate;r.getBwEstimate()*F>K&&(r.firstAutoFloor=Math.min(w,s.firstLevel),r.resetEstimator(K));var V=r.findBestLevel(K,c,w,0,S,1,1);V>-1&&(w=V),r.warn("Fragment "+n.sn+(a?" part "+a.index:"")+" of level "+g+" is loading too slowly;\n Fragment duration: "+n.duration.toFixed(3)+"\n Time to underbuffer: "+S.toFixed(3)+" s\n Estimated load time for current fragment: "+C.toFixed(3)+" s\n Estimated load time for down switch fragment: "+N.toFixed(3)+" s\n TTFB estimate: "+(0|A)+" ms\n Current BW estimate: "+(x(L)?0|L:"Unknown")+" bps\n New BW estimate: "+(0|r.getBwEstimate())+" bps\n Switching to level "+w+" @ "+(0|K)+" bps"),s.nextLoadLevel=s.nextAutoLevel=w,r.clearTimer();var H=function(){r.clearTimer();var e=r.hls;if(r.fragCurrent===n&&e.loadLevel===w&&w>0){var t=r.getStarvationDelay();if(r.warn("Aborting inflight request "+(w>0?"and switching down":"")+"\n Fragment duration: "+n.duration.toFixed(3)+" s\n Time to underbuffer: "+t.toFixed(3)+" s"),n.abortRequests(),r.fragCurrent=r.partCurrent=null,w>c){var i=r.findBestLevel(e.levels[c].bitrate,c,w,0,t,1,1);-1===i&&(i=c),e.nextLoadLevel=e.nextAutoLevel=i,r.firstAutoFloor=Math.min(i,e.firstLevel),r.resetEstimator(e.levels[i].bitrate)}}};m||C>2*N?H():r.timer=self.setInterval(H,1e3*N),s.trigger(G.FRAG_LOAD_EMERGENCY_ABORTED,{frag:n,part:a,stats:d})}}}}}}}}},r.hls=t,r.bwEstimator=r.initEstimator(),r.registerListeners(),r}d(t,e);var r=t.prototype;return r.resetEstimator=function(e){this.hls&&(e&&(this.log("setting initial bwe to "+e),this.hls.config.abrEwmaDefaultEstimate=e),this.firstSelection=-1,this.bwEstimator=this.initEstimator())},r.initEstimator=function(){var e=this.hls.config;return new le(e.abrEwmaSlowVoD,e.abrEwmaFastVoD,e.abrEwmaDefaultEstimate)},r.registerListeners=function(){var e=this.hls;e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.FRAG_LOADING,this.onFragLoading,this),e.on(G.FRAG_LOADED,this.onFragLoaded,this),e.on(G.FRAG_BUFFERED,this.onFragBuffered,this),e.on(G.LEVEL_SWITCHING,this.onLevelSwitching,this),e.on(G.LEVEL_LOADED,this.onLevelLoaded,this),e.on(G.LEVELS_UPDATED,this.onLevelsUpdated,this),e.on(G.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),e.on(G.ERROR,this.onError,this)},r.unregisterListeners=function(){var e=this.hls;e&&(e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.FRAG_LOADING,this.onFragLoading,this),e.off(G.FRAG_LOADED,this.onFragLoaded,this),e.off(G.FRAG_BUFFERED,this.onFragBuffered,this),e.off(G.LEVEL_SWITCHING,this.onLevelSwitching,this),e.off(G.LEVEL_LOADED,this.onLevelLoaded,this),e.off(G.LEVELS_UPDATED,this.onLevelsUpdated,this),e.off(G.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),e.off(G.ERROR,this.onError,this))},r.destroy=function(){this.unregisterListeners(),this.clearTimer(),this.hls=this._abandonRulesCheck=this.supportedCache=null,this.audioTracksByGroup=this.codecTiers=this.fragCurrent=this.partCurrent=null},r.onManifestLoading=function(e,t){this.lastLoadedFragLevel=-1,this.firstSelection=-1,this.firstAutoFloor=-1,this.lastLevelLoadSec=0,this.supportedCache={},this.fragCurrent=this.partCurrent=null,this.onLevelsUpdated(),this.clearTimer()},r.onLevelsUpdated=function(){this.lastLoadedFragLevel>-1&&this.fragCurrent&&(this.lastLoadedFragLevel=this.fragCurrent.level),this._nextAutoLevel=-1,this.onMaxAutoLevelUpdated(),this.codecTiers=null,this.audioTracksByGroup=null},r.onMaxAutoLevelUpdated=function(){this.firstSelection=-1,this.nextAutoLevelKey=""},r.onFragLoading=function(e,t){var r,i=t.frag;this.ignoreFragment(i)||(i.bitrateTest||(this.fragCurrent=i,this.partCurrent=null!=(r=t.part)?r:null),this.clearTimer(),this.timer=self.setInterval(this._abandonRulesCheck,100))},r.onLevelSwitching=function(e,t){this.clearTimer()},r.onError=function(e,t){if(!t.fatal)switch(t.details){case B.BUFFER_ADD_CODEC_ERROR:case B.BUFFER_APPEND_ERROR:this.lastLoadedFragLevel=-1,this.firstSelection=-1;break;case B.FRAG_LOAD_TIMEOUT:var r=t.frag,i=this.fragCurrent,n=this.partCurrent;if(r&&X(r,i)){var a=performance.now(),s=n?n.stats:r.stats,o=a-s.loading.start,l=s.loading.first?s.loading.first-s.loading.start:-1;if(s.loaded&&l>-1){var u=this.bwEstimator.getEstimateTTFB();this.bwEstimator.sample(o-Math.min(u,l),s.loaded)}else this.bwEstimator.sampleTTFB(o)}}},r.getTimeToLoadFrag=function(e,t,r,i){return e+r/t+(i?e+this.lastLevelLoadSec:0)},r.onLevelLoaded=function(e,t){var r=this.hls.config,i=t.stats.loading,n=i.end-i.first;x(n)&&(this.lastLevelLoadSec=n/1e3),t.details.live?this.bwEstimator.update(r.abrEwmaSlowLive,r.abrEwmaFastLive):this.bwEstimator.update(r.abrEwmaSlowVoD,r.abrEwmaFastVoD),this.timer>-1&&this._abandonRulesCheck(t.levelInfo)},r.onFragLoaded=function(e,t){var r,i=t.frag,n=t.part,a=n?n.stats:i.stats;if(i.type===H.MAIN&&this.bwEstimator.sampleTTFB(a.loading.first-a.loading.start),!this.ignoreFragment(i)){if(this.clearTimer(),i.level===this._nextAutoLevel&&(this._nextAutoLevel=-1),this.firstSelection=-1,null!=(r=this.hls)&&r.config.abrMaxWithRealBitrate){var s=n?n.duration:i.duration,o=this.hls.levels[i.level],l=(o.loaded?o.loaded.bytes:0)+a.loaded,u=(o.loaded?o.loaded.duration:0)+s;o.loaded={bytes:l,duration:u},o.realBitrate=Math.round(8*l/u)}if(i.bitrateTest){var d={stats:a,frag:i,part:n,id:i.type};this.onFragBuffered(G.FRAG_BUFFERED,d),i.bitrateTest=!1}else this.lastLoadedFragLevel=i.level}},r.onFragBuffered=function(e,t){var r=t.frag,i=t.part,n=null!=i&&i.stats.loaded?i.stats:r.stats;if(!n.aborted&&!this.ignoreFragment(r)){var a=((r.bitrate||0)>=1e5?n.parsing.end:n.loading.end)-n.loading.start-Math.min(n.loading.first-n.loading.start,this.bwEstimator.getEstimateTTFB());this.bwEstimator.sample(a,n.loaded),n.bwEstimate=this.getBwEstimate(),r.bitrateTest?this.bitrateTestDelay=a/1e3:(this.bitrateTestDelay=0,this.firstAutoFloor>-1&&this.bwEstimator.canEstimate()&&(this.firstAutoFloor=-1))}},r.ignoreFragment=function(e){return e.type!==H.MAIN||"initSegment"===e.sn},r.clearTimer=function(){this.timer>-1&&(self.clearInterval(this.timer),this.timer=-1)},r.getAutoLevelKey=function(){return this.getBwEstimate()+"_"+this.getStarvationDelay().toFixed(2)},r.getNextABRAutoLevel=function(){var e=this.fragCurrent,t=this.partCurrent,r=this.hls;if(!r)return-1;if(r.levels.length<=1)return r.loadLevel;var i=r.maxAutoLevel,n=r.config,a=r.minAutoLevel,s=t?t.duration:e?e.duration:0,o=this.getBwEstimate(),l=this.getStarvationDelay(),u=n.abrBandWidthFactor,d=n.abrBandWidthUpFactor;if(l){var h=this.findBestLevel(o,a,i,l,0,u,d);if(h>=0)return this.rebufferNotice=-1,h}var f=s?Math.min(s,n.maxStarvationDelay):n.maxStarvationDelay;if(!l){var c=this.bitrateTestDelay;c&&(f=(s?Math.min(s,n.maxLoadingDelay):n.maxLoadingDelay)-c,this.info("bitrate test took "+Math.round(1e3*c)+"ms, set first fragment max fetchDuration to "+Math.round(1e3*f)+" ms"),u=d=1)}var g=this.findBestLevel(o,a,i,l,f,u,d);if(this.rebufferNotice!==g&&(this.rebufferNotice=g,this.info((l?"rebuffering expected":"buffer is empty")+", optimal quality level "+g)),g>-1)return g;var v=r.levels[a],m=r.loadLevelObj;return m&&(null==v?void 0:v.bitrate)0),f=Math.min(f,t.minHeight),c=Math.min(c,t.minFramerate),g=Math.min(g,t.minBitrate),T.filter((function(e){return t.videoRanges[e]>0})).length>0&&(h=!0)},A=a.length;A--;)S();f=x(f)?f:0,c=x(c)?c:0;var I=Math.max(1080,f),L=Math.max(30,c);g=x(g)?g:r,r=Math.max(g,r),h||(t=void 0);var R=a.length>1;return{codecSet:a.reduce((function(t,i){var n=e[i];if(i===t)return t;if(p=h?T.filter((function(e){return n.videoRanges[e]>0})):[],R){if(n.minBitrate>r)return sr(i,"min bitrate of "+n.minBitrate+" > current estimate of "+r),t;if(!n.hasDefaultAudio)return sr(i,"no renditions with default or auto-select sound found"),t;if(o&&i.indexOf(o.substring(0,4))%5!=0)return sr(i,'audio codec preference "'+o+'" not found'),t;if(s&&!u){if(!n.channels[s])return sr(i,"no renditions with "+s+" channel sound found (channels options: "+Object.keys(n.channels)+")"),t}else if((!o||u)&&d&&0===n.channels[2])return sr(i,"no renditions with stereo sound found"),t;if(n.minHeight>I)return sr(i,"min resolution of "+n.minHeight+" > maximum of "+I),t;if(n.minFramerate>L)return sr(i,"min framerate of "+n.minFramerate+" > maximum of "+L),t;if(!p.some((function(e){return n.videoRanges[e]>0})))return sr(i,"no variants with VIDEO-RANGE of "+ar(p)+" found"),t;if(l&&i.indexOf(l.substring(0,4))%5!=0)return sr(i,'video codec preference "'+l+'" not found'),t;if(n.maxScore=Ot(t)||n.fragmentError>e[t].fragmentError)?t:(v=n.minIndex,m=n.maxScore,i)}),void 0),videoRanges:p,preferHDR:E,minFramerate:c,minBitrate:g,minIndex:v}}(P,I,e,R,b),C=_.codecSet,w=_.videoRanges,O=_.minFramerate,M=_.minBitrate,F=_.minIndex,N=_.preferHDR;D=F,E=C,I=N?w[w.length-1]:w[0],L=O,e=Math.max(e,M),this.log("picked start tier "+ar(_))}else E=null==T?void 0:T.codecSet,I=null==T?void 0:T.videoRange;for(var U,B=h?h.duration:d?d.duration:0,G=this.bwEstimator.getEstimateTTFB()/1e3,K=[],V=function(){var t,l=g[H],d=H>c;if(!l)return 0;if(y.useMediaCapabilities&&!l.supportedResult&&!l.supportedPromise){var v=navigator.mediaCapabilities;"function"==typeof(null==v?void 0:v.decodingInfo)&&function(e,t,r,i,n,a){var s=e.videoCodec,o=e.audioCodec?e.audioGroups:null,l=null==a?void 0:a.audioCodec,u=null==a?void 0:a.channels,d=u?parseInt(u):l?1/0:2,h=null;if(null!=o&&o.length)try{h=1===o.length&&o[0]?t.groups[o[0]].channels:o.reduce((function(e,r){if(r){var i=t.groups[r];if(!i)throw new Error("Audio track group "+r+" not found");Object.keys(i.channels).forEach((function(t){e[t]=(e[t]||0)+i.channels[t]}))}return e}),{2:0})}catch(e){return!0}return void 0!==s&&(s.split(",").some((function(e){return ft(e)}))||e.width>1920&&e.height>1088||e.height>1920&&e.width>1088||e.frameRate>Math.max(i,30)||"SDR"!==e.videoRange&&e.videoRange!==r||e.bitrate>Math.max(n,8e6))||!!h&&x(d)&&Object.keys(h).some((function(e){return parseInt(e)>d}))}(l,k,I,L,e,R)?(l.supportedPromise=jt(l,k,v,o.supportedCache),l.supportedPromise.then((function(e){if(o.hls){l.supportedResult=e;var t=o.hls.levels,r=t.indexOf(l);e.error?o.warn('MediaCapabilities decodingInfo error: "'+e.error+'" for level '+r+" "+ar(e)):e.supported?e.decodingInfoResults.some((function(e){return!1===e.smooth||!1===e.powerEfficient}))&&o.log("MediaCapabilities decodingInfo for level "+r+" not smooth or powerEfficient: "+ar(e)):(o.warn("Unsupported MediaCapabilities decodingInfo result for level "+r+" "+ar(e)),r>-1&&t.length>1&&(o.log("Removing unsupported level "+r),o.hls.removeLevel(r),-1===o.hls.loadLevel&&(o.hls.nextLoadLevel=0)))}})).catch((function(e){o.warn("Error handling MediaCapabilities decodingInfo: "+e)}))):l.supportedResult=Ht}if((E&&l.codecSet!==E||I&&l.videoRange!==I||d&&L>l.frameRate||!d&&L>0&&L=2*B&&0===n?l.averageBitrate:l.maxBitrate,_=o.getTimeToLoadFrag(G,p,P*b,!T||T.live);if(p>=P&&(H===u||0===l.loadError&&0===l.fragmentError||se(l,y.errorPenaltyExpireMs))&&(_<=G||!x(_)||S&&!o.bitrateTestDelay||_"+H+" adjustedbw("+Math.round(p)+")-bitrate="+Math.round(p-P)+" ttfb:"+G.toFixed(1)+" avgDuration:"+b.toFixed(1)+" maxFetchDuration:"+f.toFixed(1)+" fetchDuration:"+_.toFixed(1)+" firstSelection:"+A+" codecSet:"+l.codecSet+" videoRange:"+l.videoRange+" hls.loadLevel:"+m)),A&&(o.firstSelection=H),{v:H}}},H=r;H>=t;H--)if(0!==(U=V())&&U)return U.v;return-1},r.deriveNextAutoLevel=function(e){if(!this.hls)return-1;var t=this.hls,r=t.maxAutoLevel,i=t.minAutoLevel;return Math.min(Math.max(e,i),r)},a(t,[{key:"firstAutoLevel",get:function(){var e=this.hls;if(!e)return 0;var t=e.maxAutoLevel,r=e.minAutoLevel,i=this.getBwEstimate(),n=this.getStarvationDelay(),a=e.config.maxStarvationDelay,s=this.findBestLevel(i,r,t,n/2,a,1,1);if(s>-1)return s;var o=e.firstLevel,l=-1===this.firstAutoFloor?1/0:this.firstAutoFloor,u=Math.min(Math.max(o,r),t,l);return this.warn("Could not find best starting auto level. Defaulting to first in playlist "+o+" clamped to "+u),u}},{key:"forcedAutoLevel",get:function(){return this.nextAutoLevelKey?-1:this._nextAutoLevel}},{key:"nextAutoLevel",get:function(){if(!this.hls)return-1;var e=this.forcedAutoLevel,t=this.bwEstimator.canEstimate(),r=this.lastLoadedFragLevel>-1;if(!(-1===e||t&&r&&this.nextAutoLevelKey!==this.getAutoLevelKey()))return e;var i=t&&r?this.getNextABRAutoLevel():this.firstAutoLevel;if(-1!==e){var n=this.hls.levels;if(n.length>Math.max(e,i)&&n[e].loadError<=n[i].loadError)return e}return this._nextAutoLevel=i,this.nextAutoLevelKey=this.getAutoLevelKey(),i},set:function(e){var t=this.deriveNextAutoLevel(e);this._nextAutoLevel!==t&&(this.nextAutoLevelKey="",this._nextAutoLevel=t)}}])}(ue),pr=function(e,t){for(var r=0,i=e.length-1,n=null,a=null;r<=i;){var s=t(a=e[n=(r+i)/2|0]);if(s>0)r=n+1;else{if(!(s<0))return a;i=n-1}}return null};function yr(e,t,r,i,n){void 0===r&&(r=0),void 0===i&&(i=0),void 0===n&&(n=.005);var a=null;if(e){a=t[1+e.sn-t[0].sn]||null;var s=e.endDTS-r;s>0&&s<15e-7&&(r+=15e-7),a&&e.level!==a.level&&a.end<=e.end&&(a=t[2+e.sn-t[0].sn]||null)}else 0===r&&0===t[0].start&&(a=t[0]);if(a&&((!e||e.level===a.level)&&0===Er(r,i,a)||function(e,t,r){if(0===(null==t?void 0:t.start)&&t.level0){var i=t.tagList.reduce((function(e,t){return"INF"===t[0]&&(e+=parseFloat(t[1])),e}),r);return e.start<=i}return!1}(a,e,Math.min(n,i))))return a;var o=pr(t,Er.bind(null,r,i));return!o||o===e&&a?a:o}function Er(e,t,r){if(void 0===e&&(e=0),void 0===t&&(t=0),r.start<=e&&r.start+r.duration>e)return 0;var i=Math.min(t,r.duration+(r.deltaPTS?r.deltaPTS:0));return r.start+r.duration-i<=e?1:r.start-i>e&&r.start?-1:0}function Tr(e,t,r){var i=1e3*Math.min(t,r.duration+(r.deltaPTS?r.deltaPTS:0));return(r.endProgramDateTime||0)-i>e}function Sr(e,t,r){if(e&&e.startCC<=t&&e.endCC>=t){var i,n=e.fragments,a=e.fragmentHint;return a&&(n=n.concat(a)),pr(n,(function(e){return e.cct?-1:(i=e,e.end<=r?1:e.start>r?-1:0)})),i||null}return null}function Ar(e,t){return t===e.endSN&&e.fragmentHint?e.fragmentHint:e.fragments[1+t-e.startSN]||null}var Ir=0,Lr=2,Rr=3,br=5,kr=0,Dr=1,Pr=2,_r=4,Cr=8,wr=16,Or=function(e){function t(t){var r;return(r=e.call(this,"error-controller",t.logger)||this).hls=void 0,r.playlistError=0,r.hls=t,r.registerListeners(),r}d(t,e);var r=t.prototype;return r.registerListeners=function(){var e=this.hls;e.on(G.ERROR,this.onError,this),e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.LEVEL_UPDATED,this.onLevelUpdated,this)},r.unregisterListeners=function(){var e=this.hls;e&&(e.off(G.ERROR,this.onError,this),e.off(G.ERROR,this.onErrorOut,this),e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.LEVEL_UPDATED,this.onLevelUpdated,this))},r.destroy=function(){this.unregisterListeners(),this.hls=null},r.startLoad=function(e){},r.stopLoad=function(){this.playlistError=0},r.getVariantLevelIndex=function(e){return(null==e?void 0:e.type)===H.MAIN?e.level:this.getVariantIndex()},r.getVariantIndex=function(){var e,t=this.hls,r=t.currentLevel;return null!=(e=t.loadLevelObj)&&e.details||-1===r?t.loadLevel:r},r.variantHasKey=function(e,t){if(e){var r;if(null!=(r=e.details)&&r.hasKey(t))return!0;var i=e.audioGroups;if(i)return this.hls.allAudioTracks.filter((function(e){return i.indexOf(e.groupId)>=0})).some((function(e){var r;return null==(r=e.details)?void 0:r.hasKey(t)}))}return!1},r.onManifestLoading=function(){this.playlistError=0},r.onLevelUpdated=function(){this.playlistError=0},r.onError=function(e,t){var r;if(!t.fatal){var i=this.hls,n=t.context;switch(t.details){case B.FRAG_LOAD_ERROR:case B.FRAG_LOAD_TIMEOUT:case B.KEY_LOAD_ERROR:case B.KEY_LOAD_TIMEOUT:return void(t.errorAction=this.getFragRetryOrSwitchAction(t));case B.FRAG_PARSING_ERROR:if(null!=(r=t.frag)&&r.gap)return void(t.errorAction=Fr());case B.FRAG_GAP:case B.FRAG_DECRYPT_ERROR:return t.errorAction=this.getFragRetryOrSwitchAction(t),void(t.errorAction.action=Lr);case B.PLAYLIST_UNCHANGED_ERROR:case B.LEVEL_EMPTY_ERROR:case B.LEVEL_PARSING_ERROR:var a,s=t.parent===H.MAIN?t.level:i.loadLevel;return void(t.details===B.LEVEL_EMPTY_ERROR&&null!=(a=t.context)&&null!=(a=a.levelDetails)&&a.live?t.errorAction=this.getPlaylistRetryOrSwitchAction(t,s):(t.levelRetry=!1,t.errorAction=this.getLevelSwitchAction(t,s)));case B.LEVEL_LOAD_ERROR:case B.LEVEL_LOAD_TIMEOUT:return void("number"==typeof(null==n?void 0:n.level)&&(t.errorAction=this.getPlaylistRetryOrSwitchAction(t,n.level)));case B.AUDIO_TRACK_LOAD_ERROR:case B.AUDIO_TRACK_LOAD_TIMEOUT:case B.SUBTITLE_LOAD_ERROR:case B.SUBTITLE_TRACK_LOAD_TIMEOUT:if(n){var o=i.loadLevelObj;if(o&&(n.type===V.AUDIO_TRACK&&o.hasAudioGroup(n.groupId)||n.type===V.SUBTITLE_TRACK&&o.hasSubtitleGroup(n.groupId)))return t.errorAction=this.getPlaylistRetryOrSwitchAction(t,i.loadLevel),t.errorAction.action=Lr,void(t.errorAction.flags=Dr)}return;case B.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED:return void(t.errorAction={action:Lr,flags:Pr});case B.KEY_SYSTEM_SESSION_UPDATE_FAILED:case B.KEY_SYSTEM_STATUS_INTERNAL_ERROR:case B.KEY_SYSTEM_NO_SESSION:return void(t.errorAction={action:Lr,flags:_r});case B.BUFFER_ADD_CODEC_ERROR:case B.REMUX_ALLOC_ERROR:case B.BUFFER_APPEND_ERROR:return void(t.errorAction||(t.errorAction=this.getLevelSwitchAction(t,t.level)));case B.MEDIA_SOURCE_REQUIRES_RESET:return t.errorAction||(t.errorAction=this.getLevelSwitchAction(t,t.level)),void(t.errorAction.flags|=wr|Cr);case B.INTERNAL_EXCEPTION:case B.BUFFER_APPENDING_ERROR:case B.BUFFER_APPEND_NO_PROGRESS:case B.BUFFER_FULL_ERROR:case B.LEVEL_SWITCH_ERROR:case B.BUFFER_STALLED_ERROR:case B.BUFFER_SEEK_OVER_HOLE:case B.BUFFER_NUDGE_ON_STALL:return void(t.errorAction=Fr())}t.type===U.KEY_SYSTEM_ERROR&&(t.levelRetry=!1,t.errorAction=Fr())}},r.getPlaylistRetryOrSwitchAction=function(e,t){var r=te(this.hls.config.playlistLoadPolicy,e),i=this.playlistError++;if(ne(r,i,Z(e),e.response))return{action:br,flags:kr,retryConfig:r,retryCount:i};var n=this.getLevelSwitchAction(e,t);return r&&(n.retryConfig=r,n.retryCount=i),n},r.getFragRetryOrSwitchAction=function(e){var t=this.hls,r=this.getVariantLevelIndex(e.frag),i=t.levels[r],n=t.config,a=n.fragLoadPolicy,s=n.keyLoadPolicy,o=te(J(e)?s:a,e),l=t.levels.reduce((function(e,t){return e+t.fragmentError}),0);if(i&&(e.details!==B.FRAG_GAP&&i.fragmentError++,!ee(e)&&ne(o,l,Z(e),e.response)))return{action:br,flags:kr,retryConfig:o,retryCount:l};var u=this.getLevelSwitchAction(e,r);return o&&(u.retryConfig=o,u.retryCount=l),u},r.getLevelSwitchAction=function(e,t){var r=this.hls;null==t&&(t=r.loadLevel);var i=this.hls.levels[t];if(i){var n,a,s=e.details;i.loadError++,i.loadErrorTime=self.performance.now(),s===B.BUFFER_APPEND_ERROR&&i.fragmentError++;var o=-1,l=r.levels,u=r.loadLevel,d=r.minAutoLevel,h=r.maxAutoLevel;r.autoLevelEnabled||r.config.preserveManualLevelOnError||(r.loadLevel=-1);for(var f,c=null==(n=e.frag)?void 0:n.type,g=(c===H.AUDIO&&s===B.FRAG_PARSING_ERROR||"audio"===e.sourceBufferName&&Mr(e))&&l.some((function(e){var t=e.audioCodec;return i.audioCodec!==t})),v="video"===e.sourceBufferName&&Mr(e)&&l.some((function(e){var t=e.codecSet,r=e.audioCodec;return i.codecSet!==t&&i.audioCodec===r})),m=null!=(a=e.context)?a:{},p=m.type,y=m.groupId,E=function(){var t=(T+u)%l.length;if(t!==u&&t>=d&&t<=h&&(0===l[t].loadError||se(l[t],r.config.errorPenaltyExpireMs))){var n,a,f=l[t];if(s===B.FRAG_GAP&&c===H.MAIN&&e.frag){var m=l[t].details;if(m){var E=yr(e.frag,m.fragments,e.frag.start);if(null!=E&&E.gap)return 0}}else{if(p===V.AUDIO_TRACK&&f.hasAudioGroup(y)||p===V.SUBTITLE_TRACK&&f.hasSubtitleGroup(y))return 0;if(c===H.AUDIO&&null!=(n=i.audioGroups)&&n.some((function(e){return f.hasAudioGroup(e)}))||c===H.SUBTITLE&&null!=(a=i.subtitleGroups)&&a.some((function(e){return f.hasSubtitleGroup(e)}))||g&&i.audioCodec===f.audioCodec||v&&i.codecSet===f.codecSet||!g&&i.audioCodec!==f.audioCodec)return 0}return o=t,1}},T=l.length;T--&&(0===(f=E())||1!==f););if(o>-1&&r.loadLevel!==o)return e.levelRetry=!0,this.playlistError=0,{action:Lr,flags:kr,nextAutoLevel:o}}return{action:Lr,flags:Dr}},r.onErrorOut=function(e,t){var r,i;switch(null==(r=t.errorAction)?void 0:r.action){case Ir:break;case Lr:this.sendAlternateToPenaltyBox(t),t.errorAction.resolved||t.details===B.FRAG_GAP||t.details===B.PLAYLIST_UNCHANGED_ERROR||xr(t)||(t.fatal=!0)}((null==(i=t.errorAction)?void 0:i.flags)||0)&wr&&this.hls.recoverMediaError(),t.fatal&&this.hls.stopLoad()},r.sendAlternateToPenaltyBox=function(e){var t=this.hls,r=e.errorAction;if(r){var i=r.nextAutoLevel;if(r.flags===kr)this.switchLevel(e,i);else if(r.flags&Cr)for(var n=this.hls.levels,a=n.length;a--;)"SDR"!==n[a].videoRange?(n[a].fragmentError++,n[a].loadError++):void 0===i&&(i=a);else if(r.flags&Pr){var s=this.getVariantLevelIndex(e.frag),o=t.levels[s],l=null==o?void 0:o.attrs["HDCP-LEVEL"];r.hdcpLevel=l;var u="NONE"===l;l&&!u?(t.maxHdcpLevel=zt[zt.indexOf(l)-1],r.resolved=!0,this.warn('Restricting playback to HDCP-LEVEL of "'+t.maxHdcpLevel+'" or lower')):(u&&this.warn("HDCP policy resticted output with HDCP-LEVEL=NONE"),r.flags|=_r)}if(r.flags&_r){var d=e.decryptdata;if(d){for(var h=this.hls.levels,f=h.length,c=f;c--;){var g,v;this.variantHasKey(h[c],d)&&(this.log("Banned key found in level "+c+" ("+h[c].bitrate+'bps) or audio group "'+(null==(g=h[c].audioGroups)?void 0:g.join(","))+'" ('+(null==(v=e.frag)?void 0:v.type)+" fragment) "+xe(d.keyId||[])),h[c].fragmentError++,h[c].loadError++,this.log("Removing level "+c+" with key error ("+e.error+")"),this.hls.removeLevel(c))}var m=e.frag;if(this.hls.levels.length=u.sn))if(l.buffered||l.loaded&&!a){var d=l.range[e];if(d)if(0!==d.time.length){if(d.time.some((function(e){var r=!s.isTimeBuffered(e.startPTS,e.endPTS,t);return r&&s.removeFragment(u),r})),i&&i!==u&&Rt()){var h=i.endPTS,f=u.startPTS;if(h&&f&&l.range.video){var c=f-h;c<0&&c>-.1&&(s.removeFragment(u),s.hls.trigger(G.BUFFER_FLUSHING,{startOffset:h+.004,endOffset:1/0,type:"video"}))}}}else s.removeFragment(u)}else u.type===r&&s.removeFragment(u)}}))},t.detectPartialFragments=function(e){var t=this,r=this.timeRanges,i=e.frag,n=e.part,a=e.id;if(r&&W(i)){var s=Hr(i),o=this.fragments[s];if(!(!o||o.buffered&&i.gap)){var l=!i.relurl;Object.keys(r).forEach((function(e){var s=i.elementaryStreams[e];if(s){var u=r[e],d=l||!0===s.partial;o.range[e]=t.getBufferedTimes(i,n,d,u),t.detectEvictedFragments(e,u,a,i,n)}})),o.loaded=null;var u=Object.keys(o.range);if(u.length){if(this.bufferedEnd(o,i),Vr(o)||this.removeParts(i.sn-1,i.type),!n){var d=Math.min(.004,i.duration);u.some((function(e){var r,n=null==(r=o.range[e])?void 0:r.time,a=0===n.length||1===n.length&&n[0].endPTS-n[0].startPTS=e})))},t.fragBuffered=function(e,t){var r=Hr(e),i=this.fragments[r];return!i&&t&&(i=this.fragments[r]={body:e,appendedPTS:null,loaded:null,buffered:!1,range:Object.create(null)},e.gap&&(this.hasGaps=!0)),i&&(i.loaded=null,this.bufferedEnd(i,e)),i},t.getBufferedTimes=function(e,t,r,i){for(var n={time:[],partial:r},a=e.start,s=e.end,o=e.minEndPTS||s,l=e.maxStartPTS||a,u=0;u=d&&o<=h){n.time.push({startPTS:Math.max(a,i.start(u)),endPTS:Math.min(s,i.end(u))});break}if(ad){var f=Math.max(a,i.start(u)),c=Math.min(s,i.end(u));c>f&&(n.partial=!0,n.time.push({startPTS:f,endPTS:c}))}else if(s<=d)break}return n},t.getPartialFragment=function(e){var t,r,i,n=null,a=0,s=this.bufferPadding,o=this.fragments;return Object.keys(o).forEach((function(l){var u=o[l];u&&Vr(u)&&(r=u.body.start-s,i=u.body.end+s,e>=r&&e<=i&&(t=Math.min(e-r,i-e),a<=t&&(n=u.body,a=t)))})),n},t.isEndListAppended=function(e){var t=this.endListFragments[e];return void 0!==t&&(t.buffered||Vr(t))},t.getState=function(e){var t=Hr(e),r=this.fragments[t];return r?r.buffered?Vr(r)?Br:Gr:Ur:Nr},t.isTimeBuffered=function(e,t,r){for(var i,n,a=0;a=i&&t<=n)return!0;if(t<=i)return!1}return!1},t.onManifestLoading=function(){this.removeAllFragments()},t.onFragLoaded=function(e,t){if(W(t.frag)&&!t.frag.bitrateTest){var r=t.frag,i=t.part?null:t,n=Hr(r);this.fragments[n]={body:r,appendedPTS:null,loaded:i,buffered:!1,range:Object.create(null)}}},t.onBufferAppended=function(e,t){var r=t.frag,i=t.part,n=t.timeRanges;if(W(r)){var a=r.type;if(i){var s=this.activePartLists[a];s||(this.activePartLists[a]=s=[]),s.push(i)}this.timeRanges=n}},t.onFragBuffered=function(e,t){this.detectPartialFragments(t)},t.hasFragment=function(e){var t=Hr(e);return!!this.fragments[t]},t.hasFragments=function(e){var t=this.fragments,r=Object.keys(t);if(!e)return r.length>0;for(var i=r.length;i--;){var n=t[r[i]];if((null==n?void 0:n.body.type)===e)return!0}return!1},t.hasParts=function(e){var t;return!(null==(t=this.activePartLists[e])||!t.length)},t.getBackBufferEvictionEnd=function(e,t,r){for(var i=this.fragments,n=0,a=0,s=Object.keys(i),o=0;o=r))return a}}return a>0?a:0},t.removeFragmentsInRange=function(e,t,r,i,n){var a=this;i&&!this.hasGaps||Object.keys(this.fragments).forEach((function(s){var o=a.fragments[s];if(o){var l=o.body;l.type!==r||i&&!l.gap||l.starte&&(o.buffered||n)&&a.removeFragment(l)}}))},t.removeFragment=function(e){var t=Hr(e);e.clearElementaryStreamInfo();var r=this.activePartLists[e.type];if(r){var i=e.sn;this.activePartLists[e.type]=Yr(r,(function(e){return e.fragment.sn!==i}))}delete this.fragments[t],e.endList&&delete this.endListFragments[e.type]},t.removeAllFragments=function(){var e;this.fragments=Object.create(null),this.endListFragments=Object.create(null),this.activePartLists=Object.create(null),this.hasGaps=!1;var t=null==(e=this.hls)||null==(e=e.latestLevelDetails)?void 0:e.partList;t&&t.forEach((function(e){return e.clearElementaryStreamInfo()}))},e}();function Vr(e){var t,r,i,n;return e.buffered&&!!(e.body.gap||null!=(t=e.range.video)&&t.partial||null!=(r=e.range.audio)&&r.partial||null!=(i=e.range.audiovideo)&&i.partial||null!=(n=e.range.subs)&&n.partial)}function Hr(e){return e.type+"_"+e.level+"_"+e.sn}function Yr(e,t){return e.filter((function(e){var r=t(e);return r||e.clearElementaryStreamInfo(),r}))}var jr=0,Wr=1,qr=function(){function e(e,t,r){this.subtle=void 0,this.aesIV=void 0,this.aesMode=void 0,this.subtle=e,this.aesIV=t,this.aesMode=r}return e.prototype.decrypt=function(e,t){switch(this.aesMode){case jr:return this.subtle.decrypt({name:"AES-CBC",iv:this.aesIV},t,e);case Wr:return this.subtle.decrypt({name:"AES-CTR",counter:this.aesIV,length:64},t,e);default:throw new Error("[AESCrypto] invalid aes mode "+this.aesMode)}},e}(),Xr=function(){function e(){this.rcon=[0,1,2,4,8,16,32,64,128,27,54],this.subMix=[new Uint32Array(256),new Uint32Array(256),new Uint32Array(256),new Uint32Array(256)],this.invSubMix=[new Uint32Array(256),new Uint32Array(256),new Uint32Array(256),new Uint32Array(256)],this.sBox=new Uint32Array(256),this.invSBox=new Uint32Array(256),this.key=new Uint32Array(0),this.ksRows=0,this.keySize=0,this.keySchedule=void 0,this.invKeySchedule=void 0,this.initTable()}var t=e.prototype;return t.uint8ArrayToUint32Array_=function(e){for(var t=new DataView(e),r=new Uint32Array(4),i=0;i<4;i++)r[i]=t.getUint32(4*i);return r},t.initTable=function(){var e=this.sBox,t=this.invSBox,r=this.subMix,i=r[0],n=r[1],a=r[2],s=r[3],o=this.invSubMix,l=o[0],u=o[1],d=o[2],h=o[3],f=new Uint32Array(256),c=0,g=0,v=0;for(v=0;v<256;v++)f[v]=v<128?v<<1:v<<1^283;for(v=0;v<256;v++){var m=g^g<<1^g<<2^g<<3^g<<4;m=m>>>8^255&m^99,e[c]=m,t[m]=c;var p=f[c],y=f[p],E=f[y],T=257*f[m]^16843008*m;i[c]=T<<24|T>>>8,n[c]=T<<16|T>>>16,a[c]=T<<8|T>>>24,s[c]=T,T=16843009*E^65537*y^257*p^16843008*c,l[m]=T<<24|T>>>8,u[m]=T<<16|T>>>16,d[m]=T<<8|T>>>24,h[m]=T,c?(c=p^f[f[f[E^p]]],g^=f[f[g]]):c=g=1}},t.expandKey=function(e){for(var t=this.uint8ArrayToUint32Array_(e),r=!0,i=0;i=r.length?r:r.slice(s.start,s.end):(n=(i=r).byteLength,(a=n&&new DataView(i.buffer).getUint8(n-1))?i.slice(0,n-a):i)},t.reset=function(){this.currentResult=null,this.currentIV=null,this.remainderData=null,this.softwareDecrypter&&(this.softwareDecrypter=null)},t.decrypt=function(e,t,r,i,n){var a=this;return this.decryptRange=n,this.useSoftware||n?new Promise((function(n,s){var o=ArrayBuffer.isView(e)?e:new Uint8Array(e);a.softwareDecrypt(o,t,r,i);var l=a.flush();l?n(l.buffer):s(new Error("[softwareDecrypt] Failed to decrypt data"))})):this.webCryptoDecrypt(new Uint8Array(e),t,r,i)},t.softwareDecrypt=function(e,t,r,i){var n=this.currentIV,a=this.currentResult,s=this.remainderData;if(i!==jr||16!==t.byteLength)return me.warn("SoftwareDecrypt: can only handle AES-128-CBC"),null;this.logOnce("JS AES decrypt"),s&&(e=ht(s,e),this.remainderData=null);var o=this.getValidChunk(e);if(!o.length)return null;n&&(r=n);var l=this.softwareDecrypter;l||(l=this.softwareDecrypter=new Xr),l.expandKey(t);var u=a;return this.currentResult=l.decrypt(o.buffer,0,r),this.currentIV=o.slice(-16).buffer,u||null},t.webCryptoDecrypt=function(e,t,r,i){var n=this;if(this.key!==t||!this.fastAesKey){if(!this.subtle)return Promise.resolve(this.onWebCryptoError(e,t,r,i));this.key=t,this.fastAesKey=new zr(this.subtle,t,i)}return this.fastAesKey.expandKey().then((function(t){return n.subtle?(n.logOnce("WebCrypto AES decrypt"),new qr(n.subtle,new Uint8Array(r),i).decrypt(e.buffer,t)):Promise.reject(new Error("web crypto not initialized"))})).catch((function(a){return me.warn("[decrypter]: WebCrypto Error, disable WebCrypto API, "+a.name+": "+a.message),n.onWebCryptoError(e,t,r,i)}))},t.onWebCryptoError=function(e,t,r,i){var n=this.enableSoftwareAES;if(n){this.useSoftware=!0,this.logEnabled=!0,this.softwareDecrypt(e,t,r,i);var a=this.flush();if(a)return a.buffer}throw new Error("WebCrypto"+(n?" and softwareDecrypt":"")+": failed to decrypt data")},t.getValidChunk=function(e){var t=e,r=e.length-e.length%16;return r!==e.length&&(t=e.slice(0,r),this.remainderData=e.slice(r)),t},t.logOnce=function(e){this.logEnabled&&(me.log("[decrypter]: "+e),this.logEnabled=!1)},e}(),$r=Math.pow(2,17),Zr=function(){function e(e){this.config=void 0,this.loader=null,this.partLoadTimeout=-1,this.config=e}var t=e.prototype;return t.destroy=function(){this.loader&&(this.loader.destroy(),this.loader=null)},t.abort=function(){this.loader&&this.loader.abort()},t.load=function(e,t,r,i){var n=this,a=e.url;if(!a)return Promise.reject(new ri({type:U.NETWORK_ERROR,details:B.FRAG_LOAD_ERROR,fatal:!1,frag:e,error:new Error("Fragment does not have a "+(a?"part list":"url")),networkDetails:null}));this.abort();var s=this.config,o=s.fLoader,l=s.loader;return new Promise((function(u,d){if(n.loader&&n.loader.destroy(),e.gap){if(e.tagList.some((function(e){return"GAP"===e[0]})))return void d(ti(e));e.gap=!1}var h=n.loader=o?new o(s):new l(s),f=Jr(e,null,t);e.loader=h;var g=ie(s.fragLoadPolicy.default),v={loadPolicy:g,timeout:g.maxLoadTimeMs,maxRetry:0,retryDelay:0,maxRetryDelay:0,highWaterMark:"initSegment"===e.sn?1/0:$r};h.stats.retry=e.stats.retry,e.stats=h.stats;var m={onSuccess:function(t,r,i,a){n.resetLoader(e,h);var s=t.data;i.resetIV&&e.decryptdata&&(e.decryptdata.iv=new Uint8Array(s.slice(0,16)),s=s.slice(16)),u({frag:e,part:null,payload:s,networkDetails:a})},onError:function(t,r,i,s){n.resetLoader(e,h),d(new ri({type:U.NETWORK_ERROR,details:B.FRAG_LOAD_ERROR,fatal:!1,frag:e,response:c({url:a,data:void 0},t),error:new Error("HTTP Error "+t.code+" "+t.text),networkDetails:i,stats:s}))},onAbort:function(t,r,i){n.resetLoader(e,h),d(new ri({type:U.NETWORK_ERROR,details:B.INTERNAL_ABORTED,fatal:!1,frag:e,error:new Error("Aborted"),networkDetails:i,stats:t}))},onTimeout:function(t,r,i){n.resetLoader(e,h),d(new ri({type:U.NETWORK_ERROR,details:B.FRAG_LOAD_TIMEOUT,fatal:!1,frag:e,error:new Error("Timeout after "+v.timeout+"ms"),networkDetails:i,stats:t}))}};if(r){var p=n.gateProgress(r,i);m.onProgress=function(t,r,i,n){return p({frag:e,part:null,payload:i,networkDetails:n})}}h.load(f,v,m)}))},t.gateProgress=function(e,t){var r=e;if(t){var i=[],n=!1;t.then((function(){n=!0,i.forEach((function(t){return e(t)})),i.length=0})).catch((function(){})),r=function(t){n?e(t):i.push(t)}}return r},t.loadPart=function(e,t,r){var i=this;this.abort();var n=this.config,a=n.fLoader,s=n.loader;return new Promise((function(o,l){if(i.loader&&i.loader.destroy(),e.gap||t.gap)l(ti(e,t));else{var u=i.loader=a?new a(n):new s(n),d=Jr(e,t);e.loader=u;var h=ie(n.fragLoadPolicy.default),f={loadPolicy:h,timeout:h.maxLoadTimeMs,maxRetry:0,retryDelay:0,maxRetryDelay:0,highWaterMark:$r};t.stats=u.stats,u.load(d,f,{onSuccess:function(n,a,s,l){i.resetLoader(e,u),i.updateStatsFromPart(e,t);var d={frag:e,part:t,payload:n.data,networkDetails:l};r(d),o(d)},onError:function(r,n,a,s){i.resetLoader(e,u),l(new ri({type:U.NETWORK_ERROR,details:B.FRAG_LOAD_ERROR,fatal:!1,frag:e,part:t,response:c({url:d.url,data:void 0},r),error:new Error("HTTP Error "+r.code+" "+r.text),networkDetails:a,stats:s}))},onAbort:function(r,n,a){e.stats.aborted=t.stats.aborted,i.resetLoader(e,u),l(new ri({type:U.NETWORK_ERROR,details:B.INTERNAL_ABORTED,fatal:!1,frag:e,part:t,error:new Error("Aborted"),networkDetails:a,stats:r}))},onTimeout:function(r,n,a){i.resetLoader(e,u),l(new ri({type:U.NETWORK_ERROR,details:B.FRAG_LOAD_TIMEOUT,fatal:!1,frag:e,part:t,error:new Error("Timeout after "+f.timeout+"ms"),networkDetails:a,stats:r}))}})}}))},t.updateStatsFromPart=function(e,t){var r=e.stats,i=t.stats,n=i.total;if(r.loaded+=i.loaded,n){var a=Math.round(e.duration/t.duration),s=Math.min(Math.round(r.loaded/n),a),o=(a-s)*Math.round(r.loaded/s);r.total=r.loaded+o}else r.total=Math.max(r.loaded,r.total);var l=r.loading,u=i.loading;l.start?l.first+=u.first-u.start:(l.start=u.start,l.first=u.first),l.end=u.end},t.resetLoader=function(e,t){e.loader=null,e.url=null,this.loader===t&&(self.clearTimeout(this.partLoadTimeout),this.loader=null),t.destroy()},e}();function Jr(e,t,r){void 0===t&&(t=null);var i=t||e,n={type:V.MEDIA_FRAGMENT,frag:e,part:t,responseType:"arraybuffer",url:i.url,headers:{},rangeStart:0,rangeEnd:0},a=ei(e,t,r);return a.resetIV&&(n.resetIV=!0),a.byteRange&&(n.rangeStart=a.byteRange.start,n.rangeEnd=a.byteRange.end),n}function ei(e,t,r){void 0===t&&(t=null);var i,n,a={},s=t||e,o=s.byteRangeStartOffset,l=s.byteRangeEndOffset;if(x(o)&&x(l))if("initSegment"!==e.sn&&!r||"AES-128"!==(n=null==(i=e.decryptdata)?void 0:i.method)&&"AES-256"!==n)a.byteRange={start:o,end:l};else{var u=16*Math.floor(o/16),d=16*Math.ceil(l/16);a.decryptRange={start:o-u,end:l-u},u>=16&&(a.resetIV=!0,u-=16),a.byteRange={start:u,end:d}}return a}function ti(e,t){var r=new Error("GAP "+(e.gap?"tag":"attribute")+" found"),i={type:U.MEDIA_ERROR,details:B.FRAG_GAP,fatal:!1,frag:e,error:r,networkDetails:null};return t&&(i.part=t),(t||e).stats.aborted=!0,new ri(i)}var ri=function(e){function t(t){var r;return(r=e.call(this,t.error.message)||this).data=void 0,r.data=t,r}return d(t,e),t}(E(Error)),ii=function(e){function t(t,r){var i;return(i=e.call(this,t,r)||this)._boundTick=void 0,i._tickTimer=null,i._tickInterval=null,i._tickCallCount=0,i._boundTick=i.tick.bind(i),i}d(t,e);var r=t.prototype;return r.destroy=function(){this.onHandlerDestroying(),this.onHandlerDestroyed()},r.onHandlerDestroying=function(){this.clearNextTick(),this.clearInterval()},r.onHandlerDestroyed=function(){},r.hasInterval=function(){return!!this._tickInterval},r.hasNextTick=function(){return!!this._tickTimer},r.setInterval=function(e){return!this._tickInterval&&(this._tickCallCount=0,this._tickInterval=self.setInterval(this._boundTick,e),!0)},r.clearInterval=function(){return!!this._tickInterval&&(self.clearInterval(this._tickInterval),this._tickInterval=null,!0)},r.clearNextTick=function(){return!!this._tickTimer&&(self.clearTimeout(this._tickTimer),this._tickTimer=null,!0)},r.tick=function(){this._tickCallCount++,1===this._tickCallCount?(this.doTick(),this._tickCallCount>1&&this.tickImmediate(),this._tickCallCount=0):(this.log("possible exception thrown in task-loop ("+this.constructor.name+".doTick)"),this._tickCallCount=0)},r.tickImmediate=function(){this.clearNextTick(),this._tickTimer=self.setTimeout(this._boundTick,0)},r.doTick=function(){},t}(ue),ni=function(e,t,r,i,n,a,s,o,l){void 0===i&&(i=0),void 0===n&&(n=-1),void 0===a&&(a=!1),this.level=void 0,this.sn=void 0,this.part=void 0,this.id=void 0,this.size=void 0,this.partial=void 0,this.iframe=void 0,this.duration=void 0,this.decryptRange=void 0,this.transmuxing={start:0,executeStart:0,executeEnd:0,end:0},this.buffering={audio:{start:0,executeStart:0,executeEnd:0,end:0},video:{start:0,executeStart:0,executeEnd:0,end:0},audiovideo:{start:0,executeStart:0,executeEnd:0,end:0}},this.level=e,this.sn=t,this.id=r,this.size=i,this.part=n,this.partial=a,this.duration=s||0,this.decryptRange=l,this.iframe=o||!1},ai={length:0,start:function(){return 0},end:function(){return 0}},si=function(){function e(){}return e.isBuffered=function(t,r){if(t)for(var i=e.getBuffered(t),n=i.length;n--;)if(r>=i.start(n)&&r<=i.end(n))return!0;return!1},e.bufferedRanges=function(t){if(t){var r=e.getBuffered(t);return e.timeRangesToArray(r)}return[]},e.timeRangesToArray=function(e){for(var t=[],r=0;r1&&e.sort((function(e,t){return e.start-t.start||t.end-e.end}));var i=-1,n=[];if(r)for(var a=0;a=e[a].start&&t<=e[a].end&&(i=a);var s=n.length;if(s){var o=n[s-1].end;e[a].start-oo&&(n[s-1].end=e[a].end):n.push(e[a])}else n.push(e[a])}else n=e;for(var l,u=0,d=t,h=t,f=0;f=c&&t<=g&&(i=f),t+r>=c&&tNumber.MAX_SAFE_INTEGER?1/0:t},t.hexadecimalInteger=function(e){if(this[e]){var t=(this[e]||"0x").slice(2);t=(1&t.length?"0":"")+t;for(var r=new Uint8Array(t.length/2),i=0;iNumber.MAX_SAFE_INTEGER?1/0:t},t.decimalFloatingPoint=function(e){return parseFloat(this[e])},t.optionalFloat=function(e,t){var r=this[e];return r?parseFloat(r):t},t.enumeratedString=function(e){return this[e]},t.enumeratedStringList=function(e,t){var r=this[e];return(r?r.split(/[ ,]+/):[]).reduce((function(e,t){return e[t.toLowerCase()]=!0,e}),t)},t.bool=function(e){return"YES"===this[e]},t.decimalResolution=function(e){var t=fi.exec(this[e]);if(null!==t)return{width:parseInt(t[1],10),height:parseInt(t[2],10)}},e.parseAttrList=function(e,t){var r,i={};for(ci.lastIndex=0;null!==(r=ci.exec(e));){var n=r[1].trim(),a=r[2],s=0===a.indexOf('"')&&a.lastIndexOf('"')===a.length-1,o=!1;if(s)a=a.slice(1,-1);else switch(n){case"IV":case"SCTE35-CMD":case"SCTE35-IN":case"SCTE35-OUT":o=!0}if(t&&(s||o))a=ui(t,a);else if(!o&&!s)switch(n){case"CLOSED-CAPTIONS":if("NONE"===a)break;case"ALLOWED-CPC":case"CLASS":case"ASSOC-LANGUAGE":case"AUDIO":case"BYTERANGE":case"CHANNELS":case"CHARACTERISTICS":case"CODECS":case"DATA-ID":case"END-DATE":case"GROUP-ID":case"ID":case"IMPORT":case"INSTREAM-ID":case"KEYFORMAT":case"KEYFORMATVERSIONS":case"LANGUAGE":case"NAME":case"PATHWAY-ID":case"QUERYPARAM":case"RECENTLY-REMOVED-DATERANGES":case"SERVER-URI":case"STABLE-RENDITION-ID":case"STABLE-VARIANT-ID":case"START-DATE":case"SUBTITLES":case"SUPPLEMENTAL-CODECS":case"URI":case"VALUE":case"VIDEO":case"X-ASSET-LIST":case"X-ASSET-URI":me.warn(e+": attribute "+n+" is missing quotes")}i[n]=a}return i},a(e,[{key:"clientAttrs",get:function(){return Object.keys(this).filter((function(e){return"X-"===e.substring(0,2)}))}}])}();function vi(e){return"SCTE35-OUT"===e||"SCTE35-IN"===e||"SCTE35-CMD"===e}var mi=function(){return a((function(e,t,r){var i;if(void 0===r&&(r=0),this.attr=void 0,this.tagAnchor=void 0,this.tagOrder=void 0,this._startDate=void 0,this._endDate=void 0,this._dateAtEnd=void 0,this._cue=void 0,this._badValueForSameId=void 0,this.tagAnchor=(null==t?void 0:t.tagAnchor)||null,this.tagOrder=null!=(i=null==t?void 0:t.tagOrder)?i:r,t){var n=t.attr;for(var a in n)if(Object.prototype.hasOwnProperty.call(e,a)&&e[a]!==n[a]){me.warn('DATERANGE tag attribute: "'+a+'" does not match for tags with ID: "'+e.ID+'"'),this._badValueForSameId=a;break}e=l(new gi({}),n,e)}if(this.attr=e,t?(this._startDate=t._startDate,this._cue=t._cue,this._endDate=t._endDate,this._dateAtEnd=t._dateAtEnd):this._startDate=new Date(e["START-DATE"]),"END-DATE"in e){var s=(null==t?void 0:t.endDate)||new Date(e["END-DATE"]);x(s.getTime())&&(this._endDate=s)}}),[{key:"id",get:function(){return this.attr.ID}},{key:"class",get:function(){return this.attr.CLASS}},{key:"cue",get:function(){var e=this._cue;return void 0===e?this._cue=this.attr.enumeratedStringList(this.attr.CUE?"CUE":"X-CUE",{pre:!1,post:!1,once:!1}):e}},{key:"startTime",get:function(){var e=this.tagAnchor;return null===e||null===e.programDateTime?(me.warn('Expected tagAnchor Fragment with PDT set for DateRange "'+this.id+'": '+e),NaN):e.start+(this.startDate.getTime()-e.programDateTime)/1e3}},{key:"startDate",get:function(){return this._startDate}},{key:"endDate",get:function(){var e=this._endDate||this._dateAtEnd;if(e)return e;var t=this.duration;return null!==t?this._dateAtEnd=new Date(this._startDate.getTime()+1e3*t):null}},{key:"duration",get:function(){if("DURATION"in this.attr){var e=this.attr.decimalFloatingPoint("DURATION");if(x(e))return e}else if(this._endDate)return(this._endDate.getTime()-this._startDate.getTime())/1e3;return null}},{key:"plannedDuration",get:function(){return"PLANNED-DURATION"in this.attr?this.attr.decimalFloatingPoint("PLANNED-DURATION"):null}},{key:"endOnNext",get:function(){return this.attr.bool("END-ON-NEXT")}},{key:"isInterstitial",get:function(){return"com.apple.hls.interstitial"===this.class}},{key:"isValid",get:function(){return!!this.id&&!this._badValueForSameId&&x(this.startDate.getTime())&&(null===this.duration||this.duration>=0)&&(!this.endOnNext||!!this.class)&&(!this.attr.CUE||!this.cue.pre&&!this.cue.post||this.cue.pre!==this.cue.post)&&(!this.isInterstitial||"X-ASSET-URI"in this.attr||"X-ASSET-LIST"in this.attr)}},{key:"invalidReason",get:function(){return this.id?this._badValueForSameId?"Date range "+this._badValueForSameId+" mismatch":x(this.startDate.getTime())?!this.isInterstitial||"X-ASSET-URI"in this.attr||"X-ASSET-LIST"in this.attr?this.isValid?null:"Unknown (check DURATION(|END|NEXT), CLASS, and CUE)":"Interstitial Date range missing X-ASSET-(LIST|URI)":"Date range invalid start date":"Missing ID"}}])}(),pi=function(){function e(e){this.PTSKnown=!1,this.alignedSliding=!1,this.averagetargetduration=void 0,this.endCC=0,this.endSN=0,this.fragments=void 0,this.fragmentHint=void 0,this.partList=null,this.dateRanges=void 0,this.dateRangeTagCount=0,this.live=!0,this.iframesOnly=!1,this.requestScheduled=-1,this.ageHeader=0,this.advancedDateTime=void 0,this.updated=!0,this.advanced=!0,this.misses=0,this.startCC=0,this.startSN=0,this.startTimeOffset=null,this.targetduration=0,this.totalduration=0,this.type=null,this.url=void 0,this.m3u8="",this.version=null,this.canBlockReload=!1,this.canSkipUntil=0,this.canSkipDateRanges=!1,this.skippedSegments=0,this.recentlyRemovedDateranges=void 0,this.partHoldBack=0,this.holdBack=0,this.partTarget=0,this.preloadHint=void 0,this.renditionReports=void 0,this.tuneInGoal=0,this.deltaUpdateFailed=void 0,this.driftStartTime=0,this.driftEndTime=0,this.driftStart=0,this.driftEnd=0,this.encryptedFragments=void 0,this.playlistParsingError=null,this.variableList=null,this.hasVariableRefs=!1,this.appliedTimelineOffset=void 0,this.fragments=[],this.encryptedFragments=[],this.dateRanges={},this.url=e}var t=e.prototype;return t.reloaded=function(e){if(!e)return this.advanced=!0,void(this.updated=!0);var t=this.lastPartSn-e.lastPartSn,r=this.lastPartIndex-e.lastPartIndex;this.updated=this.endSN!==e.endSN||!!r||!!t||!this.live,this.advanced=this.endSN>e.endSN||t>0||0===t&&r>0,this.updated||this.advanced?this.misses=0:this.misses=e.misses+1},t.hasKey=function(e){return this.encryptedFragments.some((function(t){var r=t.decryptdata;return r||(t.setKeyFormat(e.keyFormat),r=t.decryptdata),!!r&&e.matches(r)}))},a(e,[{key:"hasProgramDateTime",get:function(){return!!this.fragments.length&&x(this.fragments[this.fragments.length-1].programDateTime)}},{key:"levelTargetDuration",get:function(){return this.averagetargetduration||this.targetduration||10}},{key:"drift",get:function(){var e=this.driftEndTime-this.driftStartTime;return e>0?1e3*(this.driftEnd-this.driftStart)/e:1}},{key:"edge",get:function(){return this.partEnd||this.fragmentEnd}},{key:"partEnd",get:function(){var e;return null!=(e=this.partList)&&e.length?this.partList[this.partList.length-1].end:this.fragmentEnd}},{key:"fragmentEnd",get:function(){return this.fragments.length?this.fragments[this.fragments.length-1].end:0}},{key:"fragmentStart",get:function(){return this.fragments.length?this.fragments[0].start:0}},{key:"age",get:function(){return this.advancedDateTime?Math.max(Date.now()-this.advancedDateTime,0)/1e3:0}},{key:"lastPartIndex",get:function(){var e;return null!=(e=this.partList)&&e.length?this.partList[this.partList.length-1].index:-1}},{key:"maxPartIndex",get:function(){var e=this.partList;if(e){var t=this.lastPartIndex;if(-1!==t){for(var r=e.length;r--;)if(e[r].index>t)return e[r].index;return t}}return 0}},{key:"lastPartSn",get:function(){var e;return null!=(e=this.partList)&&e.length?this.partList[this.partList.length-1].fragment.sn:this.endSN}},{key:"expired",get:function(){if(this.live&&this.age&&this.misses<3){var e=this.partEnd-this.fragmentStart;return this.age>Math.max(e,this.totalduration)+this.levelTargetDuration}return!1}}])}();function yi(e,t){return e.length===t.length&&!e.some((function(e,r){return e!==t[r]}))}function Ei(e,t){return!e&&!t||!(!e||!t)&&yi(e,t)}function Ti(e){return"AES-128"===e||"AES-256"===e||"AES-256-CTR"===e}function Si(e){switch(e){case"AES-128":case"AES-256":return jr;case"AES-256-CTR":return Wr;default:throw new Error("invalid full segment method "+e)}}function Ai(e){return Uint8Array.from(unescape(encodeURIComponent(e)),(function(e){return e.charCodeAt(0)}))}function Ii(e){var t=function(e,t,r){var i=e[t];e[t]=e[r],e[r]=i};t(e,0,3),t(e,1,2),t(e,4,5),t(e,6,7)}function Li(e){var t,r,i=e.split(":"),n=null;if("data"===i[0]&&2===i.length){var a=i[1].split(";"),s=a[a.length-1].split(",");if(2===s.length){var o="base64"===s[0],l=s[1];o?(a.splice(-1,1),n=Ne(l)):(t=Ai(l).subarray(0,16),(r=new Uint8Array(16)).set(t,16-t.length),n=r)}}return n}var Ri="undefined"!=typeof self?self:void 0,bi={CLEARKEY:"org.w3.clearkey",FAIRPLAY:"com.apple.fps",PLAYREADY:"com.microsoft.playready",WIDEVINE:"com.widevine.alpha"},ki="org.w3.clearkey",Di="com.apple.streamingkeydelivery",Pi="com.microsoft.playready",_i="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed";function Ci(e){switch(e){case Di:return bi.FAIRPLAY;case Pi:return bi.PLAYREADY;case _i:return bi.WIDEVINE;case ki:return bi.CLEARKEY}}var wi="edef8ba979d64acea3c827dcd51d21ed";function Oi(e){switch(e){case bi.FAIRPLAY:return Di;case bi.PLAYREADY:return Pi;case bi.WIDEVINE:return _i;case bi.CLEARKEY:return ki}}function Mi(e){var t=e.drmSystems,r=e.widevineLicenseUrl,i=t?[bi.FAIRPLAY,bi.WIDEVINE,bi.PLAYREADY,bi.CLEARKEY].filter((function(e){return!!t[e]})):[];return!i[bi.WIDEVINE]&&r&&i.push(bi.WIDEVINE),i}var xi,Fi=null!=Ri&&null!=(xi=Ri.navigator)&&xi.requestMediaKeySystemAccess?self.navigator.requestMediaKeySystemAccess.bind(self.navigator):null;function Ni(e){var t=Ue(new Uint16Array(e.buffer,e.byteOffset,e.byteLength/2)),r=t.substring(t.indexOf("<"),t.length),i=(new DOMParser).parseFromString(r,"text/xml").getElementsByTagName("KID")[0];if(i){var n=i.childNodes[0]?i.childNodes[0].nodeValue:i.getAttribute("VALUE");if(n){var a=Ne(n).subarray(0,16);return Ii(a),a}}return null}var Ui={},Bi=function(){function e(e,t,r,i,n,a){void 0===i&&(i=[1]),void 0===n&&(n=null),this.uri=void 0,this.method=void 0,this.keyFormat=void 0,this.keyFormatVersions=void 0,this.encrypted=void 0,this.isCommonEncryption=void 0,this.iv=null,this.key=null,this.keyId=null,this.pssh=null,this.scheme=void 0,this.method=e,this.uri=t,this.keyFormat=r,this.keyFormatVersions=i,this.iv=n,this.encrypted=!!e&&"NONE"!==e,this.isCommonEncryption=this.encrypted&&!Ti(e),null!=a&&a.startsWith("0x")&&(this.keyId=new Uint8Array(Fe(a)))}e.clearKeyUriToKeyIdMap=function(){Ui={}},e.setKeyIdForUri=function(e,t){Ui[e]=t},e.addKeyIdForUri=function(e){var t=Object.keys(Ui).length%Number.MAX_SAFE_INTEGER,r=new Uint8Array(16);return new DataView(r.buffer,12,4).setUint32(0,t),Ui[e]=r,r};var t=e.prototype;return t.matches=function(e){return e.uri===this.uri&&e.method===this.method&&e.encrypted===this.encrypted&&e.keyFormat===this.keyFormat&&yi(e.keyFormatVersions,this.keyFormatVersions)&&Ei(e.iv,this.iv)&&Ei(e.keyId,this.keyId)},t.isSupported=function(){if(this.method){if(Ti(this.method)||"NONE"===this.method)return!0;if("identity"===this.keyFormat)return"SAMPLE-AES"===this.method;switch(this.keyFormat){case Di:case _i:case Pi:case ki:return-1!==["SAMPLE-AES","SAMPLE-AES-CENC","SAMPLE-AES-CTR"].indexOf(this.method)}}return!1},t.getDecryptData=function(t,r){var i;if(!this.encrypted||!this.uri)return null;if(Ti(this.method)){var n=this.iv;return n||("number"!=typeof t&&(me.warn('missing IV for initialization segment with method="'+this.method+'" - compliance issue'),t=0),n=function(e){for(var t=new Uint8Array(16),r=12;r<16;r++)t[r]=e>>8*(15-r)&255;return t}(t)),new e(this.method,this.uri,"identity",this.keyFormatVersions,n)}if(this.keyId){var a=Ui[this.uri];if(a&&!yi(this.keyId,a)&&e.setKeyIdForUri(this.uri,this.keyId),this.pssh)return this}var s,o=Li(this.uri);if(o)switch(this.keyFormat){case _i:if(this.pssh=o,!this.keyId){var l=pt(o.buffer).filter((function(e){return e.systemId===wi}));if(l.length){var u,d=l[0];this.keyId=null!=(u=d.kids)&&u.length?d.kids[0]:null}}this.keyId||(this.keyId=Gi(r));break;case Pi:var h=new Uint8Array([154,4,240,121,152,64,66,134,171,146,230,91,224,136,95,149]);this.pssh=mt(h,0,o),this.keyId=Ni(o);break;default:var f=o.subarray(0,16);if(16!==f.length){var c=new Uint8Array(16);c.set(f,16-f.length),f=c}this.keyId=f}return 16!==(null==(i=this.keyId)?void 0:i.byteLength)&&(s=function(e){var t=null==e?void 0:e[_i];return t?t.keyId:null}(r),s||(s=Gi(r))||(s=Ui[this.uri]),s&&(this.keyId=s,e.setKeyIdForUri(this.uri,s))),this},e}();function Gi(e){var t=null==e?void 0:e[Pi];if(t){var r=Li(t.uri);if(r)return Ni(r)}return null}var Ki=/#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-(I-FRAME-STREAM-INF|SESSION-DATA|SESSION-KEY|DEFINE|CONTENT-STEERING|START):([^\r\n]*)(?:[\r\n]+|$)/g,Vi=/#EXT-X-MEDIA:(.*)/g,Hi=/^#EXT(?:INF|-X-TARGETDURATION):/m,Yi=new RegExp([/#EXTINF:\s*(\d*(?:\.\d+)?)(?:,(.*)\s+)?/.source,/(?!#) *(\S[^\r\n]*)/.source,/#.*/.source].join("|"),"g"),ji=new RegExp([/#EXT-X-(PROGRAM-DATE-TIME|BYTERANGE|DATERANGE|DEFINE|KEY|MAP|PART|PART-INF|PLAYLIST-TYPE|PRELOAD-HINT|RENDITION-REPORT|SERVER-CONTROL|SKIP|START):(.+)/.source,/#EXT-X-(BITRATE|DISCONTINUITY-SEQUENCE|MEDIA-SEQUENCE|TARGETDURATION|VERSION): *(\d+)/.source,/#EXT-X-(DISCONTINUITY|ENDLIST|GAP|INDEPENDENT-SEGMENTS|I-FRAMES-ONLY)/.source,/(#)([^:]*):(.*)/.source,/(#)(.*)(?:.*)\r?\n?/.source].join("|")),Wi=function(){function e(){}return e.findGroup=function(e,t){for(var r=0;r0&&a.length0&&h.bool("CAN-SKIP-DATERANGES"),c.partHoldBack=h.optionalFloat("PART-HOLD-BACK",0),c.holdBack=h.optionalFloat("HOLD-BACK",0);break;case"PART-INF":c.partTarget&&nn(c,O,o);var Z=new gi(M);c.partTarget=Z.decimalFloatingPoint("PART-TARGET");break;case"PART":var J=c.partList;J||(J=c.partList=[]);var ee=y>0?J[J.length-1]:void 0,te=y++,re=new gi(M,c),ie=new Q(re,I,f,te,ee);J.push(ie),I.duration+=ie.duration;break;case"PRELOAD-HINT":var ne=new gi(M,c);c.preloadHint=ne;break;case"RENDITION-REPORT":var ae=new gi(M,c);c.renditionReports=c.renditionReports||[],c.renditionReports.push(ae);break;default:me.warn("line parsed but not handled: "+o)}}}A&&!A.relurl?(g.pop(),E-=A.duration,c.partList&&(c.fragmentHint=A)):c.partList&&(en(I,A,v),I.cc=T,c.fragmentHint=I,d&&rn(I,d,c)),c.targetduration||(c.playlistParsingError=new Error("Missing Target Duration"));var se=g.length,oe=g[0],le=g[se-1];if((E+=c.skippedSegments*c.targetduration)>0&&se&&le){c.averagetargetduration=E/se;var ue=le.sn;c.endSN="initSegment"!==ue?ue:0,c.live||(le.endList=!0),L>0&&(function(e,t,r){if(r||(r=e[t]),r)for(var i=t;i--;){var n=e[i];if(!n)return;n.programDateTime=r.programDateTime-1e3*n.duration,r=n}}(g,L,L>se-1?I:null),oe&&v.unshift(oe))}return c.fragmentHint&&(E+=c.fragmentHint.duration),c.totalduration=E,v.length&&c.dateRangeTagCount&&oe&&Xi(v,c),c.endCC=T,c},e}();function qi(e,t,r,i,n,a){var s;if(void 0===t)return null;var o=ui(i,t),l={attrs:e,bitrate:e.decimalInteger("BANDWIDTH")||e.decimalInteger("AVERAGE-BANDWIDTH"),name:e.NAME,url:Wi.resolve(o,r)},u=e.decimalResolution("RESOLUTION");u&&(l.width=u.width,l.height=u.height),Zi(e.CODECS,l);var d=e["SUPPLEMENTAL-CODECS"];return d&&(l.supplemental={},Zi(d,l.supplemental)),null!=(s=l.unknownCodecs)&&s.length||n.push(l),a&&(l.iframes=!0),l}function Xi(e,t){var r=e.length;if(!r){if(!t.hasProgramDateTime)return;var i=t.fragments[t.fragments.length-1];e.push(i),r++}for(var n=e[r-1],a=t.live?1/0:t.totalduration,s=Object.keys(t.dateRanges),o=s.length;o--;){var l=t.dateRanges[s[o]],u=l.startDate.getTime();l.tagAnchor=n.ref;for(var d=r;d--;){var h;if((null==(h=e[d])?void 0:h.sn)=o||0===i)&&t<=o+1e3*(((null==(s=r[i+1])?void 0:s.start)||n)-a.start)){var l=a.sn-e.startSN,u=e.fragments;if(l>=0&&lr.length)for(var d=r[i+1]||u[u.length-1],h=Math.min(d.sn-e.startSN,u.length-1);h>l;h--){var f=u[h].programDateTime;if(t>=f&&te.sn?(n=r-e.start,i=e):(n=e.start-r,i=t),i.duration!==n&&i.setDuration(n)}else t.sn>e.sn?e.cc===t.cc&&e.minEndPTS?t.setStart(e.start+(e.minEndPTS-e.start)):t.setStart(e.start+e.duration):t.setStart(Math.max(e.start-t.duration,0))}function on(e,t,r,i,n,a,s,o){i-r<=0&&(o.warn("Fragment should have a positive duration",t),i=r+t.duration,a=n+t.duration);var l=r,u=i,d=t.startPTS,h=t.endPTS;if(x(d)){var f=Math.abs(d-r);e&&f>e.totalduration?o.warn("media timestamps and playlist times differ by "+f+"s for level "+t.level+" "+e.url):x(t.deltaPTS)?t.deltaPTS=Math.max(f,t.deltaPTS):t.deltaPTS=f,l=Math.max(r,d),r=Math.min(r,d),n=void 0!==t.startDTS?Math.min(n,t.startDTS):n,u=Math.min(i,h),i=Math.max(i,h),a=void 0!==t.endDTS?Math.max(a,t.endDTS):a}var c=r-t.start;0!==t.start&&t.setStart(r),t.setDuration(i-t.start),t.startPTS=r,t.maxStartPTS=l,t.startDTS=n,t.endPTS=i,t.minEndPTS=u,t.endDTS=a;var g,v=t.sn;if(!e||ve.endSN)return 0;var m=v-e.startSN,p=e.fragments;for(p[m]=t,g=m;g>0;g--)sn(p[g],p[g-1]);for(g=m;g=0;s--){var o=a[s].initSegment;if(o){n=o;break}}e.fragmentHint&&delete e.fragmentHint.endPTS,function(e,t,r){for(var i,n=t.skippedSegments,a=Math.max(e.startSN,t.startSN)-t.startSN,s=(e.fragmentHint?1:0)+(n?t.endSN:Math.min(e.endSN,t.endSN))-t.startSN,o=t.startSN-e.startSN,l=t.fragmentHint?t.fragments.concat(t.fragmentHint):t.fragments,u=null!=(i=e.fragmentHint)&&i.duration?e.fragments.concat(e.fragmentHint):e.fragments,d=a;d<=s;d++){var h=u[o+d],f=l[d];if(n&&!f&&h&&(f=t.fragments[d]=h),h&&f){r(h,f,d,l);var c=h.relurl,g=f.relurl;if(c&&mn(c,g))return void(t.playlistParsingError=un("media sequence mismatch "+f.sn+":",e,t,0,f));if(h.cc!==f.cc)return void(t.playlistParsingError=un("discontinuity sequence mismatch ("+h.cc+"!="+f.cc+")",e,t,0,f))}}}(e,t,(function(e,r,a,s){if((!t.startCC||t.skippedSegments)&&r.cc!==e.cc){for(var o=e.cc-r.cc,l=a;l=0,o=0;if(s&&nt){var n=1e3*i[i.length-1].duration;n>0&&ne.startCC)}(t,e)){var i=Math.min(t.endCC,e.endCC),n=yn(t.fragments,i),a=yn(e.fragments,i);if(n&&a){var s=n.start-a.start;r.log("Aligning playlists using dicontinuity sequence "+i+" (diff: "+s+")"),Tn(s,e)}}}(t,e,r),t.alignedSliding||function(e,t,r){if(e.hasProgramDateTime&&t.hasProgramDateTime){var i,n,a=e.fragments,s=t.fragments;if(a.length&&s.length){var o=Math.min(t.endCC,e.endCC);t.startCCMath.max(60,e.totalduration))r.log("Cannot align playlists using PDT without overlap ("+Math.abs(d)+" > "+e.totalduration+")");else{var h=d-(n.start-i.start);r.log("Aligning playlists using PDT (diff: "+h+")"),Tn(h,e)}}}}}(t,e,r),t.alignedSliding||t.skippedSegments||dn(e,t,!1,r))}function An(e,t,r){In(e,t,r),e.addEventListener(t,r)}function In(e,t,r){e.removeEventListener(t,r)}function Ln(e){for(var t="",r=e.length,i=0;il.end){var c=of;(c||g)&&(!r.loader||!g&&s.isFragmentNearlyDownloaded(r)?s.fragPrevious=null:(s.log("Cancelling fragment load for seek (sn: "+r.sn+") - "+(c?"backward":"forward")+" seek"),r.abortRequests(),s.resetLoadingState()))}}if(i&&(s.fragmentTracker.removeFragmentsInRange(o,1/0,s.playlistType,!0),o>s.lastCurrentTime&&(s.lastCurrentTime=o),!s.loadingParts)){var v=Math.max(l.end,o),m=s.shouldLoadParts(s.getLevelDetails(),v);m&&(s.log("LL-Part loading ON after seeking to "+o+" with buffer @"+v),s.loadingParts=m)}var p=!si.isBuffered(i,o);s.hls.hasEnoughToStart&&!p||(s.log("Setting "+(p?"startPosition":"nextLoadPosition")+" to "+o+" for seek without enough to start"),s.nextLoadPosition=o,p&&(s.startPosition=o)),u<1&&s.state===Rn.IDLE&&s.tickImmediate()},s.onMediaEnded=function(){s.log("setting startPosition to 0 because media ended"),s.startPosition=s.lastCurrentTime=0},s.playlistType=a,s.hls=t,s.fragmentLoader=new Zr(t.config),s.initFragmentLoader=new Zr(t.config),s.keyLoader=i,s.fragmentTracker=r,s.config=t.config,s.decrypter=new Qr(t.config),s}d(t,e);var r=t.prototype;return r.registerListeners=function(){var e=this.hls;e.on(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.MANIFEST_LOADED,this.onManifestLoaded,this),e.on(G.ERROR,this.onError,this)},r.unregisterListeners=function(){var e=this.hls;e.off(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.MANIFEST_LOADED,this.onManifestLoaded,this),e.off(G.ERROR,this.onError,this)},r.doTick=function(){this.onTickEnd()},r.onTickEnd=function(){},r.startLoad=function(e){},r.stopLoad=function(){if(this.state!==Rn.STOPPED){this.fragmentLoader.abort(),this.initFragmentLoader.abort(),this.keyLoader.abort(this.playlistType);var e=this.fragCurrent;null!=e&&e.loader&&(e.abortRequests(),this.fragmentTracker.removeFragment(e)),this.resetTransmuxer(),this.fragCurrent=null,this.fragPrevious=null,this.clearInterval(),this.clearNextTick(),this.state=Rn.STOPPED}},r.pauseBuffering=function(){this.buffering=!1},r.resumeBuffering=function(){this.buffering=!0},r._streamEnded=function(e,t){if(t.live||!this.media)return!1;var r=e.end||0,i=this.config.timelineOffset||0;if(r<=i)return!1;var n=e.buffered;this.config.maxBufferHole&&n&&n.length>1&&(e=si.bufferedInfo(n,e.start,0));var a=e.nextStart;if(a&&a>i&&a0&&null!=a&&a.key&&a.iv&&Ti(a.method)){var s=self.performance.now(),o=ei(i,null,t.iframesOnly).decryptRange;return t.decrypter.decrypt(new Uint8Array(n),a.key.buffer,a.iv.buffer,Si(a.method),o).catch((function(e){throw r.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.FRAG_DECRYPT_ERROR,fatal:!1,error:e,reason:e.message,frag:i}),t.fragmentLoader.abort(),e})).then((function(n){var a=self.performance.now();return r.trigger(G.FRAG_DECRYPTED,{frag:i,part:null,payload:n,stats:{tstart:s,tdecrypt:a}}),e.payload=n,t.completeInitSegmentLoad(e)}))}return t.completeInitSegmentLoad(e)}))},r.completeInitSegmentLoad=function(e){var t=e.frag.stats;e.frag.data=new Uint8Array(e.payload),t.parsing.start=t.buffering.start=self.performance.now(),t.parsing.end=t.buffering.end=self.performance.now()},r.loadInitSegmentIfNeeded=function(e){var t,r=this,i=e.initSegment;if(!this.bitrateTest&&W(e)&&i&&!i.data)return(!i.encrypted||null!=(t=i.decryptdata)&&t.key?this._loadInitSegment(i):this.keyLoader.load(i).then((function(){return r._loadInitSegment(i)}))).catch((function(t){if(r.state===Rn.STOPPED||r.state===Rn.ERROR)throw t;throw"data"in t&&(t.data.frag=e,r.fragmentLoader.abort(),r.handleFragLoadError(t)),r.resetFragmentLoading(e),t}))},r.unhandledEncryptionError=function(e,t){var r,i,n=e.tracks;if(n&&!t.encrypted&&(null!=(r=n.audio)&&r.encrypted||null!=(i=n.video)&&i.encrypted)&&(!this.config.emeEnabled||!this.keyLoader.emeController)){var a=this.media,s=new Error("Encrypted track with no key in "+this.fragInfo(t)+" (media "+(a?"attached mediaKeys: "+a.mediaKeys:"detached")+")");return this.warn(s.message),!(!a||a.mediaKeys)&&(this.hls.trigger(G.ERROR,{type:U.KEY_SYSTEM_ERROR,details:B.KEY_SYSTEM_NO_KEYS,fatal:!1,error:s,frag:t}),this.resetTransmuxer(),!0)}return!1},r.fragContextChanged=function(e){var t=this.fragCurrent;return!e||!X(e,t)},r.fragBufferedComplete=function(e,t){var r=this.mediaBuffer?this.mediaBuffer:this.media;if(this.log("Buffered "+e.type+" sn: "+e.sn+(t?" part: "+t.index:"")+" of "+this.fragInfo(e,!1,t)+" > buffer:"+(r?Ln(si.getBuffered(r)):"(detached)")+")"),W(e)){var i;if(e.type!==H.SUBTITLE){var n=e.elementaryStreams;if(!Object.keys(n).some((function(e){return!!n[e]})))return void(this.state=Rn.IDLE)}var a=null==(i=this.levels)?void 0:i[e.level];null!=a&&a.fragmentError&&(t||this.fragmentTracker.getState(e)!==Br)&&(this.log("Resetting level fragment error count of "+a.fragmentError+" on frag buffered"),a.fragmentError=0,e.stats.retry=0)}this.state=Rn.IDLE},r._handleFragmentLoadComplete=function(e){var t=this.transmuxer;if(t){var r=e.frag,i=e.part,n=e.partsLoaded,a=!n||0===n.length||n.some((function(e){return!e})),s=ei(r,i).decryptRange,o=new ni(r.level,r.sn,r.stats.chunkCount+1,0,i?i.index:-1,!a,r.duration,this.iframesOnly,s);this.log("load complete "+r.type+" sn: "+r.sn+(i?" part: "+i.index:"")+" of "+this.fragInfo(r,!1,i)),t.flush(o)}},r._handleFragmentLoadProgress=function(e){},r.loadKeyFor=function(e,t,r){var i,n=this,a=null;return!e.encrypted||null!=(i=e.decryptdata)&&i.key?e.encrypted||(a=this.keyLoader.loadClear(e,t.encryptedFragments,this.startFragRequested))&&this.log("[eme] blocking frag sn: "+e.sn+" load until media-keys acquired"):(this.log("Loading key for "+e.sn+" of ["+t.startSN+"-"+t.endSN+"], "+this.playlistLabel()+" "+e.level),this.state=Rn.KEY_LOADING,a=this.keyLoader.load(e,r).then((function(e){if(!n.fragContextChanged(e.frag))return n.hls.trigger(G.KEY_LOADED,e),n.state===Rn.KEY_LOADING&&(n.state=Rn.IDLE),e})),this.hls.trigger(G.KEY_LOADING,{frag:e})),a},r._doFragLoad=function(e,t,r,i){var n=this;void 0===r&&(r=null),this.fragCurrent=e;var a=t.details;if(!this.levels||!a)throw new Error("frag load aborted, missing level"+(a?"":" detail")+"s");var s,o=this.fragPrevious;if(!q(e,o)){var l=this.shouldLoadParts(t.details,e.end);l!==this.loadingParts&&(this.log("LL-Part loading "+(l?"ON":"OFF")+" loading sn "+(null==o?void 0:o.sn)+"->"+e.sn),this.loadingParts=l)}if(r=Math.max(e.start,r||0),this.loadingParts){var u=a.partList;if(u&&i){r>a.fragmentEnd&&a.fragmentHint&&(e=a.fragmentHint);var d=this.getNextPart(u,e,r);if(d>-1){var h=u[d];if(!q(e=this.fragCurrent=h.fragment,o)&&!this.shouldLoadParts(t.details,e.end))return this.log("LL-Part loading OFF @"+this.playhead+" next part: "+this.fragInfo(e,!1,h)),this.loadingParts=!1,Promise.resolve(null);if(!this.filterReplacedPrimary(h,t.details))return Promise.resolve(null);var f,c=this.loadKeyFor(e,a);if(this.fragContextChanged(e))return Promise.resolve(null);this.log("Loading "+e.type+" sn: "+e.sn+" part: "+h.index+" ("+d+"/"+(u.length-1)+") of "+this.fragInfo(e,!1,h)+" cc: "+e.cc+" ["+a.startSN+"-"+a.endSN+"], target: "+r+(h.byteRange.length?" range:"+h.byteRange.join("-"):"")),this.nextLoadPosition=h.start+h.duration,this.state=Rn.FRAG_LOADING;var g=this.loadInitSegmentIfNeeded(e);if(c||g){var v=!!c;f=Promise.all([c,g]).then((function(r){var a=r[0];return!v||a&&!n.fragContextChanged(a.frag)?n.doFragPartsLoad(e,h,t,i):null})).catch((function(e){return n.handleFragLoadError(e)}))}else f=this.doFragPartsLoad(e,h,t,i).catch((function(e){return n.handleFragLoadError(e)}));return this.hls.trigger(G.FRAG_LOADING,{frag:e,part:h,targetBufferTime:r}),null===this.fragCurrent?Promise.reject(new Error("frag load aborted, context changed in FRAG_LOADING parts")):f}if(!e.url||this.loadedEndOfParts(u,r))return Promise.resolve(null)}}if(this.loadingParts)this.log("LL-Part loading OFF after next part miss @"+r+" Check buffer at sn: "+e.sn+" loaded parts: "+(null==(s=a.partList)?void 0:s.filter((function(e){return e.loaded})).map((function(e){return"["+e.start+"-"+e.end+"]"})))),this.loadingParts=!1;else if(!e.url)return Promise.resolve(null);if(!this.filterReplacedPrimary(e,t.details))return Promise.resolve(null);var m,p=this.loadInitSegmentIfNeeded(e),y=this.loadKeyFor(e,a,p);if(this.fragContextChanged(e))return this.log("Context changed in KEY_LOADING sn: "+e.sn+" "+e.relurl+" > "+(null==(m=this.fragCurrent)?void 0:m.relurl)),null==p||p.catch((function(){return null})),null==y||y.catch((function(){return null})),Promise.resolve(null);this.log("Loading "+e.type+" sn: "+e.sn+" of "+this.fragInfo(e,!1)+" cc: "+e.cc+" ["+a.startSN+"-"+a.endSN+"], target: "+r+(e.byteRange.length?" range:"+e.byteRange.join("-"):"")),x(e.sn)&&!this.bitrateTest&&(this.nextLoadPosition=e.start+e.duration),this.state=Rn.FRAG_LOADING;var E,T=this.config.progressive&&e.type!==H.SUBTITLE;return E=T&&y?y.then((function(t){return!t||n.fragContextChanged(t.frag)?null:n.fragmentLoader.load(e,n.iframesOnly,i,p)})).catch((function(e){return n.handleFragLoadError(e)})):Promise.all([this.fragmentLoader.load(e,this.iframesOnly,T?i:void 0,T?p:void 0),y,p]).then((function(e){var t=e[0];return!T&&i&&i(t),t})).catch((function(e){return n.handleFragLoadError(e)})),this.hls.trigger(G.FRAG_LOADING,{frag:e,targetBufferTime:r}),null===this.fragCurrent?Promise.reject(new Error("frag load aborted, context changed in FRAG_LOADING")):Promise.all([E,p]).then((function(e){return e[0]}))},r.doFragPartsLoad=function(e,t,r,i){var n=this;return new Promise((function(a,s){var o,l=[],u=null==(o=r.details)?void 0:o.partList,d=function(t){n.fragmentLoader.loadPart(e,t,i).then((function(i){l[t.index]=i;var s=i.part;n.hls.trigger(G.FRAG_LOADED,i);var o=cn(r.details,e.sn,t.index+1)||gn(u,e.sn,t.index+1);if(!o)return a({frag:e,part:s,partsLoaded:l});d(o)})).catch(s)};d(t)}))},r.handleFragLoadError=function(e){if("data"in e){var t=e.data;t.frag&&t.details===B.INTERNAL_ABORTED?this.handleFragLoadAborted(t.frag,t.part):t.frag&&t.type===U.KEY_SYSTEM_ERROR&&!t.fatal&&(t.frag.abortRequests(),this.resetStartWhenNotLoaded(),this.resetFragmentLoading(t.frag)),this.hls.trigger(G.ERROR,t)}else this.hls.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.INTERNAL_EXCEPTION,err:e,error:e,fatal:!0});return null},r._handleTransmuxerFlush=function(e){var t=this.getCurrentContext(e);if(t&&this.state===Rn.PARSING){var r=t.frag,i=t.part,n=t.level,a=self.performance.now();r.stats.parsing.end=a,i&&(i.stats.parsing.end=a);var s=this.getLevelDetails(),o=s&&r.sn>s.endSN||this.shouldLoadParts(s,r.end);o!==this.loadingParts&&(this.log("LL-Part loading "+(o?"ON":"OFF")+" after parsing segment ending @"+r.end),this.loadingParts=o),this.updateLevelTiming(r,i,n,e)}else this.fragCurrent||this.state===Rn.STOPPED||this.state===Rn.ERROR||(this.state=Rn.IDLE)},r.shouldLoadParts=function(e,t){if(this.config.lowLatencyMode){if(!e)return this.loadingParts;if(e.partList&&!this.iframesOnly){var r,i=e.partList[0];if(t>=i.end+((null==(r=e.fragmentHint)?void 0:r.duration)||0)&&this.playhead>i.start-i.fragment.duration)return!0}}return!1},r.getCurrentContext=function(e){var t=this.levels,r=this.fragCurrent,i=e.level,n=e.sn,a=e.part;if(null==t||!t[i])return this.warn("Levels object was unset while buffering fragment "+n+" of "+this.playlistLabel()+" "+i+". The current chunk will not be buffered."),null;var s=t[i],o=s.details,l=a>-1?cn(o,n,a):null,u=l?l.fragment:fn(o,n,r);return u?(r&&r!==u&&(u.stats=r.stats),{frag:u,part:l,level:s}):null},r.bufferFragmentData=function(e,t,r,i,n){if(this.state===Rn.PARSING){W(t)&&!this.fragContextChanged(t)&&(this.fragPrevious=t);var a=e.data1,s=e.data2,o=a;if(s&&(o=ht(a,s)),o.length){var l=this.initPTS[t.cc],u=l?-l.baseTime/l.timescale:void 0,d={type:e.type,frag:t,part:r,chunkMeta:i,offset:u,parent:t.type,data:o};if(this.hls.trigger(G.BUFFER_APPENDING,d),e.dropped&&e.independent&&!r){if(n)return;this.flushBufferGap(t)}}}},r.flushBufferGap=function(e){var t=this.media;if(t)if(si.isBuffered(t,t.currentTime)){var r=t.currentTime,i=si.bufferInfo(t,r,0),n=e.duration,a=Math.min(2*this.config.maxFragLookUpTolerance,.25*n),s=Math.max(Math.min(e.start-a,i.end-a),r+a);e.start-s>a&&this.flushMainBuffer(s,e.start)}else this.flushMainBuffer(0,e.start)},r.getFwdBufferInfo=function(e,t){var r,i=this.playhead,n=this.lastCurrentTime>i||null!=(r=this.media)&&r.paused?0:this.config.maxBufferHole;return this.getFwdBufferInfoAtPos(e,i,t,n)},r.getFwdBufferInfoAtPos=function(e,t,r,i){var n=si.bufferInfo(e,t,i);if(0===n.len&&void 0!==n.nextStart){var a=this.fragmentTracker.getBufferedFrag(t,r);if(a&&(n.nextStart<=a.end||a.gap)){var s=Math.max(Math.min(n.nextStart,a.end)-t,i);return si.bufferInfo(e,t,s)}}return n},r.getMaxBufferLength=function(e){var t,r=this.config;return t=e?Math.max(8*r.maxBufferSize/e,r.maxBufferLength):r.maxBufferLength,Math.min(t,r.maxMaxBufferLength)},r.exceedsMaxBuffer=function(e,t,r){var i=e.nextStart;if(i&&r.start>i){var n=e.buffered;if(n){for(var a=e.len,s=e.bufferedIndex,o=n.length-1;o>s;o--)n[o].start=t}}return!1},r.reduceMaxBufferLength=function(e,t){var r=this.config,i=Math.max(Math.min(e-t,r.maxBufferLength),t/2),n=Math.max(e-3*t,r.maxMaxBufferLength/2,i);return n>=i&&(r.maxMaxBufferLength=n,this.warn("Reduce max buffer length to "+n+"s"),!0)},r.getAppendedFrag=function(e){var t=this.fragmentTracker?this.fragmentTracker.getAppendedFrag(e,this.playlistType):null;return t&&"fragment"in t?t.fragment:t},r.getNextFragment=function(e,t){var r=t.fragments,i=r.length;if(!i)return null;var n=this.config,a=t.fragmentStart,s=n.lowLatencyMode&&!!t.partList,o=null;if(t.live){var l=n.initialLiveManifestSize;if(i=a?(d=c,h=c===f?"config":"next load start"):g?(d=g,h="live edge"):(d=e,h="buffer pos"),d1)return!0}return!1},r.getNextFragmentLoopLoading=function(e,t,r,i,n){var a=null;if(e.gap&&(a=this.getNextFragment(this.nextLoadPosition,t))&&!a.gap&&r.nextStart){var s=this.getFwdBufferInfoAtPos(this.mediaBuffer?this.mediaBuffer:this.media,r.nextStart,i,0);if(null!==s&&r.len+s.len>=n){var o=a.sn;return this.loopSn!==o&&(this.log('buffer full after gaps in "'+i+'" playlist starting at sn: '+o),this.loopSn=o),null}}return this.loopSn=void 0,a},r.filterReplacedPrimary=function(e,t){if(!e)return e;if(Dn(this.config)&&("type"in e?e:e.fragment).type!==H.SUBTITLE){var r=this.hls.interstitialsManager,i=null==r?void 0:r.bufferingItem;if(i){var n=i.event;if(n){if(!n.appendInPlace&&(Math.abs(e.start-i.start)>1||0===i.start))return null}else{if(e.end<=i.start&&!1===(null==t?void 0:t.live))return null;if(e.start>=i.end&&i.nextEvent&&(i.nextEvent.appendInPlace&&!i.nextEvent.hasPlayed||e.start-i.end>1))return null}}if(kn(e,null==r?void 0:r.playerQueue,this.playhead))return null}return e},r.getNextPart=function(e,t,r){for(var i=-1,n=!1,a=!0,s=0,o=e.length;s-1&&r "+d.fragment.sn+")"),i},r.loadedEndOfParts=function(e,t){for(var r,i=e.length;i--;){if(!(r=e[i]).loaded)return!1;if(t>r.start)return!0}return!1},r.getInitialLiveFragment=function(e){var t=e.fragments,r=this.fragPrevious,i=null;if(r){if(e.hasProgramDateTime&&(i=function(e,t,r){if(null===t||!Array.isArray(e)||!e.length||!x(t))return null;if(t<(e[0].programDateTime||0))return null;if(t>=(e[e.length-1].endProgramDateTime||0))return null;for(var i=0;i=e.startSN&&n<=e.endSN){var a=t[n-e.startSN];r.cc===a.cc&&(i=a,this.log("Live playlist, switching playlist, load frag with next SN: "+i.sn))}i||(i=Sr(e,r.cc,r.end))&&this.log("Live playlist, switching playlist, load frag with same CC: "+i.sn)}}else{var s=this.hls.liveSyncPosition;null!==s&&(i=this.getFragmentAtPosition(s,this.bitrateTest?e.fragmentEnd:e.edge,e))}return i},r.getFragmentAtPosition=function(e,t,r){var i,n,a=this.config,s=this.fragPrevious,o=r.fragments,l=r.endSN,u=r.fragmentHint,d=a.maxFragLookUpTolerance,h=r.partList,f=!!(this.loadingParts&&null!=h&&h.length&&u);if(f&&!this.bitrateTest&&h[h.length-1].fragment.sn===u.sn&&(o=o.concat(u),l=u.sn),s&&this.fragmentTracker.getState(s)===Nr&&(s=null),i=et-d||null!=(n=this.media)&&n.paused||!this.startFragRequested?0:d):o[o.length-1]){var c=i.sn-r.startSN,g=this.fragmentTracker.getState(i);if((g===Gr||g===Br&&i.gap)&&(s=i),q(i,s)&&(!f||h[0].fragment.sn>i.sn||!r.live)){var v=o[c+1];i=i.sn"+e.startSN+" fragments: "+i),o}return n},r.waitForCdnTuneIn=function(e){return e.live&&e.canBlockReload&&e.partTarget&&e.tuneInGoal>Math.max(e.partHoldBack,3*e.partTarget)},r.setStartPosition=function(e,t){var r=this.startPosition;r=0&&(r=this.nextLoadPosition),r},r.handleFragLoadAborted=function(e,t){this.transmuxer&&e.type===this.playlistType&&W(e)&&e.stats.aborted&&(this.log("Fragment "+e.sn+(t?" part "+t.index:"")+" of "+this.playlistLabel()+" "+e.level+" was aborted"),this.resetFragmentLoading(e))},r.resetFragmentLoading=function(e){this.fragCurrent&&(this.fragContextChanged(e)||this.state===Rn.FRAG_LOADING_WAITING_RETRY)||(this.state=Rn.IDLE)},r.onFragmentOrKeyLoadError=function(e,t){if(t.chunkMeta&&!t.frag){var r=this.getCurrentContext(t.chunkMeta);r&&(t.frag=r.frag)}var i=t.frag,n=t.part;if(i&&this.levels&&i.type===e)if(this.fragContextChanged(i)){var a;this.warn("Frag load error must match current frag to retry "+i.relurl+" > "+(null==(a=this.fragCurrent)?void 0:a.relurl))}else{var s=t.details===B.FRAG_GAP;s&&this.fragmentTracker.addAsGap(i);var o=t.errorAction;if(o){var l=o.action,u=o.flags,d=o.retryCount,h=void 0===d?0:d,f=o.retryConfig,c=!!f,g=c&&l===br,v=c&&!o.resolved&&u&Dr,m=this.hls.latestLevelDetails,p=null==m?void 0:m.live;if(!g&&v&&W(i)&&p&&i.sn=t||r&&!ae(0))&&(r&&this.log("Connection restored (online)"),this.resetStartWhenNotLoaded(),this.state=Rn.IDLE)},r.reduceLengthAndFlushBuffer=function(e){if(this.state===Rn.PARSING||this.state===Rn.PARSED){var t=e.frag,r=e.parent,i=this.getFwdBufferInfo(this.mediaBuffer,r),n=i&&i.len>.5;n&&this.reduceMaxBufferLength(i.len,(null==t?void 0:t.duration)||10);var a=!n;return a?this.warn("Buffer full error while media.currentTime ("+this.playhead+") is not buffered, flush "+r+" buffer"):i.nextStart&&t&&i.nextStart>t.start&&this.flushMainBuffer(i.nextStart,Number.POSITIVE_INFINITY,r===H.AUDIO?r:void 0),t&&(this.fragmentTracker.removeFragment(t),this.nextLoadPosition=t.start),this.resetLoadingState(),a}return!1},r.resetFragmentErrors=function(e){e===H.AUDIO&&(this.fragCurrent=null),this.hls.hasEnoughToStart||(this.startFragRequested=!1),this.state!==Rn.STOPPED&&(this.state=Rn.IDLE)},r.afterBufferFlushed=function(e,t){if(e){var r=si.getBuffered(e);this.fragmentTracker.detectEvictedFragments(t,r,this.playlistType),this.state===Rn.ENDED&&this.resetLoadingState()}},r.resetLoadingState=function(){this.log("Reset loading state"),this.fragCurrent=null,this.fragPrevious=null,this.state!==Rn.STOPPED&&(this.state=Rn.IDLE)},r.resetStartWhenNotLoaded=function(){if(!this.hls.hasEnoughToStart){this.startFragRequested=!1;var e=this.getLevelDetails();null!=e&&e.live?(this.log("resetting startPosition for live start"),this.startPosition=-1,this.setStartPosition(e,e.fragmentStart),this.resetLoadingState()):this.nextLoadPosition=this.startPosition}},r.resetWhenMissingContext=function(e){this.log("Loading context changed while buffering sn "+e.sn+" of "+this.playlistLabel()+" "+(-1===e.level?"":e.level)+". This chunk will not be buffered."),this.removeUnbufferedFrags(),this.resetStartWhenNotLoaded(),this.resetLoadingState()},r.removeUnbufferedFrags=function(e){void 0===e&&(e=0),this.fragmentTracker.removeFragmentsInRange(e,1/0,this.playlistType,!1,!0)},r.updateLevelTiming=function(e,t,r,i){var n=this,a=r.details;if(a){if(this.log("update level timing "+e.type+" sn: "+e.sn+(t?" part: "+t.index:"")+" of "+this.fragInfo(e,!1,t)),!Object.keys(e.elementaryStreams).reduce((function(t,s){var o=e.elementaryStreams[s];if(o){var l=o.endPTS-o.startPTS;if(l<=0)return n.warn("Could not parse fragment "+e.sn+" "+s+" duration reliably ("+l+")"),t||!1;var u=i.partial?0:on(a,e,o.startPTS,o.endPTS,o.startDTS,o.endDTS,n.iframesOnly,n);return n.hls.trigger(G.LEVEL_PTS_UPDATED,{details:a,level:r,drift:u,type:s,frag:e,start:o.startPTS,end:o.endPTS}),!0}return t}),!1)){var s,o,l,u,d=null===(null==(s=this.transmuxer)?void 0:s.error),h=null!=(null==(o=this.transmuxer)?void 0:o.error),f=(null!=(l=null==(u=this.levels)?void 0:u.length)?l:0)>1;if((0===r.fragmentError||d&&(r.fragmentError<2||e.endList)||h&&f)&&this.treatAsGap(e,r),d){var c=new Error("Found no media in "+this.playlistLabel()+" "+e.level+" "+(t?"part: "+t.index+" of ":"")+"sn: "+e.sn+" at playlist time: "+e.start+". Resetting transmuxer to fallback to playlist timing");if(this.warn(c.message),this.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.FRAG_PARSING_ERROR,fatal:!1,error:c,frag:e,reason:"Found no media in msn "+e.sn+" of "+this.playlistLabel()+' "'+r.url+'"'}),!this.hls)return;this.resetTransmuxer()}}this.state=Rn.PARSED,this.log("Parsed "+e.type+" sn: "+e.sn+(t?" part: "+t.index:"")+" of "+this.fragInfo(e,!1,t)),this.hls.trigger(G.FRAG_PARSED,{frag:e,part:t,chunkMeta:i})}else this.warn("level.details undefined")},r.playlistLabel=function(){return this.playlistType+" playlist"},r.fragInfo=function(e,t,r){var i,n;return void 0===t&&(t=!0),this.playlistLabel()+" "+e.level+" ("+(r?"part":"frag")+":["+(null!=(i=t&&!r?e.startPTS:(r||e).start)?i:NaN)+"-"+(null!=(n=t&&!r?e.endPTS:(r||e).end)?n:NaN)+"]"+(r&&"main"===e.type?"INDEPENDENT="+(r.independent?"YES":"NO"):"")+")"},r.treatAsGap=function(e,t){t&&t.fragmentError++,this.fragmentTracker.addAsGap(e)},r.resetTransmuxer=function(){var e;null==(e=this.transmuxer)||e.reset()},r.isFragmentNearlyDownloaded=function(e){var t,r=null==(t=e.loader)?void 0:t.stats;if(!r)return!1;var i=r.loading.first>0,n=(r.total-r.loaded)/(this.hls.bandwidthEstimate||this.hls.config.abrEwmaDefaultEstimate);return i&&n<=.15},r.recoverWorkerError=function(e){"demuxerWorker"===e.event&&(this.fragmentTracker.removeAllFragments(),this.transmuxer&&(this.transmuxer.destroy(),this.transmuxer=null),this.resetStartWhenNotLoaded(),this.resetLoadingState())},r.calculateOptimalSwitchPoint=function(e,t){var r=0,i=this.hls,n=this.media,a=this.config,s=this.levels,o=this.playlistType,l=this.getLevelDetails();if(n&&!n.paused&&s){var u,d=o===H.AUDIO?Wt(e.audioCodec,128e3):e.maxBitrate,h=1+i.ttfbEstimate/1e3,f=i.bandwidthEstimate*a.abrBandWidthUpFactor;r=h+d*(l&&(this.loadingParts?l.partTarget:l.averagetargetduration)||(null==(u=this.fragCurrent)?void 0:u.duration)||6)/f,e.details||(r+=h)}var c=this.playhead;return{fetchdelay:r,okToFlushForwardBuffer:!(null!=l&&l.live)||t.end-c>1.5*r}},r.scheduleTrackSwitch=function(e,t,r){var i=this.media,n=this.playlistType;if(i&&e){var a=r?this.getBufferedFrag(this.playhead+t):null;if(a){var s=this.followingBufferedFrag(a);if(s){var o;this.abortCurrentFrag();var l=s.maxStartPTS?s.maxStartPTS:s.start,u=s.duration,d=Math.max(a.end,l+Math.min(Math.max(u-this.config.maxFragLookUpTolerance,u*(this.couldBacktrack?.5:.125)),u*(this.couldBacktrack?.75:.25)));if(((null==(o=this.getLevelDetails())?void 0:o.fragmentStart)||0)>d)return;var h=n===H.MAIN?null:"audio";this.flushMainBuffer(d,Number.POSITIVE_INFINITY,h),this.cleanupBackBuffer()}}}},r.cleanupBackBuffer=function(){var e=this.media,t=this.playlistType;if(e){var r=this.getAppendedFrag(this.playhead);if(r&&r.start>1){var i=t===H.AUDIO;this.flushMainBuffer(0,r.start-(i?0:1),i?"audio":null)}}},r.getBufferedFrag=function(e){return this.fragmentTracker.getBufferedFrag(e,this.playlistType)},r.followingBufferedFrag=function(e){return e?this.getBufferedFrag(e.end+.5):null},r.abortCurrentFrag=function(){var e=this.fragCurrent;switch(this.fragCurrent=null,e&&(e.abortRequests(),this.fragmentTracker.removeFragment(e)),this.state){case Rn.KEY_LOADING:case Rn.FRAG_LOADING:case Rn.FRAG_LOADING_WAITING_RETRY:case Rn.PARSING:case Rn.PARSED:this.state=Rn.IDLE}this.nextLoadPosition=this.playhead},r.checkFragPlaying=function(){var e=this.media,t=null;if(e&&e.readyState>1&&!1===e.seeking){var r=e.currentTime;if(si.isBuffered(e,r)?t=this.getAppendedFrag(r):si.isBuffered(e,r+.1)&&(t=this.getAppendedFrag(r+.1)),t&&(this.backtrackFragment=void 0,!q(t,this.fragPlaying)))return this.fragPlaying=t,!0}return!1},r.getBufferOutput=function(){return null},r.nextLevelSwitch=function(){var e=this.levels,t=this.media,r=this.hls,i=this.config,n=this.playlistType;if(null!=t&&t.readyState&&e&&r&&i){var a=this.getBufferOutput(),s=this.getFwdBufferInfo(a,n);if(!s)return;var o=e[n===H.AUDIO?r.nextAudioTrack:r.nextLoadLevel],l=this.calculateOptimalSwitchPoint(o,s),u=l.fetchdelay,d=l.okToFlushForwardBuffer;this.scheduleTrackSwitch(s,u,d)}this.tickImmediate()},a(t,[{key:"startPositionValue",get:function(){var e=this.nextLoadPosition,t=this.startPosition;if(-1===t&&e){var r=this.getLevelDetails();return null!=r&&r.live?(this.hls.liveSyncPosition||r.fragmentStart)+this.timelineOffset:e}return t}},{key:"bufferingEnabled",get:function(){return this.buffering}},{key:"backtrackFragment",get:function(){},set:function(e){}},{key:"couldBacktrack",get:function(){return!1},set:function(e){}},{key:"inFlightFrag",get:function(){return{frag:this.fragCurrent,state:this.state}}},{key:"timelineOffset",get:function(){var e,t=this.config.timelineOffset;return t?(null==(e=this.getLevelDetails())?void 0:e.appliedTimelineOffset)||t:0}},{key:"primaryPrefetch",get:function(){var e;return!(!Dn(this.config)||!(null==(e=this.hls.interstitialsManager)||null==(e=e.playingItem)?void 0:e.event))}},{key:"playhead",get:function(){var e;if(null!=(e=this.hls)&&e.hasEnoughToStart){var t,r=null==(t=this.media)?void 0:t.currentTime;return x(r)?r:this.lastCurrentTime}return this.getLoadPosition()}},{key:"iframesOnly",get:function(){var e;if(this.playlistType!==H.MAIN)return!1;var t=this.getLevelDetails();return t?t.iframesOnly:null==(e=this.levelLastLoaded)?void 0:e.iframes}},{key:"state",get:function(){return this._state},set:function(e){var t=this._state;t!==e&&(this._state=e,this.log(t+"->"+e))}}])}(ii);function kn(e,t,r){if(t)for(var i=t.length;i--;){var n=t[i].interstitial,a=n.startTime,s=Math.min(a+n.duration,n.resumeTime);if(n.appendInPlace){if(e.start>=a&&e.end<=s)return!0;if(r>e.start&&e.end>a&&e.start>>5}function On(e,t){return t+1=e.length)return!1;var i=wn(e,t);if(i<=r)return!1;var n=t+i;return n===e.length||On(e,n)}return!1}function xn(e,t,r,i,n){if(!e.samplerate){var a=function(e,t,r,i){var n=t[r+2],a=n>>2&15;if(!(a>12)){var s,o=1+(n>>6&3),l=t[r+3]>>6&3|(1&n)<<2,u=[96e3,88200,64e3,48e3,44100,32e3,24e3,22050,16e3,12e3,11025,8e3,7350][a],d=a;5!==o&&29!==o||(d-=3);var h=(14&d)>>1,f=(1&d)<<7|l<<3,c=0;if(1===o&&a>=6){var g=a-3;s=[(c=5)<<3|h,f|(14&g)>>1,(1&g)<<7|8,0]}else s=[o<<3|h,f];var v="mp4a.40."+(c||o);return me.log("manifest codec:"+i+", parsed codec:"+v+", channels:"+l+", rate:"+u+" (ADTS object type:"+o+" sampling index:"+a+" esds config:["+s.join(",")+"])"),{config:s,samplerate:u,channelCount:l,codec:v,parsedCodec:v,manifestCodec:i}}var m=new Error("invalid ADTS sampling index:"+a);e.emit(G.ERROR,G.ERROR,{type:U.MEDIA_ERROR,details:B.FRAG_PARSING_ERROR,fatal:!0,error:m,reason:m.message})}(t,r,i,n);if(!a)return;l(e,a)}}function Fn(e){return 9216e4/e}function Nn(e,t,r,i,n){var a,s=i+n*Fn(e.samplerate),o=function(e,t){var r=Cn(e,t);if(t+r<=e.length){var i=wn(e,t)-r;if(i>0)return{headerLength:r,frameLength:i}}}(t,r);if(o){var l=o.frameLength,u=o.headerLength,d=u+l,h=Math.max(0,r+d-t.length);h?(a=new Uint8Array(d-u)).set(t.subarray(r+u,t.length),0):a=t.subarray(r+u,r+d);var f={unit:a,pts:s};return h||e.samples.push(f),{sample:f,length:d,missing:h}}var c=t.length-r;return(a=new Uint8Array(c)).set(t.subarray(r,t.length),0),{sample:{unit:a,pts:s},length:c,missing:-1}}var Un=function(e){return e.audioId3="org.id3",e.dateRange="com.apple.quicktime.HLS",e.emsg="https://aomedia.org/emsg/ID3",e.misbklv="urn:misb:KLV:bin:1910.1",e}({});function Bn(e,t){return void 0===e&&(e=""),void 0===t&&(t=9e4),{type:e,id:-1,pid:-1,inputTimeScale:t,sequenceNumber:-1,samples:[],dropped:0}}var Gn=function(){function e(){this._audioTrack=void 0,this._id3Track=void 0,this.frameIndex=0,this.cachedData=null,this.basePTS=null,this.initPTS=null,this.lastPTS=null}var t=e.prototype;return t.resetInitSegment=function(e,t,r,i){this._id3Track={type:"id3",id:3,pid:-1,inputTimeScale:9e4,sequenceNumber:0,samples:[],dropped:0}},t.resetTimeStamp=function(e){this.initPTS=e,this.resetContiguity()},t.resetContiguity=function(){this.basePTS=null,this.lastPTS=null,this.frameIndex=0},t.canParse=function(e,t){return!1},t.appendFrame=function(e,t,r){},t.demux=function(e,t){this.cachedData&&(e=ht(this.cachedData,e),this.cachedData=null);var r,i=Ie(e,0),n=i?i.length:0,a=this._audioTrack,s=this._id3Track,o=i?Me(i):void 0,l=e.length;for((null===this.basePTS||0===this.frameIndex&&x(o))&&(this.basePTS=Kn(o,t,this.initPTS),this.lastPTS=this.basePTS),null===this.lastPTS&&(this.lastPTS=this.basePTS),i&&i.length>0&&s.samples.push({pts:this.lastPTS,dts:this.lastPTS,data:i,type:Un.audioId3,duration:Number.POSITIVE_INFINITY});nt.length)){var a=qn(t,r);if(a&&r+a.frameLength<=t.length){var s=i+n*(9e4*a.samplesPerFrame/a.sampleRate),o={unit:t.subarray(r,r+a.frameLength),pts:s,dts:s};return e.config=[],e.channelCount=a.channelCount,e.samplerate=a.sampleRate,e.samples.push(o),{sample:o,length:a.frameLength,missing:0}}}}function qn(e,t){var r=e[t+1]>>3&3,i=e[t+1]>>1&3,n=e[t+2]>>4&15,a=e[t+2]>>2&3;if(1!==r&&0!==n&&15!==n&&3!==a){var s=e[t+2]>>1&1,o=e[t+3]>>6,l=1e3*Vn[14*(3===r?3-i:3===i?3:4)+n-1],u=Hn[3*(3===r?0:2===r?1:2)+a],d=3===o?1:2,h=Yn[r][i],f=jn[i],c=8*h*f,g=Math.floor(h*l/u+s)*f,v=Rt();return!!v&&v<=87&&2===i&&l>=224e3&&0===o&&(e[t+3]=128|e[t+3]),{sampleRate:u,channelCount:d,frameLength:g,samplesPerFrame:c}}}function Xn(e,t){return 255===e[t]&&224==(224&e[t+1])&&0!=(6&e[t+1])}function zn(e,t){return t+1=e.length)return void r();if(!(e[t].unit.length<32||(this.decryptAacSample(e,t,r),this.decrypter.isSync())))return}},t.getAvcEncryptedData=function(e){for(var t=16*Math.floor((e.length-48)/160)+16,r=new Int8Array(t),i=0,n=32;n=e.length)return void i();for(var n=e[t].units;!(r>=n.length);r++){var a=n[r];if(!(a.data.length<=48||1!==a.type&&5!==a.type||(this.decryptAvcSample(e,t,r,i,a),this.decrypter.isSync())))return}}},e}(),Zn=function(e){function t(t,r){var i;return(i=e.call(this)||this).observer=void 0,i.config=void 0,i.sampleAes=null,i.observer=t,i.config=r,i}d(t,e);var r=t.prototype;return r.resetInitSegment=function(t,r,i,n){e.prototype.resetInitSegment.call(this,t,r,i,n),this._audioTrack={container:"audio/adts",type:"audio",id:2,pid:-1,sequenceNumber:0,segmentCodec:"aac",samples:[],manifestCodec:r,duration:n,inputTimeScale:9e4,dropped:0}},t.probe=function(e,t){if(!e)return!1;var r=Ie(e,0),i=(null==r?void 0:r.length)||0;if(Qn(e,i))return!1;for(var n=e.length;i0;){s[0]=e[t];var o=Math.min(i,8),l=8-o;a[0]=4278190080>>>24+l<>l,r=r?r<t.length)return-1;if(11!==t[r]||119!==t[r+1])return-1;var a=t[r+4]>>6;if(a>=3)return-1;var s=[48e3,44100,32e3][a],o=63&t[r+4],l=2*[64,69,96,64,70,96,80,87,120,80,88,120,96,104,144,96,105,144,112,121,168,112,122,168,128,139,192,128,140,192,160,174,240,160,175,240,192,208,288,192,209,288,224,243,336,224,244,336,256,278,384,256,279,384,320,348,480,320,349,480,384,417,576,384,418,576,448,487,672,448,488,672,512,557,768,512,558,768,640,696,960,640,697,960,768,835,1152,768,836,1152,896,975,1344,896,976,1344,1024,1114,1536,1024,1115,1536,1152,1253,1728,1152,1254,1728,1280,1393,1920,1280,1394,1920][3*o+a];if(r+l>t.length)return-1;var u=t[r+6]>>5,d=0;2===u?d+=2:(1&u&&1!==u&&(d+=2),4&u&&(d+=2));var h=(t[r+6]<<8|t[r+7])>>12-d&1,f=[2,1,2,3,3,4,4,5][u]+h,c=t[r+5]>>3,g=7&t[r+5],v=new Uint8Array([a<<6|c<<1|g>>2,(3&g)<<6|u<<3|h<<2|o>>4,o<<4&224]),m=i+n*(1536/s*9e4),p=t.subarray(r,r+l);return e.config=v,e.channelCount=f,e.samplerate=s,e.samples.push({unit:p,pts:m}),l}var ra=function(e){function t(){return e.apply(this,arguments)||this}d(t,e);var r=t.prototype;return r.resetInitSegment=function(t,r,i,n){e.prototype.resetInitSegment.call(this,t,r,i,n),this._audioTrack={container:"audio/mpeg",type:"audio",id:2,pid:-1,sequenceNumber:0,segmentCodec:"mp3",samples:[],manifestCodec:r,duration:n,inputTimeScale:9e4,dropped:0}},t.probe=function(e){if(!e)return!1;var t=Ie(e,0),r=(null==t?void 0:t.length)||0;if(t&&11===e[r]&&119===e[r+1]&&void 0!==Me(t)&&Jn(e,r)<=16)return!1;for(var i=e.length;r8&&Ye(e,i+4)===Ke[t])return!0;i=n>1?i+n:r}return!1}(e,"moof")},t.demux=function(e,t,r){this.timeOffset=t;var i=e,n=this.videoTrack,a=this.txtTrack;if(this.config.progressive){this.remainderData&&(i=ht(this.remainderData,e));var s=function(e){var t={valid:null,remainder:null},r=Ze(e,["moof"]);if(r.length<2)return t.remainder=e,t;var i=r[r.length-1];return t.valid=e.slice(0,i.byteOffset-8),t.remainder=e.slice(i.byteOffset-8),t}(i);this.remainderData=s.remainder,n.samples=s.valid||new Uint8Array}else n.samples=i;var o=this.extractID3Track(n,t),l=this.parseFragment(n,a,t,r);return{videoTrack:n,audioTrack:this.audioTrack,id3Track:o,textTrack:this.txtTrack,initData:this.initData,sampleData:l}},t.flush=function(e,t){var r=this.timeOffset,i=this.videoTrack,n=this.txtTrack;i.samples=this.remainderData||new Uint8Array,this.remainderData=null;var a=this.extractID3Track(i,this.timeOffset),s=this.parseFragment(i,n,r,t);return{videoTrack:i,audioTrack:Bn(),id3Track:a,textTrack:Bn(),initData:this.initData,sampleData:s}},t.parseFragment=function(e,t,r,i){var n=this.initData;if(n&&i){var a=function(e,t,r,i,n){void 0===n&&(n=!1);var a=[];return{samples:a,sampleData:{includeSampleDetails:n,tracks:dt(t.samples,r,i,n,{isHEVCFlavor:ft(t.codec),samples:a,timeOffset:e,timescale:t.timescale,trackId:t.id})}}}(r,e,n,this.logger,i.iframe),s=a.samples,o=a.sampleData;return t.samples=s,o}t.samples=function(e,t){var r=[],i=t.samples,n=t.timescale,a=t.id,s=!1;return Ze(i,["moof"]).map((function(o){var l=o.byteOffset-8;Ze(o,["traf"]).map((function(o){var u=Ze(o,["tfdt"]).map((function(e){var t=e[0],r=Ye(e,4);return 1===t&&(r*=Math.pow(2,32),r+=Ye(e,8)),r/n}))[0];return void 0!==u&&(e=u),Ze(o,["tfhd"]).map((function(u){var d=Ye(u,4),h=16777215&Ye(u,0),f=0!=(1&h),c=0,g=0!=(2&h),v=0!=(8&h),m=0,p=0!=(16&h),y=0,E=0!=(32&h),T=!f&&0!=(131072&h),S=8;d===a&&(f?(c=Ye(u,S),S+=4,c*=Math.pow(2,32),c+=Ye(u,S),S+=4):T&&(c=l),g&&(S+=4),v&&(m=Ye(u,S),S+=4),p&&(y=Ye(u,S),S+=4),E&&(S+=4),"video"===t.type&&(s=ft(t.codec)),Ze(o,["trun"]).map((function(a){var o=a[0],l=16777215&Ye(a,0),u=0!=(1&l),d=0,h=0!=(4&l),f=0!=(256&l),g=0,v=0!=(512&l),p=0,E=0!=(1024&l),T=0!=(2048&l),S=0,A=Ye(a,4),I=8;u&&(d=Ye(a,I),I+=4),h&&(I+=4);for(var L=c+d,R=0;R0&&0===t[f-1];)f--;if(!(h-f+(0===f?a:0)<2)){if(u>=0)s.push({data:t.subarray(u,f),type:d});else{var c=this.getLastNalUnit(e.samples);c&&(f>0?c.data=ht(c.data,t.subarray(0,f)):a>0&&(c.data=c.data.subarray(0,c.data.byteLength-a)),c.state=0)}if(!(l0&&0===t[g-1];)g--;o=i-g+(0===g?a:0)}if(u>=0)s.push({data:t.subarray(u),type:d,state:o});else if(0===s.length&&-1!==o){var v=this.getLastNalUnit(e.samples);v&&(v.data=ht(v.data,t),v.state=o)}return e.naluState=o,s},e}(),oa=function(){function e(e){this.data=void 0,this.bytesAvailable=void 0,this.word=void 0,this.bitsAvailable=void 0,this.data=e,this.bytesAvailable=e.byteLength,this.word=0,this.bitsAvailable=0}var t=e.prototype;return t.loadWord=function(){var e=this.data,t=this.bytesAvailable,r=e.byteLength-t,i=new Uint8Array(4),n=Math.min(4,t);if(0===n)throw new Error("no bytes available");i.set(e.subarray(r,r+n)),this.word=new DataView(i.buffer).getUint32(0),this.bitsAvailable=8*n,this.bytesAvailable-=n},t.skipBits=function(e){var t;e=Math.min(e,8*this.bytesAvailable+this.bitsAvailable),this.bitsAvailable>e?(this.word<<=e,this.bitsAvailable-=e):(e-=this.bitsAvailable,e-=(t=e>>3)<<3,this.bytesAvailable-=t,this.loadWord(),this.word<<=e,this.bitsAvailable-=e)},t.readBits=function(e){var t=Math.min(this.bitsAvailable,e),r=this.word>>>32-t;if(e>32&&me.error("Cannot read more than 32 bits at a time"),this.bitsAvailable-=t,this.bitsAvailable>0)this.word<<=t;else{if(!(this.bytesAvailable>0))throw new Error("no bits available");this.loadWord()}return(t=e-t)>0&&this.bitsAvailable?r<>>e))return this.word<<=e,this.bitsAvailable-=e,e;return this.loadWord(),e+this.skipLZ()},t.skipUEG=function(){this.skipBits(1+this.skipLZ())},t.skipEG=function(){this.skipBits(1+this.skipLZ())},t.readUEG=function(){var e=this.skipLZ();return this.readBits(e+1)-1},t.readEG=function(){var e=this.readUEG();return 1&e?1+e>>>1:-1*(e>>>1)},t.readBoolean=function(){return 1===this.readBits(1)},t.readUByte=function(){return this.readBits(8)},e}(),la=function(e){function t(){return e.apply(this,arguments)||this}d(t,e);var r=t.prototype;return r.parsePES=function(e,t,r,i,n){var a,s=this,o=n.iframe,l=this.parseNALu(e,r.data,i),u=this.VideoSample,d=!1;r.data=null,u&&l.length&&!e.audFound&&(this.pushAccessUnit(u,e),u=this.VideoSample=this.createVideoSample(!1,r.pts,r.dts)),l.forEach((function(i){var n,l;switch(i.type){case 1:if(o)break;var h=!1;a=!0;var f,c=i.data;if(d&&c.length>4){var g=s.readSliceType(c);2!==g&&4!==g&&7!==g&&9!==g||(h=!0)}h&&null!=(f=u)&&f.frame&&!u.key&&(s.pushAccessUnit(u,e),u=s.VideoSample=null),u||(u=s.VideoSample=s.createVideoSample(!0,r.pts,r.dts)),u.frame=!0,u.key=h;break;case 5:a=!0,null!=(n=u)&&n.frame&&!u.key&&(s.pushAccessUnit(u,e),u=s.VideoSample=null),u||(u=s.VideoSample=s.createVideoSample(!0,r.pts,r.dts)),u.key=!0,u.frame=!0;break;case 6:if(o)break;a=!0,gt(i.data,1,r.pts,t.samples);break;case 7:var v,m;a=!0,d=!0;var p=i.data,y=s.readSPS(p);if(!e.sps||e.width!==y.width||e.height!==y.height||(null==(v=e.pixelRatio)?void 0:v[0])!==y.pixelRatio[0]||(null==(m=e.pixelRatio)?void 0:m[1])!==y.pixelRatio[1]){e.width=y.width,e.height=y.height,e.pixelRatio=y.pixelRatio,e.sps=[p];for(var E=p.subarray(1,4),T="avc1.",S=0;S<3;S++){var A=E[S].toString(16);A.length<2&&(A="0"+A),T+=A}e.codec=T}break;case 8:a=!0,e.pps=[i.data];break;case 9:a=!0,e.audFound=!0,null!=(l=u)&&l.frame&&(s.pushAccessUnit(u,e),u=null),u||(u=s.VideoSample=s.createVideoSample(!1,r.pts,r.dts));break;case 12:a=!0;break;default:a=!1}u&&a&&u.units.push(i)})),i&&u&&(this.pushAccessUnit(u,e),this.VideoSample=null)},r.getNALuType=function(e,t){return 31&e[t]},r.readSliceType=function(e){var t=new oa(e);return t.readUByte(),t.readUEG(),t.readUEG()},r.skipScalingList=function(e,t){for(var r=8,i=8,n=0;n=16&&i.type<=23;o=d(c),u=!1}switch(i.type){case 0:case 1:case 2:case 3:case 4:case 5:case 6:case 7:case 8:case 9:o||(o=d(!1)),o.frame=!0,n=!0;break;case 16:case 17:case 18:case 21:case 22:case 23:var g;n=!0,u&&null!=(g=o)&&g.frame&&!o.key&&(a.pushAccessUnit(o,e),o=a.VideoSample=null),o||(o=d(!0)),o.key=!0,o.frame=!0;break;case 19:case 20:n=!0,null!=(h=o)&&h.frame&&!o.key&&(a.pushAccessUnit(o,e),o=a.VideoSample=null),o||(o=d(!0)),o.key=!0,o.frame=!0;break;case 39:n=!0,gt(i.data,2,r.pts,t.samples);break;case 32:n=!0,e.vps||("object"!=typeof e.params&&(e.params={}),e.params=l(e.params,a.readVPS(i.data)),a.initVPS=i.data),e.vps=[i.data];break;case 33:if(n=!0,u=!0,void 0===e.vps||e.vps[0]===a.initVPS||void 0===e.sps||a.matchSPS(e.sps[0],i.data)||(a.initVPS=e.vps[0],e.sps=e.pps=void 0),!e.sps){var v=a.readSPS(i.data);for(var m in e.width=v.width,e.height=v.height,e.pixelRatio=v.pixelRatio,e.codec=v.codecString,e.sps=[],"object"!=typeof e.params&&(e.params={}),v.params)e.params[m]=v.params[m]}a.pushParameterSet(e.sps,i.data,e.vps),o||(o=d(!1));break;case 34:if(n=!0,"object"==typeof e.params){if(!e.pps){e.pps=[];var p=a.readPPS(i.data);for(var y in p)e.params[y]=p[y]}a.pushParameterSet(e.pps,i.data,e.vps)}break;case 35:n=!0,e.audFound=!0,null!=(f=o)&&f.frame&&(a.pushAccessUnit(o,e),o=null),o||(o=d(!1));break;default:n=!1}o&&n&&o.units.push(i)})),i&&o&&(this.pushAccessUnit(o,e),this.VideoSample=null)},r.pushParameterSet=function(e,t,r){(r&&r[0]===this.initVPS||!r&&!e.length)&&e.push(t)},r.startsNewAccessUnit=function(e,t,r,i){if(!e)return!1;if(!t.audFound&&0===i)return!0;if(!e.frame)return!1;var n=r.type,a=r.data;return!!(n>=32&&n<=35||39===n||n>=41&&n<=44||n>=48&&n<=55)||n<=31&&a.length>2&&0!=(128&a[2])},r.getNALuType=function(e,t){return(126&e[t])>>>1},r.ebsp2rbsp=function(e){for(var t=new Uint8Array(e.byteLength),r=0,i=0;i=2&&3===e[i]&&0===e[i-1]&&0===e[i-2]||(t[r]=e[i],r++);return new Uint8Array(t.buffer,0,r)},r.pushAccessUnit=function(t,r){e.prototype.pushAccessUnit.call(this,t,r),this.initVPS&&(this.initVPS=null)},r.readVPS=function(e){var t=new oa(e);return t.readUByte(),t.readUByte(),t.readBits(4),t.skipBits(2),t.readBits(6),{numTemporalLayers:t.readBits(3)+1,temporalIdNested:t.readBoolean()}},r.readSPS=function(e){var t=new oa(this.ebsp2rbsp(e));t.readUByte(),t.readUByte(),t.readBits(4);var r=t.readBits(3);t.readBoolean();for(var i=t.readBits(2),n=t.readBoolean(),a=t.readBits(5),s=t.readUByte(),o=t.readUByte(),l=t.readUByte(),u=t.readUByte(),d=t.readUByte(),h=t.readUByte(),f=t.readUByte(),c=t.readUByte(),g=t.readUByte(),v=t.readUByte(),m=t.readUByte(),p=[],y=[],E=0;E0)for(var T=r;T<8;T++)t.readBits(2);for(var S=0;S1&&t.readEG();for(var N=0;N0&&ae<16?(ee=[1,12,10,16,40,24,20,32,80,18,15,64,160,4,3,2][ae-1],te=[1,11,11,11,33,11,11,11,33,11,11,33,99,3,2,1][ae-1]):255===ae&&(ee=t.readBits(16),te=t.readBits(16))}if(t.readBoolean()&&t.readBoolean(),t.readBoolean()&&(t.readBits(3),t.readBoolean(),t.readBoolean()&&(t.readUByte(),t.readUByte(),t.readUByte())),t.readBoolean()&&(t.readUEG(),t.readUEG()),t.readBoolean(),t.readBoolean(),t.readBoolean(),t.readBoolean()&&(t.skipUEG(),t.skipUEG(),t.skipUEG(),t.skipUEG()),t.readBoolean()&&(ie=t.readBits(32),ne=t.readBits(32),t.readBoolean()&&t.readUEG(),t.readBoolean())){var se=t.readBoolean(),oe=t.readBoolean(),le=!1;(se||oe)&&((le=t.readBoolean())&&(t.readUByte(),t.readBits(5),t.readBoolean(),t.readBits(5)),t.readBits(4),t.readBits(4),le&&t.readBits(4),t.readBits(5),t.readBits(5),t.readBits(5));for(var ue=0;ue<=r;ue++){var de=!1;(re=t.readBoolean())||t.readBoolean()?t.readEG():de=t.readBoolean();var he=de?1:t.readUEG()+1;if(se)for(var fe=0;fe>Se&1)<<31-Se)>>>0;var Ae=Te.toString(16);return 1===a&&"2"===Ae&&(Ae="6"),{codecString:"hvc1."+ye+a+"."+Ae+"."+(n?"H":"L")+m+".B0",params:{general_tier_flag:n,general_profile_idc:a,general_profile_space:i,general_profile_compatibility_flags:[s,o,l,u],general_constraint_indicator_flags:[d,h,f,c,g,v],general_level_idc:m,bit_depth:_+8,bit_depth_luma_minus8:_,bit_depth_chroma_minus8:C,min_spatial_segmentation_idc:J,chroma_format_idc:A,frame_rate:{fixed:re,fps:ne/ie}},width:ge,height:ve,pixelRatio:[ee,te]}},r.readPPS=function(e){var t=new oa(this.ebsp2rbsp(e));t.readUByte(),t.readUByte(),t.skipUEG(),t.skipUEG(),t.skipBits(2),t.skipBits(3),t.skipBits(2),t.skipUEG(),t.skipUEG(),t.skipEG(),t.skipBits(2),t.readBoolean()&&t.skipUEG(),t.skipEG(),t.skipEG(),t.skipBits(4);var r=t.readBoolean(),i=t.readBoolean(),n=1;return i&&r?n=0:i?n=3:r&&(n=2),{parallelismType:n}},r.matchSPS=function(e,t){return Ue(e).substring(3)===Ue(t).substring(3)},t}(sa),da=188,ha=function(){function e(e,t,r,i){this.logger=void 0,this.observer=void 0,this.config=void 0,this.typeSupported=void 0,this.sampleAes=null,this.pmtParsed=!1,this.audioCodec=void 0,this.videoCodec=void 0,this._pmtId=-1,this._videoTrack=void 0,this._audioTrack=void 0,this._id3Track=void 0,this._txtTrack=void 0,this._klvPid=-1,this.aacOverFlow=null,this.remainderData=null,this.videoParser=void 0,this.videoIntegrityChecker=null,this.observer=e,this.config=t,this.typeSupported=r,this.logger=i,this.videoParser=null}e.probe=function(t,r){var i=e.syncOffset(t);return i>0&&r.warn("MPEG2-TS detected but first sync word found @ offset "+i),-1!==i},e.syncOffset=function(e){for(var t=e.length,r=Math.min(940,t-da)+1,i=0;i1&&(0===a&&s>2||o+da>r))return a}i++}return-1},e.createTrack=function(e,t){return{container:"video"===e||"audio"===e?"video/mp2t":void 0,type:e,id:Ve[e],pid:-1,inputTimeScale:9e4,sequenceNumber:0,samples:[],dropped:0,duration:"audio"===e?t:void 0}};var t=e.prototype;return t.resetInitSegment=function(t,r,i,n,a,s){this.pmtParsed=!1,this._pmtId=-1,this._videoTrack=e.createTrack("video"),this._videoTrack.duration=n,this.videoIntegrityChecker="skip"===this.config.handleMpegTsVideoIntegrityErrors?new Ea(this.logger):null,this._audioTrack=e.createTrack("audio",n),this._id3Track=e.createTrack("id3"),this._txtTrack=e.createTrack("text"),this._audioTrack.segmentCodec="aac",this.videoParser=null,this.aacOverFlow=null,this.remainderData=null,this.audioCodec=r,this.videoCodec=i,t&&this.demux(t,0,s,"SAMPLE-AES"===(null==a?void 0:a.method))},t.resetTimeStamp=function(){},t.resetContiguity=function(){var e=this._audioTrack,t=this._videoTrack,r=this._id3Track;e&&(e.pesData=null),t&&(t.pesData=null),r&&(r.pesData=null),this.aacOverFlow=null,this.remainderData=null},t.demux=function(t,r,i,n,a){var s;void 0===n&&(n=!1),void 0===a&&(a=!1),n||(this.sampleAes=null);var o=this._videoTrack,l=this.videoIntegrityChecker,u=this._audioTrack,d=this._id3Track,h=this._txtTrack,f=i.iframe,c=o.pid,g=o.pesData,v=u.pid,m=d.pid,p=this._klvPid,y=u.pesData,E=d.pesData,T=null,S=null,A=this.pmtParsed,I=this._pmtId,L=t.length;if(this.remainderData&&(L=(t=ht(this.remainderData,t)).length,this.remainderData=null),L>4>1){if((_=k+5+t[k+4])===k+da)continue}else _=k+4;switch(P){case c:D&&(!g||null!=l&&l.isCorrupted||!(s=pa(g,this.logger))||(this.readyVideoParser(o.segmentCodec),null!==this.videoParser&&this.videoParser.parsePES(o,h,s,!1,i)),g={data:[],size:0},null==l||l.reset(c)),null==l||l.handlePacket(t.subarray(k)),g&&(g.data.push(t.subarray(_,k+da)),g.size+=k+da-_);break;case v:if(f)break;if(D){if(y&&(s=pa(y,this.logger)))switch(u.segmentCodec){case"aac":this.parseAACPES(u,s,i);break;case"mp3":this.parseMPEGPES(u,s);break;case"ac3":this.parseAC3PES(u,s)}y={data:[],size:0}}y&&(y.data.push(t.subarray(_,k+da)),y.size+=k+da-_);break;case m:if(f)break;D&&(E&&(s=pa(E,this.logger))&&this.parseID3PES(d,s),E={data:[],size:0}),E&&(E.data.push(t.subarray(_,k+da)),E.size+=k+da-_);break;case p:if(f)break;D&&(T&&(s=pa(T,this.logger))&&this.parseKlvPES(d,s),T={data:[],size:0}),T&&(T.data.push(t.subarray(_,k+da)),T.size+=k+da-_);break;case 0:D&&(_+=t[_]+1),I=this._pmtId=ca(t,_);break;case I:D&&(_+=t[_]+1);var C=ga(t,_,this.typeSupported,n,this.observer,this.logger,this.config,i);(c=C.videoPid)>0&&(o.pid=c,o.segmentCodec=C.segmentVideoCodec),(v=C.audioPid)>0&&(u.pid=v,u.segmentCodec=C.segmentAudioCodec),(m=C.id3Pid)>0&&(d.pid=m),(p=C.klvPid)>0&&(this._klvPid=p),null===S||A||(this.logger.warn("MPEG-TS PMT found at "+k+" after unknown PID '"+S+"'. Backtracking to sync byte @"+R+" to parse all TS packets."),S=null,k=R-188),A=this.pmtParsed=!0;break;case 17:case 8191:break;default:S=P}}else b++;b>0&&va(this.observer,new Error("Found "+b+" TS packet/s that do not start with 0x47"),i,void 0,this.logger),o.pesData=g,u.pesData=y,d.pesData=E;var w={audioTrack:u,videoTrack:o,id3Track:d,textTrack:h};return a&&this.extractRemainingSamples(w,i),w},t.flush=function(e,t){var r,i=this.remainderData;return this.remainderData=null,r=i?this.demux(i,0,t,!1,!0):{videoTrack:this._videoTrack,audioTrack:this._audioTrack,id3Track:this._id3Track,textTrack:this._txtTrack},this.extractRemainingSamples(r,t),this.sampleAes?this.decrypt(r,this.sampleAes):r},t.extractRemainingSamples=function(e,t){var r,i=e.audioTrack,n=e.videoTrack,a=e.id3Track,s=e.textTrack,o=n.pesData,l=i.pesData,u=a.pesData,d=this.videoIntegrityChecker;if(!o||null!=d&&d.isCorrupted||!(r=pa(o,this.logger))?n.pesData=o:(this.readyVideoParser(n.segmentCodec),null!==this.videoParser&&(this.videoParser.parsePES(n,s,r,!0,t),n.pesData=null)),l&&(r=pa(l,this.logger))){switch(i.segmentCodec){case"aac":this.parseAACPES(i,r,t);break;case"mp3":this.parseMPEGPES(i,r);break;case"ac3":this.parseAC3PES(i,r)}i.pesData=null}else null!=l&&l.size&&this.logger.log("last AAC PES packet truncated,might overlap between fragments"),i.pesData=l;u&&(r=pa(u,this.logger))?(this.parseID3PES(a,r),a.pesData=null):a.pesData=u},t.demuxSampleAes=function(e,t,r,i){var n=this.demux(e,r,i,!0,!this.config.progressive),a=this.sampleAes=new $n(this.observer,this.config,t);return this.decrypt(n,a)},t.readyVideoParser=function(e){null===this.videoParser&&("avc"===e?this.videoParser=new la:"hevc"===e&&(this.videoParser=new ua))},t.decrypt=function(e,t){return new Promise((function(r){var i=e.audioTrack,n=e.videoTrack;i.samples&&"aac"===i.segmentCodec?t.decryptAacSamples(i.samples,0,(function(){n.samples?t.decryptAvcSamples(n.samples,0,0,(function(){r(e)})):r(e)})):n.samples&&t.decryptAvcSamples(n.samples,0,0,(function(){r(e)}))}))},t.destroy=function(){this.observer&&this.observer.removeAllListeners(),this.config=this.logger=this.observer=null,this.aacOverFlow=this.videoParser=this.remainderData=this.sampleAes=null,this._videoTrack=this._audioTrack=this._id3Track=this._txtTrack=void 0,this.videoIntegrityChecker=null},t.parseAACPES=function(e,t,r){var i,n,a,s=0,o=this.aacOverFlow,l=t.data;if(o){this.aacOverFlow=null;var u=o.missing,d=o.sample.unit.byteLength;if(-1===u)l=ht(o.sample.unit,l);else{var h=d-u;o.sample.unit.set(l.subarray(0,u),h),e.samples.push(o.sample),s=o.missing}}for(i=s,n=l.length;i0;)o+=n;else this.logger.warn("[tsdemuxer]: AC3 PES unknown PTS")},t.parseID3PES=function(e,t){if(void 0!==t.pts){var r=l({},t,{type:this._videoTrack?Un.emsg:Un.audioId3,duration:Number.POSITIVE_INFINITY});e.samples.push(r)}else this.logger.warn("[tsdemuxer]: ID3 PES unknown PTS")},t.parseKlvPES=function(e,t){var r,i=null!=(r=t.pts)?r:t.dts;if(void 0!==i){var n=t.data;if(n.length<16)this.logger.warn("[tsdemuxer]: KLV PES payload too short");else for(var a=0;an.length)break;var o=a;if((a+=16)>=n.length)break;var l=n[a];a+=1;var u=0;if(128&l){var d=127&l;if(0===d||d>4||a+d>n.length){this.logger.warn("[tsdemuxer]: Invalid KLV length encoding");break}for(var h=0;hn.length){this.logger.warn("[tsdemuxer]: KLV value extends beyond PES payload");break}var f=a+u,c=n.subarray(o,f),g={data:c,len:c.byteLength,pts:i,dts:null!=(s=t.dts)?s:i,type:Un.misbklv,duration:Number.POSITIVE_INFINITY};e.samples.push(g),a=f}}else this.logger.warn("[tsdemuxer]: KLV PES unknown PTS and DTS")},e}();function fa(e,t){return((31&e[t+1])<<8)+e[t+2]}function ca(e,t){return(31&e[t+10])<<8|e[t+11]}function ga(e,t,r,i,n,a,s,o){var l={audioPid:-1,videoPid:-1,id3Pid:-1,klvPid:-1,segmentVideoCodec:"avc",segmentAudioCodec:"aac"},u=t+3+((15&e[t+1])<<8|e[t+2])-4;for(t+=12+((15&e[t+10])<<8|e[t+11]);t0)for(var f=t+5,c=h;c>2;){switch(e[f]){case 106:-1===l.audioPid&&(!0!==r.ac3?a.log("AC-3 audio found, not supported in this browser for now"):(l.audioPid=d,l.segmentAudioCodec="ac3"));break;case 5:-1===l.klvPid&&s.enableEmsgKLVMetadata&&(l.klvPid=d,a.log("KLV metadata PID found: "+d))}var g=e[f+1]+2;f+=g,c-=g}break;case 194:case 135:return va(n,new Error("Unsupported EC-3 in M2TS found"),o,void 0,a),l;case 36:-1===l.videoPid&&(l.videoPid=d,l.segmentVideoCodec="hevc",a.log("HEVC in M2TS found"))}t+=h+5}return l}function va(e,t,r,i,n){n.warn("parsing error: "+t.message),e.emit(G.ERROR,G.ERROR,{type:U.MEDIA_ERROR,details:B.FRAG_PARSING_ERROR,fatal:!1,levelRetry:i,chunkMeta:r,error:t,reason:t.message})}function ma(e,t){t.log(e+" with AES-128-CBC encryption found in unencrypted stream")}function pa(e,t){var r,i,n,a,s,o=0,l=e.data;if(!e||0===e.size)return null;for(;l[0].length<19&&l.length>1;)l[0]=ht(l[0],l[1]),l.splice(1,1);if(1===((r=l[0])[0]<<16)+(r[1]<<8)+r[2]){if((i=(r[4]<<8)+r[5])&&i>e.size-6)return null;var u=r[7];192&u&&(a=536870912*(14&r[9])+4194304*(255&r[10])+16384*(254&r[11])+128*(255&r[12])+(254&r[13])/2,64&u?a-(s=536870912*(14&r[14])+4194304*(255&r[15])+16384*(254&r[16])+128*(255&r[17])+(254&r[18])/2)>54e5&&(t.warn(Math.round((a-s)/9e4)+"s delta between PTS and DTS, align them"),a=s):s=a);var d=(n=r[8])+9;if(e.size<=d)return null;e.size-=d;for(var h=new Uint8Array(e.size),f=0,c=l.length;fg){d-=g;continue}r=r.subarray(d),g-=d,d=0}h.set(r,o),o+=g}return i&&(i-=n+3),{data:h,pts:a,dts:s,len:i}}return null}var ya,Ea=function(){function e(e){this.logger=void 0,this.pid=0,this.lastContinuityCounter=-1,this.integrityState="ok",this.logger=e}var t=e.prototype;return t.reset=function(e){this.pid=e,this.lastContinuityCounter=-1,this.integrityState="ok"},t.handlePacket=function(e){if(!(e.byteLength<4)){var t=fa(e,0);if(t===this.pid){var r=(48&e[3])>>4;if(0!==r){var i=15&e[3],n=this.lastContinuityCounter;this.lastContinuityCounter=i;var a=0!=(1&r);if(!(0!=(2&r)&&0!=e[4]&&0!=(128&e[5])||n<0)){var s=a?n+1&15:n;return i!==s?(this.logger.warn("MPEG-TS Continuity Counter check failed for PID='"+t+"', CC="+i+", Expected-CC="+s+" Last-CC="+n),void(this.integrityState="cc-failed")):0!=(128&e[1])?(this.logger.warn("MPEG-TS Packet had TEI flag set for PID='"+t+"'"),void(this.integrityState="tei-bit")):void 0}}}else this.logger.debug("Packet PID mismatch, expected "+this.pid+" got "+t)}},a(e,[{key:"isCorrupted",get:function(){return"ok"!==this.integrityState}}])}(),Ta=function(){function e(){}return e.getSilentFrame=function(e,t){if("mp4a.40.2"===e){if(1===t)return new Uint8Array([0,200,0,128,35,128]);if(2===t)return new Uint8Array([33,0,73,144,2,25,0,35,128]);if(3===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,142]);if(4===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,128,44,128,8,2,56]);if(5===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,130,48,4,153,0,33,144,2,56]);if(6===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,130,48,4,153,0,33,144,2,0,178,0,32,8,224])}else{if(1===t)return new Uint8Array([1,64,34,128,163,78,230,128,186,8,0,0,0,28,6,241,193,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94]);if(2===t)return new Uint8Array([1,64,34,128,163,94,230,128,186,8,0,0,0,0,149,0,6,241,161,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94]);if(3===t)return new Uint8Array([1,64,34,128,163,94,230,128,186,8,0,0,0,0,149,0,6,241,161,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94])}},e}(),Sa={video:new Uint8Array([0,0,0,0,0,0,0,0,118,105,100,101,0,0,0,0,0,0,0,0,0,0,0,0,86,105,100,101,111,72,97,110,100,108,101,114,0]),audio:new Uint8Array([0,0,0,0,0,0,0,0,115,111,117,110,0,0,0,0,0,0,0,0,0,0,0,0,83,111,117,110,100,72,97,110,100,108,101,114,0])},Aa=new Uint8Array([0,0,0,0,0,0,0,0]),Ia=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0]),La=new Uint8Array([0,0,0,1,0,0,0,0,0,0,0,0]),Ra=new Uint8Array([0,0,0,0,0,0,0,0]),ba=new Uint8Array([0,0,0,0,0,0,0,1]),ka=new Uint8Array([105,115,111,109]),Da=new Uint8Array([97,118,99,49]),Pa=new Uint8Array([0,0,0,1]),_a=function(){function e(){}return e.box=function(e){for(var t=8,r=arguments.length,i=new Array(r>1?r-1:0),n=1;n>24&255,t>>16&255,t>>8&255,255&t,i>>24,i>>16&255,i>>8&255,255&i,n>>24,n>>16&255,n>>8&255,255&n,85,196,0,0]))},e.mdia=function(t){return e.box(Ke.mdia,e.mdhd(t.timescale||0,t.duration||0),e.hdlr(t.type),e.minf(t))},e.mfhd=function(t){return e.box(Ke.mfhd,new Uint8Array([0,0,0,0,t>>24,t>>16&255,t>>8&255,255&t]))},e.minf=function(t){return"audio"===t.type?e.box(Ke.minf,e.box(Ke.smhd,Ra),e.DINF,e.stbl(t)):e.box(Ke.minf,e.box(Ke.vmhd,La),e.DINF,e.stbl(t))},e.moof=function(t,r,i){return e.box(Ke.moof,e.mfhd(t),e.traf(i,r))},e.moov=function(t){for(var r=t.length,i=[];r--;)i[r]=e.trak(t[r]);return e.box.apply(null,[Ke.moov,e.mvhd(t[0].timescale||0,t[0].duration||0)].concat(i).concat(e.mvex(t)))},e.mvex=function(t){for(var r=t.length,i=[];r--;)i[r]=e.trex(t[r]);return e.box.apply(null,[Ke.mvex].concat(i))},e.mvhd=function(t,r){r*=t;var i=Math.floor(r/(Ge+1)),n=Math.floor(r%(Ge+1)),a=new Uint8Array([1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,t>>24&255,t>>16&255,t>>8&255,255&t,i>>24,i>>16&255,i>>8&255,255&i,n>>24,n>>16&255,n>>8&255,255&n,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255]);return e.box(Ke.mvhd,a)},e.sdtp=function(t){var r,i,n=t.samples||[],a=new Uint8Array(4+n.length);for(r=0;r>>8&255),a.push(255&n),a=a.concat(Array.prototype.slice.call(i));for(r=0;r>>8&255),s.push(255&n),s=s.concat(Array.prototype.slice.call(i));var o=e.box(Ke.avcC,new Uint8Array([1,a[3],a[4],a[5],255,224|t.sps.length].concat(a).concat([t.pps.length]).concat(s))),l=t.width,u=t.height,d=t.pixelRatio[0],h=t.pixelRatio[1];return e.box(Ke.avc1,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,l>>8&255,255&l,u>>8&255,255&u,0,72,0,0,0,72,0,0,0,0,0,0,0,1,18,100,97,105,108,121,109,111,116,105,111,110,47,104,108,115,46,106,115,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,17,17]),o,e.box(Ke.btrt,new Uint8Array([0,28,156,128,0,45,198,192,0,45,198,192])),e.box(Ke.pasp,new Uint8Array([d>>24,d>>16&255,d>>8&255,255&d,h>>24,h>>16&255,h>>8&255,255&h])))},e.esds=function(e){var t=e.config,r=t.length;return new Uint8Array([0,0,0,0,3,23+r,0,1,0,4,15+r,64,21,0,0,0,0,0,0,0,0,0,0,0,5,r].concat(t,[6,1,2]))},e.audioStsd=function(e){var t=e.samplerate||0;return new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,e.channelCount||0,0,16,0,0,0,0,t>>8&255,255&t,0,0])},e.mp4a=function(t){return e.box(Ke.mp4a,e.audioStsd(t),e.box(Ke.esds,e.esds(t)))},e.mp3=function(t){return e.box(Ke[".mp3"],e.audioStsd(t))},e.ac3=function(t){return e.box(Ke["ac-3"],e.audioStsd(t),e.box(Ke.dac3,t.config))},e.stsd=function(t){var r=t.segmentCodec;if("audio"===t.type){if("aac"===r)return e.box(Ke.stsd,ba,e.mp4a(t));if("ac3"===r&&t.config)return e.box(Ke.stsd,ba,e.ac3(t));if("mp3"===r&&"mp3"===t.codec)return e.box(Ke.stsd,ba,e.mp3(t))}else{if(!t.pps||!t.sps)throw new Error("video track missing pps or sps");if("avc"===r)return e.box(Ke.stsd,ba,e.avc1(t));if("hevc"===r&&t.vps)return e.box(Ke.stsd,ba,e.hvc1(t))}throw new Error("unsupported "+t.type+" segment codec ("+r+"/"+t.codec+")")},e.tkhd=function(t){var r=t.id,i=(t.duration||0)*(t.timescale||0),n=t.width||0,a=t.height||0,s=Math.floor(i/(Ge+1)),o=Math.floor(i%(Ge+1));return e.box(Ke.tkhd,new Uint8Array([1,0,0,7,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,r>>24&255,r>>16&255,r>>8&255,255&r,0,0,0,0,s>>24,s>>16&255,s>>8&255,255&s,o>>24,o>>16&255,o>>8&255,255&o,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,n>>8&255,255&n,0,0,a>>8&255,255&a,0,0]))},e.traf=function(t,r){var i=e.sdtp(t),n=t.id,a=Math.floor(r/(Ge+1)),s=Math.floor(r%(Ge+1));return e.box(Ke.traf,e.box(Ke.tfhd,new Uint8Array([0,0,0,0,n>>24,n>>16&255,n>>8&255,255&n])),e.box(Ke.tfdt,new Uint8Array([1,0,0,0,a>>24,a>>16&255,a>>8&255,255&a,s>>24,s>>16&255,s>>8&255,255&s])),e.trun(t,i.length+16+20+8+16+8+8),i)},e.trak=function(t){return t.duration=t.duration||4294967295,e.box(Ke.trak,e.tkhd(t),e.mdia(t))},e.trex=function(t){var r=t.id;return e.box(Ke.trex,new Uint8Array([0,0,0,0,r>>24,r>>16&255,r>>8&255,255&r,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,1]))},e.trun=function(t,r){var i,n,a,s,o,l,u=t.samples||[],d=u.length,h=12+16*d,f=new Uint8Array(h);for(r+=8+h,f.set(["video"===t.type?1:0,0,15,1,d>>>24&255,d>>>16&255,d>>>8&255,255&d,r>>>24&255,r>>>16&255,r>>>8&255,255&r],0),i=0;i>>24&255,a>>>16&255,a>>>8&255,255&a,s>>>24&255,s>>>16&255,s>>>8&255,255&s,o.isLeading<<2|o.dependsOn,o.isDependedOn<<6|o.hasRedundancy<<4|o.paddingValue<<1|o.isNonSync,61440&o.degradPrio,15&o.degradPrio,l>>>24&255,l>>>16&255,l>>>8&255,255&l],12+16*i);return e.box(Ke.trun,f)},e.initSegment=function(t){var r=e.moov(t);return ht(e.FTYP,r)},e.hvc1=function(t){for(var r=t.params,i=[t.vps,t.sps,t.pps],n=new Uint8Array([1,r.general_profile_space<<6|(r.general_tier_flag?32:0)|r.general_profile_idc,r.general_profile_compatibility_flags[0],r.general_profile_compatibility_flags[1],r.general_profile_compatibility_flags[2],r.general_profile_compatibility_flags[3],r.general_constraint_indicator_flags[0],r.general_constraint_indicator_flags[1],r.general_constraint_indicator_flags[2],r.general_constraint_indicator_flags[3],r.general_constraint_indicator_flags[4],r.general_constraint_indicator_flags[5],r.general_level_idc,240|r.min_spatial_segmentation_idc>>8,255&r.min_spatial_segmentation_idc,252|r.parallelismType,252|r.chroma_format_idc,248|r.bit_depth_luma_minus8,248|r.bit_depth_chroma_minus8,0,parseInt(r.frame_rate.fps),3|r.temporal_id_nested<<2|r.num_temporal_layers<<3|(r.frame_rate.fixed?64:0),i.length]),a=n.length,s=0;s>8,255&i[d][h].length]),a),a+=2,l.set(i[d][h],a),a+=i[d][h].length}var f=e.box(Ke.hvcC,l),c=t.width,g=t.height,v=t.pixelRatio[0],m=t.pixelRatio[1];return e.box(Ke.hvc1,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,c>>8&255,255&c,g>>8&255,255&g,0,72,0,0,0,72,0,0,0,0,0,0,0,1,18,100,97,105,108,121,109,111,116,105,111,110,47,104,108,115,46,106,115,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,17,17]),f,e.box(Ke.btrt,new Uint8Array([0,28,156,128,0,45,198,192,0,45,198,192])),e.box(Ke.pasp,new Uint8Array([v>>24,v>>16&255,v>>8&255,255&v,m>>24,m>>16&255,m>>8&255,255&m])))},e}();ya=_a,_a.FTYP=ya.box(Ke.ftyp,ka,Pa,ka,Da),_a.DINF=ya.box(Ke.dinf,ya.box(Ke.dref,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,12,117,114,108,32,0,0,0,1])));var Ca=9e4;function wa(e,t,r,i){void 0===r&&(r=1),void 0===i&&(i=!1);var n=e*t*r;return i?Math.round(n):n}function Oa(e,t){return wa(e,1e3,1/Ca,t)}function Ma(e){var t=e.baseTime,r=e.timescale;return t/r+" ("+t+"/"+r+") trackId: "+e.trackId}var xa=8589934592;function Fa(e,t,r,i){return{duration:t,size:r,cts:i,flags:{isLeading:0,isDependedOn:0,hasRedundancy:0,degradPrio:0,dependsOn:e?2:1,isNonSync:e?0:1,paddingValue:0}}}var Na=function(e){function t(t,r,i,n){var a;return(a=e.call(this,"mp4-remuxer",n)||this).observer=void 0,a.config=void 0,a.typeSupported=void 0,a.ISGenerated=!1,a._initPTS=null,a._initDTS=null,a.nextVideoTs=null,a.nextAudioTs=null,a.videoSampleDuration=null,a.isAudioContiguous=!1,a.isVideoContiguous=!1,a.videoTrackConfig=void 0,a.observer=t,a.config=r,a.typeSupported=i,a.ISGenerated=!1,a}d(t,e);var r=t.prototype;return r.destroy=function(){this.config=this.videoTrackConfig=this._initPTS=this._initDTS=null},r.resetTimeStamp=function(e){var t=this._initPTS;t&&e&&e.trackId===t.trackId&&e.baseTime===t.baseTime&&e.timescale===t.timescale||this.log("Reset initPTS: "+(t?Ma(t):t)+" > "+(e?Ma(e):e)),this._initPTS=this._initDTS=e},r.resetNextTimestamp=function(){this.log("reset next timestamp"),this.isVideoContiguous=!1,this.isAudioContiguous=!1},r.resetInitSegment=function(){this.log("ISGenerated flag reset"),this.ISGenerated=!1,this.videoTrackConfig=void 0},r.getVideoStartPts=function(e){var t=!1,r=e[0].pts,i=e.reduce((function(e,i){var n=i.pts,a=n-e;return a<-4294967296&&(t=!0,a=(n=Ua(n,r))-e),a>0?e:n}),r);return t&&this.debug("PTS rollover detected"),i},r.remux=function(e,t,r,i,n,a,s,o,l){var u,d,h,f,c,g,v=n,m=n,p=!l.iframe&&e.pid>-1,y=t.pid>-1,E=t.samples.length,T=e.samples.length>0,S=s&&E>0||E>1;if((!p||T)&&(!y||S)||this.ISGenerated||s){if(this.ISGenerated){var A,I,L,R,b=this.videoTrackConfig;(b&&(t.width!==b.width||t.height!==b.height||(null==(A=t.pixelRatio)?void 0:A[0])!==(null==(I=b.pixelRatio)?void 0:I[0])||(null==(L=t.pixelRatio)?void 0:L[1])!==(null==(R=b.pixelRatio)?void 0:R[1]))||!b&&S||null===this.nextAudioTs&&T)&&this.resetInitSegment()}this.ISGenerated||(h=this.generateIS(e,t,n,a));var k,D=this.isVideoContiguous,P=-1;if(S&&(P=function(e){for(var t=0;t0){this.warn("Dropped "+P+" out of "+E+" video samples due to a missing keyframe");var _=this.getVideoStartPts(t.samples);t.samples=t.samples.slice(P),t.dropped+=P,k=m+=(t.samples[0].pts-_)/t.inputTimeScale}else-1===P&&(this.warn("No keyframe found out of "+E+" video samples"),g=!1);if(this.ISGenerated){if(T&&S&&n){var C=this.getVideoStartPts(t.samples),w=(Ua(e.samples[0].pts,C)-C)/t.inputTimeScale;v+=Math.max(0,w),m+=Math.max(0,-w)}if(T){if(e.samplerate||(this.warn("regenerate InitSegment as audio detected"),h=this.generateIS(e,t,n,a)),d=this.remuxAudio(e,v,this.isAudioContiguous,a,y||S||o===H.AUDIO?m:void 0,l),S){var O=d?d.endPTS-d.startPTS:0;t.inputTimeScale||(this.warn("regenerate InitSegment as video detected"),h=this.generateIS(e,t,n,a)),u=this.remuxVideo(t,m,D,O,l)}}else S&&(u=this.remuxVideo(t,m,D,0,l));u&&(u.firstKeyFrame=P,u.independent=-1!==P,u.firstKeyFramePTS=k)}}return this.ISGenerated&&this._initPTS&&this._initDTS&&(r.samples.length&&(c=Ba(r,n,this._initPTS,this._initDTS)),i.samples.length&&(f=Ga(i,n,this._initPTS))),{audio:d,video:u,initSegment:h,independent:g,text:f,id3:c}},r.computeInitPts=function(e,t,r,i){var n=Math.round(r*t),a=Ua(e,n);if(a0?I-1:I].dts&&(E=!0)}E&&u.sort((function(e,t){var r=e.dts-t.dts,i=e.pts-t.pts;return r||i})),a=u[0].dts;var R=(s=u[u.length-1].dts)-a,b=R?Math.round(R/(h-1)):m||e.inputTimeScale/30;if(r){var k=a-A,D=k>b,P=k<-1;if((D||P)&&(D?this.warn((e.segmentCodec||"").toUpperCase()+": "+Oa(k,!0)+" ms ("+k+"dts) hole between fragments detected at "+t.toFixed(3)):this.warn((e.segmentCodec||"").toUpperCase()+": "+Oa(-k,!0)+" ms ("+k+"dts) overlapping between fragments detected at "+t.toFixed(3)),!P||A>=u[0].pts||Rt())){a=A;var _=u[0].pts-k;if(D)u[0].dts=a,u[0].pts=_;else for(var C=!0,w=0;w_&&C);w++){var O=u[w].pts;if(u[w].dts-=k,u[w].pts-=k,w0?te.dts-u[ee-1].dts:b;if(ue=ee>0?te.pts-u[ee-1].pts:b,de.stretchShortVideoTrack&&null!==this.nextAudioTs){var fe=Math.floor(de.maxBufferHole*o),ce=(i?p+i*o:this.nextAudioTs+c)-te.pts;ce>fe?((m=ce-he)<0?m=he:z=!0,this.log("It is approximately "+ce/90+" ms to the next segment; using duration "+m/90+" ms for the last video frame.")):m=he}else m=he}var ge=Math.round(te.pts-te.dts);if(Q=Math.min(Q,m),Z=Math.max(Z,m),$=Math.min($,ue),J=Math.max(J,ue),!r&&0===ee&&ge>m){var ve=Math.round(ge/b)*b;ve-ge==1&&(this.log("pad first CTS "+ge+" -> "+ve+" of sn: "+n.sn),ge=ve)}d.push(Fa(te.key,m,ie,ge))}if(d.length){var me=Rt();if(me){if(me<70){var pe=d[0].flags;pe.dependsOn=2,pe.isNonSync=0}}else if(function(){if(void 0===St){var e=At.match(/Safari\/(\d+)/i);St=e?parseInt(e[1]):0}return St}()&&J-$0&&(i&&Math.abs(E-(p+y))<9e3||Math.abs(Ua(v[0].pts,E)-(p+y))<20*d),v.forEach((function(e){e.pts=Ua(e.pts,E)})),!r||p<0){var T=v.length;if(v=v.filter((function(e){return e.pts>=0})),T!==v.length&&this.warn("Removed "+(v.length-T)+" of "+T+" samples (initPTS "+y+" / "+s+")"),!v.length)return;p=0===n?0:i&&!g?Math.max(0,E-y):v[0].pts-y}if("aac"===e.segmentCodec)for(var S=this.config.maxAudioFramesDrift,A=0,I=p+y;A=S*d&&k<1e4&&g){var D=Math.round(b/d);for(I=R-D*d;I<0&&D&&d;)D--,I+=d;0===A&&(this.nextAudioTs=p=I-y),this.warn("Injecting "+D+" audio frames @ "+((I-y)/s).toFixed(3)+"s due to "+Math.round(1e3*b/s)+" ms gap.");for(var P=0;P0))return;M+=m;try{C=new Uint8Array(M)}catch(e){return void this.observer.emit(G.ERROR,G.ERROR,{type:U.MUX_ERROR,details:B.REMUX_ALLOC_ERROR,fatal:!1,chunkMeta:a,error:e,bytes:M,reason:"fail allocating audio mdat "+M})}f||(Xe(C,0,M),Xe(C,4,Ke.mdat))}C&&C.set(V,m);var Y=V.byteLength;m+=Y,c.push(Fa(!0,u,Y,0)),O=H}var j=c.length;if(j){var W=c[c.length-1];p=O-y,this.nextAudioTs=p+o*W.duration;var q=f?new Uint8Array(0):_a.moof(e.sequenceNumber++,w/o,l({},e,{samples:c}));e.samples=[];var X=(w-y)/s,z=this.nextAudioTs/s,Q={data1:q,data2:C,startPTS:X,endPTS:z,startDTS:X,endDTS:z,type:"audio",hasAudio:!0,hasVideo:!1,nb:j};return this.isAudioContiguous=!0,Q}},t}(ue);function Ua(e,t){var r;if(null===t)return e;for(r=t4294967296;)e+=r;return e}function Ba(e,t,r,i){var n=e.samples.length;if(n){for(var a=e.inputTimeScale,s=0;ssinf>>tenc' box: "+xe(i)+" -> "+xe(r)),e.set(r,8))}))}}(e,t);else{var l=s||o;null!=l&&l.encrypted&&this.warn('Init segment with encrypted track with has no key ("'+l.codec+'")!')}s&&(i=Ya(s,Y.AUDIO,this)),o&&(n=Ya(o,Y.VIDEO,this));var u={};s&&o?u.audiovideo={container:"video/mp4",codec:i+","+n,supplemental:o.supplemental,encrypted:o.encrypted,initSegment:e,id:"main"}:s?u.audio={container:"audio/mp4",codec:i,encrypted:s.encrypted,initSegment:e,id:"audio"}:o?u.video={container:"video/mp4",codec:n,supplemental:o.supplemental,encrypted:o.encrypted,initSegment:e,id:"main"}:this.warn("initSegment does not contain moov or trak boxes."),this.initTracks=u},r.remux=function(e,t,r,i,n,a,s,o,l,u,d){var h,f,c=this.initPTS,g=this.lastEndTime,v={audio:void 0,video:void 0,text:void 0,id3:r,initSegment:void 0};x(g)||(g=this.lastEndTime=n||0);var m=t.samples;if(!m.length)return v;var p=this.pendingInitSegment;p&&(this.pendingInitSegment=void 0,this.generateInitSegment(p,this.decryptdata,u));var y={initPTS:void 0,timescale:void 0,trackId:void 0},E=this.initData;if(null!=(h=E)&&h.length||(this.generateInitSegment(m),E=this.initData),null==(f=E)||!f.length)return this.warn("Failed to generate initSegment."),v;if(l.iframe&&E.audio&&E.video){var T,S,A=null==(T=this.initTracks)||null==(T=T.audiovideo)?void 0:T.initSegment,I=A?function(e){var t=Ze(e,["moov"]);if(1!==t.length||Ze(e,["moof"]).length)return null;for(var r=e.byteOffset,i=[],n=[],a=!1,s=Ze(t[0],["trak"]),o=0;oM||w>F)&&this.warn("audio and video track sample timestamps do not overlap. v: "+w+"-"+M+" a: "+O+"-"+F+"}",k,b);var U=!!b&&(!k||!c&&O=1&&E.video&&l.iframe){var V,H=k.trun,Y=k.start,j=1===H.length,W=H.length?j?H[0].samples:(V=H[0].samples).concat.apply(V,H.slice(1).map((function(e){return e.samples}))):[];if(W.length){for(var q=H[0],X=q,z=H.length;z--;)if(H[z].samples.length){X=H[z];break}var Q=X.lastSampleDurationOffset,$=q.defaultSampleDurationOffset,Z=Math.abs(l.duration-(M-w))>.05,J=W.length=n}(c,C,n,N)||ne!==c.timescale)){var oe=!1,le=U?"audio":"video";if(c){var ue=0!==n?C/n:1+C/1;oe=C>=0&&Math.abs(1-ue)<.001,this.log(le+" timestamps in track "+se+" at playlist time: "+(a?"":"~")+n+" maps to "+C+" with initPTS: "+c.baseTime/c.timescale+" ("+(ae/ne-c.baseTime/c.timescale)+"s diff) ("+_+") drift estimate: "+ue+" "+(oe?"(ignoring drift)":"remapping timestamps (initPTS)"))}oe||(this.log("Found initPTS in "+le+" track "+se+" at playlist time: "+n+" offset: "+(C-n)+" ("+ae+"/"+ne+")"),c=null,y.initPTS=ae,y.timescale=ne,y.trackId=se)}c?(y.initPTS=c.baseTime,y.timescale=c.timescale,y.trackId=c.trackId):(y.timescale&&void 0!==y.trackId&&void 0!==y.initPTS||(this.warn("Could not set initPTS"),y.initPTS=C,y.timescale=1,y.trackId=-1),this.initPTS=c={baseTime:y.initPTS,timescale:y.timescale,trackId:y.trackId});var de=C-c.baseTime/c.timescale,he=de+N,fe=P&&void 0!==(null==B?void 0:B.ptsMin)?B.ptsMin/B.timescale-c.baseTime/c.timescale:de,ce=void 0!==G&&k?G/k.timescale-c.baseTime/c.timescale:P&&null!=B&&B.ptsMax?B.ptsMax/B.timescale-c.baseTime/c.timescale:he;N>0?this.lastEndTime=he:(this.warn("Duration parsed from mp4 should be greater than zero"),this.resetNextTimestamp());var ge={data1:K,data2:void 0,startPTS:fe,startDTS:de,endPTS:ce,endDTS:he,type:_,hasAudio:D,hasVideo:P,nb:1,dropped:0,encrypted:!!E.audio&&E.audio.encrypted||!!E.video&&E.video.encrypted};v.audio=D&&!P?ge:void 0,v.video=P?ge:void 0;var ve=this.isVideoContiguous,me=null==k?void 0:k.sampleCount;if(me){var pe=k.keyFrameIndex,ye=-1!==pe;ge.nb=me,ge.dropped=0===pe||ve?0:ye?pe:me,ge.independent=ye,ge.firstKeyFrame=pe,ye&&k.keyFrameStart&&(ge.firstKeyFramePTS=(k.keyFrameStart-c.baseTime)/c.timescale),ve||(v.independent=ye),this.isVideoContiguous||(this.isVideoContiguous=ye),ge.dropped&&this.warn("fmp4 does not start with IDR: firstIDR "+pe+"/"+me+" dropped: "+ge.dropped+" start: "+(ge.firstKeyFramePTS||"NA"))}return v.initSegment=y,v.id3=Ba(r,n,c,c),i.samples.length&&(v.text=Ga(i,n,c)),v},t}(ue);function Ha(e,t,r){return void 0===r&&(r=!1),void 0!==(null==e?void 0:e.start)?(e.start+(r?e.duration:0))/e.timescale:t}function Ya(e,t,r){var i=e.codec;return i&&i.length>4?i:t===Y.AUDIO?"ec-3"===i||"ac-3"===i||"alac"===i?i:"fLaC"===i||"Opus"===i?Ft(i,!1):(r.warn('Unhandled audio codec "'+i+'" in mp4 MAP'),i||"mp4a"):"mjpg"===i?i:(r.warn('Unhandled video codec "'+i+'" in mp4 MAP'),i||"avc1")}try{Ka=self.performance.now.bind(self.performance)}catch(e){Ka=Date.now}var ja=[{demux:na,remux:Va},{demux:ha,remux:Na},{demux:Zn,remux:Na},{demux:ra,remux:Na}];ja.splice(2,0,{demux:ea,remux:Na});var Wa=function(){function e(e,t,r,i,n,a){this.asyncResult=!1,this.logger=void 0,this.observer=void 0,this.typeSupported=void 0,this.config=void 0,this.id=void 0,this.demuxer=void 0,this.remuxer=void 0,this.decrypter=void 0,this.probe=void 0,this.decryptionPromise=null,this.transmuxConfig=void 0,this.currentTransmuxState=void 0,this.observer=e,this.typeSupported=t,this.config=r,this.id=n,this.logger=a}var t=e.prototype;return t.configure=function(e){this.transmuxConfig=e,this.decrypter&&this.decrypter.reset()},t.push=function(e,t,r,i){var n=this;if(!this.observer)return qa(r);var a=r.transmuxing;a.executeStart=Ka();var s=new Uint8Array(e),o=this.currentTransmuxState,l=this.transmuxConfig;i&&(this.currentTransmuxState=i);var u=i||o,d=u.contiguous,h=u.discontinuity,f=u.trackSwitch,c=u.accurateTimeOffset,g=u.timeOffset,v=u.initSegmentChange,m=l.audioCodec,p=l.videoCodec,y=l.defaultInitPts,E=l.duration,T=l.initSegmentData,S=function(e,t){var r=null;return e.byteLength>0&&null!=(null==t?void 0:t.key)&&null!==t.iv&&null!=t.method&&(r=t),r}(s,t);if(S&&Ti(S.method)){var A=this.getDecrypter(),I=Si(S.method);return this.asyncResult=!0,this.decryptionPromise=A.decrypt(s,S.key.buffer,S.iv.buffer,I,r.decryptRange).then((function(e){var t=n.push(e,null,r);return n.decryptionPromise=null,t})),this.decryptionPromise}var L=this.needsProbing(h,f);if(L){var R=r.iframe&&T&&ha.probe(T,this.logger)?T:s,b=this.configureTransmuxer(R);if(b)return this.logger.warn("[transmuxer] "+b.message),this.observer.emit(G.ERROR,G.ERROR,{type:U.MEDIA_ERROR,details:B.FRAG_PARSING_ERROR,fatal:!1,error:b,reason:b.message}),a.executeEnd=Ka(),qa(r)}(h||f||v||L)&&this.resetInitSegment(T,m,p,E,t,r),(h||v||L)&&this.resetInitialTimestamp(y),d||this.resetContiguity();var k=this.transmux(s,S,g,c,r);this.asyncResult=Xa(k);var D=this.currentTransmuxState;return D.contiguous=!0,D.discontinuity=!1,D.trackSwitch=!1,a.executeEnd=Ka(),k},t.flush=function(e){var t=this,r=e.transmuxing;r.executeStart=Ka();var i=this.decrypter,n=this.currentTransmuxState,a=this.decryptionPromise;if(a)return this.asyncResult=!0,a.then((function(){return t.flush(e)}));var s=[],o=n.timeOffset;if(i){var l=i.flush();l&&s.push(this.push(l.buffer,null,e))}var u=this.demuxer,d=this.remuxer;if(!u||!d){r.executeEnd=Ka();var h=[qa(e)];return this.asyncResult?Promise.resolve(h):h}var f=u.flush(o,e);return Xa(f)?(this.asyncResult=!0,f.then((function(r){return t.flushRemux(s,r,e),s}))):(this.flushRemux(s,f,e),this.asyncResult?Promise.resolve(s):s)},t.flushRemux=function(e,t,r){if(this.remuxer){var i=t.audioTrack,n=t.videoTrack,a=t.id3Track,s=t.textTrack,o=this.currentTransmuxState,l=o.accurateTimeOffset,u=o.timeOffset;this.logger.log("[transmuxer.ts]: Flushed "+this.id+" sn: "+r.sn+(r.part>-1?" part: "+r.part:"")+" of "+this.id+" playlist "+r.level);var d=this.remuxer.remux(i,n,a,s,u,l,!0,this.id,r,t.initData,t.sampleData);e.push({remuxResult:d,chunkMeta:r}),r.transmuxing.executeEnd=Ka()}},t.resetInitialTimestamp=function(e){var t=this.demuxer,r=this.remuxer;t&&r&&(t.resetTimeStamp(e),r.resetTimeStamp(e))},t.resetContiguity=function(){var e=this.demuxer,t=this.remuxer;e&&t&&(e.resetContiguity(),t.resetNextTimestamp())},t.resetInitSegment=function(e,t,r,i,n,a){var s=this.demuxer,o=this.remuxer;s&&o&&(s.resetInitSegment(e,t,r,i,n,a),o.resetInitSegment(e,t,r,n))},t.destroy=function(){this.demuxer&&(this.demuxer.destroy(),this.demuxer=void 0),this.remuxer&&(this.remuxer.destroy(),this.remuxer=void 0),this.observer&&this.observer.removeAllListeners(),this.decryptionPromise=null,this.decrypter=this.observer=void 0,this.logger=this.config=this.probe=void 0},t.transmux=function(e,t,r,i,n){return"SAMPLE-AES"===(null==t?void 0:t.method)?this.transmuxSampleAes(e,t,r,i,n):this.transmuxUnencrypted(e,r,i,n)},t.transmuxUnencrypted=function(e,t,r,i){var n=this.demuxer,a=this.remuxer;if(!n||!a)return qa(i);var s=n.demux(e,t,i,!1,!this.config.progressive),o=s.audioTrack,l=s.videoTrack,u=s.id3Track,d=s.textTrack;return{remuxResult:a.remux(o,l,u,d,t,r,!1,this.id,i,s.initData,s.sampleData),chunkMeta:i}},t.transmuxSampleAes=function(e,t,r,i,n){var a=this;return this.demuxer?this.demuxer.demuxSampleAes(e,t,r,n).then((function(e){return{remuxResult:a.remuxer.remux(e.audioTrack,e.videoTrack,e.id3Track,e.textTrack,r,i,!1,a.id,n,e.initData,e.sampleData),chunkMeta:n}})):Promise.reject(new Error("no demuxer"))},t.configureTransmuxer=function(e){var t=this.config,r=this.observer,i=this.typeSupported;if(r){var n;try{for(var a=0,s=ja.length;a1&&l.id===(null==p?void 0:p.stats.chunkCount),I=!E&&(1===T||0===T&&(1===S||A&&S<=0)),L=self.performance.now();(E||T||0===n.stats.parsing.start)&&(n.stats.parsing.start=L),!a||!S&&I||(a.stats.parsing.start=L);var R=!(p&&(null==(d=n.initSegment)?void 0:d.url)===(null==(h=p.initSegment)?void 0:h.url)),b=new Qa(y,I,o,E,v,R);if(!I||y||R){this.hls.logger.log("[transmuxer-interface]: Starting new transmux session for "+n.type+" sn: "+l.sn+(l.part>-1?" part: "+l.part:"")+" "+this.id+" playlist: "+l.level+" id: "+l.id+"\n discontinuity: "+y+"\n trackSwitch: "+E+"\n contiguous: "+I+"\n accurateTimeOffset: "+o+"\n timeOffset: "+v+"\n initSegmentChange: "+R);var k=new za(r,i,t,s,u);this.configureTransmuxer(k)}if(this.frag=n,this.part=a,this.workerContext)this.workerContext.worker.postMessage({instanceNo:c,cmd:"demux",data:e,decryptdata:m,chunkMeta:l,state:b},e instanceof ArrayBuffer?[e]:[]);else if(g){var D=g.push(e,m,l,b);Xa(D)?D.then((function(e){f.handleTransmuxComplete(e)})).catch((function(e){f.transmuxerError(e,l,"transmuxer-interface push error")})):this.handleTransmuxComplete(D)}},r.flush=function(e){var t=this;e.transmuxing.start=self.performance.now();var r=this.instanceNo,i=this.transmuxer;if(this.workerContext)this.workerContext.worker.postMessage({instanceNo:r,cmd:"flush",chunkMeta:e});else if(i){var n=i.flush(e);Xa(n)?n.then((function(r){t.handleFlushResult(r,e)})).catch((function(r){t.transmuxerError(r,e,"transmuxer-interface flush error")})):this.handleFlushResult(n,e)}},r.transmuxerError=function(e,t,r){this.hls&&(this.error=e,this.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.FRAG_PARSING_ERROR,chunkMeta:t,frag:this.frag||void 0,part:this.part||void 0,fatal:!1,error:e,err:e,reason:r}))},r.handleFlushResult=function(e,t){var r=this;e.forEach((function(e){r.handleTransmuxComplete(e)})),this.onFlush(t)},r.configureTransmuxer=function(e){var t=this.instanceNo,r=this.transmuxer;this.workerContext?this.workerContext.worker.postMessage({instanceNo:t,cmd:"configure",config:e}):r&&r.configure(e)},r.handleTransmuxComplete=function(e){e.chunkMeta.transmuxing.end=self.performance.now(),this.onTransmuxComplete(e)},t}(),ss=function(e){function t(t,r,i){var n;return(n=e.call(this,t,r,i,"audio-stream-controller",H.AUDIO)||this).mainAnchor=null,n.mainFragLoading=null,n.audioOnly=!1,n.bufferedTrack=null,n.switchingTrack=null,n.trackId=-1,n.nextTrackId=-1,n.waitingData=null,n.mainDetails=null,n.flushing=!1,n.bufferFlushed=!1,n.cachedTrackLoadedData=null,n.registerListeners(),n}d(t,e);var r=t.prototype;return r.onHandlerDestroying=function(){this.unregisterListeners(),e.prototype.onHandlerDestroying.call(this),this.resetItem()},r.resetItem=function(){this.mainDetails=this.mainAnchor=this.mainFragLoading=this.bufferedTrack=this.switchingTrack=this.waitingData=this.cachedTrackLoadedData=null},r.registerListeners=function(){e.prototype.registerListeners.call(this);var t=this.hls;t.on(G.LEVEL_LOADED,this.onLevelLoaded,this),t.on(G.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),t.on(G.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),t.on(G.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),t.on(G.BUFFER_RESET,this.onBufferReset,this),t.on(G.BUFFER_CREATED,this.onBufferCreated,this),t.on(G.BUFFER_FLUSHING,this.onBufferFlushing,this),t.on(G.BUFFER_FLUSHED,this.onBufferFlushed,this),t.on(G.INIT_PTS_FOUND,this.onInitPtsFound,this),t.on(G.FRAG_LOADING,this.onFragLoading,this),t.on(G.FRAG_BUFFERED,this.onFragBuffered,this)},r.unregisterListeners=function(){var t=this.hls;t&&(e.prototype.unregisterListeners.call(this),t.off(G.LEVEL_LOADED,this.onLevelLoaded,this),t.off(G.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),t.off(G.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),t.off(G.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),t.off(G.BUFFER_RESET,this.onBufferReset,this),t.off(G.BUFFER_CREATED,this.onBufferCreated,this),t.off(G.BUFFER_FLUSHING,this.onBufferFlushing,this),t.off(G.BUFFER_FLUSHED,this.onBufferFlushed,this),t.off(G.INIT_PTS_FOUND,this.onInitPtsFound,this),t.off(G.FRAG_LOADING,this.onFragLoading,this),t.off(G.FRAG_BUFFERED,this.onFragBuffered,this))},r.onInitPtsFound=function(e,t){var r=t.frag,i=t.id,n=t.initPTS,a=t.timescale,s=t.trackId,o=t.timestampOffsets;if(i===H.MAIN){var l=r.cc,u=this.fragCurrent;if(this.initPTS=o,this.log("InitPTS for cc: "+l+" found from main: "+n/a+" ("+n+"/"+a+") trackId: "+s),this.mainAnchor=r,this.state===Rn.WAITING_INIT_PTS){var d=this.waitingData;(!d&&!this.loadingParts||d&&d.frag.cc!==l)&&this.syncWithAnchor(r,null==d?void 0:d.frag)}else!this.hls.hasEnoughToStart&&u&&u.cc!==l?(u.abortRequests(),this.syncWithAnchor(r,u)):this.state===Rn.IDLE&&this.tick()}},r.getLoadPosition=function(){var t;return!this.startFragRequested&&this.nextLoadPosition>=0&&!1!==(null==(t=this.switchingTrack)?void 0:t.flushImmediate)?this.nextLoadPosition:e.prototype.getLoadPosition.call(this)},r.syncWithAnchor=function(e,t){var r,i=(null==(r=this.mainFragLoading)?void 0:r.frag)||null;if(!t||(null==i?void 0:i.cc)!==t.cc){var n=(i||e).cc,a=Sr(this.getLevelDetails(),n,this.getLoadPosition());a&&(this.log("Syncing with main frag at "+a.start+" cc "+a.cc),this.startFragRequested=!1,this.nextLoadPosition=a.start,this.resetLoadingState(),this.state===Rn.IDLE&&this.doTickIdle())}},r.startLoad=function(e,t){if(!this.levels)return this.startPosition=e,void(this.state=Rn.STOPPED);var r=this.lastCurrentTime;this.stopLoad(),this.setInterval(100),r>0&&-1===e&&!t&&this.initPTS.length&&(this.log("Override startPosition with lastCurrentTime @"+r.toFixed(3)),e=r),this.state=Rn.IDLE,this.nextLoadPosition=this.lastCurrentTime=e+this.timelineOffset,this.startPosition=t?-1:e,t||this.fragmentTracker.hasFragments(this.playlistType)||(this.startFragRequested=!1),this.tick()},r.doTick=function(){switch(this.state){case Rn.IDLE:this.doTickIdle();break;case Rn.WAITING_TRACK:var t=this.levels,r=this.trackId,i=null==t?void 0:t[r],n=null==i?void 0:i.details;if(n&&!this.waitForLive(i)){if(this.waitForCdnTuneIn(n))break;this.state=Rn.WAITING_INIT_PTS}break;case Rn.FRAG_LOADING_WAITING_RETRY:this.checkRetryDate();break;case Rn.WAITING_INIT_PTS:var a=this.waitingData;if(a){var s=a.frag,o=a.part,l=a.cache,u=a.complete,d=this.mainAnchor;if(void 0!==this.initPTS[s.cc]){this.waitingData=null,this.state=Rn.FRAG_LOADING;var h={frag:s,part:o,payload:l.flush().buffer,networkDetails:null};this._handleFragmentLoadProgress(h),u&&e.prototype._handleFragmentLoadComplete.call(this,h)}else d&&d.cc!==a.frag.cc&&this.syncWithAnchor(d,a.frag)}else this.state=Rn.IDLE}this.onTickEnd()},r.resetLoadingState=function(){var t=this.waitingData;t&&(this.fragmentTracker.removeFragment(t.frag),this.waitingData=null),e.prototype.resetLoadingState.call(this)},r.onTickEnd=function(){var e=this.media;null!=e&&e.readyState&&(this.lastCurrentTime=e.currentTime,this.checkFragmentChanged())},r.doTickIdle=function(){var e,t=this.hls,r=this.levels,i=this.media,n=this.trackId,a=t.config;if(this.buffering&&(i||this.primaryPrefetch||!this.startFragRequested&&a.startFragPrefetch)&&null!=r&&r[n]){var s=r[n],o=s.details;if(!o||this.waitForLive(s)||this.waitForCdnTuneIn(o))return this.state=Rn.WAITING_TRACK,void(this.startFragRequested=!1);var l=this.getBufferOutput();this.bufferFlushed&&l&&(this.bufferFlushed=!1,this.afterBufferFlushed(l,Y.AUDIO));var u=this.getFwdBufferInfo();if(null!==u){if(!this.switchingTrack&&this._streamEnded(u,o))return t.trigger(G.BUFFER_EOS,{type:"audio"}),void(this.state=Rn.ENDED);var d=u.len,h=t.maxBufferLength,f=o.fragments,c=f[0].start,g=this.getLoadPosition(),v=this.flushing||this.switchingTrack&&!this.switchingTrack.flushImmediate?g:u.end;if(this.switchingTrack&&i){var m=g;o.PTSKnown&&m=this.timelineOffset&&(u.end>c||u.nextStart)&&(this.log("Alt audio track ahead of main track, seek to start of alt audio track "+m+" -> "+c+" + 0.05"),i.currentTime=c+.05)}if(!(d>=h&&!this.switchingTrack&&vy.end){var E=this.fragmentTracker.getFragAtPos(v,H.MAIN);E&&E.end>y.end&&(y=E,this.mainFragLoading={frag:E,targetBufferTime:null})}if(p.start>y.end)return}this.loadFragment(p,s,v)}}else this.bufferFlushed=!0}}}},r.onMediaDetaching=function(t,r){this.bufferFlushed=this.flushing=!1,e.prototype.onMediaDetaching.call(this,t,r)},r.onAudioTracksUpdated=function(e,t){var r=t.audioTracks;this.resetTransmuxer(),this.levels=r.map((function(e){return new rr(e)}))},r.onAudioTrackSwitching=function(e,t){var r=!!t.url;this.trackId=t.id;var i=this.fragCurrent;i&&(i.abortRequests(),this.removeUnbufferedFrags(i.start)),this.resetLoadingState(),r?(this.switchingTrack=t,t.flushImmediate||(this.nextTrackId=t.id,this.nextLevelSwitch()),this.flushAudioIfNeeded(t),this.state!==Rn.STOPPED&&(this.setInterval(100),this.state=Rn.IDLE,this.tick())):(this.resetTransmuxer(),this.switchingTrack=null,this.bufferedTrack=t,this.clearInterval())},r.onManifestLoading=function(){e.prototype.onManifestLoading.call(this),this.bufferFlushed=this.flushing=this.audioOnly=!1,this.resetItem(),this.trackId=-1},r.onLevelLoaded=function(e,t){this.mainDetails=t.details;var r=this.cachedTrackLoadedData;r&&(this.cachedTrackLoadedData=null,this.onAudioTrackLoaded(G.AUDIO_TRACK_LOADED,r))},r.onAudioTrackLoaded=function(e,t){var r,i=this.levels,n=t.details,a=t.id,s=t.groupId,o=t.track;if(i){var l=this.mainDetails;if(!l||n.endCC>l.endCC||l.expired)return this.cachedTrackLoadedData=t,void(this.state!==Rn.STOPPED&&(this.state=Rn.WAITING_TRACK));this.cachedTrackLoadedData=null,this.log("Audio track "+a+' "'+o.name+'" of "'+s+'" loaded ['+n.startSN+","+n.endSN+"]"+(n.lastPartSn?"[part-"+n.lastPartSn+"-"+n.lastPartIndex+"]":"")+",duration:"+n.totalduration);var u=i[a],d=0;if(n.live||null!=(r=u.details)&&r.live){if(this.checkLiveUpdate(n),n.deltaUpdateFailed)return;var h;u.details&&(d=this.alignPlaylists(n,u.details,null==(h=this.levelLastLoaded)?void 0:h.details)),n.alignedSliding||(Sn(l,n,this),d=n.fragmentStart)}u.details=n,this.levelLastLoaded=u,this.startFragRequested||this.setStartPosition(l,d),this.hls.trigger(G.AUDIO_TRACK_UPDATED,{details:n,id:a,groupId:t.groupId}),this.state!==Rn.WAITING_TRACK||this.waitForCdnTuneIn(n)||(this.state=Rn.IDLE),this.tick()}else this.warn("Audio tracks reset while loading track "+a+' "'+o.name+'" of "'+s+'"')},r._handleFragmentLoadProgress=function(e){var t,r=e.frag,i=e.part,n=e.payload,a=this.config,s=this.trackId,o=this.levels;if(o){var l=o[s];if(l){var u=l.details;if(!u)return this.warn("Audio track details undefined on fragment load progress"),void this.removeUnbufferedFrags(r.start);var d=a.defaultAudioCodec||l.audioCodec||"mp4a.40.2",h=this.transmuxer;h||(h=this.transmuxer=new as(this.hls,H.AUDIO,this._handleTransmuxComplete.bind(this),this._handleTransmuxerFlush.bind(this)));var f=this.initPTS[r.cc],c=null==(t=r.initSegment)?void 0:t.data;if(void 0!==f){var g=i?i.index:-1,v=-1!==g,m=new ni(r.level,r.sn,r.stats.chunkCount,n.byteLength,g,v,r.duration);h.push(n,c,d,"",r,i,u.totalduration,!1,m,f)}else this.log("Unknown video PTS for cc "+r.cc+", waiting for video PTS before demuxing audio frag "+r.sn+" of ["+u.startSN+" ,"+u.endSN+"],track "+s),(this.waitingData=this.waitingData||{frag:r,part:i,cache:new Pn,complete:!1}).cache.push(new Uint8Array(n)),this.state!==Rn.STOPPED&&(this.state=Rn.WAITING_INIT_PTS)}else this.warn("Audio track is undefined on fragment load progress")}else this.warn("Audio tracks were reset while fragment load was in progress. Fragment "+r.sn+" of level "+r.level+" will not be buffered")},r._handleFragmentLoadComplete=function(t){this.waitingData?this.waitingData.complete=!0:e.prototype._handleFragmentLoadComplete.call(this,t)},r.onBufferReset=function(){this.mediaBuffer=null},r.onBufferCreated=function(e,t){this.bufferFlushed=this.flushing=!1;var r=t.tracks.audio;r&&(this.mediaBuffer=r.buffer||null)},r.onFragLoading=function(e,t){!this.audioOnly&&t.frag.type===H.MAIN&&W(t.frag)&&(this.mainFragLoading=t,this.state===Rn.IDLE&&this.tick())},r.onFragBuffered=function(e,t){var r=t.frag,i=t.part;if(r.type===H.AUDIO)if(this.fragContextChanged(r))this.warn("Fragment "+r.sn+(i?" p: "+i.index:"")+" of level "+r.level+" finished buffering, but was aborted. state: "+this.state+", audioSwitch: "+(this.switchingTrack?this.switchingTrack.name:"false"));else{if(W(r)){var n=this.switchingTrack;n&&(this.bufferedTrack=n)}this.fragBufferedComplete(r,i),this.media&&this.tick()}else this.audioOnly||r.type!==H.MAIN||r.elementaryStreams.video||r.elementaryStreams.audiovideo||(this.audioOnly=!0,this.mainFragLoading=null)},r.getFwdBufferInfo=function(){var t=this.getBufferOutput();return e.prototype.getFwdBufferInfo.call(this,t,H.AUDIO)},r.getBufferOutput=function(){return this.mediaBuffer?this.mediaBuffer:this.media},r.checkFragmentChanged=function(){var e=this.fragPlaying;if(!this.checkFragPlaying())return!1;var t=this.fragPlaying,r=null==e?void 0:e.level;return t&&t.level===r||(e&&this.cleanupBackBuffer(),this.switchingTrack&&this.completeAudioSwitch(this.switchingTrack)),!0},r.onError=function(t,r){var i;if(r.fatal)this.state=Rn.ERROR;else switch(r.details){case B.FRAG_GAP:case B.FRAG_PARSING_ERROR:case B.FRAG_DECRYPT_ERROR:case B.FRAG_LOAD_ERROR:case B.FRAG_LOAD_TIMEOUT:case B.KEY_LOAD_ERROR:case B.KEY_LOAD_TIMEOUT:this.onFragmentOrKeyLoadError(H.AUDIO,r);break;case B.AUDIO_TRACK_LOAD_ERROR:case B.AUDIO_TRACK_LOAD_TIMEOUT:case B.LEVEL_PARSING_ERROR:r.levelRetry||this.state!==Rn.WAITING_TRACK||(null==(i=r.context)?void 0:i.type)!==V.AUDIO_TRACK||(this.state=Rn.IDLE);break;case B.BUFFER_APPEND_NO_PROGRESS:if("audio"!==r.parent)return;r.frag&&(r.appendsWithoutProgress||0)>=this.config.appendErrorMaxRetry&&(this.warn("Marking fragment "+r.frag.sn+" of track "+r.frag.level+" as a gap after "+r.appendsWithoutProgress+" appends without buffered range growth, to prevent loop loading"),this.fragmentTracker.addAsGap(r.frag));break;case B.BUFFER_ADD_CODEC_ERROR:case B.BUFFER_APPEND_ERROR:if("audio"!==r.parent)return;this.reduceLengthAndFlushBuffer(r)||this.resetLoadingState();break;case B.BUFFER_FULL_ERROR:if("audio"!==r.parent)return;this.reduceLengthAndFlushBuffer(r)&&(this.bufferedTrack=null,e.prototype.flushMainBuffer.call(this,0,Number.POSITIVE_INFINITY,"audio"));break;case B.INTERNAL_EXCEPTION:this.recoverWorkerError(r)}},r.onBufferFlushing=function(e,t){t.type!==Y.VIDEO&&(this.flushing=!0)},r.onBufferFlushed=function(e,t){var r=t.type;if(r!==Y.VIDEO){this.flushing=!1,this.bufferFlushed=!0,this.state===Rn.ENDED&&(this.state=Rn.IDLE);var i=this.getBufferOutput();i&&(this.afterBufferFlushed(i,r),this.tick())}},r._handleTransmuxComplete=function(t){var r,i="audio",n=this.hls,a=t.remuxResult,s=t.chunkMeta,o=this.getCurrentContext(s);if(o){var u=o.frag,d=o.part,h=o.level,f=h.details,c=a.audio,g=a.text,v=a.id3,m=a.initSegment;if(this.fragContextChanged(u)||!f)return this.fragmentTracker.removeFragment(u),void(null!=m&&m.tracks&&this.resetTransmuxer());if(this.state=Rn.PARSING,c&&this.switchingTrack&&!this.switchingTrack.flushImmediate){var p=this.config.nextAudioTrackBufferFlushForwardOffset,y=Math.max(this.getLoadPosition()+p,u.start);e.prototype.flushMainBuffer.call(this,y,Number.POSITIVE_INFINITY,H.AUDIO)}if(null!=m&&m.tracks){var E=u.initSegment||u;if(this.unhandledEncryptionError(m,u))return;this._bufferInitSegment(h,m.tracks,E,s),n.trigger(G.FRAG_PARSING_INIT_SEGMENT,{frag:E,id:i,tracks:m.tracks})}if(c){var T=c.startPTS,S=c.endPTS,A=c.startDTS,I=c.endDTS;d&&(d.elementaryStreams[Y.AUDIO]={startPTS:T,endPTS:S,startDTS:A,endDTS:I}),u.setElementaryStreamInfo(Y.AUDIO,T,S,A,I),this.bufferFragmentData(c,u,d,s)}if(null!=v&&null!=(r=v.samples)&&r.length){var L=l({id:i,frag:u,details:f},v);n.trigger(G.FRAG_PARSING_METADATA,L)}if(g){var R=l({id:i,frag:u,details:f},g);n.trigger(G.FRAG_PARSING_USERDATA,R)}}else this.resetWhenMissingContext(s)},r._bufferInitSegment=function(e,t,r,i){if(this.state===Rn.PARSING&&(t.video&&delete t.video,t.audiovideo&&delete t.audiovideo,t.audio)){var n=t.audio;n.id=H.AUDIO;var a=e.audioCodec;this.log("Init audio buffer, container:"+n.container+", codecs[level/parsed]=["+a+"/"+n.codec+"]"),1===(null==a?void 0:a.split(",").length)&&(n.levelCodec=a),this.hls.trigger(G.BUFFER_CODECS,t);var s=n.initSegment;if(null!=s&&s.byteLength){var o={type:"audio",frag:r,part:null,chunkMeta:i,parent:r.type,data:s};this.hls.trigger(G.BUFFER_APPENDING,o)}this.tickImmediate()}},r.loadFragment=function(t,r,i){var n,a=this.fragmentTracker.getState(t);if(this.switchingTrack||a===Nr||a===Br)if(null!=(n=r.details)&&n.live&&!this.initPTS[t.cc]){this.log("Waiting for video PTS in continuity counter "+t.cc+" of live stream before loading audio fragment "+t.sn+" of level "+this.trackId),this.state=Rn.WAITING_INIT_PTS;var s=this.mainDetails;s&&s.fragmentStart!==r.details.fragmentStart&&Sn(s,r.details,this)}else e.prototype.loadFragment.call(this,t,r,i);else this.clearTrackerIfNeeded(t)},r.flushAudioIfNeeded=function(t){if(this.media&&this.bufferedTrack&&t.flushImmediate){var r=this.bufferedTrack;dr({name:r.name,lang:r.lang,assocLang:r.assocLang,characteristics:r.characteristics,audioCodec:r.audioCodec,channels:r.channels},t,hr)||(cr(t.url,this.hls)?(this.log("Switching audio track : flushing all audio"),e.prototype.flushMainBuffer.call(this,0,Number.POSITIVE_INFINITY,"audio"),this.bufferedTrack=null):this.bufferedTrack=t)}},r.completeAudioSwitch=function(e){var t=this.hls;this.bufferedTrack=e,this.switchingTrack=null,t.trigger(G.AUDIO_TRACK_SWITCHED,c({},e))},a(t,[{key:"nextAudioTrack",get:function(){return this.nextTrackId}}])}(bn),os=function(e){function t(t,r){var i;return(i=e.call(this,r,t.logger)||this).hls=void 0,i.canLoad=!1,i.timer=-1,i.loadingSync=!1,i.hls=t,i}d(t,e);var r=t.prototype;return r.destroy=function(){this.clearTimer(),this.hls=null},r.clearTimer=function(){-1!==this.timer&&(self.clearTimeout(this.timer),this.timer=-1)},r.startLoad=function(){this.canLoad=!0,this.loadPlaylist()},r.stopLoad=function(){this.canLoad=!1,this.clearTimer()},r.switchParams=function(e,t,r){var i=null==t?void 0:t.renditionReports;if(i){for(var n=-1,a=0;a=0&&h>t.partTarget&&(d+=1)}var f=r&&er(r);return new tr(u,d>=0?d:void 0,f)}}},r.loadPlaylist=function(e){this.clearTimer()},r.loadingPlaylist=function(e,t){this.clearTimer()},r.shouldLoadPlaylist=function(e){return this.canLoad&&!!e&&!!e.url&&(!e.details||e.details.live)},r.getUrlWithDirectives=function(e,t){if(t)try{return t.addDirectives(e)}catch(e){this.warn("Could not construct new URL with HLS Delivery Directives: "+e)}return e},r.playlistLoaded=function(e,t,r){var i=t.details,n=t.stats,a=i.fragments,s=self.performance.now(),o=n.loading.first?Math.max(0,Math.floor(s-n.loading.first)):0;i.advancedDateTime=Date.now()-o;var l=this.hls.config.timelineOffset;if(l!==i.appliedTimelineOffset){var u=Math.max(l||0,0);i.appliedTimelineOffset=u,a.forEach((function(e){null==e||e.setStart(e.playlistOffset+u)}))}if(i.live||null!=r&&r.live){var d,h="levelInfo"in t?t.levelInfo:t.track;i.reloaded(r);var f=null==(d=a[a.length-1])?void 0:d.type;if(i.misses>=this.hls.config.liveMaxUnchangedPlaylistRefresh){var c=new Error(f+" playlist "+h.id+" hit max allowed unchanged reloads.");this.warn(c);var g=t.networkDetails,v=t.context;return void this.hls.trigger(G.ERROR,{type:U.NETWORK_ERROR,details:B.PLAYLIST_UNCHANGED_ERROR,fatal:!1,url:i.url,error:c,reason:c.message,level:t.level,parent:f,context:v,networkDetails:g,stats:n})}if(r){ln(r,i,this);var m=i.playlistParsingError;if(m){this.warn(m);var p=this.hls;if(!p.config.ignorePlaylistParsingErrors){var y=t.networkDetails;return void p.trigger(G.ERROR,{type:U.NETWORK_ERROR,details:B.LEVEL_PARSING_ERROR,fatal:!1,url:i.url,error:m,reason:m.message,level:t.level,parent:f,networkDetails:y,stats:n})}i.playlistParsingError=null}}-1===i.requestScheduled&&(i.requestScheduled=n.loading.start);var E=this.hls.mainForwardBufferInfo,T=E?E.end-E.len:0,S=hn(i,1e3*(i.edge-T));i.requestScheduled+S0){if(w>3*i.targetduration)this.log("Playlist last advanced "+C.toFixed(2)+"s ago. Omitting segment and part directives."),L=void 0,R=void 0;else if(null!=r&&r.tuneInGoal&&w-i.partTarget>r.tuneInGoal)this.warn("CDN Tune-in goal increased from: "+r.tuneInGoal+" to: "+O+" with playlist age: "+i.age),O=0;else{var M=Math.floor(O/i.targetduration);L+=M,void 0!==R&&(R+=Math.round(O%i.targetduration/i.partTarget)),this.log("CDN Tune-in age: "+i.ageHeader+"s last advanced "+C.toFixed(2)+"s goal: "+O+" skip sn "+M+" to part "+R)}i.tuneInGoal=O}if(A=this.getDeliveryDirectives(i,t.deliveryDirectives,L,R),b||!_)return i.requestScheduled=s,void this.loadingPlaylist(h,A)}else(i.canBlockReload||i.canSkipUntil)&&(A=this.getDeliveryDirectives(i,t.deliveryDirectives,L,R));A&&void 0!==L&&i.canBlockReload&&(i.requestScheduled=n.loading.first+Math.max(S-2*o,S/2)),this.scheduleLoading(h,A,i)}else this.clearTimer()},r.scheduleLoading=function(e,t,r){var i=this,n=r||e.details;if(n){var a=self.performance.now(),s=n.requestScheduled;if(a>=s&&!this.loadingSync)return this.loadingSync=!0,this.loadingPlaylist(e,t),void(this.loadingSync=!1);var o=Math.max(s-a,0);this.log("reload live playlist "+(e.name||e.bitrate+"bps")+" in "+Math.round(o)+" ms"),this.clearTimer(),this.timer=self.setTimeout((function(){return i.loadingPlaylist(e,t)}),o)}else this.loadingPlaylist(e,t)},r.getDeliveryDirectives=function(e,t,r,i){var n=er(e);return null!=t&&t.skip&&e.deltaUpdateFailed&&(r=t.msn,i=t.part,n=$t),new tr(r,i,n)},r.checkRetry=function(e){var t=this,r=e.details,i=Z(e),n=e.errorAction,a=n||{},s=a.action,o=a.retryCount,l=void 0===o?0:o,u=a.retryConfig,d=!!n&&!!u&&(s===br||!n.resolved&&s===Lr);if(d){var h;if(l>=u.maxNumRetry)return!1;if(i&&null!=(h=e.context)&&h.deliveryDirectives)this.warn("Retrying playlist loading "+(l+1)+"/"+u.maxNumRetry+' after "'+r+'" without delivery-directives'),this.loadPlaylist();else{var f,c=ae(null==(f=e.response)?void 0:f.code);if(this.clearTimer(),c)this.log("Waiting for connection (offline)"),e.reason="offline",this.timer=self.setTimeout((function(){return t.checkOfflineStatus()}),1e3);else{var g=re(u,l);this.warn("Retrying playlist loading "+(l+1)+"/"+u.maxNumRetry+' after "'+r+'" in '+g+"ms"),this.timer=self.setTimeout((function(){return t.loadPlaylist()}),g)}}e.levelRetry=!0,n.resolved=!0}return d},r.checkOfflineStatus=function(){var e=this;this.clearTimer(),ae(0)?this.timer=self.setTimeout((function(){return e.checkOfflineStatus()}),1e3):(this.log("Connection restored (online)"),this.loadPlaylist())},t}(ue);function ls(e,t){if(e.length!==t.length)return!1;for(var r=0;r-1)n=a[o];else{var l=ur(s,this.tracks);n=this.tracks[l]}}var u=this.findTrackId(n);-1===u&&n&&(u=this.findTrackId(null));var d={audioTracks:a};this.log("Updating audio tracks, "+a.length+" track(s) found in group(s): "+(null==r?void 0:r.join(","))),this.hls.trigger(G.AUDIO_TRACKS_UPDATED,d);var h=this.trackId;if(-1!==u&&-1===h)this.setAudioTrack(u);else if(a.length&&-1===h){var f,c=new Error("No audio track selected for current audio group-ID(s): "+(null==(f=this.groupIds)?void 0:f.join(","))+" track count: "+a.length);this.warn(c.message),this.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.AUDIO_TRACK_LOAD_ERROR,fatal:!0,error:c})}}}},r.onError=function(e,t){!t.fatal&&t.context&&t.context.type===V.AUDIO_TRACK&&t.context.id===this.trackId&&gr(t.context.groupId,this.groupIds)&&this.checkRetry(t)},r.setAudioOption=function(e){var t=this.hls;if(t.config.audioPreference=e,e){var r=this.allAudioTracks;if(this.selectDefaultTrack=!1,r.length){var i=this.currentTrack;if(i&&dr(e,i,hr))return i;var n=ur(e,this.tracksInGroup,hr);if(n>-1){var a=this.tracksInGroup[n];return this.setAudioTrack(n,"flushImmediate"in e&&e.flushImmediate),a}if(i){var s=t.loadLevel;-1===s&&(s=t.firstAutoLevel);var o=function(e,t,r,i,n){var a=t[i],s=t.reduce((function(e,t,r){var i=t.uri;return(e[i]||(e[i]=[])).push(r),e}),{})[a.uri];s.length>1&&(i=Math.max.apply(Math,s));var o=a.videoRange,l=a.frameRate,u=a.codecSet.substring(0,4),d=fr(t,i,(function(t){if(t.videoRange!==o||t.frameRate!==l||t.codecSet.substring(0,4)!==u)return!1;var i=t.audioGroups,a=r.filter((function(e){return gr(e.groupId,i)}));return ur(e,a,n)>-1}));return d>-1?d:fr(t,i,(function(t){var i=t.audioGroups,a=r.filter((function(e){return gr(e.groupId,i)}));return ur(e,a,n)>-1}))}(e,t.levels,r,s,hr);if(-1===o)return null;t.nextLoadLevel=o}if(e.channels||e.audioCodec){var l=ur(e,r);if(l>-1)return r[l]}}}return null},r.setAudioTrack=function(e,t){void 0===t&&(t=!0);var r=this.tracksInGroup;if(e<0||e>=r.length)this.warn("Invalid audio track id: "+e);else{this.selectDefaultTrack=!1;var i=this.currentTrack,n=r[e],a=n.details&&!n.details.live;if(!(e===this.trackId&&n===i&&a||(this.log("Switching to audio-track "+e+' "'+n.name+'" lang:'+n.lang+" group:"+n.groupId+" channels:"+n.channels),this.trackId=e,this.currentTrack=n,this.hls.trigger(G.AUDIO_TRACK_SWITCHING,c(c({},n),{},{flushImmediate:t})),a))){var s=this.switchParams(n.url,null==i?void 0:i.details,n.details);this.loadPlaylist(s)}}},r.findTrackId=function(e){for(var t=this.tracksInGroup,r=0;r":"\n"+this.list("video")+"\n"+this.list("audio")+"\n"+this.list("audiovideo")+"}"},t.list=function(e){var t,r;return null!=(t=this.queues)&&t[e]||null!=(r=this.tracks)&&r[e]?e+": ("+this.listSbInfo(e)+") "+this.listOps(e):""},t.listSbInfo=function(e){var t,r=null==(t=this.tracks)?void 0:t[e],i=null==r?void 0:r.buffer;return i?"SourceBuffer"+(i.updating?" updating":"")+(r.ended?" ended":"")+(r.ending?" ending":""):"none"},t.listOps=function(e){var t;return(null==(t=this.queues)?void 0:t[e].map((function(e){return e.label})).join(", "))||""},e}(),fs=/(avc[1234]|hvc1|hev1|dvh[1e]|vp09|av01)(?:\.[^.,]+)+/,cs="HlsJsTrackRemovedError",gs=function(e){function t(t){var r;return(r=e.call(this,t)||this).name=cs,r}return d(t,e),t}(E(Error)),vs=function(e){function t(t,r){var i,n;return(i=e.call(this,"buffer-controller",t.logger)||this).hls=void 0,i.fragmentTracker=void 0,i.fragmentAppendProgress=Object.create(null),i.appendsWithoutProgress=Object.create(null),i.details=null,i._objectUrl=null,i.operationQueue=null,i.bufferCodecEventsTotal=0,i.media=null,i.mediaSource=null,i.lastMpegAudioChunk=null,i.lastVideoAppendEnd=0,i.appendSource=void 0,i.transferData=void 0,i.overrides=void 0,i.appendErrors={audio:0,video:0,audiovideo:0},i.appendError=void 0,i._quotaEvictionPending={},i.tracks={},i.sourceBuffers=[[null,null],[null,null]],i._onEndStreaming=function(e){var t;i.hls&&"open"===(null==(t=i.mediaSource)?void 0:t.readyState)&&i.media&&!i.media.seeking&&i.hls.pauseBuffering()},i._onStartStreaming=function(e){i.hls&&i.hls.resumeBuffering()},i._onMediaSourceOpen=function(e){var t=i,r=t.media,n=t.mediaSource;e&&i.log("Media source opened"),r&&n&&(In(n,"sourceopen",i._onMediaSourceOpen),In(r,"emptied",i._onMediaEmptied),i.updateDuration(),i.hls.trigger(G.MEDIA_ATTACHED,{media:r,mediaSource:n}),null!==i.mediaSource&&i.checkPendingTracks())},i._onMediaSourceClose=function(){if(i.log("Media source closed"),i.media){var e=i.media.error,t=i,r=t.appendError,n=t.appendErrors,a=!1;!r&&e&&(a=Math.max(++n.audio,++n.video,++n.audiovideo)>=i.hls.config.appendErrorMaxRetry);var s=new Error("MediaSource closed while media attached"+ys(a,e));i.warn(s),i.hls.trigger(G.ERROR,l({fatal:a},r,{type:U.MEDIA_ERROR,details:B.MEDIA_SOURCE_REQUIRES_RESET,error:s}))}},i._onMediaSourceEnded=function(){i.log("Media source ended")},i._onMediaEmptied=function(){var e=i,t=e.mediaSrc,r=e._objectUrl;t!==r&&i.error("Media element src was set while attaching MediaSource ("+r+" > "+t+")")},i._onMediaError=function(){var e,t,r=i.media;r&&i.log("Media error (code: "+(null==(e=r.error)?void 0:e.code)+"): "+(null==(t=r.error)?void 0:t.message))},i.hls=t,i.fragmentTracker=r,i.appendSource=(n=pe(t.config.preferManagedMediaSource),"undefined"!=typeof self&&n===self.ManagedMediaSource),i.initTracks(),i.registerListeners(),i}d(t,e);var r=t.prototype;return r.hasSourceTypes=function(){return Object.keys(this.tracks).length>0},r.destroy=function(){this.unregisterListeners(),this.details=null,this.lastMpegAudioChunk=null,this.transferData=this.overrides=void 0,this.operationQueue&&(this.operationQueue.destroy(),this.operationQueue=null),this.hls=this.fragmentTracker=null,this._onMediaSourceOpen=this._onMediaSourceClose=null,this._onMediaSourceEnded=null,this._onStartStreaming=this._onEndStreaming=null,this._onMediaEmptied=this._onMediaError=null},r.registerListeners=function(){var e=this.hls;e.on(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.MANIFEST_PARSED,this.onManifestParsed,this),e.on(G.BUFFER_RESET,this.onBufferReset,this),e.on(G.BUFFER_APPENDING,this.onBufferAppending,this),e.on(G.BUFFER_CODECS,this.onBufferCodecs,this),e.on(G.BUFFER_EOS,this.onBufferEos,this),e.on(G.BUFFER_FLUSHING,this.onBufferFlushing,this),e.on(G.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(G.FRAG_PARSED,this.onFragParsed,this),e.on(G.FRAG_CHANGED,this.onFragChanged,this),e.on(G.ERROR,this.onError,this)},r.unregisterListeners=function(){var e=this.hls;e.off(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.MANIFEST_PARSED,this.onManifestParsed,this),e.off(G.BUFFER_RESET,this.onBufferReset,this),e.off(G.BUFFER_APPENDING,this.onBufferAppending,this),e.off(G.BUFFER_CODECS,this.onBufferCodecs,this),e.off(G.BUFFER_EOS,this.onBufferEos,this),e.off(G.BUFFER_FLUSHING,this.onBufferFlushing,this),e.off(G.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(G.FRAG_PARSED,this.onFragParsed,this),e.off(G.FRAG_CHANGED,this.onFragChanged,this),e.off(G.ERROR,this.onError,this)},r.transferMedia=function(){var e=this,t=this.media,r=this.mediaSource;if(!t)return null;var i={};if(this.operationQueue){var n=this.isUpdating();n||this.operationQueue.removeBlockers();var a=this.isQueued();(n||a)&&this.warn("Transfering MediaSource with"+(a?" operations in queue":"")+(n?" updating SourceBuffer(s)":"")+" "+this.operationQueue),this.operationQueue.destroy()}var s=this.transferData;return s&&!this.sourceBufferCount&&s.mediaSource===r?l(i,s.tracks):this.sourceBuffers.forEach((function(t){var r=t[0];r&&(i[r]=l({},e.tracks[r]),e.removeBuffer(r)),t[0]=t[1]=null})),{media:t,mediaSource:r,tracks:i}},r.initTracks=function(){this.resetAppendProgress(),this.sourceBuffers=[[null,null],[null,null]],this.tracks={},this.resetQueue(),this.lastMpegAudioChunk=null,this.lastVideoAppendEnd=0},r.onManifestLoading=function(){this.bufferCodecEventsTotal=0,this.details=null,this.resetAppendErrors(),this.resetAppendProgress()},r.onManifestParsed=function(e,t){var r,i=2;(t.audio&&!t.video||!t.altAudio)&&(i=1),this.bufferCodecEventsTotal=i,this.log(i+" bufferCodec event(s) expected."),null!=(r=this.transferData)&&r.mediaSource&&this.sourceBufferCount&&i&&this.bufferCreated()},r.onMediaAttaching=function(e,t){var r=this.media=t.media;this.transferData=this.overrides=void 0;var i=pe(this.appendSource);if(i){var n=!!t.mediaSource;(n||t.overrides)&&(this.transferData=t,this.overrides=t.overrides);var a=this.mediaSource=t.mediaSource||new i;if(this.assignMediaSource(a),n)this._objectUrl=r.src,this.attachTransferred();else{var s=this._objectUrl=self.URL.createObjectURL(a);if(this.appendSource)try{r.removeAttribute("src");var o=self.ManagedMediaSource;r.disableRemotePlayback=r.disableRemotePlayback||o&&a instanceof o,ms(r),function(e,t){var r=self.document.createElement("source");r.type="video/mp4",r.src=t,e.appendChild(r)}(r,s),r.load()}catch(e){r.src=s}else r.src=s}An(r,"emptied",this._onMediaEmptied),An(r,"error",this._onMediaError),this.appendSource&&An(r,"seeking",this._onStartStreaming)}},r.assignMediaSource=function(e){var t,r;this.log(((null==(t=this.transferData)?void 0:t.mediaSource)===e?"transferred":"created")+" media source: "+(null==(r=e.constructor)?void 0:r.name)),An(e,"sourceopen",this._onMediaSourceOpen),An(e,"sourceended",this._onMediaSourceEnded),An(e,"sourceclose",this._onMediaSourceClose),this.appendSource&&(An(e,"startstreaming",this._onStartStreaming),An(e,"endstreaming",this._onEndStreaming))},r.attachTransferred=function(){var e=this,t=this.media,r=this.transferData;if(r&&t){var i=this.tracks,n=r.tracks,a=n?Object.keys(n):null,s=a?a.length:0,o=function(){Promise.resolve().then((function(){e.media&&e.mediaSourceOpenOrEnded&&e._onMediaSourceOpen()}))};if(n&&a&&s){if(!this.tracksReady)return this.hls.config.startFragPrefetch=!0,void this.log("attachTransferred: waiting for SourceBuffer track info");if(this.log("attachTransferred: (bufferCodecEventsTotal "+this.bufferCodecEventsTotal+")\nrequired tracks: "+ar(i,(function(e,t){return"initSegment"===e?void 0:t}))+";\ntransfer tracks: "+ar(n,(function(e,t){return"initSegment"===e?void 0:t}))+"}"),!ye(n,i)){r.mediaSource=null,r.tracks=void 0;var l=t.currentTime,u=this.details,d=Math.max(l,(null==u?void 0:u.fragments[0].start)||0);return d-l>1?void this.log("attachTransferred: waiting for playback to reach new tracks start time "+l+" -> "+d):(this.warn('attachTransferred: resetting MediaSource for incompatible tracks ("'+Object.keys(n)+'"->"'+Object.keys(i)+'") start time: '+d+" currentTime: "+l),this.onMediaDetaching(G.MEDIA_DETACHING,{}),this.onMediaAttaching(G.MEDIA_ATTACHING,r),void(t.currentTime=d))}this.transferData=void 0,a.forEach((function(t){var r=t,i=n[r];if(i){var a=i.buffer;if(a){var s=e.fragmentTracker,o=i.id;if(s.hasFragments(o)||s.hasParts(o)){var l=si.getBuffered(a);s.detectEvictedFragments(r,l,o,null,null,!0)}var u=ps(r),d=[r,a];e.sourceBuffers[u]=d,a.updating&&e.operationQueue&&e.operationQueue.prependBlocker(r),e.trackSourceBuffer(r,i)}}})),o(),this.bufferCreated()}else this.log("attachTransferred: MediaSource w/o SourceBuffers"),o()}},r.onMediaDetaching=function(e,t){var r=this,i=!!t.transferMedia;this.transferData=this.overrides=void 0;var n=this.media,a=this.mediaSource,s=this._objectUrl;if(a){if(this.log("media source "+(i?"transferring":"detaching")),i)this.sourceBuffers.forEach((function(e){var t=e[0];t&&r.removeBuffer(t)})),this.resetQueue();else{if(this.mediaSourceOpenOrEnded){var o="open"===a.readyState;try{for(var l=a.sourceBuffers,u=l.length;u--;)o&&l[u].abort(),a.removeSourceBuffer(l[u]);o&&a.endOfStream()}catch(e){this.warn("onMediaDetaching: "+e.message+" while calling endOfStream")}}this.sourceBufferCount&&this.onBufferReset()}In(a,"sourceopen",this._onMediaSourceOpen),In(a,"sourceended",this._onMediaSourceEnded),In(a,"sourceclose",this._onMediaSourceClose),this.appendSource&&(In(a,"startstreaming",this._onStartStreaming),In(a,"endstreaming",this._onEndStreaming)),this.mediaSource=null,this._objectUrl=null}n&&(In(n,"emptied",this._onMediaEmptied),In(n,"error",this._onMediaError),In(n,"seeking",this._onStartStreaming),i||(s&&self.URL.revokeObjectURL(s),this.mediaSrc===s?(n.removeAttribute("src"),this.appendSource&&ms(n),n.load()):this.warn("media|source.src was changed by a third party - skip cleanup")),this.media=null)},r.onBufferReset=function(){var e=this;this.sourceBuffers.forEach((function(t){var r=t[0];r&&e.resetBuffer(r)})),this.initTracks()},r.resetBuffer=function(e){var t,r=null==(t=this.tracks[e])?void 0:t.buffer;if(this.removeBuffer(e),r)try{var i;null!=(i=this.mediaSource)&&i.sourceBuffers.length&&this.mediaSource.removeSourceBuffer(r)}catch(t){this.warn("onBufferReset "+e,t)}delete this.tracks[e]},r.removeBuffer=function(e){this.removeBufferListeners(e),this.sourceBuffers[ps(e)]=[null,null];var t=this.tracks[e];t&&(this.clearBufferAppendTimeoutId(t),t.buffer=void 0)},r.resetQueue=function(){this.operationQueue&&this.operationQueue.destroy(),this.operationQueue=new hs(this.tracks)},r.onBufferCodecs=function(e,t){var r,i=this,n=this.tracks,a=Object.keys(t);this.log('BUFFER_CODECS: "'+a+'" (current SB count '+this.sourceBufferCount+")");var s="audiovideo"in t&&(n.audio||n.video)||n.audiovideo&&("audio"in t||"video"in t),o=!s&&this.sourceBufferCount&&this.media&&a.some((function(e){return!n[e]}));s||o?this.warn('Unsupported transition between "'+Object.keys(n)+'" and "'+a+'" SourceBuffers'):(a.forEach((function(e){var r,a,s=t[e],o=s.id,l=s.codec,u=s.levelCodec,d=s.container,h=s.metadata,f=s.supplemental,c=n[e],g=null==(r=i.transferData)||null==(r=r.tracks)?void 0:r[e],v=null!=g&&g.buffer?g:c,m=(null==v?void 0:v.pendingCodec)||(null==v?void 0:v.codec),p=null==v?void 0:v.levelCodec;c||(c=n[e]={buffer:void 0,listeners:[],codec:l,supplemental:f,container:d,levelCodec:u,metadata:h,id:o});var y=Nt(m,p),E=null==y?void 0:y.replace(fs,"$1"),T=Nt(l,u),S=null==(a=T)?void 0:a.replace(fs,"$1");T&&y&&E!==S&&("audio"===e.slice(0,5)&&(T=Ft(T,i.appendSource)),i.log("switching codec "+m+" to "+T),T!==(c.pendingCodec||c.codec)&&(c.pendingCodec=T),c.container=d,i.appendChangeType(e,d,T))})),(this.tracksReady||this.sourceBufferCount)&&(t.tracks=this.sourceBufferTracks),this.sourceBufferCount||(this.bufferCodecEventsTotal>1&&!this.tracks.video&&!t.video&&"main"===(null==(r=t.audio)?void 0:r.id)&&(this.log("Main audio-only"),this.bufferCodecEventsTotal=1),this.mediaSourceOpenOrEnded&&this.checkPendingTracks()))},r.appendChangeType=function(e,t,r){var i=this,n=t+";codecs="+r,a={label:"change-type="+n,execute:function(){var a=i.tracks[e];if(a){var s=a.buffer;null!=s&&s.changeType&&(i.log("changing "+e+" sourceBuffer type to "+n),s.changeType(n),a.codec=r,a.container=t)}i.shiftAndExecuteNext(e)},onStart:function(){},onComplete:function(){},onError:function(t){i.warn("Failed to change "+e+" SourceBuffer type",t)}};this.append(a,e,this.isPending(this.tracks[e]))},r.blockAudio=function(e){var t,r=this,i=e.start,n=i+.05*e.duration;if(!0!==(null==(t=this.fragmentTracker.getAppendedFrag(i,H.MAIN))?void 0:t.gap)){var a={label:"block-audio",execute:function(){var e,t;(r.lastVideoAppendEnd>n||si.isBuffered(null==(e=r.tracks.video)?void 0:e.buffer,n)||!0===(null==(t=r.fragmentTracker.getAppendedFrag(n,H.MAIN))?void 0:t.gap))&&r.unblockAudio()},onStart:function(){},onComplete:function(){},onError:function(e){r.warn("Error executing block-audio operation",e)}};this.append(a,"audio",!0)}},r.unblockAudio=function(){this.operationQueue&&this.operationQueue.unblockAudio()},r.onBufferAppending=function(e,t){var r=this,i=this.tracks,n=t.data,a=t.type,s=t.parent,o=t.frag,l=t.part,u=t.chunkMeta,d=t.offset,h=u.buffering[a],f=W(o)&&!l&&!o.gap&&!u.partial&&!u.iframe,c=null,g=!1,v=o.sn,m=o.cc,p=self.performance.now();h.start=p;var y=o.stats.buffering,E=l?l.stats.buffering:null;0===y.start&&(y.start=p),0===(null==E?void 0:E.start)&&(E.start=p);var T=i.audio,S=!1;"audio"===a&&"audio/mpeg"===(null==T?void 0:T.container)&&(S=!this.lastMpegAudioChunk||1===u.id||this.lastMpegAudioChunk.sn!==u.sn,this.lastMpegAudioChunk=u);var A=i.video,I=null==A?void 0:A.buffer;if(I&&"initSegment"!==v&&void 0!==d){var L=null==T?void 0:T.buffer,R=l||o;if("audio"===a&&"main"!==s&&!A.ending&&!A.ended&&L&&si.getBuffered(L).length){var b=R.start+.05*R.duration,k=si.getBuffered(I),D=this.currentOp("video");k.length||D?!si.isBuffered(I,b)&&this.lastVideoAppendEndR.duration?this.unblockAudio():this.isAudioBlocked()&&this.executeNext("audio")}}var C=(l||o).start,w={label:"append-"+a,execute:function(){var e;h.executeStart=self.performance.now();var t=null==(e=r.tracks[a])?void 0:e.buffer;t&&(f&&(c=si.timeRangesToArray(si.getBuffered(t)),g=Ss(Ts(c,o),o)),S?r.updateTimestampOffset(t,C,.1,a,v,m):void 0!==d&&x(d)&&r.updateTimestampOffset(t,d,1e-6,a,v,m)),r.appendExecutor(n,a)},onStart:function(){},onComplete:function(){r.clearBufferAppendTimeoutId(r.tracks[a]);var e=self.performance.now();h.executeEnd=h.end=e,0===y.first&&(y.first=e),0===(null==E?void 0:E.first)&&(E.first=e);var t={};if(r.sourceBuffers.forEach((function(e){var r=e[0],i=e[1];r&&(t[r]=si.getBuffered(i))})),f){var i=r.getFragmentAppendProgress(o),n=t[a];if(n&&c){var d=Ts(si.timeRangesToArray(n),o);i.progressed||(i.progressed=d-Ts(c,o)>.001);var v=g&&Ss(d,o);i.fullyBuffered[a]=(void 0===i.fullyBuffered[a]||i.fullyBuffered[a])&&v}}r.hls.trigger(G.BUFFER_APPENDED,{type:a,frag:o,part:l,chunkMeta:u,parent:s,timeRanges:t})},onError:function(e){var t;r.clearBufferAppendTimeoutId(r.tracks[a]);var i=e.code===DOMException.QUOTA_EXCEEDED_ERR||"QuotaExceededError"==e.name||"quota"in e;if(i&&!r._quotaEvictionPending[a]){var d=r.getBackBufferEvictionTarget(a,n.byteLength,o.type);if(d>0){r._quotaEvictionPending[a]=!0,r.log('QuotaExceededError on "'+a+'" append sn: '+v+" - evicting back buffer to "+d.toFixed(3)+"s and retrying");var h=r.getFlushOp(a,0,d),c=r.getClearEvictionPendingOp(a);return void r.insertNext([h,w,c],a)}r.warn('QuotaExceededError on "'+a+'" sn: '+v+" - no back buffer available to evict")}f&&(r.getFragmentAppendProgress(o).errored=!0);var g={type:U.MEDIA_ERROR,parent:s,details:B.BUFFER_APPEND_ERROR,sourceBufferName:a,frag:o,part:l,chunkMeta:u,error:e,err:e,fatal:!1},m=null==(t=r.media)?void 0:t.error;if(e.name!==cs||0!==r.sourceBufferCount||r.media&&0!==r.pendingTrackCount){var p;i&&(g.details=B.BUFFER_FULL_ERROR);var y=++r.appendErrors[a],E=r.hls.config.appendErrorMaxRetry;r.warn("Failed "+y+"/"+(E+1)+' times to append segment in "'+a+'" sourceBuffer with error: '+e.message),y>=E&&(g.fatal=!i);var T=null==(p=r.mediaSource)?void 0:p.readyState;("ended"===T||"closed"===T||m)&&(r.warn('MediaSource readyState "'+T+'" during SourceBuffer'+ys(g.fatal,m)),g.details=B.MEDIA_SOURCE_REQUIRES_RESET)}else g.errorAction=Fr(!0);r.appendError=g,r.hls.trigger(G.ERROR,g),i&&r.hls&&r.trimBuffers(o.start,1)}};this.log('queuing "'+a+'" append sn: '+v+(l?" p: "+l.index:"")+" of "+s+" playlist "+o.level+" cc: "+m+" offset: "+d+" bytes: "+n.byteLength),this.append(w,a,this.isPending(this.tracks[a]))},r.getClearEvictionPendingOp=function(e){var t=this;return{label:"clear",execute:function(){t._quotaEvictionPending[e]=!1,t.shiftAndExecuteNext(e)},onStart:function(){},onComplete:function(){},onError:function(){}}},r.getFlushOp=function(e,t,r){var i=this;return this.log('queuing "'+e+'" remove '+t+"-"+r),{label:"remove",execute:function(){i.removeExecutor(e,t,r)},onStart:function(){},onComplete:function(){var n,a=null==(n=i.tracks[e])?void 0:n.buffer;i.log("Remove request "+t+"-"+r+" from "+e+" Source Buffer complete > buffer: "+(a?Ln(si.getBuffered(a)):"(detached)")),i.hls.trigger(G.BUFFER_FLUSHED,{type:e,start:t,end:r})},onError:function(n){i.warn("Failed to remove "+t+"-"+r+' from "'+e+'" SourceBuffer',n),i.hls.trigger(G.BUFFER_FLUSHED,{type:e,start:0,end:0,error:n})}}},r.onBufferFlushing=function(e,t){var r=this,i=t.type,n=t.startOffset,a=t.endOffset;i&&"audio"!==i||this.unblockAudio(),i?this.append(this.getFlushOp(i,n,a),i):this.sourceBuffers.forEach((function(e){var t=e[0];t&&r.append(r.getFlushOp(t,n,a),t)}))},r.onFragParsed=function(e,t){var r=this,i=t.frag,n=t.part,a=t.chunkMeta,s=[],o=n?n.elementaryStreams:i.elementaryStreams;o[Y.AUDIOVIDEO]?s.push("audiovideo"):(o[Y.AUDIO]&&s.push("audio"),o[Y.VIDEO]&&s.push("video")),0===s.length&&this.log("Fragments must have at least one ElementaryStreamType set. "+i.type+" "+i.level+" "+(n?"part: "+n.index+" of ":"")+"sn: "+i.sn),this.blockBuffers((function(){var e=self.performance.now();i.stats.buffering.end=e,n&&(n.stats.buffering.end=e);var t=n?n.stats:i.stats;r.checkAppendProgress(i,n,a,s),r.hls&&(r.hls.trigger(G.FRAG_BUFFERED,{frag:i,part:n,stats:t,id:i.type,chunkMeta:a}),!n&&i.gap&&t.retry&&r.log("Nothing buffered for "+i.type+" level: "+i.level+" sn: "+i.sn+" retries "+t.retry))}),s).catch((function(e){r.warn("Fragment buffered callback "+e.message),r.stepOperationQueue(r.sourceBufferTypes)}))},r.onFragChanged=function(e,t){var r,i,n=this,a=null==(r=this.hls)?void 0:r.config;if(a){var s=a.backBufferLength,o=a.frontBufferFlushThreshold;this.trimBuffers(o,s,t.frag,t.previousFrag);var l=t.frag.elementaryStreams,u=this.appendErrors,d=null==(i=this.appendError)?void 0:i.sourceBufferName;Object.keys(l).forEach((function(e){l[e]&&(u[e]=0,e===d&&(n.appendError=void 0),"audio"===e||"video"===e?(u.audiovideo=0,"audiovideo"===d&&(n.appendError=void 0)):(u.audio=0,u.video=0,"audiovideo"!==d&&(n.appendError=void 0)))}))}},r.onBufferEos=function(e,t){var r,i=this;this.sourceBuffers.forEach((function(e){var r=e[0];if(r){var n=i.tracks[r];t.type&&t.type!==r||(n.ending=!0,n.ended||(n.ended=!0,i.log(r+" buffer reached EOS")))}}));var n=!1!==(null==(r=this.overrides)?void 0:r.endOfStream);this.sourceBufferCount>0&&!this.sourceBuffers.some((function(e){var t,r=e[0];return r&&!(null!=(t=i.tracks[r])&&t.ended)}))?(this.unblockAudio(),n?(this.log("Queueing EOS"),this.blockUntilOpen((function(){i.tracksEnded();var e=i.mediaSource;"open"===(null==e?void 0:e.readyState)?(i.log("Calling mediaSource.endOfStream()"),e.endOfStream(),i.hls.trigger(G.BUFFERED_TO_END,void 0)):e&&i.log("Could not call mediaSource.endOfStream(). mediaSource.readyState: "+e.readyState)}))):(this.tracksEnded(),this.hls.trigger(G.BUFFERED_TO_END,void 0))):"video"===t.type&&this.unblockAudio()},r.tracksEnded=function(){var e=this;this.sourceBuffers.forEach((function(t){var r=t[0];if(null!==r){var i=e.tracks[r];i&&(i.ending=!1)}}))},r.onLevelUpdated=function(e,t){var r=t.details;r.fragments.length&&(this.details=r,this.updateDuration())},r.updateDuration=function(){var e=this;this.blockUntilOpen((function(){var t=e.getDurationAndRange();t&&e.updateMediaSource(t)}))},r.onError=function(e,t){if(t.details===B.BUFFER_APPEND_ERROR&&t.frag){var r,i=null==(r=t.errorAction)?void 0:r.nextAutoLevel;x(i)&&i!==t.frag.level&&this.resetAppendErrors()}},r.getBackBufferEvictionTarget=function(e,t,r){var i=this.media;return i?this.fragmentTracker.getBackBufferEvictionEnd(i.currentTime,r,t):0},r.getFragmentAppendProgress=function(e){var t,r=Es(e),i=this.fragmentAppendProgress[r];return(null==(t=i)?void 0:t.stats)!==e.stats&&(i={stats:e.stats,progressed:!1,errored:!1,fullyBuffered:Object.create(null)},this.fragmentAppendProgress[r]=i),i},r.checkAppendProgress=function(e,t,r,i){if(!(t||!W(e)||e.gap||r.iframe||r.partial)){var n=Es(e),a=this.fragmentAppendProgress[n];delete this.fragmentAppendProgress[n];var s=e.stats.buffering;if(!s.start||s.first){var o=(null==a?void 0:a.stats)===e.stats?a:void 0;if(null==o||!o.errored){var l=i.length>0&&i.every((function(e){return!0===(null==o?void 0:o.fullyBuffered[e])}));if(null!=o&&o.progressed||l)delete this.appendsWithoutProgress[n];else if(o||0!==i.length){var u=(this.appendsWithoutProgress[n]||0)+1,d=this.hls;u>=d.config.appendErrorMaxRetry?delete this.appendsWithoutProgress[n]:this.appendsWithoutProgress[n]=u,this.warn("Fragment "+e.sn+" of "+e.type+" playlist "+e.level+" appended "+u+" time"+(u>1?"s":"")+" without buffered range growth"),d.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.BUFFER_APPEND_NO_PROGRESS,fatal:!1,frag:e,chunkMeta:r,parent:e.type,appendsWithoutProgress:u,error:new Error("Fragment append did not increase buffered coverage ("+u+")")})}}}}},r.resetAppendProgress=function(){this.fragmentAppendProgress=Object.create(null),this.appendsWithoutProgress=Object.create(null)},r.resetAppendErrors=function(){this.appendErrors={audio:0,video:0,audiovideo:0},this.appendError=void 0},r.trimBuffers=function(e,t,r,i){var n=this.hls,a=this.details,s=this.media;if(s&&null!==a&&this.sourceBufferCount){var o=n.config,l=s.currentTime,u=a.levelTargetDuration;t=a.live&&null!==o.liveBackBufferLength?o.liveBackBufferLength:t;var d=-1/0;if(x(t)&&t>=0){var h=Math.max(t,u);d=Math.floor(l/u)*u-h}if(r){var f=this.getLoopBackBufferFlushEnd(r,null!=i?i:null);f>0&&(d=Math.max(d,f))}if(d>0&&this.flushBackBuffer(l,u,d),x(e)&&e>0){var c=Math.max(o.maxBufferLength,e),g=Math.max(c,u),v=Math.floor(l/u)*u+g;this.flushFrontBuffer(l,u,v)}}},r.getLoopBackBufferFlushEnd=function(e,t){var r,i=this.media;if(!1===(null==(r=this.hls)?void 0:r.config.loopBackBufferFlush)||null==i||!i.loop||!t||e.level<=t.level)return 0;var n=e.elementaryStreams,a=n.video,s=n.audiovideo;if(null!=a&&a.partial||null!=s&&s.partial)return 0;var o=this.getEarliestElementaryStreamStart(e)-.25;return o<=0?0:(this.log("Flushing lower quality back buffer for loop: level "+e.level+", range [0-"+o.toFixed(3)+"]"),o)},r.getEarliestElementaryStreamStart=function(e){var t=e.elementaryStreams,r=t.audio,i=t.video,n=t.audiovideo,a=e.start;return n?a=Math.min(a,n.startDTS):(r&&(a=Math.min(a,r.startDTS)),i&&(a=Math.min(a,i.startDTS))),a},r.flushBackBuffer=function(e,t,r){var i=this;this.sourceBuffers.forEach((function(e){var t=e[0],n=e[1];if(n){var a=si.getBuffered(n);if(a.length>0&&r>a.start(0)){var s,o,l;i.hls.trigger(G.BACK_BUFFER_REACHED,{bufferEnd:r});var u=i.tracks[t];if(null!=(s=i.details)&&s.live)i.hls.trigger(G.LIVE_BACK_BUFFER_REACHED,{bufferEnd:r});else if(null!=u&&u.ended&&(null==(o=i.media)||!o.loop||!1===(null==(l=i.hls)?void 0:l.config.loopBackBufferFlush)))return void i.log("Cannot flush "+t+" back buffer while SourceBuffer is in ended state");i.hls.trigger(G.BUFFER_FLUSHING,{startOffset:0,endOffset:r,type:t})}}}))},r.flushFrontBuffer=function(e,t,r){var i=this;this.sourceBuffers.forEach((function(t){var n=t[0],a=t[1];if(a){var s=si.getBuffered(a),o=s.length;if(o<2)return;var l=s.start(o-1),u=s.end(o-1);if(r>l||e>=l&&e<=u)return;i.hls.trigger(G.BUFFER_FLUSHING,{startOffset:l,endOffset:1/0,type:n})}}))},r.getDurationAndRange=function(){var e,t=this.details,r=this.mediaSource;if(!t||!this.media||"open"!==(null==r?void 0:r.readyState))return null;var i=t.edge;if(t.live&&this.hls.config.liveDurationInfinity){if(t.fragments.length&&r.setLiveSeekableRange){var n=Math.max(0,t.fragmentStart);return{duration:1/0,start:n,end:Math.max(n,i)}}return{duration:1/0}}var a=null==(e=this.overrides)?void 0:e.duration;if(a)return x(a)?{duration:a}:null;var s=this.media.duration;return i>(x(r.duration)?r.duration:0)&&i>s||!x(s)?{duration:i}:null},r.updateMediaSource=function(e){var t=e.duration,r=e.start,i=e.end,n=this.mediaSource;n&&this.media&&"open"===n.readyState&&(n.duration!==t&&(x(t)&&this.log("Updating MediaSource duration to "+t.toFixed(3)),n.duration=t),void 0!==r&&void 0!==i&&(this.log("MediaSource duration is set to "+n.duration+". Setting seekable range to "+r+"-"+i+"."),n.setLiveSeekableRange(r,i)))},r.checkPendingTracks=function(){var e=this.bufferCodecEventsTotal,t=this.pendingTrackCount,r=this.tracks;if(this.log("checkPendingTracks (pending: "+t+" codec events expected: "+e+") "+ar(r)),this.tracksReady){var i,n=null==(i=this.transferData)?void 0:i.tracks;n&&Object.keys(n).length?this.attachTransferred():this.createSourceBuffers()}},r.bufferCreated=function(){var e=this;if(this.sourceBufferCount){var t={};this.sourceBuffers.forEach((function(r){var i=r[0],n=r[1];if(i){var a=e.tracks[i];t[i]={buffer:n,container:a.container,codec:a.codec,supplemental:a.supplemental,levelCodec:a.levelCodec,id:a.id,metadata:a.metadata}}})),this.hls.trigger(G.BUFFER_CREATED,{tracks:t}),this.log("SourceBuffers created. Running queue: "+this.operationQueue),this.sourceBuffers.forEach((function(t){var r=t[0];e.executeNext(r)}))}else{var r=new Error("could not create source buffer for media codec(s)");this.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.BUFFER_INCOMPATIBLE_CODECS_ERROR,fatal:!0,error:r,reason:r.message})}},r.createSourceBuffers=function(){var e=this.tracks,t=this.sourceBuffers,r=this.mediaSource;if(!r)throw new Error("createSourceBuffers called when mediaSource was null");for(var i in e){var n=i,a=e[n];if(this.isPending(a)){var s=this.getTrackCodec(a,n),o=a.container+";codecs="+s;a.codec=s,this.log("creating sourceBuffer("+o+")"+(this.currentOp(n)?" Queued":"")+" "+ar(a));try{var l=r.addSourceBuffer(o),u=ps(n),d=[n,l];t[u]=d,a.buffer=l}catch(e){var h;return this.error("error while trying to add sourceBuffer: "+e.message),this.shiftAndExecuteNext(n),null==(h=this.operationQueue)||h.removeBlockers(),delete this.tracks[n],void this.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.BUFFER_ADD_CODEC_ERROR,fatal:!1,error:e,sourceBufferName:n,mimeType:o,parent:a.id})}this.trackSourceBuffer(n,a)}}this.bufferCreated()},r.clearBufferAppendTimeoutId=function(e){e&&(self.clearTimeout(e.bufferAppendTimeoutId),e.bufferAppendTimeoutId=void 0)},r.getTrackCodec=function(e,t){var r=e.supplemental,i=e.codec;r&&("video"===t||"audiovideo"===t)&&Pt(r,"video")&&(i=function(e,t){var r=[];if(e)for(var i=e.split(","),n=0;n=r&&(this.log("Updating "+i+" SourceBuffer timestampOffset to "+t+" (sn: "+n+" cc: "+a+")"),e.timestampOffset=t)},r.removeExecutor=function(e,t,r){var i=this.media,n=this.mediaSource,a=this.tracks[e],s=null==a?void 0:a.buffer;if(!i||!n||!s)throw new Error("Attempting to remove from the "+e+" SourceBuffer, but it does not exist");var o,l=x(i.duration)?i.duration:1/0,u=x(n.duration)?n.duration:1/0,d=Math.max(0,t);if(d>=u)return null==(o=this.currentOp(e))||o.onComplete(),void this.shiftAndExecuteNext(e);var h=Math.min(r,l,u);if(!(h>d)||a.ending&&!a.ended)throw new Error("Cannot remove "+(h<=d?"invalid range ("+d+" >= "+h+") ":"")+"from the "+e+" SourceBuffer"+(a.ending?" while track ending":""));a.ended=!1,this.log("Removing ["+d+","+h+"] from the "+e+" SourceBuffer"),s.remove(d,h)},r.appendExecutor=function(e,t){var r=this,i=this.tracks[t],n=null==i?void 0:i.buffer;if(!n)throw new gs("Attempting to append to the "+t+" SourceBuffer, but it does not exist");if(i.ending=!1,i.ended=!1,this.hls.config.appendTimeout!==1/0){var a=this.calculateAppendTimeoutTime(n);i.bufferAppendTimeoutId=self.setTimeout((function(){return r.appendTimeoutHandler(t,n,a)}),a)}n.appendBuffer(e)},r.appendTimeoutHandler=function(e,t,r){this.log("Received timeout after "+r+"ms for append on "+e+" source buffer. Aborting and triggering error.");try{t.abort()}catch(t){this.log("Failed to abort append on "+e+" source buffer after timeout.")}var i=this.currentOp(e);i&&i.onError(new Error(e+"-append-timeout"))},r.calculateAppendTimeoutTime=function(e){var t=10;this.details&&(t=this.details.levelTargetDuration);var r=2*t*1e3;if(null===this.media)return r;var i=si.bufferInfo(e,this.media.currentTime,0);return i.len?(r=Math.max(1e3*i.len,r),Math.max(this.hls.config.appendTimeout,r)):r},r.blockUntilOpen=function(e){var t=this;if(this.isUpdating()||this.isQueued())this.blockBuffers(e).catch((function(e){t.warn("SourceBuffer blocked callback "+e),t.stepOperationQueue(t.sourceBufferTypes)}));else try{e()}catch(e){this.warn("Callback run without blocking "+this.operationQueue+" "+e)}},r.isUpdating=function(){return this.sourceBuffers.some((function(e){var t=e[0],r=e[1];return t&&r.updating}))},r.isQueued=function(){var e=this;return this.sourceBuffers.some((function(t){var r=t[0];return r&&!!e.currentOp(r)}))},r.isPending=function(e){return!!e&&!e.buffer},r.isAudioBlocked=function(){var e;return"block-audio"===(null==(e=this.currentOp("audio"))?void 0:e.label)},r.isAudioBlocking=function(){return!!this.operationQueue&&this.operationQueue.audioBlocking()},r.blockBuffers=function(e,t){var r=this;if(void 0===t&&(t=this.sourceBufferTypes),!t.length)return this.log("Blocking operation requested, but no SourceBuffers exist"),Promise.resolve().then(e);var i=this.operationQueue,n=2===t.length&&this.isAudioBlocking()?[this.appendBlocker("video")]:t.map((function(e){return r.appendBlocker(e)}));return Promise.all(n).then((function(t){i===r.operationQueue&&(e(),r.stepOperationQueue(r.sourceBufferTypes))}))},r.stepOperationQueue=function(e){var t=this;e.forEach((function(e){var r,i=null==(r=t.tracks[e])?void 0:r.buffer;!i||i.updating||"audio"===e&&t.isAudioBlocked()||t.shiftAndExecuteNext(e)}))},r.append=function(e,t,r){this.operationQueue&&this.operationQueue.append(e,t,r)},r.insertNext=function(e,t){this.operationQueue&&this.operationQueue.insertNext(e,t)},r.appendBlocker=function(e){if(this.operationQueue)return this.operationQueue.appendBlocker(e)},r.currentOp=function(e){return this.operationQueue?this.operationQueue.current(e):null},r.executeNext=function(e){e&&this.operationQueue&&this.operationQueue.executeNext(e)},r.shiftAndExecuteNext=function(e){this.operationQueue&&this.operationQueue.shiftAndExecuteNext(e)},r.addBufferListener=function(e,t,r){var i=this.tracks[e];if(i){var n=i.buffer;if(n){var a=r.bind(this,e);i.listeners.push({event:t,listener:a}),An(n,t,a)}}},r.removeBufferListeners=function(e){var t=this.tracks[e];if(t){var r=t.buffer;r&&(t.listeners.forEach((function(e){In(r,e.event,e.listener)})),t.listeners.length=0)}},a(t,[{key:"mediaSourceOpenOrEnded",get:function(){var e,t=null==(e=this.mediaSource)?void 0:e.readyState;return"open"===t||"ended"===t}},{key:"sourceBufferTracks",get:function(){var e=this;return Object.keys(this.tracks).reduce((function(t,r){var i=e.tracks[r];return t[r]={id:i.id,container:i.container,codec:i.codec,levelCodec:i.levelCodec},t}),{})}},{key:"bufferedToEnd",get:function(){var e=this;return this.sourceBufferCount>0&&!this.sourceBuffers.some((function(t){var r=t[0];if(r){var i=e.tracks[r];if(i)return!i.ended||i.ending}return!1}))}},{key:"tracksReady",get:function(){var e=this.pendingTrackCount;return e>0&&(e>=this.bufferCodecEventsTotal||this.isPending(this.tracks.audiovideo))}},{key:"mediaSrc",get:function(){var e,t,r=(null==(e=this.media)||null==(t=e.querySelector)?void 0:t.call(e,"source"))||this.media;return null==r?void 0:r.src}},{key:"pendingTrackCount",get:function(){var e=this;return Object.keys(this.tracks).reduce((function(t,r){return t+(e.isPending(e.tracks[r])?1:0)}),0)}},{key:"sourceBufferCount",get:function(){return this.sourceBuffers.reduce((function(e,t){return e+(t[0]?1:0)}),0)}},{key:"sourceBufferTypes",get:function(){return this.sourceBuffers.map((function(e){return e[0]})).filter((function(e){return!!e}))}}])}(ue);function ms(e){var t=e.querySelectorAll("source");[].slice.call(t).forEach((function(t){e.removeChild(t)}))}function ps(e){return"audio"===e?1:0}function ys(e,t){return(e?"":" - triggering recovery")+(t?" with HTMLMediaElement Error: "+t.message:"")}function Es(e){return e.type+"_"+e.sn+"_"+e.level}function Ts(e,t){for(var r=t.start,i=t.end,n=0,a=0;a=i)break;var o=Math.max(r,s.start),l=Math.min(i,s.end);l>o&&(n+=l-o)}return n}function Ss(e,t){return t.duration-e<=.05}var As=function(){function e(e){this.hls=null,this.autoLevelCapping=void 0,this.media=void 0,this.restrictedLevels=void 0,this.timer=void 0,this.observer=void 0,this.clientRect=void 0,this.streamController=void 0,this.hls=e,this.autoLevelCapping=Number.POSITIVE_INFINITY,this.media=null,this.restrictedLevels=[],this.clientRect=null,this.registerListeners()}var t=e.prototype;return t.setStreamController=function(e){this.streamController=e},t.destroy=function(){this.hls&&this.unregisterListener(),(this.timer||this.observer)&&this.stopCapping(),this.media=this.clientRect=this.hls=null,this.streamController=void 0},t.registerListeners=function(){var e=this.hls;e&&(e.on(G.FPS_DROP_LEVEL_CAPPING,this.onFpsDropLevelCapping,this),e.on(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(G.MANIFEST_PARSED,this.onManifestParsed,this),e.on(G.LEVELS_UPDATED,this.onLevelsUpdated,this),e.on(G.BUFFER_CODECS,this.onBufferCodecs,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this))},t.unregisterListener=function(){var e=this.hls;e&&(e.off(G.FPS_DROP_LEVEL_CAPPING,this.onFpsDropLevelCapping,this),e.off(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(G.MANIFEST_PARSED,this.onManifestParsed,this),e.off(G.LEVELS_UPDATED,this.onLevelsUpdated,this),e.off(G.BUFFER_CODECS,this.onBufferCodecs,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this))},t.onFpsDropLevelCapping=function(e,t){if(this.hls){var r=this.hls.levels[t.droppedLevel];this.isLevelAllowed(r)&&this.restrictedLevels.push({bitrate:r.bitrate,height:r.height,width:r.width})}},t.onMediaAttaching=function(e,t){var r=t.media;this.clientRect=null,this.hls&&(r instanceof HTMLVideoElement?(this.media=r,this.hls.config.capLevelToPlayerSize&&this.observe()):this.media=null,(this.timer||this.observer)&&this.hls.levels.length>1&&this.detectPlayerSize())},t.onManifestParsed=function(e,t){this.restrictedLevels=[],t.video&&this.startCapping(),this.onLevelsUpdated()},t.onLevelsUpdated=function(){if(this.hls)if((this.timer||this.observer)&&(x(this.autoLevelCapping)||this.clientRect))this.detectPlayerSize();else if(void 0===this.observer&&void 0===this.timer){var e=this.hls.levels;e.length>1&&e.some((function(e){return!!e.videoCodec}))&&this.startCapping()}},t.onBufferCodecs=function(e,t){t.video&&this.startCapping()},t.onMediaDetaching=function(){this.stopCapping(),this.media=null},t.detectPlayerSize=function(){if(this.media){if(this.mediaHeight<=0||this.mediaWidth<=0||!this.hls)return void(this.clientRect=null);var e=this.hls.levels;if(e.length>1){var t=this.hls,r=this.getMaxLevel(e.length-1);r!==this.autoLevelCapping&&t.logger.log("Setting autoLevelCapping to "+r+": "+e[r].height+"p@"+e[r].bitrate+" for media "+this.mediaWidth+"x"+this.mediaHeight),t.autoLevelCapping=r,t.autoLevelEnabled&&t.autoLevelCapping>this.autoLevelCapping&&this.streamController&&this.streamController.nextLevelSwitch(),this.autoLevelCapping=t.autoLevelCapping}else 1===e.length&&this.stopCapping()}},t.getMaxLevel=function(t){var r=this;if(!this.hls)return-1;var i=this.hls.levels;if(!i.length)return-1;var n=i.filter((function(e,i){return r.isLevelAllowed(e)&&i<=t}));return this.observer||(this.clientRect=null),e.getMaxLevelByMediaSize(n,this.mediaWidth,this.mediaHeight)},t.observe=function(){var e=this,t=self.ResizeObserver;t&&(this.observer=new t((function(t){var r,i=null==(r=t[0])?void 0:r.contentRect;i&&(e.clientRect=i,e.detectPlayerSize())}))),this.observer&&this.media&&this.observer.observe(this.media)},t.startCapping=function(){var e;this.timer||this.observer||null==(e=this.hls)||!e.config.capLevelToPlayerSize||(self.clearInterval(this.timer),this.timer=void 0,this.autoLevelCapping=Number.POSITIVE_INFINITY,this.observe(),this.observer||(this.timer=self.setInterval(this.detectPlayerSize.bind(this),1e3)),this.detectPlayerSize())},t.stopCapping=function(){this.restrictedLevels=[],this.autoLevelCapping=Number.POSITIVE_INFINITY,this.timer&&(self.clearInterval(this.timer),this.timer=void 0),this.observer&&(this.observer.disconnect(),this.observer=void 0)},t.getDimensions=function(){if(this.clientRect)return this.clientRect;var e=this.media,t={width:0,height:0};if(e){var r=e.getBoundingClientRect();t.width=r.width,t.height=r.height,t.width||t.height||(t.width=r.right-r.left||e.width||0,t.height=r.bottom-r.top||e.height||0)}return this.clientRect=t,t},t.isLevelAllowed=function(e){return!this.restrictedLevels.some((function(t){return e.bitrate===t.bitrate&&e.width===t.width&&e.height===t.height}))},e.getMaxLevelByMediaSize=function(e,t,r){if(null==e||!e.length)return-1;for(var i,n,a=e.length-1,s=Math.max(t,r),o=0;o=s||l.height>=s)&&(i=l,!(n=e[o+1])||i.width!==n.width||i.height!==n.height)){a=o;break}}return a},a(e,[{key:"mediaWidth",get:function(){return this.getDimensions().width*this.contentScaleFactor}},{key:"mediaHeight",get:function(){return this.getDimensions().height*this.contentScaleFactor}},{key:"contentScaleFactor",get:function(){var e=1;if(!this.hls)return e;var t=this.hls.config,r=t.ignoreDevicePixelRatio,i=t.maxDevicePixelRatio;if(!r)try{e=self.devicePixelRatio}catch(e){}return Math.min(e,i)}}])}(),Is=function e(t,r){Array.isArray(t)&&(t=t.map((function(t){return t instanceof e?t:new e(t)}))),this.value=t,this.params=r},Ls="Dict";function Rs(e,t,r,i){return new Error("failed to "+e+' "'+(n=t,(Array.isArray(n)?JSON.stringify(n):n instanceof Map?"Map{}":n instanceof Set?"Set{}":"object"==typeof n?JSON.stringify(n):String(n))+'" as ')+r,{cause:i});var n}var bs="Bare Item",ks="Boolean",Ds="Byte Sequence",Ps="Decimal",_s="Integer",Cs="String",ws=/[\x00-\x1f\x7f]+/,Os=function(e){this.description=e},Ms="Token",xs="Key";function Fs(e,t,r){return Rs("serialize",e,t,r)}function Ns(e){if(!1===ArrayBuffer.isView(e))throw Fs(e,Ds);return":"+(t=e,btoa(String.fromCharCode.apply(String,t))+":");var t}function Us(e){if(function(e){return e<-999999999999999||99999999999999912)throw Fs(e,Ps);var r=t.toString();return r.includes(".")?r:r+".0"}function Gs(e){var t,r=(t=e).description||t.toString().slice(7,-1);if(!1===/^([a-zA-Z*])([!#$%&'*+\-.^_`|~\w:/]*)$/.test(r))throw Fs(r,Ms);return r}function Ks(e){switch(typeof e){case"number":if(!x(e))throw Fs(e,bs);return Number.isInteger(e)?Us(e):Bs(e);case"string":return function(e){if(ws.test(e))throw Fs(e,Cs);return'"'+e.replace(/\\/g,"\\\\").replace(/"/g,'\\"')+'"'}(e);case"symbol":return Gs(e);case"boolean":return function(e){if("boolean"!=typeof e)throw Fs(e,ks);return e?"?1":"?0"}(e);case"object":if(e instanceof Date)return function(e){return"@"+Us(e.getTime()/1e3)}(e);if(e instanceof Uint8Array)return Ns(e);if(e instanceof Os)return Gs(e);default:throw Fs(e,bs)}}function Vs(e){if(!1===/^[a-z*][a-z0-9\-_.*]*$/.test(e))throw Fs(e,xs);return e}function Hs(e){return null==e?"":Object.entries(e).map((function(e){var t=e[0],r=e[1];return!0===r?";"+Vs(t):";"+Vs(t)+"="+Ks(r)})).join("")}function Ys(e){return e instanceof Is?""+Ks(e.value)+Hs(e.params):Ks(e)}function js(e,t){var r;if(void 0===t&&(t={whitespace:!0}),"object"!=typeof e||null==e)throw Fs(e,Ls);var i=e instanceof Map?e.entries():Object.entries(e),n=null!=(r=t)&&r.whitespace?" ":"";return Array.from(i).map((function(e){var t=e[0],r=e[1];r instanceof Is==0&&(r=new Is(r));var i,n=Vs(t);return!0===r.value?n+=Hs(r.params):(n+="=",Array.isArray(r.value)?n+="("+(i=r).value.map(Ys).join(" ")+")"+Hs(i.params):n+=Ys(r)),n})).join(","+n)}function Ws(e,t){return js(e,t)}var qs,Xs=["url"],zs=["version","eventTargets","sid","transmissionMode"],Qs=["customData","headers"],$s=function(e){return e instanceof Is?new Is(Math.round(e.value),e.params):Math.round(e)},Zs=function(e){return Array.isArray(e)?e.map($s):$s(e)},Js=function(e,t){return Array.isArray(e)?e.map((function(e){return Js(e,t)})):e instanceof Is&&"string"==typeof e.value?new Is(Js(e.value,t),e.params):(t.baseUrl&&(e=function(e,t){var r;try{r=new URL(e)}catch(t){return e}var i=new URL(t);if(r.origin!==i.origin)return e;for(var n=r.pathname.split("/").slice(1),a=i.pathname.split("/").slice(1,-1),s=Math.min(n.length,a.length),o=0;o=2&&(e instanceof Is&&"string"==typeof e.value?r=new Is([e]):"string"==typeof e&&(r=[e])),Js(r,t)},rtp:to,tb:Zs},io=2,no="pr",ao="b",so="rr",oo="ce",lo="bc",uo="e",ho=so,fo=oo,co=new Map([["ps","sta"],[no,"pr"],["c","cid"],[ao,"bg"],["bc","br"]]),go="event",vo="request",mo=["cen","e","h","ts"],po=["ab","bg","bl","br","bs","bsa","bsd","bsda","cdn","cid","cs","d","dfa","dl","ec","lab","lb","ltc","msd","mtp","nor","nr","ot","pb","pr","pt","rtp","sf","sid","sn","st","sta","su","tab","tb","tbl","tpb","v"],yo=/^[a-zA-Z0-9-.]+-[a-zA-Z0-9-.]+$/;function Eo(e){return yo.test(e)}var To=new Set(po);function So(e){return To.has(e)||Eo(e)}var Ao=["cmsdd","cmsds","rc","smrt","ttfb","ttfbb","ttlb","url"],Io=new Set(Ao);function Lo(e){return Io.has(e)}var Ro=new Set(mo),bo=new Set(["ab","bl","br","bsa","bsd","bsda","lab","lb","mtp","pb","tab","tb","tbl","tpb"]),ko=["bl","br","bs","cid","d","dl","mtp","nor","nrr","ot","pr","rtp","sf","sid","st","su","tb","v"],Do=new Set(ko);function Po(e){return Do.has(e)||Eo(e)}var _o=new Set(["ot","sf","st","e","sta"]);function Co(e){return _o.has(e)}function wo(e){return"number"==typeof e?x(e):null!=e&&""!==e&&!1!==e}var Oo=((qs={})[go]=function(e){return So(e)||Lo(e)||Ro.has(e)},qs[vo]=So,qs);function Mo(e,t){var r;return Array.isArray(e)?(t&&(r=e.find((function(e){var r;return(null==(r=e.params)?void 0:r.ot)===t}))),r||(r=e[0]),Mo(r)):e instanceof Is?e.value:e}function xo(e,t){void 0===t&&(t={});var r={};if(null==e||"object"!=typeof e)return r;var i=t.version||e.v||io,n=t.reportingMode||vo,a=1===i?function(e){for(var t={},r=0,i=Object.entries(e);r1&&!l.includes("v")&&l.push("v");var c={version:i,reportingMode:n,baseUrl:t.baseUrl};l.sort();for(var g,v=s(l);!(g=v()).done;){var m,p,y=g.value,E=a[y],T=null!=(m=null==(p=t.formatters)?void 0:p[y])?m:ro[y];if("function"==typeof T&&(E=T(E,c)),"v"===y){if(1===i)continue;E=i}if("pr"!==y||1!==E||d&&a.e===no){d&&"ts"===y&&!x(E)&&(E=Date.now());var S=d&&!1===E&&"bg"===y&&a.e===ao;(wo(E)||S)&&(Co(y)&&"string"==typeof E&&(E=new Os(E)),r[y]=E)}}return r}var Fo="CMCD-Object",No="CMCD-Request",Uo="CMCD-Session",Bo="CMCD-Status",Go={REQUEST:No},Ko={ab:Fo,br:Fo,d:Fo,lab:Fo,lb:Fo,ot:Fo,tab:Fo,tb:Fo,tpb:Fo,bl:No,cs:No,dfa:No,dl:No,ltc:No,mtp:No,nor:No,nrr:No,pb:No,sn:No,sta:No,su:No,tbl:No,cid:Uo,msd:Uo,sf:Uo,sid:Uo,st:Uo,v:Uo,bg:Bo,bs:Bo,bsa:Bo,bsd:Bo,bsda:Bo,cdn:Bo,ec:Bo,nr:Bo,pr:Bo,pt:Bo,rtp:Bo};function Vo(e,t){var r={};if(!e)return r;for(var i,n=Object.keys(e),a=t?(i=t,Object.keys(i).reduce((function(e,t){var r;return null==(r=i[t])||r.forEach((function(r){return e[r]=t})),e}),{})):{},s=0,o=n;s0)}})),r&&this.processEventTargets()},t.sendEventReport=function(){var e,t=(e=v().m((function e(t,r){var i,n,a;return v().w((function(e){for(;;)switch(e.n){case 0:return i=ul(go,t),e.n=1,this.requester({url:t.url,method:"POST",headers:{"Content-Type":"application/cmcd"},body:r.map((function(e){return jo(e,i)})).join("\n")+"\n"});case 1:if(n=e.v,410!==(a=n.status)){e.n=2;break}this.disposeEventTarget(t),e.n=3;break;case 2:if(!(429===a||a>499&&a<600)){e.n=3;break}throw new Error("Event report failed with status "+a);case 3:return e.a(2)}}),e,this)})),function(){var t=this,r=arguments;return new Promise((function(n,a){var s=e.apply(t,r);function o(e){i(s,n,a,o,l,"next",e)}function l(e){i(s,n,a,o,l,"throw",e)}o(void 0)}))});return function(e,r){return t.apply(this,arguments)}}(),t.disarmInterval=function(e){clearInterval(e.intervalId),e.intervalId=void 0},t.disposeEventTarget=function(e){var t=this.eventTargets.get(e);t&&(this.disarmInterval(t),this.eventTargets.delete(e))},t.resetSession=function(){this.eventTargets.forEach((function(e){return e.sn=0})),this.requestTarget.sn=0,this.lastEmitted={}},e}(),ml="h",pl="v",yl="l",El="ll";new Set(ko),new Set(Wo);var Tl=["includeKeys"];function Sl(e){return Object.keys(e).every(Eo)}var Al=function(){function e(e){var t=this;this.hls=void 0,this.config=void 0,this.media=void 0,this.initialized=!1,this.starved=!1,this.buffering=!0,this.playerState=void 0,this.reporter=void 0,this.playheadLevel=void 0,this.onWaiting=function(){t.initialized&&(t.starved=!0,t.setPlayerState(rl)),t.buffering=!0},this.onPlay=function(){t.initialized||t.setPlayerState(Jo)},this.onPlaying=function(){t.initialized||(t.initialized=!0),t.buffering=!1,t.setPlayerState(el)},this.onPause=function(){t.media&&!t.media.ended&&t.setPlayerState(il)},this.onSeeking=function(){t.initialized&&t.setPlayerState(tl)},this.onSeeked=function(){var e;t.initialized&&null!=(e=t.media)&&e.paused&&t.setPlayerState(il)},this.onRateChange=function(){t.reporter&&t.media&&t.reporter.update({pr:t.media.playbackRate})},this.applyPlaylistData=function(e){try{t.apply(e,{ot:qo,su:!t.initialized})}catch(e){t.hls.logger.warn("Could not generate manifest CMCD data.",e)}},this.applyFragmentData=function(e){try{var r=e.frag,i=e.part,n=t.hls.levels[r.level],a=t.getObjectType(r,n),s={d:1e3*(i||r).duration,ot:a};if(a===zo||a===Xo||a===Qo||null==a&&("main"===r.type||"audio"===r.type)){var o,l=n.bitrate/1e3;s.br=[l];var u=t.config.cmcd,d=null!=(o=null==u?void 0:u.rtpSafetyFactor)?o:5;if(s.rtp=100*Math.round(l*d/100),a===Qo||null==a&&"main"===r.type){var h=t.getTopBandwidth()/1e3;x(h)&&(s.tb=[h]);var f=t.getLowestBandwidth()/1e3;x(f)&&(s.lb=[f])}var c=t.getBufferLength(r);if(x(c)){var g;s.bl=[c];var v=(null==(g=t.media)?void 0:g.playbackRate)||1;s.dl=100*Math.round(c/v/100)}t.playheadLevel&&(s.pb=[t.playheadLevel.bitrate/1e3]);var m=t.hls.maxAutoLevel;if(m>=0){var p=t.hls.levels[m];p&&(s.tpb=[p.bitrate/1e3])}}var y=i?t.getNextPart(i):t.getNextFrag(r);null!=y&&y.url&&y.url!==r.url&&(y.byteRange.length>0?s.nor=[(E=y.url,T={r:y.byteRange[0]+"-"+y.byteRange[1]},new Is(E,T))]:s.nor=[y.url]),t.apply(e,s)}catch(e){t.hls.logger.warn("Could not generate segment CMCD data.",e)}var E,T},this.recordFragmentResponse=function(e,r,i){var n,a=t.config.cmcd,s=null==a||null==(n=a.eventTargets)?void 0:n.some((function(e){var t;return null==(t=e.events)?void 0:t.includes(ho)}));if(t.reporter&&i.loading.first>0&&s)try{t.reporter.recordResponseReceived({request:{url:e},status:r.code,resourceTiming:{startTime:i.loading.start,responseStart:i.loading.first,duration:i.loading.end-i.loading.start,encodedBodySize:i.total}})}catch(e){t.hls.logger.warn("Could not record fragment response CMCD data.",e)}},this.hls=e;var r=this.config=e.config;null!=r.cmcd&&(r.pLoader=this.createPlaylistLoader(),r.fLoader=this.createFragmentLoader(),this.registerListeners())}var t=e.prototype;return t.createReporter=function(){var e,t,r=this.config.cmcd;if(null!=r){var i=r.version||1;if(this.reporter=new vl({sid:r.sessionId||this.hls.sessionId,cid:r.contentId,version:i,transmissionMode:!0===r.useHeaders?ll:ol,enabledKeys:null!=(e=r.includeKeys)?e:[].concat(i>=io?Wo:ko),eventTargets:(null!=(t=r.eventTargets)?t:[]).map((function(e){var t=e.includeKeys;return c(c({},g(e,Tl)),{},{enabledKeys:null!=t?t:Wo})}))},r.loader),this.reporter.update({sf:ml,sta:this.playerState}),r.reporterCallback){var n=this.reporter,a={updateCustomData:function(e){Sl(e)&&n.update(e)},recordCustomEvent:function(e,t){void 0===t&&(t={}),Sl(t)&&n.recordEvent(fo,c({cen:e},t))}};r.reporterCallback(a)}this.reporter.start()}},t.registerListeners=function(){var e=this.hls;e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(G.MEDIA_DETACHED,this.onMediaDetached,this),e.on(G.MEDIA_ENDED,this.onMediaEnded,this),e.on(G.ERROR,this.onError,this),e.on(G.LEVEL_SWITCHING,this.onLevelSwitching,this),e.on(G.LEVEL_SWITCHED,this.onLevelSwitched,this),e.on(G.BUFFER_APPENDED,this.onBufferInfoChange,this),e.on(G.BUFFER_FLUSHED,this.onBufferInfoChange,this)},t.unregisterListeners=function(){var e=this.hls;e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(G.MEDIA_DETACHED,this.onMediaDetached,this),e.off(G.MEDIA_ENDED,this.onMediaEnded,this),e.off(G.ERROR,this.onError,this),e.off(G.LEVEL_SWITCHING,this.onLevelSwitching,this),e.off(G.LEVEL_SWITCHED,this.onLevelSwitched,this),e.off(G.BUFFER_APPENDED,this.onBufferInfoChange,this),e.off(G.BUFFER_FLUSHED,this.onBufferInfoChange,this)},t.destroy=function(){this.unregisterListeners(),this.onMediaDetached(),this.reporter&&(this.reporter.stop(!0),this.reporter=void 0),this.hls=this.config=null,this.onWaiting=this.onPlay=this.onPlaying=this.onPause=null,this.onSeeking=this.onSeeked=this.onRateChange=this.media=null},t.onMediaAttaching=function(e,t){var r=this.media=t.media;this.setPlayerState(this.media.autoplay?Jo:sl),An(r,"waiting",this.onWaiting),An(r,"play",this.onPlay),An(r,"playing",this.onPlaying),An(r,"pause",this.onPause),An(r,"seeking",this.onSeeking),An(r,"seeked",this.onSeeked),An(r,"ratechange",this.onRateChange)},t.onMediaDetached=function(){var e=this.media;e&&(In(e,"waiting",this.onWaiting),In(e,"play",this.onPlay),In(e,"playing",this.onPlaying),In(e,"pause",this.onPause),In(e,"seeking",this.onSeeking),In(e,"seeked",this.onSeeked),In(e,"ratechange",this.onRateChange),this.media=null)},t.onMediaEnded=function(e,t){this.setPlayerState(nl)},t.onManifestLoading=function(e,t){this.initialized=!1,this.starved=!1,this.buffering=!0,this.reporter&&(this.reporter.stop(!0),this.reporter=void 0),this.createReporter(),this.media||this.setPlayerState(sl)},t.onError=function(e,t){t.fatal&&(this.setPlayerState(al),this.reporter&&this.reporter.recordEvent(uo,{ec:[t.details]}))},t.onLevelSwitching=function(e,t){var r;if(this.reporter){var i={br:[t.bitrate/1e3]},n=null==(r=t.details)?void 0:r.fragments[0];n&&(i.ot=this.getObjectType(n,t)),this.reporter.recordEvent(lo,i)}},t.onLevelSwitched=function(e,t){var r=this.hls.levels[t.level];r&&(this.playheadLevel=r)},t.setPlayerState=function(e){this.playerState=e,this.reporter&&this.reporter.update({sta:e})},t.getStreamType=function(){var e=this.hls.latestLevelDetails;if(e)return e.live?e.partList&&e.canBlockReload?El:yl:pl},t.apply=function(e,t){var r;if(void 0===t&&(t={}),this.reporter){this.reporter.update({mtp:[this.hls.bandwidthEstimate/1e3],pr:null==(r=this.media)?void 0:r.playbackRate,st:this.getStreamType()});var i=t.ot===$o||t.ot===zo||t.ot===Qo;this.starved&&i&&(t.bs=!0,t.su=!0,this.starved=!1),null==t.su&&(t.su=this.buffering);var n=this.reporter.createRequestReport({url:e.url,headers:e.headers},t);e.url=n.url,e.headers=n.headers}},t.getNextFrag=function(e){var t,r=null==(t=this.hls.levels[e.level])?void 0:t.details;if(r){var i=e.sn-r.startSN;return r.fragments[i+1]}},t.getNextPart=function(e){var t,r=e.index,i=e.fragment,n=null==(t=this.hls.levels[i.level])||null==(t=t.details)?void 0:t.partList;if(n)for(var a=i.sn,s=n.length-1;s>=0;s--){var o=n[s];if(o.index===r&&o.fragment.sn===a)return n[s+1]}},t.getObjectType=function(e,t){var r=e.type;if("subtitle"===r)return Zo;if("initSegment"===e.sn)return $o;if("audio"===r)return Xo;if("main"===r){if(e.hasStreams){var i=e.elementaryStreams;if(i.audiovideo)return Qo;if(i.video)return zo;if(i.audio)return Xo}if(t){var n=t.audioCodec,a=t.videoCodec;if(!this.hls.audioTracks.length){if(n&&a)return Qo;if(n)return Xo}if(a)return zo}}},t.getTopBandwidth=function(){var e=0,t=this.hls,r=t.maxAutoLevel,i=r>-1?r+1:t.levels.length;return t.levels.slice(0,i).forEach((function(t){t.bitrate>e&&(e=t.bitrate)})),e>0?e:NaN},t.getLowestBandwidth=function(){var e=1/0,t=this.hls,r=t.maxAutoLevel,i=r>-1?r+1:t.levels.length;return t.levels.slice(0,i).forEach((function(t){t.bitrate0)return void this.scheduleRefresh(this.uri,e)}this.loadSteeringManifest(this.uri)}},r.stopLoad=function(){this.started=!1,this.loader&&(this.loader.destroy(),this.loader=null),this.clearTimeout()},r.clearTimeout=function(){-1!==this.reloadTimer&&(self.clearTimeout(this.reloadTimer),this.reloadTimer=-1)},r.destroy=function(){this.unregisterListeners(),this.stopLoad(),this.hls=null,this.levels=this.audioTracks=this.subtitleTracks=null},r.removeLevel=function(e){var t=this.levels;t&&(this.levels=t.filter((function(t){return t!==e})))},r.onManifestLoading=function(){this.stopLoad(),this.enabled=!0,this.timeToLoad=300,this.updated=0,this.uri=null,this.pathwayId=".",this.levels=this.audioTracks=this.subtitleTracks=null},r.onManifestLoaded=function(e,t){var r=t.contentSteering;null!==r&&(this.pathwayId=r.pathwayId,this.uri=r.uri,this.started&&this.startLoad())},r.onManifestParsed=function(e,t){this.audioTracks=t.audioTracks,this.subtitleTracks=t.subtitleTracks},r.onError=function(e,t){var r=t.errorAction;if((null==r?void 0:r.action)===Lr&&r.flags&Dr){var i=this.levels,n=this._pathwayPriority,a=this.pathwayId;if(t.context){var s=t.context,o=s.groupId,l=s.pathwayId,u=s.type;o&&i?a=this.getPathwayForGroupId(o,u,a):l&&(a=l)}a in this.penalizedPathways||(this.penalizedPathways[a]=performance.now()),!n&&i&&(n=this.pathways()),n&&n.length>1&&(this.updatePathwayPriority(n),r.resolved=this.pathwayId!==a,r.resolved||t.details===B.BUFFER_APPEND_ERROR&&!t.fatal||this.warn("Could not resolve "+t.details+' ("'+t.error.message+'") with content-steering for Pathway: '+a+" levels: "+(i?i.length:i)+" priorities: "+ar(n)+" penalized: "+ar(this.penalizedPathways)))}},r.filterParsedLevels=function(e){this.levels=e;var t=this.getLevelsForPathway(this.pathwayId);if(0===t.length){var r=e[0].pathwayId;this.log("No levels found in Pathway "+this.pathwayId+'. Setting initial Pathway to "'+r+'"'),t=this.getLevelsForPathway(r),this.pathwayId=r}return t.length!==e.length&&this.log("Found "+t.length+"/"+e.length+' levels in Pathway "'+this.pathwayId+'"'),t},r.getLevelsForPathway=function(e){return null===this.levels?[]:this.levels.filter((function(t){return e===t.pathwayId}))},r.updatePathwayPriority=function(e){var t;this._pathwayPriority=e;var r=this.penalizedPathways,i=performance.now();Object.keys(r).forEach((function(e){i-r[e]>3e5&&delete r[e]}));for(var n=0;n0){this.log('Setting Pathway to "'+a+'"'),this.pathwayId=a,vn(t),this.hls.trigger(G.LEVELS_UPDATED,{levels:t});var l=this.hls.levels[s];o&&l&&this.levels&&(l.attrs["STABLE-VARIANT-ID"]!==o.attrs["STABLE-VARIANT-ID"]&&l.bitrate!==o.bitrate&&this.log("Unstable Pathways change from bitrate "+o.bitrate+" to "+l.bitrate),this.hls.nextLoadLevel=s);break}}}},r.getPathwayForGroupId=function(e,t,r){for(var i=this.getLevelsForPathway(r).concat(this.levels||[]),n=0;n0}},{key:"resolved",get:function(){return this.ok}},{key:"useable",get:function(){var e=this;return this.ok?Promise.resolve():this.error?Promise.reject(this.error):new Promise((function(t,r){e.emitter.on("error",r),e.emitter.on("resolved",t)}))}}])}(),kl=function(e){function t(t,r){var i;return(i=e.call(this,r)||this).data=void 0,t.error||(t.error=new Error(r)),i.data=t,t.err=t.error,i}return d(t,e),t}(E(Error)),Dl=function(e){function t(r){var i;return(i=e.call(this,"eme",r.logger)||this).hls=void 0,i.config=void 0,i.media=null,i.mediaResolved=void 0,i.keyFormatPromise=null,i.keySystemAccessPromises={},i.sessionSetupByUri={},i.mediaKeySessions=[],i.activeKeys={},i.mediaKeys=null,i.setMediaKeysQueue=t.CDMCleanupPromise?[t.CDMCleanupPromise]:[],i.onMediaEncrypted=function(e){var t=e.initDataType,r=e.initData,n='"'+e.type+'" event: init data type: "'+t+'"';if(i.log(n),null!==r){if(!i.keyFormatPromise){var a=Object.keys(i.keySystemAccessPromises);a.length||(a=Mi(i.config));var s=a.map(Oi).filter((function(e){return!!e}));i.keyFormatPromise=i.getKeyFormatPromise(s)}i.keyFormatPromise.then((function(a){var s,o,l=Ci(a),u=i.activeLevelKeys();if(l===bi.FAIRPLAY&&"sinf"===t)try{var d=Ue(new Uint8Array(r)),h=lt(Ne(JSON.parse(d).sinf));if(!h)throw new Error("'schm' box missing or not cbcs/cenc with schi > tenc");s=new Uint8Array(h.subarray(8,24))}catch(e){return void i.warn(n+" Failed to parse sinf: "+e)}else if(l===bi.PLAYREADY)s=Ni(new Uint8Array(r));else if(l===bi.WIDEVINE){var f=pt(r).filter((function(e){return e.systemId===wi}));if(f.length){var c,g=f[0];s=null!=(c=g.kids)&&c.length?g.kids[0]:null}}if(s)for(var v=xe(s),m=0;m0)for(var a,s=0,o=n.length;s in key message");return Ai(atob(c))},r.setupLicenseXHR=function(e,t,r,i,n){var a=this,s=this.config.licenseXhrSetup;return s?Promise.resolve().then((function(){return a.throwIfDestroyed(),s.call(a.hls,e,t,c(c({},r),{},{decryptdata:i}),n)})).catch((function(o){if(!r.mediaKeysSession)throw o;return e.open("POST",t,!0),s.call(a.hls,e,t,c(c({},r),{},{decryptdata:i}),n)})).then((function(r){return e.readyState||e.open("POST",t,!0),{xhr:e,licenseChallenge:r||n}})):(e.open("POST",t,!0),Promise.resolve({xhr:e,licenseChallenge:n}))},r.requestLicense=function(e,t,r){var i=this;return new Promise((function(n,a){var s,o,l,u,d,h=(s=e.keySystem,o=i.config,l=o.drmSystems,u=o.widevineLicenseUrl,(d=null==l?void 0:l[s])?d.licenseUrl:s===bi.WIDEVINE&&u?u:void 0);if(!h)return a(new Error('no license server URL configured for key-system "'+e.keySystem+'"'));var f=e.keyRequests[t.uri];if(!f)return a(new Error("License emitter removed"));i.log("Sending license request to URL: "+h);var g=i.config.keyLoadPolicy.default,v=new XMLHttpRequest;v.responseType="arraybuffer",v.onreadystatechange=function(){if(!i.hls||!e.mediaKeysSession)return a(new Error("invalid state"));if(4===v.readyState)if(200===v.status){f.requestErrors=[];var s=v.response;i.log("License received "+(s instanceof ArrayBuffer?s.byteLength:s));var o=i.config.licenseResponseCallback;if(o)try{s=o.call(i.hls,v,h,c(c({},e),{},{decryptdata:t}))}catch(e){i.error(e)}n(s)}else{var l=g.errorRetry,u=l?l.maxNumRetry:0;if(f.requestErrors.push({status:v.status,message:v.statusText}),f.requestErrors.length>u||v.status>=400&&v.status<500)a(new kl({type:U.KEY_SYSTEM_ERROR,details:B.KEY_SYSTEM_LICENSE_REQUEST_FAILED,decryptdata:t,fatal:!0,networkDetails:v,response:{url:h,data:void 0,code:v.status,text:v.statusText}},"License Request XHR failed ("+h+"). Status: "+v.status+" ("+v.statusText+")"));else{var d=u-f.requestErrors.length;i.warn("Retrying license request, "+d+" attempts left"),i.requestLicense(e,t,r).then(n,a)}}},f.licenseXhr&&f.licenseXhr.readyState!==XMLHttpRequest.DONE&&f.licenseXhr.abort(),f.licenseXhr=v,i.setupLicenseXHR(v,h,e,t,r).then((function(t){var r=t.xhr,n=t.licenseChallenge;e.keySystem==bi.PLAYREADY&&(n=i.unpackPlayReadyKeyMessage(r,n)),r.send(n)})).catch(a)}))},r.onDestroying=function(){this.unregisterListeners(),this._clear()},r.onMediaAttached=function(e,t){if(this.config.emeEnabled){var r=t.media;this.media=r,An(r,"encrypted",this.onMediaEncrypted),An(r,"waitingforkey",this.onWaitingForKey);var i=this.mediaResolved;i?i():this.mediaKeys=r.mediaKeys}},r.onMediaDetached=function(){var e=this.media;e&&(In(e,"encrypted",this.onMediaEncrypted),In(e,"waitingforkey",this.onWaitingForKey),this.media=null,this.mediaKeys=null)},r._clear=function(){var e,r=this;this.keyFormatPromise=null,this.keySystemAccessPromises={},this.activeKeys={},this.sessionSetupByUri={};var i=this.mediaResolved;if(i&&i(),this.mediaKeys||this.mediaKeySessions.length){var n=this.media,a=this.mediaKeySessions.slice();this.mediaKeySessions=[],this.mediaKeys=null,Bi.clearKeyUriToKeyIdMap();var s=a.length;t.CDMCleanupPromise=Promise.all(a.map((function(e){return r.removeSession(e)})).concat((null==n||null==(e=n.setMediaKeys(null))?void 0:e.catch((function(e){r.log("Could not clear media keys: "+e),r.hls&&r.hls.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR,fatal:!1,error:new Error("Could not clear media keys: "+e)})})))||Promise.resolve())).catch((function(e){r.log("Could not close sessions and clear media keys: "+e),r.hls&&r.hls.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR,fatal:!1,error:new Error("Could not close sessions and clear media keys: "+e)})})).then((function(){s&&r.log("finished closing key sessions and clearing media keys")}))}},r.onManifestLoading=function(){this._clear(),this.keyFormatPromise=null},r.onManifestLoaded=function(e,t){var r=t.sessionKeys;if(r&&this.config.emeEnabled&&!this.keyFormatPromise){var i=r.reduce((function(e,t){return-1===e.indexOf(t.keyFormat)&&e.push(t.keyFormat),e}),[]);this.log("Selecting key-system from session-keys "+i.join(", ")),this.keyFormatPromise=this.getKeyFormatPromise(i),this.keyFormatPromise.catch((function(){}))}},r.removeSession=function(e){var t=this,r=e.mediaKeysSession;if(r){this.log('Remove licenses and keys and close session "'+r.sessionId+'"'),Object.keys(e.keyRequests).forEach((function(t){var i=e.keyRequests[t];i.onmessage&&r.removeEventListener("message",i.onmessage),i.onkeystatuseschange&&r.removeEventListener("keystatuseschange",i.onkeystatuseschange),i.reset(),e.keyRequests[t]=void 0})),e.mediaKeysSession=void 0;var i=this.mediaKeySessions.indexOf(e);i>-1&&this.mediaKeySessions.splice(i,1);var n=e.keyStatusTimeouts;n&&Object.keys(n).forEach((function(e){return self.clearTimeout(n[e])}));var a=function(e){var t;return!(!e||"persistent-license"!==e.sessionType&&(null==(t=e.sessionTypes)||!t.some((function(e){return"persistent-license"===e}))))}(this.config.drmSystemOptions)?new Promise((function(e,t){self.setTimeout((function(){return t(new Error("MediaKeySession.remove() timeout"))}),8e3),r.remove().then(e).catch(t)})):Promise.resolve();return a.catch((function(e){t.log("Could not remove session: "+e),t.hls&&t.hls.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR,fatal:!1,error:new Error("Could not remove session: "+e)})})).then((function(){return r.close()})).then((function(){return r.closed})).catch((function(e){t.log("Could not close session: "+e),t.hls&&t.hls.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR,fatal:!1,error:new Error("Could not close session: "+e)})}))}return Promise.resolve()},t}(ue);function Pl(e,t){var r=e.keyId,i=t.mediaKeysSession;if(r&&i){var n=t.mediaKeysSession.keyStatuses;if(n.size)return n.has(r)?n.get(r):t.keyStatuses[xe(r)]}}function _l(e,t){var r="output-restricted"===e,i=r?B.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED:B.KEY_SYSTEM_STATUS_INTERNAL_ERROR;return new kl({type:U.KEY_SYSTEM_ERROR,details:i,fatal:!1,decryptdata:t},r?"HDCP level output restricted":'key status changed to "'+e+'"')}Dl.CDMCleanupPromise=void 0;var Cl=function(){function e(e){this.hls=void 0,this.isVideoPlaybackQualityAvailable=!1,this.timer=void 0,this.media=null,this.lastTime=void 0,this.lastDroppedFrames=0,this.lastDecodedFrames=0,this.streamController=void 0,this.hls=e,this.registerListeners()}var t=e.prototype;return t.setStreamController=function(e){this.streamController=e},t.registerListeners=function(){this.hls.on(G.MEDIA_ATTACHING,this.onMediaAttaching,this),this.hls.on(G.MEDIA_DETACHING,this.onMediaDetaching,this)},t.unregisterListeners=function(){this.hls.off(G.MEDIA_ATTACHING,this.onMediaAttaching,this),this.hls.off(G.MEDIA_DETACHING,this.onMediaDetaching,this)},t.destroy=function(){this.timer&&clearInterval(this.timer),this.unregisterListeners(),this.isVideoPlaybackQualityAvailable=!1,this.media=null},t.onMediaAttaching=function(e,t){var r=this.hls.config;if(r.capLevelOnFPSDrop){var i=t.media instanceof self.HTMLVideoElement?t.media:null;this.media=i,i&&"function"==typeof i.getVideoPlaybackQuality&&(this.isVideoPlaybackQualityAvailable=!0),self.clearInterval(this.timer),this.timer=self.setInterval(this.checkFPSInterval.bind(this),r.fpsDroppedMonitoringPeriod)}},t.onMediaDetaching=function(){this.media=null},t.checkFPS=function(e,t,r){var i=performance.now();if(t){if(this.lastTime){var n=i-this.lastTime,a=r-this.lastDroppedFrames,s=t-this.lastDecodedFrames,o=1e3*a/n,l=this.hls;if(l.trigger(G.FPS_DROP,{currentDropped:a,currentDecoded:s,totalDroppedFrames:r}),o>0&&a>l.config.fpsDroppedMonitoringThreshold*s){var u=l.currentLevel;l.logger.warn("drop FPS ratio greater than max allowed value for currentLevel: "+u),u>0&&(-1===l.autoLevelCapping||l.autoLevelCapping>=u)&&(u-=1,l.trigger(G.FPS_DROP_LEVEL_CAPPING,{level:u,droppedLevel:l.currentLevel}),l.autoLevelCapping=u,this.streamController.nextLevelSwitch())}}this.lastTime=i,this.lastDroppedFrames=r,this.lastDecodedFrames=t}},t.checkFPSInterval=function(){var e=this.media;if(e)if(this.isVideoPlaybackQualityAvailable){var t=e.getVideoPlaybackQuality();this.checkFPS(e,t.totalVideoFrames,t.droppedVideoFrames)}else this.checkFPS(e,e.webkitDecodedFrameCount,e.webkitDroppedFrameCount)},e}(),wl=function(e){function t(t,r){var i;return(i=e.call(this,"gap-controller",t.logger)||this).hls=void 0,i.fragmentTracker=void 0,i.media=null,i.mediaSource=void 0,i.nudgeRetry=0,i.skipRetry=0,i.stallReported=!1,i.stalled=null,i.moved=!1,i.seeking=!1,i.buffered={},i.lastCurrentTime=void 0,i.ended=0,i.waiting=0,i.onMediaPlaying=function(){i.ended=0,i.waiting=0},i.onMediaWaiting=function(){var e;null!=(e=i.media)&&e.seeking||(i.waiting=self.performance.now(),i.tick())},i.onMediaEnded=function(){var e;i.hls&&(i.ended=(null==(e=i.media)?void 0:e.currentTime)||1,i.hls.trigger(G.MEDIA_ENDED,{stalled:!1}))},i.hls=t,i.fragmentTracker=r,i.lastCurrentTime=i.getCurrentTime(),i.registerListeners(),i}d(t,e);var r=t.prototype;return r.registerListeners=function(){var e=this.hls;e&&(e.on(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(G.BUFFER_APPENDED,this.onBufferAppended,this))},r.unregisterListeners=function(){var e=this.hls;e&&(e.off(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(G.BUFFER_APPENDED,this.onBufferAppended,this))},r.destroy=function(){e.prototype.destroy.call(this),this.unregisterListeners(),this.media=this.hls=this.fragmentTracker=null,this.mediaSource=void 0},r.onMediaAttached=function(e,t){this.setInterval(100),this.mediaSource=t.mediaSource;var r=this.media=t.media;An(r,"playing",this.onMediaPlaying),An(r,"waiting",this.onMediaWaiting),An(r,"ended",this.onMediaEnded)},r.onMediaDetaching=function(e,t){this.clearInterval();var r=this.media;r&&(In(r,"playing",this.onMediaPlaying),In(r,"waiting",this.onMediaWaiting),In(r,"ended",this.onMediaEnded),this.media=null),this.mediaSource=void 0},r.onBufferAppended=function(e,t){this.buffered=t.timeRanges},r.getCurrentTime=function(){var e,t=(null==(e=this.hls)?void 0:e.config.timelineOffset)||0;return this.media?Math.max(this.media.currentTime,t):t},r.tick=function(){var e;if(null!=(e=this.media)&&e.readyState&&this.hasBuffered){var t=this.getCurrentTime();this.media.currentTimet&&this.nudgeOnVideoHole(e,t)),void(0===this.waiting&&this.stallResolved(e));if(l||o)o&&this.stallResolved(e);else{if(u)return this.skipRetry=this.nudgeRetry=0,this.stallResolved(e),void(!this.ended&&a.ended&&this.hls&&(this.ended=e||1,this.hls.trigger(G.MEDIA_ENDED,{stalled:!1})));if(si.getBuffered(a).length){var d=si.bufferInfo(a,e,0),h=this.fragmentTracker;h&&this.hls&&Fl(d,e,h,this.hls);var f=d.nextStart||0;if(s&&h&&this.hls){var c=Ol(this.hls.inFlightFragments,e),g=d.len>2,v=!f||c||f-e>2&&!h.getPartialFragment(e);if(g||v)return;this.moved=!1}var m=null==(i=this.hls)?void 0:i.latestLevelDetails;if(!this.moved&&null!==this.stalled&&h){if(!(d.len>0||f))return;var p=Math.max(f,d.start||0)-e,y=null!=m&&m.live?2*m.targetduration:2,E=xl(e,h);if(p>0&&(p<=y||E))return void(a.paused||this._trySkipBufferHole(E))}var T=n.detectStallWithCurrentTimeMs,S=self.performance.now(),A=this.waiting,I=this.stalled;if(null===I){if(!(A>0&&S-A=T||A&&this.moved)&&this.hls){var R;if("ended"===(null==(R=this.mediaSource)?void 0:R.readyState)&&(null==m||!m.live)&&Math.abs(e-((null==m?void 0:m.edge)||0))<1){if(this.ended)return;return this.ended=e||1,void this.hls.trigger(G.MEDIA_ENDED,{stalled:!0})}if(this._reportStall(d),!this.media||!this.hls)return;if(a.currentTime!==e){var b=e;return e=a.currentTime,this.moved=!0,void this.log("currentTime changed "+b+" > "+e)}}var k=si.bufferInfo(a,e,n.maxBufferHole);this._tryFixBufferStall(k,L,e)}else this.skipRetry=this.nudgeRetry=0}}}},r.stallResolved=function(e){var t=this.stalled;if(t&&this.hls&&(this.stalled=null,this.stallReported)){var r=self.performance.now()-t;this.log("playback not stuck anymore @"+e+", after "+Math.round(r)+"ms"),this.stallReported=!1,this.waiting=0,this.hls.trigger(G.STALL_RESOLVED,{})}},r.nudgeOnVideoHole=function(e,t){var r,i=this.buffered.video;if(this.hls&&this.media&&this.fragmentTracker&&null!=(r=this.buffered.audio)&&r.length&&i&&i.length>1&&e>i.end(0)){var n=si.bufferedInfo(si.timeRangesToArray(this.buffered.audio),e,0);if(n.len>1&&t>=n.start){var a=si.timeRangesToArray(i),s=si.bufferedInfo(a,t,0).bufferedIndex;if(s>-1&&ss)&&u-l<1&&e-l<2){var d=new Error("nudging playhead to flush pipeline after video hole. currentTime: "+e+" hole: "+l+" -> "+u+" buffered index: "+o);this.warn(d.message),this.media.currentTime+=1e-6;var h=xl(e,this.fragmentTracker);h&&"fragment"in h?h=h.fragment:h||(h=void 0);var f=si.bufferInfo(this.media,e,0);this.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.BUFFER_SEEK_OVER_HOLE,fatal:!1,error:d,reason:d.message,frag:h,buffer:f.len,bufferInfo:f})}}}}},r._tryFixBufferStall=function(e,t,r){var i,n,a=this.fragmentTracker,s=this.media,o=null==(i=this.hls)?void 0:i.config;if(s&&a&&o){var l=null==(n=this.hls)?void 0:n.latestLevelDetails,u=xl(r,a);if((u||null!=l&&l.live&&r1&&e.len>o.maxBufferHole||e.nextStart&&(e.nextStart-r1e3*o.highBufferWatchdogPeriod||this.waiting)&&(this.warn("Trying to nudge playhead over buffer-hole"),this._tryNudgeBuffer(e))}},r.adjacentTraversal=function(e,t){var r=this.fragmentTracker,i=e.nextStart;if(r&&i){var n=r.getFragAtPos(t,H.MAIN),a=r.getFragAtPos(i,H.MAIN);if(n&&a)return a.sn-n.sn<2}return!1},r._reportStall=function(e){var t=this.hls,r=this.media,i=this.stallReported,n=this.stalled;if(!i&&null!==n&&r&&t){this.stallReported=!0;var a=new Error("Playback stalling at @"+r.currentTime+" due to low buffer ("+ar(e)+")");this.warn(a.message),t.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.BUFFER_STALLED_ERROR,fatal:!1,error:a,buffer:e.len,bufferInfo:e,stalled:{start:n}})}},r._trySkipBufferHole=function(e){var t=this.fragmentTracker,r=this.media,i=this.hls,n=null==i?void 0:i.config;if(!r||!t||!n)return 0;var a=r.currentTime,s=si.bufferInfo(r,a,0);Fl(s,a,t,i);var o=a0&&s.len<1&&r.readyState<3,d=o-a;if(d>0&&(l||u)){if(d>n.maxBufferHole){var h=!1;if(0===a){var f=t.getAppendedFrag(0,H.MAIN);f&&o0)){g=!0;break}v+=p}if(g)return 0}}var y=n.nudgeMaxRetry,E=n.skipBufferHolePadding,T=++this.skipRetry>y,S=Math.max(o,a)+E;if(T||(this.warn("skipping hole, adjusting currentTime from "+a+" to "+S),this.moved=!0,r.currentTime=S),null==e||!e.gap||T){var A=new Error(T?"Playhead still not moving after seeking over buffer hole from "+a+" to "+S+" after "+n.nudgeMaxRetry+" attempts.":"fragment loaded with buffer holes, seeking from "+a+" to "+S),I={type:U.MEDIA_ERROR,details:B.BUFFER_SEEK_OVER_HOLE,fatal:T,error:A,reason:A.message,buffer:s.len,bufferInfo:s};e&&("fragment"in e?I.part=e:I.frag=e),i.trigger(G.ERROR,I)}return S}}return 0},r._tryNudgeBuffer=function(e){var t=this.hls,r=this.media,i=this.nudgeRetry,n=null==t?void 0:t.config;if(!r||!n)return 0;var a=r.currentTime;if(this.nudgeRetry++,i0}}])}(ii);function Ol(e,t){var r=Ml(e.main);if(r&&r.start<=t)return r;var i=Ml(e.audio);return i&&i.start<=t?i:null}function Ml(e){if(!e)return null;switch(e.state){case Rn.IDLE:case Rn.STOPPED:case Rn.ENDED:case Rn.ERROR:return null}return e.frag}function xl(e,t){return t.getAppendedFrag(e,H.MAIN)||t.getPartialFragment(e)}function Fl(e,t,r,i){var n=i.latestLevelDetails,a=xl(t,r);if(Dn(i.config)&&!e.nextStart&&n&&a&&i.interstitialsManager){var s="type"in a?a:a.fragment,o=Ar(n,s.sn);(null==o?void 0:o.level)===s.level&&kn(o,i.interstitialsManager.playerQueue,t)&&(e.nextStart=o.start)}}function Nl(e,t,r,i,n){void 0===i&&(i=""),void 0===n&&(n="disabled");var a=e.ownerDocument.createElement("track");return a.kind=t,a.label=r,i&&(a.srclang=i),a.src="data:,WEBVTT",a.track.mode=n,e.appendChild(a),a}function Ul(e){return e.forced&&(It.indexOf("Apple")>-1||/iPhone|iPad|iPod/.test(At))?"forced":e.characteristics&&/transcribes-spoken-dialog/gi.test(e.characteristics)&&/describes-music-and-sound/gi.test(e.characteristics)?"captions":"subtitles"}function Bl(e,t){var r=e.mode;if("disabled"===r&&(e.mode="hidden"),e.cues&&!e.cues.getCueById(t.id))try{if(e.addCue(t),!e.cues.getCueById(t.id))throw new Error("addCue is failed for: "+t)}catch(r){me.debug("[texttrack-utils]: "+r);try{var i=new self.TextTrackCue(t.startTime,t.endTime,t.text);i.id=t.id,e.addCue(i)}catch(e){me.debug("[texttrack-utils]: Legacy TextTrackCue fallback failed: "+e)}}"disabled"===r&&(e.mode=r)}function Gl(e,t,r,i){var n=e.mode;if("disabled"===n&&(e.mode="hidden"),e.cues&&e.cues.length>0)for(var a=function(e,t,r){var i=[],n=function(e,t){if(t<=e[0].startTime)return 0;var r=e.length-1;if(t>e[r].endTime)return-1;for(var i,n=0,a=r;n<=a;)if(te[i].startTime&&n-1)for(var a=n,s=e.length;a=t&&o.endTime<=r)i.push(o);else if(o.startTime>r)return i}return i}(e.cues,t,r),s=0;sHl&&(h=Hl),h-d<=0&&(h=d+.25);for(var f=0;f.01&&this.updateDateRangeCues(t.details)},t.updateDateRangeCues=function(e,t){var r=this;if(this.hls&&this.media){var i=this.hls.config,n=i.assetPlayerId,a=i.timelineOffset,s=i.enableDateRangeMetadataCues,o=i.interstitialsController;if(s){var l=Kl();if(n&&a&&!o){var u=e.fragmentStart,d=e.fragmentEnd,h=this.assetCue;h?(h.startTime=u,h.endTime=d):l&&(h=this.assetCue=Vl(l,u,d,{assetPlayerId:this.hls.config.assetPlayerId},"hlsjs.interstitial.asset"))&&(h.id=n,this.id3Track||(this.id3Track=this.createTrack(this.media)),this.id3Track.track.addCue(h),h.addEventListener("enter",this.onEventCueEnter))}if(e.hasProgramDateTime){var f,c=this.id3Track,g=e.dateRanges,v=Object.keys(g),m=this.dateRangeCuesAppended;if(c&&t)if(null!=(f=c.track.cues)&&f.length)for(var p=Object.keys(m).filter((function(e){return-1===v.indexOf(e)})),y=function(){var e,t=p[E],i=null==(e=m[t])?void 0:e.cues;delete m[t],i&&Object.keys(i).forEach((function(e){var t=i[e];if(t){t.removeEventListener("enter",r.onEventCueEnter);try{c.track.removeCue(t)}catch(e){}}}))},E=p.length;E--;)y();else m=this.dateRangeCuesAppended={};var T=e.fragments[e.fragments.length-1];if(0!==v.length&&x(null==T?void 0:T.programDateTime)){this.id3Track||(this.id3Track=this.createTrack(this.media));for(var S=function(){var e=v[A],t=g[e],i=t.startTime,n=m[e],a=(null==n?void 0:n.cues)||{},s=(null==n?void 0:n.durationKnown)||!1,u=Hl,d=t.duration;if(t.endDate&&null!==d)u=i+d,s=!0;else if(t.endOnNext&&!s){var h=v.reduce((function(e,r){if(r!==t.id){var i=g[r];if(i.class===t.class&&i.startDate>t.startDate&&(!e||t.startDate.01&&(E.startTime=i,E.endTime=u):E.endTime=u;else if(l){var T=t.attr[y];vi(y)&&(T=Fe(T));var S=Vl(l,i,u,{key:y,data:T},Un.dateRange);S&&(S.id=e,r.id3Track.track.addCue(S),a[y]=S,o&&("X-ASSET-LIST"!==y&&"X-ASSET-URL"!==y||S.addEventListener("enter",r.onEventCueEnter)))}}}m[e]={cues:a,dateRange:t,durationKnown:s}},A=0;A1?(n=0,this.bitrateTest=!0):n=i.firstAutoLevel),i.nextLoadLevel=n,this.level=i.loadLevel,this._hasEnoughToStart=!!t}r>0&&-1===e&&!t&&this.initPTS.length&&(this.log("Override startPosition with lastCurrentTime @"+r),e=r),this.state=Rn.IDLE,this.nextLoadPosition=this.lastCurrentTime=e+this.timelineOffset,this.startPosition=t?-1:e,t||this.fragmentTracker.hasFragments(this.playlistType)||(this._hasEnoughToStart=this.startFragRequested=!1),this.tick()}else this._forceStartLoad=!0,this.state=Rn.STOPPED},r.stopLoad=function(){this._forceStartLoad=!1,e.prototype.stopLoad.call(this)},r.doTick=function(){switch(this.state){case Rn.WAITING_LEVEL:var e=this.levels,t=this.level,r=null==e?void 0:e[t],i=null==r?void 0:r.details;if(i&&(!i.live||this.levelLastLoaded===r&&!this.waitForLive(r))){if(this.waitForCdnTuneIn(i))break;this.state=Rn.IDLE;break}if(this.hls.nextLoadLevel!==this.level){this.state=Rn.IDLE;break}break;case Rn.FRAG_LOADING_WAITING_RETRY:this.checkRetryDate()}this.state===Rn.IDLE&&this.doTickIdle(),this.onTickEnd()},r.onTickEnd=function(){var t;e.prototype.onTickEnd.call(this),null!=(t=this.media)&&t.readyState&&!1===this.media.seeking&&(this.lastCurrentTime=this.media.currentTime),this.checkFragmentChanged()},r.doTickIdle=function(){var e=this.hls,t=this.levelLastLoaded,r=this.levels,i=this.media;if(null!==t&&(i||this.primaryPrefetch||!this.startFragRequested&&e.config.startFragPrefetch)&&(!this.altAudio||!this.audioOnly)){var n=this.buffering?e.nextLoadLevel:e.loadLevel;if(null!=r&&r[n]){var a=r[n],s=this.getMainFwdBufferInfo();if(null!==s){var o=this.getLevelDetails();if(o&&this._streamEnded(s,o)){var l={type:this.targetBufferType()};return this.hls.trigger(G.BUFFER_EOS,l),void(this.state=Rn.ENDED)}if(this.buffering){e.loadLevel!==n&&-1===e.manualLevel&&this.log("Adapting to level "+n+" from level "+this.level),this.level=e.nextLoadLevel=n;var u=a.details;if(!u||this.state===Rn.WAITING_LEVEL||this.waitForLive(a))return this.level=n,this.state=Rn.WAITING_LEVEL,void(this.startFragRequested=!1);var d=s.len,h=this.getMaxBufferLength(a.maxBitrate);if(!(d>=h)){this.backtrackFragment&&this.backtrackFragment.start>s.end&&(this.backtrackFragment=void 0);var f=this.backtrackFragment?this.backtrackFragment.start:s.end,c=this.getNextFragment(f,u);if(this.couldBacktrack&&!this.fragPrevious&&c&&W(c)&&this.fragmentTracker.getState(c)!==Gr){var g,v=(null!=(g=this.backtrackFragment)?g:c).sn-u.startSN,m=u.fragments[v-1];m&&c.cc===m.cc&&(c=m,this.fragmentTracker.removeFragment(m))}else this.backtrackFragment&&s.len&&(this.backtrackFragment=void 0);if(c&&this.isLoopLoading(c,f)){if(!c.gap){var p=this.audioOnly&&!this.altAudio?Y.AUDIO:Y.VIDEO,y=(p===Y.VIDEO?this.videoBuffer:this.mediaBuffer)||this.media;y&&this.afterBufferFlushed(y,p)}c=this.getNextFragmentLoopLoading(c,u,s,H.MAIN,h)}c&&(this.exceedsMaxBuffer(s,h,c)||this.loadFragment(c,a,f))}}}}}},r.loadFragment=function(t,r,i){var n=this.fragmentTracker.getState(t);n===Nr||n===Br?this.bitrateTest?(this.log("Fragment "+t.sn+" of level "+t.level+" is being downloaded to test bitrate and will not be buffered"),this._loadBitrateTestFrag(t,r)):e.prototype.loadFragment.call(this,t,r,i):this.clearTrackerIfNeeded(t)},r.immediateLevelSwitch=function(){var t;this.abortCurrentFrag(),this.flushMainBuffer(0,Number.POSITIVE_INFINITY),this.altAudio!==zl&&((null==(t=this.getLevelDetails())?void 0:t.fragmentStart)||0)>this.lastCurrentTime&&e.prototype.flushMainBuffer.call(this,0,Number.POSITIVE_INFINITY,"audio")},r.getBufferOutput=function(){return this.mediaBuffer&&this.altAudio===$l?this.mediaBuffer:this.media},r.checkFragmentChanged=function(){var e=this.fragPlaying;if(!this.checkFragPlaying())return!1;var t=this.fragPlaying;if(t){var r=t.level;this.hls.trigger(G.FRAG_CHANGED,{frag:t,previousFrag:e}),(null==e?void 0:e.level)!==r&&this.hls.trigger(G.LEVEL_SWITCHED,{level:r})}return!0},r.abortCurrentFrag=function(){this.backtrackFragment=void 0,e.prototype.abortCurrentFrag.call(this)},r.flushMainBuffer=function(t,r){e.prototype.flushMainBuffer.call(this,t,r,this.targetBufferType())},r.targetBufferType=function(){return this.altAudio!==$l||this.audioOnly?null:"video"},r.onMediaAttached=function(t,r){e.prototype.onMediaAttached.call(this,t,r);var i=r.media;An(i,"playing",this.onMediaPlaying),An(i,"seeked",this.onMediaSeeked)},r.onMediaDetaching=function(t,r){var i=this.media;i&&(In(i,"playing",this.onMediaPlaying),In(i,"seeked",this.onMediaSeeked)),this.videoBuffer=null,e.prototype.onMediaDetaching.call(this,t,r),r.transferMedia||(this._hasEnoughToStart=!1)},r.onManifestLoading=function(){e.prototype.onManifestLoading.call(this),this.log("Trigger BUFFER_RESET"),this.hls.trigger(G.BUFFER_RESET,void 0),this.couldBacktrack=!1,this.backtrackFragment=void 0,this.altAudio=zl,this.audioOnly=!1},r.onManifestParsed=function(e,t){for(var r,i,n=!1,a=!1,s=0;s=o.startCC)return!0;delete i.initPTS[t]})),this.tick()}else this.warn("Levels were reset while loading level "+s)},r.synchronizeToLiveEdge=function(e){var t=this.config,r=this.media;if(r){var i=this.hls.liveSyncPosition,n=this.playhead,a=e.fragmentStart,s=e.edge,o=n>=a-t.maxFragLookUpTolerance&&n<=s;if(null!==i&&r.duration>i&&(n=this.config.appendErrorMaxRetry&&(this.warn("Marking fragment "+t.frag.sn+" of level "+t.frag.level+" as a gap after "+t.appendsWithoutProgress+" appends without buffered range growth, to prevent loop loading"),this.fragmentTracker.addAsGap(t.frag));break;case B.BUFFER_ADD_CODEC_ERROR:case B.BUFFER_APPEND_ERROR:if("main"!==t.parent)return;this.reduceLengthAndFlushBuffer(t)&&this.resetLoadingState();break;case B.BUFFER_FULL_ERROR:if("main"!==t.parent)return;this.reduceLengthAndFlushBuffer(t)&&(!this.config.interstitialsController&&this.config.assetPlayerId?this._hasEnoughToStart=!0:this.flushMainBuffer(0,Number.POSITIVE_INFINITY));break;case B.INTERNAL_EXCEPTION:this.recoverWorkerError(t)}},r.onFragLoadEmergencyAborted=function(){this.state=Rn.IDLE,this._hasEnoughToStart||(this.startFragRequested=!1,this.nextLoadPosition=this.lastCurrentTime),this.tickImmediate()},r.onBufferFlushed=function(e,t){var r=t.type;if(r!==Y.AUDIO||!this.altAudio){var i=(r===Y.VIDEO?this.videoBuffer:this.mediaBuffer)||this.media;i&&(this.afterBufferFlushed(i,r),this.tick())}},r.onLevelsUpdated=function(e,t){this.level>-1&&this.fragCurrent&&(this.level=this.fragCurrent.level,-1===this.level&&this.resetWhenMissingContext(this.fragCurrent)),this.levels=t.levels},r.swapAudioCodec=function(){this.audioCodecSwap=!this.audioCodecSwap},r.seekToStartPos=function(){var e=this.media;if(e){var t=e.currentTime,r=this.startPosition;if(r>=0&&t0&&(oA.cc;if(!1!==i.independent){var R=u.startPTS,b=u.endPTS,k=u.startDTS,D=u.endDTS;if(o)o.elementaryStreams[u.type]={startPTS:R,endPTS:b,startDTS:k,endDTS:D};else if(u.firstKeyFrame&&u.independent&&1===n.id&&!L&&(this.couldBacktrack=!0),u.dropped&&u.independent){var P=this.getMainFwdBufferInfo(),_=(P?P.end:this.getLoadPosition())+this.config.maxBufferHole,C=u.firstKeyFramePTS?u.firstKeyFramePTS:R;if(!I&&_2&&(s.gap=!0);s.setElementaryStreamInfo(u.type,R,b,k,D),this.backtrackFragment&&(this.backtrackFragment=s),this.bufferFragmentData(u,s,o,n,I||L)}else{if(!I&&!L)return void this.backtrack(s);s.gap=!0}}if(g){var w=g.startPTS,O=g.endPTS,M=g.startDTS,F=g.endDTS;o&&(o.elementaryStreams[Y.AUDIO]={startPTS:w,endPTS:O,startDTS:M,endDTS:F}),s.setElementaryStreamInfo(Y.AUDIO,w,O,M,F),this.bufferFragmentData(g,s,o,n)}if(c&&null!=h&&h.samples.length){var N={id:t,frag:s,details:c,samples:h.samples};r.trigger(G.FRAG_PARSING_METADATA,N)}if(c&&d){var U={id:t,frag:s,details:c,samples:d.samples};r.trigger(G.FRAG_PARSING_USERDATA,U)}}}else this.resetWhenMissingContext(n)},r.logMuxedErr=function(e){this.warn((W(e)?"Media":"Init")+" segment with muxed audiovideo where only video expected: "+e.url)},r._bufferInitSegment=function(e,t,r,i){var n=this;if(this.state===Rn.PARSING){this.audioOnly=!!t.audio&&!t.video,this.altAudio&&!this.audioOnly&&(delete t.audio,t.audiovideo&&this.logMuxedErr(r));var a=t.audio,s=t.video,o=t.audiovideo;if(a){var l=e.audioCodec,u=Nt(a.codec,l);if("mp4a"===u&&(u="mp4a.40.5"),this.audioCodecSwitch){u&&(u=-1!==u.indexOf("mp4a.40.5")?"mp4a.40.2":"mp4a.40.5");var d=a.metadata;d&&"channelCount"in d&&1!==(d.channelCount||1)&&!function(){if(void 0===Tt){var e=At.match(/Firefox\/(\d+)/i);Tt=e?parseInt(e[1]):0}return Tt}()&&(u="mp4a.40.5")}u&&-1!==u.indexOf("mp4a.40.5")&&/Android/.test(At)&&"audio/mpeg"!==a.container&&(u="mp4a.40.2",this.log("Android: force audio codec to "+u)),l&&l!==u&&this.log('Swapping manifest audio codec "'+l+'" for "'+u+'"'),a.levelCodec=u,a.id=H.MAIN,this.log("Init audio buffer, container:"+a.container+", codecs[selected/level/parsed]=["+(u||"")+"/"+(l||"")+"/"+a.codec+"]"),delete t.audiovideo}if(s){s.levelCodec=e.videoCodec,s.id=H.MAIN;var h=s.codec;if(4===(null==h?void 0:h.length))switch(h){case"hvc1":case"hev1":s.codec="hvc1.1.6.L120.90";break;case"av01":s.codec="av01.0.04M.08";break;case"avc1":s.codec="avc1.42e01e"}this.log("Init video buffer, container:"+s.container+", codecs[level/parsed]=["+(e.videoCodec||"")+"/"+h+"]"+(s.codec!==h?" parsed-corrected="+s.codec:"")+(s.supplemental?" supplemental="+s.supplemental:"")),delete t.audiovideo}o&&(this.iframesOnly&&this.logMuxedErr(r),this.log("Init audiovideo buffer, container:"+o.container+", codecs[level/parsed]=["+e.codecs+"/"+o.codec+"]"),delete t.video,delete t.audio);var f=Object.keys(t);if(f.length){if(this.hls.trigger(G.BUFFER_CODECS,t),!this.hls)return;f.forEach((function(e){var a=t[e].initSegment;null!=a&&a.byteLength&&n.hls.trigger(G.BUFFER_APPENDING,{type:e,data:a,frag:r,part:null,chunkMeta:i,parent:r.type})}))}this.tickImmediate()}},r.getMainFwdBufferInfo=function(){var e=this.getBufferOutput();return this.getFwdBufferInfo(e,H.MAIN)},r.backtrack=function(e){this.couldBacktrack=!0,this.backtrackFragment=e,this.resetTransmuxer(),this.flushBufferGap(e),this.fragmentTracker.removeFragment(e),this.fragPrevious=null,this.nextLoadPosition=e.start,this.state=Rn.IDLE},a(t,[{key:"backtrackFragment",get:function(){return this._backtrackFragment},set:function(e){this._backtrackFragment=e}},{key:"couldBacktrack",get:function(){return this._couldBacktrack},set:function(e){this._couldBacktrack=e}},{key:"hasEnoughToStart",get:function(){return this._hasEnoughToStart}},{key:"maxBufferLength",get:function(){var e=this.levels,t=this.level,r=null==e?void 0:e[t];return r?this.getMaxBufferLength(r.maxBitrate):this.config.maxBufferLength}},{key:"nextLevel",get:function(){var e=this.nextBufferedFrag;return e?e.level:-1}},{key:"currentFrag",get:function(){var e;if(this.fragPlaying)return this.fragPlaying;var t=(null==(e=this.media)?void 0:e.currentTime)||this.lastCurrentTime;return x(t)?this.getAppendedFrag(t):null}},{key:"currentProgramDateTime",get:function(){var e,t=(null==(e=this.media)?void 0:e.currentTime)||this.lastCurrentTime;if(x(t)){var r=this.getLevelDetails(),i=this.currentFrag||(r?yr(null,r.fragments,t):null);if(i){var n=i.programDateTime;if(null!==n){var a=n+1e3*(t-i.start);return new Date(a)}}}return null}},{key:"currentLevel",get:function(){var e=this.currentFrag;return e?e.level:-1}},{key:"nextBufferedFrag",get:function(){var e=this.currentFrag;return e?this.followingBufferedFrag(e):null}},{key:"forceStartLoad",get:function(){return this._forceStartLoad}}])}(bn),Jl=function(e){function t(){for(var t,r=arguments.length,i=new Array(r),n=0;n=n&&(e=n-.01),si.bufferInfo(t,e,0).len>0&&this.getBufferedAt(e))return t.currentTime=e,!0}return!1},r.getBufferedAt=function(e){return this.fragmentTracker.getBufferedFrag(e,H.MAIN)},r.cacheSet=function(e,t){if(this.hls){e.data=t;var r=this.cached;r.push(e),this.cachedSize+=t.buffer.byteLength;for(var i=this.hls.config.iframeCacheLimit;this.cachedSize>i&&r.length>1;){var n=r.shift();if(!n.data){this.cached=r.filter((function(e){return!!e.data})),this.cachedSize=r.reduce((function(e,t){var r=t.data;return r?r.buffer.byteLength:0}),0);break}this.cachedSize-=n.data.byteLength,n.data=void 0}}},r.onHandlerDestroying=function(){this.cached.length=0,this.cachedSize=0,e.prototype.onHandlerDestroying.call(this)},r._handleFragmentLoadProgress=function(t){var r=t.frag,i=t.part,n=t.payload;this.cacheFragmentData&&!i&&null!=n&&n.byteLength&&!r.data&&W(r)&&this.cacheSet(r,new Uint8Array(n.slice(0))),e.prototype._handleFragmentLoadProgress.call(this,t)},r.loadFragment=function(t,r,i){var n=this.fragmentTracker.getState(t);n!==Nr&&n!==Br||!this.canReplayCached(t)?e.prototype.loadFragment.call(this,t,r,i):this.replayCachedFragment(t)},r.canReplayCached=function(e){var t;if(!this.cacheFragmentData||null==(t=e.data)||!t.byteLength)return!1;var r=e.decryptdata;return"identity"!==(null==r?void 0:r.keyFormat)||!!r.key},r.replayCachedFragment=function(e){var t=e.data.slice().buffer;this.log("Buffering cached "+e.type+" sn: "+e.sn+" of level "+e.level+" ("+t.byteLength+" bytes)"),this.fragCurrent=e,this.startFragRequested=!0;var r=e.stats=new K;r.loading.start=r.loading.first=r.loading.end=self.performance.now(),r.loaded=r.total=t.byteLength,r.chunkCount=1,this.state=Rn.FRAG_LOADING;var i={frag:e,part:null,payload:t,networkDetails:null};this._handleFragmentLoadProgress(i),this.hls.trigger(G.FRAG_LOADED,i),this._handleFragmentLoadComplete(i)},r.fragBufferedComplete=function(t,r){e.prototype.fragBufferedComplete.call(this,t,r);var i=this.currentOp,n=this.nextOp;this.currentOp=this.nextOp=void 0,this.state=Rn.STOPPED,null!=i&&i[1].seekOnAppend&&(this.flushPipeline(i[0],!n),this.seekTo(i[0])||this.log("Could not seek to "+i[0]+" after fragment buffered (superseded or flushed) buffer: "+(this.media?Ln(si.getBuffered(this.media)):"none"))),n&&this.loadMediaAt.apply(this,n)},r.flushPipeline=function(e,t){if(t){var r=this.media;if(r){var i=si.bufferInfo(r,e,0),n=i.len?i.nextStart:0;n&&(this.fragmentTracker.removeFragmentsInRange(n,1/0,H.MAIN),this.flushMainBuffer(n,1/0))}this.hls.once(G.BUFFERED_TO_END,this.onBufferedToEnd)}this.hls.trigger(G.BUFFER_EOS,{type:"video"})},r.startLoad=function(){if(!this.startFragRequested){var e=this.hls;e.nextLoadLevel=-1===e.startLevel?0:e.firstAutoLevel}},r.seekToStartPos=function(){},r.setStartPosition=function(){},r.alignPlaylists=function(t,r,i){var n=this.initDetails;return this.initDetails=null,e.prototype.alignPlaylists.call(this,t,r,i||n||void 0)},r.getMainFwdBufferInfo=function(){var e=this.playhead,t=this.getBufferedAt(e);if(t){var r=t.duration;return{len:r,start:e,end:e+r,bufferedIndex:-1}}return{len:0,start:e,end:e,bufferedIndex:-1}},r._streamEnded=function(){var e=this.playhead,t=this.fragmentTracker.getBufferedFrag(e,H.MAIN);return!(null==t||!t.endList)},a(t,[{key:"playhead",get:function(){return this.nextLoadPosition}}])}(Zl),eu=function(e){function t(){for(var t,r=arguments.length,i=new Array(r),n=0;n=i.start&&e10&&this.iframeInstances.forEach((function(e){var t;e.loadingEnabled||e.startLoad((null==(t=i.hls)||null==(t=t.media)?void 0:t.currentTime)||0)}))},r.createIFramePlayer=function(e){var t;return this.createInstance(ql,null==(t=this.hls)?void 0:t.iframeVariants,e)},r.createImageIFramePlayer=function(e){var t,r=null==(t=this.hls)?void 0:t.iframeVariants.filter((function(e){return e.imageCodec}));return this.createInstance(Xl,r,e,"image-")},r.createInstance=function(e,t,r,i){var n=this.hls;if(!n)return null;var a=n.url,s=n.userConfig,o=n.latestLevelDetails,l=n.loadLevelObj,u=n.loadLevel,d=n.bandwidthEstimate,h=n.sessionId,f=this.stats,g=this.variableList;if(null==t||!t.length||!f||!a)return null;var v=(i||"")+"iframe-player-"+this.instanceCounter++,m=n.levels,p=l||m[u],y=vr(p,s.videoPreference),E=(null==p?void 0:p.pathwayId)||".",T=new e(c(c({},s),{},{loggerId:v,primarySessionId:h,videoPreference:y,abrEwmaDefaultEstimate:d,streamController:void 0,audioStreamController:void 0,audioTrackController:void 0,subtitleStreamController:void 0,subtitleTrackController:void 0,timelineController:void 0,id3TrackController:void 0,fpsController:void 0,gapController:void 0,iframeController:void 0,cmcd:void 0,interstitialsController:void 0,backBufferLength:1/0,maxMaxBufferLength:1,capLevelToPlayerSize:!0,enableWorker:!1,autoStartLoad:!1,startFragPrefetch:!1,testBandwidth:!1,progressive:!1},r),a,this.initPTS,o);return this.iframeInstances=this.iframeInstances.filter((function(e){return!e.url})),this.iframeInstances.push(T),T.trigger(G.MANIFEST_LOADED,{levels:t,contentSteering:{uri:"",pathwayId:E},sessionKeys:null,audioTracks:[],iframeVariants:[],sessionData:null,startTimeOffset:null,stats:f,networkDetails:null,url:a,variableList:g}),T},t}(ue);function nu(e){for(var t=5381,r=e.length;r;)t=33*t^e.charCodeAt(--r);return(t>>>0).toString()}var au=.025,su=function(e){return e[e.Point=0]="Point",e[e.Range=1]="Range",e}({});function ou(e,t,r){return e.identifier+"-"+(r+1)+"-"+nu(t)}var lu=function(){function e(e,t){this.base=void 0,this._duration=null,this._timelineStart=null,this.appendInPlaceDisabled=void 0,this.appendInPlaceStarted=void 0,this.dateRange=void 0,this.hasPlayed=!1,this.cumulativeDuration=0,this.resumeOffset=NaN,this.playoutLimit=NaN,this.restrictions={skip:!1,jump:!1},this.snapOptions={out:!1,in:!1},this.assetList=[],this.assetListLoader=void 0,this.assetListResponse=null,this.resumeAnchor=void 0,this.error=void 0,this.resetOnResume=void 0,this.base=t,this.dateRange=e,this.setDateRange(e)}var t=e.prototype;return t.setDateRange=function(e){this.dateRange=e,this.resumeOffset=e.attr.optionalFloat("X-RESUME-OFFSET",this.resumeOffset),this.playoutLimit=e.attr.optionalFloat("X-PLAYOUT-LIMIT",this.playoutLimit),this.restrictions=e.attr.enumeratedStringList("X-RESTRICT",this.restrictions),this.snapOptions=e.attr.enumeratedStringList("X-SNAP",this.snapOptions)},t.reset=function(){var e;this.appendInPlaceStarted=!1,null==(e=this.assetListLoader)||e.destroy(),this.assetListLoader=void 0,this.supplementsPrimary||(this.assetListResponse=null,this.assetList=[],this._duration=null)},t.isAssetPastPlayoutLimit=function(e){var t;if(e>0&&e>=this.assetList.length)return!0;var r=this.playoutLimit;return!(e<=0||isNaN(r))&&(0===r||((null==(t=this.assetList[e])?void 0:t.startOffset)||0)>r)},t.findAssetIndex=function(e){return this.assetList.indexOf(e)},t.toString=function(){return'["'+(e=this).identifier+'" '+(e.cue.pre?"
":e.cue.post?"":"")+e.timelineStart.toFixed(2)+"-"+e.resumeTime.toFixed(2)+"]";var e},a(e,[{key:"identifier",get:function(){return this.dateRange.id}},{key:"startDate",get:function(){return this.dateRange.startDate}},{key:"startTime",get:function(){var e=this.dateRange.startTime;if(this.snapOptions.out){var t=this.dateRange.tagAnchor;if(t)return du(t,e)?uu(e,t):e}return e}},{key:"startOffset",get:function(){return this.cue.pre?0:this.startTime}},{key:"startIsAligned",get:function(){if(0===this.startTime||this.snapOptions.out)return!0;var e=this.dateRange.tagAnchor;if(e){var t=this.dateRange.startTime;return t-uu(t,e)<.1}return!1}},{key:"resumptionOffset",get:function(){var e=this.resumeOffset,t=x(e)?e:this.duration;return this.cumulativeDuration+t}},{key:"resumeTime",get:function(){var e=this.startOffset+this.resumptionOffset;if(this.snapOptions.in){var t=this.resumeAnchor;if(t)return du(t,e)?uu(e,t):e}return e}},{key:"appendInPlace",get:function(){return!!this.appendInPlaceStarted||!this.appendInPlaceDisabled&&!(this.cue.once||this.cue.pre||!this.startIsAligned||!(isNaN(this.playoutLimit)&&isNaN(this.resumeOffset)||this.resumeOffset&&this.duration&&Math.abs(this.resumeOffset-this.duration)0||null!==this.assetListResponse}}])}();function uu(e,t){return e-t.starte.start-au&&t=i-.02},t.reachedPlayout=function(e){var t=this.interstitial.playoutLimit;return this.startOffset+e>=t},t.getAssetTime=function(e){var t=this.timelineOffset,r=this.duration;return Math.min(Math.max(0,e-t),r)},t.removeMediaListeners=function(){var e=this.mediaAttached;e&&(this._currentTime=e.currentTime,this.bufferSnapShot(),e.removeEventListener("timeupdate",this.checkPlayout))},t.bufferSnapShot=function(){var e;this.mediaAttached&&null!=(e=this.hls)&&e.bufferedToEnd&&(this._bufferedEosTime=this.bufferedEnd)},t.destroy=function(){this.removeMediaListeners(),this.hls&&this.hls.destroy(),this.hls=null,this.tracks=this.mediaAttached=this.checkPlayout=null},t.attachMedia=function(e){var t;this.loadSource(),null==(t=this.hls)||t.attachMedia(e)},t.detachMedia=function(){var e;this.removeMediaListeners(),this.mediaAttached=null,null==(e=this.hls)||e.detachMedia()},t.resumeBuffering=function(){var e;null==(e=this.hls)||e.resumeBuffering()},t.pauseBuffering=function(){var e;null==(e=this.hls)||e.pauseBuffering()},t.transferMedia=function(){var e;return this.bufferSnapShot(),(null==(e=this.hls)?void 0:e.transferMedia())||null},t.resetDetails=function(){var e=this.hls;if(e&&this.hasDetails){e.stopLoad();var t=function(e){return delete e.details};e.levels.forEach(t),e.allAudioTracks.forEach(t),e.allSubtitleTracks.forEach(t),this.hasDetails=!1}},t.on=function(e,t,r){var i;null==(i=this.hls)||i.on(e,t)},t.once=function(e,t,r){var i;null==(i=this.hls)||i.once(e,t)},t.off=function(e,t,r){var i;null==(i=this.hls)||i.off(e,t)},t.toString=function(){var e;return"HlsAssetPlayer: "+cu(this.assetItem)+" "+(null==(e=this.hls)?void 0:e.sessionId)+" "+(this.appendInPlace?"append-in-place":"")},a(e,[{key:"appendInPlace",get:function(){return this.interstitial.appendInPlace}},{key:"destroyed",get:function(){var e;return!(null!=(e=this.hls)&&e.userConfig)}},{key:"assetId",get:function(){return this.assetItem.identifier}},{key:"interstitialId",get:function(){return this.assetItem.parentIdentifier}},{key:"media",get:function(){var e;return(null==(e=this.hls)?void 0:e.media)||null}},{key:"bufferedEnd",get:function(){var e=this.media||this.mediaAttached;if(!e)return this._bufferedEosTime?this._bufferedEosTime:this.currentTime;var t=si.bufferInfo(e,e.currentTime,.001);return this.getAssetTime(t.end)}},{key:"currentTime",get:function(){var e=this.media||this.mediaAttached;return e?this.getAssetTime(e.currentTime):this._currentTime||0}},{key:"duration",get:function(){var e=this.assetItem.duration;if(!e)return 0;var t=this.interstitial.playoutLimit;if(t){var r=t-this.startOffset;if(r>0&&r1/9e4&&this.hls){if(this.hasDetails)throw new Error("Cannot set timelineOffset after playlists are loaded");this.hls.config.timelineOffset=e}}}}])}(),vu=.033,mu=function(e){function t(t,r){var i;return(i=e.call(this,"interstitials-sched",r)||this).onScheduleUpdate=void 0,i.eventMap={},i.events=null,i.items=null,i.durations={primary:0,playout:0,integrated:0},i.onScheduleUpdate=t,i}d(t,e);var r=t.prototype;return r.destroy=function(){this.reset(),this.onScheduleUpdate=null},r.reset=function(){this.eventMap={},this.setDurations(0,0,0),this.events&&this.events.forEach((function(e){return e.reset()})),this.events=this.items=null},r.resetErrorsInRange=function(e,t){return this.events?this.events.reduce((function(r,i){return e<=i.startOffset&&t>i.startOffset?(delete i.error,r+1):r}),0):0},r.getEvent=function(e){return e&&this.eventMap[e]||null},r.hasEvent=function(e){return e in this.eventMap},r.findItemIndex=function(e,t){if(e.event)return this.findEventIndex(e.event.identifier);var r=-1;e.nextEvent?r=this.findEventIndex(e.nextEvent.identifier)-1:e.previousEvent&&(r=this.findEventIndex(e.previousEvent.identifier)+1);var i=this.items;if(i)for(i[r]||(void 0===t&&(t=e.start),r=this.findItemIndexAtTime(t));r>=0&&null!=(n=i[r])&&n.event;){var n;r--}return r},r.findItemIndexAtTime=function(e,t){var r=this.items;if(r)for(var i=0;ia&&e1)for(var n=0;ns&&(t.005||Math.abs(e.playout.end-a[t].playout.end)>.005})))&&(this.items=s,this.onScheduleUpdate(t,a))}},r.parseDateRanges=function(e,t,r){for(var i=[],n=Object.keys(e),a=0;avu){var R=o,b=l;l+=L;var k=s;s+=L;var D={previousEvent:e[u-1]||null,nextEvent:t,start:R,end:R+L,playout:{start:k,end:s},integrated:{start:b,end:l}};i.push(D)}else L>0&&f&&(f.cumulativeDuration+=L,i[i.length-1].end=g)}h&&(S=T),t.timelineStart=T;var P=l;l+=m;var _=s;s+=v,i.push({event:t,start:T,end:S,playout:{start:_,end:s},integrated:{start:P,end:l}})}var C=p||t.resumeTime;o=h||C>a?a:!d&&S&&t.appendInPlaceStarted&&C-S>vu?S:C})),oau?(this.log('"'+e.identifier+'" resumption '+i+" not aligned with estimated timeline end "+n),!1):!Object.keys(t).some((function(n){var a=t[n].details,s=a.edge;if(i>=s)return r.log('"'+e.identifier+'" resumption '+i+" past "+n+" playlist end "+s),!1;var o=yr(null,a.fragments,i);if(!o)return r.log('"'+e.identifier+'" resumption '+i+" does not align with any fragments in "+n+" playlist ("+a.fragStart+"-"+a.fragmentEnd+")"),!0;var l="audio"===n?.175:0;return!(Math.abs(o.start-i)=n.end){var a,s=i.findItemIndex(n),o=i.schedule.findItemIndexAtTime(e);if(-1===o&&(o=s+(r?-1:1),i.log("seeked "+(r?"back ":"")+"to position not covered by schedule "+e+" (resolving from "+s+" to "+o+")")),!i.isInterstitial(n)&&null!=(a=i.media)&&a.paused&&(i.shouldPlay=!1),!r&&o>s){var l=i.schedule.findJumpRestrictedIndex(s+1,o);if(l>s)return void i.setSchedulePosition(l)}i.setSchedulePosition(o)}else{var u=i.playingAsset;if(u){var d,h=u.timelineStart,f=u.duration||0;(r&&e=h+f)&&(null!=(d=n.event)&&d.appendInPlace&&(i.clearAssetPlayers(n.event,n),i.flushFrontBuffer(e)),i.setScheduleToAssetAtTime(e,u))}else if(i.playingLastItem&&i.isInterstitial(n)){var c=n.event.assetList[0];c&&(i.endedItem=i.playingItem,i.playingItem=null,i.setScheduleToAssetAtTime(e,c))}}else i.checkBuffer()}}},i.onTimeupdate=function(){var e=i.currentTime;if(void 0!==e&&!i.playbackDisabled&&(-1!==i.timelinePos||i.effectivePlayingItem||i.checkStart(),e>i.timelinePos)){i.timelinePos=e,e>i.bufferedPos&&i.checkBuffer();var t=i.playingItem;if(t&&!i.playingLastItem){if(e>=t.end){i.timelinePos=t.end;var r=i.findItemIndex(t);i.setSchedulePosition(r+1)}var n=i.playingAsset;n&&e>=n.timelineStart+(n.duration||0)&&i.setScheduleToAssetAtTime(e,n)}}},i.onScheduleUpdate=function(e,t){var r=i.schedule;if(r){var n=i.playingItem,a=r.events||[],s=r.items||[],o=r.durations,l=e.map((function(e){return e.identifier})),u=!(!a.length&&!l.length);(u||t)&&i.log("INTERSTITIALS_UPDATED ("+a.length+"): "+a+"\nSchedule: "+s.map((function(e){return pu(e)}))+" pos: "+i.timelinePos),l.length&&i.log("Removed events "+l);var d=null,h=null;n&&(d=i.updateItem(n,i.timelinePos),i.itemsMatch(n,d)?i.playingItem=d:i.waitingItem=null),i.waitingItem=i.updateItem(i.waitingItem);var f=i.bufferingItem;if(f&&(h=i.updateItem(f,i.bufferedPos),i.itemsMatch(f,h)?i.bufferingItem=h:f.event&&(i.bufferingItem=i.playingItem,i.clearInterstitial(f.event,null))),e.forEach((function(e){e.assetList.forEach((function(e){i.clearAssetPlayer(e.identifier,null)}))})),i.playerQueue.forEach((function(e){if(e.interstitial.appendInPlace){var t=e.assetItem.timelineStart,r=e.timelineOffset-t;if(r)try{e.timelineOffset=t}catch(n){Math.abs(r)>au&&i.warn(n+' ("'+e.assetId+'" '+e.timelineOffset+"->"+t+")")}}})),u||t){if(i.hls.trigger(G.INTERSTITIALS_UPDATED,{events:a.slice(0),schedule:s.slice(0),durations:o,removedIds:l}),i.isInterstitial(n)&&l.indexOf(n.event.identifier)>-1)return i.warn('Interstitial "'+n.event.identifier+'" removed while playing'),void i.primaryFallback(n.event);n&&i.trimInPlace(d,n),f&&h!==d&&i.trimInPlace(h,f),i.checkBuffer()}}},i.hls=t,i.HlsPlayerClass=r,i.assetListLoader=new yu(t),i.schedule=new mu(i.onScheduleUpdate,t.logger),i.registerListeners(),i}d(t,e);var r=t.prototype;return r.registerListeners=function(){var e=this.hls;e&&(e.on(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(G.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.on(G.AUDIO_TRACK_UPDATED,this.onAudioTrackUpdated,this),e.on(G.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),e.on(G.SUBTITLE_TRACK_UPDATED,this.onSubtitleTrackUpdated,this),e.on(G.EVENT_CUE_ENTER,this.onInterstitialCueEnter,this),e.on(G.ASSET_LIST_LOADED,this.onAssetListLoaded,this),e.on(G.BUFFER_APPENDED,this.onBufferAppended,this),e.on(G.BUFFER_FLUSHED,this.onBufferFlushed,this),e.on(G.BUFFERED_TO_END,this.onBufferedToEnd,this),e.on(G.MEDIA_ENDED,this.onMediaEnded,this),e.on(G.ERROR,this.onError,this),e.on(G.DESTROYING,this.onDestroying,this))},r.unregisterListeners=function(){var e=this.hls;e&&(e.off(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(G.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.off(G.AUDIO_TRACK_UPDATED,this.onAudioTrackUpdated,this),e.off(G.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),e.off(G.SUBTITLE_TRACK_UPDATED,this.onSubtitleTrackUpdated,this),e.off(G.EVENT_CUE_ENTER,this.onInterstitialCueEnter,this),e.off(G.ASSET_LIST_LOADED,this.onAssetListLoaded,this),e.off(G.BUFFER_CODECS,this.onBufferCodecs,this),e.off(G.BUFFER_APPENDED,this.onBufferAppended,this),e.off(G.BUFFER_FLUSHED,this.onBufferFlushed,this),e.off(G.BUFFERED_TO_END,this.onBufferedToEnd,this),e.off(G.MEDIA_ENDED,this.onMediaEnded,this),e.off(G.ERROR,this.onError,this),e.off(G.DESTROYING,this.onDestroying,this))},r.startLoad=function(){this.resumeBuffering()},r.stopLoad=function(){this.pauseBuffering()},r.resumeBuffering=function(){var e;null==(e=this.getBufferingPlayer())||e.resumeBuffering()},r.pauseBuffering=function(){var e;null==(e=this.getBufferingPlayer())||e.pauseBuffering()},r.destroy=function(){this.unregisterListeners(),this.stopLoad(),this.assetListLoader&&this.assetListLoader.destroy(),this.emptyPlayerQueue(),this.clearScheduleState(),this.schedule&&this.schedule.destroy(),this.media=this.detachedData=this.mediaSelection=this.requiredTracks=this.altSelection=this.schedule=this.manager=null,this.hls=this.HlsPlayerClass=null,this.assetListLoader=null,this.onPlay=this.onPause=this.onSeeking=this.onTimeupdate=null,this.onScheduleUpdate=null},r.onDestroying=function(){var e=this.primaryMedia||this.media;e&&this.removeMediaListeners(e)},r.removeMediaListeners=function(e){In(e,"play",this.onPlay),In(e,"pause",this.onPause),In(e,"seeking",this.onSeeking),In(e,"timeupdate",this.onTimeupdate)},r.onMediaAttaching=function(e,t){var r=this.media=t.media;An(r,"seeking",this.onSeeking),An(r,"timeupdate",this.onTimeupdate),An(r,"play",this.onPlay),An(r,"pause",this.onPause)},r.onMediaAttached=function(e,t){var r=this.effectivePlayingItem,i=this.detachedData;if(this.detachedData=null,null===r)this.checkStart();else if(!i){this.clearScheduleState();var n=this.findItemIndex(r);this.setSchedulePosition(n)}},r.clearScheduleState=function(){this.log("clear schedule state"),this.playingItem=this.bufferingItem=this.waitingItem=this.endedItem=this.playingAsset=this.endedAsset=this.bufferingAsset=null},r.onMediaDetaching=function(e,t){var r=!!t.transferMedia,i=this.media;if(this.media=null,!r&&(i&&this.removeMediaListeners(i),this.detachedData)){var n=this.getBufferingPlayer();n&&(this.log("Removing schedule state for detachedData and "+n),this.playingAsset=this.endedAsset=this.bufferingAsset=this.bufferingItem=this.waitingItem=this.detachedData=null,n.detachMedia()),this.shouldPlay=!1}},r.isInterstitial=function(e){return!(null==e||!e.event)},r.retreiveMediaSource=function(e,t){var r=this.getAssetPlayer(e);r&&this.transferMediaFromPlayer(r,t)},r.transferMediaFromPlayer=function(e,t){var r=e.interstitial.appendInPlace,i=e.media;if(r&&i===this.primaryMedia){if(this.bufferingAsset=null,(!t||this.isInterstitial(t)&&!t.event.appendInPlace)&&t&&i)return void(this.detachedData={media:i});var n=e.transferMedia();this.log("transfer MediaSource from "+e+" "+ar(n)),this.detachedData=n}else t&&i&&(this.shouldPlay||(this.shouldPlay=!i.paused))},r.transferMediaTo=function(e,t){var r,i,n=this;if(e.media!==t){var a,s=null,o=this.hls,l=e!==o,u=l&&e.interstitial.appendInPlace,d=null==(r=this.detachedData)?void 0:r.mediaSource;if(o.media)u&&(s=o.transferMedia(),this.detachedData=s),a="Primary";else{var h=this.getBufferingPlayer();h?(s=h.transferMedia(),this.detachedData=s,a=""+h):a=d?"detached MediaSource":"detached media"}if(!s)if(d)s=this.detachedData,this.log("using detachedData: MediaSource "+ar(s));else if(!this.detachedData||o.media===t){var f=this.playerQueue;f.length>1&&f.forEach((function(e){if(l&&e.interstitial.appendInPlace!==u){var t=e.interstitial;n.clearInterstitial(e.interstitial,null),t.appendInPlace=!1,t.appendInPlace&&n.warn("Could not change append strategy for queued assets "+t)}})),this.hls.detachMedia(),this.detachedData={media:t}}var g=s&&"mediaSource"in s&&"closed"!==(null==(i=s.mediaSource)?void 0:i.readyState);if(this.log((g?"transfering MediaSource":"attaching media")+" to "+(l?e:"Primary")+" from "+a+" (media.currentTime: "+t.currentTime+")"),g||l){var v=g&&s?c({},s):{media:t},m=this.schedule;if(m&&l){var p=e.assetId===m.assetIdAtEnd;v.overrides={duration:m.duration,endOfStream:p||!1===e.appendInPlace}}if(!g&&u){var y=e,E=y.assetItem.timelineStart;if(E){var T=y.hls,S=Math.max(this.timelinePos-E,Number.EPSILON);T.startPosition!==S&&(this.log("Set "+y+" startPosition: "+S),T.config.startPosition=S,T.loadingEnabled&&T.startLoad(S))}}e.attachMedia(v)}else e.attachMedia(t)}},r.onInterstitialCueEnter=function(){this.onTimeupdate()},r.checkStart=function(){var e=this.schedule,t=null==e?void 0:e.events,r=this.hls;if(t&&!this.playbackDisabled&&this.media&&r){-1===this.bufferedPos&&(this.bufferedPos=0);var i=this.timelinePos,n=this.effectivePlayingItem;if(-1===i){if(!r.loadingEnabled)return void this.log("waiting for startLoad(  )");var a=r.startPosition;if(this.timelinePos=a,0===t.length)this.setSchedulePosition(0);else if(t[0].cue.pre){this.log(Tu("checkStart (preroll)",a));var s=e.findEventIndex(t[0].identifier);this.setSchedulePosition(s)}else if(a>=0||!this.primaryLive){this.log(Tu("checkStart",a));var o=this.timelinePos=a>0?a:0,l=e.findItemIndexAtTime(o);this.setSchedulePosition(l)}else 0===r.liveSyncPosition?this.setSchedulePosition(0):this.log("[checkStart] waiting for live start")}else if(n&&!this.playingItem){this.log(Tu("checkStart (playing item)",n.start));var u=e.findItemIndex(n);this.setSchedulePosition(u)}}},r.advanceAssetBuffering=function(e,t){var r=e.event,i=r.findAssetIndex(t),n=fu(r,i);if(r.isAssetPastPlayoutLimit(n)){if(this.schedule){var a,s=this.getNextItemIndex(e,!0),o=null==(a=this.schedule.items)?void 0:a[s];o&&this.bufferedToItem(o)}}else this.bufferedToEvent(e,n)},r.livePrerollResumption=function(e,t){if(e.cue.pre&&this.primaryLive&&!x(e.resumeOffset)&&this.hls){var r=this.getLiveStartPos();if(t){var i=this.getBufferingPlayer();return r+(null!=i&&i.interstitial.cue.pre?i.remaining:0)}return r}},r.getNextItemIndex=function(e,t){if(this.schedule){var r=this.livePrerollResumption(e.event,t);if(void 0!==r)return this.log("find next item index @"+r),this.schedule.findItemIndexAtTime(r)}return this.findItemIndex(e)+1},r.advanceAfterAssetEnded=function(e,t,r){var i=fu(e,r);if(e.isAssetPastPlayoutLimit(i)){if(this.schedule){var n=this.schedule.items;if(n){var a=n[t],s=a.event?this.getNextItemIndex(a):t+1;if(s>=n.length)return void this.setSchedulePosition(-1);var o=e.resumeTime;this.timelinePos=0?i[e]:null;this.log("setSchedulePosition "+e+", "+t+" ("+(n?pu(n):n)+") pos: "+this.timelinePos);var a=this.waitingItem||this.playingItem,s=this.playingLastItem;if(this.isInterstitial(a)){var o=a.event,l=this.playingAsset,u=null==l?void 0:l.identifier,d=u?this.getAssetPlayer(u):null;if(d&&u&&(!this.eventItemsMatch(a,n)||void 0!==t&&u!==o.assetList[t].identifier)){var h,f=o.findAssetIndex(l);if(this.log("INTERSTITIAL_ASSET_ENDED "+(f+1)+"/"+o.assetList.length+" "+cu(l)),this.endedAsset=l,this.playingAsset=null,this.hls.trigger(G.INTERSTITIAL_ASSET_ENDED,{asset:l,assetListIndex:f,event:o,schedule:i.slice(0),scheduleIndex:e,player:d}),a!==this.playingItem)return void(this.itemsMatch(a,this.playingItem)&&!this.playingAsset&&this.advanceAfterAssetEnded(o,this.findItemIndex(this.playingItem),f));this.retreiveMediaSource(u,n),!d.media||null!=(h=this.detachedData)&&h.mediaSource||d.detachMedia()}if(!this.eventItemsMatch(a,n)&&(this.endedItem=a,this.playingItem=this.waitingItem=null,this.log("INTERSTITIAL_ENDED "+o+" "+pu(a)),o.hasPlayed=!0,this.hls.trigger(G.INTERSTITIAL_ENDED,{event:o,schedule:i.slice(0),scheduleIndex:e}),o.cue.once&&this.hls)){var c;this.updateSchedule();var g=null==(c=this.schedule)?void 0:c.items;if(n&&g){var v=this.findItemIndex(n),m=this.livePrerollResumption(o);if(void 0!==m){var p=m,y=this.getNextItemIndex(a);if(!this.isInterstitial(i[y])){var E,T=null==(E=this.primaryDetails)?void 0:E.fragments;if(o.snapOptions.in&&T){var S=yr(null,T,m);S&&(p=Math.min(Math.max(i[y].start,uu(p,S)),i[y].end))}}if(this.log(Tu("live join (preroll)",p)),this.timelinePos=p,y!==v&&(y||0===y))return void this.advanceSchedule(y,g,void 0,a,s)}this.advanceSchedule(v,g,t,a,s)}return}}this.advanceSchedule(e,i,t,a,s)}},r.advanceSchedule=function(e,t,r,i,n){var a=this,s=this.schedule;if(s){var o=t[e]||null,l=this.primaryMedia,u=this.playerQueue;if(u.length&&u.forEach((function(t){var r=t.interstitial,i=s.findEventIndex(r.identifier);(ie+1)&&a.clearInterstitial(r,o)})),this.isInterstitial(o)){this.timelinePos=Math.min(Math.max(this.timelinePos,o.start),o.end);var d=o.event;if(void 0===r){var h=fu(d,(r=d.cue.pre?0:s.findAssetIndex(d,this.timelinePos))-1);if(d.isAssetPastPlayoutLimit(h)||d.appendInPlace&&this.timelinePos===o.end)return void this.advanceAfterAssetEnded(d,e,r);r=h}var f=this.waitingItem;this.assetsBuffered(o,l)||this.setBufferingItem(o);var c=this.preloadAssets(d,r);if(this.eventItemsMatch(o,f||i)||(this.waitingItem=o,this.log("INTERSTITIAL_STARTED "+pu(o)+" "+(d.appendInPlace?"append in place":"")),d.hasPlayed=!1,this.hls.trigger(G.INTERSTITIAL_STARTED,{event:d,schedule:t.slice(0),scheduleIndex:e})),!d.assetListLoaded)return void this.log("Waiting for ASSET-LIST to complete loading "+d);if(d.assetListLoader&&(d.assetListLoader.destroy(),d.assetListLoader=void 0),!l)return void this.log("Waiting for attachMedia to start Interstitial "+d);this.waitingItem=this.endedItem=null,this.playingItem=o;var g=d.assetList[r];if(!g)return void this.advanceAfterAssetEnded(d,e,r||0);if(c||(c=this.getAssetPlayer(g.identifier)),null===c||c.destroyed){var v=d.assetList.length;this.warn("asset "+(r+1)+"/"+v+" player destroyed "+d),(c=this.createAssetPlayer(d,g,r)).loadSource()}if(!this.eventItemsMatch(o,this.bufferingItem)&&d.appendInPlace&&this.isAssetBuffered(g))return;this.startAssetPlayer(c,r,t,e,l),this.shouldPlay&&Eu(c.media)}else o?(this.resumePrimary(o,e,i),this.shouldPlay&&Eu(this.hls.media)):n&&this.isInterstitial(i)&&(this.endedItem=null,this.playingItem=i,i.event.appendInPlace||this.attachPrimary(s.durations.primary,null))}},r.resumePrimary=function(e,t,r){var i,n;this.playingItem=e,this.playingAsset=this.endedAsset=null,this.waitingItem=null,this.bufferedToItem(e),this.endedItem=null,this.log("resuming "+pu(e));var a=this.timelinePos;if(null!=(i=this.detachedData)&&i.mediaSource?this.primaryLive&&this.startLoadingPrimaryAt(a):((a=e.end||al&&!r){var u=this.hls.liveSyncPosition;return this.log("primary resumption out of bounds ("+i+" ["+o+"-"+l+"]) snapping to liveSync "+u),u||o}}}return this.log("primary resumption itemStart: "+i),i},r.getLiveStartPos=function(){var e,t=this.hls.startPosition,r=this.hls.liveSyncPosition||0,i=3*((null==(e=this.primaryDetails)?void 0:e.targetduration)||10);return-1===t||r&&r-t=e.timelineStart+(e.duration||0)},r.attachPrimary=function(e,t,r){t?this.setBufferingItem(t):this.bufferingItem=this.playingItem,this.bufferingAsset=null;var i=this.primaryMedia;if(i){var n=this.hls;n.media?this.checkBuffer():(this.transferMediaTo(n,i),r&&this.startLoadingPrimaryAt(e,r)),r||(this.log(Tu("attachPrimary",e)),this.timelinePos=e,this.startLoadingPrimaryAt(e,r))}},r.startLoadingPrimaryAt=function(e,t){var r=this.hls,i=r.loadingEnabled,n=r.bufferingEnabled,a=r.startPosition,s=r.hasEnoughToStart;if(this.log("Start loading primary @"+e+" bufferedPos: "+this.bufferedPos+" startPosition: "+a+" skip seek: "+t+" has enough "+s+" loadingEnabled: "+i+" bufferingEnabled: "+n),!t&&s||!i||Math.abs(a-e)>.1){var o=this.primaryDetails;if(null!=o&&o.live&&e+.5>=o.edge){var l=this.bufferingItem;return this.log("Resume primary loading when live passes "+e+" buffering item: "+(l?pu(l):null)),r.pauseBuffering(),void(this.bufferPastEdge=!0)}r.startLoad(e,t)}else n||r.resumeBuffering()},r.onManifestLoading=function(){var e;this.stopLoad(),null==(e=this.schedule)||e.reset(),this.emptyPlayerQueue(),this.clearScheduleState(),this.shouldPlay=!1,this.bufferedPos=this.timelinePos=-1,this.mediaSelection=this.altSelection=this.manager=this.requiredTracks=null,this.hls.off(G.BUFFER_CODECS,this.onBufferCodecs,this),this.hls.on(G.BUFFER_CODECS,this.onBufferCodecs,this)},r.onLevelUpdated=function(e,t){if(-1!==t.level&&this.schedule){var r=this.hls.levels[t.level];if(r.details){var i=c(c({},this.mediaSelection||this.altSelection),{},{main:r});this.mediaSelection=i;var n=this.hls.startPosition;if(this.schedule.parseInterstitialDateRanges(i,this.hls.config.interstitialAppendInPlace,-1===n?0:n),this.schedule.items)if(this.effectivePlayingItem){if(this.bufferPastEdge){var a=this.primaryDetails,s=this.bufferedPos;if(null!=a&&a.live&&s+.5.25){e.event.assetList.forEach((function(t,i){e.event.isAssetPastPlayoutLimit(i)&&r.clearAssetPlayer(t.identifier,null)}));var i=e.end+.25,n=si.bufferInfo(this.primaryMedia,i,0);(n.end>i||(n.nextStart||0)>i)&&(this.log("trim buffered interstitial "+pu(e)+" (was "+pu(t)+")"),this.attachPrimary(i,null,!0),this.flushFrontBuffer(i))}},r.itemsMatch=function(e,t){return!!t&&(e===t||e.event&&t.event&&this.eventItemsMatch(e,t)||!e.event&&!t.event&&this.findItemIndex(e)===this.findItemIndex(t))},r.eventItemsMatch=function(e,t){var r;return!!t&&(e===t||e.event.identifier===(null==(r=t.event)?void 0:r.identifier))},r.findEventIndex=function(e){return this.schedule?this.schedule.findEventIndex(e):-1},r.getEventItem=function(e){if(this.schedule){var t,r=null==(t=this.schedule.items)?void 0:t[this.findEventIndex(e)];if(this.isInterstitial(r)&&r.event.identifier===e)return r}},r.findItemIndex=function(e,t){return e&&this.schedule?this.schedule.findItemIndex(e,t):-1},r.updateSchedule=function(e){var t,r;void 0===e&&(e=!1);var i=this.mediaSelection;if(i){var n=(null==(t=this.hls)?void 0:t.liveSyncPosition)||0;null==(r=this.schedule)||r.updateSchedule(i,[],n,e)}},r.checkBuffer=function(e){var t,r=null==(t=this.schedule)?void 0:t.items;if(r){var i=si.bufferInfo(this.primaryMedia,this.timelinePos,0);e&&(this.bufferedPos=this.timelinePos),e||(e=i.len<1),this.updateBufferedPos(i.end,r,e)}},r.updateBufferedPos=function(e,t,r){var i=this.schedule,n=this.bufferingItem;if(!(this.bufferedPos>e)&&i)if(1===t.length&&this.itemsMatch(t[0],n))this.bufferedPos=e;else{var a=this.playingItem,s=this.findItemIndex(a),o=i.findItemIndexAtTime(e);if(this.bufferedPos=n.end||null!=(l=h.event)&&l.appendInPlace&&e+.01>=h.start)&&(o=d),this.isInterstitial(n)){var f=n.event;if(d-s>1&&!1===f.appendInPlace)return;if(0===f.assetList.length&&f.assetListLoader)return}if(this.bufferedPos=e,o>u&&o>s)this.bufferedToItem(h);else{var c=this.primaryDetails;this.primaryLive&&c&&e>c.edge-c.targetduration&&h.start0&&(o=Math.round(1e3*f)/1e3)}if(this.log("Load interstitial asset "+(t+1)+"/"+(r?1:i)+" "+e+(h?" live-start: "+h+" start-offset: "+o+" (timeline-start: "+l+")":"")),r)return this.createAsset(e,0,0,l,e.duration,r);var c=this.assetListLoader.loadAssetList(e,o);c&&(e.assetListLoader=c)}else if(!a&&i){for(var g=t;g=i.edge||(this.log("Removing front buffer starting at "+e),Object.keys(r).forEach((function(r){t.hls.trigger(G.BUFFER_FLUSHING,{startOffset:e,endOffset:1/0,type:r})})))}},r.getAssetPlayerQueueIndex=function(e){for(var t=this.playerQueue,r=0;r=0){var f=t.duration;f&&hu)&&(y=!1,i.log('Interstitial asset "'+g+'" duration change '+u+" > "+l),t.duration=l,i.updateSchedule())}};p.on(G.LEVEL_UPDATED,(function(e,t){var r=t.details;return E(r)})),p.on(G.LEVEL_PTS_UPDATED,(function(e,t){var r=t.details;return E(r)})),p.on(G.EVENT_CUE_ENTER,(function(){return i.onInterstitialCueEnter()}));var T=function(e,t){var r=i.getAssetPlayer(g);if(r&&t.tracks){r.off(G.BUFFER_CODECS,T),r.tracks=t.tracks;var n=i.primaryMedia;i.bufferingAsset===r.assetItem&&n&&!r.media&&i.bufferAssetPlayer(r,n)}};p.on(G.BUFFER_CODECS,T),p.on(G.BUFFERED_TO_END,(function(){var t=i.getAssetPlayer(g);if(i.log("buffered to end of asset "+t),t&&i.schedule){var r=i.getEventItem(e.identifier);r&&i.advanceAssetBuffering(r,t.assetItem)}}));var S=function(t){return function(){if(i.getAssetPlayer(g)&&i.schedule){i.shouldPlay=!0;var r=i.findEventIndex(e.identifier);i.advanceAfterAssetEnded(e,r,t)}}};return p.once(G.MEDIA_ENDED,S(r)),p.once(G.PLAYOUT_LIMIT_REACHED,S(1/0)),p.on(G.ERROR,(function(t,n){if(i.schedule){var a=i.getAssetPlayer(g);if(n.details===B.BUFFER_STALLED_ERROR)return null!=a&&a.appendInPlace?void i.handleInPlaceStall(e):(i.onTimeupdate(),void i.checkBuffer(!0));n.details!==B.BUFFER_SEEK_OVER_HOLE&&i.handleAssetItemError(n,e,i.findEventIndex(e.identifier),r,"Asset player error "+n.error+" "+e)}})),p.on(G.DESTROYING,(function(){if(i.getAssetPlayer(g)&&i.schedule){var t=new Error("Asset player destroyed unexpectedly "+g),n={fatal:!0,type:U.OTHER_ERROR,details:B.INTERSTITIAL_ASSET_ITEM_ERROR,error:t};i.handleAssetItemError(n,e,i.findEventIndex(e.identifier),r,t.message)}})),this.log("INTERSTITIAL_ASSET_PLAYER_CREATED "+cu(t)),this.hls.trigger(G.INTERSTITIAL_ASSET_PLAYER_CREATED,{asset:t,assetListIndex:r,event:e,player:p}),p},r.clearInterstitial=function(e,t){this.clearAssetPlayers(e,t),e.reset()},r.clearAssetPlayers=function(e,t){var r=this;e.assetList.forEach((function(e){r.clearAssetPlayer(e.identifier,t)}))},r.resetAssetPlayer=function(e){var t=this.getAssetPlayerQueueIndex(e);if(-1!==t){this.log('reset asset player "'+e+'" after error');var r=this.playerQueue[t];this.transferMediaFromPlayer(r,null),r.resetDetails()}},r.clearAssetPlayer=function(e,t){var r=this.getAssetPlayerQueueIndex(e);if(-1!==r){var i=this.playerQueue[r];this.log("clear "+i+" toSegment: "+(t?pu(t):t)),this.transferMediaFromPlayer(i,t),this.playerQueue.splice(r,1),i.destroy()}},r.emptyPlayerQueue=function(){for(var e;e=this.playerQueue.pop();)e.destroy();this.playerQueue=[]},r.startAssetPlayer=function(e,t,r,i,n){var a=e.interstitial,s=e.assetItem,o=e.assetId,l=a.assetList.length,u=this.playingAsset;this.endedAsset=null,this.playingAsset=s,(null==u?void 0:u.identifier)!==o&&(u&&(this.clearAssetPlayer(u.identifier,r[i]),delete u.error),this.log("INTERSTITIAL_ASSET_STARTED "+(t+1)+"/"+l+" "+cu(s)),this.hls.trigger(G.INTERSTITIAL_ASSET_STARTED,{asset:s,assetListIndex:t,event:a,schedule:r.slice(0),scheduleIndex:i,player:e})),this.bufferAssetPlayer(e,n)},r.bufferAssetPlayer=function(e,t){var r;if(this.schedule){var i=e.interstitial,n=e.assetItem,a=this.getEventItem(i.identifier);if(a)if(e.loadSource(),this.setBufferingItem(a),this.bufferingAsset=n,e.bufferedInPlaceToEnd(this.primaryMedia,e.currentTime))this.advanceAssetBuffering(a,n);else{var s=this.getBufferingPlayer();if(s!==e){var o=i.appendInPlace;if(!o||!1!==(null==s?void 0:s.interstitial.appendInPlace)){var l=(null==s?void 0:s.tracks)||(null==(r=this.detachedData)?void 0:r.tracks)||this.requiredTracks;if(o&&n!==this.playingAsset){if(!e.tracks)return void this.log("Waiting for track info before buffering "+e);if(l&&!ye(l,e.tracks)){var u=new Error("Asset "+cu(n)+" SourceBuffer tracks ('"+Object.keys(e.tracks)+"') are not compatible with primary content tracks ('"+Object.keys(l)+"')"),d={fatal:!0,type:U.OTHER_ERROR,details:B.INTERSTITIAL_ASSET_ITEM_ERROR,error:u},h=i.findAssetIndex(n);return void this.handleAssetItemError(d,i,this.findEventIndex(i.identifier),h,u.message)}}this.transferMediaTo(e,t)}}}}},r.handleInPlaceStall=function(e){var t=this.schedule,r=this.primaryMedia;if(t&&r){var i=r.currentTime,n=t.findAssetIndex(e,i),a=e.assetList[n];if(a){var s=this.getAssetPlayer(a.identifier);if(s){var o=s.currentTime||i-a.timelineStart,l=s.duration-o;if(this.warn("Stalled at "+o+" of "+(o+l)+" in "+s+" "+e+" (media.currentTime: "+i+")"),o&&(l/r.playbackRate<.5||s.bufferedInPlaceToEnd(r,s.currentTime))&&s.hls){var u=t.findEventIndex(e.identifier);this.advanceAfterAssetEnded(e,u,n)}}}}},r.advanceInPlace=function(e){var t=this.primaryMedia;if(t&&t.currentTimee?(this.log(r),t.currentTime=e):this.log("Cannot "+r)}},r.handleAssetItemError=function(e,t,r,i,n){if(e.details!==B.BUFFER_STALLED_ERROR){var a=t.assetList[i]||null;if(this.warn("INTERSTITIAL_ASSET_ERROR "+(a?cu(a):a)+" "+e.error),this.schedule){var s=(null==a?void 0:a.identifier)||"",o=this.getAssetPlayerQueueIndex(s),u=this.playerQueue[o]||null,d=this.schedule.items,h=l({},e,{fatal:!1,errorAction:Fr(!0),asset:a,assetListIndex:i,event:t,schedule:d,scheduleIndex:r,player:u});if(this.hls.trigger(G.INTERSTITIAL_ASSET_ERROR,h),e.fatal){var f=this.playingAsset,c=this.bufferingAsset,g=new Error(n);if(a&&(this.clearAssetPlayer(s,null),a.error=g),t.assetList.some((function(e){return!e.error})))for(var v=i;vv.end&&this.schedule.findItemIndexAtTime(this.timelinePos)!==g)return a.error=new Error("Interstitial "+(o.length?"no longer within playback range":"asset-list is empty")+" "+this.timelinePos+" "+a),this.log(a.error.message),this.updateSchedule(!0),void this.primaryFallback(a);this.setBufferingItem(v)}this.setSchedulePosition(g)}else if((null==c?void 0:c.identifier)===s){var m=a.assetList[0];if(m){var p=this.getAssetPlayer(m.identifier);if(c.appendInPlace){var y=this.primaryMedia;p&&y&&this.bufferAssetPlayer(p,y)}else p&&p.loadSource()}}}},r.onError=function(e,t){if(this.schedule)switch(t.details){case B.ASSET_LIST_PARSING_ERROR:case B.ASSET_LIST_LOAD_ERROR:case B.ASSET_LIST_LOAD_TIMEOUT:var r=t.interstitial;r&&(this.updateSchedule(!0),this.primaryFallback(r));break;case B.BUFFER_STALLED_ERROR:var i=this.endedItem||this.waitingItem||this.playingItem;if(this.isInterstitial(i)&&i.event.appendInPlace)return void this.handleInPlaceStall(i.event);var n=this.primaryMedia;this.log("Primary player stall @"+this.timelinePos+" currentTime: "+(null==n?void 0:n.currentTime)+" bufferedPos: "+this.bufferedPos),this.onTimeupdate(),this.checkBuffer(!0)}},a(t,[{key:"interstitialsManager",get:function(){if(!this.hls)return null;if(this.manager)return this.manager;var e=this,t=function(){return e.bufferingItem||e.waitingItem},r=function(t){return t?e.getAssetPlayer(t.identifier):t},i=function(t,i,a,s,o){if(t){var l=t[i].start,u=t.event;if(u){if("playout"===i||u.timelineOccupancy!==su.Point){var d=r(a);(null==d?void 0:d.interstitial)===u&&(l+=d.assetItem.startOffset+d[o])}}else l+=("bufferedPos"===s?n():e[s])-t.start;return l}return 0},n=function(){var t=e.bufferedPos;return t===Number.MAX_VALUE?a("primary"):Math.max(t,0)},a=function(t){var r,i;return null!=(r=e.primaryDetails)&&r.live?e.primaryDetails.edge:(null==(i=e.schedule)?void 0:i.durations[t])||0},s=function(t,n){var a,s,o=e.effectivePlayingItem;if((null==o||null==(a=o.event)||!a.restrictions.skip)&&e.schedule){e.log("seek to "+t+' "'+n+'"');var l=e.effectivePlayingItem,u=e.schedule.findItemIndexAtTime(t,n),d=null==(s=e.schedule.items)?void 0:s[u],h=e.getBufferingPlayer(),f=null==h?void 0:h.interstitial,c=null==f?void 0:f.appendInPlace,g=l&&e.itemsMatch(l,d);if(l&&(c||g)){var v=r(e.playingAsset),m=(null==v?void 0:v.media)||e.primaryMedia;if(m){var p="primary"===n?m.currentTime:i(l,n,e.playingAsset,"timelinePos","currentTime"),y=t-p,E=(c?p:m.currentTime)+y;if(E>=0&&(!v||c||E<=v.duration))return void(m.currentTime=E)}}if(d){var T=t;if("primary"!==n){var S=t-d[n].start;T=d.start+S}var A=!e.isInterstitial(d);if(e.isInterstitial(l)&&!l.event.appendInPlace||!A&&!d.event.appendInPlace){if(l){var I=e.findItemIndex(l);if(u>I){var L=e.schedule.findJumpRestrictedIndex(I+1,u);if(L>I)return void e.setSchedulePosition(L)}var R=0;if(A)e.timelinePos=T,e.checkBuffer();else for(var b=d.event.assetList,k=t-(d[n]||d).start,D=b.length;D--;){var P=b[D];if(P.duration&&k>=P.startOffset&&k0?t:0},set currentTime(e){s(e,"primary")},get duration(){return a("primary")},get seekableStart(){return e.seekableStart}},integrated:{get bufferedEnd(){return i(t(),"integrated",e.bufferingAsset,"bufferedPos","bufferedEnd")},get currentTime(){return i(e.effectivePlayingItem,"integrated",e.effectivePlayingAsset,"timelinePos","currentTime")},set currentTime(e){s(e,"integrated")},get duration(){return a("integrated")},get seekableStart(){return function(t,r){var i;if(0!==t&&"primary"!==r&&null!=(i=e.schedule)&&i.length){var n,a=e.schedule.findItemIndexAtTime(t),s=null==(n=e.schedule.items)?void 0:n[a];if(s)return t+(s[r].start-s.start)}return t}(e.seekableStart,"integrated")}},skip:function(){var t=e.effectivePlayingItem,r=null==t?void 0:t.event;if(r&&!r.restrictions.skip){var i=e.findItemIndex(t);if(r.appendInPlace){var n=t.playout.start+t.event.duration;s(n+.001,"playout")}else e.advanceAfterAssetEnded(r,i,1/0)}}}}},{key:"effectivePlayingItem",get:function(){return this.waitingItem||this.playingItem||this.endedItem}},{key:"effectivePlayingAsset",get:function(){return this.playingAsset||this.endedAsset}},{key:"playingLastItem",get:function(){var e,t=this.playingItem,r=null==(e=this.schedule)?void 0:e.items;return!!(this.playbackStarted&&t&&r)&&this.findItemIndex(t)===r.length-1}},{key:"playbackStarted",get:function(){return null!==this.effectivePlayingItem}},{key:"currentTime",get:function(){var e,t;if(null!==this.mediaSelection){var r=this.waitingItem||this.playingItem;if(!this.isInterstitial(r)||r.event.appendInPlace){var i=this.media;!i&&null!=(e=this.bufferingItem)&&null!=(e=e.event)&&e.appendInPlace&&(i=this.primaryMedia);var n=null==(t=i)?void 0:t.currentTime;if(void 0!==n&&x(n))return n}}}},{key:"primaryMedia",get:function(){var e;return this.media||(null==(e=this.detachedData)?void 0:e.media)||null}},{key:"playbackDisabled",get:function(){return!1===this.hls.config.enableInterstitialPlayback}},{key:"primaryDetails",get:function(){var e;return null==(e=this.mediaSelection)?void 0:e.main.details}},{key:"primaryLive",get:function(){var e;return!(null==(e=this.primaryDetails)||!e.live)}},{key:"seekableStart",get:function(){var e;return this.primaryLive&&(null==(e=this.primaryDetails)?void 0:e.fragmentStart)||0}}])}(ue),Au=function(){function e(e){var t=this;this.hls=void 0,this.media=null,this.currentTime=0,this.stallCount=0,this._latency=null,this._targetLatencyUpdated=!1,this.onTimeupdate=function(){var e,r=t.media,i=t.levelDetails;if(r&&i&&t.hls){t.currentTime=r.currentTime;var n=t.hls.config,a=t.computeLatency();if(null!==a){t._latency=a;var s=n.lowLatencyMode,o=n.maxLiveSyncPlaybackRate;if(s&&1!==o&&i.live){var l=t.targetLatency;if(null!==l){var u=a-l,d=u.05&&t.forwardBufferLength>1&&!h){var f=Math.min(2,Math.max(1,o)),c=Math.round(2/(1+Math.exp(-.75*u-t.edgeStalled))*20)/20,g=Math.min(f,Math.max(1,c));t.changeMediaPlaybackRate(r,g)}else 1!==r.playbackRate&&0!==r.playbackRate&&t.changeMediaPlaybackRate(r,1)}}}}},this.hls=e,this.registerListeners()}var t=e.prototype;return t.destroy=function(){this.unregisterListeners(),this.onMediaDetaching(),this.hls=null},t.registerListeners=function(){var e=this.hls;e&&(e.on(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(G.ERROR,this.onError,this),e.on(G.INTERSTITIAL_ASSET_STARTED,this.onAssetStarted,this))},t.unregisterListeners=function(){var e=this.hls;e&&(e.off(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(G.ERROR,this.onError,this),e.off(G.INTERSTITIAL_ASSET_STARTED,this.onAssetStarted,this))},t.onMediaAttached=function(e,t){this.media=t.media,this.media.addEventListener("timeupdate",this.onTimeupdate)},t.onMediaDetaching=function(){this.media&&(this.media.removeEventListener("timeupdate",this.onTimeupdate),this.media=null)},t.onManifestLoading=function(){this._latency=null,this.stallCount=0},t.onLevelUpdated=function(e,t){var r=t.details;r.advanced&&this.onTimeupdate(),!r.live&&this.media&&this.media.removeEventListener("timeupdate",this.onTimeupdate)},t.onAssetStarted=function(e,t){var r,i=this.hls,n=this.media||t.event.appendInPlace&&(null==i||null==(r=i.interstitialsManager)?void 0:r.playerQueue.reduce((function(e,t){return e||t.media}),null));i&&n&&n.playbackRate>1&&n.playbackRate<=i.config.maxLiveSyncPlaybackRate&&this.changeMediaPlaybackRate(n,1)},t.onError=function(e,t){var r;t.details===B.BUFFER_STALLED_ERROR&&(this.stallCount++,this.hls&&null!=(r=this.levelDetails)&&r.live&&this.hls.logger.warn("[latency-controller]: Stall detected, adjusting target latency"))},t.changeMediaPlaybackRate=function(e,t){var r,i;e.playbackRate!==t&&(null==(r=this.hls)||r.logger.debug("[latency-controller]: latency="+this.latency.toFixed(3)+", targetLatency="+(null==(i=this.targetLatency)?void 0:i.toFixed(3))+", forwardBufferLength="+this.forwardBufferLength.toFixed(3)+": adjusting playback rate from "+e.playbackRate+" to "+t),e.playbackRate=t)},t.estimateLiveEdge=function(){var e=this.levelDetails;return null===e?null:e.edge+e.age},t.computeLatency=function(){var e=this.estimateLiveEdge();return null===e?null:e-this.currentTime},a(e,[{key:"levelDetails",get:function(){var e;return(null==(e=this.hls)?void 0:e.latestLevelDetails)||null}},{key:"latency",get:function(){return this._latency||0}},{key:"maxLatency",get:function(){var e,t=null==(e=this.hls)?void 0:e.config;if(!t)return 0;if(void 0!==t.liveMaxLatencyDuration)return t.liveMaxLatencyDuration;var r=this.levelDetails;return r?t.liveMaxLatencyDurationCount*r.targetduration:0}},{key:"targetLatency",get:function(){var e=this.levelDetails;if(null===e||!this.hls)return null;var t=this.hls.config,r=e.holdBack,i=e.partHoldBack,n=e.targetduration,a=t.liveSyncDuration,s=t.liveSyncDurationCount,o=t.lowLatencyMode,l=this.hls.userConfig,u=o&&i||r;(this._targetLatencyUpdated||l.liveSyncDuration||l.liveSyncDurationCount||0===u)&&(u=void 0!==a?a:s*n);var d=n;return u+Math.min(this.stallCount*t.liveSyncOnStallIncrease,d)},set:function(e){this.hls&&(this.stallCount=0,this.hls.config.liveSyncDuration=e,this._targetLatencyUpdated=!0)}},{key:"liveSyncPosition",get:function(){var e=this.estimateLiveEdge(),t=this.targetLatency;if(null===e||null===t||!this.hls)return null;var r=this.levelDetails;if(null===r)return null;var i=r.edge,n=e-t-this.edgeStalled,a=i-r.totalduration,s=i-(this.hls.config.lowLatencyMode&&r.partTarget||r.targetduration);return Math.min(Math.max(a,n),s)}},{key:"drift",get:function(){var e=this.levelDetails;return null===e?1:e.drift}},{key:"edgeStalled",get:function(){var e=this.levelDetails;if(null===e||!this.hls)return 0;var t=3*(this.hls.config.lowLatencyMode&&e.partTarget||e.targetduration);return Math.max(e.age-t,0)}},{key:"forwardBufferLength",get:function(){var e=this.media,t=this.levelDetails;if(!e||!t)return 0;var r=e.buffered.length;return(r?e.buffered.end(r-1):t.edge)-this.currentTime}}])}(),Iu=function(e){function t(t,r,i){var n;return(n=e.call(this,t,r,i,"subtitle-stream-controller",H.SUBTITLE)||this).currentTrackId=-1,n.tracksBuffered=[],n.mainDetails=null,n.registerListeners(),n}d(t,e);var r=t.prototype;return r.onHandlerDestroying=function(){this.unregisterListeners(),e.prototype.onHandlerDestroying.call(this),this.mainDetails=null},r.registerListeners=function(){e.prototype.registerListeners.call(this);var t=this.hls;t.on(G.LEVEL_LOADED,this.onLevelLoaded,this),t.on(G.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),t.on(G.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),t.on(G.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),t.on(G.SUBTITLE_FRAG_PROCESSED,this.onSubtitleFragProcessed,this),t.on(G.BUFFER_FLUSHING,this.onBufferFlushing,this)},r.unregisterListeners=function(){e.prototype.unregisterListeners.call(this);var t=this.hls;t.off(G.LEVEL_LOADED,this.onLevelLoaded,this),t.off(G.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),t.off(G.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),t.off(G.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),t.off(G.SUBTITLE_FRAG_PROCESSED,this.onSubtitleFragProcessed,this),t.off(G.BUFFER_FLUSHING,this.onBufferFlushing,this)},r.startLoad=function(e,t){this.stopLoad(),this.state=Rn.IDLE,this.setInterval(500),this.nextLoadPosition=this.lastCurrentTime=e+this.timelineOffset,this.startPosition=t?-1:e,this.tick()},r.onManifestLoading=function(){e.prototype.onManifestLoading.call(this),this.mainDetails=null},r.onMediaDetaching=function(t,r){this.tracksBuffered=[],e.prototype.onMediaDetaching.call(this,t,r)},r.onLevelLoaded=function(e,t){this.mainDetails=t.details},r.onSubtitleFragProcessed=function(e,t){var r=t.frag,i=t.part;if(t.success){var n=this.tracksBuffered[this.currentTrackId];if(n){for(var a,s=(i||r).start,o=0;o=n[o].start&&s<=n[o].end){a=n[o];break}var l=s+(i||r).duration;if(a?a.end=l:(a={start:s,end:l},n.push(a)),!i||l>=r.end){var u=this.fragmentTracker.fragBuffered(r);i&&u&&(u.range.subs={time:[{startPTS:r.start,endPTS:l}],partial:!0}),W(r)&&!this.fragContextChanged(r)&&(this.fragPrevious=r),this.fragBufferedComplete(r,i),this.media&&this.tickImmediate()}}}},r.onBufferFlushing=function(e,t){var r=t.startOffset,i=t.endOffset;if(0===r&&i!==Number.POSITIVE_INFINITY){var n=i-1;if(n<=0)return;t.endOffsetSubtitles=Math.max(0,n),this.tracksBuffered.forEach((function(e){if(e)for(var t=0;t=n.length)&&o){this.log("Subtitle track "+s+" loaded ["+a.startSN+","+a.endSN+"]"+(a.lastPartSn?"[part-"+a.lastPartSn+"-"+a.lastPartIndex+"]":"")+",duration:"+a.totalduration),this.mediaBuffer=this.mediaBufferTimeRanges;var l=this.mainDetails,u=0;if(a.live||null!=(r=o.details)&&r.live){if(a.deltaUpdateFailed)return;if(!l)return void(this.startFragRequested=!1);var d;o.details&&(u=this.alignPlaylists(a,o.details,null==(d=this.levelLastLoaded)?void 0:d.details)),a.alignedSliding||(Sn(l,a,this),u=a.fragmentStart)}o.details=a,this.levelLastLoaded=o,l&&!this.startFragRequested&&this.setStartPosition(l,u),s===i&&(this.hls.trigger(G.SUBTITLE_TRACK_UPDATED,{details:a,id:s,groupId:t.groupId}),this.tickImmediate())}}else this.warn("Subtitle tracks were reset while loading level "+s)},r._handleFragmentLoadProgress=function(e){var t=e.frag,r=e.part,i=e.payload;r&&null!=i&&i.byteLength&&this.shouldDecrypt(t)&&this.decryptPayload(i,t,r)},r._handleFragmentLoadComplete=function(e){var t=e.frag,r=e.payload;!this.fragContextChanged(t)&&null!=r&&r.byteLength&&this.shouldDecrypt(t)&&this.decryptPayload(r,t,null)},r.shouldDecrypt=function(e){var t=e.decryptdata;return!!(null!=t&&t.key&&t.iv&&Ti(t.method))},r.decryptPayload=function(e,t,r){var i=this,n=t.decryptdata,a=performance.now();this.decrypter.decrypt(new Uint8Array(e),n.key.buffer,n.iv.buffer,Si(n.method)).then((function(e){i.fragContextChanged(t)||i.hls.trigger(G.FRAG_DECRYPTED,{frag:t,part:r,payload:e,stats:{tstart:a,tdecrypt:performance.now()}})})).catch((function(e){i.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.FRAG_DECRYPT_ERROR,fatal:!1,error:e,reason:e.message,frag:t,part:r}),r||(i.warn(e.name+": "+e.message),i.state=Rn.IDLE)}))},r.doTick=function(){if(this.state===Rn.IDLE&&this.levels){var e;if(!this.media&&!this.primaryPrefetch&&(this.startFragRequested||!this.config.startFragPrefetch))return;var t=this.currentTrackId,r=this.levels[t],i=null==r?void 0:r.details;if(!i||this.waitForLive(r)||this.waitForCdnTuneIn(i))return void(this.startFragRequested=!1);var n=this.config,a=this.playhead,s=si.bufferedInfo(this.tracksBuffered[this.currentTrackId]||[],a,n.maxBufferHole),o=s.end,l=s.len;if(l>Math.max(this.hls.maxBufferLength+i.levelTargetDuration,(null==(e=this.hls.mainForwardBufferInfo)?void 0:e.len)||0)||l&&!this.buffering)return;var u=this.getNextFragment(o,i);if(!u)return;if(W(u)){var d,h=u.sn-i.startSN,f=i.fragments[h-1];u.cc!==(null==f?void 0:f.cc)||null!=(d=i.partList)&&d.length||this.fragmentTracker.getState(f)!==Nr||(u=f)}this.loadFragment(u,r,o)}},r.loadFragment=function(t,r,i){var n=this.fragmentTracker.getState(t);n!==Nr&&n!==Br||e.prototype.loadFragment.call(this,t,r,i)},a(t,[{key:"mediaBufferTimeRanges",get:function(){return new Lu(this.tracksBuffered[this.currentTrackId]||[])}}])}(bn),Lu=function(e){this.buffered=void 0;var t=function(t,r,i){if((r>>>=0)>i-1)throw new DOMException("Failed to execute '"+t+"' on 'TimeRanges': The index provided ("+r+") is greater than the maximum bound ("+i+")");return e[r][t]};this.buffered={get length(){return e.length},end:function(r){return t("end",r,e.length)},start:function(r){return t("start",r,e.length)}}},Ru=function(){if(null!=Ri&&Ri.VTTCue)return self.VTTCue;var e=["","lr","rl"],t=["start","middle","end","left","right"];function r(e,t){if("string"!=typeof t)return!1;if(!Array.isArray(e))return!1;var r=t.toLowerCase();return!!~e.indexOf(r)&&r}function i(e){return r(t,e)}function n(e){for(var t=arguments.length,r=new Array(t>1?t-1:0),i=1;i100)throw new Error("Position must be between 0 and 100.");E=e,this.hasBeenReset=!0}})),Object.defineProperty(o,"positionAlign",n({},l,{get:function(){return T},set:function(e){var t=i(e);if(!t)throw new SyntaxError("An invalid or illegal string was specified.");T=t,this.hasBeenReset=!0}})),Object.defineProperty(o,"size",n({},l,{get:function(){return S},set:function(e){if(e<0||e>100)throw new Error("Size must be between 0 and 100.");S=e,this.hasBeenReset=!0}})),Object.defineProperty(o,"align",n({},l,{get:function(){return A},set:function(e){var t=i(e);if(!t)throw new SyntaxError("An invalid or illegal string was specified.");A=t,this.hasBeenReset=!0}})),o.displayState=void 0}return a.prototype.getCueAsHTML=function(){return self.WebVTT.convertCueToDOMTree(self,this.text)},a}(),bu=function(){function e(){}return e.prototype.decode=function(e,t){if(!e)return"";if("string"!=typeof e)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(e))},e}();function ku(e){function t(e,t,r,i){return 3600*(0|e)+60*(0|t)+(0|r)+parseFloat(i||0)}var r=e.match(/^(?:(\d+):)?(\d{2}):(\d{2})(\.\d+)?/);return r?parseFloat(r[2])>59?t(r[2],r[3],0,r[4]):t(r[1],r[2],r[3],r[4]):null}var Du=function(){function e(){this.values=Object.create(null)}var t=e.prototype;return t.set=function(e,t){this.get(e)||""===t||(this.values[e]=t)},t.get=function(e,t,r){return r?this.has(e)?this.values[e]:t[r]:this.has(e)?this.values[e]:t},t.has=function(e){return e in this.values},t.alt=function(e,t,r){for(var i=0;i=0&&r<=100)return this.set(e,r),!0}return!1},e}();function Pu(e,t,r,i){var n=i?e.split(i):[e];for(var a in n)if("string"==typeof n[a]){var s=n[a].split(r);2===s.length&&t(s[0],s[1])}}var _u=new Ru(0,0,""),Cu="middle"===_u.align?"middle":"center";function wu(e,t,r){var i=e;function n(){var t=ku(e);if(null===t)throw new Error("Malformed timestamp: "+i);return e=e.replace(/^[^\sa-zA-Z-]+/,""),t}function a(){e=e.replace(/^\s+/,"")}if(a(),t.startTime=n(),a(),"--\x3e"!==e.slice(0,3))throw new Error("Malformed time stamp (time stamps must be separated by '--\x3e'): "+i);e=e.slice(3),a(),t.endTime=n(),a(),function(e,t){var i=new Du;Pu(e,(function(e,t){var n;switch(e){case"region":for(var a=r.length-1;a>=0;a--)if(r[a].id===t){i.set(e,r[a].region);break}break;case"vertical":i.alt(e,t,["rl","lr"]);break;case"line":n=t.split(","),i.integer(e,n[0]),i.percent(e,n[0])&&i.set("snapToLines",!1),i.alt(e,n[0],["auto"]),2===n.length&&i.alt("lineAlign",n[1],["start",Cu,"end"]);break;case"position":n=t.split(","),i.percent(e,n[0]),2===n.length&&i.alt("positionAlign",n[1],["start",Cu,"end","line-left","line-right","auto"]);break;case"size":i.percent(e,t);break;case"align":i.alt(e,t,["start",Cu,"end","left","right"])}}),/:/,/\s/),t.region=i.get("region",null),t.vertical=i.get("vertical","");var n=i.get("line","auto");"auto"===n&&-1===_u.line&&(n=-1),t.line=n,t.lineAlign=i.get("lineAlign","start"),t.snapToLines=i.get("snapToLines",!0),t.size=i.get("size",100),t.align=i.get("align",Cu);var a=i.get("position","auto");"auto"===a&&50===_u.position&&(a="start"===t.align||"left"===t.align?0:"end"===t.align||"right"===t.align?100:50),t.position=a}(e,t)}function Ou(e){return e.replace(//gi,"\n")}var Mu=function(){function e(){this.state="INITIAL",this.buffer="",this.decoder=new bu,this.regionList=[],this.cue=null,this.oncue=void 0,this.onparsingerror=void 0,this.onflush=void 0}var t=e.prototype;return t.parse=function(e){var t=this;function r(){var e=t.buffer,r=0;for(e=Ou(e);r0&&f.push(e)},d.onparsingerror=function(e){u=e},d.onflush=function(){u?s(u):a(f)},h.forEach((function(e){if(y){if(e.startsWith("X-TIMESTAMP-MAP=")){y=!1,p=!0,e.slice(16).split(",").forEach((function(e){e.startsWith("LOCAL:")?g=e.slice(6):e.startsWith("MPEGTS:")&&(v=parseInt(e.slice(7)))}));try{m=function(e){var t=parseInt(e.slice(-3)),r=parseInt(e.slice(-6,-4)),i=parseInt(e.slice(-9,-7)),n=e.length>9?parseInt(e.substring(0,e.indexOf(":"))):0;if(!(x(t)&&x(r)&&x(i)&&x(n)))throw Error("Malformed X-TIMESTAMP-MAP: Local:"+e);return t+=1e3*r,(t+=6e4*i)+36e5*n}(g)/1e3}catch(e){u=e}return}""===e&&(y=!1)}d.parse(e+"\n")})),d.flush()}var Bu="stpp.ttml.im1t",Gu=/^(\d{2,}):(\d{2}):(\d{2}):(\d{2})\.?(\d+)?$/,Ku=/^(\d*(?:\.\d*)?)(h|m|s|ms|f|t)$/,Vu={left:"start",center:"center",right:"end",start:"start",end:"end"};function Hu(e,t,r,i){var n=Ze(new Uint8Array(e),["mdat"]);if(0!==n.length){var a,s,o,u,d=n.map((function(e){return Re(e)})),h=(a=t.baseTime,s=1,void 0===(o=t.timescale)&&(o=1),void 0===u&&(u=!1),wa(a,s,1/o,u));try{d.forEach((function(e){return r(function(e,t){var r=(new DOMParser).parseFromString(e,"text/xml"),i=r.getElementsByTagName("tt")[0];if(!i)throw new Error("Invalid ttml");var n={frameRate:30,subFrameRate:1,frameRateMultiplier:0,tickRate:1},a=Object.keys(n).reduce((function(e,t){return e[t]=i.getAttribute("ttp:"+t)||n[t],e}),{});!i.getAttribute("ttp:tickRate")&&i.getAttribute("ttp:frameRate")&&(a.tickRate=Number(a.frameRate)*Number(a.subFrameRate));var s="preserve"!==i.getAttribute("xml:space"),o=ju(Yu(i,"styling","style")),u=ju(Yu(i,"layout","region")),d=Yu(i,"body","[begin]");return[].map.call(d,(function(e){var r=Wu(e,s);if(!r||!e.hasAttribute("begin"))return null;var i=zu(e.getAttribute("begin"),a),n=zu(e.getAttribute("dur"),a),d=zu(e.getAttribute("end"),a);if(null===i)throw Xu(e);if(null===d){if(null===n)throw Xu(e);d=i+n}var h=new Ru(i-t,d-t,r);h.id=Nu(h.startTime,h.endTime,h.text);var f=function(e,t,r){var i="http://www.w3.org/ns/ttml#styling",n=null,a=["displayAlign","textAlign","color","backgroundColor","fontSize","fontFamily"],s=null!=e&&e.hasAttribute("style")?e.getAttribute("style"):null;return s&&r.hasOwnProperty(s)&&(n=r[s]),a.reduce((function(r,a){var s=qu(t,i,a)||qu(e,i,a)||qu(n,i,a);return s&&(r[a]=s),r}),{})}(u[e.getAttribute("region")],o[e.getAttribute("style")],o),c=f.textAlign;if(c){var g=Vu[c];g&&(h.lineAlign=g),h.align=c}return l(h,f),h})).filter((function(e){return null!==e}))}(e,h))}))}catch(e){i(e)}}else i(new Error("Could not parse IMSC1 mdat"))}function Yu(e,t,r){var i=e.getElementsByTagName(t)[0];return i?[].slice.call(i.querySelectorAll(r)):[]}function ju(e){return e.reduce((function(e,t){var r=t.getAttribute("xml:id");return r&&(e[r]=t),e}),{})}function Wu(e,t){return[].slice.call(e.childNodes).reduce((function(e,r,i){var n;return"br"===r.nodeName&&i?e+"\n":null!=(n=r.childNodes)&&n.length?Wu(r,t):t?e+r.textContent.trim().replace(/\s+/g," "):e+r.textContent}),"")}function qu(e,t,r){return e&&e.hasAttributeNS(t,r)?e.getAttributeNS(t,r):null}function Xu(e){return new Error("Could not parse ttml timestamp "+e)}function zu(e,t){if(!e)return null;var r=ku(e);return null===r&&(Gu.test(e)?r=function(e,t){var r=Gu.exec(e),i=(0|r[4])+(0|r[5])/t.subFrameRate;return 3600*(0|r[1])+60*(0|r[2])+(0|r[3])+i/t.frameRate}(e,t):Ku.test(e)&&(r=function(e,t){var r=Ku.exec(e),i=Number(r[1]);switch(r[2]){case"h":return 3600*i;case"m":return 60*i;case"ms":return i/1e3;case"f":return i/t.frameRate;case"t":return i/t.tickRate}return i}(e,t))),r}var Qu=function(e){function t(t){var r;return(r=e.call(this,t,"subtitle-track-controller")||this).media=null,r.tracks=[],r.groupIds=null,r.tracksInGroup=[],r.trackId=-1,r.currentTrack=null,r.selectDefaultTrack=!0,r.queuedDefaultTrack=-1,r.useTextTrackPolling=!1,r.subtitlePollingInterval=-1,r._subtitleDisplay=!0,r.asyncPollTrackChange=function(){return r.pollTrackChange(0)},r.onTextTracksChanged=function(){if(r.useTextTrackPolling||self.clearInterval(r.subtitlePollingInterval),r.media&&r.hls.config.renderTextTracksNatively){var e,t=-1,i=!1;if(r.currentTrack){var n,a=null==(n=r.currentTrack.trackNode)?void 0:n.track.mode;"showing"===a?(t=r.trackId,i=!0):"hidden"===a&&(t=r.trackId)}if(!i)for(var s=0;s-1&&(r._subtitleDisplay="showing"===(null==(e=r.tracksInGroup[t].trackNode)?void 0:e.track.mode)),r.setSubtitleTrack(t,!0)}},r.registerListeners(),r}d(t,e);var r=t.prototype;return r.destroy=function(){this.unregisterListeners(),this.tracks.length=0,this.tracksInGroup.length=0,this.currentTrack=null,this.onTextTracksChanged=this.asyncPollTrackChange=null,e.prototype.destroy.call(this)},r.registerListeners=function(){var e=this.hls;e.on(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.MANIFEST_PARSED,this.onManifestParsed,this),e.on(G.LEVEL_LOADING,this.onLevelLoading,this),e.on(G.LEVEL_SWITCHING,this.onLevelSwitching,this),e.on(G.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),e.on(G.ERROR,this.onError,this)},r.unregisterListeners=function(){var e=this.hls;e.off(G.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.MANIFEST_PARSED,this.onManifestParsed,this),e.off(G.LEVEL_LOADING,this.onLevelLoading,this),e.off(G.LEVEL_SWITCHING,this.onLevelSwitching,this),e.off(G.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),e.off(G.ERROR,this.onError,this)},r.createTracksInGroup=function(){var e,t=this;if(this.media&&this.hls.config.renderTextTracksNatively){this.tracksInGroup.forEach((function(e){e.trackNode||(e.trackNode=Nl(t.media,Ul(e),e.name,e.lang))}));var r=null==(e=this.currentTrack)||null==(e=e.trackNode)?void 0:e.track;"disabled"===(null==r?void 0:r.mode)&&(r.mode=this._subtitleDisplay?"showing":"hidden")}},r.onMediaAttached=function(e,t){var r=t.media;this.media=r;var i=this.trackId;this.queuedDefaultTrack>-1&&(i=this.queuedDefaultTrack,this.queuedDefaultTrack=-1),this.setSubtitleTrack(i),this.createTracksInGroup(),this.useTextTrackPolling=!(r.textTracks&&"onchange"in r.textTracks),this.useTextTrackPolling?this.pollTrackChange(500):r.textTracks.addEventListener("change",this.asyncPollTrackChange)},r.pollTrackChange=function(e){self.clearInterval(this.subtitlePollingInterval),this.subtitlePollingInterval=self.setInterval(this.onTextTracksChanged,e)},r.onMediaDetaching=function(e,t){var r=this.media;if(r){var i=!!t.transferMedia;self.clearInterval(this.subtitlePollingInterval),this.useTextTrackPolling||r.textTracks.removeEventListener("change",this.asyncPollTrackChange),this.trackId>-1&&(this.queuedDefaultTrack=this.trackId,this.setSubtitleTrack(-1)),this.media=null,i||this.hls.config.renderTextTracksNatively&&this.tracksInGroup.forEach((function(e){var t=e.trackNode;t&&(t.track.mode="hidden",t.remove(),e.trackNode=void 0)}))}},r.onManifestLoading=function(){this.tracks=[],this.groupIds=null,this.tracksInGroup=[],this.trackId=-1,this.currentTrack=null,this.selectDefaultTrack=!0},r.onManifestParsed=function(e,t){this.tracks=t.subtitleTracks},r.onSubtitleTrackLoaded=function(e,t){var r=t.id,i=t.groupId,n=t.details,a=this.tracksInGroup[r];if(a&&a.groupId===i){var s=a.details;a.details=t.details,this.log("Subtitle track "+r+' "'+a.name+'" lang:'+a.lang+" group:"+i+" loaded ["+n.startSN+"-"+n.endSN+"]"),r===this.trackId&&this.playlistLoaded(r,t,s)}else this.warn("Subtitle track with id:"+r+" and group:"+i+" not found in active group "+(null==a?void 0:a.groupId))},r.onLevelLoading=function(e,t){this.switchLevel(t.level)},r.onLevelSwitching=function(e,t){this.switchLevel(t.level)},r.switchLevel=function(e){var t=this,r=this.hls.levels[e];if(r){var i=r.subtitleGroups||null,n=this.groupIds,a=this.currentTrack;if(!i||(null==n?void 0:n.length)!==(null==i?void 0:i.length)||null!=i&&i.some((function(e){return-1===(null==n?void 0:n.indexOf(e))}))){this.groupIds=i;var s=[];if(this.tracks.forEach((function(e){(e.textCodec===Bu?t.hls.config.enableIMSC1:t.hls.config.enableWebVTT)&&(!i||i.indexOf(e.groupId)>-1?(e.id=s.length,s.push(e)):e.trackNode&&(e.trackNode.remove(),e.trackNode=void 0))})),s.length===this.tracksInGroup.length){for(var o=!1,l=0;l-1)a=s[d];else{var h=ur(u,this.tracks);a=this.tracks[h]}}var f=this.findTrackId(a);-1===f&&a&&(f=this.findTrackId(null));var c={subtitleTracks:s};this.log("Updating subtitle tracks, "+s.length+' track(s) found in "'+(null==i?void 0:i.join(","))+'" group-id'),this.hls.trigger(G.SUBTITLE_TRACKS_UPDATED,c),this.setSubtitleTrack(f),this.createTracksInGroup()}}},r.findTrackId=function(e){for(var t=this.tracksInGroup,r=this.selectDefaultTrack,i=0;i-1){var n=this.tracksInGroup[i];return this.setSubtitleTrack(i,!0),n}if(r)return null;var a=ur(e,t);if(a>-1)return t[a]}}return null},r.loadPlaylist=function(t){e.prototype.loadPlaylist.call(this),this.shouldLoadPlaylist(this.currentTrack)&&this.scheduleLoading(this.currentTrack,t)},r.loadingPlaylist=function(t,r){e.prototype.loadingPlaylist.call(this,t,r);var i=t.id,n=t.groupId,a=this.getUrlWithDirectives(t.url,r),s=t.details,o=null==s?void 0:s.age;this.log("Loading subtitle "+i+' "'+t.name+'" lang:'+t.lang+" group:"+n+(void 0!==(null==r?void 0:r.msn)?" at sn "+r.msn+" part "+r.part:"")+(o&&s.live?" age "+o.toFixed(1)+(s.type&&" "+s.type||""):"")+" "+a),this.hls.trigger(G.SUBTITLE_TRACK_LOADING,{url:a,id:i,groupId:n,deliveryDirectives:r||null,track:t})},r.toggleTrackModes=function(){var e=this;if(this.media&&this.hls.config.renderTextTracksNatively){var t=this.currentTrack;this.tracksInGroup.forEach((function(r){if(r.trackNode){var i=r===t?e._subtitleDisplay?"showing":"hidden":"disabled",n=r.trackNode.track;n.mode!==i&&(n.mode=i)}}))}},r.setSubtitleTrack=function(e,t){void 0===t&&(t=!1);var r=this.tracksInGroup;if(this.media||(this.queuedDefaultTrack=e),e<-1||e>=r.length||!x(e))this.warn("Invalid subtitle track id: "+e);else{var i=this.currentTrack,n=r[e]||null;if(this.trackId=e,this.currentTrack=n,t&&this.toggleTrackModes(),n){var a=!!n.details&&!n.details.live;if(n!==i||!a){this.log("Switching to subtitle-track "+e+(n?' "'+n.name+'" lang:'+n.lang+" group:"+n.groupId:""));var s=n.id,o=n.groupId,l=void 0===o?"":o,u=n.name,d=n.type,h=n.url;this.hls.trigger(G.SUBTITLE_TRACK_SWITCH,{id:s,groupId:l,name:u,type:d,url:h});var f=this.switchParams(n.url,null==i?void 0:i.details,n.details);this.loadPlaylist(f)}}else i&&this.hls.trigger(G.SUBTITLE_TRACK_SWITCH,{id:e})}},a(t,[{key:"subtitleDisplay",get:function(){return this._subtitleDisplay},set:function(e){this._subtitleDisplay=e,this.trackId>-1&&this.toggleTrackModes()}},{key:"allSubtitleTracks",get:function(){return this.tracks}},{key:"subtitleTracks",get:function(){return this.tracksInGroup}},{key:"subtitleTrack",get:function(){return this.trackId},set:function(e){this.selectDefaultTrack=!1,this.setSubtitleTrack(e,!0)}}])}(os),$u={42:225,92:233,94:237,95:243,96:250,123:231,124:247,125:209,126:241,127:9608,128:174,129:176,130:189,131:191,132:8482,133:162,134:163,135:9834,136:224,137:32,138:232,139:226,140:234,141:238,142:244,143:251,144:193,145:201,146:211,147:218,148:220,149:252,150:8216,151:161,152:42,153:8217,154:9473,155:169,156:8480,157:8226,158:8220,159:8221,160:192,161:194,162:199,163:200,164:202,165:203,166:235,167:206,168:207,169:239,170:212,171:217,172:249,173:219,174:171,175:187,176:195,177:227,178:205,179:204,180:236,181:210,182:242,183:213,184:245,185:123,186:125,187:92,188:94,189:95,190:124,191:8764,192:196,193:228,194:214,195:246,196:223,197:165,198:164,199:9475,200:197,201:229,202:216,203:248,204:9487,205:9491,206:9495,207:9499},Zu=function(e){return String.fromCharCode($u[e]||e)},Ju=15,ed=42,td={17:1,18:3,21:5,22:7,23:9,16:11,19:12,20:14},rd={17:2,18:4,21:6,22:8,23:10,19:13,20:15},id={25:1,26:3,29:5,30:7,31:9,24:11,27:12,28:14},nd={25:2,26:4,29:6,30:8,31:10,27:13,28:15},ad=["white","green","blue","cyan","red","yellow","magenta","black","transparent"],sd=function(){function e(){this.time=null,this.verboseLevel=0}return e.prototype.log=function(e,t){if(this.verboseLevel>=e){var r="function"==typeof t?t():t;me.log(this.time+" ["+e+"] "+r)}},e}(),od=function(e){for(var t=[],r=0;r=ed&&(this.logger.log(3,"Too large cursor position "+this.pos),this.pos=41)},t.moveCursor=function(e){var t=Math.min(this.pos+e,ed);if(e>1)for(var r=this.pos+1;r=144&&this.backSpace();var r=Zu(e);this.pos>=ed?this.logger.log(0,(function(){return"Cannot insert "+e.toString(16)+" ("+r+") at position "+t.pos+". Skipping it!"})):(this.chars[this.pos].setChar(r,this.currPenState),this.moveCursor(1))},t.clearFromPos=function(e){var t;for(t=e;t0&&(r=e?"["+t.join(" | ")+"]":t.join("\n")),r},t.getTextAndFormat=function(){return this.rows},e}(),cd=function(){function e(e,t,r){this.chNr=void 0,this.outputFilter=void 0,this.mode=void 0,this.verbose=void 0,this.displayedMemory=void 0,this.nonDisplayedMemory=void 0,this.lastOutputScreen=void 0,this.currRollUpRow=void 0,this.writeScreen=void 0,this.cueStartTime=void 0,this.logger=void 0,this.chNr=e,this.outputFilter=t,this.mode=null,this.verbose=0,this.displayedMemory=new fd(r),this.nonDisplayedMemory=new fd(r),this.lastOutputScreen=new fd(r),this.currRollUpRow=this.displayedMemory.rows[14],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null,this.logger=r}var t=e.prototype;return t.reset=function(){this.mode=null,this.displayedMemory.reset(),this.nonDisplayedMemory.reset(),this.lastOutputScreen.reset(),this.outputFilter.reset(),this.currRollUpRow=this.displayedMemory.rows[14],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null},t.getHandler=function(){return this.outputFilter},t.setHandler=function(e){this.outputFilter=e},t.setPAC=function(e){this.writeScreen.setPAC(e)},t.setBkgData=function(e){this.writeScreen.setBkgData(e)},t.setMode=function(e){e!==this.mode&&(this.mode=e,this.logger.log(2,(function(){return"MODE="+e})),"MODE_POP-ON"===this.mode?this.writeScreen=this.nonDisplayedMemory:(this.writeScreen=this.displayedMemory,this.writeScreen.reset()),"MODE_ROLL-UP"!==this.mode&&(this.displayedMemory.nrRollUpRows=null,this.nonDisplayedMemory.nrRollUpRows=null),this.mode=e)},t.insertChars=function(e){for(var t=this,r=0;r=46,t.italics)t.foreground="white";else{var r=Math.floor(e/2)-16;t.foreground=["white","green","blue","cyan","red","yellow","magenta"][r]}this.logger.log(2,"MIDROW: "+ar(t)),this.writeScreen.setPen(t)},t.outputDataUpdate=function(e){void 0===e&&(e=!1);var t=this.logger.time;null!==t&&this.outputFilter&&(null!==this.cueStartTime||this.displayedMemory.isEmpty()?this.displayedMemory.equals(this.lastOutputScreen)||(this.outputFilter.newCue(this.cueStartTime,t,this.lastOutputScreen),e&&this.outputFilter.dispatchCue&&this.outputFilter.dispatchCue(),this.cueStartTime=this.displayedMemory.isEmpty()?null:t):this.cueStartTime=t,this.lastOutputScreen.copy(this.displayedMemory))},t.cueSplitAtTime=function(e){this.outputFilter&&(this.displayedMemory.isEmpty()||(this.outputFilter.newCue&&this.outputFilter.newCue(this.cueStartTime,e,this.displayedMemory),this.cueStartTime=e))},e}(),gd=function(){function e(e,t,r){this.channels=[null,null,null],this.currentChannel=0,this.cmdHistory={a:null,b:null},this.logger=void 0,this.field=void 0,this.outputFilters=void 0,this.logger=new sd,this.field=e,this.outputFilters=[null,t,r]}var t=e.prototype;return t.getHandler=function(e){return this.outputFilters[e]},t.setHandler=function(e,t){var r;this.outputFilters[e]=t,null==(r=this.channels[e])||r.setHandler(t)},t.getChannel=function(e){var t=this.channels[e];return t||(t=this.channels[e]=new cd(this.field+e-1,this.outputFilters[e],this.logger)),t},t.addData=function(e,t){var r=this;this.logger.time=e;for(var i=function(e){var i=127&t[e],n=127&t[e+1],a=!1,s=null;if(0===i&&0===n)return 0;r.logger.log(3,(function(){return"["+od([t[e],t[e+1]])+"] -> ("+od([i,n])+")"}));var o=r.cmdHistory;if(i>=16&&i<=31){if(function(e,t,r){return r.a===e&&r.b===t}(i,n,o))return vd(null,null,o),r.logger.log(3,(function(){return"Repeated command ("+od([i,n])+") is dropped"})),0;vd(i,n,r.cmdHistory),(a=r.parseCmd(i,n))||(a=r.parseMidrow(i,n)),a||(a=r.parsePAC(i,n)),a||(a=r.parseBackgroundAttributes(i,n))}else vd(null,null,o);if(!a&&(s=r.parseChars(i,n))){var l=r.currentChannel;l&&l>0?r.getChannel(l).insertChars(s):r.logger.log(2,"No channel found yet. TEXT-MODE?")}a||s||r.logger.log(2,(function(){return"Couldn't parse cleaned data "+od([i,n])+" orig: "+od([t[e],t[e+1]])}))},n=0;n=32&&t<=47||(23===e||31===e)&&t>=33&&t<=35))return!1;var r=20===e||21===e||23===e?1:2,i=this.getChannel(r);return 20===e||21===e||28===e||29===e?32===t?i.ccRCL():33===t?i.ccBS():34===t?i.ccAOF():35===t?i.ccAON():36===t?i.ccDER():37===t?i.ccRU(2):38===t?i.ccRU(3):39===t?i.ccRU(4):40===t?i.ccFON():41===t?i.ccRDC():42===t?i.ccTR():43===t?i.ccRTD():44===t?i.ccEDM():45===t?i.ccCR():46===t?i.ccENM():47===t&&i.ccEOC():i.ccTO(t-32),this.currentChannel=r,!0},t.parseMidrow=function(e,t){var r=0;if((17===e||25===e)&&t>=32&&t<=47){if((r=17===e?1:2)!==this.currentChannel)return this.logger.log(0,"Mismatch channel in midrow parsing"),!1;var i=this.channels[r];return!!i&&(i.ccMIDROW(t),this.logger.log(3,(function(){return"MIDROW ("+od([e,t])+")"})),!0)}return!1},t.parsePAC=function(e,t){var r;if(!((e>=17&&e<=23||e>=25&&e<=31)&&t>=64&&t<=127||(16===e||24===e)&&t>=64&&t<=95))return!1;var i=e<=23?1:2;return r=t>=64&&t<=95?1===i?td[e]:id[e]:1===i?rd[e]:nd[e],this.getChannel(i).setPAC(this.interpretPAC(r,t)),this.currentChannel=i,!0},t.interpretPAC=function(e,t){var r,i={color:null,italics:!1,indent:null,underline:!1,row:e};return r=t>95?t-96:t-64,i.underline=1==(1&r),r<=13?i.color=["white","green","blue","cyan","red","yellow","magenta","white"][Math.floor(r/2)]:r<=15?(i.italics=!0,i.color="white"):i.indent=4*Math.floor((r-16)/2),i},t.parseChars=function(e,t){var r,i,n=null,a=null;return e>=25?(r=2,a=e-8):(r=1,a=e),a>=17&&a<=19?(i=17===a?t+80:18===a?t+112:t+144,this.logger.log(2,(function(){return"Special char '"+Zu(i)+"' in channel "+r})),n=[i]):e>=32&&e<=127&&(n=0===t?[e]:[e,t]),n&&this.logger.log(3,(function(){return"Char codes =  "+od(n).join(",")})),n},t.parseBackgroundAttributes=function(e,t){var r;if(!((16===e||24===e)&&t>=32&&t<=47||(23===e||31===e)&&t>=45&&t<=47))return!1;var i={};16===e||24===e?(r=Math.floor((t-32)/2),i.background=ad[r],t%2==1&&(i.background=i.background+"_semi")):45===t?i.background="transparent":(i.foreground="black",47===t&&(i.underline=!0));var n=e<=23?1:2;return this.getChannel(n).setBkgData(i),!0},t.reset=function(){for(var e=0;ee)&&(this.startTime=e),this.endTime=t,this.screen=r,this.timelineController.createCaptionsTrack(this.trackName)},t.reset=function(){this.cueRanges=[],this.startTime=null},e}(),pd=function(){function e(e){this.hls=void 0,this.media=null,this.config=void 0,this.enabled=!0,this.Cues=void 0,this.tracks=[],this.initPTS=[],this.unparsedVttFrags=[],this.captionsTracks={},this.cueCache={},this.nonNativeCaptionsTracks={},this.cea608Parser1=void 0,this.cea608Parser2=void 0,this.lastCc=-1,this.lastSn=-1,this.lastPartIndex=-1,this.prevCC=-1,this.vttCCs={ccOffset:0,presentationOffset:0,0:{start:0,prevCC:-1,new:!0}},this.captionsProperties=void 0,this.hls=e,this.config=e.config,this.Cues=e.config.cueHandler,this.cueCache={},this.captionsProperties={textTrack1:{label:this.config.captionsTextTrack1Label,languageCode:this.config.captionsTextTrack1LanguageCode},textTrack2:{label:this.config.captionsTextTrack2Label,languageCode:this.config.captionsTextTrack2LanguageCode},textTrack3:{label:this.config.captionsTextTrack3Label,languageCode:this.config.captionsTextTrack3LanguageCode},textTrack4:{label:this.config.captionsTextTrack4Label,languageCode:this.config.captionsTextTrack4LanguageCode}},e.on(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.MANIFEST_LOADED,this.onManifestLoaded,this),e.on(G.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),e.on(G.FRAG_LOADING,this.onFragLoading,this),e.on(G.FRAG_LOADED,this.onFragLoaded,this),e.on(G.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),e.on(G.FRAG_DECRYPTED,this.onFragDecrypted,this),e.on(G.INIT_PTS_FOUND,this.onInitPtsFound,this),e.on(G.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),e.on(G.BUFFER_APPENDED,this.onBufferAppended,this),e.on(G.BUFFER_FLUSHING,this.onBufferFlushing,this)}var t=e.prototype;return t.destroy=function(){var e=this.hls;e.off(G.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(G.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.MANIFEST_LOADED,this.onManifestLoaded,this),e.off(G.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),e.off(G.FRAG_LOADING,this.onFragLoading,this),e.off(G.FRAG_LOADED,this.onFragLoaded,this),e.off(G.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),e.off(G.FRAG_DECRYPTED,this.onFragDecrypted,this),e.off(G.INIT_PTS_FOUND,this.onInitPtsFound,this),e.off(G.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),e.off(G.BUFFER_APPENDED,this.onBufferAppended,this),e.off(G.BUFFER_FLUSHING,this.onBufferFlushing,this),this.hls=this.config=this.media=null,this.cea608Parser1=this.cea608Parser2=void 0},t.initCea608Parsers=function(){var e=new md(this,"textTrack1"),t=new md(this,"textTrack2"),r=new md(this,"textTrack3"),i=new md(this,"textTrack4");this.cea608Parser1=new gd(1,e,t),this.cea608Parser2=new gd(3,r,i)},t.addCues=function(e,t,r,i,n){for(var a,s,o,l,u=!1,d=n.length;d--;){var h=n[d],f=(a=h[0],s=h[1],o=t,l=r,Math.min(s,l)-Math.max(a,o));if(f>=0&&(h[0]=Math.min(h[0],t),h[1]=Math.max(h[1],r),u=!0,f/(r-t)>.5))return}if(u||n.push([t,r]),this.config.renderTextTracksNatively){var c=this.cueCache[e];if(c){var g=this.Cues.newCue(null,t,r,i);g.length&&c.push.apply(c,g)}else{var v=this.captionsTracks[e].track;this.Cues.newCue(v,t,r,i)}}else{var m,p=this.Cues.newCue(null,t,r,i),y=null==(m=this.captionsProperties[e])?void 0:m.media;this.hls.trigger(G.CUES_PARSED,{type:"captions",cues:p,track:e,closedCaptions:y})}},t.onInitPtsFound=function(e,t){var r=this,i=t.id,n=t.timestampOffsets,a=this.unparsedVttFrags;i===H.MAIN&&(this.initPTS=n),a.length&&(this.unparsedVttFrags=[],a.forEach((function(e){r.initPTS[e.frag.cc]?r.onFragLoaded(G.FRAG_LOADED,e):r.hls.trigger(G.SUBTITLE_FRAG_PROCESSED,{success:!1,frag:e.frag,part:null,error:new Error("Subtitle discontinuity domain does not match main")})})))},t.createCaptionsTrack=function(e){this.config.renderTextTracksNatively?this.createNativeTrack(e):this.createNonNativeTrack(e)},t.createNativeTrack=function(e){if(!this.captionsTracks[e]){var t=this.captionsProperties,r=this.captionsTracks,i=this.cueCache,n=this.media,a=t[e],s=a.label,o=a.languageCode;n&&(i[e]=[],r[e]=Nl(n,"captions",s,o,"hidden"))}},t.createNonNativeTrack=function(e){if(!this.nonNativeCaptionsTracks[e]){var t=this.captionsProperties[e];if(t){var r={_id:e,label:t.label,kind:"captions",default:!!t.media&&!!t.media.default,closedCaptions:t.media};this.nonNativeCaptionsTracks[e]=r,this.hls.trigger(G.NON_NATIVE_TEXT_TRACKS_FOUND,{tracks:[r]})}}},t.onMediaAttaching=function(e,t){this.media=t.media},t.onMediaDetaching=function(e,t){var r=!!t.transferMedia;if(this.media=null,!r){if(this.config.renderTextTracksNatively){var i=this.captionsTracks,n=this.cueCache;for(var a in i)i[a].remove(),delete n[a]}this.cueCache={},this.captionsTracks={},this.nonNativeCaptionsTracks={}}},t.onManifestLoading=function(){this.lastCc=-1,this.lastSn=-1,this.lastPartIndex=-1,this.prevCC=-1,this.vttCCs={ccOffset:0,presentationOffset:0,0:{start:0,prevCC:-1,new:!0}},this.tracks=[],this.captionsTracks={},this.nonNativeCaptionsTracks={},this.unparsedVttFrags=[],this.initPTS=[],this.cea608Parser1&&this.cea608Parser2&&(this.cea608Parser1.reset(),this.cea608Parser2.reset())},t.onSubtitleTracksUpdated=function(e,t){var r=t.subtitleTracks||[];if(r.length&&!this.config.renderTextTracksNatively&&!ls(this.tracks,r)){var i=r.map((function(e){return{label:e.name,kind:e.type.toLowerCase(),default:e.default,subtitleTrack:e}}));this.hls.trigger(G.NON_NATIVE_TEXT_TRACKS_FOUND,{tracks:i})}this.tracks=r},t.onManifestLoaded=function(e,t){var r=this;this.config.enableCEA708Captions&&t.captions&&t.captions.forEach((function(e){var t=/(?:CC|SERVICE)([1-4])/.exec(e.instreamId);if(t){var i="textTrack"+t[1],n=r.captionsProperties[i];n&&(n.label=e.name,e.lang&&(n.languageCode=e.lang),n.media=e)}}))},t.closedCaptionsForLevel=function(e){var t=this.hls.levels[e.level];return null==t?void 0:t.attrs["CLOSED-CAPTIONS"]},t.onFragLoading=function(e,t){if(this.enabled&&t.frag.type===H.MAIN){var r,i,n=this.cea608Parser1,a=this.cea608Parser2,s=this.lastSn,o=t.frag,l=o.cc,u=o.sn,d=null!=(r=null==(i=t.part)?void 0:i.index)?r:-1;n&&a&&(u!==s+1||u===s&&d!==this.lastPartIndex+1||l!==this.lastCc)&&(n.reset(),a.reset()),this.lastCc=l,this.lastSn=u,this.lastPartIndex=d}},t.onFragLoaded=function(e,t){var r,i=t.frag,n=t.payload;if(i.level=r&&a.endTime<=i&&t.splice(n,1)}}))}this.config.renderTextTracksNatively&&0===r&&void 0!==n&&this.tracks.forEach((function(e){var t,i=null==(t=e.trackNode)?void 0:t.track;i&&Gl(i,r,n)}))}},t.extractCea608Data=function(e){for(var t=[[],[]],r=31&e[0],i=2,n=0;n=16?o--:o++;var g=Ou(l.trim()),v=Nu(t,r,g);null!=e&&null!=(f=e.cues)&&f.getCueById(v)||((a=new d(t,r,g)).id=v,a.line=h+1,a.align="left",a.position=10+Math.min(80,10*Math.floor(8*o/32)),u.push(a))}return e&&u.length&&(u.sort((function(e,t){return"auto"===e.line||"auto"===t.line?0:e.line>8&&t.line>8?t.line-e.line:e.line-t.line})),u.forEach((function(t){return Bl(e,t)}))),u}},Td=function(){function e(){this.requestTimeout=void 0,this.retryTimeout=void 0,this.retryDelay=0,this.config=null,this.callbacks=null,this.context=null,this.stats=new K}var t=e.prototype;return t.destroy=function(){this.callbacks=this.context=this.config=null,this.abortInternal(),this.stats=null},t.load=function(e,t,r){var i=this.stats;if(i.loading.start)throw new Error("Loader can only be used once.");i.loading.start=self.performance.now(),this.context=e,this.config=t,this.callbacks=r,this.loadInternal()},t.abort=function(){var e;this.abortInternal(),null!=(e=this.callbacks)&&e.onAbort&&this.callbacks.onAbort(this.stats,this.context,this.getNetworkDetails())},t.loadtimeout=function(){if(this.config){var e=this.config.loadPolicy.timeoutRetry;if(ne(e,this.stats.retry,!0))this.retry(e);else{var t;me.warn("timeout while loading "+(null==(t=this.context)?void 0:t.url));var r=this.callbacks;r&&(this.abortInternal(),r.onTimeout(this.stats,this.context,this.getNetworkDetails()))}}},t.retry=function(e){var t=this,r=this.context,i=this.stats;this.retryDelay=re(e,i.retry),i.retry++,me.warn((status?"HTTP Status "+status:"Timeout")+" while loading "+(null==r?void 0:r.url)+", retrying "+i.retry+"/"+e.maxNumRetry+" in "+this.retryDelay+"ms"),this.abortInternal(),this.resetInternalLoader(),self.clearTimeout(this.retryTimeout),this.retryTimeout=self.setTimeout((function(){return t.loadInternal()}),this.retryDelay)},e}(),Sd=/(\d+)-(\d+)\/(\d+)/,Ad=function(e){function t(t){var r;return(r=e.call(this)||this).fetchSetup=void 0,r.request=null,r.response=null,r.controller=null,r.fetchSetup=t.fetchSetup||Id,r}d(t,e);var r=t.prototype;return r.destroy=function(){this.request=null,e.prototype.destroy.call(this),this.response=null,this.controller=null,this.fetchSetup=null},r.abortInternal=function(){self.clearTimeout(this.retryTimeout),this.controller&&!this.stats.loading.end&&(this.stats.aborted=!0,this.controller.abort())},r.getNetworkDetails=function(){return this.response},r.resetInternalLoader=function(){this.response=null},r.loadInternal=function(){var e=this,t=this.config,r=this.context,i=this.stats;if(t&&r){i.loading.first=0,i.loaded=0,i.aborted=!1,this.controller=new self.AbortController;var n=function(e,t){var r={method:"GET",mode:"cors",credentials:"same-origin",signal:t,headers:new self.Headers(l({},e.headers))};return e.rangeEnd&&r.headers.set("Range","bytes="+e.rangeStart+"-"+String(e.rangeEnd-1)),r}(r,this.controller.signal),a="arraybuffer"===r.responseType,s=a?"byteLength":"length",o=t.loadPolicy,u=o.maxTimeToFirstByteMs,d=o.maxLoadTimeMs;this.request=this.fetchSetup(r,n),self.clearTimeout(this.requestTimeout),t.timeout=u&&x(u)?u:d,this.requestTimeout=self.setTimeout((function(){e.loadtimeout()}),t.timeout),(Xa(this.request)?this.request.then(self.fetch):self.fetch(this.request)).then((function(n){var s;e.response=n;var o=Math.max(self.performance.now(),i.loading.start);if(self.clearTimeout(e.requestTimeout),t.timeout=d,e.requestTimeout=self.setTimeout((function(){e.loadtimeout()}),d-(o-i.loading.start)),!n.ok){var l=n.status,u=n.statusText;throw new Ld(u||"fetch, bad network response",l,n)}i.loading.first=o,i.total=function(e){var t=e.get("Content-Range");if(t){var r=function(e){var t=Sd.exec(e);if(t)return parseInt(t[2])-parseInt(t[1])+1}(t);if(x(r))return r}var i=e.get("Content-Length");if(i)return parseInt(i)}(n.headers)||i.total;var h=null==(s=e.callbacks)?void 0:s.onProgress;return h&&x(t.highWaterMark)?e.loadProgressively(n,i,r,t.highWaterMark,h):a?n.arrayBuffer():"json"===r.responseType?n.json():n.text()})).then((function(n){var a,o,l=e.response;if(!l)throw new Error("loader destroyed");self.clearTimeout(e.requestTimeout),i.loading.end=Math.max(self.performance.now(),i.loading.first);var u=n[s];u&&(i.loaded=i.total=u);var d={url:l.url,data:n,code:l.status},h=null==(a=e.callbacks)?void 0:a.onProgress;h&&!x(t.highWaterMark)&&h(i,r,n,l),null==(o=e.callbacks)||o.onSuccess(d,i,r,l)})).catch((function(n){if(self.clearTimeout(e.requestTimeout),!i.aborted){var a,s=n&&n.code||0,o=n?n.message:null,l=t.loadPolicy.errorRetry;ne(l,i.retry,!1,{url:r.url,data:void 0,code:s})?e.retry(l):(me.error(s+" while loading "+r.url),null==(a=e.callbacks)||a.onError({code:s,text:o},r,n?n.details:null,i))}}))}},r.getCacheAge=function(){var e=null;if(this.response){var t=this.response.headers.get("age");e=t?parseFloat(t):null}return e},r.getResponseHeader=function(e){return this.response?this.response.headers.get(e):null},r.loadProgressively=function(e,t,r,i,n){void 0===i&&(i=0);var a=new Pn,s=e.body.getReader(),o=function(){return s.read().then((function(s){if(s.done)return a.dataLength&&n(t,r,a.flush().buffer,e),Promise.resolve(new ArrayBuffer(0));var l=s.value,u=l.length;return t.loaded+=u,u=i&&n(t,r,a.flush().buffer,e)):n(t,r,l.buffer,e),o()})).catch((function(){return Promise.reject()}))};return o()},t}(Td);function Id(e,t){return new self.Request(e.url,t)}var Ld=function(e){function t(t,r,i){var n;return(n=e.call(this,t)||this).code=void 0,n.details=void 0,n.code=r,n.details=i,n}return d(t,e),t}(E(Error)),Rd=/^age:\s*[\d.]+\s*$/im,bd=function(e){function t(t){var r;return(r=e.call(this)||this).xhrSetup=void 0,r.loader=null,r.xhrSetup=t.xhrSetup||null,r}d(t,e);var r=t.prototype;return r.destroy=function(){e.prototype.destroy.call(this),this.loader=null,this.xhrSetup=null},r.abortInternal=function(){var e=this.loader;self.clearTimeout(this.requestTimeout),self.clearTimeout(this.retryTimeout),e&&(e.onreadystatechange=null,e.onprogress=null,4!==e.readyState&&(this.stats.aborted=!0,e.abort()))},r.getNetworkDetails=function(){return this.loader},r.resetInternalLoader=function(){this.loader=null},r.loadInternal=function(){var e=this,t=this.config,r=this.context;if(t&&r){var i=this.loader=new self.XMLHttpRequest,n=this.stats;n.loading.first=0,n.loaded=0,n.aborted=!1;var a=this.xhrSetup;a?Promise.resolve().then((function(){if(e.loader===i&&!e.stats.aborted)return a.call(e,i,r.url,r)})).catch((function(t){if(e.loader===i&&!e.stats.aborted)return i.open("GET",r.url,!0),a.call(e,i,r.url,r)})).then((function(){e.loader!==i||e.stats.aborted||e.openAndSendXhr(i,r,t)})).catch((function(t){var a;null==(a=e.callbacks)||a.onError({code:i.status,text:t.message},r,i,n)})):this.openAndSendXhr(i,r,t)}},r.openAndSendXhr=function(e,t,r){e.readyState||e.open("GET",t.url,!0);var i=t.headers,n=r.loadPolicy,a=n.maxTimeToFirstByteMs,s=n.maxLoadTimeMs;if(i)for(var o in i)e.setRequestHeader(o,i[o]);t.rangeEnd&&e.setRequestHeader("Range","bytes="+t.rangeStart+"-"+(t.rangeEnd-1)),e.onreadystatechange=this.readystatechange.bind(this),e.onprogress=this.loadprogress.bind(this),e.responseType=t.responseType,self.clearTimeout(this.requestTimeout),r.timeout=a&&x(a)?a:s,this.requestTimeout=self.setTimeout(this.loadtimeout.bind(this),r.timeout),e.send()},r.readystatechange=function(){var e=this.context,t=this.loader,r=this.stats;if(e&&t){var i=t.readyState,n=this.config;if(!r.aborted&&i>=2&&(0===r.loading.first&&(r.loading.first=Math.max(self.performance.now(),r.loading.start),n.timeout!==n.loadPolicy.maxLoadTimeMs&&(self.clearTimeout(this.requestTimeout),n.timeout=n.loadPolicy.maxLoadTimeMs,this.requestTimeout=self.setTimeout(this.loadtimeout.bind(this),n.loadPolicy.maxLoadTimeMs-(r.loading.first-r.loading.start)))),4===i)){self.clearTimeout(this.requestTimeout),t.onreadystatechange=null,t.onprogress=null;var a=t.status,s="text"===t.responseType?t.responseText:null;if(a>=200&&a<300){var o=null!=s?s:t.response;if(null!=o){var l,u;r.loading.end=Math.max(self.performance.now(),r.loading.first);var d="arraybuffer"===t.responseType?o.byteLength:o.length;r.loaded=r.total=d,r.bwEstimate=8e3*r.total/(r.loading.end-r.loading.first);var h=null==(l=this.callbacks)?void 0:l.onProgress;h&&h(r,e,o,t);var f={url:t.responseURL,data:o,code:a};return e.rangeEnd&&d!==e.rangeEnd-e.rangeStart&&me.warn("Payload length "+d+" does not match requested Range: bytes="+e.rangeStart+"-"+(e.rangeEnd-1)),void(null==(u=this.callbacks)||u.onSuccess(f,r,e,t))}}var c,g=n.loadPolicy.errorRetry;ne(g,r.retry,!1,{url:e.url,data:void 0,code:a})?this.retry(g):(me.error(a+" while loading "+e.url),null==(c=this.callbacks)||c.onError({code:a,text:t.statusText},e,t,r))}}},r.loadprogress=function(e){var t=this.stats;t.loaded=e.loaded,e.lengthComputable&&(t.total=e.total)},r.getCacheAge=function(){var e=null;if(this.loader&&Rd.test(this.loader.getAllResponseHeaders())){var t=this.loader.getResponseHeader("age");e=t?parseFloat(t):null}return e},r.getResponseHeader=function(e){return this.loader&&new RegExp("^"+e+":\\s*[\\d.]+\\s*$","im").test(this.loader.getAllResponseHeaders())?this.loader.getResponseHeader(e):null},t}(Td),kd=c(c({autoStartLoad:!0,startPosition:-1,defaultAudioCodec:void 0,debug:!1,capLevelOnFPSDrop:!1,capLevelToPlayerSize:!1,ignoreDevicePixelRatio:!1,maxDevicePixelRatio:Number.POSITIVE_INFINITY,preferManagedMediaSource:!1,initialLiveManifestSize:1,maxBufferLength:30,backBufferLength:1/0,frontBufferFlushThreshold:1/0,loopBackBufferFlush:void 0,startOnSegmentBoundary:!1,nextAudioTrackBufferFlushForwardOffset:.25,maxBufferSize:6e7,maxFragLookUpTolerance:.25,maxBufferHole:.1,detectStallWithCurrentTimeMs:1250,highBufferWatchdogPeriod:2,nudgeOffset:.1,nudgeMaxRetry:3,nudgeOnVideoHole:!0,skipBufferHolePadding:.1,liveSyncMode:"edge",liveSyncDurationCount:3,liveSyncOnStallIncrease:1,liveMaxLatencyDurationCount:1/0,liveMaxUnchangedPlaylistRefresh:1/0,liveSyncDuration:void 0,liveMaxLatencyDuration:void 0,maxLiveSyncPlaybackRate:1,liveDurationInfinity:!1,liveBackBufferLength:null,maxMaxBufferLength:600,enableWorker:!0,workerPath:null,enableSoftwareAES:!0,startLevel:void 0,startFragPrefetch:!1,fpsDroppedMonitoringPeriod:5e3,fpsDroppedMonitoringThreshold:.2,appendErrorMaxRetry:3,appendTimeout:1/0,ignorePlaylistParsingErrors:!1,loader:bd,fLoader:void 0,pLoader:void 0,xhrSetup:void 0,licenseXhrSetup:void 0,licenseResponseCallback:void 0,stretchShortVideoTrack:!1,maxAudioFramesDrift:1,forceKeyFrameOnDiscontinuity:!0,handleMpegTsVideoIntegrityErrors:"process",abrEwmaFastLive:3,abrEwmaSlowLive:9,abrEwmaFastVoD:3,abrEwmaSlowVoD:9,abrEwmaDefaultEstimate:5e5,abrEwmaDefaultEstimateMax:5e6,abrBandWidthFactor:.95,abrBandWidthUpFactor:.7,abrMaxWithRealBitrate:!1,abrSwitchInterval:0,maxStarvationDelay:4,maxLoadingDelay:4,minAutoBitrate:0,emeEnabled:!1,widevineLicenseUrl:void 0,drmSystems:{},drmSystemOptions:{},requestMediaKeySystemAccessFunc:Fi,requireKeySystemAccessOnStart:!1,testBandwidth:!0,progressive:!1,lowLatencyMode:!0,cmcd:void 0,enableDateRangeMetadataCues:!0,enableEmsgMetadataCues:!0,enableEmsgKLVMetadata:!1,enableID3MetadataCues:!0,emsgKLVSchemaUri:void 0,enableInterstitialPlayback:!0,interstitialAppendInPlace:!0,interstitialLiveLookAhead:10,iframeCacheLimit:2097152,useMediaCapabilities:!0,preserveManualLevelOnError:!1,errorPenaltyExpireMs:0,certLoadPolicy:{default:{maxTimeToFirstByteMs:8e3,maxLoadTimeMs:2e4,timeoutRetry:null,errorRetry:null}},keyLoadPolicy:{default:{maxTimeToFirstByteMs:8e3,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:1,retryDelayMs:1e3,maxRetryDelayMs:2e4,backoff:"linear"},errorRetry:{maxNumRetry:8,retryDelayMs:1e3,maxRetryDelayMs:2e4,backoff:"linear"}}},manifestLoadPolicy:{default:{maxTimeToFirstByteMs:1/0,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:2,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:1,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},playlistLoadPolicy:{default:{maxTimeToFirstByteMs:1e4,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:2,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:2,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},fragLoadPolicy:{default:{maxTimeToFirstByteMs:1e4,maxLoadTimeMs:12e4,timeoutRetry:{maxNumRetry:4,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:6,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},steeringManifestLoadPolicy:{default:{maxTimeToFirstByteMs:1e4,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:2,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:1,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},interstitialAssetListLoadPolicy:{default:{maxTimeToFirstByteMs:1e4,maxLoadTimeMs:3e4,timeoutRetry:{maxNumRetry:0,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:0,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},manifestLoadingTimeOut:1e4,manifestLoadingMaxRetry:1,manifestLoadingRetryDelay:1e3,manifestLoadingMaxRetryTimeout:64e3,levelLoadingTimeOut:1e4,levelLoadingMaxRetry:4,levelLoadingRetryDelay:1e3,levelLoadingMaxRetryTimeout:64e3,fragLoadingTimeOut:2e4,fragLoadingMaxRetry:6,fragLoadingRetryDelay:1e3,fragLoadingMaxRetryTimeout:64e3,streamController:Zl,abrController:mr,bufferController:vs,capLevelController:As,errorController:Or,fpsController:Cl,id3TrackController:Yl,gapController:wl,latencyController:Au},{cueHandler:Ed,enableWebVTT:!0,enableIMSC1:!0,enableCEA708Captions:!0,captionsTextTrack1Label:"English",captionsTextTrack1LanguageCode:"en",captionsTextTrack2Label:"Spanish",captionsTextTrack2LanguageCode:"es",captionsTextTrack3Label:"Unknown CC",captionsTextTrack3LanguageCode:"",captionsTextTrack4Label:"Unknown CC",captionsTextTrack4LanguageCode:"",renderTextTracksNatively:!0}),{},{subtitleStreamController:Iu,subtitleTrackController:Qu,timelineController:pd,audioStreamController:ss,audioTrackController:ds,emeController:Dl,cmcdController:Al,contentSteeringController:Il,iframeController:iu,interstitialsController:Su});function Dd(e,t,r){if((t.liveSyncDurationCount||t.liveMaxLatencyDurationCount)&&(t.liveSyncDuration||t.liveMaxLatencyDuration))throw new Error("Illegal hls.js config: don't mix up liveSyncDurationCount/liveMaxLatencyDurationCount and liveSyncDuration/liveMaxLatencyDuration");if(void 0!==t.liveMaxLatencyDurationCount&&(void 0===t.liveSyncDurationCount||t.liveMaxLatencyDurationCount<=t.liveSyncDurationCount))throw new Error('Illegal hls.js config: "liveMaxLatencyDurationCount" must be greater than "liveSyncDurationCount"');if(void 0!==t.liveMaxLatencyDuration&&(void 0===t.liveSyncDuration||t.liveMaxLatencyDuration<=t.liveSyncDuration))throw new Error('Illegal hls.js config: "liveMaxLatencyDuration" must be greater than "liveSyncDuration"');if(void 0!==t.liveMaxUnchangedPlaylistRefresh){var i=t.liveMaxUnchangedPlaylistRefresh,n=(a=i,s=2,o=1/0,Math.max(s,Math.min(a,o)));n!==i&&r.warn('hls.js config: "liveMaxUnchangedPlaylistRefresh" clamped from '+i+" to "+n+"."),t.liveMaxUnchangedPlaylistRefresh=n}var a,s,o,l=Pd(e),u=["TimeOut","MaxRetry","RetryDelay","MaxRetryTimeout"];return["manifest","level","frag"].forEach((function(e){var i=("level"===e?"playlist":e)+"LoadPolicy",n=void 0===t[i],a=[];u.forEach((function(r){var s=e+"Loading"+r,o=t[s];if(void 0!==o&&n){a.push(s);var u=l[i].default;switch(t[i]={default:u},r){case"TimeOut":u.maxLoadTimeMs=o,u.maxTimeToFirstByteMs=o;break;case"MaxRetry":u.errorRetry.maxNumRetry=o,u.timeoutRetry.maxNumRetry=o;break;case"RetryDelay":u.errorRetry.retryDelayMs=o,u.timeoutRetry.retryDelayMs=o;break;case"MaxRetryTimeout":u.errorRetry.maxRetryDelayMs=o,u.timeoutRetry.maxRetryDelayMs=o}}})),a.length&&r.warn('hls.js config: "'+a.join('", "')+'" setting(s) are deprecated, use "'+i+'": '+ar(t[i]))})),c(c({},l),t)}function Pd(e){return e&&"object"==typeof e?Array.isArray(e)?e.map(Pd):Object.keys(e).reduce((function(t,r){return t[r]=Pd(e[r]),t}),{}):e}function _d(e,t){var r=e.loader;r!==Ad&&r!==bd?(t.log("[config]: Custom loader detected, cannot enable progressive streaming"),e.progressive=!1):function(){if(self.fetch&&self.AbortController&&self.ReadableStream&&self.Request)try{return new self.ReadableStream({}),!0}catch(e){}return!1}()&&(e.loader=Ad,e.progressive=!0,e.enableSoftwareAES=!0,t.log("[config]: Progressive streaming enabled, using FetchLoader"))}var Cd=function(e){function t(t,r){var i;return(i=e.call(this,t,"level-controller")||this)._levels=[],i._firstLevel=-1,i._maxAutoLevel=-1,i._startLevel=void 0,i.currentLevel=null,i.currentLevelIndex=-1,i.manualLevelIndex=-1,i.steering=void 0,i.lastABRSwitchTime=-1,i._iframeVariants=[],i.onParsedComplete=void 0,i.steering=r,i._registerListeners(),i}d(t,e);var r=t.prototype;return r._registerListeners=function(){var e=this.hls;e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.MANIFEST_LOADED,this.onManifestLoaded,this),e.on(G.LEVEL_LOADED,this.onLevelLoaded,this),e.on(G.LEVELS_UPDATED,this.onLevelsUpdated,this),e.on(G.FRAG_BUFFERED,this.onFragBuffered,this),e.on(G.ERROR,this.onError,this)},r._unregisterListeners=function(){var e=this.hls;e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.MANIFEST_LOADED,this.onManifestLoaded,this),e.off(G.LEVEL_LOADED,this.onLevelLoaded,this),e.off(G.LEVELS_UPDATED,this.onLevelsUpdated,this),e.off(G.FRAG_BUFFERED,this.onFragBuffered,this),e.off(G.ERROR,this.onError,this)},r.destroy=function(){this._unregisterListeners(),this.steering=null,this.resetLevels(),e.prototype.destroy.call(this)},r.stopLoad=function(){this._levels.forEach((function(e){e.loadError=0,e.fragmentError=0})),e.prototype.stopLoad.call(this)},r.resetLevels=function(){this._startLevel=void 0,this.manualLevelIndex=-1,this.currentLevelIndex=-1,this.currentLevel=null,this._levels=[],this._maxAutoLevel=-1,this._iframeVariants=[]},r.onManifestLoading=function(e,t){this.resetLevels()},r.onManifestLoaded=function(e,t){var r=this,i=this.hls.config.preferManagedMediaSource,n=[],a={},s={},o=!1,l=!1,u=!1,d=!1;t.levels.forEach((function(e){var t=e.attrs,h=Md(e,i),f=e.audioCodec,c=e.videoCodec,g=e.imageCodec,v=e.width,m=e.height;if(h){o||(o=!(!v||!m)),l||(l=!!c),u||(u=!!f),d||(d=!!g);var p=t.CODECS,y=t["FRAME-RATE"],E=t["HDCP-LEVEL"],T=t["PATHWAY-ID"],S=t.RESOLUTION,A=t["VIDEO-RANGE"],I=(T||".")+"-"+e.bitrate+"-"+S+"-"+y+"-"+p+"-"+A+"-"+E;if(a[I])if(a[I].uri===e.url||e.attrs["PATHWAY-ID"])e.iframes||(a[I].addGroupId("audio",t.AUDIO),a[I].addGroupId("text",t.SUBTITLES));else{var L=s[I]+=1;e.attrs["PATHWAY-ID"]=new Array(L+1).join(".");var R=r.createLevel(e);a[I]=R,n.push(R)}else{var b=r.createLevel(e);a[I]=b,s[I]=1,n.push(b)}}else r.log('Some or all CODECS not supported "'+t.CODECS+'"')})),this.filterAndSortMediaOptions(n,t,o,l,u,d)},r.createLevel=function(e){var t=new rr(e),r=e.supplemental;if(null!=r&&r.videoCodec&&!Od(r.videoCodec,this.hls.config.preferManagedMediaSource)){var i=new Error('SUPPLEMENTAL-CODECS not supported "'+r.videoCodec+'"');this.log(i.message),t.supportedResult=Yt(i,[])}return t},r.filterAndSortMediaOptions=function(e,t,r,i,n,a){var s,o=this,l=[],u=[],d=e,h=(null==(s=t.stats)?void 0:s.parsing)||{},f=this.hls.config.preferManagedMediaSource;if((r||i)&&n&&(d=d.filter((function(e){var t,r=e.videoCodec,i=e.videoRange,n=e.width,a=e.height;return(!!r||!(!n||!a))&&!!(t=i)&&Qt.indexOf(t)>-1}))),i&&a&&(d=d.filter((function(e){return!e.imageCodec}))),0===d.length)return Promise.resolve().then((function(){if(o.hls){var e="no level with compatible codecs found in manifest",r=e;t.levels.length&&(r="one or more CODECS in variant not supported: "+ar(t.levels.map((function(e){return e.attrs.CODECS})).filter((function(e,t,r){return r.indexOf(e)===t}))),o.warn(r),e+=" ("+r+")");var i=new Error(e);o.hls.trigger(G.ERROR,{type:U.MEDIA_ERROR,details:B.MANIFEST_INCOMPATIBLE_CODECS_ERROR,fatal:!0,url:t.url,error:i,reason:r})}})),void(h.end=performance.now());t.audioTracks&&xd(l=t.audioTracks.filter((function(e){return!e.audioCodec||wd(e.audioCodec,f)}))),t.subtitles&&xd(u=t.subtitles);var c=d.slice(0);d.sort((function(e,t){if(e.attrs["HDCP-LEVEL"]!==t.attrs["HDCP-LEVEL"])return(e.attrs["HDCP-LEVEL"]||"")>(t.attrs["HDCP-LEVEL"]||"")?1:-1;if(r&&e.height!==t.height)return e.height-t.height;if(e.frameRate!==t.frameRate)return e.frameRate-t.frameRate;if(e.videoRange!==t.videoRange)return Qt.indexOf(e.videoRange)-Qt.indexOf(t.videoRange);if(e.videoCodec!==t.videoCodec){var i=wt(e.videoCodec),n=wt(t.videoCodec);if(i!==n)return n-i}if(e.uri===t.uri&&e.codecSet!==t.codecSet){var a=Ot(e.codecSet),s=Ot(t.codecSet);if(a!==s)return s-a}return e.averageBitrate!==t.averageBitrate?e.averageBitrate-t.averageBitrate:0}));var g=c[0];if(this.steering&&(d=this.steering.filterParsedLevels(d)).length!==c.length)for(var v=0;vE&&E===this.hls.abrEwmaDefaultEstimate&&(this.hls.bandwidthEstimate=T)}break}var S=t.iframeVariants.filter((function(e){return Md(e,f)}));this._iframeVariants=S;var A=n&&!i,I=this.hls.config,L=!(!I.audioStreamController||!I.audioTrackController),R={levels:d,audioTracks:l,subtitleTracks:u,iframeVariants:S,sessionData:t.sessionData,sessionKeys:t.sessionKeys,firstLevel:this._firstLevel,stats:t.stats,audio:n,video:i,altAudio:L&&!A&&l.some((function(e){return!!e.url}))};h.end=performance.now(),this.hls.trigger(G.MANIFEST_PARSED,R)},r.onError=function(e,t){!t.fatal&&t.context&&t.context.type===V.LEVEL&&t.context.level===this.level&&this.checkRetry(t)},r.onFragBuffered=function(e,t){var r=t.frag;if(r.type===H.MAIN){var i=r.elementaryStreams;if(!Object.keys(i).some((function(e){return!!i[e]})))return;var n=this._levels[r.level];null!=n&&n.loadError&&(this.log("Resetting level error count of "+n.loadError+" on frag buffered"),n.loadError=0)}},r.onLevelLoaded=function(e,t){var r,i,n=t.level,a=t.details,s=t.levelInfo;if(!s)return this.warn("Invalid level index "+n),void(null!=(i=t.deliveryDirectives)&&i.skip&&(a.deltaUpdateFailed=!0));if(s===this.currentLevel||t.withoutMultiVariant){0===s.fragmentError&&(s.loadError=0);var o=s.details;o===t.details&&o.advanced&&(o=void 0),this.playlistLoaded(n,t,o)}else null!=(r=t.deliveryDirectives)&&r.skip&&(a.deltaUpdateFailed=!0)},r.loadPlaylist=function(t){e.prototype.loadPlaylist.call(this),this.shouldLoadPlaylist(this.currentLevel)&&this.scheduleLoading(this.currentLevel,t)},r.loadingPlaylist=function(t,r){e.prototype.loadingPlaylist.call(this,t,r);var i=this.getUrlWithDirectives(t.uri,r),n=this.currentLevelIndex,a=t.attrs["PATHWAY-ID"],s=t.details,o=null==s?void 0:s.age;this.log("Loading level index "+n+(void 0!==(null==r?void 0:r.msn)?" at sn "+r.msn+" part "+r.part:"")+(a?" Pathway "+a:"")+(o&&s.live?" age "+o.toFixed(1)+(s.type&&" "+s.type||""):"")+" "+i),this.hls.trigger(G.LEVEL_LOADING,{url:i,level:n,levelInfo:t,pathwayId:t.attrs["PATHWAY-ID"],id:0,deliveryDirectives:r||null})},r.removeLevel=function(e){var t,r=this;if(1!==this._levels.length){var i=this._levels.filter((function(t,i){return i!==e||(r.steering&&r.steering.removeLevel(t),t===r.currentLevel&&(r.currentLevel=null,r.currentLevelIndex=-1,t.details&&t.details.fragments.forEach((function(e){return e.level=-1}))),!1)}));vn(i),this._levels=i,this.currentLevelIndex>-1&&null!=(t=this.currentLevel)&&t.details&&(this.currentLevelIndex=this.currentLevel.details.fragments[0].level),this.manualLevelIndex>-1&&(this.manualLevelIndex=this.currentLevelIndex);var n=i.length-1;this._firstLevel=Math.min(this._firstLevel,n),this._startLevel&&(this._startLevel=Math.min(this._startLevel,n)),this.hls.trigger(G.LEVELS_UPDATED,{levels:i})}},r.onLevelsUpdated=function(e,t){var r=t.levels;this._levels=r},r.checkMaxAutoUpdated=function(){var e=this.hls,t=e.autoLevelCapping,r=e.maxAutoLevel,i=e.maxHdcpLevel;this._maxAutoLevel!==r&&(this._maxAutoLevel=r,this.hls.trigger(G.MAX_AUTO_LEVEL_UPDATED,{autoLevelCapping:t,levels:this.levels,maxAutoLevel:r,minAutoLevel:this.hls.minAutoLevel,maxHdcpLevel:i}))},a(t,[{key:"iframeVariants",get:function(){return 0===this._iframeVariants.length?null:this._iframeVariants}},{key:"levels",get:function(){return 0===this._levels.length?null:this._levels}},{key:"loadLevelObj",get:function(){return this.currentLevel}},{key:"level",get:function(){return this.currentLevelIndex},set:function(e){var t=this._levels;if(0!==t.length){if(e<0||e>=t.length){var r=new Error("invalid level idx"),i=e<0;if(this.hls.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.LEVEL_SWITCH_ERROR,level:e,fatal:i,error:r,reason:r.message}),i)return;e=Math.min(e,t.length-1)}var n=this.currentLevelIndex,a=this.currentLevel,s=a?a.attrs["PATHWAY-ID"]:void 0,o=t[e],l=o.attrs["PATHWAY-ID"];if(this.currentLevelIndex=e,this.currentLevel=o,n!==e||!a||s!==l){this.log("Switching to level "+e+" ("+(o.height?o.height+"p ":"")+(o.videoRange?o.videoRange+" ":"")+(o.codecSet?o.codecSet+" ":"")+"@"+o.bitrate+")"+(l?" with Pathway "+l:"")+" from level "+n+(s?" with Pathway "+s:""));var u={level:e,attrs:o.attrs,details:o.details,bitrate:o.bitrate,averageBitrate:o.averageBitrate,maxBitrate:o.maxBitrate,realBitrate:o.realBitrate,width:o.width,height:o.height,codecSet:o.codecSet,audioCodec:o.audioCodec,videoCodec:o.videoCodec,audioGroups:o.audioGroups,subtitleGroups:o.subtitleGroups,loaded:o.loaded,loadError:o.loadError,fragmentError:o.fragmentError,name:o.name,id:o.id,uri:o.uri,url:o.url,urlId:0,audioGroupIds:o.audioGroupIds,textGroupIds:o.textGroupIds};this.hls.trigger(G.LEVEL_SWITCHING,u);var d=o.details;if(!d||d.live){var h=this.switchParams(o.uri,null==a?void 0:a.details,d);this.loadPlaylist(h)}}}}},{key:"manualLevel",get:function(){return this.manualLevelIndex},set:function(e){this.manualLevelIndex=e,void 0===this._startLevel&&(this._startLevel=e),-1!==e&&(this.level=e)}},{key:"firstLevel",get:function(){return this._firstLevel},set:function(e){this._firstLevel=e}},{key:"startLevel",get:function(){if(void 0===this._startLevel){var e=this.hls.config.startLevel;return void 0!==e?e:this.hls.firstAutoLevel}return this._startLevel},set:function(e){this._startLevel=e}},{key:"pathways",get:function(){return this.steering?this.steering.pathways():[]}},{key:"pathwayPriority",get:function(){return this.steering?this.steering.pathwayPriority:null},set:function(e){if(this.steering){var t=this.steering.pathways(),r=e.filter((function(e){return-1!==t.indexOf(e)}));if(e.length<1)return void this.warn("pathwayPriority "+e+" should contain at least one pathway from list: "+t);this.steering.pathwayPriority=r}}},{key:"nextLoadLevel",get:function(){return-1!==this.manualLevelIndex?this.manualLevelIndex:this.hls.nextAutoLevel},set:function(e){var t=this.currentLevelIndex;if(-1===this.manualLevelIndex&&e!==t&&-1!==e){var r=this.hls.config.abrSwitchInterval;if(r>0){var i=performance.now(),n=i-this.lastABRSwitchTime,a=1e3*r;if(this.lastABRSwitchTime>-1&&n "+e+" ("+Math.round(n)+"ms < "+a+"ms / "+r+"s)");this.lastABRSwitchTime=i,this.log("Allowing ABR level switch: "+t+" -> "+e+" ("+Math.round(n)+"ms >= "+a+"ms / "+r+"s)")}}this.level=e,-1===this.manualLevelIndex&&(this.hls.nextAutoLevel=e)}}])}(os);function wd(e,t){return Pt(e,"audio",t)}function Od(e,t){return Pt(e,"video",t)}function Md(e,t){var r=e.audioCodec,i=e.videoCodec;r&&(e.audioCodec=r=Ft(r,t)||void 0),i&&(i=e.videoCodec=function(e){for(var t=e.split(","),r=0;r2&&"avc1"===i[0]&&(t[r]="avc1."+parseInt(i[1]).toString(16)+("000"+parseInt(i[2]).toString(16)).slice(-4))}return t.join(",")}(i));var n=e.unknownCodecs;return!((null==n?void 0:n.length)||r&&!wd(r,t)||i&&!Od(i,t))}function xd(e){var t={};e.forEach((function(e){var r=e.groupId||"";e.id=t[r]=t[r]||0,t[r]++}))}var Fd=function(e){function t(t,r){var i;return(i=e.call(this,"key-loader",r)||this).config=void 0,i.keyLoaderInfo={},i.emeController=null,i.config=t,i}d(t,e);var r=t.prototype;return r.abort=function(e){for(var t in this.keyLoaderInfo){var r=this.keyLoaderInfo[t].loader;if(r){var i;if(e&&e!==(null==(i=r.context)?void 0:i.frag.type))return;r.abort()}}},r.destroy=function(){for(var e in this.keyLoaderInfo){var t=this.keyLoaderInfo[e].loader;t&&t.destroy()}this.emeController=null,this.keyLoaderInfo={}},r.loadClear=function(e,t,r){return this.emeController?this.emeController.loadClear(e,t,r):null},r.load=function(e,t){var r=this;return!e.decryptdata&&e.encrypted&&this.emeController&&this.config.emeEnabled?this.emeController.selectKeySystemFormat(e).then((function(i){return r.loadInternal(e,t,i)})):this.loadInternal(e,t)},r.loadInternal=function(e,t,r){var i=this;r&&e.setKeyFormat(r);var n=e.decryptdata;if(!n){var a=new Error(r?"Expected frag.decryptdata to be defined after setting format "+r:"Missing decryption data on fragment in onKeyLoading (emeEnabled with controller: "+(this.emeController&&this.config.emeEnabled)+")");return Promise.reject(Nd(e,B.KEY_LOAD_ERROR,a))}var s=e;switch(n.method){case"SAMPLE-AES":case"SAMPLE-AES-CENC":case"SAMPLE-AES-CTR":return"identity"===n.keyFormat?this.loadKeyHTTP(s):t?t.then((function(){return i.loadKeyEME(s)})):this.loadKeyEME(s);case"AES-128":case"AES-256":case"AES-256-CTR":return this.loadKeyHTTP(s);default:return Promise.reject(Nd(e,B.KEY_LOAD_ERROR,new Error('Key supplied with unsupported METHOD: "'+n.method+'"')))}},r.loadKeyEME=function(e){if(this.emeController&&this.config.emeEnabled){var t;if(!e.decryptdata.keyId&&null!=(t=e.initSegment)&&t.data){var r=function(e){var t=[];return ot(e,(function(e){return t.push(e.subarray(8,24))})),t}(e.initSegment.data);if(r.length){var i=r[0],n=e.decryptdata.uri;if(i.some((function(e){return 0!==e})))this.log("Using keyId found in init segment "+xe(i)+" keyUri: "+n),Bi.setKeyIdForUri(n,i);else{var a=Bi.addKeyIdForUri(n);this.log("Patching empty keyId with "+xe(a)+" keyUri: "+n),i=a}e.decryptdata.keyId=i}}return this.emeController.loadKey(e).then((function(){return{frag:e,keyInfo:{decryptdata:e.decryptdata}}}))}return Promise.reject(Nd(e,B.KEY_LOAD_ERROR,new Error("emeEnabled with controller: "+(this.emeController&&this.config.emeEnabled)+")")))},r.loadKeyHTTP=function(e){var t=this,r=e.decryptdata,i=r.uri;if(!i)return Promise.reject(Nd(e,B.KEY_LOAD_ERROR,new Error('Invalid key URI: "'+i+'"')));var n=this.keyLoaderInfo[i];if(n){if(n.decryptdata.key)return r.key=n.decryptdata.key,n.keyLoadPromise=null,Promise.resolve({frag:e,keyInfo:n});if(n.keyLoadPromise)return n.keyLoadPromise.then((function(t){return r.key=n.decryptdata.key,c(c({},t),{},{frag:e})}))}this.log("Loading"+(r.keyId?" keyId: "+xe(r.keyId):"")+" URI: "+r.uri+" from "+e.type+" "+e.level);var a=this.config,s=new(0,a.loader)(a);e.keyLoader=s;var o=this.keyLoaderInfo[i]={decryptdata:r,keyLoadPromise:null,loader:s};return o.keyLoadPromise=new Promise((function(r,n){var l={type:V.KEY,keyInfo:o,frag:e,responseType:"arraybuffer",url:i},u=a.keyLoadPolicy.default,d={loadPolicy:u,timeout:u.maxLoadTimeMs,maxRetry:0,retryDelay:0,maxRetryDelay:0},h={onSuccess:function(e,i,a,s){var o=a.frag,l=a.keyInfo,u=a.url;if(!o.decryptdata||l!==t.keyLoaderInfo[u])return n(Nd(o,B.KEY_LOAD_ERROR,new Error("after key load, decryptdata unset or changed"),s));l.decryptdata.key=o.decryptdata.key=new Uint8Array(e.data),o.keyLoader=l.loader=l.keyLoadPromise=null,r({frag:o,keyInfo:l})},onError:function(r,a,s,o){t.resetLoader(a),n(Nd(e,B.KEY_LOAD_ERROR,new Error("HTTP Error "+r.code+" loading key "+r.text),s,c({url:i,data:void 0},r)))},onTimeout:function(r,i,a){t.resetLoader(i),n(Nd(e,B.KEY_LOAD_TIMEOUT,new Error("key loading timed out"),a))},onAbort:function(r,i,a){t.resetLoader(i),n(Nd(e,B.INTERNAL_ABORTED,new Error("key loading aborted"),a))}};s.load(l,d,h)}))},r.resetLoader=function(e){var t=e.frag,r=e.keyInfo,i=e.url,n=r.loader;t.keyLoader===n&&(t.keyLoader=r.loader=r.keyLoadPromise=null),delete this.keyLoaderInfo[i],n&&n.destroy()},t}(ue);function Nd(e,t,r,i,n){return void 0===t&&(t=B.KEY_LOAD_ERROR),new ri({type:U.NETWORK_ERROR,details:t,fatal:!1,frag:e,response:n,error:r,networkDetails:i||null})}function Ud(e){switch(e.type){case V.AUDIO_TRACK:return H.AUDIO;case V.SUBTITLE_TRACK:return H.SUBTITLE;default:return H.MAIN}}function Bd(e,t){var r=e.url;return void 0!==r&&0!==r.indexOf("data:")||(r=t.url),r}var Gd=function(){function e(e){this.hls=void 0,this.loaders=Object.create(null),this.variableList=null,this.onManifestLoaded=this.checkAutostartLoad,this.hls=e,this.registerListeners()}var t=e.prototype;return t.startLoad=function(e){},t.stopLoad=function(){this.destroyInternalLoaders()},t.registerListeners=function(){var e=this.hls;e.on(G.MANIFEST_LOADING,this.onManifestLoading,this),e.on(G.LEVEL_LOADING,this.onLevelLoading,this),e.on(G.AUDIO_TRACK_LOADING,this.onAudioTrackLoading,this),e.on(G.SUBTITLE_TRACK_LOADING,this.onSubtitleTrackLoading,this),e.on(G.LEVELS_UPDATED,this.onLevelsUpdated,this)},t.unregisterListeners=function(){var e=this.hls;e.off(G.MANIFEST_LOADING,this.onManifestLoading,this),e.off(G.LEVEL_LOADING,this.onLevelLoading,this),e.off(G.AUDIO_TRACK_LOADING,this.onAudioTrackLoading,this),e.off(G.SUBTITLE_TRACK_LOADING,this.onSubtitleTrackLoading,this),e.off(G.LEVELS_UPDATED,this.onLevelsUpdated,this)},t.createInternalLoader=function(e){var t=this.hls.config,r=t.pLoader,i=t.loader,n=new(r||i)(t);return this.loaders[e.type]=n,n},t.getInternalLoader=function(e){return this.loaders[e.type]},t.resetInternalLoader=function(e){this.loaders[e]&&delete this.loaders[e]},t.destroyInternalLoaders=function(){for(var e in this.loaders){var t=this.loaders[e];t&&t.destroy(),this.resetInternalLoader(e)}},t.destroy=function(){this.variableList=null,this.unregisterListeners(),this.destroyInternalLoaders()},t.onManifestLoading=function(e,t){var r=t.url;this.variableList=null,this.load({id:null,level:0,responseType:"text",type:V.MANIFEST,url:r,deliveryDirectives:null,levelOrTrack:null})},t.onLevelLoading=function(e,t){var r=t.id,i=t.level,n=t.pathwayId,a=t.url,s=t.deliveryDirectives,o=t.levelInfo;this.load({id:r,level:i,pathwayId:n,responseType:"text",type:V.LEVEL,url:a,deliveryDirectives:s,levelOrTrack:o})},t.onAudioTrackLoading=function(e,t){var r=t.id,i=t.groupId,n=t.url,a=t.deliveryDirectives,s=t.track;this.load({id:r,groupId:i,level:null,responseType:"text",type:V.AUDIO_TRACK,url:n,deliveryDirectives:a,levelOrTrack:s})},t.onSubtitleTrackLoading=function(e,t){var r=t.id,i=t.groupId,n=t.url,a=t.deliveryDirectives,s=t.track;this.load({id:r,groupId:i,level:null,responseType:"text",type:V.SUBTITLE_TRACK,url:n,deliveryDirectives:a,levelOrTrack:s})},t.onLevelsUpdated=function(e,t){var r=this.loaders[V.LEVEL];if(r){var i=r.context;i&&!t.levels.some((function(e){return e===i.levelOrTrack}))&&(r.abort(),delete this.loaders[V.LEVEL])}},t.load=function(e){var t,r,i,n=this,a=this.hls.config,s=this.getInternalLoader(e);if(s){var o=this.hls.logger,u=s.context;if((null==u?void 0:u.levelOrTrack)===e.levelOrTrack&&(u.url===e.url||u.deliveryDirectives&&!e.deliveryDirectives))return void(u.url===e.url?o.log("[playlist-loader]: ignore "+e.url+" ongoing request"):o.log("[playlist-loader]: ignore "+e.url+" in favor of "+u.url));o.log("[playlist-loader]: aborting previous loader for type: "+e.type),s.abort()}if(r=e.type===V.MANIFEST?a.manifestLoadPolicy.default:l({},a.playlistLoadPolicy.default,{timeoutRetry:null,errorRetry:null}),s=this.createInternalLoader(e),x(null==(t=e.deliveryDirectives)?void 0:t.part)&&(e.type===V.LEVEL&&null!==e.level?i=this.hls.levels[e.level].details:e.type===V.AUDIO_TRACK&&null!==e.id?i=this.hls.audioTracks[e.id].details:e.type===V.SUBTITLE_TRACK&&null!==e.id&&(i=this.hls.subtitleTracks[e.id].details),i)){var d=i.partTarget,h=i.targetduration;if(d&&h){var f=1e3*Math.max(3*d,.8*h);r=l({},r,{maxTimeToFirstByteMs:Math.min(f,r.maxTimeToFirstByteMs),maxLoadTimeMs:Math.min(f,r.maxTimeToFirstByteMs)})}}var c=r.errorRetry||r.timeoutRetry||{},g={loadPolicy:r,timeout:r.maxLoadTimeMs,maxRetry:c.maxNumRetry||0,retryDelay:c.retryDelayMs||0,maxRetryDelay:c.maxRetryDelayMs||0},v={onSuccess:function(e,t,r,i){var a=n.getInternalLoader(r);n.resetInternalLoader(r.type);var s=e.data;t.parsing.start=performance.now(),Wi.isMediaPlaylist(s)||r.type!==V.MANIFEST?n.handleTrackOrLevelPlaylist(e,t,r,i||null,a):n.handleMasterPlaylist(e,t,r,i)},onError:function(e,t,r,i){n.handleNetworkError(t,r,!1,e,i)},onTimeout:function(e,t,r){n.handleNetworkError(t,r,!0,void 0,e)}};s.load(e,g,v)},t.checkAutostartLoad=function(){if(this.hls){var e=this.hls,t=e.config,r=t.autoStartLoad,i=t.startPosition,n=e.forceStartLoad;(r||n)&&(this.hls.logger.log((r?"auto":"force")+" startLoad with configured startPosition "+i),this.hls.startLoad(i))}},t.handleMasterPlaylist=function(e,t,r,i){var n=this,a=this.hls,s=e.data,o=Bd(e,r),l=Wi.parseMasterPlaylist(s,o);if(l.playlistParsingError)return t.parsing.end=performance.now(),void this.handleManifestParsingError(e,r,l.playlistParsingError,i,t);var u=l.contentSteering,d=l.levels,h=l.iframeVariants,f=l.sessionData,c=l.sessionKeys,g=l.startTimeOffset,v=l.variableList;this.variableList=v,d.forEach((function(e){var t=e.unknownCodecs;if(t)for(var r=n.hls.config.preferManagedMediaSource,i=e.audioCodec,a=e.videoCodec,s=t.length;s--;){var o=t[s];Pt(o,"audio",r)?(e.audioCodec=i=i?i+","+o:o,kt.audio[i.substring(0,4)]=2,t.splice(s,1)):Pt(o,"video",r)&&(e.videoCodec=a=a?a+","+o:o,kt.video[a.substring(0,4)]=2,t.splice(s,1))}}));var m=Wi.parseMasterPlaylistMedia(s,o,l),p=m.AUDIO,y=void 0===p?[]:p,E=m.SUBTITLES,T=m["CLOSED-CAPTIONS"];y.length&&(y.some((function(e){return!e.url}))||!d[0].audioCodec||d[0].attrs.AUDIO||(this.hls.logger.log("[playlist-loader]: audio codec signaled in quality level, but no embedded audio track signaled, create one"),y.unshift({type:"main",name:"main",groupId:"main",default:!1,autoselect:!1,forced:!1,id:-1,attrs:new gi({}),bitrate:0,url:""}))),a.trigger(G.MANIFEST_LOADED,{levels:d,audioTracks:y,subtitles:E,iframeVariants:h,captions:T,contentSteering:u,url:o,stats:t,networkDetails:i,sessionData:f,sessionKeys:c,startTimeOffset:g,variableList:v})},t.handleTrackOrLevelPlaylist=function(e,t,r,i,n){var a=this.hls,s=r.id,o=r.level,l=r.type,u=Bd(e,r),d=x(o)?o:x(s)?s:0,h=Ud(r),f=Wi.parseLevelPlaylist(e.data,u,d,h,0,this.variableList);if(l===V.MANIFEST){var c={attrs:new gi({}),bitrate:0,details:f,name:"",url:u};f.requestScheduled=t.loading.start+hn(f,0),a.trigger(G.MANIFEST_LOADED,{levels:[c],audioTracks:[],iframeVariants:[],url:u,stats:t,networkDetails:i,sessionData:null,sessionKeys:null,contentSteering:null,startTimeOffset:null,variableList:null})}t.parsing.end=performance.now(),r.levelDetails=f,this.handlePlaylistLoaded(f,e,t,r,i,n)},t.handleManifestParsingError=function(e,t,r,i,n){this.hls.trigger(G.ERROR,{type:U.NETWORK_ERROR,details:B.MANIFEST_PARSING_ERROR,fatal:t.type===V.MANIFEST,url:e.url,err:r,error:r,reason:r.message,response:e,context:t,networkDetails:i,stats:n})},t.handleNetworkError=function(e,t,r,i,n){void 0===r&&(r=!1);var a="A network "+(r?"timeout":"error"+(i?" (status "+i.code+")":""))+" occurred while loading "+e.type;e.type===V.LEVEL?a+=": "+e.level+" id: "+e.id:e.type!==V.AUDIO_TRACK&&e.type!==V.SUBTITLE_TRACK||(a+=" id: "+e.id+' group-id: "'+e.groupId+'"');var s=new Error(a);this.hls.logger.warn("[playlist-loader]: "+a);var o=B.UNKNOWN,l=!1,u=this.getInternalLoader(e);switch(e.type){case V.MANIFEST:o=r?B.MANIFEST_LOAD_TIMEOUT:B.MANIFEST_LOAD_ERROR,l=!0;break;case V.LEVEL:o=r?B.LEVEL_LOAD_TIMEOUT:B.LEVEL_LOAD_ERROR,l=!1;break;case V.AUDIO_TRACK:o=r?B.AUDIO_TRACK_LOAD_TIMEOUT:B.AUDIO_TRACK_LOAD_ERROR,l=!1;break;case V.SUBTITLE_TRACK:o=r?B.SUBTITLE_TRACK_LOAD_TIMEOUT:B.SUBTITLE_LOAD_ERROR,l=!1}u&&this.resetInternalLoader(e.type);var d={type:U.NETWORK_ERROR,details:o,fatal:l,url:e.url,loader:u,context:e,error:s,networkDetails:t,stats:n};if(i){var h=e.url;t&&"url"in t&&(h=t.url),d.response=c({url:h,data:void 0},i)}this.hls.trigger(G.ERROR,d)},t.handlePlaylistLoaded=function(e,t,r,i,n,a){var s=this.hls,o=i.type,l=i.level,u=i.levelOrTrack,d=i.id,h=i.groupId,f=i.deliveryDirectives,c=Bd(t,i),g=Ud(i),v="number"==typeof i.level&&g===H.MAIN?l:void 0;u&&"iframes"in u&&u.iframes&&!e.iframesOnly&&(e.playlistParsingError=new Error("EXT-X-I-FRAME-STREAM-INF media playlist MUST contain an EXT-X-I-FRAMES-ONLY tag"),e.iframesOnly=!0);var m=e.playlistParsingError;if(m){if(this.hls.logger.warn(m+" "+e.url),!s.config.ignorePlaylistParsingErrors)return void s.trigger(G.ERROR,{type:U.NETWORK_ERROR,details:B.LEVEL_PARSING_ERROR,fatal:!1,url:c,error:m,reason:m.message,response:t,context:i,level:v,parent:g,networkDetails:n,stats:r});e.playlistParsingError=null}if(e.fragments.length)switch(e.live&&a&&(a.getCacheAge&&(e.ageHeader=a.getCacheAge()||0),a.getCacheAge&&!isNaN(e.ageHeader)||(e.ageHeader=0)),o){case V.MANIFEST:case V.LEVEL:if(v)if(u){if(u!==s.levels[v]){var p=s.levels.indexOf(u);p>-1&&(v=p)}}else v=0;s.trigger(G.LEVEL_LOADED,{details:e,levelInfo:u||s.levels[0],level:v||0,id:d||0,stats:r,networkDetails:n,deliveryDirectives:f,withoutMultiVariant:o===V.MANIFEST,context:i});break;case V.AUDIO_TRACK:s.trigger(G.AUDIO_TRACK_LOADED,{details:e,track:u,id:d||0,groupId:h||"",stats:r,networkDetails:n,deliveryDirectives:f,context:i});break;case V.SUBTITLE_TRACK:s.trigger(G.SUBTITLE_TRACK_LOADED,{details:e,track:u,id:d||0,groupId:h||"",stats:r,networkDetails:n,deliveryDirectives:f,context:i})}else{var y=e.playlistParsingError=new Error("No Segments found in Playlist");s.trigger(G.ERROR,{type:U.NETWORK_ERROR,details:B.LEVEL_EMPTY_ERROR,fatal:!1,url:c,error:y,reason:y.message,response:t,context:i,level:v,parent:g,networkDetails:n,stats:r})}},e}(),Kd=function(){function e(t){void 0===t&&(t={}),this.config=void 0,this.userConfig=void 0,this.logger=void 0,this._url=null,this.streamController=void 0,this.coreComponents=void 0,this.networkControllers=void 0,this._emitter=new w,this._autoLevelCapping=-1,this._maxHdcpLevel=null,this.abrController=void 0,this.bufferController=void 0,this.capLevelController=void 0,this.latencyController=void 0,this.levelController=void 0,this.audioStreamController=void 0,this.subtititleStreamController=void 0,this.audioTrackController=void 0,this.subtitleTrackController=void 0,this.interstitialsController=void 0,this.iframeController=void 0,this.gapController=void 0,this.emeController=void 0,this.cmcdController=void 0,this._media=null,this._sessionId=void 0,this.triggeringException=void 0,this.started=!1;var r=this.logger=ve(t.debug||!1,"Hls instance",t.loggerId||t.assetPlayerId),i=this.config=Dd(e.DefaultConfig,t,r);this.userConfig=t,i.progressive&&_d(i,r);var n=i.streamController,a=i.abrController,s=i.bufferController,o=i.capLevelController,l=i.errorController,u=i.fpsController,d=i.id3TrackController,h=i.iframeController,f=i.gapController,c=new l(this),g=this.abrController=new a(this),v=new Kr(this),m=i.interstitialsController,p=m?this.interstitialsController=new m(this,e):null,y=s?this.bufferController=new s(this,v):null,E=o?this.capLevelController=new o(this):null,T=u?new u(this):null,S=i.cmcdController,A=S?this.cmcdController=new S(this):null,I=new Gd(this),L=i.contentSteeringController,R=L?new L(this):null,b=this.levelController=new Cd(this,R),k=d?new d(this):void 0,D=new Fd(this.config,this.logger),P=this.streamController=new n(this,v,D),_=this.gapController=f?new f(this,v):void 0;E&&E.setStreamController(P);var C=[I,b,P];p&&C.splice(1,0,p),R&&C.splice(1,0,R),this.networkControllers=C;var O=[g];y&&O.push(y),_&&O.push(_),E&&O.push(E),T&&(T.setStreamController(P),O.push(T)),k&&O.push(k),O.push(v),this.audioTrackController=this.createController(i.audioTrackController,C);var M=i.audioStreamController;M&&C.push(this.audioStreamController=new M(this,v,D)),this.subtitleTrackController=this.createController(i.subtitleTrackController,C);var x=i.subtitleStreamController;x&&C.push(this.subtititleStreamController=new x(this,v,D)),this.createController(i.timelineController,O),D.emeController=this.emeController=this.createController(i.emeController,O),A&&O.push(A),this.latencyController=this.createController(i.latencyController,O),this.coreComponents=O,this.iframeController=h?new h(this,e):void 0,C.push(c);var F=c.onErrorOut;"function"==typeof F&&this.on(G.ERROR,F,c),this.on(G.MANIFEST_LOADED,I.onManifestLoaded,I)}e.isMSESupported=function(){return Wl()},e.isSupported=function(){return function(){if(!Wl())return!1;var e=pe();return"function"==typeof(null==e?void 0:e.isTypeSupported)&&(["avc1.42E01E,mp4a.40.2","av01.0.01M.08","vp09.00.50.08"].some((function(t){return e.isTypeSupported(Ct(t,"video"))}))||["mp4a.40.2","fLaC"].some((function(t){return e.isTypeSupported(Ct(t,"audio"))})))}()},e.getMediaSource=function(){return pe()};var t=e.prototype;return t.createController=function(e,t){if(e){var r=new e(this);return t&&t.push(r),r}return null},t.on=function(e,t,r){void 0===r&&(r=this),this._emitter.on(e,t,r)},t.once=function(e,t,r){void 0===r&&(r=this),this._emitter.once(e,t,r)},t.removeAllListeners=function(e){this._emitter.removeAllListeners(e)},t.off=function(e,t,r,i){void 0===r&&(r=this),this._emitter.off(e,t,r,i)},t.listeners=function(e){return this._emitter.listeners(e)},t.emit=function(e,t,r){return this._emitter.emit(e,t,r)},t.trigger=function(e,t){if(this.config.debug)return this.emit(e,e,t);try{return this.emit(e,e,t)}catch(t){if(this.logger.error("An internal error happened while handling event "+e+'. Error message: "'+t.message+'". Here is a stacktrace:',t),!this.triggeringException){this.triggeringException=!0;var r=e===G.ERROR;this.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.INTERNAL_EXCEPTION,fatal:r,event:e,error:t}),this.triggeringException=!1}}return!1},t.listenerCount=function(e){return this._emitter.listenerCount(e)},t.destroy=function(){this.logger.log("destroy"),this.trigger(G.DESTROYING,void 0),this.detachMedia(),this.removeAllListeners(),this._autoLevelCapping=-1,this._url=null,this.networkControllers.forEach((function(e){return e.destroy()})),this.networkControllers.length=0,this.coreComponents.forEach((function(e){return e.destroy()})),this.coreComponents.length=0,this.iframeController=void 0;var e=this.config;e.xhrSetup=e.fetchSetup=void 0,this.userConfig=null},t.attachMedia=function(e){if(!e||"media"in e&&!e.media){var t=new Error("attachMedia failed: invalid argument ("+e+")");this.trigger(G.ERROR,{type:U.OTHER_ERROR,details:B.ATTACH_MEDIA_ERROR,fatal:!0,error:t})}else{this.logger.log("attachMedia"),this._media&&(this.logger.warn("media must be detached before attaching"),this.detachMedia());var r="media"in e,i=r?e.media:e,n=r?e:{media:i};this._media=i,this.trigger(G.MEDIA_ATTACHING,n)}},t.detachMedia=function(){this.logger.log("detachMedia");var e={};this.trigger(G.MEDIA_DETACHING,e),this._media=null,this.trigger(G.MEDIA_DETACHED,e)},t.transferMedia=function(){var e;this._media=null;var t=(null==(e=this.bufferController)?void 0:e.transferMedia())||null,r={transferMedia:t};return this.trigger(G.MEDIA_DETACHING,r),this.trigger(G.MEDIA_DETACHED,r),t},t.loadSource=function(e){var t;this.stopLoad();var r=this.media,i=this._url,n=this._url=M.buildAbsoluteURL(self.location.href,e,{alwaysNormalize:!0});this._autoLevelCapping=-1,this._maxHdcpLevel=null,this.logger.log("loadSource:"+n),r&&i&&(i!==n||null!=(t=this.bufferController)&&t.hasSourceTypes())&&(this.detachMedia(),this.attachMedia(r)),this.trigger(G.MANIFEST_LOADING,{url:e})},t.startLoad=function(e,t){void 0===e&&(e=-1),this.logger.log("startLoad("+e+(t?", ":"")+")"),this.started=!0,this.resumeBuffering();for(var r=0;r-1?this.abrController.forcedAutoLevel:e},set:function(e){this.logger.log("set startLevel:"+e),-1!==e&&(e=Math.max(e,this.minAutoLevel)),this.levelController.startLevel=e}},{key:"capLevelToPlayerSize",get:function(){return this.config.capLevelToPlayerSize},set:function(e){var t=this.capLevelController,r=!!e;t&&r!==this.config.capLevelToPlayerSize&&(r?t.startCapping():(t.stopCapping(),this.autoLevelCapping=-1,this.streamController.nextLevelSwitch()),this.config.capLevelToPlayerSize=r)}},{key:"autoLevelCapping",get:function(){return this._autoLevelCapping},set:function(e){this._autoLevelCapping!==e&&(this.logger.log("set autoLevelCapping:"+e),this._autoLevelCapping=e,this.levelController.checkMaxAutoUpdated())}},{key:"bandwidthEstimate",get:function(){var e=this.abrController.bwEstimator;return e?e.getEstimate():NaN},set:function(e){this.abrController.resetEstimator(e)}},{key:"abrEwmaDefaultEstimate",get:function(){var e=this.abrController.bwEstimator;return e?e.defaultEstimate:NaN}},{key:"ttfbEstimate",get:function(){var e=this.abrController.bwEstimator;return e?e.getEstimateTTFB():NaN}},{key:"maxHdcpLevel",get:function(){return this._maxHdcpLevel},set:function(e){(function(e){return zt.indexOf(e)>-1})(e)&&this._maxHdcpLevel!==e&&(this._maxHdcpLevel=e,this.levelController.checkMaxAutoUpdated())}},{key:"autoLevelEnabled",get:function(){return-1===this.levelController.manualLevel}},{key:"manualLevel",get:function(){return this.levelController.manualLevel}},{key:"minAutoLevel",get:function(){var e=this.levels,t=this.config.minAutoBitrate;if(!e)return 0;for(var r=e.length,i=0;i=t)return i;return 0}},{key:"maxAutoLevel",get:function(){var e,t=this.levels,r=this.autoLevelCapping,i=this.maxHdcpLevel;if(e=-1===r&&null!=t&&t.length?t.length-1:r,i)for(var n=e;n--;){var a=t[n].attrs["HDCP-LEVEL"];if(a&&a<=i)return n}return e}},{key:"firstAutoLevel",get:function(){return this.abrController.firstAutoLevel}},{key:"nextAutoLevel",get:function(){return this.abrController.nextAutoLevel},set:function(e){this.abrController.nextAutoLevel=e}},{key:"playingDate",get:function(){return this.streamController.currentProgramDateTime}},{key:"mainForwardBufferInfo",get:function(){return this.streamController.getMainFwdBufferInfo()}},{key:"audioForwardBufferInfo",get:function(){var e;return(null==(e=this.audioStreamController)?void 0:e.getFwdBufferInfo())||null}},{key:"maxBufferLength",get:function(){return this.streamController.maxBufferLength}},{key:"allAudioTracks",get:function(){var e=this.audioTrackController;return e?e.allAudioTracks:[]}},{key:"audioTracks",get:function(){var e=this.audioTrackController;return e?e.audioTracks:[]}},{key:"audioTrack",get:function(){var e=this.audioTrackController;return e?e.audioTrack:-1},set:function(e){var t=this.audioTrackController;t&&(t.audioTrack=e)}},{key:"nextAudioTrack",get:function(){var e,t;return null!=(e=null==(t=this.audioStreamController)?void 0:t.nextAudioTrack)?e:-1},set:function(e){var t=this.audioTrackController;t&&(t.nextAudioTrack=e)}},{key:"allSubtitleTracks",get:function(){var e=this.subtitleTrackController;return e?e.allSubtitleTracks:[]}},{key:"subtitleTracks",get:function(){var e=this.subtitleTrackController;return e?e.subtitleTracks:[]}},{key:"subtitleTrack",get:function(){var e=this.subtitleTrackController;return e?e.subtitleTrack:-1},set:function(e){var t=this.subtitleTrackController;t&&(t.subtitleTrack=e)}},{key:"media",get:function(){return this._media}},{key:"subtitleDisplay",get:function(){var e=this.subtitleTrackController;return!!e&&e.subtitleDisplay},set:function(e){var t=this.subtitleTrackController;t&&(t.subtitleDisplay=e)}},{key:"lowLatencyMode",get:function(){return this.config.lowLatencyMode},set:function(e){this.config.lowLatencyMode=e}},{key:"liveSyncPosition",get:function(){var e,t;return null!=(e=null==(t=this.latencyController)?void 0:t.liveSyncPosition)?e:null}},{key:"latency",get:function(){var e;return(null==(e=this.latencyController)?void 0:e.latency)||0}},{key:"maxLatency",get:function(){var e;return(null==(e=this.latencyController)?void 0:e.maxLatency)||0}},{key:"targetLatency",get:function(){var e;return(null==(e=this.latencyController)?void 0:e.targetLatency)||null},set:function(e){this.latencyController&&(this.latencyController.targetLatency=e)}},{key:"drift",get:function(){var e;return(null==(e=this.latencyController)?void 0:e.drift)||null}},{key:"forceStartLoad",get:function(){return this.streamController.forceStartLoad}},{key:"pathways",get:function(){return this.levelController.pathways}},{key:"pathwayPriority",get:function(){return this.levelController.pathwayPriority},set:function(e){this.levelController.pathwayPriority=e}},{key:"bufferedToEnd",get:function(){var e;return!(null==(e=this.bufferController)||!e.bufferedToEnd)}},{key:"interstitialsManager",get:function(){return this.interstitialsController?this.interstitialsController.interstitialsManager:null}},{key:"iframeVariants",get:function(){var e=this.levelController.iframeVariants;return e||[]}}],[{key:"version",get:function(){return rs}},{key:"Events",get:function(){return G}},{key:"MetadataSchema",get:function(){return Un}},{key:"ErrorTypes",get:function(){return U}},{key:"ErrorDetails",get:function(){return B}},{key:"DefaultConfig",get:function(){return e.defaultConfig?e.defaultConfig:kd},set:function(t){e.defaultConfig=t}}])}();return Kd.defaultConfig=void 0,Kd},"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(r="undefined"!=typeof globalThis?globalThis:r||self).Hls=i()}(!1);
+//# sourceMappingURL=hls.min.js.map
diff --git a/internal/webui/index.html b/internal/webui/index.html
new file mode 100644
index 0000000..d0499ad
--- /dev/null
+++ b/internal/webui/index.html
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+短剧库
+
+
+
+
+
+
+
+
+
+
+
+ 短剧库 + +
+ + +
+
+
+ +
+
+
+ + +
+ + + + + +
+
+ + +
+
+
+
+ + +
+ +
+
+ + + +
+ +

剧集详情

+ +

信息

+ +
+

站点榜单

+
+

按站点名次展示,独立于剧库筛选。

+
+
    + +
    + +
    +

    观看记录

    + +
    +
    +
      + +
      + +

      剧库设置

      +

      外观

      选择适合当前环境的明暗模式

      +

      登录管理员账号后可调整代理、保存目录与服务并发。

      +
      +

      个人记录 当前浏览器

      +

      登录后可跨设备接着看,观看记录和追剧清单各自独立。

      + +

      +
      +
      +
      +
      + +

      Emby 自动同步

      +
      +

      自动写入剧集和分集文件,后续补入新集数,无需先下载视频。请将输出目录加入 Emby 的电视剧媒体库。

      +
      + +
      + + + +
      + +

      +
      扫描与海报同步(可选)

      填写后自动通知扫描,并由 Emby 获取和缓存海报,导出目录无需保存图片。已有海报会保留;本程序同步的海报随资料更新。未填写时只生成媒体库文件,使用 Emby 的实时监控或定时扫描。更换 Emby 服务器后需重新填写 API Key。

      +
      +
      +

      +
      尚未同步

      +

      启用后会自动加入所选范围内的剧集。

        +

        移出所有同步范围或关闭同步会停止后续更新,已导入的文件保留。手动导出 ZIP 的入口仍在剧集详情中。

        +
        +
        + + +
        +
        正在观看

        在线观看

        +
        +
        +
        + +
        + + 正在加载 + + + +
        + + +
        +

        正在加载

        +
        上下滑动切换分集0:00 / 0:00
        +
        + + + 0:00 + + 0:00 +
        +
        + + + +
        +
        + + + +
        +
        +
        +
        + +
        + +
        + +
        +
        +
        + + + + + + + + + + + + + + + + + diff --git a/internal/webui/library-sort.js b/internal/webui/library-sort.js new file mode 100644 index 0000000..758e6f4 --- /dev/null +++ b/internal/webui/library-sort.js @@ -0,0 +1,78 @@ +(function (root) { + 'use strict'; + const modes = ['default', 'newest', 'oldest', 'heat', 'views', 'title']; + const collator = new Intl.Collator('zh-Hans-CN', {numeric: true, sensitivity: 'base'}); + + function chineseNumber(text) { + const digits = {零:0,〇:0,一:1,二:2,两:2,兩:2,三:3,四:4,五:5,六:6,七:7,八:8,九:9}; + const units = {十:10,百:100,千:1000,万:10000,萬:10000}; + if ([...text].every(character => character in digits)) return Number([...text].map(character => digits[character]).join('')); + let total=0, section=0, digit=0; + for (const character of text) { + if (character in digits) digit=digits[character]; + else if (units[character]===10000) {total+=(section+digit||1)*10000;section=0;digit=0;} + else {section+=(digit||1)*units[character];digit=0;} + } + return total+section+digit; + } + + function titleKey(drama) { + + + return String(drama.title || drama.name || '').normalize('NFKC').trim() + .replace(/第\s*([零〇一二两兩三四五六七八九十百千万萬]+)\s*(?=[季部集章卷篇册冊辑輯])/g, (_, number) => '第'+chineseNumber(number)); + } + + function metricValue(value) { + if (typeof value === 'number') return Number.isFinite(value) && value >= 0 ? value : null; + const text = String(value ?? '').replace(/[,,\s]/g, ''); + const match = /^(\d+(?:\.\d+)?)(亿|万|千|w|k|m|b)?\+?(?:次播放|次观看|人看过|热度|播放|观看|次)?\+?$/i.exec(text); + if (!match) return null; + const unit = (match[2] || '').toLowerCase(); + const number = Number(match[1]) * ({亿: 1e8, 万: 1e4, 千: 1e3, w: 1e4, k: 1e3, m: 1e6, b: 1e9}[unit] || 1); + return Number.isFinite(number) ? number : null; + } + + function dateValue(value) { + const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(value || '').trim()); + if (!match) return null; + const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + if (year < 2000 || year > 2100 || date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null; + return date.getTime(); + } + + function sortValue(drama, mode) { + if (mode === 'newest' || mode === 'oldest') return dateValue(drama.onlineDate); + if (mode === 'heat') return metricValue(drama.heat); + if (mode === 'views') return metricValue(drama.views); + return null; + } + + function sortDramas(list, mode) { + if (!modes.includes(mode) || mode === 'default') return list.slice(); + + return list.map((drama, index) => ({drama, index, title: titleKey(drama), value: sortValue(drama, mode)})).sort((left, right) => { + if (mode !== 'title') { + if (left.value === null && right.value === null) return left.index - right.index; + if (left.value === null && right.value !== null) return 1; + if (left.value !== null && right.value === null) return -1; + if (left.value !== right.value) return mode === 'oldest' ? left.value - right.value : right.value - left.value; + } + return collator.compare(left.title, right.title) || left.index - right.index; + }).map(item => item.drama); + } + + function summary(list, mode) { + if (!['newest', 'oldest', 'heat', 'views'].includes(mode)) return ''; + const label = {newest: '上线日期', oldest: '上线日期', heat: '热度', views: '播放量'}[mode]; + const known = list.filter(drama => sortValue(drama, mode) !== null).length; + if (!list.length) return ''; + if (!known) return '当前结果缺少' + label + ',无法按此排序'; + return known < list.length ? label + '未知 ' + (list.length - known) + ' 部,已置后' : ''; + } + + const librarySort = {modes, metricValue, dateValue, sortDramas, summary, titleKey}; + if (typeof module !== 'undefined' && module.exports) module.exports = librarySort; + if (root) root.JukuLibrarySort = librarySort; +})(typeof window !== 'undefined' ? window : null); diff --git a/internal/webui/library-sort.test.cjs b/internal/webui/library-sort.test.cjs new file mode 100644 index 0000000..cd7829b --- /dev/null +++ b/internal/webui/library-sort.test.cjs @@ -0,0 +1,84 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {sortDramas, metricValue, dateValue, summary} = require('./library-sort.js'); + +test('numeric popularity, Chinese units, missing values and zero retain their meaning', () => { + assert.equal(metricValue('1.2亿热度'), 120000000); + assert.equal(metricValue('2.5w次播放'), 25000); + assert.equal(metricValue('1,234'), 1234); + assert.equal(metricValue('3.5万+'), 35000); + assert.equal(metricValue('0'), 0); + for (const value of ['', null, undefined, '未知', '-1', '9.5分', '约一万']) assert.equal(metricValue(value), null); +}); + +test('dates sort both ways while missing or impossible dates always stay last', () => { + const dramas = [ + {id: 'unknown', title: 'A'}, {id: 'new', title: '新', onlineDate: '2026-09-13'}, + {id: 'old', title: '旧', onlineDate: '2025-11-01'}, {id: 'bad', title: 'B', onlineDate: '2026-02-31'} + ]; + const original = dramas.slice(); + assert.deepEqual(sortDramas(dramas, 'newest').map(x => x.id), ['new', 'old', 'unknown', 'bad']); + assert.deepEqual(sortDramas(dramas, 'oldest').map(x => x.id), ['old', 'new', 'unknown', 'bad']); + assert.deepEqual(dramas, original); + assert.equal(dateValue('2026-02-29'), null); + assert.notEqual(dateValue('2024-02-29'), null); + assert.equal(summary(dramas, 'newest'), '上线日期未知 2 部,已置后'); +}); + +test('heat and play counts are independent sorts and default preserves source order', () => { + const dramas = [ + {id: 'unknown', title: 'A'}, {id: 'heat', title: 'B', heat: '2亿热度', views: '0'}, + {id: 'views', title: 'C', heat: '8万热度', views: '10万次播放'}, {id: 'zero', title: 'D', heat: '0'} + ]; + assert.deepEqual(sortDramas(dramas, 'heat').map(x => x.id), ['heat', 'views', 'zero', 'unknown']); + assert.deepEqual(sortDramas(dramas, 'views').map(x => x.id), ['views', 'heat', 'unknown', 'zero']); + assert.deepEqual(sortDramas(dramas, 'default'), dramas); + assert.equal(summary([{views: '10万次播放'}], 'heat'), '当前结果缺少热度,无法按此排序'); +}); + +test('equal values use a predictable title order and retain equal-title input order', () => { + const dramas = [ + {id: 'ten', title: '剧10', heat: '100'}, {id: 'two', title: '剧2', heat: '100'}, + {id: 'also-two', title: '剧2', heat: '100'} + ]; + assert.deepEqual(sortDramas(dramas, 'heat').map(x => x.id), ['two', 'also-two', 'ten']); +}); + +test('missing search metrics preserve input order and explain why time sorting cannot apply', () => { + const results = [{id: 'first', title: 'Z'}, {id: 'second', title: 'A'}]; + for (const mode of ['newest', 'oldest', 'heat', 'views']) { + assert.deepEqual(sortDramas(results, mode), results); + } + assert.equal(summary(results, 'newest'), '当前结果缺少上线日期,无法按此排序'); + assert.deepEqual(sortDramas(results, 'title').map(x => x.id), ['second', 'first']); + const withHeat = [...results, {id: 'known', title: 'B', heat: '20019'}]; + assert.deepEqual(sortDramas(withHeat, 'heat').map(x => x.id), ['known', 'first', 'second']); +}); + +test('search heat keeps precision instead of sorting by rounded display values', () => { + const results = [ + {id: 'lower', title: 'A', heat: '20001'}, + {id: 'higher', title: 'Z', heat: '20019'}, + {id: 'zero', title: '零', heat: '0'}, + {id: 'missing', title: '未知'} + ]; + assert.deepEqual(sortDramas(results, 'heat').map(x => x.id), ['higher', 'lower', 'zero', 'missing']); +}); + +test('聚宝仙盆 seasons sort numerically without changing titles or mixing separate series', () => { + const base='聚宝仙盆之杂灵根才是真BOSS'; + const seasons=['第八季','第二季','第九季','第七季','第三季','第十季','第十一季','第四季','第五季','第六季','']; + const rows=seasons.map((suffix,index)=>({id:String(index),title:base+suffix,heat:'100'})); + const expected=['','第二季','第三季','第四季','第五季','第六季','第七季','第八季','第九季','第十季','第十一季'].map(suffix=>base+suffix); + for (const mode of ['title','heat']) assert.deepEqual(sortDramas(rows,mode).map(row=>row.title),expected); + assert.deepEqual(rows.map(row=>row.title),seasons.map(suffix=>base+suffix)); + const separate=[...rows,{title:'聚宝仙盆仙界篇第七季'},{title:'聚宝仙盆'}]; + assert.deepEqual(sortDramas(separate,'title').map(row=>row.title),['聚宝仙盆','聚宝仙盆仙界篇第七季',...expected]); +}); + +test('Chinese ordinals, Arabic numerals and full-width digits share natural order', () => { + const {titleKey}=require('./library-sort.js'); + const titles=['剧第一百零二季','剧第2季','剧第十一季','剧第十季','剧第一百季','剧第二十一季','剧第3季']; + assert.deepEqual(sortDramas(titles.map(title=>({title})),'title').map(row=>row.title),['剧第2季','剧第3季','剧第十季','剧第十一季','剧第二十一季','剧第一百季','剧第一百零二季']); + for(const title of ['三生三世','十一的故事','聚宝仙盆']) assert.equal(titleKey({title}),title); +}); diff --git a/internal/webui/library-tools.css b/internal/webui/library-tools.css new file mode 100644 index 0000000..81dfc30 --- /dev/null +++ b/internal/webui/library-tools.css @@ -0,0 +1,42 @@ +.ranking-dialog{color:var(--text);background:var(--panel);border:1px solid var(--line);border-radius:12px;width:min(720px,calc(100vw - 20px));height:min(700px,84dvh);max-height:calc(100dvh - 20px);padding:0;overflow:hidden} +.ranking-dialog::backdrop{background:#10151080} +.ranking-dialog[open]{display:flex;flex-direction:column} +.ranking-header{padding:16px 18px 12px;flex:none;border-bottom:1px solid var(--line)} +.ranking-header h2{font-size:18px} +.ranking-header .heading{flex-wrap:nowrap} +.ranking-heading-actions{display:flex;gap:6px;flex:none} +.ranking-controls{display:flex;align-items:center;gap:10px;margin-top:10px;min-width:0} +.ranking-controls select{width:84px;flex:none;font-size:12px;padding:6px} +.ranking-tabs{display:flex;gap:4px;min-width:0;overflow-x:auto;scrollbar-width:thin;padding:3px} +.ranking-tabs button{white-space:nowrap;flex:none;font-size:12px;padding:6px 9px;background:transparent;color:var(--muted);border-color:transparent} +.ranking-tabs button[aria-selected=true]{background:var(--brand);color:var(--on-brand);border-color:var(--brand)} +.ranking-summary{margin:7px 0 0;font-size:12px;line-height:1.5;color:var(--muted)} +.ranking-content{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding:0 18px} +.ranking-list{list-style:none;padding:0;margin:0} +.ranking-row{display:grid;grid-template-columns:30px minmax(0,1fr) auto;gap:10px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)} +.ranking-number{font-size:17px;font-variant-numeric:tabular-nums;text-align:center;color:var(--muted);font-weight:600} +.ranking-number.top{color:var(--text)} +.ranking-title{font-weight:600;line-height:1.5;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.ranking-meta{font-size:12px;color:var(--muted);line-height:1.5;margin-top:2px;overflow-wrap:anywhere} +.ranking-actions{display:flex;gap:5px;align-items:center} +.ranking-actions button{padding:5px 8px;font-size:12px;white-space:nowrap} +.ranking-footer{flex:none;padding:12px 18px;border-top:1px solid var(--line)} +.ranking-footer-row{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:30px} +.ranking-footer-row .small{font-size:12px;min-width:0} +.ranking-footer button{font-size:12px;padding:5px 9px;white-space:nowrap} +.ranking-error,.ranking-action-status{font-size:12px;line-height:1.5;margin:4px 0 0;overflow-wrap:anywhere} +.ranking-error:empty,.ranking-action-status:empty{display:none} +.ranking-state{padding:28px 12px;text-align:center;color:var(--muted)} +.ranking-dialog [hidden]{display:none!important} +@media(max-width:600px){ + .ranking-header{padding:10px 10px 8px} + .ranking-controls{gap:5px} + .ranking-controls select{width:69px} + .ranking-tabs{gap:2px} + .ranking-tabs button{padding:6px 7px} + .ranking-content{padding:0 10px} + .ranking-row{grid-template-columns:25px minmax(0,1fr) auto;gap:7px;padding:10px 0} + .ranking-actions{flex-direction:column;gap:4px} + .ranking-actions button{font-size:12px;padding:5px 7px;min-width:52px} + .ranking-footer{padding:7px 10px 9px} +} diff --git a/internal/webui/library.css b/internal/webui/library.css new file mode 100644 index 0000000..56ada4a --- /dev/null +++ b/internal/webui/library.css @@ -0,0 +1,128 @@ +.library-toolbar{display:flex;align-items:center;gap:18px;margin-bottom:10px} +.library-search{display:flex;gap:6px;flex:1;min-width:230px;max-width:960px} +.library-search>button{flex:none;white-space:nowrap;padding:6px 11px} +.search-field{display:flex;align-items:center;gap:8px;flex:1;min-width:0;border:1px solid var(--line);background:var(--panel);border-radius:var(--control-radius);padding:0 10px;min-height:36px} +.search-field>svg{color:var(--muted);width:16px;height:16px} +.search-field:focus-within{border-color:var(--brand-text);box-shadow:0 0 0 2px var(--tint)} +.search-field input{border:0;background:none;outline:0;width:100%;padding:6px 0;min-height:34px;min-width:0;box-shadow:none} +.search-field input:focus-visible{outline:none} +.search-field input::-webkit-search-cancel-button{display:none} +.search-field .icon-button{width:26px;min-width:26px;height:28px;min-height:28px} +.recent-searches{display:flex;align-items:center;gap:5px;flex-wrap:wrap;padding:0 0 8px} +.recent-searches button{background:var(--panel);color:var(--muted);border-color:var(--line);font-size:11px;padding:4px 8px;min-height:28px} +.recent-searches .small{margin-right:3px} +.catalog-controls{display:flex;align-items:center;gap:6px;margin-left:auto;min-width:0}.catalog-action{flex:none;font-size:12px;padding:5px 8px;white-space:nowrap}.catalog-action svg{width:15px;height:15px}.catalog-action[aria-pressed=true]{background:var(--tint);color:var(--brand-text)} +.catalog-select{position:relative;display:block;min-width:0} +.catalog-select select{appearance:none;-webkit-appearance:none;cursor:pointer;padding:6px 29px 6px 10px;background:transparent;border-color:transparent;width:122px;max-width:170px;text-overflow:ellipsis;color:var(--text)} +.catalog-select svg{position:absolute;right:8px;top:50%;transform:translateY(-50%);width:13px;height:13px;pointer-events:none;color:var(--muted)} +.catalog-select select:focus-visible{outline-offset:1px} +.library-updated{margin-left:auto;white-space:nowrap;font-size:11px} +.library-feedback{display:flex;gap:8px;align-items:center;margin:0 0 8px;padding:7px 10px;border-radius:6px;background:var(--tint)} +.library-feedback>span{flex:1;min-width:0;overflow-wrap:anywhere} +.library-feedback button{flex:none} +.catalog-heading{margin-bottom:10px;min-height:30px;padding-top:2px} +.catalog-heading h2{font-size:15px;white-space:nowrap} +.catalog-heading>.row{gap:3px;min-width:0;margin-left:auto}.catalog-heading>h2{margin-right:10px;flex:none}.catalog-heading>.continue-section{flex:1;min-width:0;margin:0;padding:0;border:0;gap:8px}.catalog-heading .continue-section h2{font-size:11px}.catalog-heading .continue-list{max-width:100%}.catalog-heading .continue-section .section-heading button{padding:2px;min-width:24px;min-height:28px}.catalog-heading .continue-section svg{width:12px;height:12px} +.catalog-heading .quiet{font-size:12px;min-height:30px;padding:4px 8px} +.catalog-heading .quiet svg{width:15px;height:15px} +.catalog-content{min-width:0} +.cards{overflow-anchor:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:18px 14px;align-items:start} +.card{min-width:0;position:relative;border-radius:8px} +.card.selected{outline:2px solid var(--brand-text);outline-offset:4px} +.poster{position:relative;display:block;width:100%;aspect-ratio:3/4;min-height:0;border:0;border-radius:8px;padding:0;overflow:hidden;background:var(--poster);color:#fff;box-shadow:0 1px 3px #10201818;isolation:isolate} +.poster:after{content:"";position:absolute;inset:0;border:1px solid #1020180c;border-radius:inherit;pointer-events:none} +.cover{display:block;width:100%;height:100%;object-fit:cover} +.cover.placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:var(--poster);color:var(--poster-ink);font-size:32px;font-weight:550;letter-spacing:.08em} +.cover.placeholder>span{position:relative;padding:20px 8px;border-top:1px solid var(--line-strong);border-bottom:1px solid var(--line-strong)} +.cover.placeholder small{font-size:11px;font-weight:400;letter-spacing:.16em} +.poster-badge{position:absolute;left:9px;top:9px;padding:2px 6px;border-radius:4px;background:#17251dc9;color:#fff;font-size:10px;font-weight:500;line-height:1.5;letter-spacing:.02em} +.poster-caption{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:5px;padding:23px 10px 9px;background:linear-gradient(transparent,#101a12ae);color:#fff;font-size:11px;font-weight:500;line-height:1.5;text-align:left} +.poster-caption svg{width:13px;height:13px;fill:currentColor;stroke-width:1.4} +.poster-caption .poster-category{margin-left:auto;min-width:0;max-width:70%;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;font-size:10px;font-weight:400;color:#ffffffe8} +.poster.has-progress .poster-caption{background:linear-gradient(transparent,#143e2bd9)}.poster:has(.cover.placeholder) .poster-caption{background:none;color:var(--poster-ink);padding-top:8px}.poster:has(.cover.placeholder) .poster-category{color:var(--muted)} +.poster-progress{position:absolute;left:0;bottom:0;right:0;height:3px;background:#ffffff33} +.poster-progress>span{display:block;height:100%;background:#fff} +.card-body{padding-top:6px} +.card-title{display:block;width:100%;min-height:22px;background:none;border:0;color:var(--text);padding:0;text-align:left;font-weight:600;font-size:14px;line-height:22px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;border-radius:3px} +.card-footer{display:flex;gap:3px;align-items:center;min-width:0} +.card .meta{flex:1;min-width:0;display:block;color:var(--muted);font-size:11px;line-height:18px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} +.card-actions{display:flex;gap:0;flex:none;margin-right:-4px} +.card-actions .icon-button{width:28px;min-width:28px;height:28px;min-height:28px;border-color:transparent;background:transparent;color:var(--muted);border-radius:5px} +.card-actions svg{width:15px;height:15px} +.save-button{flex:none;background:transparent;border-color:var(--line);color:var(--muted)} +.save-button[aria-pressed=true]{background:var(--tint);color:var(--brand-text);border-color:transparent} +.save-button[aria-pressed=true] svg{fill:currentColor;stroke-width:1.6} +.card .save-button[aria-pressed=true]{color:var(--brand-text);background:var(--tint)} +.card-select{display:none;align-items:center;justify-content:center;position:absolute;z-index:2;top:6px;left:6px;width:36px;height:36px;border:1px solid var(--line);border-radius:6px;background:var(--panel);cursor:pointer} +.library-batch .card-select{display:flex} +.library-batch .poster-badge{display:none} +.batch-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:fit-content;max-width:100%;margin:16px auto 0;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:var(--shadow)} +.batch-bar .small{white-space:nowrap} +.batch-range{display:flex;gap:2px;align-items:center} +.batch-range .quiet{font-size:12px;padding:5px 7px} +.batch-bar button{font-size:12px;white-space:nowrap} +.continue-section{display:flex;align-items:center;gap:12px;padding:5px 0 8px;margin-bottom:5px;border-bottom:1px solid var(--line);min-width:0} +.continue-section>.section-heading{display:flex;gap:4px;flex:none;margin:0} +.continue-section h2{font-size:12px;font-weight:500;white-space:nowrap;color:var(--muted)} +.continue-section>.section-heading button{min-width:26px;min-height:28px;padding:2px;color:var(--muted)} +.continue-section>.section-heading svg{width:13px;height:13px} +.continue-list{display:flex;gap:8px;flex:1;min-width:0;overflow-x:auto;scrollbar-width:thin;padding:1px 0} +.continue-card{position:relative;flex:0 0 auto;max-width:360px;min-height:30px;padding:4px 9px;display:flex;gap:8px;align-items:center;background:var(--tint);color:var(--brand-text);border-color:transparent;border-radius:6px;text-align:left} +.continue-card strong{display:block;min-width:0;max-width:160px;font-size:12px;font-weight:550;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.continue-card .small{display:block;font-size:11px;font-weight:400;color:var(--brand-text);white-space:nowrap} +.continue-card .continue-initial,.continue-card .progress{display:none} +.continue-card .continue-play{flex:none;width:12px;height:12px;fill:currentColor;order:-1} +.continue-content{display:flex;gap:8px;align-items:center;min-width:0} +.continue-initial{width:40px;height:54px;flex:none;display:grid;place-items:center;background:var(--poster);color:var(--poster-ink);border-radius:5px;font-size:18px;font-weight:500;white-space:nowrap} +.detail-dialog{width:min(520px,calc(100vw - 30px))} +.detail-top{display:flex;align-items:flex-start;gap:15px} +.detail-cover{position:relative;width:96px;aspect-ratio:3/4;flex:none;overflow:hidden;border-radius:7px;display:grid;place-items:center;background:var(--poster);color:var(--poster-ink)} +.detail-cover img{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain} +.detail-cover-status{padding:8px;font-size:11px;line-height:1.6;text-align:center} +.detail-top h1{font-size:22px;line-height:1.45;overflow-wrap:anywhere} +.detail-meta{color:var(--muted);font-size:12px;margin-top:5px} +.detail-tags{margin-top:10px} +.detail-description{font-size:14px;line-height:1.85;color:var(--muted);margin:18px 0;white-space:pre-wrap;overflow-wrap:anywhere} +.detail-progress{font-size:12px;color:var(--brand-text);padding:9px 11px;background:var(--tint);border-left:2px solid var(--brand-text);border-radius:0 5px 5px 0;margin-top:14px} +.detail-actions{display:flex;gap:7px;margin-top:15px;flex-wrap:wrap} +.detail-actions .primary-action{flex:1;min-width:110px} +.detail-facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:18px 0;padding:16px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)} +.detail-facts dt{color:var(--muted);font-size:11px;margin-bottom:3px} +.detail-facts dd{margin:0;font-size:13px;overflow-wrap:anywhere} +.detail-secondary{display:flex;gap:7px;flex-wrap:wrap;margin-top:8px} +.detail-secondary button{font-size:12px} +.following-toolbar{display:flex;align-items:center;gap:14px;margin-bottom:16px} +.segments{display:flex;gap:0;background:transparent;border-bottom:1px solid var(--line);max-width:100%} +.segments button{background:none;border:0;border-bottom:2px solid transparent;color:var(--muted);border-radius:0;padding:7px 16px;white-space:nowrap;font-size:13px;font-weight:500} +.segments button[aria-pressed=true]{color:var(--brand-text);border-bottom-color:var(--brand);font-weight:600} +.segments span{font-size:11px;margin-left:4px} +.following-toolbar>input{margin-left:auto;width:min(300px,38%)} +.following-list{display:grid;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius)} +.following-row{display:flex;align-items:center;gap:14px;padding:12px 16px;border:0;border-bottom:1px solid var(--line);background:transparent} +.following-row:last-child{border-bottom:0} +.following-list>.empty{border:0} +.following-cover{width:40px;height:54px;flex:none;display:grid;place-items:center;overflow:hidden;background:var(--poster);color:var(--poster-ink);border-radius:5px;font-size:18px;font-weight:500;white-space:nowrap} +.following-cover img{display:block;width:100%;height:100%;object-fit:cover} +.following-title{font-size:14px;min-height:26px;text-align:left;display:block;width:100%;background:none;border:0;color:var(--text);padding:0;overflow-wrap:anywhere} +.following-description{flex:1;min-width:0} +.following-description .small{margin:2px 0} +.following-update{font-size:12px;color:var(--success)} +.following-actions{display:flex;align-items:center;gap:6px;flex:none} +.following-actions button{font-size:12px} +.page-footnote{margin-top:16px;color:var(--muted);font-size:12px} +@media(hover:hover){.catalog-select select:hover{background:var(--soft)}.card-title:hover,.following-title:hover{color:var(--brand-text);filter:none}.card-actions .icon-button:not(:disabled):hover{background:var(--tint);color:var(--brand-text)}.poster:not(:disabled):hover{filter:none;box-shadow:0 3px 12px #163b2126}.continue-card:hover{border-color:var(--line-strong)}} +@media(min-width:701px){.detail-dialog{height:100dvh;max-height:100dvh;margin:0 0 0 auto;border-radius:0}} +@media(max-width:1100px){.library-updated{display:none}.library-toolbar{gap:8px}.catalog-select select{width:108px}.cards{grid-template-columns:repeat(auto-fill,minmax(164px,1fr))}} +@media(max-width:850px){.library-toolbar{flex-wrap:wrap;gap:6px}.library-search{max-width:none;flex-basis:100%}.catalog-controls{margin-left:0;width:100%;gap:4px}.catalog-select{flex:1}.catalog-action{padding:5px 8px;min-width:34px}.catalog-action .control-label{display:none}.catalog-select select{width:100%;max-width:none;background:var(--soft)}.cards{grid-template-columns:repeat(4,minmax(0,1fr));gap:16px 12px}.continue-section{padding-top:2px}.library-toolbar{margin-bottom:6px}} +@media(max-width:700px){ + .library-toolbar{gap:4px;margin-bottom:4px}.library-search{min-width:0;gap:5px}.library-search>button{font-size:12px;padding:6px 9px}.search-field{min-height:36px;gap:6px;padding:0 9px}.search-field input{font-size:16px;padding:5px 0}.catalog-select select{font-size:12px;padding:5px 23px 5px 8px;min-height:32px}.catalog-select svg{right:6px;width:12px;height:12px}.catalog-heading{margin-bottom:8px;min-height:28px}.catalog-heading h2{font-size:14px}.catalog-heading>.row{gap:0}.catalog-heading>h2{margin-right:0}.catalog-heading{flex-wrap:nowrap;gap:6px;padding-top:0;margin-bottom:6px}.catalog-heading>.continue-section{flex:1;flex-basis:auto;padding:0;gap:4px}.catalog-heading .continue-section .section-heading button{min-height:28px;min-width:22px}.catalog-heading .continue-section h2{display:none}.catalog-heading .quiet{font-size:11px;padding:4px 6px;min-height:28px}.catalog-heading .quiet svg{width:13px;height:13px}.cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 12px}.poster{border-radius:7px}.poster-badge{left:7px;top:7px;font-size:10px}.poster-caption{padding:20px 8px 7px}.cover.placeholder{font-size:30px;gap:8px}.cover.placeholder small{font-size:10px}.card-body{padding-top:5px}.card-title{font-size:13px;line-height:21px;min-height:21px}.card-actions .icon-button{width:28px;min-width:28px}.card .meta{font-size:10px}.continue-section{gap:7px;padding-bottom:6px;margin-bottom:3px}.continue-section h2{font-size:11px}.continue-section>.section-heading{gap:0}.continue-list{gap:6px}.continue-card{padding:3px 7px;min-height:28px;gap:6px;max-width:100%}.continue-content{gap:6px}.continue-card strong{font-size:11px;max-width:112px}.continue-card .small{font-size:10px}.continue-card .continue-play{width:10px;height:10px}.batch-bar{padding:7px;gap:5px}.batch-bar .small{font-size:11px}.batch-bar button{font-size:11px;padding:5px 7px}.batch-bar .batch-range{margin-left:auto}.batch-bar .spacer{display:none}.following-toolbar{flex-wrap:wrap;gap:10px}.segments{width:100%}.segments button{flex:1;font-size:12px;padding:7px 10px}.following-toolbar>input{width:100%;margin:0}.following-row{gap:10px;padding:11px}.following-row .following-cover{width:38px;height:51px;font-size:17px}.following-title{font-size:13px}.following-actions{gap:4px;flex-direction:column}.following-actions button{font-size:11px;padding:6px 8px}.following-description .small{font-size:11px}.detail-dialog{width:100%;max-width:100%}.detail-top{gap:13px}.detail-top h1{font-size:20px}.detail-cover{width:80px}.detail-facts{grid-template-columns:repeat(2,minmax(0,1fr))} +} +@media(max-width:355px){.cards{gap:12px 10px}.catalog-heading .quiet{padding:4px 5px}.catalog-heading .quiet svg{display:none}.continue-card .small{max-width:87px;overflow:hidden;text-overflow:ellipsis}.continue-card strong{max-width:92px}.card-actions .icon-button{width:26px;min-width:26px}.batch-bar{width:100%}.batch-range{order:2;width:100%;justify-content:flex-end}.poster-caption .poster-category{max-width:60%}} +@media(pointer:coarse){.search-field input{min-height:38px}.catalog-select select{min-height:36px}.catalog-heading .quiet{min-height:36px}.card-actions{gap:2px}.card-actions .icon-button{width:34px;min-width:34px;height:34px;min-height:34px}.card-select{width:44px;height:44px}.continue-section>.section-heading button{min-height:34px;min-width:30px}.continue-card{min-height:34px}.card-title{min-height:24px;line-height:24px}.card-actions .icon-button{width:32px;min-width:32px;height:32px;min-height:32px}.catalog-action{min-height:36px}} + +.library-view-switch{display:flex;align-items:center;gap:2px;flex:none;white-space:nowrap}.library-view-switch .quiet{font-size:13px;font-weight:550;padding:4px 8px}.library-view-switch button[aria-pressed=true]{color:var(--brand-text);background:var(--tint)}.library-view-switch>span{margin:0 6px;font-size:11px}.recommend-toolbar{display:flex;align-items:center;gap:10px;width:100%;min-height:36px}.recommend-toolbar>.quiet{margin-left:auto;font-size:12px;padding:5px 8px;white-space:nowrap}.recommend-toolbar .catalog-select{flex:none}.recommend-footer{display:flex;align-items:center;gap:12px;justify-content:center;margin:18px 0}.recommend-footer button{font-size:12px;flex:none}.recommend-footer>span{overflow-wrap:anywhere}.show-recommendations .library-updated{display:none}#recommendRefreshBtn[aria-busy=true] svg{animation:library-refresh-turn .9s linear infinite}@media(max-width:700px){.library-view-switch .quiet{font-size:12px;padding:4px 6px}.library-view-switch>span{display:none}.recommend-toolbar{gap:6px}.recommend-toolbar .catalog-select select{width:94px}.recommend-toolbar>.small{font-size:11px}.recommend-footer{flex-wrap:wrap;gap:8px}.recommend-footer>span{text-align:center}} +@media(prefers-reduced-motion:reduce){#recommendRefreshBtn[aria-busy=true] svg{animation:none}} + +.cards-spacer{grid-column:1/-1;pointer-events:none;contain:strict}.cards-spacer:empty{display:block} + +.vip-badge{position:absolute;left:8px;bottom:34px;padding:3px 6px;border-radius:4px;background:#ffe0a5;color:#513410;font-size:10px;font-weight:650;line-height:1.3;max-width:calc(100% - 16px)}.vip-tag,.vip-notice{color:#815a18;background:#fff1d4}.vip-filter{white-space:nowrap;font-size:12px}.vip-filter[aria-pressed=true]{color:var(--brand-text);background:var(--tint)} diff --git a/internal/webui/library.js b/internal/webui/library.js new file mode 100644 index 0000000..32e36fb --- /dev/null +++ b/internal/webui/library.js @@ -0,0 +1,470 @@ +import { createCardViewport } from './card-viewport.js'; +import { createCoverRepair } from './cover-repair.js'; +import { createDramaRefresh } from './drama-refresh.js'; +import { createRecommendations } from './recommendations.js'; +import { icon, initial, withFocus, readPreference, savePreference } from './ui-core.js'; +import { $, element, empty, button, setMessage, api, post, valueText, firstNonEmpty, dramaTitle, watchLabel, historySuffix, normalizeSource, sourceKey, sourceLabel, categoryName, episodeCount, coverURL, tagsText, dramaSearchText, rebuildOptions, number, formatBytes, formatTime, statusText, releaseText, phaseText, progressText, progressBar, episodeLabel, groupStats } from './ui-core.js'; + +export function createLibrary(app) { + const dramas = [], selected = new Set(), byID = new Map(), cards = $('cards'); + let visibleIDs = [], libraryRevision = 0, libraryRequest = false, libraryReload = false; + let libraryUpdateRequest = false, libraryQueuedUpdate = false; + let libraryTimer, libraryBusyText = '', libraryIsLoading = false, libraryMetadataRemaining = {}; + let sortMessage = '', onlineSearchMessage = '', onlineSearchQuery = '', onlineSearchIDs = new Set(); + let searchController, searchSequence = 0, batch = false, filterTimer; + let pendingCategory = null, sourceWarning = '', readError = false; + let vipMetadataRequest = false, vipMetadataMessage = ''; + const recommendations = createRecommendations({allowed: () => app.viewer?.sources?.includes('hongguo') !== false, merge: mergeRecommendations, changed: renderDramas, switched: () => {resetOnlineSearch(); toggleBatch(false); app.shell.resetScroll();}}); + const viewport = createCardViewport({container: cards, scroller: $('workspaceMain'), create: renderCard, key: dramaCardKey, rendered: refreshFollowing, update: (card, drama) => { + card.classList.toggle('selected', selected.has(drama.id)); + card.querySelector('input[type=checkbox]').checked = selected.has(drama.id); + }}); + const searchText = new WeakMap(); + const coverRepair = createCoverRepair({get: id => byID.get(id), post, apply: (id, address, previous) => { + const drama = byID.get(id); + if (!drama) return; + drama.cover = address; + drama.coverUrl = address; + if (address === previous) { + for (const card of cards.querySelectorAll('.card')) {if (card.dataset.dramaId === id) card.retryCover?.();} + } else viewport.refresh(); + app.details.refreshCover(id, address === previous); + app.following.refreshCover(id); + }}); + const dramaRefresh = createDramaRefresh({post, apply: applyDramaRefresh, failure: (id) => { + app.details.metadataStatus(id, '资料暂未更新,已保留原有信息;稍后重新打开可重试。'); + coverRepair.touch(id); + }}); + function reconcileDrama(drama) {return coverRepair.reconcile(dramaRefresh.reconcile(drama));} + function applyDramaRefresh(drama, warning) { + const previous = byID.get(drama.id), index = dramas.findIndex(item => item.id === drama.id); + if (index >= 0) dramas[index] = drama; else dramas.push(drama); + byID.set(drama.id, drama); + libraryRevision = 0; + coverRepair.updated(drama.id); + app.details.metadataStatus(drama.id, warning); + rebuildChannels(false); + renderDramas(); + app.following.render(); + app.details.refresh(); + if (previous && coverURL(previous) === coverURL(drama)) { + for (const card of cards.querySelectorAll('.card')) {if (card.dataset.dramaId === drama.id) card.retryCover?.();} + app.details.refreshCover(drama.id, true); + app.following.refreshCover(drama.id); + } + window.dispatchEvent(new Event('jukulibrarychange')); + } + function refreshDrama(id) { + if (!['hongguo', 'huangdou', 'huangguoai', 'huangguo-video'].includes(String(id).split(':')[0])) return; + void dramaRefresh.refresh(id); + } + const renderTasks = () => app.downloads.render(); + const pollTasks = () => app.downloads.refresh(); +function searchableText(drama) {if (!searchText.has(drama)) searchText.set(drama, dramaSearchText(drama)); return searchText.get(drama);} +function filteredDramas(){ const keyword=$('searchInput').value.trim().toLowerCase();const source=$('sourceSelect').value;const channel=$('channelSelect').value;return dramas.filter(dr=>{if(!window.JukuVIP.visible(dr))return false;if(source&&sourceKey(dr)!==source)return false;const cat=categoryName(dr);if(channel&&cat!==channel)return false;return !keyword||searchableText(dr).includes(keyword)||onlineSearchQuery===keyword&&onlineSearchIDs.has(dr.id);}); } + +function rebuildSources(){ rebuildOptions($('sourceSelect'),app.viewer?.sources || ['huangguo','huangdou','hongguo'],'全部站源',sourceLabel,false); } + +function rebuildChannels(reset){ const source=$('sourceSelect').value;const values=Array.from(new Set(dramas.filter(dr=>!source||sourceKey(dr)===source).map(categoryName))).sort((left,right)=>left.localeCompare(right,'zh-Hans-CN'));rebuildOptions($('channelSelect'),values,'全部分类',value=>value,reset);updateRefreshLabel(); } + +function updateLibraryButton() { + const source = recommendations.enabled ? 'hongguo' : $('sourceSelect').value, hongguo = !source || source === 'hongguo'; + const button = $('refreshBtn'), label = $('refreshBtnLabel'); + const busy = libraryUpdateRequest || libraryIsLoading; + const text = busy ? '更新中…' : '更新剧库'; + if (label.textContent !== text) label.textContent = text; + if (button.disabled !== busy) button.disabled = busy; + if (button.getAttribute('aria-busy') !== String(busy)) button.setAttribute('aria-busy', String(busy)); + button.setAttribute('aria-label', (busy ? '正在更新' : '更新') + (source ? sourceLabel(source) : '全部站源') + '剧库'); + button.title = '检查新内容' + (hongguo ? '并继续加载历史目录' : '') + ',后台分批补充缺失资料;优先当前筛选结果' + (libraryMetadataRemaining[source] ? ',待检查 ' + libraryMetadataRemaining[source] + ' 部' : ''); +} + +function metadataPriorityIDs(){const pending=new Set(dramas.filter(drama=>!drama.sortMetadata||drama.sortMetadata.version!==(['huangguo','huangguoai','huangguoai.com'].includes(String(drama.source||drama.id).split(':')[0])?2:1)||sourceKey(drama)==='huangdou'&&drama.vip==null&&!drama.sortMetadata.vipChecked||sourceKey(drama)==='hongguo'&&!coverURL(drama)&&!drama.sortMetadata.coverChecked).map(drama=>drama.id));return visibleIDs.filter(id=>pending.has(id)).slice(0,40);} + +function updateRefreshLabel(){ const source=$('sourceSelect').value;$('vipFilterBtn').hidden=app.viewer?.sources?.includes('huangdou')===false||Boolean(source&&source!=='huangdou');const hongguo=app.viewer?.sources?.includes('hongguo')!==false&&(!source||source==='hongguo');updateLibraryButton();$('onlineSearchBtn').hidden=!hongguo;$('searchInput').placeholder='搜索剧名、简介或标签';$('onlineSearchBtn').title='联网搜索红果,也可按回车'; } + +function placeholder(text){ return element('div','cover placeholder',text||'暂无封面'); } + +function dramaMetaText(drama) { + const history = window.JukuHistory.progressText(drama.id); + const count = episodeCount(drama); + const status = drama.releaseStatus === 'finished' ? '已完结' : drama.releaseStatus === 'ongoing' ? '连载中' : ''; + return [history || (count ? count + ' 集' : '集数未知'), history ? '' : status].filter(Boolean).join(' · '); +} + +function dramaCardKey(drama) { + return JSON.stringify([dramaTitle(drama), sourceKey(drama), categoryName(drama), episodeCount(drama), coverURL(drama), drama.releaseStatus, drama.vip]); +} + +function renderCard(drama) { + const title = dramaTitle(drama); + const card = element('article', 'card' + (selected.has(drama.id) ? ' selected' : '')); + card.dataset.dramaId = drama.id; + card.renderKey = dramaCardKey(drama); + const label = element('label', 'card-select'); + label.dataset.downloadOnly = ''; + const checkbox = element('input'); + checkbox.type = 'checkbox'; + checkbox.checked = selected.has(drama.id); + checkbox.dataset.focusKey = 'select-' + drama.id; + checkbox.setAttribute('aria-label', '选择 ' + title); + checkbox.addEventListener('change', () => { + if (checkbox.checked) selected.add(drama.id); else selected.delete(drama.id); + card.classList.toggle('selected', checkbox.checked); + updateDramaSelection(); + }); + label.appendChild(checkbox); + const hasProgress = Boolean(window.JukuHistory.get(drama.id)); + const poster = button('', () => app.play(drama.id, title), false, 'poster watch-button' + (hasProgress ? ' has-progress' : '')); + poster.setAttribute('aria-label', (hasProgress ? '继续观看 ' : '播放 ') + title); + poster.dataset.focusKey = 'play-' + drama.id; + const cover = coverURL(drama); + const fallback = element('span', 'cover placeholder'); + fallback.setAttribute('aria-hidden', 'true'); + fallback.append(element('span', '', initial(title)), element('small', '', cover ? '海报加载失败' : '暂无海报')); + if (cover) { + const image = element('img', 'cover'); + image.alt = ''; + image.loading = 'lazy'; + image.decoding = 'async'; + image.addEventListener('error', () => {image.replaceWith(fallback); coverRepair.failed(drama.id, cover);}); + image.addEventListener('load', () => coverRepair.loaded(drama.id, cover)); + image.src = cover; + card.retryCover = () => {if (fallback.isConnected) {image.src = cover; fallback.replaceWith(image);}}; + poster.appendChild(image); + } else poster.appendChild(fallback); + poster.appendChild(element('span', 'poster-badge', sourceLabel(sourceKey(drama)))); + if (window.JukuVIP.isVIP(drama)) poster.appendChild(element('span', 'vip-badge', 'VIP')); + const caption = element('span', 'poster-caption'); + caption.setAttribute('aria-hidden', 'true'); + caption.append(icon('play'), element('span', 'watch-label', hasProgress ? '续播' : '播放'), element('span', 'poster-category', categoryName(drama))); + const progress = element('span', 'poster-progress'); + progress.setAttribute('aria-hidden', 'true'); + progress.appendChild(element('span')); + progress.hidden = true; + poster.append(caption, progress); + const body = element('div', 'card-body'); + const name = button(title, () => app.details.open(drama.id), false, 'card-title'); + name.title = '查看详情 · ' + title; + name.setAttribute('aria-label', '查看 ' + title + ' 详情'); + name.dataset.focusKey = 'title-' + drama.id; + const meta = element('div', 'meta', dramaMetaText(drama)); + meta.title = dramaMetaText(drama); + const footer = element('div', 'card-footer'); + const actions = element('div', 'card-actions'); + const download = button('', async () => { + if (download.disabled) return; + download.disabled = true; + try {await app.downloads.enqueue([drama.id]);} finally {download.disabled = false;} + }, false, 'quiet icon-button card-download'); + download.appendChild(icon('download')); + download.title = '加入下载'; + download.setAttribute('aria-label', '下载 ' + title); + download.dataset.focusKey = 'download-' + drama.id; + actions.appendChild(app.following.saveButton(drama.id, title)); + if (!app.viewer?.onlineOnly) actions.appendChild(download); + footer.append(meta, actions); + body.append(name, footer); + card.append(label, poster, body); + return card; +} + +function updateDramaSelection() { + $('dramaSelection').textContent = '已选 ' + selected.size + ' 部'; + $('enqueueBtn').disabled = !selected.size; +} + +function renderDramas() { + clearTimeout(filterTimer); + for (const id of selected) {if (!byID.has(id) || !window.JukuVIP.visible(byID.get(id))) selected.delete(id);} + const filtered = filteredDramas(); + const mode = $('sortSelect').value; + const list = recommendations.enabled ? recommendations.items().map(drama => byID.get(drama.id) || drama) : window.JukuLibrarySort.sortDramas(filtered, mode); + sortMessage = recommendations.enabled ? '' : window.JukuLibrarySort.summary(filtered, mode); + $('sortSelect').title = '缺少数据的剧集排在后面;不同站源的统计口径可能不同。' + sortMessage; + visibleIDs = list.map(drama => drama.id); + $('dramaCount').textContent = recommendations.enabled ? list.length + ' 部' : list.length === dramas.length ? list.length + ' 部' : list.length + ' / ' + dramas.length + ' 部'; + updateDramaSelection(); + updateLibraryStatus(); + refreshFilterSummary(); + let state = null; + if (!list.length && recommendations.enabled) { + state = element('div', 'empty', '红果分类推荐会显示在这里。'); + } else if (!list.length) { + state = element('div', 'empty'); + state.append(element('strong', '', dramas.length ? '没有找到匹配的剧集' : '剧库还没有内容'), element('p', '', dramas.length ? '换个关键词,或清除当前筛选。' : '更新剧库即可获取可用站源的内容。')); + state.appendChild(button(dramas.length ? '清除筛选' : '更新剧库', () => dramas.length ? resetFilters() : loadDramas(true), false, 'secondary')); + } + viewport.setItems(list, state); +} + +async function loadDramas(update) { + if (libraryRequest) { + libraryReload = true; + if (update) {libraryQueuedUpdate = true; libraryUpdateRequest = true; updateLibraryButton();} + return; + } + libraryRequest = true; + libraryUpdateRequest = Boolean(update); + clearTimeout(libraryTimer); + updateLibraryButton(); + const query = update ? '?update=1&source=' + encodeURIComponent(recommendations.enabled ? 'hongguo' : $('sourceSelect').value) + '&priority=' + encodeURIComponent(metadataPriorityIDs().join(',')) : '?revision=' + libraryRevision; + try { + const result = await api('/api/ui/dramas' + query); + readError = false; + libraryRevision = result.revision || 0; + libraryIsLoading = Boolean(result.loading || result.metadata?.running); + libraryMetadataRemaining = result.metadataRemaining || {}; + app.shell.sourceStates(result.sources, result.loading); + sourceWarning = result.error ? '部分站源尚未完成,可在“更多”查看状态' : ''; + if (Array.isArray(result.data)) { + dramas.length = 0; + byID.clear(); + for (let drama of result.data) {drama = reconcileDrama(drama); dramas.push(drama); byID.set(drama.id, drama);} + for (const drama of recommendations.all()) {if (!byID.has(drama.id)) {dramas.push(drama); byID.set(drama.id, drama);}} + rebuildSources(); + rebuildChannels(false); + if (pendingCategory !== null) { + const found = Array.from($('channelSelect').options).some(option => option.value === pendingCategory); + if (found) $('channelSelect').value = pendingCategory; + if (found || !result.loading) pendingCategory = null; + } + renderDramas(); + app.following.render(); + app.details.refresh(); + window.dispatchEvent(new Event('jukulibrarychange')); + } + const loaded = result.loadedAt && !result.loadedAt.startsWith('0001') ? new Date(result.loadedAt).toLocaleString() : ''; + $('libraryUpdatedAt').textContent = loaded ? '更新于 ' + loaded : ''; + $('libraryUpdatedAt').title = $('libraryUpdatedAt').textContent; + $('libraryMenuUpdatedAt').textContent = $('libraryUpdatedAt').textContent; + const metadata = result.metadata || {}; + if (!metadata.running && !vipMetadataRequest) vipMetadataMessage = ''; + libraryBusyText = result.loading ? '正在更新剧库,已有内容可继续使用' : metadata.running ? '后台补充资料 ' + metadata.checked + ' / ' + metadata.total : ''; + updateLibraryStatus(); + if (result.loading || metadata.running) libraryTimer = setTimeout(() => loadDramas(false), result.loading ? 1200 : 5000); + } catch (error) { + libraryIsLoading = false; + readError = true; + libraryBusyText = '剧库读取失败:' + error.message; + if (!dramas.length) renderDramas(); + updateLibraryStatus(); + } finally { + libraryRequest = false; + if (libraryReload) { + const queuedUpdate = libraryQueuedUpdate; + libraryReload = false; + libraryQueuedUpdate = false; + loadDramas(queuedUpdate); + } else { + libraryUpdateRequest = false; + updateLibraryButton(); + } + } +} + +function mergeRecommendations(items) { + const positions = new Map(dramas.map((drama, index) => [drama.id, index])); + for (let drama of items) { + drama = reconcileDrama(drama); + const index = positions.get(drama.id); + if (index === undefined) {positions.set(drama.id, dramas.length); dramas.push(drama);} else dramas[index] = drama; + byID.set(drama.id, drama); + } + libraryRevision = 0; + rebuildSources(); + rebuildChannels(false); + app.following.render(); + app.details.refresh(); +} + +function updateLibraryStatus() { + const source = $('sourceSelect').value; + const vip = recommendations.enabled ? '' : window.JukuVIP.summary(dramas.filter(drama => (!source || sourceKey(drama) === source))); + const text = [libraryBusyText, onlineSearchMessage, sourceWarning, vip, vipMetadataMessage, sortMessage].filter(Boolean).join(' · '); + $('libraryStatus').textContent = text; + $('libraryFeedback').hidden = !text; + $('retryLibraryBtn').hidden = !readError; +} + +async function refreshVIPMetadata(priority = visibleIDs) { + if (vipMetadataRequest || app.viewer?.sources?.includes('huangdou') === false) return; + vipMetadataRequest = true; + vipMetadataMessage = '正在检查 VIP 状态'; + updateLibraryStatus(); + try { + const ids = priority.filter(id => id.startsWith('huangdou:')).slice(0, 40); + const result = await post('/api/ui/vip/metadata', {priority: ids}); + vipMetadataMessage = result.pending ? '后台分批识别 VIP,也可点开剧集立即补充资料' : result.remaining ? '尚未识别的内容暂保留,可稍后点击“更新剧库”重试' : ''; + await loadDramas(false); + } catch (_) {vipMetadataMessage = 'VIP 状态暂未补齐,可点击“更新剧库”重试';} + finally {vipMetadataRequest = false; updateLibraryStatus();} +} + +function resetOnlineSearch(){searchSequence++;if(searchController)searchController.abort();searchController=null;onlineSearchQuery='';onlineSearchIDs.clear();onlineSearchMessage='';$('onlineSearchBtn').disabled=false;$('onlineSearchBtn').textContent='联网搜索';updateLibraryStatus();} + +async function searchOnline(){ + rememberSearch($('searchInput').value); + if(app.viewer?.sources?.includes('hongguo')===false||$('sourceSelect').value&&$('sourceSelect').value!=='hongguo')return; + const keyword=$('searchInput').value.trim();if(!keyword){setMessage('请先输入搜索词',true);return;}if(searchController)return; + resetOnlineSearch();const sequence=searchSequence;const controller=new AbortController();searchController=controller; + $('onlineSearchBtn').disabled=true;$('onlineSearchBtn').textContent='搜索中';onlineSearchMessage='正在联网搜索红果';updateLibraryStatus(); + try{ + const result=await api('/api/ui/search?q='+encodeURIComponent(keyword),{signal:controller.signal}); + if(sequence!==searchSequence||keyword!==$('searchInput').value.trim())return; + const matches=(Array.isArray(result.data)?result.data:[]).filter(drama=>sourceKey(drama)==='hongguo'); + onlineSearchQuery=keyword.toLowerCase();onlineSearchIDs=new Set(matches.map(drama=>drama.id)); + + const positions=new Map(dramas.map((drama,index)=>[drama.id,index]));for(let drama of matches){drama=reconcileDrama(drama);const position=positions.get(drama.id);if(position===undefined){positions.set(drama.id,dramas.length);dramas.push(drama);}else dramas[position]=drama;} + onlineSearchMessage=matches.length?'联网返回 '+matches.length+' 部红果'+(result.total>matches.length?'(首批匹配)':''):'联网暂无匹配'; + if(matches.length&&result.saved===false)onlineSearchMessage+=',缓存未保存'; + for(const drama of dramas)byID.set(drama.id,drama);rebuildSources();rebuildChannels(false);renderDramas();app.following.render();updateLibraryStatus();libraryRevision=0;await loadDramas(false); + }catch(error){if(sequence===searchSequence&&!controller.signal.aborted){onlineSearchMessage='联网搜索暂不可用:'+error.message+';可重试,本地筛选仍可使用';updateLibraryStatus();}} + finally{if(sequence===searchSequence){searchController=null;$('onlineSearchBtn').disabled=false;$('onlineSearchBtn').textContent='联网搜索';}} + } + +async function enqueueSelected() { + if (!selected.size) {setMessage('请先选择剧集', true); return;} + $('enqueueBtn').disabled = true; + const ok = await app.downloads.enqueue(Array.from(selected)); + if (ok) toggleBatch(false); + updateDramaSelection(); +} + + + +function refreshFilterSummary() { + for (const id of ['sourceSelect', 'channelSelect']) $(id).title = $(id).selectedOptions[0]?.textContent || ''; + $('resetFiltersBtn').hidden = recommendations.enabled || !$('searchInput').value && !$('channelSelect').value && $('sortSelect').value === 'default' && ['', 'hongguo'].includes($('sourceSelect').value); + $('clearSearchBtn').hidden = !$('searchInput').value; +} + +function persistFilters() { + savePreference('libraryFilters', {source: $('sourceSelect').value, category: $('channelSelect').value, sort: $('sortSelect').value, search: $('searchInput').value}); +} + +function resetFilters() { + resetOnlineSearch(); + $('sourceSelect').value = ''; + $('searchInput').value = ''; + $('sortSelect').value = 'default'; + pendingCategory = null; + rebuildChannels(true); + renderDramas(); + persistFilters(); + app.shell.resetScroll(); +} + +function toggleBatch(value) { + batch = value; + if (!batch) selected.clear(); + document.body.classList.toggle('library-batch', batch); + $('libraryBatchBar').hidden = !batch; + $('batchSelectBtn').setAttribute('aria-pressed', String(batch)); + $('batchSelectLabel').textContent = batch ? '退出批量' : '批量选择'; + $('batchSelectBtn').setAttribute('aria-label', batch ? '退出批量选择' : '批量选择'); + $('batchSelectBtn').title = batch ? '退出批量选择' : '批量选择'; + renderDramas(); +} + +function refreshFollowing(nodes = cards.querySelectorAll('.card')) { + for (const card of nodes) { + const id = card.dataset.dramaId, drama = byID.get(id); + if (!drama) continue; + const save = card.querySelector('[data-save-id]'); + if (save) { + const saved = Boolean(app.following.get(id)?.saved); + save.setAttribute('aria-pressed', String(saved)); + save.setAttribute('aria-label', (saved ? '取消收藏 ' : '收藏 ') + dramaTitle(drama)); + save.title = saved ? '取消收藏' : '加入想看'; + save.disabled = app.following.busy(id); + } + const history = window.JukuHistory.get(id); + const watch = card.querySelector('.watch-button'); + watch.querySelector('.watch-label').textContent = history ? '续播' : '播放'; + watch.classList.toggle('has-progress', Boolean(history)); + watch.setAttribute('aria-label', (history ? '继续观看 ' : '播放 ') + dramaTitle(drama)); + const progress = watch.querySelector('.poster-progress'); + progress.hidden = !(history?.duration > 0); + progress.firstElementChild.style.width = history?.duration > 0 ? Math.min(100, Math.max(0, history.position / history.duration * 100)) + '%' : '0%'; + card.querySelector('.meta').textContent = dramaMetaText(drama); + card.querySelector('.meta').title = dramaMetaText(drama); + } +} + +function searchHistory() { + const items = readPreference('recentSearches', []); + return Array.isArray(items) ? items.filter(item => typeof item === 'string' && item.length <= 80).slice(0, 10) : []; +} + +function rememberSearch(raw) { + const query = raw.trim().slice(0, 80); + if (query) savePreference('recentSearches', [query, ...searchHistory().filter(item => item !== query)].slice(0, 10)); + $('recentSearches').hidden = true; +} + +function renderSearchHistory() { + const list = searchHistory(), target = $('recentSearches'); + target.replaceChildren(); + target.hidden = !list.length; + if (!list.length) return; + target.appendChild(element('span', 'small', '最近搜索')); + list.forEach(query => target.appendChild(button(query, () => { + $('searchInput').value = query; + resetOnlineSearch(); + renderDramas(); + persistFilters(); + target.hidden = true; + }, false, 'secondary'))); + target.appendChild(button('清除记录', () => {savePreference('recentSearches', []); target.hidden = true;}, false, 'quiet')); +} + +function init() { + recommendations.init(); + window.addEventListener('jukuvipfilterchange', event => {renderDramas(); app.shell.resetScroll(); if (event.detail?.interactive) void refreshVIPMetadata(event.detail.priority || visibleIDs);}); + rebuildSources(); + const stored = readPreference('libraryFilters', {}); + const saved = stored && typeof stored === 'object' ? stored : {}; + const allowedSources = app.viewer?.sources || ['huangguo','huangdou','hongguo']; + $('sourceSelect').value = ['', ...allowedSources].includes(saved.source) ? saved.source : allowedSources.includes('hongguo') ? 'hongguo' : allowedSources[0] || ''; + $('sortSelect').value = window.JukuLibrarySort.modes.includes(saved.sort) ? saved.sort : 'default'; + $('searchInput').value = typeof saved.search === 'string' ? saved.search.slice(0, 80) : ''; + pendingCategory = typeof saved.category === 'string' ? saved.category : null; + rebuildChannels(false); + refreshFilterSummary(); + const filters = () => {persistFilters(); renderDramas(); app.shell.resetScroll();}; + $('refreshBtn').addEventListener('click', () => loadDramas(true)); + $('retryLibraryBtn').addEventListener('click', () => loadDramas(false)); + $('searchInput').addEventListener('input', () => { + resetOnlineSearch(); + persistFilters(); + refreshFilterSummary(); + clearTimeout(filterTimer); + filterTimer = setTimeout(() => {renderDramas(); app.shell.resetScroll();}, 90); + }); + $('searchInput').addEventListener('focus', renderSearchHistory); + $('searchInput').addEventListener('blur', () => setTimeout(() => { + if (!$('recentSearches').contains(document.activeElement)) $('recentSearches').hidden = true; + }, 150)); + $('searchInput').addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.isComposing) {event.preventDefault(); searchOnline();} + }); + $('clearSearchBtn').addEventListener('click', () => {$('searchInput').value = ''; resetOnlineSearch(); filters(); $('searchInput').focus();}); + $('onlineSearchBtn').addEventListener('click', searchOnline); + $('sourceSelect').addEventListener('change', () => {pendingCategory = null; resetOnlineSearch(); rebuildChannels(true); filters();}); + $('channelSelect').addEventListener('change', filters); + $('sortSelect').addEventListener('change', filters); + $('resetFiltersBtn').addEventListener('click', resetFilters); + $('batchSelectBtn').addEventListener('click', () => toggleBatch(!batch)); + $('finishBatchBtn').addEventListener('click', () => toggleBatch(false)); + $('selectVisibleBtn').addEventListener('click', () => {visibleIDs.forEach(id => selected.add(id)); renderDramas();}); + $('invertVisibleBtn').addEventListener('click', () => {visibleIDs.forEach(id => selected.has(id) ? selected.delete(id) : selected.add(id)); renderDramas();}); + $('deselectDramasBtn').addEventListener('click', () => {selected.clear(); renderDramas();}); + $('enqueueBtn').addEventListener('click', enqueueSelected); + for (let index = 0; index < 12; index++) {const item = element('div', 'skeleton-card'); item.setAttribute('aria-hidden', 'true'); cards.appendChild(item);} + return loadDramas(false); +} + +return {init, refreshDrama, layout: viewport.refresh, get: id => byID.get(id), all: () => dramas, render: renderDramas, refreshFollowing, repairCover: coverRepair.touch, coverFailed: coverRepair.failed, coverLoaded: coverRepair.loaded, refresh: () => {libraryRevision = 0; return loadDramas(false);}, retryCovers: () => {cards.querySelectorAll('.card').forEach(card => card.retryCover?.()); app.details.retryCover(); app.following.retryCovers();}}; + +} diff --git a/internal/webui/main.js b/internal/webui/main.js new file mode 100644 index 0000000..9c2c9c9 --- /dev/null +++ b/internal/webui/main.js @@ -0,0 +1,82 @@ +import { api, post, sourceLabel, normalizeSource, setMessage } from './ui-core.js'; +import { createShell } from './shell.js'; +import { createLibrary } from './library.js'; +import { createDownloads } from './downloads.js'; +import { createSettings } from './settings.js'; +import { createFollowing } from './following.js'; +import { createDetails } from './details.js'; +import { initializeViewer } from './viewer.js'; +import { createAccount } from './account.js'; +import { createUsers } from './users.js'; +import { createEmby } from './emby.js'; + +const app = {api, post}; +app.play = (id, title) => { + if (window.JukuHistory.get(id)) window.dramaPlayer.openHistory(id, title); + else window.dramaPlayer.open(id, title); + app.following.acknowledge(id); + app.library.refreshDrama(id); +}; +app.shell = createShell(app); +app.library = createLibrary(app); +app.downloads = createDownloads(app); +app.settings = createSettings(app); +app.following = createFollowing(app); +app.details = createDetails(app); +app.account = createAccount(app); +app.users = createUsers(app); +app.emby = createEmby(app); + +app.shell.init(); +window.addEventListener('jukuviewerchange', () => { + document.getElementById('viewerNotice').textContent = '账号权限已变化,正在重新加载…'; + document.getElementById('viewerNotice').hidden = false; + window.location.reload(); +}); + +async function initialize() { + try { + app.viewer = await initializeViewer(); + } catch (error) { + const notice = document.getElementById('viewerNotice'); + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'secondary'; + retry.textContent = '重试'; + retry.addEventListener('click', () => window.location.reload()); + notice.replaceChildren(document.createTextNode(error.message), retry); + notice.hidden = false; + return; + } +if (app.account.init()) return; +app.users.init(); +app.emby.init(); +app.shell.access(); +window.JukuVIP.init(app.viewer?.sources?.includes('huangdou') !== false); +window.JukuHistory.init({ + api, post, + sourceLabel: value => sourceLabel(normalizeSource(value) || value), + onChanged: () => {app.library.refreshFollowing(); app.following.render(); app.details.refresh(); app.downloads.render();}, + onError: message => setMessage(message, true), + play: app.play +}); +window.JukuRankings.init({ + api, post, + getSource: () => document.getElementById('sourceSelect').value, + getDrama: id => app.library.get(id), + sourceLabel, + onLibraryChanged: () => app.library.refresh(), + canDownload: () => !app.viewer?.onlineOnly, + onDownloadsChanged: app.downloads.refresh, + getDownloadQuality: app.downloads.quality, + play: app.play +}); +Promise.allSettled([app.settings.init(), app.library.init(), app.downloads.init(), app.following.init()]).then(results => { + const failure = results.find(result => result.status === 'rejected'); + if (failure) setMessage('部分功能未能初始化:' + (failure.reason?.message || '请刷新页面重试'), true); + document.documentElement.dataset.ready = 'true'; +}); + +} + +initialize(); diff --git a/internal/webui/navigation.js b/internal/webui/navigation.js new file mode 100644 index 0000000..18493b8 --- /dev/null +++ b/internal/webui/navigation.js @@ -0,0 +1,110 @@ +(() => { + const pages = ['library', 'following', 'downloads', 'users']; + const native = new WeakMap(); + let currentPage = pages.includes(location.hash.slice(1)) ? location.hash.slice(1) : 'library'; + let active = null, pendingBack = false, pendingOpen = null, pendingPage = '', pendingClose = '', listener = null; + const state = (dialog = '', layer = '') => ({duanju: true, page: currentPage, dialog, layer}); + const url = () => location.pathname + location.search + '#' + currentPage; + history.replaceState(state(), '', url()); + + function backdropState() { + document.body.classList.toggle('modal-open', Boolean(active)); + const toast = document.getElementById('messageText'); + if (toast) (active || document.body).appendChild(toast); + } + + function finish(dialog) { + if (dialog?.open) native.get(dialog).close(); + if (active === dialog) active = null; + backdropState(); + } + + function open(dialog) { + if (typeof dialog === 'string') dialog = document.getElementById(dialog); + if (!dialog || dialog.open) return; + if (active && history.state?.layer) close(active); + const replace = Boolean(active); + if (active) finish(active); + active = dialog; + native.get(dialog).show(); + backdropState(); + if (pendingBack) pendingOpen = dialog; + else history[replace ? 'replaceState' : 'pushState'](state(dialog.id), '', url()); + } + + function close(dialog) { + if (!dialog?.open) return; + const managed = history.state?.duanju && history.state.dialog === dialog.id; + const count = history.state?.layer ? 2 : 1; + if (pendingOpen === dialog) pendingOpen = null; + finish(dialog); + if (managed && !pendingBack) { + pendingBack = true; + history.go(-count); + } else if (managed && pendingBack) pendingClose = dialog.id; + } + + function navigate(page) { + if (!pages.includes(page)) return; + if (active) close(active); + if (pendingBack) {pendingPage = page; return;} + if (page === currentPage) return; + currentPage = page; + history.pushState(state(), '', url()); + listener?.(currentPage); + } + + function layer(name, visible) { + if (!active) return; + if (visible && history.state?.layer !== name) history[history.state?.layer ? 'replaceState' : 'pushState'](state(active.id, name), '', url()); + if (!visible && history.state?.layer === name && !pendingBack) { + pendingBack = true; + history.back(); + } + } + + for (const dialog of document.querySelectorAll('dialog')) { + native.set(dialog, {show: dialog.showModal.bind(dialog), close: dialog.close.bind(dialog)}); + dialog.showModal = () => open(dialog); + dialog.close = () => close(dialog); + dialog.addEventListener('cancel', event => { + if (event.defaultPrevented) return; + event.preventDefault(); + if (history.state?.layer) history.back(); else close(dialog); + }); + dialog.addEventListener('click', event => { + if (event.target !== dialog) return; + const bounds = dialog.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) close(dialog); + }); + } + document.querySelectorAll('[data-close]').forEach(button => button.addEventListener('click', () => close(button.closest('dialog')))); + window.addEventListener('popstate', event => { + const next = event.state || {}; + if (pendingBack) { + pendingBack = false; + if (pendingClose && next.dialog === pendingClose) { + pendingClose = ''; + pendingBack = true; + history.back(); + return; + } + pendingClose = ''; + if (pendingOpen?.open) { + active = pendingOpen; + pendingOpen = null; + history.pushState(state(active.id), '', url()); + backdropState(); + return; + } + pendingOpen = null; + if (pendingPage) {const page = pendingPage; pendingPage = ''; navigate(page); return;} + } + if (active && next.dialog !== active.id) finish(active); + if (pages.includes(next.page)) currentPage = next.page; + listener?.(currentPage); + document.dispatchEvent(new CustomEvent('dialoglayerchange', {detail: next.layer || ''})); + if (next.dialog && !active) history.replaceState(state(), '', url()); + }); + window.JukuDialogs = {open, close, navigate, layer, page: () => currentPage, listen: callback => {listener = callback;}}; +})(); diff --git a/internal/webui/playback-settings.js b/internal/webui/playback-settings.js new file mode 100644 index 0000000..7a41b98 --- /dev/null +++ b/internal/webui/playback-settings.js @@ -0,0 +1,41 @@ +import { $, api, post } from './ui-core.js'; + +export function createPlaybackSettings() { + let settings = null; + const fields = ['maxSessions', 'maxMediaRequests', 'maxRemuxJobs', 'maxAudioTranscodes', 'maxVideoTranscodes', 'maxPrefetchJobs', 'cacheMB']; + function render(result) { + settings = result.settings; + $('originalPlaybackEnabled').checked = settings.enabled; + for (const name of fields) $('playback-' + name).value = settings[name]; + const sources = $('playbackDirectSources'); + sources.replaceChildren(); + for (const source of result.sources || []) { + const label = document.createElement('label'); + const input = document.createElement('input'); + input.type = 'checkbox'; + input.value = source.id; + input.checked = (settings.directSources || []).includes(source.id); + label.append(input, document.createTextNode(source.name)); + sources.appendChild(label); + } + } + async function load() { + try {render(await api('/api/ui/admin/playback'));} + catch (error) {$('playbackSettingsStatus').textContent = error.message;} + } + async function save() { + if (!settings) return; + const button = $('savePlaybackSettings'); + button.disabled = true; + try { + const value = {...settings, enabled: $('originalPlaybackEnabled').checked, + directSources: [...$('playbackDirectSources').querySelectorAll('input:checked')].map(input => input.value)}; + for (const name of fields) value[name] = Number($('playback-' + name).value); + render(await post('/api/ui/admin/playback', value)); + $('playbackSettingsStatus').textContent = '播放设置已保存,新任务按当前限制执行。'; + } catch (error) {$('playbackSettingsStatus').textContent = '保存失败:' + error.message;} + finally {button.disabled = false;} + } + function init() {$('savePlaybackSettings').onclick = save; return load();} + return {init}; +} diff --git a/internal/webui/player-capabilities.js b/internal/webui/player-capabilities.js new file mode 100644 index 0000000..796fe83 --- /dev/null +++ b/internal/webui/player-capabilities.js @@ -0,0 +1,36 @@ +(() => { + function createPlaybackCapabilities(environment, video, agent) { + const touchApple = /iPad|iPhone|iPod/i.test(agent.userAgent || '') || /Mac/i.test(agent.platform || '') && Number(agent.maxTouchPoints) > 1; + function native() { + if (touchApple) return true; + try {return ['application/vnd.apple.mpegurl', 'application/x-mpegURL'].some(type => ['maybe', 'probably'].includes(video.canPlayType(type)));} + catch (_) {return false;} + } + function mse(type) { + try {return typeof type === 'string' && Boolean(type) && typeof environment.MediaSource === 'function' && typeof environment.MediaSource.isTypeSupported === 'function' && environment.MediaSource.isTypeSupported(type);} + catch (_) {return false;} + } + function choose(type) { + if (touchApple && native()) return 'hls'; + if (mse(type)) return 'mse'; + return native() ? 'hls' : ''; + } + function remux() { + return !touchApple && ['42E02A', '4D002A', '64002A'].every(profile => mse('video/mp4; codecs="avc1.' + profile + ', mp4a.40.2"')); + } + function playable(type) { + try {return ['maybe', 'probably'].includes(video.canPlayType(type));} catch (_) {return false;} + } + function describe() { + const supported = type => playable(type) || mse(type); + const videoCodecs = {h264: 'avc1.42E01E', hevc: 'hvc1.1.6.L120.B0', av1: 'av01.0.08M.08', vp9: 'vp09.00.10.08'}; + const audioCodecs = {aac: 'mp4a.40.2', mp3: 'mp4a.69', ac3: 'ac-3', eac3: 'ec-3', opus: 'opus'}; + return {mp4: playable('video/mp4'), nativeHls: native(), hlsjs: Boolean(environment.Hls?.isSupported()), + video: Object.keys(videoCodecs).filter(key => supported('video/mp4; codecs="' + videoCodecs[key] + '"')), + audio: Object.keys(audioCodecs).filter(key => supported('audio/mp4; codecs="' + audioCodecs[key] + '"'))}; + } + return {native, mse, choose, remux, playable, describe}; + } + if (typeof module !== 'undefined' && module.exports) module.exports = createPlaybackCapabilities; + else window.JukuPlaybackCapabilities = createPlaybackCapabilities; +})(); diff --git a/internal/webui/player-capabilities.test.cjs b/internal/webui/player-capabilities.test.cjs new file mode 100644 index 0000000..61b2a45 --- /dev/null +++ b/internal/webui/player-capabilities.test.cjs @@ -0,0 +1,35 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const capabilities = require('./player-capabilities.js'); +const type = 'video/mp4; codecs="avc1.42C01F, mp4a.40.2"'; +function mse(supported) {class MediaSource {} MediaSource.isTypeSupported = () => supported; return {MediaSource};} +const native = {canPlayType: () => 'probably'}, absent = {canPlayType: () => ''}; +test('iPhone and desktop-mode iPad prefer native HLS even when MSE claims support', () => { + for (const agent of [{userAgent:'iPhone Safari'}, {userAgent:'iPad CriOS'}, {platform:'MacIntel',maxTouchPoints:5}]) { + assert.equal(capabilities(mse(true), native, agent).choose(type), 'hls'); + assert.equal(capabilities({}, absent, agent).choose(type), 'hls'); + } +}); +test('desktop MSE, native-only, and unsupported browsers are distinguished', () => { + assert.equal(capabilities(mse(true), native, {}).choose(type),'mse'); + assert.equal(capabilities(mse(false), native, {}).choose(type),'hls'); + assert.equal(capabilities({}, absent, {}).choose(type),''); + assert.equal(capabilities(mse(true), absent, {platform:'MacIntel',maxTouchPoints:0}).choose(type),'mse'); +}); +test('broken capability probes cannot prevent native HLS', () => { + const environment=mse(true);environment.MediaSource.isTypeSupported=()=>{throw new Error('probe failed');}; + assert.equal(capabilities(environment,native,{}).choose(type),'hls'); + assert.equal(capabilities({MediaSource:{}},native,{}).choose(type),'hls'); + assert.equal(capabilities({}, {canPlayType:()=> 'no'}, {}).choose(type),''); +}); + +test('remux requires compatible H.264 profiles and never replaces iOS HLS', () => { + assert.equal(capabilities(mse(true), native, {}).remux(), true); + assert.equal(capabilities(mse(false), native, {}).remux(), false); + assert.equal(capabilities(mse(true), native, {userAgent:'iPhone Safari'}).remux(), false); + const limited = mse(true); + limited.MediaSource.isTypeSupported = type => type.includes('42E0'); + assert.equal(capabilities(limited, absent, {}).remux(), false); + limited.MediaSource.isTypeSupported = () => {throw new Error('probe unavailable');}; + assert.equal(capabilities(limited, native, {}).remux(), false); +}); diff --git a/internal/webui/player-controls.js b/internal/webui/player-controls.js new file mode 100644 index 0000000..a67ae59 --- /dev/null +++ b/internal/webui/player-controls.js @@ -0,0 +1,185 @@ +(() => { + window.JukuPlayerControls = ({panel, video, ready, fullscreen, mobile = () => false, rotation = () => 0, canSwipe = () => false, swipe = () => ''}) => { + const feedback = document.getElementById('playbackGestureStatus'); + const pressed = new Set(); + let hold = null, gesture = null, feedbackTimer = null, suppressClickUntil = 0; + + const available = () => panel.open && ready() && video.readyState >= 1 && !video.error; + const interactive = target => target !== video && (target?.isContentEditable || target?.closest?.('input,select,textarea,button,a[href],summary,[contenteditable]:not([contenteditable=false]),[role=slider],[role=combobox],[role=textbox],[role=button],[role=tab],[role=menuitem]')); + const keyName = event => event.code === 'Space' || event.key === ' ' ? 'Space' : event.key; + const coordinates = event => rotation() === 90 ? {x: event.clientY, y: -event.clientX} : rotation() === -90 ? {x: -event.clientY, y: event.clientX} : {x: event.clientX, y: event.clientY}; + + function hint(text, persistent = false) { + clearTimeout(feedbackTimer); + feedback.textContent = text; + feedback.hidden = !text; + if (text && !persistent) feedbackTimer = setTimeout(() => { + if (hold?.active) hint('3 倍速 · 松开恢复', true); + else feedback.hidden = true; + }, 1100); + } + + function endHold(tap = false, restore = true, silent = false) { + const previous = hold; + if (!previous) return; + hold = null; + clearTimeout(previous.timer); + if (previous.source === 'pointer') { + if (previous.active) suppressClickUntil = performance.now() + 600; + if (!gesture || gesture.id !== previous.id) {try {if (video.hasPointerCapture(previous.id)) video.releasePointerCapture(previous.id);} catch (_) {}} + } + if (previous.active) { + if (restore) video.playbackRate = previous.rate; + hint(silent ? '' : '恢复 ' + video.playbackRate + ' 倍速'); + } else if (tap && previous.source === 'keyboard') seek(5); + } + + function reset() { + const previous = gesture; + gesture = null; + if (previous) {try {if (video.hasPointerCapture(previous.id)) video.releasePointerCapture(previous.id);} catch (_) {}} + endHold(false, true, true); + hint(''); + } + + function startHold(source, id, point) { + if (hold) return; + const current = {source, id, point, active: false, rate: video.playbackRate}; + hold = current; + current.timer = setTimeout(() => { + if (hold !== current || !available() || video.paused || video.ended || document.hidden) return; + current.rate = video.playbackRate; + current.active = true; + if (gesture) gesture.held = true; + video.playbackRate = 3; + hint('3 倍速 · 松开恢复', true); + }, 350); + } + + function seek(seconds) { + if (!available() || !Number.isFinite(video.duration) || video.duration <= 0) return; + const target = Math.min(video.duration, Math.max(0, video.currentTime + seconds)); + video.currentTime = target; + const minutes = Math.floor(target / 60), remainder = String(Math.floor(target % 60)).padStart(2, '0'); + hint((seconds > 0 ? '快进至 ' : '后退至 ') + minutes + ':' + remainder); + } + + function volume(delta) { + const target = Math.min(1, Math.max(0, Math.round(((video.muted ? 0 : video.volume) + delta) * 100) / 100)); + video.volume = target; + video.muted = target === 0; + hint(Math.abs(video.volume - target) > .01 ? '请使用设备音量键调节' : target === 0 ? '已静音' : '音量 ' + Math.round(target * 100) + '%'); + } + + function consume(event) { + event.preventDefault(); + event.stopPropagation(); + } + + document.addEventListener('keydown', event => { + if (!panel.open || event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || interactive(event.target)) return; + const key = keyName(event); + if (!['Space', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'f', 'F'].includes(key) || !available()) return; + consume(event); + const repeated = event.repeat || pressed.has(key); + pressed.add(key); + if (key !== 'ArrowRight') endHold(); + if (key === 'Space' && !repeated) { + if (video.paused) video.play().catch(() => {}); + else video.pause(); + } else if (key === 'ArrowRight' && !repeated) startHold('keyboard', key); + else if (key === 'ArrowLeft') seek(-5); + else if (key === 'ArrowUp') volume(.05); + else if (key === 'ArrowDown') volume(-.05); + else if (key.toLowerCase() === 'f' && !repeated) fullscreen(); + }, true); + + document.addEventListener('keyup', event => { + const key = keyName(event); + if (!pressed.delete(key)) return; + consume(event); + if (key === 'ArrowRight' && hold?.source === 'keyboard') endHold(!interactive(event.target)); + }, true); + + function cancelGesture() { + const previous = gesture; + gesture = null; + if (previous) { + suppressClickUntil = performance.now() + 600; + try {if (video.hasPointerCapture(previous.id)) video.releasePointerCapture(previous.id);} catch (_) {} + } + endHold(); + } + + document.addEventListener('pointerdown', event => { + if (!event.isPrimary && panel.open) cancelGesture(); + }, true); + video.addEventListener('pointerdown', event => { + if (!event.isPrimary) {cancelGesture(); return;} + if (event.button !== 0 || !panel.open || hold || gesture) return; + const point = coordinates(event); + const vertical = mobile() && event.pointerType === 'touch' && canSwipe(); + if (vertical) gesture = {id: event.pointerId, point, started: performance.now(), axis: '', moved: false, held: false}; + const bounds = video.getBoundingClientRect(); + if (available() && !video.paused && !video.ended && (mobile() || event.clientY < bounds.bottom - Math.min(80, bounds.height * .5))) startHold('pointer', event.pointerId, point); + if (gesture || hold) {try {video.setPointerCapture(event.pointerId);} catch (_) {}} + }, true); + + document.addEventListener('pointermove', event => { + const point = coordinates(event); + if (gesture?.id === event.pointerId) { + const x = Math.abs(point.x - gesture.point.x), y = Math.abs(point.y - gesture.point.y); + if (Math.hypot(x, y) > 12) { + gesture.moved = true; + if (!gesture.axis) gesture.axis = y > x * 1.3 ? 'vertical' : 'horizontal'; + if (gesture.axis === 'vertical') event.preventDefault(); + endHold(); + } + } else if (hold?.source === 'pointer' && hold.id === event.pointerId && Math.hypot(point.x - hold.point.x, point.y - hold.point.y) > 12) endHold(); + }, {capture: true, passive: false}); + + document.addEventListener('pointerup', event => { + const current = gesture?.id === event.pointerId ? gesture : null; + if (current) gesture = null; + if (hold?.source === 'pointer' && hold.id === event.pointerId) { + if (hold.active) consume(event); + endHold(); + } + if (!current) return; + try {if (video.hasPointerCapture(current.id)) video.releasePointerCapture(current.id);} catch (_) {} + if (current.moved || current.held) {suppressClickUntil = performance.now() + 600; consume(event);} + const point = coordinates(event); + const x = Math.abs(point.x - current.point.x), y = point.y - current.point.y; + const threshold = Math.max(56, Math.min(100, video.clientHeight * .1)); + if (!current.held && current.axis === 'vertical' && Math.abs(y) >= threshold && Math.abs(y) > x * 1.5 && performance.now() - current.started < 1500 && canSwipe()) { + consume(event); + hint(swipe(y < 0 ? 1 : -1)); + } + }, true); + + for (const type of ['pointercancel', 'lostpointercapture']) video.addEventListener(type, event => { + if (gesture?.id === event.pointerId || hold?.source === 'pointer' && hold.id === event.pointerId) cancelGesture(); + }); + + video.addEventListener('click', event => { + if (performance.now() < suppressClickUntil) consume(event); + }, true); + video.addEventListener('contextmenu', event => { + if (hold?.source === 'pointer' || performance.now() < suppressClickUntil) consume(event); + }); + video.addEventListener('dragstart', event => {if (hold?.source === 'pointer') consume(event);}); + video.addEventListener('ratechange', () => { + if (hold?.active && video.playbackRate !== 3) endHold(false, false); + }); + for (const type of ['pause', 'ended', 'emptied', 'error']) video.addEventListener(type, reset); + document.addEventListener('focusin', event => {if (interactive(event.target)) reset();}); + document.addEventListener('visibilitychange', () => {if (document.hidden) {reset(); pressed.clear();}}); + document.addEventListener('fullscreenchange', reset); + document.addEventListener('webkitfullscreenchange', reset); + window.addEventListener('blur', () => {reset(); pressed.clear();}); + window.addEventListener('pagehide', reset); + panel.addEventListener('close', reset); + panel.addEventListener('jukuorientationchange', reset); + return {reset}; + }; +})(); diff --git a/internal/webui/player-danmaku.js b/internal/webui/player-danmaku.js new file mode 100644 index 0000000..2595dc8 --- /dev/null +++ b/internal/webui/player-danmaku.js @@ -0,0 +1,282 @@ +(() => { + const lifetime = 8; + const windowMS = 30000; + + + function plan(items, width, rows, fontSize, measure) { + if (width <= 0 || rows < 1) return []; + const lanes = Array(rows).fill(null), result = [], seen = new Set(); + const valid = items.filter(item => item && typeof item.id === 'string' && item.id && typeof item.text === 'string' && item.text.trim() && Number.isFinite(item.timeMs) && item.timeMs >= 0); + for (const item of valid.slice().sort((a, b) => a.timeMs - b.timeMs)) { + if (seen.has(item.id)) continue; + seen.add(item.id); + const text = Array.from(item.text).slice(0, 100).join(''); + const size = Math.ceil(measure ? measure(text) : Array.from(text).length * fontSize * 1.1) + 8; + const start = item.timeMs / 1000; + const lane = lanes.findIndex(previous => { + if (!previous || start - previous.start >= lifetime) return true; + const gap = start - previous.start; + return (width + previous.size) * gap / lifetime - previous.size >= 24 && + width - (width + size) * (lifetime - gap) / lifetime >= 24; + }); + if (lane < 0) continue; + const entry = {id: item.id, text, start, lane, size}; + lanes[lane] = entry; + result.push(entry); + } + return result; + } + + function frames(schedule, position, width) { + return schedule.filter(item => position >= item.start && position < item.start + lifetime) + .map(item => ({...item, x: width - (width + item.size) * (position - item.start) / lifetime})); + } + + + + function createClock() { + let media = 0, wall = 0, rate = 1, moving = false; + const position = now => media + (moving ? Math.max(0, now - wall) * rate / 1000 : 0); + return { + position, + sync(value, now, speed, running, force = false) { + if (!Number.isFinite(value)) return; + if (force || running !== moving || speed !== rate || Math.abs(value - position(now)) > .35) { + media = value; + wall = now; + rate = speed; + moving = running; + } + } + }; + } + + if (typeof module !== 'undefined' && module.exports) module.exports = {plan, frames, lifetime, createClock}; + if (typeof document === 'undefined') return; + const node = id => document.getElementById(id); + const video = node('onlineVideo'), stage = node('playbackStage'), layer = node('playbackDanmaku'); + const control = node('danmakuControl'), enabled = node('danmakuEnabled'), status = node('danmakuStatus'); + let session = '', episode = 0, supported = false, ready = false, generation = 0; + let pages = new Map(), failures = new Map(), schedule = [], elements = new Map(); + let controller = null, pendingStart = -1, animation = 0, width = 0, fontSize = 18, rows = 3; + let waiting = false; + const clock = createClock(); + const measure = document.createElement('canvas').getContext('2d'); + try {enabled.checked = localStorage.getItem('juku-danmaku-enabled') !== 'false';} catch (_) {} + + function clearLayer() { + for (const item of elements.values()) item.motion?.cancel(); + layer.replaceChildren(); + elements.clear(); + } + + function moving() { + return supported && enabled.checked && ready && !video.paused && !video.ended && !video.seeking && !waiting && !document.hidden; + } + + function cancelRequest() { + if (controller) controller.abort(); + controller = null; + pendingStart = -1; + } + + function suspend() { + ready = false; + waiting = false; + cancelAnimationFrame(animation); + animation = 0; + cancelRequest(); + clearLayer(); + } + + function setEpisode(id, index, canLoad) { + suspend(); + if (session !== id || episode !== index) { + generation++; + pages = new Map(); + failures = new Map(); + schedule = []; + } + session = id; + episode = index; + supported = Boolean(canLoad); + control.hidden = !supported; + status.hidden = !supported; + status.textContent = supported && enabled.checked ? '等待播放' : ''; + } + + function close() { + setEpisode('', 0, false); + if (document.fullscreenElement === stage) document.exitFullscreen().catch(() => {}); + else if (document.webkitFullscreenElement === stage && document.webkitExitFullscreen) document.webkitExitFullscreen(); + } + + function rebuild() { + width = layer.clientWidth; + fontSize = width < 600 ? 16 : 20; + rows = Math.max(1, Math.min(4, Math.floor(layer.clientHeight * .55 / (fontSize * 1.5)))); + layer.style.fontSize = fontSize + 'px'; + if (measure) measure.font = '600 ' + fontSize + 'px sans-serif'; + schedule = plan([...pages.values()].flatMap(page => page.items), width, rows, fontSize, measure ? text => measure.measureText(text).width : undefined); + } + + function draw(now = performance.now(), sync = false) { + if (!supported || !enabled.checked || !ready || video.seeking || document.hidden) return; + const position = clock.position(now), running = moving(); + const live = frames(schedule, position, width), active = new Set(live.map(item => item.id)); + for (const [id, item] of elements) { + if (!active.has(id)) {item.motion?.cancel(); item.node.remove(); elements.delete(id);} + } + for (const item of live) { + let element = elements.get(item.id); + const y = item.lane * fontSize * 1.5; + + + if (element && (element.width !== width || element.size !== item.size || element.y !== y)) { + element.motion?.cancel(); element.node.remove(); elements.delete(item.id); element = null; + } + if (!element) { + const span = document.createElement('span'); + span.textContent = item.text; + span.className = 'danmaku-item'; + layer.appendChild(span); + const motion = span.animate ? span.animate([ + {transform: 'translate3d(' + width + 'px,' + y + 'px,0)'}, + {transform: 'translate3d(' + -item.size + 'px,' + y + 'px,0)'} + ], {duration: lifetime * 1000, easing: 'linear', fill: 'both'}) : null; + if (motion) {motion.pause(); motion.currentTime = (position - item.start) * 1000;} + element = {node: span, motion, width, size: item.size, y}; + elements.set(item.id, element); + } + if (element.motion) { + const motion = element.motion, elapsed = (position - item.start) * 1000; + if (motion.playbackRate !== video.playbackRate) motion.playbackRate = video.playbackRate; + if ((sync && Math.abs(Number(motion.currentTime) - elapsed) > 200) || !running) motion.currentTime = elapsed; + if (running && motion.playState !== 'running') motion.play(); + else if (!running && motion.playState !== 'paused') motion.pause(); + } else { + element.node.style.transform = 'translate3d(' + item.x.toFixed(2) + 'px,' + y + 'px,0)'; + } + } + } + + function refreshStatus() { + if (!supported || !enabled.checked) {status.textContent = ''; return;} + const count = [...pages.values()].reduce((sum, page) => sum + page.items.length, 0); + status.textContent = count ? count + ' 条已载入' : pages.size ? '这段暂无弹幕' : '等待播放'; + } + + async function loadWindow(start, duration) { + const token = generation, currentSession = session, currentEpisode = episode; + const requestController = new AbortController(); + controller = requestController; + pendingStart = start; + if (!pages.size) status.textContent = '加载中…'; + const timeout = setTimeout(() => requestController.abort(), 12000); + try { + const query = new URLSearchParams({session: currentSession, episode: String(currentEpisode), start: String(start), duration: String(duration)}); + const response = await fetch('/api/ui/playback/danmaku?' + query, {signal: requestController.signal, credentials: 'same-origin', headers: {'Accept': 'application/json', ...window.JukuViewer?.headers()}}); + if (!response.ok) throw new Error('弹幕暂不可用'); + const page = await response.json(); + if (token !== generation || controller !== requestController || requestController.signal.aborted) return; + if (page.startMs !== start || !Array.isArray(page.items) || !Number.isFinite(page.nextMs) || page.nextMs <= start) throw new Error('弹幕数据无效'); + page.items = page.items.filter(item => item && typeof item.id === 'string' && typeof item.text === 'string' && Number.isFinite(item.timeMs) && item.timeMs >= 0 && item.timeMs < duration).slice(0, 90); + pages.set(start, page); + failures.delete(start); + const current = Math.floor(video.currentTime * 1000 / windowMS) * windowMS; + const farthest = [...pages.keys()].sort((a, b) => Math.abs(a - current) - Math.abs(b - current)); + for (const key of farthest.slice(6)) pages.delete(key); + rebuild(); + refreshStatus(); + draw(); + } catch (error) { + if (token === generation && controller === requestController) { + failures.set(start, Date.now() + 15000); + status.textContent = '弹幕暂不可用'; + } + } finally { + clearTimeout(timeout); + if (controller === requestController) {controller = null; pendingStart = -1;} + } + } + + function ensureWindow() { + if (!supported || !enabled.checked || !ready || video.seeking || document.hidden || !session || !Number.isFinite(video.duration) || video.duration <= 0) return; + const position = Math.max(0, Math.floor(video.currentTime * 1000)); + const duration = Math.min(86400000, Math.ceil(video.duration * 1000)); + if (position >= duration) return; + const current = Math.floor(position / windowMS) * windowMS; + let wanted = current; + if (pages.has(current)) { + const next = pages.get(current).nextMs; + if (position < next - 8000 || next >= duration || pages.has(next)) return; + wanted = next; + } + if ((failures.get(wanted) || 0) > Date.now()) return; + if (controller) { + if (pendingStart === wanted || pages.has(current)) return; + cancelRequest(); + } + loadWindow(wanted, duration); + } + + function tick(now = performance.now()) { + animation = 0; + draw(now); + if (moving()) animation = requestAnimationFrame(tick); + } + + function update(force = false) { + const now = performance.now(); + clock.sync(video.currentTime, now, video.playbackRate, moving(), force); + ensureWindow(); + draw(now, true); + if (moving()) { + if (!animation) animation = requestAnimationFrame(tick); + } else { + cancelAnimationFrame(animation); + animation = 0; + } + } + + video.addEventListener('loadedmetadata', () => {ready = true; waiting = false; rebuild(); update(true);}); + video.addEventListener('playing', () => {ready = true; waiting = false; update(true);}); + video.addEventListener('waiting', () => {waiting = true; update(true);}); + video.addEventListener('timeupdate', () => update()); + video.addEventListener('seeked', () => {waiting = false; update(true);}); + for (const event of ['pause', 'ratechange']) video.addEventListener(event, () => update(true)); + video.addEventListener('seeking', () => {cancelRequest(); clearLayer(); update(true);}); + video.addEventListener('ended', suspend); + enabled.addEventListener('change', () => { + try {localStorage.setItem('juku-danmaku-enabled', String(enabled.checked));} catch (_) {} + cancelRequest(); + failures.clear(); + if (!enabled.checked) {cancelAnimationFrame(animation); animation = 0; clearLayer();} + refreshStatus(); + update(true); + }); + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + cancelAnimationFrame(animation); animation = 0; + for (const item of elements.values()) item.motion?.pause(); + } else update(true); + }); + if (window.ResizeObserver) new ResizeObserver(() => {rebuild(); draw(performance.now(), true);}).observe(layer); + else window.addEventListener('resize', () => {rebuild(); draw(performance.now(), true);}); + + const fullscreen = node('playerFullscreenBtn'), exit = node('exitPlayerFullscreenBtn'); + const supportsFullscreen = Boolean(stage.requestFullscreen || stage.webkitRequestFullscreen); + fullscreen.hidden = !supportsFullscreen; + if (supportsFullscreen) video.setAttribute('controlslist', 'nofullscreen'); + fullscreen.addEventListener('click', async () => { + try { + if (stage.requestFullscreen) await stage.requestFullscreen(); + else stage.webkitRequestFullscreen(); + } catch (_) {status.textContent = '浏览器未允许全屏';} + }); + exit.addEventListener('click', () => { + if (document.exitFullscreen) document.exitFullscreen().catch(() => {}); + else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); + }); + window.JukuPlaybackDanmaku = {setEpisode, suspend, close}; +})(); diff --git a/internal/webui/player-danmaku.test.cjs b/internal/webui/player-danmaku.test.cjs new file mode 100644 index 0000000..e258ded --- /dev/null +++ b/internal/webui/player-danmaku.test.cjs @@ -0,0 +1,60 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {plan, frames, lifetime, createClock} = require('./player-danmaku.js'); + +test('source timestamps, including zero, survive filtering and crowded lanes', () => { + const items = Array.from({length: 20}, (_, i) => ({id: String(i), text: '文字 原样', timeMs: i * 50})); + const schedule = plan([null, {}, ...items, items[0], {id: 'bad', text: 'bad', timeMs: -1}], 700, 3, 20); + assert.equal(schedule.length, 3); + assert.equal(schedule[0].start, 0); + for (const item of schedule) assert.equal(item.start * 1000, items.find(x => x.id === item.id).timeMs); + assert.equal(schedule[0].text, '文字 原样'); + assert.equal(frames(schedule, -1, 700).length, 0); + assert.equal(frames([schedule[0]], lifetime, 700).length, 0); + assert.deepEqual(plan(items, 0, 3, 20), []); +}); + +test('mixed text lengths cannot collide at any point in a lane', () => { + const items = Array.from({length: 160}, (_, i) => ({id: String(i), text: (i % 2 ? 'W' : '字').repeat(1 + i % 60), timeMs: i * 150})); + for (const width of [280, 760, 1600]) { + const schedule = plan(items, width, 4, 20, text => text.length * 22); + assert.ok(schedule.length > 4); + for (let time = 0; time < 33; time += 1 / 60) { + const frame = frames(schedule, time, width); + for (let lane = 0; lane < 4; lane++) { + const row = frame.filter(item => item.lane === lane).sort((a, b) => a.x - b.x); + for (let i = 1; i < row.length; i++) assert.ok(row[i].x - row[i - 1].x - row[i - 1].size >= 23.9, 'overlapping text'); + } + } + } +}); + +test('a coarse 4 Hz video clock still produces continuous 60 Hz movement', () => { + const clock = createClock(); + clock.sync(2, 0, 1, true, true); + let last = 2; + for (let frame = 1; frame <= 120; frame++) { + const wall = frame * 1000 / 60; + clock.sync(2 + Math.floor(wall / 250) / 4, wall, 1, true); + const position = clock.position(wall); + assert.ok(Math.abs(position - last - 1 / 60) < 1e-8, 'clock must not step with media samples'); + last = position; + } +}); + +test('pause, buffering, rate changes and seek use the media timeline', () => { + const clock = createClock(); + clock.sync(10, 0, 1, true, true); + assert.equal(clock.position(500), 10.5); + clock.sync(10.5, 500, 1, false, true); + assert.equal(clock.position(8000), 10.5); + clock.sync(10.5, 8000, 2, true, true); + assert.equal(clock.position(8500), 11.5); + clock.sync(60, 8500, 2, true, true); + assert.equal(clock.position(9000), 61); + clock.sync(5, 9000, 1, false, true); + assert.equal(clock.position(10000), 5); + clock.sync(5, 10000, 1, true, true); + clock.sync(8, 11000, 1, true); + assert.equal(clock.position(11000), 8, 'recover a real discontinuity'); +}); diff --git a/internal/webui/player-media.js b/internal/webui/player-media.js new file mode 100644 index 0000000..a947697 --- /dev/null +++ b/internal/webui/player-media.js @@ -0,0 +1,76 @@ +(() => { + function createMediaLoader(environment, video, onError) { + let hls = null; + let generation = 0; + let cancelPending = null; + function destroy() { + generation++; + if (cancelPending) cancelPending(); + cancelPending = null; + if (hls) hls.destroy(); + hls = null; + } + function failure(message, network) { + const error = new Error(message); + error.playbackNetwork = network; + error.playbackDecode = !network; + return error; + } + async function load(plan, nativeHls, signal, offset = 0) { + destroy(); + const current = generation; + if (signal.aborted) throw new DOMException('已取消', 'AbortError'); + if (plan.player === 'mp4' && plan.mime && !video.canPlayType(plan.mime)) throw failure('浏览器不支持此媒体编码,正在尝试兼容播放', false); + await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => finish(failure('媒体加载超时,请重试', true)), 60000); + const abort = () => finish(new DOMException('已取消', 'AbortError')); + const metadata = () => finish(); + const failed = () => finish(failure('浏览器无法读取此视频', video.error?.code === 2)); + function finish(error) { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener('abort', abort); + video.removeEventListener('loadedmetadata', metadata); + video.removeEventListener('error', failed); + cancelPending = null; + if (error && current === generation) { + generation++; + if (hls) hls.destroy(); + hls = null; + } + if (error) reject(error); else resolve(); + } + cancelPending = abort; + signal.addEventListener('abort', abort, {once: true}); + video.addEventListener('loadedmetadata', metadata, {once: true}); + video.addEventListener('error', failed, {once: true}); + try { + if (plan.player === 'hls' && !nativeHls) { + const Hls = environment.Hls; + if (!Hls?.isSupported()) {finish(failure('此浏览器没有可用的 HLS 播放通道', false)); return;} + hls = new Hls({enableWorker: true, lowLatencyMode: false, maxBufferLength: 30, maxMaxBufferLength: 60, backBufferLength: 20, + manifestLoadingMaxRetry: 1, levelLoadingMaxRetry: 1, fragLoadingMaxRetry: 1, startPosition: Math.max(0, Number(offset) || 0)}); + hls.on(Hls.Events.ERROR, (_, data) => { + if (!data.fatal || current !== generation || signal.aborted) return; + const error = failure(data.type === Hls.ErrorTypes.NETWORK_ERROR ? '视频连接失败,请重试' : '此媒体需要兼容播放', data.type === Hls.ErrorTypes.NETWORK_ERROR); + if (!settled) finish(error); else onError(error); + }); + hls.on(Hls.Events.MEDIA_ATTACHED, () => { + if (current !== generation || signal.aborted) return; + try {hls.loadSource(plan.url);} catch (error) {finish(failure(error.message || '无法加载 HLS 媒体', false));} + }); + hls.attachMedia(video); + } else { + video.src = plan.url; + video.load(); + } + } catch (error) {finish(failure(error.message || '无法初始化播放器', false));} + }); + } + return {load, destroy}; + } + if (typeof module !== 'undefined' && module.exports) module.exports = createMediaLoader; + else window.JukuPlayerMedia = createMediaLoader; +})(); diff --git a/internal/webui/player-media.test.cjs b/internal/webui/player-media.test.cjs new file mode 100644 index 0000000..7b1048c --- /dev/null +++ b/internal/webui/player-media.test.cjs @@ -0,0 +1,86 @@ +const assert = require('node:assert/strict'); +const {test} = require('node:test'); +const createLoader = require('./player-media.js'); + +function fixture() { + class Video extends EventTarget { + src = ''; + error = null; + canPlayType() {return 'probably';} + load() {} + } + const instances = [], failures = []; + class Hls { + static Events = {ERROR:'error',MEDIA_ATTACHED:'attached'}; + static ErrorTypes = {NETWORK_ERROR:'network'}; + static isSupported() {return true;} + handlers = new Map(); + destroyed = 0; + constructor(options) {this.options=options;instances.push(this);} + on(type,handler) {this.handlers.set(type,handler);} + attachMedia(video) {this.video=video;} + loadSource(url) {this.url=url;} + destroy() {this.destroyed++;} + emit(type,data) {this.handlers.get(type)?.(type,data);} + } + const video=new Video(); + return {Hls,video,instances,failures,loader:createLoader({Hls},video,error=>failures.push(error))}; +} + +test('changing the media cancels a pending load and ignores the old HLS callbacks', async () => { + const f=fixture(), signal=new AbortController().signal; + const first=f.loader.load({player:'hls',url:'/old.m3u8'},false,signal); + const canceled=assert.rejects(first,{name:'AbortError'}); + const old=f.instances[0]; + const next=f.loader.load({player:'hls',url:'/new.m3u8'},false,signal,7.25); + old.emit('error',{fatal:true,type:'network'}); + old.emit('attached'); + const current=f.instances[1]; + current.emit('attached'); + assert.equal(current.url,'/new.m3u8'); + assert.equal(current.options.startPosition,7.25); + assert.equal(old.url,undefined); + f.video.dispatchEvent(new Event('loadedmetadata')); + await Promise.all([next,canceled]); + assert.equal(old.destroyed,1); + assert.deepEqual(f.failures,[]); + f.loader.destroy(); +}); + +test('synchronous HLS initialization failures clean up and a later load can succeed', async () => { + const f=fixture(), signal=new AbortController().signal; + f.Hls.prototype.attachMedia=function(){throw new Error('fixture attach failure');}; + await assert.rejects(f.loader.load({player:'hls',url:'/failed.m3u8'},false,signal),error=>error.playbackDecode===true); + assert.equal(f.instances[0].destroyed,1); + const next=f.loader.load({player:'mp4',url:'/ready.mp4',mime:'video/mp4'},false,signal); + f.video.dispatchEvent(new Event('loadedmetadata')); + await next; + assert.equal(f.video.src,'/ready.mp4'); + f.loader.destroy(); +}); + +test('aborting an in-flight load destroys its HLS session without reporting a playback failure', async () => { + const f=fixture(), abort=new AbortController(); + const pending=f.loader.load({player:'hls',url:'/canceled.m3u8'},false,abort.signal); + const rejected=assert.rejects(pending,{name:'AbortError'}); + abort.abort(); + await rejected; + const old=f.instances[0]; + old.emit('error',{fatal:true,type:'network'}); + assert.equal(old.destroyed,1); + assert.deepEqual(f.failures,[]); + f.loader.destroy(); +}); + +test('fatal HLS errors after metadata retain the network versus decode distinction', async () => { + const f=fixture(); + const pending=f.loader.load({player:'hls',url:'/ready.m3u8'},false,new AbortController().signal); + f.video.dispatchEvent(new Event('loadedmetadata')); + await pending; + f.instances[0].emit('error',{fatal:false,type:'network'}); + assert.deepEqual(f.failures,[]); + f.instances[0].emit('error',{fatal:true,type:'network'}); + assert.equal(f.failures[0].playbackNetwork,true); + assert.equal(f.failures[0].playbackDecode,false); + f.loader.destroy(); +}); diff --git a/internal/webui/player-mobile.js b/internal/webui/player-mobile.js new file mode 100644 index 0000000..e51f3bb --- /dev/null +++ b/internal/webui/player-mobile.js @@ -0,0 +1,163 @@ +(() => { + window.JukuPlayerMobile = ({panel, video, state, showEpisodes, showPreferences}) => { + const node = id => document.getElementById(id); + const mode = window.matchMedia('(max-width:700px), (max-height:500px) and (pointer:coarse)'); + const progress = node('mobilePlaybackProgress'); + const play = node('mobilePlayPauseBtn'); + const transport = node('mobileTransportBtn'); + const rate = node('playbackRate'), quality = node('playbackQuality'); + const orientation = window.JukuPlayerOrientation({panel, video, active: () => mode.matches && panel.open}); + const heading = panel.querySelector('.mobile-player-heading'), caption = panel.querySelector('.mobile-player-caption'); + let scrubbing = false, qualitySignature = '', lastTitle = '', waiting = false, hideTimer = null, previousLandscape = false; + const time = value => {const seconds = Math.max(0, Math.floor(Number(value) || 0)); return Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0');}; + const duration = () => {const value = state().duration || video.duration; return Number.isFinite(value) && value > 0 ? value : 0;}; + function hideControls(hidden) { + panel.classList.toggle('player-controls-hidden', hidden); + heading.inert = hidden; + caption.inert = hidden; + } + function reveal() { + clearTimeout(hideTimer); + hideControls(false); + if (!orientation.landscape() || video.paused || waiting || state().loading || scrubbing || panel.classList.contains('episodes-open') || panel.classList.contains('preferences-open') || node('playbackError').textContent) return; + hideTimer = setTimeout(() => {if (panel.open && orientation.landscape() && !video.paused && !scrubbing) hideControls(true);}, 3000); + } + function choices(select, container) { + container.replaceChildren(); + for (const option of select.options) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'secondary'; + button.textContent = option.textContent; + button.dataset.value = option.value; + button.setAttribute('aria-pressed', String(option.value === select.value)); + button.addEventListener('click', () => { + select.value = option.value; + select.dispatchEvent(new Event('change')); + update(); + if (panel.dataset.playerSheet !== 'settings') node('closePlayerPreferencesBtn').click(); + }); + container.appendChild(button); + } + } + function updateProgress() { + if (!mode.matches || !panel.open) return; + const total = duration(); + progress.max = String(total || 1); + progress.disabled = !total || state().loading; + if (!scrubbing) progress.value = String(Math.min(total, Math.max(0, Number(video.currentTime) || 0))); + const position = scrubbing ? Number(progress.value) : Number(video.currentTime) || 0; + const text = time(position) + ' / ' + time(total); + node('mobilePlaybackTime').textContent = text; + node('mobileCurrentTime').textContent = time(position); + node('mobileDuration').textContent = time(total); + progress.setAttribute('aria-valuetext', text); + progress.style.setProperty('--played', (total ? position / total * 100 : 0) + '%'); + } + function update() { + const value = state(); + panel.classList.toggle('mobile-player', mode.matches); + video.controls = !mode.matches; + orientation.update(); + if (!mode.matches) {hideControls(false); clearTimeout(hideTimer); return;} + panel.classList.toggle('playback-loading', Boolean(value.loading)); + panel.classList.toggle('playback-buffering', waiting); + if (lastTitle !== value.title) {node('mobilePlayerTitle').textContent = node('mobileHeadingTitle').textContent = value.title || '正在加载'; lastTitle = value.title;} + node('mobileEpisodeLabel').textContent = value.episode ? '第 ' + value.episode + ' 集' : '正在加载'; + node('mobileEpisodeSummary').textContent = [value.releaseStatus === 'finished' ? '已完结' : value.releaseStatus === 'ongoing' ? '连载中' : '', value.total ? '全 ' + value.total + ' 集' : '获取分集中'].filter(Boolean).join(' · '); + node('mobileEpisodeVIP').hidden = !value.vip; + node('mobileRateBtn').textContent = node('mobileLandscapeRateBtn').textContent = video.playbackRate === 1 ? '倍速' : (video.playbackRate || 1) + ' 倍'; + const selectedQuality = quality.selectedOptions[0]?.textContent || '自动'; + node('mobileLandscapeQualityBtn').textContent = selectedQuality.match(/\d+p/i)?.[0].toUpperCase() || '自动'; + node('mobileLandscapeQualityBtn').disabled = quality.disabled; + node('mobileEpisodesBtn').disabled = !value.total; + node('mobileNextEpisodeBtn').disabled = node('nextEpisodeBtn').disabled; + panel.dataset.paused = String(video.paused); + play.hidden = !video.paused || Boolean(node('playbackError').textContent); + play.disabled = Boolean(value.loading) || !video.hasAttribute('src'); + play.setAttribute('aria-label', video.ended ? '重播本集' : '播放'); + transport.disabled = play.disabled; + transport.setAttribute('aria-label', video.paused ? video.ended ? '重播本集' : '播放' : '暂停'); + const signature = Array.from(quality.options, item => item.value + ':' + item.textContent).join('|'); + if (signature !== qualitySignature) {qualitySignature = signature; choices(quality, node('mobileQualityChoices'));} + for (const [select, target] of [[rate, 'mobileRateChoices'], [quality, 'mobileQualityChoices']]) { + for (const button of node(target).children) { + button.setAttribute('aria-pressed', String(button.dataset.value === select.value)); + button.disabled = select.disabled; + } + } + node('mobileFullscreenBtn').hidden = node('playerFullscreenBtn').hidden; + node('mobileFullscreenBtn').textContent = document.fullscreenElement === panel || document.webkitFullscreenElement === panel ? '退出全屏' : '全屏播放'; + node('mobilePreferencesTitle').textContent = panel.dataset.playerSheet === 'rate' ? '倍速' : panel.dataset.playerSheet === 'quality' ? '清晰度' : '更多'; + if (previousLandscape !== orientation.landscape() || video.paused || value.loading || waiting || panel.classList.contains('episodes-open') || panel.classList.contains('preferences-open')) reveal(); + previousLandscape = orientation.landscape(); + updateProgress(); + } + choices(rate, node('mobileRateChoices')); + node('mobileBackBtn').addEventListener('click', () => { + if (orientation.landscape()) { + orientation.portrait(); + if (document.fullscreenElement === panel) document.exitFullscreen().catch(() => {}); + else if (document.webkitFullscreenElement === panel) document.webkitExitFullscreen?.(); + } else panel.close(); + }); + node('mobileEpisodesBtn').addEventListener('click', showEpisodes); + node('mobilePlayerSettingsBtn').addEventListener('click', () => showPreferences('settings')); + for (const id of ['mobileRateBtn', 'mobileLandscapeRateBtn']) node(id).addEventListener('click', () => showPreferences('rate')); + node('mobileLandscapeQualityBtn').addEventListener('click', () => showPreferences('quality')); + node('mobileNextEpisodeBtn').addEventListener('click', () => node('nextEpisodeBtn').click()); + node('mobileFullscreenBtn').addEventListener('click', async () => { + try { + if (document.fullscreenElement === panel || document.webkitFullscreenElement === panel) await (document.exitFullscreen?.() || document.webkitExitFullscreen?.()); + else if (panel.requestFullscreen) await panel.requestFullscreen(); + else if (panel.webkitRequestFullscreen) await panel.webkitRequestFullscreen(); + else node('playerFullscreenBtn').click(); + } catch (_) {} + update(); + }); + function togglePlayback() { + if (!mode.matches || !panel.open || state().loading || !video.hasAttribute('src')) return; + if (video.paused) video.play().catch(() => {}); else video.pause(); + } + play.addEventListener('click', togglePlayback); + transport.addEventListener('click', togglePlayback); + video.addEventListener('click', event => { + if (event.defaultPrevented) return; + if (orientation.landscape()) { + if (panel.classList.contains('player-controls-hidden') || video.paused) reveal(); + else {clearTimeout(hideTimer); hideControls(true);} + } else togglePlayback(); + }); + progress.addEventListener('input', () => {scrubbing = true; reveal(); updateProgress();}); + progress.addEventListener('change', () => { + const target = Math.min(duration(), Math.max(0, Number(progress.value) || 0)); + scrubbing = false; + if (!state().loading && duration()) video.currentTime = target; + reveal(); + updateProgress(); + }); + progress.addEventListener('pointercancel', () => {scrubbing = false; updateProgress();}); + progress.addEventListener('blur', () => {scrubbing = false; updateProgress();}); + video.addEventListener('waiting', () => {waiting = true; update();}); + for (const type of ['playing', 'canplay', 'emptied']) video.addEventListener(type, () => {waiting = false; update(); reveal();}); + panel.addEventListener('jukuorientationchange', () => {update(); reveal();}); + panel.addEventListener('keydown', reveal); + panel.addEventListener('pointerdown', event => {if (event.target.closest('button,input,select')) reveal();}); + document.addEventListener('dialoglayerchange', () => {update(); reveal();}); + document.addEventListener('fullscreenchange', update); + for (const type of ['timeupdate', 'durationchange']) video.addEventListener(type, updateProgress); + for (const type of ['loadedmetadata', 'playing', 'pause', 'ended', 'ratechange', 'emptied', 'error']) video.addEventListener(type, update); + rate.addEventListener('change', update); + quality.addEventListener('change', update); + mode.addEventListener('change', update); + panel.addEventListener('close', () => { + scrubbing = false; + clearTimeout(hideTimer); + hideControls(false); + if (document.fullscreenElement === panel) document.exitFullscreen().catch(() => {}); + else if (document.webkitFullscreenElement === panel) document.webkitExitFullscreen?.(); + }); + update(); + return {update, active: () => mode.matches, rotation: () => orientation.rotation()}; + }; +})(); diff --git a/internal/webui/player-orientation.js b/internal/webui/player-orientation.js new file mode 100644 index 0000000..eb93300 --- /dev/null +++ b/internal/webui/player-orientation.js @@ -0,0 +1,150 @@ +(() => { + window.JukuPlayerOrientation = ({panel, video, active}) => { + const node = id => document.getElementById(id); + const rotate = node('mobileRotateBtn'), automatic = node('mobileAutoRotate'); + const watch = node('mobileLandscapeBtn'); + const permission = node('mobileOrientationPermissionBtn'), hint = node('mobileOrientationHint'); + let manual = null, sensed = null, sensedTurn = 90, angle = 0, landscape = false; + let previousViewport = null, sensorKind = null, candidate = '', timer = null, listening = false, granted = false, signature = ''; + let enabled = true; + try {enabled = localStorage.getItem('juku.playback.autoRotate') !== 'false';} catch (_) {} + automatic.checked = enabled; + + function sensorAvailable() {return window.isSecureContext && typeof window.DeviceOrientationEvent !== 'undefined';} + function permissionNeeded() {return sensorAvailable() && typeof window.DeviceOrientationEvent.requestPermission === 'function' && !granted;} + function clearCandidate() {clearTimeout(timer); timer = null; candidate = '';} + function stopSensor() { + window.removeEventListener('deviceorientation', sense); + listening = false; + clearCandidate(); + } + function startSensor() { + if (!active() || !enabled || !sensorAvailable() || permissionNeeded() || listening) return; + window.addEventListener('deviceorientation', sense, {passive: true}); + listening = true; + } + function sense(event) { + if (!active() || !enabled || !Number.isFinite(event.beta) || !Number.isFinite(event.gamma)) return; + const beta = event.beta * Math.PI / 180, gamma = event.gamma * Math.PI / 180; + const x = Math.sin(gamma) * Math.cos(beta), y = Math.sin(beta); + if (Math.hypot(x, y) < .65) {clearCandidate(); return;} + let next; + if (Math.abs(x) > Math.abs(y) * 1.4) next = true; + else if (Math.abs(y) > Math.abs(x) * 1.4) next = false; + else {clearCandidate(); return;} + const turn = x > 0 ? -90 : 90, key = String(next) + ':' + (next ? turn : 0); + if (key === sensorKind) {clearCandidate(); return;} + if (manual !== null && sensorKind === null) {sensorKind = key; clearCandidate(); return;} + if (key === candidate) return; + clearCandidate(); + candidate = key; + timer = setTimeout(() => { + timer = null; + candidate = ''; + if (!active() || !enabled) return; + sensorKind = key; + sensed = next; + sensedTurn = turn; + manual = null; + update(); + }, 300); + } + function update() { + const on = active(), width = panel.clientWidth || window.innerWidth, height = panel.clientHeight || window.innerHeight; + const viewportLandscape = width > height; + const videoWidth = video.videoWidth, videoHeight = video.videoHeight; + const known = videoWidth > 0 && videoHeight > 0; + const previousLandscape = landscape; + landscape = on && (manual ?? (enabled && sensed !== null ? sensed : viewportLandscape)); + const nextAngle = !on || landscape === viewportLandscape ? 0 : landscape ? sensedTurn : -90; + const viewWidth = nextAngle ? height : width, viewHeight = nextAngle ? width : height; + const nextSignature = [on, width, height, videoWidth, videoHeight, landscape, nextAngle].join(':'); + if (signature !== nextSignature) { + signature = nextSignature; + panel.dataset.videoLayout = known ? videoWidth > videoHeight ? 'landscape' : videoWidth < videoHeight ? 'portrait' : 'square' : 'unknown'; + node('mobileVideoSize').textContent = known ? videoWidth + ' × ' + videoHeight + ' · ' + (videoWidth > videoHeight ? '横屏视频' : videoWidth < videoHeight ? '竖屏视频' : '方形视频') : '读取视频尺寸…'; + panel.classList.toggle('player-rotated', nextAngle !== 0); + panel.classList.toggle('player-landscape', landscape); + for (const [key, value] of Object.entries({'frame-width': width, 'frame-height': height, 'view-height': viewHeight, 'view-width': viewWidth})) panel.style.setProperty('--player-' + key, value + 'px'); + panel.style.setProperty('--player-rotation', nextAngle + 'deg'); + if (known) panel.style.setProperty('--landscape-watch-top', Math.max(70, Math.min(viewHeight - 190, (viewHeight + Math.min(viewHeight, viewWidth * videoHeight / videoWidth)) / 2 + 18)) + 'px'); + } + rotate.disabled = !known; + watch.hidden = !on || !known || videoWidth <= videoHeight || landscape; + rotate.setAttribute('aria-pressed', String(landscape)); + rotate.setAttribute('aria-label', landscape ? '竖屏播放' : '横屏播放'); + rotate.title = landscape ? '切换为竖屏播放' : '切换为横屏播放'; + permission.hidden = !on || !enabled || !permissionNeeded(); + node('mobileBackBtn').setAttribute('aria-label', landscape ? '返回竖屏播放' : '返回剧库'); + if (nextAngle !== angle || previousLandscape !== landscape) { + angle = nextAngle; + panel.dispatchEvent(new Event('jukuorientationchange')); + } + if (on && enabled) startSensor(); else stopSensor(); + if (on && previousViewport === null) previousViewport = viewportLandscape; + } + function viewportChanged() { + if (!active()) return; + const next = (panel.clientWidth || window.innerWidth) > (panel.clientHeight || window.innerHeight); + if (previousViewport !== null && next !== previousViewport) { + if (enabled) manual = null; + sensed = null; + sensorKind = null; + clearCandidate(); + } + previousViewport = next; + update(); + } + async function allowSensor() { + if (!permissionNeeded()) {startSensor(); return;} + permission.disabled = true; + try { + granted = await window.DeviceOrientationEvent.requestPermission() === 'granted'; + hint.textContent = granted ? '已开启方向感应,也可随时手动旋转。' : '方向感应未开启,仍可手动旋转或跟随屏幕方向。'; + startSensor(); + } catch (_) {hint.textContent = '当前浏览器未开放方向感应,可手动旋转或跟随屏幕方向。';} + finally {permission.disabled = false; update();} + } + function setLandscape(value) { + if (!active()) return; + manual = value; + clearCandidate(); + update(); + panel.dispatchEvent(new Event('jukuorientationchange')); + } + rotate.addEventListener('click', () => {if (!rotate.disabled) setLandscape(!landscape);}); + watch.addEventListener('click', () => { + setLandscape(true); + try { + const request = panel.requestFullscreen?.() || panel.webkitRequestFullscreen?.(); + request?.catch?.(() => {}); + } catch (_) {} + }); + automatic.addEventListener('change', () => { + enabled = automatic.checked; + manual = enabled ? null : landscape; + sensed = null; + sensorKind = null; + try {localStorage.setItem('juku.playback.autoRotate', String(enabled));} catch (_) {} + if (!enabled) stopSensor(); + update(); + }); + permission.addEventListener('click', allowSensor); + for (const type of ['resize', 'orientationchange']) window.addEventListener(type, viewportChanged); + window.screen?.orientation?.addEventListener?.('change', viewportChanged); + window.visualViewport?.addEventListener('resize', viewportChanged); + for (const type of ['loadedmetadata', 'resize', 'emptied']) video.addEventListener(type, update); + for (const type of ['fullscreenchange', 'webkitfullscreenchange']) document.addEventListener(type, viewportChanged); + panel.addEventListener('close', () => { + manual = null; + sensed = null; + sensedTurn = 90; + sensorKind = null; + previousViewport = null; + stopSensor(); + update(); + }); + if (!sensorAvailable()) hint.textContent = '可跟随屏幕方向旋转,也可手动旋转;方向感应需 HTTPS 和浏览器支持。'; + return {update, rotation: () => angle, landscape: () => landscape, portrait: () => setLandscape(false)}; + }; +})(); diff --git a/internal/webui/player-session.js b/internal/webui/player-session.js new file mode 100644 index 0000000..f4633c7 --- /dev/null +++ b/internal/webui/player-session.js @@ -0,0 +1,60 @@ +(() => { + function playbackSession({request, onState, onExpired, onError, interval = 20000, timeout = 8000, retry = 5000}) { + let session = '', timer = null, pending = null, ended = false; + function stop() { + session = ''; + ended = false; + clearTimeout(timer); + timer = null; + const previous = pending; + pending = null; + previous?.controller.abort(); + } + function schedule(delay) { + clearTimeout(timer); + if (session && !ended) timer = setTimeout(() => ping(), delay); + } + function ping(fresh = false) { + if (!session || ended) return Promise.resolve(null); + if (pending && !fresh) return pending.promise; + if (pending) { + const previous = pending; + pending = null; + previous.controller.abort(); + } + clearTimeout(timer); + const call = {session, controller: new AbortController()}; + pending = call; + call.promise = (async () => { + let deadline, delay = interval; + try { + const expiry = new Promise((_, reject) => { + deadline = setTimeout(() => {call.controller.abort(); reject(new Error('播放保活请求超时'));}, timeout); + }); + const state = await Promise.race([Promise.resolve().then(() => request(call.session, call.controller.signal)), expiry]); + if (pending !== call || session !== call.session) return null; + onState(state); + return state; + } catch (error) { + if (pending !== call || session !== call.session) return null; + if (error.status === 410) { + ended = true; + onExpired(error); + } else if (error.status === 401 || error.status === 403) { + ended = true; + onError(error); + } else delay = retry; + return null; + } finally { + clearTimeout(deadline); + if (pending === call) {pending = null; schedule(delay);} + } + })(); + return call.promise; + } + function start(id) {stop(); session = id; schedule(interval);} + return {start, stop, ping}; + } + if (typeof module === 'object' && module.exports) module.exports = playbackSession; + else window.JukuPlaybackSession = playbackSession; +})(); diff --git a/internal/webui/player-session.test.cjs b/internal/webui/player-session.test.cjs new file mode 100644 index 0000000..025d92a --- /dev/null +++ b/internal/webui/player-session.test.cjs @@ -0,0 +1,88 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const vm = require('node:vm'); +const fs = require('node:fs'); + +function fixture(request) { + let now = 0, number = 0; + const timers = new Map(), states = [], expired = [], errors = []; + const context = {module: {exports: {}}, AbortController, Promise, Error, setTimeout: (fn, delay) => {const id = ++number; timers.set(id, {fn, at: now + delay}); return id;}, clearTimeout: id => timers.delete(id)}; + vm.runInNewContext(fs.readFileSync(require.resolve('./player-session.js'), 'utf8'), context); + const session = context.module.exports({request, onState: state => states.push(state), onExpired: error => expired.push(error), onError: error => errors.push(error)}); + const flush = async () => {for (let index = 0; index < 12; index++) await Promise.resolve();}; + const tick = async amount => { + const end = now + amount; + await flush(); + while (true) { + const next = Array.from(timers).filter(([, value]) => value.at <= end).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + now = next[1].at; timers.delete(next[0]); next[1].fn(); await flush(); + } + now = end; await flush(); + }; + return {session, states, expired, errors, tick, flush}; +} + +test('heartbeats renew every 20 seconds and concurrent callers share one request', async () => { + const requests = []; + let resolve; + const f = fixture((id, signal) => {requests.push({id, signal}); return new Promise(done => {resolve = done;});}); + f.session.start('watch-one'); + await f.tick(20000); + assert.equal(requests.length, 1); + const first = f.session.ping(), second = f.session.ping(); + assert.equal(first, second); + resolve({run: 1}); await f.flush(); + assert.equal(f.states.length, 1); + await f.tick(19999); assert.equal(requests.length, 1); + await f.tick(1); assert.equal(requests.length, 2); + f.session.stop(); +}); + +test('a hung heartbeat times out and retries without blocking future keepalives', async () => { + const requests = []; + const f = fixture((id, signal) => {requests.push({id, signal}); return requests.length === 1 ? new Promise(() => {}) : Promise.resolve({run: 7});}); + f.session.start('watch'); f.session.ping(); + await f.tick(8000); + assert.equal(requests[0].signal.aborted, true); + await f.tick(5000); + assert.equal(requests.length, 2); + assert.equal(f.states[0].run, 7); + assert.equal(f.expired.length, 0); + f.session.stop(); +}); + +test('foreground checks cancel a stuck request and ignore its late expiry response', async () => { + let failOld; + const requests = []; + const f = fixture((id, signal) => {requests.push(signal); return requests.length === 1 ? new Promise((_, reject) => {failOld = reject;}) : Promise.resolve({run: 3});}); + f.session.start('watch'); f.session.ping(); await f.flush(); + await f.session.ping(true); + assert.equal(requests[0].aborted, true); + failOld(Object.assign(new Error('expired'), {status: 410})); await f.flush(); + assert.equal(f.expired.length, 0); + assert.equal(f.states[0].run, 3); + f.session.stop(); +}); + +test('expired sessions request one recovery; authentication failures do not trigger recovery loops', async () => { + for (const status of [410, 401, 403]) { + let requests = 0; + const f = fixture(() => {requests++; return Promise.reject(Object.assign(new Error('unavailable'), {status}));}); + f.session.start('watch'); await f.session.ping(); await f.tick(120000); + assert.equal(requests, 1); + assert.equal(f.expired.length, status === 410 ? 1 : 0); + assert.equal(f.errors.length, status === 410 ? 0 : 1); + f.session.stop(); + } +}); + +test('closing or changing a drama invalidates late heartbeats from its old session', async () => { + let finish; + const f = fixture(() => new Promise(resolve => {finish = resolve;})); + f.session.start('old'); f.session.ping(); await f.flush(); + f.session.start('new'); finish({run: 1}); await f.flush(); + assert.equal(f.states.length, 0); + f.session.stop(); await f.tick(120000); + assert.equal(f.states.length, 0); +}); diff --git a/internal/webui/player.css b/internal/webui/player.css new file mode 100644 index 0000000..d153aab --- /dev/null +++ b/internal/webui/player.css @@ -0,0 +1,147 @@ +.player-dialog{width:min(1120px,calc(100vw - 32px));max-height:94dvh;padding:0;border-radius:14px;overflow:auto}.player-dialog::backdrop{background:#101510b8}.player-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 16px;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:3;background:var(--panel)}.player-heading h2{font-size:20px;overflow-wrap:anywhere;min-width:0}.player-heading>div{min-width:0}.player-heading>button{flex:none}.player-heading .small{font-size:11px}.player-layout{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:16px;padding:16px;align-items:start}.player-screen{min-width:0}#onlineVideo{display:block;width:100%;height:57dvh;min-height:200px;max-height:670px;object-fit:contain;background:#000;border-radius:8px}.player-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:10px 0 8px}.player-toolbar button,.player-toolbar select{font-size:12px;padding:6px 9px}.player-toolbar .icon-button{padding:0}.player-toolbar label{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;white-space:nowrap}.player-toolbar select{max-width:135px}.player-toolbar #toggleEpisodesBtn{display:none}.playback-feedback{display:flex;align-items:center;gap:8px 18px;flex-wrap:wrap;min-height:24px}.playback-feedback #prefetchStatus{color:var(--success)}.playback-error-row{display:flex;align-items:center;gap:12px;margin-top:9px}.playback-error-row:has(#playbackError:empty){margin:0}.playback-error-row button{flex:none}#playbackError{max-height:120px;overflow:auto;flex:1;min-width:0}.player-preferences{margin-top:15px;border:1px solid var(--line);border-radius:10px;padding:8px 16px 16px;background:var(--bg)}.preference-toggle{display:flex;align-items:center;justify-content:space-between;gap:15px;min-height:46px;font-size:14px;border-bottom:1px solid var(--line);padding:6px 0;cursor:pointer}.preference-toggle>span{flex:1}.preference-toggle small{display:block;font-size:12px;color:var(--muted);margin-top:2px}.preference-toggle input{margin:12px}.player-preferences>.toolbar{margin-top:14px}.player-hint{margin:12px 0 0}.player-shortcuts{margin-top:8px}.player-episodes{display:flex;flex-direction:column;gap:12px;min-width:0;max-height:calc(57dvh + 120px)}.episode-panel-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.episode-panel-heading #closeEpisodesBtn{display:none}.episode-panel-heading h3{font-size:16px}.episode-navigation{display:flex;gap:6px;align-items:center}.episode-navigation select{flex:1;min-width:0;font-size:13px}.episode-navigation button{flex:none;font-size:12px;padding:8px}.player-episode-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;min-height:0;max-height:410px;overflow:auto;align-content:start;padding:2px;scrollbar-gutter:stable}.player-episode-list button{font-size:12px;padding:7px 3px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-height:44px}.player-episode-list button.watched{color:var(--success);background:var(--success-bg);border-color:transparent}.player-episode-list button[aria-current=true]{background:var(--brand);color:var(--on-brand);border-color:var(--brand)}.episode-jump{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);margin:0}.episode-jump input{min-width:0;width:64px;font-size:14px}.episode-jump button{font-size:13px;padding:8px 12px}#playbackHistoryStatus:empty{display:none}.player-dialog [hidden]{display:none!important} +@media(max-width:850px) and (min-width:701px){.player-layout{grid-template-columns:minmax(0,1fr) 218px;gap:16px;padding:18px}.player-episode-list{grid-template-columns:repeat(3,minmax(0,1fr))}.player-toolbar{gap:6px}} +@media(max-width:700px){.player-dialog{width:100%;max-width:100%;height:100dvh;max-height:100dvh;border:0;border-radius:0;margin:0;padding:0 0 env(safe-area-inset-bottom)}.player-heading{padding:12px 16px}.player-heading h2{font-size:18px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.player-layout{grid-template-columns:minmax(0,1fr);padding:12px 14px 22px;gap:0}#onlineVideo{height:53dvh;min-height:175px;border-radius:8px}.player-toolbar{gap:7px;margin:12px 0 8px}.player-toolbar button,.player-toolbar select{font-size:12px;padding:8px 9px}.player-toolbar label{font-size:12px;gap:5px}.player-toolbar #toggleEpisodesBtn{display:block}.player-toolbar #playbackQualityControl,.player-toolbar label{flex:none}.player-toolbar select{max-width:117px}.player-episodes{display:none;position:fixed;bottom:0;left:0;right:0;z-index:6;background:var(--panel);border-top:1px solid var(--line);border-radius:16px 16px 0 0;box-shadow:0 -6px 24px #10151020;padding:16px 16px calc(16px + env(safe-area-inset-bottom));max-height:65dvh;gap:11px}.player-dialog.episodes-open .player-episodes{display:flex}.episode-panel-heading #closeEpisodesBtn{display:flex}.player-episode-list{grid-template-columns:repeat(6,minmax(0,1fr));max-height:38dvh;gap:7px;scrollbar-gutter:auto}.episode-navigation select{flex:0 1 160px}.episode-jump{justify-content:flex-end}.playback-error-row{flex-wrap:wrap}.playback-error-row button{margin-left:auto}.player-preferences{padding:6px 13px 14px}.player-keyboard-hint{display:none}} +@media(max-width:355px){.player-layout{padding-left:12px;padding-right:12px}.player-toolbar{gap:6px}.player-toolbar label{gap:4px}.player-toolbar button,.player-toolbar select{padding:7px 8px}.player-toolbar .icon-button{width:44px}.player-episode-list{grid-template-columns:repeat(5,minmax(0,1fr))}} +@media(max-height:500px) and (orientation:landscape){.player-heading{position:static;padding-top:8px;padding-bottom:8px}.player-layout{padding-top:10px}.player-dialog{max-height:100dvh}#onlineVideo{height:68dvh;min-height:150px}.player-episodes{max-height:85dvh}.player-episode-list{max-height:50dvh}} +.playback-stage{position:relative;min-width:0;background:#000;border-radius:8px;overflow:hidden} +.playback-danmaku{position:absolute;inset:10px 0 60px;overflow:hidden;pointer-events:none;contain:layout style paint;z-index:1} +.danmaku-item{position:absolute;top:0;left:0;white-space:nowrap;font-family:sans-serif;font-weight:600;line-height:1.5;color:#fff;text-shadow:1px 1px 2px #000,-1px -1px 2px #000,1px -1px 2px #000,-1px 1px 2px #000;opacity:.85;will-change:transform} +#danmakuStatus{font-size:11px;color:var(--muted)} +.stage-fullscreen-exit{display:none;position:absolute;right:12px;top:12px;z-index:2;background:#202735cc;color:white;border-color:#ffffff55} +.playback-stage:is(:fullscreen,:-webkit-full-screen){width:100%;height:100%;border-radius:0} +.playback-stage:is(:fullscreen,:-webkit-full-screen) #onlineVideo{width:100%;height:100%;max-height:none;border-radius:0} +.playback-stage:is(:fullscreen,:-webkit-full-screen) .stage-fullscreen-exit{display:block} + +.player-toolbar #playerFullscreenBtn{margin-left:auto}.player-episode-list button{min-height:36px;border-radius:6px}.player-toolbar .icon-button{padding:0}.player-episodes{gap:10px}.preference-toggle input{appearance:none;-webkit-appearance:none;position:relative;width:32px;height:20px;border:1px solid var(--line-strong);background:var(--soft);border-radius:12px;cursor:pointer;flex:none;margin:3px 0}.preference-toggle input:before{content:"";position:absolute;width:14px;height:14px;border-radius:50%;left:2px;top:2px;background:var(--muted)}.preference-toggle input:checked{background:var(--brand);border-color:var(--brand)}.preference-toggle input:checked:before{left:14px;background:var(--on-brand)} +@media(pointer:coarse){.player-episode-list button{min-height:40px}.preference-toggle{min-height:46px}} + +.playback-gesture-status{position:absolute;top:16px;left:50%;transform:translateX(-50%);max-width:calc(100% - 32px);padding:7px 13px;border:1px solid #ffffff26;border-radius:20px;background:#101510d9;color:#fff;font-size:13px;line-height:1.5;white-space:nowrap;pointer-events:none;z-index:2;font-variant-numeric:tabular-nums}#onlineVideo{-webkit-touch-callout:none;user-select:none;touch-action:pan-y}#onlineVideo:focus-visible{outline:2px solid var(--brand);outline-offset:-3px} + +.mobile-only,.landscape-only,.player-sheet-backdrop{display:none} +.player-episode-list .vip-episode{color:#a97920} +.player-dialog.mobile-player{--panel:#202023;--bg:#171719;--soft:#303033;--text:#f7f7f8;--muted:#b8b8bf;--line:#ffffff1f;--line-strong:#ffffff35;--brand:#fa4567;--brand-text:#ff7992;--on-brand:#fff;--tint:#ff466c22;--success:#9ae5c0;--success-bg:#9ae5c019;width:100%;max-width:100%;height:100dvh;max-height:100dvh;margin:0;padding:0;border:0;border-radius:0;background:#000;color:#fff;overflow:hidden;overscroll-behavior:none} +.mobile-player .mobile-only{display:block} +.mobile-player .player-heading,.mobile-player .player-toolbar{display:none} +.mobile-player .player-layout{display:block;width:100%;height:100%;padding:0;gap:0} +.mobile-player .player-screen,.mobile-player .playback-stage{width:100%;height:100%;border-radius:0} +.mobile-player #onlineVideo{width:100%;height:100%;min-height:0;max-height:none;border-radius:0;object-fit:contain;touch-action:pan-x pinch-zoom} +.mobile-player .mobile-player-heading{position:absolute;inset:0 0 auto;z-index:3;display:flex;align-items:center;gap:7px;padding:calc(8px + env(safe-area-inset-top)) max(8px,env(safe-area-inset-right)) 28px max(8px,env(safe-area-inset-left));background:linear-gradient(#000a,transparent);pointer-events:none;font-size:14px;text-shadow:0 1px 3px #000} +.mobile-player-heading button{pointer-events:auto;min-height:42px;color:#fff;background:transparent;border-color:transparent} +.mobile-player-heading #mobileRateBtn{margin-left:auto;font-size:14px;padding:7px 9px} +.mobile-player-heading .icon-button{width:42px;height:42px} +#mobileBackBtn svg{transform:rotate(90deg)} +.mobile-player .mobile-play-toggle{position:absolute;z-index:2;left:50%;top:45%;transform:translate(-50%,-50%);display:grid;place-items:center;width:72px;height:72px;border:0;border-radius:50%;background:#0005;color:#fffffff0;filter:drop-shadow(0 1px 4px #0008)} +.mobile-play-toggle svg{width:40px;height:40px} +.mobile-player .mobile-player-caption{position:absolute;bottom:0;left:0;right:0;z-index:3;padding:48px max(16px,env(safe-area-inset-right)) env(safe-area-inset-bottom) max(16px,env(safe-area-inset-left));background:linear-gradient(transparent,#0008 32%,#080808 90%);pointer-events:none} +.mobile-player-caption h2{font-size:18px;font-weight:650;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin:0 0 9px;text-shadow:0 1px 3px #000;overflow-wrap:anywhere} +.mobile-progress-heading{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:#e2e2e5;line-height:1.5;font-variant-numeric:tabular-nums} +.mobile-player-caption #mobilePlaybackProgress{appearance:none;-webkit-appearance:none;display:block;width:100%;height:24px;margin:0;padding:0;border:0;background:transparent;border-radius:0;pointer-events:auto;touch-action:pan-x;cursor:pointer;accent-color:#fff} +#mobilePlaybackProgress::-webkit-slider-runnable-track{height:3px;border-radius:3px;background:linear-gradient(to right,#fff var(--played,0%),#ffffff45 var(--played,0%))} +#mobilePlaybackProgress::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:11px;height:11px;margin-top:-4px;border:0;border-radius:50%;background:#fff} +#mobilePlaybackProgress::-moz-range-track{height:3px;background:#ffffff45;border:0} +#mobilePlaybackProgress::-moz-range-progress{height:3px;background:#fff} +#mobilePlaybackProgress::-moz-range-thumb{width:11px;height:11px;border:0;border-radius:50%;background:#fff} +.mobile-episode-rail{display:flex;width:100%;align-items:center;justify-content:space-between;gap:10px;padding:10px 0 13px;min-height:45px;color:#fff;background:transparent;border:0;border-radius:0;pointer-events:auto;text-align:left;font-size:14px;font-weight:600} +.mobile-episode-rail small{font-size:12px;color:#c7c7cf;margin-left:8px;font-weight:400} +#mobileEpisodesBtn svg{transform:rotate(-90deg)} +.mobile-vip-label{display:inline-block;padding:3px 7px;margin-bottom:7px;background:#f3d495;color:#44300c;border-radius:4px;font-size:11px;font-weight:600} +.mobile-player .playback-feedback{display:none;position:absolute;z-index:3;top:calc(67px + env(safe-area-inset-top));left:20px;right:20px;justify-content:center;color:#fff;pointer-events:none;font-size:12px;text-shadow:0 1px 4px #000} +.mobile-player.playback-loading .playback-feedback,.mobile-player.playback-buffering .playback-feedback{display:flex} +.mobile-player .playback-feedback #playbackStatus{color:#fff;background:#0008;border-radius:16px;padding:5px 11px;max-width:100%;text-align:center} +.mobile-player #prefetchStatus,.mobile-player #playbackHistoryStatus{display:none} +.mobile-player .playback-error-row{position:absolute;top:36%;left:20px;right:20px;z-index:4;display:flex;flex-direction:column;gap:12px;background:#151518ed;padding:16px;border:1px solid #ffffff22;border-radius:12px;max-height:calc(var(--player-view-height,100dvh) * .42);overflow:auto} +.mobile-player .playback-error-row:has(#playbackError:empty){display:none} +.mobile-player #playbackError{color:#ffb6bd;font-size:13px;line-height:1.7} +.mobile-player .playback-error-row button{margin:0;align-self:center} +.mobile-player .player-sheet-backdrop{position:fixed;inset:0;z-index:5;width:100%;height:100%;margin:0;padding:0;border:0;border-radius:0;background:#0007;touch-action:none} +.mobile-player.episodes-open .player-sheet-backdrop,.mobile-player.preferences-open .player-sheet-backdrop{display:block} +.mobile-player .player-episodes,.mobile-player .player-preferences{position:fixed;bottom:0;left:0;right:0;z-index:6;border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;background:var(--panel);color:var(--text);padding:16px max(18px,env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) max(18px,env(safe-area-inset-left));max-height:calc(var(--player-view-height,100dvh) * .78);overflow:auto;overscroll-behavior:contain;margin:0;box-shadow:0 -10px 40px #0004} +.mobile-player .player-episodes{display:none} +.mobile-player.episodes-open .player-episodes{display:flex} +.mobile-player .episode-panel-heading #closeEpisodesBtn{display:flex} +.mobile-player .player-episode-list{grid-template-columns:repeat(6,minmax(0,1fr));max-height:calc(var(--player-view-height,100dvh) * .43);min-height:0;overflow:auto;gap:7px;overscroll-behavior:contain;touch-action:pan-y} +.mobile-player .player-episode-list button{min-height:43px;padding:6px 2px;font-size:12px} +.mobile-player .episode-navigation select{flex:1;max-width:180px} +.mobile-player .episode-jump{justify-content:flex-end} +.mobile-player .mobile-settings-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px} +.mobile-settings-heading h3{font-size:17px} +.mobile-setting-group{margin:14px 0 18px;font-size:13px} +.mobile-setting-group>span{display:block;margin-bottom:9px;color:var(--muted)} +.mobile-setting-choices{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} +.mobile-setting-choices button{min-height:39px;padding:8px 5px;font-size:12px;overflow-wrap:anywhere} +.mobile-setting-choices button[aria-pressed=true]{background:#ff466c20;border-color:#ff466c88;color:#ff7e97} +.mobile-player .player-shortcuts{font-size:11px;color:var(--muted);line-height:1.6} +.mobile-player .player-keyboard-hint{display:none} +.mobile-player .playback-danmaku{inset:76px 0 190px} +.mobile-player .playback-gesture-status{top:calc(98px + env(safe-area-inset-top));z-index:4} +@media(max-width:355px){.mobile-player .player-episode-list{grid-template-columns:repeat(5,minmax(0,1fr))}.mobile-player-caption h2{font-size:16px}.mobile-progress-heading{font-size:10px}.mobile-player .player-episodes,.mobile-player .player-preferences{padding-left:12px;padding-right:12px}} + .mobile-player .player-viewport{position:relative;width:100%;height:100%;transform:translateZ(0)} +.mobile-player.player-rotated .player-viewport{position:absolute;left:50%;top:50%;width:var(--player-view-width);height:var(--player-view-height);transform:translate(-50%,-50%) rotate(var(--player-rotation))} +.mobile-player.player-rotated #onlineVideo{touch-action:pinch-zoom} +.mobile-player .player-sheet-backdrop,.mobile-player .player-episodes,.mobile-player .player-preferences{position:absolute} +.mobile-player .mobile-landscape-watch{position:absolute;top:var(--landscape-watch-top,65%);left:50%;transform:translateX(-50%);z-index:3;display:flex;gap:6px;align-items:center;padding:8px 12px;min-height:36px;border:0;border-radius:8px;background:#202023e8;color:#fff;font-size:13px;font-weight:500;white-space:nowrap} +.mobile-landscape-watch svg{width:18px;height:18px} +.mobile-player-heading #mobileEpisodeLabel{white-space:nowrap} +.mobile-player-heading #mobilePlayerSettingsBtn svg{transform:rotate(90deg)} +.mobile-player-heading #mobileRotateBtn{width:36px;flex:none} +.mobile-progress-row{display:flex;align-items:center;gap:12px;min-width:0} +.mobile-player-caption #mobilePlaybackProgress{flex:1;min-width:0} +.mobile-episode-rail{padding:10px 13px;margin-bottom:12px;border-radius:9px;background:#1c1c1fe8} +.mobile-bottom-actions{display:flex;align-items:center;justify-content:flex-end;gap:16px;pointer-events:auto} +.mobile-bottom-actions button{color:#fff} +.mobile-player .mobile-settings-heading{position:relative;justify-content:center;min-height:40px;padding:0 34px;margin-bottom:12px} +.mobile-player .mobile-settings-heading #closePlayerPreferencesBtn{position:absolute;left:-6px;top:0} +.mobile-player .mobile-setting-group{padding:12px;border-radius:12px;background:#ffffff0b;margin:8px 0 12px} +.mobile-player .mobile-setting-group>span{font-size:13px;color:#e8e8eb} +.mobile-player .mobile-setting-choices button[aria-pressed=true]{background:#eee;border-color:#eee;color:#202023} +#mobileOrientationHint{margin:10px 0 0;line-height:1.6} +#mobileOrientationPermissionBtn{margin-top:10px;font-size:12px} +.mobile-player[data-player-sheet=rate] .player-preferences>:not(.mobile-settings-heading):not(#mobileRateGroup),.mobile-player[data-player-sheet=quality] .player-preferences>:not(.mobile-settings-heading):not(#mobileQualityGroup){display:none!important} +.mobile-player[data-player-sheet=rate] #mobileRateGroup>span,.mobile-player[data-player-sheet=quality] #mobileQualityGroup>span{display:none} +.mobile-player.player-landscape{--episode-sheet-width:min(280px,38%);--settings-sheet-width:min(380px,48%)} +.mobile-player.player-landscape .landscape-only{display:flex;align-items:center;justify-content:center} +.player-landscape .mobile-player-heading{padding:4px max(16px,env(safe-area-inset-right)) 26px max(14px,env(safe-area-inset-left));gap:8px;align-items:center} +.player-landscape .mobile-player-heading #mobileHeadingTitle{font-size:16px;min-width:0;max-width:48%;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.player-landscape .mobile-player-heading #mobileEpisodeLabel{font-size:15px;flex:none} +.player-landscape .mobile-player-heading #mobileRateBtn{display:none} +.player-landscape .mobile-player-heading #mobileRotateBtn{margin-left:auto} +.mobile-player.player-landscape .mobile-player-caption{padding:36px max(22px,env(safe-area-inset-right)) max(6px,env(safe-area-inset-bottom)) max(22px,env(safe-area-inset-left));background:linear-gradient(transparent,#0008)} +.player-landscape .mobile-player-caption h2,.player-landscape .mobile-progress-heading,.player-landscape .mobile-play-toggle{display:none} +.player-landscape .mobile-vip-label{position:absolute;bottom:100px;left:22px} +.player-landscape .mobile-progress-row{gap:12px;min-height:42px} +.mobile-player.player-landscape .mobile-progress-row button{width:36px;height:38px;min-height:38px;padding:0;background:transparent;color:#fff;border:0;flex:none;pointer-events:auto} +.player-landscape .mobile-progress-row button svg{width:24px;height:24px} +.player-landscape .mobile-time{font-size:12px;font-variant-numeric:tabular-nums;flex:none;min-width:34px} +.player-landscape .mobile-bottom-actions{gap:18px;min-height:38px} +.player-landscape .mobile-bottom-actions button{font-size:14px;font-weight:500;min-height:38px;padding:5px 8px;margin:0;background:transparent;border:0;width:auto;flex:none} +.player-landscape #mobileEpisodesBtn small,.player-landscape #mobileEpisodesBtn>.mobile-episode-chevron{display:none} +.player-landscape[data-paused=true] #mobileTransportBtn .control-pause,.player-landscape[data-paused=false] #mobileTransportBtn .control-play{display:none} +.player-landscape .mobile-player-heading,.player-landscape .mobile-player-caption{transition:opacity .15s ease} +.player-landscape.player-controls-hidden .mobile-player-heading,.player-landscape.player-controls-hidden .mobile-player-caption,.player-landscape.episodes-open .mobile-player-heading,.player-landscape.episodes-open .mobile-player-caption{opacity:0;pointer-events:none} +.player-landscape.player-controls-hidden .mobile-player-heading *,.player-landscape.player-controls-hidden .mobile-player-caption *,.player-landscape.episodes-open .mobile-player-heading *,.player-landscape.episodes-open .mobile-player-caption *{pointer-events:none} +.mobile-player.player-landscape .player-preferences,.mobile-player.player-landscape .player-episodes{top:0;bottom:0;left:auto;right:0;width:var(--settings-sheet-width);height:100%;max-height:100%;padding:10px max(16px,env(safe-area-inset-right)) max(10px,env(safe-area-inset-bottom)) 16px;border:0;border-radius:16px 0 0 16px;background:#202023f5;box-shadow:-8px 0 32px #0003} +.mobile-player.player-landscape .player-episodes{width:var(--episode-sheet-width);gap:9px} +.mobile-player.player-landscape.episodes-open .player-screen{width:calc(100% - var(--episode-sheet-width))} +.mobile-player.player-landscape .player-episode-list{grid-template-columns:repeat(4,minmax(0,1fr));max-height:none;flex:1;gap:8px;scrollbar-gutter:auto} +.mobile-player.player-landscape .player-episode-list button{min-height:43px;font-size:14px;border:0;border-radius:9px;background:#ffffff0d} +.mobile-player.player-landscape .player-episode-list button[aria-current=true]{background:#fa71352e;color:#ff864b} +.player-landscape .episode-panel-heading{position:relative;min-height:42px;justify-content:center;padding:0 30px} +.player-landscape .episode-panel-heading>div{text-align:center} +.player-landscape .episode-panel-heading h3{font-size:16px} +.player-landscape .episode-panel-heading #closeEpisodesBtn{position:absolute;left:-6px;top:0} +.player-landscape #playbackEpisodeCount{font-size:10px;white-space:nowrap} +.player-landscape .episode-navigation select{max-width:none} +.player-landscape .episode-navigation button{font-size:11px;padding:6px} +.player-landscape .episode-jump{font-size:11px;gap:5px;justify-content:center} +.player-landscape .episode-jump input{width:50px;padding:7px} +.player-landscape .episode-jump button{font-size:11px;padding:7px} +.mobile-player.player-landscape .player-sheet-backdrop{background:#0001} +.mobile-player.player-landscape .playback-danmaku{inset:60px 0 100px} +.mobile-player.player-landscape .playback-gesture-status{top:58px} +.mobile-player.player-landscape .playback-feedback{top:55px} +.mobile-player.player-landscape[data-player-sheet=quality] .player-preferences,.mobile-player.player-landscape[data-player-sheet=rate] .player-preferences{width:var(--episode-sheet-width)} +.player-landscape[data-player-sheet=quality] .mobile-setting-choices,.player-landscape[data-player-sheet=rate] .mobile-setting-choices{display:flex;flex-direction:column;gap:5px} +.player-landscape[data-player-sheet=quality] .mobile-setting-choices button,.player-landscape[data-player-sheet=rate] .mobile-setting-choices button{min-height:40px;border:0;font-size:15px;background:transparent;color:#e1e1e5} +.player-landscape[data-player-sheet=quality] .mobile-setting-choices button[aria-pressed=true],.player-landscape[data-player-sheet=rate] .mobile-setting-choices button[aria-pressed=true]{background:#ffffff29;color:#fff} +.player-landscape[data-player-sheet=quality] .mobile-setting-group,.player-landscape[data-player-sheet=rate] .mobile-setting-group{background:transparent;padding:0;margin:0} +@media(prefers-reduced-motion:reduce){.player-landscape .mobile-player-heading,.player-landscape .mobile-player-caption{transition:none}} diff --git a/internal/webui/player.js b/internal/webui/player.js new file mode 100644 index 0000000..c8289c1 --- /dev/null +++ b/internal/webui/player.js @@ -0,0 +1,953 @@ +(() => { + const node = id => document.getElementById(id); + const panel = node('playerPanel'); + const video = node('onlineVideo'); + const episodeList = node('playbackEpisodes'); + const statusText = node('playbackStatus'); + const errorText = node('playbackError'); + const qualityControl = node('playbackQualityControl'); + const qualitySelect = node('playbackQuality'); + let quality = 0; + let transport = 'mse'; + let allowRemux = false, streamRemux = false; + let mediaPlan = null, planMode = 'auto', planKey = '', planFallbacks = 0, networkRefreshes = 0; + try {quality = Number(localStorage.getItem('juku.playback.quality')) || 0;} catch (_) {} + if (!Number.isInteger(quality) || quality < 0 || quality > 4320) quality = 0; + let dramaID = ''; + let dramaName = ''; + let collectionTaskID = ''; + let collectionMode = false; + let preparedIndex = 0; + let sessionID = ''; + let sessionAvailable = false; + let mimeType = ''; + let episodes = []; + let episodeButtons = []; + let episodePage = 0, prefetchStatusTimer = null, prefetchPreparing = false; + const completedEpisodes = new Set(); + let currentIndex = 0; + let openingVersion = 0; + let streamVersion = 0; + let openingController = null; + let streamController = null; + let objectURL = ''; + let loading = false; + let recoveryPromise = null, recoveryTarget = null, recoveryBlocked = false; + let recoveryAttempts = []; + let seekTimer = null; + let lastPosition = 0; + let playbackRun = 0; + let streamComplete = false; + let prefetchAttempted = 0; + let prefetchVersion = 0; + let historySource = '', releaseStatus = ''; + let historySequence = 0; + let historyPlayed = false; + let historyCompleted = false; + let historySentAt = 0; + let historySignature = ''; + let playbackDuration = 0; + let historyMessage = ''; + const historyStatus = node('playbackHistoryStatus'); + const prefetchToggle = node('prefetchNextEpisode'); + const prefetchStatus = node('prefetchStatus'); + try {prefetchToggle.checked = localStorage.getItem('juku.playback.prefetchNext') !== 'false';} catch (_) {} + const mobile = window.JukuPlayerMobile({panel, video, state: () => ({title: dramaName, episode: episodes[currentIndex - 1]?.episode, total: episodes.length, duration: playbackDuration, loading, releaseStatus, vip: episodes[currentIndex - 1]?.vip}), showEpisodes: showEpisodePanel, showPreferences: section => setPlayerPreferences(true, section)}); + const controls = window.JukuPlayerControls({panel, video, mobile: () => mobile.active(), rotation: () => mobile.rotation(), canSwipe: () => panel.open && episodes.length > 0 && !panel.classList.contains('episodes-open') && node('playerPreferences').hidden, swipe: direction => { + const next = currentIndex + direction; + if (next < 1) return '已经是第一集'; + if (next > episodes.length) return '已经是最后一集'; + playEpisode(next); + return '第 ' + episodes[next - 1].episode + ' 集'; + }, ready: () => !loading && Boolean(currentIndex), fullscreen: () => { + if (document.fullscreenElement === node('playbackStage') || document.webkitFullscreenElement === node('playbackStage')) node('exitPlayerFullscreenBtn').click(); + else if (!node('playerFullscreenBtn').hidden) node('playerFullscreenBtn').click(); + }}); + + function clear(element) { + while (element.firstChild) element.removeChild(element.firstChild); + } + + const capabilities = window.JukuPlaybackCapabilities(window, video, navigator); + const supportsNativePlayback = capabilities.native; + const supportsMediaSource = capabilities.mse; + let pendingMediaFailure = null; + const mediaLoader = window.JukuPlayerMedia(window, video, async error => { + const version = streamVersion; + if (!mediaPlan || !panel.open) return; + if (!await handleMediaFailure(error, version)) showError(error); + }); + + function handleMediaFailure(error, version) { + if (pendingMediaFailure?.version === version) return pendingMediaFailure.promise; + const pending = {version}; + pendingMediaFailure = pending; + pending.promise = (async () => { + const state = await heartbeat(true); + if (version !== streamVersion || !panel.open || !sessionAvailable || recoveryPromise) return true; + if (!state || state.mediaFailure) {error.playbackNetwork = true; error.playbackDecode = false;} + return fallbackPlanned(error); + })().finally(() => {if (pendingMediaFailure === pending) pendingMediaFailure = null;}); + return pending.promise; + } + + function fallbackPlanned(error) { + if (!mediaPlan || !sessionAvailable || !currentIndex) return false; + if (mediaPlan.delivery === 'redirect') planMode = 'proxy'; + else if (error.playbackNetwork) { + if (networkRefreshes >= 1) return false; + networkRefreshes++; + planMode = 'proxy'; + } else { + if (planFallbacks >= 2) return false; + planFallbacks++; + planMode = mediaPlan.processing === 'original' ? 'compatible' : 'legacy'; + if (planMode === 'legacy') allowRemux = false; + } + const position = !loading && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition; + playEpisode(currentIndex, position, desiredPlayback, true); + return true; + } + let desiredPlayback = true; + const keepalive = window.JukuPlaybackSession({ + request: (session, signal) => requestJSON('/api/ui/playback/control', {session, action: 'heartbeat'}, signal), + onState: state => {if (state.run === playbackRun) renderPrefetchStatus(state.prefetch);}, + onExpired: () => {sessionAvailable = false; recoverPlayback();}, + onError: error => {sessionAvailable = false; recoveryBlocked = true; showError(error);} + }); + + function fallbackPlayback(index, offset, shouldPlay, keepResumeMessage = true) { + if (transport === 'mse' && allowRemux && streamRemux) { + allowRemux = false; + playEpisode(index, offset, shouldPlay, keepResumeMessage); + return true; + } + if (transport !== 'mse' || !supportsNativePlayback()) return false; + transport = 'hls'; + playEpisode(index, offset, shouldPlay, keepResumeMessage); + return true; + } + + function updateQualities(options, current) { + clear(qualitySelect); + const automatic = document.createElement('option'); + automatic.value = '0'; + automatic.textContent = current > 0 ? '自动(' + current + 'p)' : '自动'; + qualitySelect.appendChild(automatic); + const values = new Set(); + for (const entry of Array.isArray(options) ? options : []) { + const value = Number(entry.value); + if (!Number.isInteger(value) || value <= 0 || value > 4320 || values.has(value)) continue; + values.add(value); + const option = document.createElement('option'); + option.value = String(value); + option.textContent = value + 'p'; + qualitySelect.appendChild(option); + } + qualitySelect.value = values.has(quality) ? String(quality) : '0'; + qualityControl.hidden = values.size < 2; + qualitySelect.disabled = loading; + mobile.update(); + } + + async function requestJSON(path, body, signal) { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, {once: true}); + let timedOut = false; + const timeout = setTimeout(() => {timedOut = true; controller.abort();}, 100000); + try { + const response = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: {'Accept': 'application/json', 'Content-Type': 'application/json', ...window.JukuViewer?.headers()}, + credentials: 'same-origin', + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + const result = await response.json(); + window.JukuViewer?.checkResponse(result); + if (!response.ok) { + const error = new Error(result.error || 'HTTP ' + response.status); + error.status = response.status; + throw error; + } + return result; + } catch (error) { + if (timedOut) throw new Error('播放请求超时,请检查连接后重试'); + throw error; + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + } + } + + function historySnapshot(force = false, repeat = false) { + if (!historyPlayed || !sessionID || !playbackRun || !episodes[currentIndex - 1] || !window.JukuHistory) return null; + const position = !loading && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition; + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : playbackDuration; + if (!Number.isFinite(position) || position < 0) return null; + const signature = playbackRun + ':' + position.toFixed(3) + ':' + historyCompleted; + if (!repeat && signature === historySignature || !force && Date.now() - historySentAt < 10000) return null; + historySignature = signature; + historySentAt = Date.now(); + const episode = episodes[currentIndex - 1]; + const progress = {run: playbackRun, sequence: ++historySequence, episode: currentIndex, position, duration: duration || 0, completed: historyCompleted}; + const entry = {dramaId: dramaID, title: dramaName, source: historySource, chapterId: episode.chapterId || '', episode: episode.episode, index: episode.number || currentIndex, total: episode.total || episodes.length, position, duration: duration || 0, completed: historyCompleted, mode: collectionMode ? 'collection' : 'online', taskId: episode.taskId || '', watchedAt: new Date().toISOString()}; + return {progress, entry}; + } + + function saveHistory(force = false) { + const snapshot = historySnapshot(force); + if (!snapshot) return; + const session = sessionID; + window.JukuHistory.save(session, snapshot.progress, snapshot.entry).then(() => { + if (session === sessionID && snapshot.progress.sequence === historySequence) historyStatus.textContent = historyMessage; + }).catch(error => { + if (session !== sessionID || snapshot.progress.sequence !== historySequence) return; + historySignature = ''; + if (error.status === 410) {sessionAvailable = false; recoverPlayback(); return;} + historyStatus.textContent = '观看进度暂未保存:' + error.message; + }); + } + + function releaseSession(id, progress, unloading = false) { + if (!id) return; + const body = JSON.stringify({session: id, action: 'close', ...(progress ? {progress} : {})}); + if (unloading && navigator.sendBeacon && navigator.sendBeacon('/api/ui/playback/control', new Blob([body], {type: 'application/json'}))) return; + fetch('/api/ui/playback/control', {method: 'POST', headers: {'Content-Type': 'application/json', ...window.JukuViewer?.headers()}, credentials: 'same-origin', body, keepalive: true}).then(response => response.json()).then(result => { + window.JukuViewer?.checkResponse(result); + if (progress && result.historyError) window.JukuHistory?.reportError(result.historyError); + }).catch(error => {if (progress && !unloading) window.JukuHistory?.reportError(error.message);}); + } + + function stopStream() { + mediaLoader.destroy(); + mediaPlan = null; + controls.reset(); + saveHistory(true); + historyPlayed = false; + historyCompleted = false; + historySignature = ''; + playbackDuration = 0; + window.JukuPlaybackDanmaku?.suspend(); + streamVersion++; + playbackRun = 0; + streamComplete = false; + prefetchAttempted = 0; + prefetchPreparing = false; + clearTimeout(prefetchStatusTimer); + prefetchVersion++; + prefetchStatus.hidden = true; + prefetchStatus.textContent = ''; + loading = true; + qualityControl.hidden = true; + qualitySelect.disabled = true; + clearTimeout(seekTimer); + if (streamController) streamController.abort(); + streamController = null; + video.pause(); + video.removeAttribute('src'); + video.load(); + if (objectURL) URL.revokeObjectURL(objectURL); + objectURL = ''; + } + + function dispose(unloading = false) { + const snapshot = historySnapshot(true, true); + if (snapshot) window.JukuHistory.remember(snapshot.entry); + historyPlayed = false; + openingVersion++; + if (openingController) openingController.abort(); + openingController = null; + keepalive.stop(); + recoveryPromise = null; + recoveryTarget = null; + stopStream(); + releaseSession(sessionID, snapshot?.progress, unloading); + sessionID = ''; + sessionAvailable = false; + preparedIndex = 0; + window.JukuPlaybackDanmaku?.close(); + } + + function updateEpisodeControls() { + node('previousEpisodeBtn').disabled = currentIndex <= 1; + node('nextEpisodeBtn').disabled = currentIndex < 1 || currentIndex >= episodes.length; + node('retryPlaybackBtn').disabled = !dramaID && !collectionTaskID; + node('retryPlaybackBtn').hidden = !errorText.textContent; + for (const button of episodeButtons) { + const index = Number(button.dataset.episodeIndex); + button.setAttribute('aria-current', String(index === currentIndex)); + button.classList.toggle('watched', completedEpisodes.has(index)); + } + node('playbackEpisodeCount').textContent = episodes.length ? '第 ' + (episodes[currentIndex - 1]?.episode || currentIndex || '—') + ' 集 / 共 ' + episodes.length + ' 集' : '正在获取分集'; + node('jumpEpisode').max = episodes.length || 1; + node('jumpEpisode').disabled = !episodes.length; + node('jumpEpisodeBtn').disabled = !episodes.length; + node('currentEpisodeBtn').disabled = !currentIndex; + node('episodeRange').disabled = !episodes.length; + mobile.update(); + } + + function showError(error) { + controls.reset(); + window.JukuPlaybackDanmaku?.suspend(); + loading = false; + qualitySelect.disabled = false; + errorText.textContent = (error.message || String(error)) + ';可点击“重试播放”。'; + statusText.textContent = '播放未完成'; + if (error.status === 410) sessionAvailable = false; + updateEpisodeControls(); + } + + function updateDependency(state, text) { + if (!panel.open || !openingController || sessionID || errorText.textContent) return; + if (state.status === 'downloading' || state.status === 'verifying') statusText.textContent = text; + else if (state.status === 'ready') statusText.textContent = collectionMode ? '正在读取合集分集…' : '正在获取分集…'; + } + + function renderEpisodes(page = episodePage) { + episodePage = Math.max(0, Math.min(page, Math.ceil(episodes.length / 30) - 1)); + clear(episodeList); + const range = node('episodeRange'); + clear(range); + for (let start = 0; start < episodes.length; start += 30) { + const option = document.createElement('option'); + option.value = String(start / 30); + option.textContent = (start + 1) + '–' + Math.min(episodes.length, start + 30) + ' 集'; + range.appendChild(option); + } + range.value = String(episodePage); + episodeButtons = episodes.slice(episodePage * 30, episodePage * 30 + 30).map(episode => { + const button = document.createElement('button'); + button.className = 'secondary'; + button.textContent = episode.episode + (episode.vip ? ' VIP' : ''); + button.classList.toggle('vip-episode', Boolean(episode.vip)); + button.title = (episode.title || '第 ' + episode.episode + ' 集') + (episode.vip ? ' · VIP,仅试看' : ''); + button.setAttribute('aria-label', '播放第 ' + episode.episode + ' 集' + (episode.vip ? ',VIP,仅试看' : '')); + button.dataset.episodeIndex = episode.index; + button.addEventListener('click', () => {playEpisode(episode.index); closeEpisodePanel();}); + episodeList.appendChild(button); + return button; + }); + updateEpisodeControls(); + } + + function heartbeat(fresh = false) {return keepalive.ping(fresh);} + + function recoveryPosition() { + const position = !loading && !video.error && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition; + const duration = playbackDuration || video.duration; + return Math.max(0, Number.isFinite(duration) && duration > 0 ? Math.min(position, Math.max(0, duration - .25)) : position); + } + + function recoverPlayback(index = currentIndex, offset = recoveryPosition(), shouldPlay = desiredPlayback) { + const episode = episodes[index - 1]; + if (!panel.open || !episode || recoveryBlocked) return Promise.resolve(false); + const target = {index, offset, shouldPlay, chapter: episode.chapterId, episode: episode.episode, task: episode.taskId}; + if (recoveryPromise) {if (arguments.length) recoveryTarget = target; return recoveryPromise;} + recoveryTarget = target; + sessionAvailable = false; + if (document.hidden || navigator.onLine === false) return Promise.resolve(false); + recoveryAttempts = recoveryAttempts.filter(time => Date.now() - time < 60000); + if (recoveryAttempts.length >= 2) { + showError(new Error('播放连接暂未恢复,请检查网络后重试')); + return Promise.resolve(false); + } + recoveryAttempts.push(Date.now()); + const snapshot = historySnapshot(true, true); + if (snapshot) window.JukuHistory?.remember(snapshot.entry); + const completed = new Set(Array.from(completedEpisodes, number => episodes[number - 1]?.chapterId)); + const previousSession = sessionID; + const version = ++openingVersion; + keepalive.stop(); + openingController?.abort(); + const controller = new AbortController(); + openingController = controller; + historyPlayed = false; + stopStream(); + sessionID = ''; + releaseSession(previousSession); + errorText.textContent = ''; + statusText.textContent = '正在恢复播放连接…'; + updateEpisodeControls(); + const promise = (async () => { + try { + const selection = collectionMode ? {taskId: target.task || collectionTaskID} : {dramaId: dramaID}; + const result = await requestJSON('/api/ui/playback/open', {...selection, resume: false}, controller.signal); + if (version !== openingVersion || !panel.open) {releaseSession(result.session); return false;} + sessionID = result.session; + sessionAvailable = true; + mimeType = result.mimeType || mimeType; + dramaID = result.dramaId || dramaID; + dramaName = result.title || dramaName; + historySource = result.source || historySource; + releaseStatus = result.releaseStatus || releaseStatus; + episodes = result.episodes || []; + if (!episodes.length) throw new Error('站点没有返回可播放的分集'); + const latest = recoveryTarget || target; + let selected = latest.chapter && episodes.find(item => item.chapterId === latest.chapter); + if (!selected) selected = episodes.find(item => item.episode === latest.episode); + if (!selected) throw new Error('原分集已不在当前选集中,请重新选集'); + completedEpisodes.clear(); + for (const item of episodes) if (item.chapterId && completed.has(item.chapterId)) completedEpisodes.add(item.index); + preparedIndex = 0; + historySequence = 0; + historySentAt = 0; + historyMessage = '已自动恢复到刚才的观看位置'; + historyStatus.textContent = historyMessage; + node('playerTitle').textContent = dramaName; + renderEpisodes(Math.floor((selected.index - 1) / 30)); + keepalive.start(sessionID); + recoveryTarget = null; + playEpisode(selected.index, Math.max(0, latest.offset), latest.shouldPlay, true); + return true; + } catch (error) { + if (version === openingVersion && error.name !== 'AbortError') { + if (error.status === 401 || error.status === 403) recoveryBlocked = true; + sessionAvailable = false; + showError(error); + } + return false; + } finally { + if (recoveryPromise === promise) recoveryPromise = null; + } + })(); + recoveryPromise = promise; + return promise; + } + + async function wakePlayback() { + if (!panel.open || document.hidden || navigator.onLine === false || recoveryBlocked || recoveryPromise) return; + if (!sessionAvailable) { + const target = recoveryTarget; + if (target) recoverPlayback(target.index, !loading && target.index === currentIndex ? recoveryPosition() : target.offset, !loading && target.index === currentIndex ? desiredPlayback : target.shouldPlay); + else recoverPlayback(); + return; + } + const version = streamVersion; + const state = await heartbeat(true); + if (state && version === streamVersion && panel.open && video.error) recoverPlayback(); + } + + async function open(id, title, initialIndex = 0, offset = 0, taskID = '', resume = true, fromHistory = false) { + dispose(); + recoveryAttempts = []; + recoveryBlocked = false; + planKey = ''; + dramaID = id; + dramaName = title; + collectionTaskID = taskID; + collectionMode = Boolean(taskID); + episodes = []; + episodeButtons = []; + episodePage = 0; + completedEpisodes.clear(); + currentIndex = 0; + lastPosition = offset; + historySource = ''; + releaseStatus = ''; + historySequence = 0; + historyMessage = ''; + historySentAt = 0; + historyStatus.textContent = ''; + node('playerTitle').textContent = title; + statusText.textContent = collectionMode ? '正在读取合集分集…' : '正在获取分集…'; + updatePlaybackHint(''); + errorText.textContent = ''; + clear(episodeList); + updateEpisodeControls(); + if (!panel.open) {panel.showModal(); video.focus({preventScroll: true});} + if (!supportsMediaSource('video/mp4; codecs="avc1.42C01F, mp4a.40.2"') && !supportsNativePlayback() && !capabilities.describe().mp4) { + showError(new Error('当前浏览器不支持 H.264 在线播放,请更新浏览器后重试')); + return; + } + const version = openingVersion; + openingController = new AbortController(); + try { + const shouldResume = resume && initialIndex === 0; + const result = await requestJSON('/api/ui/playback/open', {...(taskID ? {taskId: taskID} : {dramaId: id}), resume: shouldResume, fromHistory}, openingController.signal); + if (version !== openingVersion || !panel.open) { + releaseSession(result.session); + return; + } + sessionID = result.session; + sessionAvailable = true; + mimeType = result.mimeType; + dramaID = result.dramaId || id; + dramaName = result.title || title; + historySource = result.source || ''; + releaseStatus = result.releaseStatus || ''; + collectionMode = result.mode === 'collection'; + episodes = result.episodes || []; + const remembered = window.JukuHistory.get(dramaID); + if (remembered?.completed) { + const finished = episodes.find(episode => remembered.chapterId ? episode.chapterId === remembered.chapterId : episode.episode === remembered.episode); + if (finished) completedEpisodes.add(finished.index); + } + transport = capabilities.choose(mimeType); + allowRemux = transport === 'mse' && capabilities.remux(); + if (!episodes.length) throw new Error('站点没有返回可播放的分集'); + if (!transport && !capabilities.describe().mp4) throw new Error('当前浏览器没有可用的在线播放通道,请使用支持 HLS 或 H.264 的浏览器'); + node('playerTitle').textContent = result.title || title; + renderEpisodes(); + keepalive.start(sessionID); + historyMessage = shouldResume ? result.resumeMessage || '' : ''; + historyStatus.textContent = historyMessage; + playEpisode(Math.min(Math.max(initialIndex || result.initialIndex || 1, 1), episodes.length), shouldResume ? Number(result.initialPosition) || 0 : offset, !shouldResume || !result.resumePaused, true); + } catch (error) { + if (version === openingVersion && error.name !== 'AbortError') showError(error); + } + } + + function openCollection(taskID, title, resume = false) { + return open('', title, 0, 0, taskID, resume); + } + + function openHistory(id, title) { + return open(id, title, 0, 0, '', true, true); + } + + function reopen(index, offset) { + if (collectionMode) return open('', dramaName, 0, offset, episodes[index - 1]?.taskId || collectionTaskID, false); + return open(dramaID, dramaName, index, offset, '', false); + } + + function updatePlaybackHint(source) { + if (!collectionMode) { + node('playbackHint').textContent = '直接观看,不加入下载任务。开启预缓存后,临近播完时提前准备下一集;关闭窗口即停止取流并释放缓存。'; + } else { + const prefix = source === 'local' ? '本集播放本地已完成文件。' : source === 'online' ? '本集在线缓冲,同时使用原下载任务保存视频。' : '已完成分集优先播放本地,播到未完成分集时自动下载该集。'; + node('playbackHint').textContent = prefix + '只补下载播到的分集;关闭播放器不取消下载,可在下载合集中暂停或取消。'; + } + } + + function abortError() { + return new DOMException('播放已停止', 'AbortError'); + } + + function waitForEvent(target, eventName, signal, action) { + return new Promise((resolve, reject) => { + function cleanup() { + target.removeEventListener(eventName, done); + target.removeEventListener('error', failed); + signal.removeEventListener('abort', aborted); + } + function done() {cleanup(); resolve();} + function failed() {cleanup(); const error = new Error('浏览器无法解码此视频流'); error.playbackDecode = true; reject(error);} + function aborted() {cleanup(); reject(abortError());} + if (signal.aborted) {aborted(); return;} + target.addEventListener(eventName, done, {once: true}); + target.addEventListener('error', failed, {once: true}); + signal.addEventListener('abort', aborted, {once: true}); + try {if (action) action();} catch (error) {cleanup(); reject(error);} + }); + } + + function bufferedAhead() { + for (let index = 0; index < video.buffered.length; index++) { + if (video.currentTime >= video.buffered.start(index) && video.currentTime <= video.buffered.end(index)) return video.buffered.end(index) - video.currentTime; + } + return 0; + } + + function schedulePrefetchStatus() { + clearTimeout(prefetchStatusTimer); + if (!prefetchPreparing || !prefetchToggle.checked || !panel.open || !sessionID) return; + const session = sessionID, run = playbackRun; + prefetchStatusTimer = setTimeout(async () => { + if (session !== sessionID || run !== playbackRun) return; + await heartbeat(); + if (session === sessionID && run === playbackRun) schedulePrefetchStatus(); + }, document.hidden ? 10000 : 2000); + } + + function renderPrefetchStatus(view) { + if (!prefetchToggle.checked || !view || view.episode !== currentIndex + 1) return; + prefetchStatus.hidden = false; + prefetchStatus.textContent = view.state === 'resolved' ? '下一集地址已准备' : view.state === 'ready' ? '下一集已缓存' : view.state === 'partial' ? '下一集开头已缓存' : view.state === 'failed' ? '下一集将正常缓冲' : '正在缓存下一集…'; + prefetchPreparing = !['ready', 'partial', 'resolved', 'failed'].includes(view.state); + schedulePrefetchStatus(); + } + + function maybePrefetchNext() { + if ((mediaPlan || transport === 'hls') && playbackDuration > 0 && video.currentTime > 0 && bufferedAhead() >= playbackDuration - video.currentTime - 0.5) streamComplete = true; + if (!prefetchToggle.checked || !streamComplete || loading || video.paused || video.ended || video.seeking || !panel.open || !sessionAvailable || !playbackRun || !currentIndex || currentIndex >= episodes.length || prefetchAttempted === currentIndex) return; + const remaining = video.duration - video.currentTime; + if (!Number.isFinite(remaining) || remaining <= 0 || remaining / Math.max(video.playbackRate, 0.25) > 30 || bufferedAhead() < remaining - 0.5) return; + const session = sessionID; + const run = playbackRun; + const version = ++prefetchVersion; + prefetchAttempted = currentIndex; + renderPrefetchStatus({episode: currentIndex + 1, state: 'preparing'}); + requestJSON('/api/ui/playback/prefetch', {session, episode: currentIndex + 1, run, version, remux: allowRemux && transport === 'mse'}, openingController?.signal).then(view => { + if (session === sessionID && run === playbackRun && version === prefetchVersion) renderPrefetchStatus(view); + }).catch(error => { + if (session === sessionID && run === playbackRun && version === prefetchVersion && error.name !== 'AbortError') renderPrefetchStatus({episode: currentIndex + 1, state: 'failed'}); + }); + } + + prefetchToggle.addEventListener('change', () => { + try {localStorage.setItem('juku.playback.prefetchNext', String(prefetchToggle.checked));} catch (_) {} + prefetchAttempted = 0; + const version = ++prefetchVersion; + prefetchStatus.hidden = true; + prefetchPreparing = false; + clearTimeout(prefetchStatusTimer); + if (prefetchToggle.checked) { + maybePrefetchNext(); + } else if (sessionID && playbackRun) { + requestJSON('/api/ui/playback/prefetch', {session: sessionID, run: playbackRun, version, cancel: true}, openingController?.signal).catch(() => {}); + } + }); + + async function trimBuffer(buffer, signal) { + const cutoff = video.currentTime - 20; + if (cutoff > 0 && buffer.buffered.length && buffer.buffered.start(0) < cutoff) { + await waitForEvent(buffer, 'updateend', signal, () => buffer.remove(0, cutoff)); + } + } + + async function playNative(index, offset, shouldPlay, version, currentSession, signal) { + const result = await requestJSON('/api/ui/playback/hls/open', {session: currentSession, episode: index, start: offset, quality, version}, signal); + if (signal.aborted || version !== streamVersion) throw abortError(); + playbackRun = Number(result.run); + playbackDuration = Number(result.duration) || 0; + updatePlaybackHint(result.source); + updateQualities(result.qualities, Number(result.quality)); + statusText.textContent = result.prefetched ? '正在读取预缓存…' : '正在缓冲…'; + await waitForEvent(video, 'loadedmetadata', signal, () => {video.src = result.url; video.load();}); + if (signal.aborted || version !== streamVersion) throw abortError(); + if (offset > 0) video.currentTime = Math.min(offset, Math.max(0, playbackDuration - 0.05)); + video.playbackRate = Number(node('playbackRate').value) || 1; + loading = false; + qualitySelect.disabled = false; + mobile.update(); + statusText.textContent = shouldPlay ? '正在播放' : '已暂停'; + if (shouldPlay) video.play().catch(error => { + if (version !== streamVersion || signal.aborted) return; + if (error.name === 'NotAllowedError') statusText.textContent = '已就绪,点击视频中的播放按钮'; + else if (error.name !== 'AbortError') showError(error); + }); + } + + async function playPlanned(index, offset, shouldPlay, version, currentSession, signal) { + const result = await requestJSON('/api/ui/playback/plan', {session: currentSession, episode: index, start: offset, quality, version, mode: planMode, client: capabilities.describe()}, signal); + if (signal.aborted || version !== streamVersion) throw abortError(); + if (result.player === 'legacy') { + planMode = 'legacy'; + if (result.reason !== 'optimization_disabled') allowRemux = false; + playEpisode(index, offset, shouldPlay, true); + return; + } + mediaPlan = result; + transport = result.player === 'mp4' ? 'file' : 'original-hls'; + playbackRun = Number(result.run); + playbackDuration = Number(result.duration) || 0; + updatePlaybackHint(result.source); + updateQualities(result.qualities, Number(result.quality)); + statusText.textContent = '正在缓冲…'; + await mediaLoader.load(result, supportsNativePlayback(), signal, offset); + if (signal.aborted || version !== streamVersion) throw abortError(); + if (Number.isFinite(video.duration) && video.duration > 0) playbackDuration = video.duration; + if (offset > 0) video.currentTime = playbackDuration > 0 ? Math.min(offset, Math.max(0, playbackDuration - 0.05)) : offset; + video.playbackRate = Number(node('playbackRate').value) || 1; + loading = false; + qualitySelect.disabled = false; + mobile.update(); + statusText.textContent = shouldPlay ? '正在播放' : '已暂停'; + if (shouldPlay) video.play().catch(error => { + if (version !== streamVersion || signal.aborted) return; + if (error.name === 'NotAllowedError') statusText.textContent = '已就绪,点击视频中的播放按钮'; + else if (error.name !== 'AbortError') showError(error); + }); + } + + async function playEpisode(index, offset = 0, shouldPlay = true, keepResumeMessage = false) { + if (!episodes[index - 1]) return; + const nextPlanKey = dramaID + ':' + index + ':' + quality; + if (planKey !== nextPlanKey) {planKey = nextPlanKey; planMode = 'auto'; planFallbacks = 0; networkRefreshes = 0;} + if (!sessionAvailable) {recoverPlayback(index, offset, shouldPlay); return;} + stopStream(); + if (!keepResumeMessage) { + historyMessage = ''; + historyStatus.textContent = ''; + } + currentIndex = index; + streamRemux = false; + desiredPlayback = shouldPlay; + if (Math.floor((index - 1) / 30) !== episodePage) renderEpisodes(Math.floor((index - 1) / 30)); + window.JukuPlaybackDanmaku?.setEpisode(sessionID, index, episodes[index - 1].danmaku); + lastPosition = offset; + errorText.textContent = ''; + updateEpisodeControls(); + statusText.textContent = offset > 0 ? '正在跳转并缓冲…' : '正在解析播放地址…'; + const version = streamVersion; + const currentSession = sessionID; + const controller = new AbortController(); + streamController = controller; + const signal = controller.signal; + let reader; + try { + if (collectionMode && preparedIndex !== index) { + statusText.textContent = '正在检查本地分集并准备下载…'; + const preparation = await requestJSON('/api/ui/playback/prepare', {session: currentSession, episode: index}, signal); + if (signal.aborted) throw abortError(); + preparedIndex = index; + updatePlaybackHint(preparation.source); + window.dispatchEvent(new Event('downloadsChanged')); + } + if (planMode !== 'legacy') { + await playPlanned(index, offset, shouldPlay, version, currentSession, signal); + return; + } + if (transport !== 'mse' && transport !== 'hls') transport = capabilities.choose(mimeType); + if (!transport) throw new Error('浏览器没有可用的兼容播放通道'); + if (transport === 'hls') { + await playNative(index, offset, shouldPlay, version, currentSession, signal); + return; + } + const source = new window.MediaSource(); + objectURL = URL.createObjectURL(source); + await waitForEvent(source, 'sourceopen', signal, () => {video.src = objectURL;}); + const response = await fetch('/api/ui/playback/stream?' + new URLSearchParams({session: currentSession, episode: String(index), start: String(offset), quality: String(quality), version: String(version), remux: allowRemux ? '1' : '0'}), {signal, cache: 'no-store', credentials: 'same-origin', headers: window.JukuViewer?.headers()}); + if (!response.ok) { + const result = await response.json(); + window.JukuViewer?.checkResponse(result); + const error = new Error(result.error || 'HTTP ' + response.status); + error.status = response.status; + throw error; + } + if (signal.aborted) throw abortError(); + let options = []; + try {options = JSON.parse(response.headers.get('X-Playback-Qualities') || '[]');} catch (_) {} + updateQualities(options, Number(response.headers.get('X-Playback-Quality'))); + streamRemux = response.headers.get('X-Playback-Mode') === 'remux'; + const streamMIME = response.headers.get('X-Playback-MIME') || mimeType; + if (!supportsMediaSource(streamMIME)) { + if (fallbackPlayback(index, offset, shouldPlay, keepResumeMessage)) return; + throw new Error('浏览器不支持当前清晰度的编码,可切换较低清晰度后重试'); + } + updatePlaybackHint(response.headers.get('X-Playback-Source')); + const duration = Number(response.headers.get('X-Playback-Duration')); + playbackDuration = Number.isFinite(duration) && duration > 0 ? duration : 0; + const run = Number(response.headers.get('X-Playback-Run')); + playbackRun = run; + const buffer = source.addSourceBuffer(streamMIME); + buffer.timestampOffset = offset; + if (duration > 0 && Number.isFinite(duration)) source.duration = duration; + statusText.textContent = response.headers.get('X-Playback-Prefetched') === '1' ? '正在读取预缓存…' : '正在缓冲…'; + reader = response.body.getReader(); + let initialized = false; + while (!signal.aborted) { + while (!signal.aborted && bufferedAhead() > (video.paused && initialized ? 5 : 30)) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + if (signal.aborted) throw abortError(); + const chunk = await reader.read(); + if (chunk.done) break; + await trimBuffer(buffer, signal); + await waitForEvent(buffer, 'updateend', signal, () => buffer.appendBuffer(chunk.value)); + if (!initialized && buffer.buffered.length) { + initialized = true; + video.currentTime = Math.min(buffer.buffered.end(0) - 0.001, Math.max(offset, buffer.buffered.start(0) + 0.03)); + video.playbackRate = Number(node('playbackRate').value) || 1; + loading = false; + qualitySelect.disabled = false; + mobile.update(); + statusText.textContent = shouldPlay ? '正在播放' : '已暂停'; + if (shouldPlay) video.play().catch(error => { + if (version !== streamVersion || signal.aborted) return; + if (error.name === 'NotAllowedError') statusText.textContent = '已就绪,点击视频中的播放按钮'; + else if (error.name !== 'AbortError') showError(error); + }); + } + } + if (signal.aborted) throw abortError(); + const state = await requestJSON('/api/ui/playback/status?session=' + encodeURIComponent(currentSession), undefined, signal); + if (signal.aborted || version !== streamVersion) throw abortError(); + if (state.run !== run || state.state !== 'ended') throw new Error(state.error || '视频连接中断,请重试'); + if (!initialized) throw new Error('未收到可播放的视频画面'); + if (source.readyState === 'open') source.endOfStream(); + streamComplete = true; + maybePrefetchNext(); + } catch (error) { + if (reader) await reader.cancel().catch(() => {}); + if (version === streamVersion && !signal.aborted) { + if (error.status === 410) {recoverPlayback(index, Math.max(offset, lastPosition), desiredPlayback); return;} + if (mediaPlan && (error.playbackDecode || error.playbackNetwork) && await handleMediaFailure(error, version)) return; + const compatibleFailure = error.playbackDecode || error.name === 'NotSupportedError'; + if (compatibleFailure && fallbackPlayback(index, Math.max(offset, lastPosition), desiredPlayback, keepResumeMessage)) return; + showError(error); + } + } finally { + if (reader) reader.releaseLock(); + } + } + + video.addEventListener('seeking', () => { + if (mediaPlan || transport === 'hls') return; + if (loading || !currentIndex || !Number.isFinite(video.currentTime)) return; + const target = video.currentTime; + clearTimeout(seekTimer); + for (let index = 0; index < video.buffered.length; index++) { + if (target >= video.buffered.start(index) && target < video.buffered.end(index)) return; + } + seekTimer = setTimeout(() => playEpisode(currentIndex, target, !video.paused), 180); + }); + video.addEventListener('timeupdate', () => {if (!loading && Number.isFinite(video.currentTime)) {lastPosition = video.currentTime; if (!video.paused && !video.seeking) saveHistory();} maybePrefetchNext();}); + video.addEventListener('playing', () => {desiredPlayback = true; if (!loading && !errorText.textContent) {historyPlayed = true; historyCompleted = false; statusText.textContent = '正在播放'; saveHistory(true);} maybePrefetchNext();}); + video.addEventListener('waiting', () => {if (!loading && !errorText.textContent) statusText.textContent = '正在缓冲…';}); + video.addEventListener('pause', () => {if (!loading && !video.error) desiredPlayback = false; if (!loading) saveHistory(true); if (!loading && !video.ended && !errorText.textContent) statusText.textContent = '已暂停';}); + video.addEventListener('error', async () => { + if (!video.error || !video.hasAttribute('src') || !panel.open) return; + const version = streamVersion, code = video.error.code; + if (mediaPlan && loading) return; + if (mediaPlan) { + if (await handleMediaFailure({playbackNetwork: code === 2, playbackDecode: code !== 2}, version)) return; + } else if (transport === 'hls' || code === 2) { + await heartbeat(true); + if (version !== streamVersion || !panel.open || recoveryPromise || !sessionAvailable) return; + } + if ([3, 4].includes(code) && currentIndex && fallbackPlayback(currentIndex, lastPosition, desiredPlayback)) return; + if (streamController) streamController.abort(); + showError(new Error('浏览器播放失败,请检查网络或重试(错误 ' + code + ')')); + }); + video.addEventListener('ended', () => { + if (loading || !panel.open || errorText.textContent) return; + historyCompleted = true; + completedEpisodes.add(currentIndex); + updateEpisodeControls(); + saveHistory(true); + if (node('autoNextEpisode').checked && currentIndex < episodes.length) playEpisode(currentIndex + 1); + else statusText.textContent = '本集播放完毕'; + }); + node('previousEpisodeBtn').addEventListener('click', () => playEpisode(currentIndex - 1)); + node('nextEpisodeBtn').addEventListener('click', () => playEpisode(currentIndex + 1)); + node('retryPlaybackBtn').addEventListener('click', () => { + planKey = ''; + preparedIndex = 0; + recoveryAttempts = []; + recoveryBlocked = false; + if (episodes.length && sessionAvailable) playEpisode(currentIndex || 1, lastPosition); + else if (episodes.length) recoverPlayback(currentIndex || 1, lastPosition, true); + else reopen(currentIndex || 1, lastPosition); + }); + node('playbackRate').addEventListener('change', () => {video.playbackRate = Number(node('playbackRate').value) || 1;}); + qualitySelect.addEventListener('change', () => { + quality = Number(qualitySelect.value) || 0; + try {localStorage.setItem('juku.playback.quality', String(quality));} catch (_) {} + if (currentIndex) playEpisode(currentIndex, !loading && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition, !video.paused); + }); + node('closePlayerBtn').addEventListener('click', () => panel.close()); + panel.addEventListener('close', () => {if (!panel.open) dispose();}); + document.addEventListener('visibilitychange', () => {if (document.hidden) saveHistory(true); else wakePlayback();}); + window.addEventListener('pagehide', () => {lastPosition = recoveryPosition(); dispose(true);}); + for (const type of ['pageshow', 'online']) window.addEventListener(type, wakePlayback); + video.addEventListener('play', () => {if (!loading) wakePlayback();}); + + function showEpisodePanel() { + renderEpisodes(Math.floor(Math.max(0, currentIndex - 1) / 30)); + if (mobile.active()) { + node('playerPreferences').hidden = true; + panel.classList.remove('preferences-open'); + node('playerPreferencesBtn').setAttribute('aria-expanded', 'false'); + panel.classList.add('episodes-open'); + node('toggleEpisodesBtn').setAttribute('aria-expanded', 'true'); + window.JukuDialogs.layer('episodes', true); + node('closeEpisodesBtn').focus(); + } else node('episodeRange').focus(); + } + + function closeEpisodePanel() { + if (!panel.classList.contains('episodes-open')) return; + panel.classList.remove('episodes-open'); + node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false'); + window.JukuDialogs.layer('episodes', false); + (mobile.active() ? node('mobileEpisodesBtn') : node('toggleEpisodesBtn')).focus({preventScroll: true}); + } + + node('toggleEpisodesBtn').addEventListener('click', showEpisodePanel); + node('closeEpisodesBtn').addEventListener('click', closeEpisodePanel); + node('episodeRange').addEventListener('change', () => renderEpisodes(Number(node('episodeRange').value) || 0)); + node('currentEpisodeBtn').addEventListener('click', () => { + renderEpisodes(Math.floor(Math.max(0, currentIndex - 1) / 30)); + episodeButtons.find(button => Number(button.dataset.episodeIndex) === currentIndex)?.focus(); + }); + node('episodeJumpForm').addEventListener('submit', event => { + event.preventDefault(); + const index = Number(node('jumpEpisode').value); + if (!Number.isInteger(index) || index < 1 || index > episodes.length) { + node('jumpEpisode').setCustomValidity('请输入 1 至 ' + episodes.length + ' 之间的集号'); + node('jumpEpisode').reportValidity(); + return; + } + node('jumpEpisode').setCustomValidity(''); + playEpisode(index); + closeEpisodePanel(); + }); + node('jumpEpisode').addEventListener('input', () => node('jumpEpisode').setCustomValidity('')); + function setPlayerPreferences(visible, section = 'settings') { + controls.reset(); + node('playerPreferences').hidden = !visible; + if (visible) panel.dataset.playerSheet = section; + panel.classList.toggle('preferences-open', visible); + node('playerPreferencesBtn').setAttribute('aria-expanded', String(visible)); + if (mobile.active()) { + if (visible) { + panel.classList.remove('episodes-open'); + node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false'); + } + window.JukuDialogs.layer('player-settings', visible); + if (visible) node('closePlayerPreferencesBtn').focus({preventScroll: true}); + } else if (visible) node('playerPreferences').scrollIntoView({block: 'nearest'}); + mobile.update(); + } + node('playerPreferencesBtn').addEventListener('click', () => setPlayerPreferences(node('playerPreferences').hidden)); + node('closePlayerPreferencesBtn').addEventListener('click', () => {setPlayerPreferences(false); node('mobilePlayerSettingsBtn').focus({preventScroll: true});}); + node('playerSheetBackdrop').addEventListener('click', () => { + if (panel.classList.contains('episodes-open')) closeEpisodePanel(); + else setPlayerPreferences(false); + }); + document.addEventListener('dialoglayerchange', event => { + const preferences = event.detail === 'player-settings' && panel.open; + node('playerPreferences').hidden = !preferences; + panel.classList.toggle('preferences-open', preferences); + node('playerPreferencesBtn').setAttribute('aria-expanded', String(preferences)); + if (event.detail !== 'episodes') { + const wasOpen = panel.classList.contains('episodes-open'); + panel.classList.remove('episodes-open'); + node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false'); + if (wasOpen && panel.open) node('toggleEpisodesBtn').focus({preventScroll: true}); + } else if (panel.open) { + panel.classList.add('episodes-open'); + node('toggleEpisodesBtn').setAttribute('aria-expanded', 'true'); + } + }); + panel.addEventListener('close', () => { + panel.classList.remove('episodes-open', 'preferences-open'); + node('playerPreferences').hidden = true; + node('playerPreferencesBtn').setAttribute('aria-expanded', 'false'); + node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false'); + }); + try { + node('autoNextEpisode').checked = localStorage.getItem('duanju.playback.autoNext') !== 'false'; + const rate = localStorage.getItem('duanju.playback.rate'); + if (Array.from(node('playbackRate').options).some(option => option.value === rate)) node('playbackRate').value = rate; + } catch (_) {} + node('autoNextEpisode').addEventListener('change', () => {try {localStorage.setItem('duanju.playback.autoNext', String(node('autoNextEpisode').checked));} catch (_) {}}); + node('playbackRate').addEventListener('change', () => {try {localStorage.setItem('duanju.playback.rate', node('playbackRate').value);} catch (_) {}}); + const pip = node('pictureInPictureBtn'); + pip.hidden = !document.pictureInPictureEnabled || typeof video.requestPictureInPicture !== 'function'; + pip.title = '画中画展示视频,网页弹幕留在当前页面'; + pip.addEventListener('click', async () => { + try { + if (document.pictureInPictureElement === video) await document.exitPictureInPicture(); + else await video.requestPictureInPicture(); + } catch (_) {statusText.textContent = '画中画暂不可用,请先开始播放。';} + }); + document.addEventListener('visibilitychange', schedulePrefetchStatus); + + window.dramaPlayer = {open, openCollection, openHistory, updateDependency}; +})(); diff --git a/internal/webui/rankings.js b/internal/webui/rankings.js new file mode 100644 index 0000000..351ea82 --- /dev/null +++ b/internal/webui/rankings.js @@ -0,0 +1,252 @@ +(() => { + 'use strict'; + window.JukuRankings = {init}; + + function init({api, post, getSource, getDrama = () => null, sourceLabel, onLibraryChanged, onDownloadsChanged, getDownloadQuality = () => 0, canDownload = () => true, play}) { + const $ = id => document.getElementById(id); + const dialog = $('rankingPanel'); + const list = $('rankingList'); + const tabs = $('rankingTabs'); + let boards = [], board = null, entries = [], page = 0, hasMore = false; + let busy = false, controller = null, sequence = 0, changed = false, fetchedAt = '', updatedText = ''; + const lastBoards = new Map(), submitted = new Set(), submitting = new Set(); + + function node(tag, className, text) { + const result = document.createElement(tag); + if (className) result.className = className; + if (text !== undefined) result.textContent = text; + return result; + } + function action(text, handler) { + const result = node('button', 'secondary', text); + result.type = 'button'; + result.addEventListener('click', handler); + return result; + } + function cancel() { + sequence++; + if (controller) controller.abort(); + controller = null; + busy = false; + } + function controls() { + $('refreshRankingBtn').disabled = busy; + $('rankingMoreBtn').disabled = busy; + $('rankingMoreBtn').hidden = !hasMore; + $('rankingMoreBtn').textContent = busy ? '加载中…' : '加载更多'; + $('rankingContent').setAttribute('aria-busy', String(busy)); + } + function state(text) { + list.replaceChildren(node('li', 'ranking-state', text)); + } + function setError(text) { $('rankingError').textContent = text || ''; } + function renderEntries() { + const visible = entries.filter(item => window.JukuVIP.visible(dramaFor(item))); + list.replaceChildren(...visible.map(renderEntry)); + if (!visible.length) state(entries.length ? '本页 VIP 内容已隐藏,可将 VIP 开关切换为“显示”或继续加载。' : '此榜单暂时没有条目'); + } + window.addEventListener('jukuvipfilterchange', event => {if (dialog.open) {renderEntries(); updateSummary(false); if (event.detail?.interactive) event.detail.priority = entries.map(item => item.drama.id);}}); + function updateSummary(stale) { + $('rankingSummary').textContent = (updatedText || board?.description || '按站点名次展示') + (entries.length ? ' · 显示 ' + entries.filter(item => window.JukuVIP.visible(dramaFor(item))).length + ' / ' + entries.length + ' 部' : '') + (board?.source === 'huangdou' ? ' · ' + window.JukuVIP.summary(entries.map(dramaFor)) : ''); + $('rankingFetchedAt').textContent = fetchedAt ? (stale ? '上次获取:' : '获取于 ') + new Date(fetchedAt).toLocaleString() : ''; + } + + function dramaFor(item) { + const known = getDrama(item.drama.id) || {}; + if ((Date.parse(known.sortMetadata?.checkedAt) || 0) > (Date.parse(fetchedAt) || 0)) return {...item.drama, ...known}; + const drama = {...known, ...item.drama}; + for (const [key, value] of Object.entries(known)) {if (drama[key] == null || drama[key] === '') drama[key] = value;} + return drama; + } + window.addEventListener('jukulibrarychange', () => {if (dialog.open) {renderEntries(); updateSummary(false);}}); + + function renderEntry(item) { + const drama = dramaFor(item), title = drama.title || drama.name || '短剧'; + const row = node('li', 'ranking-row'); + row.value = item.rank; + const rank = node('span', 'ranking-number' + (item.rank <= 3 ? ' top' : ''), String(item.rank).padStart(2, '0')); + rank.setAttribute('aria-label', '第 ' + item.rank + ' 名'); + const content = node('div'); + const heading = node('div', 'ranking-title', title); + heading.title = title; + content.appendChild(heading); + const count = drama.totalEpisode || drama.episodeCount; + const details = [window.JukuVIP.isVIP(drama) ? 'VIP · 仅试看' : '', item.metric, drama.remark || (count ? count + ' 集' : ''), drama.categoryName].filter(Boolean); + if (details.length) content.appendChild(node('div', 'ranking-meta', details.join(' · '))); + const actions = node('div', 'ranking-actions'); + const watchLabel = window.JukuHistory?.get(drama.id) ? '继续观看' : '播放'; + const watch = action(watchLabel, () => { dialog.close(); play(drama.id, title); }); + watch.setAttribute('aria-label', watchLabel + ' ' + title); + const download = action(submitted.has(drama.id) ? '已提交' : '下载', async () => { + if (!canDownload() || submitting.has(drama.id) || submitted.has(drama.id)) return; + submitting.add(drama.id); + download.disabled = true; + download.textContent = '提交中'; + try { + await post('/api/ui/download', {ids: [drama.id], quality: getDownloadQuality()}); + submitted.add(drama.id); + download.textContent = '已提交'; + $('rankingActionStatus').textContent = '已将《' + title + '》加入下载队列'; + onDownloadsChanged(); + } catch (error) { + download.textContent = '下载'; + download.disabled = false; + $('rankingActionStatus').textContent = '加入下载失败:' + error.message; + } finally { + submitting.delete(drama.id); + } + }); + download.disabled = submitted.has(drama.id) || submitting.has(drama.id); + download.setAttribute('aria-label', '下载 ' + title); + actions.appendChild(watch); + if (canDownload()) actions.appendChild(download); + row.append(rank, content, actions); + return row; + } + + async function loadPage(nextPage, refresh = false) { + if (!board) return; + cancel(); + const ticket = sequence, activeBoard = board; + controller = new AbortController(); + const signal = controller.signal; + busy = true; + setError(''); + $('rankingActionStatus').textContent = ''; + if (!entries.length) state('正在获取站点榜单…'); + controls(); + try { + const result = await api('/api/ui/rankings?board=' + encodeURIComponent(activeBoard.id) + '&page=' + nextPage + (refresh ? '&refresh=1' : ''), {signal}); + if (ticket !== sequence || !dialog.open) return; + if (result.boardId !== activeBoard.id || result.page !== nextPage || !Array.isArray(result.items)) throw new Error('榜单响应不完整,请刷新重试'); + if (nextPage > 1 && result.stale) throw new Error('此页暂时只能取得旧榜单,请刷新后继续'); + const incoming = result.items; + let previousRank = nextPage > 1 && entries.length ? entries[entries.length - 1].rank : 0; + const ids = new Set(nextPage > 1 ? entries.map(item => item.drama.id) : []); + for (const item of incoming) { + if (!item.drama?.id || !Number.isInteger(item.rank) || item.rank <= previousRank || ids.has(item.drama.id)) throw new Error('榜单顺序已变化,请刷新后继续'); + previousRank = item.rank; + ids.add(item.drama.id); + } + if (nextPage > 1 && updatedText && result.updatedText && result.updatedText !== updatedText) throw new Error('站点榜单已更新,请刷新后继续'); + if (nextPage === 1) { + entries = []; + list.replaceChildren(); + $('rankingContent').scrollTop = 0; + fetchedAt = result.fetchedAt || ''; + updatedText = result.updatedText || ''; + } + entries.push(...incoming); + renderEntries(); + page = nextPage; + hasMore = Boolean(result.hasMore) && incoming.length > 0 && !result.stale; + changed = changed || incoming.length > 0; + if (!entries.length) state('此榜单暂时没有条目'); + updateSummary(result.stale); + setError([result.warning, result.saved === false && incoming.length ? '剧库缓存未保存,本次仍可播放和下载。' : ''].filter(Boolean).join(' ')); + } catch (error) { + if (ticket !== sequence || signal.aborted) return; + setError(error.message + (entries.length ? ';已保留当前列表。' : '')); + if (!entries.length) state('暂时无法取得榜单,点击“刷新”重试'); + } finally { + if (ticket === sequence) { busy = false; controller = null; controls(); } + } + } + + function selectBoard(id) { + board = boards.find(item => item.id === id); + if (!board) return; + $('rankingVipFilterBtn').hidden = board.source !== 'huangdou'; + lastBoards.set(board.source, board.id); + entries = []; page = 0; hasMore = false; fetchedAt = ''; updatedText = ''; + for (const tab of tabs.children) { + const active = tab.dataset.board === id; + tab.setAttribute('aria-selected', String(active)); + tab.tabIndex = active ? 0 : -1; + } + $('rankingContent').setAttribute('aria-labelledby', 'ranking-tab-' + id); + updateSummary(false); + loadPage(1); + } + + function selectSource() { + const source = $('rankingSource').value; + const available = boards.filter(item => item.source === source); + tabs.replaceChildren(); + for (const item of available) { + const tab = action(item.name, () => selectBoard(item.id)); + tab.id = 'ranking-tab-' + item.id; + tab.dataset.board = item.id; + tab.setAttribute('role', 'tab'); + tab.setAttribute('aria-controls', 'rankingContent'); + tabs.appendChild(tab); + } + selectBoard(available.find(item => item.id === lastBoards.get(source))?.id || available[0]?.id); + } + + async function loadCatalog() { + cancel(); + const ticket = sequence; + busy = true; + controller = new AbortController(); + const signal = controller.signal; + controls(); + setError(''); + state('正在读取可用榜单…'); + try { + if (!boards.length) { + const result = await api('/api/ui/rankings', {signal}); + if (ticket !== sequence || !dialog.open) return; + boards = Array.isArray(result.boards) ? result.boards : []; + } + if (!boards.length) throw new Error('暂未提供可用榜单'); + const sources = [...new Set(boards.map(item => item.source))]; + const wanted = getSource() || $('rankingSource').value; + $('rankingSource').replaceChildren(...sources.map(source => { + const option = node('option', '', sourceLabel(source)); + option.value = source; + return option; + })); + $('rankingSource').value = sources.includes(wanted) ? wanted : sources[0]; + selectSource(); + } catch (error) { + if (ticket !== sequence || signal.aborted) return; + state('点击“刷新”重试'); + setError(error.message); + } finally { + if (ticket === sequence) { busy = false; controller = null; controls(); } + } + } + + $('openRankingsBtn').addEventListener('click', () => { + dialog.showModal(); + $('closeRankingsBtn').focus(); + $('rankingActionStatus').textContent = ''; + loadCatalog(); + }); + $('closeRankingsBtn').addEventListener('click', () => dialog.close()); + $('refreshRankingBtn').addEventListener('click', () => board ? loadPage(1, true) : loadCatalog()); + $('rankingMoreBtn').addEventListener('click', () => { if (!busy && hasMore) loadPage(page + 1); }); + $('rankingSource').addEventListener('change', selectSource); + tabs.addEventListener('keydown', event => { + const buttons = [...tabs.children], index = buttons.indexOf(event.target); + if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length; + buttons[next].focus(); + selectBoard(buttons[next].dataset.board); + }); + dialog.addEventListener('click', event => { + if (event.target !== dialog) return; + const box = dialog.getBoundingClientRect(); + if (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom) dialog.close(); + }); + dialog.addEventListener('close', () => { + + if (dialog.open) return; + cancel(); + controls(); + if (changed) { changed = false; onLibraryChanged(); } + }); + } +})(); diff --git a/internal/webui/recommendations.js b/internal/webui/recommendations.js new file mode 100644 index 0000000..0488c65 --- /dev/null +++ b/internal/webui/recommendations.js @@ -0,0 +1,101 @@ +import { $, api } from './ui-core.js'; + +export function createRecommendations({merge, changed, switched, allowed = () => true}) { + const feeds = new Map(); + let enabled = false, genre = 'short_play', controller, sequence = 0, busy = false; + function current() { + if (!feeds.has(genre)) feeds.set(genre, {items: [], offset: 0, sessionId: '', hasMore: true, loaded: false, error: '', saved: true}); + return feeds.get(genre); + } + function cancel() { + sequence++; + controller?.abort(); + controller = null; + busy = false; + } + function render() { + const feed = current(); + $('recommendFooter').hidden = !enabled; + $('recommendMoreBtn').hidden = !feed.hasMore && !feed.error; + $('recommendMoreBtn').disabled = busy; + $('recommendMoreBtn').textContent = busy ? '读取中…' : feed.error ? '重试' : feed.loaded ? '继续推荐' : '获取推荐'; + $('recommendRefreshBtn').disabled = busy; + $('recommendRefreshBtn').setAttribute('aria-busy', String(busy)); + $('recommendStatus').textContent = busy ? '正在读取红果分类推荐…' : feed.error || (feed.loaded ? '已显示 ' + feed.items.length + ' 部' + (feed.hasMore ? ',按红果默认推荐顺序' : ',本轮已读完') + (feed.saved ? '' : ';缓存暂未保存') : '选择分类获取推荐'); + $('recommendStatus').classList.toggle('error', Boolean(feed.error)); + } + async function load(reset = Boolean(current().retryReset)) { + if (!allowed() || !enabled || busy) return; + const feed = current(); + if (!reset && !feed.hasMore && !feed.error) return; + cancel(); + const token = sequence, requestGenre = genre; + controller = new AbortController(); + const signal = controller.signal; + busy = true; + feed.error = ''; + render(); + try { + const result = await api('/api/ui/recommendations', { + method: 'POST', signal, + body: JSON.stringify({genre, offset: reset ? 0 : feed.offset, sessionId: reset ? '' : feed.sessionId, seen: reset ? [] : feed.items.slice(-540).map(item => item.id)}) + }); + if (signal.aborted || token !== sequence || genre !== requestGenre || !enabled) return; + if (!Array.isArray(result.data) || typeof result.hasMore !== 'boolean' || !Number.isInteger(result.nextOffset) || typeof result.sessionId !== 'string') throw new Error('推荐数据格式无效'); + const known = new Set(reset ? [] : feed.items.map(item => item.id)); + const rows = result.data.filter(item => /^hongguo:[0-9]{1,32}$/.test(item.id) && item.source === 'hongguo').filter(item => { + if (known.has(item.id)) return false; + known.add(item.id); + return true; + }); + if (result.hasMore && (result.nextOffset <= (reset ? 0 : feed.offset) || !rows.length)) throw new Error('推荐分页未前进,请重新获取'); + feed.items = reset ? rows : feed.items.concat(rows); + feed.offset = result.nextOffset; + feed.sessionId = result.sessionId; + feed.hasMore = result.hasMore; + feed.loaded = true; + feed.retryReset = false; + feed.saved = result.saved !== false; + merge(rows); + changed(); + } catch (error) { + if (token === sequence && !signal.aborted) {feed.error = error.message; feed.retryReset = reset;} + } finally { + if (token === sequence) { + busy = false; + controller = null; + render(); + } + } + } + function setEnabled(value) { + if (value && !allowed()) return; + cancel(); + enabled = value; + $('librarySearch').hidden = enabled; + $('libraryFilters').hidden = enabled; + $('recommendToolbar').hidden = !enabled; + $('recentSearches').hidden = true; + $('libraryModeBtn').setAttribute('aria-pressed', String(!enabled)); + $('recommendModeBtn').setAttribute('aria-pressed', String(enabled)); + document.body.classList.toggle('show-recommendations', enabled); + switched(); + render(); + if (enabled && !current().loaded) load(); + } + function init() { + $('recommendModeBtn').hidden = !allowed(); + $('libraryModeBtn').addEventListener('click', () => setEnabled(false)); + $('recommendModeBtn').addEventListener('click', () => setEnabled(true)); + $('recommendGenre').addEventListener('change', () => { + cancel(); + genre = $('recommendGenre').value; + changed(); + render(); + if (!current().loaded) load(); + }); + $('recommendMoreBtn').addEventListener('click', () => load()); + $('recommendRefreshBtn').addEventListener('click', () => load(true)); + } + return {init, get enabled() {return enabled;}, items: () => current().items, all: () => Array.from(feeds.values()).flatMap(feed => feed.items)}; +} diff --git a/internal/webui/settings.js b/internal/webui/settings.js new file mode 100644 index 0000000..b9c3ad3 --- /dev/null +++ b/internal/webui/settings.js @@ -0,0 +1,65 @@ +import { $, element, empty, button, setMessage, api, post, valueText, firstNonEmpty, dramaTitle, watchLabel, historySuffix, normalizeSource, sourceKey, sourceLabel, categoryName, episodeCount, coverURL, tagsText, dramaSearchText, rebuildOptions, number, formatBytes, formatTime, statusText, releaseText, phaseText, progressText, progressBar, episodeLabel, groupStats } from './ui-core.js'; + +import { createPlaybackSettings } from './playback-settings.js'; + +export function createSettings(app) { + let config = {}; +function refreshConfigFields(){ $('downloadLocation').textContent=config.outputDir||'尚未设置';$('downloadLocation').title=config.outputDir||''; $('downloadDirectory').value=config.outputDirSetting||config.outputDir||'./短剧下载';$('downloadDirectoryHint').textContent=(config.restartRequired?'已保存新目录,重启后生效。':'')+'选择程序所在电脑的文件夹,或输入相对/绝对路径。重启后新合集使用新目录,原任务和文件保留。';$('downloadConcurrency').value=config.concurrency||2;$('requestConcurrency').value=config.requestConcurrency||2;$('requestInterval').value=config.requestIntervalMs||500;$('proxyMode').value=config.proxyMode||'auto';$('proxyURL').value=config.proxyURL||'';$('proxyUsername').value='';$('proxyPassword').value='';$('proxyPassword').placeholder=config.proxyHasAuth?'已保存,留空保留认证':'';updateProxyFields();$('configText').textContent='当前输出:'+(config.outputDir||'')+' · 下载并发 '+(config.concurrency||2)+' · '+(config.network||'')+' · FFmpeg '+(config.ffmpeg||''); } + +function updateProxyFields(){const disabled=$('proxyMode').value!=='manual';for(const name of ['proxyURL','proxyUsername','proxyPassword'])$(name).disabled=disabled;} + +async function loadConfig(){try{config=await api('/api/ui/config');refreshConfigFields();}catch(error){$('configText').textContent='配置读取失败:'+error.message;}} + +async function saveSettings(){ + const settings={outputDir:$('downloadDirectory').value.trim(),concurrency:number($('downloadConcurrency').value),requestConcurrency:number($('requestConcurrency').value),requestIntervalMs:number($('requestInterval').value)}; + try{ + const mode=$('proxyMode').value;if(mode!=='manual')settings.proxyURL=mode;else{const raw=$('proxyURL').value.trim();const username=$('proxyUsername').value;const password=$('proxyPassword').value;if(!(config.proxyHasAuth&&config.proxyMode==='manual'&&raw===config.proxyURL&&!username&&!password)){const endpoint=new URL(raw);if(!['http:','https:','socks5:','socks5h:'].includes(endpoint.protocol))throw new Error('仅支持 HTTP/HTTPS/SOCKS5 代理');if(username||password){endpoint.username=username;endpoint.password=password;}settings.proxyURL=endpoint.toString();}} + $('saveSettingsBtn').disabled=true;config=await post('/api/ui/config',settings);refreshConfigFields();$('settingsStatus').textContent=config.restartRequired?'设置已保存;下载目录重启后生效,当前任务保持原路径':'设置已保存并应用'; + }catch(error){$('settingsStatus').textContent='保存失败:'+error.message;}finally{$('saveSettingsBtn').disabled=false;} + } + +async function checkNetwork(){ $('checkNetworkBtn').disabled=true;$('settingsStatus').textContent='正在检测已保存的网络配置、各站入口和黄果实际媒体,最多约一分钟…';try{const result=await post('/api/ui/network/check',{});$('settingsStatus').textContent=(result.data||[]).map(item=>item.name+':'+(item.error||[item.status?'HTTP '+item.status:'',item.detail||''].filter(Boolean).join(' · '))).join(';');}catch(error){$('settingsStatus').textContent='检测失败:'+error.message;}finally{$('checkNetworkBtn').disabled=false;} } + +async function chooseDownloadDirectory(){const button=$('browseDirectoryBtn');if(button.disabled)return;button.disabled=true;$('settingsStatus').textContent='请在系统窗口中选择下载文件夹…';try{const result=await post('/api/ui/directory/pick',{initialPath:$('downloadDirectory').value});if(result.canceled){$('settingsStatus').textContent='已取消选择,原目录未修改';return;}$('downloadDirectory').value=result.selectionPath||result.path;$('settingsStatus').textContent='已选择文件夹,请点击“保存并应用”';}catch(error){$('settingsStatus').textContent='选择文件夹失败:'+error.message;}finally{button.disabled=false;}} + +async function importLegacyRecords() { + const control = $('legacyImportBtn'); + control.disabled = true; + $('viewerImportStatus').textContent = '正在接回旧版记录…'; + try { + await post('/api/ui/viewer/legacy', {code: $('legacyImportCode').value.trim()}); + $('legacyImportCode').value = ''; + $('legacyImportSection').hidden = true; + app.viewer.legacyAvailable = false; + await Promise.all([window.JukuHistory.refresh(), app.following.refresh()]); + $('viewerImportStatus').textContent = app.viewer?.account ? '旧版记录已接入当前账号,原始文件仍保留。' : '旧版记录已接回当前浏览器,原始文件仍保留。'; + } catch (error) { + $('viewerImportStatus').textContent = error.message; + } finally { + control.disabled = false; + } +} + +function init() { + const admin = Boolean(app.viewer?.account?.admin && !app.viewer.account.requirePasswordChange); + $('serverSettings').hidden = !admin; + $('serverSettingsNotice').hidden = admin; + $('downloadLocationBtn').hidden = !admin; + if (!admin) $('downloadLocation').textContent = '服务端下载目录'; + $('legacyImportSection').hidden = !app.viewer?.legacyAvailable; + $('viewerImportStatus').textContent = app.viewer?.legacyError || ''; + $('legacyImportBtn').addEventListener('click', importLegacyRecords); + $('browseDirectoryBtn').addEventListener('click', chooseDownloadDirectory); + $('proxyMode').addEventListener('change', updateProxyFields); + $('saveSettingsBtn').addEventListener('click', saveSettings); + $('checkNetworkBtn').addEventListener('click', checkNetwork); + $('openSettingsBtn').addEventListener('click', () => window.JukuDialogs.open('settingsPanel')); + $('closeSettingsBtn').addEventListener('click', () => $('settingsPanel').close()); + $('themeSelect').value = window.JukuTheme.get(); + $('themeSelect').addEventListener('change', () => window.JukuTheme.set($('themeSelect').value)); + document.addEventListener('themechange', event => {$('themeSelect').value = event.detail;}); + return admin ? Promise.all([loadConfig(), createPlaybackSettings().init()]) : Promise.resolve(); +} +return {init, config: () => config}; + +} diff --git a/internal/webui/shell.js b/internal/webui/shell.js new file mode 100644 index 0000000..6358b5c --- /dev/null +++ b/internal/webui/shell.js @@ -0,0 +1,197 @@ +import { $, element, button, sourceLabel, normalizeSource, readPreference, savePreference, populateIcons } from './ui-core.js'; + +export function createShell(app) { + const main = $('workspaceMain'); + const positions = {library: 0, following: 0, downloads: 0, users: 0}; + let current = '', sourceStates = {}, sourceLoading = false, sourceRequest = ''; + let sourceNotice; + const sourceRows = new Map(), sourceErrors = new Map(); + + function setPage(page) { + if (!app.viewer && page === 'users') return; + if (app.viewer && (page === 'users' && !app.viewer.account?.admin || page === 'downloads' && app.viewer.onlineOnly)) { + window.JukuDialogs.navigate('library'); + page = 'library'; + } + if (page === current) return; + if (current) positions[current] = main.scrollTop; + current = page; + document.body.dataset.page = page; + document.querySelectorAll('[data-view]').forEach(view => view.hidden = view.dataset.view !== page); + document.querySelectorAll('button[data-page]').forEach(control => { + if (control.dataset.page === page) control.setAttribute('aria-current', 'page'); + else control.removeAttribute('aria-current'); + }); + if (page === 'users') app.users?.load(); + if (page === 'following') app.following?.render(); + if (page === 'downloads') app.downloads?.render(); + if (page === 'library') app.library?.layout(); + requestAnimationFrame(() => {main.scrollTop = positions[page] || 0;}); + } + + function activity(visible) { + visible = visible && !app.viewer?.onlineOnly; + $('activityPanel').hidden = !visible; + $('catalogLayout').classList.toggle('with-activity', visible); + $('activityToggle').setAttribute('aria-pressed', String(visible)); + $('activityToggleLabel').textContent = visible ? '收起任务' : '任务侧栏'; + if (!app.viewer?.onlineOnly) savePreference('activitySidebar', visible); + if (visible) app.downloads?.render(); + } + + function info(title, build) { + sourceRows.clear(); + sourceNotice = null; + $('infoTitle').textContent = title; + $('infoContent').replaceChildren(); + if (typeof build === 'function') build($('infoContent')); + else $('infoContent').appendChild(element('p', '', build)); + window.JukuDialogs.open('infoPanel'); + } + + function updateSourceRows() { + const statuses = {loading: '正在获取', ready: '已就绪', success: '已更新', done: '已更新', failed: '暂不可用', error: '暂不可用', partial: '部分更新', cached: '使用缓存', pending: '等待更新'}; + for (const [id, controls] of sourceRows) { + const state = sourceStates[id]; + const allowed = !app.viewer?.sources || app.viewer.sources.includes(normalizeSource(id)); + const busy = sourceRequest === id || sourceLoading && state?.status === 'loading'; + controls.row.hidden = !allowed; + controls.status.textContent = sourceRequest === id ? '正在提交' : state ? statuses[state.status] || (state.error ? '暂不可用' : '已有缓存') : '未检查'; + const date = state?.updatedAt && !state.updatedAt.startsWith('0001') ? new Date(state.updatedAt).toLocaleString() : ''; + controls.detail.textContent = [Number.isFinite(state?.count) ? state.count + ' 部' : '', date].filter(Boolean).join(' · '); + controls.error.textContent = sourceErrors.get(id) || state?.error || ''; + controls.action.disabled = !allowed || sourceLoading || Boolean(sourceRequest); + controls.action.textContent = busy ? '更新中…' : '更新'; + controls.action.setAttribute('aria-busy', String(busy)); + controls.action.title = sourceLoading || sourceRequest ? '正在更新剧库,完成后可再次更新' : '只更新' + controls.label + '的目录和资料'; + } + if (sourceNotice) sourceNotice.textContent = sourceLoading || sourceRequest ? '正在更新剧库,已有内容可继续使用;完成后可单独更新站源。' : '点击对应站源的“更新”可查新、续载和补齐资料,失败时保留已有内容。'; + } + + async function updateSource(id) { + if (sourceLoading || sourceRequest || app.viewer?.sources && !app.viewer.sources.includes(normalizeSource(id))) return; + sourceRequest = id; + sourceErrors.delete(id); + updateSourceRows(); + try { + const result = await app.api('/api/ui/dramas?update=1&source=' + encodeURIComponent(id)); + sourceStates = result.sources || {}; + sourceLoading = Boolean(result.loading); + if (result.updateAccepted === false) sourceErrors.set(id, '本次未提交:已有更新任务,可在完成后重试。'); + } catch (error) { + sourceErrors.set(id, '更新请求失败:' + error.message); + } finally { + sourceRequest = ''; + updateSourceRows(); + void app.library.refresh(); + } + } + + function showSources() { + const names = {cloudfront: '黄果旧 API', huangguoai: '黄果网页', 'huangguo-video': '黄果备用网页', huangdou: '黄豆', hongguo: '红果'}; + info('站源状态', content => { + for (const [id, label] of Object.entries(names)) { + if (app.viewer?.sources && !app.viewer.sources.includes(normalizeSource(id))) continue; + const row = element('section', 'source-status-row'); + row.dataset.source = id; + const heading = element('div', 'heading source-status-heading'); + const actions = element('div', 'source-status-actions'); + const status = element('span', 'tag'); + status.setAttribute('aria-live', 'polite'); + const action = button('更新', () => updateSource(id), false, 'secondary source-update'); + action.setAttribute('aria-label', '更新' + label); + actions.append(status, action); + heading.append(element('h3', '', label), actions); + const detail = element('p', 'small'), error = element('p', 'error'); + error.setAttribute('role', 'status'); + row.append(heading, detail, error); + sourceRows.set(id, {row, label, status, action, detail, error}); + content.appendChild(row); + } + sourceNotice = element('p', 'small notice'); + content.appendChild(sourceNotice); + updateSourceRows(); + }); + void app.library.refresh(); + } + + function positionMenu(menu) { + const popover = menu.querySelector('.action-popover'); + if (!menu.open || !popover) return; + popover.style.maxHeight = ''; + const anchor = menu.getBoundingClientRect(); + const viewport = main.getBoundingClientRect(); + const width = popover.getBoundingClientRect().width; + const left = Math.max(12, Math.min(anchor.left, window.innerWidth - width - 12)); + const below = Math.max(0, Math.min(window.innerHeight, viewport.bottom) - anchor.bottom - 12); + const above = Math.max(0, anchor.top - Math.max(0, viewport.top) - 12); + const height = popover.scrollHeight + 2; + const upward = height > below && above > below; + popover.style.left = left - anchor.left + 'px'; + popover.style.right = 'auto'; + popover.style.maxHeight = Math.max(80, upward ? above : below) + 'px'; + popover.style.overflowY = 'auto'; + popover.style.top = upward ? -(Math.min(height, above) + 6) + 'px' : anchor.height + 6 + 'px'; + } + + function access() { + updateSourceRows(); + document.body.classList.toggle('online-only', Boolean(app.viewer?.onlineOnly)); + document.querySelectorAll('[data-admin-only]').forEach(control => {control.hidden = !app.viewer?.account?.admin;}); + document.querySelectorAll('button[data-page=downloads], [data-go=downloads]').forEach(control => {control.hidden = Boolean(app.viewer?.onlineOnly);}); + activity(readPreference('activitySidebar', false) === true); + setPage(window.JukuDialogs.page()); + } + + function init() { + populateIcons(); + window.JukuDialogs.listen(setPage); + document.querySelectorAll('button[data-page], [data-go]').forEach(control => control.addEventListener('click', () => { + if (control.dataset.go === 'following') app.following.showTab('watching'); + window.JukuDialogs.navigate(control.dataset.page || control.dataset.go); + })); + document.querySelector('.brand').addEventListener('click', event => {event.preventDefault(); window.JukuDialogs.navigate('library');}); + document.querySelector('.skip-link').addEventListener('click', event => {event.preventDefault(); main.focus();}); + $('headerMenu').addEventListener('click', event => { + if (event.target.closest('button')) { + $('headerMenu').open = false; + $('moreButton').focus({preventScroll: true}); + } + }, true); + $('headerMenu').addEventListener('toggle', () => $('moreButton').setAttribute('aria-expanded', String($('headerMenu').open))); + $('menuHistoryBtn').addEventListener('click', () => $('openHistoryBtn').click()); + $('sourceStatusBtn').addEventListener('click', showSources); + $('aboutBtn').addEventListener('click', () => info('使用提示', content => { + for (const text of ['输入关键词先筛选本地剧库,点击“联网搜索”补充红果结果。', '更新剧库会查新、继续加载目录,并在后台补齐历史资料。', '登录账号后,观看进度和追剧清单可跨设备接续;允许匿名访问时,匿名记录仍按浏览器独立保存。', '下载保存到运行短剧库的电脑;手机浏览器不会把下载任务保存到手机相册。']) content.appendChild(element('p', 'notice', text)); + })); + $('activityToggle').addEventListener('click', () => activity($('activityPanel').hidden)); + $('closeActivityBtn').addEventListener('click', () => activity(false)); + $('downloadLocationBtn').addEventListener('click', () => { + window.JukuDialogs.open('settingsPanel'); + $('downloadDirectory').focus(); + }); + document.addEventListener('click', event => { + for (const menu of document.querySelectorAll('.action-menu[open]')) { + if (!menu.contains(event.target) || event.target.closest('button')) menu.open = false; + } + }); + document.addEventListener('toggle', event => { + if (event.target.matches?.('.action-menu')) positionMenu(event.target); + }, true); + document.addEventListener('keydown', event => { + if (event.key !== 'Escape' || document.querySelector('dialog[open]')) return; + const menu = document.querySelector('.action-menu[open]'); + if (!menu) return; + event.preventDefault(); + menu.open = false; + menu.querySelector('summary').focus({preventScroll: true}); + }); + const positionMenus = () => document.querySelectorAll('.action-menu[open]').forEach(positionMenu); + window.addEventListener('resize', positionMenus); + main.addEventListener('scroll', positionMenus, {passive: true}); + activity(readPreference('activitySidebar', false) === true); + setPage(window.JukuDialogs.page()); + } + + return {init, access, info, page: () => current, resetScroll: () => {main.scrollTop = 0;}, sourceStates: (value, loading) => {sourceStates = value || {}; sourceLoading = Boolean(loading); updateSourceRows();}}; +} diff --git a/internal/webui/theme.js b/internal/webui/theme.js new file mode 100644 index 0000000..a7abdc6 --- /dev/null +++ b/internal/webui/theme.js @@ -0,0 +1,23 @@ +(() => { + const system = window.matchMedia('(prefers-color-scheme: dark)'); + let preference = 'system'; + try {preference = localStorage.getItem('duanju.theme') || 'system';} catch (_) {} + if (!['system', 'light', 'dark'].includes(preference)) preference = 'system'; + function apply() { + const theme = preference === 'system' ? (system.matches ? 'dark' : 'light') : preference; + document.documentElement.dataset.theme = theme; + const browserColor = document.querySelector('meta[name="theme-color"]'); + if (browserColor) browserColor.content = theme === 'dark' ? '#161b19' : '#f7f7f2'; + document.dispatchEvent(new CustomEvent('themechange', {detail: preference})); + } + function set(value) { + if (!['system', 'light', 'dark'].includes(value)) return; + preference = value; + try {localStorage.setItem('duanju.theme', value);} catch (_) {} + apply(); + } + if (system.addEventListener) system.addEventListener('change', apply); + else system.addListener(apply); + window.JukuTheme = {set, get: () => preference}; + apply(); +})(); diff --git a/internal/webui/ui-core.js b/internal/webui/ui-core.js new file mode 100644 index 0000000..97481cd --- /dev/null +++ b/internal/webui/ui-core.js @@ -0,0 +1,164 @@ +import { viewerHeaders, checkViewerResponse } from './viewer.js'; + +export const $ = id => document.getElementById(id); +let messageTimer; + +function element(tag, className, text){ const node=document.createElement(tag); if(className)node.className=className; if(text!==undefined)node.textContent=String(text); return node; } + +function empty(node){ while(node.firstChild)node.removeChild(node.firstChild); } + +function button(text, action, disabled, className){ const node=element('button',className||'secondary',text); node.type='button';node.disabled=Boolean(disabled); node.addEventListener('click',action); return node; } + +function setMessage(text, isError = false, action = null) { + clearTimeout(messageTimer); + const target = $('messageText'); + target.replaceChildren(); + target.className = isError ? 'error toast' : 'toast'; + if (!text) return; + target.appendChild(element('span', '', text)); + if (action) target.appendChild(button(action.label || '撤销', async () => { + try {await action.run();} catch (error) {setMessage(error.message, true);} + })); + messageTimer = setTimeout(() => target.replaceChildren(), action ? 10000 : 6500); +} + +async function api(path, options){ const init=options||{}; init.headers=Object.assign({'Accept':'application/json'},viewerHeaders(),init.headers||{}); if(init.body)init.headers['Content-Type']='application/json'; const response=await fetch(path,init); let data; try{data=await response.json();}catch(_){throw new Error('服务器未返回有效 JSON,请确认已启动新版程序');} checkViewerResponse(data); if(!response.ok){const error=new Error(data.error||'HTTP '+response.status);error.status=response.status;throw error;} return data; } + +function post(path, body){ return api(path,{method:'POST',body:JSON.stringify(body)}); } + +function valueText(value){ if(value===undefined||value===null)return ''; if(Array.isArray(value))return value.length?String(value.length):''; if(typeof value==='object'){ for(const key of ['url','src','path','cover','image','pic','name','title','count','total']){const text=valueText(value[key]);if(text)return text;}return '';} const text=String(value).trim(); return text==='0'?'':text; } + +function firstNonEmpty(...values){ for(const value of values){const text=valueText(value);if(text)return text;}return ''; } + +function dramaTitle(drama){ return drama.title||drama.name||'短剧'; } + +function watchLabel(id,fallback){return window.JukuHistory.get(id)?'继续观看':fallback;} + +function historySuffix(id){const text=window.JukuHistory.progressText(id);return text?' · '+text:'';} + +function normalizeSource(value){ + let source=String(value||'').trim().toLowerCase(); + if(/^https?:\/\//.test(source)){try{source=new URL(source).hostname;}catch(_){return '';}} + source=source.replace(/^www\./,'').split(':')[0]; + const aliases={huangguo:'huangguo',huangguoai:'huangguo','huangguo-video':'huangguo','huangguoai.com':'huangguo','huangguo.video':'huangguo',cloudfront:'huangguo',api:'huangguo',huangdou:'huangdou','tideember.cc':'huangdou','xqjurgek.top':'huangdou',hongguo:'hongguo','hongguoduanju.com':'hongguo'}; + return Object.prototype.hasOwnProperty.call(aliases,source)?aliases[source]:''; + } + +function sourceKey(dr){ for(const hint of [dr.source,dr.id,dr.channelName,dr.channel_name,dr.site,dr.host]){const source=normalizeSource(hint);if(source)return source;} if(!dr.source&&['黄果原创','成人短剧','成人漫剧','AI魔改'].includes(dr.channelName))return 'huangguo'; return String(dr.source||'other').trim().toLowerCase()||'other'; } + +function sourceLabel(value){ const names={huangguo:'黄果',huangdou:'黄豆',hongguo:'红果',other:'其他'};return names[normalizeSource(value)||value]||value; } + +function categoryName(dr){ const category=firstNonEmpty(dr.categoryName,dr.category_name,dr.typeName,dr.type_name,dr.sortName,dr.sort_name,dr.category,dr.categoryNameSnake);if(category)return category;return ['黄果原创','成人短剧','成人漫剧','AI魔改'].includes(dr.channelName)?dr.channelName:'未分类'; } + +function episodeCount(dr){ return firstNonEmpty(dr.totalEpisode,dr.total_episode,dr.chapterCount,dr.chapter_count,dr.episodeCount,dr.episode_count,dr.episodes,dr.total); } + +function coverURL(dr){ return firstNonEmpty(dr.cover,dr.coverUrl,dr.cover_url,dr.image,dr.imageUrl,dr.image_url,dr.img,dr.pic,dr.picture,dr.poster,dr.thumb,dr.thumbnail); } + +function tagsText(dr){ return Array.isArray(dr.tags)?dr.tags.map(valueText).filter(Boolean):[]; } + +function dramaSearchText(dr){ return [dr.title,dr.name,dr.id,dr.desc,dr.intro,dr.remark,sourceLabel(sourceKey(dr)),categoryName(dr),tagsText(dr).join(' ')].join(' ').toLowerCase(); } + +function rebuildOptions(select,values,allLabel,labelFor,reset){ const previous=reset?'':select.value;empty(select);const all=element('option','',allLabel);all.value='';select.appendChild(all);values.forEach(value=>{const option=element('option','',labelFor(value));option.value=value;select.appendChild(option);});select.value=values.includes(previous)?previous:''; } + +function number(value){const result=Number(value);return Number.isFinite(result)?result:0;} + +function formatBytes(value){let count=number(value);if(count<=0)return '0 B';const units=['B','KB','MB','GB','TB'];let index=0;while(count>=1024&&index=100?0:1):Math.round(count))+' '+units[index];} + +function formatTime(value){const seconds=Math.max(0,Math.floor(number(value)));if(!seconds)return '—';const minutes=Math.floor(seconds/60);return (minutes?minutes+'分':'')+(seconds%60)+'秒';} + +function statusText(status){return {queued:'排队中',parsing:'解析中',running:'下载中',success:'成功',failed:'失败',paused:'已暂停',canceled:'已取消'}[status]||status;} + +function releaseText(status){return {finished:'完结',ongoing:'未完结',unknown:'状态未知'}[status]||'状态未知';} + +function phaseText(task){if(task.removeRequested)return '正在停止并清理';if(task.pauseRequested)return '暂停中';if(task.cancelRequested)return '取消中';return {preparing:'准备 FFmpeg',starting:'准备下载',resolving:'解析播放地址',retrying:'等待重试'}[task.phase]||statusText(task.status);} + +function progressText(task){ if(task.status==='success')return '100%';if(task.status==='running'&&!number(task.mediaTotalSeconds)&&!number(task.totalBytes))return phaseText(task)+' · 进度未知';return Math.max(0,Math.min(100,number(task.progress)))+'%'; } + +function progressBar(percent){const bar=element('div','progress');const fill=element('span');fill.style.width=Math.max(0,Math.min(100,number(percent)))+'%';bar.appendChild(fill);return bar;} + +function episodeLabel(task){const sequence=number(task.episode||task.index);return sequence>0?'第'+String(sequence).padStart(3,'0')+'集':task.title||'章节';} + +function groupStats(group){const stats={success:0,failed:0,running:0,parsing:0,queued:0,paused:0,canceled:0,percent:0,bytes:0,speed:0};group.tasks.forEach(task=>{if(task.status in stats)stats[task.status]++;stats.percent+=task.status==='success'?100:number(task.progress);stats.bytes+=number(task.downloadedBytes);if(task.status==='running')stats.speed+=number(task.speedBytesPerSecond);});stats.percent=group.tasks.length?Math.floor(stats.percent/group.tasks.length):0;return stats;} + +export { element, empty, button, setMessage, api, post, valueText, firstNonEmpty, dramaTitle, watchLabel, historySuffix, normalizeSource, sourceKey, sourceLabel, categoryName, episodeCount, coverURL, tagsText, dramaSearchText, rebuildOptions, number, formatBytes, formatTime, statusText, releaseText, phaseText, progressText, progressBar, episodeLabel, groupStats }; + +const iconShapes = { + user: [['circle', {cx: 12, cy: 8, r: 4}], ['path', {d: 'M4 21v-2a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v2'}]], + brand: [['rect', {x: 3.5, y: 3.5, width: 17, height: 17, rx: 3}], ['path', {d: 'm10 8 6 4-6 4Z', fill: 'currentColor', stroke: 'none'}]], + library: [['rect', {x: 3, y: 3, width: 7, height: 7, rx: 1.5}], ['rect', {x: 14, y: 3, width: 7, height: 7, rx: 1.5}], ['rect', {x: 3, y: 14, width: 7, height: 7, rx: 1.5}], ['rect', {x: 14, y: 14, width: 7, height: 7, rx: 1.5}]], + bookmark: [['path', {d: 'M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16l-6-4-6 4Z'}]], + download: [['path', {d: 'M12 3v12m-5-5 5 5 5-5M4 17v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3'}]], + search: [['circle', {cx: 10.5, cy: 10.5, r: 6.5}], ['path', {d: 'm16 16 4.5 4.5'}]], + refresh: [['path', {d: 'M20 4v5h-5M4 20v-5h5M5.6 7A8 8 0 0 1 19 6l1 3M4 15l1 3a8 8 0 0 0 13.4-1'}]], + settings: [['path', {d: 'M4 7h8m4 0h4M4 17h4m4 0h8'}], ['circle', {cx: 14, cy: 7, r: 2}], ['circle', {cx: 10, cy: 17, r: 2}]], + more: [['circle', {cx: 5, cy: 12, r: 1, fill: 'currentColor', stroke: 'none'}], ['circle', {cx: 12, cy: 12, r: 1, fill: 'currentColor', stroke: 'none'}], ['circle', {cx: 19, cy: 12, r: 1, fill: 'currentColor', stroke: 'none'}]], + close: [['path', {d: 'm6 6 12 12M6 18 18 6'}]], + chevron: [['path', {d: 'm7 10 5 5 5-5'}]], + right: [['path', {d: 'm9 6 6 6-6 6'}]], + play: [['path', {d: 'M8 5.5v13l10-6.5L8 5.5Z'}]], + filter: [['path', {d: 'M4 7h16M7 12h10M10 17h4'}]], + chart: [['rect', {x: 3, y: 13, width: 4, height: 7, rx: 1}], ['rect', {x: 10, y: 4, width: 4, height: 16, rx: 1}], ['rect', {x: 17, y: 9, width: 4, height: 11, rx: 1}]], + select: [['rect', {x: 4, y: 4, width: 16, height: 16, rx: 3}], ['path', {d: 'm8 12 3 3 5-6'}]], + sidebar: [['rect', {x: 3, y: 4, width: 18, height: 16, rx: 2}], ['path', {d: 'M15 4v16'}]], + folder: [['path', {d: 'M3 8V5a2 2 0 0 1 2-2h5l3 3h6a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8h18'}]], + previous: [['path', {d: 'M6 5v14m12-14L8 12l10 7Z'}]], + next: [['path', {d: 'M18 5v14M6 5l10 7-10 7Z'}]], + pause: [['path', {d: 'M8 5v14M16 5v14', 'stroke-width': 4}]], + rotate: [['rect', {x: 7, y: 6, width: 10, height: 12, rx: 2, transform: 'rotate(-25 12 12)'}], ['path', {d: 'M3 10V5h5m13 9v5h-5M3 5a11 11 0 0 1 17 2M21 19A11 11 0 0 1 4 17'}]], + fullscreen: [['path', {d: 'M9 3H3v6m12-6h6v6M3 15v6h6m12-6v6h-6'}]], + clock: [['circle', {cx: 12, cy: 12, r: 9}], ['path', {d: 'M12 7v5l3 2'}]], + activity: [['path', {d: 'M3 12h4l3-8 4 16 3-8h4'}]], + help: [['circle', {cx: 12, cy: 12, r: 9}], ['path', {d: 'M9.5 9a2.5 2.5 0 1 1 4.3 1.75c-.9.7-1.8 1.2-1.8 2.75'}], ['circle', {cx: 12, cy: 17, r: .75, fill: 'currentColor', stroke: 'none'}]], + check: [['path', {d: 'm5 12 4 4L19 6'}]] +}; + +const iconTemplates = new Map(); + +export function icon(name) { + if (iconTemplates.has(name)) return iconTemplates.get(name).cloneNode(true); + const namespace = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(namespace, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + for (const [tag, attributes] of iconShapes[name] || iconShapes.play) { + const shape = document.createElementNS(namespace, tag); + for (const [key, value] of Object.entries(attributes)) shape.setAttribute(key, value); + svg.appendChild(shape); + } + iconTemplates.set(name, svg); + return svg.cloneNode(true); +} + +export function populateIcons(root = document) { + root.querySelectorAll('[data-icon]').forEach(node => node.replaceWith(icon(node.dataset.icon))); +} + +export function readPreference(key, fallback) { + try {const value = localStorage.getItem('duanju.' + key); return value === null ? fallback : JSON.parse(value);} catch (_) {return fallback;} +} + +export function savePreference(key, value) { + try {localStorage.setItem('duanju.' + key, JSON.stringify(value));} catch (_) {} +} + +export function reconcileChildren(root, nodes) { + const keep = new Set(nodes); + for (const child of Array.from(root.childNodes)) if (!keep.has(child)) child.remove(); + let cursor = root.firstChild; + for (const node of nodes) { + if (cursor === node) cursor = cursor.nextSibling; + else root.insertBefore(node, cursor); + } +} + +export function withFocus(root, render) { + const active = document.activeElement; + const key = root.contains(active) && active.dataset.focusKey; + render(); + if (key && !active.isConnected) root.querySelector('[data-focus-key="' + CSS.escape(key) + '"]')?.focus({preventScroll: true}); +} + +export function initial(title) { + return Array.from(String(title || '剧').replace(/[《》「」\s]/g, '')).slice(0, 2).join(''); +} diff --git a/internal/webui/users.js b/internal/webui/users.js new file mode 100644 index 0000000..4fef0cd --- /dev/null +++ b/internal/webui/users.js @@ -0,0 +1,146 @@ +import { $, api, post, element } from './ui-core.js'; + +export function createUsers(app) { + let adminLoading = false, creating = false, saving = false; + + function sourceChoices(container, selected, disabled = false) { + container.replaceChildren(); + const choices = app.viewer.sourceChoices || []; + for (const choice of choices) { + const label = element('label', 'account-source-choice'); + const checkbox = element('input'); + checkbox.type = 'checkbox'; + checkbox.value = choice.id; + checkbox.checked = selected.includes(choice.id); + checkbox.disabled = disabled; + label.append(checkbox, element('span', '', choice.name)); + container.appendChild(label); + } + } + + function selectedSources(container) { + return Array.from(container.querySelectorAll('input:checked'), input => input.value); + } + + function filterAccounts() { + const keyword = $('adminAccountSearch').value.trim().toLocaleLowerCase(); + let shown = 0; + for (const row of $('adminAccountList').children) { + row.hidden = !row.dataset.username.toLocaleLowerCase().includes(keyword); + if (!row.hidden) shown++; + } + $('adminAccountEmpty').hidden = shown > 0; + } + + function renderAccounts(accounts) { + $('adminAccountCount').textContent = accounts.length + ' 个账号'; + const rows = accounts.map(account => { + const row = element('li', 'admin-account-row'); + row.dataset.username = account.username; + const heading = element('div', 'admin-account-heading'); + const role = element('span', 'small', account.admin ? '管理员 · 全部权限' : account.onlineOnly ? '仅在线观看' : '可观看和下载'); + heading.append(element('strong', '', account.username), role); + const permissions = element('div', 'admin-account-permissions'); + const choices = element('div', 'account-source-choices'); + choices.setAttribute('role', 'group'); + choices.setAttribute('aria-label', account.username + ' 的站源权限'); + sourceChoices(choices, account.sources || [], account.admin); + permissions.appendChild(choices); + row.append(heading, permissions); + if (!account.admin) { + const onlyLabel = element('label', 'account-check'); + const only = element('input'); + only.type = 'checkbox'; + only.className = 'account-online-only'; + only.checked = account.onlineOnly === true; + onlyLabel.append(only, element('span', '', '仅在线观看')); + permissions.appendChild(onlyLabel); + const save = element('button', 'secondary', '保存权限'); + save.type = 'button'; + save.disabled = true; + permissions.addEventListener('change', () => { + const selected = selectedSources(choices); + save.disabled = !selected.length || selected.join(',') === account.sources.join(',') && only.checked === account.onlineOnly; + }); + save.addEventListener('click', async () => { + save.disabled = true; + permissions.querySelectorAll('input').forEach(input => input.disabled = true); + try { + const result = await post('/api/ui/admin/accounts/permissions', {username: account.username, sources: selectedSources(choices), onlineOnly: only.checked}); + account = result.account; + sourceChoices(choices, account.sources); + only.checked = account.onlineOnly; + role.textContent = account.onlineOnly ? '仅在线观看' : '可观看和下载'; + $('adminStatus').textContent = '已更新 ' + account.username + ' 的权限,现有登录立即生效。'; + } catch (error) { + save.disabled = false; + $('adminStatus').textContent = error.message; + } finally {permissions.querySelectorAll('input').forEach(input => input.disabled = false);} + }); + row.appendChild(save); + } + return row; + }); + $('adminAccountList').replaceChildren(...rows); + filterAccounts(); + } + + async function loadAdmin() { + if (!app.viewer?.account?.admin || app.viewer.account.requirePasswordChange || adminLoading) return; + adminLoading = true; + $('adminRefreshBtn').disabled = true; + $('adminSavePolicyBtn').disabled = true; + try { + const [policy, accounts] = await Promise.all([api('/api/ui/admin/settings'), api('/api/ui/admin/accounts')]); + $('adminRequireLogin').checked = policy.requireLogin; + $('adminAllowRegistration').checked = policy.allowRegistration; + $('adminSavePolicyBtn').disabled = false; + if (accounts.sourceChoices) app.viewer.sourceChoices = accounts.sourceChoices; + sourceChoices($('adminNewUserSources'), (app.viewer.sourceChoices || []).map(choice => choice.id)); + renderAccounts(accounts.data || []); + $('adminStatus').textContent = ''; + } catch (error) { $('adminStatus').textContent = error.message; } + finally { adminLoading = false; $('adminRefreshBtn').disabled = false; } + } + + async function savePolicy(event) { + event.preventDefault(); + if (saving || adminLoading) return; + saving = true; + $('adminSavePolicyBtn').disabled = true; + $('adminStatus').textContent = '正在保存访问设置…'; + try { + const policy = await post('/api/ui/admin/settings', {requireLogin: $('adminRequireLogin').checked, allowRegistration: $('adminAllowRegistration').checked}); + Object.assign(app.viewer, policy); + $('adminStatus').textContent = '访问设置已保存并生效。'; + } catch (error) { $('adminStatus').textContent = error.message; } + finally { saving = false; $('adminSavePolicyBtn').disabled = false; } + } + + async function createUser(event) { + event.preventDefault(); + if (creating || adminLoading) return; + creating = true; + $('adminCreateUserBtn').disabled = true; + try { + const username = $('adminNewUsername').value.trim(); + const sources = selectedSources($('adminNewUserSources')); + if (!sources.length) throw new Error('请至少选择一个可用站源'); + await post('/api/ui/admin/accounts', {username, password: $('adminNewUserPassword').value, sources, onlineOnly: $('adminNewOnlineOnly').checked}); + $('adminCreateUserForm').reset(); + await loadAdmin(); + $('adminStatus').textContent = '已创建用户 ' + username + ',可以登录此剧库。'; + } catch (error) { $('adminStatus').textContent = error.message; } + finally { creating = false; $('adminCreateUserBtn').disabled = false; } + } + + function init() { + if (!app.viewer?.account?.admin) return; + $('adminPolicyForm').addEventListener('submit', savePolicy); + $('adminCreateUserForm').addEventListener('submit', createUser); + $('adminRefreshBtn').addEventListener('click', loadAdmin); + $('adminAccountSearch').addEventListener('input', filterAccounts); + } + + return {init, load: loadAdmin}; +} diff --git a/internal/webui/viewer.js b/internal/webui/viewer.js new file mode 100644 index 0000000..72b8390 --- /dev/null +++ b/internal/webui/viewer.js @@ -0,0 +1,44 @@ +let identity = '', sources = '', onlineOnly = 'false'; +let changed = false; +const changeKey = 'juku.account.change'; + +export function announceViewerChange() { + try { localStorage.setItem(changeKey, Date.now() + '-' + Math.random()); } catch (_) {} +} + +window.addEventListener('storage', event => { + if (event.key === changeKey && identity && !changed) { + changed = true; + window.dispatchEvent(new Event('jukuviewerchange')); + } +}); + +export function viewerHeaders() { + return identity ? {'X-Juku-Viewer': identity, 'X-Juku-Sources': sources, 'X-Juku-Online-Only': onlineOnly} : {}; +} + +export function checkViewerResponse(result) { + if (!identity || changed || !['viewer_required', 'viewer_changed', 'login_required', 'password_change_required', 'sources_changed', 'permissions_changed'].includes(result?.code)) return; + changed = true; + window.dispatchEvent(new Event('jukuviewerchange')); +} + +export async function initializeViewer() { + async function read(path) { + const response = await fetch(path, {credentials: 'same-origin', cache: 'no-store', headers: {'Accept': 'application/json'}}); + let result; + try {result = await response.json();} catch (_) {throw new Error('无法建立独立观看记录,请确认已启动新版程序');} + if (!response.ok) throw new Error(result.error || '无法建立独立观看记录'); + return result; + } + let result = await read('/api/ui/viewer'); + if (!result.ready) result = await read('/api/ui/viewer?confirm=1'); + if (!result.ready || !/^[a-f0-9]{64}$/.test(result.id)) throw new Error('无法确认浏览器身份,请刷新页面重试'); + identity = result.id; + sources = (result.sources || []).join(','); + onlineOnly = String(result.onlineOnly === true); + changed = false; + return result; +} + +window.JukuViewer = {headers: viewerHeaders, checkResponse: checkViewerResponse}; diff --git a/internal/webui/vip-filter.js b/internal/webui/vip-filter.js new file mode 100644 index 0000000..f9a78c8 --- /dev/null +++ b/internal/webui/vip-filter.js @@ -0,0 +1,41 @@ +(() => { + const key = 'juku.vip.show'; + let shown = false, enabled = false; + try {shown = localStorage.getItem(key) === 'true';} catch (_) {} + const isVIP = drama => drama?.vip === true; + const visible = drama => shown || !isVIP(drama); + function refresh() { + for (const button of document.querySelectorAll('[data-vip-filter]')) { + button.textContent = shown ? '显示 VIP' : '隐藏 VIP'; + button.setAttribute('aria-pressed', String(shown)); + button.title = shown ? '当前显示 VIP,点击切换为隐藏;VIP 分集仅能试看' : '当前隐藏已识别的 VIP,点击切换为显示;未识别内容会分批检查'; + if (!enabled) button.hidden = true; + } + } + function changed(value, interactive = false) { + shown = Boolean(value); + refresh(); + window.dispatchEvent(new CustomEvent('jukuvipfilterchange', {detail: {interactive}})); + } + function toggle() { + if (!enabled) return; + try {localStorage.setItem(key, String(!shown));} catch (_) {} + changed(!shown, true); + } + function init(allowed) { + enabled = Boolean(allowed); + for (const button of document.querySelectorAll('[data-vip-filter]')) { + button.hidden = !enabled; + button.addEventListener('click', toggle); + } + refresh(); + } + window.addEventListener('storage', event => {if (event.key === key) changed(event.newValue === 'true');}); + function summary(dramas) { + const rows = dramas.filter(drama => drama.source === 'huangdou' || drama.id?.startsWith('huangdou:')); + if (!rows.length) return ''; + const paid = rows.filter(isVIP).length, unknown = rows.filter(drama => drama.vip == null).length; + return (shown ? '已显示 ' : '已隐藏 ') + paid + ' 部 VIP' + (unknown ? ' · ' + unknown + ' 部 VIP 状态待识别,暂保留显示' : ''); + } + window.JukuVIP = {isVIP, visible, summary, init, get shown() {return shown;}}; +})(); diff --git a/internal/webui/vip-filter.test.cjs b/internal/webui/vip-filter.test.cjs new file mode 100644 index 0000000..22b1d29 --- /dev/null +++ b/internal/webui/vip-filter.test.cjs @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); + +function environment(saved) { + const window = new EventTarget(), buttons = [new EventTarget(), new EventTarget()]; + for (const button of buttons) {button.attributes = {}; button.setAttribute = (key, value) => {button.attributes[key] = value;};} + const values = new Map(saved ? [['juku.vip.show', saved]] : []); + const localStorage = {getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value)}; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, 'vip-filter.js'), 'utf8'), {window, document: {querySelectorAll: () => buttons}, localStorage, Event, CustomEvent}); + return {window, buttons, values, vip: window.JukuVIP}; +} + +test('VIP filtering immediately toggles known entries, preserves unknowns, and explains missing classification', () => { + const {window, vip, buttons, values} = environment(); + const paid = {id: 'huangdou:paid', vip: true}, free = {id: 'huangdou:free', vip: false}, unknown = {id: 'huangdou:unknown'}; + vip.init(true); + assert.equal(vip.visible(paid), false); + assert.equal(buttons[0].textContent, '隐藏 VIP'); + assert.equal(vip.visible(free), true); + assert.equal(vip.visible(unknown), true); + assert.match(vip.summary([paid, free, unknown]), /已隐藏 1 部 VIP.*1 部 VIP 状态待识别/); + let requested = 0; + window.addEventListener('jukuvipfilterchange', event => {if (event.detail.interactive) requested++;}); + buttons[0].dispatchEvent(new Event('click')); + assert.equal(vip.visible(paid), true); + assert.equal(buttons[1].textContent, '显示 VIP'); + assert.equal(values.get('juku.vip.show'), 'true'); + buttons[1].dispatchEvent(new Event('click')); + assert.equal(vip.visible(paid), false); + assert.equal(buttons[0].textContent, '隐藏 VIP'); + assert.equal(requested, 2); +}); + +test('a stored preference restores visibility while storage synchronization does not request a new batch', () => { + const {window, vip, buttons} = environment('true'); + vip.init(true); + assert.equal(vip.shown, true); + let interactive; + window.addEventListener('jukuvipfilterchange', event => {interactive = event.detail.interactive;}); + const event = new Event('storage'); + event.key = 'juku.vip.show'; event.newValue = 'false'; + window.dispatchEvent(event); + assert.equal(vip.shown, false); + assert.equal(interactive, false); + assert.equal(buttons[0].attributes['aria-pressed'], 'false'); +}); diff --git a/main.go b/main.go new file mode 100644 index 0000000..a776333 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "juku/internal/app" + +func main() { + app.Run() +} diff --git a/scripts/build.bat b/scripts/build.bat new file mode 100644 index 0000000..5ce2108 --- /dev/null +++ b/scripts/build.bat @@ -0,0 +1,10 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0.." +if not exist dist mkdir dist +set CGO_ENABLED=0 +set GOOS=windows +set GOARCH=amd64 +go build -trimpath -ldflags="-s -w" -o dist\juku_windows_amd64.exe . +if errorlevel 1 exit /b 1 +echo 已生成 dist\juku_windows_amd64.exe diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..d475e7b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +mkdir -p dist + +echo "Building macOS arm64..." +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/juku_darwin_arm64 . + +echo "Building macOS amd64..." +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_darwin_amd64 . + +echo "Building Linux amd64..." +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_linux_amd64 . + +echo "Building Windows amd64..." +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_windows_amd64.exe . + +echo "Done. Files are in dist/." diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100644 index 0000000..8da77f8 --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! command -v docker >/dev/null 2>&1; then + echo "请先安装 Docker。" >&2 + exit 1 +fi +exec docker build --pull -t "${JUKU_IMAGE:-juku:local}" "$@" "$project_dir" diff --git a/scripts/docker-up.sh b/scripts/docker-up.sh new file mode 100644 index 0000000..fa2981b --- /dev/null +++ b/scripts/docker-up.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then + echo "请先安装 Docker 及 Compose v2。" >&2 + exit 1 +fi +exec docker compose --project-directory "$project_dir" -f "$project_dir/compose.yaml" up -d --build "$@" diff --git a/scripts/update_from_zip.py b/scripts/update_from_zip.py new file mode 100644 index 0000000..7b8ea2f --- /dev/null +++ b/scripts/update_from_zip.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""把新版 短剧库.zip 与本地源码对比,展示变更并更新本地旧代码。 + +用法: + python scripts/update_from_zip.py # 从默认地址下载最新包并预览变更 + python scripts/update_from_zip.py --apply # 下载并真正更新本地代码 + python scripts/update_from_zip.py 某个路径/短剧库.zip # 用本地压缩包 + python scripts/update_from_zip.py --url https://... # 指定其他下载地址 + +行为: + 1. 读取压缩包(自动处理中文文件名 GBK/UTF-8、自动剥离压缩包内公共顶层目录) + 2. 与本地源码逐文件比对(忽略 .git、data/、日志、压缩包等运行时文件) + 3. 列出 新增 / 修改 / 删除 三类变更;加 --apply 后写入本地 + 4. 更新完成后用 git status / git diff 自查,再提交 +""" +import argparse +import hashlib +import sys +import tempfile +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path + +DEFAULT_URL = "https://www.252035.xyz/短剧库.zip" +REPO_ROOT = Path(__file__).resolve().parent.parent +IGNORE_DIRS = {".git", "data", "__pycache__", "dist", "短剧下载"} +IGNORE_SUFFIX = {".log", ".zip", ".tar", ".pyc"} +IGNORE_NAMES = {".DS_Store", "Thumbs.db"} +PROTECT = {"scripts/update_from_zip.py"} # 更新工具自身,永远不删 + + +def download(url: str) -> Path: + quoted = urllib.parse.quote(url, safe=":/?&=%") + print(f"下载最新压缩包:{url}") + request = urllib.request.Request(quoted, headers={"User-Agent": "juku-update/1.0"}) + with urllib.request.urlopen(request, timeout=60) as resp: + total = int(resp.headers.get("Content-Length") or 0) + target = Path(tempfile.gettempdir()) / "短剧库-update.zip" + done = 0 + with target.open("wb") as f: + while True: + chunk = resp.read(1 << 16) + if not chunk: + break + f.write(chunk) + done += len(chunk) + if total: + print(f"\r {done}/{total} 字节", end="", flush=True) + print(f"\n 已保存到 {target}") + return target + + +def norm_name(info: zipfile.ZipInfo) -> str: + """修正 zip 内中文文件名。 + + 打包工具有两种常见错误:用 UTF-8 编码但不打 0x800 标志(跨平台工具), + 或用 GBK 编码且不打标志(中文 Windows 右键压缩)。依次尝试恢复: + UTF-8 优先(其字节序列极少与 GBK 混淆),失败再试 GBK。 + """ + name = info.filename + if not info.flag_bits & 0x800: + try: + raw = name.encode("cp437") + except UnicodeEncodeError: + return name.replace("\\", "/") + for encoding in ("utf-8", "gbk"): + try: + name = raw.decode(encoding) + break + except UnicodeDecodeError: + continue + return name.replace("\\", "/") + + +def ignored(rel: str) -> bool: + parts = rel.split("/") + return ( + any(p in IGNORE_DIRS for p in parts[:-1]) + or Path(rel).name in IGNORE_NAMES + or Path(rel).suffix in IGNORE_SUFFIX + ) + + +def common_prefix(names: list[str]) -> str: + """所有条目共享同一顶层目录时返回该前缀(如 '短剧库/'),否则返回空串。""" + if not names: + return "" + tops = {n.split("/", 1)[0] for n in names} + if len(tops) == 1 and all("/" in n for n in names): + return next(iter(tops)) + "/" + return "" + + +def md5_of(path: Path) -> str: + h = hashlib.md5() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description="用新版 zip 更新本地短剧库源码") + parser.add_argument("zipfile", nargs="?", default=None, + help="本地压缩包路径;省略则从 --url 地址下载最新包") + parser.add_argument("--url", default=DEFAULT_URL, help=f"新版下载地址(默认 {DEFAULT_URL})") + parser.add_argument("--apply", action="store_true", help="真正写入本地(默认只预览)") + args = parser.parse_args() + + if args.zipfile: + zip_path = Path(args.zipfile) + if not zip_path.exists(): + print(f"找不到压缩包:{args.zipfile}") + return 1 + else: + zip_path = download(args.url) + + with zipfile.ZipFile(zip_path) as zf: + infos = [i for i in zf.infolist() if not i.is_dir()] + names = [norm_name(i) for i in infos] + # 先丢弃 macOS 压缩附带的资源文件,避免干扰顶层目录识别 + pairs = [ + (info, name) + for info, name in zip(infos, names) + if "__MACOSX" not in name.split("/") and not Path(name).name.startswith("._") + ] + prefix = common_prefix([name for _, name in pairs]) + if prefix: + print(f"检测到压缩包顶层目录:{prefix}(比对时已忽略)") + incoming: dict[str, bytes] = {} + for info, name in pairs: + rel = name[len(prefix):] if prefix else name + if rel and not ignored(rel): + incoming[rel] = zf.read(info) + + added, modified, unchanged = [], [], [] + for rel, blob in sorted(incoming.items()): + local = REPO_ROOT / rel + if not local.exists(): + added.append(rel) + elif md5_of(local) != hashlib.md5(blob).hexdigest(): + modified.append(rel) + else: + unchanged.append(rel) + + local_files: set[str] = set() + for path in REPO_ROOT.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(REPO_ROOT).as_posix() + if ignored(rel) or rel in IGNORE_NAMES: + continue + local_files.add(rel) + deleted = sorted(local_files - set(incoming) - PROTECT) + + print(f"\n对比完成:压缩包 {len(incoming)} 个文件,本地 {len(local_files)} 个文件") + print(f" 新增 {len(added)} 个") + for rel in added: + print(f" + {rel}") + print(f" 修改 {len(modified)} 个") + for rel in modified: + print(f" ~ {rel}") + print(f" 删除 {len(deleted)} 个") + for rel in deleted: + print(f" - {rel}") + print(f" 未变化 {len(unchanged)} 个") + + if not (added or modified or deleted): + print("本地代码与压缩包一致,无需更新。") + return 0 + if not args.apply: + print("\n当前为预览模式。确认无误后追加 --apply 执行更新,然后 git add -A && git commit。") + return 0 + + for rel in added + modified: + target = REPO_ROOT / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(incoming[rel]) + print(f"已写入 {rel}") + for rel in deleted: + (REPO_ROOT / rel).unlink(missing_ok=True) + print(f"已删除 {rel}") + print("\n更新完成。请执行 git status / git diff 复核后提交。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/启动剧库.bat b/scripts/启动剧库.bat new file mode 100644 index 0000000..212d8ce --- /dev/null +++ b/scripts/启动剧库.bat @@ -0,0 +1,11 @@ +@echo off +chcp 65001 >nul +title 短剧库 +cd /d "%~dp0.." +if not exist "dist\juku_windows_amd64.exe" ( + echo 找不到下载器,请先编译或获取包含 dist 的完整版本。 + pause + exit /b 1 +) +"dist\juku_windows_amd64.exe" %* +if errorlevel 1 pause diff --git a/scripts/启动剧库.command b/scripts/启动剧库.command new file mode 100644 index 0000000..8e7cf2a --- /dev/null +++ b/scripts/启动剧库.command @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +case "$(uname -m)" in + arm64) binary="./dist/juku_darwin_arm64" ;; + x86_64) binary="./dist/juku_darwin_amd64" ;; + *) echo "不支持的 macOS 架构,请使用 go run ."; exit 1 ;; +esac +if [ ! -x "$binary" ]; then + echo "找不到可执行程序,请先在项目目录运行 ./scripts/build.sh。" + exit 1 +fi +exec "$binary" "$@" diff --git a/scripts/启动剧库.sh b/scripts/启动剧库.sh new file mode 100644 index 0000000..7f213fa --- /dev/null +++ b/scripts/启动剧库.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +if [ ! -x "./dist/juku_linux_amd64" ]; then + echo "找不到 Linux x64 程序,请先运行 ./scripts/build.sh。" + exit 1 +fi +exec ./dist/juku_linux_amd64 "$@"