From 71bf60c69c489c1a23b9563a007a584a6131c15b Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:12:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初始化项目 --- README.md | 4 +- README_EN.md | 4 +- cmd/server/main.go | 13 +- internal/database/database_test.go | 101 --- internal/database/schema_migration.go | 43 -- .../database/schema_subscription_identity.go | 54 -- .../database/sqlite_migration_bootstrap.go | 2 - internal/handler/admin.go | 4 +- internal/handler/admin_settings.go | 3 - internal/handler/ai.go | 79 --- internal/handler/assistant.go | 147 ---- internal/handler/auth.go | 1 - internal/handler/cloud.go | 160 ----- internal/handler/cloud_mount.go | 149 ---- internal/handler/cloud_playback.go | 284 -------- internal/handler/cloud_playback_test.go | 55 -- internal/handler/cloud_qr.go | 49 -- internal/handler/cloud_test.go | 157 ----- internal/handler/discover.go | 48 -- internal/handler/discover_extra.go | 338 --------- internal/handler/discover_extra_test.go | 183 ----- internal/handler/download_client_handler.go | 274 -------- internal/handler/download_clients.go | 108 --- internal/handler/downloads.go | 258 ------- internal/handler/downloads_extra.go | 112 --- internal/handler/duplicate.go | 46 -- internal/handler/emby_images.go | 5 - internal/handler/emby_items_test.go | 59 -- .../emby_playback_cloud_routes_test.go | 223 ------ internal/handler/handler.go | 39 -- internal/handler/license.go | 167 ----- internal/handler/license_client.go | 229 ------- internal/handler/license_heartbeat.go | 172 ----- internal/handler/license_state.go | 211 ------ internal/handler/license_test.go | 445 ------------ internal/handler/media.go | 32 +- internal/handler/media_scan.go | 43 +- internal/handler/media_test.go | 115 ---- internal/handler/metadata_enrich_download.go | 188 ----- .../handler/metadata_enrich_subscription.go | 100 --- internal/handler/notify.go | 27 - internal/handler/notify_channels.go | 77 --- internal/handler/notify_handler.go | 196 ------ internal/handler/playback_extra_test.go | 58 -- internal/handler/recycle.go | 65 -- internal/handler/repair_rescrape.go | 84 --- internal/handler/routes_admin.go | 65 -- internal/handler/routes_admin_test.go | 8 - internal/handler/routes_authenticated.go | 11 - internal/handler/routes_authenticated_core.go | 9 - .../handler/routes_authenticated_extras.go | 42 -- .../handler/routes_authenticated_features.go | 68 -- internal/handler/routes_authenticated_test.go | 26 +- internal/handler/scheduler.go | 42 -- internal/handler/scheduler_handler.go | 42 -- internal/handler/search_extra.go | 21 - internal/handler/site_handler.go | 170 ----- internal/handler/sites.go | 164 ----- internal/handler/sites_extra.go | 62 -- internal/handler/stats.go | 154 ----- internal/handler/stats_extra.go | 180 ----- internal/handler/stats_test.go | 115 ---- internal/handler/storage_config.go | 145 ---- internal/handler/storage_config_test.go | 101 --- internal/handler/streaming.go | 36 - internal/handler/strm.go | 437 ------------ internal/handler/strm_test.go | 47 -- internal/handler/subscription_errors.go | 22 - internal/handler/subscription_errors_test.go | 32 - internal/handler/subscription_extra.go | 210 ------ internal/handler/subscription_logging.go | 54 -- internal/handler/subscriptions.go | 219 ------ internal/handler/system_extra.go | 40 +- internal/handler/system_meta.go | 8 - internal/handler/task_helpers.go | 2 +- internal/handler/tasks.go | 40 -- internal/handler/telegram_webhook.go | 104 --- internal/helper/flaresolverr.go | 128 ---- internal/helper/http.go | 7 +- internal/helper/page_source.go | 48 -- internal/helper/site_auth.go | 50 -- internal/helper/site_connectivity.go | 96 --- internal/middleware/middleware_test.go | 12 +- internal/model/download_client.go | 16 - internal/model/download_subscription.go | 81 --- internal/model/model.go | 10 - internal/model/model_test.go | 1 - internal/model/notify_channel.go | 14 - internal/model/permission.go | 15 +- internal/model/site.go | 57 -- internal/model/storage_assistant.go | 20 - internal/model/strm.go | 36 - internal/model/subscription_identity.go | 90 --- internal/model/subscription_identity_test.go | 45 -- internal/model/telegram_binding.go | 12 - internal/repository/assistant_repo.go | 64 -- internal/repository/download_client_repo.go | 105 --- internal/repository/download_repository.go | 24 - internal/repository/notify_channel_repo.go | 74 -- internal/repository/repository.go | 16 - internal/repository/site_repo.go | 56 -- internal/repository/strm_repo.go | 82 --- .../repository/subscription_repository.go | 72 -- internal/repository/user_repository.go | 3 - internal/service/ai.go | 265 ------- internal/service/ai_runtime_config.go | 60 -- internal/service/ai_test.go | 70 -- internal/service/aria2_adp.go | 203 ------ internal/service/aria2_parse.go | 116 ---- internal/service/aria2_rpc.go | 166 ----- internal/service/assistant.go | 209 ------ internal/service/auth.go | 11 +- internal/service/auth_user_limits_test.go | 56 +- internal/service/bangumi_calendar.go | 77 --- internal/service/boot_cloud.go | 74 -- internal/service/boot_cloud_health.go | 102 --- internal/service/boot_cloud_health_test.go | 53 -- internal/service/bot_cleanup_test.go | 246 ------- internal/service/bot_commands_test.go | 175 ----- internal/service/bot_device_policy_test.go | 253 ------- internal/service/bot_features.go | 285 -------- internal/service/bot_features_test.go | 135 ---- internal/service/bot_policy_commands_test.go | 124 ---- .../service/bot_registration_code_test.go | 141 ---- internal/service/bot_registration_test.go | 94 --- internal/service/bot_unbind_test.go | 141 ---- internal/service/cloud/cloud.go | 137 ---- internal/service/cloud/cloud_115_test.go | 212 ------ internal/service/cloud/cloud_test.go | 29 - internal/service/cloud/clouddrive2.go | 237 ------- internal/service/cloud/clouddrive2_dav.go | 172 ----- .../service/cloud/clouddrive2_dav_error.go | 55 -- .../service/cloud/clouddrive2_dav_path.go | 85 --- .../service/cloud/clouddrive2_mutation.go | 233 ------- .../service/cloud/clouddrive2_openlist.go | 240 ------- .../cloud/clouddrive2_openlist_helpers.go | 126 ---- internal/service/cloud/clouddrive2_test.go | 171 ----- internal/service/cloud/openlist_list_test.go | 280 -------- .../service/cloud/openlist_resolve_test.go | 204 ------ internal/service/cloud/pan115.go | 237 ------- internal/service/cloud/pan115_crypto.go | 184 ----- internal/service/cloud/pan115_qr.go | 147 ---- internal/service/cloud_auto_category.go | 288 -------- .../service/cloud_library_display_stubs.go | 248 +++++++ internal/service/cloud_metadata.go | 168 ----- internal/service/cloud_metadata_artwork.go | 119 ---- internal/service/cloud_metadata_candidates.go | 122 ---- internal/service/cloud_metadata_episode.go | 83 --- internal/service/cloud_metadata_json.go | 265 ------- internal/service/cloud_metadata_merge.go | 159 ----- internal/service/cloud_mount.go | 143 ---- internal/service/cloud_mount_counts.go | 38 -- internal/service/cloud_mount_dedupe.go | 122 ---- internal/service/cloud_mount_display.go | 234 ------- internal/service/cloud_mount_filter.go | 123 ---- internal/service/cloud_mount_filter_test.go | 532 --------------- internal/service/cloud_mount_label.go | 136 ---- internal/service/cloud_mount_merge.go | 90 --- internal/service/cloud_mount_scope.go | 152 ----- internal/service/cloud_path_repair.go | 122 ---- internal/service/cloud_path_repair_test.go | 233 ------- internal/service/cloud_path_rescrape.go | 217 ------ internal/service/discover.go | 202 ------ internal/service/discover_artwork.go | 115 ---- internal/service/discover_artwork_test.go | 135 ---- internal/service/discover_section_cache.go | 104 --- .../service/discover_section_cache_test.go | 44 -- internal/service/douban_discover.go | 90 --- internal/service/download_active_paths.go | 36 - internal/service/download_adapter.go | 90 --- .../service/download_adapter_file_test.go | 219 ------ internal/service/download_add.go | 232 ------- .../download_add_classification_test.go | 160 ----- internal/service/download_add_dedup.go | 196 ------ internal/service/download_add_dedup_test.go | 332 --------- .../service/download_add_existing_test.go | 220 ------ internal/service/download_add_local.go | 92 --- internal/service/download_add_subscription.go | 48 -- internal/service/download_add_test.go | 149 ---- internal/service/download_classifier.go | 83 --- internal/service/download_client_endpoint.go | 115 ---- internal/service/download_clients.go | 270 -------- internal/service/download_clients_test.go | 248 ------- internal/service/download_completion.go | 216 ------ internal/service/download_completion_state.go | 327 --------- internal/service/download_config_test.go | 390 ----------- internal/service/download_control_test.go | 75 -- internal/service/download_controls.go | 105 --- internal/service/download_delete_test.go | 195 ------ internal/service/download_helpers.go | 75 ++ internal/service/download_identity.go | 207 ------ internal/service/download_live_snapshot.go | 52 -- internal/service/download_manager_svc.go | 346 ---------- .../service/download_multi_client_test.go | 417 ----------- internal/service/download_notification.go | 84 --- internal/service/download_polling.go | 201 ------ internal/service/download_runtime.go | 191 ------ .../service/download_torrent_normalize.go | 80 --- internal/service/download_views.go | 217 ------ internal/service/downloads.go | 308 --------- internal/service/downloads_completion_test.go | 157 ----- internal/service/downloads_config_runtime.go | 134 ---- internal/service/downloads_organize_test.go | 195 ------ internal/service/downloads_poll_test.go | 142 ---- internal/service/downloads_progress_test.go | 325 --------- internal/service/downloads_test.go | 38 -- internal/service/downloads_view_test.go | 84 --- internal/service/duplicate.go | 216 ------ internal/service/duplicate_groups.go | 93 --- internal/service/duplicate_hash.go | 62 -- internal/service/duplicate_test.go | 71 -- .../service/emby_cloud_library_merge_test.go | 88 --- internal/service/emby_compat.go | 22 - internal/service/emby_compat_test.go | 28 - internal/service/emby_playback.go | 57 -- internal/service/emby_playback_test.go | 163 ----- internal/service/emby_versions_test.go | 101 --- internal/service/external_search_test.go | 9 + internal/service/image_proxy_cloud.go | 151 ---- internal/service/image_proxy_cloud_test.go | 165 ----- internal/service/license.go | 81 --- internal/service/local_availability.go | 104 +-- internal/service/media_classifier_test.go | 33 +- internal/service/media_cloud_test.go | 285 -------- .../service/media_display_library_test.go | 64 -- internal/service/media_library_roots.go | 23 - internal/service/media_library_roots_test.go | 25 - internal/service/media_visibility_test.go | 47 -- internal/service/notifier.go | 145 ---- internal/service/notify_bark.go | 77 --- internal/service/notify_channels.go | 272 -------- internal/service/notify_channels_test.go | 186 ----- internal/service/notify_dispatch.go | 163 ----- internal/service/notify_email.go | 147 ---- internal/service/notify_svc.go | 186 ----- internal/service/notify_telegram.go | 141 ---- internal/service/notify_telegram_body.go | 175 ----- internal/service/notify_telegram_config.go | 47 -- internal/service/notify_telegram_data.go | 170 ----- internal/service/notify_telegram_fields.go | 226 ------ internal/service/notify_telegram_media.go | 138 ---- internal/service/notify_webhook.go | 119 ---- internal/service/notify_wechat.go | 77 --- internal/service/organizer.go | 17 - .../service/organizer_active_downloads.go | 35 - .../organizer_directory_source_test.go | 43 -- .../service/organizer_reclassify_cloud.go | 186 ----- .../service/organizer_reclassify_scanned.go | 3 - internal/service/permission.go | 10 +- internal/service/qbittorrent.go | 310 --------- internal/service/qbittorrent_add.go | 233 ------- internal/service/qbittorrent_adp.go | 285 -------- internal/service/qbittorrent_adp_list.go | 101 --- internal/service/qbittorrent_bencode.go | 91 --- internal/service/qbittorrent_convert.go | 40 -- internal/service/qbittorrent_login.go | 101 --- internal/service/qbittorrent_test.go | 411 ----------- internal/service/runtime_settings.go | 6 - internal/service/scanner.go | 142 +--- internal/service/scanner_cloud_115_test.go | 99 --- internal/service/scanner_cloud_artwork.go | 158 ----- .../service/scanner_cloud_artwork_test.go | 386 ----------- .../scanner_cloud_autocategory_test.go | 457 ------------- internal/service/scanner_cloud_candidates.go | 247 ------- internal/service/scanner_cloud_conflict.go | 42 -- internal/service/scanner_cloud_enrich.go | 155 ----- internal/service/scanner_cloud_entry.go | 66 -- internal/service/scanner_cloud_ingest.go | 125 ---- internal/service/scanner_cloud_jobs.go | 151 ---- .../service/scanner_cloud_metadata_test.go | 486 ------------- .../service/scanner_cloud_openlist_test.go | 338 --------- internal/service/scanner_cloud_paths.go | 61 -- internal/service/scanner_cloud_probe.go | 122 ---- internal/service/scanner_cloud_scan.go | 184 ----- .../service/scanner_cloud_scan_progress.go | 170 ----- internal/service/scanner_cloud_status.go | 265 ------- internal/service/scanner_cloud_status_test.go | 57 -- internal/service/scanner_cloud_test.go | 152 ----- .../scanner_cloud_test_helpers_test.go | 58 -- internal/service/scanner_existing_media.go | 56 -- .../service/scanner_existing_media_test.go | 54 -- internal/service/scanner_local_probe_queue.go | 33 + internal/service/scanner_metadata_refresh.go | 112 --- internal/service/scanner_notifications.go | 36 - internal/service/scanner_probe_queue.go | 98 --- internal/service/scanner_probe_queue_test.go | 69 -- internal/service/scanner_scan.go | 8 - internal/service/scanner_strm.go | 82 --- internal/service/scanner_strm_test.go | 75 -- internal/service/scheduler.go | 20 +- internal/service/scheduler_cloud.go | 201 ------ internal/service/scheduler_cloud_sync_test.go | 232 ------- internal/service/scheduler_local_jobs.go | 11 +- internal/service/scheduler_organize_test.go | 8 +- internal/service/scheduler_scan_test.go | 4 +- internal/service/scheduler_test.go | 8 +- internal/service/scraper_library.go | 17 - internal/service/scraper_library_test.go | 44 -- internal/service/scraper_query_paths.go | 9 + internal/service/scraper_service.go | 7 - internal/service/security_safety_test.go | 22 - internal/service/service.go | 37 +- internal/service/service_builder.go | 47 +- internal/service/service_library_normalize.go | 24 +- internal/service/session_tracker_test.go | 56 -- internal/service/site.go | 30 - internal/service/site_adapter.go | 233 ------- internal/service/site_adapter_custom_rss.go | 172 ----- internal/service/site_adapter_discuz.go | 167 ----- internal/service/site_adapter_factory.go | 40 -- internal/service/site_adapter_gazelle.go | 227 ------ internal/service/site_adapter_mteam.go | 258 ------- internal/service/site_adapter_mteam_parse.go | 135 ---- internal/service/site_adapter_nexusphp.go | 122 ---- .../service/site_adapter_nexusphp_detail.go | 43 -- .../service/site_adapter_nexusphp_list.go | 232 ------- internal/service/site_adapter_parse.go | 51 -- internal/service/site_adapter_test.go | 448 ------------ internal/service/site_adapter_unit3d.go | 198 ------ internal/service/site_adapter_yemapt.go | 92 --- internal/service/site_connection.go | 99 --- internal/service/site_crud.go | 161 ----- internal/service/site_download.go | 179 ----- internal/service/site_rate_limiter.go | 280 -------- internal/service/site_search.go | 209 ------ internal/service/site_test.go | 184 ----- internal/service/stats.go | 163 ----- internal/service/stats_test.go | 42 -- internal/service/storage_cloud_resolve.go | 277 -------- internal/service/storage_cloud_text.go | 45 -- internal/service/storage_config.go | 276 -------- internal/service/storage_config_cache_test.go | 89 --- internal/service/storage_config_cloud.go | 147 ---- .../service/storage_config_logout_test.go | 102 --- internal/service/storage_config_probe.go | 164 ----- internal/service/storage_config_probe_test.go | 153 ----- internal/service/storage_config_secrets.go | 88 --- internal/service/storage_test.go | 50 -- internal/service/storage_types.go | 31 - internal/service/storage_types_test.go | 29 - internal/service/storage_upload.go | 278 -------- internal/service/storage_upload_alist.go | 208 ------ internal/service/storage_upload_jobs_test.go | 192 ------ internal/service/storage_upload_test.go | 233 ------- internal/service/storage_upload_webdav.go | 128 ---- internal/service/strm_generate.go | 309 --------- internal/service/strm_generate_cleanup.go | 116 ---- internal/service/strm_output_dir.go | 195 ------ internal/service/strm_output_presets.go | 79 --- internal/service/strm_output_presets_test.go | 86 --- internal/service/strm_proxy.go | 89 --- internal/service/strm_refresh.go | 89 --- internal/service/strm_refresh_test.go | 65 -- internal/service/strm_repair.go | 145 ---- internal/service/strm_repair_test.go | 77 --- internal/service/strm_svc.go | 171 ----- internal/service/strm_svc_test.go | 504 -------------- internal/service/strm_tree_cleanup_test.go | 150 ---- internal/service/strm_tree_import.go | 283 -------- internal/service/strm_tree_import_test.go | 340 --------- internal/service/strm_tree_missing.go | 73 -- internal/service/strm_tree_normalize.go | 380 ----------- internal/service/strm_tree_parse.go | 196 ------ internal/service/strm_tree_parse_test.go | 234 ------- internal/service/strm_tree_paths.go | 187 ----- internal/service/strm_tree_url_test.go | 312 --------- internal/service/strm_url.go | 196 ------ internal/service/subscription.go | 274 -------- internal/service/subscription_archive.go | 173 ----- internal/service/subscription_archive_test.go | 318 --------- internal/service/subscription_availability.go | 263 ------- .../service/subscription_availability_scan.go | 93 --- .../service/subscription_availability_test.go | 336 --------- .../subscription_availability_titles.go | 182 ----- ...ubscription_candidate_availability_test.go | 340 --------- .../service/subscription_candidate_collect.go | 237 ------- .../subscription_candidate_defaults_test.go | 299 -------- .../service/subscription_candidate_select.go | 184 ----- .../subscription_candidate_selection_test.go | 191 ------ internal/service/subscription_classifier.go | 172 ----- internal/service/subscription_delete.go | 131 ---- .../service/subscription_downloader_test.go | 88 --- .../service/subscription_episode_queries.go | 176 ----- .../service/subscription_episode_totals.go | 197 ------ .../subscription_episode_totals_test.go | 64 -- .../service/subscription_exclude_rules.go | 187 ----- internal/service/subscription_guid.go | 69 -- internal/service/subscription_identity.go | 89 --- .../service/subscription_identity_test.go | 114 ---- internal/service/subscription_logging.go | 54 -- .../service/subscription_metadata_prepare.go | 110 --- .../subscription_metadata_prepare_test.go | 76 --- internal/service/subscription_notification.go | 73 -- internal/service/subscription_pack.go | 23 - .../subscription_pending_availability_test.go | 267 -------- internal/service/subscription_planner.go | 85 --- internal/service/subscription_rss.go | 79 --- .../service/subscription_rss_defaults_test.go | 35 - internal/service/subscription_rss_run.go | 188 ----- internal/service/subscription_rules.go | 104 --- internal/service/subscription_rules_test.go | 213 ------ internal/service/subscription_run_rss_test.go | 162 ----- .../service/subscription_run_wash_test.go | 267 -------- internal/service/subscription_score.go | 167 ----- internal/service/subscription_site_search.go | 241 ------- .../subscription_site_search_enqueue.go | 164 ----- .../subscription_site_search_enqueue_test.go | 156 ----- .../subscription_site_search_helpers.go | 203 ------ .../subscription_site_search_keywords_test.go | 110 --- .../subscription_site_search_limit_test.go | 129 ---- internal/service/subscription_test.go | 180 ----- .../service/subscription_torrent_rules.go | 56 -- .../subscription_torrent_rules_test.go | 59 -- internal/service/subtitle.go | 28 +- internal/service/subtitle_cloud.go | 208 ------ internal/service/telegram_admin_codes.go | 133 ---- internal/service/telegram_admin_users.go | 186 ----- internal/service/telegram_api.go | 232 ------- internal/service/telegram_api_client.go | 124 ---- .../service/telegram_api_commands_test.go | 192 ------ internal/service/telegram_api_proxy_test.go | 62 -- internal/service/telegram_api_test.go | 88 --- internal/service/telegram_api_webhook_test.go | 197 ------ internal/service/telegram_binding.go | 139 ---- internal/service/telegram_bot.go | 298 -------- internal/service/telegram_bot_binding_test.go | 293 -------- .../service/telegram_bot_commands_core.go | 225 ------ .../service/telegram_bot_commands_help.go | 81 --- internal/service/telegram_bot_user_test.go | 286 -------- internal/service/telegram_callback.go | 77 --- internal/service/telegram_channel_resolve.go | 160 ----- internal/service/telegram_cleanup_rules.go | 220 ------ .../service/telegram_cleanup_rules_test.go | 34 - internal/service/telegram_command_menus.go | 158 ----- internal/service/telegram_commands.go | 224 ------ internal/service/telegram_device_policy.go | 252 ------- internal/service/telegram_membership.go | 122 ---- internal/service/telegram_menu.go | 219 ------ internal/service/telegram_menu_layout.go | 115 ---- internal/service/telegram_mgo_admin.go | 159 ----- internal/service/telegram_mgo_audit.go | 133 ---- internal/service/telegram_mgo_backup.go | 49 -- internal/service/telegram_mgo_bulk.go | 94 --- internal/service/telegram_mgo_compat.go | 104 --- internal/service/telegram_mgo_compat_test.go | 278 -------- internal/service/telegram_mgo_membership.go | 92 --- internal/service/telegram_mgo_users.go | 184 ----- internal/service/telegram_polling.go | 208 ------ internal/service/telegram_redeem.go | 185 ----- internal/service/telegram_reply.go | 127 ---- internal/service/telegram_stats.go | 224 ------ internal/service/telegram_unbind.go | 224 ------ internal/service/telegram_user_self.go | 241 ------- internal/service/telegram_webhook_admin.go | 31 - internal/service/transmission_adp.go | 220 ------ internal/service/transmission_parse.go | 100 --- internal/service/transmission_rpc.go | 141 ---- internal/service/watcher.go | 3 - web/index.html | 9 +- web/package.json | 2 +- web/public/brand/logo-192.png | Bin 0 -> 61940 bytes web/public/brand/logo-512.png | Bin 0 -> 380764 bytes web/public/brand/logo-64.png | Bin 0 -> 9201 bytes web/public/brand/logo.png | Bin 0 -> 1432978 bytes web/public/brand/logo.svg | 3 + web/public/brand/mediastationgo-logo.svg | 13 +- web/public/brand/mgo-emby-icon.svg | 13 +- web/public/brand/mmtl-logo.png | Bin 0 -> 1432978 bytes web/public/favicon.ico | Bin 0 -> 766 bytes web/public/favicon.png | Bin 0 -> 2887 bytes web/public/favicon.svg | 13 +- web/src/App.tsx | 2 +- web/src/api/ai.ts | 50 -- web/src/api/assistant.ts | 65 -- web/src/api/discover.ts | 84 --- web/src/api/download_clients.ts | 53 -- web/src/api/downloads.ts | 35 - web/src/api/duplicates.ts | 37 - web/src/api/license.ts | 54 -- web/src/api/notify_channels.ts | 41 -- web/src/api/recycle.ts | 9 - web/src/api/scheduler.ts | 15 - web/src/api/sites.ts | 70 -- web/src/api/stats.ts | 7 - web/src/api/storage_config.ts | 175 ----- web/src/api/strm.ts | 124 ---- web/src/api/subscriptions.ts | 104 --- web/src/api/tasks.ts | 55 -- web/src/appRoutes.tsx | 54 +- web/src/components/AppFooter.tsx | 41 -- web/src/components/Layout.tsx | 38 +- web/src/components/LayoutHeaderSections.tsx | 305 ++++++--- web/src/components/LayoutSearchBox.tsx | 143 ---- web/src/components/LayoutSections.tsx | 33 +- web/src/components/LayoutSidebarContent.tsx | 137 +--- web/src/components/LayoutUserMenu.tsx | 43 +- web/src/components/MediaCard.tsx | 29 +- web/src/components/RouteErrorBoundary.tsx | 2 +- web/src/components/layoutNavigation.ts | 105 +-- web/src/components/manageLibrariesDialog.tsx | 51 ++ web/src/components/useLayoutSearch.ts | 85 --- web/src/components/useLayoutSidebar.ts | 29 +- web/src/pages/AIAssistantExternalResults.tsx | 69 -- web/src/pages/AIAssistantHeader.tsx | 40 -- web/src/pages/AIAssistantPage.tsx | 51 -- .../AIAssistantRecommendationsSection.tsx | 47 -- web/src/pages/AIAssistantSearchSection.tsx | 138 ---- web/src/pages/AdminLibraryPanel.tsx | 4 +- web/src/pages/AdminPage.tsx | 72 +- web/src/pages/AdminUsersPanel.tsx | 18 +- web/src/pages/AssistantChatPage.tsx | 134 ---- web/src/pages/AssistantChatSections.tsx | 143 ---- web/src/pages/CloudBrowser.tsx | 46 -- web/src/pages/CloudBrowserToolbar.tsx | 99 --- web/src/pages/CloudEntryList.tsx | 61 -- web/src/pages/CloudMountList.tsx | 36 - web/src/pages/CloudScanPanel.tsx | 70 -- web/src/pages/DiscoverContentRow.tsx | 202 ------ web/src/pages/DiscoverDetailModal.tsx | 61 -- web/src/pages/DiscoverDetailModalSections.tsx | 172 ----- web/src/pages/DiscoverPage.tsx | 239 ------- web/src/pages/DiscoverPageSections.tsx | 188 ----- web/src/pages/DownloadClientCard.tsx | 70 -- web/src/pages/DownloadClientFormModal.tsx | 170 ----- web/src/pages/DownloadClientsPage.tsx | 129 ---- web/src/pages/DownloadTaskCard.tsx | 143 ---- web/src/pages/DownloadsPage.tsx | 134 ---- web/src/pages/DuplicatesPage.tsx | 130 ---- web/src/pages/HomePage.tsx | 179 ++++- web/src/pages/HomePageSections.tsx | 645 ++++++++++++++---- web/src/pages/LibrariesPageSections.tsx | 21 +- web/src/pages/LibrarySettingsPanel.tsx | 305 +++++++++ web/src/pages/LibraryStorageStats.tsx | 180 +++++ web/src/pages/LicensePage.tsx | 99 --- web/src/pages/LicensePageSections.tsx | 219 ------ web/src/pages/LoginPageSections.tsx | 22 +- web/src/pages/NotifyChannelCard.tsx | 60 -- web/src/pages/NotifyChannelConfigControls.tsx | 97 --- web/src/pages/NotifyChannelEventFields.tsx | 67 -- web/src/pages/NotifyChannelFormField.tsx | 10 - web/src/pages/NotifyChannelFormModal.tsx | 185 ----- web/src/pages/NotifyChannelProviderFields.tsx | 203 ------ web/src/pages/NotifyChannelsPage.tsx | 181 ----- web/src/pages/QRLoginPanel.tsx | 78 --- web/src/pages/RecycleBinPage.tsx | 179 ----- web/src/pages/SchedulerPage.tsx | 84 --- web/src/pages/SearchExternalResults.tsx | 209 ------ web/src/pages/SearchHeader.tsx | 25 - web/src/pages/SearchInputBar.tsx | 37 - web/src/pages/SearchLocalResults.tsx | 38 -- web/src/pages/SearchPage.tsx | 54 -- web/src/pages/SearchStatusPanels.tsx | 42 -- web/src/pages/SettingsPage.tsx | 22 +- web/src/pages/SiteCard.tsx | 161 ----- web/src/pages/SiteFormActions.tsx | 41 -- web/src/pages/SiteFormAdvancedOptions.tsx | 183 ----- web/src/pages/SiteFormAuthFields.tsx | 133 ---- web/src/pages/SiteFormBasicFields.tsx | 121 ---- web/src/pages/SiteFormModal.tsx | 76 --- web/src/pages/SiteSearchPage.tsx | 169 ----- web/src/pages/SitesPage.tsx | 191 ------ web/src/pages/SitesPageSections.tsx | 113 --- web/src/pages/StatsPage.tsx | 94 --- web/src/pages/StatsPageSections.tsx | 197 ------ web/src/pages/StorageConfigPage.tsx | 52 -- web/src/pages/StorageForm.tsx | 202 ------ web/src/pages/StorageFormActions.tsx | 48 -- web/src/pages/StoragePage.tsx | 179 ----- web/src/pages/StorageTransferSettings.tsx | 49 -- web/src/pages/StorageUploadPanel.tsx | 131 ---- web/src/pages/StrmAttachSection.tsx | 103 --- web/src/pages/StrmGenerateSection.tsx | 65 -- web/src/pages/StrmGenerateSectionParts.tsx | 338 --------- web/src/pages/StrmImportSection.tsx | 71 -- web/src/pages/StrmOutputDirPicker.tsx | 50 -- web/src/pages/StrmPage.tsx | 86 --- web/src/pages/StrmPageHeader.tsx | 17 - web/src/pages/StrmRepairSection.tsx | 101 --- web/src/pages/StrmTreeGenerateSection.tsx | 253 ------- web/src/pages/SubscriptionCard.tsx | 105 --- web/src/pages/SubscriptionForm.tsx | 170 ----- web/src/pages/SubscriptionHistorySection.tsx | 99 --- web/src/pages/SubscriptionsPage.tsx | 273 -------- web/src/pages/SystemUpdatePanel.tsx | 242 ------- web/src/pages/TaskItemRetryDialog.tsx | 171 ----- web/src/pages/TaskRuntimeTables.tsx | 85 --- web/src/pages/TasksPage.tsx | 436 ------------ web/src/pages/discoverDetailModalModel.ts | 94 --- web/src/pages/discoverPageModel.ts | 169 ----- web/src/pages/downloadClientPageModel.ts | 7 - web/src/pages/downloadTaskCardModel.ts | 63 -- web/src/pages/librariesPageModel.ts | 21 + web/src/pages/libraryDisplayModel.ts | 10 +- web/src/pages/libraryPageModel.ts | 15 - web/src/pages/notifyChannelsModel.ts | 87 --- web/src/pages/settingsGroupAPIConfigs.ts | 8 + web/src/pages/settingsGroupCloud.ts | 99 --- web/src/pages/settingsGroupGeneral.ts | 41 -- web/src/pages/settingsGroupLibrary.ts | 8 + web/src/pages/settingsGroupSubscriptions.ts | 16 - web/src/pages/settingsGroupSystemUpdate.ts | 32 - web/src/pages/settingsGroups.ts | 13 +- web/src/pages/siteFormModalTypes.ts | 10 - web/src/pages/sitesPageModel.ts | 124 ---- web/src/pages/storageConfigModel.ts | 109 --- web/src/pages/strmPageModel.ts | 206 ------ web/src/pages/strmPageUtils.ts | 7 - web/src/pages/subscriptionFormModel.ts | 48 -- web/src/pages/subscriptionPageModel.ts | 81 --- web/src/pages/useAIAssistantPage.ts | 82 --- web/src/pages/useAutoOrganizeSettings.ts | 8 +- web/src/pages/useCloudBrowser.ts | 105 --- web/src/pages/useCloudBrowserFileActions.ts | 73 -- web/src/pages/useCloudBrowserMounts.ts | 101 --- web/src/pages/useCloudBrowserScan.ts | 61 -- web/src/pages/useLibraryScanStatus.ts | 80 +-- web/src/pages/useSearchPage.ts | 133 ---- web/src/pages/useStrmAttachForm.ts | 86 --- web/src/pages/useStrmGenerateForm.ts | 206 ------ web/src/pages/useStrmImportForm.ts | 52 -- web/src/pages/useStrmPage.ts | 35 - web/src/pages/useStrmRepairForm.ts | 78 --- web/src/pages/useStrmTreeGenerateForm.ts | 173 ----- web/src/stores/permissions.ts | 3 - web/src/types/auth.ts | 1 - web/src/types/discover.ts | 23 - web/src/types/index.ts | 5 - web/src/types/scheduler.ts | 16 - web/src/types/sites.ts | 40 -- web/src/types/stats.ts | 22 - web/src/types/subscriptions.ts | 40 -- 631 files changed, 2121 insertions(+), 76050 deletions(-) delete mode 100644 internal/database/schema_subscription_identity.go delete mode 100644 internal/handler/ai.go delete mode 100644 internal/handler/assistant.go delete mode 100644 internal/handler/cloud.go delete mode 100644 internal/handler/cloud_mount.go delete mode 100644 internal/handler/cloud_playback.go delete mode 100644 internal/handler/cloud_playback_test.go delete mode 100644 internal/handler/cloud_qr.go delete mode 100644 internal/handler/cloud_test.go delete mode 100644 internal/handler/discover.go delete mode 100644 internal/handler/discover_extra.go delete mode 100644 internal/handler/discover_extra_test.go delete mode 100644 internal/handler/download_client_handler.go delete mode 100644 internal/handler/download_clients.go delete mode 100644 internal/handler/downloads.go delete mode 100644 internal/handler/downloads_extra.go delete mode 100644 internal/handler/duplicate.go delete mode 100644 internal/handler/emby_playback_cloud_routes_test.go delete mode 100644 internal/handler/license.go delete mode 100644 internal/handler/license_client.go delete mode 100644 internal/handler/license_heartbeat.go delete mode 100644 internal/handler/license_state.go delete mode 100644 internal/handler/license_test.go delete mode 100644 internal/handler/metadata_enrich_download.go delete mode 100644 internal/handler/metadata_enrich_subscription.go delete mode 100644 internal/handler/notify.go delete mode 100644 internal/handler/notify_channels.go delete mode 100644 internal/handler/notify_handler.go delete mode 100644 internal/handler/repair_rescrape.go delete mode 100644 internal/handler/scheduler.go delete mode 100644 internal/handler/scheduler_handler.go delete mode 100644 internal/handler/site_handler.go delete mode 100644 internal/handler/sites.go delete mode 100644 internal/handler/sites_extra.go delete mode 100644 internal/handler/stats.go delete mode 100644 internal/handler/stats_extra.go delete mode 100644 internal/handler/stats_test.go delete mode 100644 internal/handler/storage_config.go delete mode 100644 internal/handler/storage_config_test.go delete mode 100644 internal/handler/strm.go delete mode 100644 internal/handler/strm_test.go delete mode 100644 internal/handler/subscription_errors.go delete mode 100644 internal/handler/subscription_errors_test.go delete mode 100644 internal/handler/subscription_extra.go delete mode 100644 internal/handler/subscription_logging.go delete mode 100644 internal/handler/subscriptions.go delete mode 100644 internal/handler/tasks.go delete mode 100644 internal/handler/telegram_webhook.go delete mode 100644 internal/helper/flaresolverr.go delete mode 100644 internal/helper/page_source.go delete mode 100644 internal/helper/site_auth.go delete mode 100644 internal/helper/site_connectivity.go delete mode 100644 internal/model/download_client.go delete mode 100644 internal/model/download_subscription.go delete mode 100644 internal/model/notify_channel.go delete mode 100644 internal/model/site.go delete mode 100644 internal/model/strm.go delete mode 100644 internal/model/subscription_identity.go delete mode 100644 internal/model/subscription_identity_test.go delete mode 100644 internal/model/telegram_binding.go delete mode 100644 internal/repository/assistant_repo.go delete mode 100644 internal/repository/download_client_repo.go delete mode 100644 internal/repository/download_repository.go delete mode 100644 internal/repository/notify_channel_repo.go delete mode 100644 internal/repository/site_repo.go delete mode 100644 internal/repository/strm_repo.go delete mode 100644 internal/repository/subscription_repository.go delete mode 100644 internal/service/ai.go delete mode 100644 internal/service/ai_runtime_config.go delete mode 100644 internal/service/ai_test.go delete mode 100644 internal/service/aria2_adp.go delete mode 100644 internal/service/aria2_parse.go delete mode 100644 internal/service/aria2_rpc.go delete mode 100644 internal/service/assistant.go delete mode 100644 internal/service/bangumi_calendar.go delete mode 100644 internal/service/boot_cloud.go delete mode 100644 internal/service/boot_cloud_health.go delete mode 100644 internal/service/boot_cloud_health_test.go delete mode 100644 internal/service/bot_cleanup_test.go delete mode 100644 internal/service/bot_commands_test.go delete mode 100644 internal/service/bot_device_policy_test.go delete mode 100644 internal/service/bot_features.go delete mode 100644 internal/service/bot_features_test.go delete mode 100644 internal/service/bot_policy_commands_test.go delete mode 100644 internal/service/bot_registration_code_test.go delete mode 100644 internal/service/bot_registration_test.go delete mode 100644 internal/service/bot_unbind_test.go delete mode 100644 internal/service/cloud/cloud.go delete mode 100644 internal/service/cloud/cloud_115_test.go delete mode 100644 internal/service/cloud/cloud_test.go delete mode 100644 internal/service/cloud/clouddrive2.go delete mode 100644 internal/service/cloud/clouddrive2_dav.go delete mode 100644 internal/service/cloud/clouddrive2_dav_error.go delete mode 100644 internal/service/cloud/clouddrive2_dav_path.go delete mode 100644 internal/service/cloud/clouddrive2_mutation.go delete mode 100644 internal/service/cloud/clouddrive2_openlist.go delete mode 100644 internal/service/cloud/clouddrive2_openlist_helpers.go delete mode 100644 internal/service/cloud/clouddrive2_test.go delete mode 100644 internal/service/cloud/openlist_list_test.go delete mode 100644 internal/service/cloud/openlist_resolve_test.go delete mode 100644 internal/service/cloud/pan115.go delete mode 100644 internal/service/cloud/pan115_crypto.go delete mode 100644 internal/service/cloud/pan115_qr.go delete mode 100644 internal/service/cloud_auto_category.go create mode 100644 internal/service/cloud_library_display_stubs.go delete mode 100644 internal/service/cloud_metadata.go delete mode 100644 internal/service/cloud_metadata_artwork.go delete mode 100644 internal/service/cloud_metadata_candidates.go delete mode 100644 internal/service/cloud_metadata_episode.go delete mode 100644 internal/service/cloud_metadata_json.go delete mode 100644 internal/service/cloud_metadata_merge.go delete mode 100644 internal/service/cloud_mount.go delete mode 100644 internal/service/cloud_mount_counts.go delete mode 100644 internal/service/cloud_mount_dedupe.go delete mode 100644 internal/service/cloud_mount_display.go delete mode 100644 internal/service/cloud_mount_filter.go delete mode 100644 internal/service/cloud_mount_filter_test.go delete mode 100644 internal/service/cloud_mount_label.go delete mode 100644 internal/service/cloud_mount_merge.go delete mode 100644 internal/service/cloud_mount_scope.go delete mode 100644 internal/service/cloud_path_repair.go delete mode 100644 internal/service/cloud_path_repair_test.go delete mode 100644 internal/service/cloud_path_rescrape.go delete mode 100644 internal/service/discover.go delete mode 100644 internal/service/discover_artwork.go delete mode 100644 internal/service/discover_artwork_test.go delete mode 100644 internal/service/discover_section_cache.go delete mode 100644 internal/service/discover_section_cache_test.go delete mode 100644 internal/service/douban_discover.go delete mode 100644 internal/service/download_active_paths.go delete mode 100644 internal/service/download_adapter.go delete mode 100644 internal/service/download_adapter_file_test.go delete mode 100644 internal/service/download_add.go delete mode 100644 internal/service/download_add_classification_test.go delete mode 100644 internal/service/download_add_dedup.go delete mode 100644 internal/service/download_add_dedup_test.go delete mode 100644 internal/service/download_add_existing_test.go delete mode 100644 internal/service/download_add_local.go delete mode 100644 internal/service/download_add_subscription.go delete mode 100644 internal/service/download_add_test.go delete mode 100644 internal/service/download_classifier.go delete mode 100644 internal/service/download_client_endpoint.go delete mode 100644 internal/service/download_clients.go delete mode 100644 internal/service/download_clients_test.go delete mode 100644 internal/service/download_completion.go delete mode 100644 internal/service/download_completion_state.go delete mode 100644 internal/service/download_config_test.go delete mode 100644 internal/service/download_control_test.go delete mode 100644 internal/service/download_controls.go delete mode 100644 internal/service/download_delete_test.go create mode 100644 internal/service/download_helpers.go delete mode 100644 internal/service/download_identity.go delete mode 100644 internal/service/download_live_snapshot.go delete mode 100644 internal/service/download_manager_svc.go delete mode 100644 internal/service/download_multi_client_test.go delete mode 100644 internal/service/download_notification.go delete mode 100644 internal/service/download_polling.go delete mode 100644 internal/service/download_runtime.go delete mode 100644 internal/service/download_torrent_normalize.go delete mode 100644 internal/service/download_views.go delete mode 100644 internal/service/downloads.go delete mode 100644 internal/service/downloads_completion_test.go delete mode 100644 internal/service/downloads_config_runtime.go delete mode 100644 internal/service/downloads_organize_test.go delete mode 100644 internal/service/downloads_poll_test.go delete mode 100644 internal/service/downloads_progress_test.go delete mode 100644 internal/service/downloads_test.go delete mode 100644 internal/service/downloads_view_test.go delete mode 100644 internal/service/duplicate.go delete mode 100644 internal/service/duplicate_groups.go delete mode 100644 internal/service/duplicate_hash.go delete mode 100644 internal/service/duplicate_test.go delete mode 100644 internal/service/emby_cloud_library_merge_test.go delete mode 100644 internal/service/image_proxy_cloud.go delete mode 100644 internal/service/image_proxy_cloud_test.go delete mode 100644 internal/service/license.go delete mode 100644 internal/service/media_cloud_test.go delete mode 100644 internal/service/media_display_library_test.go delete mode 100644 internal/service/notifier.go delete mode 100644 internal/service/notify_bark.go delete mode 100644 internal/service/notify_channels.go delete mode 100644 internal/service/notify_channels_test.go delete mode 100644 internal/service/notify_dispatch.go delete mode 100644 internal/service/notify_email.go delete mode 100644 internal/service/notify_svc.go delete mode 100644 internal/service/notify_telegram.go delete mode 100644 internal/service/notify_telegram_body.go delete mode 100644 internal/service/notify_telegram_config.go delete mode 100644 internal/service/notify_telegram_data.go delete mode 100644 internal/service/notify_telegram_fields.go delete mode 100644 internal/service/notify_telegram_media.go delete mode 100644 internal/service/notify_webhook.go delete mode 100644 internal/service/notify_wechat.go delete mode 100644 internal/service/organizer_reclassify_cloud.go delete mode 100644 internal/service/qbittorrent.go delete mode 100644 internal/service/qbittorrent_add.go delete mode 100644 internal/service/qbittorrent_adp.go delete mode 100644 internal/service/qbittorrent_adp_list.go delete mode 100644 internal/service/qbittorrent_bencode.go delete mode 100644 internal/service/qbittorrent_convert.go delete mode 100644 internal/service/qbittorrent_login.go delete mode 100644 internal/service/qbittorrent_test.go delete mode 100644 internal/service/scanner_cloud_115_test.go delete mode 100644 internal/service/scanner_cloud_artwork.go delete mode 100644 internal/service/scanner_cloud_artwork_test.go delete mode 100644 internal/service/scanner_cloud_autocategory_test.go delete mode 100644 internal/service/scanner_cloud_candidates.go delete mode 100644 internal/service/scanner_cloud_conflict.go delete mode 100644 internal/service/scanner_cloud_enrich.go delete mode 100644 internal/service/scanner_cloud_entry.go delete mode 100644 internal/service/scanner_cloud_ingest.go delete mode 100644 internal/service/scanner_cloud_jobs.go delete mode 100644 internal/service/scanner_cloud_metadata_test.go delete mode 100644 internal/service/scanner_cloud_openlist_test.go delete mode 100644 internal/service/scanner_cloud_paths.go delete mode 100644 internal/service/scanner_cloud_probe.go delete mode 100644 internal/service/scanner_cloud_scan.go delete mode 100644 internal/service/scanner_cloud_scan_progress.go delete mode 100644 internal/service/scanner_cloud_status.go delete mode 100644 internal/service/scanner_cloud_status_test.go delete mode 100644 internal/service/scanner_cloud_test.go delete mode 100644 internal/service/scanner_cloud_test_helpers_test.go delete mode 100644 internal/service/scanner_notifications.go delete mode 100644 internal/service/scanner_probe_queue.go delete mode 100644 internal/service/scanner_probe_queue_test.go delete mode 100644 internal/service/scanner_strm_test.go delete mode 100644 internal/service/scheduler_cloud.go delete mode 100644 internal/service/scheduler_cloud_sync_test.go delete mode 100644 internal/service/site.go delete mode 100644 internal/service/site_adapter.go delete mode 100644 internal/service/site_adapter_custom_rss.go delete mode 100644 internal/service/site_adapter_discuz.go delete mode 100644 internal/service/site_adapter_factory.go delete mode 100644 internal/service/site_adapter_gazelle.go delete mode 100644 internal/service/site_adapter_mteam.go delete mode 100644 internal/service/site_adapter_mteam_parse.go delete mode 100644 internal/service/site_adapter_nexusphp.go delete mode 100644 internal/service/site_adapter_nexusphp_detail.go delete mode 100644 internal/service/site_adapter_nexusphp_list.go delete mode 100644 internal/service/site_adapter_parse.go delete mode 100644 internal/service/site_adapter_test.go delete mode 100644 internal/service/site_adapter_unit3d.go delete mode 100644 internal/service/site_adapter_yemapt.go delete mode 100644 internal/service/site_connection.go delete mode 100644 internal/service/site_crud.go delete mode 100644 internal/service/site_download.go delete mode 100644 internal/service/site_rate_limiter.go delete mode 100644 internal/service/site_search.go delete mode 100644 internal/service/site_test.go delete mode 100644 internal/service/stats.go delete mode 100644 internal/service/stats_test.go delete mode 100644 internal/service/storage_cloud_resolve.go delete mode 100644 internal/service/storage_cloud_text.go delete mode 100644 internal/service/storage_config.go delete mode 100644 internal/service/storage_config_cache_test.go delete mode 100644 internal/service/storage_config_cloud.go delete mode 100644 internal/service/storage_config_logout_test.go delete mode 100644 internal/service/storage_config_probe.go delete mode 100644 internal/service/storage_config_probe_test.go delete mode 100644 internal/service/storage_config_secrets.go delete mode 100644 internal/service/storage_test.go delete mode 100644 internal/service/storage_types.go delete mode 100644 internal/service/storage_types_test.go delete mode 100644 internal/service/storage_upload.go delete mode 100644 internal/service/storage_upload_alist.go delete mode 100644 internal/service/storage_upload_jobs_test.go delete mode 100644 internal/service/storage_upload_test.go delete mode 100644 internal/service/storage_upload_webdav.go delete mode 100644 internal/service/strm_generate.go delete mode 100644 internal/service/strm_generate_cleanup.go delete mode 100644 internal/service/strm_output_dir.go delete mode 100644 internal/service/strm_output_presets.go delete mode 100644 internal/service/strm_output_presets_test.go delete mode 100644 internal/service/strm_proxy.go delete mode 100644 internal/service/strm_refresh.go delete mode 100644 internal/service/strm_refresh_test.go delete mode 100644 internal/service/strm_repair.go delete mode 100644 internal/service/strm_repair_test.go delete mode 100644 internal/service/strm_svc.go delete mode 100644 internal/service/strm_svc_test.go delete mode 100644 internal/service/strm_tree_cleanup_test.go delete mode 100644 internal/service/strm_tree_import.go delete mode 100644 internal/service/strm_tree_import_test.go delete mode 100644 internal/service/strm_tree_missing.go delete mode 100644 internal/service/strm_tree_normalize.go delete mode 100644 internal/service/strm_tree_parse.go delete mode 100644 internal/service/strm_tree_parse_test.go delete mode 100644 internal/service/strm_tree_paths.go delete mode 100644 internal/service/strm_tree_url_test.go delete mode 100644 internal/service/strm_url.go delete mode 100644 internal/service/subscription.go delete mode 100644 internal/service/subscription_archive.go delete mode 100644 internal/service/subscription_archive_test.go delete mode 100644 internal/service/subscription_availability.go delete mode 100644 internal/service/subscription_availability_scan.go delete mode 100644 internal/service/subscription_availability_test.go delete mode 100644 internal/service/subscription_availability_titles.go delete mode 100644 internal/service/subscription_candidate_availability_test.go delete mode 100644 internal/service/subscription_candidate_collect.go delete mode 100644 internal/service/subscription_candidate_defaults_test.go delete mode 100644 internal/service/subscription_candidate_select.go delete mode 100644 internal/service/subscription_candidate_selection_test.go delete mode 100644 internal/service/subscription_classifier.go delete mode 100644 internal/service/subscription_delete.go delete mode 100644 internal/service/subscription_downloader_test.go delete mode 100644 internal/service/subscription_episode_queries.go delete mode 100644 internal/service/subscription_episode_totals.go delete mode 100644 internal/service/subscription_episode_totals_test.go delete mode 100644 internal/service/subscription_exclude_rules.go delete mode 100644 internal/service/subscription_guid.go delete mode 100644 internal/service/subscription_identity.go delete mode 100644 internal/service/subscription_identity_test.go delete mode 100644 internal/service/subscription_logging.go delete mode 100644 internal/service/subscription_metadata_prepare.go delete mode 100644 internal/service/subscription_metadata_prepare_test.go delete mode 100644 internal/service/subscription_notification.go delete mode 100644 internal/service/subscription_pack.go delete mode 100644 internal/service/subscription_pending_availability_test.go delete mode 100644 internal/service/subscription_planner.go delete mode 100644 internal/service/subscription_rss.go delete mode 100644 internal/service/subscription_rss_defaults_test.go delete mode 100644 internal/service/subscription_rss_run.go delete mode 100644 internal/service/subscription_rules.go delete mode 100644 internal/service/subscription_rules_test.go delete mode 100644 internal/service/subscription_run_rss_test.go delete mode 100644 internal/service/subscription_run_wash_test.go delete mode 100644 internal/service/subscription_score.go delete mode 100644 internal/service/subscription_site_search.go delete mode 100644 internal/service/subscription_site_search_enqueue.go delete mode 100644 internal/service/subscription_site_search_enqueue_test.go delete mode 100644 internal/service/subscription_site_search_helpers.go delete mode 100644 internal/service/subscription_site_search_keywords_test.go delete mode 100644 internal/service/subscription_site_search_limit_test.go delete mode 100644 internal/service/subscription_test.go delete mode 100644 internal/service/subscription_torrent_rules.go delete mode 100644 internal/service/subscription_torrent_rules_test.go delete mode 100644 internal/service/subtitle_cloud.go delete mode 100644 internal/service/telegram_admin_codes.go delete mode 100644 internal/service/telegram_admin_users.go delete mode 100644 internal/service/telegram_api.go delete mode 100644 internal/service/telegram_api_client.go delete mode 100644 internal/service/telegram_api_commands_test.go delete mode 100644 internal/service/telegram_api_proxy_test.go delete mode 100644 internal/service/telegram_api_test.go delete mode 100644 internal/service/telegram_api_webhook_test.go delete mode 100644 internal/service/telegram_binding.go delete mode 100644 internal/service/telegram_bot.go delete mode 100644 internal/service/telegram_bot_binding_test.go delete mode 100644 internal/service/telegram_bot_commands_core.go delete mode 100644 internal/service/telegram_bot_commands_help.go delete mode 100644 internal/service/telegram_bot_user_test.go delete mode 100644 internal/service/telegram_callback.go delete mode 100644 internal/service/telegram_channel_resolve.go delete mode 100644 internal/service/telegram_cleanup_rules.go delete mode 100644 internal/service/telegram_cleanup_rules_test.go delete mode 100644 internal/service/telegram_command_menus.go delete mode 100644 internal/service/telegram_commands.go delete mode 100644 internal/service/telegram_device_policy.go delete mode 100644 internal/service/telegram_membership.go delete mode 100644 internal/service/telegram_menu.go delete mode 100644 internal/service/telegram_menu_layout.go delete mode 100644 internal/service/telegram_mgo_admin.go delete mode 100644 internal/service/telegram_mgo_audit.go delete mode 100644 internal/service/telegram_mgo_backup.go delete mode 100644 internal/service/telegram_mgo_bulk.go delete mode 100644 internal/service/telegram_mgo_compat.go delete mode 100644 internal/service/telegram_mgo_compat_test.go delete mode 100644 internal/service/telegram_mgo_membership.go delete mode 100644 internal/service/telegram_mgo_users.go delete mode 100644 internal/service/telegram_polling.go delete mode 100644 internal/service/telegram_redeem.go delete mode 100644 internal/service/telegram_reply.go delete mode 100644 internal/service/telegram_stats.go delete mode 100644 internal/service/telegram_unbind.go delete mode 100644 internal/service/telegram_user_self.go delete mode 100644 internal/service/telegram_webhook_admin.go delete mode 100644 internal/service/transmission_adp.go delete mode 100644 internal/service/transmission_parse.go delete mode 100644 internal/service/transmission_rpc.go create mode 100644 web/public/brand/logo-192.png create mode 100644 web/public/brand/logo-512.png create mode 100644 web/public/brand/logo-64.png create mode 100644 web/public/brand/logo.png create mode 100644 web/public/brand/logo.svg create mode 100644 web/public/brand/mmtl-logo.png create mode 100644 web/public/favicon.ico create mode 100644 web/public/favicon.png delete mode 100644 web/src/api/ai.ts delete mode 100644 web/src/api/assistant.ts delete mode 100644 web/src/api/discover.ts delete mode 100644 web/src/api/download_clients.ts delete mode 100644 web/src/api/downloads.ts delete mode 100644 web/src/api/duplicates.ts delete mode 100644 web/src/api/license.ts delete mode 100644 web/src/api/notify_channels.ts delete mode 100644 web/src/api/scheduler.ts delete mode 100644 web/src/api/sites.ts delete mode 100644 web/src/api/stats.ts delete mode 100644 web/src/api/storage_config.ts delete mode 100644 web/src/api/strm.ts delete mode 100644 web/src/api/subscriptions.ts delete mode 100644 web/src/api/tasks.ts delete mode 100644 web/src/components/AppFooter.tsx delete mode 100644 web/src/components/LayoutSearchBox.tsx create mode 100644 web/src/components/manageLibrariesDialog.tsx delete mode 100644 web/src/components/useLayoutSearch.ts delete mode 100644 web/src/pages/AIAssistantExternalResults.tsx delete mode 100644 web/src/pages/AIAssistantHeader.tsx delete mode 100644 web/src/pages/AIAssistantPage.tsx delete mode 100644 web/src/pages/AIAssistantRecommendationsSection.tsx delete mode 100644 web/src/pages/AIAssistantSearchSection.tsx delete mode 100644 web/src/pages/AssistantChatPage.tsx delete mode 100644 web/src/pages/AssistantChatSections.tsx delete mode 100644 web/src/pages/CloudBrowser.tsx delete mode 100644 web/src/pages/CloudBrowserToolbar.tsx delete mode 100644 web/src/pages/CloudEntryList.tsx delete mode 100644 web/src/pages/CloudMountList.tsx delete mode 100644 web/src/pages/CloudScanPanel.tsx delete mode 100644 web/src/pages/DiscoverContentRow.tsx delete mode 100644 web/src/pages/DiscoverDetailModal.tsx delete mode 100644 web/src/pages/DiscoverDetailModalSections.tsx delete mode 100644 web/src/pages/DiscoverPage.tsx delete mode 100644 web/src/pages/DiscoverPageSections.tsx delete mode 100644 web/src/pages/DownloadClientCard.tsx delete mode 100644 web/src/pages/DownloadClientFormModal.tsx delete mode 100644 web/src/pages/DownloadClientsPage.tsx delete mode 100644 web/src/pages/DownloadTaskCard.tsx delete mode 100644 web/src/pages/DownloadsPage.tsx delete mode 100644 web/src/pages/DuplicatesPage.tsx create mode 100644 web/src/pages/LibrarySettingsPanel.tsx create mode 100644 web/src/pages/LibraryStorageStats.tsx delete mode 100644 web/src/pages/LicensePage.tsx delete mode 100644 web/src/pages/LicensePageSections.tsx delete mode 100644 web/src/pages/NotifyChannelCard.tsx delete mode 100644 web/src/pages/NotifyChannelConfigControls.tsx delete mode 100644 web/src/pages/NotifyChannelEventFields.tsx delete mode 100644 web/src/pages/NotifyChannelFormField.tsx delete mode 100644 web/src/pages/NotifyChannelFormModal.tsx delete mode 100644 web/src/pages/NotifyChannelProviderFields.tsx delete mode 100644 web/src/pages/NotifyChannelsPage.tsx delete mode 100644 web/src/pages/QRLoginPanel.tsx delete mode 100644 web/src/pages/RecycleBinPage.tsx delete mode 100644 web/src/pages/SchedulerPage.tsx delete mode 100644 web/src/pages/SearchExternalResults.tsx delete mode 100644 web/src/pages/SearchHeader.tsx delete mode 100644 web/src/pages/SearchInputBar.tsx delete mode 100644 web/src/pages/SearchLocalResults.tsx delete mode 100644 web/src/pages/SearchPage.tsx delete mode 100644 web/src/pages/SearchStatusPanels.tsx delete mode 100644 web/src/pages/SiteCard.tsx delete mode 100644 web/src/pages/SiteFormActions.tsx delete mode 100644 web/src/pages/SiteFormAdvancedOptions.tsx delete mode 100644 web/src/pages/SiteFormAuthFields.tsx delete mode 100644 web/src/pages/SiteFormBasicFields.tsx delete mode 100644 web/src/pages/SiteFormModal.tsx delete mode 100644 web/src/pages/SiteSearchPage.tsx delete mode 100644 web/src/pages/SitesPage.tsx delete mode 100644 web/src/pages/SitesPageSections.tsx delete mode 100644 web/src/pages/StatsPage.tsx delete mode 100644 web/src/pages/StatsPageSections.tsx delete mode 100644 web/src/pages/StorageConfigPage.tsx delete mode 100644 web/src/pages/StorageForm.tsx delete mode 100644 web/src/pages/StorageFormActions.tsx delete mode 100644 web/src/pages/StoragePage.tsx delete mode 100644 web/src/pages/StorageTransferSettings.tsx delete mode 100644 web/src/pages/StorageUploadPanel.tsx delete mode 100644 web/src/pages/StrmAttachSection.tsx delete mode 100644 web/src/pages/StrmGenerateSection.tsx delete mode 100644 web/src/pages/StrmGenerateSectionParts.tsx delete mode 100644 web/src/pages/StrmImportSection.tsx delete mode 100644 web/src/pages/StrmOutputDirPicker.tsx delete mode 100644 web/src/pages/StrmPage.tsx delete mode 100644 web/src/pages/StrmPageHeader.tsx delete mode 100644 web/src/pages/StrmRepairSection.tsx delete mode 100644 web/src/pages/StrmTreeGenerateSection.tsx delete mode 100644 web/src/pages/SubscriptionCard.tsx delete mode 100644 web/src/pages/SubscriptionForm.tsx delete mode 100644 web/src/pages/SubscriptionHistorySection.tsx delete mode 100644 web/src/pages/SubscriptionsPage.tsx delete mode 100644 web/src/pages/SystemUpdatePanel.tsx delete mode 100644 web/src/pages/TaskItemRetryDialog.tsx delete mode 100644 web/src/pages/TaskRuntimeTables.tsx delete mode 100644 web/src/pages/TasksPage.tsx delete mode 100644 web/src/pages/discoverDetailModalModel.ts delete mode 100644 web/src/pages/discoverPageModel.ts delete mode 100644 web/src/pages/downloadClientPageModel.ts delete mode 100644 web/src/pages/downloadTaskCardModel.ts delete mode 100644 web/src/pages/notifyChannelsModel.ts create mode 100644 web/src/pages/settingsGroupAPIConfigs.ts delete mode 100644 web/src/pages/settingsGroupCloud.ts create mode 100644 web/src/pages/settingsGroupLibrary.ts delete mode 100644 web/src/pages/settingsGroupSubscriptions.ts delete mode 100644 web/src/pages/settingsGroupSystemUpdate.ts delete mode 100644 web/src/pages/siteFormModalTypes.ts delete mode 100644 web/src/pages/sitesPageModel.ts delete mode 100644 web/src/pages/storageConfigModel.ts delete mode 100644 web/src/pages/strmPageModel.ts delete mode 100644 web/src/pages/strmPageUtils.ts delete mode 100644 web/src/pages/subscriptionFormModel.ts delete mode 100644 web/src/pages/subscriptionPageModel.ts delete mode 100644 web/src/pages/useAIAssistantPage.ts delete mode 100644 web/src/pages/useCloudBrowser.ts delete mode 100644 web/src/pages/useCloudBrowserFileActions.ts delete mode 100644 web/src/pages/useCloudBrowserMounts.ts delete mode 100644 web/src/pages/useCloudBrowserScan.ts delete mode 100644 web/src/pages/useSearchPage.ts delete mode 100644 web/src/pages/useStrmAttachForm.ts delete mode 100644 web/src/pages/useStrmGenerateForm.ts delete mode 100644 web/src/pages/useStrmImportForm.ts delete mode 100644 web/src/pages/useStrmPage.ts delete mode 100644 web/src/pages/useStrmRepairForm.ts delete mode 100644 web/src/pages/useStrmTreeGenerateForm.ts delete mode 100644 web/src/types/discover.ts delete mode 100644 web/src/types/scheduler.ts delete mode 100644 web/src/types/sites.ts delete mode 100644 web/src/types/stats.ts delete mode 100644 web/src/types/subscriptions.ts diff --git a/README.md b/README.md index ae588a3..7bc7e66 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# MediaStationGo +# MMTL (My Movie and TV Library)

- MediaStationGo Logo + MMTL Logo

适合 NAS、家庭共享和多端播放的私人媒体中心

diff --git a/README_EN.md b/README_EN.md index 36bea25..59f2c2f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,7 +1,7 @@ -# MediaStationGo +# MMTL (My Movie and TV Library)

- MediaStationGo Logo + MMTL Logo

A lightweight, polished, NAS-friendly private media center

diff --git a/cmd/server/main.go b/cmd/server/main.go index 5eafad8..4725a56 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,7 +26,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/database" - "github.com/ShukeBta/MediaStationGo/internal/handler" "github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -63,7 +62,7 @@ func main() { defer func() { _ = logger.Sync() }() appVersion := effectiveVersion(version) - logger.Info("starting MediaStationGo", + logger.Info("starting MMTL", zap.String("version", appVersion), zap.Int("port", cfg.App.Port), zap.String("data_dir", cfg.App.DataDir), @@ -95,12 +94,6 @@ func main() { applyCPUThreadLimit(cfg, logger) services := service.NewWithVersion(cfg, logger, repos, appVersion) - if repaired, err := services.RepairCloudPathMetadata(context.Background()); err != nil { - logger.Warn("cloud path metadata repair failed", zap.Error(err)) - } else if repaired > 0 { - logger.Info("cloud path metadata repair completed", zap.Int("media_count", repaired)) - } - // 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致 // 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。 if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil { @@ -143,8 +136,6 @@ func main() { } }() go services.Boot() - go handler.RunLicenseHeartbeatLoop(services.Context(), services) - go services.TelegramBot.StartPolling(context.Background()) // Graceful shutdown. stop := make(chan os.Signal, 1) @@ -158,5 +149,5 @@ func main() { logger.Error("graceful shutdown failed", zap.Error(err)) } services.Close() - logger.Info("MediaStationGo stopped") + logger.Info("MMTL stopped") } diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 4c3d6af..892fc22 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -4,7 +4,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -54,106 +53,6 @@ func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) { } } -func TestEnforceTelegramBindingOneToOneCleansDuplicatesAndAddsIndex(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.TelegramBinding{}); err != nil { - t.Fatal(err) - } - createdAt := time.Now().Add(-time.Hour) - rows := []model.TelegramBinding{ - {TelegramUserID: 10001, ChatID: 10001, UserID: "user-1"}, - {TelegramUserID: 10002, ChatID: 10002, UserID: "user-1"}, - } - for i := range rows { - rows[i].CreatedAt = createdAt.Add(time.Duration(i) * time.Minute) - if err := db.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - if err := enforceTelegramBindingOneToOne(db); err != nil { - t.Fatal(err) - } - - var count int64 - if err := db.Model(&model.TelegramBinding{}).Where("user_id = ?", "user-1").Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("active bindings for user-1 = %d, want 1", count) - } - var kept model.TelegramBinding - if err := db.First(&kept, "user_id = ?", "user-1").Error; err != nil { - t.Fatal(err) - } - if kept.TelegramUserID != 10002 { - t.Fatalf("kept telegram binding = %d, want newest 10002", kept.TelegramUserID) - } - if err := db.Create(&model.TelegramBinding{TelegramUserID: 10003, ChatID: 10003, UserID: "user-1"}).Error; err == nil { - t.Fatal("expected unique index to reject another active binding for the same user") - } -} - -func TestEnsureSubscriptionIdentityUniquenessArchivesDuplicatesAndAddsIndex(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Subscription{}); err != nil { - t.Fatal(err) - } - createdAt := time.Now().Add(-time.Hour) - rows := []model.Subscription{ - {UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50}, - {UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50}, - } - for i := range rows { - rows[i].CreatedAt = createdAt.Add(time.Duration(i) * time.Minute) - if err := db.Select("*").Omit("DeletedAt").Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - if err := ensureSubscriptionIdentityUniqueness(db); err != nil { - t.Fatal(err) - } - var active []model.Subscription - if err := db.Where("archived_at IS NULL").Order("created_at asc").Find(&active).Error; err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != rows[0].ID { - t.Fatalf("active subscriptions = %#v, want earliest row only", active) - } - var archived model.Subscription - if err := db.First(&archived, "id = ?", rows[1].ID).Error; err != nil { - t.Fatal(err) - } - if archived.ArchivedAt == nil || archived.ArchiveReason != duplicateSubscriptionMigrationReason { - t.Fatalf("duplicate was not archived by migration: %#v", archived) - } - - duplicate := rows[0] - duplicate.ID = "" - duplicate.CreatedAt = time.Time{} - duplicate.UpdatedAt = time.Time{} - model.RefreshSubscriptionIdentity(&duplicate) - if err := db.Select("*").Omit("DeletedAt").Create(&duplicate).Error; err == nil { - t.Fatal("expected active identity index to reject a duplicate rule") - } - different := rows[0] - different.ID = "" - different.CreatedAt = time.Time{} - different.UpdatedAt = time.Time{} - different.Resolution = "2160p" - model.RefreshSubscriptionIdentity(&different) - if err := db.Select("*").Omit("DeletedAt").Create(&different).Error; err != nil { - t.Fatalf("different rule should be allowed: %v", err) - } -} - func TestEnsurePerformanceIndexesCreatesHotPathIndexes(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index 0ed7db7..e58b799 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -14,12 +14,6 @@ func AutoMigrate(db *gorm.DB) error { if err := ensurePostgresColumnCompatibility(db); err != nil { return err } - if err := enforceTelegramBindingOneToOne(db); err != nil { - return err - } - if err := ensureSubscriptionIdentityUniqueness(db); err != nil { - return err - } if err := ensurePerformanceIndexes(db); err != nil { return err } @@ -44,7 +38,6 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error { `ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`, `ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`, `ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`, - `ALTER TABLE strm_records ALTER COLUMN media_id TYPE varchar(128)`, } for _, stmt := range statements { if err := db.Exec(stmt).Error; err != nil { @@ -84,39 +77,3 @@ func ensurePerformanceIndexes(db *gorm.DB) error { } return nil } - -func enforceTelegramBindingOneToOne(db *gorm.DB) error { - if !db.Migrator().HasTable(&model.TelegramBinding{}) { - return nil - } - return db.Transaction(func(tx *gorm.DB) error { - if err := tx.Exec(` -DELETE FROM telegram_bindings -WHERE deleted_at IS NULL - AND user_id IN ( - SELECT user_id - FROM telegram_bindings - WHERE deleted_at IS NULL - GROUP BY user_id - HAVING COUNT(*) > 1 - ) - AND id NOT IN ( - SELECT id - FROM ( - SELECT id, - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC, created_at DESC, id DESC) AS rn - FROM telegram_bindings - WHERE deleted_at IS NULL - ) AS ranked_bindings - WHERE rn = 1 - ) -`).Error; err != nil { - return err - } - return tx.Exec(` -CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_bindings_user_id_active -ON telegram_bindings(user_id) -WHERE deleted_at IS NULL -`).Error - }) -} diff --git a/internal/database/schema_subscription_identity.go b/internal/database/schema_subscription_identity.go deleted file mode 100644 index aa9c88e..0000000 --- a/internal/database/schema_subscription_identity.go +++ /dev/null @@ -1,54 +0,0 @@ -package database - -import ( - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const duplicateSubscriptionMigrationReason = "迁移合并重复订阅规则" - -func ensureSubscriptionIdentityUniqueness(db *gorm.DB) error { - if db == nil || !db.Migrator().HasTable(&model.Subscription{}) { - return nil - } - return db.Transaction(func(tx *gorm.DB) error { - var rows []model.Subscription - if err := tx.Unscoped().Order("created_at asc, id asc").Find(&rows).Error; err != nil { - return err - } - seen := make(map[string]string) - for i := range rows { - row := &rows[i] - key := model.RefreshSubscriptionIdentity(row) - updates := map[string]any{"identity_key": key} - if !row.DeletedAt.Valid && row.ArchivedAt == nil { - activeKey := row.UserID + "\x00" + key - if _, duplicate := seen[activeKey]; duplicate { - archivedAt := row.UpdatedAt - if archivedAt.IsZero() { - archivedAt = row.CreatedAt - } - if archivedAt.IsZero() { - archivedAt = time.Now() - } - updates["enabled"] = false - updates["archived_at"] = &archivedAt - updates["archive_reason"] = duplicateSubscriptionMigrationReason - } else { - seen[activeKey] = row.ID - } - } - if err := tx.Unscoped().Model(&model.Subscription{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { - return err - } - } - return tx.Exec(` -CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_user_identity_active -ON subscriptions(user_id, identity_key) -WHERE deleted_at IS NULL AND archived_at IS NULL AND identity_key <> '' -`).Error - }) -} diff --git a/internal/database/sqlite_migration_bootstrap.go b/internal/database/sqlite_migration_bootstrap.go index 8abf895..33db6f9 100644 --- a/internal/database/sqlite_migration_bootstrap.go +++ b/internal/database/sqlite_migration_bootstrap.go @@ -68,8 +68,6 @@ func targetLooksLikeBootstrapOnly(target *gorm.DB) (bool, error) { &model.Favorite{}, &model.Playlist{}, &model.PlaylistItem{}, - &model.DownloadTask{}, - &model.Subscription{}, } { if !target.Migrator().HasTable(m) { continue diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 8ceb57c..a1ed07c 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -43,7 +43,6 @@ func createUserHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - refreshLicenseCapacityBestEffort(c.Request.Context(), svc) u, _, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password) if err != nil { writeUserMutationError(c, svc, err) @@ -227,8 +226,7 @@ func writeUserMutationError(c *gin.Context, svc *service.Container, err error) { case errors.Is(err, service.ErrUsernameTaken): c.JSON(http.StatusConflict, gin.H{"error": "username already taken"}) case errors.Is(err, service.ErrUserLimitReached): - maxUsers := service.LicensedMaxUsers(c.Request.Context(), svc.Repo) - c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers}) + c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index af5f605..ed8afbb 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -59,9 +59,6 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" { svc.Transcoder.StopAll() } - if req.Key == "cloud.auto_sync_enabled" && !service.ParseBoolSetting(req.Value, false) && svc.Scan != nil { - _ = svc.Scan.CancelAllCloudScans() - } c.Status(http.StatusNoContent) } } diff --git a/internal/handler/ai.go b/internal/handler/ai.go deleted file mode 100644 index 67abab1..0000000 --- a/internal/handler/ai.go +++ /dev/null @@ -1,79 +0,0 @@ -// Package handler — AI integration endpoints. -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type smartSearchReq struct { - Query string `json:"query" binding:"required"` -} - -func smartSearchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req smartSearchReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - intent, err := svc.AI.SmartSearch(c.Request.Context(), req.Query) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - // Run the actual library search using the cleaned query so the - // caller can render local + external results in one round-trip. - items, _ := svc.Media.SearchMediaVisible(c.Request.Context(), intent.Query, 60, mediaVisibilityForRequest(c, svc)) - external := service.SearchExternalMedia( - c.Request.Context(), - intent.Query, - intent.Year, - intent.Type, - svc.TMDb, - svc.Douban, - svc.Bangumi, - ) - service.EnrichExternalMediaAvailability(c.Request.Context(), svc.Repo, external) - c.JSON(http.StatusOK, gin.H{ - "intent": intent, - "items": items, - "external_items": external, - }) - } -} - -func aiRecommendHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - hist, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), 10) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - titles := make([]string, 0, len(hist)) - visibility := mediaVisibilityForRequest(c, svc) - for _, h := range hist { - if h.Media != nil && visibility.Allows(h.Media) && strings.TrimSpace(h.Media.Title) != "" { - titles = append(titles, h.Media.Title) - } - } - out, err := svc.AI.Recommend(c.Request.Context(), titles, 8) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"titles": out}) - } -} - -func aiStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, svc.AI.Status(c.Request.Context())) - } -} diff --git a/internal/handler/assistant.go b/internal/handler/assistant.go deleted file mode 100644 index a59c6a1..0000000 --- a/internal/handler/assistant.go +++ /dev/null @@ -1,147 +0,0 @@ -// Package handler — multi-turn AI assistant chat endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listAssistantSessionsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - rows, err := svc.Assistant.ListSessions( - c.Request.Context(), toString(uid), role == "admin", - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, rows) - } -} - -type createSessionReq struct { - Title string `json:"title"` -} - -func createAssistantSessionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req createSessionReq - _ = c.ShouldBindJSON(&req) - uid, _ := c.Get(middleware.CtxUserID) - sess, err := svc.Assistant.CreateSession(c.Request.Context(), toString(uid), req.Title) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, sess) - } -} - -func getAssistantSessionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - view, err := svc.Assistant.GetSession( - c.Request.Context(), c.Param("id"), toString(uid), role == "admin", - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, view) - } -} - -func deleteAssistantSessionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - if err := svc.Assistant.DeleteSession( - c.Request.Context(), c.Param("id"), toString(uid), role == "admin", - ); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -type chatReq struct { - SessionID string `json:"session_id" binding:"required"` - Message string `json:"message" binding:"required"` -} - -func assistantChatHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req chatReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - view, err := svc.Assistant.Chat( - c.Request.Context(), req.SessionID, toString(uid), req.Message, role == "admin", - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, view) - } -} - -type executeReq struct { - SessionID string `json:"session_id" binding:"required"` - Action map[string]interface{} `json:"action" binding:"required"` -} - -func assistantExecuteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req executeReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - opID, err := svc.Assistant.Execute( - c.Request.Context(), req.SessionID, toString(uid), req.Action, - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"op_id": opID}) - } -} - -func assistantUndoHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Assistant.Undo(c.Request.Context(), c.Param("op_id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func assistantHistoryHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - rows, err := svc.Assistant.History( - c.Request.Context(), toString(uid), role == "admin", - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": rows}) - } -} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 19855db..41547c5 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -62,7 +62,6 @@ func registerHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - refreshLicenseCapacityBestEffort(c.Request.Context(), svc) u, tokens, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password) if err != nil { if errors.Is(err, service.ErrUsernameTaken) { diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go deleted file mode 100644 index 3c9ba7a..0000000 --- a/internal/handler/cloud.go +++ /dev/null @@ -1,160 +0,0 @@ -// Package handler — cloud-disk (网盘) endpoints: directory browsing, QR-code -// login, media import and 302 playback redirects. -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// cloudListHandler browses a configured cloud disk directory. -func cloudListHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider", "items": []any{}}) - return - } - dir := c.Query("dir") - entries, err := svc.StorageCfg.CloudList(c.Request.Context(), typ, dir) - if err != nil { - c.JSON(http.StatusOK, gin.H{"error": err.Error(), "items": []any{}}) - return - } - c.JSON(http.StatusOK, gin.H{"items": entries}) - } -} - -func cloudMkdirHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Dir string `json:"dir"` - Name string `json:"name" binding:"required"` - } - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - entry, err := svc.StorageCfg.CloudMkdir(c.Request.Context(), typ, in.Dir, in.Name) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"entry": entry}) - } -} - -func cloudRenameHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Ref string `json:"ref" binding:"required"` - Name string `json:"name" binding:"required"` - } - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - entry, err := svc.StorageCfg.CloudRename(c.Request.Context(), typ, in.Ref, in.Name) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"entry": entry}) - } -} - -// cloudImportHandler turns a cloud file into a playable 302-backed media item. -func cloudImportHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Ref string `json:"ref" binding:"required"` - Name string `json:"name"` - Size int64 `json:"size"` - } - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - m, err := svc.StorageCfg.CloudImport(c.Request.Context(), typ, in.Ref, in.Name, in.Size) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, m) - } -} - -func cloudScanAllHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if svc.Scan == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"}) - return - } - statuses, err := svc.Scan.StartAllCloudLibraryScans() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusAccepted, gin.H{ - "items": statuses, - "scan_queued": true, - "message": "已开始扫描所有启用的网盘媒体库", - "resume_message": "中断后再次点击扫描会重新遍历,但已入库媒体会去重更新,只补齐缺失项。", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } -} - -func cloudScanCancelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if svc.Scan == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"}) - return - } - libraryID := strings.TrimSpace(c.Query("library_id")) - provider := strings.TrimSpace(c.Query("provider")) - cancelled := 0 - if libraryID != "" { - if svc.Scan.CancelCloudScan(libraryID) { - cancelled = 1 - } - } else if provider != "" { - cancelled = svc.Scan.CancelCloudScansForProvider(provider) - } else { - cancelled = svc.Scan.CancelAllCloudScans() - } - c.JSON(http.StatusOK, gin.H{ - "cancelled": cancelled, - "message": "已发送中断信号;正在等待当前网盘请求返回后停止", - }) - } -} - -func cloudScanStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if svc.Scan == nil { - c.JSON(http.StatusOK, gin.H{"items": []service.CloudScanStatus{}}) - return - } - c.JSON(http.StatusOK, gin.H{"items": svc.Scan.CloudScanStatuses()}) - } -} diff --git a/internal/handler/cloud_mount.go b/internal/handler/cloud_mount.go deleted file mode 100644 index be4d8a6..0000000 --- a/internal/handler/cloud_mount.go +++ /dev/null @@ -1,149 +0,0 @@ -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// cloudMountHandler creates or reuses a cloud:// media library for a cloud -// directory, then queues a recursive import scan. The scan runs outside the -// request so large 115/OpenList folders do not make the UI report a timeout. -func cloudMountHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Dir string `json:"dir"` - DirPath string `json:"dir_path"` - Name string `json:"name"` - MediaType string `json:"media_type"` - } - _ = c.ShouldBindJSON(&in) - if !cloud.IsCloudType(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - if _, err := svc.StorageCfg.CloudProvider(c.Request.Context(), typ); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - path := service.BuildCloudLibraryPath(typ, in.Dir, in.DirPath) - name := strings.TrimSpace(in.Name) - if name == "" { - name = cloudMountLibraryName(typ, strings.TrimSpace(in.Dir), strings.TrimSpace(in.DirPath)) - } - mediaType := strings.TrimSpace(in.MediaType) - if mediaType == "" || strings.EqualFold(mediaType, "auto") { - displayDir := strings.TrimSpace(in.DirPath) - if displayDir == "" { - displayDir = strings.TrimSpace(in.Dir) - } - mediaType = service.InferCloudMountMediaType(displayDir, name) - } - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - var lib *model.Library - alreadyMounted := false - if conflict := service.FindCloudMountConflict(libs, typ, in.Dir, in.DirPath); conflict != nil { - lib = &conflict.Library - alreadyMounted = conflict.Exact - if conflict.Nested { - c.JSON(http.StatusOK, gin.H{ - "library": lib, - "skipped": true, - "reason": "cloud mount overlaps an existing mounted parent/child directory", - "conflict_library": conflict.Library, - }) - return - } - } - if lib == nil { - lib = &model.Library{Name: name, Path: path, Type: mediaType, Enabled: true} - if err := svc.Repo.Library.Create(c.Request.Context(), lib); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } else if alreadyMounted { - updates := map[string]any{} - if path != "" && path != lib.Path { - updates["path"] = path - lib.Path = path - } - if mediaType != "" && mediaType != lib.Type { - updates["type"] = mediaType - lib.Type = mediaType - } - currentDisplayName, _ := service.CloudLibraryDisplayName(*lib) - if name != "" && name != lib.Name && (currentDisplayName == "" || currentDisplayName != name || strings.Contains(lib.Name, " · ")) { - updates["name"] = name - lib.Name = name - } - if len(updates) > 0 { - if err := svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Library{}).Where("id = ?", lib.ID).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - } - if svc.Scan != nil { - libID := lib.ID - if svc.WSHub != nil { - svc.WSHub.Publish("scan", gin.H{ - "library_id": libID, - "cloud": true, - "queued": true, - "stage": "queued", - "message": "云盘扫描已加入后台队列,会递归扫描并自动加入媒体库", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } - _, _, _ = svc.Scan.StartCloudLibraryScan(libID, false) - } - c.JSON(http.StatusAccepted, gin.H{ - "library": lib, - "already_mounted": alreadyMounted, - "scan_queued": svc.Scan != nil, - "message": "挂载后会后台递归扫描,发现的媒体会自动加入当前媒体库", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } -} - -func cloudMountLibraryName(typ, dir, displayDir string) string { - base := service.CloudMountProviderLabel(typ) - displayDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(displayDir, "\\", "/")), "/") - if displayDir != "" { - parts := strings.Split(displayDir, "/") - for i := len(parts) - 1; i >= 0; i-- { - if part := strings.TrimSpace(parts[i]); part != "" { - return part - } - } - } - if dir == "" || dir == "0" { - return base - } - dir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(dir, "\\", "/")), "/") - if dir == "" { - return base - } - parts := strings.Split(dir, "/") - for i := len(parts) - 1; i >= 0; i-- { - if part := strings.TrimSpace(parts[i]); part != "" { - return part - } - } - return base -} diff --git a/internal/handler/cloud_playback.go b/internal/handler/cloud_playback.go deleted file mode 100644 index ad833f1..0000000 --- a/internal/handler/cloud_playback.go +++ /dev/null @@ -1,284 +0,0 @@ -package handler - -import ( - "crypto/sha256" - "encoding/hex" - "io" - "net/http" - "net/url" - "path" - "sort" - "strings" - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudPlaybackRequest struct { - svc *service.Container - c *gin.Context - typ string - ref string - link *cloud.DirectLink - resolveStart time.Time - resolveDur time.Duration -} - -// cloudPlayHandler resolves a cloud file to its direct link and either issues a -// 302 redirect (true offload — host does not stream the bytes) or, when the -// provider requires authenticated headers, reverse-proxies the response. -func cloudPlayHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - ref := c.Query("ref") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - if ref == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"}) - return - } - if !enforceScopedCloudPlaybackToken(c, svc, typ, ref) { - return - } - serveCloudResolvedLink(svc, c, typ, ref) - } -} - -func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref string) { - if isCloudImageRef(ref) && svc != nil && svc.ImageProxy != nil { - if svc.ImageProxy.ServeCloudCached(c.Writer, c.Request, typ+":"+ref) { - return - } - } - if svc == nil || svc.StorageCfg == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"}) - return - } - resolveStart := time.Now() - link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent()) - resolveDur := time.Since(resolveStart) - if err != nil { - logCloudPlayback(svc, "cloud playback resolve failed", - append(cloudPlaybackLogFields(typ, ref, nil, resolveDur), zap.Error(err))...) - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - if isCloudImageRef(ref) && svc.ImageProxy != nil { - if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, typ+":"+ref, link); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - } - return - } - if isCloudImageRef(ref) { - c.Header("Cache-Control", "public, max-age=2592000, immutable") - } - if !link.Proxy { - // Pure offload: send the client straight to the cloud CDN. - setRedirectNoStoreHeaders(c) - logCloudPlayback(svc, "cloud playback redirect", - append(cloudPlaybackLogFields(typ, ref, link, resolveDur), - zap.String("mode", "redirect"), - zap.Int("status", http.StatusFound), - zap.String("method", c.Request.Method), - zap.String("range", c.GetHeader("Range")), - )...) - c.Redirect(http.StatusFound, link.URL) - return - } - proxyCloudResolvedLink(cloudPlaybackRequest{ - svc: svc, - c: c, - typ: typ, - ref: ref, - link: link, - resolveStart: resolveStart, - resolveDur: resolveDur, - }) -} - -func proxyCloudResolvedLink(playback cloudPlaybackRequest) { - c := playback.c - clientMethod := playback.c.Request.Method - if clientMethod == "" { - clientMethod = http.MethodGet - } - upstreamMethod := clientMethod - req, err := http.NewRequestWithContext(c.Request.Context(), upstreamMethod, playback.link.URL, nil) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - for k, v := range playback.link.Headers { - req.Header.Set(k, v) - } - if rng := c.GetHeader("Range"); rng != "" { - req.Header.Set("Range", rng) - } - if accept := c.GetHeader("Accept"); accept != "" { - req.Header.Set("Accept", accept) - } - if c.GetHeader("Accept-Encoding") == "" { - req.Header.Set("Accept-Encoding", "identity") - } - upstreamStart := time.Now() - resp, err := http.DefaultClient.Do(req) - upstreamHeaderDur := time.Since(upstreamStart) - if err != nil { - logCloudPlayback(playback.svc, "cloud playback proxy upstream failed", - append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur), - zap.String("mode", "proxy"), - zap.String("method", clientMethod), - zap.String("upstream_method", upstreamMethod), - zap.String("range", c.GetHeader("Range")), - zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)), - zap.Error(err), - )...) - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - defer resp.Body.Close() - for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} { - if v := resp.Header.Get(h); v != "" { - c.Header(h, v) - } - } - if c.Writer.Header().Get("Accept-Ranges") == "" { - c.Header("Accept-Ranges", "bytes") - } - if resp.StatusCode >= 400 { - handleCloudProxyError(playback, req, resp, clientMethod, upstreamMethod, upstreamHeaderDur) - return - } - streamCloudProxyResponse(playback, req, resp, clientMethod, upstreamMethod, upstreamHeaderDur) -} - -func handleCloudProxyError(playback cloudPlaybackRequest, req *http.Request, resp *http.Response, clientMethod, upstreamMethod string, upstreamHeaderDur time.Duration) { - c := playback.c - c.Header("Cache-Control", "no-store") - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - fields := append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur), - zap.String("mode", "proxy"), - zap.String("method", clientMethod), - zap.String("upstream_method", upstreamMethod), - zap.String("range", c.GetHeader("Range")), - zap.String("upstream_range", req.Header.Get("Range")), - zap.Int("status", resp.StatusCode), - zap.String("content_range", resp.Header.Get("Content-Range")), - zap.String("content_length", resp.Header.Get("Content-Length")), - zap.String("upstream_error_body", strings.TrimSpace(string(body))), - zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)), - zap.Int64("total_ms", durationMilliseconds(time.Since(playback.resolveStart))), - ) - logCloudPlayback(playback.svc, "cloud playback proxy upstream returned error", fields...) - c.Status(resp.StatusCode) - if clientMethod != http.MethodHead && len(body) > 0 { - _, _ = c.Writer.Write(body) - } -} - -func streamCloudProxyResponse(playback cloudPlaybackRequest, req *http.Request, resp *http.Response, clientMethod, upstreamMethod string, upstreamHeaderDur time.Duration) { - c := playback.c - c.Status(resp.StatusCode) - var copied int64 - var copyErr error - streamStart := time.Now() - if c.Request.Method != http.MethodHead { - copied, copyErr = io.Copy(c.Writer, resp.Body) - } - fields := append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur), - zap.String("mode", "proxy"), - zap.String("method", clientMethod), - zap.String("upstream_method", upstreamMethod), - zap.String("range", c.GetHeader("Range")), - zap.String("upstream_range", req.Header.Get("Range")), - zap.Int("status", resp.StatusCode), - zap.String("content_range", resp.Header.Get("Content-Range")), - zap.String("content_length", resp.Header.Get("Content-Length")), - zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)), - zap.Int64("stream_ms", durationMilliseconds(time.Since(streamStart))), - zap.Int64("total_ms", durationMilliseconds(time.Since(playback.resolveStart))), - zap.Int64("bytes", copied), - ) - if copyErr != nil { - logCloudPlayback(playback.svc, "cloud playback proxy copy failed", append(fields, zap.Error(copyErr))...) - return - } - logCloudPlayback(playback.svc, "cloud playback proxy finished", fields...) -} - -func isCloudImageRef(ref string) bool { - ref = strings.ToLower(strings.TrimSpace(ref)) - for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} { - if strings.HasSuffix(ref, suffix) { - return true - } - } - return false -} - -func logCloudPlayback(svc *service.Container, msg string, fields ...zap.Field) { - if svc == nil || svc.Log == nil { - return - } - svc.Log.Info(msg, fields...) -} - -func cloudPlaybackLogFields(typ, ref string, link *cloud.DirectLink, resolveDur time.Duration) []zap.Field { - refHash, refExt := cloudPlaybackRefFingerprint(ref) - fields := []zap.Field{ - zap.String("provider", strings.TrimSpace(typ)), - zap.String("ref_hash", refHash), - zap.String("ref_ext", refExt), - zap.Int64("resolve_ms", durationMilliseconds(resolveDur)), - } - if link != nil { - fields = append(fields, - zap.String("target_host", cloudPlaybackLinkHost(link.URL)), - zap.Bool("headers_required", len(link.Headers) > 0), - zap.Strings("header_names", cloudPlaybackHeaderNames(link.Headers)), - ) - } - return fields -} - -func cloudPlaybackRefFingerprint(ref string) (string, string) { - ref = strings.TrimSpace(ref) - sum := sha256.Sum256([]byte(ref)) - ext := strings.ToLower(path.Ext(strings.Trim(strings.ReplaceAll(ref, "\\", "/"), "/"))) - return hex.EncodeToString(sum[:])[:12], ext -} - -func cloudPlaybackLinkHost(raw string) string { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || u.Host == "" { - return "" - } - return u.Host -} - -func cloudPlaybackHeaderNames(headers map[string]string) []string { - if len(headers) == 0 { - return nil - } - out := make([]string, 0, len(headers)) - for key := range headers { - if key = strings.TrimSpace(key); key != "" { - out = append(out, key) - } - } - sort.Strings(out) - return out -} - -func durationMilliseconds(d time.Duration) int64 { - if d <= 0 { - return 0 - } - return d.Milliseconds() -} diff --git a/internal/handler/cloud_playback_test.go b/internal/handler/cloud_playback_test.go deleted file mode 100644 index d8c4a48..0000000 --- a/internal/handler/cloud_playback_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package handler - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func TestProxyCloudResolvedLinkUsesHEADWithoutSyntheticRange(t *testing.T) { - gin.SetMode(gin.TestMode) - var upstreamMethod, upstreamRange string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamMethod = r.Method - upstreamRange = r.Header.Get("Range") - w.Header().Set("Content-Type", "video/mp4") - w.Header().Set("Content-Length", "123456") - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - })) - defer upstream.Close() - - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodHead, "/api/cloud/play/openlist?ref=movie", nil) - - proxyCloudResolvedLink(cloudPlaybackRequest{ - c: c, - typ: "openlist", - ref: "movie", - link: &cloud.DirectLink{ - URL: upstream.URL + "/movie.mp4", - Proxy: true, - }, - }) - - if upstreamMethod != http.MethodHead { - t.Fatalf("upstream method = %q, want HEAD", upstreamMethod) - } - if upstreamRange != "" { - t.Fatalf("upstream Range = %q, want empty", upstreamRange) - } - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Content-Length"); got != "123456" { - t.Fatalf("Content-Length = %q, want full upstream length", got) - } - if rec.Body.Len() != 0 { - t.Fatalf("HEAD response body length = %d, want 0", rec.Body.Len()) - } -} diff --git a/internal/handler/cloud_qr.go b/internal/handler/cloud_qr.go deleted file mode 100644 index f04013d..0000000 --- a/internal/handler/cloud_qr.go +++ /dev/null @@ -1,49 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// cloud115QRStartHandler begins a 115 QR-code login and returns the session + -// QR image URL for the frontend to render. -func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if c.Param("type") != cloud.Type115 { - c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"}) - return - } - sess, err := cloud.QRStart(c.Request.Context(), nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, sess) - } -} - -// cloud115QRPollHandler polls a 115 QR session; on confirmation it returns the -// session cookie so the frontend can save it as the storage credential. -func cloud115QRPollHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if c.Param("type") != cloud.Type115 { - c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"}) - return - } - var sess cloud.QRSession - if err := c.ShouldBindJSON(&sess); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - st, err := cloud.QRPoll(c.Request.Context(), nil, &sess) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, st) - } -} diff --git a/internal/handler/cloud_test.go b/internal/handler/cloud_test.go deleted file mode 100644 index c749116..0000000 --- a/internal/handler/cloud_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package handler - -import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -var handlerTestJPEG = []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0xff, 0xd9} - -func TestCloudMountLibraryNameDefaultsToDirectoryBaseName(t *testing.T) { - tests := []struct { - name string - provider string - dir string - displayDir string - want string - }{ - {name: "openlist directory", provider: "openlist", dir: "/国产剧", displayDir: "/国产剧", want: "国产剧"}, - {name: "nested directory", provider: "openlist", dir: "id-123", displayDir: "剧集/国产剧", want: "国产剧"}, - {name: "provider root", provider: "openlist", dir: "", displayDir: "", want: "OpenList"}, - {name: "115 root id", provider: "cloud115", dir: "0", displayDir: "", want: "115 网盘"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := cloudMountLibraryName(tt.provider, tt.dir, tt.displayDir); got != tt.want { - t.Fatalf("cloudMountLibraryName() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestCloudPlaybackDiagnosticsDoNotExposeRawRefOrURL(t *testing.T) { - rawRef := "/剧集/国产剧/很长的敏感文件名.S01E01.mkv" - refHash, refExt := cloudPlaybackRefFingerprint(rawRef) - if refHash == "" || strings.Contains(rawRef, refHash) { - t.Fatalf("ref hash should be a short fingerprint, got %q", refHash) - } - if refExt != ".mkv" { - t.Fatalf("ref ext = %q, want .mkv", refExt) - } - if host := cloudPlaybackLinkHost("https://cdn.example.test/movie.mkv?token=secret"); host != "cdn.example.test" { - t.Fatalf("host = %q, want cdn.example.test", host) - } - names := cloudPlaybackHeaderNames(map[string]string{ - "Authorization": "Bearer secret", - "Cookie": "sid=secret", - }) - if got := strings.Join(names, ","); got != "Authorization,Cookie" { - t.Fatalf("header names = %q", got) - } -} - -func TestAdminCloudHandlersRejectQuarkBrowsing(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - router.GET("/admin/cloud/:type/list", cloudListHandler(nil)) - - req := httptest.NewRequest(http.MethodGet, "/admin/cloud/quark/list?dir=0", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "unsupported cloud provider") { - t.Fatalf("body = %s, want unsupported cloud provider", w.Body.String()) - } -} - -func TestCloudPlayRejectsQuarkProvider(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - router.GET("/api/cloud/play/:type", cloudPlayHandler(nil)) - - req := httptest.NewRequest(http.MethodGet, "/api/cloud/play/quark?ref=file-1", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "unsupported cloud provider") { - t.Fatalf("body = %s, want unsupported cloud provider", w.Body.String()) - } -} - -func TestCloudArtworkProxyServesCachedImageWithoutCloudResolve(t *testing.T) { - gin.SetMode(gin.TestMode) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(handlerTestJPEG) - })) - defer upstream.Close() - - imageProxy := service.NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, zap.NewNop()) - stableKey := "openlist:/Anime/JianLai/poster.jpg" - if err := imageProxy.PrefetchCloudResolved(t.Context(), stableKey, &cloud.DirectLink{URL: upstream.URL + "/poster.jpg"}); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.GET("/api/img/cloud/:type", cloudArtworkProxyHandler(&service.Container{ImageProxy: imageProxy})) - - req := httptest.NewRequest(http.MethodGet, "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String()) - } - if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) { - t.Fatalf("body = %q, want cached poster", got) - } - if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") { - t.Fatalf("cache-control = %q, want long static cache", got) - } -} - -func TestCloudArtworkProxyAcceptsCachedTBNImage(t *testing.T) { - gin.SetMode(gin.TestMode) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(handlerTestJPEG) - })) - defer upstream.Close() - - imageProxy := service.NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, zap.NewNop()) - stableKey := "openlist:/Movies/Movie.tbn" - if err := imageProxy.PrefetchCloudResolved(t.Context(), stableKey, &cloud.DirectLink{URL: upstream.URL + "/Movie.tbn"}); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.GET("/api/img/cloud/:type", cloudArtworkProxyHandler(&service.Container{ImageProxy: imageProxy})) - - req := httptest.NewRequest(http.MethodGet, "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.tbn", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String()) - } - if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) { - t.Fatalf("body = %q, want cached tbn poster", got) - } -} diff --git a/internal/handler/discover.go b/internal/handler/discover.go deleted file mode 100644 index 89b83b1..0000000 --- a/internal/handler/discover.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package handler — TMDb discovery endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// trendingHandler 返回 TMDb 当日热门列表。 -// -// 当本机无法连接 TMDb(GFW / 代理未配 / API key 无效)时,TMDb 调用会 -// 在 15 秒后超时;这种情况下不应该让首页显示 500 错误,而是把空列表 -// 直接返回——前端按 items.length === 0 渲染"暂无推荐"即可。 -func trendingHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Discover.Trending(c.Request.Context()) - if err != nil { - svc.Log.Warn("discover trending failed (returning empty list)", zap.Error(err)) - c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()}) - return - } - if items == nil { - items = []service.Match{} - } - svc.Discover.WarmMatchArtwork(items) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func popularHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Discover.Popular(c.Request.Context()) - if err != nil { - svc.Log.Warn("discover popular failed (returning empty list)", zap.Error(err)) - c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()}) - return - } - if items == nil { - items = []service.Match{} - } - svc.Discover.WarmMatchArtwork(items) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go deleted file mode 100644 index aa5b017..0000000 --- a/internal/handler/discover_extra.go +++ /dev/null @@ -1,338 +0,0 @@ -// Package handler — multi-section discover endpoints. -// -// The Vue DiscoverView paginates a configurable list of "sections" -// (trending day/week, popular movies, top rated, etc.) and asks the -// backend for a feed keyed by section name. We mirror that surface so -// the React DiscoverPage can render the same rails without a rewrite. -package handler - -import ( - "context" - "errors" - "net/http" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type discoverSectionDef struct { - Key string - Label string - Provider string -} - -var discoverSectionCatalog = []discoverSectionDef{ - {Key: "tmdb_trending_day", Label: "TMDb 今日趋势", Provider: "tmdb"}, - {Key: "tmdb_trending_week", Label: "TMDb 本周热门", Provider: "tmdb"}, - {Key: "tmdb_latest_movie", Label: "TMDb 最新电影", Provider: "tmdb"}, - {Key: "tmdb_latest_tv", Label: "TMDb 最新剧集", Provider: "tmdb"}, - {Key: "tmdb_popular_movie", Label: "TMDb 热门电影", Provider: "tmdb"}, - {Key: "tmdb_popular_tv", Label: "TMDb 热门剧集", Provider: "tmdb"}, - {Key: "tmdb_top_rated_movie", Label: "TMDb 高分电影", Provider: "tmdb"}, - {Key: "tmdb_upcoming_movie", Label: "TMDb 即将上映", Provider: "tmdb"}, - {Key: "douban_hot_movie", Label: "豆瓣热门电影", Provider: "douban"}, - {Key: "douban_hot_tv", Label: "豆瓣热门剧集", Provider: "douban"}, - {Key: "douban_top_movie", Label: "豆瓣高分电影", Provider: "douban"}, - {Key: "bangumi_calendar", Label: "Bangumi 每日放送", Provider: "bangumi"}, -} - -const discoverFeedSectionTimeout = 20 * time.Second -const discoverFeedBangumiTimeout = 30 * time.Second -const discoverFeedSlowSectionThreshold = 2 * time.Second - -// discoverSectionsHandler returns the catalog of sections the UI can -// pick from. The names match the upstream Vue UI so existing settings -// keep working. -func discoverSectionsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - sections := make([]gin.H, 0, len(discoverSectionCatalog)) - for _, section := range enabledDiscoverSections(c.Request.Context(), svc) { - sections = append(sections, gin.H{"key": section.Key, "label": section.Label, "provider": section.Provider}) - } - c.JSON(http.StatusOK, gin.H{"sections": sections}) - } -} - -// discoverFeedHandler resolves one or more section keys (?sections=a,b) -// to TMDb / Douban / Bangumi rails and returns the joined results keyed by -// section name. Unknown keys are silently dropped so URL typos don't break -// the page. -func discoverFeedHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rawSections := c.Query("sections") - if strings.TrimSpace(rawSections) == "" { - rawSections = strings.Join(defaultDiscoverSectionKeys(c.Request.Context(), svc), ",") - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - keys := strings.Split(rawSections, ",") - out := gin.H{} - meta := gin.H{} - artworkItems := []service.ExternalMediaResult{} - for _, raw := range keys { - k := strings.TrimSpace(raw) - if k == "" { - continue - } - if provider := discoverSectionProvider(k); provider != "" && !discoverProviderEnabled(c.Request.Context(), svc, provider) { - out[k] = []service.ExternalMediaResult{} - meta[k] = gin.H{"page": page, "has_next": false, "disabled": true} - continue - } - sectionTimeout := discoverSectionTimeout(k) - sectionCtx, cancel := context.WithTimeout(c.Request.Context(), sectionTimeout) - started := time.Now() - items, err := discoverSectionItems(sectionCtx, svc, k, page) - elapsed := time.Since(started) - cancel() - metaEntry := gin.H{"page": page, "has_next": false, "duration_ms": elapsed.Milliseconds()} - if err != nil { - logDiscoverFetchFailed(svc, k, page, elapsed, sectionTimeout, err) - if cached, ok := cachedDiscoverSection(svc, k, page); ok { - items = cached - metaEntry["stale"] = true - metaEntry["warning"] = discoverFeedStaleMessage(err) - } else if fallbackItems, fallbackKey, ok := fallbackDiscoverSectionItems(c.Request.Context(), svc, k, page); ok { - items = fallbackItems - metaEntry["fallback"] = fallbackKey - metaEntry["warning"] = discoverFeedFallbackMessage(fallbackKey, err) - rememberDiscoverSection(svc, k, page, items) - } else { - metaEntry["error"] = discoverFeedErrorMessage(err) - items = nil - } - } else { - logDiscoverFetchSlow(svc, k, page, elapsed, len(items)) - rememberDiscoverSection(svc, k, page, items) - } - artworkItems = append(artworkItems, items...) - out[k] = items - metaEntry["has_next"] = discoverSectionHasNext(k, len(items)) - meta[k] = metaEntry - } - out["_meta"] = meta - if svc != nil && svc.Discover != nil { - svc.Discover.WarmExternalArtwork(artworkItems) - } - c.JSON(http.StatusOK, out) - } -} - -func cachedDiscoverSection(svc *service.Container, key string, page int) ([]service.ExternalMediaResult, bool) { - if svc == nil || svc.Discover == nil { - return nil, false - } - return svc.Discover.CachedSection(key, page) -} - -func rememberDiscoverSection(svc *service.Container, key string, page int, items []service.ExternalMediaResult) { - if svc == nil || svc.Discover == nil { - return - } - svc.Discover.RememberSection(key, page, items) -} - -func fallbackDiscoverSectionItems(parent context.Context, svc *service.Container, key string, page int) ([]service.ExternalMediaResult, string, bool) { - fallbackKey := fallbackDiscoverSectionKey(key) - if fallbackKey == "" || svc == nil || svc.Discover == nil { - return nil, "", false - } - ctx, cancel := context.WithTimeout(parent, discoverSectionTimeout(fallbackKey)) - defer cancel() - items, err := discoverSectionItems(ctx, svc, fallbackKey, page) - if err != nil || len(items) == 0 { - return nil, fallbackKey, false - } - if svc.Log != nil { - svc.Log.Info("discover section fallback used", - zap.String("section", key), - zap.String("fallback_section", fallbackKey), - zap.Int("page", page), - zap.Int("items", len(items))) - } - return items, fallbackKey, true -} - -func fallbackDiscoverSectionKey(key string) string { - switch key { - case "douban_hot_movie": - return "tmdb_popular_movie" - case "douban_hot_tv": - return "tmdb_popular_tv" - case "douban_top_movie": - return "tmdb_top_rated_movie" - default: - return "" - } -} - -func logDiscoverFetchFailed(svc *service.Container, key string, page int, elapsed, timeout time.Duration, err error) { - if svc == nil || svc.Log == nil || err == nil { - return - } - svc.Log.Warn("discover section fetch failed", - zap.String("section", key), - zap.String("provider", discoverSectionProvider(key)), - zap.Int("page", page), - zap.Duration("duration", elapsed), - zap.Int64("duration_ms", elapsed.Milliseconds()), - zap.Duration("timeout", timeout), - zap.Error(err)) -} - -func logDiscoverFetchSlow(svc *service.Container, key string, page int, elapsed time.Duration, itemCount int) { - if svc == nil || svc.Log == nil || elapsed < discoverFeedSlowSectionThreshold { - return - } - svc.Log.Info("discover section fetch slow", - zap.String("section", key), - zap.String("provider", discoverSectionProvider(key)), - zap.Int("page", page), - zap.Int("items", itemCount), - zap.Duration("duration", elapsed), - zap.Int64("duration_ms", elapsed.Milliseconds()), - zap.Duration("slow_threshold", discoverFeedSlowSectionThreshold)) -} - -func discoverFeedErrorMessage(err error) string { - if err == nil { - return "" - } - if errors.Is(err, context.DeadlineExceeded) { - return "推荐源响应超时,已跳过本次加载" - } - var timeout interface{ Timeout() bool } - if errors.As(err, &timeout) && timeout.Timeout() { - return "推荐源响应超时,已跳过本次加载" - } - msg := strings.ToLower(err.Error()) - if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "context deadline exceeded") { - return "推荐源响应超时,已跳过本次加载" - } - return "推荐源暂时不可用,已跳过本次加载" -} - -func discoverFeedStaleMessage(err error) string { - if discoverFeedErrorMessage(err) == "推荐源响应超时,已跳过本次加载" { - return "推荐源响应超时,已显示上次成功结果" - } - return "推荐源暂时不可用,已显示上次成功结果" -} - -func discoverFeedFallbackMessage(fallbackKey string, err error) string { - if strings.TrimSpace(fallbackKey) == "" { - return discoverFeedErrorMessage(err) - } - return "推荐源暂时不可用,已显示同类备用榜单" -} - -func discoverSectionTimeout(key string) time.Duration { - if key == "bangumi_calendar" { - return discoverFeedBangumiTimeout - } - return discoverFeedSectionTimeout -} - -func enabledDiscoverSections(ctx context.Context, svc *service.Container) []discoverSectionDef { - sections := make([]discoverSectionDef, 0, len(discoverSectionCatalog)) - for _, section := range discoverSectionCatalog { - if !discoverProviderEnabled(ctx, svc, section.Provider) { - continue - } - sections = append(sections, section) - } - return sections -} - -func defaultDiscoverSectionKeys(ctx context.Context, svc *service.Container) []string { - preferred := []string{"tmdb_trending_day", "tmdb_latest_movie", "tmdb_latest_tv", "douban_hot_movie", "douban_hot_tv", "bangumi_calendar"} - enabled := map[string]struct{}{} - for _, section := range enabledDiscoverSections(ctx, svc) { - enabled[section.Key] = struct{}{} - } - out := make([]string, 0, len(preferred)) - for _, key := range preferred { - if _, ok := enabled[key]; ok { - out = append(out, key) - } - } - if len(out) > 0 { - return out - } - for _, section := range enabledDiscoverSections(ctx, svc) { - out = append(out, section.Key) - if len(out) >= 4 { - break - } - } - return out -} - -func discoverSectionProvider(key string) string { - for _, section := range discoverSectionCatalog { - if section.Key == key { - return section.Provider - } - } - switch key { - case "trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": - return "tmdb" - default: - return "" - } -} - -func discoverProviderEnabled(ctx context.Context, svc *service.Container, provider string) bool { - if svc == nil || svc.APIConfig == nil || strings.TrimSpace(provider) == "" { - return true - } - cfg, err := svc.APIConfig.Get(ctx, provider) - if err != nil || cfg == nil { - return true - } - return cfg.Enabled -} - -func discoverSectionItems(ctx context.Context, svc *service.Container, k string, page int) ([]service.ExternalMediaResult, error) { - switch k { - case "tmdb_trending_day", "tmdb_trending_week", "tmdb_latest_movie", "tmdb_latest_tv", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie", "tmdb_upcoming_movie", - "trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": - return svc.Discover.TMDbSection(ctx, k, page) - case "douban_hot_movie", "douban_hot_tv", "douban_top_movie": - if svc.Douban == nil { - return []service.ExternalMediaResult{}, nil - } - return svc.Douban.Discover(ctx, k, page) - case "bangumi_calendar": - if svc.Bangumi == nil { - return []service.ExternalMediaResult{}, nil - } - if page > 1 { - return []service.ExternalMediaResult{}, nil - } - return svc.Bangumi.Calendar(ctx) - default: - return []service.ExternalMediaResult{}, nil - } -} - -func discoverSectionHasNext(key string, itemCount int) bool { - if itemCount <= 0 { - return false - } - switch discoverSectionProvider(key) { - case "tmdb": - return itemCount >= 20 - case "douban": - return itemCount >= 24 - default: - return false - } -} diff --git a/internal/handler/discover_extra_test.go b/internal/handler/discover_extra_test.go deleted file mode 100644 index b5ec25f..0000000 --- a/internal/handler/discover_extra_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package handler - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "go.uber.org/zap/zaptest/observer" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestDiscoverProviderEnabledHonorsAPIConfigToggle(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.APIConfig{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - apiConfig := service.NewAPIConfigService(zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())) - enabled := false - if _, err := apiConfig.Update(t.Context(), "douban", service.APIConfigPatch{Enabled: &enabled}); err != nil { - t.Fatal(err) - } - svc := &service.Container{APIConfig: apiConfig} - - if discoverProviderEnabled(t.Context(), svc, "douban") { - t.Fatal("disabled API config should disable discover provider") - } - if !discoverProviderEnabled(t.Context(), svc, "missing-provider") { - t.Fatal("missing API config should keep discover provider available") - } -} - -func TestDiscoverFetchFailureLogIncludesDiagnostics(t *testing.T) { - core, observed := observer.New(zap.WarnLevel) - logger := zap.New(core) - - logDiscoverFetchFailed( - &service.Container{Log: logger}, - "tmdb_latest_movie", - 2, - 1500*time.Millisecond, - discoverSectionTimeout("tmdb_latest_movie"), - context.DeadlineExceeded, - ) - - entries := observed.FilterMessage("discover section fetch failed").All() - if len(entries) != 1 { - t.Fatalf("expected one failure log entry, got %d", len(entries)) - } - fields := entries[0].ContextMap() - if fields["section"] != "tmdb_latest_movie" || fields["provider"] != "tmdb" { - t.Fatalf("unexpected section/provider fields: %#v", fields) - } - if fields["page"] != int64(2) && fields["page"] != 2 { - t.Fatalf("page field missing or wrong: %#v", fields["page"]) - } - if fields["duration_ms"] != int64(1500) && fields["duration_ms"] != 1500 { - t.Fatalf("duration_ms field missing or wrong: %#v", fields["duration_ms"]) - } - if _, ok := fields["timeout"]; !ok { - t.Fatalf("timeout field missing: %#v", fields) - } -} - -func TestDiscoverSectionTimeoutRaisesBangumiBudget(t *testing.T) { - if got := discoverSectionTimeout("bangumi_calendar"); got != discoverFeedBangumiTimeout { - t.Fatalf("bangumi timeout = %s, want %s", got, discoverFeedBangumiTimeout) - } - if got := discoverSectionTimeout("tmdb_latest_movie"); got != discoverFeedSectionTimeout { - t.Fatalf("tmdb timeout = %s, want %s", got, discoverFeedSectionTimeout) - } -} - -func TestDiscoverSlowFetchLogIncludesSectionTiming(t *testing.T) { - core, observed := observer.New(zap.InfoLevel) - logger := zap.New(core) - - logDiscoverFetchSlow(&service.Container{Log: logger}, "douban_hot_movie", 1, discoverFeedSlowSectionThreshold-time.Millisecond, 24) - if got := observed.FilterMessage("discover section fetch slow").Len(); got != 0 { - t.Fatalf("fast section should not log, got %d entries", got) - } - - logDiscoverFetchSlow(&service.Container{Log: logger}, "douban_hot_movie", 1, discoverFeedSlowSectionThreshold, 24) - entries := observed.FilterMessage("discover section fetch slow").All() - if len(entries) != 1 { - t.Fatalf("expected one slow log entry, got %d", len(entries)) - } - fields := entries[0].ContextMap() - if fields["section"] != "douban_hot_movie" || fields["provider"] != "douban" { - t.Fatalf("unexpected section/provider fields: %#v", fields) - } - if fields["items"] != int64(24) && fields["items"] != 24 { - t.Fatalf("items field missing or wrong: %#v", fields["items"]) - } - if _, ok := fields["duration_ms"]; !ok { - t.Fatalf("duration_ms field missing: %#v", fields) - } - if _, ok := fields["slow_threshold"]; !ok { - t.Fatalf("slow_threshold field missing: %#v", fields) - } -} - -func TestDiscoverFeedErrorMessageHidesTechnicalTimeout(t *testing.T) { - for _, err := range []error{ - context.DeadlineExceeded, - errors.New("timeout of 30000ms exceeded"), - } { - got := discoverFeedErrorMessage(err) - if got != "推荐源响应超时,已跳过本次加载" { - t.Fatalf("message for %q = %q", err, got) - } - } - if got := discoverFeedErrorMessage(errors.New("upstream 503")); got != "推荐源暂时不可用,已跳过本次加载" { - t.Fatalf("generic message = %q", got) - } -} - -func TestDefaultDiscoverSectionKeysSkipDisabledProviders(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.APIConfig{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - apiConfig := service.NewAPIConfigService(zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())) - disabled := false - for _, provider := range []string{"douban", "bangumi"} { - if _, err := apiConfig.Update(t.Context(), provider, service.APIConfigPatch{Enabled: &disabled}); err != nil { - t.Fatal(err) - } - } - svc := &service.Container{APIConfig: apiConfig} - - keys := defaultDiscoverSectionKeys(t.Context(), svc) - for _, key := range keys { - switch discoverSectionProvider(key) { - case "douban", "bangumi": - t.Fatalf("disabled provider key %q should not be selected by default; keys=%v", key, keys) - } - } - if len(keys) == 0 { - t.Fatal("default keys should keep enabled providers") - } -} - -func TestDefaultDiscoverSectionKeysIncludeLatestTMDbRails(t *testing.T) { - keys := defaultDiscoverSectionKeys(t.Context(), &service.Container{}) - keySet := map[string]struct{}{} - for _, key := range keys { - keySet[key] = struct{}{} - } - for _, key := range []string{"tmdb_latest_movie", "tmdb_latest_tv"} { - if _, ok := keySet[key]; !ok { - t.Fatalf("default discover keys should include %q: %v", key, keys) - } - } -} - -func TestFallbackDiscoverSectionKeyUsesTMDbForDoubanRails(t *testing.T) { - cases := map[string]string{ - "douban_hot_movie": "tmdb_popular_movie", - "douban_hot_tv": "tmdb_popular_tv", - "douban_top_movie": "tmdb_top_rated_movie", - "tmdb_latest_tv": "", - } - for key, want := range cases { - if got := fallbackDiscoverSectionKey(key); got != want { - t.Fatalf("fallbackDiscoverSectionKey(%q) = %q, want %q", key, got, want) - } - } -} diff --git a/internal/handler/download_client_handler.go b/internal/handler/download_client_handler.go deleted file mode 100644 index 7c49948..0000000 --- a/internal/handler/download_client_handler.go +++ /dev/null @@ -1,274 +0,0 @@ -// Package handler — 下载客户端管理 HTTP 端点。 -package handler - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// DownloadClientHandler 处理下载客户端的 CRUD 操作。 -type DownloadClientHandler struct { - svc *service.Container - log *zap.Logger -} - -// NewDownloadClientHandler 创建下载客户端处理器。 -func NewDownloadClientHandler(svc *service.Container, log *zap.Logger) *DownloadClientHandler { - return &DownloadClientHandler{svc: svc, log: log} -} - -// downloadClientCreateRequest 创建下载客户端请求体。 -type downloadClientCreateRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required,oneof=qbittorrent transmission aria2"` - Host string `json:"host" binding:"required"` - Username string `json:"username"` - Password string `json:"password"` - IsDefault bool `json:"is_default"` - Extra map[string]string `json:"extra,omitempty"` -} - -// downloadClientUpdateRequest 更新下载客户端请求体。 -type downloadClientUpdateRequest struct { - Name string `json:"name"` - Type string `json:"type" binding:"omitempty,oneof=qbittorrent transmission aria2"` - Host string `json:"host"` - Username string `json:"username"` - Password string `json:"password"` - IsDefault *bool `json:"is_default"` - Enabled *bool `json:"enabled"` - Extra map[string]string `json:"extra,omitempty"` -} - -// Create 创建新的下载客户端。 -func (h *DownloadClientHandler) Create(c *gin.Context) { - var req downloadClientCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - _ = h.svc.Repo.Setting.Set(ctx, "download_clients.managed", "true") - normalizedHost, err := service.NormalizeDownloadClientHost(req.Type, req.Host) - if err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - // 加密密码 - password := req.Password - if password != "" && h.svc.Crypto != nil { - password = h.svc.Crypto.Encrypt(password) - } - - // 加密 Extra 配置 - extraStr := "" - if len(req.Extra) > 0 { - extraJSON, _ := json.Marshal(req.Extra) - extraStr = string(extraJSON) - if h.svc.Crypto != nil { - extraStr = h.svc.Crypto.Encrypt(extraStr) - } - } - - // 如果设为默认,先清除其他默认 - if req.IsDefault { - _ = h.svc.Repo.DownloadClient.ClearDefault(ctx) - } - - client := &model.DownloadClient{ - Name: req.Name, - Type: req.Type, - Host: normalizedHost, - Username: req.Username, - Password: password, - IsDefault: req.IsDefault, - Enabled: true, - Extra: extraStr, - } - - if err := h.svc.Repo.DownloadClient.Create(ctx, client); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "创建失败: "+err.Error()) - return - } - - if err := h.svc.Downloads.ReloadConfig(ctx); err != nil { - h.log.Warn("failed to reload download clients after create", zap.Error(err)) - Error(c, http.StatusInternalServerError, ErrInternal, "客户端已保存,但运行时重载失败: "+err.Error()) - return - } - - Success(c, client) -} - -// List 返回所有下载客户端。 -func (h *DownloadClientHandler) List(c *gin.Context) { - ctx := c.Request.Context() - clients, err := h.svc.Repo.DownloadClient.List(ctx) - if err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "查询失败") - return - } - Success(c, clients) -} - -// Get 返回指定下载客户端详情。 -func (h *DownloadClientHandler) Get(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil || client == nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - Success(c, client) -} - -// Update 更新下载客户端。 -func (h *DownloadClientHandler) Update(c *gin.Context) { - id := c.Param("id") - var req downloadClientUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil || client == nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - - if req.Name != "" { - client.Name = req.Name - } - if req.Type != "" { - client.Type = req.Type - } - if req.Host != "" { - clientType := client.Type - if req.Type != "" { - clientType = req.Type - } - normalizedHost, err := service.NormalizeDownloadClientHost(clientType, req.Host) - if err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - client.Host = normalizedHost - } - if req.Username != "" { - client.Username = req.Username - } - if req.Password != "" { - if h.svc.Crypto != nil { - client.Password = h.svc.Crypto.Encrypt(req.Password) - } else { - client.Password = req.Password - } - } - if req.IsDefault != nil && *req.IsDefault { - _ = h.svc.Repo.DownloadClient.ClearDefault(ctx) - client.IsDefault = *req.IsDefault - } - if req.Enabled != nil { - client.Enabled = *req.Enabled - } - if len(req.Extra) > 0 { - extraJSON, _ := json.Marshal(req.Extra) - extraStr := string(extraJSON) - if h.svc.Crypto != nil { - client.Extra = h.svc.Crypto.Encrypt(extraStr) - } else { - client.Extra = extraStr - } - } - normalizedHost, err := service.NormalizeDownloadClientHost(client.Type, client.Host) - if err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - client.Host = normalizedHost - - if err := h.svc.Repo.DownloadClient.Update(ctx, client); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "更新失败") - return - } - clearLegacyQBitSettingsIfNoDefault(c.Request.Context(), h.svc) - - if err := h.svc.Downloads.ReloadConfig(ctx); err != nil { - h.log.Warn("failed to reload download clients after update", zap.Error(err)) - Error(c, http.StatusInternalServerError, ErrInternal, "客户端已更新,但运行时重载失败: "+err.Error()) - return - } - - Success(c, client) -} - -// Delete 删除下载客户端。 -func (h *DownloadClientHandler) Delete(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - _ = h.svc.Repo.Setting.Set(ctx, "download_clients.managed", "true") - - _, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - - if delErr := h.svc.Repo.DownloadClient.Delete(ctx, id); delErr != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "删除失败") - return - } - - clearLegacyQBitSettingsIfNoDefault(c.Request.Context(), h.svc) - if err := h.svc.Downloads.ReloadConfig(ctx); err != nil { - h.log.Warn("failed to reload download clients after delete", zap.Error(err)) - Error(c, http.StatusInternalServerError, ErrInternal, "客户端已删除,但运行时重载失败: "+err.Error()) - return - } - - SuccessWithMessage(c, "已删除", nil) -} - -func clearLegacyQBitSettingsIfNoDefault(ctx context.Context, svc *service.Container) { - if svc == nil || svc.Repo == nil || svc.Repo.DownloadClient == nil || svc.Repo.Setting == nil { - return - } - defaultClient, err := svc.Repo.DownloadClient.FindDefault(ctx) - if err != nil || defaultClient != nil { - return - } - _ = svc.Repo.Setting.Set(ctx, "qbittorrent.url", "") - _ = svc.Repo.Setting.Set(ctx, "qbittorrent.username", "") - _ = svc.Repo.Setting.Set(ctx, "qbittorrent.password", "") -} - -// Test 测试下载客户端连接。 -func (h *DownloadClientHandler) Test(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil || client == nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - - if err := h.svc.DownloadMgr.TestConnection(ctx, client); err != nil { - Error(c, http.StatusBadRequest, ErrExternal, "连接测试失败: "+err.Error()) - return - } - - SuccessWithMessage(c, "连接成功", nil) -} diff --git a/internal/handler/download_clients.go b/internal/handler/download_clients.go deleted file mode 100644 index 308eeac..0000000 --- a/internal/handler/download_clients.go +++ /dev/null @@ -1,108 +0,0 @@ -// Package handler — download client (qBittorrent / Aria2 / Transmission) -// configuration endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listDownloadClientsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, err := svc.DownloadClients.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if rows == nil { - rows = []model.DownloadClient{} - } - c.JSON(http.StatusOK, rows) - } -} - -func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.DownloadClientInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.DownloadClients.Create(c.Request.Context(), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - // 让真正发起下载的 DownloadService 立刻读到新的 qb 配置, - // 避免保存后还要重启进程才能生效。 - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "client saved but runtime reload failed: " + err.Error()}) - return - } - c.JSON(http.StatusCreated, row) - } -} - -func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.DownloadClientInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.DownloadClients.Update(c.Request.Context(), c.Param("id"), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "client updated but runtime reload failed: " + err.Error()}) - return - } - c.JSON(http.StatusOK, row) - } -} - -func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.DownloadClients.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "client deleted but runtime reload failed: " + err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -func testDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.DownloadClients.Test(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func aria2StatsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - clientID := c.Query("client_id") - if clientID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "client_id required"}) - return - } - out, err := svc.DownloadClients.Aria2GlobalStats(c.Request.Context(), clientID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, out) - } -} diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go deleted file mode 100644 index 25e3917..0000000 --- a/internal/handler/downloads.go +++ /dev/null @@ -1,258 +0,0 @@ -// Package handler — download manager endpoints. -package handler - -import ( - "context" - "errors" - "net/http" - "net/url" - "strings" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type addDownloadReq struct { - URL string `json:"url" binding:"required"` - SavePath string `json:"save_path"` - Title string `json:"title"` - PosterURL string `json:"poster_url"` - BackdropURL string `json:"backdrop_url"` - Overview string `json:"overview"` - MediaType string `json:"media_type"` - MediaCategory string `json:"media_category"` - SourceCategory string `json:"source_category"` -} - -// resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接 -// 拉到 .torrent 文件的真实下载 URL。 -// -// 链路: -// -// 1. 拿 URL 的 host,到 sites 表里找 base_url 同源的站点。 -// 2. 如果站点的 type 是已知 PT 框架(mteam/nexusphp/unit3d/...), -// 就用对应适配器的 GetDownloadURL,传入从 URL 里 parse 出来的 id。 -// 3. 任一步失败都直接返回原 URL,让 qb 自己去拉(保持向后兼容)。 -// -// 这一步存在的意义:M-Team 等站点的搜索结果里 download_url 是 -// /api/torrent/genDlToken?id=xxx,需要带 x-api-key 才能调用,qb 自己 -// 是没法识别这种 PT 专属端点的。 -func resolvePTDownloadURL(ctx context.Context, svc *service.Container, raw string, log *zap.Logger) string { - if raw == "" || svc == nil || svc.Site == nil { - return raw - } - resolved := svc.Site.ResolveDownloadURL(ctx, raw) - if resolved == raw { - return raw - } - log.Info("resolved PT download URL", - zap.String("from", redactDownloadURL(raw)), - zap.String("to", redactDownloadURL(resolved))) - return resolved -} - -func addDownloadHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req addDownloadReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - // 把站点搜索 URL 转换成真实可下载 URL(M-Team 走 genDlToken 等)。 - realURL := resolvePTDownloadURL(c.Request.Context(), svc, req.URL, svc.Log) - fallbackTitle := req.Title - if strings.TrimSpace(fallbackTitle) == "" { - fallbackTitle = realURL - } - meta := enrichDownloadTaskMeta(c.Request.Context(), svc, service.DownloadTaskMeta{ - Title: req.Title, - PosterURL: req.PosterURL, - BackdropURL: req.BackdropURL, - Overview: req.Overview, - MediaType: req.MediaType, - MediaCategory: req.MediaCategory, - SourceCategory: req.SourceCategory, - }, fallbackTitle, req.MediaType) - t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta) - if err != nil { - if errors.Is(err, service.ErrMediaAlreadyInLibrary) { - c.JSON(http.StatusConflict, gin.H{"error": "media already exists in library"}) - return - } - if errors.Is(err, service.ErrDownloadAlreadyExists) { - c.JSON(http.StatusOK, t) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", redactDownloadURL(realURL), c.ClientIP(), "") - c.JSON(http.StatusCreated, t) - } -} - -func listDownloadsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, live, err := svc.Downloads.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - rows = visibleDownloadRows(c, rows) - enrichAndPersistDownloadRows(c.Request.Context(), svc, rows) - if !isAdminRequest(c) { - live = visibleLiveTorrents(rows, live) - } - taskViews, torrentViews := service.DownloadViews(rows, live) - enrichDownloadTorrentViews(c.Request.Context(), svc, torrentViews) - c.JSON(http.StatusOK, gin.H{ - "tasks": taskViews, - "torrents": torrentViews, - }) - } -} - -func visibleDownloadRows(c *gin.Context, rows []model.DownloadTask) []model.DownloadTask { - if isAdminRequest(c) { - return rows - } - uid, _ := c.Get(middleware.CtxUserID) - userID, _ := uid.(string) - filtered := make([]model.DownloadTask, 0, len(rows)) - for _, row := range rows { - if row.UserID == userID { - filtered = append(filtered, row) - } - } - return filtered -} - -func visibleLiveTorrents(rows []model.DownloadTask, live []service.QBitTorrent) []service.QBitTorrent { - if len(rows) == 0 || len(live) == 0 { - return nil - } - filtered := make([]service.QBitTorrent, 0, len(live)) - for _, torrent := range live { - matchedByID := false - for _, row := range rows { - if strings.TrimSpace(row.ExternalID) != "" && strings.EqualFold(row.ExternalID, torrent.Hash) && - (strings.TrimSpace(row.DownloadClientID) == "" || row.DownloadClientID == torrent.ClientID) { - filtered = append(filtered, torrent) - matchedByID = true - break - } - } - if matchedByID { - continue - } - torrentTitle := normalizeTitle(torrent.Name) - if torrentTitle == "" { - continue - } - for _, row := range rows { - if strings.TrimSpace(row.DownloadClientID) != "" && strings.TrimSpace(torrent.ClientID) != "" && row.DownloadClientID != torrent.ClientID { - continue - } - rowTitle := normalizeTitle(row.Title) - if rowTitle == "" { - continue - } - if strings.Contains(torrentTitle, rowTitle) || strings.Contains(rowTitle, torrentTitle) { - filtered = append(filtered, torrent) - break - } - } - } - return filtered -} - -func isAdminRequest(c *gin.Context) bool { - role, _ := c.Get(middleware.CtxUserRole) - return role == "admin" -} - -func normalizeTitle(title string) string { - title = strings.ToLower(title) - var b strings.Builder - for _, r := range title { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r > 127 { - b.WriteRune(r) - } - } - return b.String() -} - -func redactDownloadURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "magnet:?xt=***" - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return "[redacted-download-url]" - } - u.RawQuery = "" - u.Fragment = "" - base := u.String() - if base == "" { - return u.Scheme + "://" + u.Host - } - return base -} - -func deleteDownloadHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - hash := c.Param("hash") - withFiles := c.Query("delete_files") == "true" - if err := svc.Downloads.Delete(c.Request.Context(), hash, withFiles, c.Query("client_id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -type relocateDownloadReq struct { - Hash string `json:"hash" binding:"required"` - Location string `json:"location" binding:"required"` - ClientID string `json:"client_id"` -} - -// relocateDownloadHandler moves a torrent's data to a new directory while -// keeping it seeding (qBittorrent setLocation). -func relocateDownloadHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req relocateDownloadReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := svc.Downloads.RelocateTorrent(c.Request.Context(), req.Hash, req.Location, req.ClientID); err != nil { - status := http.StatusInternalServerError - if errors.Is(err, service.ErrDownloadOperationUnsupported) { - status = http.StatusBadRequest - } - c.JSON(status, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"hash": strings.TrimSpace(req.Hash), "location": strings.TrimSpace(req.Location)}) - } -} - -func reloadDownloadConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} diff --git a/internal/handler/downloads_extra.go b/internal/handler/downloads_extra.go deleted file mode 100644 index c3c33c6..0000000 --- a/internal/handler/downloads_extra.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package handler — pause/resume/organize on individual download tasks -// and a thin sync-trigger surface used by the Vue UI's auto-sync toggle. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func downloadPauseHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.PauseDownloadTask(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func downloadResumeHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.ResumeDownloadTask(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -// downloadOrganizeOneHandler runs the file organizer for one task. -// It looks up the task, then delegates to OrganizerService.OrganizePath(). -func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var t model.DownloadTask - if err := svc.Repo.DB.WithContext(c.Request.Context()). - Where("id = ?", c.Param("id")).First(&t).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if t.SavePath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "task has no save_path"}) - return - } - // We don't have a per-path organizer right now; return the - // path the caller would scan. The general OrganizeAll endpoint - // (below) is the supported workflow. - c.JSON(http.StatusOK, gin.H{ - "ok": true, - "path": t.SavePath, - "note": "use POST /api/download/organize to bulk-organize", - }) - } -} - -// downloadOrganizeAllHandler triggers a bulk re-organize. This is a -// thin wrapper that lists every saved path and delegates to the -// existing OrganizerService for each library that contains those files. -func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - // Walk each library through the unified organize pipeline so rename, - // scan, scrape and task reporting stay identical to manual organize. - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - results := make([]any, 0, len(libs)) - for _, l := range libs { - resp, err := organizePipeline(svc).Run(c.Request.Context(), service.OrganizePipelineRequest{ - Scope: service.OrganizeScopeLibrary, - Trigger: service.OrganizeTriggerManual, - TaskName: "批量整理媒体库:" + l.Name, - LibraryID: l.ID, - }) - if err != nil { - results = append(results, gin.H{"library": l.Name, "error": err.Error()}) - continue - } - results = append(results, gin.H{"library": l.Name, "result": resp.Result}) - } - c.JSON(http.StatusOK, gin.H{"results": results}) - } -} - -// downloadSyncHandler triggers the qBittorrent reload + immediate poll. -func downloadSyncHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -// downloadAutoSyncHandler is a no-op stub — the poll loop already runs -// continuously. Returning 200 keeps the Vue UI's toggle happy. -func downloadAutoSyncHandler(_ *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"ok": true, "auto_sync": true}) - } -} - -// downloadTasksAliasHandler is the alias used by the Vue UI; it -// returns the same shape as listDownloadsHandler but at /download/tasks. -func downloadTasksAliasHandler(svc *service.Container) gin.HandlerFunc { - return listDownloadsHandler(svc) -} diff --git a/internal/handler/duplicate.go b/internal/handler/duplicate.go deleted file mode 100644 index 2c9e3ca..0000000 --- a/internal/handler/duplicate.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package handler — duplicate-file finder. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listDuplicatesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Query("library_id") - report, err := svc.Duplicate.Current(c.Request.Context(), libraryID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, report) - } -} - -func detectDuplicatesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Query("library_id") - report, err := svc.Duplicate.Detect(c.Request.Context(), libraryID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, report) - } -} - -func unmarkDuplicatesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Query("library_id") - n, err := svc.Duplicate.Unmark(c.Request.Context(), libraryID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"unmarked": n}) - } -} diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index 49e1915..799c9a0 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -40,11 +40,6 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { embyServePlaceholderImage(c) return } - if typ, ref, ok := service.ParseCloudArtworkURL(raw); ok { - c.Request = req - serveCloudResolvedLink(svc, c, typ, ref) - return - } if svc.ImageProxy == nil { embyServePlaceholderImage(c) return diff --git a/internal/handler/emby_items_test.go b/internal/handler/emby_items_test.go index 94afd63..c296c0a 100644 --- a/internal/handler/emby_items_test.go +++ b/internal/handler/emby_items_test.go @@ -1,7 +1,6 @@ package handler import ( - "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -19,7 +18,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) { @@ -92,63 +90,6 @@ func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) { } } -func TestEmbyItemImageServesCachedCloudArtworkWithoutResolve(t *testing.T) { - gin.SetMode(gin.TestMode) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(handlerTestJPEG) - })) - defer upstream.Close() - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(&model.Media{}); err != nil { - t.Fatalf("migrate: %v", err) - } - - cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}} - imageProxy := service.NewImageProxy(cfg, zap.NewNop()) - ref := "/Movies/Cloud Movie/poster.jpg" - if err := imageProxy.PrefetchCloudResolved(t.Context(), "openlist:"+ref, &cloud.DirectLink{URL: upstream.URL + "/poster.jpg"}); err != nil { - t.Fatalf("prefetch cloud poster: %v", err) - } - repos := repository.New(db) - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-media-1"}, - Title: "Cloud Poster Test", - Path: "cloud://openlist/Movies/Cloud Movie/movie.mkv", - PosterURL: service.CloudArtworkURL("openlist", ref), - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - router := gin.New() - registerEmbyRoutes(router, "test-secret", &service.Container{ - Repo: repos, - Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), - ImageProxy: imageProxy, - }) - - req := httptest.NewRequest(http.MethodGet, "/Items/cloud-media-1/Images/Primary", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) { - t.Fatalf("body = %q, want cached cloud poster", got) - } - if location := w.Header().Get("Location"); location != "" { - t.Fatalf("expected direct cached image response, got redirect to %q", location) - } - if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") { - t.Fatalf("image Cache-Control = %q, want long browser cache", got) - } -} - func TestEmbyMissingItemImageReturnsTransparentPlaceholder(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/internal/handler/emby_playback_cloud_routes_test.go b/internal/handler/emby_playback_cloud_routes_test.go deleted file mode 100644 index b8baa4c..0000000 --- a/internal/handler/emby_playback_cloud_routes_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package handler - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestEmbyVideoStreamUsesSTRMWhenRedirectProxyDisabled(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(model.AllModels()...); err != nil { - t.Fatalf("migrate: %v", err) - } - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil { - t.Fatalf("enable strm playback: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { - t.Fatalf("disable redirect playback: %v", err) - } - if err := repos.User.Create(t.Context(), &model.User{ - Base: model.Base{ID: "user-1"}, - Username: "tester", - PasswordHash: "x", - Role: "admin", - Tier: "plus", - IsActive: true, - }); err != nil { - t.Fatalf("create user: %v", err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: "cloud://openlist/Movies/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", - Container: "mkv", - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - const secret = "test-secret" - router := gin.New() - cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}} - registerEmbyRoutes(router, secret, &service.Container{ - Repo: repos, - Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), - Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil), - }) - - token := signedTestToken(t, secret) - req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream?api_key="+token, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") { - t.Fatalf("STRM mode should redirect /Videos fallback to tokenized /api/stream, got %q", loc) - } - if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { - t.Fatalf("STRM fallback redirect Cache-Control = %q, want no-store", got) - } - if strings.Contains(loc, "/api/cloud/play/") { - t.Fatalf("STRM mode should not expose cloud play directly from /Videos fallback: %q", loc) - } -} - -func TestEmbyVideoStreamIssuesTokenForSessionFallbackSTRMRedirect(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(model.AllModels()...); err != nil { - t.Fatalf("migrate: %v", err) - } - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil { - t.Fatalf("enable strm playback: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { - t.Fatalf("disable redirect playback: %v", err) - } - user := model.User{ - Base: model.Base{ID: "user-1"}, - Username: "tester", - PasswordHash: "x", - Role: "admin", - Tier: "plus", - IsActive: true, - } - if err := repos.User.Create(t.Context(), &user); err != nil { - t.Fatalf("create user: %v", err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: "cloud://openlist/Movies/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", - Container: "mkv", - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - const secret = "test-secret" - cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}} - svc := &service.Container{ - Repo: repos, - Auth: service.NewAuthService(cfg, zap.NewNop(), repos, nil, nil), - Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), - Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil), - } - router := gin.New() - router.GET("/videos/:id/stream", func(c *gin.Context) { - c.Set(middleware.CtxUserID, user.ID) - c.Set(middleware.CtxUserRole, user.Role) - embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)(c) - }) - - req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") { - t.Fatalf("session fallback redirect should include api_key for /api/stream, got %q", loc) - } -} - -func TestEmbyVideoStreamRedirectKeepsMediaBrowserAuthorizationToken(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(model.AllModels()...); err != nil { - t.Fatalf("migrate: %v", err) - } - repos := repository.New(db) - if err := repos.User.Create(t.Context(), &model.User{ - Base: model.Base{ID: "user-1"}, - Username: "tester", - PasswordHash: "x", - Role: "admin", - Tier: "plus", - IsActive: true, - }); err != nil { - t.Fatalf("create user: %v", err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: "cloud://openlist/Movies/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", - Container: "mkv", - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - const secret = "test-secret" - router := gin.New() - registerEmbyRoutes(router, secret, &service.Container{ - Repo: repos, - Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), - Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil), - }) - - token := signedTestToken(t, secret) - req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil) - req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", Token="`+token+`"`) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token=") { - t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc) - } -} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 57a1b1f..77ddbed 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -20,9 +20,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C api.GET("/version", versionInfo) api.GET("/public/ui-config", publicUIConfigHandler(svc)) - // Telegram Bot webhook — called by Telegram servers, no auth. - api.POST("/telegram/webhook", telegramWebhookHandler(svc)) - registerPublicAuthRoutes(api, svc, log) registerAuthenticatedRoutes(api, cfg, svc) @@ -90,42 +87,6 @@ func testApiConfigHandler(svc *service.Container) gin.HandlerFunc { return h.TestApiConfig } -// ─── Download Client Handler 包装 ───────────────────────────────────────────── - -func getDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - h := NewDownloadClientHandler(svc, svc.Log) - return h.Get -} - -// ─── Notify Channel Handler 包装 ────────────────────────────────────────────── - -func getNotifyChannelTypesHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.GetTypes -} - -func getNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.Get -} - -// ─── Scheduler Handler 包装 ────────────────────────────────────────────────── - -func schedulerListTasksHandler(svc *service.Container) gin.HandlerFunc { - h := NewSchedulerHandler(svc, svc.Log) - return h.ListTasks -} - -func schedulerRunTaskHandler(svc *service.Container) gin.HandlerFunc { - h := NewSchedulerHandler(svc, svc.Log) - return h.RunTask -} - -func schedulerGetStatusHandler(svc *service.Container) gin.HandlerFunc { - h := NewSchedulerHandler(svc, svc.Log) - return h.GetStatus -} - // ─── SSE Handler ────────────────────────────────────────────────────────────── func sseHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/license.go b/internal/handler/license.go deleted file mode 100644 index 46a646a..0000000 --- a/internal/handler/license.go +++ /dev/null @@ -1,167 +0,0 @@ -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -const ( - licenseServerURLSetting = "license.server_url" - licenseHMACSecretSetting = "license.hmac_secret" // #nosec G101 -- setting key name, not the HMAC secret value. - licenseDeviceIDSetting = "license.device_id" - licenseDeviceNameSetting = "license.device_name" -) - -type licenseActivateReq struct { - Key string `json:"key" binding:"required"` - // DeviceID is accepted for wire compatibility with older web clients but is - // intentionally ignored. Licensing binds to this MediaStationGo server - // instance, not to the browser that opened the admin page. - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` -} - -type licenseServerSignedResp struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - Signature string `json:"signature"` - SignatureAlg string `json:"signature_alg"` - LegacySignature bool `json:"-"` -} - -type licenseServerStatusResp struct { - Valid bool `json:"valid"` - LicenseType *string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - UnlimitedUsers bool `json:"unlimited_users"` - DaysRemaining *int `json:"days_remaining"` - DeviceName string `json:"device_name"` - IsActive bool `json:"is_active"` - HeartbeatRequested bool `json:"heartbeat_requested"` -} - -func licenseActivateHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req licenseActivateReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - client, err := newLicenseClient(c.Request.Context(), svc) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - deviceID, err := ensureLicenseDeviceID(c.Request.Context(), svc, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - deviceName := strings.TrimSpace(req.DeviceName) - if deviceName == "" { - deviceName = defaultLicenseDeviceName() - } - _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) - - payload := map[string]any{ - "key": strings.TrimSpace(req.Key), - "fingerprint": deviceID, - "device_name": deviceName, - "instance_id": deviceID, - } - var upstream licenseServerSignedResp - if err := client.post(c.Request.Context(), "/api/v1/activate", payload, &upstream); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - if err := client.verifySigned(&upstream); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - state := licenseStateFromSigned(upstream, deviceID, deviceName) - state.LicenseKey = strings.TrimSpace(req.Key) - if err := persistLicenseState(c.Request.Context(), svc, state); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, licenseActivationView(state)) - } -} - -func licenseStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - state, _ := loadLicenseState(c.Request.Context(), svc) - client, err := newLicenseClient(c.Request.Context(), svc) - hasLicenseKey := strings.TrimSpace(state.LicenseKey) != "" - if err == nil && hasLicenseKey { - deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, state.DeviceID) - if idErr == nil { - deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting) - if strings.TrimSpace(deviceName) == "" { - deviceName = defaultLicenseDeviceName() - _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) - } - var signed licenseServerSignedResp - if heartbeatErr := client.post(c.Request.Context(), "/api/v1/heartbeat", licenseHeartbeatPayload(state, deviceID, deviceName), &signed); heartbeatErr == nil && client.verifySigned(&signed) == nil { - nextState := licenseStateFromSigned(signed, deviceID, deviceName) - nextState.LicenseKey = state.LicenseKey - state = nextState - if refreshed, ok, _, refreshErr := refreshLicenseServerStatus(c.Request.Context(), client, state, deviceID); refreshErr == nil && ok { - refreshed.LicenseKey = state.LicenseKey - state = refreshed - } - _ = persistLicenseState(c.Request.Context(), svc, state) - } else { - if refreshed, ok, _, getErr := refreshLicenseServerStatus(c.Request.Context(), client, state, deviceID); getErr == nil && ok { - state = refreshed - _ = persistLicenseState(c.Request.Context(), svc, state) - } else if getErr == nil { - state.Valid = false - _ = persistLicenseState(c.Request.Context(), svc, state) - } - } - } - } else if err == nil && state.Valid { - deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, state.DeviceID) - if idErr == nil { - if refreshed, ok, _, getErr := refreshLicenseServerStatus(c.Request.Context(), client, state, deviceID); getErr == nil && ok { - state = refreshed - _ = persistLicenseState(c.Request.Context(), svc, state) - } else if getErr == nil { - state.Valid = false - _ = persistLicenseState(c.Request.Context(), svc, state) - } - } - } - active := state.Valid && !licenseStateExpired(state.ExpiryDate) - c.JSON(http.StatusOK, gin.H{ - "active": active, - "message": licenseStatusMessage(active, err), - "max_users": licenseStatusMaxUsers(state), - "unlimited_users": state.Valid && !licenseStateExpired(state.ExpiryDate) && state.UnlimitedUsers, - "activation": licenseActivationView(state), - }) - } -} - -func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - state, err := sendLicenseHeartbeat(c.Request.Context(), svc) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, licenseActivationView(state)) - } -} diff --git a/internal/handler/license_client.go b/internal/handler/license_client.go deleted file mode 100644 index 64ade37..0000000 --- a/internal/handler/license_client.go +++ /dev/null @@ -1,229 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/hmac" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type licenseClient struct { - baseURL string - hmacSecret string - ed25519PublicKey ed25519.PublicKey - httpClient *http.Client -} - -func newLicenseClient(ctx context.Context, svc *service.Container) (*licenseClient, error) { - baseURL, _ := svc.Repo.Setting.Get(ctx, licenseServerURLSetting) - if strings.TrimSpace(baseURL) == "" { - baseURL = svc.Cfg.License.ServerURL - } - secret, _ := svc.Repo.Setting.Get(ctx, licenseHMACSecretSetting) - if strings.TrimSpace(secret) == "" { - secret = svc.Cfg.License.HMACSecret - } - publicKeyRaw, _ := svc.Repo.Setting.Get(ctx, "license.public_key") - if strings.TrimSpace(publicKeyRaw) == "" { - publicKeyRaw = svc.Cfg.License.PublicKey - } - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if baseURL == "" { - return nil, errors.New("license server url not configured") - } - secret = strings.TrimSpace(secret) - publicKey, err := parseLicenseEd25519PublicKey(publicKeyRaw) - if err != nil { - return nil, err - } - if secret == "" && len(publicKey) == 0 { - return nil, errors.New("license public key or hmac secret not configured") - } - return &licenseClient{ - baseURL: baseURL, - hmacSecret: secret, - ed25519PublicKey: publicKey, - httpClient: &http.Client{Timeout: 15 * time.Second}, - }, nil -} - -func (c *licenseClient) post(ctx context.Context, path string, payload any, out any) error { - body, err := json.Marshal(payload) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - return c.do(req, out) -} - -func (c *licenseClient) get(ctx context.Context, path string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) - if err != nil { - return err - } - return c.do(req, out) -} - -func (c *licenseClient) do(req *http.Request, out any) error { - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - var er struct { - Error string `json:"error"` - Message string `json:"message"` - } - _ = json.Unmarshal(data, &er) - if er.Message != "" { - return fmt.Errorf("license server: %s", er.Message) - } - if er.Error != "" { - return fmt.Errorf("license server: %s", er.Error) - } - return fmt.Errorf("license server http %d", resp.StatusCode) - } - return json.Unmarshal(data, out) -} - -func (c *licenseClient) verifySigned(resp *licenseServerSignedResp) error { - if strings.TrimSpace(resp.Signature) == "" { - return errors.New("license server signature missing") - } - switch strings.ToLower(strings.TrimSpace(resp.SignatureAlg)) { - case "ed25519": - return c.verifyEd25519Signed(*resp) - case "", "hmac", "hmac-sha256": - return c.verifyHMACSigned(resp) - default: - return fmt.Errorf("unsupported license signature algorithm %q", resp.SignatureAlg) - } -} - -func (c *licenseClient) verifyEd25519Signed(resp licenseServerSignedResp) error { - if len(c.ed25519PublicKey) != ed25519.PublicKeySize { - return errors.New("license Ed25519 public key not configured") - } - signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(resp.Signature)) - if err != nil { - return err - } - payload, err := json.Marshal(licenseSignedPayload(resp)) - if err != nil { - return err - } - if !ed25519.Verify(c.ed25519PublicKey, payload, signature) { - return errors.New("license server signature verification failed") - } - return nil -} - -func (c *licenseClient) verifyHMACSigned(resp *licenseServerSignedResp) error { - if c.hmacSecret == "" { - return errors.New("license hmac secret not configured") - } - payload, err := json.Marshal(licenseSignedPayload(*resp)) - if err != nil { - return err - } - mac := hmac.New(sha256.New, []byte(c.hmacSecret)) - _, _ = mac.Write(payload) - expected := hex.EncodeToString(mac.Sum(nil)) - if !hmac.Equal([]byte(expected), []byte(resp.Signature)) { - if c.verifyLegacySigned(*resp) { - resp.LegacySignature = true - return nil - } - return errors.New("license server signature verification failed") - } - return nil -} - -func (c *licenseClient) verifyLegacySigned(resp licenseServerSignedResp) bool { - unsigned := struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - }{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: resp.ExpiryDate, - MaxDevices: resp.MaxDevices, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - } - payload, err := json.Marshal(unsigned) - if err != nil { - return false - } - mac := hmac.New(sha256.New, []byte(c.hmacSecret)) - _, _ = mac.Write(payload) - expected := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(expected), []byte(resp.Signature)) -} - -func parseLicenseEd25519PublicKey(encoded string) (ed25519.PublicKey, error) { - encoded = strings.TrimSpace(encoded) - if encoded == "" { - return nil, nil - } - raw, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf("decode license Ed25519 public key: %w", err) - } - if key, err := x509.ParsePKIXPublicKey(raw); err == nil { - if publicKey, ok := key.(ed25519.PublicKey); ok && len(publicKey) == ed25519.PublicKeySize { - return publicKey, nil - } - return nil, errors.New("license public key is not an Ed25519 PKIX public key") - } - if len(raw) == ed25519.PublicKeySize { - return ed25519.PublicKey(raw), nil - } - return nil, errors.New("license public key must be base64 PKIX or raw 32-byte Ed25519 public key") -} - -func licenseSignedPayload(resp licenseServerSignedResp) any { - return struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - }{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: resp.ExpiryDate, - MaxDevices: resp.MaxDevices, - MaxUsers: resp.MaxUsers, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - } -} diff --git a/internal/handler/license_heartbeat.go b/internal/handler/license_heartbeat.go deleted file mode 100644 index fc109bc..0000000 --- a/internal/handler/license_heartbeat.go +++ /dev/null @@ -1,172 +0,0 @@ -package handler - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -const ( - licenseHeartbeatInterval = 12 * time.Hour - licenseHeartbeatCheckInterval = 30 * time.Minute - licenseHeartbeatStartupDelay = 2 * time.Minute -) - -func refreshLicenseCapacityBestEffort(ctx context.Context, svc *service.Container) { - if svc == nil || svc.Repo == nil || svc.Repo.Setting == nil { - return - } - _, _, _ = maybeSendLicenseHeartbeat(ctx, svc, 0) -} - -// RunLicenseHeartbeatLoop keeps the license server aware of active deployments. -// The loop checks periodically, but only sends when the last stored heartbeat is -// older than licenseHeartbeatInterval. -func RunLicenseHeartbeatLoop(ctx context.Context, svc *service.Container) { - if svc == nil { - return - } - run := func(interval time.Duration) { - state, sent, err := maybeSendLicenseHeartbeat(ctx, svc, interval) - logLicenseHeartbeatResult(svc, state, sent, err) - } - runStartup := func() { - state, sent, err := maybeSendStartupLicenseHeartbeat(ctx, svc) - logLicenseHeartbeatResult(svc, state, sent, err) - } - - timer := time.NewTimer(licenseHeartbeatStartupDelay) - defer timer.Stop() - select { - case <-ctx.Done(): - return - case <-timer.C: - runStartup() - } - - ticker := time.NewTicker(licenseHeartbeatCheckInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - run(licenseHeartbeatInterval) - } - } -} - -func logLicenseHeartbeatResult(svc *service.Container, state service.LicenseActivationState, sent bool, err error) { - if svc == nil || svc.Log == nil { - return - } - if err != nil { - svc.Log.Warn("license heartbeat failed", zap.Error(err)) - return - } - if sent { - svc.Log.Info("license heartbeat sent", zap.String("device_id", state.DeviceID)) - } -} - -func maybeSendStartupLicenseHeartbeat(ctx context.Context, svc *service.Container) (service.LicenseActivationState, bool, error) { - state, err := loadLicenseState(ctx, svc) - if err != nil { - return state, false, nil - } - if strings.TrimSpace(state.LicenseKey) == "" { - return state, false, nil - } - return maybeSendLicenseHeartbeat(ctx, svc, 0) -} - -func maybeSendLicenseHeartbeat(ctx context.Context, svc *service.Container, interval time.Duration) (service.LicenseActivationState, bool, error) { - state, err := loadLicenseState(ctx, svc) - if err != nil { - return state, false, nil - } - if !licenseHeartbeatEligible(state) { - return state, false, nil - } - if !licenseHeartbeatDue(state, interval) { - client, clientErr := newLicenseClient(ctx, svc) - if clientErr != nil { - return state, false, nil - } - deviceID, idErr := ensureLicenseDeviceID(ctx, svc, state.DeviceID) - if idErr != nil { - return state, false, idErr - } - refreshed, ok, requested, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID) - if refreshErr == nil && ok { - state = refreshed - _ = persistLicenseState(ctx, svc, state) - } - if !requested { - return state, false, nil - } - } - next, err := sendLicenseHeartbeat(ctx, svc) - if err != nil { - return state, false, err - } - return next, true, nil -} - -func licenseHeartbeatEligible(state service.LicenseActivationState) bool { - return strings.TrimSpace(state.LicenseKey) != "" -} - -func licenseHeartbeatDue(state service.LicenseActivationState, interval time.Duration) bool { - if interval <= 0 { - return true - } - updatedAt := strings.TrimSpace(state.UpdatedAt) - if updatedAt == "" { - return true - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if t, err := time.Parse(layout, updatedAt); err == nil { - return time.Since(t) >= interval - } - } - return true -} - -func sendLicenseHeartbeat(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { - client, err := newLicenseClient(ctx, svc) - if err != nil { - return service.LicenseActivationState{}, err - } - oldState, _ := loadLicenseState(ctx, svc) - deviceID, err := ensureLicenseDeviceID(ctx, svc, oldState.DeviceID) - if err != nil { - return service.LicenseActivationState{}, err - } - deviceName, _ := svc.Repo.Setting.Get(ctx, licenseDeviceNameSetting) - if strings.TrimSpace(deviceName) == "" { - deviceName = defaultLicenseDeviceName() - _ = svc.Repo.Setting.Set(ctx, licenseDeviceNameSetting, deviceName) - } - var upstream licenseServerSignedResp - if err := client.post(ctx, "/api/v1/heartbeat", licenseHeartbeatPayload(oldState, deviceID, deviceName), &upstream); err != nil { - return service.LicenseActivationState{}, err - } - if err := client.verifySigned(&upstream); err != nil { - return service.LicenseActivationState{}, err - } - state := licenseStateFromSigned(upstream, deviceID, deviceName) - state.LicenseKey = oldState.LicenseKey - if refreshed, ok, _, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID); refreshErr == nil && ok { - refreshed.LicenseKey = state.LicenseKey - state = refreshed - } - if err := persistLicenseState(ctx, svc, state); err != nil { - return service.LicenseActivationState{}, err - } - return state, nil -} diff --git a/internal/handler/license_state.go b/internal/handler/license_state.go deleted file mode 100644 index 549d537..0000000 --- a/internal/handler/license_state.go +++ /dev/null @@ -1,211 +0,0 @@ -package handler - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "net/url" - "os" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func refreshLicenseServerStatus(ctx context.Context, client *licenseClient, state service.LicenseActivationState, deviceID string) (service.LicenseActivationState, bool, bool, error) { - var upstream licenseServerStatusResp - if err := client.get(ctx, "/api/v1/status/"+url.PathEscape(deviceID), &upstream); err != nil { - return state, false, false, err - } - if !upstream.Valid { - state.Valid = false - state.UpdatedAt = time.Now().Format(time.RFC3339) - return state, false, upstream.HeartbeatRequested, nil - } - applyLicenseStatus(&state, upstream, deviceID) - return state, true, upstream.HeartbeatRequested, nil -} - -func ensureLicenseDeviceID(ctx context.Context, svc *service.Container, candidate string) (string, error) { - if strings.TrimSpace(candidate) != "" { - return strings.TrimSpace(candidate), svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(candidate)) - } - existing, err := svc.Repo.Setting.Get(ctx, licenseDeviceIDSetting) - if err != nil { - return "", err - } - if strings.TrimSpace(existing) != "" { - return strings.TrimSpace(existing), nil - } - var buf [16]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", err - } - id := "msgo-" + hex.EncodeToString(buf[:]) - return id, svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, id) -} - -func defaultLicenseDeviceName() string { - host, _ := os.Hostname() - if strings.TrimSpace(host) == "" { - return "MediaStationGo Server" - } - return "MediaStationGo - " + host -} - -func licenseStateFromSigned(resp licenseServerSignedResp, deviceID, deviceName string) service.LicenseActivationState { - expiry := "" - if resp.ExpiryDate != nil { - expiry = *resp.ExpiryDate - } - return service.LicenseActivationState{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: expiry, - MaxDevices: resp.MaxDevices, - MaxUsers: resp.MaxUsers, - UnlimitedUsers: !resp.LegacySignature && resp.MaxUsers == nil, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - DeviceID: deviceID, - DeviceName: deviceName, - UpdatedAt: time.Now().Format(time.RFC3339), - } -} - -func licenseHeartbeatPayload(state service.LicenseActivationState, deviceID, deviceName string) map[string]any { - payload := map[string]any{ - "fingerprint": deviceID, - "instance_id": deviceID, - "device_name": deviceName, - } - if key := strings.TrimSpace(state.LicenseKey); key != "" { - payload["key"] = key - } - return payload -} - -func licenseStatusMaxUsers(state service.LicenseActivationState) any { - active := state.Valid && !licenseStateExpired(state.ExpiryDate) - if active { - if state.UnlimitedUsers { - return nil - } - if state.MaxUsers != nil && *state.MaxUsers > 0 { - return *state.MaxUsers - } - return service.LicensedUserLimit - } - return service.OpenSourceUserLimit -} - -func applyLicenseStatus(state *service.LicenseActivationState, upstream licenseServerStatusResp, deviceID string) { - state.Valid = upstream.Valid - if upstream.LicenseType != nil { - state.LicenseType = *upstream.LicenseType - } - if upstream.ExpiryDate != nil { - state.ExpiryDate = *upstream.ExpiryDate - } else { - state.ExpiryDate = "" - } - if upstream.MaxDevices > 0 { - state.MaxDevices = upstream.MaxDevices - } - state.MaxUsers = upstream.MaxUsers - state.UnlimitedUsers = upstream.UnlimitedUsers - state.DaysRemaining = upstream.DaysRemaining - if upstream.DeviceName != "" { - state.DeviceName = upstream.DeviceName - } - state.DeviceID = deviceID - state.UpdatedAt = time.Now().Format(time.RFC3339) -} - -func persistLicenseState(ctx context.Context, svc *service.Container, state service.LicenseActivationState) error { - data, err := json.Marshal(state) - if err != nil { - return err - } - if strings.TrimSpace(state.DeviceID) != "" { - _ = svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(state.DeviceID)) - } - return svc.Repo.Setting.Set(ctx, service.LicenseSettingActivation, string(data)) -} - -func loadLicenseState(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { - raw, err := svc.Repo.Setting.Get(ctx, service.LicenseSettingActivation) - if err != nil || raw == "" { - return service.LicenseActivationState{}, err - } - var state service.LicenseActivationState - if err := json.Unmarshal([]byte(raw), &state); err != nil { - return service.LicenseActivationState{}, err - } - return state, nil -} - -func licenseActivationView(state service.LicenseActivationState) gin.H { - updatedAt := state.UpdatedAt - if strings.TrimSpace(updatedAt) == "" { - updatedAt = time.Now().Format(time.RFC3339) - } - return gin.H{ - "id": state.DeviceID, - "key_id": state.LicenseType, - "key": maskLicenseKey(state.LicenseKey), - "device_id": state.DeviceID, - "device_name": state.DeviceName, - "plan": state.LicenseType, - "max_activations": state.MaxDevices, - "max_users": state.MaxUsers, - "unlimited_users": state.UnlimitedUsers, - "expires_at": emptyAsNil(state.ExpiryDate), - "valid": state.Valid && !licenseStateExpired(state.ExpiryDate), - "heartbeat_at": updatedAt, - "created_at": updatedAt, - } -} - -func maskLicenseKey(key string) string { - key = strings.TrimSpace(key) - if key == "" { - return "" - } - if len(key) <= 8 { - return key - } - return key[:5] + "..." + key[len(key)-4:] -} - -func licenseStatusMessage(active bool, clientErr error) string { - if active { - return "已激活" - } - if clientErr != nil && !strings.Contains(clientErr.Error(), "not configured") { - return clientErr.Error() - } - return "开源版:最多 20 个用户" -} - -func licenseStateExpired(expiry string) bool { - if expiry == "" { - return false - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if t, err := time.Parse(layout, expiry); err == nil { - return time.Now().After(t) - } - } - return false -} - -func emptyAsNil(v string) any { - if strings.TrimSpace(v) == "" { - return nil - } - return v -} diff --git a/internal/handler/license_test.go b/internal/handler/license_test.go deleted file mode 100644 index 658b4e2..0000000 --- a/internal/handler/license_test.go +++ /dev/null @@ -1,445 +0,0 @@ -package handler - -import ( - "crypto/ed25519" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestLicenseStatusMaxUsersUsesLicensedLimit(t *testing.T) { - maxUsers := 25 - state := service.LicenseActivationState{Valid: true, MaxUsers: &maxUsers} - - if got := licenseStatusMaxUsers(state); got != maxUsers { - t.Fatalf("expected licensed max users %d, got %#v", maxUsers, got) - } -} - -func TestLicenseStatusMaxUsersAllowsUnlimited(t *testing.T) { - state := service.LicenseActivationState{Valid: true, UnlimitedUsers: true} - - if got := licenseStatusMaxUsers(state); got != nil { - t.Fatalf("expected unlimited max users to be nil, got %#v", got) - } -} - -func TestLicenseStatusMaxUsersFallsBackToOpenSourceLimit(t *testing.T) { - state := service.LicenseActivationState{} - - if got := licenseStatusMaxUsers(state); got != service.OpenSourceUserLimit { - t.Fatalf("expected open-source max users %d, got %#v", service.OpenSourceUserLimit, got) - } -} - -func TestApplyLicenseStatusReflectsEditedLimitAndClearsExpiry(t *testing.T) { - maxUsers := 60 - licenseType := "subscription" - state := service.LicenseActivationState{ - Valid: true, - LicenseType: "enterprise", - ExpiryDate: "2026-01-01", - MaxDevices: 2, - UnlimitedUsers: true, - } - - applyLicenseStatus(&state, licenseServerStatusResp{ - Valid: true, - LicenseType: &licenseType, - ExpiryDate: nil, - MaxDevices: 5, - MaxUsers: &maxUsers, - UnlimitedUsers: false, - DeviceName: "Edited Device", - }, "device-1") - - if !state.Valid || state.LicenseType != "subscription" || state.ExpiryDate != "" || state.MaxDevices != 5 { - t.Fatalf("status fields were not fully refreshed: %+v", state) - } - if state.MaxUsers == nil || *state.MaxUsers != 60 || state.UnlimitedUsers { - t.Fatalf("user limit was not refreshed from status: %+v", state) - } - if state.DeviceID != "device-1" || state.DeviceName != "Edited Device" { - t.Fatalf("device fields were not refreshed: %+v", state) - } -} - -func TestApplyLicenseStatusReflectsUnlimitedUsers(t *testing.T) { - maxUsers := 30 - state := service.LicenseActivationState{Valid: true, MaxUsers: &maxUsers} - - applyLicenseStatus(&state, licenseServerStatusResp{ - Valid: true, - MaxUsers: nil, - UnlimitedUsers: true, - }, "device-1") - - if state.MaxUsers != nil || !state.UnlimitedUsers { - t.Fatalf("unlimited status should clear previous finite user limit: %+v", state) - } -} - -func TestRefreshLicenseServerStatusReflectsEditedLimitAndHeartbeatRequest(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/status/device-1" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "valid": true, - "license_type": "subscription", - "max_devices": 5, - "max_users": 60, - "unlimited_users": false, - "device_name": "NAS", - "heartbeat_requested": true, - "is_active": true - }`)) - })) - defer upstream.Close() - - state := service.LicenseActivationState{Valid: true, UnlimitedUsers: true} - client := &licenseClient{baseURL: upstream.URL, httpClient: upstream.Client()} - - refreshed, ok, requested, err := refreshLicenseServerStatus(t.Context(), client, state, "device-1") - if err != nil { - t.Fatalf("refresh status: %v", err) - } - if !ok || !requested { - t.Fatalf("expected valid status with requested heartbeat, ok=%v requested=%v", ok, requested) - } - if refreshed.MaxUsers == nil || *refreshed.MaxUsers != 60 || refreshed.UnlimitedUsers { - t.Fatalf("edited user limit was not reflected: %+v", refreshed) - } - if refreshed.MaxDevices != 5 || refreshed.DeviceName != "NAS" { - t.Fatalf("server status fields were not applied: %+v", refreshed) - } -} - -func TestLicenseHeartbeatPayloadIncludesStoredLicenseKey(t *testing.T) { - payload := licenseHeartbeatPayload(service.LicenseActivationState{ - LicenseKey: "MS-ABCD-EFGH-JKLM-NPQR", - }, "device-1", "NAS") - - if payload["fingerprint"] != "device-1" || payload["instance_id"] != "device-1" || payload["device_name"] != "NAS" { - t.Fatalf("heartbeat identity payload is wrong: %#v", payload) - } - if payload["key"] != "MS-ABCD-EFGH-JKLM-NPQR" { - t.Fatalf("heartbeat should include stored license key for server-side backfill: %#v", payload) - } -} - -func TestLicenseClientVerifiesEd25519Signature(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 2, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - SignatureAlg: "ed25519", - } - resp.Signature = signLicenseTestPayloadEd25519(privateKey, resp) - - client := &licenseClient{ed25519PublicKey: publicKey} - if err := client.verifySigned(&resp); err != nil { - t.Fatalf("verify ed25519 signature: %v", err) - } -} - -func TestLicenseClientRejectsEd25519WithoutPublicKey(t *testing.T) { - _, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 2, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - SignatureAlg: "ed25519", - } - resp.Signature = signLicenseTestPayloadEd25519(privateKey, resp) - - client := &licenseClient{} - if err := client.verifySigned(&resp); err == nil || !strings.Contains(err.Error(), "public key") { - t.Fatalf("expected missing public key error, got %v", err) - } -} - -func TestLicenseHeartbeatDueUsesTwelveHourWindow(t *testing.T) { - state := service.LicenseActivationState{ - Valid: true, - UpdatedAt: time.Now().Add(-11 * time.Hour).Format(time.RFC3339), - } - if licenseHeartbeatDue(state, 12*time.Hour) { - t.Fatalf("heartbeat should not be due before interval") - } - - state.UpdatedAt = time.Now().Add(-13 * time.Hour).Format(time.RFC3339) - if !licenseHeartbeatDue(state, 12*time.Hour) { - t.Fatalf("heartbeat should be due after interval") - } -} - -func TestStartupLicenseHeartbeatIgnoresTwelveHourWindow(t *testing.T) { - heartbeatCount := 0 - maxUsers := 40 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v1/heartbeat": - heartbeatCount++ - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 2, - MaxUsers: &maxUsers, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - } - resp.Signature = signLicenseTestPayload("test-secret", resp) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - case "/api/v1/status/device-1": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "valid": true, - "license_type": "subscription", - "max_devices": 2, - "max_users": 40, - "unlimited_users": false, - "device_name": "NAS", - "is_active": true - }`)) - default: - t.Fatalf("unexpected upstream path %s", r.URL.Path) - } - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, upstream.URL); err != nil { - t.Fatal(err) - } - if err := svc.Repo.Setting.Set(t.Context(), licenseHMACSecretSetting, "test-secret"); err != nil { - t.Fatal(err) - } - state := service.LicenseActivationState{ - Valid: true, - LicenseKey: "MS-ABCD-EFGH-JKLM-NPQR", - DeviceID: "device-1", - DeviceName: "NAS", - UpdatedAt: time.Now().Format(time.RFC3339), - } - if err := persistLicenseState(t.Context(), svc, state); err != nil { - t.Fatal(err) - } - - refreshed, sent, err := maybeSendStartupLicenseHeartbeat(t.Context(), svc) - if err != nil { - t.Fatalf("startup heartbeat: %v", err) - } - if !sent || heartbeatCount != 1 { - t.Fatalf("startup heartbeat should be sent once, sent=%v count=%d", sent, heartbeatCount) - } - if refreshed.MaxUsers == nil || *refreshed.MaxUsers != 40 { - t.Fatalf("startup heartbeat should refresh licensed user capacity, got %+v", refreshed) - } -} - -func TestStartupLicenseHeartbeatSkipsStateWithoutStoredKey(t *testing.T) { - heartbeatCount := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - heartbeatCount++ - w.WriteHeader(http.StatusInternalServerError) - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, upstream.URL); err != nil { - t.Fatal(err) - } - if err := svc.Repo.Setting.Set(t.Context(), licenseHMACSecretSetting, "test-secret"); err != nil { - t.Fatal(err) - } - if err := persistLicenseState(t.Context(), svc, service.LicenseActivationState{ - Valid: true, - DeviceID: "device-1", - UpdatedAt: time.Now().Format(time.RFC3339), - }); err != nil { - t.Fatal(err) - } - - _, sent, err := maybeSendStartupLicenseHeartbeat(t.Context(), svc) - if err != nil { - t.Fatalf("startup heartbeat should skip without error, got %v", err) - } - if sent || heartbeatCount != 0 { - t.Fatalf("startup heartbeat without stored license key should be skipped, sent=%v count=%d", sent, heartbeatCount) - } -} - -func TestLicenseHeartbeatEligibleRequiresActivationState(t *testing.T) { - if licenseHeartbeatEligible(service.LicenseActivationState{DeviceID: "device-only"}) { - t.Fatalf("device id alone should not trigger automatic license heartbeat") - } - if !licenseHeartbeatEligible(service.LicenseActivationState{LicenseKey: "MS-KEY"}) { - t.Fatalf("stored license key should trigger automatic license heartbeat") - } - if licenseHeartbeatEligible(service.LicenseActivationState{Valid: true}) { - t.Fatalf("valid state without stored license key should not trigger automatic license heartbeat") - } -} - -func TestLicenseStatusSkipsUnlicensedHeartbeatWithDefaultServer(t *testing.T) { - upstreamCalls := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamCalls++ - w.WriteHeader(http.StatusInternalServerError) - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - svc.Cfg.License.ServerURL = upstream.URL - svc.Cfg.License.HMACSecret = "test-secret" - - router := gin.New() - router.GET("/license/status", licenseStatusHandler(svc)) - req := httptest.NewRequest(http.MethodGet, "/license/status", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - if upstreamCalls != 0 { - t.Fatalf("unlicensed status should not contact license server, got %d calls", upstreamCalls) - } -} - -func TestLicenseActivateBindsServerInstanceNotBrowserFingerprint(t *testing.T) { - var upstreamFingerprint string - maxUsers := 60 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var payload map[string]any - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode upstream payload: %v", err) - } - upstreamFingerprint, _ = payload["fingerprint"].(string) - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 3, - MaxUsers: &maxUsers, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - } - resp.Signature = signLicenseTestPayload("test-secret", resp) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, upstream.URL); err != nil { - t.Fatal(err) - } - if err := svc.Repo.Setting.Set(t.Context(), licenseHMACSecretSetting, "test-secret"); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.POST("/license/activate", licenseActivateHandler(svc)) - req := httptest.NewRequest(http.MethodPost, "/license/activate", strings.NewReader(`{ - "key": "MS-ABCD-EFGH-JKLM-NPQR", - "device_id": "browser-fingerprint", - "device_name": "" - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("activate status = %d body=%s", w.Code, w.Body.String()) - } - if upstreamFingerprint == "" || upstreamFingerprint == "browser-fingerprint" || !strings.HasPrefix(upstreamFingerprint, "msgo-") { - t.Fatalf("activation should use server-generated msgo id, got %q", upstreamFingerprint) - } - stored, err := svc.Repo.Setting.Get(t.Context(), licenseDeviceIDSetting) - if err != nil { - t.Fatal(err) - } - if stored != upstreamFingerprint { - t.Fatalf("stored device id = %q, upstream fingerprint = %q", stored, upstreamFingerprint) - } -} - -func TestNewLicenseClientRequiresSignatureVerifier(t *testing.T) { - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, "http://127.0.0.1:8001"); err != nil { - t.Fatal(err) - } - if _, err := newLicenseClient(t.Context(), svc); err == nil || !strings.Contains(err.Error(), "public key or hmac secret") { - t.Fatalf("expected missing signature verifier error, got %v", err) - } -} - -func newLicenseHandlerTestService(t *testing.T) *service.Container { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Setting{}); err != nil { - t.Fatal(err) - } - return &service.Container{ - Cfg: &config.Config{}, - Repo: repository.New(db), - } -} - -func signLicenseTestPayload(secret string, resp licenseServerSignedResp) string { - unsigned := struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - }{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: resp.ExpiryDate, - MaxDevices: resp.MaxDevices, - MaxUsers: resp.MaxUsers, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - } - payload, _ := json.Marshal(unsigned) - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write(payload) - return hex.EncodeToString(mac.Sum(nil)) -} - -func signLicenseTestPayloadEd25519(privateKey ed25519.PrivateKey, resp licenseServerSignedResp) string { - payload, _ := json.Marshal(licenseSignedPayload(resp)) - return base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, payload)) -} diff --git a/internal/handler/media.go b/internal/handler/media.go index 81d0fb7..3f1a893 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -31,7 +31,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - libs = service.FilterDeprecatedNativeCloudLibraries(libs) role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") if !includeHidden { @@ -44,9 +43,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } } libs = filtered - } else { - libs = service.FilterMergedCloudAutoCategoryLibraries(libs) - libs = service.NormalizeCloudLibraryDisplayNames(libs) } c.JSON(http.StatusOK, libs) } @@ -63,23 +59,18 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib}) - if len(libs) == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") - if includeHidden { - c.JSON(http.StatusOK, service.NormalizeCloudLibraryDisplayNames(libs)[0]) - return + if !includeHidden { + libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib}) + if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, libs[0]) + } else { + c.JSON(http.StatusOK, lib) } - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) - if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - c.JSON(http.StatusOK, libs[0]) } } @@ -152,11 +143,6 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc { func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") - if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), id); err == nil && lib != nil { - if _, ok := service.ParseCloudLibraryMount(lib.Path); ok && svc.Scan != nil { - _ = svc.Scan.CancelCloudScan(id) - } - } if err := svc.Media.DeleteLibrary(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/media_scan.go b/internal/handler/media_scan.go index 21fe5d0..9c4aab0 100644 --- a/internal/handler/media_scan.go +++ b/internal/handler/media_scan.go @@ -23,47 +23,8 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { return } if _, ok := service.ParseCloudLibraryMount(lib.Path); ok { - status, started, startErr := svc.Scan.StartCloudLibraryScan(id, true) - if startErr != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": startErr.Error()}) - return - } - if !started { - c.JSON(http.StatusAccepted, gin.H{ - "library_id": id, - "queued": true, - "cloud": true, - "already_running": true, - "stage": status.Stage, - "state": status.State, - "message": "该云盘媒体库正在后台扫描,请在任务面板查看进度", - "estimate_message": "页面关闭不会中断扫描", - }) - return - } - task := startScanHTTPTask(svc, "云盘扫描队列", lib.Name, lib.Path) - if svc.WSHub != nil { - svc.WSHub.Publish("scan", gin.H{ - "library_id": id, - "cloud": true, - "queued": true, - "stage": "queued", - "message": "云盘扫描已加入后台队列,会递归扫描并自动加入媒体库", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } - finishHTTPTask(task, nil, "queued", "云盘扫描已加入后台队列", map[string]int64{"queued": 1}, nil) - c.JSON(http.StatusAccepted, gin.H{ - "library_id": id, - "visited": 0, - "added": 0, - "updated": 0, - "probed": 0, - "queued": true, - "cloud": true, - "message": "云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库;若已开启自动刮削,会在扫描后补齐元数据", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) + // 云盘扫描已随网盘后端移除;此处保留空分支以兼容既有客户端。 + c.JSON(http.StatusBadRequest, gin.H{"error": "cloud 网盘后端已移除,无法扫描云盘媒体库"}) return } finishScan, ok := svc.Scan.TryBeginLocalScan(id) diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 3a8a421..6e2ea16 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -110,86 +110,6 @@ func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T) } } -func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - cloud := model.Library{Name: "OpenList · 国产剧", Path: service.BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &cloud); err != nil { - t.Fatal(err) - } - svc := &service.Container{ - Repo: repos, - Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), - } - - all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1") - if len(all) != 1 { - t.Fatalf("include_hidden list = %#v, want one library", all) - } - if all[0].Name != "国产剧" { - t.Fatalf("cloud display name = %q, want stripped directory name", all[0].Name) - } -} - -func TestListLibrariesShowsAutoCategoryLibraries(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - auto := model.Library{Name: "欧美剧", Path: service.BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &auto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - svc := &service.Container{ - Repo: repos, - Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), - } - - all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1") - if len(all) != 2 { - t.Fatalf("include_hidden list = %#v, want root plus auto category library", all) - } - ids := map[string]bool{} - for _, lib := range all { - ids[lib.ID] = true - } - if !ids[root.ID] || !ids[auto.ID] { - t.Fatalf("include_hidden list = %#v, want root %s and auto category %s", all, root.ID, auto.ID) - } - - visible := requestLibraries(t, svc, "user-1", "user", "/api/libraries") - if len(visible) != 2 { - t.Fatalf("visible list = %#v, want root plus auto category library", visible) - } - ids = map[string]bool{} - for _, lib := range visible { - ids[lib.ID] = true - } - if !ids[root.ID] || !ids[auto.ID] { - t.Fatalf("visible list = %#v, want root %s and auto category %s", visible, root.ID, auto.ID) - } - - got := requestLibrary(t, svc, "user-1", "user", "/api/libraries/"+auto.ID, auto.ID) - if got.ID != auto.ID || got.Name != auto.Name { - t.Fatalf("auto category detail = %#v, want accessible category library", got) - } -} - func TestGetLibraryAllowsEmptyLibrary(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -336,41 +256,6 @@ func TestListLibrarySeriesDoesNotTruncateLargeEpisodeLibraries(t *testing.T) { } } -func TestScanLibraryHandlerSurfacesCloudQueueStartFailure(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - lib := model.Library{Name: "旧夸克云盘", Path: service.BuildCloudLibraryPath(service.LegacyQuarkProvider, "archive", "archive"), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - log := zap.NewNop() - svc := &service.Container{ - Log: log, - Repo: repos, - Scan: service.NewScannerService(&config.Config{}, log, repos, service.NewHub(log), nil, nil), - } - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: lib.ID}} - c.Request = httptest.NewRequest(http.MethodPost, "/api/libraries/"+lib.ID+"/scan", nil) - - scanLibraryHandler(svc)(c) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s, want bad request", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "deprecated") { - t.Fatalf("body=%s, want cloud queue start error", w.Body.String()) - } -} - func TestScrapeOptionsFromRequestPreservesEpisodeImagesFalse(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/internal/handler/metadata_enrich_download.go b/internal/handler/metadata_enrich_download.go deleted file mode 100644 index f94a571..0000000 --- a/internal/handler/metadata_enrich_download.go +++ /dev/null @@ -1,188 +0,0 @@ -package handler - -import ( - "context" - "net/url" - "path" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func enrichAndPersistDownloadRows(ctx context.Context, svc *service.Container, rows []model.DownloadTask) { - for i := range rows { - before := rows[i] - enrichDownloadRow(ctx, svc, &rows[i]) - updates := downloadTaskMetadataUpdates(before, rows[i]) - if len(updates) == 0 { - continue - } - if err := svc.Repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", rows[i].ID).Updates(updates).Error; err != nil { - svc.Log.Debug("download artwork backfill failed", zap.String("id", rows[i].ID), zap.Error(err)) - } - } -} - -func enrichDownloadRow(ctx context.Context, svc *service.Container, row *model.DownloadTask) { - if strings.TrimSpace(row.Title) == "" { - row.Title = downloadDisplayTitle(row.URL) - } - if !downloadTaskNeedsMetadata(*row) { - return - } - meta := enrichDownloadTaskMeta(ctx, svc, service.DownloadTaskMeta{ - Title: row.Title, - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - Overview: row.Overview, - OriginalName: row.OriginalName, - OriginalLanguage: row.OriginalLanguage, - Year: row.Year, - Rating: row.Rating, - Genres: row.Genres, - }, firstNonEmptyString(row.Title, row.URL), row.MediaType) - row.Title = firstNonEmptyString(row.Title, meta.Title) - row.PosterURL = meta.PosterURL - row.BackdropURL = meta.BackdropURL - row.Overview = meta.Overview - row.OriginalName = meta.OriginalName - row.OriginalLanguage = meta.OriginalLanguage - row.Year = meta.Year - row.Rating = meta.Rating - row.Genres = meta.Genres -} - -func downloadTaskNeedsMetadata(row model.DownloadTask) bool { - return strings.TrimSpace(row.PosterURL) == "" || strings.TrimSpace(row.BackdropURL) == "" || - strings.TrimSpace(row.Overview) == "" || row.Year <= 0 || row.Rating <= 0 || - strings.TrimSpace(row.Genres) == "" || strings.TrimSpace(row.OriginalName) == "" || - strings.TrimSpace(row.OriginalLanguage) == "" -} - -func downloadTaskMetadataUpdates(before, after model.DownloadTask) map[string]any { - updates := map[string]any{} - if before.Title != after.Title { - updates["title"] = after.Title - } - if before.PosterURL != after.PosterURL { - updates["poster_url"] = after.PosterURL - } - if before.BackdropURL != after.BackdropURL { - updates["backdrop_url"] = after.BackdropURL - } - if before.Overview != after.Overview { - updates["overview"] = after.Overview - } - if before.OriginalName != after.OriginalName { - updates["original_name"] = after.OriginalName - } - if before.OriginalLanguage != after.OriginalLanguage { - updates["original_language"] = after.OriginalLanguage - } - if before.Year != after.Year { - updates["year"] = after.Year - } - if before.Rating != after.Rating { - updates["rating"] = after.Rating - } - if before.Genres != after.Genres { - updates["genres"] = after.Genres - } - return updates -} - -func enrichDownloadTorrentViews(ctx context.Context, svc *service.Container, views []service.DownloadTorrentView) { - cache := map[string]displayMetadata{} - for i := range views { - if strings.TrimSpace(views[i].PosterURL) != "" && strings.TrimSpace(views[i].BackdropURL) != "" { - continue - } - query := firstNonEmptyString(views[i].Title, views[i].Name) - cacheKey, _ := metadataSearchQuery(query) - meta, ok := cache[cacheKey] - if !ok { - meta = lookupDisplayMetadata(ctx, svc, query, "", "") - cache[cacheKey] = meta - } - if strings.TrimSpace(views[i].PosterURL) == "" { - views[i].PosterURL = meta.PosterURL - } - if strings.TrimSpace(views[i].BackdropURL) == "" { - views[i].BackdropURL = meta.BackdropURL - } - if strings.TrimSpace(views[i].Overview) == "" { - views[i].Overview = meta.Overview - } - if (views[i].Title == "" || views[i].Title == views[i].Name) && meta.Title != "" { - views[i].Title = meta.Title - } - } -} - -func enrichDownloadTaskMeta(ctx context.Context, svc *service.Container, meta service.DownloadTaskMeta, fallbackTitle, mediaType string) service.DownloadTaskMeta { - if strings.TrimSpace(meta.Title) == "" { - meta.Title = strings.TrimSpace(downloadDisplayTitle(fallbackTitle)) - } - // 仅当展示字段都齐时才跳过查询(含媒体富通知所需的年份/评分/类型/原始信息)。 - if strings.TrimSpace(meta.PosterURL) != "" && strings.TrimSpace(meta.BackdropURL) != "" && - strings.TrimSpace(meta.Overview) != "" && meta.Year > 0 && meta.Rating > 0 && - strings.TrimSpace(meta.Genres) != "" && strings.TrimSpace(meta.OriginalName) != "" && - strings.TrimSpace(meta.OriginalLanguage) != "" { - return meta - } - found := lookupDisplayMetadata(ctx, svc, meta.Title, fallbackTitle, mediaType) - if strings.TrimSpace(meta.Title) == "" { - meta.Title = found.Title - } - if strings.TrimSpace(meta.PosterURL) == "" { - meta.PosterURL = found.PosterURL - } - if strings.TrimSpace(meta.BackdropURL) == "" { - meta.BackdropURL = found.BackdropURL - } - if strings.TrimSpace(meta.Overview) == "" { - meta.Overview = found.Overview - } - if strings.TrimSpace(meta.OriginalName) == "" { - meta.OriginalName = found.OriginalName - } - if strings.TrimSpace(meta.OriginalLanguage) == "" { - meta.OriginalLanguage = found.OriginalLanguage - } - if meta.Year <= 0 && found.Year > 0 { - meta.Year = found.Year - } - if meta.Rating <= 0 && found.Rating > 0 { - meta.Rating = found.Rating - } - if strings.TrimSpace(meta.Genres) == "" { - meta.Genres = found.Genres - } - return meta -} - -func downloadDisplayTitle(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if u, err := url.Parse(raw); err == nil { - if dn := strings.TrimSpace(u.Query().Get("dn")); dn != "" { - if decoded, err := url.QueryUnescape(dn); err == nil && strings.TrimSpace(decoded) != "" { - return strings.TrimSpace(decoded) - } - return dn - } - if u.Host != "" { - base := path.Base(u.Path) - if base != "." && base != "/" && base != "" { - base = strings.TrimSuffix(base, path.Ext(base)) - return strings.TrimSpace(base) - } - } - } - return raw -} diff --git a/internal/handler/metadata_enrich_subscription.go b/internal/handler/metadata_enrich_subscription.go deleted file mode 100644 index 40c3b78..0000000 --- a/internal/handler/metadata_enrich_subscription.go +++ /dev/null @@ -1,100 +0,0 @@ -package handler - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func enrichSubscriptionArtwork(ctx context.Context, svc *service.Container, sub *model.Subscription) { - if svc == nil || sub == nil { - return - } - // 已有图片且媒体展示字段也齐全时才跳过;否则仍需查一次补 年份/评分/类型 等 - // 富通知字段(老订阅只存了 poster/overview 的情况)。 - if strings.TrimSpace(sub.PosterURL) != "" && strings.TrimSpace(sub.BackdropURL) != "" && - sub.Year > 0 && sub.Rating > 0 && strings.TrimSpace(sub.Genres) != "" { - return - } - meta := lookupDisplayMetadata(ctx, svc, sub.Name, sub.Filter, sub.MediaType) - if meta.Title == "" && meta.PosterURL == "" && meta.BackdropURL == "" && meta.Overview == "" { - return - } - if strings.TrimSpace(sub.Source) == "" { - sub.Source = meta.Source - } - if strings.TrimSpace(sub.PosterURL) == "" { - sub.PosterURL = meta.PosterURL - } - if strings.TrimSpace(sub.BackdropURL) == "" { - sub.BackdropURL = meta.BackdropURL - } - if strings.TrimSpace(sub.Overview) == "" { - sub.Overview = meta.Overview - } - if strings.TrimSpace(sub.OriginalName) == "" { - sub.OriginalName = meta.OriginalName - } - if strings.TrimSpace(sub.OriginalLanguage) == "" { - sub.OriginalLanguage = meta.OriginalLanguage - } - if sub.Year <= 0 && meta.Year > 0 { - sub.Year = meta.Year - } - if sub.Rating <= 0 && meta.Rating > 0 { - sub.Rating = meta.Rating - } - if strings.TrimSpace(sub.Genres) == "" { - sub.Genres = meta.Genres - } -} - -func enrichAndPersistSubscriptions(ctx context.Context, svc *service.Container, items []model.Subscription) { - for i := range items { - before := items[i] - enrichSubscriptionArtwork(ctx, svc, &items[i]) - updates := subscriptionMetadataUpdates(before, items[i]) - if len(updates) == 0 { - continue - } - if err := svc.Repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", items[i].ID).Updates(updates).Error; err != nil { - svc.Log.Debug("subscription artwork backfill failed", zap.String("id", items[i].ID), zap.Error(err)) - } - } -} - -func subscriptionMetadataUpdates(before, after model.Subscription) map[string]any { - updates := map[string]any{} - if before.Source != after.Source { - updates["source"] = after.Source - } - if before.PosterURL != after.PosterURL { - updates["poster_url"] = after.PosterURL - } - if before.BackdropURL != after.BackdropURL { - updates["backdrop_url"] = after.BackdropURL - } - if before.Overview != after.Overview { - updates["overview"] = after.Overview - } - if before.OriginalName != after.OriginalName { - updates["original_name"] = after.OriginalName - } - if before.OriginalLanguage != after.OriginalLanguage { - updates["original_language"] = after.OriginalLanguage - } - if before.Year != after.Year { - updates["year"] = after.Year - } - if before.Rating != after.Rating { - updates["rating"] = after.Rating - } - if before.Genres != after.Genres { - updates["genres"] = after.Genres - } - return updates -} diff --git a/internal/handler/notify.go b/internal/handler/notify.go deleted file mode 100644 index e1928bf..0000000 --- a/internal/handler/notify.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package handler — notification test endpoint. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type notifyTestReq struct { - Title string `json:"title" binding:"required"` - Body string `json:"body" binding:"required"` -} - -func notifyTestHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req notifyTestReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - svc.Notifier.Send(c.Request.Context(), req.Title, req.Body, "test") - c.JSON(http.StatusOK, gin.H{"message": "notification dispatched"}) - } -} diff --git a/internal/handler/notify_channels.go b/internal/handler/notify_channels.go deleted file mode 100644 index 1f820fb..0000000 --- a/internal/handler/notify_channels.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package handler — notify channel CRUD + per-channel test endpoint. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, err := svc.NotifyChannels.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if rows == nil { - c.JSON(http.StatusOK, []struct{}{}) - return - } - c.JSON(http.StatusOK, rows) - } -} - -func createNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.ChannelInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.NotifyChannels.Create(c.Request.Context(), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, row) - } -} - -func updateNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.ChannelInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.NotifyChannels.Update(c.Request.Context(), c.Param("id"), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, row) - } -} - -func deleteNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.NotifyChannels.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -func testNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.NotifyChannels.Test(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "test sent"}) - } -} diff --git a/internal/handler/notify_handler.go b/internal/handler/notify_handler.go deleted file mode 100644 index 037a1c2..0000000 --- a/internal/handler/notify_handler.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package handler — 通知渠道管理 HTTP 端点。 -package handler - -import ( - "encoding/json" - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// NotifyHandler 处理通知渠道的 CRUD 操作。 -type NotifyHandler struct { - svc *service.Container - log *zap.Logger -} - -// NewNotifyHandler 创建通知渠道处理器。 -func NewNotifyHandler(svc *service.Container, log *zap.Logger) *NotifyHandler { - return &NotifyHandler{svc: svc, log: log} -} - -// notifyCreateRequest 创建通知渠道请求体。 -type notifyCreateRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required,oneof=telegram wechat bark webhook email"` - Enabled bool `json:"enabled"` - Config map[string]string `json:"config" binding:"required"` - Events []string `json:"events"` -} - -// notifyUpdateRequest 更新通知渠道请求体。 -type notifyUpdateRequest struct { - Name string `json:"name"` - Enabled *bool `json:"enabled"` - Config map[string]string `json:"config"` - Events []string `json:"events"` -} - -// Create 创建新的通知渠道。 -func (h *NotifyHandler) Create(c *gin.Context) { - var req notifyCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - - // 验证配置 - if err := h.svc.Notify.ValidateChannelConfig(req.Type, req.Config); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, "配置验证失败: "+err.Error()) - return - } - - // 加密配置 - configJSON, _ := json.Marshal(req.Config) - configStr := string(configJSON) - if h.svc.Crypto != nil { - configStr = h.svc.Crypto.Encrypt(configStr) - } - - // 序列化事件列表 - eventsJSON, _ := json.Marshal(req.Events) - eventsStr := string(eventsJSON) - - channel := &model.NotifyChannel{ - Name: req.Name, - Type: req.Type, - Enabled: req.Enabled, - Config: configStr, - Events: eventsStr, - } - - if err := h.svc.Repo.NotifyChannel.Create(ctx, channel); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "创建失败: "+err.Error()) - return - } - - Success(c, channel) -} - -// List 返回所有通知渠道。 -func (h *NotifyHandler) List(c *gin.Context) { - ctx := c.Request.Context() - channels, err := h.svc.Repo.NotifyChannel.List(ctx) - if err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "查询失败") - return - } - Success(c, channels) -} - -// Get 返回指定通知渠道详情。 -func (h *NotifyHandler) Get(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - channel, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id) - if err != nil || channel == nil { - Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在") - return - } - Success(c, channel) -} - -// Update 更新通知渠道。 -func (h *NotifyHandler) Update(c *gin.Context) { - id := c.Param("id") - var req notifyUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - channel, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id) - if err != nil || channel == nil { - Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在") - return - } - - if req.Name != "" { - channel.Name = req.Name - } - if req.Enabled != nil { - channel.Enabled = *req.Enabled - } - - // 更新配置 - if len(req.Config) > 0 { - if err := h.svc.Notify.ValidateChannelConfig(channel.Type, req.Config); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, "配置验证失败: "+err.Error()) - return - } - configJSON, _ := json.Marshal(req.Config) - configStr := string(configJSON) - if h.svc.Crypto != nil { - configStr = h.svc.Crypto.Encrypt(configStr) - } - channel.Config = configStr - } - - // 更新事件列表 - if req.Events != nil { - eventsJSON, _ := json.Marshal(req.Events) - channel.Events = string(eventsJSON) - } - - if err := h.svc.Repo.NotifyChannel.Update(ctx, channel); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "更新失败") - return - } - - Success(c, channel) -} - -// Delete 删除通知渠道。 -func (h *NotifyHandler) Delete(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - _, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id) - if err != nil { - Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在") - return - } - - if delErr := h.svc.Repo.NotifyChannel.Delete(ctx, id); delErr != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "删除失败") - return - } - - SuccessWithMessage(c, "已删除", nil) -} - -// Test 发送测试通知。 -func (h *NotifyHandler) Test(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - if err := h.svc.Notify.SendTest(ctx, id); err != nil { - Error(c, http.StatusBadRequest, ErrExternal, "测试通知发送失败: "+err.Error()) - return - } - - SuccessWithMessage(c, "测试通知已发送", nil) -} - -// GetTypes 返回支持的通知渠道类型列表。 -func (h *NotifyHandler) GetTypes(c *gin.Context) { - types := h.svc.Notify.GetProviderTypes() - Success(c, types) -} diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index ac5fe3b..eb5d846 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -275,63 +275,6 @@ func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) { } } -func TestScopedPlaybackTokenCanFollowCloudRedirectForSameMedia(t *testing.T) { - router, svc, _ := newPlaybackScopeTestRouter(t) - user, err := svc.Repo.User.FindByID(t.Context(), "user-1") - if err != nil || user == nil { - t.Fatalf("find user: %v", err) - } - playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) - if err != nil { - t.Fatalf("issue playback token: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-1?token="+url.QueryEscape(playToken), nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("stream status = %d body=%s, want 302", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/cloud/play/openlist?") || - !strings.Contains(loc, "media_id=media-1") || - !strings.Contains(loc, "token="+url.QueryEscape(playToken)) { - t.Fatalf("redirect Location should carry scoped token and media_id, got %q", loc) - } - - req = httptest.NewRequest(http.MethodGet, loc, nil) - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code == http.StatusForbidden || w.Code == http.StatusUnauthorized { - t.Fatalf("cloud redirect rejected scoped token: status=%d body=%s", w.Code, w.Body.String()) - } - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("cloud redirect status = %d body=%s, want storage service fallback 503", w.Code, w.Body.String()) - } -} - -func TestScopedPlaybackTokenCannotRetargetCloudRef(t *testing.T) { - router, svc, _ := newPlaybackScopeTestRouter(t) - user, err := svc.Repo.User.FindByID(t.Context(), "user-1") - if err != nil || user == nil { - t.Fatalf("find user: %v", err) - } - playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) - if err != nil { - t.Fatalf("issue playback token: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/cloud/play/openlist?ref=other&media_id=media-1&token="+url.QueryEscape(playToken), nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } -} - func TestScopedPlaybackTokenCannotCallRegularAPI(t *testing.T) { router, svc, _ := newPlaybackScopeTestRouter(t) api := router.Group("/api") @@ -464,6 +407,5 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, api.GET("/playback/:id/external-url", externalURLHandler(svc)) api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) api.GET("/stream/:id", streamHandler(svc)) - api.GET("/cloud/play/:type", cloudPlayHandler(svc)) return router, svc, cfg.Secrets.JWTSecret } diff --git a/internal/handler/recycle.go b/internal/handler/recycle.go index e88e369..cc9d352 100644 --- a/internal/handler/recycle.go +++ b/internal/handler/recycle.go @@ -2,19 +2,13 @@ package handler import ( - "context" "net/http" - "strings" "github.com/gin-gonic/gin" "github.com/ShukeBta/MediaStationGo/internal/service" ) -type recycleBatchReq struct { - MediaIDs []string `json:"media_ids"` -} - func deleteMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Media.SoftDelete(c.Request.Context(), c.Param("id")); err != nil { @@ -25,17 +19,6 @@ func deleteMediaHandler(svc *service.Container) gin.HandlerFunc { } } -func listRecycleHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Media.ListRecycleBin(c.Request.Context(), 200) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - func restoreMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Media.RestoreDeleted(c.Request.Context(), c.Param("id")); err != nil { @@ -46,22 +29,6 @@ func restoreMediaHandler(svc *service.Container) gin.HandlerFunc { } } -func restoreMediaBatchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req recycleBatchReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - applied, errorsOut := runRecycleBatch(c, compactManualScrapeIDs(req.MediaIDs), svc.Media.RestoreDeleted) - if applied == 0 && len(errorsOut) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": strings.Join(errorsOut, "\n")}) - return - } - c.JSON(http.StatusOK, gin.H{"applied": applied, "errors": errorsOut}) - } -} - func purgeMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Media.PurgeDeleted(c.Request.Context(), c.Param("id")); err != nil { @@ -71,35 +38,3 @@ func purgeMediaHandler(svc *service.Container) gin.HandlerFunc { c.Status(http.StatusNoContent) } } - -func purgeMediaBatchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req recycleBatchReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - applied, errorsOut := runRecycleBatch(c, compactManualScrapeIDs(req.MediaIDs), svc.Media.PurgeDeleted) - if applied == 0 && len(errorsOut) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": strings.Join(errorsOut, "\n")}) - return - } - c.JSON(http.StatusOK, gin.H{"applied": applied, "errors": errorsOut}) - } -} - -func runRecycleBatch(c *gin.Context, ids []string, action func(context.Context, string) error) (int, []string) { - if len(ids) == 0 { - return 0, []string{"media_ids required"} - } - applied := 0 - errorsOut := make([]string, 0) - for _, id := range ids { - if err := action(c.Request.Context(), id); err != nil { - errorsOut = append(errorsOut, id+": "+err.Error()) - continue - } - applied++ - } - return applied, errorsOut -} diff --git a/internal/handler/repair_rescrape.go b/internal/handler/repair_rescrape.go deleted file mode 100644 index 017d876..0000000 --- a/internal/handler/repair_rescrape.go +++ /dev/null @@ -1,84 +0,0 @@ -// Package handler — batch repair+rescrape endpoint. -package handler - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// repairAndRescrapeAllHandler 触发"全库修复+重刮"流程:先从媒体路径中的 -// {tmdb-N}/{bangumi-N} 等占位符回填缺失的外部 ID, 再对所有媒体库重刮一遍。 -// -// 路由: POST /api/admin/media/repair-rescrape (需 admin) -// 异步执行, 立即返回 202;通过 WS hub "scrape" topic 推送进度。 -func repairAndRescrapeAllHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - options, err := scrapeOptionsFromRequest(c, true) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"}) - return - } - task := startScrapeHTTPTask(svc, "全库修复并重刮", "", "") - go func(options service.ScrapeOptions) { - result, err := svc.RepairAndRescrapeAllLibraries(context.Background(), options) - metrics := map[string]int64{ - "repaired": int64(result.Repaired), - "reclassified": int64(result.Reclassified), - "libraries": int64(result.Libraries), - "matched": int64(result.Matched), - "processed": int64(result.Processed), - "errors": int64(result.Errors), - "reset": int64(result.Reset), - } - stage := "completed" - message := "全库修复并重刮完成" - if err != nil { - stage = "scrape" - message = "全库修复并重刮失败" - } - finishHTTPTask(task, err, stage, message, metrics, nil) - }(options) - c.JSON(http.StatusAccepted, gin.H{"status": "started"}) - } -} - -// repairAndRescrapeLibraryHandler 触发"单库修复+重刮":只对路径参数指定的 -// 媒体库回填占位符外部 ID 并重刮, 不影响其它库。 -// -// 路由: POST /api/admin/libraries/:id/repair-rescrape (需 admin) -// 异步执行, 立即返回 202;通过 WS hub "scrape" topic 推送进度。 -func repairAndRescrapeLibraryHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Param("id") - options, err := scrapeOptionsFromRequest(c, true) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"}) - return - } - task := startScrapeHTTPTask(svc, "媒体库修复并重刮", "", "") - go func(options service.ScrapeOptions) { - result, err := svc.RepairAndRescrapeLibrary(context.Background(), libraryID, options) - metrics := map[string]int64{ - "repaired": int64(result.Repaired), - "reclassified": int64(result.Reclassified), - "libraries": int64(result.Libraries), - "matched": int64(result.Matched), - "processed": int64(result.Processed), - "errors": int64(result.Errors), - "reset": int64(result.Reset), - } - stage := "completed" - message := "媒体库修复并重刮完成" - if err != nil { - stage = "scrape" - message = "媒体库修复并重刮失败" - } - finishHTTPTask(task, err, stage, message, metrics, nil) - }(options) - c.JSON(http.StatusAccepted, gin.H{"status": "started"}) - } -} diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 3651963..bd1f5e8 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -14,17 +14,10 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service. admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired()) registerAdminUserRoutes(admin, svc) registerAdminPermissionRoutes(admin, svc) - registerAdminStorageRoutes(admin, svc) - registerAdminCloudRoutes(admin, svc) - registerAdminDownloadClientRoutes(admin, svc) registerAdminSystemRoutes(admin, svc) registerAdminBackupRoutes(admin, svc) - registerAdminNotificationRoutes(admin, svc) - registerAdminTelegramRoutes(admin, svc) registerAdminOrganizerRoutes(admin, svc) - registerAdminRepairRoutes(admin, svc) registerAdminAPIConfigRoutes(admin, svc) - registerAdminSchedulerRoutes(admin, svc) registerAdminRecognitionWordRoutes(admin, svc) } @@ -48,39 +41,7 @@ func registerAdminPermissionRoutes(admin *gin.RouterGroup, svc *service.Containe admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc)) } -func registerAdminStorageRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/storage/status", listStorageConfigsHandler(svc)) - admin.GET("/storage/:type", getStorageConfigHandler(svc)) - admin.PUT("/storage/:type", saveStorageConfigHandler(svc)) - admin.POST("/storage/:type/test", testStorageConfigHandler(svc)) - admin.POST("/storage/:type/logout", logoutStorageConfigHandler(svc)) - admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc)) -} - -func registerAdminCloudRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/cloud/scan-all", cloudScanAllHandler(svc)) - admin.POST("/cloud/scan/cancel", cloudScanCancelHandler(svc)) - admin.GET("/cloud/scan/status", cloudScanStatusHandler(svc)) - admin.GET("/cloud/:type/list", cloudListHandler(svc)) - admin.POST("/cloud/:type/mkdir", cloudMkdirHandler(svc)) - admin.PUT("/cloud/:type/rename", cloudRenameHandler(svc)) - admin.POST("/cloud/:type/import", cloudImportHandler(svc)) - admin.POST("/cloud/:type/mount", cloudMountHandler(svc)) - admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc)) - admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc)) -} - -func registerAdminDownloadClientRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/download/clients", listDownloadClientsHandler(svc)) - admin.POST("/download/clients", createDownloadClientHandler(svc)) - admin.PUT("/download/clients/:id", updateDownloadClientHandler(svc)) - admin.DELETE("/download/clients/:id", deleteDownloadClientHandler(svc)) - admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc)) - admin.GET("/download/aria2/stats", aria2StatsHandler(svc)) -} - func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc)) admin.GET("/system/update", systemUpdateStatusHandler(svc)) admin.POST("/system/update/check", systemUpdateCheckHandler(svc)) admin.POST("/system/update/apply", systemUpdateApplyHandler(svc)) @@ -93,22 +54,6 @@ func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/backups/restore", restoreBackupHandler(svc)) } -func registerAdminNotificationRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/notify/test", notifyTestHandler(svc)) - admin.GET("/notify/channels", listNotifyChannelsHandler(svc)) - admin.POST("/notify/channels", createNotifyChannelHandler(svc)) - admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc)) - admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc)) - admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc)) -} - -func registerAdminTelegramRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/telegram/webhook", telegramGetWebhookHandler(svc)) - admin.POST("/telegram/webhook", telegramSetWebhookHandler(svc)) - admin.POST("/telegram/polling/start", telegramStartPollingHandler(svc)) - admin.POST("/telegram/polling/stop", telegramStopPollingHandler(svc)) -} - func registerAdminOrganizerRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/media/:id/organize", organizeMediaHandler(svc)) admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc)) @@ -116,11 +61,6 @@ func registerAdminOrganizerRoutes(admin *gin.RouterGroup, svc *service.Container admin.POST("/organize/source", organizeDirectoryHandler(svc)) } -func registerAdminRepairRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/media/repair-rescrape", repairAndRescrapeAllHandler(svc)) - admin.POST("/libraries/:id/repair-rescrape", repairAndRescrapeLibraryHandler(svc)) -} - func registerAdminAPIConfigRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.GET("/api-configs", listAPIConfigsHandler(svc)) admin.GET("/api-configs/:provider", getAPIConfigHandler(svc)) @@ -129,11 +69,6 @@ func registerAdminAPIConfigRoutes(admin *gin.RouterGroup, svc *service.Container admin.POST("/api-configs/:provider/test", testAPIConfigHandler(svc)) } -func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/scheduler", schedulerStatusHandler(svc)) - admin.POST("/scheduler/:name/run", schedulerRunHandler(svc)) -} - func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.GET("/recognition-words", getRecognitionWordsHandler(svc)) admin.PUT("/recognition-words", saveRecognitionWordsHandler(svc)) diff --git a/internal/handler/routes_admin_test.go b/internal/handler/routes_admin_test.go index 5f54e73..fd5d2a3 100644 --- a/internal/handler/routes_admin_test.go +++ b/internal/handler/routes_admin_test.go @@ -26,17 +26,9 @@ func TestAdminRouteSurfacesAreRegistered(t *testing.T) { for _, want := range []string{ "GET /api/admin/users", "GET /api/admin/users/:id/permissions", - "GET /api/admin/storage/status", - "GET /api/admin/cloud/:type/list", - "GET /api/admin/download/clients", - "POST /api/admin/system/scheduler/:name/trigger", "POST /api/admin/backups", - "GET /api/admin/notify/channels", - "GET /api/admin/telegram/webhook", "GET /api/admin/organize/sources", - "POST /api/admin/media/repair-rescrape", "GET /api/admin/api-configs", - "POST /api/admin/scheduler/:name/run", } { if !routes[want] { t.Fatalf("%s route is not registered", want) diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index e810cea..b7e4992 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -19,26 +19,15 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc * registerAuthedMediaRoutes(authed, svc) registerAuthedPlaybackAndProxyRoutes(authed, svc) registerAuthedCollectionRoutes(authed, svc) - registerAuthedDownloadRoutes(authed, svc) - registerAuthedSubscriptionRoutes(authed, svc) - registerAuthedStatsDiscoveryAndAIRoutes(authed, svc) registerAuthedFileRoutes(authed, svc) registerAuthedDLNARoutes(authed, svc) - registerAuthedSTRMRoutes(authed, svc) - registerAuthedDuplicateRoutes(authed, svc) - registerAuthedSiteRoutes(authed, svc) registerAuthedRecycleAndRealtimeRoutes(authed, svc) - registerAuthedSchedulerRoutes(authed, svc) registerAuthedUISurfaceRoutes(authed, svc) registerAuthedSearchRoutes(authed, svc) registerAuthedSystemExtraRoutes(authed, svc) registerAuthedStatsExtraRoutes(authed, svc) - registerAuthedSitesExtraRoutes(authed, svc) - registerAuthedSubscriptionExtraRoutes(authed, svc) registerAuthedPlaylistExtraRoutes(authed, svc) registerAuthedDLNAControlRoutes(authed, svc) registerAuthedFavoriteAndMediaActionRoutes(authed, svc) registerAuthedPlaybackExtraRoutes(authed, svc) - registerAuthedDownloadOpsRoutes(authed, svc) - registerAuthedAssistantRoutes(authed, svc) } diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 4a0caa1..ecfc179 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -14,10 +14,6 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co authed.POST("/me/logout", logoutHandler(svc)) authed.GET("/auth/permissions", getMyPermissionsHandler(svc)) - - authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc)) - authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc)) - authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc)) } func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container) { @@ -65,11 +61,6 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service. authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc)) authed.DELETE("/hls/:id", stopTranscodeHandler(svc)) - authed.GET("/cloud/play/:type", cloudPlayHandler(svc)) - authed.HEAD("/cloud/play/:type", cloudPlayHandler(svc)) - - authed.GET("/img/cloud/:type", cloudArtworkProxyHandler(svc)) - authed.HEAD("/img/cloud/:type", cloudArtworkProxyHandler(svc)) authed.GET("/img", imageProxyHandler(svc)) } diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index 7e4b256..bc712a2 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -16,18 +16,8 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain authed.DELETE("/watch-history", historyDeleteHandler(svc)) authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc)) - authed.GET("/discover/sections", requirePermission(svc, "can_view_discover"), discoverSectionsHandler(svc)) - authed.GET("/discover/feed", requirePermission(svc, "can_view_discover"), discoverFeedHandler(svc)) - authed.GET("/system/info", systemInfoHandler(svc)) authed.GET("/system/status", systemStatusHandler(svc)) - authed.GET("/system/scheduler", systemSchedulerHandler(svc)) - - authed.GET("/stats/overview", statsOverviewHandler(svc)) - authed.GET("/stats/trend", statsTrendHandler(svc)) - authed.GET("/stats/top-content", statsTopContentHandler(svc)) - authed.GET("/stats/libraries", statsLibrariesHandler(svc)) - authed.GET("/stats/monitor", statsMonitorHandler(svc)) authed.GET("/play-profiles", listPlayProfilesHandler(svc)) authed.POST("/play-profiles", createPlayProfileHandler(svc)) @@ -40,7 +30,6 @@ func registerAuthedSearchRoutes(authed *gin.RouterGroup, svc *service.Container) authed.GET("/search", searchUnifiedHandler(svc)) authed.GET("/search/advanced", searchAdvancedHandler(svc)) authed.GET("/search/tmdb", searchTMDbHandler(svc)) - authed.GET("/search/sites", searchSitesHandler(svc)) } func registerAuthedSystemExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { @@ -55,16 +44,6 @@ func registerAuthedStatsExtraRoutes(authed *gin.RouterGroup, svc *service.Contai authed.POST("/stats/play", statsPlayHandler(svc)) } -func registerAuthedSitesExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/sites/:id/resource", requirePermission(svc, "can_manage_sites"), siteResourceHandler(svc)) - authed.GET("/sites/:id/userdata", requirePermission(svc, "can_manage_sites"), siteUserdataHandler(svc)) -} - -func registerAuthedSubscriptionExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.PUT("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), updateSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/search", requirePermission(svc, "can_manage_subscriptions"), searchSubscriptionHandler(svc)) -} - func registerAuthedPlaylistExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc)) authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc)) @@ -94,24 +73,3 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con authed.GET("/playback/:id/external-url", externalURLHandler(svc)) authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc)) } - -func registerAuthedDownloadOpsRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.POST("/download/:id/pause", requirePermission(svc, "can_manage_downloads"), downloadPauseHandler(svc)) - authed.POST("/download/:id/resume", requirePermission(svc, "can_manage_downloads"), downloadResumeHandler(svc)) - authed.POST("/download/:id/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeOneHandler(svc)) - authed.POST("/download/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeAllHandler(svc)) - authed.POST("/download/sync", requirePermission(svc, "can_manage_downloads"), downloadSyncHandler(svc)) - authed.POST("/download/start-auto-sync", requirePermission(svc, "can_manage_downloads"), downloadAutoSyncHandler(svc)) - authed.GET("/download/tasks", requirePermission(svc, "can_manage_downloads"), downloadTasksAliasHandler(svc)) -} - -func registerAuthedAssistantRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc)) - authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc)) - authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc)) - authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc)) - authed.POST("/admin/assistant/chat", assistantChatHandler(svc)) - authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc)) - authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc)) - authed.GET("/admin/assistant/history", assistantHistoryHandler(svc)) -} diff --git a/internal/handler/routes_authenticated_features.go b/internal/handler/routes_authenticated_features.go index b902768..3ca7e16 100644 --- a/internal/handler/routes_authenticated_features.go +++ b/internal/handler/routes_authenticated_features.go @@ -7,35 +7,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/service" ) -func registerAuthedDownloadRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/downloads", requirePermission(svc, "can_manage_downloads"), listDownloadsHandler(svc)) - authed.POST("/downloads", requirePermission(svc, "can_manage_downloads"), addDownloadHandler(svc)) - authed.DELETE("/downloads/:hash", requirePermission(svc, "can_manage_downloads"), deleteDownloadHandler(svc)) - authed.POST("/downloads/relocate", requirePermission(svc, "can_manage_downloads"), relocateDownloadHandler(svc)) - authed.POST("/downloads/reload", requirePermission(svc, "can_manage_downloads"), reloadDownloadConfigHandler(svc)) -} - -func registerAuthedSubscriptionRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc)) - authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc)) - authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc)) - authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/restore", requirePermission(svc, "can_manage_subscriptions"), restoreSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc)) -} - -func registerAuthedStatsDiscoveryAndAIRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/stats", statsHandler(svc)) - authed.GET("/tasks", middleware.AdminRequired(), tasksHandler(svc)) - - authed.GET("/discover/trending", requirePermission(svc, "can_view_discover"), trendingHandler(svc)) - authed.GET("/discover/popular", requirePermission(svc, "can_view_discover"), popularHandler(svc)) - - authed.GET("/ai/status", requirePermission(svc, "can_use_ai"), aiStatusHandler(svc)) - authed.POST("/ai/search", requirePermission(svc, "can_use_ai"), smartSearchHandler(svc)) - authed.GET("/ai/recommend", requirePermission(svc, "can_use_ai"), aiRecommendHandler(svc)) -} - func registerAuthedFileRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc)) authed.POST("/files/folders", middleware.AdminRequired(), createFolderHandler(svc)) @@ -49,46 +20,7 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) { authed.POST("/dlna/cast", dlnaCastHandler(svc)) } -func registerAuthedSTRMRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc)) - authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc)) - authed.GET("/strm/output-presets", middleware.AdminRequired(), listSTRMOutputPresetsHandler(svc)) - authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc)) - authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc)) - authed.POST("/strm/generate-from-tree", middleware.AdminRequired(), generateSTRMFromTreeHandler(svc)) - authed.POST("/strm/repair", middleware.AdminRequired(), repairSTRMHandler(svc)) -} - -func registerAuthedDuplicateRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc)) - authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc)) - authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc)) -} - -func registerAuthedSiteRoutes(authed *gin.RouterGroup, svc *service.Container) { - siteHandler := NewSiteHandler(svc) - authed.GET("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.ListSites) - authed.GET("/sites/types", requirePermission(svc, "can_manage_sites"), siteHandler.GetSiteTypes) - authed.GET("/sites/auth-types", requirePermission(svc, "can_manage_sites"), siteHandler.GetAuthTypes) - authed.POST("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.CreateSite) - authed.GET("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.GetSite) - authed.PUT("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.UpdateSite) - authed.DELETE("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.DeleteSite) - authed.POST("/sites/:id/test", requirePermission(svc, "can_manage_sites"), siteHandler.TestSite) - authed.GET("/sites/search", requirePermission(svc, "can_manage_sites"), siteSearchHandler(svc)) -} - func registerAuthedRecycleAndRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc)) - authed.POST("/recycle/restore", middleware.AdminRequired(), restoreMediaBatchHandler(svc)) - authed.POST("/recycle/purge", middleware.AdminRequired(), purgeMediaBatchHandler(svc)) - authed.GET("/ws", wsHandler(svc)) authed.GET("/events", sseHandler(svc)) } - -func registerAuthedSchedulerRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc)) - authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc)) - authed.GET("/scheduler/status", schedulerGetStatusHandler(svc)) -} diff --git a/internal/handler/routes_authenticated_test.go b/internal/handler/routes_authenticated_test.go index 0b459e1..aad47b1 100644 --- a/internal/handler/routes_authenticated_test.go +++ b/internal/handler/routes_authenticated_test.go @@ -23,22 +23,16 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) { routes[route.Method+" "+route.Path] = true } - for _, want := range []string{ - "GET /api/me", - "GET /api/auth/permissions", - "GET /api/libraries", - "GET /api/media", - "GET /api/stream/:id", - "GET /api/storage", - "GET /api/downloads", - "GET /api/subscriptions", - "GET /api/sites/search", - "GET /api/watch-history", - "GET /api/discover/feed", - "GET /api/playback/:id/info", - "GET /api/download/tasks", - "GET /api/admin/assistant/history", - } { + for _, want := range []string{ + "GET /api/me", + "GET /api/auth/permissions", + "GET /api/libraries", + "GET /api/media", + "GET /api/stream/:id", + "GET /api/storage", + "GET /api/watch-history", + "GET /api/playback/:id/info", + } { if !routes[want] { t.Fatalf("%s route is not registered", want) } diff --git a/internal/handler/scheduler.go b/internal/handler/scheduler.go deleted file mode 100644 index 77ca3d8..0000000 --- a/internal/handler/scheduler.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package handler — scheduled jobs admin page. -package handler - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func schedulerStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()}) - } -} - -func schedulerRunHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - name := c.Param("name") - if !triggerSchedulerJob(c, svc, name) { - return - } - c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "任务已在后台触发"}) - } -} - -func triggerSchedulerJob(c *gin.Context, svc *service.Container, name string) bool { - if err := svc.Scheduler.RunNowAsync(c.Request.Context(), name); err != nil { - switch { - case errors.Is(err, service.ErrSchedulerJobAlreadyRunning): - c.JSON(http.StatusConflict, gin.H{"error": "任务正在运行,请稍后到实时任务查看进度"}) - case errors.Is(err, service.ErrSchedulerJobNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } - return false - } - return true -} diff --git a/internal/handler/scheduler_handler.go b/internal/handler/scheduler_handler.go deleted file mode 100644 index 75ec673..0000000 --- a/internal/handler/scheduler_handler.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package handler — 定时任务管理 HTTP 端点。 -package handler - -import ( - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// SchedulerHandler 处理定时任务的查询和管理操作。 -type SchedulerHandler struct { - svc *service.Container - log *zap.Logger -} - -// NewSchedulerHandler 创建定时任务处理器。 -func NewSchedulerHandler(svc *service.Container, log *zap.Logger) *SchedulerHandler { - return &SchedulerHandler{svc: svc, log: log} -} - -// ListTasks 返回所有定时任务列表。 -func (h *SchedulerHandler) ListTasks(c *gin.Context) { - tasks := h.svc.Scheduler.Status() - Success(c, tasks) -} - -// RunTask 手动触发指定任务。 -func (h *SchedulerHandler) RunTask(c *gin.Context) { - name := c.Param("id") - if !triggerSchedulerJob(c, h.svc, name) { - return - } - - SuccessWithMessage(c, "任务已触发后台执行", nil) -} - -// GetStatus 返回调度器运行状态。 -func (h *SchedulerHandler) GetStatus(c *gin.Context) { - status := h.svc.Scheduler.Status() - Success(c, status) -} diff --git a/internal/handler/search_extra.go b/internal/handler/search_extra.go index 0375f4f..20fea64 100644 --- a/internal/handler/search_extra.go +++ b/internal/handler/search_extra.go @@ -83,24 +83,3 @@ func searchTMDbHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"items": out}) } } - -// searchSitesHandler mirrors the existing /sites/search but at the -// /search/sites alias the Vue UI uses. -func searchSitesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - keyword := c.Query("keyword") - if keyword == "" { - keyword = c.Query("q") - } - if keyword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"}) - return - } - results, err := svc.Site.Search(c.Request.Context(), keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": results}) - } -} diff --git a/internal/handler/site_handler.go b/internal/handler/site_handler.go deleted file mode 100644 index 117a6ba..0000000 --- a/internal/handler/site_handler.go +++ /dev/null @@ -1,170 +0,0 @@ -// Package handler — PT 站点管理 HTTP 处理。 -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// SiteHandler 站点管理 CRUD。 -type SiteHandler struct { - svc *service.Container -} - -// NewSiteHandler 创建站点管理 Handler。 -func NewSiteHandler(svc *service.Container) *SiteHandler { - return &SiteHandler{svc: svc} -} - -// ListSites 列出所有站点。 -func (h *SiteHandler) ListSites(c *gin.Context) { - sites, err := h.svc.Site.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - if sites == nil { - sites = []model.Site{} - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": sites}) -} - -// GetSite 获取单个站点详情。 -func (h *SiteHandler) GetSite(c *gin.Context) { - site, err := h.svc.Site.FindByID(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - if site == nil { - c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": site}) -} - -// CreateSite 创建站点。 -func (h *SiteHandler) CreateSite(c *gin.Context) { - var body map[string]any - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - - var site model.Site - // 手动映射字段(因为敏感字段有 json:"-" 标签,不会自动反序列化) - if v, ok := body["name"].(string); ok { - site.Name = v - } - if v, ok := body["url"].(string); ok { - site.URL = v - } - if v, ok := body["type"].(string); ok { - site.Type = v - } - if v, ok := body["auth_type"].(string); ok { - site.AuthType = v - } - if v, ok := body["api_key"].(string); ok { - site.APIKey = v - } - if v, ok := body["cookie"].(string); ok { - site.Cookie = v - } - if v, ok := body["auth_header"].(string); ok { - site.AuthHeader = v - } - if v, ok := body["enabled"].(bool); ok { - site.Enabled = v - } - if v, ok := body["is_default"].(bool); ok { - site.IsDefault = v - } - if v, ok := body["extra"].(string); ok { - site.Extra = v - } - // 高级设置字段 - if v, ok := body["user_agent"].(string); ok { - site.UserAgent = v - } - if v, ok := body["rss_url"].(string); ok { - site.RSSURL = v - } - if v, ok := body["timeout"].(float64); ok { - site.Timeout = int(v) - } - if v, ok := body["priority"].(float64); ok { - site.Priority = int(v) - } - if v, ok := body["use_proxy"].(bool); ok { - site.UseProxy = v - } - if v, ok := body["rate_limit"].(bool); ok { - site.RateLimit = v - } - if v, ok := body["browser_emulation"].(bool); ok { - site.BrowserEmulation = v - } - if v, ok := body["downloader"].(string); ok { - site.Downloader = v - } - - if err := h.svc.Site.Create(c.Request.Context(), &site); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": site}) -} - -// UpdateSite 更新站点。 -func (h *SiteHandler) UpdateSite(c *gin.Context) { - patch := make(map[string]any) - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - // 字段名映射:前端使用蛇形命名,GORM 会正确映射到列名 - // 无需额外处理,Updates 直接使用 patch 中的 key-value - if err := h.svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"}) -} - -// DeleteSite 删除站点。 -func (h *SiteHandler) DeleteSite(c *gin.Context) { - if err := h.svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"}) -} - -// TestSite 测试站点连通性。 -func (h *SiteHandler) TestSite(c *gin.Context) { - ok, msg, err := h.svc.Site.TestConnection(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": msg}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": msg}) -} - -// GetSiteTypes 返回支持的站点类型列表。 -func (h *SiteHandler) GetSiteTypes(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": model.SiteTypes()}) -} - -// GetAuthTypes 返回支持的认证方式列表。 -func (h *SiteHandler) GetAuthTypes(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": model.AuthTypes()}) -} diff --git a/internal/handler/sites.go b/internal/handler/sites.go deleted file mode 100644 index 9813ef0..0000000 --- a/internal/handler/sites.go +++ /dev/null @@ -1,164 +0,0 @@ -// Package handler — site management (PT/BT tracker CRUD + cross-site search). -package handler - -import ( - "encoding/json" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// ─── CRUD ──────────────────────────────────────────────────────────────────── - -func listSitesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - sites, err := svc.Site.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": sites}) - } -} - -func getSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - site, err := svc.Site.FindByID(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if site == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - c.JSON(http.StatusOK, site) - } -} - -type createSiteReq struct { - Name string `json:"name" binding:"required"` - BaseURL string `json:"base_url" binding:"required"` - SiteType string `json:"site_type"` - AuthType string `json:"auth_type"` - Cookie string `json:"cookie"` - APIKey string `json:"api_key"` - AuthHeader string `json:"auth_header"` - UserAgent string `json:"user_agent"` - RSSURL string `json:"rss_url"` - Timeout int `json:"timeout"` - Priority int `json:"priority"` - UseProxy bool `json:"use_proxy"` - Enabled *bool `json:"enabled"` - Downloader string `json:"downloader"` -} - -func createSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req createSiteReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - enabled := true - if req.Enabled != nil { - enabled = *req.Enabled - } - // Pack fields not in the core model into Extra JSON. - extraMap := map[string]any{} - if req.UserAgent != "" { - extraMap["user_agent"] = req.UserAgent - } - if req.RSSURL != "" { - extraMap["rss_url"] = req.RSSURL - } - if req.Timeout > 0 { - extraMap["timeout"] = req.Timeout - } - if req.Priority > 0 { - extraMap["priority"] = req.Priority - } - extraMap["use_proxy"] = req.UseProxy - if req.Downloader != "" { - extraMap["downloader"] = req.Downloader - } - extraJSON, _ := json.Marshal(extraMap) - - site := &model.Site{ - Name: req.Name, - URL: req.BaseURL, - Type: req.SiteType, - AuthType: req.AuthType, - Cookie: req.Cookie, - APIKey: req.APIKey, - AuthHeader: req.AuthHeader, - Extra: string(extraJSON), - Enabled: enabled, - } - if err := svc.Site.Create(c.Request.Context(), site); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, site) - } -} - -func updateSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var patch map[string]any - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - // Return the updated row. - site, _ := svc.Site.FindByID(c.Request.Context(), c.Param("id")) - c.JSON(http.StatusOK, site) - } -} - -func deleteSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -// ─── Connection test ───────────────────────────────────────────────────────── - -func testSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - ok, msg, err := svc.Site.TestConnection(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": ok, "message": msg}) - } -} - -// ─── Cross-site search ─────────────────────────────────────────────────────── - -func siteSearchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - keyword := c.Query("keyword") - if keyword == "" { - keyword = c.Query("q") - } - results, err := svc.Site.Search(c.Request.Context(), keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": results, "total": len(results)}) - } -} diff --git a/internal/handler/sites_extra.go b/internal/handler/sites_extra.go deleted file mode 100644 index f9385ac..0000000 --- a/internal/handler/sites_extra.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package handler — extra site endpoints used by the Vue UI: -// -// GET /sites/:id/resource → keyword search scoped to one site -// GET /sites/:id/userdata → cookie-derived user info (stubbed) -package handler - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// siteResourceHandler runs a search restricted to a single site. -// -// We reuse the full SiteService.Search() and post-filter by site_id; -// it's not the hottest path so we trade simplicity for speed here. -func siteResourceHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - keyword := c.Query("keyword") - if keyword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"}) - return - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - items, err := svc.Site.SearchSite(c.Request.Context(), c.Param("id"), keyword, page) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) - } -} - -// siteUserdataHandler returns whatever the site exposes about the -// authenticated user (upload/download stats, ratio, etc.). This is a -// stub: we report the cookie length so the UI can confirm a login is -// present, but full per-site parsing is out of scope here. -func siteUserdataHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - s, err := svc.Site.FindByID(c.Request.Context(), c.Param("id")) - if err != nil || s == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "site not found"}) - return - } - loginStatus := "unknown" - if s.LastError == "ok" { - loginStatus = "ok" - } else if s.LastError != "" { - loginStatus = "fail" - } - c.JSON(http.StatusOK, gin.H{ - "site_id": s.ID, - "name": s.Name, - "cookie_set": len(s.Cookie) > 0, - "login_status": loginStatus, - "note": "userdata parsing not implemented; stub", - }) - } -} diff --git a/internal/handler/stats.go b/internal/handler/stats.go deleted file mode 100644 index 932fd80..0000000 --- a/internal/handler/stats.go +++ /dev/null @@ -1,154 +0,0 @@ -// Package handler — stats / dashboard endpoints. -package handler - -import ( - "crypto/sha1" - "encoding/hex" - "net/http" - "sort" - "strings" - "time" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func statsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := applyStatsVisibility(c, svc, snap); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, snap) - } -} - -func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.Snapshot) error { - visibility := mediaVisibilityForRequest(c, svc) - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - return err - } - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) - var visibleLibraries int64 - activeLibraryIDs := make([]string, 0, len(libs)) - for _, lib := range libs { - if !lib.Enabled { - continue - } - activeLibraryIDs = append(activeLibraryIDs, lib.ID) - if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) { - visibleLibraries++ - } - } - snap.Libraries = visibleLibraries - cacheKey := visibleStatsCacheKey(visibility, activeLibraryIDs) - if svc.Cache != nil { - var cached visibleStatsCacheValue - if svc.Cache.GetJSON(c.Request.Context(), cacheKey, &cached) { - snap.Libraries = cached.Libraries - snap.MediaCount = cached.MediaCount - snap.TotalSizeBytes = cached.TotalSizeBytes - snap.TotalSeconds = cached.TotalSeconds - snap.RecentlyAdded = cached.RecentlyAdded - return nil - } - } - - q := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility) - q = applyActiveLibraryQuery(q, activeLibraryIDs) - if err := q.Count(&snap.MediaCount).Error; err != nil { - return err - } - type sumRow struct { - Size int64 - Seconds int64 - } - var sum sumRow - if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs). - Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds"). - Scan(&sum).Error; err != nil { - return err - } - snap.TotalSizeBytes = sum.Size - snap.TotalSeconds = sum.Seconds - - var recent []model.Media - if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs). - Order("created_at desc"). - Limit(12). - Find(&recent).Error; err != nil { - return err - } - snap.RecentlyAdded = recent - if svc.Cache != nil { - svc.Cache.SetJSON(c.Request.Context(), cacheKey, visibleStatsCacheValue{ - Libraries: snap.Libraries, - MediaCount: snap.MediaCount, - TotalSizeBytes: snap.TotalSizeBytes, - TotalSeconds: snap.TotalSeconds, - RecentlyAdded: snap.RecentlyAdded, - }, 10*time.Second) - } - return nil -} - -type visibleStatsCacheValue struct { - Libraries int64 `json:"libraries"` - MediaCount int64 `json:"media_count"` - TotalSizeBytes int64 `json:"total_size_bytes"` - TotalSeconds int64 `json:"total_seconds"` - RecentlyAdded []model.Media `json:"recently_added"` -} - -func visibleStatsCacheKey(visibility service.MediaVisibility, activeLibraryIDs []string) string { - allowed := append([]string(nil), visibility.AllowedLibraryIDs...) - hidden := append([]string(nil), visibility.HiddenLibraryIDs...) - active := append([]string(nil), activeLibraryIDs...) - sort.Strings(allowed) - sort.Strings(hidden) - sort.Strings(active) - sum := sha1.Sum([]byte(strings.Join([]string{ - "visible", - strings.Join(active, ","), - strings.Join(allowed, ","), - strings.Join(hidden, ","), - boolString(visibility.IncludeNSFW), - }, "|"))) - return "stats:visible:" + hex.EncodeToString(sum[:]) -} - -func boolString(value bool) string { - if value { - return "1" - } - return "0" -} - -func applyMediaVisibilityQuery(q *gorm.DB, visibility service.MediaVisibility) *gorm.DB { - if !visibility.IncludeNSFW { - q = q.Where("nsfw = ?", false) - } - if len(visibility.HiddenLibraryIDs) > 0 { - q = q.Where("library_id NOT IN ?", visibility.HiddenLibraryIDs) - } - if len(visibility.AllowedLibraryIDs) > 0 { - q = q.Where("library_id IN ?", visibility.AllowedLibraryIDs) - } - return q -} - -func applyActiveLibraryQuery(q *gorm.DB, libraryIDs []string) *gorm.DB { - if len(libraryIDs) == 0 { - return q.Where("1 = 0") - } - return q.Where("library_id IN ?", libraryIDs) -} diff --git a/internal/handler/stats_extra.go b/internal/handler/stats_extra.go deleted file mode 100644 index 788d09c..0000000 --- a/internal/handler/stats_extra.go +++ /dev/null @@ -1,180 +0,0 @@ -// Package handler — richer dashboard statistics endpoints. -// -// /api/stats already returns the basic snapshot. The Vue admin -// dashboard also uses: -// -// /api/stats/overview — counts + total size + total seconds -// /api/stats/trend — daily play count over last N days -// /api/stats/top-content — top played media (by play count) -// /api/stats/libraries — per-library item count + size -// /api/stats/monitor — live CPU/mem/disk -package handler - -import ( - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func statsOverviewHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := applyStatsVisibility(c, svc, snap); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "libraries": snap.Libraries, - "media_count": snap.MediaCount, - "users_count": snap.UsersCount, - "total_size": snap.TotalSizeBytes, - "total_seconds": snap.TotalSeconds, - "generated_at": snap.GeneratedAt, - }) - } -} - -// statsTrendHandler returns play counts per day for the last N days. -func statsTrendHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - days, _ := strconv.Atoi(c.DefaultQuery("days", "14")) - if days <= 0 || days > 90 { - days = 14 - } - // Use the playback_history table; one row per (user, media) - // per day if we group by date(watched_at). - type bucket struct { - Day string `json:"day"` - Count int64 `json:"count"` - } - out := make([]bucket, 0, days) - now := time.Now().UTC() - for i := days - 1; i >= 0; i-- { - start := now.AddDate(0, 0, -i).Truncate(24 * time.Hour) - end := start.Add(24 * time.Hour) - var n int64 - _ = svc.Repo.DB.Model(&model.PlaybackHistory{}). - Where("watched_at >= ? AND watched_at < ?", start, end). - Count(&n).Error - out = append(out, bucket{ - Day: start.Format("2006-01-02"), - Count: n, - }) - } - c.JSON(http.StatusOK, gin.H{"trend": out, "days": days}) - } -} - -// statsTopContentHandler returns the most-watched media items. -func statsTopContentHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - if limit <= 0 || limit > 50 { - limit = 10 - } - type row struct { - MediaID string `json:"media_id"` - PlayCount int64 `json:"play_count"` - LastPlayed time.Time `json:"last_played"` - } - var rows []row - _ = svc.Repo.DB.Table("playback_histories"). - Select("media_id, COUNT(*) as play_count, MAX(watched_at) as last_played"). - Group("media_id"). - Order("play_count desc"). - Limit(limit). - Scan(&rows).Error - // Hydrate media titles in a single query. - ids := make([]string, 0, len(rows)) - for _, r := range rows { - ids = append(ids, r.MediaID) - } - mIdx := map[string]model.Media{} - if len(ids) > 0 { - var media []model.Media - _ = svc.Repo.DB.Where("id IN ?", ids).Find(&media).Error - visibility := mediaVisibilityForRequest(c, svc) - for _, m := range media { - if !visibility.Allows(&m) { - continue - } - mIdx[m.ID] = m - } - } - out := make([]gin.H, 0, len(rows)) - for _, r := range rows { - media, ok := mIdx[r.MediaID] - if !ok { - continue - } - out = append(out, gin.H{ - "media": media, - "play_count": r.PlayCount, - "last_played": r.LastPlayed, - }) - } - c.JSON(http.StatusOK, gin.H{"items": out}) - } -} - -// statsLibrariesHandler returns per-library counts + size. -func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) - out := make([]gin.H, 0, len(libs)) - visibility := mediaVisibilityForRequest(c, svc) - for _, l := range libs { - if !l.Enabled { - continue - } - if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, l, visibility) { - continue - } - libraryIDs, err := service.MergedLibraryIDsForLibrary(c.Request.Context(), svc.Repo, l.ID) - if err != nil || len(libraryIDs) == 0 { - libraryIDs = []string{l.ID} - } - var count int64 - var size int64 - _ = applyMediaVisibilityQuery(svc.Repo.DB.Model(&model.Media{}), visibility). - Where("library_id IN ?", libraryIDs). - Count(&count).Error - _ = applyMediaVisibilityQuery(svc.Repo.DB.Model(&model.Media{}), visibility). - Where("library_id IN ?", libraryIDs). - Select("COALESCE(SUM(size_bytes),0)").Row().Scan(&size) - out = append(out, gin.H{ - "library": l, - "item_count": count, - "total_size": size, - }) - } - c.JSON(http.StatusOK, gin.H{"libraries": out}) - } -} - -// statsMonitorHandler returns live system resource usage; this is just -// the Hardware portion of the snapshot but with a snappy schema. -func statsMonitorHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, snap.Hardware) - } -} diff --git a/internal/handler/stats_test.go b/internal/handler/stats_test.go deleted file mode 100644 index 35107d1..0000000 --- a/internal/handler/stats_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestStatsSnapshotHidesAdultRecentlyAddedForUser(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", HideAdult: true} - if err := repos.User.Create(t.Context(), viewer); err != nil { - t.Fatal(err) - } - safe := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true} - adult := model.Library{Name: "9KG", Path: "/media/9KG", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &safe); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &adult); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), service.AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil { - t.Fatal(err) - } - if err := db.Create(&model.Media{LibraryID: safe.ID, Title: "普通电影", Path: "/media/movie/a.mkv", SizeBytes: 100, DurationSec: 10}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&model.Media{LibraryID: adult.ID, Title: "成人影片", Path: "/media/9KG/a.mkv", SizeBytes: 200, DurationSec: 20}).Error; err != nil { - t.Fatal(err) - } - svc := &service.Container{Repo: repos} - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Set(middleware.CtxUserID, viewer.ID) - c.Request = httptest.NewRequest("GET", "/api/stats", nil) - - snap := &service.Snapshot{} - if err := applyStatsVisibility(c, svc, snap); err != nil { - t.Fatalf("applyStatsVisibility: %v", err) - } - if snap.MediaCount != 1 || snap.TotalSizeBytes != 100 || snap.TotalSeconds != 10 { - t.Fatalf("stats should only include visible media, got count=%d size=%d seconds=%d", snap.MediaCount, snap.TotalSizeBytes, snap.TotalSeconds) - } - if len(snap.RecentlyAdded) != 1 || snap.RecentlyAdded[0].LibraryID != safe.ID { - t.Fatalf("recently added should hide adult library, got %#v", snap.RecentlyAdded) - } -} - -func TestStatsLibrariesCountsMergedCloudLibraryItems(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - local := model.Library{Name: "国产电影", Path: "/media/国产电影", Type: "movie", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产电影", Path: service.BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := db.Create(&[]model.Media{ - {LibraryID: local.ID, Title: "本地版本", Path: "/media/国产电影/local.mkv", SizeBytes: 100}, - {LibraryID: cloud.ID, Title: "云盘版本", Path: "cloud://openlist/国产电影/cloud.mkv", SizeBytes: 200}, - }).Error; err != nil { - t.Fatal(err) - } - svc := &service.Container{Repo: repos} - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/api/stats/libraries", nil) - - statsLibrariesHandler(svc)(c) - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var payload struct { - Libraries []struct { - ItemCount int64 `json:"item_count"` - TotalSize int64 `json:"total_size"` - } `json:"libraries"` - } - if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { - t.Fatal(err) - } - if len(payload.Libraries) != 1 { - t.Fatalf("libraries = %#v, want one merged display library", payload.Libraries) - } - if payload.Libraries[0].ItemCount != 2 || payload.Libraries[0].TotalSize != 300 { - t.Fatalf("merged stats = %#v, want count=2 size=300", payload.Libraries[0]) - } -} diff --git a/internal/handler/storage_config.go b/internal/handler/storage_config.go deleted file mode 100644 index 032ed54..0000000 --- a/internal/handler/storage_config.go +++ /dev/null @@ -1,145 +0,0 @@ -// Package handler — external storage config endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// listStorageConfigsHandler returns the status overview used by the -// admin storage panel: every persisted backend with secrets redacted. -func listStorageConfigsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, err := svc.StorageCfg.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": rows}) - } -} - -// getStorageConfigHandler returns one config (with the decrypted body). -func getStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - row, err := svc.StorageCfg.Get(c.Request.Context(), c.Param("type")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if row == nil { - c.JSON(http.StatusOK, gin.H{"type": c.Param("type"), "config": gin.H{}}) - return - } - c.JSON(http.StatusOK, row) - } -} - -// saveStorageConfigHandler upserts the config row; the caller passes -// the type via URL and the body as a JSON object. -func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - var in service.StorageInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - in.Type = c.Param("type") - row, err := svc.StorageCfg.Save(c.Request.Context(), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if in.Enabled != nil && !*in.Enabled && svc.Scan != nil { - _ = svc.Scan.CancelCloudScansForProvider(in.Type) - } - c.JSON(http.StatusOK, row) - } -} - -// testStorageConfigHandler probes an unsaved config. -func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "unsupported storage type"}) - return - } - var in service.StorageInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - in.Type = c.Param("type") - if err := svc.StorageCfg.Test(c.Request.Context(), in); err != nil { - c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) - return - } - if service.IsAdminCloudConfigurable(in.Type) { - enabled := true - if _, err := svc.StorageCfg.Save(c.Request.Context(), service.StorageInput{ - Type: in.Type, - Config: in.Config, - Enabled: &enabled, - }); err != nil { - c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) - return - } - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func logoutStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminStorageConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - row, err := svc.StorageCfg.Logout(c.Request.Context(), typ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if svc.Scan != nil { - _ = svc.Scan.CancelCloudScansForProvider(typ) - } - c.JSON(http.StatusOK, row) - } -} - -func storageUploadLocalHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - var req service.CloudUploadInput - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - req.Type = c.Param("type") - res, err := svc.StorageCfg.UploadLocal(c.Request.Context(), req) - if err != nil && res == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err != nil { - c.JSON(http.StatusOK, gin.H{"result": res, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"result": res}) - } -} diff --git a/internal/handler/storage_config_test.go b/internal/handler/storage_config_test.go deleted file mode 100644 index dffe2b8..0000000 --- a/internal/handler/storage_config_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestStorageConfigHandlersRejectQuark(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - router.PUT("/admin/storage/:type", saveStorageConfigHandler(nil)) - - req := httptest.NewRequest(http.MethodPut, "/admin/storage/quark", strings.NewReader(`{"type":"quark","config":{"cookie":"x"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "unsupported storage type") { - t.Fatalf("body = %s, want unsupported storage type", w.Body.String()) - } -} - -func TestStorageConfigCloudTestSuccessSavesAndEnablesProvider(t *testing.T) { - gin.SetMode(gin.TestMode) - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("authorization = %q, want openlist-token", r.Header.Get("Authorization")) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "data": map[string]any{"content": []any{}, "total": 0}, - }) - })) - defer openlist.Close() - - db, err := gorm.Open(sqlite.Open("file:storage_config_cloud_test?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.StorageConfig{}, &model.Setting{}, &model.Library{}, &model.Media{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - log := zap.NewNop() - storage := service.NewStorageConfigService(log, repos, service.NewCryptoService("", log)) - enabled := false - if _, err := storage.Save(t.Context(), service.StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "token": "openlist-token", - }, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.POST("/admin/storage/:type/test", testStorageConfigHandler(&service.Container{StorageCfg: storage})) - body := `{"type":"openlist","config":{"server":"` + openlist.URL + `","token":"openlist-token"}}` - req := httptest.NewRequest(http.MethodPost, "/admin/storage/openlist/test", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), `"ok":true`) { - t.Fatalf("body = %s, want ok true", w.Body.String()) - } - view, err := storage.Get(t.Context(), "openlist") - if err != nil { - t.Fatal(err) - } - if view == nil || !view.Enabled { - t.Fatalf("openlist enabled = %#v, want enabled after successful test", view) - } - if _, err := storage.CloudProvider(t.Context(), "openlist"); err != nil { - t.Fatalf("cloud provider after test: %v", err) - } -} diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 303b95f..d0e4bef 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -85,42 +85,6 @@ func imageProxyHandler(svc *service.Container) gin.HandlerFunc { } } -func cloudArtworkProxyHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - ref := c.Query("ref") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - if ref == "" || !isCloudImageRef(ref) { - c.JSON(http.StatusBadRequest, gin.H{"error": "image ref required"}) - return - } - if svc == nil || svc.ImageProxy == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "image proxy unavailable"}) - return - } - stableKey := typ + ":" + ref - if svc.ImageProxy.ServeCloudCached(c.Writer, c.Request, stableKey) { - return - } - if svc.StorageCfg == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"}) - return - } - link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent()) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, stableKey, link); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - } -} - type scrapeRequest struct { EpisodeArtwork *bool `json:"episode_artwork"` EpisodeImages *bool `json:"episode_images"` diff --git a/internal/handler/strm.go b/internal/handler/strm.go deleted file mode 100644 index bcac96f..0000000 --- a/internal/handler/strm.go +++ /dev/null @@ -1,437 +0,0 @@ -// Package handler — STRM (URL-as-file) admin endpoints. -// -// Setting a media row's strm_url makes the stream handler issue a 302 -// redirect to that URL instead of opening a local file. This lets the -// operator expose WebDAV / Alist / S3 / HTTP direct links as ordinary -// MediaStationGo entries. -package handler - -import ( - "context" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type strmReq struct { - URL string `json:"url" binding:"required"` -} - -func setSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req strmReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - url := strings.TrimSpace(req.URL) - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - c.JSON(http.StatusBadRequest, gin.H{"error": "url must start with http:// or https://"}) - return - } - mediaID := c.Param("id") - m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID) - if err != nil || m == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) - return - } - if err := svc.Repo.DB.WithContext(c.Request.Context()). - Model(&model.Media{}). - Where("id = ?", mediaID). - Update("strm_url", url).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"strm_url": url}) - } -} - -func clearSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Repo.DB.WithContext(c.Request.Context()). - Model(&model.Media{}). - Where("id = ?", c.Param("id")). - Update("strm_url", "").Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -// importSTRMHandler creates a media row directly from a (library_id, title, url) -// tuple — useful for adding a streaming-only entry without an on-disk file. -type importSTRMReq struct { - LibraryID string `json:"library_id" binding:"required"` - Title string `json:"title" binding:"required"` - URL string `json:"url" binding:"required"` -} - -func importSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req importSTRMReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - url := strings.TrimSpace(req.URL) - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - c.JSON(http.StatusBadRequest, gin.H{"error": "url must start with http:// or https://"}) - return - } - m := &model.Media{ - LibraryID: req.LibraryID, - Title: req.Title, - Path: url, - STRMURL: url, - Container: "strm", - } - if err := svc.Repo.Media.Upsert(c.Request.Context(), m); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, m) - } -} - -type generateSTRMReq struct { - LibraryID string `json:"library_id"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url"` - Enabled bool `json:"enabled"` - Overwrite bool `json:"overwrite"` - IncludeLocal *bool `json:"include_local"` - PreserveTree bool `json:"preserve_tree"` - Refresh bool `json:"refresh_library"` - ScrapeAfter bool `json:"scrape_after"` -} - -type generateSTRMTreeReq struct { - Provider string `json:"provider"` - TreeText string `json:"tree_text"` - Paths []string `json:"paths"` - SourceRoot string `json:"source_root"` - OutputPrefix string `json:"output_prefix"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url"` - Overwrite bool `json:"overwrite"` - Cleanup bool `json:"cleanup"` - DryRun bool `json:"dry_run"` - BatchLimit int `json:"batch_limit"` - RecognizeRename bool `json:"recognize_rename"` - TransferSubtitles bool `json:"transfer_subtitles"` - MissingOnly bool `json:"missing_only"` - RefreshLibrary bool `json:"refresh_library"` - ScrapeAfter bool `json:"scrape_after"` -} - -type repairSTRMReq struct { - OutputDir string `json:"output_dir" binding:"required"` - BaseURL string `json:"base_url"` - DryRun bool `json:"dry_run"` - RefreshLibrary bool `json:"refresh_library"` - ScrapeAfter bool `json:"scrape_after"` -} - -func listSTRMOutputPresetsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := service.STRMOutputPresets(c.Request.Context(), svc.Repo) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req generateSTRMReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - strmSvc := svc.STRM - if strmSvc == nil { - strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) - } - baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - if baseURL == "" { - baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") - } - includeLocal := true - if req.IncludeLocal != nil { - includeLocal = *req.IncludeLocal - } - options := service.GenerateSTRMOptions{ - LibraryID: req.LibraryID, - OutputDir: req.OutputDir, - BaseURL: baseURL, - Enabled: req.Enabled, - Overwrite: req.Overwrite, - IncludeLocal: includeLocal, - PreserveTree: req.PreserveTree, - PlaybackToken: strmPlaybackTokenForRequest(c, svc), - } - var res *service.GenerateSTRMResult - var err error - if strings.TrimSpace(req.LibraryID) == "*" { - res, err = strmSvc.GenerateForAllLibraries(c.Request.Context(), options) - } else { - res, err = strmSvc.GenerateForLibrary(c.Request.Context(), options) - } - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.Refresh { - res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, strmRefreshQueueOptions{ - TaskName: "STRM 生成后刷新媒体库", - Changed: strmGenerationChanged(res), - ScrapeAfter: req.ScrapeAfter, - }) - } - c.JSON(http.StatusOK, res) - } -} - -func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req generateSTRMTreeReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - strmSvc := svc.STRM - if strmSvc == nil { - strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) - } - baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - if baseURL == "" { - baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") - } - res, err := strmSvc.GenerateFromTree(c.Request.Context(), service.GenerateSTRMTreeOptions{ - Provider: req.Provider, - TreeText: req.TreeText, - Paths: req.Paths, - SourceRoot: req.SourceRoot, - OutputPrefix: req.OutputPrefix, - OutputDir: req.OutputDir, - BaseURL: baseURL, - Overwrite: req.Overwrite, - Cleanup: req.Cleanup, - DryRun: req.DryRun, - BatchLimit: req.BatchLimit, - RecognizeRename: req.RecognizeRename, - TransferSubtitles: req.TransferSubtitles, - MissingOnly: req.MissingOnly, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.RefreshLibrary && !req.DryRun { - res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, strmRefreshQueueOptions{ - TaskName: "STRM 目录树生成后刷新媒体库", - Changed: strmGenerationChanged(res), - ScrapeAfter: req.ScrapeAfter, - }) - } - c.JSON(http.StatusOK, res) - } -} - -func repairSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req repairSTRMReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - strmSvc := svc.STRM - if strmSvc == nil { - strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) - } - baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - if baseURL == "" { - baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") - } - res, err := strmSvc.RepairFiles(c.Request.Context(), service.RepairSTRMOptions{ - OutputDir: req.OutputDir, - BaseURL: baseURL, - DryRun: req.DryRun, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.RefreshLibrary && !req.DryRun { - res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, strmRefreshQueueOptions{ - TaskName: "STRM 修复后刷新媒体库", - Changed: res.Repaired > 0, - ScrapeAfter: req.ScrapeAfter, - }) - } - c.JSON(http.StatusOK, res) - } -} - -func strmGenerationChanged(res *service.GenerateSTRMResult) bool { - return res != nil && (res.Generated > 0 || res.Updated > 0 || res.Cleaned > 0) -} - -type strmRefreshQueueOptions struct { - TaskName string - Changed bool - ScrapeAfter bool -} - -type strmRefreshRunOptions struct { - ScrapeAfter bool -} - -func queueSTRMRefreshAfterChanges(ctx context.Context, svc *service.Container, outputDir string, options strmRefreshQueueOptions) *service.STRMRefreshResult { - refresh := &service.STRMRefreshResult{Requested: true, ScrapeRequested: options.ScrapeAfter} - if !options.Changed { - refresh.Reason = "no strm changes" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - if svc == nil || svc.Scan == nil { - refresh.Reason = "scanner unavailable" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - targets, err := service.FindSTRMRefreshTargets(ctx, svc.Repo, outputDir) - if err != nil { - refresh.Reason = err.Error() - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - if len(targets) == 0 { - refresh.Reason = "no matching local library" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - refresh.Targets = targets - if options.ScrapeAfter && svc.Scraper == nil { - refresh.ScrapeReason = "scraper unavailable" - } - for _, target := range targets { - key := target.LibraryID - if target.RootID != "" { - key += ":" + target.RootID - } - finishScan, ok := svc.Scan.TryBeginLocalScan(key) - if !ok { - continue - } - refresh.Queued = true - runOptions := strmRefreshRunOptions{ScrapeAfter: options.ScrapeAfter && svc.Scraper != nil} - if runOptions.ScrapeAfter { - refresh.ScrapeQueued = true - } - task := startScanHTTPTask(svc, options.TaskName, target.Name, target.Path) - go runSTRMRefreshScan(svc, target, task, finishScan, runOptions) - } - if !refresh.Queued { - refresh.Reason = "matching library already scanning" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - } - return refresh -} - -func runSTRMRefreshScan(svc *service.Container, target service.STRMRefreshTarget, task *service.TaskHandle, finish func(), options strmRefreshRunOptions) { - defer finish() - var ( - res *service.ScanResult - err error - ) - if target.RootID != "" { - res, err = svc.Scan.ScanLibraryRoot(context.Background(), target.LibraryID, target.RootID) - } else { - res, err = svc.Scan.ScanLibrary(context.Background(), target.LibraryID) - } - if err != nil { - finishHTTPTask(task, err, "scan", "STRM 刷新媒体库失败", scanTaskMetrics(res), scanTaskDetails(res, 20)) - return - } - if !options.ScrapeAfter { - finishHTTPTask(task, nil, "completed", "STRM 刷新媒体库结束", scanTaskMetrics(res), scanTaskDetails(res, 20)) - return - } - scrape, reclassified, scrapeErr := runSTRMRefreshScrape(svc, target, task) - metrics := strmRefreshTaskMetrics(res, scrape, reclassified) - if scrapeErr != nil { - finishHTTPTask(task, scrapeErr, "scrape", "STRM 刷新媒体库完成,刮削失败", metrics, scanTaskDetails(res, 20)) - return - } - finishHTTPTask(task, nil, "completed", "STRM 刷新媒体库和刮削结束", metrics, scanTaskDetails(res, 20)) -} - -func runSTRMRefreshScrape(svc *service.Container, target service.STRMRefreshTarget, task *service.TaskHandle) (service.EnrichLibraryResult, int, error) { - if task != nil { - task.Update(service.TaskUpdate{ - Stage: "scrape", - SourcePath: target.Path, - Message: "正在刮削 STRM 媒体库", - }) - } - result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), target.LibraryID, service.ScrapeOptions{RetryNoMatch: true}) - reclassified := 0 - if result.Processed > 0 { - reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, target.LibraryID) - } - return result, reclassified, err -} - -func strmRefreshTaskMetrics(scan *service.ScanResult, scrape service.EnrichLibraryResult, reclassified int) map[string]int64 { - metrics := scanTaskMetrics(scan) - if metrics == nil { - metrics = map[string]int64{} - } - metrics["scrape_matched"] = int64(scrape.Matched) - metrics["scrape_processed"] = int64(scrape.Processed) - metrics["scrape_candidates"] = int64(scrape.Candidates) - if scrape.Failed > 0 { - metrics["scrape_failed"] = int64(scrape.Failed) - } - if reclassified > 0 { - metrics["scrape_reclassified"] = int64(reclassified) - } - return metrics -} - -func strmRefreshScrapeSkipReason(refresh *service.STRMRefreshResult) string { - if refresh == nil || !refresh.ScrapeRequested { - return "" - } - if refresh.Reason != "" { - return "refresh not queued: " + refresh.Reason - } - return "refresh not queued" -} - -func strmPlaybackTokenForRequest(c *gin.Context, svc *service.Container) string { - if svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil { - return "" - } - uid := middleware.GetUserID(c) - if uid == "" { - return "" - } - u, err := svc.Repo.User.FindByID(c.Request.Context(), uid) - if err != nil || u == nil { - return "" - } - token, err := svc.Auth.IssueEmbyToken(u) - if err != nil { - return "" - } - return token -} diff --git a/internal/handler/strm_test.go b/internal/handler/strm_test.go deleted file mode 100644 index ae276f5..0000000 --- a/internal/handler/strm_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package handler - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestSTRMRefreshTaskMetricsIncludesScanAndScrape(t *testing.T) { - metrics := strmRefreshTaskMetrics( - &service.ScanResult{Visited: 3, Added: 2, Updated: 1, ErrorCount: 1}, - service.EnrichLibraryResult{Matched: 4, Processed: 5, Candidates: 6, Failed: 1}, - 2, - ) - - want := map[string]int64{ - "visited": 3, - "added": 2, - "updated": 1, - "errors": 1, - "scrape_matched": 4, - "scrape_processed": 5, - "scrape_candidates": 6, - "scrape_failed": 1, - "scrape_reclassified": 2, - } - for key, value := range want { - if metrics[key] != value { - t.Fatalf("metrics[%q] = %d, want %d in %#v", key, metrics[key], value, metrics) - } - } -} - -func TestSTRMRefreshScrapeSkipReasonRequiresScrapeRequest(t *testing.T) { - if got := strmRefreshScrapeSkipReason(&service.STRMRefreshResult{Requested: true, Reason: "no strm changes"}); got != "" { - t.Fatalf("skip reason without scrape request = %q, want empty", got) - } - - got := strmRefreshScrapeSkipReason(&service.STRMRefreshResult{ - Requested: true, - ScrapeRequested: true, - Reason: "no matching local library", - }) - if got != "refresh not queued: no matching local library" { - t.Fatalf("skip reason = %q", got) - } -} diff --git a/internal/handler/subscription_errors.go b/internal/handler/subscription_errors.go deleted file mode 100644 index 8973e93..0000000 --- a/internal/handler/subscription_errors.go +++ /dev/null @@ -1,22 +0,0 @@ -package handler - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func writeSubscriptionConflict(c *gin.Context, err error) bool { - if !errors.Is(err, service.ErrSubscriptionAlreadyExists) { - return false - } - body := gin.H{"error": "subscription already exists"} - if existingID := service.SubscriptionAlreadyExistsID(err); existingID != "" { - body["existing_id"] = existingID - } - c.JSON(http.StatusConflict, body) - return true -} diff --git a/internal/handler/subscription_errors_test.go b/internal/handler/subscription_errors_test.go deleted file mode 100644 index 29aff2d..0000000 --- a/internal/handler/subscription_errors_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestWriteSubscriptionConflictReturns409WithExistingID(t *testing.T) { - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - err := &service.SubscriptionAlreadyExistsError{ExistingID: "existing-sub"} - if !writeSubscriptionConflict(c, err) { - t.Fatal("expected conflict to be handled") - } - if w.Code != http.StatusConflict { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } - if body["existing_id"] != "existing-sub" { - t.Fatalf("body = %#v", body) - } -} diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go deleted file mode 100644 index b61bfa7..0000000 --- a/internal/handler/subscription_extra.go +++ /dev/null @@ -1,210 +0,0 @@ -// Package handler — subscription update + per-subscription site search. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type subscriptionPatchReq struct { - Name *string `json:"name"` - FeedURL *string `json:"feed_url"` - Filter *string `json:"filter"` - MediaType *string `json:"media_type"` - MediaCategory *string `json:"media_category"` - SavePath *string `json:"save_path"` - SearchMode *string `json:"search_mode"` - IMDBID *string `json:"imdb_id"` - Source *string `json:"source"` - PosterURL *string `json:"poster_url"` - BackdropURL *string `json:"backdrop_url"` - Overview *string `json:"overview"` - OriginalName *string `json:"original_name"` - Year *int `json:"year"` - Rating *float32 `json:"rating"` - Genres *string `json:"genres"` - Resolution *string `json:"resolution"` - Quality *string `json:"quality"` - Effects *string `json:"effects"` - ReleaseGroups *string `json:"release_groups"` - ExcludeWords *string `json:"exclude_words"` - MinSeeders *int `json:"min_seeders"` - MaxSeeders *int `json:"max_seeders"` - MinSizeGB *float64 `json:"min_size_gb"` - MaxSizeGB *float64 `json:"max_size_gb"` - FreeOnly *bool `json:"free_only"` - WashEnabled *bool `json:"wash_enabled"` - WashPriority *string `json:"wash_priority"` - TotalEpisodes *int `json:"total_episodes"` - Priority *int `json:"priority"` - Enabled *bool `json:"enabled"` -} - -// updateSubscriptionHandler patches a subscription row. -func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var patch subscriptionPatchReq - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - updates := subscriptionPatchUpdates(patch) - if len(updates) == 0 { - c.Status(http.StatusNoContent) - return - } - if err := svc.Subscription.Update(c.Request.Context(), c.Param("id"), updates); err != nil { - logSubscriptionWarn(svc, "subscription update failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - if writeSubscriptionConflict(c, err) { - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription updated", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Strings("fields", subscriptionUpdateFieldNames(updates))) - c.Status(http.StatusNoContent) - } -} - -func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any { - updates := map[string]any{} - if patch.Name != nil { - updates["name"] = *patch.Name - } - if patch.FeedURL != nil { - updates["feed_url"] = *patch.FeedURL - } - if patch.Filter != nil { - updates["filter"] = *patch.Filter - } - if patch.MediaType != nil { - updates["media_type"] = *patch.MediaType - } - if patch.MediaCategory != nil { - updates["media_category"] = *patch.MediaCategory - } - if patch.SavePath != nil { - updates["save_path"] = *patch.SavePath - } - if patch.SearchMode != nil { - updates["search_mode"] = *patch.SearchMode - } - if patch.IMDBID != nil { - updates["imdb_id"] = *patch.IMDBID - } - if patch.Source != nil { - updates["source"] = *patch.Source - } - if patch.PosterURL != nil { - updates["poster_url"] = *patch.PosterURL - } - if patch.BackdropURL != nil { - updates["backdrop_url"] = *patch.BackdropURL - } - if patch.Overview != nil { - updates["overview"] = *patch.Overview - } - if patch.OriginalName != nil { - updates["original_name"] = *patch.OriginalName - } - if patch.Year != nil { - updates["year"] = *patch.Year - } - if patch.Rating != nil { - updates["rating"] = *patch.Rating - } - if patch.Genres != nil { - updates["genres"] = *patch.Genres - } - if patch.Resolution != nil { - updates["resolution"] = *patch.Resolution - } - if patch.Quality != nil { - updates["quality"] = *patch.Quality - } - if patch.Effects != nil { - updates["effects"] = *patch.Effects - } - if patch.ReleaseGroups != nil { - updates["release_groups"] = *patch.ReleaseGroups - } - if patch.ExcludeWords != nil { - updates["exclude_words"] = *patch.ExcludeWords - } - if patch.MinSeeders != nil { - updates["min_seeders"] = *patch.MinSeeders - } - if patch.MaxSeeders != nil { - updates["max_seeders"] = *patch.MaxSeeders - } - if patch.MinSizeGB != nil { - updates["min_size_gb"] = *patch.MinSizeGB - } - if patch.MaxSizeGB != nil { - updates["max_size_gb"] = *patch.MaxSizeGB - } - if patch.FreeOnly != nil { - updates["free_only"] = *patch.FreeOnly - } - if patch.WashEnabled != nil { - updates["wash_enabled"] = *patch.WashEnabled - } - if patch.WashPriority != nil { - updates["wash_priority"] = *patch.WashPriority - } - if patch.TotalEpisodes != nil { - updates["total_episodes"] = *patch.TotalEpisodes - } - if patch.Priority != nil { - updates["priority"] = *patch.Priority - } - if patch.Enabled != nil { - updates["enabled"] = *patch.Enabled - } - return updates -} - -func subscriptionUpdateFieldNames(updates map[string]any) []string { - names := make([]string, 0, len(updates)) - for name := range updates { - names = append(names, name) - } - return names -} - -// searchSubscriptionHandler runs a one-off keyword search against the -// configured tracker sites for the given subscription. We treat the -// subscription's filter as the search term; this lets the UI preview -// what would be queued without actually downloading anything. -func searchSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var sub model.Subscription - err := svc.Repo.DB.WithContext(c.Request.Context()). - Where("id = ?", c.Param("id")).First(&sub).Error - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "subscription not found"}) - return - } - keyword := sub.Filter - if keyword == "" { - keyword = sub.Name - } - results, err := svc.Site.Search(c.Request.Context(), keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": results, "subscription": sub}) - } -} diff --git a/internal/handler/subscription_logging.go b/internal/handler/subscription_logging.go deleted file mode 100644 index cbbf70e..0000000 --- a/internal/handler/subscription_logging.go +++ /dev/null @@ -1,54 +0,0 @@ -package handler - -import ( - "net/url" - "strings" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func subscriptionRequestUserID(c *gin.Context) string { - if c == nil { - return "" - } - if uid, ok := c.Get(middleware.CtxUserID); ok { - if userID, ok := uid.(string); ok { - return userID - } - } - return "" -} - -func subscriptionFeedKind(feedURL string) string { - raw := strings.TrimSpace(feedURL) - if raw == "" { - return "empty" - } - lower := strings.ToLower(raw) - if strings.HasPrefix(lower, "site-search://") { - return "site-search" - } - parsed, err := url.Parse(raw) - if err == nil && parsed.Scheme != "" { - return parsed.Scheme - } - return "unknown" -} - -func logSubscriptionInfo(svc *service.Container, msg string, fields ...zap.Field) { - if svc == nil || svc.Log == nil { - return - } - svc.Log.Info(msg, fields...) -} - -func logSubscriptionWarn(svc *service.Container, msg string, fields ...zap.Field) { - if svc == nil || svc.Log == nil { - return - } - svc.Log.Warn(msg, fields...) -} diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go deleted file mode 100644 index a3e74e0..0000000 --- a/internal/handler/subscriptions.go +++ /dev/null @@ -1,219 +0,0 @@ -// Package handler — RSS subscription endpoints. -package handler - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type subscriptionReq struct { - Name string `json:"name" binding:"required"` - FeedURL string `json:"feed_url" binding:"required"` - Filter string `json:"filter"` - MediaType string `json:"media_type"` - MediaCategory string `json:"media_category"` - SavePath string `json:"save_path"` - SearchMode string `json:"search_mode"` - IMDBID string `json:"imdb_id"` - Source string `json:"source"` - PosterURL string `json:"poster_url"` - BackdropURL string `json:"backdrop_url"` - Overview string `json:"overview"` - OriginalName string `json:"original_name"` - Year int `json:"year"` - Resolution string `json:"resolution"` - Quality string `json:"quality"` - Effects string `json:"effects"` - ReleaseGroups string `json:"release_groups"` - ExcludeWords string `json:"exclude_words"` - MinSeeders int `json:"min_seeders"` - MaxSeeders int `json:"max_seeders"` - MinSizeGB float64 `json:"min_size_gb"` - MaxSizeGB float64 `json:"max_size_gb"` - FreeOnly bool `json:"free_only"` - WashEnabled bool `json:"wash_enabled"` - WashPriority string `json:"wash_priority"` - TotalEpisodes int `json:"total_episodes"` - Priority int `json:"priority"` - Enabled *bool `json:"enabled"` -} - -func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req subscriptionReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - enabled := true - if req.Enabled != nil { - enabled = *req.Enabled - } - s := &model.Subscription{ - UserID: uid.(string), - Name: req.Name, - FeedURL: req.FeedURL, - Filter: req.Filter, - MediaType: req.MediaType, - MediaCategory: req.MediaCategory, - SavePath: req.SavePath, - SearchMode: req.SearchMode, - IMDBID: req.IMDBID, - Source: req.Source, - PosterURL: req.PosterURL, - BackdropURL: req.BackdropURL, - Overview: req.Overview, - OriginalName: req.OriginalName, - Year: req.Year, - Resolution: req.Resolution, - Quality: req.Quality, - Effects: req.Effects, - ReleaseGroups: req.ReleaseGroups, - ExcludeWords: req.ExcludeWords, - MinSeeders: req.MinSeeders, - MaxSeeders: req.MaxSeeders, - MinSizeGB: req.MinSizeGB, - MaxSizeGB: req.MaxSizeGB, - FreeOnly: req.FreeOnly, - WashEnabled: req.WashEnabled, - WashPriority: req.WashPriority, - TotalEpisodes: req.TotalEpisodes, - Priority: req.Priority, - Enabled: enabled, - } - enrichSubscriptionArtwork(c.Request.Context(), svc, s) - if err := svc.Subscription.Create(c.Request.Context(), s); err != nil { - logSubscriptionWarn(svc, "subscription create failed", - zap.String("user_id", s.UserID), - zap.String("name", req.Name), - zap.String("feed_kind", subscriptionFeedKind(req.FeedURL)), - zap.Bool("enabled", enabled), - zap.Error(err)) - if writeSubscriptionConflict(c, err) { - return - } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription created", - zap.String("user_id", s.UserID), - zap.String("subscription_id", s.ID), - zap.String("name", s.Name), - zap.String("feed_kind", subscriptionFeedKind(s.FeedURL)), - zap.String("media_type", s.MediaType), - zap.String("media_category", s.MediaCategory), - zap.Bool("enabled", s.Enabled), - zap.Bool("wash_enabled", s.WashEnabled)) - enriched := []model.Subscription{*s} - svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) - *s = enriched[0] - c.JSON(http.StatusCreated, s) - } -} - -func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Subscription.List(c.Request.Context()) - if err != nil { - logSubscriptionWarn(svc, "subscription list failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) - go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...)) - logSubscriptionInfo(svc, "subscription list returned", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Int("count", len(items)), - zap.Bool("history", false)) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Subscription.History(c.Request.Context()) - if err != nil { - logSubscriptionWarn(svc, "subscription history list failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) - logSubscriptionInfo(svc, "subscription list returned", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Int("count", len(items)), - zap.Bool("history", true)) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil { - logSubscriptionWarn(svc, "subscription delete failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription deleted", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id"))) - c.Status(http.StatusNoContent) - } -} - -func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id")) - if err != nil { - logSubscriptionWarn(svc, "subscription run now failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription run now completed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Int("queued", n)) - c.JSON(http.StatusOK, gin.H{"queued": n}) - } -} - -func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id")) - if err != nil { - logSubscriptionWarn(svc, "subscription restore failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - if writeSubscriptionConflict(c, err) { - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription restored", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", sub.ID), - zap.String("name", sub.Name)) - enriched := []model.Subscription{*sub} - svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) - c.JSON(http.StatusOK, enriched[0]) - } -} diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 6576d4b..b574a7c 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -58,13 +58,12 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "groups": []gin.H{ - { - "key": "general", - "label": "常规", - "items": []gin.H{ - {"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"}, - {"key": "app.server_url", "type": "text", "label": "公开访问域名 / STRM 域名"}, - {"key": "transcode.enabled", "type": "toggle", "label": "启用转码"}, + { + "key": "general", + "label": "常规", + "items": []gin.H{ + {"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"}, + {"key": "transcode.enabled", "type": "toggle", "label": "启用转码"}, {"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"}, {"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"}, {"key": "transcode.max_jobs", "type": "number", "label": "最大并发"}, @@ -121,19 +120,10 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { {"key": "qbittorrent.password", "type": "text"}, {"key": "qbittorrent.savepath", "type": "text"}, }, - }, - { - "key": "license", - "label": "授权服务", - "items": []gin.H{ - {"key": "license.server_url", "type": "text", "label": "License Server 地址"}, - {"key": "license.public_key", "type": "text", "label": "Ed25519 验签公钥"}, - {"key": "license.hmac_secret", "type": "text", "label": "HMAC 签名密钥(旧版兼容)"}, - }, - }, - { - "key": "system-update", - "label": "系统更新", +}, + { + "key": "system-update", + "label": "系统更新", "items": []gin.H{ {"key": "system.update.image", "type": "text", "label": "应用镜像"}, {"key": "system.update.compose_dir", "type": "text", "label": "Docker Compose 安装目录"}, @@ -145,16 +135,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { } } -// schedulerTriggerHandler is the alternate path for /admin/scheduler/:name/run. -func schedulerTriggerHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !triggerSchedulerJob(c, svc, c.Param("name")) { - return - } - c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "任务已在后台触发"}) - } -} - // ─── SSE ticket store ─────────────────────────────────────────────────────── // // The Vue UI's SSE event stream wants a one-time signed ticket so the diff --git a/internal/handler/system_meta.go b/internal/handler/system_meta.go index 9f97157..8e1b4d3 100644 --- a/internal/handler/system_meta.go +++ b/internal/handler/system_meta.go @@ -63,11 +63,3 @@ func systemStatusHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, out) } } - -// systemSchedulerHandler is the read-only (non-admin) variant of -// /admin/scheduler — handy on user-facing dashboards. -func systemSchedulerHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()}) - } -} diff --git a/internal/handler/task_helpers.go b/internal/handler/task_helpers.go index 33eed7b..caeed3e 100644 --- a/internal/handler/task_helpers.go +++ b/internal/handler/task_helpers.go @@ -7,4 +7,4 @@ func finishHTTPTask(task *service.TaskHandle, err error, stage, message string, return } task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details}) -} +} \ No newline at end of file diff --git a/internal/handler/tasks.go b/internal/handler/tasks.go deleted file mode 100644 index 3d4468d..0000000 --- a/internal/handler/tasks.go +++ /dev/null @@ -1,40 +0,0 @@ -// Package handler — live tasks board. -// -// /api/tasks aggregates running ffmpeg transcodes, qBittorrent torrents -// and recent scrape progress into a single snapshot suitable for the -// React Tasks panel. The panel can layer this REST snapshot on top of -// live WS events for instant updates. -package handler - -import ( - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -const tasksLiveTorrentSnapshotMaxAge = 30 * time.Second - -func tasksHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var transcodes []service.ActiveJob - if svc.Transcoder != nil { - transcodes = svc.Transcoder.Active() - } - var torrents []service.QBitTorrent - if svc.Downloads != nil { - torrents = svc.Downloads.LiveTorrentSnapshot(tasksLiveTorrentSnapshotMaxAge) - } - background := service.TaskSnapshot{} - if svc.Tasks != nil { - background = svc.Tasks.Snapshot() - } - c.JSON(http.StatusOK, gin.H{ - "transcodes": transcodes, - "torrents": torrents, - "background_tasks": background, - }) - } -} diff --git a/internal/handler/telegram_webhook.go b/internal/handler/telegram_webhook.go deleted file mode 100644 index e1abae8..0000000 --- a/internal/handler/telegram_webhook.go +++ /dev/null @@ -1,104 +0,0 @@ -// Package handler — Telegram Bot Webhook 端点。 -// -// 接收 Telegram Bot API 推送的 update 消息,路由到 TelegramBotService 处理。 -package handler - -import ( - "context" - "io" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// telegramWebhookHandler 处理 Telegram Bot 的 Webhook 回调。 -// -// 路由:POST /api/telegram/webhook (无需认证,由 Telegram 服务器调用) -func telegramWebhookHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - body, err := io.ReadAll(c.Request.Body) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read body"}) - return - } - - if err := svc.TelegramBot.HandleWebhook(c.Request.Context(), body); err != nil { - svc.Log.Error("telegram webhook error", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -// telegramSetWebhookHandler 管理员手动设置/更新 Webhook URL。 -// -// 路由:POST /api/admin/telegram/webhook (需 admin 认证) -func telegramSetWebhookHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req struct { - BotToken string `json:"bot_token" binding:"required"` - WebhookURL string `json:"webhook_url" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := svc.TelegramBot.SetWebhook(c.Request.Context(), req.BotToken, req.WebhookURL); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "webhook set successfully", "url": req.WebhookURL}) - } -} - -// telegramGetWebhookHandler 获取当前 Webhook 配置信息。 -// -// 路由:GET /api/admin/telegram/webhook (需 admin 认证) -func telegramGetWebhookHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - botToken := c.Query("bot_token") - if botToken == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"}) - return - } - - info, err := svc.TelegramBot.GetWebhookInfo(c.Request.Context(), botToken) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, info) - } -} - -// telegramStartPollingHandler 启动 Telegram 长轮询。 -// -// 路由:POST /api/admin/telegram/polling/start (需 admin 认证) -func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - setupCtx, cancel := context.WithTimeout(svc.Context(), 30*time.Second) - defer cancel() - - result := svc.TelegramBot.StartPolling(setupCtx) - c.JSON(http.StatusOK, result) - } -} - -// telegramStopPollingHandler 停止 Telegram 长轮询。 -// -// 路由:POST /api/admin/telegram/polling/stop (需 admin 认证) -func telegramStopPollingHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - stopped := svc.TelegramBot.StopPolling() - c.JSON(http.StatusOK, gin.H{"message": "polling stopped", "stopped": stopped}) - } -} diff --git a/internal/helper/flaresolverr.go b/internal/helper/flaresolverr.go deleted file mode 100644 index d001c24..0000000 --- a/internal/helper/flaresolverr.go +++ /dev/null @@ -1,128 +0,0 @@ -package helper - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "go.uber.org/zap" -) - -// FlareSolverrRequest represents a request to FlareSolverr. -type FlareSolverrRequest struct { - Cmd string `json:"cmd"` - URL string `json:"url"` - Session string `json:"session,omitempty"` - MaxTimeout int `json:"maxTimeout,omitempty"` - Proxy *FlareSolverrProxy `json:"proxy,omitempty"` - Cookies []FlareSolverrCookie `json:"cookies,omitempty"` -} - -// FlareSolverrProxy represents proxy config for FlareSolverr. -type FlareSolverrProxy struct { - URL string `json:"url"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` -} - -// FlareSolverrCookie represents a cookie for FlareSolverr. -type FlareSolverrCookie struct { - Name string `json:"name"` - Value string `json:"value"` - Domain string `json:"domain,omitempty"` - Path string `json:"path,omitempty"` -} - -// FlareSolverrResponse represents FlareSolverr's response. -type FlareSolverrResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Solution *FlareSolverrSolution `json:"solution,omitempty"` -} - -// FlareSolverrSolution contains the solved challenge result. -type FlareSolverrSolution struct { - URL string `json:"url"` - Status int `json:"status"` - Headers map[string]string `json:"headers"` - Cookies []FlareSolverrCookie `json:"cookies"` - UserAgent string `json:"userAgent"` - Response string `json:"response"` -} - -// FetchURLWithFlareSolverr uses FlareSolverr to fetch a URL, -// bypassing Cloudflare/WAF challenges. -func FetchURLWithFlareSolverr(flareSolverrURL string, targetURL string, cookieStr string, timeout int, proxyURL string, log *zap.Logger) (string, error) { - if flareSolverrURL == "" { - return "", fmt.Errorf("FlareSolverr URL not configured") - } - if timeout <= 0 { - timeout = 60 - } - - var cookies []FlareSolverrCookie - if cookieStr != "" { - cookies = parseCookiesForFlareSolverr(cookieStr) - } - - reqBody := FlareSolverrRequest{ - Cmd: "request.get", - URL: targetURL, - MaxTimeout: timeout * 1000, - Cookies: cookies, - } - if proxyURL != "" { - reqBody.Proxy = &FlareSolverrProxy{URL: proxyURL} - } - - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return "", fmt.Errorf("failed to marshal FlareSolverr request: %w", err) - } - - client := &http.Client{Timeout: time.Duration(timeout+10) * time.Second} - resp, err := client.Post(flareSolverrURL, "application/json", strings.NewReader(string(jsonBody))) - if err != nil { - return "", fmt.Errorf("FlareSolverr request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read FlareSolverr response: %w", err) - } - - var fsResp FlareSolverrResponse - if err := json.Unmarshal(body, &fsResp); err != nil { - return "", fmt.Errorf("failed to parse FlareSolverr response: %w", err) - } - - if fsResp.Status != "ok" { - return "", fmt.Errorf("FlareSolverr error: %s", fsResp.Message) - } - - if fsResp.Solution != nil { - return fsResp.Solution.Response, nil - } - return "", fmt.Errorf("FlareSolverr returned no solution") -} - -// parseCookiesForFlareSolverr converts a cookie header string to FlareSolverr format. -func parseCookiesForFlareSolverr(cookieStr string) []FlareSolverrCookie { - var cookies []FlareSolverrCookie - parts := strings.Split(cookieStr, ";") - for _, part := range parts { - part = strings.TrimSpace(part) - kv := strings.SplitN(part, "=", 2) - if len(kv) == 2 { - cookies = append(cookies, FlareSolverrCookie{ - Name: kv[0], - Value: kv[1], - }) - } - } - return cookies -} diff --git a/internal/helper/http.go b/internal/helper/http.go index 4d85d41..bbafdf3 100644 --- a/internal/helper/http.go +++ b/internal/helper/http.go @@ -6,10 +6,11 @@ import ( "net/http" "net/url" "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" ) +// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。 +const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + // NewSiteHTTPClient builds an http.Client honoring per-site policies: // - timeout (seconds, defaults to 15) // - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on @@ -42,7 +43,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client { // These mimic a real Chrome browser to avoid WAF/bot detection. func HTTPHeaderPresets() map[string]string { return map[string]string{ - "User-Agent": model.DefaultUserAgent, + "User-Agent": defaultUserAgent, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate, br", diff --git a/internal/helper/page_source.go b/internal/helper/page_source.go deleted file mode 100644 index 7adb265..0000000 --- a/internal/helper/page_source.go +++ /dev/null @@ -1,48 +0,0 @@ -package helper - -import ( - "io" - "net/http" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -// GetPageSource fetches a page with browser-like headers. -// Returns (pageSource, cookies, error). -func GetPageSource(url string, site *model.Site, timeout int, log *zap.Logger) (string, string, error) { - client := NewSiteHTTPClient(timeout, site.UseProxy) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", "", err - } - - headers := HTTPHeaderPresets() - for k, v := range headers { - req.Header.Set(k, v) - } - - ApplySiteAuthHeaders(req, site) - - resp, err := client.Do(req) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", err - } - - cookies := "" - for _, c := range resp.Cookies() { - if cookies != "" { - cookies += "; " - } - cookies += c.Name + "=" + c.Value - } - - return string(body), cookies, nil -} diff --git a/internal/helper/site_auth.go b/internal/helper/site_auth.go deleted file mode 100644 index bb7c96f..0000000 --- a/internal/helper/site_auth.go +++ /dev/null @@ -1,50 +0,0 @@ -package helper - -import ( - "net/http" - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ApplySiteAuthHeaders applies authentication headers based on site config. -func ApplySiteAuthHeaders(req *http.Request, site *model.Site) { - switch site.AuthType { - case "cookie": - if site.Cookie != "" { - req.Header.Set("Cookie", site.Cookie) - } - case "api_key": - if site.APIKey != "" { - if isYemaPTSite(site) { - req.Header.Set("Authorization", site.APIKey) - } else { - req.Header.Set("x-api-key", site.APIKey) - } - } - case "auth_header": - if site.AuthHeader != "" { - req.Header.Set("Authorization", site.AuthHeader) - } - } - - if site.UserAgent != "" { - req.Header.Set("User-Agent", site.UserAgent) - } -} - -func isYemaPTSite(site *model.Site) bool { - if site == nil { - return false - } - if strings.EqualFold(strings.TrimSpace(site.Type), "yemapt") { - return true - } - u, err := url.Parse(strings.TrimSpace(site.URL)) - if err != nil { - return false - } - host := strings.ToLower(u.Hostname()) - return host == "yemapt.org" || strings.HasSuffix(host, ".yemapt.org") -} diff --git a/internal/helper/site_connectivity.go b/internal/helper/site_connectivity.go deleted file mode 100644 index 0626ffe..0000000 --- a/internal/helper/site_connectivity.go +++ /dev/null @@ -1,96 +0,0 @@ -package helper - -import ( - "fmt" - "io" - "net/http" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -// TestSiteConnectivity performs a site connectivity test with browser-like headers. -// If flareSolverrURL is non-empty AND the site has BrowserEmulation turned on, -// it will attempt to use FlareSolverr first. -// Returns (ok, message, error). -func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, log *zap.Logger) (bool, string, error) { - // Try FlareSolverr first when (a) globally enabled and (b) the site - // asked for browser emulation. This matches the contract used by the - // search path (see service.SiteService.siteModelToConfig). - useFlare := flareSolverrURL != "" && site.BrowserEmulation - if useFlare { - log.Info("Trying FlareSolverr for site test", zap.String("url", site.URL)) - body, err := FetchURLWithFlareSolverr(flareSolverrURL, site.URL, site.Cookie, timeout, "", log) - if err == nil { - // Successfully got page via FlareSolverr - if IsCloudflareChallenge(body) { - return false, "站点被 Cloudflare/WAF 拦截,但 FlareSolverr 未能完全解决", nil - } - return true, "连接成功 (via FlareSolverr)", nil - } - log.Warn("FlareSolverr failed, falling back to direct request", zap.Error(err)) - // Fall through to direct request - } - - // Direct HTTP request with browser-like headers. Honors HTTP(S)_PROXY - // when the site has UseProxy enabled — this makes the "use proxy" - // checkbox in the UI actually do something. - client := NewSiteHTTPClient(timeout, site.UseProxy) - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if len(via) >= 10 { - return fmt.Errorf("too many redirects") - } - return nil - } - - req, err := http.NewRequest("GET", site.URL, nil) - if err != nil { - return false, err.Error(), nil - } - - headers := HTTPHeaderPresets() - for k, v := range headers { - req.Header.Set(k, v) - } - - ApplySiteAuthHeaders(req, site) - - resp, err := client.Do(req) - if err != nil { - return false, err.Error(), nil - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - bodyStr := string(body) - - if IsCloudflareChallenge(bodyStr) { - log.Warn("Cloudflare challenge detected", zap.String("url", site.URL)) - return false, "站点被 Cloudflare/WAF 拦截,请配置 FlareSolverr 或浏览器模拟", nil - } - - // Evaluate status code (mirror the reference Python project's semantics: - // 200 → success, 3xx → success/redirect-to-self, 401/403 → failure with - // hint to check credentials, 4xx/5xx → failure with raw status text). - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - return true, fmt.Sprintf("连接成功 (%s)", resp.Status), nil - case resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 307 || resp.StatusCode == 308: - loc := resp.Header.Get("Location") - if loc == "" { - loc = "(unknown)" - } - // Most PT sites redirect logged-out users to login; treat as failure. - return false, fmt.Sprintf("未登录或 Cookie 失效(重定向至 %s)", loc), nil - case resp.StatusCode == 401: - return false, "未授权(HTTP 401),请检查 API Key / Cookie", nil - case resp.StatusCode == 403: - return false, "认证失败(HTTP 403),请检查 Cookie / API Key 或站点是否需要浏览器模拟", nil - case resp.StatusCode == 429: - return false, "请求被限流(HTTP 429),请稍后再试", nil - case resp.StatusCode == 503: - return false, "服务暂时不可用(HTTP 503)", nil - default: - return false, resp.Status, nil - } -} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 33c99a1..ad684de 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -106,13 +106,13 @@ func TestAuthRequiredSyncsAccessTokenCookieFromBearer(t *testing.T) { }, }) - router := gin.New() - router.Use(AuthRequired(secret)) - router.GET("/api/discover/feed", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"ok": true}) - }) + router := gin.New() + router.Use(AuthRequired(secret)) + router.GET("/api/test-auth-cookie", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) - req := httptest.NewRequest(http.MethodGet, "/api/discover/feed", nil) + req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/internal/model/download_client.go b/internal/model/download_client.go deleted file mode 100644 index 45fca74..0000000 --- a/internal/model/download_client.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package model 定义下载客户端配置数据模型。 -package model - -// DownloadClient 下载客户端配置(qBittorrent / Transmission / Aria2)。 -// 支持多客户端并行运行,一个标记为默认客户端。 -type DownloadClient struct { - Base - Name string `gorm:"size:128;not null" json:"name"` - Type string `gorm:"size:32;not null" json:"type"` // qbittorrent / transmission / aria2 - Host string `gorm:"size:512;not null" json:"host"` // http://host:port - Username string `gorm:"size:256" json:"username"` - Password string `gorm:"size:1024" json:"-"` // AES加密存储 - IsDefault bool `gorm:"default:false" json:"is_default"` - Enabled bool `gorm:"default:true" json:"enabled"` - Extra string `gorm:"type:text" json:"-"` // JSON配置, AES加密 -} diff --git a/internal/model/download_subscription.go b/internal/model/download_subscription.go deleted file mode 100644 index 50ad885..0000000 --- a/internal/model/download_subscription.go +++ /dev/null @@ -1,81 +0,0 @@ -package model - -import "time" - -// DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。 -type DownloadTask struct { - Base - UserID string `gorm:"index;size:36" json:"user_id"` - SubscriptionID string `gorm:"index;size:36" json:"subscription_id,omitempty"` - DownloadClientID string `gorm:"index;size:36" json:"download_client_id,omitempty"` - ExternalID string `gorm:"index;size:128" json:"external_id,omitempty"` - Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / aria2 / http - URL string `gorm:"size:2048;not null" json:"-"` - Title string `gorm:"size:512" json:"title,omitempty"` - PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - SavePath string `gorm:"size:1024" json:"save_path"` - MediaType string `gorm:"size:16" json:"media_type,omitempty"` - MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` - // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 - OriginalName string `gorm:"size:512" json:"original_name,omitempty"` - OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated - Status string `gorm:"size:32;default:queued" json:"status"` - Progress float32 `json:"progress"` - - // AllowExistingLibrary is true for subscription wash/upgrade tasks that are - // allowed to replace an existing library item after download completion. - AllowExistingLibrary bool `gorm:"default:false" json:"allow_existing_library,omitempty"` -} - -// Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。 -type Subscription struct { - Base - UserID string `gorm:"index;size:36" json:"user_id"` - IdentityKey string `gorm:"size:64" json:"-"` - Name string `gorm:"size:128;not null" json:"name"` - FeedURL string `gorm:"size:2048;not null" json:"feed_url"` - Filter string `gorm:"size:512" json:"filter"` - MediaType string `gorm:"size:16" json:"media_type,omitempty"` - MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` - SavePath string `gorm:"size:1024" json:"save_path,omitempty"` - SearchMode string `gorm:"size:16;default:keyword" json:"search_mode,omitempty"` // keyword / imdb - IMDBID string `gorm:"size:32" json:"imdb_id,omitempty"` - Source string `gorm:"size:32" json:"source,omitempty"` - PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 - OriginalName string `gorm:"size:512" json:"original_name,omitempty"` - OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated - Resolution string `gorm:"size:32" json:"resolution,omitempty"` // 2160p / 1080p / 720p / best - Quality string `gorm:"size:64" json:"quality,omitempty"` // remux / bluray / web-dl / hdtv - Effects string `gorm:"size:128" json:"effects,omitempty"` // hdr,dolby-vision,atmos - ReleaseGroups string `gorm:"size:255" json:"release_groups,omitempty"` // comma separated - ExcludeWords string `gorm:"size:255" json:"exclude_words,omitempty"` // comma separated - MinSeeders int `gorm:"default:0" json:"min_seeders,omitempty"` - MaxSeeders int `gorm:"default:0" json:"max_seeders,omitempty"` - MinSizeGB float64 `gorm:"default:0" json:"min_size_gb,omitempty"` - MaxSizeGB float64 `gorm:"default:0" json:"max_size_gb,omitempty"` - FreeOnly bool `gorm:"default:false" json:"free_only,omitempty"` - WashEnabled bool `gorm:"default:false" json:"wash_enabled"` - WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders - TotalEpisodes int `gorm:"default:0" json:"total_episodes,omitempty"` - Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later - Enabled bool `gorm:"default:true" json:"enabled"` - LastRunAt *time.Time `json:"last_run_at,omitempty"` - ArchivedAt *time.Time `gorm:"index" json:"archived_at,omitempty"` - ArchiveReason string `gorm:"size:255" json:"archive_reason,omitempty"` - - DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"` - LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"` - MissingEpisodes []int `gorm:"-" json:"missing_episodes,omitempty"` - InLibrary bool `gorm:"-" json:"in_library"` -} diff --git a/internal/model/model.go b/internal/model/model.go index 28df1bd..794bc19 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -41,23 +41,13 @@ func AllModels() []interface{} { &Favorite{}, &Playlist{}, &PlaylistItem{}, - &DownloadTask{}, - &Subscription{}, &Setting{}, - &Site{}, &AccessLog{}, &APIConfig{}, &UserPermission{}, &RefreshToken{}, &ApiConfig{}, - &DownloadClient{}, - &NotifyChannel{}, - &TelegramBinding{}, - &STRMRecord{}, &PlayProfile{}, - &StorageConfig{}, - &AssistantSession{}, - &AssistantMessage{}, &RegistrationCode{}, &SignIn{}, &UserDevice{}, diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 922cda3..e64a403 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -19,7 +19,6 @@ func TestMediaReferenceFieldsAllowVirtualEmbyIDs(t *testing.T) { {name: "playback_history_media_id", model: &PlaybackHistory{}, fieldName: "MediaID"}, {name: "favorite_media_id", model: &Favorite{}, fieldName: "MediaID"}, {name: "playlist_item_media_id", model: &PlaylistItem{}, fieldName: "MediaID"}, - {name: "strm_record_media_id", model: &STRMRecord{}, fieldName: "MediaID"}, } for _, tc := range cases { diff --git a/internal/model/notify_channel.go b/internal/model/notify_channel.go deleted file mode 100644 index a31f27c..0000000 --- a/internal/model/notify_channel.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package model 定义通知渠道配置数据模型。 -package model - -// NotifyChannel 通知渠道配置。 -// 支持多种通知渠道:telegram / wechat / bark / webhook / email。 -// Events 字段存储 JSON array,表示该渠道订阅的事件类型。 -type NotifyChannel struct { - Base - Name string `gorm:"size:128;not null" json:"name"` - Type string `gorm:"size:32;not null" json:"type"` // telegram / wechat / bark / webhook / email - Enabled bool `gorm:"default:true" json:"enabled"` - Config string `gorm:"type:text" json:"-"` // JSON配置, AES加密 - Events string `gorm:"type:text" json:"events"` // 订阅的事件列表, JSON array -} diff --git a/internal/model/permission.go b/internal/model/permission.go index 45ebc6b..99bc59b 100644 --- a/internal/model/permission.go +++ b/internal/model/permission.go @@ -28,9 +28,8 @@ type UserPermission struct { CanRescrape bool `gorm:"default:false" json:"can_rescrape"` CanUseAI bool `gorm:"default:false" json:"can_use_ai"` CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"` - CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"` - CanViewDiscover bool `gorm:"default:false" json:"can_view_discover"` - CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"` + CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"` + CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"` CanManageSites bool `gorm:"default:false" json:"can_manage_sites"` CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"` CanManageUsers bool `gorm:"default:false" json:"can_manage_users"` @@ -65,9 +64,8 @@ func NewDefaultPermission(userID string) *UserPermission { CanRescrape: false, CanUseAI: false, CanCaptureFrames: false, - CanManageDownloads: false, - CanViewDiscover: false, - CanManageSubscriptions: false, + CanManageDownloads: false, + CanManageSubscriptions: false, CanManageSites: false, CanUseAIAssistant: false, CanManageUsers: false, @@ -90,9 +88,8 @@ func (p *UserPermission) PermissionMap() map[string]bool { "can_rescrape": p.CanRescrape, "can_use_ai": p.CanUseAI, "can_capture_frames": p.CanCaptureFrames, - "can_manage_downloads": p.CanManageDownloads, - "can_view_discover": p.CanViewDiscover, - "can_manage_subscriptions": p.CanManageSubscriptions, + "can_manage_downloads": p.CanManageDownloads, + "can_manage_subscriptions": p.CanManageSubscriptions, "can_manage_sites": p.CanManageSites, "can_use_ai_assistant": p.CanUseAIAssistant, "can_manage_users": p.CanManageUsers, diff --git a/internal/model/site.go b/internal/model/site.go deleted file mode 100644 index d6d7303..0000000 --- a/internal/model/site.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package model — PT 站点配置数据模型。 -package model - -import ( - "time" -) - -// Site PT 站点配置。 -type Site struct { - Base - Name string `gorm:"size:128;not null" json:"name"` - Type string `gorm:"size:32;not null" json:"type"` // nexusphp / gazelle / unit3d / mteam / yemapt / discuz / custom_rss - URL string `gorm:"size:512;not null" json:"url"` - AuthType string `gorm:"size:32;not null" json:"auth_type"` // cookie / api_key / auth_header - Cookie string `gorm:"type:text" json:"-"` // AES 加密 - APIKey string `gorm:"type:text" json:"-"` // AES 加密 - AuthHeader string `gorm:"type:text" json:"-"` // AES 加密 - - // ── 高级设置 ────────────────────────────────────────────────────────── - UserAgent string `gorm:"size:500" json:"user_agent"` // 自定义 User-Agent - RSSURL string `gorm:"size:1000" json:"rss_url"` // RSS 订阅地址 - Timeout int `gorm:"default:15" json:"timeout"` // 请求超时(秒), 0=不限制 - Priority int `gorm:"default:50" json:"priority"` // 优先级, 越小越优先 - UseProxy bool `gorm:"default:false" json:"use_proxy"` // 是否使用代理 - RateLimit bool `gorm:"default:false" json:"rate_limit"` // 是否限制访问频率 - BrowserEmulation bool `gorm:"default:false" json:"browser_emulation"` // 浏览器仿真(防爬) - - // ── 状态与统计 ──────────────────────────────────────────────────────── - LoginStatus string `gorm:"size:20;default:unknown" json:"login_status"` // unknown / ok / fail - UploadBytes int64 `gorm:"default:0" json:"upload_bytes"` // 上传字节统计 - DownloadBytes int64 `gorm:"default:0" json:"download_bytes"` // 下载字节统计 - - // ── 关联下载器 ──────────────────────────────────────────────────────── - Downloader string `gorm:"size:50" json:"downloader"` // qbittorrent / transmission / aria2 - - Enabled bool `gorm:"default:true" json:"enabled"` - IsDefault bool `gorm:"default:false" json:"is_default"` - Extra string `gorm:"type:text" json:"-"` // JSON 扩展配置, AES 加密 - LastError string `gorm:"size:1024" json:"last_error"` - LastCheckAt *time.Time `json:"last_check_at"` -} - -// SiteType 返回支持的站点类型列表。 -func SiteTypes() []string { - return []string{"nexusphp", "gazelle", "unit3d", "mteam", "yemapt", "discuz", "custom_rss"} -} - -// AuthTypes 返回支持的认证方式列表。 -func AuthTypes() []string { - return []string{"cookie", "api_key", "auth_header"} -} - -// DefaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。 -const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" - -// SiteTypes 返回支持的站点类型列表(用于前端下拉)。 -// 注意:此函数供 API 返回类型列表使用,不在此处添加新类型。 diff --git a/internal/model/storage_assistant.go b/internal/model/storage_assistant.go index 79b323b..501c770 100644 --- a/internal/model/storage_assistant.go +++ b/internal/model/storage_assistant.go @@ -14,23 +14,3 @@ type StorageConfig struct { Enabled bool `gorm:"default:true" json:"enabled"` LastError string `gorm:"size:512" json:"last_error,omitempty"` } - -// AssistantSession groups a multi-turn chat with the AI assistant. -type AssistantSession struct { - Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - Title string `gorm:"size:255" json:"title,omitempty"` -} - -// AssistantMessage is one entry in an AssistantSession transcript. -// -// Role is "user" | "assistant" | "system". The optional OperationID -// links a message to an action the assistant proposed (so the UI can -// offer Undo). -type AssistantMessage struct { - Base - SessionID string `gorm:"index;size:36;not null" json:"session_id"` - Role string `gorm:"size:16;not null" json:"role"` - Content string `gorm:"type:text;not null" json:"content"` - OperationID string `gorm:"size:36" json:"operation_id,omitempty"` -} diff --git a/internal/model/strm.go b/internal/model/strm.go deleted file mode 100644 index 9629cc2..0000000 --- a/internal/model/strm.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package model — STRM 文件记录数据模型。 -package model - -// STRMRecord STRM 文件记录。 -// 外部存储以"文件"形式入库,URL 指向实际资源。 -type STRMRecord struct { - Base - Title string `gorm:"size:512;not null;index" json:"title"` - URL string `gorm:"size:2048;not null" json:"url"` // STRM 文件指向的 URL - FilePath string `gorm:"size:1024;not null" json:"file_path"` // 本地 STRM 文件路径 - Protocol string `gorm:"size:32;not null" json:"protocol"` // webdav / alist / s3 / http / https - FileSize int64 `json:"file_size"` - MediaID string `gorm:"size:128;index" json:"media_id"` // 关联媒体 ID - MediaType string `gorm:"size:16" json:"media_type"` // movie / series - SeasonNum int `json:"season_num"` - EpisodeNum int `json:"episode_num"` -} - -// AllowedSTRMProtocols 协议白名单。 -var AllowedSTRMProtocols = []string{ - "webdav", "davs", - "alist", "alists", - "openlist", "openlists", - "s3", - "http", "https", -} - -// IsAllowedProtocol 检查协议是否在白名单中。 -func IsAllowedProtocol(protocol string) bool { - for _, p := range AllowedSTRMProtocols { - if p == protocol { - return true - } - } - return false -} diff --git a/internal/model/subscription_identity.go b/internal/model/subscription_identity.go deleted file mode 100644 index 1cad050..0000000 --- a/internal/model/subscription_identity.go +++ /dev/null @@ -1,90 +0,0 @@ -package model - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "strings" -) - -type subscriptionIdentityPayload struct { - Name string `json:"name"` - FeedURL string `json:"feed_url"` - Filter string `json:"filter"` - MediaType string `json:"media_type"` - MediaCategory string `json:"media_category"` - SavePath string `json:"save_path"` - SearchMode string `json:"search_mode"` - IMDBID string `json:"imdb_id"` - Resolution string `json:"resolution"` - Quality string `json:"quality"` - Effects string `json:"effects"` - ReleaseGroups string `json:"release_groups"` - ExcludeWords string `json:"exclude_words"` - MinSeeders int `json:"min_seeders"` - MaxSeeders int `json:"max_seeders"` - MinSizeGB float64 `json:"min_size_gb"` - MaxSizeGB float64 `json:"max_size_gb"` - FreeOnly bool `json:"free_only"` - WashEnabled bool `json:"wash_enabled"` - WashPriority string `json:"wash_priority"` - Priority int `json:"priority"` -} - -// SubscriptionIdentityKey identifies one functional subscription rule. It -// intentionally excludes display metadata and runtime/archive state because -// those fields are backfilled or changed automatically after creation. -func SubscriptionIdentityKey(sub *Subscription) string { - if sub == nil { - return "" - } - payload := subscriptionIdentityPayload{ - Name: subscriptionIdentityFold(sub.Name), - FeedURL: strings.TrimSpace(sub.FeedURL), - Filter: strings.TrimSpace(sub.Filter), - MediaType: subscriptionIdentityFold(sub.MediaType), - MediaCategory: subscriptionIdentityFold(sub.MediaCategory), - SavePath: strings.TrimSpace(sub.SavePath), - SearchMode: subscriptionIdentityFold(sub.SearchMode), - IMDBID: subscriptionIdentityFold(sub.IMDBID), - Resolution: subscriptionIdentityFold(sub.Resolution), - Quality: subscriptionIdentityFold(sub.Quality), - Effects: subscriptionIdentityList(sub.Effects), - ReleaseGroups: subscriptionIdentityList(sub.ReleaseGroups), - ExcludeWords: subscriptionIdentityList(sub.ExcludeWords), - MinSeeders: sub.MinSeeders, - MaxSeeders: sub.MaxSeeders, - MinSizeGB: sub.MinSizeGB, - MaxSizeGB: sub.MaxSizeGB, - FreeOnly: sub.FreeOnly, - WashEnabled: sub.WashEnabled, - WashPriority: subscriptionIdentityFold(sub.WashPriority), - Priority: sub.Priority, - } - raw, _ := json.Marshal(payload) - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]) -} - -func RefreshSubscriptionIdentity(sub *Subscription) string { - key := SubscriptionIdentityKey(sub) - if sub != nil { - sub.IdentityKey = key - } - return key -} - -func subscriptionIdentityFold(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func subscriptionIdentityList(value string) string { - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - if part = subscriptionIdentityFold(part); part != "" { - out = append(out, part) - } - } - return strings.Join(out, ",") -} diff --git a/internal/model/subscription_identity_test.go b/internal/model/subscription_identity_test.go deleted file mode 100644 index 361f1c0..0000000 --- a/internal/model/subscription_identity_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package model - -import "testing" - -func TestSubscriptionIdentityKeyNormalizesEquivalentRules(t *testing.T) { - base := Subscription{ - Name: " Example Show ", - FeedURL: "site-search://search?keyword=Example", - Filter: " Example.*Show ", - MediaType: "TV", - MediaCategory: " 欧美剧 ", - SearchMode: "KEYWORD", - Resolution: "1080P", - Effects: " HDR, Atmos ", - ExcludeWords: " CAM, TS ", - WashPriority: "Balanced", - Priority: 50, - } - equivalent := base - equivalent.Name = "example show" - equivalent.MediaType = "tv" - equivalent.MediaCategory = "欧美剧" - equivalent.SearchMode = "keyword" - equivalent.Resolution = "1080p" - equivalent.Effects = "hdr,atmos" - equivalent.ExcludeWords = "cam,ts" - equivalent.WashPriority = "balanced" - if got, want := SubscriptionIdentityKey(&equivalent), SubscriptionIdentityKey(&base); got != want { - t.Fatalf("equivalent keys differ: %s != %s", got, want) - } - - differentRule := base - differentRule.Resolution = "2160p" - if SubscriptionIdentityKey(&differentRule) == SubscriptionIdentityKey(&base) { - t.Fatal("different resolution should produce a different identity") - } - - displayOnly := base - displayOnly.PosterURL = "https://example.test/poster.jpg" - displayOnly.Overview = "new overview" - displayOnly.TotalEpisodes = 24 - if SubscriptionIdentityKey(&displayOnly) != SubscriptionIdentityKey(&base) { - t.Fatal("display/runtime metadata should not change identity") - } -} diff --git a/internal/model/telegram_binding.go b/internal/model/telegram_binding.go deleted file mode 100644 index 68135c9..0000000 --- a/internal/model/telegram_binding.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -// TelegramBinding links a Telegram account to a local MediaStationGo user. -// The binding is password-verified when /start is used, then reused for -// low-risk self-service actions such as toggling adult-library visibility. -type TelegramBinding struct { - Base - TelegramUserID int64 `gorm:"uniqueIndex;not null" json:"telegram_user_id"` - TelegramName string `gorm:"size:128" json:"telegram_name,omitempty"` - ChatID int64 `gorm:"index" json:"chat_id"` - UserID string `gorm:"index;size:36;not null" json:"user_id"` -} diff --git a/internal/repository/assistant_repo.go b/internal/repository/assistant_repo.go deleted file mode 100644 index b2bcd6c..0000000 --- a/internal/repository/assistant_repo.go +++ /dev/null @@ -1,64 +0,0 @@ -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// AssistantRepository persists model.AssistantSession + AssistantMessage records. -type AssistantRepository struct{ db *gorm.DB } - -// ─── Session ──────────────────────────────────────────────────────────── - -// CreateSession inserts a new chat session. -func (r *AssistantRepository) CreateSession(ctx context.Context, s *model.AssistantSession) error { - return r.db.WithContext(ctx).Create(s).Error -} - -// FindSession returns a session by ID, or (nil, nil). -func (r *AssistantRepository) FindSession(ctx context.Context, id string) (*model.AssistantSession, error) { - var s model.AssistantSession - err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &s, nil -} - -// ListSessions returns sessions for a user, or all when userID is empty. -func (r *AssistantRepository) ListSessions(ctx context.Context, userID string) ([]model.AssistantSession, error) { - q := r.db.WithContext(ctx).Model(&model.AssistantSession{}) - if userID != "" { - q = q.Where("user_id = ?", userID) - } - var rows []model.AssistantSession - err := q.Order("created_at desc").Find(&rows).Error - return rows, err -} - -// DeleteSession soft-deletes a session (cascade handled by GORM hooks if set). -func (r *AssistantRepository) DeleteSession(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.AssistantSession{}, "id = ?", id).Error -} - -// ─── Message ──────────────────────────────────────────────────────────── - -// AppendMessage inserts a new message into a session. -func (r *AssistantRepository) AppendMessage(ctx context.Context, m *model.AssistantMessage) error { - return r.db.WithContext(ctx).Create(m).Error -} - -// ListMessages returns all messages for a session in chronological order. -func (r *AssistantRepository) ListMessages(ctx context.Context, sessionID string) ([]model.AssistantMessage, error) { - var rows []model.AssistantMessage - err := r.db.WithContext(ctx).Where("session_id = ?", sessionID). - Order("created_at asc").Find(&rows).Error - return rows, err -} diff --git a/internal/repository/download_client_repo.go b/internal/repository/download_client_repo.go deleted file mode 100644 index 9883878..0000000 --- a/internal/repository/download_client_repo.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package repository 实现下载客户端配置的数据访问层。 -package repository - -import ( - "context" - "errors" - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// DownloadClientRepository persists model.DownloadClient records. -type DownloadClientRepository struct{ db *gorm.DB } - -// Create inserts a new download client. -func (r *DownloadClientRepository) Create(ctx context.Context, c *model.DownloadClient) error { - return r.db.WithContext(ctx).Create(c).Error -} - -// FindByID returns the download client by ID, or (nil, nil) when absent. -func (r *DownloadClientRepository) FindByID(ctx context.Context, id string) (*model.DownloadClient, error) { - var c model.DownloadClient - err := r.db.WithContext(ctx).Where("id = ?", id).First(&c).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &c, nil -} - -// FindDefault returns the default download client, or (nil, nil). -func (r *DownloadClientRepository) FindDefault(ctx context.Context) (*model.DownloadClient, error) { - var c model.DownloadClient - err := r.db.WithContext(ctx). - Where("is_default = ? AND enabled = ?", true, true). - Order("created_at asc"). - First(&c).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &c, nil -} - -// List returns all download clients ordered by creation time. -func (r *DownloadClientRepository) List(ctx context.Context) ([]model.DownloadClient, error) { - var rows []model.DownloadClient - err := r.db.WithContext(ctx).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// ListEnabled returns all enabled download clients. -func (r *DownloadClientRepository) ListEnabled(ctx context.Context) ([]model.DownloadClient, error) { - var rows []model.DownloadClient - err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// HasAnyIncludingDeleted reports whether the operator has ever configured a -// download client. This distinguishes legacy-only installations from systems -// where deleting/disabling all clients is an intentional "stop downloads" -// action, even though rows are soft-deleted. -func (r *DownloadClientRepository) HasAnyIncludingDeleted(ctx context.Context) (bool, error) { - var n int64 - err := r.db.WithContext(ctx).Unscoped().Model(&model.DownloadClient{}).Count(&n).Error - return n > 0, err -} - -// Update persists changes to a download client. -func (r *DownloadClientRepository) Update(ctx context.Context, c *model.DownloadClient) error { - return r.db.WithContext(ctx).Save(c).Error -} - -// Delete removes a download client (soft-delete). -func (r *DownloadClientRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.DownloadClient{}, "id = ?", id).Error -} - -// ClearDefault unsets the default flag for all clients. -func (r *DownloadClientRepository) ClearDefault(ctx context.Context) error { - return r.db.WithContext(ctx).Model(&model.DownloadClient{}). - Where("is_default = ?", true).Update("is_default", false).Error -} - -// SetDefault sets a specific client as default and clears others. -func (r *DownloadClientRepository) SetDefault(ctx context.Context, id string) error { - now := time.Now() - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.DownloadClient{}). - Where("is_default = ?", true).Update("is_default", false).Error; err != nil { - return err - } - return tx.Model(&model.DownloadClient{}). - Where("id = ?", id).Updates(map[string]any{ - "is_default": true, - "updated_at": now, - }).Error - }) -} diff --git a/internal/repository/download_repository.go b/internal/repository/download_repository.go deleted file mode 100644 index 041a557..0000000 --- a/internal/repository/download_repository.go +++ /dev/null @@ -1,24 +0,0 @@ -package repository - -import ( - "context" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// DownloadRepository persists model.DownloadTask records. -type DownloadRepository struct{ db *gorm.DB } - -// Create inserts a new download task. -func (r *DownloadRepository) Create(ctx context.Context, t *model.DownloadTask) error { - return r.db.WithContext(ctx).Create(t).Error -} - -// List returns all download tasks (admin view). -func (r *DownloadRepository) List(ctx context.Context) ([]model.DownloadTask, error) { - var rows []model.DownloadTask - err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error - return rows, err -} diff --git a/internal/repository/notify_channel_repo.go b/internal/repository/notify_channel_repo.go deleted file mode 100644 index f3aa4d4..0000000 --- a/internal/repository/notify_channel_repo.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package repository 实现通知渠道配置的数据访问层。 -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// NotifyChannelRepository persists model.NotifyChannel records. -type NotifyChannelRepository struct{ db *gorm.DB } - -// Create inserts a new notification channel. -func (r *NotifyChannelRepository) Create(ctx context.Context, c *model.NotifyChannel) error { - return r.db.WithContext(ctx).Create(c).Error -} - -// FindByID returns the notification channel by ID, or (nil, nil) when absent. -func (r *NotifyChannelRepository) FindByID(ctx context.Context, id string) (*model.NotifyChannel, error) { - var c model.NotifyChannel - err := r.db.WithContext(ctx).Where("id = ?", id).First(&c).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &c, nil -} - -// List returns all notification channels ordered by creation time. -func (r *NotifyChannelRepository) List(ctx context.Context) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - err := r.db.WithContext(ctx).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// ListEnabled returns all enabled notification channels. -func (r *NotifyChannelRepository) ListEnabled(ctx context.Context) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// ListByEvent returns all enabled channels that subscribe to the given event type. -func (r *NotifyChannelRepository) ListByEvent(ctx context.Context, eventType string) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - // Events is a JSON array stored as text; use LIKE for simple matching. - // This works for exact event type matches within the JSON array. - err := r.db.WithContext(ctx). - Where("enabled = ? AND events LIKE ?", true, "%\""+eventType+"\"%"). - Find(&rows).Error - return rows, err -} - -// ListByType returns all channels of a given type (telegram/wechat/bark/webhook/email). -func (r *NotifyChannelRepository) ListByType(ctx context.Context, channelType string) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - err := r.db.WithContext(ctx).Where("type = ?", channelType).Find(&rows).Error - return rows, err -} - -// Update persists changes to a notification channel. -func (r *NotifyChannelRepository) Update(ctx context.Context, c *model.NotifyChannel) error { - return r.db.WithContext(ctx).Save(c).Error -} - -// Delete removes a notification channel (soft-delete). -func (r *NotifyChannelRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.NotifyChannel{}, "id = ?", id).Error -} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index e94f42c..08220db 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -17,20 +17,12 @@ type Container struct { History *HistoryRepository Favorite *FavoriteRepository Playlist *PlaylistRepository - Download *DownloadRepository - Subscription *SubscriptionRepository Setting *SettingRepository Log *AccessLogRepository Permission *PermissionRepository RefreshToken *RefreshTokenRepository ApiConfig *ApiConfigRepository - DownloadClient *DownloadClientRepository - NotifyChannel *NotifyChannelRepository - Site *SiteRepository - STRM *STRMRepository PlayProfile *PlayProfileRepository - StorageConfig *StorageConfigRepository - Assistant *AssistantRepository RegCode *RegistrationCodeRepository SignIn *SignInRepository UserDevice *UserDeviceRepository @@ -47,20 +39,12 @@ func New(db *gorm.DB) *Container { History: &HistoryRepository{db: db}, Favorite: &FavoriteRepository{db: db}, Playlist: &PlaylistRepository{db: db}, - Download: &DownloadRepository{db: db}, - Subscription: &SubscriptionRepository{db: db}, Setting: &SettingRepository{db: db}, Log: &AccessLogRepository{db: db}, Permission: &PermissionRepository{db: db}, RefreshToken: &RefreshTokenRepository{db: db}, ApiConfig: &ApiConfigRepository{db: db}, - DownloadClient: &DownloadClientRepository{db: db}, - NotifyChannel: &NotifyChannelRepository{db: db}, - Site: &SiteRepository{db: db}, - STRM: &STRMRepository{db: db}, PlayProfile: &PlayProfileRepository{db: db}, - StorageConfig: &StorageConfigRepository{db: db}, - Assistant: &AssistantRepository{db: db}, RegCode: &RegistrationCodeRepository{db: db}, SignIn: &SignInRepository{db: db}, UserDevice: &UserDeviceRepository{db: db}, diff --git a/internal/repository/site_repo.go b/internal/repository/site_repo.go deleted file mode 100644 index a3726bf..0000000 --- a/internal/repository/site_repo.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package repository — PT 站点数据访问层。 -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SiteRepository persists model.Site records. -type SiteRepository struct{ db *gorm.DB } - -// Create inserts a new site. -func (r *SiteRepository) Create(ctx context.Context, s *model.Site) error { - return r.db.WithContext(ctx).Create(s).Error -} - -// FindByID returns the site by ID, or (nil, nil) when absent. -func (r *SiteRepository) FindByID(ctx context.Context, id string) (*model.Site, error) { - var s model.Site - err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &s, nil -} - -// List returns all sites ordered by name. -func (r *SiteRepository) List(ctx context.Context) ([]model.Site, error) { - var rows []model.Site - err := r.db.WithContext(ctx).Order("name asc").Find(&rows).Error - return rows, err -} - -// ListEnabled returns all enabled sites. -func (r *SiteRepository) ListEnabled(ctx context.Context) ([]model.Site, error) { - var rows []model.Site - err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("name asc").Find(&rows).Error - return rows, err -} - -// Update updates site fields. -func (r *SiteRepository) Update(ctx context.Context, s *model.Site) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// Delete removes a site (soft-delete). -func (r *SiteRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.Site{}, "id = ?", id).Error -} diff --git a/internal/repository/strm_repo.go b/internal/repository/strm_repo.go deleted file mode 100644 index c9dafd3..0000000 --- a/internal/repository/strm_repo.go +++ /dev/null @@ -1,82 +0,0 @@ -// Package repository — STRM 文件记录数据访问层。 -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// STRMRepository persists model.STRMRecord records. -type STRMRepository struct{ db *gorm.DB } - -// Create inserts a new STRM record. -func (r *STRMRepository) Create(ctx context.Context, s *model.STRMRecord) error { - return r.db.WithContext(ctx).Create(s).Error -} - -// CreateBatch inserts multiple STRM records. -func (r *STRMRepository) CreateBatch(ctx context.Context, records []model.STRMRecord) error { - if len(records) == 0 { - return nil - } - return r.db.WithContext(ctx).CreateInBatches(records, 100).Error -} - -// FindByID returns the STRM record by ID, or (nil, nil) when absent. -func (r *STRMRepository) FindByID(ctx context.Context, id string) (*model.STRMRecord, error) { - var s model.STRMRecord - err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &s, nil -} - -// List returns STRM records with optional filters. Supports pagination. -// Filters: media_id, media_type, protocol -func (r *STRMRepository) List(ctx context.Context, filters map[string]string, offset, limit int) ([]model.STRMRecord, int64, error) { - q := r.db.WithContext(ctx).Model(&model.STRMRecord{}) - - if mediaID, ok := filters["media_id"]; ok && mediaID != "" { - q = q.Where("media_id = ?", mediaID) - } - if mediaType, ok := filters["media_type"]; ok && mediaType != "" { - q = q.Where("media_type = ?", mediaType) - } - if protocol, ok := filters["protocol"]; ok && protocol != "" { - q = q.Where("protocol = ?", protocol) - } - - var total int64 - if err := q.Count(&total).Error; err != nil { - return nil, 0, err - } - - var rows []model.STRMRecord - err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error - return rows, total, err -} - -// Update updates a STRM record. -func (r *STRMRepository) Update(ctx context.Context, s *model.STRMRecord) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// Delete removes a STRM record (soft-delete). -func (r *STRMRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.STRMRecord{}, "id = ?", id).Error -} - -// FindByMediaID returns STRM records for a given media ID. -func (r *STRMRepository) FindByMediaID(ctx context.Context, mediaID string) ([]model.STRMRecord, error) { - var rows []model.STRMRecord - err := r.db.WithContext(ctx).Where("media_id = ?", mediaID).Find(&rows).Error - return rows, err -} diff --git a/internal/repository/subscription_repository.go b/internal/repository/subscription_repository.go deleted file mode 100644 index 3a557b7..0000000 --- a/internal/repository/subscription_repository.go +++ /dev/null @@ -1,72 +0,0 @@ -package repository - -import ( - "context" - "errors" - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SubscriptionRepository persists model.Subscription records. -type SubscriptionRepository struct{ db *gorm.DB } - -// Create inserts a new subscription rule. -func (r *SubscriptionRepository) Create(ctx context.Context, s *model.Subscription) error { - return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error -} - -// FindActiveByIdentity returns an unarchived, non-deleted rule with the same -// per-user functional identity. excludeID is used while editing/restoring. -func (r *SubscriptionRepository) FindActiveByIdentity(ctx context.Context, userID, identityKey, excludeID string) (*model.Subscription, error) { - if identityKey == "" { - return nil, nil - } - q := r.db.WithContext(ctx). - Where("user_id = ? AND identity_key = ? AND archived_at IS NULL", userID, identityKey) - if excludeID != "" { - q = q.Where("id <> ?", excludeID) - } - var sub model.Subscription - err := q.First(&sub).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &sub, nil -} - -// List returns active subscription rules. Archived rows live in history and are -// intentionally excluded from scheduler polling and the active management list. -func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) { - var rows []model.Subscription - err := r.db.WithContext(ctx).Unscoped(). - Where("archived_at IS NULL"). - Where("deleted_at IS NULL OR enabled = ?", true). - Order("created_at desc"). - Find(&rows).Error - return rows, err -} - -// History returns archived subscription rules. -func (r *SubscriptionRepository) History(ctx context.Context) ([]model.Subscription, error) { - var rows []model.Subscription - err := r.db.WithContext(ctx).Where("archived_at IS NOT NULL").Order("archived_at desc, updated_at desc").Find(&rows).Error - return rows, err -} - -// Archive moves a completed subscription out of the active list without -// deleting its rule details, so users can audit completed subscriptions later. -func (r *SubscriptionRepository) Archive(ctx context.Context, id, reason string, archivedAt time.Time) error { - return r.db.WithContext(ctx).Model(&model.Subscription{}). - Where("id = ? AND archived_at IS NULL", id). - Updates(map[string]any{ - "enabled": false, - "archived_at": &archivedAt, - "archive_reason": reason, - }).Error -} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 0e51362..6f7ac87 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -141,9 +141,6 @@ func (r *UserRepository) Delete(ctx context.Context, id string) error { if err := tx.Where("id = ?", id).First(&user).Error; err != nil { return err } - if err := tx.Unscoped().Where("user_id = ?", id).Delete(&model.TelegramBinding{}).Error; err != nil { - return err - } released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000") if len(released) > 64 { sum := sha256.Sum256([]byte(user.ID + user.Username)) diff --git a/internal/service/ai.go b/internal/service/ai.go deleted file mode 100644 index 3ca88d2..0000000 --- a/internal/service/ai.go +++ /dev/null @@ -1,265 +0,0 @@ -// Package service — AI integration (OpenAI-compatible chat completions). -// -// AIService is a thin wrapper around any OpenAI-compatible REST endpoint -// (OpenAI, DeepSeek, Qwen, Ollama, …). Today we expose two operations: -// -// - SmartSearch: interpret a free-form Chinese / English query and -// return a normalised JSON intent the React UI can -// translate into filter params. -// - Recommend: given a list of recently-watched titles, generate -// a short list of "you might like…" recommendations. -// -// The service is disabled (every method returns nil) when ai.enabled is -// false or ai.api_key is empty. -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" -) - -// AIService talks to an OpenAI-compatible chat-completions endpoint. -type AIService struct { - cfg *config.Config - log *zap.Logger - client *http.Client - apiConfig *APIConfigService -} - -// NewAIService is the constructor. -func NewAIService(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigService) *AIService { - timeout := time.Duration(cfg.AI.Timeout) * time.Second - if timeout <= 0 { - timeout = 30 * time.Second - } - return &AIService{ - cfg: cfg, - log: log, - apiConfig: apiConfig, - client: NewExternalHTTPClient(timeout), - } -} - -// Enabled reports whether the AI integration is configured. -func (a *AIService) Enabled() bool { - return a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != "" -} - -// EnabledFor reports whether the AI integration is configured for a request. -func (a *AIService) EnabledFor(ctx context.Context) bool { - return a.resolveRuntimeConfig(ctx).Enabled -} - -// AIStatus is returned to the UI for connection-state display. -type AIStatus struct { - Enabled bool `json:"enabled"` - Provider string `json:"provider"` - Model string `json:"model"` -} - -// Status resolves live database-backed AI config for the UI. -func (a *AIService) Status(ctx context.Context) AIStatus { - cfg := a.resolveRuntimeConfig(ctx) - return AIStatus{Enabled: cfg.Enabled, Provider: cfg.Provider, Model: cfg.Model} -} - -// SearchIntent is the structured output the smart search endpoint returns. -type SearchIntent struct { - Query string `json:"query"` - Year int `json:"year,omitempty"` - Genre string `json:"genre,omitempty"` - Type string `json:"type,omitempty"` // movie / tv / anime / music - Sort string `json:"sort,omitempty"` // recent / rating / random - Language string `json:"language,omitempty"` -} - -// SmartSearch turns a natural-language query into a structured intent. -// Returns a best-effort intent on parse failure (raw query passes through). -func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent, error) { - runtime := a.resolveRuntimeConfig(ctx) - if !runtime.Enabled { - return &SearchIntent{Query: raw}, nil - } - const sys = "You are a media-library search assistant. Read the user's query and " + - "output a JSON object with the keys: query (string), year (int, optional), " + - "genre (string, optional), type (movie|tv|anime|music, optional), sort " + - "(recent|rating|random, optional), language (zh|en, optional). Respond with " + - "JSON only, no commentary." - out, err := a.complete(ctx, runtime, sys, raw) - if err != nil { - return &SearchIntent{Query: raw}, err - } - var intent SearchIntent - if err := json.Unmarshal([]byte(out), &intent); err != nil { - // Fallback: tolerate non-JSON output by treating the raw text as - // the cleaned query. - intent.Query = strings.TrimSpace(out) - } - if intent.Query == "" { - intent.Query = raw - } - return &intent, nil -} - -// Recommend builds a short comma-separated list of titles given the user's -// history. The first call is intentionally best-effort: a future iteration -// may chain media DB lookups onto each suggestion. -func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([]string, error) { - runtime := a.resolveRuntimeConfig(ctx) - if !runtime.Enabled || len(history) == 0 { - return nil, nil - } - if max <= 0 || max > 20 { - max = 8 - } - sys := fmt.Sprintf("You are a film / TV recommendation assistant. Reply with %d "+ - "comma-separated titles only, no commentary, in the same language as the input.", max) - usr := "I recently watched: " + strings.Join(history, "; ") - out, err := a.complete(ctx, runtime, sys, usr) - if err != nil { - return nil, err - } - parts := strings.Split(out, ",") - titles := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - p = strings.Trim(p, "\"'`") - if p != "" { - titles = append(titles, p) - } - } - return titles, nil -} - -// complete is the shared helper — POST /v1/chat/completions. -func (a *AIService) complete(ctx context.Context, runtime aiRuntimeConfig, system, user string) (string, error) { - payload := map[string]any{ - "model": runtime.Model, - "temperature": 0.2, - "messages": []map[string]string{ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - }, - } - body, _ := json.Marshal(payload) - endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+runtime.APIKey) - resp, err := a.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - raw, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("ai %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) - } - - type choice struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } - var out struct { - Choices []choice `json:"choices"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", errors.New("ai: empty completion") - } - return strings.TrimSpace(out.Choices[0].Message.Content), nil -} - -// ChatTurn is one message in a multi-turn assistant transcript. -type ChatTurn struct { - Role string `json:"role"` - Content string `json:"content"` -} - -// Chat sends an entire transcript to the LLM. When the AI is disabled -// we return a deterministic offline reply so the assistant UI still -// has something to render. -func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error) { - runtime := a.resolveRuntimeConfig(ctx) - if !runtime.Enabled || len(history) == 0 { - return offlineReply(history), nil - } - // Build a chat/completions payload preserving the history order. - msgs := make([]map[string]string, 0, len(history)+1) - msgs = append(msgs, map[string]string{ - "role": "system", - "content": "You are MediaStationGo's helpful media-library assistant. " + - "Respond concisely in the user's language. " + - "Never invent file paths or media that don't exist.", - }) - for _, t := range history { - msgs = append(msgs, map[string]string{"role": t.Role, "content": t.Content}) - } - payload := map[string]any{ - "model": runtime.Model, - "temperature": 0.4, - "messages": msgs, - } - body, _ := json.Marshal(payload) - endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+runtime.APIKey) - resp, err := a.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - raw, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("ai %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) - } - type choice struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } - var out struct { - Choices []choice `json:"choices"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", errors.New("ai: empty completion") - } - return strings.TrimSpace(out.Choices[0].Message.Content), nil -} - -// offlineReply returns a deterministic stand-in response so the UI's -// chat view stays functional when the AI provider is not configured. -func offlineReply(history []ChatTurn) string { - if len(history) == 0 { - return "Hi — AI provider is not configured. Set up OpenAI/DeepSeek in API Configs to chat with me." - } - last := history[len(history)-1].Content - if len(last) > 80 { - last = last[:80] + "…" - } - return "(offline) Heard: " + last + "\n请在 API 配置中接入 LLM 后重试。" -} diff --git a/internal/service/ai_runtime_config.go b/internal/service/ai_runtime_config.go deleted file mode 100644 index 6fb5418..0000000 --- a/internal/service/ai_runtime_config.go +++ /dev/null @@ -1,60 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" -) - -type aiRuntimeConfig struct { - Enabled bool - Provider string - APIBase string - APIKey string - Model string -} - -func (a *AIService) resolveRuntimeConfig(ctx context.Context) aiRuntimeConfig { - out := aiRuntimeConfig{ - Enabled: a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != "", - Provider: strings.TrimSpace(a.cfg.AI.Provider), - APIBase: strings.TrimSpace(a.cfg.AI.APIBase), - APIKey: strings.TrimSpace(a.cfg.AI.APIKey), - Model: strings.TrimSpace(a.cfg.AI.Model), - } - if out.Provider == "" { - out.Provider = "openai" - } - if out.APIBase == "" { - out.APIBase = "https://api.openai.com/v1" - } - if out.Model == "" { - out.Model = "gpt-4o-mini" - } - - if a.apiConfig != nil { - resolved, err := a.apiConfig.Resolve(ctx, "openai") - if err != nil { - if a.log != nil { - a.log.Warn("ai: failed to resolve openai api config", zap.Error(err)) - } - return out - } - if resolved.BaseURL != "" { - out.APIBase = strings.TrimSpace(resolved.BaseURL) - } - if resolved.APIKey != "" { - out.APIKey = strings.TrimSpace(resolved.APIKey) - } - if resolved.Enabled && out.APIKey != "" { - out.Enabled = true - out.Provider = "openai" - return out - } - if !resolved.Enabled && (resolved.APIKey != "" || resolved.BaseURL != "" || resolved.Extra != "") { - out.Enabled = false - } - } - return out -} diff --git a/internal/service/ai_test.go b/internal/service/ai_test.go deleted file mode 100644 index c9e27a0..0000000 --- a/internal/service/ai_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package service - -import ( - "context" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAIStatusUsesDatabaseOpenAIConfig(t *testing.T) { - db := newServiceTestDB(t, &model.APIConfig{}) - repo := &repository.Container{DB: db} - crypto := NewCryptoService("test-secret", zap.NewNop()) - apiConfig := NewAPIConfigService(zap.NewNop(), repo, crypto) - key := "sk-test" - baseURL := "https://example.test/v1" - enabled := true - if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{ - APIKey: &key, - BaseURL: &baseURL, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - - ai := NewAIService(&config.Config{ - AI: config.AIConfig{ - Enabled: false, - Model: "gpt-4o-mini", - }, - }, zap.NewNop(), apiConfig) - - status := ai.Status(context.Background()) - if !status.Enabled { - t.Fatalf("AI status disabled, want enabled from database config") - } - if status.Provider != "openai" { - t.Fatalf("provider = %q, want openai", status.Provider) - } -} - -func TestAIStatusHonorsDisabledDatabaseOpenAIConfig(t *testing.T) { - db := newServiceTestDB(t, &model.APIConfig{}) - repo := &repository.Container{DB: db} - apiConfig := NewAPIConfigService(zap.NewNop(), repo, NewCryptoService("test-secret", zap.NewNop())) - key := "sk-test" - enabled := false - if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{ - APIKey: &key, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - - ai := NewAIService(&config.Config{ - AI: config.AIConfig{ - Enabled: true, - APIKey: "sk-file", - Model: "gpt-4o-mini", - }, - }, zap.NewNop(), apiConfig) - - if ai.Status(context.Background()).Enabled { - t.Fatalf("AI status enabled, want disabled when database config is explicitly disabled") - } -} diff --git a/internal/service/aria2_adp.go b/internal/service/aria2_adp.go deleted file mode 100644 index 01af2e1..0000000 --- a/internal/service/aria2_adp.go +++ /dev/null @@ -1,203 +0,0 @@ -// Package service — Aria2 下载适配器。 -// -// Aria2Adapter 实现了 DownloadAdapter 接口,通过 Aria2 JSON-RPC API -// 管理下载任务。 -package service - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "sync" - "time" -) - -var errAria2ListUnavailable = errors.New("aria2 task list unavailable") - -// Aria2Adapter 是 Aria2 的 DownloadAdapter 实现。 -type Aria2Adapter struct { - mu sync.Mutex - cfg DownloadClientConfig - client *http.Client - idSeq int -} - -// NewAria2Adapter 创建新的 Aria2 适配器。 -func NewAria2Adapter() *Aria2Adapter { - return &Aria2Adapter{ - client: NewInternalHTTPClient(20 * time.Second), - } -} - -// AddTorrent 通过 URL 添加种子或磁力链接。 -func (a *Aria2Adapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - - // Aria2 addUri 的参数: [secret, [uris], options] - uris := []string{torrentURL} - options := map[string]string{} - if savePath != "" { - options["dir"] = savePath - } - - result, err := a.rpcLocked(ctx, "aria2.addUri", []interface{}{uris, options}) - if err != nil { - return "", err - } - var gid string - if err := json.Unmarshal(result, &gid); err != nil { - return "", err - } - return gid, nil -} - -// AddMagnet 通过磁力链接添加下载。 -func (a *Aria2Adapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) { - return a.AddTorrent(ctx, magnet, savePath) -} - -// AddTorrentFile submits application-fetched .torrent bytes using -// aria2.addTorrent. The empty URI list matches aria2's RPC signature. -func (a *Aria2Adapter) AddTorrentFile(ctx context.Context, data []byte, _ string, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - options := map[string]string{} - if savePath != "" { - options["dir"] = savePath - } - result, err := a.rpcLocked(ctx, "aria2.addTorrent", []interface{}{ - base64.StdEncoding.EncodeToString(data), - []string{}, - options, - }) - if err != nil { - return "", err - } - var gid string - if err := json.Unmarshal(result, &gid); err != nil { - return "", err - } - return gid, nil -} - -// Pause 暂停下载任务(通过 GID)。 -func (a *Aria2Adapter) Pause(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "aria2.pause", []interface{}{hash}) - return err -} - -// Resume 恢复下载任务(通过 GID)。 -func (a *Aria2Adapter) Resume(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "aria2.unpause", []interface{}{hash}) - return err -} - -// Remove 移除下载任务。 -func (a *Aria2Adapter) Remove(ctx context.Context, hash string, deleteFiles bool) error { - a.mu.Lock() - defer a.mu.Unlock() - _, removeErr := a.rpcLocked(ctx, "aria2.remove", []interface{}{hash}) - _, resultErr := a.rpcLocked(ctx, "aria2.removeDownloadResult", []interface{}{hash}) - if removeErr != nil && resultErr != nil { - return errors.Join(removeErr, resultErr) - } - _ = deleteFiles // aria2 RPC removes the task/result but has no delete-local-data flag. - return nil -} - -// List 列出所有活动/等待/已停止的任务。 -func (a *Aria2Adapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - - var allResults []TorrentInfo - var listErrs []error - var successfulCalls int - - // 获取活动任务 - active, err := a.rpcLocked(ctx, "aria2.tellActive", []interface{}{ - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"}, - }) - if err == nil && active != nil { - successfulCalls++ - items := a.parseAria2Items(active) - allResults = append(allResults, items...) - } else if err != nil { - listErrs = append(listErrs, err) - } - - // 获取等待中的任务 - waiting, err := a.rpcLocked(ctx, "aria2.tellWaiting", []interface{}{ - 0, 100, - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"}, - }) - if err == nil && waiting != nil { - successfulCalls++ - items := a.parseAria2Items(waiting) - allResults = append(allResults, items...) - } else if err != nil { - listErrs = append(listErrs, err) - } - - // 获取已停止的任务 - stopped, err := a.rpcLocked(ctx, "aria2.tellStopped", []interface{}{ - 0, 100, - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"}, - }) - if err == nil && stopped != nil { - successfulCalls++ - items := a.parseAria2Items(stopped) - allResults = append(allResults, items...) - } else if err != nil { - listErrs = append(listErrs, err) - } - if successfulCalls == 0 { - return nil, fmt.Errorf("%w: %w", errAria2ListUnavailable, errors.Join(listErrs...)) - } - - if filter != "" { - filtered := make([]TorrentInfo, 0, len(allResults)) - for _, item := range allResults { - if strings.EqualFold(item.State, filter) { - filtered = append(filtered, item) - } - } - return filtered, errors.Join(listErrs...) - } - - return allResults, errors.Join(listErrs...) -} - -// GetInfo 获取单个任务信息。 -func (a *Aria2Adapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - - result, err := a.rpcLocked(ctx, "aria2.tellStatus", []interface{}{ - hash, - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections"}, - }) - if err != nil { - return nil, err - } - - var item map[string]interface{} - if err := json.Unmarshal(result, &item); err != nil { - return nil, err - } - - info := a.parseSingleItem(item) - if info == nil { - return nil, fmt.Errorf("task %s not found", hash) - } - return info, nil -} diff --git a/internal/service/aria2_parse.go b/internal/service/aria2_parse.go deleted file mode 100644 index 8976851..0000000 --- a/internal/service/aria2_parse.go +++ /dev/null @@ -1,116 +0,0 @@ -package service - -import ( - "encoding/json" - "time" -) - -// parseAria2Items 解析 Aria2 返回的任务列表。 -func (a *Aria2Adapter) parseAria2Items(raw json.RawMessage) []TorrentInfo { - var items []map[string]interface{} - if err := json.Unmarshal(raw, &items); err != nil { - return nil - } - - result := make([]TorrentInfo, 0, len(items)) - for _, item := range items { - info := a.parseSingleItem(item) - if info != nil { - result = append(result, *info) - } - } - return result -} - -// parseSingleItem 解析单个 Aria2 任务项。 -func (a *Aria2Adapter) parseSingleItem(item map[string]interface{}) *TorrentInfo { - gid := strVal(item["gid"]) - totalLength := toInt64(item["totalLength"]) - completedLength := toInt64(item["completedLength"]) - dlSpeed := toInt64(item["downloadSpeed"]) - upSpeed := toInt64(item["uploadSpeed"]) - status := strVal(item["status"]) - dir := strVal(item["dir"]) - numSeeders := int(toInt64(item["numSeeders"])) - connections := int(toInt64(item["connections"])) - - var name string - var contentPath string - - // 尝试从 bittorrent info 获取名称和 hash - if bt, ok := item["bittorrent"].(map[string]interface{}); ok { - if info, ok := bt["info"].(map[string]interface{}); ok { - name = strVal(info["name"]) - } - contentPath = downloaderPayloadPath(dir, name) - } - - if name == "" { - // 尝试从 files 获取文件名 - if files, ok := item["files"].([]interface{}); ok && len(files) > 0 { - if f, ok := files[0].(map[string]interface{}); ok { - filePath := strVal(f["path"]) - if filePath != "" { - contentPath = filePath - name = downloaderPathBase(filePath) - } - if name == "" { - name = strVal(f["uris"]) - } - } - } - } - if name == "" { - name = gid - } - - var progress float64 - if totalLength > 0 { - progress = float64(completedLength) / float64(totalLength) - } - - // Aria2 状态映射 - state := canonicalTorrentState(aria2StatusStr(status), progress) - - return &TorrentInfo{ - Hash: gid, - Name: name, - Size: totalLength, - Progress: progress, - DLSpeed: dlSpeed, - UPSpeed: upSpeed, - State: state, - SavePath: dir, - NumSeeds: numSeeders, - NumLeechs: aria2MaxInt(connections-numSeeders, 0), - AddedOn: time.Now(), - ContentPath: contentPath, - } -} - -// aria2StatusStr 将 Aria2 状态转为可读字符串。 -func aria2StatusStr(status string) string { - switch status { - case "active": - return "downloading" - case "waiting": - return "queued" - case "paused": - return "paused" - case "error": - return "error" - case "complete": - return "seeding" - case "removed": - return "removed" - default: - return status - } -} - -func aria2MaxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/service/aria2_rpc.go b/internal/service/aria2_rpc.go deleted file mode 100644 index 0444e73..0000000 --- a/internal/service/aria2_rpc.go +++ /dev/null @@ -1,166 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -// aria2Request 是 Aria2 JSON-RPC 请求结构。 -type aria2Request struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - ID string `json:"id"` - Params []interface{} `json:"params"` -} - -// aria2Response 是 Aria2 JSON-RPC 响应结构。 -type aria2Response struct { - JSONRPC string `json:"jsonrpc"` - ID string `json:"id"` - Result json.RawMessage `json:"result"` - Error *aria2Error `json:"error"` -} - -// aria2Error 是 Aria2 JSON-RPC 错误结构。 -type aria2Error struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// Initialize 配置并初始化 Aria2 RPC 连接。 -func (a *Aria2Adapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error { - a.mu.Lock() - defer a.mu.Unlock() - endpoint, err := normalizeDownloadClientEndpoint("aria2", cfg.Host) - if err != nil { - return err - } - cfg.Host = endpoint - a.cfg = cfg - a.idSeq = 0 - return a.getVersionLocked(ctx) -} - -// Ping 测试连接。 -func (a *Aria2Adapter) Ping(ctx context.Context) error { - a.mu.Lock() - defer a.mu.Unlock() - return a.getVersionLocked(ctx) -} - -// getVersionLocked 内部版本检查(调用者必须持有锁)。 -func (a *Aria2Adapter) getVersionLocked(ctx context.Context) error { - rpcURL, err := downloadClientRPCURL("aria2", a.cfg.Host) - if err != nil { - return err - } - - req := &aria2Request{ - JSONRPC: "2.0", - Method: "aria2.getVersion", - ID: a.nextID(), - Params: []interface{}{"token:" + a.cfg.Password}, - } - - body, err := json.Marshal(req) - if err != nil { - return err - } - - httpReq, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) - if err != nil { - return err - } - httpReq.Header.Set("Content-Type", "application/json") - if a.cfg.Username != "" { - httpReq.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - - resp, err := a.client.Do(httpReq) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("aria2 rpc: %d", resp.StatusCode) - } - return nil -} - -// rpcLocked 发送 JSON-RPC 请求(调用者必须持有锁)。 -func (a *Aria2Adapter) rpcLocked(ctx context.Context, method string, params []interface{}) (json.RawMessage, error) { - rpcURL, err := downloadClientRPCURL("aria2", a.cfg.Host) - if err != nil { - return nil, err - } - - if params == nil { - params = []interface{}{} - } - - // 如果 secret 不在 params 中,添加到第一位 - if len(params) > 0 { - if secret, ok := params[0].(string); ok && strings.HasPrefix(secret, "token:") { - // 已经有 secret - } else { - newParams := make([]interface{}, 0, len(params)+1) - newParams = append(newParams, "token:"+a.cfg.Password) - newParams = append(newParams, params...) - params = newParams - } - } else { - params = []interface{}{"token:" + a.cfg.Password} - } - - req := &aria2Request{ - JSONRPC: "2.0", - Method: method, - ID: a.nextID(), - Params: params, - } - - body, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) - if err != nil { - return nil, err - } - httpReq.Header.Set("Content-Type", "application/json") - if a.cfg.Username != "" { - httpReq.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - - resp, err := a.client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var rpcResp aria2Response - if err := json.Unmarshal(respBody, &rpcResp); err != nil { - return nil, err - } - if rpcResp.Error != nil { - return nil, fmt.Errorf("aria2 rpc error [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message) - } - return rpcResp.Result, nil -} - -// nextID 生成递增的请求 ID。 -func (a *Aria2Adapter) nextID() string { - a.idSeq++ - return fmt.Sprintf("msg-%d", a.idSeq) -} diff --git a/internal/service/assistant.go b/internal/service/assistant.go deleted file mode 100644 index fed00a2..0000000 --- a/internal/service/assistant.go +++ /dev/null @@ -1,209 +0,0 @@ -// Package service — multi-turn AI assistant chat. -// -// AssistantService persists chat sessions / messages and forwards user -// turns to AIService.Chat() for the actual LLM call. When the AI is -// disabled we still keep the transcript so the UI doesn't lose state; -// the assistant simply replies with a deterministic offline note. -// -// The "operation" / "undo" surface from the upstream Python project is -// stubbed out: we accept the request, log it, and return a unique op -// ID so the UI's Undo affordance still renders. Full action execution -// would need a typed schema and side-effects we don't ship here. -package service - -import ( - "context" - "errors" - "strings" - "time" - - "github.com/google/uuid" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// AssistantService coordinates AssistantSession + AssistantMessage rows -// against the underlying AIService. -type AssistantService struct { - log *zap.Logger - repo *repository.Container - ai *AIService -} - -// NewAssistantService is the constructor. -func NewAssistantService(log *zap.Logger, repo *repository.Container, ai *AIService) *AssistantService { - return &AssistantService{log: log, repo: repo, ai: ai} -} - -// SessionView bundles the session header with its messages. -type SessionView struct { - Session model.AssistantSession `json:"session"` - Messages []model.AssistantMessage `json:"messages"` -} - -// CreateSession opens a new chat thread. -func (s *AssistantService) CreateSession(ctx context.Context, userID, title string) (*model.AssistantSession, error) { - if title == "" { - title = "New chat" - } - sess := &model.AssistantSession{UserID: userID, Title: title} - if err := s.repo.Assistant.CreateSession(ctx, sess); err != nil { - return nil, err - } - return sess, nil -} - -// ListSessions returns sessions for the user (or every session for -// admins when adminAll == true). -func (s *AssistantService) ListSessions(ctx context.Context, userID string, adminAll bool) ([]model.AssistantSession, error) { - if adminAll { - return s.repo.Assistant.ListSessions(ctx, "") - } - return s.repo.Assistant.ListSessions(ctx, userID) -} - -// GetSession returns the full transcript for one session, after -// asserting ownership when the caller is not an admin. -func (s *AssistantService) GetSession(ctx context.Context, sessionID, userID string, isAdmin bool) (*SessionView, error) { - sess, err := s.repo.Assistant.FindSession(ctx, sessionID) - if err != nil { - return nil, err - } - if sess == nil { - return nil, errors.New("session not found") - } - if !isAdmin && sess.UserID != userID { - return nil, errors.New("forbidden") - } - msgs, err := s.repo.Assistant.ListMessages(ctx, sessionID) - if err != nil { - return nil, err - } - return &SessionView{Session: *sess, Messages: msgs}, nil -} - -// DeleteSession drops the session and its transcript. -func (s *AssistantService) DeleteSession(ctx context.Context, sessionID, userID string, isAdmin bool) error { - sess, err := s.repo.Assistant.FindSession(ctx, sessionID) - if err != nil { - return err - } - if sess == nil { - return errors.New("session not found") - } - if !isAdmin && sess.UserID != userID { - return errors.New("forbidden") - } - return s.repo.Assistant.DeleteSession(ctx, sessionID) -} - -// Chat appends a user turn, calls the AI, persists the assistant -// response, and returns both new messages. -func (s *AssistantService) Chat(ctx context.Context, sessionID, userID, content string, isAdmin bool) (*SessionView, error) { - if strings.TrimSpace(content) == "" { - return nil, errors.New("content required") - } - sess, err := s.repo.Assistant.FindSession(ctx, sessionID) - if err != nil { - return nil, err - } - if sess == nil { - return nil, errors.New("session not found") - } - if !isAdmin && sess.UserID != userID { - return nil, errors.New("forbidden") - } - - // Append the user turn. - userMsg := &model.AssistantMessage{ - SessionID: sessionID, - Role: "user", - Content: strings.TrimSpace(content), - } - if err := s.repo.Assistant.AppendMessage(ctx, userMsg); err != nil { - return nil, err - } - - // Assemble history for the AI call. - prior, _ := s.repo.Assistant.ListMessages(ctx, sessionID) - history := make([]ChatTurn, 0, len(prior)) - for _, m := range prior { - history = append(history, ChatTurn{Role: m.Role, Content: m.Content}) - } - - // Call the LLM (or fall back to a deterministic offline reply). - reply, err := s.ai.Chat(ctx, history) - if err != nil { - s.log.Warn("assistant chat failed", zap.Error(err)) - reply = "(AI 暂未配置或调用失败,请稍后再试。)" - } - asstMsg := &model.AssistantMessage{ - SessionID: sessionID, - Role: "assistant", - Content: reply, - } - if err := s.repo.Assistant.AppendMessage(ctx, asstMsg); err != nil { - return nil, err - } - return s.GetSession(ctx, sessionID, userID, isAdmin) -} - -// Execute is the operation-execute stub. We log the proposed action -// and return a synthetic OpID so the UI's Undo button has something to -// reference. Real execution would need a typed action schema we don't -// ship here. -func (s *AssistantService) Execute(ctx context.Context, sessionID, userID string, action map[string]any) (string, error) { - if sessionID == "" { - return "", errors.New("session_id required") - } - opID := uuid.NewString() - s.log.Info("assistant.execute (stub)", - zap.String("session_id", sessionID), - zap.String("user_id", userID), - zap.String("op_id", opID), - zap.Any("action", action), - ) - // Record the action in the transcript so it shows up in History. - _ = s.repo.Assistant.AppendMessage(ctx, &model.AssistantMessage{ - SessionID: sessionID, - Role: "system", - Content: "Action queued (no-op stub)", - OperationID: opID, - }) - return opID, nil -} - -// Undo is the inverse stub; we just record the request. -func (s *AssistantService) Undo(ctx context.Context, opID string) error { - s.log.Info("assistant.undo (stub)", zap.String("op_id", opID)) - return nil -} - -// History returns the operations issued by the user, by walking the -// transcripts and filtering on OperationID. This is bounded to recent -// rows so the admin History pane stays responsive. -func (s *AssistantService) History(ctx context.Context, userID string, isAdmin bool) ([]map[string]any, error) { - sessions, err := s.ListSessions(ctx, userID, isAdmin) - if err != nil { - return nil, err - } - out := make([]map[string]any, 0) - cutoff := time.Now().AddDate(0, 0, -30) - for _, sess := range sessions { - msgs, _ := s.repo.Assistant.ListMessages(ctx, sess.ID) - for _, m := range msgs { - if m.OperationID == "" || m.CreatedAt.Before(cutoff) { - continue - } - out = append(out, map[string]any{ - "op_id": m.OperationID, - "session": sess.ID, - "created_at": m.CreatedAt, - "content": m.Content, - }) - } - } - return out, nil -} diff --git a/internal/service/auth.go b/internal/service/auth.go index 9029a6c..d72f9a3 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -40,10 +40,15 @@ var ( ErrUserExpired = errors.New("user account has expired") ) -// MaxUsers is kept for compatibility with tests and callers; dynamic runtime -// checks use LicensedMaxUsers so official licensed builds can raise the quota. +// MaxUsers 是单实例允许的最大用户数(开源版本固定上限)。 const MaxUsers = OpenSourceUserLimit +// OpenSourceUserLimit 是开源版本的用户数上限。 +const OpenSourceUserLimit = 20 + +// UserLimit 是注册/邀请码发放时的固定用户数上限(授权管理已移除,固定为开源上限)。 +const UserLimit = OpenSourceUserLimit + // SeedAdmin makes sure at least one admin user exists. It mirrors the // legacy default behaviour: if no admin row is found we create // `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn. @@ -100,7 +105,7 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( } if n, err := s.repo.User.Count(ctx); err != nil { return nil, nil, err - } else if n >= LicensedMaxUsers(ctx, s.repo) { + } else if n >= UserLimit { return nil, nil, ErrUserLimitReached } hash, err := hashPassword(password) diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go index fe10110..3b3a9a4 100644 --- a/internal/service/auth_user_limits_test.go +++ b/internal/service/auth_user_limits_test.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/json" "errors" "fmt" "path/filepath" @@ -21,7 +20,7 @@ import ( func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *ProfileService, *PermissionService) { t.Helper() - db := newServiceTestDB(t, &model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.TelegramBinding{}, &model.Setting{}) + db := newServiceTestDB(t, &model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.Setting{}) sqlDB, err := db.DB() if err != nil { t.Fatal(err) @@ -58,44 +57,6 @@ func TestRegisterRejectsMoreThanTwentyUsers(t *testing.T) { } } -func TestRegisterUsesLicensedUserLimit(t *testing.T) { - ctx := context.Background() - repos, auth, _, _ := newAuthTestServices(t) - maxUsers := 25 - state := LicenseActivationState{Valid: true, LicenseType: "plus", MaxUsers: &maxUsers} - raw, _ := json.Marshal(state) - if err := repos.Setting.Set(ctx, LicenseSettingActivation, string(raw)); err != nil { - t.Fatal(err) - } - for i := 0; i < OpenSourceUserLimit; i++ { - if err := repos.User.Create(ctx, &model.User{ - Username: fmt.Sprintf("licensed-%02d", i), - PasswordHash: "hash", - Role: "user", - Tier: "free", - }); err != nil { - t.Fatal(err) - } - } - - if _, _, err := auth.Register(ctx, "extra", "password"); err != nil { - t.Fatalf("licensed user limit should allow user 21: %v", err) - } -} - -func TestLicensedMaxUsersCanBeUnlimited(t *testing.T) { - ctx := context.Background() - repos, _, _, _ := newAuthTestServices(t) - state := LicenseActivationState{Valid: true, LicenseType: "enterprise", UnlimitedUsers: true} - raw, _ := json.Marshal(state) - if err := repos.Setting.Set(ctx, LicenseSettingActivation, string(raw)); err != nil { - t.Fatal(err) - } - if got := LicensedMaxUsers(ctx, repos); got <= 1_000_000 { - t.Fatalf("unlimited license should return a very high limit, got %d", got) - } -} - func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) { _, auth, _, _ := newAuthTestServices(t) user, _, err := auth.Register(context.Background(), "viewer", "password") @@ -114,14 +75,6 @@ func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) { if err != nil { t.Fatalf("register old user: %v", err) } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 10001, - TelegramName: "@viewer", - ChatID: 10001, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create telegram binding: %v", err) - } if err := repos.User.Delete(ctx, user.ID); err != nil { t.Fatalf("delete user: %v", err) } @@ -136,13 +89,6 @@ func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) { if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil { t.Fatalf("login recreated user: %v", err) } - var bindings int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 10001).Count(&bindings).Error; err != nil { - t.Fatalf("count bindings: %v", err) - } - if bindings != 0 { - t.Fatalf("deleted user telegram bindings should be removed, got %d", bindings) - } } func TestRegisterReleasesLegacySoftDeletedUsername(t *testing.T) { diff --git a/internal/service/bangumi_calendar.go b/internal/service/bangumi_calendar.go deleted file mode 100644 index 192e70d..0000000 --- a/internal/service/bangumi_calendar.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package service — Bangumi discovery calendar. -package service - -import ( - "context" - "strconv" - "strings" -) - -// Calendar returns Bangumi's public on-air anime calendar as a recommendation -// rail. It needs no token, but NewBangumiProvider still attaches one when set. -func (b *BangumiProvider) Calendar(ctx context.Context) ([]ExternalMediaResult, error) { - type subject struct { - ID int `json:"id"` - Name string `json:"name"` - NameCN string `json:"name_cn"` - Summary string `json:"summary"` - AirDate string `json:"air_date"` - Images struct { - Large string `json:"large"` - Common string `json:"common"` - } `json:"images"` - Rating struct { - Score float32 `json:"score"` - } `json:"rating"` - } - type day struct { - Items []subject `json:"items"` - } - var days []day - if err := b.getJSON(ctx, b.base+"/calendar", &days); err != nil { - return nil, err - } - out := make([]ExternalMediaResult, 0, 24) - seen := map[int]struct{}{} - for _, day := range days { - for _, item := range day.Items { - if _, ok := seen[item.ID]; ok { - continue - } - seen[item.ID] = struct{}{} - title := strings.TrimSpace(item.NameCN) - if title == "" { - title = strings.TrimSpace(item.Name) - } - if title == "" { - continue - } - poster := item.Images.Large - if poster == "" { - poster = item.Images.Common - } - poster = normalizeBangumiImageURL(poster) - year := 0 - if len(item.AirDate) >= 4 { - year, _ = strconv.Atoi(item.AirDate[:4]) - } - out = append(out, ExternalMediaResult{ - Source: "bangumi", - MediaType: "anime", - Title: title, - OriginalName: item.Name, - Overview: item.Summary, - PosterURL: poster, - Year: year, - Rating: item.Rating.Score, - BangumiID: item.ID, - SubscribeKeyword: buildSubscribeKeyword(title, year), - SubscribeAliases: buildSubscribeAliases(title, item.Name, year), - }) - if len(out) >= 24 { - return out, nil - } - } - } - return out, nil -} diff --git a/internal/service/boot_cloud.go b/internal/service/boot_cloud.go deleted file mode 100644 index 85dcea2..0000000 --- a/internal/service/boot_cloud.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// BootCloudLibraries optionally scans cloud libraries after startup. It is -// disabled by default for huge cloud mounts; normal automatic refresh is handled -// by the nightly cloud_sync scheduler window, and operators can still scan -// manually at any time. -func (c *Container) BootCloudLibraries(ctx context.Context) { - if c == nil || c.Repo == nil || c.Scan == nil { - return - } - if !bootCloudLibraryScanEnabled(ctx, c.Repo) { - c.Log.Info("boot: cloud library scans disabled; use manual scan or nightly cloud sync") - return - } - libs, err := c.Repo.Library.List(ctx) - if err != nil { - c.Log.Warn("boot cloud libraries: list failed", zap.Error(err)) - return - } - libs = FilterScannableCloudLibraries(ctx, c.Repo, libs) - cloudLibs := make([]model.Library, 0) - for _, lib := range libs { - if !lib.Enabled { - continue - } - if _, ok := ParseCloudLibraryMount(lib.Path); ok { - cloudLibs = append(cloudLibs, lib) - } - } - if len(cloudLibs) == 0 { - return - } - c.Log.Info("boot: scheduling cloud library scans", zap.Int("count", len(cloudLibs))) - // 延迟3秒后启动,避免和系统初始化任务冲突 - time.AfterFunc(3*time.Second, func() { - go c.runBootCloudLibraryScanQueue(cloudLibs) - }) -} - -func (c *Container) runBootCloudLibraryScanQueue(cloudLibs []model.Library) { - for _, lib := range cloudLibs { - libID := lib.ID - libName := lib.Name - scanCtx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), c.Repo, 24*time.Hour)) - c.Log.Info("boot: scanning cloud library", zap.String("id", libID), zap.String("name", libName)) - if _, err := c.Scan.ScanLibraryWithoutAutoScrape(scanCtx, libID); err != nil { - c.Log.Warn("boot: cloud library scan failed", zap.String("id", libID), zap.String("name", libName), zap.Error(err)) - } else { - c.Log.Info("boot: cloud library scan completed", zap.String("id", libID), zap.String("name", libName)) - } - cancel() - } -} - -func bootCloudLibraryScanEnabled(ctx context.Context, repo *repository.Container) bool { - if repo == nil || repo.Setting == nil { - return false - } - value, err := repo.Setting.Get(ctx, "cloud.boot_scan_enabled") - if err != nil { - return false - } - return parseBoolSetting(value, false) -} diff --git a/internal/service/boot_cloud_health.go b/internal/service/boot_cloud_health.go deleted file mode 100644 index 8da6560..0000000 --- a/internal/service/boot_cloud_health.go +++ /dev/null @@ -1,102 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" -) - -const cloudStorageMissingConfigWarnPrefix = "cloud.storage.missing_config_warned." - -// BootCloudStorageHealthCheck验证所有已配置的云盘存储在启动时是否可用 -func (c *Container) BootCloudStorageHealthCheck(ctx context.Context) { - if c == nil || c.StorageCfg == nil { - return - } - - configs, err := c.StorageCfg.List(ctx) - if err != nil { - c.Log.Warn("boot: cloud storage health check failed to list configs", zap.Error(err)) - return - } - - cloudConfigs := make([]StorageView, 0) - for _, cfg := range configs { - if cfg.Enabled && IsAdminCloudConfigurable(cfg.Type) { - cloudConfigs = append(cloudConfigs, cfg) - } - } - - if len(cloudConfigs) == 0 { - c.Log.Info("boot: no enabled cloud storage configured") - return - } - - c.Log.Info("boot: checking cloud storage health", zap.Int("count", len(cloudConfigs))) - - for _, cfg := range cloudConfigs { - go func(typ string) { - checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - provider, err := c.StorageCfg.CloudProvider(checkCtx, typ) - if err != nil { - if c.warnMissingCloudStorageConfigOnce(checkCtx, typ, err) { - return - } - c.Log.Warn("boot: cloud storage unavailable", zap.String("type", typ), zap.Error(err)) - return - } - - if err := provider.Ping(checkCtx); err != nil { - if c.warnMissingCloudStorageConfigOnce(checkCtx, typ, err) { - return - } - c.Log.Warn("boot: cloud storage ping failed", zap.String("type", typ), zap.Error(err)) - } else { - c.Log.Info("boot: cloud storage healthy", zap.String("type", typ)) - } - }(cfg.Type) - } -} - -func (c *Container) warnMissingCloudStorageConfigOnce(ctx context.Context, typ string, err error) bool { - reason := cloudStorageMissingConfigReason(err) - if reason == "" { - return false - } - if c == nil || c.Repo == nil || c.Repo.Setting == nil { - if c != nil && c.Log != nil { - c.Log.Warn("boot: cloud storage config incomplete; skipping health check", zap.String("type", typ), zap.String("reason", reason), zap.Error(err)) - } - return true - } - key := cloudStorageMissingConfigWarnPrefix + strings.TrimSpace(typ) + "." + reason - if value, getErr := c.Repo.Setting.Get(ctx, key); getErr == nil && strings.EqualFold(strings.TrimSpace(value), "true") { - return true - } - if c.Log != nil { - c.Log.Warn("boot: cloud storage config incomplete; skipping health check", zap.String("type", typ), zap.String("reason", reason), zap.Error(err)) - } - if setErr := c.Repo.Setting.Set(ctx, key, "true"); setErr != nil && c.Log != nil { - c.Log.Debug("remember cloud storage config warning failed", zap.String("type", typ), zap.Error(setErr)) - } - return true -} - -func cloudStorageMissingConfigReason(err error) string { - if err == nil { - return "" - } - msg := strings.ToLower(strings.TrimSpace(err.Error())) - switch { - case strings.Contains(msg, "missing cookie") || (strings.Contains(msg, "missing") && strings.Contains(msg, "cookie")): - return "missing_cookie" - case strings.Contains(msg, "missing webdav url"): - return "missing_webdav_url" - default: - return "" - } -} diff --git a/internal/service/boot_cloud_health_test.go b/internal/service/boot_cloud_health_test.go deleted file mode 100644 index 09245cc..0000000 --- a/internal/service/boot_cloud_health_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - - "go.uber.org/zap" - "go.uber.org/zap/zaptest/observer" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestCloudStorageMissingConfigReason(t *testing.T) { - cases := []struct { - err error - want string - }{ - {errors.New("115: missing cookie"), "missing_cookie"}, - {errors.New("openlist: missing cookie"), "missing_cookie"}, - {errors.New("clouddrive2: missing WebDAV URL"), "missing_webdav_url"}, - {errors.New("openlist: token expired"), ""}, - } - for _, tc := range cases { - if got := cloudStorageMissingConfigReason(tc.err); got != tc.want { - t.Fatalf("reason(%q) = %q, want %q", tc.err, got, tc.want) - } - } -} - -func TestWarnMissingCloudStorageConfigOncePersistsMarker(t *testing.T) { - db := newServiceTestDB(t, &model.Setting{}) - core, observed := observer.New(zap.WarnLevel) - c := &Container{ - Log: zap.New(core), - Repo: repository.New(db), - } - err := errors.New("115: missing cookie") - - if !c.warnMissingCloudStorageConfigOnce(context.Background(), "cloud115", err) { - t.Fatal("missing config should be handled") - } - if !c.warnMissingCloudStorageConfigOnce(context.Background(), "cloud115", err) { - t.Fatal("missing config should still be classified on second call") - } - if observed.FilterMessage("boot: cloud storage config incomplete; skipping health check").Len() != 1 { - t.Fatalf("warn count = %d, want 1", observed.Len()) - } - if c.warnMissingCloudStorageConfigOnce(context.Background(), "cloud115", errors.New("network timeout")) { - t.Fatal("non-missing config error should not be swallowed") - } -} diff --git a/internal/service/bot_cleanup_test.go b/internal/service/bot_cleanup_test.go deleted file mode 100644 index 71629b7..0000000 --- a/internal/service/bot_cleanup_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotCleanupRulesDefaultToEmpty(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - - cfg := loadBotConfig(ctx, repos) - if len(cfg.AccountCleanupRules) != 0 { - t.Fatalf("default cleanup rules should be empty, got %+v", cfg.AccountCleanupRules) - } -} - -func TestBotCleanupRulesCanBeDeletedUntilEmpty(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - if _, err := bot.executeCommand(ctx, channel, msg, "/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6"); err != nil { - t.Fatal(err) - } - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup_rule del watch_3_5d_6h") - if err != nil { - t.Fatal(err) - } - cfg := loadBotConfig(ctx, repos) - if len(cfg.AccountCleanupRules) != 0 { - t.Fatalf("cleanup rules should stay empty after deleting the last rule; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule list") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "暂无规则") { - t.Fatalf("expected empty rule list, got %q", reply.Text) - } -} - -func TestBotCleanupRunPreviewsBeforeConfirm(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - now := time.Now() - old := now.Add(-30 * 24 * time.Hour) - stale := &model.User{Username: "stale", PasswordHash: "x", Role: "user", IsActive: true} - stale.CreatedAt = old - stale.LastLoginAt = &old - recent := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true} - recent.CreatedAt = old - recent.LastLoginAt = &now - newUser := &model.User{Username: "newbie", PasswordHash: "x", Role: "user", IsActive: true} - newUser.CreatedAt = now - for _, user := range []*model.User{stale, recent, newUser} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupKeepMode, "any"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[ - {"id":"login_7d","name":"最近登录","type":"recent_login","enabled":true,"window_days_max":7}, - {"id":"new_7d","name":"新号宽限","type":"account_age_grace","enabled":true,"min_count":7} - ]`); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup run") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "当前只是预览") || !strings.Contains(reply.Text, "stale") || !strings.Contains(reply.Text, "/cleanup run confirm") { - t.Fatalf("cleanup run should preview candidates and confirmation command, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got == nil { - t.Fatal("cleanup preview must not delete the stale user") - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/deleted") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "当前只是预览") { - t.Fatalf("/deleted alias should preview only, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got == nil { - t.Fatal("/deleted preview alias must not delete users") - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup run confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已清理 1") { - t.Fatalf("cleanup confirm should delete exactly one stale user, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got != nil { - t.Fatal("stale user should be deleted after explicit confirmation") - } - for _, user := range []*model.User{recent, newUser, admin} { - if got, _ := repos.User.FindByID(ctx, user.ID); got == nil { - t.Fatalf("%s should be kept by保号 rules/protection", user.Username) - } - } -} - -func TestBotCleanupLegacyCountModeStillKeepsSingleMatchedRule(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - now := time.Now() - old := now.Add(-30 * 24 * time.Hour) - recent := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true} - recent.CreatedAt = old - recent.LastLoginAt = &now - stale := &model.User{Username: "stale", PasswordHash: "x", Role: "user", IsActive: true} - stale.CreatedAt = old - stale.LastLoginAt = &old - for _, user := range []*model.User{recent, stale} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupKeepMode, "count"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRequiredCount, "2"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[ - {"id":"login_7d","name":"最近登录","type":"recent_login","enabled":true,"window_days_max":7}, - {"id":"new_7d","name":"新号宽限","type":"account_age_grace","enabled":true,"min_count":7} - ]`); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup run") - if err != nil { - t.Fatal(err) - } - if strings.Contains(reply.Text, "recent") { - t.Fatalf("user matching one keep rule must not be a cleanup candidate, got %q", reply.Text) - } - if !strings.Contains(reply.Text, "stale") { - t.Fatalf("user matching no keep rules should be a candidate, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup run confirm") - if err != nil { - t.Fatal(err) - } - if got, _ := repos.User.FindByID(ctx, recent.ID); got == nil { - t.Fatal("legacy count mode must not delete a user matching one keep rule") - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got != nil { - t.Fatalf("stale user should be deleted after confirm, reply=%q", reply.Text) - } -} - -func TestBotCleanupConfirmRequiresEnabledRules(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - user.CreatedAt = time.Now().Add(-30 * 24 * time.Hour) - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup run confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "没有启用的保号规则") { - t.Fatalf("cleanup confirm without rules should be blocked, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, user.ID); got == nil { - t.Fatal("cleanup confirm without enabled rules must not delete users") - } -} - -func TestBotCleanupRuleListInfersDaysAndHidesDuplicateNames(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[ - {"id":"login_7d","name":"login_7d","type":"recent_login","enabled":true,"window_days_min":1,"window_days_max":5,"min_count":1}, - {"id":"new_7d","name":"new_7d","type":"account_age_grace","enabled":true,"window_days_min":1,"window_days_max":1,"min_count":1} - ]`); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup_rule") - if err != nil { - t.Fatal(err) - } - for _, bad := range []string{"login_7d · login_7d", "new_7d · new_7d", "5 天内登录", "新号宽限 1 天", "add watch_hours", "Mgo 保号规则命令"} { - if strings.Contains(reply.Text, bad) { - t.Fatalf("rule list still contains bad fragment %q: %s", bad, reply.Text) - } - } - for _, want := range []string{"login_7d", "7 天内登录", "new_7d", "新号宽限 7 天"} { - if !strings.Contains(reply.Text, want) { - t.Fatalf("rule list missing %q: %s", want, reply.Text) - } - } -} diff --git a/internal/service/bot_commands_test.go b/internal/service/bot_commands_test.go deleted file mode 100644 index 53a328f..0000000 --- a/internal/service/bot_commands_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotRegistrationCommandUsesOpenRegQuota(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9051, - TelegramName: "@root", - ChatID: 9051, - UserID: admin.ID, - }).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9051"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9051, Username: "root"}, Chat: TelegramChat{ID: 9051, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/registration on 2") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "2 个名额") { - t.Fatalf("expected quota feedback, got %q", reply.Text) - } - capacity := bot.loadCapacity(ctx) - if !capacity.OpenRegOn || capacity.OpenRegLimit != 2 || capacity.OpenRegUsed != 0 { - t.Fatalf("registration command should open quota-aware registration, got %+v", capacity) - } -} - -func TestBotUserCommandsAndAdminGate(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9101, - TelegramName: "@viewer", - ChatID: 9101, - UserID: user.ID, - }).Error; err != nil { - t.Fatal(err) - } - now := time.Now() - if err := repos.UserDevice.Create(ctx, &model.UserDevice{ - UserID: user.ID, DeviceID: "dev-1", DeviceName: "iPhone", Client: "Infuse", FirstSeenAt: now, LastSeenAt: now, - }); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9101, Username: "viewer"}, Chat: TelegramChat{ID: 9101, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "仅管理员") { - t.Fatalf("regular user should not manage policy, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/devices") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "我的登录设备") { - t.Fatalf("expected device list, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/kick 1") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已踢下线") { - t.Fatalf("expected kick feedback, got %q", reply.Text) - } - if kicked := bot.device; kicked != nil { - t.Fatal("test should not require wired device service") - } - if ok := NewDeviceService(zap.NewNop(), repos).IsDeviceKicked(ctx, user.ID, "dev-1"); !ok { - t.Fatal("device should be marked kicked") - } -} - -func TestBotAdminCodeAndUserCommands(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9301, - TelegramName: "@root", - ChatID: 9301, - UserID: admin.ID, - }).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "root"}, Chat: TelegramChat{ID: 9301, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/gencode renew 90 7") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已生成续期码") { - t.Fatalf("expected generated renew code, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/renew_user viewer 30") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "viewer") { - t.Fatalf("renew command should return user actions, got %q", reply.Text) - } - updated, _ := repos.User.FindByID(ctx, user.ID) - if updated.ExpiredAt == nil || updated.ExpiredAt.Before(time.Now()) { - t.Fatalf("renew_user should set future expiry, got %v", updated.ExpiredAt) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/delete_user viewer") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "需要确认") { - t.Fatalf("delete without confirm should be rejected, got %q", reply.Text) - } -} - -func TestBotGroupMenuShowsAdminActionsOnlyForAdmins(t *testing.T) { - ctx := context.Background() - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301","group_chat_id":"-1001"}`} - adminMsg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "admin"}, Chat: TelegramChat{ID: -1001, Type: "group"}} - reply, err := bot.executeCommand(ctx, channel, adminMsg, "/menu") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "管理员入口") || len(reply.Buttons) == 0 { - t.Fatalf("admin group menu should expose management actions, got %#v", reply) - } - - userMsg := &TelegramMessage{From: TelegramUser{ID: 9302, Username: "user"}, Chat: TelegramChat{ID: -1001, Type: "group"}} - reply, err = bot.executeCommand(ctx, channel, userMsg, "/menu") - if err != nil { - t.Fatal(err) - } - if strings.Contains(reply.Text, "管理员入口") { - t.Fatalf("non-admin group menu must not expose management actions, got %#v", reply) - } -} diff --git a/internal/service/bot_device_policy_test.go b/internal/service/bot_device_policy_test.go deleted file mode 100644 index 2b43673..0000000 --- a/internal/service/bot_device_policy_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDeviceKickAndConcurrency(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - u := &model.User{Username: "carol", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4") - dev.RecordPlayback(ctx, u.ID, "dev-1", "iPhone", "Infuse") - devices, _ := dev.ListDevices(ctx, u.ID) - if len(devices) != 1 { - t.Fatalf("expected 1 device, got %d", len(devices)) - } - - // 踢下线后命中 kicked - if err := dev.KickDevice(ctx, u.ID, "dev-1"); err != nil { - t.Fatal(err) - } - if !dev.IsDeviceKicked(ctx, u.ID, "dev-1") { - t.Fatal("device should be kicked") - } - // 重新登录清除 kicked - dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4") - if dev.IsDeviceKicked(ctx, u.ID, "dev-1") { - t.Fatal("re-login should clear kicked flag") - } - - // 并发播放计数 - now := time.Now() - for i, id := range []string{"d1", "d2", "d3", "d4"} { - _ = repos.UserDevice.Create(ctx, &model.UserDevice{ - UserID: u.ID, DeviceID: id, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now, - }) - _ = i - } - n, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute)) - if err != nil { - t.Fatal(err) - } - if n < 4 { - t.Fatalf("expected >=4 concurrent playing, got %d", n) - } -} - -func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - now := time.Date(2026, 6, 25, 21, 30, 0, 0, time.UTC) - tracker := NewSessionTrackerService(zap.NewNop()) - tracker.now = func() time.Time { return now } - dev.SetSessionTracker(tracker) - u := &model.User{Username: "device-user", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAntiShareEnabled, "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil { - t.Fatal(err) - } - - for _, login := range []struct { - id string - name string - client string - }{ - {id: "phone-infuse", name: "iPhone", client: "Infuse"}, - {id: "phone-emby", name: " iPhone ", client: "Emby"}, - {id: "phone-jellyfin", name: "IPHONE", client: "Jellyfin"}, - } { - dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4") - now = now.Add(time.Second) - } - count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, now.Add(-24*time.Hour)) - if err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("same terminal through multiple apps should count as 1, got %d", count) - } - devices, err := dev.ListDevices(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(devices) != 1 { - t.Fatalf("same terminal should show as one device row, got %#v", devices) - } - rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(rawRows) != 1 { - t.Fatalf("same terminal should be persisted as one canonical row, got %#v", rawRows) - } - if devices[0].DeviceID != "phone-jellyfin" || devices[0].Client != "Jellyfin" { - t.Fatalf("merged device row should keep latest login channel, got %#v", devices[0]) - } - got, _ := repos.User.FindByID(ctx, u.ID) - if !got.IsActive { - t.Fatal("same terminal through multiple apps must not disable the account") - } - - dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4") - dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4") - count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, now.Add(-24*time.Hour)) - if err != nil { - t.Fatal(err) - } - if count != 3 { - t.Fatalf("three distinct terminal devices should count as 3, got %d", count) - } - got, _ = repos.User.FindByID(ctx, u.ID) - if !got.IsActive { - t.Fatal("device limit is inclusive; 3 of 3 terminals should stay active") - } - - dev.RecordLogin(ctx, u.ID, "tv", "Apple TV", "Emby", "1.2.3.4") - got, _ = repos.User.FindByID(ctx, u.ID) - if got.IsActive { - t.Fatal("fourth distinct terminal should disable the account") - } -} - -func TestDeviceKickAppliesToMergedTerminal(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - u := &model.User{Username: "kick-merged", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - dev.RecordLogin(ctx, u.ID, "phone-infuse", "iPhone", "Infuse", "1.2.3.4") - dev.RecordLogin(ctx, u.ID, "phone-emby", " iPhone ", "Emby", "1.2.3.4") - if err := dev.KickDevice(ctx, u.ID, "phone-emby"); err != nil { - t.Fatal(err) - } - if !dev.IsTerminalKicked(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin") { - t.Fatal("same terminal with a new app/device id should still be kicked") - } - - dev.RecordLogin(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin", "1.2.3.4") - if dev.IsTerminalKicked(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin") { - t.Fatal("re-login should clear kicked state for the merged terminal") - } - rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(rawRows) != 1 || rawRows[0].DeviceID != "phone-jellyfin" { - t.Fatalf("merged terminal should keep one latest row, got %#v", rawRows) - } -} - -func TestRecordPlaybackMergesChangingDeviceIDOnSameTerminal(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - u := &model.User{Username: "play-merged", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - dev.RecordPlayback(ctx, u.ID, "tv-emby", "Living Room TV", "Emby") - dev.RecordPlayback(ctx, u.ID, "tv-infuse", " living room tv ", "Infuse") - - rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(rawRows) != 1 { - t.Fatalf("same playback terminal should persist one row, got %#v", rawRows) - } - if rawRows[0].DeviceID != "tv-infuse" || rawRows[0].Client != "Infuse" || rawRows[0].LastPlayAt == nil { - t.Fatalf("merged playback row should keep latest playback channel, got %#v", rawRows[0]) - } - count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, time.Now().Add(-time.Minute)) - if err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("same terminal playback should count once, got %d", count) - } -} - -func TestConcurrentPlaybackDeduplicatesAppsOnSameDevice(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - u := &model.User{Username: "play-user", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - now := time.Now() - fp := fingerprint("Infuse", "Living Room TV") - for _, row := range []model.UserDevice{ - {UserID: u.ID, DeviceID: "tv-emby", DeviceName: "Living Room TV", Client: "Emby", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now}, - {UserID: u.ID, DeviceID: "tv-jellyfin", DeviceName: "living room tv", Client: "Jellyfin", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now}, - {UserID: u.ID, DeviceID: "phone", DeviceName: "iPhone", Client: "Infuse", Fingerprint: fingerprint("Infuse", "iPhone"), FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now}, - } { - if err := repos.UserDevice.Create(ctx, &row); err != nil { - t.Fatal(err) - } - } - count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute)) - if err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("same terminal playback through multiple apps should count as 1 terminal, got %d", count) - } -} - -func TestProtectedAdminNeverViolated(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - _ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true") - cfg := loadBotConfig(ctx, repos) - // 多次违规也不应删除/警告/禁用管理员 - for i := 0; i < 5; i++ { - dev.registerFingerprintWarning(ctx, admin.ID, "test", cfg) - } - got, _ := repos.User.FindByID(ctx, admin.ID) - if got == nil { - t.Fatal("admin must never be auto-deleted") - } - if !got.IsActive { - t.Fatal("admin must never be auto-disabled") - } - if got.ShareWarnings != 0 { - t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings) - } -} diff --git a/internal/service/bot_features.go b/internal/service/bot_features.go deleted file mode 100644 index e39ab65..0000000 --- a/internal/service/bot_features.go +++ /dev/null @@ -1,285 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ── 容量 / 开注名额 ────────────────────────────────────────────────────────── - -// capacityInfo 描述当前用户容量(随凭证授权实时变化)与开注名额状态。 -type capacityInfo struct { - UsedUsers int64 - MaxUsers int64 // 来自 LicensedMaxUsers,随授权实时变化 - OpenRegOn bool - OpenRegLimit int // 0 = 不限(仅受 MaxUsers 约束) - OpenRegUsed int -} - -// Remaining 返回还能注册多少个账号(同时受授权上限与开注名额约束)。 -func (c capacityInfo) Remaining() int64 { - byLicense := c.MaxUsers - c.UsedUsers - if byLicense < 0 { - byLicense = 0 - } - if c.OpenRegLimit > 0 { - byQuota := int64(c.OpenRegLimit - c.OpenRegUsed) - if byQuota < 0 { - byQuota = 0 - } - if byQuota < byLicense { - return byQuota - } - } - return byLicense -} - -// loadCapacity reads live capacity + open-reg quota state. -func (s *TelegramBotService) loadCapacity(ctx context.Context) capacityInfo { - used, _ := s.repo.User.Count(ctx) - info := capacityInfo{ - UsedUsers: used, - MaxUsers: LicensedMaxUsers(ctx, s.repo), - OpenRegOn: s.openRegEnabled(ctx), - OpenRegLimit: s.intSetting(ctx, SettingOpenRegLimit, 0), - OpenRegUsed: s.intSetting(ctx, SettingOpenRegUsed, 0), - } - return info -} - -func (s *TelegramBotService) intSetting(ctx context.Context, key string, fallback int) int { - v, err := s.repo.Setting.Get(ctx, key) - if err != nil { - return fallback - } - return parseIntSettingDefault(v, fallback) -} - -// openRegEnabled reports whether bot registration is currently open. It honours -// both the new open-reg switch and the legacy registration switch. -func (s *TelegramBotService) openRegEnabled(ctx context.Context) bool { - if v, _ := s.repo.Setting.Get(ctx, SettingOpenRegEnabled); v != "" { - return parseBoolSetting(v, false) - } - return s.registrationEnabled(ctx) -} - -// openRegistration opens registration for `limit` new accounts (0 = unlimited, -// bounded only by the license). Resets the used counter. -func (s *TelegramBotService) openRegistration(ctx context.Context, limit int) error { - if limit < 0 { - limit = 0 - } - if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "true"); err != nil { - return err - } - if err := s.repo.Setting.Set(ctx, SettingOpenRegLimit, strconv.Itoa(limit)); err != nil { - return err - } - if err := s.repo.Setting.Set(ctx, SettingOpenRegUsed, "0"); err != nil { - return err - } - // 与旧开关同步,兼容系统设置页。 - return s.setRegistrationEnabled(ctx, true) -} - -// closeRegistration disables bot registration. -func (s *TelegramBotService) closeRegistration(ctx context.Context) error { - if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil { - return err - } - return s.setRegistrationEnabled(ctx, false) -} - -// consumeOpenRegSlot increments the used counter and auto-closes registration -// once the quota is exhausted. Call after a successful bot registration. -func (s *TelegramBotService) consumeOpenRegSlot(ctx context.Context) { - limit := s.intSetting(ctx, SettingOpenRegLimit, 0) - used := s.intSetting(ctx, SettingOpenRegUsed, 0) + 1 - _ = s.repo.Setting.Set(ctx, SettingOpenRegUsed, strconv.Itoa(used)) - if limit > 0 && used >= limit { - _ = s.closeRegistration(ctx) - } -} - -// ── 兑换码 ────────────────────────────────────────────────────────────────── - -// generateCode creates a random redemption code of the given kind. durationDays -// sets the account validity granted on redeem (0 = permanent). validDays sets -// how long the code itself stays redeemable (0 = never expires). -func (s *TelegramBotService) generateCode(ctx context.Context, kind string, durationDays, validDays int, createdBy string) (*model.RegistrationCode, error) { - return s.generateCodeWithUses(ctx, kind, durationDays, validDays, 1, createdBy) -} - -func (s *TelegramBotService) generateCodeWithUses(ctx context.Context, kind string, durationDays, validDays, maxUses int, createdBy string) (*model.RegistrationCode, error) { - if kind != model.RegistrationCodeRegister && kind != model.RegistrationCodeRenew { - kind = model.RegistrationCodeRegister - } - if maxUses <= 0 { - maxUses = 1 - } - code := &model.RegistrationCode{ - Code: randomCode(12), - Kind: kind, - DurationDays: durationDays, - MaxUses: maxUses, - CreatedByID: createdBy, - } - if validDays > 0 { - exp := time.Now().Add(time.Duration(validDays) * 24 * time.Hour) - code.ExpiresAt = &exp - } - if err := s.repo.RegCode.Create(ctx, code); err != nil { - return nil, err - } - return code, nil -} - -// lookupRedeemableCode validates a code without consuming it. Callers mark it -// used only after the dependent action (account create / renew) succeeds, so a -// failed action never burns a code. -func (s *TelegramBotService) lookupRedeemableCode(ctx context.Context, raw, wantKind string) (*model.RegistrationCode, string) { - code := normalizeRedemptionCode(raw) - if code == "" { - return nil, "请提供兑换码。" - } - rc, err := s.repo.RegCode.FindByCode(ctx, code) - if err != nil || rc == nil { - return nil, "兑换码无效。" - } - if rc.IsUsed() { - return nil, "兑换码已被使用。" - } - if rc.IsExpired() { - return nil, "兑换码已过期。" - } - if wantKind != "" && rc.Kind != wantKind { - switch rc.Kind { - case model.RegistrationCodeRenew: - return nil, "这是续期兑换码,请在「我的账号」里使用它续期。" - default: - return nil, "这是注册兑换码,请用于注册新账号。" - } - } - return rc, "" -} - -func normalizeRedemptionCode(raw string) string { - code := strings.ToUpper(strings.TrimSpace(raw)) - code = strings.NewReplacer(" ", "", "-", "", "_", "").Replace(code) - return code -} - -func looksLikeRedemptionCode(raw string) bool { - code := normalizeRedemptionCode(raw) - if len(code) < 8 || len(code) > 32 { - return false - } - for _, ch := range code { - if !strings.ContainsRune(codeAlphabet, ch) { - return false - } - } - return true -} - -// ── 续期 ──────────────────────────────────────────────────────────────────── - -// renewUser extends a user's expiry by durationDays. A nil/zero current expiry -// starts from now; a future expiry is extended from that point. durationDays<=0 -// sets the account to never expire (permanent). -func renewExpiry(current *time.Time, durationDays int) *time.Time { - if durationDays <= 0 { - return nil // permanent - } - base := time.Now() - if current != nil && current.After(base) { - base = *current - } - exp := base.Add(time.Duration(durationDays) * 24 * time.Hour) - return &exp -} - -// applyRenewal renews a user account and clears any expiry-related suspension. -func (s *TelegramBotService) applyRenewal(ctx context.Context, userID string, durationDays int) error { - u, err := s.repo.User.FindByID(ctx, userID) - if err != nil || u == nil { - return fmt.Errorf("user not found") - } - exp := renewExpiry(u.ExpiredAt, durationDays) - updates := map[string]any{"expired_at": exp, "is_active": true} - return s.repo.User.UpdateFields(ctx, userID, updates) -} - -// ── 签到 ──────────────────────────────────────────────────────────────────── - -// signInResult 描述一次签到的结果。 -type signInResult struct { - AlreadySigned bool - Streak int - Total int -} - -// signIn records a daily sign-in for the user, tracking consecutive-day streaks -// only (no points). A second sign-in on the same calendar day is a no-op. -func (s *TelegramBotService) signIn(ctx context.Context, userID string) (signInResult, error) { - now := time.Now() - today := now.Truncate(24 * time.Hour) - rec, err := s.repo.SignIn.Get(ctx, userID) - if err != nil { - return signInResult{}, err - } - if rec == nil { - rec = &model.SignIn{UserID: userID, LastSignIn: now, StreakDays: 1, TotalDays: 1} - if err := s.repo.SignIn.Save(ctx, rec); err != nil { - return signInResult{}, err - } - return signInResult{Streak: 1, Total: 1}, nil - } - last := rec.LastSignIn.Truncate(24 * time.Hour) - switch { - case last.Equal(today): - return signInResult{AlreadySigned: true, Streak: rec.StreakDays, Total: rec.TotalDays}, nil - case last.Equal(today.Add(-24 * time.Hour)): - rec.StreakDays++ - default: - rec.StreakDays = 1 // streak broken - } - rec.TotalDays++ - rec.LastSignIn = now - if err := s.repo.SignIn.Save(ctx, rec); err != nil { - return signInResult{}, err - } - return signInResult{Streak: rec.StreakDays, Total: rec.TotalDays}, nil -} - -// ── helpers ───────────────────────────────────────────────────────────────── - -const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous 0/O/1/I - -func randomCode(n int) string { - b := make([]byte, n) - secureRandomBytes(b) - out := make([]byte, n) - for i := range b { - out[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] - } - return string(out) -} - -// formatExpiry renders a user's expiry status for display. -func formatExpiry(t *time.Time) string { - if t == nil { - return "永久有效" - } - if time.Now().After(*t) { - return "已过期(" + t.Format("2006-01-02") + ")" - } - days := int(time.Until(*t).Hours() / 24) - return fmt.Sprintf("%s(剩 %d 天)", t.Format("2006-01-02"), days) -} diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go deleted file mode 100644 index d5b5758..0000000 --- a/internal/service/bot_features_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "go.uber.org/zap" -) - -func newBotTestService(t *testing.T) (*repository.Container, *TelegramBotService) { - t.Helper() - db := newServiceTestDB(t, model.AllModels()...) - repos := repository.New(db) - cfg := &config.Config{} - cfg.Secrets.JWTSecret = "test-secret" - log := zap.NewNop() - perms := NewPermissionService(log, repos) - tokenSvc := NewTokenService(cfg, log, repos) - auth := NewAuthService(cfg, log, repos, tokenSvc, perms) - crypto := NewCryptoService("test-secret", log) - bot := NewTelegramBotService(log, repos, crypto, auth) - return repos, bot -} - -// ── pure logic ────────────────────────────────────────────────────────────── - -func TestRenewExpiry(t *testing.T) { - // 永久(0 天)→ nil - if got := renewExpiry(nil, 0); got != nil { - t.Fatalf("expected nil for permanent, got %v", got) - } - // 从现在起 +30 天(当前为空) - got := renewExpiry(nil, 30) - if got == nil || got.Before(time.Now().Add(29*24*time.Hour)) { - t.Fatalf("expected ~30d expiry, got %v", got) - } - // 已有未来到期 → 在原到期基础上叠加 - future := time.Now().Add(10 * 24 * time.Hour) - got = renewExpiry(&future, 30) - if got == nil || got.Before(future.Add(29*24*time.Hour)) { - t.Fatalf("expected stacking on future expiry, got %v", got) - } - // 已过期 → 从现在起算 - past := time.Now().Add(-10 * 24 * time.Hour) - got = renewExpiry(&past, 5) - if got == nil || got.Before(time.Now().Add(4*24*time.Hour)) { - t.Fatalf("expected fresh window from now, got %v", got) - } -} - -func TestCapacityRemaining(t *testing.T) { - cases := []struct { - name string - c capacityInfo - want int64 - }{ - {"license only", capacityInfo{UsedUsers: 5, MaxUsers: 20}, 15}, - {"quota tighter", capacityInfo{UsedUsers: 5, MaxUsers: 100, OpenRegLimit: 10, OpenRegUsed: 3}, 7}, - {"license tighter", capacityInfo{UsedUsers: 95, MaxUsers: 100, OpenRegLimit: 50, OpenRegUsed: 0}, 5}, - {"full", capacityInfo{UsedUsers: 20, MaxUsers: 20}, 0}, - {"quota exhausted", capacityInfo{UsedUsers: 1, MaxUsers: 100, OpenRegLimit: 5, OpenRegUsed: 5}, 0}, - } - for _, tc := range cases { - if got := tc.c.Remaining(); got != tc.want { - t.Errorf("%s: Remaining()=%d want %d", tc.name, got, tc.want) - } - } -} - -func TestRandomWindowDays(t *testing.T) { - for i := 0; i < 200; i++ { - d := randomWindowDays(3, 5) - if d < 3 || d > 5 { - t.Fatalf("randomWindowDays(3,5)=%d out of range", d) - } - } - if d := randomWindowDays(4, 4); d != 4 { - t.Fatalf("randomWindowDays(4,4)=%d want 4", d) - } -} - -func TestFingerprintStability(t *testing.T) { - a := fingerprint("Infuse", "iPhone") - b := fingerprint("infuse", " iPhone ") - if a != b { - t.Fatalf("fingerprint should be case/space-insensitive: %s != %s", a, b) - } - if a != fingerprint("Emby", "iPhone") { - t.Fatal("different apps on the same terminal must share one fingerprint") - } - if a == fingerprint("Infuse", "iPad") { - t.Fatal("different device names must yield different fingerprints") - } -} - -// ── DB-backed ───────────────────────────────────────────────────────────── - -func TestSignInStreak(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - u := &model.User{Username: "alice", PasswordHash: "x", Role: "user"} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - res, err := bot.signIn(ctx, u.ID) - if err != nil || res.Streak != 1 || res.Total != 1 { - t.Fatalf("first sign-in: %+v err=%v", res, err) - } - // 同日重复签到 → 不增长 - res, _ = bot.signIn(ctx, u.ID) - if !res.AlreadySigned || res.Streak != 1 { - t.Fatalf("same-day re-signin should be no-op: %+v", res) - } - // 模拟昨天签到 → 连续 +1 - rec, _ := repos.SignIn.Get(ctx, u.ID) - rec.LastSignIn = time.Now().Add(-24 * time.Hour) - _ = repos.SignIn.Save(ctx, rec) - res, _ = bot.signIn(ctx, u.ID) - if res.Streak != 2 || res.Total != 2 { - t.Fatalf("consecutive day should bump streak: %+v", res) - } - // 中断(前天)→ 重置为 1 - rec, _ = repos.SignIn.Get(ctx, u.ID) - rec.LastSignIn = time.Now().Add(-72 * time.Hour) - _ = repos.SignIn.Save(ctx, rec) - res, _ = bot.signIn(ctx, u.ID) - if res.Streak != 1 { - t.Fatalf("broken streak should reset to 1: %+v", res) - } -} diff --git a/internal/service/bot_policy_commands_test.go b/internal/service/bot_policy_commands_test.go deleted file mode 100644 index 4ae8faf..0000000 --- a/internal/service/bot_policy_commands_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotAdminCommandsManageDevicePolicy(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9001, - TelegramName: "@root", - ChatID: 9001, - UserID: admin.ID, - }).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on play=4 login=5 warn=3") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "防共享:已开启") { - t.Fatalf("expected antishare enabled reply, got %q", reply.Text) - } - cfg := loadBotConfig(ctx, repos) - if !cfg.AntiShareEnabled || cfg.MaxConcurrentPlay != 4 || cfg.MaxLoggedClients != 5 || cfg.WarnThreshold != 3 { - t.Fatalf("unexpected device policy: %+v", cfg) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_mode count 2") - if err != nil { - t.Fatal(err) - } - cfg = loadBotConfig(ctx, repos) - if cfg.AccountCleanupKeepMode != "any" || cfg.AccountCleanupRequiredCount != 1 { - t.Fatalf("unexpected cleanup mode: %+v; reply=%q", cfg, reply.Text) - } - if !strings.Contains(reply.Text, "满足任意一条") { - t.Fatalf("cleanup mode should explain fixed any-rule policy, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add recent_login login_7d 七天内登录 7") - if err != nil { - t.Fatal(err) - } - cfg = loadBotConfig(ctx, repos) - found := false - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "login_7d" && rule.Type == "recent_login" && rule.WindowDaysMax == 7 { - found = true - } - } - if !found { - t.Fatalf("cleanup rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule edit recent_login login_7d 十四天内登录 14") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已更新规则") { - t.Fatalf("expected cleanup rule update reply, got %q", reply.Text) - } - cfg = loadBotConfig(ctx, repos) - matches := 0 - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "login_7d" { - matches++ - if rule.Type != "recent_login" || rule.WindowDaysMax != 14 || rule.Name != "十四天内登录" { - t.Fatalf("cleanup rule should be updated in place, got %+v", rule) - } - } - } - if matches != 1 { - t.Fatalf("cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule 修改 recent_login login_7d 二十一天内登录 21") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已更新规则") { - t.Fatalf("expected Chinese cleanup rule update reply, got %q", reply.Text) - } - cfg = loadBotConfig(ctx, repos) - matches = 0 - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "login_7d" { - matches++ - if rule.WindowDaysMax != 21 || rule.Name != "二十一天内登录" { - t.Fatalf("Chinese cleanup rule update should update values, got %+v", rule) - } - } - } - if matches != 1 { - t.Fatalf("Chinese cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add account_age_grace new_7d 7") - if err != nil { - t.Fatal(err) - } - cfg = loadBotConfig(ctx, repos) - found = false - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "new_7d" && rule.Type == "account_age_grace" && rule.MinCount == 7 { - found = true - } - } - if !found { - t.Fatalf("cleanup shorthand rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) - } -} diff --git a/internal/service/bot_registration_code_test.go b/internal/service/bot_registration_code_test.go deleted file mode 100644 index ab98e21..0000000 --- a/internal/service/bot_registration_code_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotRedeemRegisterRequiresAllowedTelegramUser(t *testing.T) { - ctx := context.Background() - _, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9201, Username: "outsider"}, Chat: TelegramChat{ID: 9201, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "不在管理员配置") { - t.Fatalf("outsider should not redeem register code, got %q", reply.Text) - } - - channel.Config = `{"admin_user_ids":"9201"}` - reply, err = bot.executeCommand(ctx, channel, msg, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换成功") { - t.Fatalf("allowed user should redeem register code, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9201); binding == nil { - t.Fatal("redeemed account should be bound to telegram user") - } -} - -func TestBotRedeemRegisterCodeCreatesOnlyOneAccount(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9201,9202"}`} - - first := &TelegramMessage{From: TelegramUser{ID: 9201, Username: "first"}, Chat: TelegramChat{ID: 9201, Type: "private"}} - reply, err := bot.executeCommand(ctx, channel, first, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换成功") { - t.Fatalf("first redeem should succeed, got %q", reply.Text) - } - - second := &TelegramMessage{From: TelegramUser{ID: 9202, Username: "second"}, Chat: TelegramChat{ID: 9202, Type: "private"}} - reply, err = bot.executeCommand(ctx, channel, second, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换码已被使用") && !strings.Contains(reply.Text, "兑换码刚刚被使用") { - t.Fatalf("second redeem should be rejected as used, got %q", reply.Text) - } - var users int64 - if err := repos.DB.Model(&model.User{}).Count(&users).Error; err != nil { - t.Fatal(err) - } - if users != 1 { - t.Fatalf("one register code must create exactly one user, got %d", users) - } - if binding := bot.telegramBinding(ctx, 9202); binding != nil { - t.Fatal("second telegram user must not be bound by an already-used register code") - } -} - -func TestBotRegisterCommandAcceptsRegistrationCode(t *testing.T) { - ctx := context.Background() - _, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "codeuser"}, Chat: TelegramChat{ID: 9301, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/register "+strings.ToLower(code.Code[:4])+"-"+strings.ToLower(code.Code[4:])) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换成功") { - t.Fatalf("/register CODE should redeem registration code, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9301); binding == nil { - t.Fatal("register code should bind the newly created account") - } -} - -func TestBotPlainRegistrationCodeMessageRedeems(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.NotifyChannel{ - Name: "Telegram", - Type: "telegram", - Enabled: true, - Config: `{"admin_user_ids":"9302"}`, - }).Error; err != nil { - t.Fatal(err) - } - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 1, - Message: &TelegramMessage{ - MessageID: 12, - Text: strings.ToLower(code.Code), - From: TelegramUser{ID: 9302, Username: "plaincode"}, - Chat: TelegramChat{ID: 9302, Type: "private"}, - }, - }) - - if err := bot.HandleWebhook(ctx, update); err != nil { - t.Fatal(err) - } - if binding := bot.telegramBinding(ctx, 9302); binding == nil { - t.Fatal("plain code private message should redeem and bind account") - } - var used model.RegistrationCode - if err := repos.DB.Where("code = ?", code.Code).First(&used).Error; err != nil { - t.Fatal(err) - } - if used.UsedAt == nil || used.UsedByUserID == "" { - t.Fatal("plain code message should mark registration code as used") - } -} diff --git a/internal/service/bot_registration_test.go b/internal/service/bot_registration_test.go deleted file mode 100644 index 5ea91b1..0000000 --- a/internal/service/bot_registration_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestRegistrationCodeRedeemOnce(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - - code, err := bot.generateCode(ctx, model.RegistrationCodeRenew, 30, 0, "") - if err != nil { - t.Fatal(err) - } - // 首次校验通过 - rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew) - if rc == nil { - t.Fatalf("expected valid code, got msg=%q", msg) - } - // 标记使用后不可再用 - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil { - t.Fatal(err) - } - if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" { - t.Fatal("used code must not validate again") - } - // 第二次 MarkUsed 应失败(防止双花) - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err == nil { - t.Fatal("double-spend should be rejected") - } - // 类型不匹配应被拒 - reg, _ := bot.generateCode(ctx, model.RegistrationCodeRegister, 0, 0, "") - if _, msg := bot.lookupRedeemableCode(ctx, reg.Code, model.RegistrationCodeRenew); msg == "" { - t.Fatal("register code should not validate as renew") - } -} - -func TestRegistrationCodeCanBeGeneratedForMultipleUses(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - - code, err := bot.generateCodeWithUses(ctx, model.RegistrationCodeRenew, 30, 0, 2, "") - if err != nil { - t.Fatal(err) - } - rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew) - if rc == nil { - t.Fatalf("expected valid code, got msg=%q", msg) - } - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil { - t.Fatal(err) - } - rc, msg = bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew) - if rc == nil { - t.Fatalf("code should remain redeemable after first use, got msg=%q", msg) - } - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err != nil { - t.Fatal(err) - } - if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" { - t.Fatal("code should be exhausted after max uses") - } - var used model.RegistrationCode - if err := repos.DB.Where("id = ?", code.ID).First(&used).Error; err != nil { - t.Fatal(err) - } - if used.UsedCount != 2 || used.UsedAt == nil { - t.Fatalf("expected exhausted code with used_count=2, got %+v", used) - } -} - -func TestRenewalClearsExpiry(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - past := time.Now().Add(-time.Hour) - u := &model.User{Username: "bob", PasswordHash: "x", Role: "user", IsActive: false, ExpiredAt: &past} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - if err := bot.applyRenewal(ctx, u.ID, 30); err != nil { - t.Fatal(err) - } - got, _ := repos.User.FindByID(ctx, u.ID) - if !got.IsActive { - t.Fatal("renewal should re-activate account") - } - if got.ExpiredAt == nil || got.ExpiredAt.Before(time.Now()) { - t.Fatalf("renewal should set future expiry, got %v", got.ExpiredAt) - } -} diff --git a/internal/service/bot_unbind_test.go b/internal/service/bot_unbind_test.go deleted file mode 100644 index ad88a50..0000000 --- a/internal/service/bot_unbind_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotAdminUnbindMultipleUsers(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - viewer := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - guest := &model.User{Username: "guest", PasswordHash: "x", Role: "user", IsActive: true} - for _, user := range []*model.User{admin, viewer, guest} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - bindings := []model.TelegramBinding{ - {TelegramUserID: 9401, TelegramName: "@root", ChatID: 9401, UserID: admin.ID}, - {TelegramUserID: 9402, TelegramName: "@viewer", ChatID: 9402, UserID: viewer.ID}, - {TelegramUserID: 9403, TelegramName: "@guest", ChatID: 9403, UserID: guest.ID}, - } - for i := range bindings { - if err := repos.DB.Create(&bindings[i]).Error; err != nil { - t.Fatal(err) - } - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9401"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9401, Username: "root"}, Chat: TelegramChat{ID: 9401, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/unbind viewer,guest missing root") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已解绑:2") || !strings.Contains(reply.Text, "root(管理员)") || !strings.Contains(reply.Text, "missing") { - t.Fatalf("unexpected unbind reply: %q", reply.Text) - } - for _, user := range []*model.User{viewer, guest} { - var count int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("%s binding count = %d, want 0", user.Username, count) - } - } - if binding := bot.telegramBinding(ctx, 9401); binding == nil { - t.Fatal("admin binding should be protected from /unbind by username") - } -} - -func TestBotAdminUnbindInactiveAndInvalidBindings(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - oldTime := time.Now().Add(-45 * 24 * time.Hour) - recentTime := time.Now().Add(-2 * 24 * time.Hour) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true, LastLoginAt: &oldTime} - oldUser := &model.User{Username: "old", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &oldTime} - realtimeUser := &model.User{Username: "realtime", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &oldTime} - recentUser := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &recentTime} - for _, user := range []*model.User{admin, oldUser, realtimeUser, recentUser} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - for _, binding := range []model.TelegramBinding{ - {TelegramUserID: 9501, TelegramName: "@root", ChatID: 9501, UserID: admin.ID}, - {TelegramUserID: 9502, TelegramName: "@old", ChatID: 9502, UserID: oldUser.ID}, - {TelegramUserID: 9503, TelegramName: "@recent", ChatID: 9503, UserID: recentUser.ID}, - {TelegramUserID: 9505, TelegramName: "@realtime", ChatID: 9505, UserID: realtimeUser.ID}, - {TelegramUserID: 9504, TelegramName: "@ghost", ChatID: 9504, UserID: "missing-user"}, - } { - row := binding - if err := repos.DB.Create(&row).Error; err != nil { - t.Fatal(err) - } - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9501"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9501, Username: "root"}, Chat: TelegramChat{ID: 9501, Type: "private"}} - tracker := NewSessionTrackerService(zap.NewNop()) - tracker.RecordActivity(ctx, realtimeUser.ID, realtimeUser.Username, "phone-1", "iPhone", "Infuse", "192.0.2.10") - device := NewDeviceService(zap.NewNop(), repos) - device.SetSessionTracker(tracker) - bot.SetDeviceService(device) - - reply, err := bot.executeCommand(ctx, channel, msg, "/unbind_inactive 30") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已解绑:1") || !strings.Contains(reply.Text, "old") { - t.Fatalf("unexpected inactive unbind reply: %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9502); binding != nil { - t.Fatal("old user binding should be removed") - } - if binding := bot.telegramBinding(ctx, 9501); binding == nil { - t.Fatal("admin binding should be skipped by inactive cleanup") - } - if binding := bot.telegramBinding(ctx, 9503); binding == nil { - t.Fatal("recent user binding should remain") - } - if binding := bot.telegramBinding(ctx, 9505); binding == nil { - t.Fatal("realtime active user binding should remain") - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/unbind_duplicates") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已解绑:1") || !strings.Contains(reply.Text, "tg:9504") { - t.Fatalf("unexpected duplicate cleanup reply: %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9504); binding != nil { - t.Fatal("invalid binding should be removed") - } -} - -func TestTelegramMembershipChatIDsIncludesCommandChatID(t *testing.T) { - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"command_chat_id":"-100123"}`} - got := bot.telegramMembershipChatIDs(channel) - if len(got) != 1 || got[0] != "-100123" { - t.Fatalf("telegramMembershipChatIDs() = %#v, want command_chat_id", got) - } -} - -func TestTelegramMembershipChatIDsDedupesGroupChannelAndCommandIDs(t *testing.T) { - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","channel_chat_id":"-100124","command_chat_id":"-100123"}`} - got := bot.telegramMembershipChatIDs(channel) - if len(got) != 2 || got[0] != "-100123" || got[1] != "-100124" { - t.Fatalf("telegramMembershipChatIDs() = %#v, want deduped ids", got) - } -} diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go deleted file mode 100644 index 985bef7..0000000 --- a/internal/service/cloud/cloud.go +++ /dev/null @@ -1,137 +0,0 @@ -// Package cloud implements pluggable cloud-disk (网盘) providers used by the -// external-storage subsystem to expose remote files as playable media via -// HTTP 302 redirects. -// -// The design offloads playback to the cloud provider: instead of the -// host downloading and re-streaming bytes, a provider resolves a file to a -// short-lived direct download URL and the player is 302-redirected straight to -// the cloud CDN. The host only performs a tiny redirect, freeing its CPU and -// bandwidth. -// -// Each provider authenticates with a cookie (obtained via the web UI, an API -// cookie, or a QR-code login flow). Providers are intentionally side-effect -// free and take an *http.Client so they can be exercised against httptest -// mock servers in unit tests. -package cloud - -import ( - "context" - "errors" - "net/http" - "strings" - "time" -) - -// timeNow is a seam so tests can pin timestamps. -var timeNow = time.Now - -// Provider types recognised by the registry. -const ( - Type115 = "cloud115" // 115 网盘 - TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘 - TypeOpenList = "openlist" // OpenList / AList-compatible bridge -) - -// ErrUnsupported is returned for an unknown provider type. -var ErrUnsupported = errors.New("unsupported cloud provider") - -// FileEntry is one item in a cloud directory listing. -type FileEntry struct { - ID string `json:"id"` // provider-native file id - Name string `json:"name"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - // PickCode is 115-specific; other providers use ID directly. - PickCode string `json:"pick_code,omitempty"` -} - -// DirectLink is a resolved playback target. -type DirectLink struct { - URL string `json:"url"` - // Headers that must accompany a request to URL (e.g. User-Agent, Cookie). - Headers map[string]string `json:"-"` - // Proxy reports whether URL requires the host to reverse-proxy the bytes - // (because the headers cannot be carried by a plain browser 302). When - // false the play handler issues a pure 302 redirect (true offload). - Proxy bool `json:"-"` -} - -// Provider is the common cloud-disk interface. -type Provider interface { - // Type returns the provider key. - Type() string - // Ping validates the stored credentials (cookie). Cheap, used by the - // storage-config Test() probe. - Ping(ctx context.Context) error - // List returns the entries under dirID. An empty dirID means the root. - List(ctx context.Context, dirID string) ([]FileEntry, error) - // Resolve turns a provider-native file reference (id or pickcode) into a - // short-lived direct download link suitable for 302 playback. - Resolve(ctx context.Context, fileRef string) (*DirectLink, error) -} - -// MutableProvider is implemented by cloud bridges that support safe folder -// management through their official API or standard WebDAV methods. -type MutableProvider interface { - Provider - Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error) - Rename(ctx context.Context, ref, name string) (*FileEntry, error) -} - -// MovableProvider is implemented by writable cloud bridges that can move an -// entry across directories, optionally renaming it in the same operation. -type MovableProvider interface { - MutableProvider - Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error) -} - -// New constructs a provider of the given type from a free-form config map -// (as persisted by StorageConfigService). The client is shared so callers can -// inject timeouts / test transports. -func New(typ string, cfg map[string]any, client *http.Client) (Provider, error) { - if client == nil { - client = http.DefaultClient - } - switch typ { - case Type115: - return new115(cfg, client), nil - case TypeCloudDrive2: - return newCloudDrive2(cfg, client), nil - case TypeOpenList: - return newOpenList(cfg, client), nil - default: - return nil, ErrUnsupported - } -} - -// IsCloudType reports whether typ is a cloud-disk provider. -func IsCloudType(typ string) bool { - return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList -} - -// str coerces a config value to a trimmed string. -func str(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return "" -} - -// boolish coerces a config value to bool ("true"/"1"/true → true). -func boolish(v any) bool { - switch t := v.(type) { - case bool: - return t - case string: - s := strings.ToLower(strings.TrimSpace(t)) - return s == "1" || s == "true" || s == "yes" || s == "on" - default: - return false - } -} - -// defaultUA is a desktop browser UA accepted by upstream cloud providers. -const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" diff --git a/internal/service/cloud/cloud_115_test.go b/internal/service/cloud/cloud_115_test.go deleted file mode 100644 index 631ebf7..0000000 --- a/internal/service/cloud/cloud_115_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package cloud - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" -) - -func Test115ListAndResolve(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/files": - if r.URL.Query().Get("cid") != "0" { - t.Errorf("bad cid %q", r.URL.Query().Get("cid")) - } - w.Write([]byte(`{"state":true,"data":[ - {"cid":"100","n":"Movies","s":0}, - {"fid":"200","n":"Inception.mkv","s":456,"pc":"pick200"}]}`)) - default: - t.Errorf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client()) - if err != nil { - t.Fatal(err) - } - // The downurl endpoint is m115-encrypted end-to-end (the server side - // requires 115's private key), so stub the decrypted payload via the seam - // and assert the pickcode->URL extraction. The live crypto/transport path is - // exercised by integration testing against the real 115 API. - p115, ok := p.(*pan115Provider) - if !ok { - t.Fatalf("expected *pan115Provider, got %T", p) - } - p115.downURLPayload = func(ctx context.Context, pickcode string) ([]byte, error) { - if pickcode != "pick200" { - t.Errorf("bad pickcode %q", pickcode) - } - return []byte(`{"200":{"file_name":"Inception.mkv","file_size":"456","url":{"url":"https://cdn.115/x.mkv?t=1"}}}`), nil - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if len(entries) != 2 { - t.Fatalf("want 2 entries: %#v", entries) - } - if !entries[0].IsDir || entries[0].ID != "100" { - t.Fatalf("dir entry wrong: %#v", entries[0]) - } - if entries[1].IsDir || entries[1].PickCode != "pick200" || entries[1].Size != 456 { - t.Fatalf("file entry wrong: %#v", entries[1]) - } - link, err := p.Resolve(context.Background(), "pick200") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if link.URL != "https://cdn.115/x.mkv?t=1" { - t.Fatalf("bad url: %s", link.URL) - } - if link.Proxy { - t.Fatalf("115 should default to 302 (no proxy)") - } -} - -func Test115ListPaginates(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/files" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) - count := 100 - if offset > 0 { - count = 1 - } - items := make([]string, 0, count) - for i := 0; i < count; i++ { - n := offset + i - items = append(items, fmt.Sprintf(`{"fid":"%d","n":"Movie.%03d.mkv","s":%d,"pc":"pick%d"}`, n, n, n, n)) - } - w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`)) - })) - defer srv.Close() - - p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client()) - if err != nil { - t.Fatal(err) - } - entries, err := p.List(context.Background(), "0") - if err != nil { - t.Fatalf("list: %v", err) - } - if len(entries) != 101 { - t.Fatalf("entries = %d, want 101", len(entries)) - } - if entries[100].ID != "100" || entries[100].PickCode != "pick100" { - t.Fatalf("last entry wrong: %#v", entries[100]) - } -} - -// Test115DownURLEndpointAndError exercises the live fetchDownURLPayload path: -// it must POST an m115-encrypted `data` body to /app/chrome/downurl?t=... and -// surface 115's error when state=false (no decryption needed for that branch). -func Test115DownURLEndpointAndError(t *testing.T) { - var gotData, gotT string - pro := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/app/chrome/downurl" { - t.Errorf("unexpected path %s", r.URL.Path) - } - gotT = r.URL.Query().Get("t") - _ = r.ParseForm() - gotData = r.PostFormValue("data") - w.Write([]byte(`{"state":false,"error":"not exist"}`)) - })) - defer pro.Close() - - p, err := New(Type115, map[string]any{"cookie": "UID=1", "pro_base": pro.URL}, pro.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "pickX") - if err == nil || !strings.Contains(err.Error(), "not exist") { - t.Fatalf("want upstream error surfaced, got %v", err) - } - if gotT == "" { - t.Errorf("missing t query param") - } - if gotData == "" { - t.Errorf("missing encrypted data body") - } - if _, derr := base64.StdEncoding.DecodeString(gotData); derr != nil { - t.Errorf("data body is not base64: %v", derr) - } -} - -func Test115QRFlow(t *testing.T) { - // status sequence: waiting -> scanned -> confirmed - calls := 0 - api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/1.0/web/1.0/token/": - w.Write([]byte(`{"state":1,"data":{"uid":"U1","time":1700,"sign":"S1"}}`)) - case "/get/status/": - if r.URL.Query().Get("uid") != "U1" { - t.Errorf("bad uid %q", r.URL.Query().Get("uid")) - } - calls++ - switch calls { - case 1: - w.Write([]byte(`{"state":1,"data":{"status":0}}`)) - case 2: - w.Write([]byte(`{"state":1,"data":{"status":1}}`)) - default: - w.Write([]byte(`{"state":1,"data":{"status":2}}`)) - } - default: - t.Errorf("unexpected api path %s", r.URL.Path) - } - })) - defer api.Close() - passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/app/1.0/web/1.0/login/qrcode/" { - t.Errorf("unexpected passport path %s", r.URL.Path) - } - w.Write([]byte(`{"state":1,"data":{"cookie":{"UID":"u","CID":"c","SEID":"s"}}}`)) - })) - defer passport.Close() - - oldA, oldP := qr115APIBase, qr115PassportBase - qr115APIBase, qr115PassportBase = api.URL, passport.URL - defer func() { qr115APIBase, qr115PassportBase = oldA, oldP }() - - ctx := context.Background() - sess, err := QRStart(ctx, api.Client()) - if err != nil { - t.Fatalf("qr start: %v", err) - } - if sess.UID != "U1" || sess.QRImageURL == "" { - t.Fatalf("bad session: %#v", sess) - } - want := []string{"waiting", "scanned", "confirmed"} - for i, exp := range want { - st, err := QRPoll(ctx, api.Client(), sess) - if err != nil { - t.Fatalf("poll %d: %v", i, err) - } - if st.State != exp { - t.Fatalf("poll %d: want %s got %s", i, exp, st.State) - } - if exp == "confirmed" { - if st.Cookie == "" || !containsAll(st.Cookie, "UID=u", "SEID=s") { - t.Fatalf("confirmed must yield cookie: %q", st.Cookie) - } - } - } -} - -func containsAll(s string, subs ...string) bool { - for _, sub := range subs { - if !strings.Contains(s, sub) { - return false - } - } - return true -} diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go deleted file mode 100644 index c22fccd..0000000 --- a/internal/service/cloud/cloud_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package cloud - -import ( - "net/http" - "testing" -) - -func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) { - pan115 := new115(map[string]any{"cookie": "UID=1; CID=2", "force_proxy": "true"}, http.DefaultClient) - if pan115.proxy { - t.Fatalf("115 should keep safe direct mode; force_proxy is deprecated") - } - cd2 := newCloudDrive2(map[string]any{"url": "http://example.test/dav", "force_302": "true"}, http.DefaultClient) - if !cd2.proxy { - t.Fatalf("clouddrive2 should keep safe proxy mode; force_302 is deprecated") - } -} - -func TestUnsupportedProvider(t *testing.T) { - if _, err := New("dropbox", nil, nil); err != ErrUnsupported { - t.Fatalf("want ErrUnsupported, got %v", err) - } - if _, err := New("quark", nil, nil); err != ErrUnsupported { - t.Fatalf("quark should be unsupported, got %v", err) - } - if IsCloudType("quark") { - t.Fatal("quark should not be an active cloud provider") - } -} diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go deleted file mode 100644 index 64ddfa6..0000000 --- a/internal/service/cloud/clouddrive2.go +++ /dev/null @@ -1,237 +0,0 @@ -package cloud - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "net/url" - "path" - "strings" -) - -// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint. -// -// CloudDrive2 integrates many cloud disks (115 / 123 / Aliyun and more). -// Treating it as a WebDAV-backed cloud provider lets MediaStationGo -// browse, mount and upload to those disks without carrying every provider's -// private chunk-upload protocol in this project. -type cloudDrive2Provider struct { - typ string - name string - base *url.URL - username string - password string - token string - ua string - apiBase *url.URL - client *http.Client - proxy bool -} - -func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider { - return newCloudDAVProvider(TypeCloudDrive2, "clouddrive2", cfg, client, "/dav") -} - -func newOpenList(cfg map[string]any, client *http.Client) *cloudDrive2Provider { - return newCloudDAVProvider(TypeOpenList, "openlist", cfg, client, "/dav") -} - -func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Client, defaultDAVPath string) *cloudDrive2Provider { - rawURL := webDAVURLFromConfig(cfg, defaultDAVPath) - u, _ := url.Parse(strings.TrimRight(rawURL, "/")) - var apiBase *url.URL - if typ == TypeOpenList { - apiBase = openListAPIBaseFromConfig(cfg, rawURL, defaultDAVPath) - } - ua := str(cfg["ua"]) - if ua == "" { - ua = defaultUA - } - proxy := true - return &cloudDrive2Provider{ - typ: typ, - name: name, - base: u, - username: str(cfg["username"]), - password: str(cfg["password"]), - token: str(cfg["token"]), - ua: ua, - apiBase: apiBase, - client: client, - proxy: proxy, - } -} - -func (p *cloudDrive2Provider) Type() string { return p.typ } - -func (p *cloudDrive2Provider) Ping(ctx context.Context) error { - _, err := p.List(ctx, "") - return err -} - -func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) { - if err := p.validate(); err != nil { - return nil, err - } - ref := normalizeCloudDAVPath(fileRef) - if ref == "/" { - return nil, fmt.Errorf("%s: file reference required", p.name) - } - if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) { - if p.apiBase == nil { - return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name) - } - link, err := p.resolveOpenListAPIDirect(ctx, ref) - if err != nil { - return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err) - } - return link, nil - } - if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) { - link, err := p.resolveCloudDAVRedirectDirect(ctx, ref) - if err != nil { - return nil, fmt.Errorf("%s: pure 302 playback requires CloudDrive2/WebDAV to return a CDN Location for %s: %w", p.name, ref, err) - } - return link, nil - } - headers := map[string]string{ - "User-Agent": p.ua, - } - if p.token != "" { - headers["Authorization"] = p.token - } else if p.username != "" { - headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password)) - } - return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil -} - -func (p *cloudDrive2Provider) validate() error { - if p.base == nil || p.base.Scheme == "" || p.base.Host == "" { - return fmt.Errorf("%s: missing WebDAV URL", p.name) - } - return nil -} - -func webDAVURLFromConfig(cfg map[string]any, defaultDAVPath string) string { - rawURL := str(cfg["url"]) - if rawURL == "" { - rawURL = str(cfg["webdav_url"]) - } - if rawURL != "" { - return ensureDefaultDAVPath(rawURL, defaultDAVPath) - } - return defaultWebDAVURL(str(cfg["server"]), defaultDAVPath) -} - -func defaultWebDAVURL(server, defaultDAVPath string) string { - server = strings.TrimRight(strings.TrimSpace(server), "/") - if server == "" { - return "" - } - davPath := strings.TrimSpace(defaultDAVPath) - if davPath == "" { - return server - } - if !strings.HasPrefix(davPath, "/") { - davPath = "/" + davPath - } - return server + davPath -} - -func openListAPIBaseFromConfig(cfg map[string]any, webDAVURL, defaultDAVPath string) *url.URL { - raw := str(cfg["server"]) - if raw == "" { - raw = firstNonEmpty(str(cfg["api_url"]), webDAVURL) - } - raw = strings.TrimRight(strings.TrimSpace(raw), "/") - if raw == "" { - return nil - } - u, err := url.Parse(raw) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil - } - davPath := strings.Trim(strings.TrimSpace(defaultDAVPath), "/") - if davPath != "" { - pathParts := strings.Split(strings.TrimRight(u.Path, "/"), "/") - if len(pathParts) > 0 && strings.EqualFold(pathParts[len(pathParts)-1], davPath) { - u.Path = strings.Join(pathParts[:len(pathParts)-1], "/") - if u.Path == "" { - u.Path = "/" - } - } - } - u.RawPath = "" - u.RawQuery = "" - u.Fragment = "" - return u -} - -func (p *cloudDrive2Provider) openListAPIURL(apiPath string) string { - if p.apiBase == nil { - return "" - } - u := *p.apiBase - u.RawPath = "" - basePath := strings.TrimRight(u.Path, "/") - apiPath = "/" + strings.TrimLeft(apiPath, "/") - if basePath == "" || basePath == "/" { - u.Path = apiPath - } else { - u.Path = basePath + apiPath - } - return u.String() -} - -func ensureDefaultDAVPath(rawURL, defaultDAVPath string) string { - rawURL = strings.TrimRight(strings.TrimSpace(rawURL), "/") - if rawURL == "" { - return "" - } - u, err := url.Parse(rawURL) - if err != nil || u.Scheme == "" || u.Host == "" { - return rawURL - } - if strings.TrimSpace(defaultDAVPath) == "" { - return rawURL - } - if u.Path == "" || u.Path == "/" { - davPath := strings.TrimSpace(defaultDAVPath) - if !strings.HasPrefix(davPath, "/") { - davPath = "/" + davPath - } - u.Path = davPath - u.RawPath = "" - return strings.TrimRight(u.String(), "/") - } - return rawURL -} - -func normalizeCloudDAVPath(p string) string { - p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") - if p == "" || p == "." { - return "/" - } - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - cleaned := path.Clean(p) - if cleaned == "." { - return "/" - } - return cleaned -} - -func sameCloudDAVPath(a, b string) bool { - return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/") -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - return "" -} diff --git a/internal/service/cloud/clouddrive2_dav.go b/internal/service/cloud/clouddrive2_dav.go deleted file mode 100644 index 042946c..0000000 --- a/internal/service/cloud/clouddrive2_dav.go +++ /dev/null @@ -1,172 +0,0 @@ -package cloud - -import ( - "context" - "encoding/base64" - "encoding/xml" - "fmt" - "io" - "net/http" - "net/url" - "path" - "strings" -) - -func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) { - if err := p.validate(); err != nil { - return nil, err - } - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - return p.listOpenListAPI(ctx, dir) - } - target := normalizeCloudDAVPath(dir) - req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody)) - if err != nil { - return nil, err - } - p.auth(req) - req.Header.Set("Depth", "1") - req.Header.Set("Content-Type", "application/xml; charset=utf-8") - req.Header.Set("Accept", "application/xml,text/xml,*/*") - resp, err := p.client.Do(req) - if err != nil { - return nil, decorateDAVTransportError(p.name, p.urlFor(target), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, p.decorateDAVStatusError(resp, target) - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - var multi cloudDAVMultiStatus - if err := xml.Unmarshal(body, &multi); err != nil { - return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err) - } - basePath := strings.TrimRight(p.base.EscapedPath(), "/") - currentID := normalizeCloudDAVPath(target) - out := make([]FileEntry, 0, len(multi.Responses)) - for _, item := range multi.Responses { - entryPath, err := p.entryIDFromHref(item.Href, basePath) - if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) { - continue - } - name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/"))) - if decoded, err := url.PathUnescape(name); err == nil { - name = decoded - } - if name == "" || name == "." || name == "/" { - continue - } - out = append(out, FileEntry{ - ID: entryPath, - Name: name, - IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"), - Size: parseDAVSize(item.PropStat.Prop.ContentLength), - }) - } - return out, nil -} - -func (p *cloudDrive2Provider) resolveCloudDAVRedirectDirect(ctx context.Context, fileRef string) (*DirectLink, error) { - target := p.urlFor(fileRef) - headers := map[string]string{ - "User-Agent": p.ua, - } - if p.token != "" { - headers["Authorization"] = p.token - } else if p.username != "" { - headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password)) - } - location, status, err := p.firstHTTPRedirectLocation(ctx, target, headers) - if err != nil { - return nil, decorateDAVTransportError(p.name, target, err) - } - if location == "" { - return nil, fmt.Errorf("%s: WebDAV %s returned http %d without CDN Location; refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, status) - } - return &DirectLink{URL: location, Headers: nil, Proxy: false}, nil -} - -func (p *cloudDrive2Provider) firstHTTPRedirectLocation(ctx context.Context, target string, headers map[string]string) (string, int, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) - if err != nil { - return "", 0, err - } - req.Header.Set("Accept", "*/*") - req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("Range", "bytes=0-0") - if strings.TrimSpace(p.ua) != "" { - req.Header.Set("User-Agent", p.ua) - } - for key, value := range headers { - key = strings.TrimSpace(key) - if key != "" && strings.TrimSpace(value) != "" { - req.Header.Set(key, value) - } - } - client := p.client - if client == nil { - client = http.DefaultClient - } - noFollow := *client - noFollow.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - } - resp, err := noFollow.Do(req) - if err != nil { - return "", 0, err - } - defer resp.Body.Close() - status := resp.StatusCode - if status >= 300 && status < 400 { - rawLocation := strings.TrimSpace(resp.Header.Get("Location")) - if rawLocation == "" { - return "", status, fmt.Errorf("%s: upstream returned redirect http %d without Location", p.name, status) - } - location, err := resolveHTTPRedirectLocation(target, rawLocation) - if err != nil { - return "", status, err - } - return location, status, nil - } - return "", status, nil -} - -func resolveHTTPRedirectLocation(baseURL, rawLocation string) (string, error) { - rawLocation = strings.TrimSpace(rawLocation) - if rawLocation == "" { - return "", fmt.Errorf("empty redirect Location") - } - if strings.HasPrefix(rawLocation, "//") { - base, err := url.Parse(baseURL) - if err != nil || base.Scheme == "" { - return "", fmt.Errorf("protocol-relative redirect Location without base scheme") - } - rawLocation = base.Scheme + ":" + rawLocation - } - location, err := url.Parse(rawLocation) - if err != nil { - return "", fmt.Errorf("invalid redirect Location: %w", err) - } - if location.IsAbs() { - if location.Scheme != "http" && location.Scheme != "https" { - return "", fmt.Errorf("unsupported redirect Location scheme %q", location.Scheme) - } - return location.String(), nil - } - base, err := url.Parse(baseURL) - if err != nil { - return "", fmt.Errorf("invalid redirect base URL: %w", err) - } - return base.ResolveReference(location).String(), nil -} - -func (p *cloudDrive2Provider) auth(req *http.Request) { - req.Header.Set("User-Agent", p.ua) - if p.token != "" { - req.Header.Set("Authorization", p.token) - return - } - if p.username != "" { - req.SetBasicAuth(p.username, p.password) - } -} diff --git a/internal/service/cloud/clouddrive2_dav_error.go b/internal/service/cloud/clouddrive2_dav_error.go deleted file mode 100644 index 6ae5372..0000000 --- a/internal/service/cloud/clouddrive2_dav_error.go +++ /dev/null @@ -1,55 +0,0 @@ -package cloud - -import ( - "fmt" - "io" - "net/http" - "strings" -) - -func (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target string) error { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - detail := compactDAVErrorBody(string(body)) - if detail == "" { - if resp.StatusCode == http.StatusMethodNotAllowed { - return fmt.Errorf("%s: list %s returned http %d;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("%s: list %s returned http %d;请填写 OpenList/CloudDrive2 的 Token 或用户名密码,或确认 WebDAV 凭据可用", p.name, target, resp.StatusCode) - } - return fmt.Errorf("%s: list %s returned http %d", p.name, target, resp.StatusCode) - } - if resp.StatusCode == http.StatusMethodNotAllowed { - return fmt.Errorf("%s: list %s returned http %d:%s;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode, detail) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("%s: list %s returned http %d:%s;请检查 WebDAV 用户名/密码、Authorization Token,或先在 OpenList/CloudDrive2 中保存对应网盘 Cookie", p.name, target, resp.StatusCode, detail) - } - return fmt.Errorf("%s: list %s returned http %d:%s", p.name, target, resp.StatusCode, detail) -} - -func compactDAVErrorBody(raw string) string { - raw = strings.TrimSpace(strings.ReplaceAll(raw, "\x00", "")) - if raw == "" { - return "" - } - raw = strings.Join(strings.Fields(raw), " ") - if len([]rune(raw)) > 180 { - return string([]rune(raw)[:180]) + "…" - } - return raw -} - -func decorateDAVTransportError(name, target string, err error) error { - if err == nil { - return nil - } - message := err.Error() - if strings.Contains(message, "server gave HTTP response to HTTPS client") { - return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址,例如 OpenList 默认 WebDAV 通常是 http://host:5244/dav/;如果必须使用 https,请在 OpenList 前配置反向代理和证书", name, err) - } - if strings.Contains(message, "first record does not look like a TLS handshake") { - return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target) - } - return err -} diff --git a/internal/service/cloud/clouddrive2_dav_path.go b/internal/service/cloud/clouddrive2_dav_path.go deleted file mode 100644 index 14837c4..0000000 --- a/internal/service/cloud/clouddrive2_dav_path.go +++ /dev/null @@ -1,85 +0,0 @@ -package cloud - -import ( - "net/url" - "strconv" - "strings" -) - -func (p *cloudDrive2Provider) urlFor(remotePath string) string { - u := *p.base - u.RawPath = "" - basePath := strings.TrimRight(u.Path, "/") - remote := strings.Trim(normalizeCloudDAVPath(remotePath), "/") - switch { - case basePath == "" || basePath == "/": - if remote == "" { - u.Path = "/" - } else { - u.Path = "/" + remote - } - case remote == "": - u.Path = basePath - default: - u.Path = basePath + "/" + remote - } - return u.String() -} - -func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) { - if href == "" { - return "", nil - } - parsed, err := url.Parse(href) - if err != nil { - return "", err - } - hrefPath := parsed.EscapedPath() - if hrefPath == "" { - hrefPath = href - } - if basePath != "" && basePath != "/" { - hrefPath = strings.TrimPrefix(hrefPath, basePath) - } - if decoded, err := url.PathUnescape(hrefPath); err == nil { - hrefPath = decoded - } - return normalizeCloudDAVPath(hrefPath), nil -} - -const cloudDAVPropfindBody = ` - - - - - - -` - -type cloudDAVMultiStatus struct { - Responses []cloudDAVResponse `xml:"response"` -} - -type cloudDAVResponse struct { - Href string `xml:"href"` - PropStat cloudDAVPropStat `xml:"propstat"` -} - -type cloudDAVPropStat struct { - Prop cloudDAVProp `xml:"prop"` -} - -type cloudDAVProp struct { - DisplayName string `xml:"displayname"` - ContentLength string `xml:"getcontentlength"` - ResourceType cloudDAVResourceType `xml:"resourcetype"` -} - -type cloudDAVResourceType struct { - Collection *struct{} `xml:"collection"` -} - -func parseDAVSize(raw string) int64 { - n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) - return n -} diff --git a/internal/service/cloud/clouddrive2_mutation.go b/internal/service/cloud/clouddrive2_mutation.go deleted file mode 100644 index 11a97f4..0000000 --- a/internal/service/cloud/clouddrive2_mutation.go +++ /dev/null @@ -1,233 +0,0 @@ -package cloud - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "path" - "strings" -) - -func (p *cloudDrive2Provider) Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error) { - cleanName, err := cleanCloudEntryName(name) - if err != nil { - return nil, err - } - parent := normalizeCloudDAVPath(parentDir) - target := joinOpenListAPIPath(parent, cleanName) - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - if err := p.openListAPIMkdir(ctx, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil - } - if err := p.webDAVMkdir(ctx, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil -} - -func (p *cloudDrive2Provider) Rename(ctx context.Context, ref, name string) (*FileEntry, error) { - cleanName, err := cleanCloudEntryName(name) - if err != nil { - return nil, err - } - source := normalizeCloudDAVPath(ref) - if source == "/" { - return nil, fmt.Errorf("%s: cannot rename root directory", p.name) - } - target := joinOpenListAPIPath(path.Dir(source), cleanName) - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - if err := p.openListAPIRename(ctx, source, cleanName); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil - } - if err := p.webDAVRename(ctx, source, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil -} - -func (p *cloudDrive2Provider) Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error) { - source := normalizeCloudDAVPath(ref) - if source == "/" { - return nil, fmt.Errorf("%s: cannot move root directory", p.name) - } - cleanName := strings.TrimSpace(name) - if cleanName == "" { - cleanName = path.Base(source) - } - var err error - cleanName, err = cleanCloudEntryName(cleanName) - if err != nil { - return nil, err - } - targetDir = normalizeCloudDAVPath(targetDir) - target := joinOpenListAPIPath(targetDir, cleanName) - if sameCloudDAVPath(source, target) { - return &FileEntry{ID: target, Name: cleanName}, nil - } - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - if err := p.openListAPIMove(ctx, source, targetDir, cleanName); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName}, nil - } - if err := p.webDAVRename(ctx, source, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName}, nil -} - -func cleanCloudEntryName(name string) (string, error) { - name = strings.TrimSpace(name) - if name == "" || name == "." || name == ".." { - return "", fmt.Errorf("entry name is required") - } - if strings.ContainsAny(name, `/\`) { - return "", fmt.Errorf("entry name cannot contain path separators") - } - return name, nil -} - -func (p *cloudDrive2Provider) openListAPIMkdir(ctx context.Context, target string) error { - return p.openListAPIPost(ctx, "/api/fs/mkdir", map[string]string{"path": normalizeCloudDAVPath(target)}, "mkdir") -} - -func (p *cloudDrive2Provider) openListAPIRename(ctx context.Context, source, name string) error { - return p.openListAPIPost(ctx, "/api/fs/rename", map[string]string{ - "path": normalizeCloudDAVPath(source), - "name": name, - }, "rename") -} - -func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targetDir, targetName string) error { - targetDir = normalizeCloudDAVPath(targetDir) - sourceName := path.Base(normalizeCloudDAVPath(source)) - if sameCloudDAVPath(path.Dir(source), targetDir) { - if sourceName == targetName { - return nil - } - return p.openListAPIRename(ctx, source, targetName) - } - if err := p.openListAPIPost(ctx, "/api/fs/move", map[string]any{ - "src_dir": normalizeCloudDAVPath(path.Dir(source)), - "dst_dir": targetDir, - "names": []string{sourceName}, - }, "move"); err != nil { - return err - } - if sourceName != targetName { - moved := joinOpenListAPIPath(targetDir, sourceName) - return p.openListAPIRename(ctx, moved, targetName) - } - return nil -} - -func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error { - token, err := p.openListAPIToken(ctx) - if err != nil { - return err - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - if token != "" { - req.Header.Set("Authorization", token) - } - resp, err := p.client.Do(req) - if err != nil { - return decorateDAVTransportError(p.name, p.openListAPIURL(apiPath), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s: api %s returned http %d", p.name, action, resp.StatusCode) - } - var decoded struct { - Code int `json:"code"` - Message string `json:"message"` - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { - return fmt.Errorf("%s: decode api %s: %w", p.name, action, err) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return fmt.Errorf("%s: api %s failed: %s", p.name, action, msg) - } - return nil -} - -func (p *cloudDrive2Provider) webDAVMkdir(ctx context.Context, target string) error { - req, err := http.NewRequestWithContext(ctx, "MKCOL", p.urlFor(target), nil) - if err != nil { - return err - } - p.auth(req) - resp, err := p.client.Do(req) - if err != nil { - return decorateDAVTransportError(p.name, p.urlFor(target), err) - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusCreated, http.StatusOK, http.StatusNoContent: - return nil - case http.StatusMethodNotAllowed: - return fmt.Errorf("%s: mkdir %s returned http %d; the folder may already exist or this WebDAV backend is read-only", p.name, target, resp.StatusCode) - default: - return p.decorateDAVMutationStatusError(resp, "mkdir", target) - } -} - -func (p *cloudDrive2Provider) webDAVRename(ctx context.Context, source, target string) error { - req, err := http.NewRequestWithContext(ctx, "MOVE", p.urlFor(source), nil) - if err != nil { - return err - } - p.auth(req) - req.Header.Set("Destination", p.webDAVDestination(target)) - req.Header.Set("Overwrite", "F") - resp, err := p.client.Do(req) - if err != nil { - return decorateDAVTransportError(p.name, p.urlFor(source), err) - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusCreated, http.StatusOK, http.StatusNoContent: - return nil - default: - return p.decorateDAVMutationStatusError(resp, "rename", source) - } -} - -func (p *cloudDrive2Provider) webDAVDestination(target string) string { - raw := p.urlFor(target) - u, err := url.Parse(raw) - if err != nil { - return raw - } - u.RawQuery = "" - u.Fragment = "" - return u.String() -} - -func (p *cloudDrive2Provider) decorateDAVMutationStatusError(resp *http.Response, action, target string) error { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - detail := compactDAVErrorBody(string(body)) - if detail == "" { - return fmt.Errorf("%s: %s %s returned http %d", p.name, action, target, resp.StatusCode) - } - return fmt.Errorf("%s: %s %s returned http %d:%s", p.name, action, target, resp.StatusCode, detail) -} diff --git a/internal/service/cloud/clouddrive2_openlist.go b/internal/service/cloud/clouddrive2_openlist.go deleted file mode 100644 index 6c4e2f4..0000000 --- a/internal/service/cloud/clouddrive2_openlist.go +++ /dev/null @@ -1,240 +0,0 @@ -package cloud - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) { - token, err := p.openListAPIToken(ctx) - if err != nil { - return nil, err - } - const pageSize = 500 - target := normalizeCloudDAVPath(dir) - out := make([]FileEntry, 0, pageSize) - for pageNum := 1; ; pageNum++ { - payload := map[string]any{ - "path": target, - "password": "", - "page": pageNum, - "per_page": pageSize, - "refresh": false, - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/list"), bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - if token != "" { - req.Header.Set("Authorization", token) - } - resp, err := p.client.Do(req) - if err != nil { - return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/list"), err) - } - var decoded openListListResponse - decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded) - resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, p.openListAPIStatusError("list", target, resp.StatusCode) - } - if decodeErr != nil { - return nil, fmt.Errorf("%s: decode api list: %w", p.name, decodeErr) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return nil, fmt.Errorf("%s: api list %s failed: %s", p.name, target, msg) - } - for _, item := range decoded.Data.Content { - name := strings.TrimSpace(item.Name) - if name == "" || name == "." || name == "/" { - continue - } - out = append(out, FileEntry{ - ID: joinOpenListAPIPath(target, name), - Name: name, - IsDir: item.IsDir, - Size: item.Size, - }) - } - total := decoded.Data.Total - if total > 0 { - if len(out) >= total || len(decoded.Data.Content) == 0 { - break - } - continue - } - if len(decoded.Data.Content) == 0 || len(decoded.Data.Content) < pageSize { - break - } - } - return out, nil -} - -func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) { - token, err := p.openListAPIToken(ctx) - if err != nil { - return nil, err - } - payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - if token != "" { - req.Header.Set("Authorization", token) - } - resp, err := p.client.Do(req) - if err != nil { - return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, p.openListAPIStatusError("get", fileRef, resp.StatusCode) - } - var decoded openListGetResponse - if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { - return nil, fmt.Errorf("%s: decode api get: %w", p.name, err) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg) - } - raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL) - if raw == "" { - return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef) - } - resolved, err := p.resolveOpenListPlaybackURL(raw) - if err != nil { - return nil, err - } - headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header) - if len(headers) > 0 { - return nil, fmt.Errorf("%s: api get %s returned raw_url that requires headers (%s); refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, strings.Join(sortedHeaderNames(headers), ",")) - } - resolved, err = p.resolveOpenListCDNRedirect(ctx, fileRef, resolved) - if err != nil { - return nil, err - } - return &DirectLink{URL: resolved, Headers: nil, Proxy: false}, nil -} - -func (p *cloudDrive2Provider) resolveOpenListCDNRedirect(ctx context.Context, fileRef, rawURL string) (string, error) { - if p.apiBase == nil || !sameURLHost(rawURL, p.apiBase) { - return rawURL, nil - } - location, status, err := p.firstHTTPRedirectLocation(ctx, rawURL, nil) - if err != nil { - return "", fmt.Errorf("%s: probe raw_url %s failed: %w", p.name, fileRef, err) - } - if location != "" { - return location, nil - } - return "", fmt.Errorf("%s: api get %s returned an OpenList-hosted raw_url with http %d and no CDN Location; refusing OpenList/WebDAV proxy fallback for pure 302 playback", p.name, fileRef, status) -} - -func (p *cloudDrive2Provider) openListAPIStatusError(action, target string, status int) error { - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%s: api %s %s returned http %d;请检查 OpenList Token 或用户名密码,并确认填写的是 OpenList 服务地址而不是 /dav 地址", p.name, action, target, status) - } - return fmt.Errorf("%s: api %s %s returned http %d", p.name, action, target, status) -} - -func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool { - return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "") -} - -func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) { - if token := strings.TrimSpace(p.token); token != "" { - return token, nil - } - if strings.TrimSpace(p.username) == "" || p.password == "" { - return "", nil - } - payload, _ := json.Marshal(map[string]string{ - "username": p.username, - "password": p.password, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - resp, err := p.client.Do(req) - if err != nil { - return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode) - } - var decoded openListLoginResponse - if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { - return "", fmt.Errorf("%s: decode api login: %w", p.name, err) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return "", fmt.Errorf("%s: api login failed: %s", p.name, msg) - } - token := strings.TrimSpace(decoded.Data.Token) - if token == "" { - return "", fmt.Errorf("%s: api login returned empty token", p.name) - } - p.token = token - return token, nil -} - -func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", fmt.Errorf("%s: empty playback URL", p.name) - } - if strings.HasPrefix(raw, "//") { - if p.apiBase == nil || p.apiBase.Scheme == "" { - return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name) - } - raw = p.apiBase.Scheme + ":" + raw - } - u, err := url.Parse(raw) - if err != nil { - return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err) - } - if u.IsAbs() { - if u.Scheme != "http" && u.Scheme != "https" { - return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme) - } - return u.String(), nil - } - if p.apiBase == nil { - return "", fmt.Errorf("%s: relative playback URL without API base", p.name) - } - base := *p.apiBase - base.RawPath = "" - base.RawQuery = "" - base.Fragment = "" - return base.ResolveReference(u).String(), nil -} diff --git a/internal/service/cloud/clouddrive2_openlist_helpers.go b/internal/service/cloud/clouddrive2_openlist_helpers.go deleted file mode 100644 index d5d1880..0000000 --- a/internal/service/cloud/clouddrive2_openlist_helpers.go +++ /dev/null @@ -1,126 +0,0 @@ -package cloud - -import ( - "encoding/json" - "net/url" - "path" - "sort" - "strings" -) - -func sortedHeaderNames(headers map[string]string) []string { - if len(headers) == 0 { - return nil - } - out := make([]string, 0, len(headers)) - for key := range headers { - key = strings.TrimSpace(key) - if key != "" { - out = append(out, key) - } - } - sort.Strings(out) - return out -} - -func sameURLHost(raw string, base *url.URL) bool { - if base == nil { - return false - } - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return false - } - if !u.IsAbs() { - return true - } - return strings.EqualFold(u.Host, base.Host) -} - -func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string { - if len(raw) == 0 || string(raw) == "null" { - return nil - } - var obj map[string]any - if err := json.Unmarshal(raw, &obj); err != nil { - return nil - } - out := make(map[string]string, len(obj)) - for k, v := range obj { - key := strings.TrimSpace(k) - if key == "" { - continue - } - switch value := v.(type) { - case string: - if strings.TrimSpace(value) != "" { - out[key] = strings.TrimSpace(value) - } - case []any: - parts := make([]string, 0, len(value)) - for _, item := range value { - if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { - parts = append(parts, strings.TrimSpace(s)) - } - } - if len(parts) > 0 { - out[key] = strings.Join(parts, ", ") - } - } - } - if len(out) == 0 { - return nil - } - return out -} - -func isCloudVideoPlaybackCandidate(fileRef string) bool { - switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) { - case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg": - return true - default: - return false - } -} - -type openListListResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - Content []openListListItem `json:"content"` - Total int `json:"total"` - } `json:"data"` -} - -type openListListItem struct { - Name string `json:"name"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` -} - -type openListGetResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - RawURL string `json:"raw_url"` - URL string `json:"url"` - Header json.RawMessage `json:"header"` - } `json:"data"` -} - -type openListLoginResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - Token string `json:"token"` - } `json:"data"` -} - -func joinOpenListAPIPath(dir, name string) string { - dir = strings.TrimRight(normalizeCloudDAVPath(dir), "/") - name = strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/") - if dir == "" || dir == "/" { - return normalizeCloudDAVPath(name) - } - return normalizeCloudDAVPath(dir + "/" + name) -} diff --git a/internal/service/cloud/clouddrive2_test.go b/internal/service/cloud/clouddrive2_test.go deleted file mode 100644 index 78cb264..0000000 --- a/internal/service/cloud/clouddrive2_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package cloud - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestCloudDrive2WebDAVListAndResolve(t *testing.T) { - var gotAuth, gotDepth, gotRange string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "PROPFIND" && r.URL.Path == "/dav": - gotAuth = r.Header.Get("Authorization") - gotDepth = r.Header.Get("Depth") - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - - /dav/ - - - - /dav/115/ - 115 - - - /dav/123/Movie.mkv - Movie.mkv789 - -`)) - case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv": - gotAuth = r.Header.Get("Authorization") - gotRange = r.Header.Get("Range") - http.Redirect(w, r, "https://cdn.example.test/123/Movie.mkv?sign=1", http.StatusFound) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if gotDepth != "1" { - t.Fatalf("Depth = %q, want 1", gotDepth) - } - if !strings.HasPrefix(gotAuth, "Basic ") { - t.Fatalf("missing basic auth: %q", gotAuth) - } - if len(entries) != 2 { - t.Fatalf("entries = %#v", entries) - } - if !entries[0].IsDir || entries[0].ID != "/115" { - t.Fatalf("dir entry wrong: %#v", entries[0]) - } - if entries[1].IsDir || entries[1].ID != "/123/Movie.mkv" || entries[1].Size != 789 { - t.Fatalf("file entry wrong: %#v", entries[1]) - } - link, err := p.Resolve(context.Background(), entries[1].ID) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if link.URL != "https://cdn.example.test/123/Movie.mkv?sign=1" { - t.Fatalf("bad url: %s", link.URL) - } - if link.Proxy || len(link.Headers) != 0 { - t.Fatalf("clouddrive2 video should resolve to pure 302 link: %#v", link) - } - if gotRange != "bytes=0-0" { - t.Fatalf("resolve should probe with a tiny range, got %q", gotRange) - } -} - -func TestCloudDrive2ResolveRejectsWebDAVProxyFallbackWithoutRedirect(t *testing.T) { - var getSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "PROPFIND" && r.URL.Path == "/dav": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(`/dav/`)) - case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv": - getSeen = true - w.Header().Set("Content-Range", "bytes 0-0/10") - w.WriteHeader(http.StatusPartialContent) - _, _ = w.Write([]byte("x")) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/123/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "without CDN Location") || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") { - t.Fatalf("resolve error = %v, want pure 302 refusal", err) - } - if !getSeen { - t.Fatal("expected CloudDrive2 WebDAV direct-link probe") - } -} - -func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) { - var mkcolSeen bool - var destinations []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "MKCOL" && r.URL.Path == "/dav/TV": - mkcolSeen = true - w.WriteHeader(http.StatusCreated) - case r.Method == "MOVE" && r.URL.Path == "/dav/TV": - destinations = append(destinations, r.Header.Get("Destination")) - if r.Header.Get("Overwrite") != "F" { - t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite")) - } - w.WriteHeader(http.StatusCreated) - case r.Method == "MOVE" && r.URL.Path == "/dav/Inbox/Movie.mkv": - destinations = append(destinations, r.Header.Get("Destination")) - if r.Header.Get("Overwrite") != "F" { - t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite")) - } - w.WriteHeader(http.StatusCreated) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - mutable, ok := p.(MutableProvider) - if !ok { - t.Fatal("clouddrive2 should support mutable provider") - } - if _, err := mutable.Mkdir(context.Background(), "", "TV"); err != nil { - t.Fatalf("mkdir: %v", err) - } - if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil { - t.Fatalf("rename: %v", err) - } - moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/欧美电影/Movie (2026)", "Movie (2026).mkv") - if err != nil { - t.Fatalf("move: %v", err) - } - if !mkcolSeen || len(destinations) != 2 { - t.Fatalf("mkcol=%v destinations=%#v, want mkdir and two MOVE calls", mkcolSeen, destinations) - } - if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" { - t.Fatalf("rename Destination = %q", destinations[0]) - } - if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" { - t.Fatalf("move Destination = %q", destinations[1]) - } - if moved.ID != "/电影/欧美电影/Movie (2026)/Movie (2026).mkv" { - t.Fatalf("moved entry = %#v", moved) - } -} diff --git a/internal/service/cloud/openlist_list_test.go b/internal/service/cloud/openlist_list_test.go deleted file mode 100644 index 7dd6164..0000000 --- a/internal/service/cloud/openlist_list_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestOpenListWebDAVListAndResolve(t *testing.T) { - var gotPath, gotDepth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/auth/login" { - http.NotFound(w, r) - return - } - if r.URL.Path == "/api/fs/get" { - http.NotFound(w, r) - return - } - if r.Method != "PROPFIND" || r.URL.Path != "/dav" { - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - gotPath = r.URL.Path - gotDepth = r.Header.Get("Depth") - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - - /dav/ - - - - /dav/Cloud/Movie.mkv - Movie.mkv1024 - -`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - if p.Type() != TypeOpenList { - t.Fatalf("type = %q, want %q", p.Type(), TypeOpenList) - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if gotPath != "/dav" { - t.Fatalf("path = %q, want /dav", gotPath) - } - if gotDepth != "1" { - t.Fatalf("Depth = %q, want 1", gotDepth) - } - if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 { - t.Fatalf("entries = %#v", entries) - } - _, err = p.Resolve(context.Background(), entries[0].ID) - if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") { - t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err) - } -} - -func TestOpenListListUsesAPIUsernamePasswordWithoutWebDAVFallback(t *testing.T) { - var loginSeen, listSeen, davSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/auth/login": - loginSeen = true - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode login body: %v", err) - } - if body["username"] != "alice" || body["password"] != "secret" { - t.Fatalf("login body = %#v", body) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`)) - case "/api/fs/list": - listSeen = true - if r.Header.Get("Authorization") != "api-token" { - t.Fatalf("Authorization = %q, want api-token", r.Header.Get("Authorization")) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"content":[{"name":"Movies","is_dir":true,"size":0},{"name":"Movie.mkv","is_dir":false,"size":1024}],"total":2}}`)) - case "/dav": - davSeen = true - w.WriteHeader(http.StatusMultiStatus) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if !loginSeen || !listSeen { - t.Fatalf("expected api login/list, login=%v list=%v", loginSeen, listSeen) - } - if davSeen { - t.Fatal("openlist API credentials should not fall back to WebDAV") - } - if len(entries) != 2 || entries[0].ID != "/Movies" || !entries[0].IsDir || entries[1].ID != "/Movie.mkv" || entries[1].Size != 1024 { - t.Fatalf("entries = %#v", entries) - } -} - -func TestOpenListMutableProviderUsesAPI(t *testing.T) { - var mkdirPath, renamePath, renameName, moveSrcDir, moveDstDir string - var moveNames []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/fs/mkdir": - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode mkdir body: %v", err) - } - mkdirPath = body["path"] - if r.Header.Get("Authorization") != "alist-token" { - t.Fatalf("mkdir Authorization = %q", r.Header.Get("Authorization")) - } - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/rename": - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode rename body: %v", err) - } - renamePath = body["path"] - renameName = body["name"] - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/move": - var body struct { - SrcDir string `json:"src_dir"` - DstDir string `json:"dst_dir"` - Names []string `json:"names"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode move body: %v", err) - } - moveSrcDir = body.SrcDir - moveDstDir = body.DstDir - moveNames = body.Names - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - mutable, ok := p.(MutableProvider) - if !ok { - t.Fatal("openlist should support mutable provider") - } - created, err := mutable.Mkdir(context.Background(), "/电视剧", "欧美剧") - if err != nil { - t.Fatalf("mkdir: %v", err) - } - if mkdirPath != "/电视剧/欧美剧" || created.ID != "/电视剧/欧美剧" || !created.IsDir { - t.Fatalf("mkdir path=%q entry=%#v", mkdirPath, created) - } - renamed, err := mutable.Rename(context.Background(), "/电视剧/欧美剧", "美剧") - if err != nil { - t.Fatalf("rename: %v", err) - } - if renamePath != "/电视剧/欧美剧" || renameName != "美剧" || renamed.ID != "/电视剧/美剧" { - t.Fatalf("rename path=%q name=%q entry=%#v", renamePath, renameName, renamed) - } - moved, err := mutable.(MovableProvider).Move(context.Background(), "/待整理/Show.S01E01.mkv", "/动漫/国漫/Show/Season 01", "Show - S01E01.mkv") - if err != nil { - t.Fatalf("move: %v", err) - } - if moveSrcDir != "/待整理" || moveDstDir != "/动漫/国漫/Show/Season 01" || len(moveNames) != 1 || moveNames[0] != "Show.S01E01.mkv" { - t.Fatalf("move src=%q dst=%q names=%#v", moveSrcDir, moveDstDir, moveNames) - } - if renamePath != "/动漫/国漫/Show/Season 01/Show.S01E01.mkv" || renameName != "Show - S01E01.mkv" { - t.Fatalf("post-move rename path=%q name=%q", renamePath, renameName) - } - if moved.ID != "/动漫/国漫/Show/Season 01/Show - S01E01.mkv" { - t.Fatalf("moved entry = %#v", moved) - } -} - -func TestOpenListListAPIFailureDoesNotFallbackToWebDAV(t *testing.T) { - var davSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/auth/login": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":500,"message":"bad password"}`)) - case "/dav": - davSeen = true - w.WriteHeader(http.StatusMultiStatus) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "bad"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.List(context.Background(), "") - if err == nil || !strings.Contains(err.Error(), "api login failed") || !strings.Contains(err.Error(), "bad password") { - t.Fatalf("list error = %v, want api login failure", err) - } - if davSeen { - t.Fatal("openlist API failure fell back to WebDAV") - } -} - -func TestOpenListRootURLDefaultsToDAV(t *testing.T) { - var gotPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(`/dav/`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - if _, err := p.List(context.Background(), ""); err != nil { - t.Fatalf("list: %v", err) - } - if gotPath != "/dav" { - t.Fatalf("path = %q, want /dav", gotPath) - } -} - -func TestOpenListURLForKeepsNonASCIIPathSingleEncoded(t *testing.T) { - p := newOpenList(map[string]any{"url": "http://example.test:5244/dav/"}, nil) - got := p.urlFor("/动画电影/爱宠大机密2 (2019) {tmdb-412117}") - if strings.Contains(got, "%25E") { - t.Fatalf("url is double-escaped: %s", got) - } - want := "http://example.test:5244/dav/%E5%8A%A8%E7%94%BB%E7%94%B5%E5%BD%B1/%E7%88%B1%E5%AE%A0%E5%A4%A7%E6%9C%BA%E5%AF%862%20%282019%29%20%7Btmdb-412117%7D" - if got != want { - t.Fatalf("url = %s, want %s", got, want) - } -} - -func TestOpenListDAVStatusErrorIncludesBodyHint(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusMethodNotAllowed) - _, _ = w.Write([]byte("请先填写有效Cookie并保存")) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.List(context.Background(), "") - if err == nil { - t.Fatal("want error") - } - if !strings.Contains(err.Error(), "请先填写有效Cookie并保存") || !strings.Contains(err.Error(), "WebDAV 地址") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/service/cloud/openlist_resolve_test.go b/internal/service/cloud/openlist_resolve_test.go deleted file mode 100644 index 2b9b976..0000000 --- a/internal/service/cloud/openlist_resolve_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) { - var gotPath, gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotAuth = r.Header.Get("Authorization") - if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" { - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if gotPath != "/api/fs/get" { - t.Fatalf("api path = %q, want /api/fs/get", gotPath) - } - if gotAuth != "alist-token" { - t.Fatalf("Authorization = %q, want token", gotAuth) - } - if link.URL != "https://cdn.example.test/movie.mkv?sign=1" { - t.Fatalf("url = %q", link.URL) - } - if link.Proxy { - t.Fatalf("openlist raw_url without required headers should be 302 playback") - } -} - -func TestOpenListResolveCollapsesHostedRawURLRedirectToCDN(t *testing.T) { - var probeSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/get": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`)) - case "/d/Cloud/Movie.mkv": - probeSeen = true - if r.Header.Get("Range") != "bytes=0-0" { - t.Fatalf("probe Range = %q", r.Header.Get("Range")) - } - http.Redirect(w, r, "https://cdn.example.test/movie.mkv?sign=cdn", http.StatusFound) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if !probeSeen { - t.Fatal("expected OpenList-hosted raw_url probe") - } - if link.URL != "https://cdn.example.test/movie.mkv?sign=cdn" || link.Proxy || len(link.Headers) != 0 { - t.Fatalf("link = %#v, want collapsed CDN 302 playback", link) - } -} - -func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) { - var loginSeen bool - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/auth/login": - loginSeen = true - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode login body: %v", err) - } - if body["username"] != "alice" || body["password"] != "secret" { - t.Fatalf("login body = %#v", body) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`)) - case "/api/fs/get": - gotAuth = r.Header.Get("Authorization") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if !loginSeen { - t.Fatalf("expected api login before fs/get") - } - if gotAuth != "api-token" { - t.Fatalf("Authorization = %q, want api-token", gotAuth) - } - if link.URL != "https://cdn.example.test/movie.mkv?sign=1" || link.Proxy { - t.Fatalf("link = %#v, want raw_url 302 playback", link) - } -} - -func TestOpenListResolveRejectsProxyWhenAPIRawURLNeedsHeaders(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/get" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/dav/Cloud/Movie.mkv","header":{"Cookie":"sid=abc"}}}`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") || !strings.Contains(err.Error(), "Cookie") { - t.Fatalf("resolve error = %v, want pure 302 refusal with header names", err) - } -} - -func TestOpenListResolveRejectsHostedRawURLWithoutCDNRedirect(t *testing.T) { - var probeSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/get": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`)) - case "/d/Cloud/Movie.mkv": - probeSeen = true - w.Header().Set("Content-Range", "bytes 0-0/10") - w.WriteHeader(http.StatusPartialContent) - _, _ = w.Write([]byte("x")) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "OpenList-hosted raw_url") || !strings.Contains(err.Error(), "no CDN Location") { - t.Fatalf("resolve error = %v, want hosted raw_url refusal", err) - } - if !probeSeen { - t.Fatal("expected OpenList-hosted raw_url probe") - } -} - -func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T) { - var davSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/get": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":500,"message":"driver cannot provide raw_url"}`)) - case "/dav/Cloud/Movie.mkv": - davSeen = true - w.WriteHeader(http.StatusOK) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") { - t.Fatalf("resolve error = %v, want raw_url requirement", err) - } - if davSeen { - t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure") - } -} diff --git a/internal/service/cloud/pan115.go b/internal/service/cloud/pan115.go deleted file mode 100644 index b92194f..0000000 --- a/internal/service/cloud/pan115.go +++ /dev/null @@ -1,237 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" -) - -// pan115Provider implements 115 网盘 via cookie auth. -// -// 115 has removed its desktop clients, so cookies must come from the mobile -// app / web (115.com) or a QR-code login (see QR* helpers below). Directory -// listing uses the public web API; download resolves a file's pickcode to a -// CDN URL that, like Alist's default 115 behaviour, is served by 302 redirect. -type pan115Provider struct { - cookie string - ua string - webBase string // https://webapi.115.com (override in tests) - proBase string // https://proapi.115.com (override in tests) - client *http.Client - proxy bool - - // downURLPayload fetches and decrypts the app/chrome/downurl response for a - // pickcode, returning the raw JSON payload (map of file id -> info). It is a - // seam so tests can bypass the live 115 crypto/transport. - downURLPayload func(ctx context.Context, pickcode string) ([]byte, error) -} - -const ( - pan115WebBase = "https://webapi.115.com" - pan115ProBase = "https://proapi.115.com" -) - -func new115(cfg map[string]any, client *http.Client) *pan115Provider { - web := str(cfg["base"]) - if web == "" { - web = pan115WebBase - } - ua := str(cfg["ua"]) - if ua == "" { - ua = defaultUA - } - // 115 CDN download URLs work with a plain 302 (Alist's recommended mode), - // so offload by default. The global cloud playback setting decides whether - // clients receive a STRMURL entry or a /Videos stream entry. - proxy := false - pro := str(cfg["pro_base"]) - if pro == "" { - pro = pan115ProBase - } - p := &pan115Provider{ - cookie: str(cfg["cookie"]), - ua: ua, - webBase: strings.TrimRight(web, "/"), - proBase: strings.TrimRight(pro, "/"), - client: client, - proxy: proxy, - } - p.downURLPayload = p.fetchDownURLPayload - return p -} - -func (p *pan115Provider) Type() string { return Type115 } - -func (p *pan115Provider) get(ctx context.Context, u string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - req.Header.Set("Cookie", p.cookie) - req.Header.Set("User-Agent", p.ua) - req.Header.Set("Accept", "application/json, text/plain, */*") - return p.client.Do(req) -} - -func (p *pan115Provider) Ping(ctx context.Context) error { - if p.cookie == "" { - return fmt.Errorf("115: missing cookie") - } - _, err := p.List(ctx, "0") - return err -} - -func (p *pan115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) { - if dirID == "" { - dirID = "0" - } - const pageSize = 100 - out := make([]FileEntry, 0, pageSize) - for offset := 0; ; offset += pageSize { - q := url.Values{} - q.Set("aid", "1") - q.Set("cid", dirID) - q.Set("o", "user_ptime") - q.Set("asc", "0") - q.Set("offset", strconv.Itoa(offset)) - q.Set("show_dir", "1") - q.Set("limit", strconv.Itoa(pageSize)) - q.Set("format", "json") - resp, err := p.get(ctx, p.webBase+"/files?"+q.Encode()) - if err != nil { - return nil, err - } - var r struct { - State bool `json:"state"` - Error string `json:"error"` - Data []struct { - Fid string `json:"fid"` // file id (files only) - Cid string `json:"cid"` // category id (dirs use this) - N string `json:"n"` // name - S json.Number `json:"s"` // size - Pc string `json:"pc"` // pickcode - } `json:"data"` - } - err = json.NewDecoder(resp.Body).Decode(&r) - _ = resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("115: decode list: %w", err) - } - if !r.State { - return nil, fmt.Errorf("115: list failed: %s", r.Error) - } - for _, it := range r.Data { - isDir := it.Fid == "" - id := it.Fid - if isDir { - id = it.Cid - } - size, _ := it.S.Int64() - out = append(out, FileEntry{ - ID: id, - Name: it.N, - IsDir: isDir, - Size: size, - PickCode: it.Pc, - }) - } - if len(r.Data) < pageSize { - break - } - } - return out, nil -} - -// Resolve accepts a pickcode (preferred) and returns the CDN download URL. -// -// 115 deprecated the plain web /files/download endpoint (it no longer returns -// file_url for ordinary cookies). We use the current app/chrome/downurl -// endpoint, which takes an m115-encrypted body and returns an m115-encrypted -// payload mapping the file id to a short-lived, OSS-signed CDN URL suitable for -// a 302 redirect (the same approach Alist's 115 driver uses). -func (p *pan115Provider) Resolve(ctx context.Context, pickcode string) (*DirectLink, error) { - if pickcode == "" { - return nil, fmt.Errorf("115: empty pickcode") - } - raw, err := p.downURLPayload(ctx, pickcode) - if err != nil { - return nil, err - } - var payload map[string]struct { - FileName string `json:"file_name"` - FileSize json.Number `json:"file_size"` - URL struct { - URL string `json:"url"` - } `json:"url"` - } - if err := json.Unmarshal(raw, &payload); err != nil { - return nil, fmt.Errorf("115: decode downurl: %w", err) - } - for _, info := range payload { - if info.URL.URL == "" { - continue - } - return &DirectLink{ - URL: info.URL.URL, - Headers: map[string]string{ - "User-Agent": p.ua, - "Cookie": p.cookie, - }, - Proxy: p.proxy, - }, nil - } - return nil, fmt.Errorf("115: download failed: no url") -} - -// fetchDownURLPayload performs the live encrypted app/chrome/downurl request and -// returns the decrypted JSON payload. -func (p *pan115Provider) fetchDownURLPayload(ctx context.Context, pickcode string) ([]byte, error) { - key := m115GenerateKey() - params, err := json.Marshal(map[string]string{"pickcode": pickcode}) - if err != nil { - return nil, err - } - form := url.Values{} - form.Set("data", m115Encode(params, key)) - u := fmt.Sprintf("%s/app/chrome/downurl?t=%d", p.proBase, nowUnix()) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Cookie", p.cookie) - req.Header.Set("User-Agent", p.ua) - req.Header.Set("Accept", "application/json, text/plain, */*") - resp, err := p.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var r struct { - State bool `json:"state"` - Error string `json:"error"` - Data string `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return nil, fmt.Errorf("115: decode downurl: %w", err) - } - if !r.State || r.Data == "" { - msg := r.Error - if msg == "" { - msg = "no data" - } - return nil, fmt.Errorf("115: download failed: %s", msg) - } - out, err := m115Decode(r.Data, key) - if err != nil { - return nil, fmt.Errorf("115: decrypt downurl: %w", err) - } - return out, nil -} - -// nowUnix is a seam for deterministic tests. -var nowUnix = func() int64 { return timeNow().Unix() } diff --git a/internal/service/cloud/pan115_crypto.go b/internal/service/cloud/pan115_crypto.go deleted file mode 100644 index 01ee8e9..0000000 --- a/internal/service/cloud/pan115_crypto.go +++ /dev/null @@ -1,184 +0,0 @@ -package cloud - -// 115 网盘的 app/chrome/downurl 下载接口要求请求体使用 115 自有的 "m115" 加密协议 -// (RSA + XOR 混淆),返回的直链也以同样方式加密。普通 web cookie 调用旧的 -// /files/download 接口已不再返回 file_url,必须改用该加密接口。 -// -// 下面的实现移植自 MIT 许可的 github.com/SheltonZhu/115driver -// (pkg/crypto/m115),alist 等项目同样采用此实现。仅做最小改动:函数加 m115 -// 前缀以归入本包命名空间。 -// -// Copyright (c) 115driver authors. MIT License. - -import ( - "bytes" - "crypto/rand" - "encoding/base64" - "io" - "math/big" -) - -// m115Key is the random 16-byte session key generated per request. -type m115Key [16]byte - -func m115GenerateKey() m115Key { - key := m115Key{} - _, _ = io.ReadFull(rand.Reader, key[:]) - return key -} - -// m115Encode encrypts request input for the downurl endpoint. -func m115Encode(input []byte, key m115Key) string { - buf := make([]byte, 16+len(input)) - copy(buf, key[:]) - copy(buf[16:], input) - m115XORTransform(buf[16:], m115XORDeriveKey(key[:], 4)) - m115ReverseBytes(buf[16:]) - m115XORTransform(buf[16:], m115XORClientKey) - return base64.StdEncoding.EncodeToString(m115RSAEncrypt(buf)) -} - -// m115Decode decrypts the base64 response payload using the request key. -func m115Decode(input string, key m115Key) ([]byte, error) { - data, err := base64.StdEncoding.DecodeString(input) - if err != nil { - return nil, err - } - data = m115RSADecrypt(data) - output := make([]byte, len(data)-16) - copy(output, data[16:]) - m115XORTransform(output, m115XORDeriveKey(data[:16], 12)) - m115ReverseBytes(output) - m115XORTransform(output, m115XORDeriveKey(key[:], 4)) - return output, nil -} - -func m115ReverseBytes(data []byte) { - for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 { - data[i], data[j] = data[j], data[i] - } -} - -// --- RSA layer --- - -var ( - m115N, _ = big.NewInt(0).SetString( - "8686980c0f5a24c4b9d43020cd2c22703ff3f450756529058b1cf88f09b86021"+ - "36477198a6e2683149659bd122c33592fdb5ad47944ad1ea4d36c6b172aad633"+ - "8c3bb6ac6227502d010993ac967d1aef00f0c8e038de2e4d3bc2ec368af2e9f1"+ - "0a6f1eda4f7262f136420c07c331b871bf139f74f3010e3c4fe57df3afb71683", 16) - m115E, _ = big.NewInt(0).SetString("10001", 16) - - m115KeyLength = m115N.BitLen() / 8 -) - -func m115RSAEncrypt(input []byte) []byte { - buf := &bytes.Buffer{} - for remainSize := len(input); remainSize > 0; { - sliceSize := m115KeyLength - 11 - if sliceSize > remainSize { - sliceSize = remainSize - } - m115RSAEncryptSlice(input[:sliceSize], buf) - input = input[sliceSize:] - remainSize -= sliceSize - } - return buf.Bytes() -} - -func m115RSAEncryptSlice(input []byte, w io.Writer) { - padSize := m115KeyLength - len(input) - 3 - padData := make([]byte, padSize) - _, _ = rand.Read(padData) - buf := make([]byte, m115KeyLength) - buf[0], buf[1] = 0, 2 - for i, b := range padData { - buf[2+i] = b%0xff + 0x01 - } - buf[padSize+2] = 0 - copy(buf[padSize+3:], input) - msg := big.NewInt(0).SetBytes(buf) - ret := big.NewInt(0).Exp(msg, m115E, m115N).Bytes() - if fillSize := m115KeyLength - len(ret); fillSize > 0 { - zeros := make([]byte, fillSize) - _, _ = w.Write(zeros) - } - _, _ = w.Write(ret) -} - -func m115RSADecrypt(input []byte) []byte { - buf := &bytes.Buffer{} - for remainSize := len(input); remainSize > 0; { - sliceSize := m115KeyLength - if sliceSize > remainSize { - sliceSize = remainSize - } - m115RSADecryptSlice(input[:sliceSize], buf) - input = input[sliceSize:] - remainSize -= sliceSize - } - return buf.Bytes() -} - -func m115RSADecryptSlice(input []byte, w io.Writer) { - msg := big.NewInt(0).SetBytes(input) - ret := big.NewInt(0).Exp(msg, m115E, m115N).Bytes() - for i, b := range ret { - if b == 0 && i != 0 { - _, _ = w.Write(ret[i+1:]) - break - } - } -} - -// --- XOR layer --- - -var ( - m115XORKeySeed = []byte{ - 0xf0, 0xe5, 0x69, 0xae, 0xbf, 0xdc, 0xbf, 0x8a, - 0x1a, 0x45, 0xe8, 0xbe, 0x7d, 0xa6, 0x73, 0xb8, - 0xde, 0x8f, 0xe7, 0xc4, 0x45, 0xda, 0x86, 0xc4, - 0x9b, 0x64, 0x8b, 0x14, 0x6a, 0xb4, 0xf1, 0xaa, - 0x38, 0x01, 0x35, 0x9e, 0x26, 0x69, 0x2c, 0x86, - 0x00, 0x6b, 0x4f, 0xa5, 0x36, 0x34, 0x62, 0xa6, - 0x2a, 0x96, 0x68, 0x18, 0xf2, 0x4a, 0xfd, 0xbd, - 0x6b, 0x97, 0x8f, 0x4d, 0x8f, 0x89, 0x13, 0xb7, - 0x6c, 0x8e, 0x93, 0xed, 0x0e, 0x0d, 0x48, 0x3e, - 0xd7, 0x2f, 0x88, 0xd8, 0xfe, 0xfe, 0x7e, 0x86, - 0x50, 0x95, 0x4f, 0xd1, 0xeb, 0x83, 0x26, 0x34, - 0xdb, 0x66, 0x7b, 0x9c, 0x7e, 0x9d, 0x7a, 0x81, - 0x32, 0xea, 0xb6, 0x33, 0xde, 0x3a, 0xa9, 0x59, - 0x34, 0x66, 0x3b, 0xaa, 0xba, 0x81, 0x60, 0x48, - 0xb9, 0xd5, 0x81, 0x9c, 0xf8, 0x6c, 0x84, 0x77, - 0xff, 0x54, 0x78, 0x26, 0x5f, 0xbe, 0xe8, 0x1e, - 0x36, 0x9f, 0x34, 0x80, 0x5c, 0x45, 0x2c, 0x9b, - 0x76, 0xd5, 0x1b, 0x8f, 0xcc, 0xc3, 0xb8, 0xf5, - } - - m115XORClientKey = []byte{ - 0x78, 0x06, 0xad, 0x4c, 0x33, 0x86, 0x5d, 0x18, - 0x4c, 0x01, 0x3f, 0x46, - } -) - -func m115XORDeriveKey(seed []byte, size int) []byte { - key := make([]byte, size) - for i := 0; i < size; i++ { - key[i] = (seed[i] + m115XORKeySeed[size*i]) & 0xff - key[i] ^= m115XORKeySeed[size*(size-i-1)] - } - return key -} - -func m115XORTransform(data []byte, key []byte) { - dataSize, keySize := len(data), len(key) - mod := dataSize % 4 - if mod > 0 { - for i := 0; i < mod; i++ { - data[i] ^= key[i%keySize] - } - } - for i := mod; i < dataSize; i++ { - data[i] ^= key[(i-mod)%keySize] - } -} diff --git a/internal/service/cloud/pan115_qr.go b/internal/service/cloud/pan115_qr.go deleted file mode 100644 index 8dc4ebc..0000000 --- a/internal/service/cloud/pan115_qr.go +++ /dev/null @@ -1,147 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" -) - -// QRSession is the handle returned by QRStart; the client renders QRImageURL -// and polls QRPoll until it returns a cookie. -type QRSession struct { - UID string `json:"uid"` - Time int64 `json:"time"` - Sign string `json:"sign"` - QRImageURL string `json:"qr_image_url"` -} - -// QR login hosts (overridable for tests). -var ( - qr115APIBase = "https://qrcodeapi.115.com" - qr115PassportBase = "https://passportapi.115.com" -) - -// QRStart obtains a 115 QR-code login token + image URL. -func QRStart(ctx context.Context, client *http.Client) (*QRSession, error) { - if client == nil { - client = http.DefaultClient - } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/api/1.0/web/1.0/token/", nil) - req.Header.Set("User-Agent", defaultUA) - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var r struct { - State int `json:"state"` - Data struct { - UID string `json:"uid"` - Time int64 `json:"time"` - Sign string `json:"sign"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return nil, fmt.Errorf("115 qr: decode token: %w", err) - } - if r.State != 1 || r.Data.UID == "" { - return nil, fmt.Errorf("115 qr: token request failed") - } - return &QRSession{ - UID: r.Data.UID, - Time: r.Data.Time, - Sign: r.Data.Sign, - QRImageURL: qr115APIBase + "/api/1.0/web/1.0/qrcode?uid=" + url.QueryEscape(r.Data.UID), - }, nil -} - -// QRStatus is the poll result. -type QRStatus struct { - // State is one of: "waiting" (not scanned), "scanned" (scanned, awaiting - // confirmation), "confirmed" (login approved; Cookie populated), - // "expired" (token expired/cancelled). - State string `json:"state"` - Cookie string `json:"cookie,omitempty"` -} - -// QRPoll checks the QR session status; on confirmation it exchanges the token -// for a session cookie via the passport API. -func QRPoll(ctx context.Context, client *http.Client, sess *QRSession) (*QRStatus, error) { - if client == nil { - client = http.DefaultClient - } - if sess == nil || sess.UID == "" { - return nil, fmt.Errorf("115 qr: nil session") - } - q := url.Values{} - q.Set("uid", sess.UID) - q.Set("time", strconv.FormatInt(sess.Time, 10)) - q.Set("sign", sess.Sign) - q.Set("_", strconv.FormatInt(timeNow().UnixMilli(), 10)) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/get/status/?"+q.Encode(), nil) - req.Header.Set("User-Agent", defaultUA) - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var r struct { - State int `json:"state"` - Data struct { - Status int `json:"status"` // 0 waiting, 1 scanned, 2 confirmed, -1/-2 expired - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return nil, fmt.Errorf("115 qr: decode status: %w", err) - } - switch r.Data.Status { - case 1: - return &QRStatus{State: "scanned"}, nil - case 2: - cookie, err := qr115Exchange(ctx, client, sess.UID) - if err != nil { - return nil, err - } - return &QRStatus{State: "confirmed", Cookie: cookie}, nil - case 0: - return &QRStatus{State: "waiting"}, nil - default: - return &QRStatus{State: "expired"}, nil - } -} - -// qr115Exchange swaps an approved uid for a session cookie. -func qr115Exchange(ctx context.Context, client *http.Client, uid string) (string, error) { - form := url.Values{} - form.Set("account", uid) - form.Set("app", "web") - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, qr115PassportBase+"/app/1.0/web/1.0/login/qrcode/", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", defaultUA) - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - var r struct { - State int `json:"state"` - Data struct { - Cookie map[string]string `json:"cookie"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return "", fmt.Errorf("115 qr: decode login: %w", err) - } - if r.State != 1 || len(r.Data.Cookie) == 0 { - return "", fmt.Errorf("115 qr: login exchange failed") - } - parts := make([]string, 0, len(r.Data.Cookie)) - for k, v := range r.Data.Cookie { - parts = append(parts, k+"="+v) - } - return strings.Join(parts, "; "), nil -} diff --git a/internal/service/cloud_auto_category.go b/internal/service/cloud_auto_category.go deleted file mode 100644 index 707f769..0000000 --- a/internal/service/cloud_auto_category.go +++ /dev/null @@ -1,288 +0,0 @@ -package service - -import ( - "context" - "net/url" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const cloudAutoCategoryQueryKey = "auto_category" - -func BuildCloudAutoCategoryLibraryPath(provider, displayDir string) string { - return BuildCloudAutoCategoryLibraryPathWithScanDir(provider, "", displayDir) -} - -func BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir string) string { - base := BuildCloudLibraryPath(provider, scanDir, displayDir) - if base == "" || strings.TrimSpace(displayDir) == "" { - return "" - } - sep := "?" - if strings.Contains(base, "?") { - sep = "&" - } - return base + sep + cloudAutoCategoryQueryKey + "=1" -} - -func CloudLibraryAutoCategory(lib model.Library) bool { - u, err := url.Parse(strings.TrimSpace(lib.Path)) - if err != nil || strings.ToLower(u.Scheme) != "cloud" { - return false - } - switch strings.ToLower(strings.TrimSpace(u.Query().Get(cloudAutoCategoryQueryKey))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func cloudRootMountNeedsAutoCategory(mount CloudMountInfo) bool { - return strings.TrimSpace(mount.DisplayDir) == "" && strings.TrimSpace(mount.ScanDir) == "" -} - -func cloudAutoCategoryDisplayDirForMediaPath(path string) string { - displayDir, _ := cloudAutoCategoryDirsForMediaPath(path) - return displayDir -} - -func cloudAutoCategoryDirsForMediaPath(path string) (string, string) { - info, ok := ParseCloudLibraryMount(path) - if !ok { - return "", "" - } - parts := strmSlashParts(info.DisplayDir) - if len(parts) <= 1 { - return "", "" - } - parts = parts[:len(parts)-1] - categoryParts, scanParts := cloudAutoCategoryParts(parts) - if len(categoryParts) == 0 { - return "", "" - } - return strings.Join(categoryParts, "/"), strings.Join(scanParts, "/") -} - -func cloudAutoCategoryParts(parts []string) ([]string, []string) { - for i, part := range parts { - root := strmCanonicalRoot(part) - if root != "" { - if i+1 >= len(parts) { - return nil, nil - } - category := strings.TrimSpace(parts[i+1]) - if cloudAutoCategoryRootMatches(root, category) { - return []string{root, strmCanonicalCategory(category)}, append([]string(nil), parts[:i+2]...) - } - return nil, nil - } - if root := strmCategoryRoot(part); root != "" { - return []string{root, strmCanonicalCategory(part)}, append([]string(nil), parts[:i+1]...) - } - } - return nil, nil -} - -func cloudAutoCategoryRootMatches(root, category string) bool { - category = strings.TrimSpace(category) - if category == "" { - return false - } - if strmCategoryRoot(category) == root { - return true - } - if root == "电影" { - return containsAnyText(strings.ToLower(category), "纪录片", "纪录", "documentary") - } - return false -} - -type cloudAutoCategoryTarget struct { - Library *model.Library - RootID string -} - -func (s *ScannerService) ensureCloudAutoCategoryTarget(ctx context.Context, rootLib *model.Library, provider, displayDir, scanDir string) (cloudAutoCategoryTarget, error) { - displayDir = normalizeCloudMountDir(provider, displayDir) - scanDir = normalizeCloudMountDir(provider, firstNonEmpty(scanDir, displayDir)) - if s == nil || s.repo == nil || s.repo.DB == nil || rootLib == nil || provider == "" || displayDir == "" { - return cloudAutoCategoryTarget{Library: rootLib}, nil - } - path := BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir) - if path == "" { - return cloudAutoCategoryTarget{Library: rootLib}, nil - } - name := cloudMountDirBase(displayDir) - if name == "" { - name = displayDir - } - kind := InferCloudMountMediaType(displayDir, name) - target, existingAuto := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind) - if target != nil { - root, err := s.ensureCloudLibraryRoot(ctx, target.ID, name, path) - if err != nil { - return cloudAutoCategoryTarget{}, err - } - if existingAuto != nil && existingAuto.ID != target.ID { - s.migrateCloudAutoCategoryLibrary(ctx, existingAuto, target, root) - } - return cloudAutoCategoryTarget{Library: target, RootID: libraryRootID(root)}, nil - } - if existingAuto != nil { - root, err := s.ensureCloudLibraryRoot(ctx, existingAuto.ID, name, path) - if err != nil { - return cloudAutoCategoryTarget{}, err - } - return cloudAutoCategoryTarget{Library: existingAuto, RootID: libraryRootID(root)}, nil - } - lib := &model.Library{ - Name: name, - Path: path, - Type: kind, - Enabled: true, - } - root := model.LibraryRoot{Name: name, Path: path, Enabled: true} - if err := s.repo.Library.CreateWithRoots(ctx, lib, []model.LibraryRoot{root}); err != nil { - _, existing := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind) - if existing != nil { - ensuredRoot, rootErr := s.ensureCloudLibraryRoot(ctx, existing.ID, name, path) - if rootErr != nil { - return cloudAutoCategoryTarget{}, rootErr - } - return cloudAutoCategoryTarget{Library: existing, RootID: libraryRootID(ensuredRoot)}, nil - } - return cloudAutoCategoryTarget{}, err - } - if s.log != nil { - s.log.Info("created cloud auto category library", - zap.String("root_library_id", rootLib.ID), - zap.String("library_id", lib.ID), - zap.String("provider", provider), - zap.String("display_dir", displayDir)) - } - if len(lib.Roots) > 0 { - return cloudAutoCategoryTarget{Library: lib, RootID: lib.Roots[0].ID}, nil - } - return cloudAutoCategoryTarget{Library: lib}, nil -} - -func (s *ScannerService) findCloudAutoCategoryTarget(ctx context.Context, rootLibraryID, provider, displayDir, name, kind string) (*model.Library, *model.Library) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - if s.log != nil { - s.log.Warn("list libraries for cloud auto category failed", zap.Error(err)) - } - return nil, nil - } - displayDir = normalizeCloudMountDir(provider, displayDir) - targetKey, _ := CloudLibraryMergeKey(model.Library{Name: name, Type: kind}) - var target *model.Library - var existingAuto *model.Library - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if ok && info.Provider == provider && normalizeCloudMountDir(provider, info.DisplayDir) == displayDir && CloudLibraryAutoCategory(lib) { - copy := lib - existingAuto = © - continue - } - if lib.ID == rootLibraryID || CloudLibraryAutoCategory(lib) || !lib.Enabled || targetKey == "" { - continue - } - key, ok := CloudLibraryMergeKey(lib) - if target == nil && ok && key == targetKey { - copy := lib - target = © - } - } - return target, existingAuto -} - -func (s *ScannerService) ensureCloudLibraryRoot(ctx context.Context, libraryID, name, pathValue string) (*model.LibraryRoot, error) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, nil - } - roots, err := s.repo.Library.ListRoots(ctx, libraryID) - if err != nil { - return nil, err - } - targetKey := libraryRootPathKey(pathValue) - for i := range roots { - if libraryRootPathKey(roots[i].Path) == targetKey { - if strings.TrimSpace(roots[i].Name) == "" && strings.TrimSpace(name) != "" { - _ = s.repo.Library.UpdateRoot(ctx, &roots[i], map[string]any{"name": strings.TrimSpace(name)}) - roots[i].Name = strings.TrimSpace(name) - } - return &roots[i], nil - } - } - root := &model.LibraryRoot{ - LibraryID: libraryID, - Name: strings.TrimSpace(name), - Path: pathValue, - Enabled: true, - SortOrder: len(roots), - } - if err := s.repo.Library.CreateRoot(ctx, root); err != nil { - return nil, err - } - return root, nil -} - -func (s *ScannerService) migrateCloudAutoCategoryLibrary(ctx context.Context, source, target *model.Library, root *model.LibraryRoot) { - if s == nil || s.repo == nil || s.repo.DB == nil || source == nil || target == nil || source.ID == "" || target.ID == "" { - return - } - updates := map[string]any{"library_id": target.ID} - if rootID := libraryRootID(root); rootID != "" { - updates["library_root_id"] = rootID - } - if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", source.ID).Updates(updates).Error; err != nil { - if s.log != nil { - s.log.Warn("migrate cloud auto category media failed", - zap.String("from_library_id", source.ID), - zap.String("to_library_id", target.ID), - zap.Error(err)) - } - return - } - _ = hardDeleteLibraryRoots(ctx, s.repo.DB, source.ID) - if err := s.repo.Library.Delete(ctx, source.ID); err != nil && s.log != nil { - s.log.Warn("remove migrated cloud auto category library failed", - zap.String("library_id", source.ID), - zap.Error(err)) - } -} - -func (s *ScannerService) cloudScanLibraryScopeIDs(ctx context.Context, lib *model.Library, mount CloudMountInfo) []string { - if lib == nil { - return nil - } - ids := []string{lib.ID} - if !cloudRootMountNeedsAutoCategory(mount) || s == nil || s.repo == nil || s.repo.Library == nil { - return ids - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - if s.log != nil { - s.log.Warn("list libraries for cloud scan scope failed", zap.String("library_id", lib.ID), zap.Error(err)) - } - return ids - } - for _, candidate := range libs { - if candidate.ID == lib.ID || !CloudLibraryAutoCategory(candidate) { - continue - } - info, ok := ParseCloudLibraryMount(candidate.Path) - if ok && info.Provider == mount.Provider { - ids = appendUniqueLibraryIDs(ids, candidate.ID) - } - } - return ids -} diff --git a/internal/service/cloud_library_display_stubs.go b/internal/service/cloud_library_display_stubs.go new file mode 100644 index 0000000..80222d0 --- /dev/null +++ b/internal/service/cloud_library_display_stubs.go @@ -0,0 +1,248 @@ +package service + +import ( + "context" + "net/url" + "path" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +// CloudMountInfo 是云盘挂载库的规范标识(因网盘后端已移除,仅保留类型以兼容 +// 既有调用点;实际不会有 cloud:// 路径)。 +type CloudMountInfo struct { + Provider string + DisplayDir string + ScanDir string + Path string +} + +// ParseCloudLibraryMount 原用于解析 cloud:// 挂载库路径。网盘后端已移除,恒 +// 返回 (空, false)。 +func ParseCloudLibraryMount(_ string) (CloudMountInfo, bool) { + return CloudMountInfo{}, false +} + +func cloudMountAncestor(_, _ string) bool { + return false +} + +func cloudRootMountNeedsAutoCategory(_ CloudMountInfo) bool { + return false +} + +func appendUniqueLibraryIDs(ids []string, values ...string) []string { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + exists := false + for _, id := range ids { + if id == value { + exists = true + break + } + } + if !exists { + ids = append(ids, value) + } + } + return ids +} + +func compactLibraryIDs(ids ...string) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = appendUniqueLibraryIDs(out, id) + } + return out +} + +// 云盘库展示/合并辅助函数。 +// +// 网盘后端(存储配置/云盘扫描/云播放)已随「存储配置」功能整体移除,因此 +// 库表里不再存在 cloud:// 挂载库。以下函数保留为空实现以保持媒体浏览、 +// 检索与 Emby 兼容层对原有调用点的兼容;在没有云盘库的前提下它们的 +// 语义等价于「不合并、不过滤、不开自动分类」。 + +// FilterDisplayCloudLibraries 原用于筛掉云盘库(展示时不单独列出)。现已无 +// 云盘库,原样返回。 +func FilterDisplayCloudLibraries(_ context.Context, _ *repository.Container, libs []model.Library) []model.Library { + return libs +} + +// MergedLibraryIDsForLibrary 原用于把合并展示的云盘库 ID 集合展开。现已无云盘 +// 库,仅返回目标库自身 ID。 +func MergedLibraryIDsForLibrary(_ context.Context, _ *repository.Container, libraryID string) ([]string, error) { + return []string{libraryID}, nil +} + +// ExpandMediaVisibilityForMergedCloudLibraries 原用于把用户可见范围展开到合并 +// 的云盘库。现已无云盘库,原样返回。 +func ExpandMediaVisibilityForMergedCloudLibraries(_ context.Context, _ *repository.Container, visibility MediaVisibility) MediaVisibility { + return visibility +} + +// CloudLibraryAutoCategory 原用于识别「自动分类」云盘库。现已无云盘库,恒为 +// false。 +func CloudLibraryAutoCategory(_ model.Library) bool { + return false +} + +// CloudLibraryMergeKey 原用于计算两个云盘库的合并键。现已无云盘库,返回 +// (空, false)。 +func CloudLibraryMergeKey(_ model.Library) (string, bool) { + return "", false +} + +// ShadowedCloudLibraryIDSet 原用于返回被合并/遮蔽的云盘库 ID 集合。现已无云盘 +// 库,返回空集合。 +func ShadowedCloudLibraryIDSet(_ []model.Library) map[string]bool { + return map[string]bool{} +} + +// NormalizeCloudLibraryDisplay 原用于归一化云盘库的展示名/类型。现已无云盘库, +// 原样返回。 +func NormalizeCloudLibraryDisplay(libs []model.Library) []model.Library { + return libs +} + +// normalizeRemotePath 归一化远程(云盘/STRM 目标)路径。属通用路径处理辅助, +// 保留供 STRM 目标路径使用。 +func normalizeRemotePath(p string) string { + p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") + if p == "" || p == "." { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return path.Clean(p) +} + +// scanHasImportChanges 报告一次扫描是否产生了入库/变更。属通用扫描辅助。 +func scanHasImportChanges(res *ScanResult) bool { + return res != nil && (res.Added > 0 || res.Updated > 0 || res.Removed > 0) +} + +// cloneLocalMetadata 深拷贝 LocalMetadata 值(值类型浅拷贝即可)。属通用辅助。 +func cloneLocalMetadata(src *LocalMetadata) *LocalMetadata { + if src == nil { + return nil + } + cp := *src + return &cp +} + +// joinRemotePath 拼接远程路径片段(供 STRM 目标路径使用)。属通用路径辅助。 +func joinRemotePath(base, rel string) string { + parts := []string{normalizeRemotePath(base)} + for _, part := range strings.Split(strings.ReplaceAll(rel, "\\", "/"), "/") { + part = strings.TrimSpace(part) + if part != "" && part != "." { + parts = append(parts, part) + } + } + return path.Clean(path.Join(parts...)) +} +// 通用库路径辅助(原云盘库/STRM 生成逻辑使用;网盘后端移除后保留为纯工具函数, +// 供库路径构建与既有测试作为稳定夹具使用)。 + +const LegacyQuarkProvider = "quark" + +func BuildCloudLibraryPath(provider, scanDir, displayDir string) string { + provider = strings.TrimSpace(provider) + scanDir = normalizeCloudMountDir(provider, scanDir) + displayDir = normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)) + if provider == "" { + return "" + } + base := "cloud://" + provider + if displayDir == "" { + if scanDir != "" { + return base + "?dir=" + url.QueryEscape(scanDir) + } + return base + } + pathStr := base + "/" + url.PathEscape(displayDir) + if scanDir != "" && scanDir != displayDir { + pathStr += "?dir=" + url.QueryEscape(scanDir) + } + return pathStr +} + +func BuildCloudAutoCategoryLibraryPath(provider, displayDir string) string { + return BuildCloudAutoCategoryLibraryPathWithScanDir(provider, "", displayDir) +} + +func BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir string) string { + base := BuildCloudLibraryPath(provider, scanDir, displayDir) + if base == "" || strings.TrimSpace(displayDir) == "" { + return "" + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + "auto_category=1" +} + +func normalizeCloudMountDir(provider, value string) string { + value = strings.TrimSpace(value) + if decoded, err := url.PathUnescape(value); err == nil { + value = decoded + } + if decoded, err := url.QueryUnescape(value); err == nil { + value = decoded + } + value = strings.ReplaceAll(value, "\\", "/") + value = strings.Trim(strings.TrimSpace(value), "/") + if value == "." || ((provider == "115" || provider == LegacyQuarkProvider) && value == "0") { + return "" + } + return value +} + +func cloudMountDirBase(dir string) string { + dir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(dir, "\\", "/")), "/") + if dir == "" { + return "" + } + parts := strings.Split(dir, "/") + for i := len(parts) - 1; i >= 0; i-- { + if part := strings.TrimSpace(parts[i]); part != "" { + return part + } + } + return "" +} + +func CloudMountProviderLabel(provider string) string { + switch strings.TrimSpace(provider) { + case LegacyQuarkProvider: + return "已停用网盘" + case "115": + return "115 网盘" + case "clouddrive2": + return "CloudDrive2" + case "openlist": + return "OpenList" + default: + if strings.TrimSpace(provider) == "" { + return "网盘" + } + return strings.TrimSpace(provider) + } +} + +func CloudArtworkURL(typ, ref string) string { + typ = strings.Trim(strings.ReplaceAll(strings.TrimSpace(typ), "\\", "/"), "/") + ref = strings.TrimSpace(ref) + if typ == "" || ref == "" { + return "" + } + return "/api/img/cloud/" + url.PathEscape(typ) + "?ref=" + url.QueryEscape(ref) +} diff --git a/internal/service/cloud_metadata.go b/internal/service/cloud_metadata.go deleted file mode 100644 index 589e7ca..0000000 --- a/internal/service/cloud_metadata.go +++ /dev/null @@ -1,168 +0,0 @@ -package service - -import ( - "context" - "encoding/xml" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudSidecarSet struct { - nfoByName map[string]string - nfoByBase map[string]string - jsonByName map[string]string - jsonByBase map[string]string - imageByName map[string]string - imageByBase map[string]string -} - -func newCloudSidecarSet(typ string, entries []cloud.FileEntry) cloudSidecarSet { - set := cloudSidecarSet{ - nfoByName: make(map[string]string), - nfoByBase: make(map[string]string), - jsonByName: make(map[string]string), - jsonByBase: make(map[string]string), - imageByName: make(map[string]string), - imageByBase: make(map[string]string), - } - for _, entry := range entries { - if entry.IsDir { - continue - } - ref := cloudEntryRef(typ, entry.ID, entry.PickCode) - if ref == "" { - continue - } - name := strings.TrimSpace(entry.Name) - ext := strings.ToLower(filepath.Ext(name)) - base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(name, ext))) - if name == "" || base == "" { - continue - } - switch ext { - case ".nfo": - set.nfoByName[strings.ToLower(name)] = ref - set.nfoByBase[base] = ref - case ".json": - set.jsonByName[strings.ToLower(name)] = ref - set.jsonByBase[base] = ref - case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn": - set.imageByName[strings.ToLower(name)] = ref - set.imageByBase[base] = ref - } - } - return set -} - -func (s *ScannerService) cloudDirectoryMetadata(ctx context.Context, typ, displayDir string, sidecars cloudSidecarSet, inherited *LocalMetadata) *LocalMetadata { - meta := cloneLocalMetadata(inherited) - if hinted, _ := pathHintMetadata(displayDir, true); hinted != nil { - meta = mergeCloudMetadata(meta, hinted) - } - for _, name := range cloudShowNFOCandidates(displayDir) { - ref := sidecars.nfoByName[strings.ToLower(name)] - if ref == "" { - ref = sidecars.nfoByBase[strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))] - } - if ref == "" { - continue - } - if local, doc, err := s.readCloudNFO(ctx, typ, ref, true); err == nil && local != nil { - local = applyCloudNFOArtwork(typ, sidecars, local, doc) - meta = mergeCloudMetadata(meta, local) - break - } - } - for _, name := range cloudDirectoryJSONCandidates(displayDir) { - ref := cloudJSONRefByName(sidecars, name) - if ref == "" { - continue - } - if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil { - meta = mergeCloudMetadata(meta, local) - break - } - } - meta = applyCloudDirectoryArtwork(typ, displayDir, sidecars, meta) - if !cloudMetadataUseful(meta) { - return nil - } - return meta -} - -func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath, fileName string, sidecars cloudSidecarSet, inherited *LocalMetadata, seriesLike bool) *LocalMetadata { - season, episode := ParseEpisode(displayPath) - seriesLike = seriesLike || season > 0 || episode > 0 - meta := cloneLocalMetadata(inherited) - if hinted, _ := pathHintMetadata(displayPath, seriesLike); hinted != nil { - meta = mergeCloudPathHintMetadata(meta, hinted) - } - base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) - if ref := sidecars.nfoByBase[base]; ref != "" { - if local, doc, err := s.readCloudNFO(ctx, typ, ref, seriesLike); err == nil && local != nil { - local = applyCloudNFOArtwork(typ, sidecars, local, doc) - if seriesLike && doc != nil { - if meta == nil { - meta = &LocalMetadata{} - } - mergeEpisodeMetadata(meta, local, doc) - meta.HasNFO = true - } else { - meta = mergeCloudMetadata(meta, local) - } - } - } - for _, name := range cloudFileJSONCandidates(fileName, base) { - ref := cloudJSONRefByName(sidecars, name) - if ref == "" { - continue - } - if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil { - if cloudFileJSONIsEpisodeMetadata(seriesLike, season, episode, local) { - meta = mergeCloudEpisodeMetadata(meta, local) - } else { - meta = mergeCloudMetadata(meta, local) - } - break - } - } - meta = applyCloudFileArtwork(typ, sidecars, displayPath, fileName, base, meta) - if !cloudMetadataUseful(meta) { - return nil - } - return meta -} - -func (s *ScannerService) readCloudJSONMetadata(ctx context.Context, typ, ref string, sidecars cloudSidecarSet) (*LocalMetadata, error) { - if s.storage == nil { - return nil, nil - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 512<<10) - if err != nil { - return nil, err - } - meta, artwork := metadataFromCloudJSON([]byte(body)) - if meta == nil { - return nil, nil - } - meta = applyCloudJSONArtwork(typ, sidecars, meta, artwork) - return meta, nil -} - -func (s *ScannerService) readCloudNFO(ctx context.Context, typ, ref string, seriesLike bool) (*LocalMetadata, *nfoDocument, error) { - if s.storage == nil { - return nil, nil, nil - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 512<<10) - if err != nil { - return nil, nil, err - } - var doc nfoDocument - if err := xml.Unmarshal([]byte(body), &doc); err != nil { - return nil, nil, err - } - meta := metadataFromDoc(&doc, "", seriesLike) - return meta, &doc, nil -} diff --git a/internal/service/cloud_metadata_artwork.go b/internal/service/cloud_metadata_artwork.go deleted file mode 100644 index 65201da..0000000 --- a/internal/service/cloud_metadata_artwork.go +++ /dev/null @@ -1,119 +0,0 @@ -package service - -import ( - "net/url" - "path" - "strings" -) - -func applyCloudNFOArtwork(typ string, sidecars cloudSidecarSet, meta *LocalMetadata, doc *nfoDocument) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - if doc == nil { - return meta - } - if ref := cloudImageRefFromNFOValues(sidecars, nfoPosterValues(doc)...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - if ref := cloudImageRefFromNFOValues(sidecars, nfoBackdropValues(doc)...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - return meta -} - -func applyCloudDirectoryArtwork(typ, displayDir string, sidecars cloudSidecarSet, meta *LocalMetadata) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - if meta.PosterURL == "" { - if ref := firstCloudImageRef(sidecars, cloudPosterNameCandidates(cloudDirectoryArtworkBases(displayDir), "poster", "folder", "cover", "show", "tvshow")...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - if meta.BackdropURL == "" { - if ref := firstCloudImageRef(sidecars, cloudBackdropNameCandidates(cloudDirectoryArtworkBases(displayDir), "fanart", "backdrop", "background", "landscape")...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - return meta -} - -func applyCloudFileArtwork(typ string, sidecars cloudSidecarSet, displayPath, fileName, base string, meta *LocalMetadata) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - bases := cloudFileArtworkBases(displayPath, fileName, base) - if meta.PosterURL == "" { - if ref := firstCloudImageRef(sidecars, cloudPosterNameCandidates(bases, "poster", "folder", "cover", "movie", "show", "thumb")...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - if meta.BackdropURL == "" { - if ref := firstCloudImageRef(sidecars, cloudBackdropNameCandidates(bases, "fanart", "backdrop", "background", "landscape")...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - return meta -} - -func firstCloudImageRef(sidecars cloudSidecarSet, names ...string) string { - for _, name := range names { - if ref := cloudImageRefByName(sidecars, name); ref != "" { - return ref - } - } - return "" -} - -func cloudImageRefFromNFOValues(sidecars cloudSidecarSet, values ...string) string { - for _, value := range values { - if ref := cloudImageRefByName(sidecars, value); ref != "" { - return ref - } - } - return "" -} - -func cloudImageRefByName(sidecars cloudSidecarSet, value string) string { - name := normalizeCloudArtworkName(value) - if name == "" || isHTTPURL(name) { - return "" - } - if ref := sidecars.imageByName[strings.ToLower(name)]; ref != "" { - return ref - } - base := strings.TrimSuffix(name, path.Ext(name)) - if ref := sidecars.imageByBase[strings.ToLower(base)]; ref != "" { - return ref - } - return "" -} - -func normalizeCloudArtworkName(value string) string { - value = cleanXMLText(value) - if value == "" { - return "" - } - if isHTTPURL(value) { - return value - } - if unescaped, err := url.QueryUnescape(value); err == nil { - value = unescaped - } - value = strings.ReplaceAll(value, "\\", "/") - if idx := strings.IndexAny(value, "?#"); idx >= 0 { - value = value[:idx] - } - value = strings.Trim(strings.TrimSpace(value), "/") - if value == "" { - return "" - } - return path.Base(value) -} diff --git a/internal/service/cloud_metadata_candidates.go b/internal/service/cloud_metadata_candidates.go deleted file mode 100644 index 10d125f..0000000 --- a/internal/service/cloud_metadata_candidates.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "path/filepath" - "strconv" - "strings" -) - -func cloudShowNFOCandidates(displayDir string) []string { - names := []string{"tvshow.nfo", "series.nfo", "show.nfo", "movie.nfo"} - base := strings.TrimSpace(pathBaseSlash(displayDir)) - if base != "" { - names = append(names, base+".nfo") - } - return names -} - -func cloudDirectoryJSONCandidates(displayDir string) []string { - names := []string{"movie.json", "metadata.json", "tvshow.json", "series.json", "show.json"} - base := strings.TrimSpace(pathBaseSlash(displayDir)) - if base != "" { - names = append(names, base+".json", base+"-metadata.json", base+".metadata.json", base+"-mediainfo.json", base+".mediainfo.json") - } - return names -} - -func cloudFileJSONCandidates(fileName, base string) []string { - if base == "" { - base = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) - } - cleanBases := cloudCleanArtworkBases(fileName) - bases := uniqueCloudArtworkNames(append([]string{base}, cleanBases...)...) - out := make([]string, 0, len(bases)*5+2) - for _, value := range bases { - out = append(out, value+".json", value+"-metadata.json", value+".metadata.json", value+"-mediainfo.json", value+".mediainfo.json") - } - return append(out, "movie.json", "metadata.json") -} - -func cloudJSONRefByName(sidecars cloudSidecarSet, name string) string { - name = normalizeCloudArtworkName(name) - if name == "" || isHTTPURL(name) { - return "" - } - if ref := sidecars.jsonByName[strings.ToLower(name)]; ref != "" { - return ref - } - base := strings.TrimSuffix(name, filepath.Ext(name)) - return sidecars.jsonByBase[strings.ToLower(base)] -} - -func cloudFileArtworkBases(displayPath, fileName, base string) []string { - return uniqueCloudArtworkNames(append( - []string{base}, - append(cloudCleanArtworkBases(fileName), cloudDirectoryArtworkBases(pathDirSlash(displayPath))...)..., - )...) -} - -func cloudDirectoryArtworkBases(displayDir string) []string { - base := pathBaseSlash(displayDir) - return uniqueCloudArtworkNames(append([]string{base}, cloudCleanArtworkBases(base)...)...) -} - -func cloudCleanArtworkBases(value string) []string { - title, year := CleanQuery(value) - title = strings.TrimSpace(title) - if title == "" { - return nil - } - out := []string{title} - if year > 0 { - yearText := strconv.Itoa(year) - out = append(out, - title+" ("+yearText+")", - title+"."+yearText, - title+" "+yearText, - ) - } - return out -} - -func cloudPosterNameCandidates(bases []string, fallback ...string) []string { - out := make([]string, 0, len(bases)*7+len(fallback)) - for _, base := range bases { - base = strings.TrimSpace(base) - if base == "" { - continue - } - out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb") - } - return append(out, fallback...) -} - -func cloudBackdropNameCandidates(bases []string, fallback ...string) []string { - out := make([]string, 0, len(bases)*6+len(fallback)) - for _, base := range bases { - base = strings.TrimSpace(base) - if base == "" { - continue - } - out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+".background") - } - return append(out, fallback...) -} - -func uniqueCloudArtworkNames(values ...string) []string { - out := make([]string, 0, len(values)) - seen := map[string]struct{}{} - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - key := strings.ToLower(value) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, value) - } - return out -} diff --git a/internal/service/cloud_metadata_episode.go b/internal/service/cloud_metadata_episode.go deleted file mode 100644 index 8627577..0000000 --- a/internal/service/cloud_metadata_episode.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import "strings" - -func cloudFileJSONIsEpisodeMetadata(seriesLike bool, parsedSeason, parsedEpisode int, meta *LocalMetadata) bool { - if !seriesLike || meta == nil { - return false - } - return parsedSeason > 0 || - parsedEpisode > 0 || - meta.SeasonNum > 0 || - meta.EpisodeNum > 0 || - strings.TrimSpace(meta.EpisodeTitle) != "" -} - -func mergeCloudEpisodeMetadata(dst, episode *LocalMetadata) *LocalMetadata { - if episode == nil { - return dst - } - if dst == nil { - dst = &LocalMetadata{} - } - mergeCloudEpisodeIdentity(dst, episode) - mergeCloudEpisodeDisplay(dst, episode) - mergeCloudEpisodeNumbersAndTaxonomy(dst, episode) - dst.NSFW = dst.NSFW || episode.NSFW - dst.HasNFO = dst.HasNFO || episode.HasNFO - dst.HasArtwork = dst.HasArtwork || episode.HasArtwork - return dst -} - -func mergeCloudEpisodeIdentity(dst, episode *LocalMetadata) { - showTitle := "" - if episode.EpisodeTitle != "" && episode.Title != "" && !strings.EqualFold(episode.Title, episode.EpisodeTitle) { - showTitle = episode.Title - } - if showTitle != "" { - dst.Title = showTitle - } - episodeTitle := strings.TrimSpace(episode.EpisodeTitle) - if episodeTitle == "" && (episode.SeasonNum > 0 || episode.EpisodeNum > 0) { - episodeTitle = strings.TrimSpace(episode.Title) - } - if episodeTitle != "" && !strings.EqualFold(episodeTitle, strings.TrimSpace(dst.Title)) { - dst.EpisodeTitle = episodeTitle - } -} - -func mergeCloudEpisodeDisplay(dst, episode *LocalMetadata) { - if dst.Year == 0 && episode.Year > 0 { - dst.Year = episode.Year - } - if episode.Overview != "" { - dst.Overview = episode.Overview - } - if episode.Rating > 0 { - dst.Rating = episode.Rating - } - if episode.PosterURL != "" { - dst.PosterURL = episode.PosterURL - } - if episode.BackdropURL != "" { - dst.BackdropURL = episode.BackdropURL - } -} - -func mergeCloudEpisodeNumbersAndTaxonomy(dst, episode *LocalMetadata) { - if episode.SeasonNum > 0 { - dst.SeasonNum = episode.SeasonNum - } - if episode.EpisodeNum > 0 { - dst.EpisodeNum = episode.EpisodeNum - } - if dst.Genres == "" && episode.Genres != "" { - dst.Genres = episode.Genres - } - if dst.Countries == "" && episode.Countries != "" { - dst.Countries = episode.Countries - } - if dst.Languages == "" && episode.Languages != "" { - dst.Languages = episode.Languages - } -} diff --git a/internal/service/cloud_metadata_json.go b/internal/service/cloud_metadata_json.go deleted file mode 100644 index 26907a0..0000000 --- a/internal/service/cloud_metadata_json.go +++ /dev/null @@ -1,265 +0,0 @@ -package service - -import ( - "encoding/json" - "strconv" - "strings" -) - -type cloudJSONArtwork struct { - posterValues []string - backdropValues []string -} - -func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) { - var raw any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, cloudJSONArtwork{} - } - obj := firstMetadataJSONObject(raw) - if len(obj) == 0 { - return nil, cloudJSONArtwork{} - } - meta := &LocalMetadata{ - Title: firstJSONString(obj, "title", "name", "showtitle", "show_title"), - OriginalName: firstJSONString(obj, "original_title", "originaltitle", "original_name", "originalname", "sorttitle"), - EpisodeTitle: firstJSONString(obj, "episode_title", "episodetitle", "episode_name", "episodename"), - Year: firstJSONInt(obj, "year"), - ReleaseDate: normalizeReleaseDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date")), - Overview: firstJSONString(obj, "overview", "plot", "outline", "summary", "description"), - Rating: firstJSONFloat(obj, "rating", "vote_average", "score"), - TMDbID: firstJSONInt(obj, "tmdb_id", "tmdbid", "tmdb"), - BangumiID: firstJSONInt(obj, "bangumi_id", "bangumiid", "bgm_id"), - DoubanID: firstJSONString(obj, "douban_id", "doubanid"), - TheTVDBID: firstJSONString(obj, "thetvdb_id", "tvdb_id", "thetvdbid", "tvdbid"), - SeasonNum: firstJSONInt(obj, "season", "season_num", "season_number"), - EpisodeNum: firstJSONInt(obj, "episode", "episode_num", "episode_number"), - Genres: firstJSONList(obj, "genres", "genre", "tags"), - Countries: firstJSONList(obj, "countries", "country", "production_countries"), - Languages: firstJSONList(obj, "languages", "language", "spoken_languages"), - } - if showTitle := firstJSONString(obj, "showtitle", "show_title", "series_title", "series_name"); showTitle != "" { - if meta.EpisodeTitle == "" && meta.Title != "" && !strings.EqualFold(strings.TrimSpace(meta.Title), strings.TrimSpace(showTitle)) { - meta.EpisodeTitle = meta.Title - } - meta.Title = showTitle - } - if meta.Year == 0 { - meta.Year = yearFromDate(meta.ReleaseDate) - } - artwork := cloudJSONArtwork{ - posterValues: firstJSONStrings(obj, - "poster_url", "poster", "poster_path", "cover", "cover_url", "thumb", "thumbnail", "image"), - backdropValues: firstJSONStrings(obj, - "backdrop_url", "backdrop", "backdrop_path", "fanart", "fanart_url", "background", "landscape"), - } - if images, ok := jsonObject(obj["images"]); ok { - artwork.posterValues = append(artwork.posterValues, firstJSONStrings(images, "poster", "large", "common", "medium", "small", "cover")...) - artwork.backdropValues = append(artwork.backdropValues, firstJSONStrings(images, "backdrop", "fanart", "background", "landscape")...) - } - if art, ok := jsonObject(obj["art"]); ok { - artwork.posterValues = append(artwork.posterValues, firstJSONStrings(art, "poster", "thumb", "cover")...) - artwork.backdropValues = append(artwork.backdropValues, firstJSONStrings(art, "fanart", "backdrop", "background", "landscape")...) - } - if len(artwork.posterValues) > 0 { - meta.PosterURL = firstHTTPJSONValue(artwork.posterValues) - } - if len(artwork.backdropValues) > 0 { - meta.BackdropURL = firstHTTPJSONValue(artwork.backdropValues) - } - if meta.PosterURL != "" || meta.BackdropURL != "" { - meta.HasArtwork = true - } - if localHasDescriptiveMetadata(meta) || meta.HasArtwork || len(artwork.posterValues) > 0 || len(artwork.backdropValues) > 0 { - meta.HasNFO = true - return meta, artwork - } - return nil, cloudJSONArtwork{} -} - -func applyCloudJSONArtwork(typ string, sidecars cloudSidecarSet, meta *LocalMetadata, artwork cloudJSONArtwork) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - if meta.PosterURL == "" { - if ref := cloudImageRefFromNFOValues(sidecars, artwork.posterValues...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - if meta.BackdropURL == "" { - if ref := cloudImageRefFromNFOValues(sidecars, artwork.backdropValues...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - return meta -} - -func firstMetadataJSONObject(raw any) map[string]any { - obj, ok := jsonObject(raw) - if !ok { - return nil - } - for _, key := range []string{"movie", "media", "metadata", "item", "data"} { - if nested, ok := jsonObject(obj[key]); ok && jsonObjectLooksLikeMetadata(nested) { - return nested - } - } - return obj -} - -func jsonObjectLooksLikeMetadata(obj map[string]any) bool { - for _, key := range []string{"title", "name", "overview", "plot", "tmdb_id", "tmdbid", "poster", "poster_url", "poster_path", "backdrop", "backdrop_path"} { - if _, ok := obj[key]; ok { - return true - } - } - return false -} - -func jsonObject(raw any) (map[string]any, bool) { - obj, ok := raw.(map[string]any) - return obj, ok -} - -func firstJSONString(obj map[string]any, keys ...string) string { - values := firstJSONStrings(obj, keys...) - if len(values) == 0 { - return "" - } - return values[0] -} - -func firstJSONStrings(obj map[string]any, keys ...string) []string { - out := []string{} - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - out = append(out, jsonStrings(value)...) - if len(out) > 0 { - return out - } - } - return out -} - -func firstJSONInt(obj map[string]any, keys ...string) int { - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - if i := jsonInt(value); i > 0 { - return i - } - } - return 0 -} - -func firstJSONFloat(obj map[string]any, keys ...string) float32 { - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - if f := jsonFloat(value); f > 0 { - return f - } - } - return 0 -} - -func firstJSONList(obj map[string]any, keys ...string) string { - seen := map[string]struct{}{} - out := []string{} - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - for _, part := range jsonStrings(value) { - for _, item := range strings.Split(part, ",") { - item = strings.TrimSpace(item) - if item == "" { - continue - } - dedupeKey := strings.ToLower(item) - if _, exists := seen[dedupeKey]; exists { - continue - } - seen[dedupeKey] = struct{}{} - out = append(out, item) - } - } - if len(out) > 0 { - return strings.Join(out, ",") - } - } - return "" -} - -func lookupJSONKey(obj map[string]any, key string) (any, bool) { - for existing, value := range obj { - if strings.EqualFold(strings.TrimSpace(existing), key) { - return value, true - } - } - return nil, false -} - -func jsonStrings(value any) []string { - switch v := value.(type) { - case string: - if text := strings.TrimSpace(v); text != "" { - return []string{text} - } - case []any: - out := make([]string, 0, len(v)) - for _, item := range v { - out = append(out, jsonStrings(item)...) - } - return out - case map[string]any: - return firstJSONStrings(v, "name", "title", "value", "iso_3166_1", "iso_639_1") - case float64: - if v > 0 { - return []string{strconv.Itoa(int(v))} - } - } - return nil -} - -func jsonInt(value any) int { - switch v := value.(type) { - case float64: - return int(v) - case string: - i, _ := strconv.Atoi(strings.TrimSpace(v)) - return i - } - return 0 -} - -func jsonFloat(value any) float32 { - switch v := value.(type) { - case float64: - return float32(v) - case string: - f, _ := strconv.ParseFloat(strings.TrimSpace(v), 32) - return float32(f) - } - return 0 -} - -func firstHTTPJSONValue(values []string) string { - for _, value := range values { - value = strings.TrimSpace(value) - if isHTTPURL(value) { - return value - } - } - return "" -} diff --git a/internal/service/cloud_metadata_merge.go b/internal/service/cloud_metadata_merge.go deleted file mode 100644 index e3b06b9..0000000 --- a/internal/service/cloud_metadata_merge.go +++ /dev/null @@ -1,159 +0,0 @@ -package service - -import "strings" - -func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata { - if src == nil { - return dst - } - if dst == nil { - return cloneLocalMetadata(src) - } - if src.Title != "" { - dst.Title = src.Title - } - if src.OriginalName != "" { - dst.OriginalName = src.OriginalName - } - if src.EpisodeTitle != "" { - dst.EpisodeTitle = src.EpisodeTitle - } - if src.AdultCode != "" { - dst.AdultCode = src.AdultCode - } - if src.Year > 0 { - dst.Year = src.Year - } - if src.ReleaseDate != "" { - dst.ReleaseDate = src.ReleaseDate - } - if src.Overview != "" { - dst.Overview = src.Overview - } - if src.Rating > 0 { - dst.Rating = src.Rating - } - if src.PosterURL != "" { - dst.PosterURL = src.PosterURL - } - if src.BackdropURL != "" { - dst.BackdropURL = src.BackdropURL - } - if src.TMDbID > 0 { - dst.TMDbID = src.TMDbID - } - if src.BangumiID > 0 { - dst.BangumiID = src.BangumiID - } - if src.DoubanID != "" { - dst.DoubanID = src.DoubanID - } - if src.TheTVDBID != "" { - dst.TheTVDBID = src.TheTVDBID - } - if src.SeasonNum > 0 || src.EpisodeNum > 0 { - dst.SeasonNum = src.SeasonNum - } - if src.EpisodeNum > 0 { - dst.EpisodeNum = src.EpisodeNum - } - if src.Genres != "" { - dst.Genres = src.Genres - } - if src.Countries != "" { - dst.Countries = src.Countries - } - if src.Languages != "" { - dst.Languages = src.Languages - } - dst.NSFW = dst.NSFW || src.NSFW - dst.HasNFO = dst.HasNFO || src.HasNFO - dst.HasArtwork = dst.HasArtwork || src.HasArtwork - dst.PathHint = dst.PathHint || src.PathHint - return dst -} - -func mergeCloudPathHintMetadata(dst, hint *LocalMetadata) *LocalMetadata { - if hint == nil { - return dst - } - if dst == nil || !dst.HasNFO { - return mergeCloudMetadata(dst, hint) - } - if dst.Title == "" { - dst.Title = hint.Title - } - if dst.OriginalName == "" { - dst.OriginalName = hint.OriginalName - } - if dst.Year == 0 { - dst.Year = hint.Year - } - if dst.ReleaseDate == "" { - dst.ReleaseDate = hint.ReleaseDate - } - if dst.TMDbID == 0 { - dst.TMDbID = hint.TMDbID - } - if dst.BangumiID == 0 { - dst.BangumiID = hint.BangumiID - } - if dst.DoubanID == "" { - dst.DoubanID = hint.DoubanID - } - if dst.TheTVDBID == "" { - dst.TheTVDBID = hint.TheTVDBID - } - dst.PathHint = dst.PathHint || hint.PathHint - return dst -} - -func cloneLocalMetadata(src *LocalMetadata) *LocalMetadata { - if src == nil { - return nil - } - cp := *src - return &cp -} - -func cloudMetadataUseful(meta *LocalMetadata) bool { - return meta != nil && (meta.HasNFO || meta.HasArtwork || localHasDescriptiveMetadata(meta)) -} - -func cloudPlaybackURL(typ, ref string) string { - return CloudArtworkURL(typ, ref) -} - -func joinCloudDisplayPath(parent, child string) string { - parent = strings.Trim(strings.ReplaceAll(strings.TrimSpace(parent), "\\", "/"), "/") - child = strings.Trim(strings.ReplaceAll(strings.TrimSpace(child), "\\", "/"), "/") - switch { - case parent == "": - return child - case child == "": - return parent - default: - return parent + "/" + child - } -} - -func pathBaseSlash(value string) string { - value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") - if value == "" { - return "" - } - parts := strings.Split(value, "/") - return parts[len(parts)-1] -} - -func pathDirSlash(value string) string { - value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") - if value == "" { - return "" - } - idx := strings.LastIndex(value, "/") - if idx < 0 { - return "" - } - return value[:idx] -} diff --git a/internal/service/cloud_mount.go b/internal/service/cloud_mount.go deleted file mode 100644 index bca4dc9..0000000 --- a/internal/service/cloud_mount.go +++ /dev/null @@ -1,143 +0,0 @@ -package service - -import ( - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// CloudMountInfo is the canonical identity of a mounted cloud library. ScanDir -// is the provider id/path used for listing. DisplayDir is a hierarchical path -// used to prevent mounting both a parent and its child as separate libraries. -type CloudMountInfo struct { - Provider string - DisplayDir string - ScanDir string - Path string -} - -type CloudMountConflict struct { - Library model.Library `json:"library"` - Exact bool `json:"exact"` - Nested bool `json:"nested"` - ExistingIsAncestor bool `json:"existing_is_ancestor"` -} - -func BuildCloudLibraryPath(provider, scanDir, displayDir string) string { - provider = strings.TrimSpace(provider) - scanDir = normalizeCloudMountDir(provider, scanDir) - displayDir = normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)) - if provider == "" { - return "" - } - base := "cloud://" + provider - if displayDir == "" { - if scanDir != "" { - return base + "?dir=" + url.QueryEscape(scanDir) - } - return base - } - path := base + "/" + url.PathEscape(displayDir) - if scanDir != "" && scanDir != displayDir { - path += "?dir=" + url.QueryEscape(scanDir) - } - return path -} - -func ParseCloudLibraryMount(raw string) (CloudMountInfo, bool) { - raw = strings.TrimSpace(raw) - if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { - return CloudMountInfo{}, false - } - u, err := url.Parse(raw) - if err != nil || strings.ToLower(u.Scheme) != "cloud" { - return CloudMountInfo{}, false - } - provider := strings.TrimSpace(u.Host) - if provider == "" { - return CloudMountInfo{}, false - } - displayDir := strings.Trim(strings.TrimSpace(u.Path), "/") - if decoded, err := url.PathUnescape(displayDir); err == nil { - displayDir = decoded - } - scanDir := displayDir - if qDir := strings.TrimSpace(u.Query().Get("dir")); qDir != "" { - if decoded, err := url.QueryUnescape(qDir); err == nil { - qDir = decoded - } - scanDir = qDir - } - displayDir = normalizeCloudMountDir(provider, displayDir) - scanDir = normalizeCloudMountDir(provider, scanDir) - return CloudMountInfo{ - Provider: provider, - DisplayDir: displayDir, - ScanDir: scanDir, - Path: raw, - }, true -} - -func FindCloudMountConflict(libs []model.Library, provider, scanDir, displayDir string) *CloudMountConflict { - candidate := CloudMountInfo{ - Provider: strings.TrimSpace(provider), - DisplayDir: normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)), - ScanDir: normalizeCloudMountDir(provider, scanDir), - } - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - existing, ok := ParseCloudLibraryMount(lib.Path) - if !ok || existing.Provider != candidate.Provider { - continue - } - if existing.DisplayDir == candidate.DisplayDir { - return &CloudMountConflict{Library: lib, Exact: true} - } - if existing.ScanDir != "" && candidate.ScanDir != "" && existing.ScanDir == candidate.ScanDir { - return &CloudMountConflict{Library: lib, Exact: true} - } - if cloudMountAncestor(candidate.DisplayDir, existing.DisplayDir) { - return &CloudMountConflict{Library: lib, Nested: true} - } - } - return nil -} - -func CloudLibraryShadowed(libs []model.Library, lib model.Library) *CloudMountConflict { - current, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return nil - } - for _, existing := range libs { - if existing.ID == lib.ID || !existing.Enabled { - continue - } - if CloudLibraryAutoCategory(existing) { - continue - } - info, ok := ParseCloudLibraryMount(existing.Path) - if !ok || info.Provider != current.Provider { - continue - } - if info.DisplayDir == current.DisplayDir && existing.CreatedAt.Before(lib.CreatedAt) { - return &CloudMountConflict{Library: existing, Exact: true} - } - if cloudMountAncestor(current.DisplayDir, info.DisplayDir) { - return &CloudMountConflict{Library: existing, Nested: true} - } - } - return nil -} - -func FilterShadowedCloudLibraries(libs []model.Library) []model.Library { - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if CloudLibraryShadowed(libs, lib) == nil { - out = append(out, lib) - } - } - return out -} diff --git a/internal/service/cloud_mount_counts.go b/internal/service/cloud_mount_counts.go deleted file mode 100644 index 7c23268..0000000 --- a/internal/service/cloud_mount_counts.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "context" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func cloudLibraryMediaCounts(ctx context.Context, repo *repository.Container, libs []model.Library) map[string]int64 { - counts := make(map[string]int64, len(libs)) - if repo == nil || repo.DB == nil || len(libs) == 0 { - return counts - } - ids := make([]string, 0, len(libs)) - for _, lib := range libs { - ids = append(ids, lib.ID) - } - if len(ids) == 0 { - return counts - } - var rows []struct { - LibraryID string - Count int64 - } - if err := repo.DB.WithContext(ctx). - Model(&model.Media{}). - Select("library_id, COUNT(*) AS count"). - Where("library_id IN ? AND deleted_at IS NULL", ids). - Group("library_id"). - Scan(&rows).Error; err != nil { - return counts - } - for _, row := range rows { - counts[row.LibraryID] = row.Count - } - return counts -} diff --git a/internal/service/cloud_mount_dedupe.go b/internal/service/cloud_mount_dedupe.go deleted file mode 100644 index 3bc9b96..0000000 --- a/internal/service/cloud_mount_dedupe.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func betterDisplayCloudLibrary(candidate, current model.Library, counts map[string]int64) bool { - candidateCount := counts[candidate.ID] - currentCount := counts[current.ID] - if (candidateCount > 0) != (currentCount > 0) { - return candidateCount > 0 - } - if candidate.Enabled != current.Enabled { - return candidate.Enabled - } - candidateCanonical := cloudLibraryPathIsCanonical(candidate) - currentCanonical := cloudLibraryPathIsCanonical(current) - if candidateCanonical != currentCanonical { - return candidateCanonical - } - if !candidate.CreatedAt.Equal(current.CreatedAt) { - return candidate.CreatedAt.After(current.CreatedAt) - } - return candidate.ID > current.ID -} - -func mergeDisplayCloudLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - localByKey := make(map[string]struct{}, len(libs)) - for _, lib := range libs { - if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { - continue - } - if key, ok := CloudLibraryMergeKey(lib); ok { - localByKey[key] = struct{}{} - } - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - if key, ok := CloudLibraryMergeKey(lib); ok { - if _, exists := localByKey[key]; exists && !CloudLibraryAutoCategory(lib) { - continue - } - } - } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - out = append(out, lib) - } - return out -} - -func dedupeDisplayLibrariesByMergeKey(libs []model.Library, counts map[string]int64) []model.Library { - if len(libs) == 0 { - return libs - } - out := make([]model.Library, 0, len(libs)) - byKey := make(map[string]int, len(libs)) - for _, lib := range libs { - if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - if CloudLibraryAutoCategory(lib) { - out = append(out, lib) - continue - } - key, ok := CloudLibraryMergeKey(lib) - if !ok { - out = append(out, lib) - continue - } - if prev, exists := byKey[key]; exists { - if betterCanonicalDisplayLibrary(lib, out[prev], counts) { - out[prev] = lib - } - continue - } - byKey[key] = len(out) - out = append(out, lib) - } - return out -} - -func betterCanonicalDisplayLibrary(candidate, current model.Library, counts map[string]int64) bool { - candidateScore := canonicalDisplayLibraryScore(candidate) - currentScore := canonicalDisplayLibraryScore(current) - if candidateScore != currentScore { - return candidateScore > currentScore - } - candidateCount := counts[candidate.ID] - currentCount := counts[current.ID] - if (candidateCount > 0) != (currentCount > 0) { - return candidateCount > 0 - } - if candidate.Enabled != current.Enabled { - return candidate.Enabled - } - if !candidate.CreatedAt.Equal(current.CreatedAt) { - return candidate.CreatedAt.After(current.CreatedAt) - } - return candidate.ID > current.ID -} - -func canonicalDisplayLibraryScore(lib model.Library) int { - score := 0 - if canonical := CanonicalLibraryDisplayName(lib); canonical == "" || strings.EqualFold(strings.TrimSpace(lib.Name), canonical) { - score += 4 - } - if canonical := canonicalLibraryCategoryName(lib.Type, pathBaseSlash(lib.Path)); canonical == "" { - score += 2 - } - if _, ok := ParseCloudLibraryMount(lib.Path); !ok { - score++ - } - return score -} diff --git a/internal/service/cloud_mount_display.go b/internal/service/cloud_mount_display.go deleted file mode 100644 index e7fbd6f..0000000 --- a/internal/service/cloud_mount_display.go +++ /dev/null @@ -1,234 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func NormalizeCloudLibraryDisplayNames(libs []model.Library) []model.Library { - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - out = append(out, lib) - } - return out -} - -func NormalizeCloudLibraryDisplay(libs []model.Library) []model.Library { - return normalizeDisplayLibraries(libs) -} - -func normalizeDisplayLibraries(libs []model.Library) []model.Library { - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - if displayType := CanonicalLibraryDisplayType(lib); displayType != "" { - lib.Type = displayType - } - if displayPath := CanonicalLibraryDisplayPath(lib); displayPath != "" { - lib.Path = displayPath - } - out = append(out, lib) - } - return out -} - -func CloudLibraryDisplayName(lib model.Library) (string, bool) { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return "", false - } - name := stripCloudProviderDisplayPrefix(strings.TrimSpace(lib.Name), info.Provider) - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - if name == "" || strings.EqualFold(name, CloudMountProviderLabel(info.Provider)) { - if base := cloudMountDirBase(dir); base != "" { - name = base - } - } - if name == "" { - name = CloudMountProviderLabel(info.Provider) - } - if canonical := canonicalLibraryCategoryName(lib.Type, name); canonical != "" { - name = canonical - } else if canonical := canonicalLibraryCategoryNameAny(name); canonical != "" { - name = canonical - } - return name, true -} - -func CanonicalLibraryDisplayName(lib model.Library) string { - if canonical := canonicalLibraryCategoryName(lib.Type, lib.Name); canonical != "" { - return canonical - } - return canonicalLibraryCategoryNameAny(lib.Name) -} - -func CanonicalLibraryDisplayType(lib model.Library) string { - if displayName, ok := CloudLibraryDisplayName(lib); ok { - if typ := canonicalLibraryCategoryDisplayType(displayName); typ != "" { - return typ - } - } - if typ := canonicalLibraryCategoryDisplayType(lib.Name); typ != "" { - return typ - } - return canonicalLibraryCategoryDisplayType(pathBaseSlash(lib.Path)) -} - -func CanonicalLibraryDisplayPath(lib model.Library) string { - raw := strings.TrimSpace(lib.Path) - if raw == "" { - return "" - } - if info, ok := ParseCloudLibraryMount(raw); ok { - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - displayDir := canonicalLibraryDisplayDir(dir) - if displayDir == "" { - return raw - } - if CloudLibraryAutoCategory(lib) { - return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir) - } - return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir) - } - return canonicalLocalLibraryDisplayPath(raw) -} - -func canonicalLibraryCategoryName(libraryType, name string) string { - typeKey := cloudLibraryMergeTypeKey(libraryType) - name = normalizeLibraryMergeName(name) - switch typeKey { - case "movie": - switch name { - case "国产电影", "大陆电影": - return "华语电影" - case "外语电影", "外国电影": - return "欧美电影" - case "日本电影", "韩国电影": - return "日韩电影" - case "音乐会", "concert": - return "演唱会" - case "纪录": - return "纪录片" - case "动漫电影": - return "动画电影" - } - case "tvshows": - switch name { - case "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧": - return "国产剧" - case "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized": - return "欧美剧" - case "日韩电视剧", "日剧", "韩剧", "泰剧": - return "日韩剧" - case "真人秀": - return "综艺" - case "纪录": - return "纪录片" - case "少儿": - return "儿童" - case "国产动漫", "国产动画": - return "国漫" - case "日漫", "番剧", "日本动漫", "日本动画": - return "日番" - case "韩国动漫", "韩国动画": - return "韩漫" - case "欧美动漫", "欧美动画", "西方动画": - return "美漫" - case "其他动漫", "其它动漫", "other": - return "其他" - } - case "adult": - switch name { - case "9kg", "番号", "jav", "nsfw", "adult": - return "成人" - } - } - return "" -} - -func canonicalLibraryCategoryNameAny(name string) string { - for _, libraryType := range []string{"movie", "tv", "anime", "adult"} { - if canonical := canonicalLibraryCategoryName(libraryType, name); canonical != "" { - return canonical - } - } - return "" -} - -func canonicalLibraryDisplayDir(raw string) string { - parts := strmSlashParts(raw) - if len(parts) == 0 { - return "" - } - return strings.Join(canonicalLibraryDisplayParts(parts), "/") -} - -func canonicalLocalLibraryDisplayPath(raw string) string { - value := strings.TrimSpace(raw) - if value == "" { - return "" - } - sep := "/" - if strings.Contains(value, "\\") { - sep = "\\" - } - slash := strings.ReplaceAll(value, "\\", "/") - prefix := "" - for strings.HasPrefix(slash, "/") { - prefix += "/" - slash = strings.TrimPrefix(slash, "/") - } - parts := strings.Split(slash, "/") - canonical := canonicalLibraryDisplayParts(parts) - if len(canonical) == 0 { - return raw - } - out := prefix + strings.Join(canonical, "/") - if sep == "\\" { - out = strings.ReplaceAll(out, "/", "\\") - } - return out -} - -func canonicalLibraryDisplayParts(parts []string) []string { - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" || part == "." { - continue - } - if canonical := canonicalLibraryCategoryNameAny(part); canonical != "" { - part = canonical - } - if len(out) > 0 && normalizeLibraryMergeName(out[len(out)-1]) == normalizeLibraryMergeName(part) { - continue - } - out = append(out, part) - } - return out -} - -func canonicalLibraryCategoryDisplayType(name string) string { - switch normalizeLibraryMergeName(name) { - case "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "大陆电影", "欧美电影", "外语电影", "外国电影", "日韩电影", "日本电影", "韩国电影": - return "movie" - case "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized", "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧", "综艺", "真人秀", "儿童", "少儿": - return "tv" - case "国漫", "国产动漫", "国产动画", "日番", "日漫", "番剧", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", "other": - return "anime" - case "成人", "9kg", "番号", "jav", "nsfw", "adult": - return "adult" - default: - return "" - } -} diff --git a/internal/service/cloud_mount_filter.go b/internal/service/cloud_mount_filter.go deleted file mode 100644 index dff884a..0000000 --- a/internal/service/cloud_mount_filter.go +++ /dev/null @@ -1,123 +0,0 @@ -package service - -import ( - "context" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - libs = FilterDeprecatedNativeCloudLibraries(libs) - libs = FilterMergedCloudAutoCategoryLibraries(libs) - counts := cloudLibraryMediaCounts(ctx, repo, libs) - collapsed := make([]model.Library, 0, len(libs)) - byKey := make(map[string]int, len(libs)) - for _, lib := range libs { - key, ok := cloudLibraryDisplayKey(lib) - if !ok { - collapsed = append(collapsed, lib) - continue - } - if prevIndex, exists := byKey[key]; exists { - if betterDisplayCloudLibrary(lib, collapsed[prevIndex], counts) { - collapsed[prevIndex] = lib - } - continue - } - byKey[key] = len(collapsed) - collapsed = append(collapsed, lib) - } - collapsed = FilterShadowedCloudLibraries(collapsed) - return normalizeDisplayLibraries(dedupeDisplayLibrariesByMergeKey(mergeDisplayCloudLibraries(collapsed), counts)) -} - -func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - out = append(out, lib) - } - return out -} - -func FilterMergedCloudAutoCategoryLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - nonAutoKeys := make(map[string]struct{}, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - if key, ok := CloudLibraryMergeKey(lib); ok { - nonAutoKeys[key] = struct{}{} - } - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - if key, ok := CloudLibraryMergeKey(lib); ok { - if _, merged := nonAutoKeys[key]; merged { - continue - } - } - } - out = append(out, lib) - } - return out -} - -func FilterScannableCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - counts := cloudLibraryMediaCounts(ctx, repo, libs) - collapsed := make([]model.Library, 0, len(libs)) - byKey := make(map[string]int, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - if info, ok := ParseCloudLibraryMount(lib.Path); ok && IsDeprecatedNativeCloudProvider(info.Provider) { - continue - } - key, ok := cloudLibraryDisplayKey(lib) - if !ok { - collapsed = append(collapsed, lib) - continue - } - if prevIndex, exists := byKey[key]; exists { - if betterDisplayCloudLibrary(lib, collapsed[prevIndex], counts) { - collapsed[prevIndex] = lib - } - continue - } - byKey[key] = len(collapsed) - collapsed = append(collapsed, lib) - } - return FilterShadowedCloudLibraries(collapsed) -} - -func FilterDeprecatedNativeCloudLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if ok && IsDeprecatedNativeCloudProvider(info.Provider) { - continue - } - out = append(out, lib) - } - return out -} diff --git a/internal/service/cloud_mount_filter_test.go b/internal/service/cloud_mount_filter_test.go deleted file mode 100644 index 569ea3e..0000000 --- a/internal/service/cloud_mount_filter_test.go +++ /dev/null @@ -1,532 +0,0 @@ -package service - -import ( - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestFilterDisplayCloudLibrariesPrefersPopulatedCanonicalDuplicate(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - now := time.Now() - oldEmpty := model.Library{ - Base: model.Base{ID: "old-empty", CreatedAt: now.Add(-time.Hour)}, - Name: "OpenList · 国产剧", - Path: "cloud://openlist/%2F国产剧", - Type: "tv", - Enabled: true, - } - newPopulated := model.Library{ - Base: model.Base{ID: "new-populated", CreatedAt: now}, - Name: "OpenList · 国产剧", - Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), - Type: "tv", - Enabled: true, - } - if err := repos.Library.Create(t.Context(), &oldEmpty); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &newPopulated); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.Media{ - LibraryID: newPopulated.ID, - Title: "剧集", - Path: "cloud://openlist/国产剧/剧集.mkv", - }).Error; err != nil { - t.Fatal(err) - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{oldEmpty, newPopulated}) - if len(filtered) != 1 || filtered[0].ID != newPopulated.ID { - t.Fatalf("filtered = %#v, want only populated canonical duplicate", filtered) - } - - scanner := NewScannerService(nil, zap.NewNop(), repos, nil, nil, nil) - if conflict := scanner.shadowedCloudLibrary(t.Context(), &oldEmpty); conflict == nil || conflict.Library.ID != newPopulated.ID { - t.Fatalf("old duplicate scan conflict = %#v, want populated canonical library", conflict) - } -} - -func TestFilterDisplayCloudLibrariesMergesCloudMountIntoExistingLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国产剧", Path: "/media/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - movieCloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电影/国产剧", "/电影/国产剧"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud, &movieCloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{local, cloud, movieCloud}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"国产剧", "国产剧"}) { - t.Fatalf("filtered names = %#v, want local tv plus stripped movie cloud", got) - } - if filtered[0].ID != local.ID { - t.Fatalf("first filtered library = %s, want existing local library %s", filtered[0].ID, local.ID) - } - if filtered[1].ID != movieCloud.ID { - t.Fatalf("movie cloud library should stay separate when type differs: %#v", filtered) - } - - merged := MergedLibraryIDs([]model.Library{local, cloud, movieCloud}, local) - if !slices.Equal(merged, []string{local.ID, cloud.ID}) { - t.Fatalf("merged ids = %#v, want local+same-type cloud", merged) - } -} - -func TestFilterDisplayCloudLibrariesMergesEpisodicTypeAliases(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国漫", Path: BuildCloudLibraryPath("openlist", "/国漫", "/国漫"), Type: "anime", Enabled: true} - movie := model.Library{Name: "国漫", Path: BuildCloudLibraryPath("openlist", "/电影/国漫", "/电影/国漫"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud, &movie} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{local, cloud, movie}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"国漫", "国漫"}) { - t.Fatalf("filtered names = %#v, want local episodic plus separate movie library", got) - } - if filtered[0].ID != local.ID || filtered[1].ID != movie.ID { - t.Fatalf("filtered libraries = %#v, want anime cloud merged into local tv but movie kept", filtered) - } - - merged := MergedLibraryIDs([]model.Library{local, cloud, movie}, local) - if !slices.Equal(merged, []string{local.ID, cloud.ID}) { - t.Fatalf("merged ids = %#v, want local tv + cloud anime only", merged) - } -} - -func TestFilterDisplayCloudLibrariesMergesCategoryNameAliases(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - foreignMovie := model.Library{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true} - westernMovie := model.Library{Name: "OpenList · 欧美电影", Path: BuildCloudLibraryPath("openlist", "/欧美电影", "/欧美电影"), Type: "movie", Enabled: true} - eastAsianMovie := model.Library{Name: "OpenList · 日韩电影", Path: BuildCloudLibraryPath("openlist", "/日韩电影", "/日韩电影"), Type: "movie", Enabled: true} - jpAnime := model.Library{Name: "日番", Path: "/media/动漫/日番", Type: "tv", Enabled: true} - jpAnimeCloud := model.Library{Name: "OpenList · 日漫", Path: BuildCloudLibraryPath("openlist", "/日漫", "/日漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&foreignMovie, &westernMovie, &eastAsianMovie, &jpAnime, &jpAnimeCloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"欧美电影", "日韩电影", "日番"}) { - t.Fatalf("filtered names = %#v, want legacy foreign movie merged into western movie plus anime aliases", got) - } - - movieMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, foreignMovie) - if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID}) { - t.Fatalf("movie merged ids = %#v, want legacy foreign movie merged with western movie", movieMerged) - } - animeMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, jpAnime) - if !slices.Equal(animeMerged, []string{jpAnime.ID, jpAnimeCloud.ID}) { - t.Fatalf("anime merged ids = %#v, want jp anime aliases", animeMerged) - } -} - -func TestFilterDisplayCloudLibrariesCanonicalizesLegacyDisplayPaths(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - westernAnimation := model.Library{Name: "欧美动漫", Path: `F:\media\动漫\欧美动漫`, Type: "tv", Enabled: true} - uncategorizedCloud := model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/未分类", "/未分类"), Type: "movie", Enabled: true} - adult := model.Library{Name: "9KG", Path: `F:\media\成人\9KG`, Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&westernAnimation, &uncategorizedCloud, &adult} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{westernAnimation, uncategorizedCloud, adult}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"美漫", "欧美剧", "成人"}) { - t.Fatalf("filtered names = %#v, want canonical category names", got) - } - if got := []string{filtered[0].Type, filtered[1].Type, filtered[2].Type}; !slices.Equal(got, []string{"anime", "tv", "adult"}) { - t.Fatalf("filtered types = %#v, want canonical display types", got) - } - combined := strings.Join([]string{filtered[0].Path, filtered[1].Path, filtered[2].Path}, "\n") - for _, legacy := range []string{"欧美动漫", "未分类", "9KG"} { - if strings.Contains(combined, legacy) { - t.Fatalf("display paths contain legacy category %q: %s", legacy, combined) - } - } -} - -func TestCanonicalLibraryDisplayPathPreservesAutoCategoryScanDir(t *testing.T) { - raw := BuildCloudAutoCategoryLibraryPathWithScanDir("openlist", "国漫", "动漫/国产动漫") - - got := CanonicalLibraryDisplayPath(model.Library{Name: "国漫", Path: raw, Type: "anime", Enabled: true}) - info, ok := ParseCloudLibraryMount(got) - if !ok { - t.Fatalf("canonical path did not parse: %q", got) - } - if !CloudLibraryAutoCategory(model.Library{Path: got}) { - t.Fatalf("canonical path lost auto_category flag: %q", got) - } - if info.ScanDir != "国漫" || info.DisplayDir != "动漫/国漫" { - t.Fatalf("canonical path info = %#v, want scan 国漫 and canonical display 动漫/国漫", info) - } -} - -func TestListMediaVisibleDoesNotMergeDistinctMovieRegionLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - foreignMovie := model.Library{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true} - westernMovie := model.Library{Name: "OpenList · 欧美电影", Path: BuildCloudLibraryPath("openlist", "/欧美电影", "/欧美电影"), Type: "movie", Enabled: true} - eastAsianMovie := model.Library{Name: "OpenList · 日韩电影", Path: BuildCloudLibraryPath("openlist", "/日韩电影", "/日韩电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&foreignMovie, &westernMovie, &eastAsianMovie} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: westernMovie.ID, - Title: "Western Movie", - Path: "cloud://openlist/欧美电影/Western.Movie.2026.mkv", - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), foreignMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) { - t.Fatalf("legacy foreign movie items total=%d items=%#v, want merged western media", total, mediaTitles(items)) - } - - items, total, err = svc.ListMediaVisible(t.Context(), eastAsianMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 0 || len(items) != 0 { - t.Fatalf("east asian movie items total=%d items=%#v, want empty isolated library", total, mediaTitles(items)) - } - - items, total, err = svc.ListMediaVisible(t.Context(), westernMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) { - t.Fatalf("western movie items total=%d items=%#v, want own media only", total, mediaTitles(items)) - } -} - -func TestFilterDeprecatedNativeCloudLibrariesHidesPopulatedHistory(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - emptyQuark := model.Library{Name: "旧 Quark 空库", Path: "cloud://quark/0", Type: "movie", Enabled: true} - populatedQuark := model.Library{Name: "旧 Quark 有数据", Path: "cloud://quark/archive", Type: "movie", Enabled: true} - openList := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&emptyQuark, &populatedQuark, &openList} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: populatedQuark.ID, - Title: "历史媒体", - Path: "cloud://quark/archive/movie.mkv", - }).Error; err != nil { - t.Fatal(err) - } - - filtered := FilterDeprecatedNativeCloudLibraries([]model.Library{emptyQuark, populatedQuark, openList}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"OpenList"}) { - t.Fatalf("filtered names = %#v, want only supported cloud libraries", got) - } - - displayed := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{emptyQuark, populatedQuark, openList}) - if got := libraryNames(displayed); !slices.Equal(got, []string{"OpenList"}) { - t.Fatalf("display names = %#v, want deprecated cloud hidden", got) - } -} - -func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国产剧", Path: "/media/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - other := model.Library{Name: "欧美剧", Path: BuildCloudLibraryPath("openlist", "/欧美剧", "/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud, &other} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&[]model.Media{ - {LibraryID: local.ID, Title: "本地剧", Path: "/media/国产剧/local.mkv"}, - {LibraryID: cloud.ID, Title: "云盘剧", Path: "cloud://openlist/国产剧/cloud.mkv"}, - {LibraryID: other.ID, Title: "其他剧", Path: "cloud://openlist/欧美剧/other.mkv"}, - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), local.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 2 { - t.Fatalf("total = %d, want merged local+cloud items", total) - } - if got := mediaTitles(items); !slices.Equal(got, []string{"云盘剧", "本地剧"}) { - t.Fatalf("items = %#v, want local+cloud only", got) - } - if cloudItem := mediaByTitle(items, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID { - t.Fatalf("cloud item display library = %#v, want merged local library %s", cloudItem, local.ID) - } - - items, total, err = svc.ListMediaVisible(t.Context(), local.ID, 1, 20, MediaVisibility{ - IncludeNSFW: true, - AllowedLibraryIDs: []string{local.ID}, - }) - if err != nil { - t.Fatal(err) - } - if total != 2 || !slices.Equal(mediaTitles(items), []string{"云盘剧", "本地剧"}) { - t.Fatalf("profile-limited merged list total=%d items=%#v", total, mediaTitles(items)) - } - - searchItems, err := svc.SearchMediaVisible(t.Context(), "剧", 20, MediaVisibility{ - IncludeNSFW: true, - AllowedLibraryIDs: []string{local.ID}, - }) - if err != nil { - t.Fatal(err) - } - if got := mediaTitles(searchItems); !slices.Equal(got, []string{"云盘剧", "本地剧"}) { - t.Fatalf("profile-limited merged search items=%#v, want local+hidden cloud", got) - } - if cloudItem := mediaByTitle(searchItems, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID { - t.Fatalf("search cloud item display library = %#v, want merged local library %s", cloudItem, local.ID) - } -} - -func TestListMediaVisibleUsesSpecificCloudChildLibraryAsDisplayTarget(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} - child := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &child} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: root.ID, - Title: "折腰", - Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), root.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || len(items) != 1 { - t.Fatalf("items total=%d len=%d, want one root cloud item", total, len(items)) - } - if items[0].DisplayLibraryID != child.ID { - t.Fatalf("display library = %q, want child cloud library %q", items[0].DisplayLibraryID, child.ID) - } - if items[0].DisplayLibraryPath != child.Path { - t.Fatalf("display library path = %q, want %q", items[0].DisplayLibraryPath, child.Path) - } -} - -func TestGetMediaUsesMappedLocalCategoryLibraryAsDisplayTarget(t *testing.T) { - containerRoot := filepath.Join(t.TempDir(), "media") - t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot) - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - parent := model.Library{Name: "电视剧", Path: containerRoot, Type: "tv", Enabled: true} - child := model.Library{Name: "国产剧", Path: filepath.Join("media", "电视剧", "国产剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&parent, &child} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - mediaPath := filepath.Join(containerRoot, "电视剧", "国产剧", "剧集", "Season 01", "剧集 - S01E01.mkv") - if err := repos.DB.Create(&model.Media{ - Base: model.Base{ID: "media-1"}, - LibraryID: parent.ID, - Title: "剧集", - Path: mediaPath, - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - got, err := svc.GetMedia(t.Context(), "media-1") - if err != nil { - t.Fatal(err) - } - if got.DisplayLibraryID != child.ID { - t.Fatalf("display library = %q, want mapped child library %q", got.DisplayLibraryID, child.ID) - } - if got.DisplayLibraryPath != filepath.Clean(filepath.Join(containerRoot, "电视剧", "国产剧")) { - t.Fatalf("display library path = %q, want mapped child path", got.DisplayLibraryPath) - } -} - -func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国产剧", Path: "/media/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - - statuses, err := scanner.StartAllCloudLibraryScans() - if err != nil { - t.Fatal(err) - } - if len(statuses) != 1 || statuses[0].LibraryID != cloud.ID { - t.Fatalf("scan-all statuses = %#v, want merged cloud library queued", statuses) - } -} - -func TestAutoCategoryCloudLibrariesMergeIntoExistingDisplayLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true} - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&local, &root, &auto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - libs, err := repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if shadow := CloudLibraryShadowed(libs, root); shadow != nil { - t.Fatalf("auto category should not shadow root scan: %#v", shadow) - } - display := FilterDisplayCloudLibraries(t.Context(), repos, libs) - if got := libraryNames(display); !slices.Equal(got, []string{"欧美剧", "OpenList"}) { - t.Fatalf("display libraries = %#v, want local library and user-mounted root only", got) - } - scannable := FilterScannableCloudLibraries(t.Context(), repos, libs) - if got := libraryNames(scannable); !slices.Equal(got, []string{"欧美剧", "OpenList"}) { - t.Fatalf("scannable libraries = %#v, want local library and root only", got) - } - - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - statuses, err := scanner.StartAllCloudLibraryScans() - if err != nil { - t.Fatal(err) - } - if len(statuses) != 1 || statuses[0].LibraryID != root.ID { - t.Fatalf("scan-all statuses = %#v, want only cloud root queued", statuses) - } -} - -func TestRootCloudLibraryIncludesAutoCategoryMedia(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &auto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: auto.ID, - Title: "The Show", - Path: "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv", - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), root.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || len(items) != 1 { - t.Fatalf("root cloud items total=%d len=%d, want auto-category media", total, len(items)) - } - if items[0].LibraryName != auto.Name || items[0].LibraryPath != auto.Path { - t.Fatalf("media library metadata = (%q, %q), want auto category", items[0].LibraryName, items[0].LibraryPath) - } - if items[0].DisplayLibraryID != auto.ID || items[0].DisplayLibraryPath != auto.Path { - t.Fatalf("display library = (%q, %q), want auto category", items[0].DisplayLibraryID, items[0].DisplayLibraryPath) - } -} - -func TestStartAllCloudLibraryScansSkipsDeprecatedQuarkMounts(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - quark := model.Library{Name: "旧 Quark", Path: "cloud://quark/0", Type: "movie", Enabled: true} - openList := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&quark, &openList} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - - statuses, err := scanner.StartAllCloudLibraryScans() - if err != nil { - t.Fatal(err) - } - if len(statuses) != 1 || statuses[0].Provider != "openlist" { - t.Fatalf("scan-all statuses = %#v, want only openlist", statuses) - } -} - -func libraryNames(libs []model.Library) []string { - out := make([]string, 0, len(libs)) - for _, lib := range libs { - out = append(out, lib.Name) - } - return out -} - -func mediaTitles(items []model.Media) []string { - out := make([]string, 0, len(items)) - for _, item := range items { - out = append(out, item.Title) - } - slices.Sort(out) - return out -} - -func mediaByTitle(items []model.Media, title string) *model.Media { - for i := range items { - if items[i].Title == title { - return &items[i] - } - } - return nil -} diff --git a/internal/service/cloud_mount_label.go b/internal/service/cloud_mount_label.go deleted file mode 100644 index 7fbd55e..0000000 --- a/internal/service/cloud_mount_label.go +++ /dev/null @@ -1,136 +0,0 @@ -package service - -import ( - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func CloudMountProviderLabel(provider string) string { - switch strings.TrimSpace(provider) { - case LegacyQuarkProvider: - return "已停用网盘" - case cloud.Type115: - return "115 网盘" - case cloud.TypeCloudDrive2: - return "CloudDrive2" - case cloud.TypeOpenList: - return "OpenList" - default: - if strings.TrimSpace(provider) == "" { - return "网盘" - } - return strings.TrimSpace(provider) - } -} - -func stripCloudProviderDisplayPrefix(name, provider string) string { - name = strings.TrimSpace(name) - if name == "" { - return "" - } - for _, label := range []string{CloudMountProviderLabel(provider), strings.TrimSpace(provider)} { - label = strings.TrimSpace(label) - if label == "" || len(name) < len(label) || !strings.EqualFold(name[:len(label)], label) { - continue - } - rest := strings.TrimSpace(name[len(label):]) - rest = strings.TrimLeft(rest, " \t\r\n·・-—–||:/\\") - if rest != "" { - return strings.TrimSpace(rest) - } - if strings.EqualFold(name, label) { - return "" - } - } - return name -} - -func cloudMountDirBase(dir string) string { - dir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(dir, "\\", "/")), "/") - if dir == "" { - return "" - } - parts := strings.Split(dir, "/") - for i := len(parts) - 1; i >= 0; i-- { - if part := strings.TrimSpace(parts[i]); part != "" { - return part - } - } - return "" -} - -func normalizeLibraryMergeName(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "" { - return "" - } - return strings.Join(strings.Fields(name), " ") -} - -func ShadowedCloudLibraryIDSet(libs []model.Library) map[string]bool { - out := make(map[string]bool) - for _, lib := range libs { - if CloudLibraryShadowed(libs, lib) != nil { - out[lib.ID] = true - } - } - return out -} - -func InferCloudMountMediaType(dir, name string) string { - text := strings.ToLower(dir + " " + name) - switch { - case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"): - return "adult" - case containsAny(text, "动画电影", "华语电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "音乐会", "电影", "movie", "movies", "film", "films", "documentary", "concert"): - return "movie" - case containsAny(text, "综艺", "真人秀", "脱口秀", "晚会", "variety"): - return "variety" - case containsAny(text, "国漫", "日漫", "日番", "韩漫", "美漫", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"): - return "anime" - case containsAny(text, "国产剧", "大陆剧", "华语剧", "欧美剧", "日韩剧", "韩剧", "日剧", "港剧", "台剧", "泰剧", "英剧", "美剧", "短剧", "电视剧", "剧集", "连续剧", "series", "tv", "shows"): - return "tv" - default: - return "movie" - } -} - -func containsAny(text string, values ...string) bool { - for _, value := range values { - if strings.Contains(text, value) { - return true - } - } - return false -} - -func cloudMountAncestor(parent, child string) bool { - parent = strings.Trim(parent, "/") - child = strings.Trim(child, "/") - if parent == child { - return false - } - if parent == "" { - return child != "" - } - return strings.HasPrefix(child, parent+"/") -} - -func normalizeCloudMountDir(provider, value string) string { - value = strings.TrimSpace(value) - if decoded, err := url.PathUnescape(value); err == nil { - value = decoded - } - if decoded, err := url.QueryUnescape(value); err == nil { - value = decoded - } - value = strings.ReplaceAll(value, "\\", "/") - value = strings.Trim(strings.TrimSpace(value), "/") - if value == "." || ((provider == cloud.Type115 || provider == LegacyQuarkProvider) && value == "0") { - return "" - } - return value -} diff --git a/internal/service/cloud_mount_merge.go b/internal/service/cloud_mount_merge.go deleted file mode 100644 index f7e8b65..0000000 --- a/internal/service/cloud_mount_merge.go +++ /dev/null @@ -1,90 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func cloudLibraryDisplayKey(lib model.Library) (string, bool) { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return "", false - } - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - return info.Provider + "\x00" + dir, true -} - -func cloudLibraryPathIsCanonical(lib model.Library) bool { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return false - } - return BuildCloudLibraryPath(info.Provider, info.ScanDir, info.DisplayDir) == strings.TrimSpace(lib.Path) -} - -func CloudLibraryMergeKey(lib model.Library) (string, bool) { - name := strings.TrimSpace(lib.Name) - if displayName, ok := CloudLibraryDisplayName(lib); ok { - name = displayName - } - name = normalizeLibraryMergeName(name) - if name == "" { - return "", false - } - typeKey := cloudLibraryMergeTypeKey(lib.Type) - return typeKey + "\x00" + cloudLibraryMergeNameKey(typeKey, name), true -} - -func cloudLibraryMergeTypeKey(libraryType string) string { - switch strings.ToLower(strings.TrimSpace(libraryType)) { - case "tv", "anime", "variety": - return "tvshows" - default: - return strings.ToLower(strings.TrimSpace(libraryType)) - } -} - -func cloudLibraryMergeNameKey(typeKey, name string) string { - switch typeKey { - case "movie": - switch name { - case "国产电影", "大陆电影": - return "华语电影" - case "华语电影": - return "华语电影" - case "外语电影", "外国电影", "欧美电影": - return "欧美电影" - case "日韩电影", "日本电影", "韩国电影": - return "日韩电影" - case "纪录", "纪录片": - return "纪录片" - case "演唱会", "concert": - return "演唱会" - case "动画电影", "动漫电影": - return "动画电影" - } - case "tvshows": - switch name { - case "国产剧", "大陆剧", "华语剧", "国剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧": - return "国产剧" - case "欧美剧", "欧美电视剧", "美剧", "英剧": - return "欧美剧" - case "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧": - return "日韩剧" - case "国漫", "国产动漫", "国产动画": - return "国漫" - case "日番", "日漫", "番剧", "日本动漫", "日本动画": - return "日番" - case "韩漫", "韩国动漫", "韩国动画": - return "韩漫" - case "美漫", "欧美动漫", "欧美动画", "西方动画": - return "美漫" - case "其他", "其他动漫", "其它动漫", "other": - return "其他" - case "纪录", "纪录片": - return "纪录片" - } - } - return name -} diff --git a/internal/service/cloud_mount_scope.go b/internal/service/cloud_mount_scope.go deleted file mode 100644 index 9fe8505..0000000 --- a/internal/service/cloud_mount_scope.go +++ /dev/null @@ -1,152 +0,0 @@ -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, libraryID string) ([]string, error) { - libraryID = strings.TrimSpace(libraryID) - if libraryID == "" || repo == nil || repo.Library == nil { - return []string{libraryID}, nil - } - lib, err := repo.Library.FindByID(ctx, libraryID) - if err != nil { - return nil, err - } - if lib == nil { - return []string{libraryID}, nil - } - libs, err := repo.Library.List(ctx) - if err != nil { - return nil, err - } - return MergedLibraryIDs(libs, *lib), nil -} - -func MergedLibraryIDs(libs []model.Library, target model.Library) []string { - ids := appendUniqueLibraryIDs(nil, target.ID) - if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, target); len(rootAutoIDs) > 0 { - ids = appendUniqueLibraryIDs(ids, rootAutoIDs...) - } - targetKey, hasTargetKey := CloudLibraryMergeKey(target) - if !hasTargetKey { - return ids - } - _, targetIsCloud := ParseCloudLibraryMount(target.Path) - for _, candidate := range libs { - if candidate.ID == target.ID || strings.TrimSpace(candidate.ID) == "" || !candidate.Enabled { - continue - } - key, ok := CloudLibraryMergeKey(candidate) - if ok && key == targetKey { - _, candidateIsCloud := ParseCloudLibraryMount(candidate.Path) - if !targetIsCloud && !candidateIsCloud { - continue - } - ids = appendUniqueLibraryIDs(ids, candidate.ID) - } - } - return ids -} - -func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string { - mount, ok := ParseCloudLibraryMount(lib.Path) - if !ok || !cloudRootMountNeedsAutoCategory(mount) { - return nil - } - ids := make([]string, 0) - for _, candidate := range libs { - if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) { - continue - } - info, ok := ParseCloudLibraryMount(candidate.Path) - if ok && info.Provider == mount.Provider { - ids = appendUniqueLibraryIDs(ids, candidate.ID) - } - } - return ids -} - -func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility { - if repo == nil || repo.Library == nil { - return visibility - } - libs, err := repo.Library.List(ctx) - if err != nil { - return visibility - } - if len(visibility.AllowedLibraryIDs) > 0 { - visibility.AllowedLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.AllowedLibraryIDs) - } - if len(visibility.HiddenLibraryIDs) > 0 { - visibility.HiddenLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.HiddenLibraryIDs) - } - visibility.HiddenLibraryIDs = appendUniqueLibraryIDs(visibility.HiddenLibraryIDs, DeprecatedNativeCloudLibraryIDs(libs)...) - return visibility -} - -func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string { - if len(ids) == 0 || repo == nil || repo.Library == nil { - return ids - } - libs, err := repo.Library.List(ctx) - if err != nil { - return ids - } - return expandMergedLibraryIDsFromLibraries(libs, ids) -} - -func expandMergedLibraryIDsFromLibraries(libs []model.Library, ids []string) []string { - byID := make(map[string]model.Library, len(libs)) - for _, lib := range libs { - byID[lib.ID] = lib - } - out := make([]string, 0, len(ids)) - for _, id := range ids { - id = strings.TrimSpace(id) - if id == "" { - continue - } - if lib, ok := byID[id]; ok { - out = appendUniqueLibraryIDs(out, MergedLibraryIDs(libs, lib)...) - continue - } - out = appendUniqueLibraryIDs(out, id) - } - return out -} - -func DeprecatedNativeCloudLibraryIDs(libs []model.Library) []string { - ids := make([]string, 0) - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if ok && IsDeprecatedNativeCloudProvider(info.Provider) { - ids = appendUniqueLibraryIDs(ids, lib.ID) - } - } - return ids -} - -func appendUniqueLibraryIDs(ids []string, values ...string) []string { - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - exists := false - for _, id := range ids { - if id == value { - exists = true - break - } - } - if !exists { - ids = append(ids, value) - } - } - return ids -} diff --git a/internal/service/cloud_path_repair.go b/internal/service/cloud_path_repair.go deleted file mode 100644 index cc4a11f..0000000 --- a/internal/service/cloud_path_repair.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// RepairCloudPathMetadata backfills external IDs from media paths such as -// "Movie (2025) {tmdb-123}" so existing placeholder rows can be scraped -// without requiring another successful filesystem or cloud provider traversal. -// -// 传入 libraryID 时只修复这些媒体库的行;为空则修复全库。 -func (c *Container) RepairCloudPathMetadata(ctx context.Context, libraryID ...string) (int, error) { - if c == nil || c.Repo == nil || c.Repo.DB == nil { - return 0, nil - } - libraryIDs := compactLibraryIDs(libraryID...) - var repaired int - var rows []model.Media - query := c.Repo.DB.WithContext(ctx). - Model(&model.Media{}). - Select("id, title, path, year, season_num, episode_num, scrape_status, tm_db_id, bangumi_id, douban_id, thetvdb_id"). - Where("("+strings.Join([]string{ - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - }, " OR ")+")", - "%tmdb%", "%tmdbid%", "%douban%", "%db%", "%bangumi%", "%bgm%", "%thetvdb%", "%tvdb%") - if len(libraryIDs) > 0 { - query = query.Where("library_id IN ?", libraryIDs) - } - - err := query.FindInBatches(&rows, 500, func(_ *gorm.DB, _ int) error { - for _, row := range rows { - meta, hints := pathHintMetadata(row.Path, row.SeasonNum > 0 || row.EpisodeNum > 0) - if meta == nil || !hints.useful() { - continue - } - updates := map[string]any{} - status := strings.TrimSpace(row.ScrapeStatus) - enrichable := status == "" || status == "pending" || status == "no_match" - changedExternalID := false - if meta.TMDbID > 0 && row.TMDbID != meta.TMDbID { - updates["tm_db_id"] = meta.TMDbID - changedExternalID = true - } - if meta.BangumiID > 0 && row.BangumiID != meta.BangumiID { - updates["bangumi_id"] = meta.BangumiID - changedExternalID = true - } - if strings.TrimSpace(meta.DoubanID) != "" && strings.TrimSpace(row.DoubanID) != strings.TrimSpace(meta.DoubanID) { - updates["douban_id"] = strings.TrimSpace(meta.DoubanID) - changedExternalID = true - } - if strings.TrimSpace(meta.TheTVDBID) != "" && strings.TrimSpace(row.TheTVDBID) != strings.TrimSpace(meta.TheTVDBID) { - updates["thetvdb_id"] = strings.TrimSpace(meta.TheTVDBID) - changedExternalID = true - } - if meta.Year > 0 && row.Year <= 0 { - updates["year"] = meta.Year - } - if enrichable && strings.TrimSpace(meta.Title) != "" && cloudPathRepairShouldReplaceTitle(row.Title, meta.Title) { - updates["title"] = strings.TrimSpace(meta.Title) - } - if changedExternalID && (status == "" || status == "no_match" || status == "matched") { - updates["scrape_status"] = "pending" - } - if len(updates) == 0 { - continue - } - if err := c.Repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { - return err - } - repaired++ - } - return nil - }).Error - if err != nil { - return repaired, err - } - if repaired > 0 && c.Log != nil { - c.Log.Info("cloud path metadata repaired", zap.Int("media_count", repaired)) - } - return repaired, nil -} - -func cloudPathRepairShouldReplaceTitle(current, hinted string) bool { - current = strings.TrimSpace(current) - hinted = strings.TrimSpace(hinted) - if hinted == "" || strings.EqualFold(current, hinted) { - return false - } - if current == "" { - return true - } - noise := []string{"web-dl", "bluray", "hdtv", "2160p", "1080p", "720p", "ddp", "aac", "h.264", "h.265", "x264", "x265", "adweb", "mweb", "cmctv", "bit"} - lower := strings.ToLower(current) - for _, token := range noise { - if strings.Contains(lower, token) { - return true - } - } - return len([]rune(current)) > len([]rune(hinted))*2 -} - -func compactLibraryIDs(ids ...string) []string { - out := make([]string, 0, len(ids)) - for _, id := range ids { - out = appendUniqueLibraryIDs(out, id) - } - return out -} diff --git a/internal/service/cloud_path_repair_test.go b/internal/service/cloud_path_repair_test.go deleted file mode 100644 index 9d9aae7..0000000 --- a/internal/service/cloud_path_repair_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestRepairAndRescrapeLibraryForceRematchesThenReclassifies(t *testing.T) { - scraper, repos, closeServer := newTestScraper(t) - defer closeServer() - - root := t.TempDir() - wrongRoot := filepath.Join(root, "media", "电视剧", "国产剧") - mediaPath := filepath.Join(wrongRoot, "Spy Family", "Season 01", "Spy Family - S01E01.mkv") - if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(mediaPath, []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "国产剧", Path: wrongRoot, Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - LibraryID: lib.ID, - Title: "错误旧匹配", - Path: mediaPath, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 999, - Countries: "CN", - Languages: "zh", - Genres: "Drama", - ScrapeStatus: "matched", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - cfg := &config.Config{} - cfg.Organizer.SmartClassify = true - organizer := NewOrganizerService(cfg, zap.NewNop(), repos) - organizer.SetScraper(scraper) - container := &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, Scraper: scraper, Organizer: organizer} - result, err := container.RepairAndRescrapeLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("repair and rescrape: %v", err) - } - if result.Reclassified != 1 { - t.Fatalf("result=%+v, want one corrected classification", result) - } - want := filepath.Join(root, "media", "动漫", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv") - if _, err := os.Stat(want); err != nil { - t.Fatalf("corrected media missing at %q: %v", want, err) - } - var got model.Media - if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 12345 || got.Countries != "JP" || got.Path != want { - t.Fatalf("repaired media=%#v, want rematched Japanese anime at corrected path", got) - } -} - -func TestRepairRescrapeOptionsDefaultSkipsEpisodeArtwork(t *testing.T) { - options := repairRescrapeOptions() - if !options.RetryNoMatch { - t.Fatal("repair rescrape should retry no_match rows") - } - if !options.IncludeMatched { - t.Fatal("repair rescrape should refresh already matched rows") - } - if !options.ForceRematch { - t.Fatal("repair rescrape should ignore stale external IDs and rematch by path/title") - } - if options.EpisodeArtwork == nil { - t.Fatal("repair rescrape should set an explicit episode artwork option") - } - if *options.EpisodeArtwork { - t.Fatal("repair rescrape should skip episode artwork by default") - } -} - -func TestRepairRescrapeOptionsCanEnableEpisodeArtwork(t *testing.T) { - episodeArtwork := true - options := repairRescrapeOptions(ScrapeOptions{EpisodeArtwork: &episodeArtwork}) - if !options.RetryNoMatch { - t.Fatal("repair rescrape should force retry no_match rows") - } - if !options.IncludeMatched { - t.Fatal("repair rescrape should force refreshing already matched rows") - } - if options.EpisodeArtwork == nil || !*options.EpisodeArtwork { - t.Fatal("repair rescrape should keep explicit episode artwork=true") - } -} - -func TestRepairRescrapeOptionsKeepsExplicitEpisodeArtworkFalse(t *testing.T) { - episodeArtwork := false - options := repairRescrapeOptions(ScrapeOptions{EpisodeArtwork: &episodeArtwork}) - if !options.RetryNoMatch { - t.Fatal("repair rescrape should force retry no_match rows") - } - if !options.IncludeMatched { - t.Fatal("repair rescrape should force refreshing already matched rows") - } - if options.EpisodeArtwork == nil { - t.Fatal("repair rescrape should keep explicit episode artwork option") - } - if *options.EpisodeArtwork { - t.Fatal("repair rescrape should keep explicit episode artwork=false") - } -} - -// TestResetEpisodicMatchedForRescrape 验证「修复+重刮」会把脏的 matched 剧集行 -// 重置为 pending(让 EnrichLibrary 能重新刮削),而电影行与其它库不受影响。 -func TestResetEpisodicMatchedForRescrape(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - container := &Container{Repo: repository.New(db), Log: zap.NewNop()} - - rows := []model.Media{ - // 目标库的剧集行(matched, 有季集号)→ 应被重置。 - {Base: model.Base{ID: "ep1"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 1, ScrapeStatus: "matched", Path: "/a/show/S01/ep1.mkv"}, - {Base: model.Base{ID: "ep2"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 2, ScrapeStatus: "matched", Path: "/a/show/S01/ep2.mkv"}, - // 目标库的电影行(无季集号)→ 不应被重置。 - {Base: model.Base{ID: "movie1"}, LibraryID: "lib-a", ScrapeStatus: "matched", Path: "/a/movie.mkv"}, - // 目标库已是 pending 的剧集行 → 不计入重置数。 - {Base: model.Base{ID: "ep3"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 3, ScrapeStatus: "pending", Path: "/a/show/S01/ep3.mkv"}, - // 其它库的剧集行(matched)→ 单库重置时不应受影响。 - {Base: model.Base{ID: "ep-other"}, LibraryID: "lib-b", SeasonNum: 1, EpisodeNum: 1, ScrapeStatus: "matched", Path: "/b/show/S01/ep1.mkv"}, - } - for i := range rows { - if err := db.Create(&rows[i]).Error; err != nil { - t.Fatalf("create media %s: %v", rows[i].ID, err) - } - } - - reset, err := container.resetEpisodicMatchedForRescrape(t.Context(), "lib-a") - if err != nil { - t.Fatalf("reset: %v", err) - } - if reset != 2 { - t.Fatalf("reset = %d, want 2 (only matched episodic rows in lib-a)", reset) - } - - status := func(id string) string { - var m model.Media - if err := db.First(&m, "id = ?", id).Error; err != nil { - t.Fatalf("load %s: %v", id, err) - } - return m.ScrapeStatus - } - if status("ep1") != "pending" || status("ep2") != "pending" { - t.Fatalf("episodic matched rows should be pending, got ep1=%q ep2=%q", status("ep1"), status("ep2")) - } - if status("movie1") != "matched" { - t.Fatalf("movie row should stay matched, got %q", status("movie1")) - } - if status("ep-other") != "matched" { - t.Fatalf("other library row should stay matched, got %q", status("ep-other")) - } -} - -func TestRepairAndRescrapeLibraryExpandsMergedCloudLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - container := &Container{Repo: repository.New(db), Log: zap.NewNop()} - - local := model.Library{Name: "国产剧", Path: "/media/电视剧/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{ - Name: "OpenList · 国产剧", - Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), - Type: "tv", - Enabled: true, - } - if err := container.Repo.Library.Create(t.Context(), &local); err != nil { - t.Fatal(err) - } - if err := container.Repo.Library.Create(t.Context(), &cloud); err != nil { - t.Fatal(err) - } - repairMedia := model.Media{ - LibraryID: cloud.ID, - Title: "主角", - Path: "cloud://openlist/国产剧/主角 (2026) {tmdb-284110}/Season 1/主角.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - ScrapeStatus: "matched", - } - resetMedia := model.Media{ - LibraryID: cloud.ID, - Title: "无占位符剧集", - Path: "cloud://openlist/国产剧/无占位符剧集/Season 1/无占位符剧集.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - ScrapeStatus: "matched", - } - if err := db.Create(&repairMedia).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&resetMedia).Error; err != nil { - t.Fatal(err) - } - - result, err := container.RepairAndRescrapeLibrary(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if result.Repaired != 1 || result.Reset != 1 { - t.Fatalf("result = %+v, want repaired/reset for merged cloud row", result) - } - var repaired model.Media - if err := db.First(&repaired, "id = ?", repairMedia.ID).Error; err != nil { - t.Fatal(err) - } - if repaired.TMDbID != 284110 || repaired.ScrapeStatus != "pending" { - t.Fatalf("merged cloud row not repaired/reset: tmdb=%d status=%q", repaired.TMDbID, repaired.ScrapeStatus) - } - var reset model.Media - if err := db.First(&reset, "id = ?", resetMedia.ID).Error; err != nil { - t.Fatal(err) - } - if reset.ScrapeStatus != "pending" { - t.Fatalf("merged cloud row not reset: status=%q", reset.ScrapeStatus) - } -} diff --git a/internal/service/cloud_path_rescrape.go b/internal/service/cloud_path_rescrape.go deleted file mode 100644 index e51ca76..0000000 --- a/internal/service/cloud_path_rescrape.go +++ /dev/null @@ -1,217 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// RepairAndRescrapeResult 汇总一次「全库修复+重刮」的结果。 -type RepairAndRescrapeResult struct { - Repaired int `json:"repaired"` // 从路径占位符回填外部 ID 的媒体数 - Reclassified int `json:"reclassified"` // 按元数据纠偏到正确分类/媒体库的媒体数 - Libraries int `json:"libraries"` // 参与重刮的媒体库数 - Matched int `json:"matched"` // 重刮后成功匹配的媒体数 - Processed int `json:"processed"` // 实际完成刮削处理的媒体数 - Errors int `json:"errors"` // 单条媒体刮削失败数 - Reset int `json:"reset"` // 被重置为 pending 以便重刮的剧集行数 -} - -// resetEpisodicMatchedForRescrape 把剧集类(有季集号)且已 matched 的行重置为 -// pending,使 EnrichLibrary(只处理 pending/no_match)能重新刮削它们。 -// -// 背景: 历史版本(commit b44c7f8)曾把【单集 episode id】写进整剧 tm_db_id、把 -// 单集名写进 original_name,污染了合集分组键 —— 同一部剧每集 id/原名各不相同, -// 被前端 / Emby 拆成 N 张单集卡。这些行 scrape_status 多为 matched,常规「修复+ -// 重刮」会跳过,导致「无法修复」。源头已在 local_metadata.go 修正,这里把脏的 -// matched 剧集行放回 pending,借重刮写回正确的整剧 ID / 原名。 -// -// libraryIDs 为空时处理全库;非空时仅这些库。返回被重置的行数。 -func (c *Container) resetEpisodicMatchedForRescrape(ctx context.Context, libraryIDs ...string) (int, error) { - if c == nil || c.Repo == nil || c.Repo.DB == nil { - return 0, nil - } - ids := compactLibraryIDs(libraryIDs...) - q := c.Repo.DB.WithContext(ctx).Model(&model.Media{}). - Where("(season_num > 0 OR episode_num > 0)"). - Where("LOWER(scrape_status) = ?", "matched") - if len(ids) > 0 { - q = q.Where("library_id IN ?", ids) - } - res := q.Update("scrape_status", "pending") - if res.Error != nil { - return 0, res.Error - } - reset := int(res.RowsAffected) - if reset > 0 && c.Log != nil { - c.Log.Info("episodic matched rows reset to pending for rescrape", - zap.String("libraries", strings.Join(ids, ",")), - zap.Int("reset", reset)) - } - return reset, nil -} - -// RepairAndRescrapeAllLibraries 修复并重刮所有媒体库:先从媒体路径中的 -// {tmdb-123}/{bangumi-456} 等占位符回填缺失或错误的外部 ID(回填后会把相关 -// 行的 scrape_status 重置为 pending),随后逐个媒体库重刮(含 no_match 重试), -// 让此前因空 ID / 脏 ID 无法刮削的媒体重新匹配到正确数据。 -func repairRescrapeOptions(values ...ScrapeOptions) ScrapeOptions { - options := ScrapeOptions{RetryNoMatch: true, IncludeMatched: true, ForceRematch: true} - if len(values) > 0 { - options = values[0] - options.RetryNoMatch = true - options.IncludeMatched = true - options.ForceRematch = true - } - if options.EpisodeArtwork == nil { - episodeArtwork := false - options.EpisodeArtwork = &episodeArtwork - } - return options -} - -func (c *Container) RepairAndRescrapeAllLibraries(ctx context.Context, options ...ScrapeOptions) (RepairAndRescrapeResult, error) { - var result RepairAndRescrapeResult - if c == nil || c.Repo == nil || c.Repo.DB == nil { - return result, nil - } - scrapeOptions := repairRescrapeOptions(options...) - repaired, err := c.RepairCloudPathMetadata(ctx) - if err != nil { - return result, err - } - result.Repaired = repaired - - // 重置全库脏的 matched 剧集行(单集 id 污染整剧字段),让其下方重刮一并修正。 - if reset, err := c.resetEpisodicMatchedForRescrape(ctx); err != nil { - return result, err - } else { - result.Reset = reset - } - - if c.Scraper == nil || c.Repo.Library == nil { - return result, nil - } - libraries, err := c.Repo.Library.List(ctx) - if err != nil { - return result, err - } - for i := range libraries { - select { - case <-ctx.Done(): - return result, ctx.Err() - default: - } - lib := libraries[i] - if !lib.Enabled { - continue - } - result.Libraries++ - // retryNoMatch=true:连之前匹配失败的也再试一次,因为这次可能已回填到正确 ID。 - scrapeResult, err := c.Scraper.EnrichLibraryDetailedWithOptions(ctx, lib.ID, scrapeOptions) - if err != nil { - if c.Log != nil { - c.Log.Warn("repair rescrape library failed", zap.String("library", lib.ID), zap.Error(err)) - } - result.Errors++ - continue - } - result.Matched += scrapeResult.Matched - result.Processed += scrapeResult.Processed - result.Errors += scrapeResult.Failed - } - if c.Organizer != nil { - reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{}) - if err != nil { - return result, err - } - if reclassifyResult != nil { - result.Reclassified = reclassifyResult.Reclassified - result.Errors += len(reclassifyResult.Errors) - c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified) - } - } - if c.Log != nil { - c.Log.Info("repair and rescrape all libraries done", - zap.Int("repaired", result.Repaired), - zap.Int("reclassified", result.Reclassified), - zap.Int("libraries", result.Libraries), - zap.Int("matched", result.Matched), - zap.Int("processed", result.Processed), - zap.Int("errors", result.Errors)) - } - return result, nil - } - -// RepairAndRescrapeLibrary 修复并重刮单个媒体库:先从该库媒体路径中的占位符 -// 回填缺失/错误的外部 ID(重置相关行 scrape_status=pending),再对该库重刮 -// (含 no_match 重试)。用于「按媒体库」单独触发修复,不影响其它库。 -func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID string, options ...ScrapeOptions) (RepairAndRescrapeResult, error) { - var result RepairAndRescrapeResult - libraryID = strings.TrimSpace(libraryID) - if c == nil || c.Repo == nil || c.Repo.DB == nil || libraryID == "" { - return result, nil - } - scrapeOptions := repairRescrapeOptions(options...) - libraryIDs, err := MergedLibraryIDsForLibrary(ctx, c.Repo, libraryID) - if err != nil { - return result, err - } - repaired, err := c.RepairCloudPathMetadata(ctx, libraryIDs...) - if err != nil { - return result, err - } - result.Repaired = repaired - - // 重置该库脏的 matched 剧集行,让下方重刮修正被单集 id 污染的整剧字段。 - if reset, err := c.resetEpisodicMatchedForRescrape(ctx, libraryIDs...); err != nil { - return result, err - } else { - result.Reset = reset - } - - if c.Scraper == nil { - return result, nil - } - result.Libraries = 1 - // retryNoMatch=true:连之前匹配失败的也再试一次,因为这次可能已回填到正确 ID。 - scrapeResult, err := c.Scraper.EnrichLibraryDetailedWithOptions(ctx, libraryID, scrapeOptions) - if err != nil { - return result, err - } - result.Matched = scrapeResult.Matched - result.Processed = scrapeResult.Processed - result.Errors = scrapeResult.Failed - if c.Organizer != nil { - reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs}) - if err != nil { - return result, err - } - if reclassifyResult != nil { - result.Reclassified = reclassifyResult.Reclassified - result.Errors += len(reclassifyResult.Errors) - c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified) - } - } - if c.Log != nil { - c.Log.Info("repair and rescrape library done", - zap.String("library", libraryID), - zap.Int("repaired", result.Repaired), - zap.Int("reclassified", result.Reclassified), - zap.Int("matched", result.Matched), - zap.Int("processed", result.Processed), - zap.Int("errors", result.Errors)) - } - return result, nil - } - -func (c *Container) invalidateRepairReclassifyCache(ctx context.Context, changed int) { - if c == nil || c.Cache == nil || changed <= 0 { - return - } - c.Cache.DeletePrefix(ctx, "media:") - c.Cache.DeletePrefix(ctx, "stats:") -} diff --git a/internal/service/discover.go b/internal/service/discover.go deleted file mode 100644 index 1729478..0000000 --- a/internal/service/discover.go +++ /dev/null @@ -1,202 +0,0 @@ -// Package service — TMDb discovery (trending / popular). -// -// DiscoverService surfaces curated lists from TMDb so the React home -// page can show "Trending" and "Popular" rails alongside the user's own -// library. All methods gracefully no-op when the TMDb provider is -// disabled. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "go.uber.org/zap" -) - -// DiscoverService talks to TMDb's /trending and /movie/popular endpoints. -type DiscoverService struct { - log *zap.Logger - tmdb *TMDbProvider - client *http.Client - images *ImageProxy - sectionCache *DiscoverSectionCache -} - -// NewDiscoverService is the constructor. -func NewDiscoverService(log *zap.Logger, tmdb *TMDbProvider) *DiscoverService { - return &DiscoverService{ - log: log, - tmdb: tmdb, - client: NewExternalHTTPClient(15 * time.Second), - sectionCache: NewDiscoverSectionCache(6 * time.Hour), - } -} - -// Trending returns the daily trending movies (TMDb /trending/movie/day). -func (d *DiscoverService) Trending(ctx context.Context) ([]Match, error) { - return d.fetch(ctx, "/trending/movie/day") -} - -// Popular returns the popular movies list (TMDb /movie/popular). -func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) { - return d.fetch(ctx, "/movie/popular") -} - -// TMDbSection returns one TMDb rail converted to the common external -// discovery shape used by the multi-source Discover page. -func (d *DiscoverService) TMDbSection(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { - path := tmdbDiscoverPath(key) - if path == "" { - return []ExternalMediaResult{}, nil - } - matches, err := d.Fetch(ctx, path, pages...) - if err != nil { - return nil, err - } - mediaType := "movie" - if strings.Contains(path, "/tv/") { - mediaType = "tv" - } - out := make([]ExternalMediaResult, 0, len(matches)) - for _, item := range matches { - out = append(out, ExternalMediaResult{ - Source: "tmdb", - MediaType: mediaType, - Title: item.Title, - OriginalName: item.OriginalName, - Overview: item.Overview, - PosterURL: item.PosterURL, - BackdropURL: item.BackdropURL, - Year: item.Year, - Rating: item.Rating, - TMDbID: item.TMDbID, - SubscribeKeyword: buildSubscribeKeyword(item.Title, item.Year), - SubscribeAliases: buildSubscribeAliases(item.Title, item.OriginalName, item.Year), - }) - } - return out, nil -} - -// fetch is the shared helper that paginates page=1 only — that's all the -// home page needs and it keeps us under TMDb's 50 rps limit. -func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, error) { - return d.Fetch(ctx, path) -} - -// Fetch is the public entry point used by the multi-section handler. -// It paginates page=1 only — that's all the home page needs and it -// keeps us under TMDb's 50 rps limit. -func (d *DiscoverService) Fetch(ctx context.Context, path string, pages ...int) ([]Match, error) { - if d.tmdb == nil { - return nil, nil - } - - // Resolve API key from config or database - apiKey := d.tmdb.resolveAPIKey(ctx) - if apiKey == "" { - return nil, nil - } - base := d.tmdb.resolveBaseURL(ctx) - - q := url.Values{} - q.Set("api_key", apiKey) - q.Set("language", "zh-CN") - pageNumber := 1 - if len(pages) > 0 && pages[0] > 0 { - pageNumber = pages[0] - } - q.Set("page", strconv.Itoa(pageNumber)) - u := base + path + "?" + q.Encode() - - type result struct { - ID int `json:"id"` - Title string `json:"title"` - Name string `json:"name"` - OriginalTitle string `json:"original_title"` - OriginalName string `json:"original_name"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - FirstAirDate string `json:"first_air_date"` - VoteAverage float32 `json:"vote_average"` - } - type page struct { - Results []result `json:"results"` - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - resp, err := d.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("tmdb %s: %d", path, resp.StatusCode) - } - var p page - if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { - return nil, err - } - out := make([]Match, 0, len(p.Results)) - for _, r := range p.Results { - title := r.Title - if title == "" { - title = r.Name - } - m := Match{ - TMDbID: r.ID, - Title: title, - OriginalName: firstNonEmpty(r.OriginalTitle, r.OriginalName), - Overview: r.Overview, - Rating: r.VoteAverage, - } - if r.PosterPath != "" { - m.PosterURL = d.tmdb.imgCDN + "/w500" + r.PosterPath - } - if r.BackdropPath != "" { - m.BackdropURL = d.tmdb.imgCDN + "/w1280" + r.BackdropPath - } - date := r.ReleaseDate - if date == "" { - date = r.FirstAirDate - } - if len(date) >= 4 { - _, _ = fmt.Sscanf(date[:4], "%d", &m.Year) - } - out = append(out, m) - } - return out, nil -} - -func tmdbDiscoverPath(key string) string { - switch key { - case "tmdb_trending_day", "trending_day": - return "/trending/movie/day" - case "tmdb_trending_week", "trending_week": - return "/trending/movie/week" - case "tmdb_latest_movie", "latest_movie": - return "/movie/now_playing" - case "tmdb_latest_tv", "latest_tv": - return "/tv/on_the_air" - case "tmdb_popular_movie", "popular_movie": - return "/movie/popular" - case "tmdb_popular_tv", "popular_tv": - return "/tv/popular" - case "tmdb_top_rated_movie", "top_rated_movie": - return "/movie/top_rated" - case "tmdb_upcoming_movie", "upcoming_movie": - return "/movie/upcoming" - default: - return "" - } -} diff --git a/internal/service/discover_artwork.go b/internal/service/discover_artwork.go deleted file mode 100644 index a5c8218..0000000 --- a/internal/service/discover_artwork.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "strings" - "sync" - "time" - - "go.uber.org/zap" -) - -const ( - discoverArtworkPrefetchLimit = 384 - discoverArtworkPrefetchConcurrency = 8 - discoverArtworkPrefetchTimeout = 90 * time.Second -) - -func (d *DiscoverService) SetImageProxy(images *ImageProxy) *DiscoverService { - if d != nil { - d.images = images - } - return d -} - -func (d *DiscoverService) WarmMatchArtwork(items []Match) int { - return d.warmArtworkURLs(matchArtworkURLs(items)) -} - -func matchArtworkURLs(items []Match) []string { - urls := make([]string, 0, len(items)*2) - for _, item := range items { - urls = append(urls, item.PosterURL) - } - for _, item := range items { - urls = append(urls, item.BackdropURL) - } - return urls -} - -func (d *DiscoverService) WarmExternalArtwork(items []ExternalMediaResult) int { - return d.warmArtworkURLs(externalArtworkURLs(items)) -} - -func externalArtworkURLs(items []ExternalMediaResult) []string { - urls := make([]string, 0, len(items)*2) - for _, item := range items { - urls = append(urls, item.PosterURL) - } - for _, item := range items { - urls = append(urls, item.BackdropURL) - } - return urls -} - -func (d *DiscoverService) warmArtworkURLs(urls []string) int { - if d == nil || d.images == nil || len(urls) == 0 { - return 0 - } - pending := uniqueDiscoverArtworkURLs(urls, discoverArtworkPrefetchLimit) - if len(pending) == 0 { - return 0 - } - if d.log != nil { - d.log.Debug("discover artwork prefetch scheduled", zap.Int("count", len(pending))) - } - go d.prefetchArtworkURLs(pending) - return len(pending) -} - -func uniqueDiscoverArtworkURLs(urls []string, limit int) []string { - if limit <= 0 { - return nil - } - seen := map[string]struct{}{} - out := make([]string, 0, min(len(urls), limit)) - for _, raw := range urls { - raw = strings.TrimSpace(raw) - if raw == "" || !isHTTPish(raw) { - continue - } - if _, ok := seen[raw]; ok { - continue - } - seen[raw] = struct{}{} - out = append(out, raw) - if len(out) >= limit { - break - } - } - return out -} - -func (d *DiscoverService) prefetchArtworkURLs(urls []string) { - ctx, cancel := context.WithTimeout(context.Background(), discoverArtworkPrefetchTimeout) - defer cancel() - - sem := make(chan struct{}, discoverArtworkPrefetchConcurrency) - var wg sync.WaitGroup - for _, raw := range urls { - select { - case <-ctx.Done(): - return - case sem <- struct{}{}: - } - wg.Add(1) - go func(raw string) { - defer wg.Done() - defer func() { <-sem }() - if err := d.images.PrefetchRemote(ctx, raw); err != nil && d.log != nil { - d.log.Debug("discover artwork prefetch failed", zap.String("url", raw), zap.Error(err)) - } - }(raw) - } - wg.Wait() -} diff --git a/internal/service/discover_artwork_test.go b/internal/service/discover_artwork_test.go deleted file mode 100644 index 06e069d..0000000 --- a/internal/service/discover_artwork_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package service - -import ( - "bytes" - "io" - "net/http" - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" -) - -func TestUniqueDiscoverArtworkURLsFiltersDuplicatesAndLimits(t *testing.T) { - urls := uniqueDiscoverArtworkURLs([]string{ - "", - "/local/poster.jpg", - "https://image.tmdb.org/t/p/w500/a.jpg", - "https://image.tmdb.org/t/p/w500/a.jpg", - "https://image.tmdb.org/t/p/w500/b.jpg", - "https://image.tmdb.org/t/p/w500/c.jpg", - }, 2) - if len(urls) != 2 { - t.Fatalf("len = %d, want 2: %v", len(urls), urls) - } - if urls[0] != "https://image.tmdb.org/t/p/w500/a.jpg" || urls[1] != "https://image.tmdb.org/t/p/w500/b.jpg" { - t.Fatalf("urls = %v", urls) - } -} - -func TestDiscoverArtworkURLsPrioritizePosters(t *testing.T) { - items := []ExternalMediaResult{ - {PosterURL: "https://img.example/a-poster.jpg", BackdropURL: "https://img.example/a-backdrop.jpg"}, - {PosterURL: "https://img.example/b-poster.jpg", BackdropURL: "https://img.example/b-backdrop.jpg"}, - } - urls := externalArtworkURLs(items) - want := []string{ - "https://img.example/a-poster.jpg", - "https://img.example/b-poster.jpg", - "https://img.example/a-backdrop.jpg", - "https://img.example/b-backdrop.jpg", - } - if len(urls) != len(want) { - t.Fatalf("len = %d, want %d: %v", len(urls), len(want), urls) - } - for i := range want { - if urls[i] != want[i] { - t.Fatalf("url[%d] = %q, want %q", i, urls[i], want[i]) - } - } -} - -func TestDiscoverWarmExternalArtworkPrefetchesAndCaches(t *testing.T) { - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - var calls int32 - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/jpeg"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - discover := NewDiscoverService(zap.NewNop(), nil).SetImageProxy(proxy) - poster := "https://image.tmdb.org/t/p/w500/discover-poster.jpg" - backdrop := "https://image.tmdb.org/t/p/w1280/discover-backdrop.jpg" - queued := discover.WarmExternalArtwork([]ExternalMediaResult{ - {Title: "A", PosterURL: poster, BackdropURL: backdrop}, - {Title: "B", PosterURL: poster}, - {Title: "Local", PosterURL: "/media/poster.jpg"}, - }) - if queued != 2 { - t.Fatalf("queued = %d, want 2", queued) - } - for _, raw := range []string{poster, backdrop} { - _, cachePath, _, err := proxy.remoteImageCachePaths(raw) - if err != nil { - t.Fatal(err) - } - waitForDiscoverArtworkCache(t, &calls, 2, cachePath, raw) - } - - callsAfterCache := atomic.LoadInt32(&calls) - queued = discover.WarmExternalArtwork([]ExternalMediaResult{{Title: "Cached", PosterURL: poster, BackdropURL: backdrop}}) - if queued != 2 { - t.Fatalf("queued cached = %d, want 2", queued) - } - time.Sleep(150 * time.Millisecond) - if got := atomic.LoadInt32(&calls); got != callsAfterCache { - t.Fatalf("cached prefetch should not call upstream again: got %d want %d", got, callsAfterCache) - } -} - -func TestDiscoverWarmArtworkNoImageProxyIsNoop(t *testing.T) { - discover := NewDiscoverService(zap.NewNop(), nil) - if got := discover.WarmMatchArtwork([]Match{{PosterURL: "https://image.tmdb.org/t/p/w500/a.jpg"}}); got != 0 { - t.Fatalf("queued = %d, want 0 without image proxy", got) - } -} - -func TestTMDbDiscoverPathIncludesLatestSections(t *testing.T) { - cases := map[string]string{ - "tmdb_latest_movie": "/movie/now_playing", - "latest_movie": "/movie/now_playing", - "tmdb_latest_tv": "/tv/on_the_air", - "latest_tv": "/tv/on_the_air", - "tmdb_upcoming_movie": "/movie/upcoming", - } - for key, want := range cases { - if got := tmdbDiscoverPath(key); got != want { - t.Fatalf("tmdbDiscoverPath(%q) = %q, want %q", key, got, want) - } - } -} - -func waitForDiscoverArtworkCache(t *testing.T, calls *int32, wantCalls int32, cachePath, raw string) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if atomic.LoadInt32(calls) >= wantCalls { - if _, err := os.Stat(cachePath); err == nil { - return - } - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("expected cached artwork %q after %d upstream calls: %v", raw, atomic.LoadInt32(calls), os.ErrNotExist) -} diff --git a/internal/service/discover_section_cache.go b/internal/service/discover_section_cache.go deleted file mode 100644 index 63a949c..0000000 --- a/internal/service/discover_section_cache.go +++ /dev/null @@ -1,104 +0,0 @@ -package service - -import ( - "fmt" - "sync" - "time" -) - -// DiscoverSectionCache keeps the last good discover rail in memory so a slow -// upstream provider does not turn a populated page into empty rows. -type DiscoverSectionCache struct { - ttl time.Duration - mu sync.RWMutex - entries map[string]discoverSectionCacheEntry -} - -type discoverSectionCacheEntry struct { - items []ExternalMediaResult - storedAt time.Time -} - -func NewDiscoverSectionCache(ttl time.Duration) *DiscoverSectionCache { - if ttl <= 0 { - ttl = 6 * time.Hour - } - return &DiscoverSectionCache{ - ttl: ttl, - entries: map[string]discoverSectionCacheEntry{}, - } -} - -func (d *DiscoverService) RememberSection(key string, page int, items []ExternalMediaResult) { - if d == nil || d.sectionCache == nil || len(items) == 0 { - return - } - d.sectionCache.Set(key, page, items) -} - -func (d *DiscoverService) CachedSection(key string, page int) ([]ExternalMediaResult, bool) { - if d == nil || d.sectionCache == nil { - return nil, false - } - return d.sectionCache.Get(key, page) -} - -func (c *DiscoverSectionCache) Set(key string, page int, items []ExternalMediaResult) { - if c == nil || key == "" || page < 1 || len(items) == 0 { - return - } - c.mu.Lock() - defer c.mu.Unlock() - c.entries[discoverSectionCacheKey(key, page)] = discoverSectionCacheEntry{ - items: cloneExternalMediaResults(items), - storedAt: time.Now(), - } -} - -func (c *DiscoverSectionCache) Get(key string, page int) ([]ExternalMediaResult, bool) { - if c == nil || key == "" || page < 1 { - return nil, false - } - c.mu.RLock() - entry, ok := c.entries[discoverSectionCacheKey(key, page)] - c.mu.RUnlock() - if !ok || time.Since(entry.storedAt) > c.ttl || len(entry.items) == 0 { - return nil, false - } - return cloneExternalMediaResults(entry.items), true -} - -func discoverSectionCacheKey(key string, page int) string { - return fmt.Sprintf("%s:%d", key, page) -} - -func cloneExternalMediaResults(items []ExternalMediaResult) []ExternalMediaResult { - out := make([]ExternalMediaResult, len(items)) - for i, item := range items { - out[i] = item - out[i].SubscribeAliases = cloneStrings(item.SubscribeAliases) - out[i].MissingEpisodes = cloneInts(item.MissingEpisodes) - out[i].Languages = cloneStrings(item.Languages) - out[i].Countries = cloneStrings(item.Countries) - out[i].Genres = cloneStrings(item.Genres) - } - return out -} - -func cloneStrings(items []string) []string { - if len(items) == 0 { - return nil - } - out := make([]string, len(items)) - copy(out, items) - return out -} - -func cloneInts(items []int) []int { - if len(items) == 0 { - return nil - } - out := make([]int, len(items)) - copy(out, items) - return out -} diff --git a/internal/service/discover_section_cache_test.go b/internal/service/discover_section_cache_test.go deleted file mode 100644 index 97d511f..0000000 --- a/internal/service/discover_section_cache_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package service - -import ( - "testing" - "time" -) - -func TestDiscoverSectionCacheReturnsClone(t *testing.T) { - cache := NewDiscoverSectionCache(time.Hour) - cache.Set("douban_hot_movie", 1, []ExternalMediaResult{{ - Title: "第一部", - SubscribeAliases: []string{"别名"}, - MissingEpisodes: []int{1}, - Languages: []string{"zh"}, - }}) - - got, ok := cache.Get("douban_hot_movie", 1) - if !ok || len(got) != 1 || got[0].Title != "第一部" { - t.Fatalf("cached section = %#v, %v", got, ok) - } - - got[0].Title = "被修改" - got[0].SubscribeAliases[0] = "别名被改" - got[0].MissingEpisodes[0] = 9 - got[0].Languages[0] = "en" - again, ok := cache.Get("douban_hot_movie", 1) - if !ok || - again[0].Title != "第一部" || - again[0].SubscribeAliases[0] != "别名" || - again[0].MissingEpisodes[0] != 1 || - again[0].Languages[0] != "zh" { - t.Fatalf("cache should return a clone, got %#v", again) - } -} - -func TestDiscoverSectionCacheExpires(t *testing.T) { - cache := NewDiscoverSectionCache(time.Nanosecond) - cache.Set("tmdb_latest_movie", 1, []ExternalMediaResult{{Title: "旧数据"}}) - time.Sleep(time.Millisecond) - - if got, ok := cache.Get("tmdb_latest_movie", 1); ok || len(got) != 0 { - t.Fatalf("expired cache should miss, got %#v", got) - } -} diff --git a/internal/service/douban_discover.go b/internal/service/douban_discover.go deleted file mode 100644 index f938762..0000000 --- a/internal/service/douban_discover.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package service — Douban discovery rails. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" -) - -// Discover returns public Douban movie/TV rails. Douban does not require a -// formal API key here; these are the same public web endpoints the site uses. -func (d *DoubanProvider) Discover(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { - doubanType := "movie" - tag := "热门" - switch key { - case "douban_hot_movie": - doubanType = "movie" - tag = "热门" - case "douban_top_movie": - doubanType = "movie" - tag = "高分" - case "douban_hot_tv": - doubanType = "tv" - tag = "热门" - default: - return []ExternalMediaResult{}, nil - } - q := url.Values{} - q.Set("type", doubanType) - q.Set("tag", tag) - q.Set("sort", "recommend") - q.Set("page_limit", "24") - pageNumber := 1 - if len(pages) > 0 && pages[0] > 0 { - pageNumber = pages[0] - } - q.Set("page_start", strconv.Itoa((pageNumber-1)*24)) - u := "https://movie.douban.com/j/search_subjects?" + q.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - d.setHeaders(req) - resp, err := d.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("douban discover: %d", resp.StatusCode) - } - var page struct { - Subjects []struct { - ID string `json:"id"` - Title string `json:"title"` - Rate string `json:"rate"` - Cover string `json:"cover"` - URL string `json:"url"` - } `json:"subjects"` - } - if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { - return nil, err - } - out := make([]ExternalMediaResult, 0, len(page.Subjects)) - mediaType := "movie" - if doubanType == "tv" { - mediaType = "tv" - } - for _, subject := range page.Subjects { - if strings.TrimSpace(subject.Title) == "" { - continue - } - rating, _ := strconv.ParseFloat(subject.Rate, 32) - out = append(out, ExternalMediaResult{ - Source: "douban", - MediaType: mediaType, - Title: subject.Title, - PosterURL: subject.Cover, - Rating: float32(rating), - DoubanID: subject.ID, - SubscribeKeyword: subject.Title, - SubscribeAliases: buildSubscribeAliases(subject.Title, "", 0), - }) - } - return out, nil -} diff --git a/internal/service/download_active_paths.go b/internal/service/download_active_paths.go deleted file mode 100644 index f965e0c..0000000 --- a/internal/service/download_active_paths.go +++ /dev/null @@ -1,36 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" -) - -const activeDownloadSnapshotFallbackAge = 2 * time.Minute - -func (d *DownloadService) ActiveDownloadPaths(ctx context.Context) []string { - if d == nil { - return nil - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - live = d.LiveTorrentSnapshot(activeDownloadSnapshotFallbackAge) - if d.log != nil && len(live) == 0 { - d.log.Debug("active download guard could not list download clients and has no fresh snapshot", zap.Error(err)) - } - } - return activeDownloadPathCandidates(live, d.downloadPathMappings(ctx)) -} - -func (d *DownloadService) downloadPathMappings(ctx context.Context) map[string]string { - mappings := map[string]string{ - "/var/apps/qBittorrent/shares/qBittorrent/Download": "/downloads", - "/data/qBittorrent/downloads": "/downloads", - "/downloads/qBittorrent": "/downloads", - } - for clientPrefix, localPrefix := range d.userPathMappings(ctx) { - mappings[clientPrefix] = localPrefix - } - return mappings -} diff --git a/internal/service/download_adapter.go b/internal/service/download_adapter.go deleted file mode 100644 index 275fae3..0000000 --- a/internal/service/download_adapter.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package service 定义下载适配器接口和通用数据结构。 -package service - -import ( - "context" - "time" -) - -// DownloadAdapter 定义下载客户端的统一接口。 -// 所有下载客户端(qBittorrent / Transmission / Aria2)必须实现此接口。 -type DownloadAdapter interface { - // Initialize 使用配置初始化客户端连接。 - Initialize(ctx context.Context, cfg DownloadClientConfig) error - // Ping 测试客户端连接是否可用。 - Ping(ctx context.Context) error - // AddTorrent 通过 URL(磁力链接或种子 URL)添加下载任务。 - AddTorrent(ctx context.Context, url, savePath string) (string, error) - // AddMagnet 通过磁力链接添加下载任务。 - AddMagnet(ctx context.Context, magnet, savePath string) (string, error) - // Pause 暂停指定下载任务。 - Pause(ctx context.Context, hash string) error - // Resume 恢复指定下载任务。 - Resume(ctx context.Context, hash string) error - // Remove 移除指定下载任务,deleteFiles 控制是否同时删除文件。 - Remove(ctx context.Context, hash string, deleteFiles bool) error - // List 列出所有或过滤后的种子任务。filter 可为空字符串表示全部。 - List(ctx context.Context, filter string) ([]TorrentInfo, error) - // GetInfo 获取指定种子的详细信息。 - GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) -} - -// TorrentFileDownloadAdapter is implemented by clients that can accept the -// application-fetched .torrent payload instead of fetching a private URL. -type TorrentFileDownloadAdapter interface { - AddTorrentFile(ctx context.Context, data []byte, name, savePath string) (string, error) -} - -// CategorizedTorrentDownloadAdapter is implemented by qBittorrent, whose -// native category is part of MediaStationGo's automatic classification flow. -type CategorizedTorrentDownloadAdapter interface { - AddTorrentWithCategory(ctx context.Context, url, savePath, category string) (string, error) - AddTorrentFileWithCategory(ctx context.Context, data []byte, name, savePath, category string) (string, error) -} - -// TorrentRelocateAdapter is intentionally qBittorrent-only: qB can move -// payload data while preserving its seeding task through setLocation. -type TorrentRelocateAdapter interface { - Relocate(ctx context.Context, hash, location string) error -} - -// TorrentInfo 是各种下载客户端的种子信息的统一表示。 -type TorrentInfo struct { - Hash string `json:"hash"` - Name string `json:"name"` - Size int64 `json:"size"` - Progress float64 `json:"progress"` - DLSpeed int64 `json:"dl_speed"` - UPSpeed int64 `json:"up_speed"` - State string `json:"state"` - SavePath string `json:"save_path"` - NumSeeds int `json:"num_seeds"` - NumLeechs int `json:"num_leechs"` - AddedOn time.Time `json:"added_on"` - Category string `json:"category"` - Tags string `json:"tags"` - ContentPath string `json:"content_path"` - CompletionOn int64 `json:"completion_on"` -} - -// DownloadClientConfig 是下载客户端的连接配置。 -type DownloadClientConfig struct { - Host string `json:"host"` - Username string `json:"username"` - Password string `json:"password"` - Extra map[string]string `json:"extra,omitempty"` -} - -// AdapterFactory 根据客户端类型创建适配器实例。 -func AdapterFactory(clientType string) DownloadAdapter { - switch clientType { - case "qbittorrent": - return NewQBitAdapter() - case "transmission": - return NewTransmissionAdapter() - case "aria2": - return NewAria2Adapter() - default: - return nil - } -} diff --git a/internal/service/download_adapter_file_test.go b/internal/service/download_adapter_file_test.go deleted file mode 100644 index c7a9479..0000000 --- a/internal/service/download_adapter_file_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package service - -import ( - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "testing" -) - -func TestAria2AdapterRemoveClearsTaskAndResult(t *testing.T) { - var methods []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - if req.Method != "aria2.getVersion" { - methods = append(methods, req.Method) - } - result := interface{}("OK") - if req.Method == "aria2.getVersion" { - result = map[string]interface{}{"version": "1.37"} - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "result": result}) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - if err := adapter.Remove(t.Context(), "aria2-gid", true); err != nil { - t.Fatal(err) - } - if len(methods) != 2 || methods[0] != "aria2.remove" || methods[1] != "aria2.removeDownloadResult" { - t.Fatalf("methods = %#v", methods) - } -} - -func TestAria2AdapterListReportsConnectionFailures(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - response := map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "result": map[string]interface{}{"version": "1.37"}} - if req.Method != "aria2.getVersion" { - response = map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "error": map[string]interface{}{"code": 1, "message": "offline"}} - } - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - _, err := adapter.List(t.Context(), "") - if err == nil || !errors.Is(err, errAria2ListUnavailable) { - t.Fatalf("err = %v", err) - } -} - -func TestTransmissionAdapterAddsTorrentFileAsMetainfo(t *testing.T) { - payload := []byte("d4:infod4:name5:movieee") - var added map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - if req.Method != "torrent-add" { - t.Errorf("transmission method = %q, want torrent-add", req.Method) - return - } - added = req.Arguments - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{ - Result: "success", - Arguments: map[string]interface{}{ - "torrent-added": map[string]interface{}{"hashString": "transmission-file-hash"}, - }, - }) - })) - defer server.Close() - - adapter := NewTransmissionAdapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - hash, err := adapter.AddTorrentFile(t.Context(), payload, "movie.torrent", "/downloads/movies") - if err != nil { - t.Fatal(err) - } - if hash != "transmission-file-hash" { - t.Fatalf("hash = %q", hash) - } - if added["metainfo"] != base64.StdEncoding.EncodeToString(payload) { - t.Fatalf("metainfo = %#v", added["metainfo"]) - } - if added["download-dir"] != "/downloads/movies" { - t.Fatalf("download-dir = %#v", added["download-dir"]) - } - if _, ok := added["filename"]; ok { - t.Fatalf("torrent file request unexpectedly included filename: %#v", added) - } -} - -func TestAria2AdapterAddsTorrentFileWithAddTorrent(t *testing.T) { - payload := []byte("d4:infod4:name5:movieee") - var addParams []interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - result := interface{}(map[string]interface{}{"version": "1.37"}) - if req.Method == "aria2.addTorrent" { - addParams = req.Params - result = "aria2-gid" - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": result, - }) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL, Password: "secret"}); err != nil { - t.Fatal(err) - } - gid, err := adapter.AddTorrentFile(t.Context(), payload, "movie.torrent", "/downloads/movies") - if err != nil { - t.Fatal(err) - } - if gid != "aria2-gid" { - t.Fatalf("gid = %q", gid) - } - if len(addParams) != 4 { - t.Fatalf("aria2 params = %#v", addParams) - } - if addParams[0] != "token:secret" || addParams[1] != base64.StdEncoding.EncodeToString(payload) { - t.Fatalf("aria2 params = %#v", addParams) - } - options, ok := addParams[3].(map[string]interface{}) - if !ok || options["dir"] != "/downloads/movies" { - t.Fatalf("aria2 options = %#v", addParams[3]) - } -} - -func TestQBitAdapterAddsTorrentFileWithCategory(t *testing.T) { - payload := []byte("d4:infod4:name5:movieee") - var gotCategory, gotSavePath, gotName string - var gotPayload []byte - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - reader, err := r.MultipartReader() - if err != nil { - t.Errorf("multipart reader: %v", err) - return - } - for { - part, err := reader.NextPart() - if err == io.EOF { - break - } - if err != nil { - t.Errorf("multipart next part: %v", err) - return - } - value, _ := io.ReadAll(part) - switch part.FormName() { - case "torrents": - gotName = part.FileName() - gotPayload = value - case "savepath": - gotSavePath = string(value) - case "category": - gotCategory = string(value) - } - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - adapter := NewQBitAdapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - hash, err := adapter.AddTorrentFileWithCategory(t.Context(), payload, "movie.torrent", "/downloads/movies", "Movies") - if err != nil { - t.Fatal(err) - } - if hash != torrentInfoHash(payload) { - t.Fatalf("hash = %q, want %q", hash, torrentInfoHash(payload)) - } - if gotName != "movie.torrent" || string(gotPayload) != string(payload) || gotSavePath != "/downloads/movies" || gotCategory != "Movies" { - t.Fatalf("multipart = name %q payload %q savepath %q category %q", gotName, gotPayload, gotSavePath, gotCategory) - } -} diff --git a/internal/service/download_add.go b/internal/service/download_add.go deleted file mode 100644 index 96df9af..0000000 --- a/internal/service/download_add.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "context" - "errors" - "path" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// DownloadTaskMeta carries public display metadata for a download. It is -// deliberately separate from the private torrent URL so API responses never -// need to expose tracker tokens. -type DownloadTaskMeta struct { - SubscriptionID string - Title string - PosterURL string - BackdropURL string - Overview string - MediaType string - MediaCategory string - SourceCategory string - OriginalName string - OriginalLanguage string - Year int - Rating float32 - Genres string - AllowExistingLibrary bool -} - -type downloadAddRequest struct { - title string - savePath string - qbitCategory string - meta DownloadTaskMeta -} - -// AddDownload accepts a magnet URL / HTTP URL and persists a tracking row. -func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) { - return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{}) -} - -func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) { - req, err := d.prepareDownloadAdd(ctx, urlStr, savePath, meta) - if err != nil { - return nil, err - } - if !req.meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, req.title) { - return nil, ErrMediaAlreadyInLibrary - } - if existing, ok := d.findExistingDownloadTask(ctx, req); ok { - d.linkExistingDownloadTaskToSubscription(ctx, existing, req) - return existing, ErrDownloadAlreadyExists - } - _ = d.ReloadConfig(ctx) - target, err := d.defaultDownloadTarget(ctx) - if err != nil { - return nil, d.defaultDownloaderNotConfiguredError(ctx) - } - if liveTorrent, ok := d.findLiveTorrentByIdentity(ctx, urlStr, req); ok { - existingTarget := target - if strings.TrimSpace(liveTorrent.ClientID) != "" { - existingTarget = downloadTarget{clientID: liveTorrent.ClientID, typ: firstNonEmpty(liveTorrent.Source, target.typ)} - } - task, err := d.createTask(ctx, userID, urlStr, req.savePath, req.meta, existingTarget, liveTorrent.Hash) - if err != nil { - return nil, err - } - if strings.TrimSpace(req.meta.SubscriptionID) != "" { - return task, nil - } - return task, ErrDownloadAlreadyExists - } - externalID, err := d.addPreparedDownloadToClient(ctx, urlStr, &req, target) - if err != nil { - if errors.Is(err, ErrDownloadAlreadyExists) && strings.TrimSpace(req.meta.SubscriptionID) != "" { - return d.createTask(ctx, userID, urlStr, req.savePath, req.meta, target, externalID) - } - return nil, err - } - return d.createTask(ctx, userID, urlStr, req.savePath, req.meta, target, externalID) -} - -func (d *DownloadService) prepareDownloadAdd(ctx context.Context, urlStr, savePath string, meta DownloadTaskMeta) (downloadAddRequest, error) { - if urlStr == "" { - return downloadAddRequest{}, errors.New("empty url") - } - title := strings.TrimSpace(meta.Title) - if title == "" { - title = publicDownloadTitle(urlStr) - meta.Title = title - } - autoClassify := downloadSmartClassifyEnabled(ctx, d.repo, d.organizer) - savePath, resolvedCategory := d.resolveDownloadSavePath(ctx, savePath, meta, autoClassify) - if !autoClassify { - meta.MediaCategory = "" - } else if strings.TrimSpace(meta.MediaCategory) == "" { - meta.MediaCategory = resolvedCategory - } - return downloadAddRequest{ - title: title, - savePath: savePath, - qbitCategory: strings.TrimSpace(meta.MediaCategory), - meta: meta, - }, nil -} - -func (d *DownloadService) addPreparedDownloadToClient(ctx context.Context, urlStr string, req *downloadAddRequest, target downloadTarget) (string, error) { - var siteFetchErr error - if d.site != nil { - if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == nil { - return d.addTorrentFileToTarget(ctx, data, name, req, target) - } else { - siteFetchErr = err - } - } - externalID, err := d.addTorrentURLToTarget(ctx, urlStr, req, target) - if err != nil { - return externalID, joinTorrentFetchError(err, siteFetchErr) - } - return externalID, nil -} - -func (d *DownloadService) addTorrentFileToTarget(ctx context.Context, data []byte, name string, req *downloadAddRequest, target downloadTarget) (string, error) { - if target.legacyQB { - if err := d.qb.AddTorrentFileWithCategory(ctx, data, name, req.savePath, req.qbitCategory); err != nil { - return "", err - } - return torrentInfoHash(data), nil - } - if categorized, ok := target.adapter.(CategorizedTorrentDownloadAdapter); ok { - externalID, err := categorized.AddTorrentFileWithCategory(ctx, data, name, req.savePath, req.qbitCategory) - setFetchedTorrentTitle(req, name, err) - return externalID, err - } - fileAdapter, ok := target.adapter.(TorrentFileDownloadAdapter) - if !ok { - return "", errors.New("configured downloader does not accept torrent files") - } - externalID, err := fileAdapter.AddTorrentFile(ctx, data, name, req.savePath) - setFetchedTorrentTitle(req, name, err) - return externalID, err -} - -func (d *DownloadService) addTorrentURLToTarget(ctx context.Context, urlStr string, req *downloadAddRequest, target downloadTarget) (string, error) { - if target.legacyQB { - if err := d.qb.AddTorrentWithCategory(ctx, urlStr, req.savePath, req.qbitCategory); err != nil { - return "", err - } - return torrentURLInfoHash(urlStr), nil - } - if categorized, ok := target.adapter.(CategorizedTorrentDownloadAdapter); ok { - return categorized.AddTorrentWithCategory(ctx, urlStr, req.savePath, req.qbitCategory) - } - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(urlStr)), "magnet:") { - return target.adapter.AddMagnet(ctx, urlStr, req.savePath) - } - return target.adapter.AddTorrent(ctx, urlStr, req.savePath) -} - -func setFetchedTorrentTitle(req *downloadAddRequest, name string, addErr error) { - if addErr == nil && req != nil && strings.TrimSpace(req.meta.Title) == "" { - req.meta.Title = strings.TrimSuffix(name, path.Ext(name)) - } -} - -func joinTorrentFetchError(addErr, fetchErr error) error { - if fetchErr != nil && !strings.Contains(fetchErr.Error(), "no matching PT site") { - return errors.Join(addErr, fetchErr) - } - return addErr -} - -func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitSavePath string, meta DownloadTaskMeta, autoClassify bool) (string, string) { - if strings.TrimSpace(explicitSavePath) != "" { - if !autoClassify { - return explicitSavePath, "" - } - return explicitSavePath, strings.TrimSpace(meta.MediaCategory) - } - base := downloadDefaultSaveRoot(ctx, d.repo) - if strings.TrimSpace(base) == "" { - return "", strings.TrimSpace(meta.MediaCategory) - } - mediaType := normalizeMediaType(meta.MediaType, meta.Title, meta.SourceCategory) - category := strings.TrimSpace(meta.MediaCategory) - if category == "" { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: meta.Title, - Category: meta.SourceCategory, - }, downloadCategoryMap(d.organizer)) - } - if !autoClassify || category == "" { - return base, "" - } - return downloadSavePathCategoryRoot(base, sanitizeFilename(category)), category -} - -func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta, target downloadTarget, externalID string) (*model.DownloadTask, error) { - title := strings.TrimSpace(meta.Title) - if title == "" { - title = publicDownloadTitle(urlStr) - } - t := &model.DownloadTask{ - UserID: userID, - SubscriptionID: strings.TrimSpace(meta.SubscriptionID), - DownloadClientID: target.clientID, - ExternalID: strings.TrimSpace(externalID), - Source: target.typ, - URL: urlStr, - Title: title, - PosterURL: meta.PosterURL, - BackdropURL: meta.BackdropURL, - Overview: meta.Overview, - SavePath: savePath, - MediaType: meta.MediaType, - MediaCategory: meta.MediaCategory, - OriginalName: meta.OriginalName, - OriginalLanguage: meta.OriginalLanguage, - Year: meta.Year, - Rating: meta.Rating, - Genres: meta.Genres, - Status: "queued", - AllowExistingLibrary: meta.AllowExistingLibrary, - } - if err := d.repo.Download.Create(ctx, t); err != nil { - return nil, err - } - return t, nil -} diff --git a/internal/service/download_add_classification_test.go b/internal/service/download_add_classification_test.go deleted file mode 100644 index 1e056ae..0000000 --- a/internal/service/download_add_classification_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadWithMetaAutoClassifiesSavePathAndQBitCategory(t *testing.T) { - var addCalls int32 - var gotSavePath string - var gotCategory string - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"auto123","name":"声生不息 S01E01","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - if err := r.ParseMultipartForm(1024 * 1024); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - gotSavePath = r.FormValue("savepath") - gotCategory = r.FormValue("category") - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", "/downloads"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=%E5%A3%B0%E7%94%9F%E4%B8%8D%E6%81%AF+S01E01", "", DownloadTaskMeta{ - Title: "声生不息 S01E01", - SourceCategory: "综艺", - }) - if err != nil { - t.Fatal(err) - } - wantPath := filepath.Join("/downloads", "综艺") - if task.SavePath != wantPath { - t.Fatalf("task save path = %q, want %q", task.SavePath, wantPath) - } - if gotSavePath != wantPath { - t.Fatalf("qb savepath = %q, want %q", gotSavePath, wantPath) - } - if gotCategory != "综艺" { - t.Fatalf("qb category = %q, want 综艺", gotCategory) - } -} - -func TestDownloadSavePathCategoryRootKeepsWindowsClientSeparators(t *testing.T) { - if got := downloadSavePathCategoryRoot(`F:\downloads`, "国产剧"); got != `F:\downloads\国产剧` { - t.Fatalf("downloadSavePathCategoryRoot() = %q, want Windows qB path", got) - } - if got := downloadSavePathCategoryRoot(`F:\downloads\国产剧`, "国产剧"); got != `F:\downloads\国产剧` { - t.Fatalf("downloadSavePathCategoryRoot() duplicated category: %q", got) - } - if got := downloadSavePathCategoryRoot(`/downloads`, "国产剧"); got != filepath.Join(`/downloads`, "国产剧") { - t.Fatalf("downloadSavePathCategoryRoot() = %q, want local path", got) - } -} - -func TestTranslateClientPathMapsWindowsQBitPathToContainerDownloadPath(t *testing.T) { - root := t.TempDir() - containerDownloads := filepath.Join(root, "downloads") - want := filepath.Join(containerDownloads, "国产剧", "Show.S01E01.mkv") - if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(want, []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - - got := translateClientPath(`F:\downloads\国产剧\Show.S01E01.mkv`, map[string]string{ - `F:\downloads`: containerDownloads, - }) - if got != want { - t.Fatalf("translateClientPath() = %q, want %q", got, want) - } -} - -func TestAddDownloadWithMetaCanDisableAutoClassifiedSavePath(t *testing.T) { - var addCalls int32 - var gotSavePath string - var gotCategory string - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"auto456","name":"声生不息 S01E01","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - if err := r.ParseMultipartForm(1024 * 1024); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - gotSavePath = r.FormValue("savepath") - gotCategory = r.FormValue("category") - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", "/downloads"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), DownloadSmartClassifySettingKey, "false"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=%E5%A3%B0%E7%94%9F%E4%B8%8D%E6%81%AF+S01E01", "", DownloadTaskMeta{ - Title: "声生不息 S01E01", - SourceCategory: "综艺", - }) - if err != nil { - t.Fatal(err) - } - if task.SavePath != "/downloads" { - t.Fatalf("task save path = %q, want /downloads", task.SavePath) - } - if gotSavePath != "/downloads" { - t.Fatalf("qb savepath = %q, want /downloads", gotSavePath) - } - if gotCategory != "" { - t.Fatalf("qb category = %q, want empty", gotCategory) - } -} diff --git a/internal/service/download_add_dedup.go b/internal/service/download_add_dedup.go deleted file mode 100644 index 081de44..0000000 --- a/internal/service/download_add_dedup.go +++ /dev/null @@ -1,196 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) findExistingDownloadTask(ctx context.Context, req downloadAddRequest) (*model.DownloadTask, bool) { - key := downloadTaskIdentityKey(req.title) - if key == "" || d == nil || d.repo == nil || d.repo.Download == nil { - return nil, false - } - rows, err := d.repo.Download.List(ctx) - if err != nil { - return nil, false - } - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - for i := range rows { - if subscriptionID != "" { - if !downloadTaskBlocksReadd(rows[i].Status) { - continue - } - if !downloadTaskInSubscriptionScope(rows[i], req) { - continue - } - if !d.subscriptionDownloadTaskStillLive(ctx, rows[i]) { - continue - } - } else if !downloadTaskBlocksDuplicate(rows[i].Status) { - continue - } - current := downloadTaskIdentityKey(rows[i].Title) - if downloadTaskCoversAddRequest(rows[i].Title, req) || current == key { - return &rows[i], true - } - } - return nil, false -} - -func (d *DownloadService) subscriptionDownloadTaskStillLive(ctx context.Context, row model.DownloadTask) bool { - live, ok := d.liveTorrentSnapshot(30 * time.Second) - if !ok && d != nil { - var err error - live, err = d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - return true - } - ok = true - } - if !ok { - return true - } - for _, torrent := range live { - if downloadTaskMatchesLiveTorrent(row, torrent) { - return true - } - } - return false -} - -func downloadTaskMatchesLiveTorrent(row model.DownloadTask, torrent QBitTorrent) bool { - if strings.TrimSpace(row.DownloadClientID) != "" && strings.TrimSpace(torrent.ClientID) != "" && row.DownloadClientID != torrent.ClientID { - return false - } - if strings.TrimSpace(row.ExternalID) != "" { - return strings.EqualFold(strings.TrimSpace(row.ExternalID), strings.TrimSpace(torrent.Hash)) - } - torrentName := strings.TrimSpace(torrent.Name) - if torrentName == "" { - return false - } - req := downloadAddRequest{ - title: row.Title, - savePath: row.SavePath, - meta: DownloadTaskMeta{ - SubscriptionID: row.SubscriptionID, - }, - } - if downloadTaskCoversAddRequest(torrentName, req) { - return true - } - rowKey := downloadTaskIdentityKey(row.Title) - torrentKey := downloadTaskIdentityKey(torrentName) - if rowKey != "" && torrentKey != "" { - return rowKey == torrentKey - } - if len(episodeRefsFromTitle(row.Title)) > 0 || len(episodeRefsFromTitle(torrentName)) > 0 { - return false - } - rowTorrentKey := normalizeTorrentName(row.Title) - liveTorrentKey := normalizeTorrentName(torrentName) - return rowTorrentKey != "" && rowTorrentKey == liveTorrentKey -} - -func downloadTaskCoversAddRequest(existing string, req downloadAddRequest) bool { - if subscriptionRequestHasExplicitEpisodes(req) { - return downloadExplicitEpisodesCoverRequest(existing, req.title) - } - return downloadTitleCoversRequest(existing, req.title) -} - -func subscriptionRequestHasExplicitEpisodes(req downloadAddRequest) bool { - return strings.TrimSpace(req.meta.SubscriptionID) != "" && len(episodeRefsFromTitle(req.title)) > 0 -} - -func downloadExplicitEpisodesCoverRequest(existing, requested string) bool { - current := parseDownloadMediaIdentity(existing) - want := parseDownloadMediaIdentity(requested) - if current.TitleKey == "" || want.TitleKey == "" { - return false - } - if current.TitleKey != want.TitleKey { - return false - } - if current.Year > 0 && want.Year > 0 && current.Year != want.Year { - return false - } - if len(current.Episodes) == 0 || len(want.Episodes) == 0 { - return false - } - currentEpisodes := map[string]struct{}{} - for _, ref := range current.Episodes { - currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - for _, ref := range want.Episodes { - if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequest) bool { - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - if subscriptionID == "" { - return true - } - rowSubscriptionID := strings.TrimSpace(row.SubscriptionID) - if rowSubscriptionID != "" { - return rowSubscriptionID == subscriptionID - } - rowSavePath := strings.TrimSpace(row.SavePath) - requestSavePath := strings.TrimSpace(req.savePath) - if rowSavePath == "" || requestSavePath == "" { - return false - } - return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath) -} - -func (d *DownloadService) findLiveTorrentByIdentity(ctx context.Context, downloadURL string, req downloadAddRequest) (QBitTorrent, bool) { - query := downloadTaskIdentityKey(req.title) - requestHash := torrentURLInfoHash(downloadURL) - if query == "" && requestHash == "" { - return QBitTorrent{}, false - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil { - if len(live) == 0 { - return QBitTorrent{}, false - } - } - for _, torrent := range live { - if !torrentInDownloadRequestScope(torrent, req) { - continue - } - if requestHash != "" && strings.EqualFold(requestHash, strings.TrimSpace(torrent.Hash)) { - return torrent, true - } - if downloadTaskCoversAddRequest(torrent.Name, req) { - return torrent, true - } - current := downloadTaskIdentityKey(torrent.Name) - if current == "" { - continue - } - if current == query { - return torrent, true - } - } - return QBitTorrent{}, false -} - -func torrentInDownloadRequestScope(torrent QBitTorrent, req downloadAddRequest) bool { - if strings.TrimSpace(req.meta.SubscriptionID) == "" { - return true - } - requestSavePath := strings.TrimSpace(req.savePath) - torrentSavePath := strings.TrimSpace(torrent.SavePath) - if requestSavePath == "" || torrentSavePath == "" { - return false - } - return sameOrChildPath(torrentSavePath, requestSavePath) || sameOrChildPath(requestSavePath, torrentSavePath) -} diff --git a/internal/service/download_add_dedup_test.go b/internal/service/download_add_dedup_test.go deleted file mode 100644 index 15bcace..0000000 --- a/internal/service/download_add_dedup_test.go +++ /dev/null @@ -1,332 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSingleEpisodeTask(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old", - Title: "Archives The Nanyang Mystery 2026 S01E07 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because existing task covers only E07", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSeasonOnlyTask(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - SubscriptionID: "sub-nanyang", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-season", - Title: "Archives The Nanyang Mystery 2026 S01 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Archives+The+Nanyang+Mystery+2026+S01E09-E10", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because season-only task does not prove E09-E10 exists", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaDoesNotDedupSubscriptionRangeAgainstCompletePackTask(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - SubscriptionID: "sub-nanyang", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-complete", - Title: "Archives The Nanyang Mystery 2026 S01 Complete 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:fafafafafafafafafafafafafafafafafafafafa&dn=Archives+The+Nanyang+Mystery+2026+S01E29-E33", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because complete-pack history does not prove missing range exists", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaTracksExistingQBTorrentForSubscription(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - hash := torrentInfoHash(torrentData) - if hash == "" { - t.Fatal("expected fixture info hash") - } - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/fixture.torrent": - w.Header().Set("Content-Type", "application/x-bittorrent") - _, _ = w.Write(torrentData) - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL","save_path":"/downloads/tv"}]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", qb.URL+"/fixture.torrent", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want tracking task for existing qB torrent", err) - } - if task == nil || task.SubscriptionID != "sub-nanyang" { - t.Fatalf("task = %#v, want subscription tracking task", task) - } - if task.DownloadClientID != legacyQBitDownloadClientID || task.ExternalID != hash || task.Source != "qbittorrent" { - t.Fatalf("tracked downloader identity = %#v", task) - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0 because infohash already exists", got) - } -} - -func TestDownloadTitleCoversRequestKeepsCompletePackDedup(t *testing.T) { - if !downloadTitleCoversRequest("Archives The Nanyang Mystery 2026 S01 Complete 2160p WEB-DL", "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL") { - t.Fatal("complete pack should cover requested episode range") - } - if downloadTitleCoversRequest("Archives The Nanyang Mystery 2026 S01 2160p WEB-DL", "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL") { - t.Fatal("season-only title must not cover requested episode range") - } -} - -func TestAddDownloadWithMetaScopesSubscriptionDedupBySubscriptionOrSavePath(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - for _, existing := range []model.DownloadTask{ - { - UserID: "u1", - SubscriptionID: "other-subscription", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-sub", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/other", - Status: "completed", - Progress: 1, - }, - { - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-manual", - Title: "Archives The Nanyang Mystery 2026 S01E09-S01E10 2160p WEB-DL", - SavePath: "/downloads/archive", - Status: "completed", - Progress: 1, - }, - } { - row := existing - if err := repos.Download.Create(t.Context(), &row); err != nil { - t.Fatal(err) - } - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:efefefefefefefefefefefefefefefefefefefef&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "current-subscription", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because old task is outside current subscription scope", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaRequeuesStaleSubscriptionTaskMissingFromQB(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"newhash","name":"Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - SubscriptionID: "sub-nanyang", - Source: "qbittorrent", - URL: "https://pt.example/download?id=stale", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - Progress: 0, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want stale task ignored and candidate queued", err) - } - if task == nil { - t.Fatal("task = nil, want requeued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} diff --git a/internal/service/download_add_existing_test.go b/internal/service/download_add_existing_test.go deleted file mode 100644 index 96d69d5..0000000 --- a/internal/service/download_add_existing_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "net/http" - "net/http/httptest" - "path/filepath" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "Inception", - Path: "/media/movies/Inception (2010)/Inception (2010).mkv", - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Inception+2010+1080p", "/downloads", DownloadTaskMeta{ - Title: "Inception 2010 1080p WEB-DL", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil because local media already exists", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} - -func TestAddDownloadWithMetaSkipsExistingLocalEpisodeBeforeQBAdd(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "Some Show", - Path: "/media/tv/Some Show/Season 01/Some Show - S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Some+Show+S01E01", "/downloads", DownloadTaskMeta{ - Title: "Some Show S01E01 2160p WEB-DL", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil because local episode already exists", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} - -func TestAddDownloadWithMetaQueuesEpisodeRangeWhenOnlyPartlyInLibrary(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := db.Create(&model.Media{ - Title: "Archives The Nanyang Mystery", - Path: "/media/tv/Archives The Nanyang Mystery/Season 01/Archives The Nanyang Mystery - S01E07.mkv", - SeasonNum: 1, - EpisodeNum: 7, - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because E08 is missing", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaSkipsEpisodeRangeWhenFullyInLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - for _, episode := range []int{7, 8} { - if err := db.Create(&model.Media{ - Title: "Archives The Nanyang Mystery", - Path: filepath.Join("/media/tv/Archives The Nanyang Mystery/Season 01", fmt.Sprintf("Archives The Nanyang Mystery - S01E%02d.mkv", episode)), - SeasonNum: 1, - EpisodeNum: episode, - }).Error; err != nil { - t.Fatal(err) - } - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:babababababababababababababababababababa&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil", task) - } -} - -func TestAddDownloadWithMetaQueuesExplicitEpisodeWhenOnlySeriesPackInLibrary(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := db.Create(&model.Media{ - Title: "Archives The Nanyang Mystery S01 Complete", - Path: "/media/tv/Archives The Nanyang Mystery/Season 01/Archives The Nanyang Mystery S01 Complete.mkv", - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:fefefefefefefefefefefefefefefefefefefefe&dn=Archives+The+Nanyang+Mystery+2026+S01E29-E33", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because explicit missing episodes are not proven by a pack row", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaSkipsExistingLocalEpisodeWithReleaseGroup(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}, &model.DownloadClient{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "凡人修仙传", - Path: "/media/动漫/国漫/凡人修仙传/Season 01/凡人修仙传 - S01E146.mkv", - SeasonNum: 1, - EpisodeNum: 146, - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=%5BMagicStar%5D+%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0+%E5%B9%B4%E7%95%AA+-+146+%5B1080p%5D", "/downloads", DownloadTaskMeta{ - Title: "[MagicStar] 凡人修仙传 年番 - 146 [1080p][WEB-DL]", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} diff --git a/internal/service/download_add_local.go b/internal/service/download_add_local.go deleted file mode 100644 index 8400676..0000000 --- a/internal/service/download_add_local.go +++ /dev/null @@ -1,92 +0,0 @@ -package service - -import ( - "context" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool { - rows, ok := d.localMediaAvailabilityRows(ctx, title) - if !ok { - return false - } - return localMediaRowsMatchDownloadTitle(title, rows) -} - -func (d *DownloadService) localMediaAvailabilityRows(ctx context.Context, title string) ([]model.Media, bool) { - if d == nil || d.repo == nil || d.repo.DB == nil { - return nil, false - } - if !d.repo.DB.Migrator().HasTable(&model.Media{}) { - return nil, false - } - queries := localAvailabilityTitleCandidates(title) - if len(queries) == 0 { - return nil, false - } - var rows []model.Media - db := d.repo.DB.WithContext(ctx).Model(&model.Media{}) - for i, query := range queries { - like := "%" + query + "%" - clause := "title LIKE ? OR original_name LIKE ? OR path LIKE ?" - if i == 0 { - db = db.Where(clause, like, like, like) - } else { - db = db.Or(clause, like, like, like) - } - } - if err := db. - Order("season_num asc, episode_num asc, created_at desc"). - Limit(200). - Find(&rows).Error; err != nil || len(rows) == 0 { - return nil, false - } - return rows, true -} - -func localMediaRowsMatchDownloadTitle(title string, rows []model.Media) bool { - wanted := episodeRefsFromTitle(title) - if len(wanted) == 0 { - return true - } - existing := map[string]struct{}{} - hasSeriesPack := false - for _, row := range rows { - rowSeason, rowEpisode := localMediaRowSeasonEpisode(row) - if rowEpisode > 0 { - existing[episodeKey(rowSeason, rowEpisode)] = struct{}{} - continue - } - if rowEpisode <= 0 && isSeriesPackTitle(row.Title+" "+row.OriginalName+" "+row.Path) { - hasSeriesPack = true - } - } - if hasSeriesPack { - return len(wanted) == 0 - } - for _, ref := range wanted { - if _, ok := existing[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func localMediaRowSeasonEpisode(row model.Media) (int, int) { - rowSeason := row.SeasonNum - rowEpisode := row.EpisodeNum - if rowSeason <= 0 || rowEpisode <= 0 { - parsedSeason, parsedEpisode := ParseEpisode(row.Path) - if rowSeason <= 0 { - rowSeason = parsedSeason - } - if rowEpisode <= 0 { - rowEpisode = parsedEpisode - } - } - if rowSeason <= 0 { - rowSeason = 1 - } - return rowSeason, rowEpisode -} diff --git a/internal/service/download_add_subscription.go b/internal/service/download_add_subscription.go deleted file mode 100644 index f6daab5..0000000 --- a/internal/service/download_add_subscription.go +++ /dev/null @@ -1,48 +0,0 @@ -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) linkExistingDownloadTaskToSubscription(ctx context.Context, task *model.DownloadTask, req downloadAddRequest) { - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - if d == nil || d.repo == nil || d.repo.DB == nil || task == nil || subscriptionID == "" || strings.TrimSpace(task.ID) == "" { - return - } - updates := map[string]any{} - if strings.TrimSpace(task.SubscriptionID) == "" { - updates["subscription_id"] = subscriptionID - task.SubscriptionID = subscriptionID - } - if strings.TrimSpace(task.MediaType) == "" && strings.TrimSpace(req.meta.MediaType) != "" { - updates["media_type"] = req.meta.MediaType - task.MediaType = req.meta.MediaType - } - if strings.TrimSpace(task.MediaCategory) == "" && strings.TrimSpace(req.meta.MediaCategory) != "" { - updates["media_category"] = req.meta.MediaCategory - task.MediaCategory = req.meta.MediaCategory - } - if strings.TrimSpace(task.PosterURL) == "" && strings.TrimSpace(req.meta.PosterURL) != "" { - updates["poster_url"] = req.meta.PosterURL - task.PosterURL = req.meta.PosterURL - } - if strings.TrimSpace(task.BackdropURL) == "" && strings.TrimSpace(req.meta.BackdropURL) != "" { - updates["backdrop_url"] = req.meta.BackdropURL - task.BackdropURL = req.meta.BackdropURL - } - if strings.TrimSpace(task.Overview) == "" && strings.TrimSpace(req.meta.Overview) != "" { - updates["overview"] = req.meta.Overview - task.Overview = req.meta.Overview - } - if !task.AllowExistingLibrary && req.meta.AllowExistingLibrary { - updates["allow_existing_library"] = true - task.AllowExistingLibrary = true - } - if len(updates) == 0 { - return - } - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", task.ID).Updates(updates).Error -} diff --git a/internal/service/download_add_test.go b/internal/service/download_add_test.go deleted file mode 100644 index d43cf95..0000000 --- a/internal/service/download_add_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package service - -import ( - "errors" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestPublicDownloadTitleUsesMagnetDisplayName(t *testing.T) { - got := publicDownloadTitle("magnet:?xt=urn:btih:abc&dn=%E6%B5%8B%E8%AF%95%E5%BD%B1%E7%89%87") - if got != "测试影片" { - t.Fatalf("publicDownloadTitle = %q, want %q", got, "测试影片") - } -} - -func TestTorrentURLInfoHashNormalizesBase32BTIH(t *testing.T) { - got := torrentURLInfoHash("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") - if got != "0000000000000000000000000000000000000000" { - t.Fatalf("hash = %q", got) - } -} - -func configureTestDefaultQB(t *testing.T, repos *repository.Container, baseURL string) { - t.Helper() - if err := repos.DownloadClient.Create(t.Context(), &model.DownloadClient{ - Name: "qB test", - Type: "qbittorrent", - Host: baseURL, - Username: "admin", - Password: "admin", - IsDefault: true, - Enabled: true, - }); err != nil { - t.Fatalf("create default qB client: %v", err) - } - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatalf("mark download clients managed: %v", err) - } -} - -func TestAddDownloadWithMetaSkipsExistingTaskBeforeQBAdd(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - existing := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old&passkey=old", - Title: "Some Show S01E01 1080p", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - } - if err := repos.Download.Create(t.Context(), existing); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "https://pt.example/download?id=new&passkey=new", "/downloads/tv", DownloadTaskMeta{ - Title: "Some Show S01E01 2160p WEB-DL", - }) - if !errors.Is(err, ErrDownloadAlreadyExists) { - t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) - } - if task == nil || task.ID != existing.ID { - t.Fatalf("task = %#v, want existing task %#v", task, existing) - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestAddDownloadWithMetaSkipsUserDeletedTaskBeforeQBAdd(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - existing := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old&passkey=old", - Title: "User Deleted Show S01E01 1080p", - SavePath: "/downloads/tv", - Status: "deleted", - } - if err := repos.Download.Create(t.Context(), existing); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "https://pt.example/download?id=new&passkey=new", "/downloads/tv", DownloadTaskMeta{ - Title: "User Deleted Show S01E01 1080p WEB-DL", - }) - if !errors.Is(err, ErrDownloadAlreadyExists) { - t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) - } - if task == nil || task.ID != existing.ID { - t.Fatalf("task = %#v, want existing task %#v", task, existing) - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} diff --git a/internal/service/download_classifier.go b/internal/service/download_classifier.go deleted file mode 100644 index 03a5e85..0000000 --- a/internal/service/download_classifier.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import ( - "context" - "os" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func downloadDefaultSaveRoot(ctx context.Context, repo *repository.Container) string { - if repo != nil && repo.Setting != nil { - if base, _ := repo.Setting.Get(ctx, "qbittorrent.savepath"); strings.TrimSpace(base) != "" { - return strings.TrimSpace(base) - } - } - for _, key := range []string{"MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "MEDIASTATION_DOWNLOAD_DIR"} { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - } - return "" -} - -func downloadSmartClassifyEnabled(ctx context.Context, repo *repository.Container, organizer *OrganizerService) bool { - if repo != nil && repo.Setting != nil { - val, err := repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) - if err == nil && val != "" { - return parseBoolSetting(val, true) - } - val, err = repo.Setting.Get(ctx, "organizer.smart_classify") - if err == nil && parseBoolSetting(val, false) { - return true - } - } - if organizer != nil && organizer.cfg != nil && organizer.cfg.Organizer.SmartClassify { - return true - } - return true -} - -func downloadCategoryMap(organizer *OrganizerService) map[string]string { - if organizer == nil { - return nil - } - return organizer.categoryMap() -} - -func downloadSavePathCategoryRoot(root, category string) string { - root = strings.TrimSpace(root) - category = strings.TrimSpace(category) - if root == "" || category == "" { - return root - } - if isWindowsStyleClientPath(root) { - cleanRoot := strings.ReplaceAll(root, "/", `\`) - cleanRoot = strings.TrimRight(cleanRoot, `\`) - if windowsPathBaseEqual(cleanRoot, category) { - return cleanRoot - } - return cleanRoot + `\` + category - } - return categoryRoot(root, category) -} - -func isWindowsStyleClientPath(path string) bool { - path = strings.TrimSpace(path) - return (len(path) >= 2 && isASCIIAlpha(path[0]) && path[1] == ':') || - strings.HasPrefix(path, `\\`) -} - -func windowsPathBaseEqual(path, base string) bool { - path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`) - base = strings.Trim(strings.TrimSpace(base), `\/`) - if path == "" || base == "" { - return false - } - idx := strings.LastIndex(path, `\`) - if idx >= 0 { - path = path[idx+1:] - } - return strings.EqualFold(path, base) -} diff --git a/internal/service/download_client_endpoint.go b/internal/service/download_client_endpoint.go deleted file mode 100644 index 58a3e0c..0000000 --- a/internal/service/download_client_endpoint.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" -) - -var downloadClientEndpointPattern = regexp.MustCompile(`^https?://(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~%!$&'()*+,;=:@/-]*)?$`) - -func NormalizeDownloadClientHost(clientType, raw string) (string, error) { - return normalizeDownloadClientEndpoint(clientType, raw) -} - -func normalizeDownloadClientEndpoint(clientType, raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", errors.New("host required") - } - if strings.ContainsAny(raw, "\r\n\t") { - return "", errors.New("host contains invalid control characters") - } - if !strings.Contains(raw, "://") { - raw = "http://" + raw - } - if !downloadClientEndpointPattern.MatchString(raw) { - return "", errors.New("host must be a valid http(s) URL without username, query, or fragment") - } - parsed, err := url.Parse(raw) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "", errors.New("host must be a valid http(s) URL") - } - scheme := strings.ToLower(parsed.Scheme) - if scheme != "http" && scheme != "https" { - return "", errors.New("host only supports http or https") - } - if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", errors.New("host must not include username, query, or fragment") - } - if strings.TrimSpace(parsed.Hostname()) == "" { - return "", errors.New("host must include a hostname") - } - if port := parsed.Port(); port != "" { - n, err := strconv.Atoi(port) - if err != nil || n < 1 || n > 65535 { - return "", errors.New("host port must be between 1 and 65535") - } - } - if err := validateDownloadClientPath(clientType, parsed.Path); err != nil { - return "", err - } - parsed.Scheme = scheme - parsed.Path = strings.TrimRight(parsed.Path, "/") - parsed.RawPath = "" - parsed.RawQuery = "" - parsed.Fragment = "" - return strings.TrimRight(parsed.String(), "/"), nil -} - -func validateDownloadClientPath(clientType, rawPath string) error { - rawPath = strings.TrimSpace(rawPath) - if rawPath == "" || rawPath == "/" { - return nil - } - for _, segment := range strings.Split(rawPath, "/") { - if segment == "." || segment == ".." { - return errors.New("host path must not contain traversal segments") - } - } - switch clientType { - case "qbittorrent", "aria2", "transmission": - return nil - default: - return fmt.Errorf("unsupported client type %q", clientType) - } -} - -func downloadClientRPCURL(clientType, host string) (string, error) { - base, err := normalizeDownloadClientEndpoint(clientType, host) - if err != nil { - return "", err - } - u, err := url.Parse(base) - if err != nil { - return "", err - } - switch clientType { - case "aria2": - if !strings.HasSuffix(strings.ToLower(u.Path), "/jsonrpc") { - u.Path = strings.TrimRight(u.Path, "/") + "/jsonrpc" - } - case "transmission": - if !strings.Contains(strings.ToLower(u.Path), "/rpc") { - u.Path = strings.TrimRight(u.Path, "/") + "/transmission/rpc" - } - case "qbittorrent": - default: - return "", fmt.Errorf("unsupported client type %q", clientType) - } - return u.String(), nil -} - -func newDownloadClientHTTPRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Request, error) { - endpoint = strings.TrimSpace(endpoint) - if !downloadClientEndpointPattern.MatchString(endpoint) { - return nil, errors.New("download client endpoint failed safety validation") - } - return http.NewRequestWithContext(ctx, method, endpoint, body) -} diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go deleted file mode 100644 index 64686dd..0000000 --- a/internal/service/download_clients.go +++ /dev/null @@ -1,270 +0,0 @@ -// Package service — download client (qBittorrent / Aria2 / Transmission) -// configuration. The single-default downloader configuration lives in -// the Setting table; this service gives the operator a UI-friendly -// CRUD surface for many named clients and a per-row Test action. -package service - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DownloadClientService persists model.DownloadClient rows. -type DownloadClientService struct { - log *zap.Logger - repo *repository.Container - client *http.Client -} - -// NewDownloadClientService is the constructor. -func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *DownloadClientService { - return &DownloadClientService{ - log: log, - repo: repo, - client: NewInternalHTTPClient(10 * time.Second), - } -} - -// DownloadClientInput is the create / update payload. -type DownloadClientInput struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` - Host string `json:"host" binding:"required"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - IsDefault bool `json:"is_default"` - Enabled bool `json:"enabled"` -} - -// List returns every configured client. -func (s *DownloadClientService) List(ctx context.Context) ([]model.DownloadClient, error) { - return s.repo.DownloadClient.List(ctx) -} - -// Create inserts a new client. -func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInput) (*model.DownloadClient, error) { - normalized, err := normalizeDownloadClientInput(in) - if err != nil { - return nil, err - } - s.markManaged(ctx) - c := &model.DownloadClient{ - Name: normalized.Name, - Type: normalized.Type, - Host: normalized.Host, - Username: normalized.Username, - Password: normalized.Password, - IsDefault: normalized.IsDefault, - Enabled: normalized.Enabled, - } - if !c.IsDefault && c.Enabled { - if currentDefault, err := s.repo.DownloadClient.FindDefault(ctx); err == nil && currentDefault == nil { - if enabled, err := s.repo.DownloadClient.ListEnabled(ctx); err == nil && len(enabled) == 0 { - c.IsDefault = true - } - } - } - if normalized.IsDefault { - _ = s.repo.DownloadClient.ClearDefault(ctx) - } - if err := s.repo.DownloadClient.Create(ctx, c); err != nil { - return nil, err - } - return c, nil -} - -// Update applies a patch. -func (s *DownloadClientService) Update(ctx context.Context, id string, in DownloadClientInput) (*model.DownloadClient, error) { - normalized, err := normalizeDownloadClientInput(in) - if err != nil { - return nil, err - } - s.markManaged(ctx) - patch := map[string]any{ - "name": normalized.Name, - "type": normalized.Type, - "host": normalized.Host, - "username": normalized.Username, - "is_default": normalized.IsDefault, - "enabled": normalized.Enabled, - } - // Only overwrite the password when the caller actually sent one. - if normalized.Password != "" { - patch["password"] = normalized.Password - } - // Fetch existing row, apply patch via Save - existing, err := s.repo.DownloadClient.FindByID(ctx, id) - if err != nil { - return nil, err - } - if existing == nil { - return nil, errors.New("client not found") - } - if normalized.IsDefault { - _ = s.repo.DownloadClient.ClearDefault(ctx) - } - existing.Name = patch["name"].(string) - existing.Type = patch["type"].(string) - existing.Host = patch["host"].(string) - existing.Username = patch["username"].(string) - existing.IsDefault = patch["is_default"].(bool) - existing.Enabled = patch["enabled"].(bool) - if pw, ok := patch["password"]; ok { - existing.Password = pw.(string) - } - if err := s.repo.DownloadClient.Update(ctx, existing); err != nil { - return nil, err - } - s.clearLegacyQBitConnectionIfNoDefault(ctx) - return s.repo.DownloadClient.FindByID(ctx, id) -} - -// Delete removes one client. -func (s *DownloadClientService) Delete(ctx context.Context, id string) error { - s.markManaged(ctx) - if err := s.repo.DownloadClient.Delete(ctx, id); err != nil { - return err - } - s.clearLegacyQBitConnectionIfNoDefault(ctx) - return nil -} - -// Test verifies that the client's WebUI is reachable. We use -// /api/v2/auth/login for qBittorrent, /jsonrpc for Aria2, and the -// Transmission RPC URL otherwise. -func (s *DownloadClientService) Test(ctx context.Context, id string) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - c, err := s.repo.DownloadClient.FindByID(ctx, id) - if err != nil { - return err - } - if c == nil { - return errors.New("client not found") - } - switch c.Type { - case "qbittorrent": - return qbitLogin(ctx, s.client, c.Host, c.Username, c.Password) - case "aria2", "transmission": - endpoint, err := downloadClientRPCURL(c.Type, c.Host) - if err != nil { - return err - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return err - } - resp, err := s.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 500 { - return fmt.Errorf("%s returned %d", c.Type, resp.StatusCode) - } - return nil - } - return fmt.Errorf("unsupported client type %q", c.Type) -} - -// Aria2GlobalStats issues a JSON-RPC `aria2.getGlobalStat` call against -// the first enabled aria2 client. Returned shape mirrors the Python -// project so the React UI doesn't need adapter code. -func (s *DownloadClientService) Aria2GlobalStats(ctx context.Context, clientID string) (map[string]any, error) { - c, err := s.repo.DownloadClient.FindByID(ctx, clientID) - if err != nil { - return nil, err - } - if c == nil || c.Type != "aria2" { - return nil, errors.New("aria2 client not found") - } - endpoint, err := downloadClientRPCURL("aria2", c.Host) - if err != nil { - return nil, err - } - payload := fmt.Sprintf( - `{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`, - c.Password, - ) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, endpoint, - strings.NewReader(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - resp, err := s.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("aria2 returned %d", resp.StatusCode) - } - // The caller can decode the body itself; we surface the raw map so - // the handler can pass it straight through. - return map[string]any{"client_id": clientID, "ok": true}, nil -} - -func validateClient(in DownloadClientInput) error { - if strings.TrimSpace(in.Name) == "" { - return errors.New("name required") - } - if strings.TrimSpace(in.Host) == "" { - return errors.New("host required") - } - switch in.Type { - case "qbittorrent", "aria2", "transmission": - default: - return fmt.Errorf("unsupported client type %q", in.Type) - } - return nil -} - -func normalizeDownloadClientInput(in DownloadClientInput) (DownloadClientInput, error) { - in.Name = strings.TrimSpace(in.Name) - in.Type = strings.TrimSpace(in.Type) - in.Host = strings.TrimSpace(in.Host) - in.Username = strings.TrimSpace(in.Username) - if err := validateClient(in); err != nil { - return in, err - } - if !strings.Contains(in.Host, "://") { - in.Host = "http://" + in.Host - } - normalized, err := normalizeDownloadClientEndpoint(in.Type, in.Host) - if err != nil { - return in, err - } - in.Host = normalized - return in, nil -} - -func (s *DownloadClientService) markManaged(ctx context.Context) { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return - } - _ = s.repo.Setting.Set(ctx, settingDownloadClientsManaged, "true") -} - -func (s *DownloadClientService) clearLegacyQBitConnectionIfNoDefault(ctx context.Context) { - if s == nil || s.repo == nil || s.repo.DownloadClient == nil || s.repo.Setting == nil { - return - } - defaultClient, err := s.repo.DownloadClient.FindDefault(ctx) - if err != nil || defaultClient != nil { - return - } - _ = s.repo.Setting.Set(ctx, "qbittorrent.url", "") - _ = s.repo.Setting.Set(ctx, "qbittorrent.username", "") - _ = s.repo.Setting.Set(ctx, "qbittorrent.password", "") -} diff --git a/internal/service/download_clients_test.go b/internal/service/download_clients_test.go deleted file mode 100644 index cc152bb..0000000 --- a/internal/service/download_clients_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDownloadClientCreateNormalizesHostAndClearsDefault(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadClientService(zap.NewNop(), repos) - - first, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB old", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080/", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - second, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB NAS", - Type: "qbittorrent", - Host: "172.17.0.1:8085", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - if second.Host != "http://172.17.0.1:8085" { - t.Fatalf("host = %q, want normalized http URL", second.Host) - } - refreshedFirst, err := repos.DownloadClient.FindByID(t.Context(), first.ID) - if err != nil { - t.Fatal(err) - } - if refreshedFirst == nil || refreshedFirst.IsDefault { - t.Fatalf("old default should be cleared, got %#v", refreshedFirst) - } - refreshedSecond, err := repos.DownloadClient.FindByID(t.Context(), second.ID) - if err != nil { - t.Fatal(err) - } - if refreshedSecond == nil || !refreshedSecond.IsDefault { - t.Fatalf("new default should be active, got %#v", refreshedSecond) - } -} - -func TestDownloadClientCreateMakesFirstEnabledClientDefault(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadClientService(zap.NewNop(), repos) - - client, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "127.0.0.1:8080", - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - if !client.IsDefault { - t.Fatalf("first enabled client should become default: %#v", client) - } -} - -func TestDownloadClientRejectsUnsupportedHostScheme(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - svc := NewDownloadClientService(zap.NewNop(), repository.New(db)) - - if _, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "bad", - Type: "qbittorrent", - Host: "ftp://127.0.0.1:8080", - Enabled: true, - }); err == nil { - t.Fatal("expected unsupported scheme error") - } -} - -func TestDownloadClientRejectsUnsafeEndpointParts(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - svc := NewDownloadClientService(zap.NewNop(), repository.New(db)) - - for _, host := range []string{ - "http://user:pass@127.0.0.1:6800", - "http://127.0.0.1:6800/jsonrpc?target=http://169.254.169.254", - "http://127.0.0.1:6800/jsonrpc#fragment", - "http://127.0.0.1:70000", - "file:///etc/passwd", - } { - if _, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "bad", - Type: "aria2", - Host: host, - Enabled: true, - }); err == nil { - t.Fatalf("Create allowed unsafe host %q", host) - } - } -} - -func TestDownloadClientRPCURLAppendsExpectedPath(t *testing.T) { - cases := []struct { - clientType string - host string - want string - }{ - {"aria2", "127.0.0.1:6800", "http://127.0.0.1:6800/jsonrpc"}, - {"aria2", "http://nas.local:6800/rpc", "http://nas.local:6800/rpc/jsonrpc"}, - {"transmission", "http://nas.local:9091", "http://nas.local:9091/transmission/rpc"}, - {"transmission", "http://nas.local:9091/transmission/rpc", "http://nas.local:9091/transmission/rpc"}, - } - for _, tc := range cases { - got, err := downloadClientRPCURL(tc.clientType, tc.host) - if err != nil { - t.Fatalf("downloadClientRPCURL(%q, %q) error: %v", tc.clientType, tc.host, err) - } - if got != tc.want { - t.Fatalf("downloadClientRPCURL(%q, %q) = %q, want %q", tc.clientType, tc.host, got, tc.want) - } - } -} - -func TestAria2AdapterRejectsUnsafeHostBeforeHTTPRequest(t *testing.T) { - adapter := NewAria2Adapter() - called := false - adapter.client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - called = true - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil - })} - - if err := adapter.Initialize(t.Context(), DownloadClientConfig{ - Host: "http://user:pass@127.0.0.1:6800", - Password: "secret", - }); err == nil { - t.Fatal("expected unsafe host error") - } - if called { - t.Fatal("unsafe aria2 host should be rejected before any HTTP request") - } -} - -func TestAria2AdapterUsesNormalizedRPCURL(t *testing.T) { - var gotPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{ - Host: server.URL, - Password: "secret", - }); err != nil { - t.Fatal(err) - } - if gotPath != "/jsonrpc" { - t.Fatalf("aria2 request path = %q, want /jsonrpc", gotPath) - } -} - -func TestDownloadClientDeleteClearsLegacyQBitConnectionWhenNoDefault(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - for key, value := range map[string]string{ - "qbittorrent.url": "http://127.0.0.1:8080", - "qbittorrent.username": "admin", - "qbittorrent.password": "admin", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - svc := NewDownloadClientService(zap.NewNop(), repos) - row, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), row.ID); err != nil { - t.Fatal(err) - } - - for _, key := range []string{"qbittorrent.url", "qbittorrent.username", "qbittorrent.password"} { - value, err := repos.Setting.Get(t.Context(), key) - if err != nil { - t.Fatal(err) - } - if value != "" { - t.Fatalf("%s = %q, want cleared", key, value) - } - } -} - -func TestDownloadClientUpdateClearsLegacyQBitConnectionWhenDefaultDisabled(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", "http://127.0.0.1:8080"); err != nil { - t.Fatal(err) - } - svc := NewDownloadClientService(zap.NewNop(), repos) - row, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - - if _, err := svc.Update(t.Context(), row.ID, DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080", - IsDefault: false, - Enabled: false, - }); err != nil { - t.Fatal(err) - } - - value, err := repos.Setting.Get(t.Context(), "qbittorrent.url") - if err != nil { - t.Fatal(err) - } - if value != "" { - t.Fatalf("qbittorrent.url = %q, want cleared", value) - } -} diff --git a/internal/service/download_completion.go b/internal/service/download_completion.go deleted file mode 100644 index 05250f8..0000000 --- a/internal/service/download_completion.go +++ /dev/null @@ -1,216 +0,0 @@ -package service - -import ( - "context" - "errors" - "os" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// onTorrentComplete handles a torrent that just finished downloading. -// It organizes the completed torrent payload directly. Relying on existing -// Media rows is too late for freshly-downloaded files: they usually have not -// been scanned into the library yet. -func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTorrent) { - taskRow, hasTask := d.completedTorrentTask(ctx, torrent) - d.notifyDownloadComplete(ctx, torrent, taskRow) - if d.organizer == nil { - return - } - // 仅当显式开启 organizer.auto_after_download / organize.auto 时才在下载完成后整理。 - // 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关, - // 让操作员只想启用"分类子目录"就被动触发了文件 move。 - autoOrganize := d.downloadAutoOrganizeEnabled(ctx) - if !autoOrganize { - d.log.Info("download completed, auto-organize disabled", zap.String("hash", torrent.Hash)) - return - } - source := d.completedTorrentSource(ctx, torrent) - if source == "" { - d.log.Warn("download completed but payload path is not accessible", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.String("save_path", torrent.SavePath), - zap.String("content_path", torrent.ContentPath)) - return - } - allowReplace := hasTask && taskRow.AllowExistingLibrary - d.runCompletedTorrentOrganize(ctx, torrent, taskRow, source, allowReplace) -} - -func (d *DownloadService) runCompletedTorrentOrganize(ctx context.Context, torrent QBitTorrent, task *model.DownloadTask, source string, allowReplace bool) { - d.log.Info("download completed, triggering directory organize", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.String("source", source), - zap.Bool("allow_replace_existing", allowReplace)) - resWrap, err := d.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{ - Scope: OrganizeScopeDirectory, - Trigger: OrganizeTriggerDownload, - TaskName: d.downloadOrganizeTaskName(torrent, allowReplace), - SourcePath: source, - MediaType: downloadTaskMediaType(task), - MediaCategory: firstNonEmpty(downloadTaskMediaCategory(task), torrent.Category), - AllowReplace: allowReplace, - }) - if err != nil { - if errors.Is(err, ErrUnsupportedOrganizeSource) { - d.markCompletedTorrentCatchupRecorded(context.Background(), torrent) - d.log.Warn("auto organize skipped unsupported completed torrent", - zap.String("hash", torrent.Hash), - zap.String("source", source), - zap.Error(err)) - return - } - d.log.Error("auto organize completed torrent failed", - zap.String("hash", torrent.Hash), - zap.String("source", source), - zap.Error(err)) - return - } - res := resWrap.Result - if res == nil { - res = &OrganizeResult{} - } - d.markCompletedTorrentCatchupRecorded(context.Background(), torrent) - d.log.Info("auto organize completed torrent finished", - zap.String("hash", torrent.Hash), - zap.String("source", source), - zap.String("dest", firstNonEmpty(res.DestPath, "")), - zap.Int("organized", res.Organized), - zap.Int("replaced", res.Replaced), - zap.Int("skipped", res.Skipped), - zap.Int("scrapes", len(res.Scrapes)), - zap.Int("errors", len(res.Errors))) -} - -func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string { - name := strings.TrimSpace(torrent.Name) - if name == "" { - name = "下载完成自动整理" - } - if allowReplace { - name += "(允许洗版)" - } - return name -} - -func (d *DownloadService) ensureOrganizePipeline() *OrganizePipelineService { - if d.organizePipeline != nil { - return d.organizePipeline - } - return NewOrganizePipelineService(d.log, d.repo, d.organizer, d.scanner, d.tasks) -} - -func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBitTorrent) (*model.DownloadTask, bool) { - if d == nil || d.repo == nil || d.repo.Download == nil { - return nil, false - } - rows, err := d.repo.Download.List(ctx) - if err != nil || len(rows) == 0 { - return nil, false - } - taskByKey := tasksByTorrentIdentity(rows) - if task, ok := findMatchingTaskForTorrent(torrent, taskByKey); ok { - return &task, true - } - if strings.TrimSpace(torrent.ContentPath) != "" { - pathTorrent := torrent - pathTorrent.Name = downloaderPathBase(torrent.ContentPath) - if task, ok := findMatchingTaskForTorrent(pathTorrent, taskByKey); ok { - return &task, true - } - } - return nil, false -} - -func downloadTaskMediaType(task *model.DownloadTask) string { - if task == nil { - return "" - } - return strings.TrimSpace(task.MediaType) -} - -func downloadTaskMediaCategory(task *model.DownloadTask) string { - if task == nil { - return "" - } - return strings.TrimSpace(task.MediaCategory) -} - -// DownloadPathMappingsSettingKey 允许用户自定义「下载器路径 → 本程序路径」 -// 映射,每行一条,格式 `客户端路径=本地路径`(也接受 `=>` 或单个 `:` 分隔)。 -// qBittorrent 与本程序常在不同容器/主机里,对同一份数据看到的路径不同; -// 此前映射表是写死的三条猜测,对不上时整理静默失败。 -const DownloadPathMappingsSettingKey = "download.path_mappings" - -func (d *DownloadService) completedTorrentSource(ctx context.Context, torrent QBitTorrent) string { - mappings := d.downloadPathMappings(ctx) - for _, candidate := range []string{ - torrent.ContentPath, - downloaderPayloadPath(torrent.SavePath, torrent.Name), - } { - clean := strings.TrimSpace(candidate) - if clean == "" || clean == "." { - continue - } - // 尝试直接访问或路径映射 - if translated := translateClientPath(clean, mappings); translated != "" { - return translated - } - // 复用 compose 注入的 MEDIASTATION_DOWNLOAD_DIR/MEDIA_DIR 宿主机↔容器 - // 映射(与媒体库路径换算同一套规则),覆盖「qB 跑在宿主机、 - // 本程序在容器里」的最常见部署形态。 - for _, mapped := range mappedPathCandidates(clean) { - if mapped == clean { - continue - } - if _, err := os.Stat(mapped); err == nil { - return mapped - } - } - } - return "" -} - -// userPathMappings 解析用户配置的下载器路径映射。 -func (d *DownloadService) userPathMappings(ctx context.Context) map[string]string { - out := map[string]string{} - if d == nil || d.repo == nil || d.repo.Setting == nil { - return out - } - raw, err := d.repo.Setting.Get(ctx, DownloadPathMappingsSettingKey) - if err != nil { - return out - } - for _, line := range strings.Split(raw, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - var from, to string - switch { - case strings.Contains(line, "=>"): - parts := strings.SplitN(line, "=>", 2) - from, to = parts[0], parts[1] - case strings.Contains(line, "="): - parts := strings.SplitN(line, "=", 2) - from, to = parts[0], parts[1] - case strings.Count(line, ":") == 1: - parts := strings.SplitN(line, ":", 2) - from, to = parts[0], parts[1] - default: - continue - } - from = strings.TrimSpace(from) - to = strings.TrimSpace(to) - if from != "" && to != "" { - out[from] = to - } - } - return out -} diff --git a/internal/service/download_completion_state.go b/internal/service/download_completion_state.go deleted file mode 100644 index db9da35..0000000 --- a/internal/service/download_completion_state.go +++ /dev/null @@ -1,327 +0,0 @@ -package service - -import ( - "context" - "crypto/sha1" - "fmt" - "math" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// completedTorrentCatchupWindow 限定重启补整理只覆盖最近完成的种子, -// 防止每次启动都把全部历史种子重新过一遍整理流程。 -const completedTorrentCatchupWindow = 24 * time.Hour - -const completedTorrentCatchupSettingPrefix = "download.auto_organized." -const completedTorrentNotifySettingPrefix = "download.completed_notified." - -func (d *DownloadService) downloadAutoOrganizeEnabled(ctx context.Context) bool { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return false - } - if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil && parseBoolSetting(v, false) { - return true - } - if v, err := d.repo.Setting.Get(ctx, "organize.auto"); err == nil && parseBoolSetting(v, false) { - return true - } - return false -} - -// recentlyCompletedTorrent 报告该种子是否在补整理时间窗内完成。 -// qBittorrent 未提供 completion_on 时保守地返回 false。 -func recentlyCompletedTorrent(torrent QBitTorrent, now time.Time) bool { - if torrent.CompletionOn <= 0 { - return false - } - completed := time.Unix(torrent.CompletionOn, 0) - return now.Sub(completed) <= completedTorrentCatchupWindow -} - -func qbitTorrentCompleted(torrent QBitTorrent) bool { - if torrent.Progress < 1 { - return false - } - state := strings.ToLower(strings.TrimSpace(torrent.State)) - switch state { - case "completed", "complete", "seeding", "uploading", "stalledup", "pausedup", "queuedup", "forcedup": - return true - default: - return false - } -} - -func (d *DownloadService) completedTorrentCatchupRecorded(ctx context.Context, torrent QBitTorrent) bool { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return false - } - key := completedTorrentCatchupSettingKey(torrent) - if key == "" { - return false - } - value, err := d.repo.Setting.Get(ctx, key) - if err != nil { - return false - } - return parseBoolSetting(value, false) -} - -func (d *DownloadService) markCompletedTorrentCatchupRecorded(ctx context.Context, torrent QBitTorrent) { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return - } - key := completedTorrentCatchupSettingKey(torrent) - if key == "" { - return - } - if err := d.repo.Setting.Set(ctx, key, "true"); err != nil && d.log != nil { - d.log.Debug("mark completed torrent catchup failed", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.Error(err)) - } -} - -func completedTorrentCatchupSettingKey(torrent QBitTorrent) string { - key := completedTorrentQueueKey(torrent) - if key == "" { - return "" - } - sum := sha1.Sum([]byte(key)) - return completedTorrentCatchupSettingPrefix + fmt.Sprintf("%x", sum[:]) -} - -func (d *DownloadService) completedTorrentNotified(ctx context.Context, torrent QBitTorrent) bool { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return false - } - key := completedTorrentNotifySettingKey(torrent) - if key == "" { - return false - } - value, err := d.repo.Setting.Get(ctx, key) - if err != nil { - return false - } - return parseBoolSetting(value, false) -} - -func (d *DownloadService) markCompletedTorrentNotified(ctx context.Context, torrent QBitTorrent) { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return - } - key := completedTorrentNotifySettingKey(torrent) - if key == "" { - return - } - if err := d.repo.Setting.Set(ctx, key, "true"); err != nil && d.log != nil { - d.log.Debug("mark completed torrent notification failed", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.Error(err)) - } -} - -func completedTorrentNotifySettingKey(torrent QBitTorrent) string { - key := completedTorrentQueueKey(torrent) - if key == "" { - return "" - } - sum := sha1.Sum([]byte(key)) - return completedTorrentNotifySettingPrefix + fmt.Sprintf("%x", sum[:]) -} - -func completedTorrentQueueKey(torrent QBitTorrent) string { - hash := strings.ToLower(strings.TrimSpace(torrent.Hash)) - if hash != "" { - owner := strings.ToLower(firstNonEmpty(torrent.ClientID, torrent.Source)) - if owner != "" { - return owner + "|" + hash - } - return hash - } - parts := []string{torrent.Name, torrent.ContentPath, torrent.SavePath} - if owner := firstNonEmpty(torrent.ClientID, torrent.Source); owner != "" { - parts = append([]string{owner}, parts...) - } - for i := range parts { - parts[i] = strings.TrimSpace(parts[i]) - } - key := strings.Join(parts, "|") - if strings.Trim(key, "|") == "" { - return "" - } - return strings.ToLower(key) -} - -func (d *DownloadService) syncDownloadTaskProgress(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask) { - if d == nil || d.repo == nil || d.repo.DB == nil { - return - } - matched, ok := findMatchingTaskForTorrent(torrent, taskByKey) - if !ok { - return - } - status := torrent.State - if qbitTorrentCompleted(torrent) { - status = "completed" - } - if strings.TrimSpace(status) == "" { - status = matched.Status - } - updates := map[string]any{} - if math.Abs(float64(matched.Progress-torrent.Progress)) > 0.0001 { - updates["progress"] = torrent.Progress - } - if status != "" && status != matched.Status { - updates["status"] = status - } - if strings.TrimSpace(matched.DownloadClientID) == "" && strings.TrimSpace(torrent.ClientID) != "" { - updates["download_client_id"] = strings.TrimSpace(torrent.ClientID) - } - if strings.TrimSpace(matched.ExternalID) == "" && strings.TrimSpace(torrent.Hash) != "" { - updates["external_id"] = strings.TrimSpace(torrent.Hash) - } - if strings.TrimSpace(torrent.Source) != "" && matched.Source != strings.TrimSpace(torrent.Source) { - updates["source"] = strings.TrimSpace(torrent.Source) - } - if len(updates) == 0 { - return - } - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", matched.ID).Updates(updates).Error -} - -func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask { - out := make(map[string]model.DownloadTask, len(rows)) - for _, row := range rows { - key := downloadTaskIdentityKey(row.Title) - if key != "" { - out[key] = row - } - } - return out -} - -func tasksByTorrentIdentity(rows []model.DownloadTask) map[string]model.DownloadTask { - out := make(map[string]model.DownloadTask, len(rows)*4) - for _, row := range rows { - key := normalizeTorrentName(row.Title) - if key != "" { - setDownloadTaskIndex(out, key, row) - setDownloadTaskIndex(out, downloadTaskClientTitleKey(row.DownloadClientID, key), row) - } - if externalID := strings.TrimSpace(row.ExternalID); externalID != "" { - setDownloadTaskIndex(out, downloadTaskExternalKey(row.DownloadClientID, externalID), row) - setDownloadTaskIndex(out, downloadTaskAnyExternalKey(externalID), row) - } - } - return out -} - -func setDownloadTaskIndex(index map[string]model.DownloadTask, key string, row model.DownloadTask) { - if key == "" { - return - } - if _, exists := index[key]; !exists { - index[key] = row - } -} - -func downloadTaskExternalKey(clientID, externalID string) string { - clientID = strings.ToLower(strings.TrimSpace(clientID)) - externalID = strings.ToLower(strings.TrimSpace(externalID)) - if clientID == "" || externalID == "" { - return "" - } - return "\x00external:" + clientID + ":" + externalID -} - -func downloadTaskAnyExternalKey(externalID string) string { - externalID = strings.ToLower(strings.TrimSpace(externalID)) - if externalID == "" { - return "" - } - return "\x00external-any:" + externalID -} - -func downloadTaskClientTitleKey(clientID, titleKey string) string { - clientID = strings.ToLower(strings.TrimSpace(clientID)) - titleKey = strings.TrimSpace(titleKey) - if clientID == "" || titleKey == "" { - return "" - } - return "\x00client-title:" + clientID + ":" + titleKey -} - -func findMatchingTaskForTorrent(torrent QBitTorrent, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { - if row, ok := taskByKey[downloadTaskExternalKey(torrent.ClientID, torrent.Hash)]; ok { - return row, true - } - if row, ok := taskByKey[downloadTaskAnyExternalKey(torrent.Hash)]; ok { - if strings.TrimSpace(row.DownloadClientID) == "" || strings.TrimSpace(torrent.ClientID) == "" || row.DownloadClientID == torrent.ClientID { - return row, true - } - } - titleKey := normalizeTorrentName(torrent.Name) - if row, ok := taskByKey[downloadTaskClientTitleKey(torrent.ClientID, titleKey)]; ok { - return row, true - } - row, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey) - if !ok { - return model.DownloadTask{}, false - } - if strings.TrimSpace(torrent.ClientID) != "" && strings.TrimSpace(row.DownloadClientID) != "" && row.DownloadClientID != torrent.ClientID { - return model.DownloadTask{}, false - } - return row, true -} - -func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { - key := downloadTaskIdentityKey(title) - if key == "" { - return model.DownloadTask{}, false - } - if row, ok := taskByKey[key]; ok { - return row, true - } - for currentKey, row := range taskByKey { - if strings.HasPrefix(currentKey, "\x00") { - continue - } - if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) { - return row, true - } - } - return model.DownloadTask{}, false -} - -func findMatchingTaskByTorrentIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { - key := normalizeTorrentName(title) - if key == "" { - return model.DownloadTask{}, false - } - if row, ok := taskByKey[key]; ok { - return row, true - } - for currentKey, row := range taskByKey { - if strings.HasPrefix(currentKey, "\x00") { - continue - } - if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) { - return row, true - } - } - return model.DownloadTask{}, false -} - -func downloadTaskNeedsCompletion(task model.DownloadTask) bool { - if task.Progress < 1 { - return true - } - return strings.ToLower(strings.TrimSpace(task.Status)) != "completed" -} diff --git a/internal/service/download_config_test.go b/internal/service/download_config_test.go deleted file mode 100644 index 6414920..0000000 --- a/internal/service/download_config_test.go +++ /dev/null @@ -1,390 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestReloadConfigDoesNotFallbackToLegacyAfterClientDeleted(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - _, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected add to fail when the configured downloader was deleted") - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestReloadConfigDoesNotFallbackToLegacyAfterClientDisabled(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - client.Enabled = false - if err := repos.DownloadClient.Update(t.Context(), client); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - _, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected add to fail when the configured downloader was disabled") - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestReloadConfigUsesSoleEnabledQBitWhenNoExplicitDefault(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"sole123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err != nil { - t.Fatal(err) - } - if task == nil { - t.Fatal("expected task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestReloadConfigDoesNotOverrideExplicitTransmissionDefaultWithQBit(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - transmission := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: "http://127.0.0.1:9091", IsDefault: true, Enabled: true} - qb := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: "http://127.0.0.1:8080", Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), transmission); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), qb); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - if svc.qb.IsConfigured() { - t.Fatal("legacy qB client was configured despite explicit Transmission default") - } - selected, err := repos.DownloadClient.FindDefault(t.Context()) - if err != nil { - t.Fatal(err) - } - if selected == nil || selected.ID != transmission.ID { - t.Fatalf("default client = %#v", selected) - } -} - -func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected no downloader configured error") - } - if !strings.Contains(err.Error(), "当前没有已启用的下载器") { - t.Fatalf("err = %v, want enabled downloader guidance", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} - -func TestAddDownloadSelectsFirstEnabledQBitWhenDefaultMissing(t *testing.T) { - var firstAddCalls int32 - first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&firstAddCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&firstAddCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer first.Close() - second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - t.Fatal("second qB should not be selected before first enabled qB") - default: - http.NotFound(w, r) - } - })) - defer second.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - firstClient := &model.DownloadClient{Name: "qB first", Type: "qbittorrent", Host: first.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true} - secondClient := &model.DownloadClient{Name: "qB second", Type: "qbittorrent", Host: second.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), firstClient); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), secondClient); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err != nil { - t.Fatal(err) - } - if task == nil { - t.Fatal("expected task") - } - if got := atomic.LoadInt32(&firstAddCalls); got != 1 { - t.Fatalf("first qb add calls = %d, want 1", got) - } - refreshed, err := repos.DownloadClient.FindByID(t.Context(), firstClient.ID) - if err != nil { - t.Fatal(err) - } - if refreshed == nil || !refreshed.IsDefault { - t.Fatalf("first enabled qB should be persisted as default, got %#v", refreshed) - } -} - -func TestReloadConfigManagedModeDoesNotFallbackToLegacyWithoutRows(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - _, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected managed mode to reject missing default downloader") - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestAddDownloadWithMetaUsesEnabledAria2Downloader(t *testing.T) { - var addCalls int32 - aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - var result interface{} = map[string]interface{}{"version": "1.37"} - switch req.Method { - case "aria2.tellActive", "aria2.tellWaiting", "aria2.tellStopped": - result = []interface{}{} - case "aria2.addUri": - atomic.AddInt32(&addCalls, 1) - result = "aria2-gid" - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": result, - }) - })) - defer aria2.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{ - Name: "aria2", - Type: "aria2", - Host: aria2.URL, - Enabled: true, - } - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(NewDownloadManager(zap.NewNop(), repos, nil)) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err != nil { - t.Fatal(err) - } - if task.Source != "aria2" || task.DownloadClientID != client.ID || task.ExternalID != "aria2-gid" { - t.Fatalf("task = %#v", task) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("add calls = %d", got) - } -} diff --git a/internal/service/download_control_test.go b/internal/service/download_control_test.go deleted file mode 100644 index dc4f49e..0000000 --- a/internal/service/download_control_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "reflect" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestPauseAndResumeRouteThroughTaskDownloadClient(t *testing.T) { - var methods []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - methods = append(methods, req.Method) - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: map[string]interface{}{}}) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "transmission", - DownloadClientID: client.ID, - ExternalID: "transmission-hash", - URL: "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Title: "Controlled Transmission Movie", - Status: "downloading", - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - if err := svc.PauseDownloadTask(t.Context(), task.ID); err != nil { - t.Fatal(err) - } - if err := svc.ResumeDownloadTask(t.Context(), task.ID); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(methods, []string{"torrent-stop", "torrent-start"}) { - t.Fatalf("methods = %#v", methods) - } - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "queued" { - t.Fatalf("status = %q", updated.Status) - } -} diff --git a/internal/service/download_controls.go b/internal/service/download_controls.go deleted file mode 100644 index 6da2e6f..0000000 --- a/internal/service/download_controls.go +++ /dev/null @@ -1,105 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) PauseDownloadTask(ctx context.Context, taskID string) error { - return d.controlDownloadTask(ctx, taskID, "paused", func(target downloadTarget, externalID string) error { - if target.legacyQB { - return d.qb.Pause(ctx, externalID) - } - return target.adapter.Pause(ctx, externalID) - }) -} - -func (d *DownloadService) ResumeDownloadTask(ctx context.Context, taskID string) error { - return d.controlDownloadTask(ctx, taskID, "queued", func(target downloadTarget, externalID string) error { - if target.legacyQB { - return d.qb.Resume(ctx, externalID) - } - return target.adapter.Resume(ctx, externalID) - }) -} - -func (d *DownloadService) controlDownloadTask(ctx context.Context, taskID, status string, operation func(downloadTarget, string) error) error { - taskID = strings.TrimSpace(taskID) - if taskID == "" { - return errors.New("task id is required") - } - var task model.DownloadTask - if d == nil || d.repo == nil || d.repo.DB == nil { - return errors.New("download repository is unavailable") - } - if err := d.repo.DB.WithContext(ctx).Where("id = ?", taskID).First(&task).Error; err != nil { - return err - } - clientID, externalID := d.resolveTaskDownloaderIdentity(ctx, &task) - if externalID == "" { - return errors.New("download task has no client task id") - } - target, err := d.downloadTargetByID(ctx, clientID) - if err != nil { - return err - } - if err := operation(target, externalID); err != nil { - return err - } - return d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", task.ID). - Updates(map[string]any{ - "status": status, - "download_client_id": clientID, - "external_id": externalID, - "source": firstNonEmpty(target.typ, task.Source), - }).Error -} - -func (d *DownloadService) resolveTaskDownloaderIdentity(ctx context.Context, task *model.DownloadTask) (string, string) { - if task == nil { - return "", "" - } - clientID := strings.TrimSpace(task.DownloadClientID) - persistedExternalID := strings.TrimSpace(task.ExternalID) - externalID := persistedExternalID - if externalID == "" { - externalID = torrentURLInfoHash(task.URL) - } - if clientID != "" && persistedExternalID != "" { - return clientID, externalID - } - live, _ := d.listLiveTorrents(ctx, "") - for _, torrent := range live { - if clientID != "" && torrent.ClientID != clientID { - continue - } - if externalID != "" && strings.EqualFold(torrent.Hash, externalID) { - clientID = firstNonEmpty(clientID, torrent.ClientID) - return clientID, torrent.Hash - } - if downloadTaskMatchesLiveTorrent(*task, torrent) { - clientID = firstNonEmpty(clientID, torrent.ClientID) - externalID = firstNonEmpty(externalID, torrent.Hash) - return clientID, externalID - } - } - if clientID == "" && d.manager != nil { - var matched []managedDownloadTarget - for _, target := range d.manager.targets() { - if strings.TrimSpace(task.Source) == "" || target.client.Type == task.Source { - matched = append(matched, target) - } - } - if len(matched) == 1 { - clientID = matched[0].client.ID - } - } - if clientID == "" && d.qb != nil && d.qb.IsConfigured() && (task.Source == "" || task.Source == "qbittorrent") { - clientID = legacyQBitDownloadClientID - } - return clientID, externalID -} diff --git a/internal/service/download_delete_test.go b/internal/service/download_delete_test.go deleted file mode 100644 index 2bcbfe4..0000000 --- a/internal/service/download_delete_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDeleteMarksMatchingDownloadTaskDeleted(t *testing.T) { - const hash = "abc123" - const title = "Delete Marker Show S01E01 1080p" - var deleteCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Delete Marker Show S01E01 1080p","state":"downloading","progress":0.5}]`)) - case "/api/v2/torrents/delete": - atomic.AddInt32(&deleteCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=1", - Title: title, - SavePath: "/downloads/tv", - Status: "downloading", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - if err := svc.Delete(t.Context(), hash, false); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&deleteCalls); got != 1 { - t.Fatalf("delete calls = %d, want 1", got) - } - - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("status = %q, want deleted", updated.Status) - } -} - -func TestDeleteRoutesToRequestedTransmissionClient(t *testing.T) { - var removed map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - switch req.Method { - case "torrent-get": - arguments["torrents"] = []map[string]interface{}{{ - "hashString": "transmission-hash", - "name": "Delete Transmission Movie", - "percentDone": 0.5, - "status": 4, - }} - case "torrent-remove": - removed = req.Arguments - default: - t.Errorf("unexpected transmission method %q", req.Method) - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "transmission", - DownloadClientID: client.ID, - ExternalID: "transmission-hash", - URL: "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Title: "Delete Transmission Movie", - Status: "downloading", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), "transmission-hash", true, client.ID); err != nil { - t.Fatal(err) - } - ids, ok := removed["ids"].([]interface{}) - if !ok || len(ids) != 1 || ids[0] != "transmission-hash" || removed["delete-local-data"] != true { - t.Fatalf("remove arguments = %#v", removed) - } - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("status = %q", updated.Status) - } -} - -func TestDeleteMarksMagnetTaskDeletedWhenLiveTorrentNameMissing(t *testing.T) { - const hash = "0123456789abcdef0123456789abcdef0123c0de" - var deleteCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/delete": - atomic.AddInt32(&deleteCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:" + hash + "&dn=Codex.Path.Verify.S01E01.2026", - Title: "Codex Path Verify S01E01 2026", - SavePath: "/downloads/tv", - Status: "queued", - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - if err := svc.Delete(t.Context(), hash, false); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&deleteCalls); got != 1 { - t.Fatalf("delete calls = %d, want 1", got) - } - - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("status = %q, want deleted", updated.Status) - } -} diff --git a/internal/service/download_helpers.go b/internal/service/download_helpers.go new file mode 100644 index 0000000..22df0bd --- /dev/null +++ b/internal/service/download_helpers.go @@ -0,0 +1,75 @@ +package service + +import ( + "strings" + "unicode" +) + +// 整理入库与外部搜索依赖的通用标题归一化辅助函数。 +// 这些函数原本与订阅/下载逻辑共存,移除对应功能时保留为通用帮助函数。 + +func normalizeAvailabilityComparable(value string) string { + var b strings.Builder + for _, r := range strings.ToLower(value) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func compactUniqueStrings(values ...string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + key := normalizeAvailabilityComparable(value) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out +} + +func titleMatchesResolution(titleFold, resolution string) bool { + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "2160p", "4k", "uhd": + return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd") + case "1080p": + return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd") + case "720p": + return strings.Contains(titleFold, "720p") + default: + return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution))) + } +} + +func detectResolutionScore(titleFold string) int { + switch { + case titleMatchesResolution(titleFold, "2160p"): + return 4 + case titleMatchesResolution(titleFold, "1080p"): + return 3 + case titleMatchesResolution(titleFold, "720p"): + return 2 + default: + return 1 + } +} \ No newline at end of file diff --git a/internal/service/download_identity.go b/internal/service/download_identity.go deleted file mode 100644 index 032d89a..0000000 --- a/internal/service/download_identity.go +++ /dev/null @@ -1,207 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "path" - "regexp" - "strings" - "unicode" -) - -var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`) -var downloadPackTitleToken = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:complete|batch|pack|合集|全集|整季|全季)(?:[\s._-]|$)`) - -func localAvailabilityTitleCandidates(title string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, 6) - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - if _, ok := seen[value]; ok { - return - } - seen[value] = struct{}{} - out = append(out, value) - } - add(availabilityQuery(title, "")) - if cleaned, _ := CleanQuery(title); cleaned != "" { - for _, candidate := range titleCandidates(cleaned) { - add(candidate) - fields := strings.Fields(candidate) - for i := len(fields) - 1; i >= 1; i-- { - prefix := strings.Join(fields[:i], " ") - if containsCJK(prefix) { - add(prefix) - } - } - } - } - return out -} - -func downloadTaskBlocksDuplicate(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "failed", "error", "removed", "cancelled", "canceled": - return false - default: - return true - } -} - -func downloadTaskBlocksReadd(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "failed", "error", "deleted", "removed", "cancelled", "canceled": - return false - default: - return true - } -} - -func downloadTaskIdentityKey(name string) string { - if key := downloadMediaIdentityKey(name); key != "" { - return key - } - return normalizedDownloadTitleKey(name) -} - -type downloadMediaIdentity struct { - TitleKey string - Year int - Episodes []episodeRef - Pack bool -} - -func parseDownloadMediaIdentity(name string) downloadMediaIdentity { - title, year := CleanQuery(name) - if isSeriesPackTitle(name) { - title = downloadPackTitleToken.ReplaceAllString(title, " ") - } - titleKey := normalizeAvailabilityComparable(title) - if titleKey == "" { - titleKey = normalizeAvailabilityComparable(availabilityQuery(name, "")) - } - return downloadMediaIdentity{ - TitleKey: titleKey, - Year: year, - Episodes: episodeRefsFromTitle(name), - Pack: isSeriesPackTitle(name), - } -} - -func downloadTitleCoversRequest(existing, requested string) bool { - current := parseDownloadMediaIdentity(existing) - want := parseDownloadMediaIdentity(requested) - if current.TitleKey == "" || want.TitleKey == "" { - currentKey := normalizedDownloadTitleKey(existing) - wantKey := normalizedDownloadTitleKey(requested) - return currentKey != "" && wantKey != "" && (currentKey == wantKey || strings.Contains(currentKey, wantKey) || strings.Contains(wantKey, currentKey)) - } - if current.TitleKey != want.TitleKey { - return false - } - if current.Year > 0 && want.Year > 0 && current.Year != want.Year { - return false - } - if downloadIdentityCoversWholeSeason(existing, current) { - return true - } - if len(current.Episodes) == 0 || len(want.Episodes) == 0 { - return len(current.Episodes) == len(want.Episodes) - } - currentEpisodes := map[string]struct{}{} - for _, ref := range current.Episodes { - currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - for _, ref := range want.Episodes { - if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func downloadIdentityCoversWholeSeason(title string, identity downloadMediaIdentity) bool { - if !identity.Pack || len(identity.Episodes) > 0 { - return false - } - return seriesPackRE.MatchString(title) -} - -func downloadMediaIdentityKey(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "" { - return "" - } - identity := parseDownloadMediaIdentity(name) - titleKey := identity.TitleKey - if titleKey == "" { - return "" - } - parts := []string{titleKey} - if identity.Year > 0 { - parts = append(parts, fmt.Sprintf("y%d", identity.Year)) - } - if len(identity.Episodes) > 0 { - first := identity.Episodes[0] - last := identity.Episodes[len(identity.Episodes)-1] - parts = append(parts, fmt.Sprintf("s%02de%03d", first.Season, first.Episode)) - if len(identity.Episodes) > 1 { - parts = append(parts, fmt.Sprintf("to%03d", last.Episode)) - } - } - return strings.Join(parts, "|") -} - -func normalizedDownloadTitleKey(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - var b strings.Builder - for _, r := range name { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - } - } - return b.String() -} - -func publicDownloadTitle(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "下载任务" - } - if u, err := url.Parse(raw); err == nil { - if dn := strings.TrimSpace(u.Query().Get("dn")); dn != "" { - if decoded, err := url.QueryUnescape(dn); err == nil && strings.TrimSpace(decoded) != "" { - return strings.TrimSpace(decoded) - } - return dn - } - if u.Host != "" { - base := path.Base(u.Path) - if base != "." && base != "/" && base != "" { - base = strings.TrimSuffix(base, path.Ext(base)) - if base != "" { - return base - } - } - return u.Host - } - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "磁力下载" - } - return "下载任务" -} - -func normalizeTorrentName(name string) string { - name = torrentEpisodeToken.ReplaceAllString(strings.ToLower(name), "") - var b strings.Builder - for _, r := range name { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - } - } - return b.String() -} diff --git a/internal/service/download_live_snapshot.go b/internal/service/download_live_snapshot.go deleted file mode 100644 index bbc67bb..0000000 --- a/internal/service/download_live_snapshot.go +++ /dev/null @@ -1,52 +0,0 @@ -package service - -import "time" - -func (d *DownloadService) currentTime() time.Time { - if d != nil && d.now != nil { - return d.now() - } - return time.Now() -} - -func (d *DownloadService) recordLiveTorrentSnapshot(live []QBitTorrent) { - if d == nil { - return - } - snapshot := cloneQBitTorrentSlice(live) - d.mu.Lock() - d.liveTorrents = snapshot - d.liveTorrentsAt = d.currentTime() - d.mu.Unlock() -} - -func (d *DownloadService) LiveTorrentSnapshot(maxAge time.Duration) []QBitTorrent { - snapshot, ok := d.liveTorrentSnapshot(maxAge) - if !ok { - return nil - } - return snapshot -} - -func (d *DownloadService) liveTorrentSnapshot(maxAge time.Duration) ([]QBitTorrent, bool) { - if d == nil { - return nil, false - } - now := d.currentTime() - d.mu.Lock() - defer d.mu.Unlock() - if d.liveTorrentsAt.IsZero() { - return nil, false - } - if maxAge > 0 && now.Sub(d.liveTorrentsAt) > maxAge { - return nil, false - } - return cloneQBitTorrentSlice(d.liveTorrents), true -} - -func cloneQBitTorrentSlice(in []QBitTorrent) []QBitTorrent { - if len(in) == 0 { - return nil - } - return append([]QBitTorrent(nil), in...) -} diff --git a/internal/service/download_manager_svc.go b/internal/service/download_manager_svc.go deleted file mode 100644 index e20abc2..0000000 --- a/internal/service/download_manager_svc.go +++ /dev/null @@ -1,346 +0,0 @@ -// Package service — 下载管理器,管理多个下载客户端适配器。 -// -// DownloadManager 提供多客户端分发能力,支持运行时热插拔。 -// 调用方通过 GetDefault() 或 GetClient(id) 获取适配器来执行下载操作。 -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DownloadManager 管理多个下载客户端适配器实例。 -type DownloadManager struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - - mu sync.RWMutex - clients map[string]DownloadAdapter // clientID -> adapter - configs map[string]DownloadClientConfig - models map[string]model.DownloadClient - order []string -} - -// NewDownloadManager 创建新的下载管理器。 -func NewDownloadManager(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *DownloadManager { - return &DownloadManager{ - log: log, - repo: repo, - crypto: crypto, - clients: make(map[string]DownloadAdapter), - configs: make(map[string]DownloadClientConfig), - models: make(map[string]model.DownloadClient), - } -} - -// LoadAll 从数据库加载所有已启用的客户端并初始化适配器。 -func (m *DownloadManager) LoadAll(ctx context.Context) error { - dbClients, err := m.repo.DownloadClient.ListEnabled(ctx) - if err != nil { - return err - } - if err := m.ensureEnabledDefault(ctx, dbClients); err != nil { - return err - } - - clients := make(map[string]DownloadAdapter, len(dbClients)) - configs := make(map[string]DownloadClientConfig, len(dbClients)) - models := make(map[string]model.DownloadClient, len(dbClients)) - order := make([]string, 0, len(dbClients)) - - for _, dc := range dbClients { - adapter, cfg, ok := m.initializeClient(ctx, dc) - if !ok { - continue - } - clients[dc.ID] = adapter - configs[dc.ID] = cfg - models[dc.ID] = dc - order = append(order, dc.ID) - m.log.Info("download client registered", - zap.String("id", dc.ID), - zap.String("name", dc.Name), - zap.String("type", dc.Type), - ) - } - m.mu.Lock() - m.clients = clients - m.configs = configs - m.models = models - m.order = order - m.mu.Unlock() - return nil -} - -func (m *DownloadManager) ensureEnabledDefault(ctx context.Context, clients []model.DownloadClient) error { - if len(clients) == 0 { - return nil - } - for i := range clients { - if clients[i].IsDefault { - return nil - } - } - if err := m.repo.DownloadClient.SetDefault(ctx, clients[0].ID); err != nil { - return err - } - clients[0].IsDefault = true - return nil -} - -func (m *DownloadManager) initializeClient(ctx context.Context, dc model.DownloadClient) (DownloadAdapter, DownloadClientConfig, bool) { - cfg, err := m.buildConfig(&dc) - if err != nil { - m.log.Warn("failed to build config for download client", - zap.String("id", dc.ID), - zap.String("name", dc.Name), - zap.Error(err)) - return nil, DownloadClientConfig{}, false - } - adapter := AdapterFactory(dc.Type) - if adapter == nil { - m.log.Warn("unknown download client type", - zap.String("type", dc.Type), - zap.String("id", dc.ID)) - return nil, DownloadClientConfig{}, false - } - if initErr := adapter.Initialize(ctx, cfg); initErr != nil { - // Register configured clients even when the external process is still - // starting; each operation can reconnect once it becomes reachable. - m.log.Warn("download client init failed; registered for lazy reconnect", - zap.String("id", dc.ID), - zap.String("name", dc.Name), - zap.Error(initErr)) - } - return adapter, cfg, true -} - -// GetDefault 返回默认下载客户端适配器。 -// 如果没有设置默认客户端,返回第一个可用的客户端。 -func (m *DownloadManager) GetDefault(_ context.Context) (*model.DownloadClient, DownloadAdapter, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, id := range m.order { - client := m.models[id] - if client.IsDefault { - if adapter, ok := m.clients[id]; ok { - copy := client - return ©, adapter, nil - } - } - } - for _, id := range m.order { - if adapter, ok := m.clients[id]; ok { - client := m.models[id] - copy := client - return ©, adapter, nil - } - } - - return nil, nil, errors.New("no download client available") -} - -// GetClient 返回指定 ID 的下载客户端适配器。 -func (m *DownloadManager) GetClient(id string) (DownloadAdapter, error) { - m.mu.RLock() - defer m.mu.RUnlock() - adapter, ok := m.clients[id] - if !ok { - return nil, errors.New("download client not found or not initialized") - } - return adapter, nil -} - -type managedDownloadTarget struct { - client model.DownloadClient - adapter DownloadAdapter -} - -func (m *DownloadManager) getTarget(id string) (managedDownloadTarget, error) { - m.mu.RLock() - defer m.mu.RUnlock() - adapter, ok := m.clients[id] - if !ok { - return managedDownloadTarget{}, errors.New("download client not found or not initialized") - } - client, ok := m.models[id] - if !ok { - return managedDownloadTarget{}, errors.New("download client metadata not found") - } - return managedDownloadTarget{client: client, adapter: adapter}, nil -} - -func (m *DownloadManager) targets() []managedDownloadTarget { - if m == nil { - return nil - } - m.mu.RLock() - defer m.mu.RUnlock() - out := make([]managedDownloadTarget, 0, len(m.order)) - for _, id := range m.order { - adapter, ok := m.clients[id] - if !ok { - continue - } - client, ok := m.models[id] - if !ok { - continue - } - out = append(out, managedDownloadTarget{client: client, adapter: adapter}) - } - return out -} - -func (m *DownloadManager) hasClients() bool { - if m == nil { - return false - } - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.clients) > 0 -} - -// AddClient 动态添加并初始化一个下载客户端。 -func (m *DownloadManager) AddClient(ctx context.Context, dc *model.DownloadClient) error { - cfg, err := m.buildConfig(dc) - if err != nil { - return err - } - - adapter := AdapterFactory(dc.Type) - if adapter == nil { - return errors.New("unknown download client type: " + dc.Type) - } - - if err := adapter.Initialize(ctx, cfg); err != nil { - return err - } - - m.mu.Lock() - defer m.mu.Unlock() - for i, current := range m.order { - if current == dc.ID { - m.order = append(m.order[:i], m.order[i+1:]...) - break - } - } - m.clients[dc.ID] = adapter - m.configs[dc.ID] = cfg - m.models[dc.ID] = *dc - m.order = append(m.order, dc.ID) - return nil -} - -// RemoveClient 移除一个下载客户端(停止适配器,不删除数据库记录)。 -func (m *DownloadManager) RemoveClient(id string) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.clients, id) - delete(m.configs, id) - delete(m.models, id) - for i, current := range m.order { - if current == id { - m.order = append(m.order[:i], m.order[i+1:]...) - break - } - } -} - -// UpdateClient 更新已有客户端的配置并重新初始化。 -func (m *DownloadManager) UpdateClient(ctx context.Context, dc *model.DownloadClient) error { - m.RemoveClient(dc.ID) - return m.AddClient(ctx, dc) -} - -// TestConnection 测试客户端连接。 -func (m *DownloadManager) TestConnection(ctx context.Context, dc *model.DownloadClient) error { - cfg, err := m.buildConfig(dc) - if err != nil { - return err - } - - adapter := AdapterFactory(dc.Type) - if adapter == nil { - return errors.New("unknown download client type: " + dc.Type) - } - - return adapter.Initialize(ctx, cfg) -} - -// ListAll 获取所有已加载客户端的种子列表。 -func (m *DownloadManager) ListAll(ctx context.Context, filter string) (map[string][]TorrentInfo, error) { - result := make(map[string][]TorrentInfo) - var listErrs []error - for _, target := range m.targets() { - list, err := target.adapter.List(ctx, filter) - if err != nil { - m.log.Warn("failed to list torrents from client", - zap.String("id", target.client.ID), - zap.Error(err), - ) - listErrs = append(listErrs, fmt.Errorf("%s (%s): %w", target.client.Name, target.client.Type, err)) - } - if len(list) > 0 || err == nil { - result[target.client.ID] = list - } - } - return result, errors.Join(listErrs...) -} - -// GetAdapterTypes 返回支持的下载客户端类型列表。 -func (m *DownloadManager) GetAdapterTypes() []AdapterTypeInfo { - return []AdapterTypeInfo{ - {Type: "qbittorrent", Name: "qBittorrent", Description: "qBittorrent WebUI API (v2)"}, - {Type: "transmission", Name: "Transmission", Description: "Transmission RPC API"}, - {Type: "aria2", Name: "Aria2", Description: "Aria2 JSON-RPC API"}, - } -} - -// AdapterTypeInfo 描述下载客户端类型信息。 -type AdapterTypeInfo struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` -} - -// buildConfig 从数据库模型构建适配器配置。 -func (m *DownloadManager) buildConfig(dc *model.DownloadClient) (DownloadClientConfig, error) { - password := dc.Password - if m.crypto != nil && password != "" { - password = m.crypto.Decrypt(password) - } - host, err := normalizeDownloadClientEndpoint(dc.Type, dc.Host) - if err != nil { - return DownloadClientConfig{}, err - } - - cfg := DownloadClientConfig{ - Host: host, - Username: dc.Username, - Password: password, - } - - // 解析 Extra JSON 配置 - if dc.Extra != "" { - extraStr := dc.Extra - if m.crypto != nil { - extraStr = m.crypto.Decrypt(extraStr) - } - var extra map[string]string - if err := json.Unmarshal([]byte(extraStr), &extra); err == nil { - cfg.Extra = extra - } - } - - return cfg, nil -} diff --git a/internal/service/download_multi_client_test.go b/internal/service/download_multi_client_test.go deleted file mode 100644 index 4760206..0000000 --- a/internal/service/download_multi_client_test.go +++ /dev/null @@ -1,417 +0,0 @@ -package service - -import ( - "encoding/base64" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "sort" - "sync" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadUsesDefaultTransmissionClient(t *testing.T) { - var mu sync.Mutex - var added map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - switch req.Method { - case "torrent-get": - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: map[string]interface{}{"torrents": []interface{}{}}}) - case "torrent-add": - mu.Lock() - added = req.Arguments - mu.Unlock() - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{ - Result: "success", - Arguments: map[string]interface{}{ - "torrent-added": map[string]interface{}{"hashString": "transmission-hash", "name": "Movie 2026"}, - }, - }) - default: - t.Errorf("unexpected transmission method %q", req.Method) - } - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026", "/downloads/movies", DownloadTaskMeta{Title: "Movie 2026"}) - if err != nil { - t.Fatal(err) - } - if task.Source != "transmission" || task.DownloadClientID != client.ID || task.ExternalID != "transmission-hash" { - t.Fatalf("task downloader identity = %#v", task) - } - mu.Lock() - defer mu.Unlock() - if added["filename"] != "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026" { - t.Fatalf("transmission filename = %#v", added["filename"]) - } - if added["download-dir"] != "/downloads/movies" { - t.Fatalf("transmission download-dir = %#v", added["download-dir"]) - } -} - -func TestAddDownloadSendsFetchedTorrentBytesToTransmission(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/x-bittorrent") - w.Header().Set("Content-Disposition", `attachment; filename="fixture.torrent"`) - _, _ = w.Write(torrentData) - })) - defer torrentServer.Close() - - var metainfo string - transmission := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - switch req.Method { - case "torrent-get": - arguments["torrents"] = []interface{}{} - case "torrent-add": - metainfo, _ = req.Arguments["metainfo"].(string) - arguments["torrent-added"] = map[string]interface{}{"hashString": "torrent-file-hash"} - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - defer transmission.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Site.Create(t.Context(), &model.Site{Name: "Fixture", Type: "custom_rss", URL: torrentServer.URL, AuthType: "cookie", Enabled: true}); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: transmission.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - site := NewSiteService(zap.NewNop(), repos, "") - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil, site) - svc.SetDownloadManager(manager) - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", torrentServer.URL+"/fixture.torrent", "/downloads", DownloadTaskMeta{}) - if err != nil { - t.Fatal(err) - } - if metainfo != base64.StdEncoding.EncodeToString(torrentData) { - t.Fatalf("metainfo = %q", metainfo) - } - if task.ExternalID != "torrent-file-hash" || task.Title != "fixture" { - t.Fatalf("task = %#v", task) - } -} - -func TestAddDownloadSendsPublicTorrentURLBytesToAria2(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/x-bittorrent") - _, _ = w.Write(torrentData) - })) - defer torrentServer.Close() - - var addMethod string - aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - result := interface{}(map[string]interface{}{"version": "1.37"}) - switch req.Method { - case "aria2.tellActive", "aria2.tellWaiting", "aria2.tellStopped": - result = []interface{}{} - case "aria2.addTorrent", "aria2.addUri": - addMethod = req.Method - result = "aria2-torrent-gid" - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "result": result}) - })) - defer aria2.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "aria2", Type: "aria2", Host: aria2.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - site := NewSiteService(zap.NewNop(), repos, "") - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil, site) - svc.SetDownloadManager(NewDownloadManager(zap.NewNop(), repos, nil)) - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", torrentServer.URL+"/public.torrent", "/downloads", DownloadTaskMeta{Title: "Public Torrent"}) - if err != nil { - t.Fatal(err) - } - if addMethod != "aria2.addTorrent" || task.ExternalID != "aria2-torrent-gid" { - t.Fatalf("add method = %q task = %#v", addMethod, task) - } -} - -func TestReloadConfigHotSwapsUpdatedTransmissionClient(t *testing.T) { - newServer := func(addCalls *int32, hash string) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - switch req.Method { - case "torrent-get": - arguments["torrents"] = []interface{}{} - case "torrent-add": - atomic.AddInt32(addCalls, 1) - arguments["torrent-added"] = map[string]interface{}{"hashString": hash} - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - } - var firstCalls, secondCalls int32 - first := newServer(&firstCalls, "first-hash") - defer first.Close() - second := newServer(&secondCalls, "second-hash") - defer second.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: first.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if _, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=First+Movie", "/downloads", DownloadTaskMeta{Title: "First Movie"}); err != nil { - t.Fatal(err) - } - - client.Host = second.URL - if err := repos.DownloadClient.Update(t.Context(), client); err != nil { - t.Fatal(err) - } - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Second+Movie", "/downloads", DownloadTaskMeta{Title: "Second Movie"}) - if err != nil { - t.Fatal(err) - } - if atomic.LoadInt32(&firstCalls) != 1 || atomic.LoadInt32(&secondCalls) != 1 || task.ExternalID != "second-hash" { - t.Fatalf("hot reload calls = %d/%d task = %#v", firstCalls, secondCalls, task) - } -} - -func TestDownloadManagerPersistsOldestEnabledClientAsDefault(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - http.NotFound(w, r) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}) - repos := repository.New(db) - first := &model.DownloadClient{ - Base: model.Base{CreatedAt: time.Now().Add(-time.Hour)}, - Name: "First Transmission", - Type: "transmission", - Host: server.URL, - Enabled: true, - IsDefault: false, - } - second := &model.DownloadClient{ - Base: model.Base{CreatedAt: time.Now()}, - Name: "Second Transmission", - Type: "transmission", - Host: server.URL, - Enabled: true, - IsDefault: false, - } - if err := repos.DownloadClient.Create(t.Context(), first); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), second); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - if err := manager.LoadAll(t.Context()); err != nil { - t.Fatal(err) - } - selected, _, err := manager.GetDefault(t.Context()) - if err != nil { - t.Fatal(err) - } - if selected.ID != first.ID { - t.Fatalf("default client = %#v", selected) - } - refreshed, err := repos.DownloadClient.FindByID(t.Context(), first.ID) - if err != nil { - t.Fatal(err) - } - if refreshed == nil || !refreshed.IsDefault { - t.Fatalf("persisted default = %#v", refreshed) - } -} - -func TestRelocateRejectsNonQBitClient(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - t.Errorf("unexpected Transmission request during unsupported relocation") - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - err := svc.RelocateTorrent(t.Context(), "transmission-hash", "/new/location", client.ID) - if !errors.Is(err, ErrDownloadOperationUnsupported) { - t.Fatalf("err = %v", err) - } -} - -func TestListAggregatesEnabledClientsWithNormalizedProgress(t *testing.T) { - transmission := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - if req.Method == "torrent-get" { - arguments["torrents"] = []map[string]interface{}{{ - "hashString": "transmission-hash", - "name": "Transmission Movie", - "totalSize": 1000, - "percentDone": 0.5, - "rateDownload": 100, - "rateUpload": 10, - "status": 4, - "downloadDir": "/downloads/transmission", - "addedDate": 100, - "doneDate": 0, - }} - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - defer transmission.Close() - - aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - result := interface{}(map[string]interface{}{"version": "1.37"}) - switch req.Method { - case "aria2.tellActive": - result = []map[string]interface{}{{ - "gid": "aria2-gid", - "bittorrent": map[string]interface{}{"info": map[string]interface{}{"name": "Aria Movie"}, "infoHash": "aria-info-hash"}, - "totalLength": "2000", - "completedLength": "500", - "downloadSpeed": "200", - "uploadSpeed": "20", - "status": "active", - "dir": "/downloads/aria2", - }} - case "aria2.tellWaiting", "aria2.tellStopped": - result = []interface{}{} - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": result, - }) - })) - defer aria2.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - transmissionClient := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: transmission.URL, IsDefault: true, Enabled: true} - aria2Client := &model.DownloadClient{Name: "aria2", Type: "aria2", Host: aria2.URL, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), transmissionClient); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), aria2Client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - _, live, err := svc.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(live) != 2 { - t.Fatalf("live torrents = %#v", live) - } - sort.Slice(live, func(i, j int) bool { return live[i].Source < live[j].Source }) - if live[0].Source != "aria2" || live[0].ClientID != aria2Client.ID || live[0].Progress != 0.25 || live[0].ContentPath != "/downloads/aria2/Aria Movie" { - t.Fatalf("aria2 live torrent = %#v", live[0]) - } - if live[1].Source != "transmission" || live[1].ClientID != transmissionClient.ID || live[1].Progress != 0.5 || live[1].ContentPath != "/downloads/transmission/Transmission Movie" { - t.Fatalf("transmission live torrent = %#v", live[1]) - } -} diff --git a/internal/service/download_notification.go b/internal/service/download_notification.go deleted file mode 100644 index dac1120..0000000 --- a/internal/service/download_notification.go +++ /dev/null @@ -1,84 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) notifyDownloadComplete(ctx context.Context, torrent QBitTorrent, task *model.DownloadTask) { - if d == nil || d.notify == nil { - return - } - if d.completedTorrentNotified(ctx, torrent) { - return - } - d.markCompletedTorrentNotified(ctx, torrent) - body, data := downloadCompleteNotificationPayload(torrent, task) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - d.notify.BroadcastEvent(ctx, NotifyEvent{ - Type: EventDownloadComplete, - Title: "MediaStationGo 下载完成", - Message: body, - Data: data, - }) - }() -} - -func downloadCompleteNotificationPayload(torrent QBitTorrent, task *model.DownloadTask) (string, map[string]interface{}) { - name := downloadCompleteNotificationName(torrent, task) - body := "任务:" + name - data := downloadCompleteNotificationData(torrent, task) - return body, data -} - -func downloadCompleteNotificationName(torrent QBitTorrent, task *model.DownloadTask) string { - name := strings.TrimSpace(torrent.Name) - if name == "" { - name = strings.TrimSpace(filepath.Base(torrent.ContentPath)) - } - if task != nil && strings.TrimSpace(task.Title) != "" { - name = strings.TrimSpace(task.Title) - } - if name == "" { - name = "下载任务" - } - return name -} - -func downloadCompleteNotificationData(torrent QBitTorrent, task *model.DownloadTask) map[string]interface{} { - data := map[string]interface{}{} - if rt := strings.TrimSpace(torrent.Name); rt != "" { - data["resource_title"] = rt - } - if task == nil { - return data - } - addTrimmedString(data, "poster_url", task.PosterURL) - addTrimmedString(data, "backdrop_url", task.BackdropURL) - addTrimmedString(data, "media_type", task.MediaType) - addTrimmedString(data, "media_category", task.MediaCategory) - addTrimmedString(data, "title", task.Title) - addTrimmedString(data, "overview", task.Overview) - addTrimmedString(data, "original_title", task.OriginalName) - addTrimmedString(data, "original_language", task.OriginalLanguage) - if task.Year > 0 { - data["year"] = task.Year - } - if task.Rating > 0 { - data["rating"] = task.Rating - } - addTrimmedString(data, "genres", task.Genres) - return data -} - -func addTrimmedString(data map[string]interface{}, key, value string) { - if strings.TrimSpace(value) != "" { - data[key] = value - } -} diff --git a/internal/service/download_polling.go b/internal/service/download_polling.go deleted file mode 100644 index 780c13f..0000000 --- a/internal/service/download_polling.go +++ /dev/null @@ -1,201 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const completedTorrentOrganizeQueueSize = 64 - -var completedTorrentOrganizeCooldown = 3 * time.Second - -// poll aggregates every enabled downloader every 5 s as WS events. The -// payload is opaque to the client; the React store merges by hash. -func (d *DownloadService) poll(ctx context.Context) { - t := time.NewTicker(5 * time.Second) - defer t.Stop() - // prevStates tracks previous completion states to detect "just finished" - if d.prevStates == nil { - d.prevStates = make(map[string]bool) - } - for { - select { - case <-ctx.Done(): - return - case <-d.stopCh: - return - case <-t.C: - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - continue - } - rows, _ := d.repo.Download.List(ctx) - taskByKey := tasksByTorrentIdentity(rows) - d.processDownloadSnapshot(ctx, live, taskByKey) - d.hub.Publish("download", map[string]any{"torrents": live}) - } -} - -func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QBitTorrent, taskByKey map[string]model.DownloadTask) { - d.recordLiveTorrentSnapshot(live) - firstSnapshot := d.beginDownloadSnapshot() - for _, torrent := range live { - d.processTorrentSnapshot(ctx, torrent, taskByKey, firstSnapshot) - } -} - -func (d *DownloadService) beginDownloadSnapshot() bool { - d.mu.Lock() - defer d.mu.Unlock() - if d.prevStates == nil { - d.prevStates = make(map[string]bool) - } - firstSnapshot := !d.pollInitialized - if firstSnapshot { - d.pollInitialized = true - } - return firstSnapshot -} - -func (d *DownloadService) processTorrentSnapshot(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask, firstSnapshot bool) { - stateKey := completedTorrentQueueKey(torrent) - taskNeedsOrganize := d.downloadSnapshotTaskNeedsOrganize(ctx, torrent, taskByKey) - d.syncDownloadTaskProgress(ctx, torrent, taskByKey) - if stateKey == "" { - return - } - if d.completedTorrentShouldQueue(stateKey, qbitTorrentCompleted(torrent), firstSnapshot, taskNeedsOrganize) && - d.enqueueCompletedTorrent(torrent) { - d.markCompletedTorrentState(stateKey) - } -} - -func (d *DownloadService) downloadSnapshotTaskNeedsOrganize(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask) bool { - matchedTask, hasTask := findMatchingTaskForTorrent(torrent, taskByKey) - if !hasTask || d.completedTorrentCatchupRecorded(ctx, torrent) || !d.downloadAutoOrganizeEnabled(ctx) { - return false - } - return downloadTaskNeedsCompletion(matchedTask) || recentlyCompletedTorrent(torrent, time.Now()) -} - -func (d *DownloadService) completedTorrentShouldQueue(stateKey string, complete, firstSnapshot, taskNeedsOrganize bool) bool { - d.mu.Lock() - defer d.mu.Unlock() - if d.prevStates == nil { - d.prevStates = make(map[string]bool) - } - wasComplete, wasSeen := d.prevStates[stateKey] - switch { - case complete && (firstSnapshot || !wasSeen): - // 首次快照里已完成的种子:此前一律标记「已见过」并跳过整理, - // 导致「下载完成时应用恰好不在线/正在重启」的种子永远不会被 - // 自动整理入库。现在对最近完成的种子补一次整理 - // (onTorrentComplete 内部仍受 organize.auto 开关约束,且 - // 整理对已存在的目标文件幂等跳过)。 - d.prevStates[stateKey] = true - return taskNeedsOrganize - case complete && !wasComplete: - return true - case complete && taskNeedsOrganize: - return true - case complete: - d.prevStates[stateKey] = true - default: - d.prevStates[stateKey] = false - } - return false -} - -func (d *DownloadService) markCompletedTorrentState(stateKey string) { - d.mu.Lock() - d.prevStates[stateKey] = true - d.mu.Unlock() -} - -func (d *DownloadService) startAutoOrganizeWorker(ctx context.Context) { - d.mu.Lock() - if d.organizeQueue == nil { - d.organizeQueue = make(chan QBitTorrent, completedTorrentOrganizeQueueSize) - } - if d.organizeQueued == nil { - d.organizeQueued = make(map[string]struct{}) - } - d.mu.Unlock() - d.organizeOnce.Do(func() { - go d.autoOrganizeWorker(ctx) - }) -} - -func (d *DownloadService) enqueueCompletedTorrent(torrent QBitTorrent) bool { - key := completedTorrentQueueKey(torrent) - if key == "" { - return false - } - d.mu.Lock() - if d.organizeQueue == nil { - d.organizeQueue = make(chan QBitTorrent, completedTorrentOrganizeQueueSize) - } - if d.organizeQueued == nil { - d.organizeQueued = make(map[string]struct{}) - } - if _, ok := d.organizeQueued[key]; ok { - d.mu.Unlock() - return true - } - select { - case d.organizeQueue <- torrent: - d.organizeQueued[key] = struct{}{} - d.mu.Unlock() - return true - default: - d.mu.Unlock() - if d.log != nil { - d.log.Warn("auto organize queue full; will retry completed torrent later", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name)) - } - return false - } -} - -func (d *DownloadService) autoOrganizeWorker(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-d.stopCh: - return - case torrent := <-d.organizeQueue: - d.onTorrentComplete(ctx, torrent) - d.markCompletedTorrentOrganizeDone(torrent) - if completedTorrentOrganizeCooldown <= 0 { - continue - } - timer := time.NewTimer(completedTorrentOrganizeCooldown) - select { - case <-ctx.Done(): - timer.Stop() - return - case <-d.stopCh: - timer.Stop() - return - case <-timer.C: - } - } - } -} - -func (d *DownloadService) markCompletedTorrentOrganizeDone(torrent QBitTorrent) { - key := completedTorrentQueueKey(torrent) - if key == "" { - return - } - d.mu.Lock() - delete(d.organizeQueued, key) - d.mu.Unlock() -} diff --git a/internal/service/download_runtime.go b/internal/service/download_runtime.go deleted file mode 100644 index e05c64e..0000000 --- a/internal/service/download_runtime.go +++ /dev/null @@ -1,191 +0,0 @@ -package service - -import ( - "context" - "encoding/base32" - "encoding/hex" - "errors" - "fmt" - "net/url" - "strings" -) - -const legacyQBitDownloadClientID = "legacy-qbittorrent" - -type downloadTarget struct { - clientID string - typ string - adapter DownloadAdapter - legacyQB bool -} - -func (d *DownloadService) listLiveTorrents(ctx context.Context, filter string) ([]QBitTorrent, error) { - if d != nil && d.manager != nil && d.manager.hasClients() { - var live []QBitTorrent - var listErrs []error - for _, target := range d.manager.targets() { - items, err := target.adapter.List(ctx, filter) - if err != nil { - listErrs = append(listErrs, fmt.Errorf("%s (%s): %w", target.client.Name, target.client.Type, err)) - } - for _, item := range items { - torrent := TorrentInfoToQBit(item) - torrent.ClientID = target.client.ID - torrent.Source = target.client.Type - live = append(live, torrent) - } - } - return live, errors.Join(listErrs...) - } - if d != nil && d.qb != nil && d.qb.IsConfigured() { - live, err := d.qb.List(ctx, filter) - for i := range live { - live[i].ClientID = legacyQBitDownloadClientID - live[i].Source = "qbittorrent" - live[i].Progress = float32(normalizedTorrentProgress(float64(live[i].Progress))) - live[i].State = canonicalTorrentState(live[i].State, float64(live[i].Progress)) - } - return live, err - } - return nil, errors.New("no download client available") -} - -func torrentURLInfoHash(raw string) string { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || !strings.EqualFold(parsed.Scheme, "magnet") { - return "" - } - for _, xt := range parsed.Query()["xt"] { - const prefix = "urn:btih:" - if strings.HasPrefix(strings.ToLower(xt), prefix) { - return normalizeTorrentInfoHash(xt[len(prefix):]) - } - } - return "" -} - -func normalizeTorrentInfoHash(value string) string { - value = strings.TrimSpace(value) - if len(value) == 40 { - if _, err := hex.DecodeString(value); err == nil { - return strings.ToLower(value) - } - } - if len(value) == 32 { - decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(value)) - if err == nil && len(decoded) == 20 { - return hex.EncodeToString(decoded) - } - } - return strings.ToLower(value) -} - -func (d *DownloadService) defaultDownloadTarget(ctx context.Context) (downloadTarget, error) { - if d != nil && d.manager != nil { - client, adapter, err := d.manager.GetDefault(ctx) - if err == nil && client != nil && adapter != nil { - return downloadTarget{clientID: client.ID, typ: client.Type, adapter: adapter}, nil - } - } - if d != nil && d.qb != nil && d.qb.IsConfigured() { - return downloadTarget{clientID: legacyQBitDownloadClientID, typ: "qbittorrent", legacyQB: true}, nil - } - return downloadTarget{}, errors.New("no default downloader configured: 请在下载客户端中配置并启用默认下载器") -} - -func (d *DownloadService) downloadTargetByID(ctx context.Context, clientID string) (downloadTarget, error) { - clientID = strings.TrimSpace(clientID) - if clientID == "" { - return d.defaultDownloadTarget(ctx) - } - if clientID == legacyQBitDownloadClientID { - if d != nil && d.qb != nil && d.qb.IsConfigured() { - return downloadTarget{clientID: legacyQBitDownloadClientID, typ: "qbittorrent", legacyQB: true}, nil - } - return downloadTarget{}, errors.New("legacy qbittorrent is not configured") - } - if d != nil && d.manager != nil { - target, err := d.manager.getTarget(clientID) - if err == nil { - return downloadTarget{clientID: target.client.ID, typ: target.client.Type, adapter: target.adapter}, nil - } - } - return downloadTarget{}, errors.New("download client not found or disabled") -} - -func (d *DownloadService) resolveOperationClientID(ctx context.Context, externalID, requestedClientID string) (string, string, error) { - requestedClientID = strings.TrimSpace(requestedClientID) - live, listErr := d.listLiveTorrents(ctx, "") - if clientID, name, err, resolved := resolveLiveOperationClient(live, externalID, requestedClientID); resolved { - return clientID, name, err - } - if requestedClientID != "" { - return requestedClientID, "", nil - } - if clientID, err := d.persistedOperationClientID(ctx, externalID); clientID != "" || err != nil { - return clientID, "", err - } - if clientID, err := d.singleAvailableOperationClientID(); clientID != "" || err != nil { - return clientID, "", err - } - if d != nil && d.qb != nil && d.qb.IsConfigured() { - return legacyQBitDownloadClientID, "", nil - } - if listErr != nil { - return "", "", listErr - } - return "", "", errors.New("download client not found") -} - -func resolveLiveOperationClient(live []QBitTorrent, externalID, requestedClientID string) (string, string, error, bool) { - var matched []QBitTorrent - for _, torrent := range live { - if requestedClientID != "" && torrent.ClientID != requestedClientID { - continue - } - if strings.EqualFold(strings.TrimSpace(torrent.Hash), strings.TrimSpace(externalID)) { - matched = append(matched, torrent) - } - } - if len(matched) == 1 { - return matched[0].ClientID, matched[0].Name, nil, true - } - if len(matched) > 1 && requestedClientID == "" { - return "", "", errors.New("multiple download clients contain this task; client_id is required"), true - } - return "", "", nil, false -} - -func (d *DownloadService) persistedOperationClientID(ctx context.Context, externalID string) (string, error) { - if d != nil && d.repo != nil && d.repo.Download != nil { - rows, err := d.repo.Download.List(ctx) - if err != nil { - return "", err - } - var clientID string - for _, row := range rows { - if !strings.EqualFold(strings.TrimSpace(row.ExternalID), strings.TrimSpace(externalID)) || strings.TrimSpace(row.DownloadClientID) == "" { - continue - } - if clientID != "" && clientID != row.DownloadClientID { - return "", errors.New("multiple download clients contain this task; client_id is required") - } - clientID = row.DownloadClientID - } - return clientID, nil - } - return "", nil -} - -func (d *DownloadService) singleAvailableOperationClientID() (string, error) { - if d != nil && d.manager != nil { - targets := d.manager.targets() - if len(targets) == 1 { - return targets[0].client.ID, nil - } - if len(targets) > 1 { - return "", errors.New("client_id is required when multiple download clients are enabled") - } - } - return "", nil -} diff --git a/internal/service/download_torrent_normalize.go b/internal/service/download_torrent_normalize.go deleted file mode 100644 index 0b3b98d..0000000 --- a/internal/service/download_torrent_normalize.go +++ /dev/null @@ -1,80 +0,0 @@ -package service - -import "strings" - -func normalizedTorrentProgress(progress float64) float64 { - if progress < 0 { - return 0 - } - if progress > 1 { - return 1 - } - return progress -} - -func canonicalTorrentState(state string, progress float64) string { - state = strings.ToLower(strings.TrimSpace(state)) - complete := normalizedTorrentProgress(progress) >= 1 - switch state { - case "completed", "complete", "seeding", "uploading", "stalledup", "pausedup", "queuedup", "forcedup": - if complete || state == "completed" || state == "complete" || state == "seeding" { - return "completed" - } - return "downloading" - case "downloading", "forceddl", "metadl", "stalleddl", "active": - if complete { - return "completed" - } - return "downloading" - case "queued", "queueddl", "download_pending", "seed_pending", "waiting": - if complete { - return "completed" - } - return "queued" - case "paused", "pauseddl", "stoppeddl", "stopped": - if complete { - return "completed" - } - return "paused" - case "checking", "checkingdl", "checkingup", "checkingresumedata", "check_pending", "moving": - return "checking" - case "error", "missingfiles": - return "error" - case "removed": - return "removed" - case "": - if complete { - return "completed" - } - return "" - default: - if complete { - return "completed" - } - return state - } -} - -func downloaderPayloadPath(dir, name string) string { - dir = strings.TrimSpace(dir) - name = strings.TrimSpace(name) - if name == "" { - return dir - } - if dir == "" { - return name - } - separator := "/" - if strings.Contains(dir, `\`) && !strings.Contains(dir, "/") { - separator = `\` - } - return strings.TrimRight(dir, `/\`) + separator + strings.TrimLeft(name, `/\`) -} - -func downloaderPathBase(value string) string { - value = strings.TrimRight(strings.TrimSpace(value), `/\`) - if i := strings.LastIndexAny(value, `/\`); i >= 0 { - return value[i+1:] - } - return value -} diff --git a/internal/service/download_views.go b/internal/service/download_views.go deleted file mode 100644 index b0f7384..0000000 --- a/internal/service/download_views.go +++ /dev/null @@ -1,217 +0,0 @@ -package service - -import ( - "math" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type DownloadTaskView struct { - ID string `json:"id"` - Source string `json:"source"` - DownloadClientID string `json:"download_client_id,omitempty"` - ExternalID string `json:"external_id,omitempty"` - Title string `json:"title"` - PosterURL string `json:"poster_url,omitempty"` - BackdropURL string `json:"backdrop_url,omitempty"` - Overview string `json:"overview,omitempty"` - SavePath string `json:"save_path"` - MediaType string `json:"media_type,omitempty"` - MediaCategory string `json:"media_category,omitempty"` - Status string `json:"status"` - Progress float32 `json:"progress"` - State string `json:"state,omitempty"` - DLSpeed int64 `json:"dlspeed,omitempty"` - UpSpeed int64 `json:"upspeed,omitempty"` - Size int64 `json:"size,omitempty"` - Downloaded int64 `json:"downloaded,omitempty"` - NumSeeds int `json:"num_seeds,omitempty"` - NumLeechs int `json:"num_leechs,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type DownloadTorrentView struct { - Hash string `json:"hash"` - ClientID string `json:"client_id"` - Source string `json:"source"` - Name string `json:"name"` - Title string `json:"title"` - PosterURL string `json:"poster_url,omitempty"` - BackdropURL string `json:"backdrop_url,omitempty"` - Overview string `json:"overview,omitempty"` - MediaType string `json:"media_type,omitempty"` - MediaCategory string `json:"media_category,omitempty"` - State string `json:"state"` - Progress float32 `json:"progress"` - DLSpeed int64 `json:"dlspeed"` - UpSpeed int64 `json:"upspeed"` - NumSeeds int `json:"num_seeds"` - NumLeechs int `json:"num_leechs"` - Size int64 `json:"size"` - Downloaded int64 `json:"downloaded"` - SavePath string `json:"save_path"` -} - -func DownloadViews(rows []model.DownloadTask, live []QBitTorrent) ([]DownloadTaskView, []DownloadTorrentView) { - liveByKey := map[string]QBitTorrent{} - for _, torrent := range live { - key := normalizeTorrentName(torrent.Name) - if key != "" { - setLiveTorrentIndex(liveByKey, key, torrent) - setLiveTorrentIndex(liveByKey, downloadTaskClientTitleKey(torrent.ClientID, key), torrent) - } - setLiveTorrentIndex(liveByKey, downloadTaskExternalKey(torrent.ClientID, torrent.Hash), torrent) - setLiveTorrentIndex(liveByKey, downloadTaskAnyExternalKey(torrent.Hash), torrent) - } - taskByKey := tasksByTorrentIdentity(rows) - - taskViews := make([]DownloadTaskView, 0, len(rows)) - for _, row := range rows { - view := downloadTaskView(row, QBitTorrent{}) - if torrent, ok := findMatchingTorrentForTask(row, liveByKey); ok { - view = downloadTaskView(row, torrent) - } - taskViews = append(taskViews, view) - } - - torrentViews := make([]DownloadTorrentView, 0, len(live)) - for _, torrent := range live { - var row model.DownloadTask - if matched, ok := findMatchingTaskForTorrent(torrent, taskByKey); ok { - row = matched - } - torrentViews = append(torrentViews, downloadTorrentView(torrent, row)) - } - return taskViews, torrentViews -} - -func downloadTaskView(row model.DownloadTask, torrent QBitTorrent) DownloadTaskView { - progress := row.Progress - state := row.Status - if torrent.Name != "" { - progress = torrent.Progress - state = torrent.State - } - size := torrent.Size - return DownloadTaskView{ - ID: row.ID, - Source: row.Source, - DownloadClientID: row.DownloadClientID, - ExternalID: row.ExternalID, - Title: firstNonEmpty(row.Title, "下载任务"), - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - Overview: row.Overview, - SavePath: row.SavePath, - MediaType: row.MediaType, - MediaCategory: row.MediaCategory, - Status: row.Status, - Progress: progress, - State: state, - DLSpeed: torrent.DLSpeed, - UpSpeed: torrent.UpSpeed, - Size: size, - Downloaded: downloadedBytes(size, progress), - NumSeeds: torrent.NumSeeds, - NumLeechs: torrent.NumLeech, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func downloadTorrentView(torrent QBitTorrent, row model.DownloadTask) DownloadTorrentView { - title := torrent.Name - if row.Title != "" { - title = row.Title - } - return DownloadTorrentView{ - Hash: torrent.Hash, - ClientID: torrent.ClientID, - Source: torrent.Source, - Name: torrent.Name, - Title: firstNonEmpty(title, "下载任务"), - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - Overview: row.Overview, - MediaType: row.MediaType, - MediaCategory: firstNonEmpty(row.MediaCategory, torrent.Category), - State: torrent.State, - Progress: torrent.Progress, - DLSpeed: torrent.DLSpeed, - UpSpeed: torrent.UpSpeed, - NumSeeds: torrent.NumSeeds, - NumLeechs: torrent.NumLeech, - Size: torrent.Size, - Downloaded: downloadedBytes(torrent.Size, torrent.Progress), - SavePath: torrent.SavePath, - } -} - -func setLiveTorrentIndex(index map[string]QBitTorrent, key string, torrent QBitTorrent) { - if key == "" { - return - } - if _, exists := index[key]; !exists { - index[key] = torrent - } -} - -func findMatchingTorrentForTask(row model.DownloadTask, liveByKey map[string]QBitTorrent) (QBitTorrent, bool) { - if torrent, ok := liveByKey[downloadTaskExternalKey(row.DownloadClientID, row.ExternalID)]; ok { - return torrent, true - } - if torrent, ok := liveByKey[downloadTaskAnyExternalKey(row.ExternalID)]; ok { - if strings.TrimSpace(row.DownloadClientID) == "" || row.DownloadClientID == torrent.ClientID { - return torrent, true - } - } - key := normalizeTorrentName(row.Title) - if torrent, ok := liveByKey[downloadTaskClientTitleKey(row.DownloadClientID, key)]; ok { - return torrent, true - } - if strings.TrimSpace(row.DownloadClientID) != "" { - return QBitTorrent{}, false - } - return findMatchingTorrent(row.Title, liveByKey) -} - -func findMatchingTorrent(title string, liveByKey map[string]QBitTorrent) (QBitTorrent, bool) { - key := normalizeTorrentName(title) - if key == "" { - return QBitTorrent{}, false - } - if torrent, ok := liveByKey[key]; ok { - return torrent, true - } - for currentKey, torrent := range liveByKey { - if strings.HasPrefix(currentKey, "\x00") { - continue - } - if strings.Contains(currentKey, key) || strings.Contains(key, currentKey) { - return torrent, true - } - } - return QBitTorrent{}, false -} - -func downloadedBytes(size int64, progress float32) int64 { - if size <= 0 || progress <= 0 { - return 0 - } - if progress > 1 { - progress = 1 - } - return int64(math.Round(float64(size) * float64(progress))) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} diff --git a/internal/service/downloads.go b/internal/service/downloads.go deleted file mode 100644 index a0df2d5..0000000 --- a/internal/service/downloads.go +++ /dev/null @@ -1,308 +0,0 @@ -// Package service — download manager. -// -// DownloadService persists user-initiated downloads, dispatches them to -// the configured qBittorrent, Transmission, or aria2 client and pushes live progress -// to the WS hub so the React UI can render a live table. -// -// Settings consumed (system Setting table): -// -// qbittorrent.url e.g. http://127.0.0.1:8080 -// qbittorrent.username qBittorrent WebUI user -// qbittorrent.password qBittorrent WebUI password -// qbittorrent.savepath optional default save dir -// -// Settings can be updated at runtime via the admin UI; ReloadConfig() -// re-reads them and re-authenticates. -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DownloadService is the single download orchestrator. -type DownloadService struct { - log *zap.Logger - repo *repository.Container - hub *Hub - qb *QBitClient - manager *DownloadManager - organizer *OrganizerService - organizePipeline *OrganizePipelineService - scanner *ScannerService - site *SiteService - tasks *TaskTrackerService - notify *NotifyChannelService - - mu sync.Mutex - stopCh chan struct{} - pollOnce sync.Once - organizeOnce sync.Once - prevStates map[string]bool // client/task identity -> wasCompleted - pollInitialized bool - liveTorrents []QBitTorrent - liveTorrentsAt time.Time - now func() time.Time - organizeQueue chan QBitTorrent - organizeQueued map[string]struct{} -} - -func (d *DownloadService) SetScanner(scanner *ScannerService) { - d.scanner = scanner -} - -func (d *DownloadService) SetOrganizePipeline(pipeline *OrganizePipelineService) { - d.organizePipeline = pipeline -} - -func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) { - d.tasks = tasks -} - -func (d *DownloadService) SetNotifyChannels(notify *NotifyChannelService) { - d.notify = notify -} - -func (d *DownloadService) SetDownloadManager(manager *DownloadManager) { - d.manager = manager -} - -// ErrDownloadAlreadyExists tells callers that the requested resource is already -// tracked locally or present in a downloader. Subscriptions treat this as a -// successful dedup hit, not as a retryable enqueue failure. -var ErrDownloadAlreadyExists = errors.New("download already exists") - -// ErrMediaAlreadyInLibrary tells callers that the requested movie/episode is -// already present in the scanned media library and must not be sent to the -// downloader again. -var ErrMediaAlreadyInLibrary = errors.New("media already exists in library") - -var ErrDownloadOperationUnsupported = errors.New("download client operation unsupported") - -func IsDownloadDedupError(err error) bool { - return errors.Is(err, ErrDownloadAlreadyExists) || errors.Is(err, ErrMediaAlreadyInLibrary) -} - -// NewDownloadService is the constructor. -func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService, site ...*SiteService) *DownloadService { - var siteSvc *SiteService - if len(site) > 0 { - siteSvc = site[0] - } - return &DownloadService{ - log: log, - repo: repo, - hub: hub, - qb: NewQBitClient(log, QBitConfig{}), - organizer: organizer, - site: siteSvc, - prevStates: make(map[string]bool), - now: time.Now, - organizeQueue: make(chan QBitTorrent, completedTorrentOrganizeQueueSize), - organizeQueued: make(map[string]struct{}), - stopCh: make(chan struct{}), - } -} - -// Start kicks off the background poller (idempotent). -func (d *DownloadService) Start(ctx context.Context) { - d.pollOnce.Do(func() { - if err := d.ReloadConfig(ctx); err != nil && d.log != nil { - d.log.Warn("initial download client reload failed", zap.Error(err)) - } - d.startAutoOrganizeWorker(ctx) - go d.poll(ctx) - }) -} - -// Stop terminates the poller. -func (d *DownloadService) Stop() { - close(d.stopCh) -} - -func (d *DownloadService) TorrentExistsByName(ctx context.Context, name string) bool { - query := normalizeTorrentName(name) - if query == "" { - return false - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - return false - } - for _, torrent := range live { - if downloadTitleCoversRequest(torrent.Name, name) { - return true - } - current := normalizeTorrentName(torrent.Name) - if current == "" { - continue - } - if current == query { - return true - } - } - return false -} - -// List returns every persisted download task augmented with live downloader data. -func (d *DownloadService) List(ctx context.Context) ([]model.DownloadTask, []QBitTorrent, error) { - rows, err := d.repo.Download.List(ctx) - if err != nil { - return nil, nil, err - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil { - // A failed client must not hide healthy clients or persisted rows. - d.log.Debug("download client list failed", zap.Error(err)) - if len(live) == 0 { - return rows, nil, nil - } - } - return rows, live, nil -} - -// Delete removes a task from its native downloader. clientID is optional for -// legacy callers, but disambiguates equal native IDs across multiple clients. -func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles bool, clientID ...string) error { - hash = strings.TrimSpace(hash) - if hash == "" { - return errors.New("hash is required") - } - requestedClientID := "" - if len(clientID) > 0 { - requestedClientID = clientID[0] - } - resolvedClientID, torrentName, err := d.resolveOperationClientID(ctx, hash, requestedClientID) - if err != nil { - return err - } - target, err := d.downloadTargetByID(ctx, resolvedClientID) - if err != nil { - return err - } - if target.legacyQB { - err = d.qb.Delete(ctx, hash, withFiles) - } else { - err = target.adapter.Remove(ctx, hash, withFiles) - } - if err != nil { - return err - } - d.markDownloadTaskDeleted(ctx, hash, torrentName, resolvedClientID) - stateKey := completedTorrentQueueKey(QBitTorrent{ClientID: resolvedClientID, Hash: hash}) - d.mu.Lock() - delete(d.prevStates, stateKey) - delete(d.organizeQueued, stateKey) - d.mu.Unlock() - return nil -} - -func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, hash, torrentName string, clientID ...string) { - if d == nil || d.repo == nil || d.repo.DB == nil { - return - } - rows, err := d.repo.Download.List(ctx) - if err != nil { - return - } - if matched, ok := findDownloadTaskByHash(rows, hash, clientID...); ok { - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", matched.ID). - Updates(map[string]any{ - "status": "deleted", - "progress": matched.Progress, - }).Error - return - } - if strings.TrimSpace(torrentName) == "" { - return - } - taskByKey := tasksByTorrentIdentity(rows) - matched, ok := findMatchingTaskForTorrent(QBitTorrent{Name: torrentName, Hash: hash, ClientID: firstString(clientID)}, taskByKey) - if !ok { - return - } - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", matched.ID). - Updates(map[string]any{ - "status": "deleted", - "progress": matched.Progress, - }).Error -} - -func findDownloadTaskByHash(rows []model.DownloadTask, hash string, clientID ...string) (model.DownloadTask, bool) { - hash = strings.ToLower(strings.TrimSpace(hash)) - if hash == "" { - return model.DownloadTask{}, false - } - wantClientID := strings.TrimSpace(firstString(clientID)) - for _, row := range rows { - if wantClientID != "" && strings.TrimSpace(row.DownloadClientID) != "" && row.DownloadClientID != wantClientID { - continue - } - if strings.EqualFold(strings.TrimSpace(row.ExternalID), hash) { - return row, true - } - } - for _, row := range rows { - if wantClientID != "" && strings.TrimSpace(row.DownloadClientID) != "" && row.DownloadClientID != wantClientID { - continue - } - if strings.Contains(strings.ToLower(row.URL), hash) { - return row, true - } - } - return model.DownloadTask{}, false -} - -func firstString(values []string) string { - if len(values) == 0 { - return "" - } - return values[0] -} - -// RelocateTorrent moves a torrent's data to a new save directory while keeping -// it seeding (qBittorrent performs the physical move and resumes seeding). -// 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。 -func (d *DownloadService) RelocateTorrent(ctx context.Context, hash, location string, clientID ...string) error { - if strings.TrimSpace(hash) == "" { - return errors.New("hash is required") - } - if strings.TrimSpace(location) == "" { - return errors.New("location is required") - } - requestedClientID := firstString(clientID) - resolvedClientID := strings.TrimSpace(requestedClientID) - if resolvedClientID == "" { - var err error - resolvedClientID, _, err = d.resolveOperationClientID(ctx, hash, "") - if err != nil { - return err - } - } - target, err := d.downloadTargetByID(ctx, resolvedClientID) - if err != nil { - return err - } - if target.typ != "qbittorrent" { - return fmt.Errorf("%w: %s does not support torrent relocation; only qBittorrent is supported", ErrDownloadOperationUnsupported, target.typ) - } - if target.legacyQB { - return d.qb.SetLocation(ctx, hash, strings.TrimSpace(location)) - } - relocator, ok := target.adapter.(TorrentRelocateAdapter) - if !ok { - return fmt.Errorf("%w: configured qBittorrent adapter cannot relocate torrents", ErrDownloadOperationUnsupported) - } - return relocator.Relocate(ctx, hash, strings.TrimSpace(location)) -} diff --git a/internal/service/downloads_completion_test.go b/internal/service/downloads_completion_test.go deleted file mode 100644 index 3f0982c..0000000 --- a/internal/service/downloads_completion_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestCompletedTorrentSourceDoesNotFallbackToSavePath(t *testing.T) { - root := t.TempDir() - savePath := filepath.Join(root, "downloads", "日番") - if err := os.MkdirAll(savePath, 0o755); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), newOrganizerTestRepo(t), NewHub(zap.NewNop()), nil) - - got := svc.completedTorrentSource(t.Context(), QBitTorrent{ - Hash: "done123", - Name: "Missing.Payload.S01", - SavePath: savePath, - ContentPath: filepath.Join(savePath, "Missing.Payload.S01", "Missing.Payload.S01E01.mkv"), - }) - - if got != "" { - t.Fatalf("completedTorrentSource fell back to whole save_path %q; want empty", got) - } -} - -func TestDownloadCompleteRecordsUnsupportedVideoAsHandled(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "Toy.Story.4.2019.iso") - dest := filepath.Join(root, "media") - writeOrgFile(t, src, "iso") - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - torrent := QBitTorrent{ - Hash: "unsupported-iso", - Name: "Toy.Story.4.2019", - Progress: 1, - SavePath: filepath.Dir(src), - ContentPath: src, - CompletionOn: time.Now().Add(-time.Hour).Unix(), - } - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), torrent) - - if !svc.completedTorrentCatchupRecorded(t.Context(), torrent) { - t.Fatalf("unsupported completed torrent should be marked handled to avoid repeated auto-organize retries") - } -} - -func TestAutoOrganizeSyncsVisibilityWhenTargetAlreadyExists(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv") - dest := filepath.Join(root, "media") - writeOrgFile(t, src, "episode") - - repos := newOrganizerTestRepo(t) - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - lib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - if _, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ - SourcePath: src, - DestPath: dest, - TransferMode: TransferCopy, - }); err != nil { - t.Fatalf("seed organized destination: %v", err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.SetScanner(scanner) - - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "done123", - Name: "狂飙.S01E01.2023.1080p", - Progress: 1, - SavePath: filepath.Dir(src), - ContentPath: src, - }) - - var count int64 - if err := repos.DB.Model(&model.Media{}).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("target already exists should still be scanned into DB, count=%d want 1", count) - } -} - -func TestCompletedTorrentSourceUsesConfiguredMapping(t *testing.T) { - root := t.TempDir() - localRoot := filepath.Join(root, "localdl") - payload := filepath.Join(localRoot, "Show.S01") - if err := os.MkdirAll(payload, 0o755); err != nil { - t.Fatal(err) - } - repos := newOrganizerTestRepo(t) - if err := repos.Setting.Set(t.Context(), DownloadPathMappingsSettingKey, "/qb/downloads="+localRoot); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - got := svc.completedTorrentSource(t.Context(), QBitTorrent{ContentPath: "/qb/downloads/Show.S01"}) - if got != payload { - t.Fatalf("completedTorrentSource = %q, want %q", got, payload) - } -} - -func TestUserPathMappingsParsing(t *testing.T) { - repos := newOrganizerTestRepo(t) - raw := "# comment\n/a=/b\n/c => /d\n/e:/f\nbad-line\n" - if err := repos.Setting.Set(t.Context(), DownloadPathMappingsSettingKey, raw); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - got := svc.userPathMappings(t.Context()) - want := map[string]string{"/a": "/b", "/c": "/d", "/e": "/f"} - if len(got) != len(want) { - t.Fatalf("userPathMappings = %v, want %v", got, want) - } - for k, v := range want { - if got[k] != v { - t.Fatalf("mapping %q = %q, want %q", k, got[k], v) - } - } -} diff --git a/internal/service/downloads_config_runtime.go b/internal/service/downloads_config_runtime.go deleted file mode 100644 index a228df7..0000000 --- a/internal/service/downloads_config_runtime.go +++ /dev/null @@ -1,134 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const settingDownloadClientsManaged = "download_clients.managed" - -// ReloadConfig reloads managed adapters and preserves the legacy qBittorrent -// settings fallback for deployments that never used download_clients. -// -// 配置来源优先级: -// -// 1. download_clients 表中已启用的显式默认客户端;没有默认时持久化最早 -// 创建的已启用客户端。 -// 2. 从未使用多客户端下载器配置的旧部署,读取 system Setting 表中的 -// qbittorrent.url / username / password -// (旧版「系统设置」表单写入的数据;保留作向后兼容)。 -// -// 这避免了两套配置各跑各的:之前操作员明明已经在「下载器」页面填好 -// 默认 qb,但实际下载链路读的还是 Setting 表,导致一直连不上。 -func (d *DownloadService) ReloadConfig(ctx context.Context) error { - if d.manager != nil { - if err := d.manager.LoadAll(ctx); err != nil { - return err - } - if d.manager.hasClients() { - // Managed clients use their native adapters. Keep the legacy qB - // client blank so a Transmission/aria2 default cannot be silently - // overridden by an unrelated qB row. - d.qb.Configure(QBitConfig{}) - return nil - } - } - cfg := QBitConfig{} - hasConfiguredClients := false - managedByDownloadClients := false - - // Path 1: download_clients 表。此分支主要服务未注入 DownloadManager 的 - // 单元/兼容调用;生产容器已在上方通过原生适配器返回。 - if d.repo.DownloadClient != nil { - hasConfiguredClients, _ = d.repo.DownloadClient.HasAnyIncludingDeleted(ctx) - selected, _ := d.repo.DownloadClient.FindDefault(ctx) - if selected == nil { - selected, _ = d.preferredEnabledClient(ctx) - if selected != nil { - _ = d.repo.DownloadClient.SetDefault(ctx, selected.ID) - selected.IsDefault = true - } - } - if selected != nil { - if d.log != nil { - d.log.Debug("selected managed default downloader", - zap.String("client_id", selected.ID), - zap.String("client", selected.Name), - zap.String("type", selected.Type)) - } - if selected.Type == "qbittorrent" { - cfg.BaseURL = strings.TrimRight(selected.Host, "/") - cfg.Username = selected.Username - cfg.Password = selected.Password - } - } - } - if d.repo.Setting != nil { - managedRaw, _ := d.repo.Setting.Get(ctx, settingDownloadClientsManaged) - managedByDownloadClients = strings.EqualFold(strings.TrimSpace(managedRaw), "true") - } - - // Path 2: legacy Setting 表。 - // 仅在旧部署“从未使用过 download_clients 表”时回退。只要操作员曾经 - // 配置过下载器,删除/禁用全部下载器就表示应停止投递,不能再偷偷用 - // qbittorrent.* 旧设置继续往下载器添加任务。 - if cfg.BaseURL == "" && !hasConfiguredClients && !managedByDownloadClients { - get := func(k string) string { - v, _ := d.repo.Setting.Get(ctx, k) - return v - } - cfg.BaseURL = get("qbittorrent.url") - cfg.Username = get("qbittorrent.username") - cfg.Password = get("qbittorrent.password") - } - - d.qb.Configure(cfg) - return nil -} - -func (d *DownloadService) preferredEnabledClient(ctx context.Context) (*model.DownloadClient, error) { - if d == nil || d.repo == nil || d.repo.DownloadClient == nil { - return nil, nil - } - rows, err := d.repo.DownloadClient.ListEnabled(ctx) - if err != nil { - return nil, err - } - if len(rows) == 0 { - return nil, nil - } - selected := rows[0] - return &selected, nil -} - -func (d *DownloadService) defaultDownloaderNotConfiguredError(ctx context.Context) error { - const prefix = "no default downloader configured" - if d == nil || d.repo == nil || d.repo.DownloadClient == nil { - return errors.New(prefix + ": 请在下载客户端中配置并启用下载器") - } - rows, err := d.repo.DownloadClient.ListEnabled(ctx) - if err != nil { - return fmt.Errorf("%s: 读取下载客户端配置失败: %w", prefix, err) - } - if len(rows) == 0 { - return errors.New(prefix + ": 请在下载客户端中启用下载器;当前没有已启用的下载器") - } - - var enabled []string - for _, row := range rows { - label := strings.TrimSpace(row.Name) - if label == "" { - label = row.Type - } else if row.Type != "" { - label += "(" + row.Type + ")" - } - enabled = append(enabled, label) - } - return fmt.Errorf("%s: 请检查已启用下载器的连接和默认设置;当前启用的下载器为 %s", prefix, strings.Join(enabled, ", ")) -} diff --git a/internal/service/downloads_organize_test.go b/internal/service/downloads_organize_test.go deleted file mode 100644 index a1a8ebf..0000000 --- a/internal/service/downloads_organize_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDownloadCompleteAutoOrganizesContentPath(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv") - dest := filepath.Join(root, "media") - if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(src, []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "done123", - Name: "狂飙.S01E01.2023.1080p", - Progress: 1, - SavePath: filepath.Join(root, "downloads", "国产剧"), - ContentPath: src, - }) - - want := filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv") - if _, err := os.Stat(want); err != nil { - t.Fatalf("auto organized file missing at %q: %v", want, err) - } - if _, err := os.Stat(src); err != nil { - t.Fatalf("copy mode should keep source: %v", err) - } -} - -func TestDownloadCompleteAutoOrganizeUsesTaskMediaCategory(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "Motherhood.of.Taihang.S01E01.2026.1080p.mkv") - dest := filepath.Join(root, "media") - writeOrgFile(t, src, "episode") - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:motherhood", - Title: "Motherhood.of.Taihang.S01E01.2026.1080p", - SavePath: filepath.Join(root, "downloads"), - MediaType: "tv", - MediaCategory: "国产剧", - Status: "completed", - Progress: 1, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "done-category", - Name: "Motherhood.of.Taihang.S01E01.2026.1080p", - Progress: 1, - SavePath: filepath.Join(root, "downloads"), - ContentPath: src, - }) - - categoryRoot := filepath.Join(dest, "电视剧", "国产剧") - var organized string - err := filepath.WalkDir(categoryRoot, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - if filepath.Ext(path) == ".mkv" { - organized = path - } - return nil - }) - if err != nil { - t.Fatalf("walk organized category: %v", err) - } - if organized == "" { - t.Fatalf("expected organized file under %q", categoryRoot) - } - wrongRoot := filepath.Join(dest, "电视剧", "Motherhood Of Taihang") - if _, err := os.Stat(wrongRoot); !os.IsNotExist(err) { - t.Fatalf("unexpected uncategorized organize root %q, err=%v", wrongRoot, err) - } -} - -func TestDownloadCompleteOnlyReplacesExistingWhenTaskAllowsWash(t *testing.T) { - for _, tc := range []struct { - name string - allowWash bool - wantContent string - }{ - {name: "wash disabled keeps existing", allowWash: false, wantContent: "inception-1080p"}, - {name: "wash enabled replaces existing", allowWash: true, wantContent: "inception-2160p"}, - } { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "Inception 2010 2160p BluRay.mkv") - dest := filepath.Join(root, "media") - existing := filepath.Join(dest, "电影", "Inception (2010)", "Inception (2010).mkv") - writeOrgFile(t, src, "inception-2160p") - writeOrgFile(t, existing, "inception-1080p") - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - Title: "Inception", - Path: existing, - Year: 2010, - Container: "mkv", - Width: 1920, - Height: 1080, - }); err != nil { - t.Fatal(err) - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:inception", - Title: "Inception 2010 2160p BluRay", - SavePath: filepath.Dir(src), - Status: "completed", - Progress: 1, - AllowExistingLibrary: tc.allowWash, - }); err != nil { - t.Fatal(err) - } - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "inception", - Name: "Inception 2010 2160p BluRay", - Progress: 1, - SavePath: filepath.Dir(src), - ContentPath: src, - }) - - got, err := os.ReadFile(existing) - if err != nil { - t.Fatal(err) - } - if string(got) != tc.wantContent { - t.Fatalf("existing content = %q, want %q", string(got), tc.wantContent) - } - }) - } -} diff --git a/internal/service/downloads_poll_test.go b/internal/service/downloads_poll_test.go deleted file mode 100644 index f4ccfba..0000000 --- a/internal/service/downloads_poll_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) { - repos := newOrganizerTestRepo(t) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "already-complete", - Name: "Already Complete S01E01", - Progress: 1, - State: "stalledUP", - }}, nil) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("first poll queued %d organize jobs, want 0", got) - } - if !svc.prevStates["already-complete"] { - t.Fatal("first poll should remember completed baseline state") - } - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "late-complete", - Name: "Late Complete S01E01", - Progress: 1, - State: "stalledUP", - }}, nil) - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("newly discovered completed torrent queued %d organize jobs, want 0", got) - } - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "new-download", - Name: "New Download S01E01", - Progress: 0.5, - }}, nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "new-download", - Name: "New Download S01E01", - Progress: 1, - State: "stalledUP", - }}, nil) - - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("completion transition queued %d organize jobs, want 1", got) - } -} - -func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:fresh", - Title: "Fresh Complete S01E01", - SavePath: "/downloads", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{ - {Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, State: "stalledUP", CompletionOn: time.Now().Add(-time.Hour).Unix()}, - {Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, State: "stalledUP", CompletionOn: time.Now().Add(-48 * time.Hour).Unix()}, - {Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1, State: "stalledUP"}, - }, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - // 只有补整理时间窗内、且存在本地追踪任务的种子会被补整理;无 completion_on 的保守跳过。 - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("first poll queued %d organize jobs, want 1 (recent tracked completion only)", got) - } -} - -func TestDownloadPollDoesNotCatchUpWhenAutoOrganizeDisabled(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:fresh", - Title: "Fresh Complete S01E01", - SavePath: "/downloads", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - torrent := QBitTorrent{ - Hash: "fresh-complete", - Name: "Fresh Complete S01E01", - Progress: 1, - State: "stalledUP", - CompletionOn: time.Now().Add(-time.Hour).Unix(), - } - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("auto-organize disabled queued %d completed jobs, want 0", got) - } -} - -func TestDownloadPollSkipsRecordedCompletedTorrentCatchup(t *testing.T) { - repos := newOrganizerTestRepo(t) - torrent := QBitTorrent{ - Hash: "fresh-complete", - Name: "Fresh Complete S01E01", - Progress: 1, - State: "stalledUP", - CompletionOn: time.Now().Add(-time.Hour).Unix(), - } - if err := repos.Setting.Set(t.Context(), completedTorrentCatchupSettingKey(torrent), "true"); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, nil) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("recorded completed torrent queued %d organize jobs, want 0", got) - } -} diff --git a/internal/service/downloads_progress_test.go b/internal/service/downloads_progress_test.go deleted file mode 100644 index d204318..0000000 --- a/internal/service/downloads_progress_test.go +++ /dev/null @@ -1,325 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSyncDownloadTaskProgressSkipsUnchangedCompletedTask(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Already.Done.S01E01", - SavePath: "/downloads", - Status: "completed", - Progress: 1, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - var before model.DownloadTask - if err := db.First(&before, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - time.Sleep(10 * time.Millisecond) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.syncDownloadTaskProgress(t.Context(), QBitTorrent{ - Name: task.Title, - Progress: 1, - State: "completed", - }, tasksByIdentity([]model.DownloadTask{before})) - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if !after.UpdatedAt.Equal(before.UpdatedAt) { - t.Fatalf("unchanged completed torrent touched updated_at: before=%s after=%s", before.UpdatedAt, after.UpdatedAt) - } -} - -func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", - SavePath: "/downloads/欧美剧", - Status: "queued", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.syncDownloadTaskProgress(t.Context(), QBitTorrent{ - Name: "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", - Progress: 1, - State: "stalledUP", - }, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status != "completed" || after.Progress != 1 { - t.Fatalf("task completion = %s/%v, want completed/1", after.Status, after.Progress) - } -} - -func TestSyncDownloadTaskProgressBackfillsDownloaderIdentity(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=legacy", - Title: "Legacy Identity Movie 2026", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - torrent := QBitTorrent{ - Hash: "transmission-hash", - ClientID: "transmission-client", - Source: "transmission", - Name: "Legacy Identity Movie 2026", - State: "downloading", - Progress: 0.4, - } - svc.syncDownloadTaskProgress(t.Context(), torrent, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - var updated model.DownloadTask - if err := repos.DB.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.DownloadClientID != torrent.ClientID || updated.ExternalID != torrent.Hash || updated.Source != torrent.Source { - t.Fatalf("downloader identity = %#v", updated) - } -} - -func TestProcessDownloadSnapshotCompletesTransmissionTask(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "transmission", - DownloadClientID: "transmission-client", - ExternalID: "shared-native-id", - URL: "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Title: "Transmission Complete Movie", - Status: "downloading", - Progress: 0.8, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - torrent := QBitTorrent{ - Hash: task.ExternalID, - ClientID: task.DownloadClientID, - Source: task.Source, - Name: task.Title, - State: "completed", - Progress: 1, - ContentPath: "/downloads/Transmission Complete Movie", - CompletionOn: time.Now().Unix(), - } - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("queued completed organize jobs = %d", got) - } - var updated model.DownloadTask - if err := repos.DB.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "completed" || updated.Progress != 1 { - t.Fatalf("task completion = %q/%v", updated.Status, updated.Progress) - } - other := torrent - other.ClientID = "another-client" - if completedTorrentQueueKey(torrent) == completedTorrentQueueKey(other) { - t.Fatal("completion queue keys collided across download clients") - } -} - -func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", - SavePath: "/downloads/欧美剧", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "quickdone", - Name: "Blades.of.the.Guardians.S02.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", - Progress: 1, - State: "stalledUP", - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("queued completed organize jobs = %d, want 1", got) - } -} - -func TestProcessDownloadSnapshotDoesNotQueueActiveDownloadAtFullProgress(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Still Downloading S01E01", - SavePath: "/downloads/欧美剧", - Status: "downloading", - Progress: 0.99, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "notdone", - Name: "Still.Downloading.S01E01", - Progress: 1, - State: "downloading", - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued active download organize jobs = %d, want 0", got) - } - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status == "completed" { - t.Fatalf("active download status = %q, should not be completed", after.Status) - } -} - -func TestProcessDownloadSnapshotDoesNotQueueFullProgressWithoutQBitState(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Missing State S01E01", - SavePath: "/downloads/欧美剧", - Status: "downloading", - Progress: 0.99, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "missing-state", - Name: "Missing.State.S01E01", - Progress: 1, - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued full-progress torrent without state = %d, want 0", got) - } - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status == "completed" { - t.Fatalf("missing-state torrent status = %q, should not be completed", after.Status) - } -} - -func TestProcessDownloadSnapshotDoesNotTrustCompletionOnForActiveDownload(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Still Downloading With Completion Timestamp S01E01", - SavePath: "/downloads/欧美剧", - Status: "downloading", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "notdone-completion-on", - Name: "Still.Downloading.With.Completion.Timestamp.S01E01", - Progress: 0.5, - State: "downloading", - CompletionOn: time.Now().Unix(), - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued active download organize jobs = %d, want 0", got) - } - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status == "completed" || after.Progress >= 1 { - t.Fatalf("active download mutated to completed state: status=%q progress=%v", after.Status, after.Progress) - } -} - -func TestProcessDownloadSnapshotSkipsUntrackedCompletedTorrentOnFirstSnapshot(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "historydone", - Name: "Large.History.Pack.2026.1080p", - Progress: 1, - State: "stalledUP", - CompletionOn: time.Now().Unix(), - }}, tasksByTorrentIdentity(nil)) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued untracked historical torrents = %d, want 0", got) - } -} diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go deleted file mode 100644 index 33e63dd..0000000 --- a/internal/service/downloads_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" -) - -func TestLiveTorrentSnapshotUsesPollingSnapshot(t *testing.T) { - now := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC) - svc := NewDownloadService(zap.NewNop(), nil, NewHub(zap.NewNop()), nil) - svc.now = func() time.Time { return now } - - live := []QBitTorrent{{ - Hash: "hash-1", - Name: "Release.Name.S01E01", - State: "downloading", - Progress: 0.5, - }} - svc.processDownloadSnapshot(t.Context(), live, nil) - live[0].Name = "mutated" - - got := svc.LiveTorrentSnapshot(30 * time.Second) - if len(got) != 1 || got[0].Name != "Release.Name.S01E01" { - t.Fatalf("snapshot = %#v, want cloned live torrent", got) - } - got[0].Name = "changed" - again := svc.LiveTorrentSnapshot(30 * time.Second) - if len(again) != 1 || again[0].Name != "Release.Name.S01E01" { - t.Fatalf("snapshot was mutable through caller: %#v", again) - } - - now = now.Add(31 * time.Second) - if stale := svc.LiveTorrentSnapshot(30 * time.Second); len(stale) != 0 { - t.Fatalf("stale snapshot = %#v, want empty", stale) - } -} diff --git a/internal/service/downloads_view_test.go b/internal/service/downloads_view_test.go deleted file mode 100644 index 00168ab..0000000 --- a/internal/service/downloads_view_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package service - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDownloadViewsDoNotExposePrivateURL(t *testing.T) { - rows := []model.DownloadTask{{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://tracker.example/download?id=1&passkey=private-token", - Title: "测试影片", - SavePath: "/downloads", - Status: "queued", - }} - - tasks, torrents := DownloadViews(rows, nil) - data, err := json.Marshal(map[string]any{ - "tasks": tasks, - "torrents": torrents, - }) - if err != nil { - t.Fatal(err) - } - body := string(data) - if strings.Contains(body, "private-token") || strings.Contains(body, "passkey") || strings.Contains(body, "tracker.example") { - t.Fatalf("download views leaked private URL: %s", body) - } - if !strings.Contains(body, "测试影片") { - t.Fatalf("download views should keep public title: %s", body) - } -} - -func TestDownloadCompleteNotificationPayloadUsesTaskMetadata(t *testing.T) { - body, data := downloadCompleteNotificationPayload(QBitTorrent{ - Hash: "done123", - Name: "Release.Name.S01E02.1080p", - SavePath: "/downloads/show", - ContentPath: "/downloads/show/Release.Name.S01E02.1080p.mkv", - }, &model.DownloadTask{ - Title: "正式标题", - PosterURL: "https://img.example/poster.jpg", - BackdropURL: "https://img.example/backdrop.jpg", - MediaType: "tv", - MediaCategory: "日番", - Overview: "简介", - OriginalName: "Original Title", - OriginalLanguage: "ja", - Year: 2026, - Rating: 8.7, - Genres: "动画,剧情", - }) - - if !strings.Contains(body, "任务:正式标题") { - t.Fatalf("body should prefer task title, got %q", body) - } - for _, private := range []string{"保存路径", "/downloads/show", "Hash", "done123"} { - if strings.Contains(body, private) { - t.Fatalf("body should not expose %q, got %q", private, body) - } - } - for key, want := range map[string]interface{}{ - "resource_title": "Release.Name.S01E02.1080p", - "title": "正式标题", - "poster_url": "https://img.example/poster.jpg", - "backdrop_url": "https://img.example/backdrop.jpg", - "media_type": "tv", - "media_category": "日番", - "overview": "简介", - "original_title": "Original Title", - "original_language": "ja", - "year": 2026, - "rating": float32(8.7), - "genres": "动画,剧情", - } { - if got := data[key]; got != want { - t.Fatalf("data[%s] = %#v, want %#v", key, got, want) - } - } -} diff --git a/internal/service/duplicate.go b/internal/service/duplicate.go deleted file mode 100644 index b39396b..0000000 --- a/internal/service/duplicate.go +++ /dev/null @@ -1,216 +0,0 @@ -// Package service — duplicate-file finder. -// -// DuplicateService finds duplicate media by two signals: -// -// - external identity: same TMDb / Bangumi / Douban / TheTVDB id and, for -// episodes, same season+episode; -// - sparse file hash: same head + middle + tail SHA-256 and same size. -// -// The first row (preferring scraped + larger files) is kept as the primary; -// the rest get is_duplicate = true and duplicate_of pointing at the primary. -// -// Why sparse: a full hash on a 50 GB Blu-ray remux takes minutes; the -// 3-window 3 MiB sample is enough to differentiate real-world copies -// while finishing per-file in well under a second. -package service - -import ( - "context" - "os" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DuplicateService is the entry point for the duplicate finder. -type DuplicateService struct { - log *zap.Logger - repo *repository.Container - hub *Hub -} - -// NewDuplicateService is the constructor. -func NewDuplicateService(log *zap.Logger, repo *repository.Container, hub *Hub) *DuplicateService { - return &DuplicateService{log: log, repo: repo, hub: hub} -} - -// Group describes one set of duplicates returned by Detect. -type Group struct { - Hash string `json:"hash"` - Primary model.Media `json:"primary"` - Duplicates []model.Media `json:"duplicates"` -} - -// Report is the summary the React UI displays. -type Report struct { - TotalScanned int `json:"total_scanned"` - GroupsFound int `json:"groups_found"` - ItemsMarked int `json:"items_marked"` - MissingRemoved int64 `json:"missing_removed"` - Groups []Group `json:"groups"` -} - -// Detect walks every media row in the given library (or all libraries -// when libraryID is empty), computes a hash for the ones missing it, -// then groups by hash and marks duplicates in the DB. -func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Report, error) { - var rows []model.Media - q := d.repo.DB.WithContext(ctx).Model(&model.Media{}) - if libraryID != "" { - q = q.Where("library_id = ?", libraryID) - } - if err := q.Find(&rows).Error; err != nil { - return nil, err - } - - rep := &Report{Groups: []Group{}} - rows = d.removeMissingRows(ctx, rows, rep) - rep.TotalScanned = len(rows) - totalToHash := 0 - for i := range rows { - if rows[i].FileHash == "" && rows[i].Path != "" { - totalToHash++ - } - } - - hashed := 0 - for i := range rows { - select { - case <-ctx.Done(): - return rep, ctx.Err() - default: - } - if rows[i].FileHash != "" || rows[i].Path == "" { - continue - } - h, err := SparseFileHash(rows[i].Path) - if err != nil { - d.log.Debug("hash failed", zap.String("path", rows[i].Path), zap.Error(err)) - continue - } - rows[i].FileHash = h - if err := d.repo.DB.WithContext(ctx). - Model(&model.Media{}). - Where("id = ?", rows[i].ID). - Update("file_hash", h).Error; err != nil { - d.log.Warn("hash persist failed", zap.Error(err)) - } - hashed++ - if d.hub != nil && totalToHash > 0 { - d.hub.Publish("duplicate", map[string]any{ - "hashed": hashed, - "total": totalToHash, - "current": rows[i].Title, - }) - } - } - - // Group rows by file_hash. - groups := make(map[string][]model.Media) - for _, r := range rows { - if r.FileHash == "" { - continue - } - groups[r.FileHash] = append(groups[r.FileHash], r) - } - - markedIDs := map[string]struct{}{} - for hash, group := range groups { - if len(group) < 2 { - continue - } - d.markDuplicateGroup(ctx, rep, hash, group, markedIDs) - } - - for key, group := range groupByExternalIdentity(rows) { - if len(group) < 2 { - continue - } - d.markDuplicateGroup(ctx, rep, key, group, markedIDs) - } - rep.GroupsFound = len(rep.Groups) - if d.hub != nil { - d.hub.Publish("duplicate", map[string]any{ - "finished": true, - "groups": rep.GroupsFound, - "marked": rep.ItemsMarked, - }) - } - return rep, nil -} - -// Current returns duplicate groups already marked in the database. It keeps -// the UI useful after a prior scan and avoids requiring POST on page load. -func (d *DuplicateService) Current(ctx context.Context, libraryID string) (*Report, error) { - var rows []model.Media - q := d.repo.DB.WithContext(ctx).Where("is_duplicate = ? OR duplicate_of <> ''", true) - if libraryID != "" { - q = q.Where("library_id = ?", libraryID) - } - if err := q.Find(&rows).Error; err != nil { - return nil, err - } - rep := &Report{TotalScanned: len(rows), Groups: []Group{}} - byPrimary := make(map[string][]model.Media) - for _, row := range rows { - if row.DuplicateOf == "" { - continue - } - byPrimary[row.DuplicateOf] = append(byPrimary[row.DuplicateOf], row) - } - for primaryID, dupes := range byPrimary { - primary, err := d.repo.Media.FindByID(ctx, primaryID) - if err != nil || primary == nil { - continue - } - hash := primary.FileHash - if hash == "" && len(dupes) > 0 { - hash = dupes[0].FileHash - } - rep.Groups = append(rep.Groups, Group{ - Hash: hash, - Primary: *primary, - Duplicates: dupes, - }) - } - rep.GroupsFound = len(rep.Groups) - return rep, nil -} - -func (d *DuplicateService) removeMissingRows(ctx context.Context, rows []model.Media, rep *Report) []model.Media { - kept := make([]model.Media, 0, len(rows)) - for _, row := range rows { - if row.Path == "" { - kept = append(kept, row) - continue - } - if _, err := os.Stat(row.Path); err == nil { - kept = append(kept, row) - continue - } else if !os.IsNotExist(err) { - kept = append(kept, row) - continue - } - res := d.repo.DB.WithContext(ctx).Where("id = ?", row.ID).Delete(&model.Media{}) - if res.Error != nil { - d.log.Warn("remove missing duplicate candidate failed", zap.String("media", row.ID), zap.Error(res.Error)) - continue - } - rep.MissingRemoved += res.RowsAffected - } - return kept -} - -// Unmark clears the is_duplicate flag for every row in the given library -// (or all when libraryID is empty). Useful when the operator deletes the -// physical duplicates manually. -func (d *DuplicateService) Unmark(ctx context.Context, libraryID string) (int64, error) { - q := d.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("is_duplicate = ?", true) - if libraryID != "" { - q = q.Where("library_id = ?", libraryID) - } - res := q.Updates(map[string]any{"is_duplicate": false, "duplicate_of": ""}) - return res.RowsAffected, res.Error -} diff --git a/internal/service/duplicate_groups.go b/internal/service/duplicate_groups.go deleted file mode 100644 index 3695361..0000000 --- a/internal/service/duplicate_groups.go +++ /dev/null @@ -1,93 +0,0 @@ -// Package service — duplicate grouping helpers. -package service - -import ( - "context" - "fmt" - "sort" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DuplicateService) markDuplicateGroup(ctx context.Context, rep *Report, key string, group []model.Media, markedIDs map[string]struct{}) { - primary := pickPrimary(group) - dupes := make([]model.Media, 0, len(group)-1) - for _, m := range group { - if m.ID == primary.ID || m.DuplicateOf == primary.ID { - continue - } - if _, ok := markedIDs[m.ID]; ok { - continue - } - dupes = append(dupes, m) - if err := d.repo.DB.WithContext(ctx). - Model(&model.Media{}). - Where("id = ?", m.ID). - Updates(map[string]any{ - "is_duplicate": true, - "duplicate_of": primary.ID, - }).Error; err != nil { - d.log.Warn("dup mark failed", zap.Error(err)) - continue - } - markedIDs[m.ID] = struct{}{} - rep.ItemsMarked++ - } - if len(dupes) == 0 { - return - } - rep.Groups = append(rep.Groups, Group{ - Hash: key, - Primary: primary, - Duplicates: dupes, - }) -} - -func groupByExternalIdentity(rows []model.Media) map[string][]model.Media { - groups := map[string][]model.Media{} - for _, row := range rows { - key := mediaExternalIdentityKey(row) - if key == "" { - continue - } - groups[key] = append(groups[key], row) - } - return groups -} - -func mediaExternalIdentityKey(row model.Media) string { - var key string - switch { - case row.TMDbID > 0: - key = fmt.Sprintf("tmdb:%d", row.TMDbID) - case row.BangumiID > 0: - key = fmt.Sprintf("bangumi:%d", row.BangumiID) - case row.DoubanID != "": - key = "douban:" + row.DoubanID - case row.TheTVDBID != "": - key = "thetvdb:" + row.TheTVDBID - default: - return "" - } - if row.SeasonNum > 0 || row.EpisodeNum > 0 { - key += fmt.Sprintf(":s%d:e%d", row.SeasonNum, row.EpisodeNum) - } - return key -} - -// pickPrimary picks the "best" media row to keep: prefer scraped > size > id. -func pickPrimary(group []model.Media) model.Media { - sort.SliceStable(group, func(i, j int) bool { - ai, aj := group[i].ScrapeStatus == "matched", group[j].ScrapeStatus == "matched" - if ai != aj { - return ai - } - if group[i].SizeBytes != group[j].SizeBytes { - return group[i].SizeBytes > group[j].SizeBytes - } - return group[i].ID < group[j].ID - }) - return group[0] -} diff --git a/internal/service/duplicate_hash.go b/internal/service/duplicate_hash.go deleted file mode 100644 index 9847fda..0000000 --- a/internal/service/duplicate_hash.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package service — duplicate sparse file hashing. -package service - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" -) - -const sampleSize = 1 << 20 // 1 MiB per sample window - -// SparseFileHash computes the head+mid+tail SHA-256 of a file, suffixed with -// the file size so two files that happen to collide on the sample window -// but differ in length are still distinguishable. -func SparseFileHash(path string) (string, error) { - if path == "" { - return "", errors.New("empty path") - } - f, err := os.Open(path) // #nosec G304 -- path is selected from configured media library files for duplicate detection. - if err != nil { - return "", err - } - defer f.Close() - st, err := f.Stat() - if err != nil { - return "", err - } - size := st.Size() - h := sha256.New() - if size <= int64(sampleSize)*3 { - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil - } - buf := make([]byte, sampleSize) - // head - if _, err := io.ReadFull(f, buf); err != nil { - return "", err - } - h.Write(buf) - // middle - if _, err := f.Seek(size/2-int64(sampleSize)/2, io.SeekStart); err != nil { - return "", err - } - if _, err := io.ReadFull(f, buf); err != nil { - return "", err - } - h.Write(buf) - // tail - if _, err := f.Seek(size-int64(sampleSize), io.SeekStart); err != nil { - return "", err - } - if _, err := io.ReadFull(f, buf); err != nil { - return "", err - } - h.Write(buf) - return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil -} diff --git a/internal/service/duplicate_test.go b/internal/service/duplicate_test.go deleted file mode 100644 index c0c2a9a..0000000 --- a/internal/service/duplicate_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDuplicateDetectMarksExternalIdentityDuplicates(t *testing.T) { - repos := newOrganizerTestRepo(t) - root := t.TempDir() - firstPath := filepath.Join(root, "show-a.mkv") - secondPath := filepath.Join(root, "show-b.mkv") - writeOrgFile(t, firstPath, "first-release") - writeOrgFile(t, secondPath, "second-release") - - lib := model.Library{Name: "剧集", Path: root, Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - first := model.Media{ - LibraryID: lib.ID, - Title: "间谍过家家", - Path: firstPath, - SizeBytes: 13, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 12345, - ScrapeStatus: "matched", - } - second := model.Media{ - LibraryID: lib.ID, - Title: "Spy Family", - Path: secondPath, - SizeBytes: 14, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 12345, - ScrapeStatus: "matched", - } - if err := repos.DB.Create(&first).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&second).Error; err != nil { - t.Fatal(err) - } - - report, err := NewDuplicateService(zap.NewNop(), repos, nil).Detect(t.Context(), lib.ID) - if err != nil { - t.Fatal(err) - } - if report.ItemsMarked != 1 || report.GroupsFound != 1 { - t.Fatalf("report = %#v, want one external identity duplicate", report) - } - var rows []model.Media - if err := repos.DB.Find(&rows).Error; err != nil { - t.Fatal(err) - } - marked := 0 - for _, row := range rows { - if row.IsDuplicate && row.DuplicateOf != "" { - marked++ - } - } - if marked != 1 { - t.Fatalf("marked duplicate rows = %d, want 1; rows=%#v", marked, rows) - } -} diff --git a/internal/service/emby_cloud_library_merge_test.go b/internal/service/emby_cloud_library_merge_test.go deleted file mode 100644 index 745f5fd..0000000 --- a/internal/service/emby_cloud_library_merge_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestEmbyViewsMergeEpisodicCloudLibrariesIntoUserLibrary(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国漫", Path: BuildCloudLibraryPath("openlist", "/国漫", "/国漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - if err := svc.repo.DB.Create(&model.Media{ - Base: model.Base{ID: "cloud-show-1"}, - LibraryID: cloud.ID, - Title: "云盘国漫", - Path: "cloud://openlist/国漫/云盘国漫/Season 01/云盘国漫.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - views, err := svc.Views(t.Context(), "user-1") - if err != nil { - t.Fatalf("views: %v", err) - } - viewItems := views["Items"].([]map[string]any) - if len(viewItems) != 1 { - t.Fatalf("emby views = %#v, want one merged user-facing library", viewItems) - } - if viewItems[0]["Id"] != local.ID || viewItems[0]["Name"] != "国漫" { - t.Fatalf("merged view should use local library identity, got %#v", viewItems[0]) - } - - items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, Recursive: true, IncludeItemTypes: []string{"Episode"}, Limit: 50}) - if err != nil { - t.Fatalf("items: %v", err) - } - episodes := items["Items"].([]map[string]any) - if len(episodes) != 1 || episodes[0]["Id"] != "cloud-show-1" { - t.Fatalf("merged local library should include cloud episodes, got %#v", episodes) - } -} - -func TestEmbyViewsMergeCloudCategoryAliasesIntoUserLibrary(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "日番", Path: "/media/动漫/日番", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 日漫", Path: BuildCloudLibraryPath("openlist", "/日漫", "/日漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - if err := svc.repo.DB.Create(&model.Media{ - Base: model.Base{ID: "cloud-anime-1"}, - LibraryID: cloud.ID, - Title: "云盘日漫", - Path: "cloud://openlist/日漫/云盘日漫/Season 01/云盘日漫.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - views, err := svc.Views(t.Context(), "user-1") - if err != nil { - t.Fatalf("views: %v", err) - } - viewItems := views["Items"].([]map[string]any) - if len(viewItems) != 1 || viewItems[0]["Id"] != local.ID || viewItems[0]["Name"] != "日番" { - t.Fatalf("emby views = %#v, want cloud alias merged into local 日番", viewItems) - } - - items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, Recursive: true, IncludeItemTypes: []string{"Episode"}, Limit: 50}) - if err != nil { - t.Fatalf("items: %v", err) - } - episodes := items["Items"].([]map[string]any) - if len(episodes) != 1 || episodes[0]["Id"] != "cloud-anime-1" { - t.Fatalf("merged local 日番 library should include 日漫 cloud episodes, got %#v", episodes) - } -} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index fcf395d..b024b3e 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -19,7 +19,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" "go.uber.org/zap" ) @@ -47,8 +46,6 @@ type EmbyService struct { cfg *config.Config log *zap.Logger repo *repository.Container - storage cloudPlaybackResolver - probe cloudPlaybackProber cache *RuntimeCacheService subtitle *SubtitleService @@ -60,21 +57,10 @@ type EmbyService struct { visibilityMu sync.RWMutex visibilityCache map[string]embyVisibilityCacheEntry - cloudProbeMu sync.Mutex - cloudProbeInFlight map[string]struct{} - libraryCoverMu sync.Mutex libraryCoverCache map[string]embyArtworkCacheEntry } -type cloudPlaybackResolver interface { - CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) -} - -type cloudPlaybackProber interface { - ProbeHTTP(ctx context.Context, rawURL string, headers map[string]string) (*ProbeResult, error) -} - // NewEmbyService is the constructor. func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService { return &EmbyService{cfg: cfg, log: log, repo: repo} @@ -87,14 +73,6 @@ func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService { return e } -func (e *EmbyService) SetCloudProbe(storage cloudPlaybackResolver, probe cloudPlaybackProber) { - if e == nil { - return - } - e.storage = storage - e.probe = probe -} - // SetSubtitleService wires the external-subtitle discovery service into the // Emby shim so MediaStreams can advertise sideloaded subtitle tracks. It is // nil-safe: when subtitle is nil, mediaStreams simply emits no subtitle diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 2d321be..2dd036e 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -1,7 +1,6 @@ package service import ( - "context" "testing" "time" @@ -10,7 +9,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) func newTestEmbyService(t *testing.T) *EmbyService { @@ -67,29 +65,3 @@ func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) { t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0]) } } - -type fakeCloudPlaybackResolver struct { - link *cloud.DirectLink - typ string - ref string - ua string -} - -func (f *fakeCloudPlaybackResolver) CloudResolve(_ context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) { - f.typ = typ - f.ref = fileRef - f.ua = clientUA - return f.link, nil -} - -type fakeCloudPlaybackProber struct { - probe *ProbeResult - rawURL string - headers map[string]string -} - -func (f *fakeCloudPlaybackProber) ProbeHTTP(_ context.Context, rawURL string, headers map[string]string) (*ProbeResult, error) { - f.rawURL = rawURL - f.headers = headers - return f.probe, nil -} diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index e33a679..5f84715 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -10,8 +10,6 @@ import ( "strings" "time" - "go.uber.org/zap" - "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -21,7 +19,6 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) if err != nil || m == nil { return nil, err } - e.ensureCloudTrackMetadata(ctx, m) return map[string]any{ "MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)), "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()), @@ -108,60 +105,6 @@ var ( // 拖长到秒级,又让每一次点开详情/起播都可能触发一次云盘数据下载,是 // Docker 部署下 CPU/带宽长期居高的来源之一。探测结果落库后,下一次 // 请求自然能读到完整元数据。 -func (e *EmbyService) ensureCloudTrackMetadata(ctx context.Context, m *model.Media) { - if e == nil || m == nil || e.storage == nil || e.probe == nil || !mediaTrackMetadataMissing(m) { - return - } - typ, ref, ok := parseCloudMediaPlaybackURL(m.STRMURL) - if !ok { - return - } - mediaID := m.ID - e.cloudProbeMu.Lock() - if e.cloudProbeInFlight == nil { - e.cloudProbeInFlight = make(map[string]struct{}) - } - if _, busy := e.cloudProbeInFlight[mediaID]; busy { - e.cloudProbeMu.Unlock() - return - } - e.cloudProbeInFlight[mediaID] = struct{}{} - e.cloudProbeMu.Unlock() - - go e.probeCloudTrackMetadata(mediaID, typ, ref) -} - -func (e *EmbyService) probeCloudTrackMetadata(mediaID, typ, ref string) { - defer func() { - e.cloudProbeMu.Lock() - delete(e.cloudProbeInFlight, mediaID) - e.cloudProbeMu.Unlock() - }() - probeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - link, err := e.storage.CloudResolve(probeCtx, typ, ref, "") - if err != nil { - if e.log != nil { - e.log.Debug("resolve cloud media for playback probe failed", zap.String("media_id", mediaID), zap.Error(err)) - } - return - } - probe, err := e.probe.ProbeHTTP(probeCtx, link.URL, link.Headers) - if err != nil { - if e.log != nil { - e.log.Debug("playback cloud ffprobe failed", zap.String("media_id", mediaID), zap.Error(err)) - } - return - } - updates := probeResultUpdates(probe) - if len(updates) == 0 { - return - } - if err := e.repo.DB.WithContext(probeCtx).Model(&model.Media{}).Where("id = ?", mediaID).Updates(updates).Error; err != nil && e.log != nil { - e.log.Debug("persist playback cloud probe failed", zap.String("media_id", mediaID), zap.Error(err)) - } -} - func mediaTrackMetadataMissing(m *model.Media) bool { return m.DurationSec <= 0 || m.Width <= 0 || diff --git a/internal/service/emby_playback_test.go b/internal/service/emby_playback_test.go index bc52251..6a4b6ee 100644 --- a/internal/service/emby_playback_test.go +++ b/internal/service/emby_playback_test.go @@ -5,7 +5,6 @@ import ( "time" "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) func TestEmbyRootItemsExposeLibraries(t *testing.T) { @@ -303,165 +302,3 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) { t.Fatalf("direct-only must still allow direct play: %#v", src) } } - -func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) { - svc := newTestEmbyService(t) - if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeSTRM); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: `cloud://openlist/Movies/f1.mkv`, - STRMURL: `/api/cloud/play/openlist?ref=%2FMovies%2Ff1.mkv`, - } - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - pb, err := svc.PlaybackInfo(t.Context(), "cloud-1", "user-1") - if err != nil { - t.Fatalf("playback info: %v", err) - } - src := pb["MediaSources"].([]map[string]any)[0] - if src["IsRemote"] != true { - t.Fatalf("strm media should be marked remote: %#v", src) - } - if src["DirectStreamUrl"] != "/api/stream/cloud-1" { - t.Fatalf("strm playback should prefer /api/stream when enabled: %#v", src) - } - if src["Path"] != "/Movies/f1.mkv" { - t.Fatalf("path should expose the OpenList source path: %#v", src) - } - streams := src["MediaStreams"].([]map[string]any) - if len(streams) == 0 || streams[0]["Type"] != "Video" { - t.Fatalf("strm media should expose a fallback video stream for Android clients: %#v", src) - } -} - -func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) { - svc := newTestEmbyService(t) - if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-302"}, - LibraryID: lib.ID, - Title: "Cloud 302 Movie", - Path: `cloud://openlist/Movies/Movie.mkv`, - STRMURL: `/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv`, - Container: "mkv", - } - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - pb, err := svc.PlaybackInfo(t.Context(), "cloud-302", "user-1") - if err != nil { - t.Fatalf("playback info: %v", err) - } - src := pb["MediaSources"].([]map[string]any)[0] - if src["DirectStreamUrl"] != "/Videos/cloud-302/stream.mkv" { - t.Fatalf("302/proxy mode should use Emby video stream URL: %#v", src) - } - if src["Path"] != "/Movies/Movie.mkv" { - t.Fatalf("302/proxy mode should preserve the OpenList source path: %#v", src) - } -} - -func TestEmbyMediaSourcePathFallsBackToCloudDisplayPath(t *testing.T) { - media := &model.Media{ - Path: `cloud://cloud115/Shows/My%20Show/S01E01.mkv`, - STRMURL: `/api/cloud/play/cloud115?ref=opaque-file-id`, - } - if got := embyMediaSourcePath(media); got != "/Shows/My Show/S01E01.mkv" { - t.Fatalf("source path = %q", got) - } - - local := &model.Media{Path: `D:\\Media\\Movie.mkv`, STRMURL: `https://example.test/movie.mkv`} - if got := embyMediaSourcePath(local); got != local.Path { - t.Fatalf("local path = %q, want %q", got, local.Path) - } -} - -func TestEmbyPlaybackInfoProbesMissingCloudTrackMetadata(t *testing.T) { - svc := newTestEmbyService(t) - lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-probe-1"}, - LibraryID: lib.ID, - Title: "云盘电影", - Path: `cloud://openlist/Movies/Movie.mkv`, - STRMURL: `http://nas.local/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv`, - } - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - resolver := &fakeCloudPlaybackResolver{ - link: &cloud.DirectLink{ - URL: "http://cdn.example.test/Movie.mkv", - Headers: map[string]string{"Authorization": "Bearer probe-token"}, - }, - } - prober := &fakeCloudPlaybackProber{ - probe: &ProbeResult{ - DurationSec: 3661, - Width: 3840, - Height: 2160, - VideoCodec: "hevc", - AudioCodec: "eac3", - Container: "matroska,webm", - }, - } - svc.SetCloudProbe(resolver, prober) - - if _, err := svc.PlaybackInfo(t.Context(), "cloud-probe-1", "user-1"); err != nil { - t.Fatalf("playback info: %v", err) - } - - var persisted model.Media - deadline := time.Now().Add(3 * time.Second) - for { - if err := svc.repo.DB.First(&persisted, "id = ?", "cloud-probe-1").Error; err != nil { - t.Fatalf("reload media: %v", err) - } - if persisted.DurationSec > 0 || time.Now().After(deadline) { - break - } - time.Sleep(10 * time.Millisecond) - } - if persisted.DurationSec != 3661 || persisted.Width != 3840 || persisted.Height != 2160 || persisted.VideoCodec != "hevc" || persisted.AudioCodec != "eac3" { - t.Fatalf("probe metadata not persisted: %#v", persisted) - } - if resolver.typ != "openlist" || resolver.ref != "/Movies/Movie.mkv" { - t.Fatalf("resolver called with typ=%q ref=%q", resolver.typ, resolver.ref) - } - if prober.rawURL != "http://cdn.example.test/Movie.mkv" || prober.headers["Authorization"] != "Bearer probe-token" { - t.Fatalf("probe called with url=%q headers=%#v", prober.rawURL, prober.headers) - } - - pb, err := svc.PlaybackInfo(t.Context(), "cloud-probe-1", "user-1") - if err != nil { - t.Fatalf("playback info (second): %v", err) - } - src := pb["MediaSources"].([]map[string]any)[0] - if src["RunTimeTicks"] != int64(3661)*10_000_000 { - t.Fatalf("runtime ticks not filled after async probe: %#v", src) - } - streams := src["MediaStreams"].([]map[string]any) - if len(streams) != 2 || streams[0]["Codec"] != "hevc" || streams[1]["Codec"] != "eac3" { - t.Fatalf("media streams not filled after async probe: %#v", streams) - } -} diff --git a/internal/service/emby_versions_test.go b/internal/service/emby_versions_test.go index 2fb7954..2a73ae0 100644 --- a/internal/service/emby_versions_test.go +++ b/internal/service/emby_versions_test.go @@ -7,107 +7,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) -func TestEmbyLatestItemsIncludesMergedCloudMovieLibrary(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "local-movie", CreatedAt: time.Now().Add(-time.Minute)}, - LibraryID: local.ID, - Title: "本地版本", - Path: `/media/国产电影/local.mkv`, - }, - { - Base: model.Base{ID: "cloud-movie", CreatedAt: time.Now()}, - LibraryID: cloud.ID, - Title: "云盘版本", - Path: `cloud://openlist/国产电影/cloud.mkv`, - }, - } { - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - } - - latest, err := svc.LatestItems(t.Context(), "user-1", local.ID, 10) - if err != nil { - t.Fatalf("latest items: %v", err) - } - if len(latest) != 2 { - t.Fatalf("latest items = %#v, want local and merged cloud media", latest) - } - if latest[0]["Id"] != "cloud-movie" || latest[1]["Id"] != "local-movie" { - t.Fatalf("latest order/items = %#v, want cloud then local", latest) - } -} - -func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "local-version", CreatedAt: time.Now()}, - LibraryID: local.ID, - Title: "流浪地球", - Year: 2019, - Path: `/media/国产电影/流浪地球.2019.1080p.mkv`, - Container: "mkv", - Width: 1920, - }, - { - Base: model.Base{ID: "cloud-version", CreatedAt: time.Now().Add(time.Minute)}, - LibraryID: cloud.ID, - Title: "流浪地球", - Year: 2019, - Path: `cloud://openlist/国产电影/流浪地球.2019.2160p.mkv`, - Container: "mkv", - STRMURL: "https://example.invalid/cloud", - Width: 3840, - }, - } { - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - } - - items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 10}) - if err != nil { - t.Fatalf("items: %v", err) - } - rows := items["Items"].([]map[string]any) - if len(rows) != 1 { - t.Fatalf("merged local/cloud versions should show as one item, got %#v", rows) - } - if rows[0]["Id"] != "local-version" { - t.Fatalf("local media should be the representative item, got %#v", rows[0]) - } - sources := rows[0]["MediaSources"].([]map[string]any) - if len(sources) != 2 { - t.Fatalf("merged item should expose two media sources, got %#v", sources) - } - - playback, err := svc.PlaybackInfo(t.Context(), "local-version", "user-1") - if err != nil { - t.Fatalf("playback: %v", err) - } - playSources := playback["MediaSources"].([]map[string]any) - if len(playSources) != 2 { - t.Fatalf("playback should expose local and cloud versions, got %#v", playSources) - } -} - func TestEmbyLatestItemsCollapsesMovieVersions(t *testing.T) { svc := newTestEmbyService(t) lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} diff --git a/internal/service/external_search_test.go b/internal/service/external_search_test.go index f4741a2..4f9c07c 100644 --- a/internal/service/external_search_test.go +++ b/internal/service/external_search_test.go @@ -2,6 +2,15 @@ package service import "testing" +func containsString(values []string, want string) bool { + for _, v := range values { + if v == want { + return true + } + } + return false +} + func TestBuildSubscribeKeyword(t *testing.T) { if got := buildSubscribeKeyword("沙丘", 2024); got != "沙丘 2024" { t.Fatalf("keyword = %q", got) diff --git a/internal/service/image_proxy_cloud.go b/internal/service/image_proxy_cloud.go deleted file mode 100644 index 95afffe..0000000 --- a/internal/service/image_proxy_cloud.go +++ /dev/null @@ -1,151 +0,0 @@ -package service - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "os" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// CloudImageCached reports whether a stable cloud-image ref already has a -// usable positive or short-lived negative cache entry. Scanner pre-warm uses it -// to avoid repeatedly resolving the same cloud sidecar image. -func (p *ImageProxy) CloudImageCached(stableKey string) bool { - if p == nil { - return false - } - _, cachePath, failPath := p.cloudImageCachePaths(stableKey) - if data, err := os.ReadFile(cachePath); err == nil { - if _, ok := validImageContentType(data); ok { - return true - } - _ = os.Remove(cachePath) - _ = os.Remove(failPath) - } - return freshNegativeImageCache(failPath) -} - -// ServeCloudCached serves an already-local cloud sidecar image without asking -// the cloud provider for a fresh direct link. It returns true when it wrote a -// response, including a fresh negative-cache placeholder. -func (p *ImageProxy) ServeCloudCached(w http.ResponseWriter, r *http.Request, stableKey string) bool { - if p == nil { - return false - } - key, cachePath, failPath := p.cloudImageCachePaths(stableKey) - p.removeUnusableImageCache(cachePath, failPath) - if serveCachedImageFile(w, r, key, cachePath) { - return true - } - if freshNegativeImageCache(failPath) { - serveCachedPlaceholder(w) - return true - } - return false -} - -// ServeCloudResolved stores a cloud sidecar image in the same disk cache used -// by remote posters, then serves it with long browser-cache headers. -func (p *ImageProxy) ServeCloudResolved(ctx context.Context, w http.ResponseWriter, r *http.Request, stableKey string, link *cloud.DirectLink) error { - if p == nil || link == nil || strings.TrimSpace(link.URL) == "" { - servePlaceholder(w) - return nil - } - stableKey = strings.TrimSpace(stableKey) - if stableKey == "" { - stableKey = link.URL - } - key, cachePath, failPath := p.cloudImageCachePaths(stableKey) - p.removeUnusableImageCache(cachePath, failPath) - if serveCachedImageFile(w, r, key, cachePath) { - return nil - } - if freshNegativeImageCache(failPath) { - serveCachedPlaceholder(w) - return nil - } - data, ctype, err := p.fetchAndCacheCloudImage(ctx, stableKey, link, r.UserAgent()) - if err != nil { - p.log.Warn("imageproxy: cloud image fetch failed", zap.String("url", link.URL), zap.Error(err)) - serveCachedPlaceholder(w) - return nil - } - w.Header().Set("Content-Type", ctype) - w.Header().Set("Cache-Control", imageBrowserCacheControl) - modTime := time.Now() - if stat, err := os.Stat(cachePath); err == nil && stat.Size() > 0 { - modTime = stat.ModTime() - w.Header().Set("ETag", imageFileETag(key, stat)) - } - http.ServeContent(w, r, key, modTime, bytes.NewReader(data)) - return nil -} - -// PrefetchCloudResolved downloads a cloud sidecar image into the local cache -// without writing an HTTP response. -func (p *ImageProxy) PrefetchCloudResolved(ctx context.Context, stableKey string, link *cloud.DirectLink) error { - if p == nil || link == nil || strings.TrimSpace(link.URL) == "" { - return nil - } - if p.CloudImageCached(stableKey) { - return nil - } - _, _, err := p.fetchAndCacheCloudImage(ctx, stableKey, link, "MediaStationGo/0.1") - return err -} - -func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey string, link *cloud.DirectLink, userAgent string) ([]byte, string, error) { - if err := os.MkdirAll(p.cacheDir, 0o750); err != nil { - return nil, "", err - } - _, cachePath, failPath := p.cloudImageCachePaths(stableKey) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) - if err != nil { - return nil, "", err - } - for k, v := range link.Headers { - req.Header.Set(k, v) - } - if req.Header.Get("User-Agent") == "" { - if strings.TrimSpace(userAgent) != "" { - req.Header.Set("User-Agent", userAgent) - } else { - req.Header.Set("User-Agent", "MediaStationGo/0.1") - } - } - req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") - resp, err := p.client.Do(req) - if err != nil { - p.markImageFetchFailed(failPath) - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - p.markImageFetchFailed(failPath) - return nil, "", errors.New("cloud image returned " + resp.Status) - } - data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) - if err != nil { - p.markImageFetchFailed(failPath) - return nil, "", err - } - if len(data) == 0 { - p.markImageFetchFailed(failPath) - return nil, "", errors.New("cloud image body is empty") - } - ctype, ok := validImageContentType(data) - if !ok { - p.markImageFetchFailed(failPath) - return nil, "", errors.New("cloud image returned non-image content") - } - p.writeImageCache(cachePath, failPath, "img-cloud-*.tmp", data) - return data, ctype, nil -} diff --git a/internal/service/image_proxy_cloud_test.go b/internal/service/image_proxy_cloud_test.go deleted file mode 100644 index 59c545b..0000000 --- a/internal/service/image_proxy_cloud_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package service - -import ( - "bytes" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func TestImageProxyCachesCloudResolvedImage(t *testing.T) { - var calls int32 - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/png"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/poster.png"} - if proxy.ServeCloudCached(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=poster.png", nil), "openlist:poster.png") { - t.Fatal("ServeCloudCached returned true before the cloud image was cached") - } - for i := 0; i < 2; i++ { - rec := httptest.NewRecorder() - if err := proxy.ServeCloudResolved(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=poster.png", nil), "openlist:poster.png", link); err != nil { - t.Fatal(err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Cache-Control"); got != imageBrowserCacheControl { - t.Fatalf("Cache-Control = %q, want %q", got, imageBrowserCacheControl) - } - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls = %d, want 1 due to cloud image cache", got) - } - - rec := httptest.NewRecorder() - if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=poster.png", nil), "openlist:poster.png") { - t.Fatal("ServeCloudCached returned false after the cloud image was cached") - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls after ServeCloudCached = %d, want 1", got) - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached body = %x, want cached cloud image", got) - } -} - -func TestImageProxyPrefetchCloudResolvedImage(t *testing.T) { - var calls int32 - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/png"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/folder.png"} - if err := proxy.PrefetchCloudResolved(t.Context(), "openlist:folder.png", link); err != nil { - t.Fatal(err) - } - if err := proxy.PrefetchCloudResolved(t.Context(), "openlist:folder.png", link); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls = %d, want 1 after prefetch cache hit", got) - } - rec := httptest.NewRecorder() - if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=folder.png", nil), "openlist:folder.png") { - t.Fatal("prefetched cloud image was not served from cache") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached body = %x, want prefetched cloud image", got) - } -} - -func TestImageProxyPrefetchCloudResolvedRefetchesInvalidCache(t *testing.T) { - var calls int32 - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/jpeg"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - stableKey := "openlist:bad-cache-poster.jpg" - _, cachePath, failPath := proxy.cloudImageCachePaths(stableKey) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, []byte("old bad cache"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(failPath, []byte("failed"), 0o600); err != nil { - t.Fatal(err) - } - - link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/bad-cache-poster.jpg"} - if err := proxy.PrefetchCloudResolved(t.Context(), stableKey, link); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls = %d, want 1 after invalid cache cleanup", got) - } - rec := httptest.NewRecorder() - if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=bad-cache-poster.jpg", nil), stableKey) { - t.Fatal("refetched cloud image was not served from cache") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached body = %x, want refetched cloud image", got) - } -} - -func TestImageProxyServeCloudCachedSkipsInvalidCache(t *testing.T) { - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - stableKey := "openlist:invalid-cached-poster.jpg" - _, cachePath, failPath := proxy.cloudImageCachePaths(stableKey) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, transparent1x1PNG, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(failPath, []byte("failed"), 0o600); err != nil { - t.Fatal(err) - } - - rec := httptest.NewRecorder() - if proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=invalid-cached-poster.jpg", nil), stableKey) { - t.Fatal("ServeCloudCached should skip invalid cached cloud artwork") - } - if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("invalid cache should be removed, stat err=%v", err) - } - if _, err := os.Stat(failPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("stale fail marker should be removed with invalid cache, stat err=%v", err) - } -} diff --git a/internal/service/license.go b/internal/service/license.go deleted file mode 100644 index 63eb145..0000000 --- a/internal/service/license.go +++ /dev/null @@ -1,81 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "math" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -const ( - OpenSourceUserLimit = 20 - LicensedUserLimit = 100 - - LicenseSettingActivation = "license.activation" -) - -type LicenseActivationState struct { - Valid bool `json:"valid"` - LicenseKey string `json:"license_key,omitempty"` - LicenseType string `json:"license_type,omitempty"` - ExpiryDate string `json:"expiry_date,omitempty"` - MaxDevices int `json:"max_devices,omitempty"` - MaxUsers *int `json:"max_users,omitempty"` - UnlimitedUsers bool `json:"unlimited_users,omitempty"` - DaysRemaining *int `json:"days_remaining,omitempty"` - NextHeartbeat string `json:"next_heartbeat,omitempty"` - DeviceID string `json:"device_id,omitempty"` - DeviceName string `json:"device_name,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -func LicensedMaxUsers(ctx context.Context, repos *repository.Container) int64 { - state, ok := loadLicenseActivationState(ctx, repos) - if ok && state.Valid && !licenseExpired(state.ExpiryDate) { - if state.UnlimitedUsers { - return math.MaxInt64 - } - if state.MaxUsers != nil && *state.MaxUsers > 0 { - return int64(*state.MaxUsers) - } - return LicensedUserLimit - } - return OpenSourceUserLimit -} - -func LicenseActive(ctx context.Context, repos *repository.Container) bool { - if repos == nil || repos.Setting == nil { - return false - } - state, ok := loadLicenseActivationState(ctx, repos) - return ok && state.Valid && !licenseExpired(state.ExpiryDate) -} - -func loadLicenseActivationState(ctx context.Context, repos *repository.Container) (LicenseActivationState, bool) { - if repos == nil || repos.Setting == nil { - return LicenseActivationState{}, false - } - raw, err := repos.Setting.Get(ctx, LicenseSettingActivation) - if err != nil || raw == "" { - return LicenseActivationState{}, false - } - var state LicenseActivationState - if err := json.Unmarshal([]byte(raw), &state); err != nil { - return LicenseActivationState{}, false - } - return state, true -} - -func licenseExpired(expiry string) bool { - if expiry == "" { - return false - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if t, err := time.Parse(layout, expiry); err == nil { - return time.Now().After(t) - } - } - return false -} diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go index edb59d3..da11769 100644 --- a/internal/service/local_availability.go +++ b/internal/service/local_availability.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "regexp" - "sort" "strings" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -37,27 +36,6 @@ func EnrichExternalMediaAvailability(ctx context.Context, repo *repository.Conta } } -func EnrichSubscriptionProgress(ctx context.Context, repo *repository.Container, items []model.Subscription) { - for i := range items { - availability := SubscriptionLocalAvailability(ctx, repo, &items[i]) - items[i].DownloadedEpisodes = availability.DownloadedEpisodes - items[i].LocalMediaCount = availability.LocalMediaCount - items[i].MissingEpisodes = availability.MissingEpisodes - items[i].InLibrary = availability.InLibrary - if items[i].TotalEpisodes == 0 { - items[i].TotalEpisodes = availability.TotalEpisodes - } - } -} - -func SubscriptionLocalAvailability(ctx context.Context, repo *repository.Container, sub *model.Subscription) LocalAvailability { - if sub == nil { - return LocalAvailability{} - } - expected := sub.TotalEpisodes - return LookupLocalAvailability(ctx, repo, sub.Name, sub.Filter, sub.MediaType, expected) -} - func LookupLocalAvailability(ctx context.Context, repo *repository.Container, title, keyword, mediaType string, expectedTotal int) LocalAvailability { out := LocalAvailability{ TotalEpisodes: expectedTotal, @@ -116,6 +94,15 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti return out } +func isSubscriptionSeriesType(mediaType string) bool { + switch normalizeMediaType(mediaType, "", "") { + case "tv", "anime", "variety": + return true + default: + return false + } +} + func missingEpisodes(existing map[string]struct{}, total int) []int { if total <= 0 { return nil @@ -154,76 +141,3 @@ func episodeKey(season, episode int) string { } return fmt.Sprintf("%02dE%03d", season, episode) } - -func missingEpisodeSet(availability LocalAvailability) map[int]struct{} { - out := make(map[int]struct{}, len(availability.MissingEpisodes)) - for _, episode := range availability.MissingEpisodes { - out[episode] = struct{}{} - } - return out -} - -func sortedEpisodeCandidates(candidates []siteSearchCandidate) []siteSearchCandidate { - selected := make([]siteSearchCandidate, 0, len(candidates)) - covered := make(map[string]struct{}, len(candidates)) - for _, candidate := range candidates { - keys := candidateEpisodeKeys(candidate) - if len(keys) == 0 { - continue - } - if episodeKeysOverlap(covered, keys) { - continue - } - selected = append(selected, candidate) - for _, key := range keys { - covered[key] = struct{}{} - } - } - sort.SliceStable(selected, func(i, j int) bool { - return candidateFirstEpisodeKey(selected[i]) < candidateFirstEpisodeKey(selected[j]) - }) - return selected -} - -func candidateEpisodeKeys(candidate siteSearchCandidate) []string { - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return nil - } - season := candidate.Season - if season <= 0 { - season = 1 - } - keys := make([]string, 0, len(episodes)) - seen := make(map[string]struct{}, len(episodes)) - for _, episode := range episodes { - if episode <= 0 { - continue - } - key := episodeKey(season, episode) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -func candidateFirstEpisodeKey(candidate siteSearchCandidate) string { - keys := candidateEpisodeKeys(candidate) - if len(keys) == 0 { - return "" - } - return keys[0] -} - -func episodeKeysOverlap(covered map[string]struct{}, keys []string) bool { - for _, key := range keys { - if _, ok := covered[key]; ok { - return true - } - } - return false -} diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index d317998..e90fda8 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -1,14 +1,6 @@ package service -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) +import "testing" func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { tests := []struct { @@ -438,26 +430,3 @@ func TestNormalizeMediaTypeDoesNotTreatReleaseTokensAsTV(t *testing.T) { t.Fatalf("standalone tv path token = %q, want tv", got) } } - -func TestSubscriptionResolveClassifiedSavePath(t *testing.T) { - db := newServiceTestDB(t, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", `D:\Downloads`); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, nil) - sub := &model.Subscription{Name: "声生不息 自动订阅", MediaType: "tv"} - - mediaType, category := svc.classifySubscriptionItem(t.Context(), sub, "声生不息 S01E01", "综艺") - if mediaType != "tv" || category != "综艺" { - t.Fatalf("classification = %q/%q, want tv/综艺", mediaType, category) - } - got := svc.resolveSubscriptionSavePath(t.Context(), sub, mediaType, category) - want := `D:\Downloads\综艺` - if got != want { - t.Fatalf("save path = %q, want %q", got, want) - } -} diff --git a/internal/service/media_cloud_test.go b/internal/service/media_cloud_test.go deleted file mode 100644 index c6dbda9..0000000 --- a/internal/service/media_cloud_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDeleteLibraryHardDeletesLibraryRoots(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) - repos := repository.New(db) - rootA := filepath.Join(t.TempDir(), "movies-a") - rootB := filepath.Join(t.TempDir(), "movies-b") - lib := &model.Library{Name: "电影", Path: rootA, Type: "movie", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), lib, []model.LibraryRoot{ - {Path: rootA, Enabled: true, SortOrder: 0}, - {Path: rootB, Enabled: true, SortOrder: 1}, - }); err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: lib.ID, - LibraryRootID: lib.Roots[0].ID, - Title: "测试电影", - Path: filepath.Join(rootA, "movie.mkv"), - }); err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { - t.Fatal(err) - } - - var rootCount int64 - if err := db.Unscoped().Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&rootCount).Error; err != nil { - t.Fatal(err) - } - if rootCount != 0 { - t.Fatalf("library roots should be hard deleted, count=%d", rootCount) - } - var visibleLibraryCount int64 - if err := db.Model(&model.Library{}).Where("id = ?", lib.ID).Count(&visibleLibraryCount).Error; err != nil { - t.Fatal(err) - } - if visibleLibraryCount != 0 { - t.Fatalf("deleted library should not remain visible, count=%d", visibleLibraryCount) - } - var mediaCount int64 - if err := db.Unscoped().Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&mediaCount).Error; err != nil { - t.Fatal(err) - } - if mediaCount != 0 { - t.Fatalf("local library media rows should be hard deleted, count=%d", mediaCount) - } -} - -func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) - repos := repository.New(db) - lib := model.Library{Name: "OpenList · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.LibraryRoot{ - LibraryID: lib.ID, - Name: "JianLai", - Path: lib.Path, - Enabled: true, - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: lib.ID, - Title: "剑来", - Path: "cloud://openlist/Anime/JianLai/Season 1/01.mkv", - STRMURL: "/api/cloud/play/openlist?ref=/Anime/JianLai/Season%201/01.mkv", - }); err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { - t.Fatal(err) - } - - var mediaCount int64 - if err := db.Unscoped().Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&mediaCount).Error; err != nil { - t.Fatal(err) - } - if mediaCount != 0 { - t.Fatalf("cloud mount media should be purged, count=%d", mediaCount) - } - recycle, err := svc.ListRecycleBin(t.Context(), 100) - if err != nil { - t.Fatal(err) - } - if len(recycle) != 0 { - t.Fatalf("cloud mount removal must not populate recycle bin: %#v", recycle) - } - var libCount int64 - if err := db.Unscoped().Model(&model.Library{}).Where("id = ?", lib.ID).Count(&libCount).Error; err != nil { - t.Fatal(err) - } - if libCount != 0 { - t.Fatalf("cloud mount library should be purged, count=%d", libCount) - } - var rootCount int64 - if err := db.Unscoped().Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&rootCount).Error; err != nil { - t.Fatal(err) - } - if rootCount != 0 { - t.Fatalf("cloud mount roots should be purged, count=%d", rootCount) - } -} - -func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/国漫/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - ScrapeStatus: "pending", - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - Year: 2025, - ScrapeStatus: "pending", - }); err != nil { - t.Fatal(err) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.Year != 2025 || got.ScrapeStatus != "pending" { - t.Fatalf("pending cloud row was not backfilled correctly: tmdb=%d year=%d status=%q", got.TMDbID, got.Year, got.ScrapeStatus) - } -} - -func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 220269, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - Year: 2025, - ScrapeStatus: "pending", - }); err != nil { - t.Fatal(err) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { - t.Fatalf("cloud external id conflict was not corrected: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) - } -} - -func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/动画电影/雄狮少年2 (2024) {tmdb-1154478}/雄狮少年2 (2024) - 2160p.WEB-DL.H.265.DDP 5.1-ADWeb.mp4" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-movie", - Title: "雄狮少年2 adweb", - Path: path, - ScrapeStatus: "no_match", - }).Error; err != nil { - t.Fatal(err) - } - container := &Container{Repo: repos, Log: zap.NewNop()} - repaired, err := container.RepairCloudPathMetadata(t.Context()) - if err != nil { - t.Fatal(err) - } - if repaired != 1 { - t.Fatalf("repaired = %d, want 1", repaired) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 1154478 || got.Year != 2024 || got.Title != "雄狮少年2" || got.ScrapeStatus != "pending" { - t.Fatalf("placeholder was not repaired: title=%q tmdb=%d year=%d status=%q", got.Title, got.TMDbID, got.Year, got.ScrapeStatus) - } -} - -func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 220269, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - container := &Container{Repo: repos, Log: zap.NewNop()} - repaired, err := container.RepairCloudPathMetadata(t.Context()) - if err != nil { - t.Fatal(err) - } - if repaired != 1 { - t.Fatalf("repaired = %d, want 1", repaired) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { - t.Fatalf("conflicting matched id was not repaired: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) - } -} - -func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - media := model.Media{ - Base: model.Base{ID: "cloud-media"}, - Title: "网盘电影", - Path: "cloud://openlist/电影/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=/电影/Movie.mkv", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.SoftDelete(t.Context(), media.ID); err != nil { - t.Fatal(err) - } - var count int64 - if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("cloud media should be purged, count=%d", count) - } - recycle, err := svc.ListRecycleBin(t.Context(), 100) - if err != nil { - t.Fatal(err) - } - if len(recycle) != 0 { - t.Fatalf("cloud media removal must not populate recycle bin: %#v", recycle) - } -} diff --git a/internal/service/media_display_library_test.go b/internal/service/media_display_library_test.go deleted file mode 100644 index 526e710..0000000 --- a/internal/service/media_display_library_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary reproduces issue #61. -// -// A movie scanned from a source cloud library ("115 云下载") is auto-categorized into a -// separate auto-category library ("成人"): its library_id points to the auto-category -// library, but its physical cloud path still lives under the source scan directory -// (cloud://cloud115/云下载/...). Display resolution must attribute the media to the -// library it is actually browsed under (the auto-category "成人" library), not path-match -// it back to the source cloud library — otherwise the detail page "返回媒体库" button -// jumps to the wrong library. -func TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) - repos := repository.New(db) - - source := model.Library{Name: "115 云下载", Path: "cloud://cloud115/云下载", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &source); err != nil { - t.Fatal(err) - } - - autoPath := BuildCloudAutoCategoryLibraryPathWithScanDir("cloud115", "成人/成人", "成人") - adult := model.Library{Name: "成人", Path: autoPath, Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &adult); err != nil { - t.Fatal(err) - } - if !CloudLibraryAutoCategory(adult) { - t.Fatalf("adult library should be auto-category, got path %q", adult.Path) - } - - mediaPath := "cloud://cloud115/云下载/Some.Movie.2024/Some.Movie.2024.mp4" - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: adult.ID, - Title: "Some Movie", - Path: mediaPath, - }); err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - items := []model.Media{{LibraryID: adult.ID, Title: "Some Movie", Path: mediaPath}} - svc.attachLibraryMetadata(t.Context(), items) - - got := items[0] - if got.DisplayLibraryID != adult.ID { - t.Fatalf("display_library_id = %s (%s), want auto-category library %s (成人)", - got.DisplayLibraryID, got.DisplayLibraryName, adult.ID) - } - if got.DisplayLibraryName != "成人" { - t.Fatalf("display_library_name = %q, want 成人", got.DisplayLibraryName) - } - if got.LibraryName != "成人" { - t.Fatalf("library_name = %q, want 成人 (not the source cloud library)", got.LibraryName) - } -} diff --git a/internal/service/media_library_roots.go b/internal/service/media_library_roots.go index abd0d4f..47a57fb 100644 --- a/internal/service/media_library_roots.go +++ b/internal/service/media_library_roots.go @@ -293,37 +293,14 @@ func (s *MediaService) ensureLibraryRootPathUnique(ctx context.Context, libraryI func normalizeLibraryRootPath(rawPath string) (string, error) { rawPath = strings.TrimSpace(rawPath) - if info, ok := ParseCloudLibraryMount(rawPath); ok { - displayDir := canonicalLibraryDisplayDir(firstNonEmpty(info.DisplayDir, info.ScanDir)) - if displayDir == "" { - displayDir = firstNonEmpty(info.DisplayDir, info.ScanDir) - } - if CloudLibraryAutoCategory(model.Library{Path: rawPath}) { - return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir), nil - } - return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir), nil - } return resolveAccessibleLibraryPath(rawPath) } func libraryRootPathKey(pathValue string) string { pathValue = strings.TrimSpace(pathValue) - if info, ok := ParseCloudLibraryMount(pathValue); ok { - auto := "0" - if CloudLibraryAutoCategory(model.Library{Path: pathValue}) { - auto = "1" - } - return strings.ToLower(info.Provider + "\x00" + info.ScanDir + "\x00" + info.DisplayDir + "\x00" + auto) - } return strings.ToLower(filepath.Clean(pathValue)) } func libraryRootNameForPath(pathValue string) string { - if info, ok := ParseCloudLibraryMount(pathValue); ok { - if base := cloudMountDirBase(firstNonEmpty(info.DisplayDir, info.ScanDir)); base != "" { - return base - } - return CloudMountProviderLabel(info.Provider) - } return filepath.Base(filepath.Clean(pathValue)) } diff --git a/internal/service/media_library_roots_test.go b/internal/service/media_library_roots_test.go index 10c01b5..6b9d7ae 100644 --- a/internal/service/media_library_roots_test.go +++ b/internal/service/media_library_roots_test.go @@ -98,28 +98,3 @@ func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) { t.Fatalf("library count = %d, want separate libraries for different types", libraryCount) } } - -func TestCreateLibraryWithRootsAcceptsCloudRoot(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) - repos := repository.New(db) - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - lib, err := svc.CreateLibraryWithRoots(t.Context(), "国漫", "anime", []LibraryRootInput{{ - Name: "OpenList", - Path: "cloud://openlist/动漫/国漫?dir=国漫&auto_category=1", - }}) - if err != nil { - t.Fatal(err) - } - roots, err := repos.Library.ListRoots(t.Context(), lib.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 1 { - t.Fatalf("roots = %#v, want one cloud root", roots) - } - info, ok := ParseCloudLibraryMount(roots[0].Path) - if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" || !CloudLibraryAutoCategory(model.Library{Path: roots[0].Path}) { - t.Fatalf("cloud root = %#v info=%#v", roots[0], info) - } -} diff --git a/internal/service/media_visibility_test.go b/internal/service/media_visibility_test.go index 7200f5f..42dfd2a 100644 --- a/internal/service/media_visibility_test.go +++ b/internal/service/media_visibility_test.go @@ -82,53 +82,6 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) { } } -func TestMediaVisibilityHidesDeprecatedNativeCloudLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - legacy := model.Library{ - Name: "旧云盘", - Path: BuildCloudLibraryPath(LegacyQuarkProvider, "archive", "archive"), - Type: "movie", - Enabled: true, - } - openList := model.Library{ - Name: "OpenList", - Path: BuildCloudLibraryPath("openlist", "movies", "movies"), - Type: "movie", - Enabled: true, - } - if err := repos.Library.Create(t.Context(), &legacy); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &openList); err != nil { - t.Fatal(err) - } - if err := db.Create(&[]model.Media{ - {LibraryID: legacy.ID, Title: "历史媒体", Path: "cloud://" + LegacyQuarkProvider + "/archive/old.mkv"}, - {LibraryID: openList.ID, Title: "可见媒体", Path: "cloud://openlist/movies/new.mkv"}, - }).Error; err != nil { - t.Fatal(err) - } - - items, err := svc.SearchMediaVisible(t.Context(), "媒体", 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if got := sortedMediaTitles(items); !slices.Equal(got, []string{"可见媒体"}) { - t.Fatalf("deprecated native cloud media should be hidden from search, got %#v", got) - } - - listed, total, err := svc.ListMediaVisible(t.Context(), legacy.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 0 || len(listed) != 0 { - t.Fatalf("deprecated native cloud media should be hidden from direct list total=%d rows=%#v", total, sortedMediaTitles(listed)) - } -} - func TestConfiguredAdultLibrariesDoNotHideSafeLibraryWithNSFWItems(t *testing.T) { db := newServiceTestDB(t, &model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}) repos := repository.New(db) diff --git a/internal/service/notifier.go b/internal/service/notifier.go deleted file mode 100644 index b93e2a0..0000000 --- a/internal/service/notifier.go +++ /dev/null @@ -1,145 +0,0 @@ -// Package service — multi-channel push notifications. -// -// NotifierService dispatches structured messages to one or more channels -// configured in the system settings table: -// -// notify.telegram.bot_token + notify.telegram.chat_id -// notify.bark.server + notify.bark.key -// notify.wechat.sendkey -// notify.webhook.url + notify.webhook.method -// -// Notifications are triggered by the subscription poller, the download -// poller, the scan / scrape completions, and any future event worth -// surfacing to the operator's phone. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// NotifierService dispatches push notifications. -type NotifierService struct { - log *zap.Logger - repo *repository.Container - client *http.Client -} - -// NewNotifierService is the constructor. -func NewNotifierService(log *zap.Logger, repo *repository.Container) *NotifierService { - return &NotifierService{ - log: log, - repo: repo, - client: NewExternalHTTPClient(10 * time.Second), - } -} - -// Send dispatches a notification to every configured channel. Failures -// are logged but do not propagate — notifications are best-effort. -func (n *NotifierService) Send(ctx context.Context, title, body, eventType string) { - n.sendTelegram(ctx, title, body) - n.sendBark(ctx, title, body) - n.sendWechat(ctx, title, body) - n.sendWebhook(ctx, title, body, eventType) -} - -func (n *NotifierService) get(ctx context.Context, key string) string { - v, _ := n.repo.Setting.Get(ctx, key) - return strings.TrimSpace(v) -} - -func (n *NotifierService) sendTelegram(ctx context.Context, title, body string) { - token := n.get(ctx, "notify.telegram.bot_token") - chatID := n.get(ctx, "notify.telegram.chat_id") - if token == "" || chatID == "" { - return - } - text := fmt.Sprintf("%s\n\n%s", escapeHTML(title), escapeHTML(body)) - form := url.Values{} - form.Set("chat_id", chatID) - form.Set("text", text) - form.Set("parse_mode", "HTML") - cfg := map[string]string{"bot_token": token} - if apiBase := n.get(ctx, "notify.telegram.api_base_url"); apiBase != "" { - cfg["api_base_url"] = apiBase - } - if proxyURL := n.get(ctx, "notify.telegram.proxy_url"); proxyURL != "" { - cfg["proxy_url"] = proxyURL - } - if err := telegramPostForm(ctx, cfg, "sendMessage", form, 15*time.Second); err != nil { - n.log.Debug("telegram notify failed", zap.Error(err)) - } -} - -func (n *NotifierService) sendBark(ctx context.Context, title, body string) { - server := n.get(ctx, "notify.bark.server") - key := n.get(ctx, "notify.bark.key") - if key == "" { - return - } - if server == "" { - server = "https://api.day.app" - } - u := fmt.Sprintf("%s/%s/%s/%s", - strings.TrimRight(server, "/"), - url.PathEscape(key), - url.PathEscape(title), - url.PathEscape(body), - ) - resp, err := n.client.Get(u) - if err != nil { - n.log.Debug("bark notify failed", zap.Error(err)) - return - } - defer resp.Body.Close() -} - -func (n *NotifierService) sendWechat(ctx context.Context, title, body string) { - sendkey := n.get(ctx, "notify.wechat.sendkey") - if sendkey == "" { - return - } - u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", sendkey) - form := url.Values{} - form.Set("title", title) - form.Set("desp", body) - resp, err := n.client.PostForm(u, form) - if err != nil { - n.log.Debug("wechat notify failed", zap.Error(err)) - return - } - defer resp.Body.Close() -} - -func (n *NotifierService) sendWebhook(ctx context.Context, title, body, eventType string) { - webhookURL := n.get(ctx, "notify.webhook.url") - if webhookURL == "" { - return - } - payload := fmt.Sprintf(`{"title":%q,"content":%q,"type":%q}`, title, body, eventType) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, - strings.NewReader(payload)) - if err != nil { - return - } - req.Header.Set("Content-Type", "application/json") - resp, err := n.client.Do(req) - if err != nil { - n.log.Debug("webhook notify failed", zap.Error(err)) - return - } - defer resp.Body.Close() -} - -func escapeHTML(s string) string { - r := strings.NewReplacer("&", "&", "<", "<", ">", ">") - return r.Replace(s) -} diff --git a/internal/service/notify_bark.go b/internal/service/notify_bark.go deleted file mode 100644 index b96c413..0000000 --- a/internal/service/notify_bark.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package service — Bark 通知 Provider。 -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// BarkProvider 通过 Bark 推送通知到 iOS 设备。 -// Bark API 文档: https://github.com/Finb/bark-server -type BarkProvider struct{} - -// Send 发送 Bark 推送通知。 -func (p *BarkProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - serverURL := cfg["server_url"] - deviceKey := cfg["device_key"] - if serverURL == "" { - serverURL = "https://api.day.app" - } - serverURL = strings.TrimRight(serverURL, "/") - if deviceKey == "" { - return fmt.Errorf("bark: device_key is required") - } - - payload := map[string]interface{}{ - "title": event.Title, - "body": event.Message, - "group": "MediaStationGo", - } - - if len(event.Data) > 0 { - var extra string - for k, v := range event.Data { - extra += fmt.Sprintf("%s: %v\n", k, v) - } - payload["body"] = event.Message + "\n\n" + extra - } - - body, err := json.Marshal(payload) - if err != nil { - return err - } - - apiURL := fmt.Sprintf("%s/%s", serverURL, deviceKey) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return fmt.Errorf("bark api error %d: %s", resp.StatusCode, string(respBody)) - } - return nil -} - -// ValidateConfig 验证 Bark 配置。 -func (p *BarkProvider) ValidateConfig(cfg map[string]string) error { - if cfg["device_key"] == "" { - return fmt.Errorf("bark: device_key is required") - } - return nil -} diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go deleted file mode 100644 index 35bad4b..0000000 --- a/internal/service/notify_channels.go +++ /dev/null @@ -1,272 +0,0 @@ -// Package service — notification channel CRUD + multi-channel dispatch. -// -// The original NotifierService reads a single set of keys from the -// settings table. NotifyChannelService persists *named* channels in -// their own table so the operator can add multiple Telegram bots, Bark -// servers, etc. and pick which events flow to which channel. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// NotifyChannelService manages notify channels and dispatches messages. -type NotifyChannelService struct { - log *zap.Logger - repo *repository.Container - client *http.Client -} - -// NewNotifyChannelService is the constructor. -func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *NotifyChannelService { - return &NotifyChannelService{ - log: log, - repo: repo, - client: NewExternalHTTPClient(10 * time.Second), - } -} - -// ChannelInput is the shape accepted by Create / Update. Config is a -// generic map; it gets serialised to JSON before being persisted. -type ChannelInput struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` - Config map[string]any `json:"config"` - Events []string `json:"events"` - Enabled *bool `json:"enabled,omitempty"` -} - -// channelView is the public shape — Config is decoded back to a map so -// the React form can edit it directly without unwrapping JSON twice. -type channelView struct { - model.NotifyChannel - Config map[string]any `json:"config"` - Events []string `json:"events"` -} - -// toView decodes Config + Events from their persisted JSON strings. -func toView(n model.NotifyChannel) channelView { - v := channelView{NotifyChannel: n} - if n.Config != "" { - _ = json.Unmarshal([]byte(n.Config), &v.Config) - } - if v.Config == nil { - v.Config = map[string]any{} - } - if n.Events != "" { - _ = json.Unmarshal([]byte(n.Events), &v.Events) - } - if v.Events == nil { - v.Events = []string{} - } - return v -} - -// List returns every channel as a decoded view. -func (s *NotifyChannelService) List(ctx context.Context) ([]channelView, error) { - rows, err := s.repo.NotifyChannel.List(ctx) - if err != nil { - return nil, err - } - out := make([]channelView, 0, len(rows)) - for _, r := range rows { - out = append(out, toView(r)) - } - return out, nil -} - -// Create persists a new channel. -func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) { - normalizeChannelInput(&in) - if err := validateChannel(in); err != nil { - return nil, err - } - cfgBlob, _ := json.Marshal(in.Config) - evBlob, _ := json.Marshal(in.Events) - n := &model.NotifyChannel{ - Name: strings.TrimSpace(in.Name), - Type: in.Type, - Config: string(cfgBlob), - Events: string(evBlob), - Enabled: true, - } - if in.Enabled != nil { - n.Enabled = *in.Enabled - } - if err := s.repo.NotifyChannel.Create(ctx, n); err != nil { - return nil, err - } - if n.Type == "telegram" && n.Enabled { - if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - } - v := toView(*n) - return &v, nil -} - -// Update applies a partial patch to an existing channel. -func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) { - normalizeChannelInput(&in) - if err := validateChannel(in); err != nil { - return nil, err - } - cfgBlob, _ := json.Marshal(in.Config) - evBlob, _ := json.Marshal(in.Events) - patch := map[string]any{ - "name": strings.TrimSpace(in.Name), - "type": in.Type, - "config": string(cfgBlob), - "events": string(evBlob), - } - if in.Enabled != nil { - patch["enabled"] = *in.Enabled - } - // Fetch existing row, apply patch via repo Update - existing, err := s.repo.NotifyChannel.FindByID(ctx, id) - if err != nil { - return nil, err - } - if existing == nil { - return nil, errors.New("channel not found") - } - existing.Name = patch["name"].(string) - existing.Type = patch["type"].(string) - existing.Config = patch["config"].(string) - existing.Events = patch["events"].(string) - if en, ok := patch["enabled"]; ok { - existing.Enabled = en.(bool) - } - if err := s.repo.NotifyChannel.Update(ctx, existing); err != nil { - return nil, err - } - if existing.Type == "telegram" && existing.Enabled { - if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - } - row, err := s.repo.NotifyChannel.FindByID(ctx, id) - if err != nil || row == nil { - return nil, err - } - v := toView(*row) - return &v, nil -} - -// Delete removes the channel. -func (s *NotifyChannelService) Delete(ctx context.Context, id string) error { - return s.repo.NotifyChannel.Delete(ctx, id) -} - -// Test sends a "测试通知" through a single channel. -func (s *NotifyChannelService) Test(ctx context.Context, id string) error { - row, err := s.repo.NotifyChannel.FindByID(ctx, id) - if err != nil { - return err - } - if row == nil { - return errors.New("channel not found") - } - return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。") -} - -const ( - NotifyEventAll = "__all__" - NotifyEventNone = "__none__" -) - -// Broadcast sends a message to every enabled channel that subscribes to -// `event`. Legacy empty Events values mean "all events"; the explicit -// NotifyEventNone sentinel means the channel stays enabled but receives no -// event push. -func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) { - s.BroadcastEvent(ctx, NotifyEvent{ - Type: event, - Title: title, - Message: body, - }) -} - -// BroadcastEvent sends one structured event to every subscribed enabled -// channel. Rich channels such as Telegram can use Data fields for artwork and -// cleaner formatting while simpler channels keep receiving title/body text. -func (s *NotifyChannelService) BroadcastEvent(ctx context.Context, event NotifyEvent) { - rows, err := s.repo.NotifyChannel.ListEnabled(ctx) - if err != nil { - s.log.Warn("notify list failed", zap.Error(err)) - return - } - for _, r := range rows { - if !channelSubscribes(r, event.Type) { - continue - } - if err := s.dispatchOneEvent(ctx, r, event); err != nil { - s.log.Warn("notify dispatch failed", zap.String("channel", r.Name), zap.Error(err)) - } - } -} - -// validateChannel rejects obviously-malformed inputs early so the API -// returns a useful 400 rather than a database constraint error. -func validateChannel(in ChannelInput) error { - if strings.TrimSpace(in.Name) == "" { - return errors.New("name required") - } - switch in.Type { - case "telegram", "wechat", "bark", "webhook", "email": - default: - return fmt.Errorf("unsupported channel type %q", in.Type) - } - if in.Type == "telegram" { - cfg := in.Config - if str(cfg["bot_token"]) == "" { - return errors.New("telegram bot_token required") - } - if str(cfg["admin_user_ids"]) == "" { - return errors.New("telegram admin_user_ids required") - } - } - return nil -} - -func normalizeChannelInput(in *ChannelInput) { - if in == nil || in.Type != "telegram" { - return - } - if in.Config == nil { - in.Config = map[string]any{} - } - chatID := str(in.Config["chat_id"]) - if chatID == "" { - return - } - if strings.HasPrefix(chatID, "-") && str(in.Config["group_chat_id"]) == "" && str(in.Config["channel_chat_id"]) == "" && str(in.Config["command_chat_id"]) == "" { - in.Config["group_chat_id"] = chatID - return - } - if !strings.HasPrefix(chatID, "-") && str(in.Config["admin_user_ids"]) == "" { - in.Config["admin_user_ids"] = chatID - } -} - -// str safely extracts a string from an interface{} loaded from JSON. -func str(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return strings.TrimSpace(fmt.Sprint(v)) -} diff --git a/internal/service/notify_channels_test.go b/internal/service/notify_channels_test.go deleted file mode 100644 index 83a75a0..0000000 --- a/internal/service/notify_channels_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -func TestChannelSubscribesCanDisableAllEvents(t *testing.T) { - channel := model.NotifyChannel{Events: `["` + NotifyEventNone + `"]`} - - if channelSubscribes(channel, EventDownloadComplete) { - t.Fatal("explicit none sentinel should disable event pushes") - } -} - -func TestChannelSubscribesKeepsLegacyEmptyAsAllEvents(t *testing.T) { - for _, raw := range []string{"", "[]"} { - channel := model.NotifyChannel{Events: raw} - if !channelSubscribes(channel, EventDownloadComplete) { - t.Fatalf("legacy events %q should still subscribe to all events", raw) - } - } -} - -func TestChannelSubscribesSupportsExplicitAllAndSpecificEvents(t *testing.T) { - all := model.NotifyChannel{Events: `["` + NotifyEventAll + `"]`} - if !channelSubscribes(all, EventScrapeFailed) { - t.Fatal("explicit all sentinel should subscribe to every event") - } - - specific := model.NotifyChannel{Events: `["` + EventDownloadComplete + `"]`} - if !channelSubscribes(specific, EventDownloadComplete) { - t.Fatal("specific event should be subscribed") - } - if channelSubscribes(specific, EventScrapeFailed) { - t.Fatal("unlisted event should not be subscribed") - } -} - -func TestTelegramDispatchUsesPhotoAndFormattedCaption(t *testing.T) { - var gotPath string - var gotForm map[string]string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - if err := r.ParseForm(); err != nil { - t.Fatalf("parse form: %v", err) - } - gotForm = map[string]string{} - for key := range r.Form { - gotForm[key] = r.Form.Get(key) - } - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - svc := NewNotifyChannelService(zap.NewNop(), nil) - channel := model.NotifyChannel{ - Type: "telegram", - Config: `{ - "bot_token":"123456:ABC", - "group_chat_id":"-10001", - "api_base_url":"` + server.URL + `" - }`, - } - err := svc.dispatchOneEvent(t.Context(), channel, NotifyEvent{ - Type: EventDownloadComplete, - Title: "MediaStationGo 下载完成", - Message: "任务:示例影片\n保存路径:/downloads/movie\nHash:abcdef", - Data: map[string]interface{}{ - "poster_url": server.URL + "/poster.jpg", - "media_type": "tv", - "media_category": "纪录片剧集", - "title": "美国甜心:达拉斯牛仔啦啦队", - "original_title": "AMERICA'S SWEETHEARTS: Dallas Cowboys Cheerleaders", - "original_language": "en", - "year": 2024, - "season_episode": "S03E07", - "size": "3.0GB / 5.7Mbps", - "version": "H264.NF.FHD-HHWEB", - "rating": 8.2, - "genres": "纪录", - "overview": "从试镜到训练营再到 NFL 赛季,一路跟随达拉斯牛仔队啦啦队队员们追逐梦想。", - "tmdb_url": server.URL + "/tmdb", - "imdb_url": server.URL + "/imdb", - "douban_url": server.URL + "/douban", - }, - }) - if err != nil { - t.Fatal(err) - } - if gotPath != "/bot123456:ABC/sendPhoto" { - t.Fatalf("path = %q, want sendPhoto", gotPath) - } - if gotForm["chat_id"] != "-10001" || gotForm["photo"] == "" { - t.Fatalf("telegram form = %#v", gotForm) - } - caption := gotForm["caption"] - for _, want := range []string{ - "#下载完成", - "📺 任务:示例影片", - } { - if !strings.Contains(caption, want) { - t.Fatalf("caption missing %q: %s", want, caption) - } - } - for _, unwanted := range []string{ - "✅ 下载完成", - "🎯 订阅命中新资源", - "保存路径", - "abcdef", - "美国甜心:达拉斯牛仔啦啦队", - "AMERICA'S SWEETHEARTS", - "2024", - "3.0GB", - "简介", - server.URL + "/tmdb", - } { - if strings.Contains(caption, unwanted) { - t.Fatalf("caption should not include %q: %s", unwanted, caption) - } - } -} - -func TestTelegramDownloadCompleteNeverFallsBackToSensitiveFields(t *testing.T) { - caption := formatTelegramNotification(NotifyEvent{ - Type: EventDownloadComplete, - Data: map[string]interface{}{ - "save_path": "/downloads/private/movie.mkv", - "hash": "abcdef", - }, - }) - for _, want := range []string{"#下载完成", "📺 任务:下载任务"} { - if !strings.Contains(caption, want) { - t.Fatalf("caption missing %q: %s", want, caption) - } - } - for _, unwanted := range []string{"保存路径", "/downloads/private", "Hash", "abcdef"} { - if strings.Contains(caption, unwanted) { - t.Fatalf("caption should not include %q: %s", unwanted, caption) - } - } -} - -// TestTelegramMediaTemplateRendersEnrichedFields 验证补齐的媒体字段 -// (年份/评分/类型/原名/语言)确实透传进 Telegram 富模板 caption。 -// 这是「模型/刮削链路补齐字段」与「作者富模板」对接的端到端断言。 -func TestTelegramMediaTemplateRendersEnrichedFields(t *testing.T) { - caption := formatTelegramNotification(NotifyEvent{ - Type: EventSubscriptionHit, - Title: "MediaStationGo 订阅命中新资源", - Message: "订阅:遮天\n新增资源:1", - Data: map[string]interface{}{ - "media_category": "国漫", - "title": "遮天", - "original_title": "Shrouding the Heavens", - "original_language": "zh", - "year": 2023, - "rating": 8.6, - "genres": "动画,奇幻", - "overview": "荒古禁地中走出的少年。", - "tmdb_url": "https://www.themoviedb.org/tv/223911", - "imdb_url": "https://www.imdb.com/title/tt12345678/", - }, - }) - for _, want := range []string{ - "#国漫", - "📺 中文片名:遮天", - "🧿 原始片名:Shrouding the Heavens", - "🌐 原始语言:中文", - "📅 发行年份:2023", - "⭐️ 评分:8.6", - "💎 类型:动画、奇幻", - "🪬 简介:", - `TMDB`, - `IMDB`, - } { - if !strings.Contains(caption, want) { - t.Fatalf("caption missing %q:\n%s", want, caption) - } - } -} diff --git a/internal/service/notify_dispatch.go b/internal/service/notify_dispatch.go deleted file mode 100644 index 38e8be8..0000000 --- a/internal/service/notify_dispatch.go +++ /dev/null @@ -1,163 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// channelSubscribes returns true when the channel's Events list contains the -// event, or when the list is the legacy empty/"all events" value. -func channelSubscribes(n model.NotifyChannel, event string) bool { - if event == "" || n.Events == "" { - return true - } - var ev []string - if err := json.Unmarshal([]byte(n.Events), &ev); err != nil { - return true - } - if len(ev) == 0 { - return true - } - for _, e := range ev { - switch e { - case NotifyEventNone: - return false - case NotifyEventAll: - return true - } - if e == event { - return true - } - } - return false -} - -// dispatchOne is the inner dispatcher; the channel type drives which -// HTTP request gets built. -func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error { - return s.dispatchOneEvent(ctx, n, NotifyEvent{Title: title, Message: body}) -} - -func (s *NotifyChannelService) dispatchOneEvent(ctx context.Context, n model.NotifyChannel, event NotifyEvent) error { - cfg := map[string]any{} - _ = json.Unmarshal([]byte(n.Config), &cfg) - title := event.Title - body := event.Message - - switch n.Type { - case "telegram": - telegramCfg := telegramStringConfigFromAny(cfg) - token := telegramCfg["bot_token"] - chats := telegramTargetChatIDs(telegramCfg) - if token == "" || len(chats) == 0 { - return errors.New("telegram missing bot_token / group_chat_id / channel_chat_id") - } - text := formatTelegramNotification(event) - photoURL := telegramEventPhotoURL(event) - var firstErr error - for _, chat := range chats { - if photoURL != "" && len(text) <= 1024 { - form := url.Values{} - form.Set("chat_id", chat) - form.Set("photo", photoURL) - form.Set("caption", text) - form.Set("parse_mode", "HTML") - if err := telegramPostForm(ctx, telegramCfg, "sendPhoto", form, 15*time.Second); err == nil { - continue - } else if firstErr == nil { - firstErr = err - } - } - form := url.Values{} - form.Set("chat_id", chat) - form.Set("text", text) - form.Set("parse_mode", "HTML") - if err := telegramPostForm(ctx, telegramCfg, "sendMessage", form, 15*time.Second); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr - - case "bark": - key := str(cfg["device_key"]) - if key == "" { - return errors.New("bark missing device_key") - } - server := str(cfg["server"]) - if server == "" { - server = "https://api.day.app" - } - u := fmt.Sprintf("%s/%s/%s/%s", - strings.TrimRight(server, "/"), - url.PathEscape(key), - url.PathEscape(title), - url.PathEscape(body), - ) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - return s.do(req) - - case "wechat": - key := str(cfg["sendkey"]) - if key == "" { - return errors.New("wechat missing sendkey") - } - u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", url.PathEscape(key)) - form := url.Values{} - form.Set("title", title) - form.Set("desp", body) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return s.do(req) - - case "webhook": - urlS := str(cfg["url"]) - if urlS == "" { - return errors.New("webhook missing url") - } - method := strings.ToUpper(str(cfg["method"])) - if method == "" { - method = "POST" - } - // Substitute {{title}} / {{message}} in the body template. - bodyTpl := str(cfg["body_template"]) - if bodyTpl == "" { - bodyTpl = `{"title":"{{title}}","message":"{{message}}"}` - } - bodyStr := strings.NewReplacer("{{title}}", title, "{{message}}", body).Replace(bodyTpl) - req, _ := http.NewRequestWithContext(ctx, method, urlS, strings.NewReader(bodyStr)) - // Apply custom headers (encoded as JSON in the config). - if hdrRaw := str(cfg["headers"]); hdrRaw != "" { - var hdr map[string]string - if err := json.Unmarshal([]byte(hdrRaw), &hdr); err == nil { - for k, v := range hdr { - req.Header.Set(k, v) - } - } - } - if req.Header.Get("Content-Type") == "" && method != http.MethodGet { - req.Header.Set("Content-Type", "application/json") - } - return s.do(req) - } - return fmt.Errorf("unknown channel type %q", n.Type) -} - -func (s *NotifyChannelService) do(req *http.Request) error { - resp, err := s.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("upstream returned %d", resp.StatusCode) - } - return nil -} diff --git a/internal/service/notify_email.go b/internal/service/notify_email.go deleted file mode 100644 index 429f66f..0000000 --- a/internal/service/notify_email.go +++ /dev/null @@ -1,147 +0,0 @@ -// Package service — Email(SMTP) 通知 Provider。 -package service - -import ( - "context" - "crypto/tls" - "fmt" - "net/mail" - "net/smtp" - "strconv" - "strings" -) - -// EmailProvider 通过 SMTP 发送邮件通知。 -type EmailProvider struct{} - -// Send 通过 SMTP 发送邮件。 -func (p *EmailProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - smtpHost := cfg["smtp_host"] - smtpPortStr := cfg["smtp_port"] - username := cfg["username"] - password := cfg["password"] - from := cfg["from"] - to := cfg["to"] - tlsStr := cfg["tls"] - - if smtpHost == "" || smtpPortStr == "" || username == "" || from == "" || to == "" { - return fmt.Errorf("email: smtp_host, smtp_port, username, from, and to are required") - } - - smtpPort, err := strconv.Atoi(smtpPortStr) - if err != nil { - return fmt.Errorf("email: invalid smtp_port: %s", smtpPortStr) - } - - useTLS := true - if tlsStr == "false" || tlsStr == "0" || tlsStr == "no" { - useTLS = false - } - - // 构建邮件内容 - subject := fmt.Sprintf("[MediaStationGo] %s", event.Title) - body := event.Message - if len(event.Data) > 0 { - body += "\n\n---\n详细信息:\n" - for k, v := range event.Data { - body += fmt.Sprintf(" %s: %v\n", k, v) - } - } - - recipients := strings.Split(to, ",") - for i, r := range recipients { - recipients[i] = strings.TrimSpace(r) - } - - // 构建邮件 - fromAddr := mail.Address{Name: "MediaStationGo", Address: from} - toAddrs := make([]mail.Address, 0, len(recipients)) - for _, r := range recipients { - toAddrs = append(toAddrs, mail.Address{Address: r}) - } - - msg := "From: " + fromAddr.String() + "\r\n" - msg += "To: " - for i, addr := range toAddrs { - if i > 0 { - msg += ", " - } - msg += addr.String() - } - msg += "\r\n" - msg += "Subject: " + subject + "\r\n" - msg += "MIME-Version: 1.0\r\n" - msg += "Content-Type: text/plain; charset=\"utf-8\"\r\n" - msg += "Content-Transfer-Encoding: base64\r\n" - msg += "\r\n" - msg += body - - addr := fmt.Sprintf("%s:%d", smtpHost, smtpPort) - auth := smtp.PlainAuth("", username, password, smtpHost) - - if useTLS { - // 使用 TLS 连接 - tlsConfig := &tls.Config{ - ServerName: smtpHost, - MinVersion: tls.VersionTLS12, - } - conn, err := tls.Dial("tcp", addr, tlsConfig) - if err != nil { - return fmt.Errorf("email tls dial: %w", err) - } - client, err := smtp.NewClient(conn, smtpHost) - if err != nil { - return fmt.Errorf("email smtp client: %w", err) - } - defer client.Close() - - if err = client.Auth(auth); err != nil { - return fmt.Errorf("email auth: %w", err) - } - if err = client.Mail(from); err != nil { - return fmt.Errorf("email mail from: %w", err) - } - for _, r := range recipients { - if err = client.Rcpt(r); err != nil { - return fmt.Errorf("email rcpt to: %w", err) - } - } - w, err := client.Data() - if err != nil { - return fmt.Errorf("email data: %w", err) - } - if _, err = w.Write([]byte(msg)); err != nil { - return fmt.Errorf("email write: %w", err) - } - if err = w.Close(); err != nil { - return fmt.Errorf("email close: %w", err) - } - return client.Quit() - } - - // 不使用 TLS(STARTTLS 或明文) - return smtp.SendMail(addr, auth, from, recipients, []byte(msg)) -} - -// ValidateConfig 验证 Email 配置。 -func (p *EmailProvider) ValidateConfig(cfg map[string]string) error { - if cfg["smtp_host"] == "" { - return fmt.Errorf("email: smtp_host is required") - } - if cfg["smtp_port"] == "" { - return fmt.Errorf("email: smtp_port is required") - } - if cfg["username"] == "" { - return fmt.Errorf("email: username is required") - } - if cfg["from"] == "" { - return fmt.Errorf("email: from is required") - } - if cfg["to"] == "" { - return fmt.Errorf("email: to is required") - } - if _, err := strconv.Atoi(cfg["smtp_port"]); err != nil { - return fmt.Errorf("email: invalid smtp_port") - } - return nil -} diff --git a/internal/service/notify_svc.go b/internal/service/notify_svc.go deleted file mode 100644 index 8aecc41..0000000 --- a/internal/service/notify_svc.go +++ /dev/null @@ -1,186 +0,0 @@ -// Package service — 通知服务事件分发引擎。 -// -// NotifyService 管理所有通知渠道,根据事件类型将通知分发给 -// 订阅了该事件的渠道。支持 4 种内置事件类型和 5 种通知渠道。 -package service - -import ( - "context" - "encoding/json" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// 通知事件类型常量。 -const ( - EventSubscriptionHit = "subscription_hit" - EventDownloadComplete = "download_complete" - EventScrapeFailed = "scrape_failed" - EventSystemAlert = "system_alert" - EventLibraryIngest = "library_ingest" -) - -// NotifyEvent 是通知事件的数据结构。 -type NotifyEvent struct { - Type string `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Data map[string]interface{} `json:"data,omitempty"` -} - -// NotifyProvider 定义通知渠道的发送接口。 -type NotifyProvider interface { - Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error - ValidateConfig(cfg map[string]string) error -} - -// NotifyService 是事件驱动的通知分发引擎。 -type NotifyService struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - - mu sync.RWMutex - providers map[string]NotifyProvider // type -> provider -} - -// NewNotifyService 创建通知服务。 -func NewNotifyService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *NotifyService { - ns := &NotifyService{ - log: log, - repo: repo, - crypto: crypto, - providers: make(map[string]NotifyProvider), - } - // 注册内置 Provider - ns.registerProviders() - return ns -} - -// registerProviders 注册所有内置通知 Provider。 -func (s *NotifyService) registerProviders() { - s.mu.Lock() - defer s.mu.Unlock() - s.providers["telegram"] = &TelegramProvider{} - s.providers["wechat"] = &WechatProvider{} - s.providers["bark"] = &BarkProvider{} - s.providers["webhook"] = &WebhookProvider{} - s.providers["email"] = &EmailProvider{} -} - -// Dispatch 将事件分发给所有订阅了该事件类型的已启用渠道。 -func (s *NotifyService) Dispatch(ctx context.Context, event NotifyEvent) { - channels, err := s.repo.NotifyChannel.ListByEvent(ctx, event.Type) - if err != nil { - s.log.Error("failed to list channels for event", - zap.String("event", event.Type), - zap.Error(err), - ) - return - } - - for _, ch := range channels { - go func(channel model.NotifyChannel) { - if sendErr := s.sendToChannel(ctx, channel, event); sendErr != nil { - s.log.Error("failed to send notification", - zap.String("channel", channel.Name), - zap.String("type", channel.Type), - zap.String("event", event.Type), - zap.Error(sendErr), - ) - } - }(ch) - } -} - -// SendTest 向指定渠道发送测试通知。 -func (s *NotifyService) SendTest(ctx context.Context, channelID string) error { - ch, err := s.repo.NotifyChannel.FindByID(ctx, channelID) - if err != nil { - return err - } - if ch == nil { - return ErrNotifyChannelNotFound - } - - testEvent := NotifyEvent{ - Type: "test", - Title: "MediaStationGo 测试通知", - Message: "这是一条测试通知,如果您看到此消息,说明通知渠道配置正确。", - } - - return s.sendToChannel(ctx, *ch, testEvent) -} - -// ValidateChannelConfig 验证渠道配置是否合法。 -func (s *NotifyService) ValidateChannelConfig(channelType string, config map[string]string) error { - s.mu.RLock() - provider, ok := s.providers[channelType] - s.mu.RUnlock() - if !ok { - return ErrUnknownNotifyType - } - return provider.ValidateConfig(config) -} - -// GetProviderTypes 返回支持的通知渠道类型列表。 -func (s *NotifyService) GetProviderTypes() []NotifyProviderInfo { - return []NotifyProviderInfo{ - {Type: "telegram", Name: "Telegram", Description: "通过 Telegram Bot 发送消息"}, - {Type: "wechat", Name: "Server酱", Description: "通过 Server酱 推送到微信"}, - {Type: "bark", Name: "Bark", Description: "通过 Bark 推送到 iOS"}, - {Type: "webhook", Name: "Webhook", Description: "通过自定义 HTTP Webhook 发送"}, - {Type: "email", Name: "Email", Description: "通过 SMTP 发送邮件"}, - } -} - -// NotifyProviderInfo 描述通知渠道类型信息。 -type NotifyProviderInfo struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` -} - -// sendToChannel 解密渠道配置并通过对应的 Provider 发送通知。 -func (s *NotifyService) sendToChannel(ctx context.Context, channel model.NotifyChannel, event NotifyEvent) error { - // 解密配置 - configStr := channel.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - return err - } - - s.mu.RLock() - provider, ok := s.providers[channel.Type] - s.mu.RUnlock() - if !ok { - return ErrUnknownNotifyType - } - - return provider.Send(ctx, cfg, event) -} - -// 通知服务错误定义。 -var ( - ErrNotifyChannelNotFound = &NotifyError{Code: "CHANNEL_NOT_FOUND", Message: "notification channel not found"} - ErrUnknownNotifyType = &NotifyError{Code: "UNKNOWN_TYPE", Message: "unknown notification type"} -) - -// NotifyError 是通知服务专用错误类型。 -type NotifyError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// Error 实现 error 接口。 -func (e *NotifyError) Error() string { - return e.Message -} diff --git a/internal/service/notify_telegram.go b/internal/service/notify_telegram.go deleted file mode 100644 index 933da6a..0000000 --- a/internal/service/notify_telegram.go +++ /dev/null @@ -1,141 +0,0 @@ -// Package service — Telegram 通知 Provider。 -package service - -import ( - "context" - "fmt" - "strings" - "time" - "unicode/utf8" -) - -// TelegramProvider 通过 Telegram Bot API 发送通知。 -type TelegramProvider struct{} - -// Send 发送 Telegram 消息。 -func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - botToken := cfg["bot_token"] - chatIDs := telegramTargetChatIDs(cfg) - parseMode := cfg["parse_mode"] - if parseMode == "" { - parseMode = "HTML" - } - - if botToken == "" || len(chatIDs) == 0 { - return fmt.Errorf("telegram: bot_token and group_chat_id/channel_chat_id are required") - } - - text := formatTelegramMessage(event, parseMode) - photoURL := telegramEventPhotoURL(event) - - var firstErr error - for _, chatID := range chatIDs { - if photoURL != "" && utf8.RuneCountInString(text) <= 1024 { - payload := map[string]string{ - "chat_id": chatID, - "photo": photoURL, - "caption": text, - "parse_mode": parseMode, - } - if err := telegramPostJSON(ctx, cfg, "sendPhoto", payload, 15*time.Second); err == nil { - continue - } else if firstErr == nil { - firstErr = err - } - if photo, _, err := telegramFetchRemotePhoto(ctx, cfg, photoURL, 15*time.Second); err == nil { - fields := map[string]string{ - "chat_id": chatID, - "caption": text, - "parse_mode": parseMode, - } - if err := telegramPostMultipart(ctx, cfg, "sendPhoto", fields, "photo", "poster.jpg", photo, 20*time.Second); err == nil { - continue - } else if firstErr == nil { - firstErr = err - } - } else if firstErr == nil { - firstErr = err - } - } - payload := map[string]string{ - "chat_id": chatID, - "text": text, - "parse_mode": parseMode, - } - if err := telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr -} - -// ValidateConfig 验证 Telegram 配置。 -func (p *TelegramProvider) ValidateConfig(cfg map[string]string) error { - if cfg["bot_token"] == "" { - return fmt.Errorf("telegram: bot_token is required") - } - if len(telegramTargetChatIDs(cfg)) == 0 { - return fmt.Errorf("telegram: group_chat_id or channel_chat_id is required") - } - return nil -} - -// formatTelegramMessage 格式化消息内容。 -func formatTelegramMessage(event NotifyEvent, parseMode string) string { - text := formatTelegramNotification(event) - if parseMode == "HTML" || parseMode == "" { - return text - } - result := text - result = strings.ReplaceAll(result, "", "**") - result = strings.ReplaceAll(result, "", "**") - result = strings.ReplaceAll(result, "", "`") - result = strings.ReplaceAll(result, "", "`") - result = strings.ReplaceAll(result, "<", "<") - result = strings.ReplaceAll(result, ">", ">") - result = strings.ReplaceAll(result, "&", "&") - return result -} - -func formatTelegramNotification(event NotifyEvent) string { - if text := formatTelegramMediaNotification(event); text != "" { - return text - } - - var sb strings.Builder - if tag := telegramEventTag(event); tag != "" { - sb.WriteString(tag) - if telegramShouldShowEventHeading(event) { - sb.WriteString("\n") - } - } - if telegramShouldShowEventHeading(event) { - sb.WriteString(telegramEventHeading(event)) - } - - message := strings.TrimSpace(event.Message) - if message != "" { - if sb.Len() > 0 { - sb.WriteString("\n\n") - } - sb.WriteString(formatTelegramBody(message)) - } - - fields := telegramDisplayData(event.Data) - if len(fields) > 0 { - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString("\n") - for _, field := range fields { - sb.WriteString(formatTelegramField(field.key, field.value)) - sb.WriteString("\n") - } - } - if links := telegramExternalLinks(event.Data); links != "" { - sb.WriteString("\n") - sb.WriteString(links) - } - - return strings.TrimSpace(sb.String()) -} diff --git a/internal/service/notify_telegram_body.go b/internal/service/notify_telegram_body.go deleted file mode 100644 index 3945438..0000000 --- a/internal/service/notify_telegram_body.go +++ /dev/null @@ -1,175 +0,0 @@ -package service - -import ( - "fmt" - "strings" -) - -func telegramShouldShowEventHeading(event NotifyEvent) bool { - if telegramMediaTag(event.Data) != "" { - return false - } - switch strings.TrimSpace(event.Type) { - case EventSubscriptionHit, EventDownloadComplete: - return false - default: - return true - } -} - -func telegramEventTag(event NotifyEvent) string { - if tag := telegramMediaTag(event.Data); tag != "" { - return tag - } - switch strings.TrimSpace(event.Type) { - case EventSubscriptionHit: - return "#订阅" - case EventDownloadComplete: - return "#下载完成" - case EventScrapeFailed: - return "#刮削失败" - case EventSystemAlert: - return "#系统提醒" - case EventLibraryIngest: - return "#入库" - default: - title := strings.TrimSpace(strings.TrimPrefix(event.Title, "MediaStationGo ")) - if title == "" { - return "#MediaStationGo" - } - return "#" + escapeHTML(strings.ReplaceAll(title, " ", "")) - } -} - -func telegramEventHeading(event NotifyEvent) string { - title := strings.TrimSpace(event.Title) - title = strings.TrimSpace(strings.TrimPrefix(title, "MediaStationGo ")) - if title == "" { - title = "MediaStationGo 通知" - } - icon := "🔔" - switch strings.TrimSpace(event.Type) { - case EventSubscriptionHit: - icon = "🎯" - case EventDownloadComplete: - icon = "✅" - case EventScrapeFailed: - icon = "⚠️" - case EventSystemAlert: - icon = "🚨" - case EventLibraryIngest: - icon = "📚" - } - return fmt.Sprintf("%s %s", icon, escapeHTML(title)) -} - -func formatTelegramBody(message string) string { - lines := strings.Split(message, "\n") - out := make([]string, 0, len(lines)) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - out = append(out, "") - continue - } - if strings.HasPrefix(line, "- ") { - out = append(out, "- "+escapeHTML(strings.TrimSpace(strings.TrimPrefix(line, "- ")))) - continue - } - if key, ok := trimTelegramEmptyField(line); ok { - out = append(out, fmt.Sprintf("%s %s:", telegramFieldIcon(telegramFieldLabel(key)), escapeHTML(telegramFieldLabel(key)))) - continue - } - if key, value, ok := splitTelegramField(line); ok { - out = append(out, formatTelegramField(key, value)) - continue - } - out = append(out, escapeHTML(line)) - } - return strings.TrimSpace(strings.Join(out, "\n")) -} - -func trimTelegramEmptyField(line string) (string, bool) { - line = strings.TrimSpace(line) - for _, suffix := range []string{":", ":"} { - if strings.HasSuffix(line, suffix) { - key := strings.TrimSpace(strings.TrimSuffix(line, suffix)) - if key != "" && len([]rune(key)) <= 16 { - return key, true - } - } - } - return "", false -} - -func splitTelegramField(line string) (string, string, bool) { - idx := strings.Index(line, ":") - sepLen := len(":") - if idx < 0 { - idx = strings.Index(line, ":") - sepLen = len(":") - } - if idx <= 0 { - return "", "", false - } - key := strings.TrimSpace(line[:idx]) - value := strings.TrimSpace(line[idx+sepLen:]) - if key == "" || value == "" || len([]rune(key)) > 16 { - return "", "", false - } - return key, value, true -} - -func formatTelegramField(key, value string) string { - key = telegramFieldLabel(key) - escapedValue := escapeHTML(strings.TrimSpace(value)) - if telegramCodeField(key) { - escapedValue = "" + escapedValue + "" - } - return fmt.Sprintf("%s %s:%s", telegramFieldIcon(key), escapeHTML(key), escapedValue) -} - -func telegramCodeField(key string) bool { - key = strings.ToLower(strings.TrimSpace(key)) - return strings.Contains(key, "hash") || - strings.Contains(key, "路径") || - strings.Contains(key, "path") || - strings.Contains(key, "id") -} - -func telegramFieldIcon(key string) string { - switch strings.ToLower(strings.TrimSpace(key)) { - case "中文片名", "标题", "任务", "媒体", "资源": - return "📺" - case "原始片名": - return "🧿" - case "原始语言", "语言": - return "🌐" - case "发行年份", "年份": - return "📅" - case "类别", "分类", "媒体类型": - return "🐈‍⬛" - case "季集", "集数": - return "🫧" - case "大小", "质量", "规格": - return "🔎" - case "版本", "保存路径": - return "📁" - case "评分": - return "⭐️" - case "类型": - return "💎" - case "简介": - return "🪬" - case "订阅": - return "🎯" - case "新增资源": - return "✨" - case "Hash": - return "🧿" - case "错误": - return "⚠️" - default: - return "•" - } -} diff --git a/internal/service/notify_telegram_config.go b/internal/service/notify_telegram_config.go deleted file mode 100644 index 65a4dcf..0000000 --- a/internal/service/notify_telegram_config.go +++ /dev/null @@ -1,47 +0,0 @@ -package service - -import "strings" - -func telegramTargetChatIDs(cfg map[string]string) []string { - seen := map[string]bool{} - targets := []string{} - for _, key := range []string{"group_chat_id", "channel_chat_id"} { - chatID := strings.TrimSpace(cfg[key]) - if chatID == "" || seen[chatID] { - continue - } - seen[chatID] = true - targets = append(targets, chatID) - } - if len(targets) == 0 { - chatID := strings.TrimSpace(cfg["chat_id"]) - if strings.HasPrefix(chatID, "-") { - targets = append(targets, chatID) - } else if chatID != "" && strings.TrimSpace(cfg["admin_user_ids"]) == "" { - targets = append(targets, chatID) - } - } - if len(targets) == 0 { - for _, userID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - if seen[userID] { - continue - } - seen[userID] = true - targets = append(targets, userID) - } - } - return targets -} - -func telegramConfiguredUserIDs(raw string) []string { - out := []string{} - for _, value := range strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ';' || r == ',' || r == ' ' || r == '\n' || r == '\t' - }) { - value = strings.TrimSpace(value) - if value != "" { - out = append(out, value) - } - } - return out -} diff --git a/internal/service/notify_telegram_data.go b/internal/service/notify_telegram_data.go deleted file mode 100644 index adf432c..0000000 --- a/internal/service/notify_telegram_data.go +++ /dev/null @@ -1,170 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "sort" - "strings" -) - -type telegramDataField struct { - key string - value string -} - -func telegramDisplayData(data map[string]interface{}) []telegramDataField { - if len(data) == 0 { - return nil - } - keys := make([]string, 0, len(data)) - for key := range data { - if telegramHiddenDataKey(key) { - continue - } - keys = append(keys, key) - } - sort.Strings(keys) - fields := make([]telegramDataField, 0, len(keys)) - for _, key := range keys { - value := strings.TrimSpace(fmt.Sprint(data[key])) - if value == "" || value == "" { - continue - } - fields = append(fields, telegramDataField{key: key, value: value}) - } - return fields -} - -func telegramHiddenDataKey(key string) bool { - switch strings.ToLower(strings.TrimSpace(key)) { - case "photo_url", "poster_url", "poster", "image_url", "backdrop_url", - "tmdb_url", "imdb_url", "douban_url", "detail_url", "external_url", - "resource_title", "torrent_title", "release_title": - return true - default: - return false - } -} - -func telegramFieldLabel(key string) string { - switch strings.ToLower(strings.TrimSpace(key)) { - case "title", "name": - return "标题" - case "original_title": - return "原始片名" - case "original_language": - return "原始语言" - case "year", "release_year": - return "发行年份" - case "save_path": - return "保存路径" - case "hash": - return "Hash" - case "media_type": - return "媒体类型" - case "media_category": - return "类别" - case "season_episode": - return "季集" - case "size", "bitrate": - return "大小" - case "version", "release_group": - return "版本" - case "rating": - return "评分" - case "genres": - return "类型" - case "overview": - return "简介" - case "subscription": - return "订阅" - case "queued": - return "新增资源" - default: - return strings.TrimSpace(key) - } -} - -func telegramExternalLinks(data map[string]interface{}) string { - if len(data) == 0 { - return "" - } - links := []string{} - for _, item := range []struct { - key string - name string - }{ - {key: "tmdb_url", name: "TMDB"}, - {key: "imdb_url", name: "IMDB"}, - {key: "douban_url", name: "豆瓣"}, - } { - value := telegramDataString(data, item.key) - if isTelegramRemotePhotoURL(value) { - links = append(links, fmt.Sprintf(`%s`, escapeHTML(value), escapeHTML(item.name))) - } - } - if len(links) == 0 { - return "" - } - return "🔗 外链:" + strings.Join(links, " / ") -} - -func telegramEventPhotoURL(event NotifyEvent) string { - for _, key := range []string{"photo_url", "poster_url", "poster", "image_url", "backdrop_url"} { - value := telegramDataString(event.Data, key) - if isTelegramRemotePhotoURL(value) { - return value - } - } - return "" -} - -func telegramDataString(data map[string]interface{}, key string) string { - if len(data) == 0 { - return "" - } - for k, value := range data { - if strings.EqualFold(strings.TrimSpace(k), key) { - return telegramValueString(value) - } - } - return "" -} - -func telegramValueString(value interface{}) string { - switch v := value.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(v) - case []string: - return strings.TrimSpace(strings.Join(v, ",")) - case []interface{}: - out := make([]string, 0, len(v)) - for _, item := range v { - if s := telegramValueString(item); s != "" { - out = append(out, s) - } - } - return strings.Join(out, ",") - case float32: - return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v), "0"), ".") - case float64: - return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v), "0"), ".") - default: - text := strings.TrimSpace(fmt.Sprint(value)) - if text == "" { - return "" - } - return text - } -} - -func isTelegramRemotePhotoURL(raw string) bool { - raw = strings.TrimSpace(raw) - if raw == "" { - return false - } - u, err := url.Parse(raw) - return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" -} diff --git a/internal/service/notify_telegram_fields.go b/internal/service/notify_telegram_fields.go deleted file mode 100644 index c7da82c..0000000 --- a/internal/service/notify_telegram_fields.go +++ /dev/null @@ -1,226 +0,0 @@ -package service - -import ( - "fmt" - "strconv" - "strings" -) - -func telegramFirstValue(data map[string]interface{}, keys ...string) string { - for _, key := range keys { - if value := telegramDataString(data, key); value != "" { - return value - } - } - return "" -} - -func telegramMessageFieldValue(message string, keys ...string) string { - if strings.TrimSpace(message) == "" { - return "" - } - for _, line := range strings.Split(message, "\n") { - key, value, ok := splitTelegramField(line) - if !ok { - continue - } - for _, want := range keys { - if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(want)) { - return value - } - } - } - return "" -} - -func telegramMediaCategory(data map[string]interface{}) string { - if category := telegramFirstValue(data, "media_category", "category"); category != "" { - return category - } - switch strings.ToLower(telegramFirstValue(data, "media_type")) { - case "movie": - return "电影" - case "tv", "series", "show": - return "剧集" - case "anime": - return "动漫" - case "variety": - return "综艺" - case "documentary": - return "纪录片" - default: - return telegramFirstValue(data, "media_type") - } -} - -func telegramLanguageName(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - parts := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ',' || r == '/' || r == '|' || r == '、' - }) - if len(parts) == 0 { - parts = []string{raw} - } - seen := map[string]struct{}{} - out := []string{} - for _, part := range parts { - part = strings.TrimSpace(strings.Trim(part, "[]")) - if part == "" { - continue - } - lower := strings.ToLower(strings.ReplaceAll(part, "_", "-")) - name := part - switch { - case strings.HasPrefix(lower, "zh") || lower == "cn" || lower == "cmn": - name = "中文" - case lower == "en" || strings.HasPrefix(lower, "en-"): - name = "英语" - case lower == "ja" || lower == "jp" || strings.HasPrefix(lower, "ja-"): - name = "日语" - case lower == "ko" || lower == "kr" || strings.HasPrefix(lower, "ko-"): - name = "韩语" - case lower == "fr" || strings.HasPrefix(lower, "fr-"): - name = "法语" - case lower == "de" || strings.HasPrefix(lower, "de-"): - name = "德语" - case lower == "es" || strings.HasPrefix(lower, "es-"): - name = "西班牙语" - case lower == "it" || strings.HasPrefix(lower, "it-"): - name = "意大利语" - case lower == "ru" || strings.HasPrefix(lower, "ru-"): - name = "俄语" - case lower == "th" || strings.HasPrefix(lower, "th-"): - name = "泰语" - } - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} - out = append(out, name) - } - return strings.Join(out, "、") -} - -func telegramSeasonEpisodeValue(event NotifyEvent) string { - if value := telegramFirstValue(event.Data, "season_episode", "episode_tag"); value != "" { - return strings.ToUpper(value) - } - for _, raw := range []string{ - telegramFirstValue(event.Data, "resource_title", "torrent_title", "release_title"), - telegramFirstValue(event.Data, "title", "name"), - event.Message, - } { - if value := telegramExtractSeasonEpisode(raw); value != "" { - return value - } - } - season := telegramFirstValue(event.Data, "season") - episode := telegramFirstValue(event.Data, "episode") - if season != "" && episode != "" { - return fmt.Sprintf("S%02dE%02d", telegramEpisodeNumber(season), telegramEpisodeNumber(episode)) - } - return "" -} - -func telegramEpisodeNumber(raw string) int { - raw = strings.TrimSpace(strings.TrimLeft(strings.ToUpper(raw), "SE")) - raw = strings.TrimLeft(raw, "0") - if raw == "" { - return 0 - } - n, _ := strconv.Atoi(raw) - return n -} - -func telegramExtractSeasonEpisode(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - return strings.ToUpper(telegramSeasonEpisodePattern.FindString(raw)) -} - -func telegramSizeValue(data map[string]interface{}) string { - size := telegramFirstValue(data, "size") - bitrate := telegramFirstValue(data, "bitrate") - if size != "" && bitrate != "" { - return size + " / " + bitrate - } - if size != "" { - return size - } - return bitrate -} - -func telegramVersionValue(event NotifyEvent, seasonEpisode string) string { - if version := telegramFirstValue(event.Data, "version", "release_group"); version != "" && !strings.EqualFold(version, "best") { - return version - } - return telegramVersionFromResourceTitle( - telegramFirstValue(event.Data, "resource_title", "torrent_title", "release_title"), - seasonEpisode, - telegramFirstValue(event.Data, "year", "release_year"), - ) -} - -func telegramVersionFromResourceTitle(raw, seasonEpisode, year string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - tail := "" - if seasonEpisode != "" { - upperRaw := strings.ToUpper(raw) - upperEpisode := strings.ToUpper(seasonEpisode) - if idx := strings.Index(upperRaw, upperEpisode); idx >= 0 { - tail = raw[idx+len(seasonEpisode):] - } - } - if tail == "" && year != "" { - if idx := strings.LastIndex(raw, year); idx >= 0 { - tail = raw[idx+len(year):] - } - } - tail = strings.Trim(tail, " \t\r\n._-[]()【】") - if tail == "" { - return "" - } - tail = strings.TrimSuffix(tail, ".torrent") - tail = strings.TrimSuffix(tail, ".mkv") - tail = strings.TrimSuffix(tail, ".mp4") - tail = strings.Join(strings.Fields(tail), ".") - if len([]rune(tail)) > 72 { - tail = string([]rune(tail)[:72]) + "..." - } - return tail -} - -func telegramGenresValue(raw string) string { - raw = strings.TrimSpace(strings.Trim(raw, "[]")) - if raw == "" { - return "" - } - parts := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ',' || r == '/' || r == '|' || r == '、' - }) - if len(parts) <= 1 { - return raw - } - out := make([]string, 0, len(parts)) - seen := map[string]struct{}{} - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if _, ok := seen[part]; ok { - continue - } - seen[part] = struct{}{} - out = append(out, part) - } - return strings.Join(out, "、") -} diff --git a/internal/service/notify_telegram_media.go b/internal/service/notify_telegram_media.go deleted file mode 100644 index fccc22b..0000000 --- a/internal/service/notify_telegram_media.go +++ /dev/null @@ -1,138 +0,0 @@ -package service - -import ( - "regexp" - "strings" -) - -const ( - telegramMediaTemplateHeader = "🐈‍⬛🐈‍⬛ MediaStationGo 更新啦 🐈‍⬛🐈‍⬛" - telegramMediaTemplateSeparator = "--------------------------------" -) - -var telegramSeasonEpisodePattern = regexp.MustCompile(`(?i)S\d{1,2}E\d{1,3}(?:[\-.~_ ]?E?\d{1,3})?`) - -func formatTelegramMediaNotification(event NotifyEvent) string { - if strings.TrimSpace(event.Type) == EventDownloadComplete { - return formatTelegramDownloadCompleteNotification(event) - } - - tag := telegramMediaTag(event.Data) - if tag == "" { - return "" - } - - title := telegramFirstValue(event.Data, "chinese_title", "title", "name", "media_title") - if title == "" { - title = telegramMessageFieldValue(event.Message, "任务", "订阅", "媒体", "资源") - } - originalTitle := telegramFirstValue(event.Data, "original_title", "original_name") - originalLanguage := telegramLanguageName(telegramFirstValue(event.Data, "original_language", "language", "languages")) - year := telegramFirstValue(event.Data, "year", "release_year") - category := telegramMediaCategory(event.Data) - seasonEpisode := telegramSeasonEpisodeValue(event) - size := telegramSizeValue(event.Data) - version := telegramVersionValue(event, seasonEpisode) - rating := telegramFirstValue(event.Data, "rating", "score") - genres := telegramGenresValue(telegramFirstValue(event.Data, "genres", "genre", "type")) - overview := telegramFirstValue(event.Data, "overview", "summary", "description") - links := telegramExternalLinks(event.Data) - - topFields := []string{} - if title != "" { - topFields = append(topFields, "📺 中文片名:"+escapeHTML(title)) - } - if originalTitle != "" && !strings.EqualFold(originalTitle, title) { - topFields = append(topFields, "🧿 原始片名:"+escapeHTML(originalTitle)) - } - if originalLanguage != "" { - topFields = append(topFields, "🌐 原始语言:"+escapeHTML(originalLanguage)) - } - if year != "" && year != "0" { - topFields = append(topFields, "📅 发行年份:"+escapeHTML(year)) - } - - mediaFields := []string{} - if category != "" { - mediaFields = append(mediaFields, "🐈‍⬛ 类别:"+escapeHTML(category)) - } - if seasonEpisode != "" { - mediaFields = append(mediaFields, "🫧 季集:"+escapeHTML(seasonEpisode)) - } - if size != "" { - mediaFields = append(mediaFields, "🔎 大小:"+escapeHTML(size)) - } - if version != "" { - mediaFields = append(mediaFields, "📁 版本:"+escapeHTML(version)) - } - - infoFields := []string{} - if rating != "" && rating != "0" { - infoFields = append(infoFields, "⭐️ 评分:"+escapeHTML(rating)) - } - if genres != "" { - infoFields = append(infoFields, "💎 类型:"+escapeHTML(genres)) - } - if overview != "" { - infoFields = append(infoFields, "🪬 简介:\n"+escapeHTML(overview)) - } - - if len(topFields) == 0 && len(mediaFields) == 0 && len(infoFields) == 0 && links == "" { - return "" - } - - lines := []string{telegramMediaTemplateHeader, telegramMediaTemplateSeparator, tag} - lines = append(lines, topFields...) - if len(mediaFields) > 0 { - if len(topFields) > 0 { - lines = append(lines, "") - } - lines = append(lines, mediaFields...) - } - if len(infoFields) > 0 { - if len(topFields) > 0 || len(mediaFields) > 0 { - lines = append(lines, "") - } - lines = append(lines, infoFields...) - } - if links != "" { - lines = append(lines, "", telegramMediaTemplateSeparator, links) - } - return strings.TrimSpace(strings.Join(lines, "\n")) -} - -func formatTelegramDownloadCompleteNotification(event NotifyEvent) string { - title := telegramMessageFieldValue(event.Message, "任务", "媒体", "资源") - if title == "" { - title = telegramFirstValue(event.Data, "title", "name", "media_title", "chinese_title", "resource_title") - } - if title == "" { - title = "下载任务" - } - return "#下载完成\n📺 任务:" + escapeHTML(title) -} - -func telegramMediaTag(data map[string]interface{}) string { - for _, key := range []string{"media_category", "category", "media_type"} { - value := strings.TrimSpace(telegramDataString(data, key)) - if value == "" { - continue - } - lower := strings.ToLower(value) - switch { - case strings.Contains(value, "电影") || lower == "movie": - return "#电影" - case strings.Contains(value, "剧") || lower == "tv" || lower == "series" || lower == "show": - return "#剧集" - case strings.Contains(value, "动漫") || strings.Contains(value, "动画") || lower == "anime": - return "#动漫" - case strings.Contains(value, "综艺") || lower == "variety": - return "#综艺" - case strings.Contains(value, "纪录") || lower == "documentary": - return "#纪录片" - default: - return "#" + escapeHTML(strings.ReplaceAll(value, " ", "")) - } - } - return "" -} diff --git a/internal/service/notify_webhook.go b/internal/service/notify_webhook.go deleted file mode 100644 index f6ff894..0000000 --- a/internal/service/notify_webhook.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package service — Webhook 通知 Provider。 -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// WebhookProvider 通过自定义 HTTP Webhook 发送通知。 -// 支持自定义 HTTP 方法和请求头。 -type WebhookProvider struct{} - -// Send 发送 Webhook 通知。 -func (p *WebhookProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - webhookURL := cfg["url"] - if webhookURL == "" { - return fmt.Errorf("webhook: url is required") - } - - method := cfg["method"] - if method == "" { - method = "POST" - } - method = strings.ToUpper(method) - - // 构建请求体 - bodyTemplate := cfg["body_template"] - var bodyStr string - if bodyTemplate != "" { - bodyStr = renderTemplate(bodyTemplate, event) - } else { - // 默认 JSON 格式 - bodyStr = fmt.Sprintf(`{"type":"%s","title":"%s","message":"%s","data":{}}`, - event.Type, event.Title, event.Message) - if len(event.Data) > 0 { - var dataParts []string - for k, v := range event.Data { - dataParts = append(dataParts, fmt.Sprintf(`"%s":%v`, k, v)) - } - bodyStr = fmt.Sprintf(`{"type":"%s","title":"%s","message":"%s","data":{%s}}`, - event.Type, event.Title, event.Message, strings.Join(dataParts, ",")) - } - } - - req, err := http.NewRequestWithContext(ctx, method, webhookURL, strings.NewReader(bodyStr)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - - // 自定义请求头 - headersJSON := cfg["headers_json"] - if headersJSON != "" { - headers := parseHeadersJSON(headersJSON) - for k, v := range headers { - req.Header.Set(k, v) - } - } - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("webhook error %d: %s", resp.StatusCode, string(respBody)) - } - return nil -} - -// ValidateConfig 验证 Webhook 配置。 -func (p *WebhookProvider) ValidateConfig(cfg map[string]string) error { - if cfg["url"] == "" { - return fmt.Errorf("webhook: url is required") - } - return nil -} - -// renderTemplate 简单模板渲染,支持 {{title}}, {{message}}, {{type}} 占位符。 -func renderTemplate(template string, event NotifyEvent) string { - result := template - result = strings.ReplaceAll(result, "{{title}}", event.Title) - result = strings.ReplaceAll(result, "{{message}}", event.Message) - result = strings.ReplaceAll(result, "{{type}}", event.Type) - return result -} - -// parseHeadersJSON 简单解析 headers JSON(格式: {"key":"value",...})。 -func parseHeadersJSON(jsonStr string) map[string]string { - result := make(map[string]string) - jsonStr = strings.TrimSpace(jsonStr) - if jsonStr == "" || (jsonStr[0] != '{' && jsonStr[len(jsonStr)-1] != '}') { - return result - } - - // 简单 key:value 解析 - inner := jsonStr[1 : len(jsonStr)-1] - parts := strings.Split(inner, ",") - for _, part := range parts { - kv := strings.SplitN(part, ":", 2) - if len(kv) != 2 { - continue - } - key := strings.Trim(strings.TrimSpace(kv[0]), `"`) - value := strings.Trim(strings.TrimSpace(kv[1]), `"`) - if key != "" { - result[key] = value - } - } - return result -} diff --git a/internal/service/notify_wechat.go b/internal/service/notify_wechat.go deleted file mode 100644 index 6794d87..0000000 --- a/internal/service/notify_wechat.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package service — Server酱(WeChat) 通知 Provider。 -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// WechatProvider 通过 Server酱 API 推送消息到微信。 -// Server酱 API 文档: https://sct.ftqq.com/ -type WechatProvider struct{} - -// Send 发送 Server酱 推送消息。 -func (p *WechatProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - sendkey := cfg["sendkey"] - if sendkey == "" { - return fmt.Errorf("wechat: sendkey is required") - } - - payload := map[string]string{ - "title": event.Title, - "desp": event.Message, - } - if len(event.Data) > 0 { - payload["desp"] += "\n\n---\n\n" - for k, v := range event.Data { - payload["desp"] += fmt.Sprintf("- **%s**: %v\n", k, v) - } - } - - body, err := json.Marshal(payload) - if err != nil { - return err - } - - apiURL := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", sendkey) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return fmt.Errorf("wechat server酱 api error %d: %s", resp.StatusCode, string(respBody)) - } - - // 检查 Server酱 响应 - var result map[string]interface{} - if err := json.Unmarshal(respBody, &result); err == nil { - if code, ok := result["code"].(float64); ok && code != 0 { - msg, _ := result["message"].(string) - return fmt.Errorf("wechat server酱 error: %s", msg) - } - } - return nil -} - -// ValidateConfig 验证 Server酱 配置。 -func (p *WechatProvider) ValidateConfig(cfg map[string]string) error { - if cfg["sendkey"] == "" { - return fmt.Errorf("wechat: sendkey is required") - } - return nil -} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 6db7646..b6b1511 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -49,23 +49,6 @@ func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p } // metadata before it decides the final folder and filename. func (o *OrganizerService) SetScraper(s *ScraperService) { o.scraper = s } -// SetActiveDownloadPathProvider wires a live downloader snapshot. Directory -// organize must never move/copy files that still belong to an unfinished -// torrent, regardless of which UI switch triggered the organize operation. -func (o *OrganizerService) SetActiveDownloadPathProvider(provider func(context.Context) []string) { - o.activeDownloadPaths = provider -} - -func (o *OrganizerService) SetActiveDownloadProvider(provider func(context.Context) []QBitTorrent) { - if provider == nil { - o.activeDownloadPaths = nil - return - } - o.activeDownloadPaths = func(ctx context.Context) []string { - return activeDownloadPathCandidates(provider(ctx), nil) - } -} - // OrganizeMedia moves a single media file into the target library directory. // It auto-detects whether the media is a movie or TV episode based on the // parsed season/episode numbers and builds the destination path accordingly. diff --git a/internal/service/organizer_active_downloads.go b/internal/service/organizer_active_downloads.go index cd2392c..961f84a 100644 --- a/internal/service/organizer_active_downloads.go +++ b/internal/service/organizer_active_downloads.go @@ -31,41 +31,6 @@ func (g activeDownloadGuard) contains(path string) bool { return false } -func activeDownloadPathCandidates(torrents []QBitTorrent, mappings map[string]string) []string { - if len(torrents) == 0 { - return nil - } - var out []string - for _, torrent := range torrents { - if qbitTorrentCompleted(torrent) { - continue - } - for _, raw := range []string{ - torrent.ContentPath, - filepath.Join(strings.TrimSpace(torrent.SavePath), strings.TrimSpace(torrent.Name)), - } { - out = appendDownloadPathCandidates(out, raw, mappings) - } - } - return cleanUniqueExistingPaths(out) -} - -func appendDownloadPathCandidates(out []string, raw string, mappings map[string]string) []string { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "." { - return out - } - if translated := translateClientPath(raw, mappings); translated != "" { - out = append(out, translated) - } - for _, candidate := range mappedPathCandidates(raw) { - if _, err := os.Stat(candidate); err == nil { - out = append(out, candidate) - } - } - return out -} - func cleanUniqueExistingPaths(paths []string) []string { if len(paths) == 0 { return nil diff --git a/internal/service/organizer_directory_source_test.go b/internal/service/organizer_directory_source_test.go index 6f1bdd5..1a83ae5 100644 --- a/internal/service/organizer_directory_source_test.go +++ b/internal/service/organizer_directory_source_test.go @@ -1,7 +1,6 @@ package service import ( - "context" "os" "path/filepath" "testing" @@ -34,48 +33,6 @@ func TestOrganizeDirectoryUsesConfiguredSourceWhenRequestSourceEmpty(t *testing. } } -func TestOrganizeDirectorySkipsActiveQBitTorrentDownloads(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads") - dest := filepath.Join(root, "media") - active := filepath.Join(src, "Still.Downloading.2026.1080p.mkv") - done := filepath.Join(src, "Dune 2021 2160p WEB-DL.mkv") - writeOrgFile(t, active, "partial") - writeOrgFile(t, done, "done") - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) - org.SetActiveDownloadProvider(func(context.Context) []QBitTorrent { - return []QBitTorrent{{ - Hash: "active", - Name: "Still.Downloading.2026.1080p", - State: "downloading", - Progress: 0.42, - SavePath: src, - ContentPath: active, - }} - }) - res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ - SourcePath: src, - DestPath: dest, - TransferMode: TransferMove, - }) - if err != nil { - t.Fatalf("organize directory: %v", err) - } - if res.Organized != 1 || res.Skipped != 1 { - t.Fatalf("result = %+v, want organized=1 skipped=1", res) - } - if _, err := os.Stat(active); err != nil { - t.Fatalf("active qB file must stay in source: %v", err) - } - for _, item := range res.Items { - if item.Source == active && item.Action == "skip" && item.Reason == organizeSkipActiveDownload { - return - } - } - t.Fatalf("missing active-download skip item: %+v", res.Items) -} - func TestOrganizeDirectoryMapsConfiguredHostPathsToContainerPaths(t *testing.T) { root := t.TempDir() hostDownloads := filepath.Join(root, "nas-host", "downloads") diff --git a/internal/service/organizer_reclassify_cloud.go b/internal/service/organizer_reclassify_cloud.go deleted file mode 100644 index 4a5b480..0000000 --- a/internal/service/organizer_reclassify_cloud.go +++ /dev/null @@ -1,186 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, mediaTypeHint string, dryRun bool, res *OrganizeResult) (bool, error) { - mediaType := normalizeOrganizeMediaType(lib.Type) - if mediaTypeHint != "" { - mediaType = mediaTypeHint - } - metadataMatch := organizeMatchFromMedia(&media) - if metadataMatch != nil && mediaTypeHint != "" { - metadataMatch.MediaType = mediaTypeHint - } - if !mediaHasReliableCategoryMetadata(media) { - metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType) - if metadataMatch == nil { - return false, nil - } - media = mediaWithReclassifyMatch(media, metadataMatch) - } - if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" { - mediaType = matchType - } - category := o.classifyMedia(ctx, &media, mediaType) - if category == "" { - return false, nil - } - if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { - mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType) - category = normalizedCategory - } - if mediaType == "" { - mediaType = normalizeOrganizeMediaType(lib.Type) - } - displayDir := o.cloudReclassifyCategoryDisplayDir(mediaType, category) - if displayDir == "" { - return false, nil - } - if normalizeCloudMountDir(mount.Provider, mount.DisplayDir) == normalizeCloudMountDir(mount.Provider, displayDir) { - return false, nil - } - targetLibrary, ok, err := o.ensureCloudReclassifyLibrary(ctx, mount.Provider, displayDir, mediaType, dryRun) - if err != nil || !ok { - return false, err - } - if strings.TrimSpace(targetLibrary.ID) != "" && targetLibrary.ID == lib.ID { - return false, nil - } - title := sanitizeFilename(strings.TrimSpace(media.Title)) - if title == "" { - title = "Unknown" - } - res.Items = append(res.Items, OrganizePreviewItem{ - Source: media.Path, - Target: targetLibrary.Path, - Action: "reclassify", - Reason: "cloud metadata category library changed", - MediaType: mediaType, - Category: category, - Title: title, - }) - if dryRun { - res.Reclassified++ - return true, nil - } - updates := map[string]any{ - "library_id": targetLibrary.ID, - "series_id": "", - } - if normalizeOrganizeMediaType(mediaType) == "movie" { - updates["season_num"] = 0 - updates["episode_num"] = 0 - updates["episode_title"] = "" - } - applyReclassifyMatchUpdates(updates, metadataMatch) - if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil { - return false, err - } - if o.log != nil { - o.log.Info("cloud media library reclassified by metadata", - zap.String("media", media.ID), - zap.String("path", media.Path), - zap.String("from_library", lib.ID), - zap.String("to_library", targetLibrary.ID), - zap.String("category", category), - zap.String("media_type", mediaType), - zap.String("display_dir", displayDir)) - } - res.Reclassified++ - return true, nil -} - -func (o *OrganizerService) cloudReclassifyCategoryDisplayDir(mediaType, category string) string { - category = sanitizeFilename(strings.TrimSpace(category)) - if category == "" { - return "" - } - root := o.mediaTypeRootDirForCategory(mediaType, category) - if root == "" { - return "" - } - return strings.Join([]string{root, category}, "/") -} - -func (o *OrganizerService) ensureCloudReclassifyLibrary(ctx context.Context, provider, displayDir, mediaType string, dryRun bool) (model.Library, bool, error) { - if o == nil || o.repo == nil || o.repo.Library == nil { - return model.Library{}, false, nil - } - provider = strings.TrimSpace(provider) - displayDir = normalizeCloudMountDir(provider, displayDir) - if provider == "" || displayDir == "" { - return model.Library{}, false, nil - } - if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { - return *existing, true, nil - } - path := BuildCloudAutoCategoryLibraryPath(provider, displayDir) - if path == "" { - return model.Library{}, false, nil - } - name := cloudMountDirBase(displayDir) - if name == "" { - name = displayDir - } - libType := InferCloudMountMediaType(displayDir, name) - if normalizeOrganizeMediaType(libType) == "" { - libType = organizeLibraryModelType(mediaType) - } - lib := model.Library{ - Name: name, - Path: path, - Type: libType, - Enabled: true, - } - if dryRun { - return lib, true, nil - } - if err := o.repo.Library.Create(ctx, &lib); err != nil { - if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { - return *existing, true, nil - } - return model.Library{}, false, err - } - if o.log != nil { - o.log.Info("created cloud metadata reclassify library", - zap.String("library_id", lib.ID), - zap.String("provider", provider), - zap.String("display_dir", displayDir), - zap.String("type", lib.Type)) - } - return lib, true, nil -} - -func (o *OrganizerService) findCloudReclassifyLibrary(ctx context.Context, provider, displayDir string) *model.Library { - if o == nil || o.repo == nil || o.repo.Library == nil { - return nil - } - libs, err := o.repo.Library.List(ctx) - if err != nil { - if o.log != nil { - o.log.Warn("list cloud libraries for metadata reclassify failed", zap.Error(err)) - } - return nil - } - displayDir = normalizeCloudMountDir(provider, displayDir) - for _, lib := range libs { - if !lib.Enabled { - continue - } - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok || info.Provider != provider { - continue - } - if normalizeCloudMountDir(provider, info.DisplayDir) == displayDir { - return &lib - } - } - return nil -} diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index 745efd0..b3a4730 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -107,9 +107,6 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod if res == nil || !lib.Enabled || strings.TrimSpace(media.Path) == "" { return false, nil } - if mount, ok := ParseCloudLibraryMount(lib.Path); ok { - return o.reclassifyCloudScannedMedia(ctx, media, lib, mount, mediaTypeHint, dryRun, res) - } if !organizeFileExists(media.Path) { return false, nil } diff --git a/internal/service/permission.go b/internal/service/permission.go index 87e2382..710e82a 100644 --- a/internal/service/permission.go +++ b/internal/service/permission.go @@ -40,9 +40,8 @@ func DefaultPermissions(userID string) *model.UserPermission { CanRescrape: false, CanUseAI: false, CanCaptureFrames: false, - CanManageDownloads: false, - CanViewDiscover: false, - CanManageSubscriptions: false, + CanManageDownloads: false, + CanManageSubscriptions: false, CanManageSites: false, CanUseAIAssistant: false, CanManageUsers: false, @@ -66,9 +65,8 @@ func adminGrant(userID string) *model.UserPermission { CanRescrape: true, CanUseAI: true, CanCaptureFrames: true, - CanManageDownloads: true, - CanViewDiscover: true, - CanManageSubscriptions: true, + CanManageDownloads: true, + CanManageSubscriptions: true, CanManageSites: true, CanUseAIAssistant: true, CanManageUsers: true, diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go deleted file mode 100644 index 0a1d89e..0000000 --- a/internal/service/qbittorrent.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package service — qBittorrent Web UI client. -// -// QBitClient is a thin wrapper around the qBittorrent /api/v2 REST API -// (https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API). -// -// We only need three operations for the download flow: -// -// POST /auth/login -// POST /torrents/add (multipart, accepts magnet URL or .torrent bytes) -// GET /torrents/info (filtered by hash) -// -// The client stores the SID cookie returned by /auth/login and reuses it -// across calls. Re-auth happens transparently on 403. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "strings" - "sync" - "time" - - "go.uber.org/zap" -) - -// QBitConfig holds the connection settings (typically loaded from the -// system Setting table or an env var). -type QBitConfig struct { - BaseURL string - Username string - Password string -} - -// QBitTorrent is the subset of /torrents/info we surface to the API. -type QBitTorrent struct { - Hash string `json:"hash"` - ClientID string `json:"client_id,omitempty"` - Source string `json:"source,omitempty"` - Name string `json:"name"` - State string `json:"state"` - Progress float32 `json:"progress"` - DLSpeed int64 `json:"dlspeed"` - UpSpeed int64 `json:"upspeed"` - NumSeeds int `json:"num_seeds"` - NumLeech int `json:"num_leechs"` - Size int64 `json:"size"` - SavePath string `json:"save_path"` - Category string `json:"category"` - // ContentPath is qBittorrent's resolved payload path. For single-file - // torrents it points at the file; for multi-file torrents it points at the - // root folder. Prefer it for automatic organize so we do not scan the whole - // download category. - ContentPath string `json:"content_path"` - // CompletionOn 是 qBittorrent 报告的完成时间(Unix 秒,未完成为 0 或负值)。 - // 用于应用重启后的「补整理」判断:只补最近完成的种子,避免每次启动 - // 都重新触发全部历史种子的整理。 - CompletionOn int64 `json:"completion_on"` -} - -// QBitClient is a thread-safe qBittorrent v2 API client. -type QBitClient struct { - log *zap.Logger - mu sync.Mutex - cfg QBitConfig - client *http.Client -} - -var ( - qbitAddVerifyAttempts = 10 - qbitAddVerifyInterval = 800 * time.Millisecond -) - -// NewQBitClient builds a fresh client. A blank URL intentionally stays blank: -// an unconfigured downloader must fail closed instead of silently trying a -// localhost qBittorrent instance. -func NewQBitClient(log *zap.Logger, cfg QBitConfig) *QBitClient { - cfg.BaseURL = normalizeQBitBaseURL(cfg.BaseURL) - jar, _ := cookiejar.New(nil) - client := NewInternalHTTPClient(20 * time.Second) - client.Jar = jar - return &QBitClient{ - log: log, - cfg: cfg, - client: client, - } -} - -// Configure rotates the client to a new endpoint and re-auths next call. -func (q *QBitClient) Configure(cfg QBitConfig) { - q.mu.Lock() - defer q.mu.Unlock() - cfg.BaseURL = normalizeQBitBaseURL(cfg.BaseURL) - q.cfg = cfg - jar, _ := cookiejar.New(nil) - q.client.Jar = jar -} - -func (q *QBitClient) IsConfigured() bool { - q.mu.Lock() - defer q.mu.Unlock() - return strings.TrimSpace(q.cfg.BaseURL) != "" -} - -func normalizeQBitBaseURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - normalized, err := normalizeDownloadClientEndpoint("qbittorrent", raw) - if err != nil { - return strings.TrimRight(raw, "/") - } - return normalized -} - -// Login performs POST /api/v2/auth/login. -func (q *QBitClient) Login(ctx context.Context) error { - if q.cfg.BaseURL == "" { - return errors.New("qbittorrent base url not configured") - } - return qbitLogin(ctx, q.client, q.cfg.BaseURL, q.cfg.Username, q.cfg.Password) -} - -// List returns every torrent (optionally filtered by status: all / downloading / completed). -func (q *QBitClient) List(ctx context.Context, filter string) ([]QBitTorrent, error) { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return nil, err - } - return q.listLocked(ctx, filter) -} - -func (q *QBitClient) listLocked(ctx context.Context, filter string) ([]QBitTorrent, error) { - u := strings.TrimRight(q.cfg.BaseURL, "/") + "/api/v2/torrents/info" - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - if filter != "" { - query := req.URL.Query() - query.Set("filter", filter) - req.URL.RawQuery = query.Encode() - } - req.Header.Set("Referer", q.cfg.BaseURL) - resp, err := q.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("qbittorrent list: %d", resp.StatusCode) - } - var out []QBitTorrent - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, err - } - return out, nil -} - -// Delete removes a torrent (optionally with its files). -func (q *QBitClient) Delete(ctx context.Context, hash string, deleteFiles bool) error { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - form := url.Values{} - form.Set("hashes", hash) - if deleteFiles { - form.Set("deleteFiles", "true") - } else { - form.Set("deleteFiles", "false") - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/delete", - strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", q.cfg.BaseURL) - req.Header.Set("Origin", q.cfg.BaseURL) - resp, err := q.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent delete: %d", resp.StatusCode) - } - return nil -} - -func (q *QBitClient) Pause(ctx context.Context, hash string) error { - return q.torrentAction(ctx, hash, "pause", "stop") -} - -func (q *QBitClient) Resume(ctx context.Context, hash string) error { - return q.torrentAction(ctx, hash, "resume", "start") -} - -func (q *QBitClient) torrentAction(ctx context.Context, hash string, actions ...string) error { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - baseURL := strings.TrimRight(q.cfg.BaseURL, "/") - form := url.Values{"hashes": []string{hash}} - var lastErr error - for _, action := range actions { - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/"+action, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := q.client.Do(req) - if err != nil { - return err - } - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if resp.StatusCode < 400 { - return nil - } - lastErr = fmt.Errorf("qbittorrent %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body))) - if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed { - break - } - } - return lastErr -} - -// SetLocation moves a torrent's data to a new save directory via -// POST /api/v2/torrents/setLocation. qBittorrent performs the physical move -// itself and keeps seeding from the new location — this is the seeding-safe -// way to relocate downloaded PT files. location must be an absolute path the -// qBittorrent process can write to. -func (q *QBitClient) SetLocation(ctx context.Context, hash, location string) error { - if strings.TrimSpace(hash) == "" { - return errors.New("qbittorrent setLocation: empty hash") - } - if strings.TrimSpace(location) == "" { - return errors.New("qbittorrent setLocation: empty location") - } - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - form := url.Values{} - form.Set("hashes", hash) - form.Set("location", location) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/setLocation", - strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", q.cfg.BaseURL) - req.Header.Set("Origin", q.cfg.BaseURL) - resp, err := q.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode == http.StatusBadRequest { - return errors.New("qbittorrent setLocation: 保存路径无效") - } - if resp.StatusCode == http.StatusConflict { - return errors.New("qbittorrent setLocation: 无法写入目标路径 (权限或磁盘问题)") - } - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent setLocation: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - q.log.Info("qbittorrent: torrent relocated", zap.String("hash", hash), zap.String("location", location)) - return nil -} - -// ensureAuth makes sure we have a valid SID cookie. Cheap on the happy -// path; logs in transparently otherwise. -func (q *QBitClient) ensureAuth(ctx context.Context) error { - if strings.TrimSpace(q.cfg.BaseURL) == "" { - return errors.New("qbittorrent base url not configured") - } - u, err := url.Parse(q.cfg.BaseURL) - if err != nil { - return err - } - if cookies := q.client.Jar.Cookies(u); len(cookies) > 0 { - for _, c := range cookies { - if strings.EqualFold(c.Name, "SID") && c.Value != "" { - return nil - } - } - } - return q.Login(ctx) -} diff --git a/internal/service/qbittorrent_add.go b/internal/service/qbittorrent_add.go deleted file mode 100644 index 2f4705b..0000000 --- a/internal/service/qbittorrent_add.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "path" - "strings" - "time" - - "go.uber.org/zap" -) - -// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent. -// -// qBittorrent 的 /api/v2/torrents/add 在很多失败场景下仍然返回 HTTP 200 -// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免 -// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。 -func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error { - return q.AddTorrentWithCategory(ctx, magnetOrURL, savePath, "") -} - -func (q *QBitClient) AddTorrentWithCategory(ctx context.Context, magnetOrURL, savePath, category string) error { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - - torrentData, torrentName, fetchErr := q.fetchTorrentFile(ctx, magnetOrURL) - useFileUpload := fetchErr == nil && len(torrentData) > 0 - return q.addTorrentLocked(ctx, magnetOrURL, torrentData, torrentName, useFileUpload, savePath, category) -} - -func (q *QBitClient) AddTorrentFile(ctx context.Context, data []byte, name, savePath string) error { - return q.AddTorrentFileWithCategory(ctx, data, name, savePath, "") -} - -func (q *QBitClient) AddTorrentFileWithCategory(ctx context.Context, data []byte, name, savePath, category string) error { - if len(data) == 0 { - return errors.New("empty torrent data") - } - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - return q.addTorrentLocked(ctx, "", data, name, true, savePath, category) -} - -func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, torrentData []byte, torrentName string, useFileUpload bool, savePath, category string) error { - before, beforeErr := q.listLocked(ctx, "") - beforeHashes := make(map[string]struct{}, len(before)) - if beforeErr == nil { - for _, torrent := range before { - if torrent.Hash != "" { - beforeHashes[strings.ToLower(torrent.Hash)] = struct{}{} - } - } - } - if useFileUpload && beforeErr == nil { - if hash := torrentInfoHash(torrentData); hash != "" { - if _, ok := beforeHashes[hash]; ok { - q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName)) - return ErrDownloadAlreadyExists - } - } - } - - body := &bytes.Buffer{} - w := multipart.NewWriter(body) - if useFileUpload { - if strings.TrimSpace(torrentName) == "" { - torrentName = "download.torrent" - } - part, err := w.CreateFormFile("torrents", torrentName) - if err != nil { - return err - } - if _, err := part.Write(torrentData); err != nil { - return err - } - } else { - _ = w.WriteField("urls", magnetOrURL) - } - if savePath != "" { - _ = w.WriteField("savepath", savePath) - } - if strings.TrimSpace(category) != "" { - _ = w.WriteField("category", sanitizeQBitCategory(category)) - } - _ = w.Close() - - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/add", body) - if err != nil { - return err - } - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("Referer", q.cfg.BaseURL) - req.Header.Set("Origin", q.cfg.BaseURL) - - resp, err := q.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - bodyText := strings.TrimSpace(string(raw)) - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent add: HTTP %d: %s", resp.StatusCode, bodyText) - } - // qb 的成功响应是 "Ok." 或空体;任何 "Fails." 视为失败。 - if strings.EqualFold(bodyText, "Fails.") { - return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)") - } - if beforeErr == nil { - accepted := false - var lastListErr error - for attempt := 0; attempt < qbitAddVerifyAttempts; attempt++ { - if attempt > 0 { - time.Sleep(qbitAddVerifyInterval) - } - after, err := q.listLocked(ctx, "") - if err != nil { - lastListErr = err - continue - } - for _, torrent := range after { - if torrent.Hash == "" { - continue - } - if _, ok := beforeHashes[torrent.Hash]; !ok { - accepted = true - break - } - } - if accepted { - break - } - } - if !accepted { - if lastListErr != nil { - return fmt.Errorf("qbittorrent add: 无法确认任务已加入下载器: %w", lastListErr) - } - return fmt.Errorf("qbittorrent add: 下载器未出现新任务,可能种子已存在或 URL 未被下载器接受") - } - } - q.log.Info("qbittorrent: torrent added", - zap.String("url", redactTorrentURL(magnetOrURL)), - zap.String("save_path", savePath), - zap.String("category", sanitizeQBitCategory(category)), - zap.Bool("file_upload", useFileUpload), - zap.String("body", bodyText)) - return nil -} - -func sanitizeQBitCategory(category string) string { - return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(category, "\r", " "), "\n", " ")) -} - -func redactTorrentURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "magnet:?xt=***" - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return "[redacted-download-url]" - } - u.RawQuery = "" - u.Fragment = "" - return u.String() -} - -func (q *QBitClient) fetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || u.Host == "" { - return nil, "", errors.New("not a remote URL") - } - if u.Scheme != "http" && u.Scheme != "https" { - return nil, "", errors.New("not an HTTP torrent URL") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) - if err != nil { - return nil, "", err - } - req.Header.Set("User-Agent", "MediaStationGo/0.1") - req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*") - - client := NewExternalHTTPClient(30 * time.Second) - resp, err := client.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode) - } - - const maxTorrentSize = 32 << 20 - data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1)) - if err != nil { - return nil, "", err - } - if len(data) == 0 { - return nil, "", errors.New("torrent fetch: empty body") - } - if len(data) > maxTorrentSize { - return nil, "", errors.New("torrent fetch: body too large") - } - if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") { - return nil, "", errors.New("torrent fetch: upstream returned HTML") - } - - name := strings.TrimSpace(path.Base(u.Path)) - if name == "" || name == "." || name == "/" { - name = "download.torrent" - } - if !strings.HasSuffix(strings.ToLower(name), ".torrent") { - name += ".torrent" - } - return data, name, nil -} diff --git a/internal/service/qbittorrent_adp.go b/internal/service/qbittorrent_adp.go deleted file mode 100644 index bd541cd..0000000 --- a/internal/service/qbittorrent_adp.go +++ /dev/null @@ -1,285 +0,0 @@ -// Package service — qBittorrent 下载适配器。 -// -// QBitAdapter 实现了 DownloadAdapter 接口,通过 qBittorrent WebUI API -// 管理下载任务。底层使用与 QBitClient 相同的 HTTP API 调用逻辑。 -package service - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/cookiejar" - "net/url" - "strings" - "sync" - "time" -) - -// QBitAdapter 是 qBittorrent 的 DownloadAdapter 实现。 -type QBitAdapter struct { - mu sync.Mutex - cfg DownloadClientConfig - client *http.Client - LoggedIn bool -} - -// NewQBitAdapter 创建新的 qBittorrent 适配器。 -func NewQBitAdapter() *QBitAdapter { - jar, _ := cookiejar.New(nil) - client := NewInternalHTTPClient(20 * time.Second) - client.Jar = jar - return &QBitAdapter{ - client: client, - } -} - -// Initialize 配置并初始化 qBittorrent 连接。 -func (a *QBitAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error { - a.mu.Lock() - defer a.mu.Unlock() - endpoint, err := normalizeDownloadClientEndpoint("qbittorrent", cfg.Host) - if err != nil { - return err - } - cfg.Host = endpoint - a.cfg = cfg - a.LoggedIn = false - jar, _ := cookiejar.New(nil) - a.client.Jar = jar - return a.loginLocked(ctx) -} - -// Ping 测试连接。 -func (a *QBitAdapter) Ping(ctx context.Context) error { - a.mu.Lock() - defer a.mu.Unlock() - return a.loginLocked(ctx) -} - -// AddTorrent 通过 URL 添加种子。 -func (a *QBitAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) { - return a.AddTorrentWithCategory(ctx, torrentURL, savePath, "") -} - -// AddTorrentWithCategory submits a URL or magnet while preserving the -// qBittorrent category selected by MediaStationGo's classification rules. -func (a *QBitAdapter) AddTorrentWithCategory(ctx context.Context, torrentURL, savePath, category string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return "", err - } - - body := &bytes.Buffer{} - w := multipart.NewWriter(body) - _ = w.WriteField("urls", torrentURL) - return a.addTorrentMultipartLocked(ctx, body, w, savePath, category, torrentURLInfoHash(torrentURL)) -} - -// AddMagnet 通过磁力链接添加种子。 -func (a *QBitAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) { - return a.AddTorrent(ctx, magnet, savePath) -} - -// AddTorrentFile uploads .torrent bytes as multipart/form-data. -func (a *QBitAdapter) AddTorrentFile(ctx context.Context, data []byte, name, savePath string) (string, error) { - return a.AddTorrentFileWithCategory(ctx, data, name, savePath, "") -} - -// AddTorrentFileWithCategory uploads .torrent bytes and preserves the -// qBittorrent category selected by the caller. -func (a *QBitAdapter) AddTorrentFileWithCategory(ctx context.Context, data []byte, name, savePath, category string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return "", err - } - body := &bytes.Buffer{} - w := multipart.NewWriter(body) - part, err := w.CreateFormFile("torrents", name) - if err != nil { - return "", err - } - if _, err := part.Write(data); err != nil { - return "", err - } - return a.addTorrentMultipartLocked(ctx, body, w, savePath, category, torrentInfoHash(data)) -} - -func (a *QBitAdapter) addTorrentMultipartLocked(ctx context.Context, body *bytes.Buffer, w *multipart.Writer, savePath, category, externalID string) (string, error) { - if savePath != "" { - _ = w.WriteField("savepath", savePath) - } - if category != "" { - _ = w.WriteField("category", category) - } - if err := w.Close(); err != nil { - return "", err - } - - baseURL := strings.TrimRight(a.cfg.Host, "/") - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/add", body) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - - resp, err := a.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return "", fmt.Errorf("qbittorrent add torrent: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) - } - if strings.EqualFold(strings.TrimSpace(string(raw)), "Fails.") { - return externalID, errors.New("qbittorrent add torrent: rejected by downloader") - } - return externalID, nil -} - -// Pause 暂停种子。 -func (a *QBitAdapter) Pause(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - return a.postTorrentActionLocked(ctx, hash, "pause", "stop") -} - -// Resume 恢复种子。 -func (a *QBitAdapter) Resume(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - return a.postTorrentActionLocked(ctx, hash, "resume", "start") -} - -func (a *QBitAdapter) postTorrentActionLocked(ctx context.Context, hash string, primary, fallback string) error { - baseURL := strings.TrimRight(a.cfg.Host, "/") - form := url.Values{} - form.Set("hashes", hash) - var lastErr error - for _, action := range []string{primary, fallback} { - if action == "" { - continue - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/"+action, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return err - } - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if resp.StatusCode < 400 { - return nil - } - lastErr = fmt.Errorf("qbittorrent %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body))) - if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed { - break - } - } - return lastErr -} - -// Remove 删除种子。 -func (a *QBitAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - baseURL := strings.TrimRight(a.cfg.Host, "/") - form := url.Values{} - form.Set("hashes", hash) - if deleteFiles { - form.Set("deleteFiles", "true") - } else { - form.Set("deleteFiles", "false") - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/delete", strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent delete: %d", resp.StatusCode) - } - return nil -} - -func (a *QBitAdapter) Relocate(ctx context.Context, hash, location string) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - baseURL := strings.TrimRight(a.cfg.Host, "/") - form := url.Values{} - form.Set("hashes", hash) - form.Set("location", location) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/setLocation", strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent setLocation: %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - return nil -} - -// loginLocked 执行登录(调用者必须持有锁)。 -func (a *QBitAdapter) loginLocked(ctx context.Context) error { - if a.cfg.Host == "" { - return fmt.Errorf("qbittorrent host not configured") - } - if err := qbitLogin(ctx, a.client, a.cfg.Host, a.cfg.Username, a.cfg.Password); err != nil { - return err - } - a.LoggedIn = true - return nil -} - -// ensureAuthLocked 确保已认证(调用者必须持有锁)。 -func (a *QBitAdapter) ensureAuthLocked(ctx context.Context) error { - if a.LoggedIn { - return nil - } - return a.loginLocked(ctx) -} diff --git a/internal/service/qbittorrent_adp_list.go b/internal/service/qbittorrent_adp_list.go deleted file mode 100644 index d75251a..0000000 --- a/internal/service/qbittorrent_adp_list.go +++ /dev/null @@ -1,101 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" -) - -// List 列出种子。 -func (a *QBitAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return nil, err - } - baseURL := strings.TrimRight(a.cfg.Host, "/") - u := baseURL + "/api/v2/torrents/info" - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - if filter != "" { - query := req.URL.Query() - query.Set("filter", filter) - req.URL.RawQuery = query.Encode() - } - req.Header.Set("Referer", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("qbittorrent list: %d", resp.StatusCode) - } - - var qbList []qbitTorrentListItem - if err := json.NewDecoder(resp.Body).Decode(&qbList); err != nil { - return nil, err - } - return qbitTorrentListToInfo(qbList), nil -} - -// GetInfo 获取单个种子信息。 -func (a *QBitAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) { - list, err := a.List(ctx, "") - if err != nil { - return nil, err - } - for _, t := range list { - if t.Hash == hash { - return &t, nil - } - } - return nil, fmt.Errorf("torrent %s not found", hash) -} - -type qbitTorrentListItem struct { - Hash string `json:"hash"` - Name string `json:"name"` - State string `json:"state"` - Progress float32 `json:"progress"` - DLSpeed int64 `json:"dlspeed"` - UPSpeed int64 `json:"upspeed"` - NumSeeds int `json:"num_seeds"` - NumLeechs int `json:"num_leechs"` - Size int64 `json:"size"` - SavePath string `json:"save_path"` - AddedOn int64 `json:"added_on"` - Category string `json:"category"` - Tags string `json:"tags"` - ContentPath string `json:"content_path"` - CompletionOn int64 `json:"completion_on"` -} - -func qbitTorrentListToInfo(items []qbitTorrentListItem) []TorrentInfo { - result := make([]TorrentInfo, 0, len(items)) - for _, item := range items { - result = append(result, TorrentInfo{ - Hash: item.Hash, - Name: item.Name, - Size: item.Size, - Progress: normalizedTorrentProgress(float64(item.Progress)), - DLSpeed: item.DLSpeed, - UPSpeed: item.UPSpeed, - State: canonicalTorrentState(item.State, float64(item.Progress)), - SavePath: item.SavePath, - NumSeeds: item.NumSeeds, - NumLeechs: item.NumLeechs, - AddedOn: time.Unix(item.AddedOn, 0), - Category: item.Category, - Tags: item.Tags, - ContentPath: item.ContentPath, - CompletionOn: item.CompletionOn, - }) - } - return result -} diff --git a/internal/service/qbittorrent_bencode.go b/internal/service/qbittorrent_bencode.go deleted file mode 100644 index bc754fa..0000000 --- a/internal/service/qbittorrent_bencode.go +++ /dev/null @@ -1,91 +0,0 @@ -package service - -import ( - "crypto/sha1" // #nosec G505 -- BitTorrent v1 info-hash is SHA-1 by protocol. - "encoding/hex" -) - -func torrentInfoHash(data []byte) string { - start, end, ok := torrentInfoBounds(data) - if !ok { - return "" - } - sum := sha1.Sum(data[start:end]) // #nosec G401 -- BitTorrent v1 info-hash is SHA-1 by protocol, not a security hash. - return hex.EncodeToString(sum[:]) -} - -func torrentInfoBounds(data []byte) (int, int, bool) { - if len(data) == 0 || data[0] != 'd' { - return 0, 0, false - } - pos := 1 - for pos < len(data) && data[pos] != 'e' { - keyStart, keyEnd, next, ok := parseBencodeString(data, pos) - if !ok { - return 0, 0, false - } - valueStart := next - valueEnd, ok := bencodeValueEnd(data, valueStart) - if !ok { - return 0, 0, false - } - if string(data[keyStart:keyEnd]) == "info" { - return valueStart, valueEnd, true - } - pos = valueEnd - } - return 0, 0, false -} - -func parseBencodeString(data []byte, pos int) (int, int, int, bool) { - if pos >= len(data) || data[pos] < '0' || data[pos] > '9' { - return 0, 0, 0, false - } - length := 0 - for pos < len(data) && data[pos] >= '0' && data[pos] <= '9' { - length = length*10 + int(data[pos]-'0') - pos++ - } - if pos >= len(data) || data[pos] != ':' { - return 0, 0, 0, false - } - start := pos + 1 - end := start + length - if end > len(data) { - return 0, 0, 0, false - } - return start, end, end, true -} - -func bencodeValueEnd(data []byte, pos int) (int, bool) { - if pos >= len(data) { - return 0, false - } - switch data[pos] { - case 'i': - end := pos + 1 - for end < len(data) && data[end] != 'e' { - end++ - } - if end >= len(data) { - return 0, false - } - return end + 1, true - case 'l', 'd': - end := pos + 1 - for end < len(data) && data[end] != 'e' { - next, ok := bencodeValueEnd(data, end) - if !ok { - return 0, false - } - end = next - } - if end >= len(data) { - return 0, false - } - return end + 1, true - default: - _, _, next, ok := parseBencodeString(data, pos) - return next, ok - } -} diff --git a/internal/service/qbittorrent_convert.go b/internal/service/qbittorrent_convert.go deleted file mode 100644 index e65e827..0000000 --- a/internal/service/qbittorrent_convert.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -// QBitTorrentToInfo 将旧的 QBitTorrent 转换为新的 TorrentInfo。 -func QBitTorrentToInfo(q QBitTorrent) TorrentInfo { - return TorrentInfo{ - Hash: q.Hash, - Name: q.Name, - Size: q.Size, - Progress: float64(q.Progress), - DLSpeed: q.DLSpeed, - UPSpeed: q.UpSpeed, - State: q.State, - SavePath: q.SavePath, - NumSeeds: q.NumSeeds, - NumLeechs: q.NumLeech, - ContentPath: q.ContentPath, - CompletionOn: q.CompletionOn, - } -} - -// TorrentInfoToQBit 将 TorrentInfo 转换回旧的 QBitTorrent 格式(兼容性)。 -func TorrentInfoToQBit(t TorrentInfo) QBitTorrent { - return QBitTorrent{ - Hash: t.Hash, - ClientID: "", - Source: "", - Name: t.Name, - State: canonicalTorrentState(t.State, t.Progress), - Progress: float32(normalizedTorrentProgress(t.Progress)), - DLSpeed: t.DLSpeed, - UpSpeed: t.UPSpeed, - NumSeeds: t.NumSeeds, - NumLeech: t.NumLeechs, - Size: t.Size, - SavePath: t.SavePath, - Category: t.Category, - ContentPath: t.ContentPath, - CompletionOn: t.CompletionOn, - } -} diff --git a/internal/service/qbittorrent_login.go b/internal/service/qbittorrent_login.go deleted file mode 100644 index a55d378..0000000 --- a/internal/service/qbittorrent_login.go +++ /dev/null @@ -1,101 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strings" -) - -type qbitLoginVariant struct { - name string - referer bool - origin bool -} - -func qbitLogin(ctx context.Context, client *http.Client, baseURL, username, password string) error { - baseURL = strings.TrimSpace(baseURL) - if baseURL == "" { - return errors.New("qbittorrent host not configured") - } - var err error - baseURL, err = normalizeDownloadClientEndpoint("qbittorrent", baseURL) - if err != nil { - return err - } - var lastErr error - for _, variant := range []qbitLoginVariant{ - {name: "minimal"}, - {name: "referer", referer: true}, - {name: "referer-origin", referer: true, origin: true}, - } { - err := qbitLoginOnce(ctx, client, baseURL, username, password, variant) - if err == nil { - return nil - } - lastErr = err - if errors.Is(err, errQbitBadCredentials) { - return err - } - } - return lastErr -} - -var errQbitBadCredentials = errors.New("qbittorrent: 用户名/密码错误") - -func qbitLoginOnce(ctx context.Context, client *http.Client, baseURL, username, password string, variant qbitLoginVariant) error { - form := url.Values{} - form.Set("username", username) - form.Set("password", password) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if variant.referer { - req.Header.Set("Referer", baseURL) - } - if variant.origin { - req.Header.Set("Origin", baseURL) - } - - resp, err := client.Do(req) - if err != nil { - return qbitNetworkError(baseURL, err) - } - defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - text := strings.TrimSpace(string(raw)) - switch { - case resp.StatusCode == http.StatusOK && text == "Ok.": - return nil - case resp.StatusCode == http.StatusOK && text == "Ok": - return nil - case resp.StatusCode == http.StatusNoContent: - return nil - case resp.StatusCode == http.StatusOK && text == "Fails.": - return errQbitBadCredentials - case resp.StatusCode == http.StatusForbidden: - return fmt.Errorf("qbittorrent: 403 forbidden during %s login, body=%q; check qBittorrent WebUI bypass/auth settings for the container IP and host %s", variant.name, text, baseURL) - case resp.StatusCode >= 400: - return fmt.Errorf("qbittorrent login failed during %s login: status=%d body=%q", variant.name, resp.StatusCode, text) - default: - return fmt.Errorf("qbittorrent login unexpected response during %s login: status=%d body=%q", variant.name, resp.StatusCode, text) - } -} - -func qbitNetworkError(baseURL string, err error) error { - if err == nil { - return nil - } - var netErr net.Error - if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) || strings.Contains(err.Error(), "Client.Timeout exceeded") { - return fmt.Errorf("qbittorrent: 连接 %s 超时;容器内无法访问该地址。若 qBittorrent 运行在 NAS 宿主机上,请把下载器地址改为 http://host.docker.internal:端口 或 http://172.17.0.1:端口,并确认 docker-compose.yml 包含 extra_hosts: host.docker.internal:host-gateway: %w", baseURL, err) - } - return fmt.Errorf("qbittorrent: 连接 %s 失败:%w", baseURL, err) -} diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go deleted file mode 100644 index d9081cb..0000000 --- a/internal/service/qbittorrent_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package service - -import ( - "context" - "errors" - "io" - "mime/multipart" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" -) - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestQBitLoginUsesMinimalRequestFirst(t *testing.T) { - var loginAttempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - loginAttempts.Add(1) - if r.Header.Get("Origin") != "" || r.Header.Get("Referer") != "" { - http.Error(w, "unexpected csrf headers", http.StatusForbidden) - return - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - if err := client.Login(context.Background()); err != nil { - t.Fatalf("expected minimal login to succeed: %v", err) - } - if loginAttempts.Load() != 1 { - t.Fatalf("login attempts = %d, want 1", loginAttempts.Load()) - } -} - -func TestQBitLoginTimeoutSuggestsDockerHostAddress(t *testing.T) { - client := &http.Client{ - Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return nil, context.DeadlineExceeded - }), - } - - err := qbitLogin(context.Background(), client, "http://192.168.1.125:8085", "admin", "adminadmin") - if err == nil { - t.Fatal("expected timeout error") - } - msg := err.Error() - for _, want := range []string{"连接 http://192.168.1.125:8085 超时", "host.docker.internal", "172.17.0.1"} { - if !strings.Contains(msg, want) { - t.Fatalf("timeout hint %q missing %q", msg, want) - } - } -} - -func TestQBitLoginRetriesWithRefererWhenRequired(t *testing.T) { - var loginAttempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - loginAttempts.Add(1) - if r.Header.Get("Referer") == "" { - http.Error(w, "missing referer", http.StatusForbidden) - return - } - if r.Header.Get("Origin") != "" { - http.Error(w, "origin blocked", http.StatusForbidden) - return - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - jar, _ := cookiejar.New(nil) - httpClient := &http.Client{Jar: jar} - if err := qbitLogin(context.Background(), httpClient, server.URL, "admin", "adminadmin"); err != nil { - t.Fatalf("expected referer retry to succeed: %v", err) - } - if loginAttempts.Load() != 2 { - t.Fatalf("login attempts = %d, want 2", loginAttempts.Load()) - } -} - -func TestQBitLoginAcceptsNoContentFromNewerWebUI(t *testing.T) { - var loginAttempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - loginAttempts.Add(1) - if r.Header.Get("Referer") == "" || r.Header.Get("Origin") == "" { - http.Error(w, "csrf headers required", http.StatusForbidden) - return - } - w.WriteHeader(http.StatusNoContent) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - jar, _ := cookiejar.New(nil) - httpClient := &http.Client{Jar: jar} - if err := qbitLogin(context.Background(), httpClient, server.URL, "admin", "adminadmin"); err != nil { - t.Fatalf("expected 204 login response to succeed: %v", err) - } - if loginAttempts.Load() != 3 { - t.Fatalf("login attempts = %d, want 3", loginAttempts.Load()) - } -} - -func TestQBitAddTorrentRequiresVisibleNewTask(t *testing.T) { - oldAttempts := qbitAddVerifyAttempts - oldInterval := qbitAddVerifyInterval - qbitAddVerifyAttempts = 2 - qbitAddVerifyInterval = time.Millisecond - defer func() { - qbitAddVerifyAttempts = oldAttempts - qbitAddVerifyInterval = oldInterval - }() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte("[]")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - err := client.AddTorrent(context.Background(), server.URL+"/missing.torrent", "") - if err == nil { - t.Fatal("expected add to fail when no new torrent appears") - } - if !strings.Contains(err.Error(), "下载器未出现新任务") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestQBitAddTorrentUploadsFetchedTorrentFile(t *testing.T) { - var added atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/fixture.torrent": - w.Header().Set("Content-Type", "application/x-bittorrent") - _, _ = w.Write([]byte("d4:infod4:name7:fixtureee")) - case "/api/v2/torrents/add": - reader, err := r.MultipartReader() - if err != nil { - t.Errorf("expected multipart add request: %v", err) - http.Error(w, "bad multipart", http.StatusBadRequest) - return - } - if !multipartHasTorrentFile(reader) { - t.Error("expected qbit add request to upload torrent file") - http.Error(w, "missing file", http.StatusBadRequest) - return - } - added.Store(true) - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if added.Load() { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"fixture"}]`)) - return - } - _, _ = w.Write([]byte("[]")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - if err := client.AddTorrent(context.Background(), server.URL+"/fixture.torrent", ""); err != nil { - t.Fatalf("expected fetched torrent upload to succeed: %v", err) - } -} - -func TestQBitAddTorrentFileReturnsDedupForExistingInfoHash(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - hash := torrentInfoHash(torrentData) - if hash == "" { - t.Fatal("expected fixture info hash") - } - var addCalled atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - addCalled.Store(true) - _, _ = w.Write([]byte("Fails.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"fixture"}]`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); !errors.Is(err, ErrDownloadAlreadyExists) { - t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) - } - if addCalled.Load() { - t.Fatal("expected qbit add to be skipped for existing infohash") - } -} - -func multipartHasTorrentFile(reader *multipart.Reader) bool { - for { - part, err := reader.NextPart() - if err == io.EOF { - return false - } - if err != nil { - return false - } - if part.FormName() == "torrents" && part.FileName() != "" { - return true - } - } -} - -func TestQBitSetLocationPostsHashAndLocation(t *testing.T) { - var gotHash, gotLocation string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/setLocation": - if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", http.StatusBadRequest) - return - } - gotHash = r.PostFormValue("hashes") - gotLocation = r.PostFormValue("location") - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - if err := client.SetLocation(context.Background(), "abc123", "/data/media/Movie"); err != nil { - t.Fatalf("setLocation: %v", err) - } - if gotHash != "abc123" { - t.Fatalf("hashes = %q, want abc123", gotHash) - } - if gotLocation != "/data/media/Movie" { - t.Fatalf("location = %q, want /data/media/Movie", gotLocation) - } -} - -func TestQBitSetLocationSurfacesConflict(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/setLocation": - http.Error(w, "cannot write", http.StatusConflict) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - err := client.SetLocation(context.Background(), "abc123", "/data/media/Movie") - if err == nil { - t.Fatal("expected error on 409 conflict") - } - if !strings.Contains(err.Error(), "无法写入目标路径") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestQBitAdapterPauseResumeFallsBackToQBit52Actions(t *testing.T) { - var pauseCalled, stopCalled, resumeCalled, startCalled atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/pause": - pauseCalled.Add(1) - http.NotFound(w, r) - case "/api/v2/torrents/stop": - stopCalled.Add(1) - if r.Header.Get("Origin") != serverOrigin(r) { - t.Errorf("stop Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r)) - } - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/resume": - resumeCalled.Add(1) - http.NotFound(w, r) - case "/api/v2/torrents/start": - startCalled.Add(1) - if r.Header.Get("Origin") != serverOrigin(r) { - t.Errorf("start Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r)) - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - adapter := NewQBitAdapter() - if err := adapter.Initialize(context.Background(), DownloadClientConfig{Host: server.URL, Username: "admin", Password: "adminadmin"}); err != nil { - t.Fatalf("initialize: %v", err) - } - if err := adapter.Pause(context.Background(), "abc123"); err != nil { - t.Fatalf("pause: %v", err) - } - if err := adapter.Resume(context.Background(), "abc123"); err != nil { - t.Fatalf("resume: %v", err) - } - if pauseCalled.Load() != 1 || stopCalled.Load() != 1 || resumeCalled.Load() != 1 || startCalled.Load() != 1 { - t.Fatalf("calls pause=%d stop=%d resume=%d start=%d, want all 1", - pauseCalled.Load(), stopCalled.Load(), resumeCalled.Load(), startCalled.Load()) - } -} - -func TestQBitAdapterAddTorrentSendsOriginAndRejectsFailsBody(t *testing.T) { - var addCalled atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - addCalled.Add(1) - if r.Header.Get("Origin") != serverOrigin(r) { - t.Errorf("Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r)) - } - _, _ = w.Write([]byte("Fails.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - adapter := NewQBitAdapter() - if err := adapter.Initialize(context.Background(), DownloadClientConfig{Host: server.URL, Username: "admin", Password: "adminadmin"}); err != nil { - t.Fatalf("initialize: %v", err) - } - _, err := adapter.AddTorrent(context.Background(), "magnet:?xt=urn:btih:abc", "/downloads") - if err == nil || !strings.Contains(err.Error(), "rejected") { - t.Fatalf("expected rejected add error, got %v", err) - } - if addCalled.Load() != 1 { - t.Fatalf("add calls = %d, want 1", addCalled.Load()) - } -} - -func serverOrigin(r *http.Request) string { - return "http://" + r.Host -} diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 91cdffe..d10625c 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -100,12 +100,6 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { } case "transcode.video_bitrate", "transcoder.video_bitrate": cfg.Transcoder.VideoBitrate = value - case "license.server_url": - cfg.License.ServerURL = value - case "license.hmac_secret": - cfg.License.HMACSecret = value - case "license.public_key": - cfg.License.PublicKey = value } } diff --git a/internal/service/scanner.go b/internal/service/scanner.go index cca087b..a6e7128 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -11,11 +11,9 @@ package service import ( - "context" "errors" "strings" "sync" - "time" "go.uber.org/zap" @@ -60,26 +58,10 @@ type ScannerService struct { probe *FFprobeService scraper *ScraperService organizer *OrganizerService - storage *StorageConfigService cache *RuntimeCacheService - notify *NotifyChannelService imageProxy *ImageProxy - cloudScanMu sync.Mutex - cloudScans map[string]*cloudScanEntry - cloudSlots chan struct{} - cloudImagePrefetchOnce sync.Once - cloudImagePrefetchQueue chan cloudImagePrefetchTask - cloudImagePrefetchMu sync.Mutex - cloudImagePrefetching map[string]struct{} - cloudMediaProbeOnce sync.Once - cloudMediaProbeQueue chan cloudMediaProbeTask - cloudMediaProbeMu sync.Mutex - cloudMediaProbing map[string]struct{} - cloudMediaProbeBackoff map[string]time.Time - cloudMediaProbeWarnMu sync.Mutex - cloudMediaProbeLastWarn time.Time localMediaProbeOnce sync.Once localMediaProbeQueue chan localMediaProbeTask localMediaProbeMu sync.Mutex @@ -98,34 +80,12 @@ func NewScannerService( scraper *ScraperService, ) *ScannerService { return &ScannerService{ - cfg: cfg, log: log, repo: repo, hub: hub, - probe: probe, - scraper: scraper, - cloudScans: make(map[string]*cloudScanEntry), - cloudSlots: make(chan struct{}, 1), - cloudImagePrefetchQueue: make(chan cloudImagePrefetchTask, 256), - cloudImagePrefetching: make(map[string]struct{}), - cloudMediaProbeQueue: make(chan cloudMediaProbeTask, 1024), - cloudMediaProbing: make(map[string]struct{}), - cloudMediaProbeBackoff: make(map[string]time.Time), - localMediaProbeQueue: make(chan localMediaProbeTask, 1024), - localMediaProbing: make(map[string]struct{}), - localScans: make(map[string]struct{}), - } -} - -// SetStorageConfig wires cloud-disk storage access into the scanner. It is set -// after service construction because StorageConfigService depends on Crypto, -// while the scanner is needed earlier by watcher/download services. -func (s *ScannerService) SetStorageConfig(storage *StorageConfigService) { - s.storage = storage - if storage != nil && s.probe != nil { - s.cloudMediaProbeOnce.Do(func() { - workers := s.ffprobeWorkerCount() - for i := 0; i < workers; i++ { - go s.cloudMediaProbeWorker() - } - }) + cfg: cfg, log: log, repo: repo, hub: hub, + probe: probe, + scraper: scraper, + localMediaProbeQueue: make(chan localMediaProbeTask, 1024), + localMediaProbing: make(map[string]struct{}), + localScans: make(map[string]struct{}), } } @@ -141,22 +101,8 @@ func (s *ScannerService) SetRuntimeCache(cache *RuntimeCacheService) { } } -func (s *ScannerService) SetNotifyChannels(notify *NotifyChannelService) { - if s != nil { - s.notify = notify - } -} - -// SetImageProxy lets cloud scans warm sidecar poster/backdrop files into the -// local image cache. This keeps library opening fast without forcing the UI or -// Emby clients to resolve/download every cloud poster on demand. func (s *ScannerService) SetImageProxy(imageProxy *ImageProxy) { s.imageProxy = imageProxy - if imageProxy != nil { - s.cloudImagePrefetchOnce.Do(func() { - go s.cloudImagePrefetchWorker() - }) - } } // ScanResult summarises a scan run. @@ -173,7 +119,6 @@ type ScanResult struct { Errors []string `json:"errors,omitempty"` } -var ErrCloudScanAlreadyRunning = errors.New("cloud scan already running") var ErrLocalScanAlreadyRunning = errors.New("local scan already running") const maxScanErrorDetails = 20 @@ -194,85 +139,10 @@ func addScanError(res *ScanResult, path string, err error) { res.Errors = append(res.Errors, msg) } -const maxCloudMediaProbeQueuePerScan = 256 - -const cloudMediaProbeFailureBackoff = 6 * time.Hour - -// cloudMediaProbeQueueFullBackoff 是探测队列饱和时给单个文件挂的短退避, -// 防止后续扫描轮次对同一批文件反复尝试入队。 -const cloudMediaProbeQueueFullBackoff = 30 * time.Minute - -// CloudScanStatus is the operator-facing state for long-running cloud scans. -type CloudScanStatus struct { - LibraryID string `json:"library_id"` - Provider string `json:"provider"` - Stage string `json:"stage"` - State string `json:"state"` - StartedAt time.Time `json:"started_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - FinishedAt time.Time `json:"finished_at,omitempty"` - Dirs int `json:"dirs"` - Discovered int `json:"discovered"` - Visited int `json:"visited"` - Added int `json:"added"` - Updated int `json:"updated"` - Skipped int `json:"skipped"` - Removed int64 `json:"removed"` - ErrorCount int `json:"error_count,omitempty"` - Errors []string `json:"errors,omitempty"` - Error string `json:"error,omitempty"` - ResumeHint string `json:"resume_hint,omitempty"` - Estimate string `json:"estimate_message,omitempty"` - FilesPerSecond float64 `json:"files_per_second,omitempty"` -} - -type cloudScanEntry struct { - status CloudScanStatus - cancel context.CancelFunc -} - -type cloudMediaProbeTask struct { - typ string - ref string - path string -} - type localMediaProbeTask struct { path string } -type existingCloudMedia struct { - LibraryID string - Title string - OriginalName string - EpisodeTitle string - SizeBytes int64 - DurationSec int - Width int - Height int - VideoCodec string - AudioCodec string - Container string - PosterURL string - BackdropURL string - STRMURL string - Overview string - Year int - ReleaseDate string - Rating float32 - TMDbID int - BangumiID int - DoubanID string - TheTVDBID string - SeasonNum int - EpisodeNum int - Genres string - Countries string - Languages string - NSFW bool - ScrapeStatus string -} - type existingLocalMedia struct { LibraryRootID string RelativePath string diff --git a/internal/service/scanner_cloud_115_test.go b/internal/service/scanner_cloud_115_test.go deleted file mode 100644 index a62d7eb..0000000 --- a/internal/service/scanner_cloud_115_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScan115CloudLibraryKeepsDisplayHierarchyAndSeasonCounts(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/files" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - switch r.URL.Query().Get("cid") { - case "100": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"cid":"s1","n":"Season 1","s":0}, - {"cid":"s2","n":"Season 2","s":0} - ]}`)) - case "s1": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"fid":"f101","n":"剑来 - S01E01.mkv","s":1001,"pc":"pick101"}, - {"fid":"f125","n":"剑来 - S01E25.mkv","s":1025,"pc":"pick125"} - ]}`)) - case "s2": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"fid":"f201","n":"剑来 - S02E01.mkv","s":2001,"pc":"pick201"}, - {"fid":"f204","n":"剑来 - S02E04.mkv","s":2004,"pc":"pick204"} - ]}`)) - default: - t.Fatalf("unexpected cid %q", r.URL.Query().Get("cid")) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "cloud115", - Config: map[string]any{ - "cookie": "UID=test", - "base": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "115 · 国漫 · 剑来", Path: BuildCloudLibraryPath("cloud115", "100", "动漫/国漫/剑来"), Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud115: %v", err) - } - if res.Added != 4 { - t.Fatalf("scan result = %#v, want added=4", res) - } - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 4 { - t.Fatalf("media rows = %d, want 4", len(rows)) - } - want := map[string][2]int{ - "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E01.mkv": {1, 1}, - "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E25.mkv": {1, 25}, - "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E01.mkv": {2, 1}, - "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E04.mkv": {2, 4}, - } - for _, row := range rows { - seasonEpisode, ok := want[row.Path] - if !ok { - t.Fatalf("unexpected path %q", row.Path) - } - if row.Title != "剑来" { - t.Fatalf("title = %q, want 剑来", row.Title) - } - if row.SeasonNum != seasonEpisode[0] || row.EpisodeNum != seasonEpisode[1] { - t.Fatalf("%s season/episode = %d/%d, want %d/%d", row.Path, row.SeasonNum, row.EpisodeNum, seasonEpisode[0], seasonEpisode[1]) - } - if !strings.Contains(row.STRMURL, "ref=pick") { - t.Fatalf("115 playback should keep pickcode ref, got %q", row.STRMURL) - } - } -} diff --git a/internal/service/scanner_cloud_artwork.go b/internal/service/scanner_cloud_artwork.go deleted file mode 100644 index 92360f7..0000000 --- a/internal/service/scanner_cloud_artwork.go +++ /dev/null @@ -1,158 +0,0 @@ -package service - -import ( - "context" - "net/url" - "strings" - "time" - - "go.uber.org/zap" -) - -type cloudImagePrefetchTask struct { - typ string - ref string - stableKey string -} - -func (s *ScannerService) cloudImagePrefetchWorker() { - for task := range s.cloudImagePrefetchQueue { - s.prefetchCloudImage(task) - } -} - -func (s *ScannerService) queueCloudArtworkPrefetch(raw string) { - if s == nil || s.storage == nil || s.imageProxy == nil { - return - } - typ, ref, ok := ParseCloudArtworkURL(raw) - if !ok { - return - } - stableKey := typ + ":" + ref - if s.imageProxy.CloudImageCached(stableKey) { - return - } - s.cloudImagePrefetchMu.Lock() - if _, ok := s.cloudImagePrefetching[stableKey]; ok { - s.cloudImagePrefetchMu.Unlock() - return - } - s.cloudImagePrefetching[stableKey] = struct{}{} - s.cloudImagePrefetchMu.Unlock() - - task := cloudImagePrefetchTask{typ: typ, ref: ref, stableKey: stableKey} - select { - case s.cloudImagePrefetchQueue <- task: - default: - s.cloudImagePrefetchMu.Lock() - delete(s.cloudImagePrefetching, stableKey) - s.cloudImagePrefetchMu.Unlock() - if s.log != nil { - s.log.Debug("cloud artwork prefetch queue full", zap.String("provider", typ), zap.String("ref", ref)) - } - } -} - -func (s *ScannerService) prefetchCloudImage(task cloudImagePrefetchTask) { - defer func() { - s.cloudImagePrefetchMu.Lock() - delete(s.cloudImagePrefetching, task.stableKey) - s.cloudImagePrefetchMu.Unlock() - }() - if s == nil || s.storage == nil || s.imageProxy == nil || s.imageProxy.CloudImageCached(task.stableKey) { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - link, err := s.storage.CloudResolve(ctx, task.typ, task.ref, "") - if err != nil { - if s.log != nil { - s.log.Debug("resolve cloud artwork for prefetch failed", zap.String("provider", task.typ), zap.String("ref", task.ref), zap.Error(err)) - } - return - } - if err := s.imageProxy.PrefetchCloudResolved(ctx, task.stableKey, link); err != nil && s.log != nil { - s.log.Debug("prefetch cloud artwork failed", zap.String("provider", task.typ), zap.String("ref", task.ref), zap.Error(err)) - } -} - -func (s *ScannerService) cacheCloudArtworkNow(ctx context.Context, raw string) { - if s == nil || s.storage == nil || s.imageProxy == nil { - return - } - typ, ref, ok := ParseCloudArtworkURL(raw) - if !ok { - return - } - stableKey := typ + ":" + ref - if s.imageProxy.CloudImageCached(stableKey) { - return - } - cacheCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - defer cancel() - link, err := s.storage.CloudResolve(cacheCtx, typ, ref, "") - if err != nil { - if s.log != nil { - s.log.Debug("resolve cloud artwork for priority cache failed", zap.String("provider", typ), zap.String("ref", ref), zap.Error(err)) - } - s.queueCloudArtworkPrefetch(raw) - return - } - if err := s.imageProxy.PrefetchCloudResolved(cacheCtx, stableKey, link); err != nil { - if s.log != nil { - s.log.Debug("priority cache cloud artwork failed", zap.String("provider", typ), zap.String("ref", ref), zap.Error(err)) - } - s.queueCloudArtworkPrefetch(raw) - } -} - -func (s *ScannerService) cacheCloudMetadataArtworkNow(ctx context.Context, meta *LocalMetadata) { - if meta == nil { - return - } - s.cacheCloudArtworkNow(ctx, meta.PosterURL) - s.cacheCloudArtworkNow(ctx, meta.BackdropURL) -} - -func ParseCloudArtworkURL(raw string) (string, string, bool) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return "", "", false - } - path := strings.Trim(u.Path, "/") - typ := "" - for _, prefix := range []string{"api/img/cloud/", "api/cloud/play/"} { - if strings.HasPrefix(strings.ToLower(path), prefix) { - typ = strings.TrimSpace(path[len(prefix):]) - break - } - } - if typ == "" { - return "", "", false - } - ref := strings.TrimSpace(u.Query().Get("ref")) - if typ == "" || ref == "" || !isCloudArtworkRef(ref) { - return "", "", false - } - return typ, ref, true -} - -func CloudArtworkURL(typ, ref string) string { - typ = strings.Trim(strings.ReplaceAll(strings.TrimSpace(typ), "\\", "/"), "/") - ref = strings.TrimSpace(ref) - if typ == "" || ref == "" { - return "" - } - return "/api/img/cloud/" + url.PathEscape(typ) + "?ref=" + url.QueryEscape(ref) -} - -func isCloudArtworkRef(ref string) bool { - ref = strings.ToLower(strings.TrimSpace(ref)) - for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} { - if strings.HasSuffix(ref, suffix) { - return true - } - } - return false -} diff --git a/internal/service/scanner_cloud_artwork_test.go b/internal/service/scanner_cloud_artwork_test.go deleted file mode 100644 index 5d8ee13..0000000 --- a/internal/service/scanner_cloud_artwork_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package service - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Movies" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Movie.mkvMovie.mkv4096 - /dav/Movies/Movie.nfoMovie.nfo128 - /dav/Movies/Movie.jpgMovie.jpg1024 -`)) - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Movie.nfo": - _, _ = w.Write([]byte(`Sidecar Movie2026`)) - case "/dav/Movies/Movie.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "Sidecar Movie" || media.Year != 2026 { - t.Fatalf("metadata not applied: %#v", media) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Movie.jpg") { - t.Fatal("file-level cloud poster should be cached locally during scan before media is exposed") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Movies" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Movie.mkvMovie.mkv4096 - /dav/Movies/Movie.nfoMovie.nfo256 - /dav/Movies/Artwork.Custom.tbnArtwork.Custom.tbn1024 - /dav/Movies/Scene.Still.pngScene.Still.png1024 -`)) - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Movie.nfo": - _, _ = w.Write([]byte(`NFO Custom ArtworkArtwork.Custom.tbnScene.Still.png?version=1`)) - case "/dav/Movies/Artwork.Custom.tbn": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - case "/dav/Movies/Scene.Still.png": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "NFO Custom Artwork" { - t.Fatalf("metadata title = %q", media.Title) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FArtwork.Custom.tbn" { - t.Fatalf("poster url = %q", media.PosterURL) - } - if media.BackdropURL != "/api/img/cloud/openlist?ref=%2FMovies%2FScene.Still.png" { - t.Fatalf("backdrop url = %q", media.BackdropURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Artwork.Custom.tbn") { - t.Fatal("NFO-referenced cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Anime/JianLai": - _, _ = w.Write([]byte(` - - /dav/Anime/JianLai/ - /dav/Anime/JianLai/tvshow.nfotvshow.nfo64 - /dav/Anime/JianLai/poster.jpgposter.jpg1024 - /dav/Anime/JianLai/Season1/Season1 -`)) - case "/dav/Anime/JianLai/Season1": - _, _ = w.Write([]byte(` - - /dav/Anime/JianLai/Season1/ - /dav/Anime/JianLai/Season1/JianLai.S01E01.mkvJianLai.S01E01.mkv2048 - /dav/Anime/JianLai/Season1/JianLai.S01E01.nfoJianLai.S01E01.nfo128 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Anime/JianLai/tvshow.nfo": - _, _ = w.Write([]byte(`剑来2024天地有剑气`)) - case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": - _, _ = w.Write([]byte(`剑来第一集11`)) - case "/dav/Anime/JianLai/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - // 单集名(episode 「第一集」)不得写入 OriginalName(整剧原名/分组键)。 - // tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。 - if media.Title != "剑来" || media.OriginalName != "" || media.Year != 2024 { - t.Fatalf("metadata not applied: %#v", media) - } - if media.SeasonNum != 1 || media.EpisodeNum != 1 { - t.Fatalf("episode numbers = %d/%d", media.SeasonNum, media.EpisodeNum) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { - t.Fatal("cloud poster should be cached locally during scan before media is exposed") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } - if media.ScrapeStatus != "matched" { - t.Fatalf("scrape status = %q", media.ScrapeStatus) - } -} - -func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Anime/JianLai": - _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> -<d:multistatus xmlns:d="DAV:"> - <d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> -</d:multistatus>`)) - case "/dav/Anime/JianLai/Season1": - _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> -<d:multistatus xmlns:d="DAV:"> - <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> -</d:multistatus>`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Anime/JianLai/tvshow.nfo": - _, _ = w.Write([]byte(`<tvshow><title>剑来2024天地有剑气296753`)) - case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": - _, _ = w.Write([]byte(`剑来第一集11`)) - case "/dav/Anime/JianLai/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - - mediaPath := "cloud://openlist/Anime/JianLai/Season1/JianLai.S01E01.mkv" - old := model.Media{ - LibraryID: lib.ID, - Title: "JianLai.S01E01", - Path: mediaPath, - SizeBytes: 2048, - Container: "mkv", - PosterURL: "https://image.tmdb.org/t/p/w500/old.jpg", - STRMURL: BuildRelativeCloudPlayURL("openlist", "/Anime/JianLai/Season1/JianLai.S01E01.mkv"), - ScrapeStatus: "no_match", - } - if err := repos.Media.Upsert(t.Context(), &old); err != nil { - t.Fatal(err) - } - - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Updated != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want updated=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil { - t.Fatal(err) - } - if media.Title != "剑来" || media.Year != 2024 || media.TMDbID != 296753 { - t.Fatalf("metadata not refreshed: %#v", media) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - if media.ScrapeStatus != "matched" { - t.Fatalf("scrape status = %q", media.ScrapeStatus) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { - t.Fatal("refreshed cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} diff --git a/internal/service/scanner_cloud_autocategory_test.go b/internal/service/scanner_cloud_autocategory_test.go deleted file mode 100644 index 67d4fac..0000000 --- a/internal/service/scanner_cloud_autocategory_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) { - empty := false - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if empty { - return nil, 0 - } - switch path { - case "/": - return []openListTestEntry{ - {Name: "电视剧", IsDir: true}, - {Name: "电影", IsDir: true}, - {Name: "国漫", IsDir: true}, - }, 3 - case "/电视剧": - return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1 - case "/电视剧/欧美剧": - return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1 - case "/电视剧/欧美剧/The Show": - return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1 - case "/电影": - return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1 - case "/电影/华语电影": - return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1 - case "/国漫": - return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1 - case "/国漫/剑来": - return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &root); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("scan root cloud: %v", err) - } - if res.Visited != 3 || res.Added != 3 { - t.Fatalf("scan result = %#v, want visited=3 added=3", res) - } - - libs, err := repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - byDisplayDir := map[string]model.Library{} - for _, lib := range libs { - if !CloudLibraryAutoCategory(lib) { - continue - } - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - t.Fatalf("auto category path did not parse: %q", lib.Path) - } - byDisplayDir[info.DisplayDir] = lib - } - wantTypes := map[string]string{ - "电视剧/欧美剧": "tv", - "电影/华语电影": "movie", - "动漫/国漫": "anime", - } - for dir, wantType := range wantTypes { - lib, ok := byDisplayDir[dir] - if !ok { - t.Fatalf("missing auto category library %q; got %#v", dir, byDisplayDir) - } - if lib.Type != wantType { - t.Fatalf("auto category %s type = %s, want %s", dir, lib.Type, wantType) - } - } - - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 3 { - t.Fatalf("media rows = %d, want 3", len(rows)) - } - wantLibraries := map[string]string{ - "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID, - "cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID, - "cloud://openlist/动漫/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID, - } - for _, row := range rows { - if row.LibraryID != wantLibraries[row.Path] { - t.Fatalf("%s library_id = %s, want %s", row.Path, row.LibraryID, wantLibraries[row.Path]) - } - } - - res, err = scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("rescan root cloud: %v", err) - } - if res.Added != 0 || res.Updated != 0 || res.Skipped != 3 { - t.Fatalf("rescan should skip unchanged auto-category rows, got %#v", res) - } - libs, err = repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - autoCount := 0 - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - autoCount++ - } - } - if autoCount != 3 { - t.Fatalf("auto category library count after rescan = %d, want 3", autoCount) - } - - empty = true - res, err = scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("empty rescan root cloud: %v", err) - } - if res.Removed != 3 { - t.Fatalf("removed = %d, want 3", res.Removed) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count after auto-category prune = %d, want 0", got) - } -} - -func TestScanRootCloudAutoCategoryAppendsExistingLibraryRoot(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: "电影", IsDir: true}}, 1 - case "/电影": - return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1 - case "/电影/华语电影": - return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - storage := newOpenListStorageForTest(t, repos, upstream.URL) - local := model.Library{Name: "华语电影", Path: "/media/电影/华语电影", Type: "movie", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ - Name: "华语电影", - Path: local.Path, - Enabled: true, - }}); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &root); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("scan root cloud: %v", err) - } - if res.Added != 1 { - t.Fatalf("added = %d, want 1", res.Added) - } - libs, err := repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - t.Fatalf("auto category should append to existing library, got extra library %#v", lib) - } - } - roots, err := repos.Library.ListRoots(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 2 { - t.Fatalf("roots = %#v, want local root plus cloud root", roots) - } - cloudRoot := roots[1] - if cloudRoot.Name != "华语电影" || !CloudLibraryAutoCategory(model.Library{Path: cloudRoot.Path}) { - t.Fatalf("cloud root = %#v, want auto-category 华语电影 root", cloudRoot) - } - info, ok := ParseCloudLibraryMount(cloudRoot.Path) - if !ok || info.DisplayDir != "电影/华语电影" || info.ScanDir != "电影/华语电影" { - t.Fatalf("cloud root mount = %#v, want display/scan 电影/华语电影", info) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", "cloud://openlist/电影/华语电影/Movie.2024.mkv").Error; err != nil { - t.Fatal(err) - } - if media.LibraryID != local.ID || media.LibraryRootID != cloudRoot.ID { - t.Fatalf("media placement = library %s root %s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, cloudRoot.ID) - } -} - -func TestScanRootCloudAutoCategoryPreservesFlatScanDir(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: "国漫", IsDir: true}}, 1 - case "/国漫": - return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1 - case "/国漫/剑来": - return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - storage := newOpenListStorageForTest(t, repos, upstream.URL) - local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "anime", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ - Name: "国漫", - Path: local.Path, - Enabled: true, - }}); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &root); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.SetStorageConfig(storage) - - if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil { - t.Fatalf("scan root cloud: %v", err) - } - roots, err := repos.Library.ListRoots(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 2 { - t.Fatalf("roots = %#v, want local root plus flat cloud root", roots) - } - cloudRoot := roots[1] - info, ok := ParseCloudLibraryMount(cloudRoot.Path) - if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" { - t.Fatalf("flat cloud root mount = %#v, want display 动漫/国漫 and scan 国漫", info) - } - res, err := scanner.ScanLibraryRoot(t.Context(), local.ID, cloudRoot.ID) - if err != nil { - t.Fatalf("scan flat cloud root: %v", err) - } - if res.Skipped != 1 && res.Updated != 1 { - t.Fatalf("flat cloud root rescan = %#v, want existing media refreshed/skipped", res) - } -} - -func TestScanRootCloudAutoCategoryMigratesExistingAutoLibrary(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: "电视剧", IsDir: true}}, 1 - case "/电视剧": - return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1 - case "/电视剧/欧美剧": - return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1 - case "/电视剧/欧美剧/The Show": - return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - storage := newOpenListStorageForTest(t, repos, upstream.URL) - local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ - Name: "欧美剧", - Path: local.Path, - Enabled: true, - }}); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - oldAuto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &oldAuto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - mediaPath := "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv" - if err := repos.DB.Create(&model.Media{LibraryID: oldAuto.ID, Title: "The Show", Path: mediaPath}).Error; err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.SetStorageConfig(storage) - - if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil { - t.Fatalf("scan root cloud: %v", err) - } - if old, err := repos.Library.FindByID(t.Context(), oldAuto.ID); err != nil || old != nil { - t.Fatalf("old auto library = %#v, err=%v; want removed", old, err) - } - roots, err := repos.Library.ListRoots(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 2 { - t.Fatalf("roots = %#v, want local root plus migrated cloud root", roots) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil { - t.Fatal(err) - } - if media.LibraryID != local.ID || media.LibraryRootID != roots[1].ID { - t.Fatalf("migrated media placement = %s/%s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, roots[1].ID) - } -} - -func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) { - var active int32 - var maxActive int32 - var releaseOnce sync.Once - release := make(chan struct{}) - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{ - {Name: "A", IsDir: true}, - {Name: "B", IsDir: true}, - }, 2 - case "/A", "/B": - cur := atomic.AddInt32(&active, 1) - defer atomic.AddInt32(&active, -1) - for { - prev := atomic.LoadInt32(&maxActive) - if cur <= prev || atomic.CompareAndSwapInt32(&maxActive, prev, cur) { - break - } - } - if cur >= 2 { - releaseOnce.Do(func() { close(release) }) - } - select { - case <-release: - case <-time.After(1500 * time.Millisecond): - t.Errorf("child directory requests were not concurrent") - return nil, 0 - } - id := strings.TrimPrefix(path, "/") - return []openListTestEntry{{Name: fmt.Sprintf("Movie.%s.mkv", id), Size: 123}}, 1 - default: - t.Errorf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db, err := gorm.Open(sqlite.Open("file:cloud_scan_concurrent?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - cfg := &config.Config{} - cfg.App.CloudScanMaxConcurrent = 2 - scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - - res, err := scanner.ScanLibrary(ctx, lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if got := atomic.LoadInt32(&maxActive); got < 2 { - t.Fatalf("max concurrent child lists = %d, want >= 2", got) - } - if res.Visited != 2 || res.Added != 2 { - t.Fatalf("scan result = %#v, want visited=2 added=2", res) - } -} - -func newOpenListStorageForTest(t *testing.T, repos *repository.Container, serverURL string) *StorageConfigService { - t.Helper() - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": serverURL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - return storage -} diff --git a/internal/service/scanner_cloud_candidates.go b/internal/service/scanner_cloud_candidates.go deleted file mode 100644 index 21b32e1..0000000 --- a/internal/service/scanner_cloud_candidates.go +++ /dev/null @@ -1,247 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudScanCandidateRequest struct { - provider string - rootDir string - rootDisplayDir string - autoCategoryRoot bool - progress *cloudScanProgressState - result *ScanResult -} - -func (s *ScannerService) collectCloudScanCandidates(ctx context.Context, lib *model.Library, req cloudScanCandidateRequest) ([]cloudCandidate, error) { - collector := newCloudScanCandidateCollector(s, ctx, lib, req) - return collector.collect() -} - -type cloudScanCandidateCollector struct { - scanner *ScannerService - ctx context.Context - lib *model.Library - req cloudScanCandidateRequest - - mu sync.Mutex - seenRefs map[string]struct{} - visitedDirs map[string]struct{} - candidates []cloudCandidate - candidateByKey map[string]int - - walkWG sync.WaitGroup - walkErr error - walkErrOnce sync.Once - listSlots chan struct{} -} - -func newCloudScanCandidateCollector(s *ScannerService, ctx context.Context, lib *model.Library, req cloudScanCandidateRequest) *cloudScanCandidateCollector { - return &cloudScanCandidateCollector{ - scanner: s, - ctx: ctx, - lib: lib, - req: req, - seenRefs: make(map[string]struct{}), - visitedDirs: map[string]struct{}{}, - candidates: make([]cloudCandidate, 0, 256), - candidateByKey: make(map[string]int), - listSlots: make(chan struct{}, s.cloudScanWorkerCount()), - } -} - -func (c *cloudScanCandidateCollector) collect() ([]cloudCandidate, error) { - c.walkWG.Add(1) - go func() { - _ = c.walk(c.req.rootDir, c.req.rootDisplayDir, nil) - }() - c.walkWG.Wait() - if c.walkErr != nil { - return nil, c.walkErr - } - if err := c.ctx.Err(); err != nil { - return nil, err - } - return c.candidates, nil -} - -func (c *cloudScanCandidateCollector) walk(dirID, displayDir string, inheritedMeta *LocalMetadata) error { - defer c.walkWG.Done() - if err := c.ctx.Err(); err != nil { - c.setWalkErr(err) - return err - } - if !c.markDirectoryVisited(dirID) { - return nil - } - release, err := c.acquireListSlot() - if err != nil { - c.setWalkErr(err) - return err - } - defer release() - - entries, err := c.scanner.storage.CloudList(c.ctx, c.req.provider, dirID) - if err != nil { - return c.handleListError(dirID, err) - } - c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markDirVisited()) - sidecars := newCloudSidecarSet(c.req.provider, entries) - dirMeta := c.scanner.cloudDirectoryMetadata(c.ctx, c.req.provider, displayDir, sidecars, inheritedMeta) - c.scanner.cacheCloudMetadataArtworkNow(c.ctx, dirMeta) - for _, entry := range entries { - if err := c.ctx.Err(); err != nil { - c.setWalkErr(err) - return err - } - if entry.IsDir { - c.queueChildDirectory(displayDir, entry.Name, entry.ID, dirMeta) - continue - } - c.addFileCandidate(displayDir, entry, sidecars, dirMeta) - } - return nil -} - -func (c *cloudScanCandidateCollector) markDirectoryVisited(dirID string) bool { - c.mu.Lock() - defer c.mu.Unlock() - if _, ok := c.visitedDirs[dirID]; ok { - return false - } - c.visitedDirs[dirID] = struct{}{} - return true -} - -func (c *cloudScanCandidateCollector) acquireListSlot() (func(), error) { - select { - case c.listSlots <- struct{}{}: - return func() { <-c.listSlots }, nil - case <-c.ctx.Done(): - return nil, c.ctx.Err() - } -} - -func (c *cloudScanCandidateCollector) handleListError(dirID string, err error) error { - if dirID != c.req.rootDir { - c.req.progress.addSkipped(c.req.result) - c.scanner.log.Warn("skip inaccessible cloud directory", - zap.String("library_id", c.lib.ID), - zap.String("provider", c.req.provider), - zap.String("dir", dirID), - zap.Error(err)) - return nil - } - c.setWalkErr(err) - return err -} - -func (c *cloudScanCandidateCollector) queueChildDirectory(displayDir, entryName, entryID string, dirMeta *LocalMetadata) { - if strings.TrimSpace(entryID) == "" { - return - } - c.walkWG.Add(1) - go func(childID, childDisplay string, childMeta *LocalMetadata) { - _ = c.walk(childID, childDisplay, childMeta) - }(entryID, joinCloudDisplayPath(displayDir, entryName), dirMeta) -} - -func (c *cloudScanCandidateCollector) addFileCandidate(displayDir string, entry cloud.FileEntry, sidecars cloudSidecarSet, dirMeta *LocalMetadata) { - ext := strings.ToLower(filepath.Ext(entry.Name)) - if _, ok := videoExtensions[ext]; !ok { - return - } - ref := cloudEntryRef(c.req.provider, entry.ID, entry.PickCode) - if ref == "" { - c.req.progress.addSkipped(c.req.result) - return - } - if !c.markRefSeen(ref) { - c.req.progress.addSkipped(c.req.result) - return - } - c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markFileDiscovered()) - displayPath := joinCloudDisplayPath(displayDir, entry.Name) - path := cloudMediaPath(c.req.provider, displayPath) - candidate := cloudCandidate{ - ref: ref, - name: entry.Name, - size: entry.Size, - path: path, - } - if c.req.autoCategoryRoot { - candidate.categoryDisplayDir, candidate.categoryScanDir = cloudAutoCategoryDirsForMediaPath(path) - if candidate.categoryDisplayDir != "" { - displayPath = canonicalCloudAutoCategoryMediaDisplayPath(displayPath, candidate.categoryDisplayDir, candidate.categoryScanDir) - candidate.path = cloudMediaPath(c.req.provider, displayPath) - } - } - localMeta := c.scanner.cloudFileMetadata(c.ctx, c.req.provider, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(c.lib)) - localMeta = c.scanner.enrichCloudMetadataFromExternalIDs(c.ctx, c.lib, candidate.path, localMeta) - if localMeta != nil { - c.scanner.cacheCloudMetadataArtworkNow(c.ctx, localMeta) - } - candidate.localMeta = localMeta - c.addCandidate(displayDir, entry, candidate) -} - -func canonicalCloudAutoCategoryMediaDisplayPath(displayPath, categoryDisplayDir, categoryScanDir string) string { - displayPath = strings.Trim(strings.TrimSpace(strings.ReplaceAll(displayPath, "\\", "/")), "/") - categoryDisplayDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryDisplayDir, "\\", "/")), "/") - categoryScanDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryScanDir, "\\", "/")), "/") - if displayPath == "" || categoryDisplayDir == "" || categoryScanDir == "" || displayPath == categoryDisplayDir || categoryDisplayDir == categoryScanDir { - return displayPath - } - if displayPath == categoryScanDir { - return categoryDisplayDir - } - prefix := strings.TrimRight(categoryScanDir, "/") + "/" - if strings.HasPrefix(displayPath, prefix) { - return strings.TrimRight(categoryDisplayDir, "/") + "/" + strings.TrimPrefix(displayPath, prefix) - } - return displayPath -} - -func (c *cloudScanCandidateCollector) markRefSeen(ref string) bool { - c.mu.Lock() - defer c.mu.Unlock() - if _, ok := c.seenRefs[ref]; ok { - return false - } - c.seenRefs[ref] = struct{}{} - return true -} - -func (c *cloudScanCandidateCollector) addCandidate(displayDir string, entry cloud.FileEntry, candidate cloudCandidate) { - key := cloudMediaDedupeKey(c.lib, displayDir, entry.Name, entry.Size) - c.mu.Lock() - defer c.mu.Unlock() - if key != "" { - if prevIndex, ok := c.candidateByKey[key]; ok { - if candidate.size > c.candidates[prevIndex].size { - c.candidates[prevIndex] = candidate - } - c.req.progress.addSkipped(c.req.result) - return - } - c.candidateByKey[key] = len(c.candidates) - } - c.candidates = append(c.candidates, candidate) -} - -func (c *cloudScanCandidateCollector) setWalkErr(err error) { - if err == nil { - return - } - c.walkErrOnce.Do(func() { - c.walkErr = err - }) -} diff --git a/internal/service/scanner_cloud_conflict.go b/internal/service/scanner_cloud_conflict.go deleted file mode 100644 index f712b5d..0000000 --- a/internal/service/scanner_cloud_conflict.go +++ /dev/null @@ -1,42 +0,0 @@ -package service - -import ( - "context" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) shadowedCloudLibrary(ctx context.Context, lib *model.Library) *CloudMountConflict { - libs, err := s.repo.Library.List(ctx) - if err != nil { - s.log.Warn("list libraries for cloud shadow check failed", zap.String("library_id", lib.ID), zap.Error(err)) - return nil - } - visible := FilterScannableCloudLibraries(ctx, s.repo, libs) - for _, kept := range visible { - if kept.ID == lib.ID { - return nil - } - } - current, ok := ParseCloudLibraryMount(lib.Path) - if ok { - currentKey, _ := cloudLibraryDisplayKey(*lib) - for _, kept := range visible { - info, ok := ParseCloudLibraryMount(kept.Path) - if !ok || info.Provider != current.Provider { - continue - } - keptKey, _ := cloudLibraryDisplayKey(kept) - exact := currentKey != "" && currentKey == keptKey - return &CloudMountConflict{ - Library: kept, - Exact: exact, - Nested: !exact, - ExistingIsAncestor: cloudMountAncestor(info.DisplayDir, current.DisplayDir), - } - } - } - return CloudLibraryShadowed(libs, *lib) -} diff --git a/internal/service/scanner_cloud_enrich.go b/internal/service/scanner_cloud_enrich.go deleted file mode 100644 index 77a5d1e..0000000 --- a/internal/service/scanner_cloud_enrich.go +++ /dev/null @@ -1,155 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) enrichCloudMetadataFromExternalIDs(ctx context.Context, lib *model.Library, path string, meta *LocalMetadata) *LocalMetadata { - if s == nil || s.scraper == nil || meta == nil || !cloudMetadataNeedsExternalEnrich(meta) { - return meta - } - localPoster, localBackdrop := cloudLocalArtworkURLs(meta) - media := &model.Media{ - LibraryID: "", - Title: firstNonEmpty(meta.Title, pathBaseSlash(path)), - Path: path, - Year: meta.Year, - TMDbID: meta.TMDbID, - BangumiID: meta.BangumiID, - DoubanID: meta.DoubanID, - TheTVDBID: meta.TheTVDBID, - SeasonNum: meta.SeasonNum, - EpisodeNum: meta.EpisodeNum, - PosterURL: meta.PosterURL, - BackdropURL: meta.BackdropURL, - } - if lib != nil { - media.LibraryID = lib.ID - } - enrichCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - match := s.scraper.matchFromMediaExternalIDs(enrichCtx, media, lib) - if match == nil { - return meta - } - s.scraper.applyFanartArtwork(enrichCtx, match) - mergeLocalMetadataIntoMatch(match, meta) - - enriched := cloneLocalMetadata(meta) - if enriched == nil { - enriched = &LocalMetadata{} - } - mergeMatchIntoLocalMetadata(enriched, match) - if localPoster != "" { - enriched.PosterURL = localPoster - enriched.HasArtwork = true - } - if localBackdrop != "" { - enriched.BackdropURL = localBackdrop - enriched.HasArtwork = true - } - enriched.PathHint = false - enriched.HasNFO = true - if enriched.PosterURL != "" || enriched.BackdropURL != "" { - enriched.HasArtwork = true - } - s.prefetchRemoteArtworkFromScan(ctx, enriched.PosterURL) - s.prefetchRemoteArtworkFromScan(ctx, enriched.BackdropURL) - return enriched -} - -func cloudMetadataNeedsExternalEnrich(meta *LocalMetadata) bool { - if meta == nil { - return false - } - hasExternalID := meta.TMDbID > 0 || meta.BangumiID > 0 || strings.TrimSpace(meta.DoubanID) != "" || strings.TrimSpace(meta.TheTVDBID) != "" - if !hasExternalID { - return false - } - return meta.PosterURL == "" || meta.BackdropURL == "" || meta.Overview == "" || meta.Title == "" -} - -func cloudLocalArtworkURLs(meta *LocalMetadata) (poster, backdrop string) { - if meta == nil || !meta.HasArtwork { - return "", "" - } - if _, _, ok := ParseCloudArtworkURL(meta.PosterURL); ok { - poster = meta.PosterURL - } - if _, _, ok := ParseCloudArtworkURL(meta.BackdropURL); ok { - backdrop = meta.BackdropURL - } - return poster, backdrop -} - -func mergeMatchIntoLocalMetadata(meta *LocalMetadata, match *Match) { - if meta == nil || match == nil { - return - } - if match.Title != "" { - meta.Title = match.Title - } - if match.OriginalName != "" { - meta.OriginalName = match.OriginalName - } - if match.Year > 0 { - meta.Year = match.Year - } - if match.ReleaseDate != "" { - meta.ReleaseDate = match.ReleaseDate - } - if match.Overview != "" { - meta.Overview = match.Overview - } - if match.Rating > 0 { - meta.Rating = match.Rating - } - if match.PosterURL != "" { - meta.PosterURL = match.PosterURL - } - if match.BackdropURL != "" { - meta.BackdropURL = match.BackdropURL - } - if match.TMDbID > 0 { - meta.TMDbID = match.TMDbID - } - if match.BangumiID > 0 { - meta.BangumiID = match.BangumiID - } - if match.DoubanID != "" { - meta.DoubanID = match.DoubanID - } - if match.TheTVDBID != "" { - meta.TheTVDBID = match.TheTVDBID - } - if len(match.Genres) > 0 { - meta.Genres = strings.Join(match.Genres, ",") - } - if len(match.Countries) > 0 { - meta.Countries = strings.Join(match.Countries, ",") - } - if len(match.Languages) > 0 { - meta.Languages = strings.Join(match.Languages, ",") - } - if match.NSFW { - meta.NSFW = true - } -} - -func (s *ScannerService) prefetchRemoteArtworkFromScan(ctx context.Context, raw string) { - if s == nil || s.imageProxy == nil || !isHTTPish(raw) { - return - } - fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) - err := s.imageProxy.PrefetchRemote(fetchCtx, raw) - cancel() - if err != nil && s.log != nil { - s.log.Debug("scan remote artwork prefetch failed", zap.String("url", raw), zap.Error(err)) - } -} diff --git a/internal/service/scanner_cloud_entry.go b/internal/service/scanner_cloud_entry.go deleted file mode 100644 index 6507624..0000000 --- a/internal/service/scanner_cloud_entry.go +++ /dev/null @@ -1,66 +0,0 @@ -package service - -import ( - "context" - "errors" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) scanMountedCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { - if IsDeprecatedNativeCloudProvider(mount.Provider) { - return &ScanResult{LibraryID: lib.ID, Skipped: 1}, nil - } - if CloudLibraryAutoCategory(*lib) { - res := &ScanResult{LibraryID: lib.ID, Skipped: 1} - s.log.Info("skip auto category cloud library scan", - zap.String("library_id", lib.ID), - zap.String("provider", mount.Provider)) - s.hub.Publish("scan", map[string]any{ - "library_id": lib.ID, - "finished": true, - "skipped": res.Skipped, - "cloud": true, - "auto_category": true, - }) - return res, nil - } - if shadow := s.shadowedCloudLibrary(ctx, lib); shadow != nil { - res := &ScanResult{LibraryID: lib.ID, Skipped: 1} - s.log.Warn("skip shadowed cloud library scan", - zap.String("library_id", lib.ID), - zap.String("shadowed_by", shadow.Library.ID), - zap.String("provider", mount.Provider)) - s.hub.Publish("scan", map[string]any{ - "library_id": lib.ID, - "finished": true, - "skipped": res.Skipped, - "cloud": true, - "shadowed": true, - }) - return res, nil - } - scanCtx, finish, err := s.beginCloudScan(ctx, lib, mount) - if err != nil { - if errors.Is(err, ErrCloudScanAlreadyRunning) { - return &ScanResult{LibraryID: lib.ID, Skipped: 1}, nil - } - return nil, err - } - release, err := s.acquireCloudScanSlot(scanCtx, lib.ID) - if err != nil { - res := &ScanResult{LibraryID: lib.ID} - if finish != nil { - finish(res, err) - } - return res, err - } - defer release() - res, err := s.scanCloudLibrary(scanCtx, lib, mount, autoScrape) - if finish != nil { - finish(res, err) - } - return res, err -} diff --git a/internal/service/scanner_cloud_ingest.go b/internal/service/scanner_cloud_ingest.go deleted file mode 100644 index 34bb7b2..0000000 --- a/internal/service/scanner_cloud_ingest.go +++ /dev/null @@ -1,125 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, rootID, typ, ref, path, name string, size int64, localMeta *LocalMetadata, existingMedia map[string]existingCloudMedia, writeBatch *localMediaWriteBatch, probeBudget *int, res *ScanResult) { - res.Visited++ - ext := strings.ToLower(filepath.Ext(name)) - title, year := CleanQueryWithRecognition(ctx, s.repo, name) - if title == "" { - title = strings.TrimSuffix(filepath.Base(name), ext) - } - if title == "" { - title = ref - } - title, year = preferISOParentScrapeIdentity(path, lib.Path, title, year) - parsedSeason, parsedEpisode := ParseEpisode(path) - if librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0 { - if seriesTitle, seriesYear := cloudSeriesTitleFromMediaPath(path); seriesTitle != "" { - title = seriesTitle - if seriesYear > 0 { - year = seriesYear - } - } - } - expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref) - m := &model.Media{ - LibraryID: lib.ID, - LibraryRootID: strings.TrimSpace(rootID), - Title: title, - Year: year, - Path: path, - SizeBytes: size, - Container: strings.TrimPrefix(ext, "."), - STRMURL: expectedSTRMURL, - ScrapeStatus: "pending", - SeasonNum: parsedSeason, - EpisodeNum: parsedEpisode, - } - if ext == ".strm" { - if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" { - m.STRMURL = targetURL - } else if err != nil { - s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err)) - } - } - if localMeta != nil { - applyLocalMetadata(m, localMeta) - s.queueCloudArtworkPrefetch(localMeta.PosterURL) - s.queueCloudArtworkPrefetch(localMeta.BackdropURL) - } - if _, hints := pathHintMetadata(path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0); hints.useful() { - if hints.TMDbID > 0 && m.TMDbID <= 0 { - m.TMDbID = hints.TMDbID - } - if hints.BangumiID > 0 && m.BangumiID <= 0 { - m.BangumiID = hints.BangumiID - } - if strings.TrimSpace(hints.DoubanID) != "" && strings.TrimSpace(m.DoubanID) == "" { - m.DoubanID = strings.TrimSpace(hints.DoubanID) - } - if strings.TrimSpace(hints.TheTVDBID) != "" && strings.TrimSpace(m.TheTVDBID) == "" { - m.TheTVDBID = strings.TrimSpace(hints.TheTVDBID) - } - } - isNewMedia := false - needsTrackProbe := true - if existingMedia != nil { - existing, exists := existingMedia[path] - isNewMedia = !exists - needsTrackProbe = !exists || cloudTrackMetadataMissing(existing) - if exists && existing.LibraryID == lib.ID && existing.SizeBytes == size && existing.STRMURL == expectedSTRMURL && !cloudMetadataNeedsRefresh(existing, localMeta) && !cloudDerivedMetadataNeedsRefresh(existing, m) { - if needsTrackProbe && mediaExtensionSupportsProbe(ext) { - s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) - } - res.Skipped++ - return - } - } else { - isNewMedia = !s.mediaPathExists(ctx, path) - } - if localMeta != nil { - res.LocalMetadata++ - } - if isNewMedia && writeBatch != nil { - var after func() - if needsTrackProbe && mediaExtensionSupportsProbe(ext) { - after = func() { - s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) - } - } - writeBatch.AddWithAfter(path, m, after) - return - } - if err := s.repo.Media.Upsert(ctx, m); err != nil { - addScanError(res, path, err) - s.log.Warn("upsert cloud media failed", zap.String("path", path), zap.Error(err)) - return - } - if needsTrackProbe && mediaExtensionSupportsProbe(ext) { - s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) - } - if isNewMedia { - res.Added++ - } else { - res.Updated++ - } - if s.hub != nil && (res.Visited == 1 || res.Visited%100 == 0) { - s.hub.Publish("scan", map[string]any{ - "library_id": lib.ID, - "path": path, - "visited": res.Visited, - "added": res.Added, - "updated": res.Updated, - "cloud": true, - }) - } -} diff --git a/internal/service/scanner_cloud_jobs.go b/internal/service/scanner_cloud_jobs.go deleted file mode 100644 index 8e99ce4..0000000 --- a/internal/service/scanner_cloud_jobs.go +++ /dev/null @@ -1,151 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func (s *ScannerService) StartCloudLibraryScan(libraryID string, autoScrape bool) (CloudScanStatus, bool, error) { - if s == nil { - return CloudScanStatus{}, false, errors.New("scanner unavailable") - } - lib, err := s.repo.Library.FindByID(context.Background(), libraryID) - if err != nil { - return CloudScanStatus{}, false, err - } - if lib == nil { - return CloudScanStatus{}, false, errors.New("library not found") - } - mount, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return CloudScanStatus{}, false, errors.New("library is not a cloud mount") - } - if IsDeprecatedNativeCloudProvider(mount.Provider) { - return CloudScanStatus{}, false, fmt.Errorf("cloud provider %q is deprecated; use OpenList or CloudDrive2 bridge", mount.Provider) - } - s.cloudScanMu.Lock() - if entry := s.cloudScans[libraryID]; cloudScanBlocksBegin(entry) { - status := entry.status - s.cloudScanMu.Unlock() - return status, false, nil - } - s.cloudScanMu.Unlock() - - go func() { - ctx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), s.repo, 24*time.Hour)) - defer cancel() - if autoScrape { - _, err = s.ScanLibrary(ctx, libraryID) - } else { - _, err = s.ScanLibraryWithoutAutoScrape(ctx, libraryID) - } - if err != nil && !errors.Is(err, ErrCloudScanAlreadyRunning) && s.log != nil { - s.log.Warn("cloud library background scan failed", zap.String("library_id", libraryID), zap.Error(err)) - } - }() - return newCloudScanEntry(libraryID, mount.Provider, nil).status.withQueuedState(), true, nil -} - -func (status CloudScanStatus) withQueuedState() CloudScanStatus { - status.Stage = "queued" - status.State = "queued" - return status -} - -func cloudScanContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if timeout <= 0 { - return context.WithCancel(parent) - } - return context.WithTimeout(parent, timeout) -} - -func cloudScanTimeout(ctx context.Context, repo *repository.Container, fallback time.Duration) time.Duration { - if repo == nil || repo.Setting == nil { - return fallback - } - value, err := repo.Setting.Get(ctx, "cloud.scan_timeout_hours") - if err != nil || strings.TrimSpace(value) == "" { - return fallback - } - hours := parseIntSettingDefault(strings.TrimSpace(value), int(fallback/time.Hour)) - if hours <= 0 { - return 0 - } - return time.Duration(hours) * time.Hour -} - -func (s *ScannerService) StartAllCloudLibraryScans() ([]CloudScanStatus, error) { - if s == nil { - return nil, errors.New("scanner unavailable") - } - libs, err := s.repo.Library.List(context.Background()) - if err != nil { - return nil, err - } - libs = FilterScannableCloudLibraries(context.Background(), s.repo, libs) - statuses := make([]CloudScanStatus, 0, len(libs)) - queue := make([]string, 0, len(libs)) - for _, lib := range libs { - if !lib.Enabled { - continue - } - mount, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - continue - } - status, queued := s.queueCloudLibraryScan(lib, mount) - if queued { - queue = append(queue, lib.ID) - } - statuses = append(statuses, status) - } - if len(queue) > 0 { - go s.runQueuedCloudLibraryScans(queue) - } - return statuses, nil -} - -func (s *ScannerService) queueCloudLibraryScan(lib model.Library, mount CloudMountInfo) (CloudScanStatus, bool) { - status := newCloudScanEntry(lib.ID, mount.Provider, nil).status.withQueuedState() - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - if s.cloudScans == nil { - s.cloudScans = make(map[string]*cloudScanEntry) - } - if entry := s.cloudScans[lib.ID]; cloudScanActive(entry) { - return entry.status, false - } - s.cloudScans[lib.ID] = &cloudScanEntry{status: status} - return status, true -} - -func (s *ScannerService) runQueuedCloudLibraryScans(libraryIDs []string) { - ctx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), s.repo, 24*time.Hour)) - defer cancel() - for _, libraryID := range libraryIDs { - if ctx.Err() != nil { - return - } - if s.cloudScanWasCanceled(libraryID) { - continue - } - if _, err := s.ScanLibrary(ctx, libraryID); err != nil && !errors.Is(err, ErrCloudScanAlreadyRunning) && !errors.Is(err, context.Canceled) && s.log != nil { - s.log.Warn("cloud library queued scan failed", zap.String("library_id", libraryID), zap.Error(err)) - } - } -} - -func (s *ScannerService) cloudScanWasCanceled(libraryID string) bool { - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - entry := s.cloudScans[libraryID] - return entry != nil && entry.status.State == "canceled" -} diff --git a/internal/service/scanner_cloud_metadata_test.go b/internal/service/scanner_cloud_metadata_test.go deleted file mode 100644 index f1d7bdb..0000000 --- a/internal/service/scanner_cloud_metadata_test.go +++ /dev/null @@ -1,486 +0,0 @@ -package service - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Links" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - - /dav/Links/ - - - - /dav/Links/Movie.strm - Movie.strm32 - -`)) - case http.MethodGet: - if r.URL.Path != "/dav/Links/Movie.strm" { - t.Fatalf("unexpected get path %s", r.URL.Path) - } - _, _ = w.Write([]byte("https://cdn.example.com/Movie.mkv\n")) - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Links", Path: "cloud://openlist/Links", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 { - t.Fatalf("scan result = %#v, want added=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Path != "cloud://openlist/Links/Movie.strm" { - t.Fatalf("path = %q", media.Path) - } - if media.STRMURL != "https://cdn.example.com/Movie.mkv" { - t.Fatalf("strm target = %q", media.STRMURL) - } -} - -func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Action Movie (2025) {tmdb-1197306}/Action Movie (2025) {tmdb-1197306} -`)) - case "/dav/Movies/Action Movie (2025) {tmdb-1197306}": - _, _ = w.Write([]byte(` - - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/ - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/Action%20Movie%20(2025)%20-%202160p.WEB-DL.mkvAction Movie (2025) - 2160p.WEB-DL.mkv4096 - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/movie.nfomovie.nfo128 - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/action%20movie%20(2025)-poster.jpgaction movie (2025)-poster.jpg1024 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Action Movie (2025) {tmdb-1197306}/movie.nfo": - _, _ = w.Write([]byte(`Action Movie20251197306`)) - case "/dav/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "Action Movie" || media.Year != 2025 || media.TMDbID != 1197306 { - t.Fatalf("movie.nfo metadata not applied: %#v", media) - } - wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2FAction+Movie+%282025%29+%7Btmdb-1197306%7D%2Faction+movie+%282025%29-poster.jpg" - if media.PosterURL != wantPoster { - t.Fatalf("poster url = %q, want %q", media.PosterURL, wantPoster) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg") { - t.Fatal("clean-title cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestScanCloudLibraryReadsRemoteJSONMetadataAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar Movie (2026) {tmdb-12345} -`)) - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}": - _, _ = w.Write([]byte(` - - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/ - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar%20Movie%20(2026).mkvSidecar Movie (2026).mkv4096 - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar%20Movie%20(2026)-mediainfo.jsonSidecar Movie (2026)-mediainfo.json256 - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/poster.jpgposter.jpg1024 - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/backdrop.jpgbackdrop.jpg1024 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/Sidecar Movie (2026)-mediainfo.json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"title":"JSON Sidecar Movie","year":2026,"tmdb_id":12345,"overview":"metadata from cloud json","poster":"poster.jpg","backdrop":"backdrop.jpg","genres":["Action","Drama"]}`)) - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/backdrop.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "JSON Sidecar Movie" || media.Year != 2026 || media.TMDbID != 12345 || media.ScrapeStatus != "matched" { - t.Fatalf("json metadata not applied: %#v", media) - } - wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2FSidecar+Movie+%282026%29+%7Btmdb-12345%7D%2Fposter.jpg" - if media.PosterURL != wantPoster { - t.Fatalf("poster url = %q, want %q", media.PosterURL, wantPoster) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg") { - t.Fatal("JSON cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestCloudEpisodeJSONDoesNotPolluteSeriesIdentity(t *testing.T) { - local, _ := metadataFromCloudJSON([]byte(`{ - "title": "九龙拉棺", - "episode": 1, - "tmdb_id": 4375419, - "original_title": "Burial", - "overview": "本集简介", - "poster": "episode-poster.jpg", - "genres": ["动画"] - }`)) - if local == nil { - t.Fatal("episode json metadata was not parsed") - } - dst := &LocalMetadata{Title: "遮天", Year: 2023, SeasonNum: 1} - got := mergeCloudEpisodeMetadata(dst, local) - if got.Title != "遮天" || got.OriginalName != "" || got.TMDbID != 0 { - t.Fatalf("episode json polluted series identity: %+v", got) - } - if got.EpisodeTitle != "九龙拉棺" || got.Overview != "本集简介" || got.SeasonNum != 1 || got.EpisodeNum != 1 { - t.Fatalf("episode json fields not preserved: %+v", got) - } - if got.Genres != "动画" { - t.Fatalf("episode json taxonomy should fill empty series taxonomy, got %q", got.Genres) - } -} - -func TestScanCloudLibraryEnrichesPathHintTMDbArtwork(t *testing.T) { - tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/movie/755679" { - t.Fatalf("unexpected tmdb path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "id": 755679, - "title": "速度与激情11", - "original_title": "Fast X: Part 2", - "overview": "Exact metadata by TMDb ID", - "poster_path": "/poster-fast11.jpg", - "backdrop_path": "/backdrop-fast11.jpg", - "release_date": "2028-04-07", - "vote_average": 7.2, - "genres": [{"name":"Action"}], - "production_countries": [{"iso_3166_1":"US"}], - "spoken_languages": [{"iso_639_1":"en"}] - }`)) - })) - defer tmdb.Close() - - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/速度与激情11 (2028) {tmdb-755679} -`)) - case "/dav/Movies/速度与激情11 (2028) {tmdb-755679}": - _, _ = w.Write([]byte(` - - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028).mkv速度与激情11 (2028).mkv4096 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}, &model.APIConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = tmdb.URL - cfg.Secrets.TMDbImageProxy = "https://image.tmdb.org/t/p" - scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) - scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, scraper) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.ScrapeStatus != "matched" || media.TMDbID != 755679 || media.PosterURL == "" || media.BackdropURL == "" || media.Overview == "" { - t.Fatalf("path-hint tmdb metadata not enriched: %#v", media) - } - if media.PosterURL != "https://image.tmdb.org/t/p/w500/poster-fast11.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } -} - -func TestScanCloudLibraryKeepsCloudArtworkWhenEnrichingPathHint(t *testing.T) { - tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/movie/755679" { - t.Fatalf("unexpected tmdb path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "id": 755679, - "title": "速度与激情11", - "overview": "Exact metadata by TMDb ID", - "poster_path": "/remote-poster.jpg", - "backdrop_path": "/remote-backdrop.jpg", - "release_date": "2028-04-07" - }`)) - })) - defer tmdb.Close() - - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/速度与激情11 (2028) {tmdb-755679} -`)) - case "/dav/Movies/速度与激情11 (2028) {tmdb-755679}": - _, _ = w.Write([]byte(` - - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028).mkv速度与激情11 (2028).mkv4096 - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/poster.jpgposter.jpg1024 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - if r.URL.Path != "/dav/Movies/速度与激情11 (2028) {tmdb-755679}/poster.jpg" { - t.Fatalf("unexpected get path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}, &model.APIConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = tmdb.URL - cfg.Secrets.TMDbImageProxy = "https://image.tmdb.org/t/p" - scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) - scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, scraper) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2F%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511+%282028%29+%7Btmdb-755679%7D%2Fposter.jpg" - if media.PosterURL != wantPoster { - t.Fatalf("poster url = %q, want local cloud poster %q", media.PosterURL, wantPoster) - } - if media.BackdropURL != "https://image.tmdb.org/t/p/w1280/remote-backdrop.jpg" || media.Overview == "" { - t.Fatalf("external enrichment should still fill missing fields: %#v", media) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/速度与激情11 (2028) {tmdb-755679}/poster.jpg") { - t.Fatal("local cloud poster should be cached during enriched scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} diff --git a/internal/service/scanner_cloud_openlist_test.go b/internal/service/scanner_cloud_openlist_test.go deleted file mode 100644 index 71cc5b8..0000000 --- a/internal/service/scanner_cloud_openlist_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package service - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) { - empty := false - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if empty { - return nil, 0 - } - switch path { - case "/": - return []openListTestEntry{ - {Name: "Movies", IsDir: true}, - {Name: "Root.Movie.2024.mkv", Size: 123}, - }, 2 - case "/Movies": - return []openListTestEntry{ - {Name: "Nested.Show.S01E02.mp4", Size: 456}, - }, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Visited != 2 || res.Added != 2 { - t.Fatalf("scan result = %#v, want visited=2 added=2", res) - } - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 2 { - t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows) - } - if rows[0].Path != "cloud://openlist/Movies/Nested.Show.S01E02.mp4" || !strings.Contains(rows[0].STRMURL, "ref=%2FMovies%2FNested.Show.S01E02.mp4") { - t.Fatalf("nested media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) - } - if rows[0].SeasonNum != 1 || rows[0].EpisodeNum != 2 { - t.Fatalf("nested episode metadata wrong: %#v", rows[0]) - } - if rows[1].Path != "cloud://openlist/Root.Movie.2024.mkv" || rows[1].STRMURL != "/api/cloud/play/openlist?ref=%2FRoot.Movie.2024.mkv" { - t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) - } - - res, err = scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("rescan same cloud: %v", err) - } - if res.Added != 0 || res.Updated != 0 || res.Skipped != 2 { - t.Fatalf("same cloud rescan should skip unchanged rows, got %#v", res) - } - - empty = true - res, err = scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("rescan cloud: %v", err) - } - if res.Removed != 2 { - t.Fatalf("removed = %d, want 2", res.Removed) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count after prune = %d, want 0", got) - } - var allRows int64 - if err := repos.DB.Unscoped().Model(&model.Media{}).Count(&allRows).Error; err != nil { - t.Fatal(err) - } - if allRows != 0 { - t.Fatalf("unscoped media count after cloud prune = %d, want 0", allRows) - } -} - -func TestScanOpenListCloudLibraryUsesAPIPaginationBeyondFirstPage(t *testing.T) { - const totalFiles = 125 - requestedPages := map[int]bool{} - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - if r.Method != http.MethodPost { - t.Fatalf("unexpected method %s", r.Method) - } - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("missing openlist token: %q", r.Header.Get("Authorization")) - } - var in struct { - Path string `json:"path"` - Page int `json:"page"` - PerPage int `json:"per_page"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - t.Fatalf("decode request: %v", err) - } - if in.Path != "/Movies" { - t.Fatalf("path = %q, want /Movies", in.Path) - } - if in.PerPage <= 100 { - t.Fatalf("per_page = %d, want API pagination larger than legacy 100", in.PerPage) - } - requestedPages[in.Page] = true - effectivePageSize := in.PerPage - if effectivePageSize > 100 { - effectivePageSize = 100 - } - start := (in.Page - 1) * effectivePageSize - content := []map[string]any{} - for idx := start; idx < totalFiles && idx < start+effectivePageSize; idx++ { - content = append(content, map[string]any{ - "name": fmt.Sprintf("Movie.%03d.mkv", idx+1), - "size": int64(1024 + idx), - "is_dir": false, - }) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "message": "success", - "data": map[string]any{ - "content": content, - "total": totalFiles, - }, - }) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: BuildCloudLibraryPath("openlist", "/Movies", "/Movies"), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan openlist: %v", err) - } - if res.Added != totalFiles { - t.Fatalf("scan result = %#v, want added=%d", res, totalFiles) - } - if got := countMedia(t, repos); got != totalFiles { - t.Fatalf("media count = %d, want %d", got, totalFiles) - } - if !requestedPages[1] || !requestedPages[2] { - t.Fatalf("expected pagination beyond the first 100 entries, got pages %#v", requestedPages) - } -} - -func TestScanCloudLibraryQueuesMissingExistingTrackMetadataBeforeNewFiles(t *testing.T) { - const newFiles = maxCloudMediaProbeQueuePerScan + 5 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if path != "/" { - t.Fatalf("unexpected openlist path %q", path) - } - entries := make([]openListTestEntry, 0, newFiles+1) - for i := 0; i < newFiles; i++ { - entries = append(entries, openListTestEntry{Name: fmt.Sprintf("New.Movie.%02d.mkv", i), Size: int64(1000 + i)}) - } - entries = append(entries, openListTestEntry{Name: "Existing.Show.S01E01.mkv", Size: 2048}) - return entries, len(entries) - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - existingPath := "cloud://openlist/Existing.Show.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: lib.ID, - Title: "Existing Show", - Path: existingPath, - SizeBytes: 2048, - Container: "mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FExisting.Show.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), NewFFprobeService(&config.Config{}, log), nil) - scanner.storage = storage - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != newFiles || res.Skipped != 1 { - t.Fatalf("scan result = %#v, want new files added and existing skipped", res) - } - foundExistingProbe := false - for { - select { - case task := <-scanner.cloudMediaProbeQueue: - if task.path == existingPath { - foundExistingProbe = true - } - default: - if !foundExistingProbe { - t.Fatal("existing media missing track metadata did not receive probe budget before new files") - } - return - } - } -} - -func TestScanCloudLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) { - const showDir = "Hntv Spring Festival Gala S01e (2026)" - const seasonDir = "Season 1" - const name = "Hntv Spring Festival Gala S01e - S01E202-DD5.QHstudIo.6.4K - 第 202 集.ts" - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: showDir, IsDir: true}}, 1 - case "/" + showDir: - return []openListTestEntry{{Name: seasonDir, IsDir: true}}, 1 - case "/" + showDir + "/" + seasonDir: - return []openListTestEntry{{Name: name, Size: 1}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 综艺", Path: "cloud://openlist", Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - ref := "/" + showDir + "/" + seasonDir + "/" + name - path := "cloud://openlist/" + showDir + "/" + seasonDir + "/" + name - if err := repos.DB.Create(&model.Media{ - LibraryID: lib.ID, - Title: "hntv spring festival gala s01e", - Path: path, - SizeBytes: 1, - Container: "ts", - STRMURL: BuildRelativeCloudPlayURL("openlist", ref), - ScrapeStatus: "no_match", - }).Error; err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Updated != 1 || res.Skipped != 0 { - t.Fatalf("scan result updated=%d skipped=%d, want 1/0: %#v", res.Updated, res.Skipped, res) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if media.Title != "hntv spring festival gala" || media.SeasonNum != 1 || media.EpisodeNum != 202 || media.ScrapeStatus != "pending" { - t.Fatalf("stale cloud row was not refreshed: title=%q s=%d e=%d status=%q", media.Title, media.SeasonNum, media.EpisodeNum, media.ScrapeStatus) - } -} diff --git a/internal/service/scanner_cloud_paths.go b/internal/service/scanner_cloud_paths.go deleted file mode 100644 index 556671f..0000000 --- a/internal/service/scanner_cloud_paths.go +++ /dev/null @@ -1,61 +0,0 @@ -package service - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func parseCloudLibraryPath(raw string) (typ, dirID string, ok bool) { - info, ok := ParseCloudLibraryMount(raw) - if !ok { - return "", "", false - } - return info.Provider, info.ScanDir, true -} - -func cloudEntryRef(typ, id, pickCode string) string { - if typ == "cloud115" && strings.TrimSpace(pickCode) != "" { - return strings.TrimSpace(pickCode) - } - return strings.TrimSpace(id) -} - -func cloudMediaPath(typ, ref string) string { - return "cloud://" + strings.TrimSpace(typ) + "/" + strings.TrimLeft(strings.TrimSpace(ref), "/") -} - -func cloudMediaDedupeKey(lib *model.Library, dirID, name string, size int64) string { - base := strings.TrimSpace(strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))) - if base == "" { - return "" - } - season, episode := ParseEpisode(name) - title, year := CleanQuery(name) - title = normalizeCloudDedupeText(title) - if (season > 0 || episode > 0) && title != "" { - return fmt.Sprintf("episode:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), title, year, season, episode) - } - if (season > 0 || episode > 0) && title == "" { - return fmt.Sprintf("episode-dir:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), normalizeCloudDedupeText(dirID), season, episode, size) - } - return fmt.Sprintf("file:%s:%d", normalizeCloudDedupeText(base), size) -} - -func normalizeCloudDedupeText(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "" - } - fields := strings.FieldsFunc(value, func(r rune) bool { - switch r { - case '.', '_', '-', ' ', '\t', '/', '\\', '[', ']', '(', ')': - return true - default: - return false - } - }) - return strings.Join(fields, " ") -} diff --git a/internal/service/scanner_cloud_probe.go b/internal/service/scanner_cloud_probe.go deleted file mode 100644 index df0f1c9..0000000 --- a/internal/service/scanner_cloud_probe.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) probeCloudMediaAsync(task cloudMediaProbeTask) { - defer func() { - s.cloudMediaProbeMu.Lock() - delete(s.cloudMediaProbing, task.path) - s.cloudMediaProbeMu.Unlock() - }() - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - probe, err := s.probeCloudFileMetadata(ctx, task.typ, task.ref) - if err != nil { - if s.log != nil { - s.log.Debug("cloud media async probe failed", zap.String("provider", task.typ), zap.String("path", task.path), zap.Error(err)) - } - s.cloudMediaProbeMu.Lock() - if s.cloudMediaProbeBackoff == nil { - s.cloudMediaProbeBackoff = make(map[string]time.Time) - } - s.cloudMediaProbeBackoff[task.path] = time.Now().Add(cloudMediaProbeFailureBackoff) - s.cloudMediaProbeMu.Unlock() - return - } - updates := probeResultUpdates(probe) - if len(updates) == 0 { - return - } - if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", task.path).Updates(updates).Error; err != nil { - if s.log != nil { - s.log.Debug("update cloud media track metadata failed", zap.String("path", task.path), zap.Error(err)) - } - return - } - s.cloudMediaProbeMu.Lock() - delete(s.cloudMediaProbeBackoff, task.path) - s.cloudMediaProbeMu.Unlock() - if s.hub != nil { - s.hub.Publish("scan", map[string]any{ - "path": task.path, - "cloud": true, - "track_probed": true, - "duration_sec": probe.DurationSec, - "video_codec": probe.VideoCodec, - "audio_codec": probe.AudioCodec, - "width": probe.Width, - "height": probe.Height, - "probe_message": "云盘媒体轨道元数据已后台补齐", - }) - } -} - -func (s *ScannerService) ffprobeWorkerCount() int { - if s == nil || s.cfg == nil { - return 1 - } - return normalizeFFprobeMaxConcurrent(s.cfg.App.FFprobeMaxConcurrent) -} - -func (s *ScannerService) cloudScanWorkerCount() int { - if s == nil || s.cfg == nil { - return 4 - } - return normalizeCloudScanMaxConcurrent(s.cfg.App.CloudScanMaxConcurrent) -} - -func normalizeCloudScanMaxConcurrent(n int) int { - if n <= 0 { - return 1 - } - if n > 16 { - return 16 - } - return n -} - -func (s *ScannerService) probeCloudFileMetadata(ctx context.Context, typ, ref string) (*ProbeResult, error) { - if s == nil || s.probe == nil || s.storage == nil { - return nil, errors.New("cloud probe unavailable") - } - link, err := s.storage.CloudResolve(ctx, typ, ref, "") - if err != nil { - return nil, err - } - return s.probe.ProbeHTTP(ctx, link.URL, link.Headers) -} - -func probeResultUpdates(probe *ProbeResult) map[string]any { - updates := map[string]any{} - if probe == nil { - return updates - } - if probe.DurationSec > 0 { - updates["duration_sec"] = probe.DurationSec - } - if probe.Width > 0 { - updates["width"] = probe.Width - } - if probe.Height > 0 { - updates["height"] = probe.Height - } - if strings.TrimSpace(probe.VideoCodec) != "" { - updates["video_codec"] = probe.VideoCodec - } - if strings.TrimSpace(probe.AudioCodec) != "" { - updates["audio_codec"] = probe.AudioCodec - } - if probe.Container != "" { - updates["container"] = probe.Container - } - return updates -} diff --git a/internal/service/scanner_cloud_scan.go b/internal/service/scanner_cloud_scan.go deleted file mode 100644 index 45d2105..0000000 --- a/internal/service/scanner_cloud_scan.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type cloudScanImportRequest struct { - provider string - candidates []cloudCandidate - existingMedia map[string]existingCloudMedia - writeBatch *localMediaWriteBatch - probeBudget *int - defaultRootID string - progress *cloudScanProgressState - result *ScanResult -} - -type cloudScanImportResult struct { - seen map[string]struct{} - touchedLibraryIDs []string - scopeLibraryIDs []string -} - -type cloudLibraryScanCompletion struct { - libraryID string - touchedLibraryIDs []string - result *ScanResult - progress *cloudScanProgressState - autoScrape bool -} - -func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { - return s.scanCloudLibraryWithRoot(ctx, lib, mount, "", autoScrape) -} - -func (s *ScannerService) scanCloudLibraryRoot(ctx context.Context, lib *model.Library, root *model.LibraryRoot, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { - return s.scanCloudLibraryWithRoot(ctx, lib, mount, libraryRootID(root), autoScrape) -} - -func (s *ScannerService) scanCloudLibraryWithRoot(ctx context.Context, lib *model.Library, mount CloudMountInfo, defaultRootID string, autoScrape bool) (*ScanResult, error) { - res := &ScanResult{LibraryID: lib.ID} - if s.storage == nil { - return res, fmt.Errorf("cloud storage service unavailable") - } - - cfg, err := s.repo.StorageConfig.Get(ctx, mount.Provider) - if err != nil || cfg == nil { - return res, fmt.Errorf("storage config not found: %s", mount.Provider) - } - if !cfg.Enabled { - return res, fmt.Errorf("storage %s is disabled", mount.Provider) - } - typ := mount.Provider - rootDir := mount.ScanDir - rootDisplayDir := mount.DisplayDir - autoCategoryRoot := cloudRootMountNeedsAutoCategory(mount) - scopeIDs := s.cloudScanLibraryScopeIDs(ctx, lib, mount) - progress := newCloudScanProgressState() - progress.publish(s, lib.ID, res, "listing", true) - candidates, err := s.collectCloudScanCandidates(ctx, lib, cloudScanCandidateRequest{ - provider: typ, - rootDir: rootDir, - rootDisplayDir: rootDisplayDir, - autoCategoryRoot: autoCategoryRoot, - progress: progress, - result: res, - }) - if err != nil { - return res, err - } - existingMedia, err := s.existingCloudMediaSnapshotForLibraries(ctx, scopeIDs) - if err != nil { - s.log.Warn("load existing cloud media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err)) - existingMedia = nil - } - sortCloudCandidatesByRefreshPriority(candidates, existingMedia) - writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100) - probeBudget := maxCloudMediaProbeQueuePerScan - imported, err := s.importCloudScanCandidates(ctx, lib, cloudScanImportRequest{ - provider: typ, - candidates: candidates, - existingMedia: existingMedia, - writeBatch: writeBatch, - probeBudget: &probeBudget, - defaultRootID: defaultRootID, - progress: progress, - result: res, - }) - if err != nil { - return res, err - } - scopeIDs = appendUniqueLibraryIDs(scopeIDs, imported.scopeLibraryIDs...) - writeBatch.Flush() - var removed int64 - if defaultRootID != "" { - removed, err = s.pruneMissingCloudMediaForRoot(ctx, lib.ID, defaultRootID, imported.seen) - } else { - removed, err = s.pruneMissingCloudMediaForLibraries(ctx, scopeIDs, imported.seen) - } - if err != nil { - s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err)) - } else { - res.Removed = removed - } - s.completeCloudLibraryScan(ctx, cloudLibraryScanCompletion{ - libraryID: lib.ID, - touchedLibraryIDs: imported.touchedLibraryIDs, - result: res, - progress: progress, - autoScrape: autoScrape, - }) - return res, nil -} - -type cloudScanTarget struct { - lib *model.Library - rootID string -} - -func (s *ScannerService) importCloudScanCandidates(ctx context.Context, rootLib *model.Library, req cloudScanImportRequest) (cloudScanImportResult, error) { - imported := cloudScanImportResult{ - seen: make(map[string]struct{}), - touchedLibraryIDs: []string{}, - scopeLibraryIDs: []string{}, - } - targetLibs := map[string]cloudScanTarget{"": {lib: rootLib, rootID: req.defaultRootID}} - for _, candidate := range req.candidates { - select { - case <-ctx.Done(): - return imported, ctx.Err() - default: - } - target := targetLibs[""] - if candidate.categoryDisplayDir != "" { - categoryKey := candidate.categoryDisplayDir + "\x00" + candidate.categoryScanDir - if cached, ok := targetLibs[categoryKey]; ok { - target = cached - } else if categoryTarget, err := s.ensureCloudAutoCategoryTarget(ctx, rootLib, req.provider, candidate.categoryDisplayDir, candidate.categoryScanDir); err == nil && categoryTarget.Library != nil { - target = cloudScanTarget{lib: categoryTarget.Library, rootID: categoryTarget.RootID} - targetLibs[categoryKey] = target - imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryTarget.Library.ID) - } else if err != nil { - s.log.Warn("ensure cloud auto category library failed", - zap.String("library_id", rootLib.ID), - zap.String("provider", req.provider), - zap.String("category", candidate.categoryDisplayDir), - zap.String("scan_dir", candidate.categoryScanDir), - zap.Error(err)) - } - } - targetLib := target.lib - if targetLib == nil { - targetLib = rootLib - } - imported.touchedLibraryIDs = appendUniqueLibraryIDs(imported.touchedLibraryIDs, targetLib.ID) - imported.seen[candidate.path] = struct{}{} - s.ingestCloudFile(ctx, targetLib, target.rootID, req.provider, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, req.existingMedia, req.writeBatch, req.probeBudget, req.result) - req.progress.publish(s, rootLib.ID, req.result, "importing", req.result.Visited == 1 || req.result.Visited%100 == 0) - } - return imported, nil -} - -func (s *ScannerService) completeCloudLibraryScan(ctx context.Context, req cloudLibraryScanCompletion) { - publishCloudScanFinished(s, req.libraryID, req.result, req.progress) - s.invalidateMediaCache(ctx) - targetIDs := appendUniqueLibraryIDs(req.touchedLibraryIDs, req.libraryID) - for _, targetID := range targetIDs { - s.maybeGenerateSTRMAfterScan(targetID) - } - if scanHasImportChanges(req.result) && req.autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { - for _, targetID := range targetIDs { - s.startAutoScrape(ctx, targetID) - } - } -} - -func scanHasImportChanges(res *ScanResult) bool { - return res != nil && (res.Added > 0 || res.Updated > 0 || res.Removed > 0) -} diff --git a/internal/service/scanner_cloud_scan_progress.go b/internal/service/scanner_cloud_scan_progress.go deleted file mode 100644 index 284671a..0000000 --- a/internal/service/scanner_cloud_scan_progress.go +++ /dev/null @@ -1,170 +0,0 @@ -package service - -import ( - "sort" - "sync" - "time" -) - -type cloudCandidate struct { - ref string - name string - size int64 - path string - categoryDisplayDir string - categoryScanDir string - localMeta *LocalMetadata -} - -type cloudScanProgressState struct { - mu sync.Mutex - startedAt time.Time - lastProgress time.Time - dirsVisited int - filesDiscovered int -} - -type cloudScanProgressSnapshot struct { - dirsVisited int - filesDiscovered int - visited int - added int - updated int - skipped int - removed int64 - elapsed time.Duration -} - -func newCloudScanProgressState() *cloudScanProgressState { - return &cloudScanProgressState{startedAt: time.Now()} -} - -func (p *cloudScanProgressState) markDirVisited() bool { - p.mu.Lock() - defer p.mu.Unlock() - p.dirsVisited++ - return p.dirsVisited == 1 || p.dirsVisited%20 == 0 -} - -func (p *cloudScanProgressState) markFileDiscovered() bool { - p.mu.Lock() - defer p.mu.Unlock() - p.filesDiscovered++ - return p.filesDiscovered%100 == 0 -} - -func (p *cloudScanProgressState) addSkipped(res *ScanResult) { - p.mu.Lock() - defer p.mu.Unlock() - res.Skipped++ -} - -func (p *cloudScanProgressState) publish(s *ScannerService, libraryID string, res *ScanResult, stage string, force bool) { - if s == nil || s.hub == nil { - return - } - snap, ok := p.snapshotForProgress(res, force) - if !ok { - return - } - filesPerSecond := snap.filesPerSecond() - s.updateCloudScanProgress(libraryID, stage, snap.dirsVisited, snap.filesDiscovered, snap.visited, snap.added, snap.updated, snap.skipped, snap.removed, filesPerSecond) - s.hub.Publish("scan", map[string]any{ - "library_id": libraryID, - "cloud": true, - "stage": stage, - "dirs": snap.dirsVisited, - "discovered": snap.filesDiscovered, - "visited": snap.visited, - "added": snap.added, - "updated": snap.updated, - "skipped": snap.skipped, - "elapsed_seconds": int(snap.elapsed.Seconds()), - "files_per_second": filesPerSecond, - "estimate_message": "云盘接口不提供总文件数,剩余时间会随目录大小和网盘响应速度变化", - }) -} - -func (p *cloudScanProgressState) snapshotForProgress(res *ScanResult, force bool) (cloudScanProgressSnapshot, bool) { - p.mu.Lock() - defer p.mu.Unlock() - if !force && time.Since(p.lastProgress) < 2*time.Second { - return cloudScanProgressSnapshot{}, false - } - p.lastProgress = time.Now() - return p.snapshotLocked(res), true -} - -func (p *cloudScanProgressState) finalSnapshot(res *ScanResult) cloudScanProgressSnapshot { - p.mu.Lock() - defer p.mu.Unlock() - return p.snapshotLocked(res) -} - -func (p *cloudScanProgressState) snapshotLocked(res *ScanResult) cloudScanProgressSnapshot { - snap := cloudScanProgressSnapshot{ - dirsVisited: p.dirsVisited, - filesDiscovered: p.filesDiscovered, - elapsed: time.Since(p.startedAt), - } - if res != nil { - snap.visited = res.Visited - snap.added = res.Added - snap.updated = res.Updated - snap.skipped = res.Skipped - snap.removed = res.Removed - } - return snap -} - -func (s cloudScanProgressSnapshot) filesPerSecond() float64 { - processed := s.filesDiscovered - if s.visited > processed { - processed = s.visited - } - if s.elapsed.Seconds() <= 0 { - return 0 - } - return float64(processed) / s.elapsed.Seconds() -} - -func publishCloudScanFinished(s *ScannerService, libraryID string, res *ScanResult, progress *cloudScanProgressState) { - if s == nil || s.hub == nil || progress == nil { - return - } - snap := progress.finalSnapshot(res) - s.hub.Publish("scan", map[string]any{ - "library_id": libraryID, - "finished": true, - "visited": res.Visited, - "added": res.Added, - "updated": res.Updated, - "skipped": res.Skipped, - "removed": res.Removed, - "error_count": res.ErrorCount, - "errors": res.Errors, - "discovered": snap.filesDiscovered, - "dirs": snap.dirsVisited, - "elapsed_seconds": int(snap.elapsed.Seconds()), - "cloud": true, - }) -} - -func sortCloudCandidatesByRefreshPriority(candidates []cloudCandidate, existingMedia map[string]existingCloudMedia) { - if existingMedia == nil { - return - } - priority := func(candidate cloudCandidate) int { - existing, ok := existingMedia[candidate.path] - if !ok { - return 2 - } - if cloudTrackMetadataMissing(existing) || cloudMetadataNeedsRefresh(existing, candidate.localMeta) { - return 0 - } - return 1 - } - sort.SliceStable(candidates, func(i, j int) bool { - return priority(candidates[i]) < priority(candidates[j]) - }) -} diff --git a/internal/service/scanner_cloud_status.go b/internal/service/scanner_cloud_status.go deleted file mode 100644 index 32ff11e..0000000 --- a/internal/service/scanner_cloud_status.go +++ /dev/null @@ -1,265 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) beginCloudScan(ctx context.Context, lib *model.Library, mount CloudMountInfo) (context.Context, func(*ScanResult, error), error) { - if s == nil || lib == nil { - return ctx, func(*ScanResult, error) {}, nil - } - s.cloudScanMu.Lock() - if s.cloudScans == nil { - s.cloudScans = make(map[string]*cloudScanEntry) - } - if entry := s.cloudScans[lib.ID]; cloudScanBlocksBegin(entry) { - s.cloudScanMu.Unlock() - return ctx, nil, ErrCloudScanAlreadyRunning - } - runCtx, cancel := context.WithCancel(ctx) - s.cloudScans[lib.ID] = newCloudScanEntry(lib.ID, mount.Provider, cancel) - s.cloudScanMu.Unlock() - - finish := func(res *ScanResult, err error) { - s.finishCloudScan(lib, mount, res, err) - } - return runCtx, finish, nil -} - -func newCloudScanEntry(libraryID, provider string, cancel context.CancelFunc) *cloudScanEntry { - now := time.Now() - return &cloudScanEntry{ - status: CloudScanStatus{ - LibraryID: libraryID, - Provider: provider, - Stage: "listing", - State: "running", - StartedAt: now, - UpdatedAt: now, - ResumeHint: "中断后再次点击扫描会从头遍历,但已入库媒体会去重更新,只补齐缺失项。", - Estimate: "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度。", - }, - cancel: cancel, - } -} - -func (s *ScannerService) finishCloudScan(lib *model.Library, mount CloudMountInfo, res *ScanResult, err error) { - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - current := s.cloudScans[lib.ID] - if current == nil { - return - } - applyCloudScanResult(¤t.status, res) - current.status.UpdatedAt = time.Now() - current.status.FinishedAt = current.status.UpdatedAt - current.cancel = nil - applyCloudScanCompletion(¤t.status, err) - s.publishCloudScanFinished(lib.ID, mount.Provider, current.status) - s.notifyScanFinished(lib, res, err, true) -} - -func applyCloudScanResult(status *CloudScanStatus, res *ScanResult) { - if status == nil || res == nil { - return - } - status.Visited = res.Visited - status.Added = res.Added - status.Updated = res.Updated - status.Skipped = res.Skipped - status.Removed = res.Removed - status.ErrorCount = res.ErrorCount - status.Errors = append([]string(nil), res.Errors...) -} - -func applyCloudScanCompletion(status *CloudScanStatus, err error) { - if status == nil { - return - } - switch { - case errors.Is(err, context.Canceled): - status.State = "canceled" - status.Stage = "canceled" - status.Error = "" - case errors.Is(err, context.DeadlineExceeded): - status.State = "error" - status.Stage = "error" - status.Error = "扫描超时:" + err.Error() - case err != nil: - status.State = "error" - status.Stage = "error" - status.Error = err.Error() - default: - status.State = "finished" - status.Stage = "finished" - if status.ErrorCount > 0 { - status.Error = fmt.Sprintf("部分文件入库失败:%d 个,详情见 errors", status.ErrorCount) - } else { - status.Error = "" - } - } -} - -func (s *ScannerService) publishCloudScanFinished(libraryID, provider string, status CloudScanStatus) { - if s == nil || s.hub == nil { - return - } - s.hub.Publish("scan", map[string]any{ - "library_id": libraryID, - "provider": provider, - "cloud": true, - "finished": true, - "state": status.State, - "stage": status.Stage, - "error": status.Error, - "visited": status.Visited, - "added": status.Added, - "updated": status.Updated, - "skipped": status.Skipped, - "removed": status.Removed, - "error_count": status.ErrorCount, - "errors": status.Errors, - }) -} - -func (s *ScannerService) updateCloudScanProgress(libraryID, stage string, dirs, discovered, visited, added, updated, skipped int, removed int64, filesPerSecond float64) { - if s == nil { - return - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - entry := s.cloudScans[libraryID] - if entry == nil { - return - } - entry.status.Stage = stage - entry.status.UpdatedAt = time.Now() - entry.status.Dirs = dirs - entry.status.Discovered = discovered - entry.status.Visited = visited - entry.status.Added = added - entry.status.Updated = updated - entry.status.Skipped = skipped - entry.status.Removed = removed - entry.status.FilesPerSecond = filesPerSecond -} - -func (s *ScannerService) acquireCloudScanSlot(ctx context.Context, libraryID string) (func(), error) { - if s == nil { - return func() {}, nil - } - s.cloudScanMu.Lock() - if s.cloudSlots == nil { - s.cloudSlots = make(chan struct{}, 1) - } - slots := s.cloudSlots - if entry := s.cloudScans[libraryID]; entry != nil { - entry.status.Stage = "queued" - entry.status.UpdatedAt = time.Now() - } - s.cloudScanMu.Unlock() - - select { - case slots <- struct{}{}: - s.cloudScanMu.Lock() - if entry := s.cloudScans[libraryID]; entry != nil && entry.status.State == "running" { - entry.status.Stage = "listing" - entry.status.UpdatedAt = time.Now() - } - s.cloudScanMu.Unlock() - return func() { <-slots }, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -// CloudScanStatuses returns the current or most recent status per cloud library. -func (s *ScannerService) CloudScanStatuses() []CloudScanStatus { - if s == nil { - return nil - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - out := make([]CloudScanStatus, 0, len(s.cloudScans)) - for _, entry := range s.cloudScans { - out = append(out, entry.status) - } - return out -} - -func (s *ScannerService) CancelCloudScan(libraryID string) bool { - if s == nil || strings.TrimSpace(libraryID) == "" { - return false - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - return cancelCloudScanEntry(s.cloudScans[libraryID]) -} - -func (s *ScannerService) CancelAllCloudScans() int { - if s == nil { - return 0 - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - cancelled := 0 - for _, entry := range s.cloudScans { - if cancelCloudScanEntry(entry) { - cancelled++ - } - } - return cancelled -} - -func (s *ScannerService) CancelCloudScansForProvider(provider string) int { - if s == nil { - return 0 - } - provider = strings.TrimSpace(provider) - if provider == "" { - return 0 - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - cancelled := 0 - for _, entry := range s.cloudScans { - if entry == nil || entry.status.Provider != provider { - continue - } - if cancelCloudScanEntry(entry) { - cancelled++ - } - } - return cancelled -} - -func cancelCloudScanEntry(entry *cloudScanEntry) bool { - if !cloudScanActive(entry) { - return false - } - entry.status.State = "canceling" - entry.status.Stage = "canceling" - entry.status.UpdatedAt = time.Now() - if entry.cancel != nil { - entry.cancel() - return true - } - entry.status.State = "canceled" - entry.status.Stage = "canceled" - entry.status.FinishedAt = time.Now() - return true -} - -func cloudScanActive(entry *cloudScanEntry) bool { - return entry != nil && (entry.status.State == "running" || entry.status.State == "queued" || entry.status.State == "canceling") -} - -func cloudScanBlocksBegin(entry *cloudScanEntry) bool { - return entry != nil && (entry.status.State == "running" || entry.status.State == "canceling") -} diff --git a/internal/service/scanner_cloud_status_test.go b/internal/service/scanner_cloud_status_test.go deleted file mode 100644 index 5468e71..0000000 --- a/internal/service/scanner_cloud_status_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBeginCloudScanAllowsQueuedEntryToStart(t *testing.T) { - scanner := &ScannerService{ - cloudScans: map[string]*cloudScanEntry{ - "lib-1": {status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "queued", Stage: "queued"}}, - }, - } - lib := &model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies"} - mount := CloudMountInfo{Provider: "openlist"} - - _, finish, err := scanner.beginCloudScan(context.Background(), lib, mount) - if err != nil { - t.Fatalf("queued scan should be allowed to start, got %v", err) - } - if finish == nil { - t.Fatal("finish callback should not be nil") - } - statuses := scanner.CloudScanStatuses() - if len(statuses) != 1 || statuses[0].State != "running" || statuses[0].Stage != "listing" { - t.Fatalf("status after begin = %#v, want running/listing", statuses) - } - - finish(&ScanResult{Visited: 5, Added: 2, Updated: 1, ErrorCount: 1, Errors: []string{"bad file"}}, nil) - statuses = scanner.CloudScanStatuses() - if len(statuses) != 1 || statuses[0].State != "finished" || statuses[0].Visited != 5 || statuses[0].Added != 2 || statuses[0].ErrorCount != 1 { - t.Fatalf("status after finish = %#v", statuses) - } - if statuses[0].Error == "" { - t.Fatal("finished scan with error_count should keep summary error text") - } -} - -func TestBeginCloudScanRejectsRunningEntry(t *testing.T) { - scanner := &ScannerService{ - cloudScans: map[string]*cloudScanEntry{ - "lib-1": {status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "running", Stage: "listing"}}, - }, - } - lib := &model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies"} - - _, finish, err := scanner.beginCloudScan(context.Background(), lib, CloudMountInfo{Provider: "openlist"}) - if !errors.Is(err, ErrCloudScanAlreadyRunning) { - t.Fatalf("err = %v, want ErrCloudScanAlreadyRunning", err) - } - if finish != nil { - t.Fatal("finish callback should be nil when begin is rejected") - } -} diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go deleted file mode 100644 index 38fb4dd..0000000 --- a/internal/service/scanner_cloud_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestCloudLibraryPathParsing(t *testing.T) { - typ, dir, ok := parseCloudLibraryPath("cloud://cloud115/abc%20123?ignored=1") - if !ok || typ != "cloud115" || dir != "abc 123" { - t.Fatalf("parse path got typ=%q dir=%q ok=%v", typ, dir, ok) - } - typ, dir, ok = parseCloudLibraryPath("cloud://openlist/Movies?dir=%2FMovies") - if !ok || typ != "openlist" || dir != "Movies" { - t.Fatalf("parse query got typ=%q dir=%q ok=%v", typ, dir, ok) - } - if ref := cloudEntryRef("cloud115", "fid", "pick"); ref != "pick" { - t.Fatalf("115 ref = %q, want pick", ref) - } -} - -func TestCloudMountConflictDetectsNestedMounts(t *testing.T) { - root := model.Library{Base: model.Base{ID: "root"}, Name: "115", Path: "cloud://cloud115", Enabled: true} - childPath := BuildCloudLibraryPath("cloud115", "child-id", "parent-id/child-id") - info, ok := ParseCloudLibraryMount(childPath) - if !ok || info.ScanDir != "child-id" || info.DisplayDir != "parent-id/child-id" { - t.Fatalf("parse child mount = %#v ok=%v", info, ok) - } - - conflict := FindCloudMountConflict([]model.Library{root}, "cloud115", "child-id", "parent-id/child-id") - if conflict != nil { - t.Fatalf("child mount under existing root should be allowed, got conflict %#v", conflict) - } - - sibling := model.Library{Base: model.Base{ID: "sibling"}, Name: "Sibling", Path: BuildCloudLibraryPath("cloud115", "sibling-id", "parent-id/sibling-id"), Enabled: true} - conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "child-id", "parent-id/child-id") - if conflict != nil { - t.Fatalf("sibling conflict = %#v, want nil", conflict) - } - - conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "parent-id", "parent-id") - if conflict == nil || !conflict.Nested { - t.Fatalf("parent mount over existing child = %#v, want nested conflict", conflict) - } - oldIDPath := BuildCloudLibraryPath("cloud115", "child-id", "old-parent-id/child-id") - conflict = FindCloudMountConflict([]model.Library{{Base: model.Base{ID: "old"}, Name: "Old", Path: oldIDPath, Enabled: true}}, "cloud115", "child-id", "父目录/子目录") - if conflict == nil || !conflict.Exact { - t.Fatalf("same scan dir with renamed display path = %#v, want exact conflict", conflict) - } - - root.CreatedAt = root.CreatedAt.Add(-1) - child := model.Library{Base: model.Base{ID: "child"}, Name: "Child", Path: childPath, Enabled: true} - if shadow := CloudLibraryShadowed([]model.Library{root, child}, child); shadow != nil { - t.Fatalf("child should not be shadowed by root: %#v", shadow) - } - if shadow := CloudLibraryShadowed([]model.Library{root, child}, root); shadow == nil || !shadow.Nested { - t.Fatalf("root should be shadowed by child, got %#v", shadow) - } -} - -func TestCancelCloudScansForProviderSignalsRunningScan(t *testing.T) { - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repository.New(nil), NewHub(zap.NewNop()), nil, nil) - cancelled := false - scanner.cloudScans["lib-1"] = &cloudScanEntry{ - status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "running"}, - cancel: func() { - cancelled = true - }, - } - - if got := scanner.CancelCloudScansForProvider("openlist"); got != 1 { - t.Fatalf("cancelled = %d, want 1", got) - } - if !cancelled { - t.Fatal("cancel func was not called") - } - if state := scanner.cloudScans["lib-1"].status.State; state != "canceling" { - t.Fatalf("state = %q, want canceling", state) - } -} - -func TestInferCloudMountMediaType(t *testing.T) { - cases := map[string]string{ - "/日漫": "anime", - "/国漫": "anime", - "/美漫": "anime", - "/电视剧/国产剧": "tv", - "/电视剧/欧美剧": "tv", - "/电视剧/日韩剧": "tv", - "/电影/动画电影": "movie", - "/电影/华语电影": "movie", - "/电影/欧美电影": "movie", - "/综艺": "variety", - } - for dir, want := range cases { - if got := InferCloudMountMediaType(dir, "OpenList · "+dir); got != want { - t.Fatalf("%s type = %s, want %s", dir, got, want) - } - } -} - -func TestCloudSeriesTitlePrefersShowFolder(t *testing.T) { - title, year := cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川 (2024) {tmdb-247590}/Season 2/紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv") - if title != "紫川" || year != 2024 { - t.Fatalf("cloud series title = %q/%d, want 紫川/2024", title, year) - } - title, year = cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川.2024.S02E24.mkv") - if title != "" || year != 0 { - t.Fatalf("single category folder should not override title, got %q/%d", title, year) - } -} - -func TestCloudMetadataNeedsRefreshWhenPathHintConflicts(t *testing.T) { - existing := existingCloudMedia{ - Year: 2025, - TMDbID: 220269, - } - local := &LocalMetadata{ - Year: 2025, - TMDbID: 296753, - PathHint: true, - } - if !cloudMetadataNeedsRefresh(existing, local) { - t.Fatal("conflicting explicit cloud path hint should refresh existing media") - } -} - -func TestParseCloudArtworkURL(t *testing.T) { - typ, ref, ok := ParseCloudArtworkURL("http://nas.local/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg") - if !ok || typ != "openlist" || ref != "/Anime/JianLai/poster.jpg" { - t.Fatalf("parse cloud image url = typ=%q ref=%q ok=%v", typ, ref, ok) - } - typ, ref, ok = ParseCloudArtworkURL("/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg") - if !ok || typ != "openlist" || ref != "/Anime/JianLai/poster.jpg" { - t.Fatalf("parse cached cloud artwork url = typ=%q ref=%q ok=%v", typ, ref, ok) - } - typ, ref, ok = ParseCloudArtworkURL("/api/img/cloud/openlist?ref=%2FMovies%2FMovie.tbn") - if !ok || typ != "openlist" || ref != "/Movies/Movie.tbn" { - t.Fatalf("parse tbn cloud artwork url = typ=%q ref=%q ok=%v", typ, ref, ok) - } - if _, _, ok := ParseCloudArtworkURL("/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fmovie.mkv"); ok { - t.Fatal("video cloud url should not be treated as artwork") - } - if _, _, ok := ParseCloudArtworkURL("https://image.tmdb.org/t/p/w500/poster.jpg"); ok { - t.Fatal("remote HTTP poster should not be treated as cloud artwork") - } -} diff --git a/internal/service/scanner_cloud_test_helpers_test.go b/internal/service/scanner_cloud_test_helpers_test.go deleted file mode 100644 index 33504ba..0000000 --- a/internal/service/scanner_cloud_test_helpers_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -type openListTestEntry struct { - Name string - Size int64 - IsDir bool -} - -func newOpenListAPIServer(t *testing.T, list func(path string, page, perPage int) ([]openListTestEntry, int)) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected openlist api request %s", r.URL.Path) - } - var in struct { - Path string `json:"path"` - Page int `json:"page"` - PerPage int `json:"per_page"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - t.Fatalf("decode openlist list request: %v", err) - } - if in.Path == "" { - in.Path = "/" - } - if in.Page <= 0 { - in.Page = 1 - } - if in.PerPage <= 0 { - in.PerPage = 500 - } - entries, total := list(in.Path, in.Page, in.PerPage) - content := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - content = append(content, map[string]any{ - "name": entry.Name, - "size": entry.Size, - "is_dir": entry.IsDir, - }) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "message": "success", - "data": map[string]any{ - "content": content, - "total": total, - }, - }) - })) -} diff --git a/internal/service/scanner_existing_media.go b/internal/service/scanner_existing_media.go index b41abd7..15f224c 100644 --- a/internal/service/scanner_existing_media.go +++ b/internal/service/scanner_existing_media.go @@ -8,62 +8,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) -func (s *ScannerService) existingCloudMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingCloudMedia, error) { - return s.existingCloudMediaSnapshotForLibraries(ctx, []string{libraryID}) -} - -func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Context, libraryIDs []string) (map[string]existingCloudMedia, error) { - if len(libraryIDs) == 0 { - return map[string]existingCloudMedia{}, nil - } - var rows []model.Media - if err := s.repo.DB.WithContext(ctx). - Model(&model.Media{}). - Select("library_id", "path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "poster_url", "backdrop_url", "strm_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status"). - Where("library_id IN ? AND path LIKE ?", libraryIDs, "cloud://%"). - Find(&rows).Error; err != nil { - return nil, err - } - snapshot := make(map[string]existingCloudMedia, len(rows)) - for _, row := range rows { - if strings.TrimSpace(row.Path) == "" { - continue - } - snapshot[row.Path] = existingCloudMedia{ - LibraryID: row.LibraryID, - Title: row.Title, - OriginalName: row.OriginalName, - EpisodeTitle: row.EpisodeTitle, - SizeBytes: row.SizeBytes, - DurationSec: row.DurationSec, - Width: row.Width, - Height: row.Height, - VideoCodec: row.VideoCodec, - AudioCodec: row.AudioCodec, - Container: row.Container, - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - STRMURL: row.STRMURL, - Overview: row.Overview, - Year: row.Year, - ReleaseDate: row.ReleaseDate, - Rating: row.Rating, - TMDbID: row.TMDbID, - BangumiID: row.BangumiID, - DoubanID: row.DoubanID, - TheTVDBID: row.TheTVDBID, - SeasonNum: row.SeasonNum, - EpisodeNum: row.EpisodeNum, - Genres: row.Genres, - Countries: row.Countries, - Languages: row.Languages, - NSFW: row.NSFW, - ScrapeStatus: row.ScrapeStatus, - } - } - return snapshot, nil -} - func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingLocalMedia, error) { var rows []model.Media if err := s.repo.DB.WithContext(ctx). diff --git a/internal/service/scanner_existing_media_test.go b/internal/service/scanner_existing_media_test.go index ed49328..8d1aa7e 100644 --- a/internal/service/scanner_existing_media_test.go +++ b/internal/service/scanner_existing_media_test.go @@ -10,60 +10,6 @@ import ( "go.uber.org/zap" ) -func TestExistingCloudMediaSnapshotFiltersCloudRows(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - - if err := db.Create(&[]model.Media{ - { - LibraryID: "lib-1", - Path: "cloud://openlist/Movie.mkv", - SizeBytes: 2048, - DurationSec: 120, - Width: 1920, - Height: 1080, - VideoCodec: "h264", - AudioCodec: "aac", - Container: "mkv", - PosterURL: "/poster.jpg", - BackdropURL: "/backdrop.jpg", - STRMURL: "/api/cloud/play/openlist?ref=movie", - Year: 2026, - TMDbID: 123, - BangumiID: 456, - DoubanID: "douban-1", - TheTVDBID: "tvdb-1", - ScrapeStatus: "matched", - }, - {LibraryID: "lib-1", Path: "/media/local.mkv", SizeBytes: 99}, - {LibraryID: "lib-2", Path: "cloud://openlist/Other.mkv", SizeBytes: 88}, - }).Error; err != nil { - t.Fatal(err) - } - - got, err := scanner.existingCloudMediaSnapshot(t.Context(), "lib-1") - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("snapshot len = %d, want 1: %#v", len(got), got) - } - row := got["cloud://openlist/Movie.mkv"] - if row.SizeBytes != 2048 || row.DurationSec != 120 || row.Width != 1920 || row.Height != 1080 { - t.Fatalf("track fields not preserved: %#v", row) - } - if row.VideoCodec != "h264" || row.AudioCodec != "aac" || row.Container != "mkv" { - t.Fatalf("codec fields not preserved: %#v", row) - } - if row.PosterURL != "/poster.jpg" || row.BackdropURL != "/backdrop.jpg" || row.STRMURL == "" { - t.Fatalf("artwork/strm fields not preserved: %#v", row) - } - if row.Year != 2026 || row.TMDbID != 123 || row.BangumiID != 456 || row.DoubanID != "douban-1" || row.TheTVDBID != "tvdb-1" { - t.Fatalf("scraper ids not preserved: %#v", row) - } -} - func TestExistingLocalMediaSnapshotFiltersAndCleansLocalRows(t *testing.T) { db := newServiceTestDB(t, &model.Media{}) repos := repository.New(db) diff --git a/internal/service/scanner_local_probe_queue.go b/internal/service/scanner_local_probe_queue.go index 752f155..fd6d9e9 100644 --- a/internal/service/scanner_local_probe_queue.go +++ b/internal/service/scanner_local_probe_queue.go @@ -50,6 +50,39 @@ func (s *ScannerService) startLocalMediaProbeWorkers() { }) } +func (s *ScannerService) ffprobeWorkerCount() int { + if s == nil || s.cfg == nil { + return 1 + } + return normalizeFFprobeMaxConcurrent(s.cfg.App.FFprobeMaxConcurrent) +} + +func probeResultUpdates(probe *ProbeResult) map[string]any { + updates := map[string]any{} + if probe == nil { + return updates + } + if probe.DurationSec > 0 { + updates["duration_sec"] = probe.DurationSec + } + if probe.Width > 0 { + updates["width"] = probe.Width + } + if probe.Height > 0 { + updates["height"] = probe.Height + } + if strings.TrimSpace(probe.VideoCodec) != "" { + updates["video_codec"] = probe.VideoCodec + } + if strings.TrimSpace(probe.AudioCodec) != "" { + updates["audio_codec"] = probe.AudioCodec + } + if probe.Container != "" { + updates["container"] = probe.Container + } + return updates +} + func (s *ScannerService) reserveLocalMediaProbe(path string) bool { s.localMediaProbeMu.Lock() defer s.localMediaProbeMu.Unlock() diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go index 1bd4d60..c46a079 100644 --- a/internal/service/scanner_metadata_refresh.go +++ b/internal/service/scanner_metadata_refresh.go @@ -19,100 +19,6 @@ type scanDerivedMetadata struct { EpisodeNum int } -func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool { - if localMeta == nil { - return false - } - if localMeta.PathHint && !localMeta.HasNFO && !localMeta.HasArtwork { - return cloudPathHintNeedsRefresh(existing, localMeta) - } - if localMetadataMarksMatched(localMeta) && strings.TrimSpace(existing.ScrapeStatus) != "matched" { - return true - } - if localMeta.Title != "" && strings.TrimSpace(existing.Title) != strings.TrimSpace(localMeta.Title) { - return true - } - if localMeta.OriginalName != "" && strings.TrimSpace(existing.OriginalName) != strings.TrimSpace(localMeta.OriginalName) { - return true - } - if localMeta.EpisodeTitle != "" && strings.TrimSpace(existing.EpisodeTitle) != strings.TrimSpace(localMeta.EpisodeTitle) { - return true - } - if localMeta.AdultCode != "" && !strings.EqualFold(strings.TrimSpace(existing.OriginalName), strings.TrimSpace(localMeta.AdultCode)) { - return true - } - if localMeta.Year > 0 && existing.Year != localMeta.Year { - return true - } - if localMeta.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(localMeta.ReleaseDate) { - return true - } - if localMeta.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(localMeta.Overview) { - return true - } - if localMeta.Rating > 0 && existing.Rating != localMeta.Rating { - return true - } - if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID { - return true - } - if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID { - return true - } - if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) { - return true - } - if strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID) { - return true - } - if strings.TrimSpace(localMeta.PosterURL) != "" && strings.TrimSpace(existing.PosterURL) != strings.TrimSpace(localMeta.PosterURL) { - return true - } - if strings.TrimSpace(localMeta.BackdropURL) != "" && strings.TrimSpace(existing.BackdropURL) != strings.TrimSpace(localMeta.BackdropURL) { - return true - } - if (localMeta.SeasonNum > 0 || localMeta.EpisodeNum > 0) && existing.SeasonNum != localMeta.SeasonNum { - return true - } - if localMeta.EpisodeNum > 0 && existing.EpisodeNum != localMeta.EpisodeNum { - return true - } - if localMeta.Genres != "" && strings.TrimSpace(existing.Genres) != strings.TrimSpace(localMeta.Genres) { - return true - } - if localMeta.Countries != "" && strings.TrimSpace(existing.Countries) != strings.TrimSpace(localMeta.Countries) { - return true - } - if localMeta.Languages != "" && strings.TrimSpace(existing.Languages) != strings.TrimSpace(localMeta.Languages) { - return true - } - if localMeta.NSFW && !existing.NSFW { - return true - } - return false -} - -func cloudPathHintNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool { - if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID { - return true - } - if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID { - return true - } - if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) { - return true - } - return strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID) -} - -func cloudTrackMetadataMissing(existing existingCloudMedia) bool { - return existing.DurationSec <= 0 || - existing.Width <= 0 || - existing.Height <= 0 || - strings.TrimSpace(existing.VideoCodec) == "" || - strings.TrimSpace(existing.AudioCodec) == "" -} - func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata) bool { if local == nil { return false @@ -180,24 +86,6 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata return local.NSFW && !existing.NSFW } -func cloudDerivedMetadataNeedsRefresh(existing existingCloudMedia, incoming *model.Media) bool { - if incoming == nil { - return false - } - return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{ - Title: existing.Title, - ScrapeStatus: existing.ScrapeStatus, - Year: existing.Year, - ReleaseDate: existing.ReleaseDate, - TMDbID: existing.TMDbID, - BangumiID: existing.BangumiID, - DoubanID: existing.DoubanID, - TheTVDBID: existing.TheTVDBID, - SeasonNum: existing.SeasonNum, - EpisodeNum: existing.EpisodeNum, - }, incoming) -} - func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *model.Media) bool { if incoming == nil { return false diff --git a/internal/service/scanner_notifications.go b/internal/service/scanner_notifications.go deleted file mode 100644 index 5d652d6..0000000 --- a/internal/service/scanner_notifications.go +++ /dev/null @@ -1,36 +0,0 @@ -package service - -import ( - "context" - "fmt" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) notifyScanFinished(lib *model.Library, res *ScanResult, err error, cloud bool) { - if s == nil || s.notify == nil || lib == nil || res == nil { - return - } - if err != nil { - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - s.notify.Broadcast(ctx, "MediaStationGo 扫描异常", fmt.Sprintf("媒体库:%s\n错误:%s", lib.Name, err.Error()), EventSystemAlert) - }() - return - } - if res.Added+res.Updated <= 0 { - return - } - source := "本地媒体库" - if cloud { - source = "网盘媒体库" - } - body := fmt.Sprintf("%s:%s\n新增:%d\n更新:%d\n跳过:%d\n移除:%d", source, lib.Name, res.Added, res.Updated, res.Skipped, res.Removed) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - s.notify.Broadcast(ctx, "MediaStationGo 入库完成", body, EventLibraryIngest) - }() -} diff --git a/internal/service/scanner_probe_queue.go b/internal/service/scanner_probe_queue.go deleted file mode 100644 index c9e48e8..0000000 --- a/internal/service/scanner_probe_queue.go +++ /dev/null @@ -1,98 +0,0 @@ -package service - -import ( - "strings" - "time" - - "go.uber.org/zap" -) - -func (s *ScannerService) cloudMediaProbeWorker() { - for task := range s.cloudMediaProbeQueue { - s.probeCloudMediaAsync(task) - } -} - -func (s *ScannerService) queueCloudMediaProbe(typ, ref, path string) bool { - task, ok := s.newCloudMediaProbeTask(typ, ref, path) - if !ok || !s.reserveCloudMediaProbe(task, time.Now()) { - return false - } - select { - case s.cloudMediaProbeQueue <- task: - return true - default: - s.deferCloudMediaProbe(task, cloudMediaProbeQueueFullBackoff) - s.logCloudMediaProbeQueueFull(task) - return false - } -} - -func (s *ScannerService) newCloudMediaProbeTask(typ, ref, path string) (cloudMediaProbeTask, bool) { - if s == nil || s.storage == nil || s.probe == nil { - return cloudMediaProbeTask{}, false - } - task := cloudMediaProbeTask{ - typ: strings.TrimSpace(typ), - ref: strings.TrimSpace(ref), - path: strings.TrimSpace(path), - } - return task, task.typ != "" && task.ref != "" && task.path != "" -} - -func (s *ScannerService) reserveCloudMediaProbe(task cloudMediaProbeTask, now time.Time) bool { - s.cloudMediaProbeMu.Lock() - defer s.cloudMediaProbeMu.Unlock() - if until, ok := s.cloudMediaProbeBackoff[task.path]; ok { - if now.Before(until) { - return false - } - delete(s.cloudMediaProbeBackoff, task.path) - } - if _, ok := s.cloudMediaProbing[task.path]; ok { - return false - } - s.cloudMediaProbing[task.path] = struct{}{} - return true -} - -func (s *ScannerService) deferCloudMediaProbe(task cloudMediaProbeTask, backoff time.Duration) { - s.cloudMediaProbeMu.Lock() - defer s.cloudMediaProbeMu.Unlock() - delete(s.cloudMediaProbing, task.path) - if s.cloudMediaProbeBackoff == nil { - s.cloudMediaProbeBackoff = make(map[string]time.Time) - } - s.cloudMediaProbeBackoff[task.path] = time.Now().Add(backoff) -} - -func (s *ScannerService) logCloudMediaProbeQueueFull(task cloudMediaProbeTask) { - if s == nil || s.log == nil { - return - } - now := time.Now() - s.cloudMediaProbeWarnMu.Lock() - shouldWarn := now.Sub(s.cloudMediaProbeLastWarn) >= time.Minute - if shouldWarn { - s.cloudMediaProbeLastWarn = now - } - s.cloudMediaProbeWarnMu.Unlock() - if shouldWarn { - s.log.Warn("cloud media probe queue full; deferring remaining probes (logged at most once per minute)", - zap.String("provider", task.typ), zap.String("path", task.path)) - return - } - s.log.Debug("cloud media probe queue full", zap.String("provider", task.typ), zap.String("path", task.path)) -} - -func (s *ScannerService) queueCloudMediaProbeWithBudget(typ, ref, path string, budget *int) bool { - if budget != nil { - if *budget <= 0 { - return false - } - // Budget is consumed per attempt, not only per successful enqueue, so a - // full probe queue cannot generate unbounded repeated attempts/logging. - *budget-- - } - return s.queueCloudMediaProbe(typ, ref, path) -} diff --git a/internal/service/scanner_probe_queue_test.go b/internal/service/scanner_probe_queue_test.go deleted file mode 100644 index 467f62f..0000000 --- a/internal/service/scanner_probe_queue_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" -) - -func newProbeQueueTestScanner(capacity int) *ScannerService { - return &ScannerService{ - log: zap.NewNop(), - storage: &StorageConfigService{}, - probe: &FFprobeService{}, - cloudMediaProbeQueue: make(chan cloudMediaProbeTask, capacity), - cloudMediaProbing: make(map[string]struct{}), - cloudMediaProbeBackoff: make(map[string]time.Time), - } -} - -func TestQueueCloudMediaProbeTrimsTaskAndRejectsDuplicate(t *testing.T) { - scanner := newProbeQueueTestScanner(1) - if !scanner.queueCloudMediaProbe(" openlist ", " /Movies/a.mkv ", " cloud://openlist/Movies/a.mkv ") { - t.Fatal("first cloud probe should enqueue") - } - if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") { - t.Fatal("duplicate cloud probe should be rejected while in flight") - } - - task := <-scanner.cloudMediaProbeQueue - if task.typ != "openlist" || task.ref != "/Movies/a.mkv" || task.path != "cloud://openlist/Movies/a.mkv" { - t.Fatalf("task was not normalized: %#v", task) - } -} - -func TestQueueCloudMediaProbeFullQueueBacksOffAndReleases(t *testing.T) { - scanner := newProbeQueueTestScanner(0) - if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") { - t.Fatal("unbuffered queue without receiver should reject enqueue") - } - - scanner.cloudMediaProbeMu.Lock() - _, probing := scanner.cloudMediaProbing["cloud://openlist/Movies/a.mkv"] - until, backedOff := scanner.cloudMediaProbeBackoff["cloud://openlist/Movies/a.mkv"] - scanner.cloudMediaProbeMu.Unlock() - if probing { - t.Fatal("queue-full path should release in-flight marker") - } - if !backedOff || !until.After(time.Now()) { - t.Fatalf("queue-full path should receive future backoff, got %v", until) - } - if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") { - t.Fatal("backed-off path should not be retried immediately") - } -} - -func TestQueueCloudMediaProbeBudgetConsumesAttempts(t *testing.T) { - scanner := newProbeQueueTestScanner(0) - budget := 1 - if scanner.queueCloudMediaProbeWithBudget("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv", &budget) { - t.Fatal("unbuffered queue without receiver should reject enqueue") - } - if budget != 0 { - t.Fatalf("budget = %d, want 0 after attempted enqueue", budget) - } - if scanner.queueCloudMediaProbeWithBudget("openlist", "/Movies/b.mkv", "cloud://openlist/Movies/b.mkv", &budget) { - t.Fatal("zero budget should prevent enqueue") - } -} diff --git a/internal/service/scanner_scan.go b/internal/service/scanner_scan.go index 84ec7e3..010bee9 100644 --- a/internal/service/scanner_scan.go +++ b/internal/service/scanner_scan.go @@ -32,9 +32,6 @@ func (s *ScannerService) ScanLibraryRoot(ctx context.Context, libraryID, rootID if root == nil { return nil, errors.New("library root not found") } - if mount, ok := ParseCloudLibraryMount(root.Path); ok { - return s.scanCloudLibraryRoot(ctx, lib, root, mount, true) - } return s.scanLocalLibraryRoot(ctx, lib, root, true) } @@ -74,9 +71,6 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto if lib == nil { return nil, errors.New("library not found") } - if mount, ok := ParseCloudLibraryMount(lib.Path); ok { - return s.scanMountedCloudLibrary(ctx, lib, mount, autoScrape) - } res := &ScanResult{LibraryID: lib.ID} writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100) existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID) @@ -216,9 +210,7 @@ func (s *ScannerService) finishLocalLibraryScan(ctx context.Context, lib *model. "error_count": res.ErrorCount, "errors": res.Errors, }) - s.notifyScanFinished(lib, res, nil, false) s.invalidateMediaCache(ctx) - s.maybeGenerateSTRMAfterScan(lib.ID) if scanHasImportChanges(res) && autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { s.startAutoScrape(ctx, lib.ID) diff --git a/internal/service/scanner_strm.go b/internal/service/scanner_strm.go index 186b980..89371f1 100644 --- a/internal/service/scanner_strm.go +++ b/internal/service/scanner_strm.go @@ -1,40 +1,11 @@ package service import ( - "context" "net/url" "os" - "path/filepath" "strings" - - "go.uber.org/zap" ) -func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref string) (string, error) { - if s.storage == nil { - return "", nil - } - content, err := s.storage.CloudReadText(ctx, typ, ref, 64<<10) - if err != nil { - return "", err - } - for _, line := range strings.Split(content, "\n") { - candidate := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) - if candidate == "" || strings.HasPrefix(candidate, "#") { - continue - } - u, err := url.Parse(candidate) - if err != nil { - continue - } - switch strings.ToLower(u.Scheme) { - case "http", "https", "webdav", "davs", "alist", "alists", "openlist", "openlists": - return candidate, nil - } - } - return "", nil -} - func readLocalSTRMTarget(path string) (string, error) { data, err := os.ReadFile(path) // #nosec G304 -- path is a discovered .strm file under the configured library root. if err != nil { @@ -59,56 +30,3 @@ func readLocalSTRMTarget(path string) (string, error) { } return "", nil } - -func (s *ScannerService) maybeGenerateSTRMAfterScan(libraryID string) { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return - } - value, err := s.repo.Setting.Get(context.Background(), "strm.auto_generate_enabled") - if err != nil || !parseBoolSetting(value, false) { - return - } - go func() { - ctx := context.Background() - strmSvc := NewSTRMService(s.log, s.repo, s.cfg) - opts := GenerateSTRMOptions{ - LibraryID: libraryID, - Enabled: true, - IncludeLocal: true, - Overwrite: true, - PreserveTree: s.autoSTRMPreserveTree(ctx), - SkipSettingsSave: true, - } - if outDir, scope := s.autoSTRMOutputDir(ctx); outDir != "" { - opts.OutputDir = outDir - if scope == "all" { - if lib, err := s.repo.Library.FindByID(ctx, libraryID); err == nil && lib != nil { - opts.OutputDir = filepath.Join(outDir, strmLibraryOutputSubdir(*lib)) - } - } - } - if _, err := strmSvc.GenerateForLibrary(ctx, opts); err != nil && s.log != nil { - s.log.Warn("auto generate strm failed", zap.String("library_id", libraryID), zap.Error(err)) - } - }() -} - -func (s *ScannerService) autoSTRMOutputDir(ctx context.Context) (string, string) { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return "", "" - } - outDir, err := s.repo.Setting.Get(ctx, "strm.output_dir") - if err != nil { - return "", "" - } - scope, _ := s.repo.Setting.Get(ctx, "strm.output_scope") - return resolveMappedDestinationPath(strings.TrimSpace(outDir)), strings.ToLower(strings.TrimSpace(scope)) -} - -func (s *ScannerService) autoSTRMPreserveTree(ctx context.Context) bool { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return false - } - value, err := s.repo.Setting.Get(ctx, "strm.preserve_tree") - return err == nil && parseBoolSetting(value, false) -} diff --git a/internal/service/scanner_strm_test.go b/internal/service/scanner_strm_test.go deleted file mode 100644 index 2ccc9a4..0000000 --- a/internal/service/scanner_strm_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAutoGenerateSTRMAfterScanUsesAllScopeRoot(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - outDir := t.TempDir() - if err := repos.Setting.Set(t.Context(), "strm.auto_generate_enabled", "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "strm.output_dir", outDir); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "strm.output_scope", "all"); err != nil { - t.Fatal(err) - } - lib := model.Library{ - Base: model.Base{ID: "tv-lib"}, - Name: "OpenList 欧美剧", - Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), - Type: "tv", - Enabled: true, - } - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - Base: model.Base{ID: "show-1"}, - LibraryID: lib.ID, - Title: "第一集", - Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", - STRMURL: "/api/cloud/play/openlist?ref=show", - SeasonNum: 1, - EpisodeNum: 1, - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.maybeGenerateSTRMAfterScan(lib.ID) - - want := filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm") - waitForFile(t, want) - assertFileContains(t, want, "/api/stream/show-1") - if _, err := os.Stat(filepath.Join(outDir, "电视剧", "欧美剧", "电视剧", "欧美剧")); !os.IsNotExist(err) { - t.Fatalf("auto STRM output was nested twice") - } -} - -func waitForFile(t *testing.T, path string) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for { - if _, err := os.Stat(path); err == nil { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for %s", path) - } - time.Sleep(20 * time.Millisecond) - } -} diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 1ee11fa..9c2f66c 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -5,8 +5,6 @@ // // library_scan every 24 h — optional full re-scan for local libraries; // filesystem watchers handle normal changes. -// cloud_sync low frequency — optional cloud library sync. -// cloud_upload low frequency — optional local-to-cloud metadata upload. // organize_source opt-in — organize the configured staging folder. // transcode_cleanup every 24 h — purge HLS transcode artefacts // older than 24 h. @@ -38,7 +36,6 @@ type SchedulerService struct { transcoder *TranscoderService organizer *OrganizerService organizePipeline *OrganizePipelineService - storageCfg *StorageConfigService hub *Hub tasks *TaskTrackerService cacheDir string @@ -75,10 +72,7 @@ type scheduledJob struct { type schedulerManualRunKey struct{} -const ( - localLastPeriodicScanDateKey = "scan.last_periodic_date" - cloudAutoSyncCompletedDateForm = "2006-01-02" -) +const localLastPeriodicScanDateKey = "scan.last_periodic_date" // NewSchedulerService is the constructor. func NewSchedulerService( @@ -87,7 +81,6 @@ func NewSchedulerService( scanner *ScannerService, transcoder *TranscoderService, organizer *OrganizerService, - storageCfg *StorageConfigService, hub *Hub, cacheDir string, ) *SchedulerService { @@ -97,7 +90,6 @@ func NewSchedulerService( scanner: scanner, transcoder: transcoder, organizer: organizer, - storageCfg: storageCfg, hub: hub, cacheDir: cacheDir, now: time.Now, @@ -113,16 +105,6 @@ func (s *SchedulerService) Start(ctx context.Context) { interval: 24 * time.Hour, run: s.jobScanLibraries, }, - { - name: "cloud_sync", - interval: s.cloudSyncInterval(ctx), - run: s.jobSyncCloudLibraries, - }, - { - name: "cloud_upload", - interval: s.cloudUploadInterval(ctx), - run: s.jobUploadLocalToCloud, - }, { name: "organize_source", interval: s.organizeSourceInterval(ctx), diff --git a/internal/service/scheduler_cloud.go b/internal/service/scheduler_cloud.go deleted file mode 100644 index 2bd643b..0000000 --- a/internal/service/scheduler_cloud.go +++ /dev/null @@ -1,201 +0,0 @@ -package service - -import ( - "context" - "strconv" - "strings" - "time" - - "go.uber.org/zap" -) - -const ( - cloudAutoSyncEnabledKey = "cloud.auto_sync_enabled" - cloudSyncIntervalSecondsKey = "cloud.sync_interval_seconds" - cloudLastAutoSyncDateKey = "cloud.last_auto_sync_date" - cloudAutoSyncWindowStartHour = 23 - cloudAutoSyncWindowEndHour = 5 -) - -// jobUploadLocalToCloud copies local media files into the configured external -// storage backend. It is opt-in and never deletes the local source files. -func (s *SchedulerService) jobUploadLocalToCloud(ctx context.Context) error { - manual, _ := ctx.Value(schedulerManualRunKey{}).(bool) - if s.storageCfg == nil || (!manual && !s.autoCloudUploadEnabled(ctx)) { - return nil - } - input := s.cloudUploadInput(ctx) - if strings.TrimSpace(input.Type) == "" || strings.TrimSpace(input.SourcePath) == "" { - return nil - } - res, err := s.storageCfg.UploadLocal(ctx, input) - if s.log != nil && res != nil { - s.log.Info("cloud upload finished", - zap.String("type", input.Type), - zap.String("source", res.SourcePath), - zap.String("dest", res.DestPath), - zap.Int("uploaded", res.Uploaded), - zap.Int("skipped", res.Skipped), - zap.Int64("bytes", res.Bytes), - zap.Int("errors", len(res.Errors)), - ) - } - return err -} - -func (s *SchedulerService) cloudUploadInput(ctx context.Context) CloudUploadInput { - get := func(key string) string { - if s.repo == nil || s.repo.Setting == nil { - return "" - } - v, _ := s.repo.Setting.Get(ctx, key) - return strings.TrimSpace(v) - } - return CloudUploadInput{ - Type: get(CloudUploadProviderKey), - SourcePath: get(CloudUploadSourceDirKey), - DestPath: get(CloudUploadDestPathKey), - Recursive: parseBoolSetting(get(CloudUploadRecursiveKey), true), - IncludeSidecars: parseBoolSetting(get(CloudUploadSidecarsKey), true), - Overwrite: parseBoolSetting(get(CloudUploadOverwriteKey), false), - TransferMode: get(CloudUploadTransferModeKey), - } -} - -func (s *SchedulerService) autoCloudUploadEnabled(ctx context.Context) bool { - if s.repo == nil || s.repo.Setting == nil { - return false - } - v, err := s.repo.Setting.Get(ctx, CloudUploadAutoEnabledKey) - if err != nil { - return false - } - return parseBoolSetting(v, false) -} - -func (s *SchedulerService) cloudUploadInterval(ctx context.Context) time.Duration { - const fallback = time.Hour - if s.repo == nil || s.repo.Setting == nil { - return fallback - } - v, err := s.repo.Setting.Get(ctx, CloudUploadIntervalSecondsKey) - if err != nil { - return fallback - } - seconds, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil || seconds <= 0 { - return fallback - } - if seconds < 300 { - seconds = 300 - } - return time.Duration(seconds) * time.Second -} - -// jobSyncCloudLibraries keeps mounted cloud:// libraries refreshed without -// enabling full disk scans. It imports remote cloud files as STRM-backed media -// rows; the actual bytes stay on the provider and playback continues through -// /api/cloud/play 302/proxy. -func (s *SchedulerService) jobSyncCloudLibraries(ctx context.Context) error { - manual, _ := ctx.Value(schedulerManualRunKey{}).(bool) - if s.scanner == nil || (!manual && !s.autoCloudSyncDue(ctx, s.currentTime())) { - return nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - return err - } - libs = FilterScannableCloudLibraries(ctx, s.repo, libs) - var firstErr error - for _, l := range libs { - if !l.Enabled { - continue - } - if _, ok := ParseCloudLibraryMount(l.Path); !ok { - continue - } - if _, err := s.scanner.ScanLibraryWithoutAutoScrape(ctx, l.ID); err != nil { - s.log.Warn("cloud sync failed", zap.String("library", l.ID), zap.Error(err)) - if firstErr == nil { - firstErr = err - } - } - } - if firstErr != nil { - return firstErr - } - if !manual { - _ = s.markCloudAutoSyncCompleted(ctx, s.currentTime()) - } - return nil -} - -func (s *SchedulerService) autoCloudSyncEnabled(ctx context.Context) bool { - if s.repo == nil || s.repo.Setting == nil { - return false - } - v, err := s.repo.Setting.Get(ctx, cloudAutoSyncEnabledKey) - if err != nil { - return false - } - return parseBoolSetting(v, false) -} - -func (s *SchedulerService) autoCloudSyncDue(ctx context.Context, now time.Time) bool { - if !s.autoCloudSyncEnabled(ctx) || !cloudAutoSyncInWindow(now) { - return false - } - if s.repo == nil || s.repo.Setting == nil { - return true - } - last, err := s.repo.Setting.Get(ctx, cloudLastAutoSyncDateKey) - if err != nil { - return true - } - return strings.TrimSpace(last) != cloudAutoSyncWindowDate(now) -} - -func cloudAutoSyncInWindow(now time.Time) bool { - hour := now.In(time.Local).Hour() - if cloudAutoSyncWindowStartHour == cloudAutoSyncWindowEndHour { - return true - } - if cloudAutoSyncWindowStartHour < cloudAutoSyncWindowEndHour { - return hour >= cloudAutoSyncWindowStartHour && hour < cloudAutoSyncWindowEndHour - } - return hour >= cloudAutoSyncWindowStartHour || hour < cloudAutoSyncWindowEndHour -} - -func cloudAutoSyncWindowDate(now time.Time) string { - local := now.In(time.Local) - if cloudAutoSyncWindowStartHour > cloudAutoSyncWindowEndHour && local.Hour() < cloudAutoSyncWindowEndHour { - local = local.AddDate(0, 0, -1) - } - return local.Format(cloudAutoSyncCompletedDateForm) -} - -func (s *SchedulerService) markCloudAutoSyncCompleted(ctx context.Context, now time.Time) error { - if s.repo == nil || s.repo.Setting == nil { - return nil - } - return s.repo.Setting.Set(ctx, cloudLastAutoSyncDateKey, cloudAutoSyncWindowDate(now)) -} - -func (s *SchedulerService) cloudSyncInterval(ctx context.Context) time.Duration { - const fallback = 30 * time.Minute - if s.repo == nil || s.repo.Setting == nil { - return fallback - } - v, err := s.repo.Setting.Get(ctx, cloudSyncIntervalSecondsKey) - if err != nil { - return fallback - } - seconds, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil || seconds <= 0 { - return fallback - } - if seconds < 300 { - seconds = 300 - } - return time.Duration(seconds) * time.Second -} diff --git a/internal/service/scheduler_cloud_sync_test.go b/internal/service/scheduler_cloud_sync_test.go deleted file mode 100644 index ef5ecca..0000000 --- a/internal/service/scheduler_cloud_sync_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSchedulerCloudSyncImportsMountedCloudLibrary(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if path != "/" { - t.Fatalf("unexpected openlist path %q", path) - } - return []openListTestEntry{{Name: "Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - local := model.Library{Name: "电影", Path: "/media/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &local); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 电影", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "cloud.auto_sync_enabled", "true"); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - scheduler.now = func() time.Time { return fixedNightlySyncTime() } - - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("cloud sync: %v", err) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", "cloud://openlist/Cloud.Movie.2026.mkv").Error; err != nil { - t.Fatalf("cloud media not imported: %v", err) - } - if media.STRMURL != "/api/cloud/play/openlist?ref=%2FCloud.Movie.2026.mkv" { - t.Fatalf("strm url = %q", media.STRMURL) - } -} - -func TestSchedulerCloudSyncRunsOnlyOnceInsideNightlyWindow(t *testing.T) { - var requests atomic.Int32 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - requests.Add(1) - if path != "/" { - t.Fatalf("unexpected openlist path %q", path) - } - return []openListTestEntry{{Name: "Nightly.Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), cloudAutoSyncEnabledKey, "true"); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - - scheduler.now = func() time.Time { - return time.Date(2026, 6, 11, 22, 30, 0, 0, time.Local) - } - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("cloud sync outside window: %v", err) - } - if got := requests.Load(); got != 0 { - t.Fatalf("outside nightly window made %d requests, want 0", got) - } - - scheduler.now = func() time.Time { return fixedNightlySyncTime() } - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("cloud sync inside window: %v", err) - } - if got := requests.Load(); got != 1 { - t.Fatalf("inside nightly window requests = %d, want 1", got) - } - if got := countMedia(t, repos); got != 1 { - t.Fatalf("media count = %d, want 1", got) - } - - scheduler.now = func() time.Time { - return time.Date(2026, 6, 12, 4, 15, 0, 0, time.Local) - } - if !cloudAutoSyncInWindow(scheduler.now()) { - t.Fatalf("04:15 should still be inside overnight cloud sync window") - } - if got := cloudAutoSyncWindowDate(scheduler.now()); got != "2026-06-11" { - t.Fatalf("04:15 should belong to previous nightly window, got %s", got) - } - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("second cloud sync same overnight window: %v", err) - } - if got := requests.Load(); got != 1 { - t.Fatalf("same overnight auto sync should not rerun, requests = %d", got) - } - - scheduler.now = func() time.Time { - return time.Date(2026, 6, 12, 5, 0, 0, 0, time.Local) - } - if cloudAutoSyncInWindow(scheduler.now()) { - t.Fatalf("05:00 should be outside overnight cloud sync window") - } -} - -func TestSchedulerRunNowCloudSyncBypassesNightlyWindow(t *testing.T) { - var requests atomic.Int32 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - requests.Add(1) - return []openListTestEntry{{Name: "Manual.Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - scheduler.now = func() time.Time { - return time.Date(2026, 6, 11, 10, 0, 0, 0, time.Local) - } - scheduler.jobs = []*scheduledJob{{ - name: "cloud_sync", - interval: time.Minute, - run: scheduler.jobSyncCloudLibraries, - }} - - if err := scheduler.RunNow(t.Context(), "cloud_sync"); err != nil { - t.Fatalf("manual cloud sync: %v", err) - } - if got := requests.Load(); got != 1 { - t.Fatalf("manual cloud sync requests = %d, want 1", got) - } -} - -func TestSchedulerCloudSyncDisabledByDefault(t *testing.T) { - var requests atomic.Int32 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - requests.Add(1) - return []openListTestEntry{{Name: "Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("disabled cloud sync should be a no-op: %v", err) - } - if got := requests.Load(); got != 0 { - t.Fatalf("cloud sync made %d upstream requests while disabled by default", got) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count = %d, want 0 while cloud sync disabled by default", got) - } -} - -func fixedNightlySyncTime() time.Time { - return time.Date(2026, 6, 11, 23, 30, 0, 0, time.Local) -} diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go index 5f17a3a..e93af0a 100644 --- a/internal/service/scheduler_local_jobs.go +++ b/internal/service/scheduler_local_jobs.go @@ -31,13 +31,6 @@ func (s *SchedulerService) jobScanLibraries(ctx context.Context) error { if !l.Enabled { continue } - if _, ok := ParseCloudLibraryMount(l.Path); ok { - // 云盘库由 cloud_sync 任务在夜间窗口低频同步;周期性整库 - // 重扫只面向本地磁盘库。否则十几个云盘库每小时全量遍历 - // 会把 CPU/网络长期吃满,还会占住唯一的云扫描槽位,让 - // 手动扫描看起来一直"卡死"在排队。 - continue - } if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil { s.log.Warn("scheduled scan failed", zap.String("library", l.ID), zap.Error(err)) @@ -74,14 +67,14 @@ func (s *SchedulerService) periodicScanDue(ctx context.Context, now time.Time) b if err != nil { return true } - return strings.TrimSpace(last) != now.In(time.Local).Format(cloudAutoSyncCompletedDateForm) + return strings.TrimSpace(last) != now.In(time.Local).Format("2006-01-02") } func (s *SchedulerService) markPeriodicScanCompleted(ctx context.Context, now time.Time) error { if s.repo == nil || s.repo.Setting == nil { return nil } - return s.repo.Setting.Set(ctx, localLastPeriodicScanDateKey, now.In(time.Local).Format(cloudAutoSyncCompletedDateForm)) + return s.repo.Setting.Set(ctx, localLastPeriodicScanDateKey, now.In(time.Local).Format("2006-01-02")) } // jobOrganizeSource periodically organizes the configured staging/download diff --git a/internal/service/scheduler_organize_test.go b/internal/service/scheduler_organize_test.go index cf39a56..1c1fea9 100644 --- a/internal/service/scheduler_organize_test.go +++ b/internal/service/scheduler_organize_test.go @@ -31,7 +31,7 @@ func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) { } organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") if err := scheduler.jobOrganizeSource(t.Context()); err != nil { t.Fatalf("disabled organize source job should be a no-op: %v", err) } @@ -61,7 +61,7 @@ func TestSchedulerOrganizeSourceUsesConfiguredSourceAndDestination(t *testing.T) } organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") if err := scheduler.jobOrganizeSource(t.Context()); err != nil { t.Fatalf("organize source job: %v", err) } @@ -90,7 +90,7 @@ func TestSchedulerRunNowOrganizeSourceBypassesDisabledSwitch(t *testing.T) { } organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") scheduler.jobs = []*scheduledJob{{ name: "organize_source", interval: time.Minute, @@ -136,7 +136,7 @@ func TestSchedulerOrganizeSourceSyncsVisibilityWhenTargetAlreadyExists(t *testin t.Fatalf("seed organize destination: %v", err) } scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scheduler := NewSchedulerService(zap.NewNop(), repos, scanner, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, scanner, nil, organizer, NewHub(zap.NewNop()), "") scheduler.jobs = []*scheduledJob{{ name: "organize_source", interval: time.Minute, diff --git a/internal/service/scheduler_scan_test.go b/internal/service/scheduler_scan_test.go index 4f65113..d8686dd 100644 --- a/internal/service/scheduler_scan_test.go +++ b/internal/service/scheduler_scan_test.go @@ -28,7 +28,7 @@ func TestSchedulerPeriodicLocalScanRunsAtMostOncePerDay(t *testing.T) { } log := zap.NewNop() scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, nil, NewHub(log), "") + scheduler := NewSchedulerService(log, repos, scanner, nil, nil, NewHub(log), "") scheduler.now = func() time.Time { return time.Date(2026, 6, 20, 10, 0, 0, 0, time.Local) } @@ -75,7 +75,7 @@ func TestSchedulerManualLocalScanBypassesDailyPeriodicLimit(t *testing.T) { } log := zap.NewNop() scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, nil, NewHub(log), "") + scheduler := NewSchedulerService(log, repos, scanner, nil, nil, NewHub(log), "") scheduler.now = func() time.Time { return time.Date(2026, 6, 20, 10, 0, 0, 0, time.Local) } diff --git a/internal/service/scheduler_test.go b/internal/service/scheduler_test.go index 66f2106..6c5de3c 100644 --- a/internal/service/scheduler_test.go +++ b/internal/service/scheduler_test.go @@ -12,7 +12,7 @@ import ( ) func TestSchedulerRunNowAsyncSurvivesCallerCancellation(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") started := make(chan struct{}) release := make(chan struct{}) finished := make(chan struct{}) @@ -66,7 +66,7 @@ func TestSchedulerRunNowAsyncSurvivesCallerCancellation(t *testing.T) { } func TestSchedulerRunNowAsyncRejectsDuplicateRun(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") started := make(chan struct{}) release := make(chan struct{}) scheduler.jobs = []*scheduledJob{{ @@ -90,7 +90,7 @@ func TestSchedulerRunNowAsyncRejectsDuplicateRun(t *testing.T) { } func TestSchedulerStartDoesNotRegisterSubscriptionPullJob(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") ctx, cancel := context.WithCancel(t.Context()) cancel() scheduler.Start(ctx) @@ -104,7 +104,7 @@ func TestSchedulerStartDoesNotRegisterSubscriptionPullJob(t *testing.T) { } func TestSchedulerLoopWaitsIntervalAfterSlowRun(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") ctx, cancel := context.WithCancel(t.Context()) defer cancel() diff --git a/internal/service/scraper_library.go b/internal/service/scraper_library.go index 5d71af3..2b4bab3 100644 --- a/internal/service/scraper_library.go +++ b/internal/service/scraper_library.go @@ -109,7 +109,6 @@ func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, l } if err := s.EnrichOneWithOptions(ctx, &rows[i], runOptions); err != nil { s.log.Warn("enrich failed", zap.String("media", rows[i].ID), zap.Error(err)) - s.notifyScrapeFailed(rows[i], err) result.Failed++ continue } @@ -194,22 +193,6 @@ func shouldScrapeCandidateRow(media model.Media) bool { return organizeMediaTitleLooksLikeRelease(media.Title) } -func (s *ScraperService) notifyScrapeFailed(m model.Media, err error) { - if s == nil || s.notify == nil || err == nil { - return - } - body := strings.TrimSpace(m.Title) - if body == "" { - body = m.Path - } - body = "媒体:" + body + "\n错误:" + err.Error() - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - s.notify.Broadcast(ctx, "MediaStationGo 刮削失败", body, EventScrapeFailed) - }() -} - func (s *ScraperService) scrapeDelay(ctx context.Context) time.Duration { minMS := s.scrapeDelaySetting(ctx, "scrape.delay_min_ms", defaultScrapeDelayMinMS) maxMS := s.scrapeDelaySetting(ctx, "scrape.delay_max_ms", defaultScrapeDelayMaxMS) diff --git a/internal/service/scraper_library_test.go b/internal/service/scraper_library_test.go index 4089dc3..9f7afd4 100644 --- a/internal/service/scraper_library_test.go +++ b/internal/service/scraper_library_test.go @@ -116,47 +116,3 @@ func TestScrapeCandidateRowsPrioritizeLibraryArtworkBeforeEpisodes(t *testing.T) t.Fatalf("scrape order = [%s, %s], want series-level row before episode row", got[0].ID, got[1].ID) } } - -func TestEnrichLibraryIncludesMergedCloudLibraryMedia(t *testing.T) { - scraper, repos, closeServer := newTestScraper(t) - defer closeServer() - - local := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true} - cloud := model.Library{ - Name: "OpenList · 番剧", - Path: BuildCloudLibraryPath("openlist", "/番剧", "/番剧"), - Type: "tv", - Enabled: true, - } - if err := repos.DB.Create(&local).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&cloud).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.Media{ - LibraryID: cloud.ID, - Title: "间谍过家家", - Path: "cloud://openlist/番剧/间谍过家家 - S02E02.mkv", - SeasonNum: 2, - EpisodeNum: 2, - ScrapeStatus: "pending", - }).Error; err != nil { - t.Fatal(err) - } - - result, err := scraper.EnrichLibraryDetailed(t.Context(), local.ID, true) - if err != nil { - t.Fatal(err) - } - if result.Matched != 1 || result.Processed != 1 || result.Candidates != 1 || result.Failed != 0 { - t.Fatalf("result=%+v, want merged cloud media to be scraped once", result) - } - var got model.Media - if err := repos.DB.First(&got, "library_id = ?", cloud.ID).Error; err != nil { - t.Fatal(err) - } - if got.ScrapeStatus != "matched" || got.TMDbID != 12345 { - t.Fatalf("merged cloud media was not enriched: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID) - } -} diff --git a/internal/service/scraper_query_paths.go b/internal/service/scraper_query_paths.go index f28ccda..236a6ef 100644 --- a/internal/service/scraper_query_paths.go +++ b/internal/service/scraper_query_paths.go @@ -67,6 +67,15 @@ func cleanSlashPath(value string) string { return strings.TrimRight(value, "/") } +func pathBaseSlash(value string) string { + value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") + if value == "" { + return "" + } + parts := strings.Split(value, "/") + return parts[len(parts)-1] +} + func comparableLibraryRoot(libraryRoot string) string { if info, ok := ParseCloudLibraryMount(libraryRoot); ok { if strings.TrimSpace(info.DisplayDir) == "" { diff --git a/internal/service/scraper_service.go b/internal/service/scraper_service.go index d898a87..e3150d2 100644 --- a/internal/service/scraper_service.go +++ b/internal/service/scraper_service.go @@ -22,7 +22,6 @@ type ScraperService struct { fanart *FanartProvider adult *AdultProvider hub *Hub - notify *NotifyChannelService cache *RuntimeCacheService images *ImageProxy } @@ -53,12 +52,6 @@ func (s *ScraperService) SetDouban(douban *DoubanProvider) { s.douban = douban } -func (s *ScraperService) SetNotifyChannels(notify *NotifyChannelService) { - if s != nil { - s.notify = notify - } -} - func (s *ScraperService) SetRuntimeCache(cache *RuntimeCacheService) *ScraperService { if s != nil { s.cache = cache diff --git a/internal/service/security_safety_test.go b/internal/service/security_safety_test.go index 921a36e..c5dede3 100644 --- a/internal/service/security_safety_test.go +++ b/internal/service/security_safety_test.go @@ -7,28 +7,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" ) -func TestValidateSTRMProxyURLBlocksPrivateTargets(t *testing.T) { - blocked := []string{ - "http://127.0.0.1/video.mkv", - "http://192.168.1.2/video.mkv", - "http://169.254.169.254/latest/meta-data", - "file:///etc/passwd", - } - for _, raw := range blocked { - if _, err := validateSTRMProxyURL(raw); err == nil { - t.Fatalf("validateSTRMProxyURL(%q) allowed unsafe target", raw) - } - } -} - -func TestValidateSTRMProxyURLAllowsPublicHTTP(t *testing.T) { - for _, raw := range []string{"https://example.com/video.mkv", "http://8.8.8.8/video.mkv"} { - if _, err := validateSTRMProxyURL(raw); err != nil { - t.Fatalf("validateSTRMProxyURL(%q) = %v, want nil", raw, err) - } - } -} - func TestBackupFilePathRejectsTraversal(t *testing.T) { svc := &BackupService{cfg: &config.Config{}} svc.cfg.App.DataDir = t.TempDir() diff --git a/internal/service/service.go b/internal/service/service.go index 9a298f2..c89cfff 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -31,46 +31,30 @@ type Container struct { Bangumi *BangumiProvider TheTVDB *TheTVDBProvider Fanart *FanartProvider - Scraper *ScraperService - Discover *DiscoverService - Playback *PlaybackService + Scraper *ScraperService + Playback *PlaybackService ImageProxy *ImageProxy Watcher *WatcherService - Downloads *DownloadService - Subscription *SubscriptionService Subtitle *SubtitleService - Stats *StatsService Profile *ProfileService Audit *AuditService NFO *NFOService - AI *AIService APIConfig *APIConfigService Crypto *CryptoService - Duplicate *DuplicateService FileManager *FileManagerService DLNA *DLNAService Scheduler *SchedulerService Storage *StorageService Emby *EmbyService Backup *BackupService - Notifier *NotifierService - NotifyChannels *NotifyChannelService - TelegramBot *TelegramBotService PlayProfiles *PlayProfileService Permissions *PermissionService - StorageCfg *StorageConfigService - STRM *STRMService SystemUpdate *SystemUpdateService - DownloadClients *DownloadClientService - Assistant *AssistantService Organizer *OrganizerService OrganizePipeline *OrganizePipelineService Douban *DoubanProvider Token *TokenService ApiConfig *ApiConfigService - DownloadMgr *DownloadManager - Notify *NotifyService - Site *SiteService Device *DeviceService Cache *RuntimeCacheService Sessions *SessionTrackerService @@ -96,14 +80,9 @@ func (c *Container) Boot() { if err := c.NormalizeLocalLibraryPaths(c.stopCtx); err != nil { c.Log.Warn("normalize local library paths failed", zap.Error(err)) } - if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil { - c.Log.Warn("normalize cloud library types failed", zap.Error(err)) - } if err := c.Watcher.Start(c.stopCtx); err != nil { c.Log.Warn("watcher start failed", zap.Error(err)) } - c.Downloads.Start(c.stopCtx) - c.Subscription.Start(c.stopCtx) if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil { c.Log.Warn("api config seed failed", zap.Error(err)) } @@ -112,12 +91,6 @@ func (c *Container) Boot() { // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) - // 云盘存储健康检查 - c.BootCloudStorageHealthCheck(c.stopCtx) - - // 自动扫描云盘媒体库,使内容对所有用户立即可见 - c.BootCloudLibraries(c.stopCtx) - // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { @@ -163,12 +136,6 @@ func (c *Container) Close() { if c.Watcher != nil { c.Watcher.Stop() } - if c.Subscription != nil { - c.Subscription.Stop() - } - if c.Downloads != nil { - c.Downloads.Stop() - } if c.Transcoder != nil { c.Transcoder.StopAll() } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index d3dbbe7..8abdb30 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -37,7 +37,6 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository. builder.initContentServices() builder.initAccessAndStorageServices() builder.initIdentityServices() - builder.initSiteDownloadServices() builder.initImageProxy() builder.attachRuntimeContext() return builder.c @@ -92,7 +91,6 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Organizer = NewOrganizerService(b.cfg, b.log, b.repos) b.c.Organizer.SetProbe(b.c.FFprobe) b.c.Organizer.SetScraper(b.c.Scraper) - b.c.Discover = NewDiscoverService(b.log, b.c.TMDb) b.c.Transcoder = NewTranscoderService(b.cfg, b.log, b.repos, b.c.WSHub) b.c.Scan = NewScannerService(b.cfg, b.log, b.repos, b.c.WSHub, b.c.FFprobe, b.c.Scraper) b.c.Scan.SetOrganizer(b.c.Organizer) @@ -100,22 +98,15 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.OrganizePipeline = NewOrganizePipelineService(b.log, b.repos, b.c.Organizer, b.c.Scan, b.c.Tasks) b.c.Watcher = NewWatcherService(b.log, b.repos, b.c.Scan) b.c.NFO = NewNFOService(b.log, b.repos) - b.c.AI = NewAIService(b.cfg, b.log, b.c.APIConfig) - b.c.Duplicate = NewDuplicateService(b.log, b.repos, b.c.WSHub) b.c.FileManager = NewFileManagerService(b.cfg, b.log, b.repos) b.c.DLNA = NewDLNAService(b.log) b.c.Storage = NewStorageService(b.log, b.repos) b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos) b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB) - b.c.Notifier = NewNotifierService(b.log, b.repos) - b.c.NotifyChannels = NewNotifyChannelService(b.log, b.repos) - b.c.Scan.SetNotifyChannels(b.c.NotifyChannels) - b.c.Scraper.SetNotifyChannels(b.c.NotifyChannels) b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache) b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder) b.c.Playback = NewPlaybackService(b.log, b.repos) b.c.Subtitle = NewSubtitleService(b.log, b.repos) - b.c.Stats = NewStatsService(b.log, b.repos).SetRuntimeCache(b.c.Cache) b.c.Profile = NewProfileService(b.log, b.repos) b.c.Audit = NewAuditService(b.log, b.repos) } @@ -123,18 +114,11 @@ func (b *serviceContainerBuilder) initContentServices() { func (b *serviceContainerBuilder) initAccessAndStorageServices() { b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos) b.c.Permissions = NewPermissionService(b.log, b.repos) - b.c.StorageCfg = NewStorageConfigService(b.log, b.repos, b.c.Crypto) - b.c.STRM = NewSTRMService(b.log, b.repos, b.cfg) - b.c.Scan.SetStorageConfig(b.c.StorageCfg) - b.c.Subtitle.SetStorageConfig(b.c.StorageCfg) b.c.Emby.SetRuntimeCache(b.c.Cache) - b.c.Emby.SetCloudProbe(b.c.StorageCfg, b.c.FFprobe) b.c.Emby.SetSubtitleService(b.c.Subtitle) - b.c.DownloadClients = NewDownloadClientService(b.log, b.repos) - b.c.Assistant = NewAssistantService(b.log, b.repos, b.c.AI) b.c.Scheduler = NewSchedulerService( b.log, b.repos, b.c.Scan, b.c.Transcoder, - b.c.Organizer, b.c.StorageCfg, b.c.WSHub, b.cfg.Cache.CacheDir, + b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir, ) b.c.Scheduler.SetTaskTracker(b.c.Tasks) b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline) @@ -146,28 +130,7 @@ func (b *serviceContainerBuilder) initIdentityServices() { b.c.Sessions = NewSessionTrackerService(b.log) b.c.Device = NewDeviceService(b.log, b.repos) b.c.Device.SetSessionTracker(b.c.Sessions) - b.c.TelegramBot = NewTelegramBotService(b.log, b.repos, b.c.Crypto, b.c.Auth) - b.c.TelegramBot.SetDeviceService(b.c.Device) - b.c.TelegramBot.SetBackupService(b.c.Backup) - // Device enforcement notifies users through their Telegram binding before destructive actions. - b.c.Device.SetNotifier(b.c.TelegramBot.NotifyUserByID) b.c.ApiConfig = NewApiConfigService(b.cfg, b.log, b.repos, b.c.Crypto) - b.c.DownloadMgr = NewDownloadManager(b.log, b.repos, b.c.Crypto) - b.c.Notify = NewNotifyService(b.log, b.repos, b.c.Crypto) -} - -func (b *serviceContainerBuilder) initSiteDownloadServices() { - b.c.Site = NewSiteService(b.log, b.repos, b.flareSolverrURL()) - b.c.Downloads = NewDownloadService(b.log, b.repos, b.c.WSHub, b.c.Organizer, b.c.Site) - b.c.Downloads.SetDownloadManager(b.c.DownloadMgr) - b.c.Organizer.SetActiveDownloadPathProvider(b.c.Downloads.ActiveDownloadPaths) - b.c.Downloads.SetScanner(b.c.Scan) - b.c.Downloads.SetTaskTracker(b.c.Tasks) - b.c.Downloads.SetOrganizePipeline(b.c.OrganizePipeline) - b.c.Downloads.SetNotifyChannels(b.c.NotifyChannels) - b.c.Subscription = NewSubscriptionService(b.cfg, b.log, b.repos, b.c.Downloads, b.c.Site, b.c.WSHub) - b.c.Subscription.SetScraper(b.c.Scraper) - b.c.Subscription.SetNotifyChannels(b.c.NotifyChannels) } func (b *serviceContainerBuilder) initImageProxy() { @@ -175,7 +138,6 @@ func (b *serviceContainerBuilder) initImageProxy() { b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots) b.c.Scan.SetImageProxy(b.c.ImageProxy) b.c.Scraper.SetImageProxy(b.c.ImageProxy) - b.c.Discover.SetImageProxy(b.c.ImageProxy) } func (b *serviceContainerBuilder) libraryRoots() []string { @@ -201,13 +163,6 @@ func (b *serviceContainerBuilder) libraryRoots() []string { return roots } -func (b *serviceContainerBuilder) flareSolverrURL() string { - if b.cfg.FlareSolverr.Enabled && b.cfg.FlareSolverr.URL != "" { - return b.cfg.FlareSolverr.URL - } - return "" -} - func (b *serviceContainerBuilder) attachRuntimeContext() { b.c.stopCtx, b.c.stopCancel = context.WithCancel(context.Background()) } diff --git a/internal/service/service_library_normalize.go b/internal/service/service_library_normalize.go index 7c44f9a..d33bedd 100644 --- a/internal/service/service_library_normalize.go +++ b/internal/service/service_library_normalize.go @@ -79,28 +79,6 @@ func normalizePersistedLocalLibraryPath(pathValue string) string { } func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error { - if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil { - return nil - } - libs, err := c.Repo.Library.List(ctx) - if err != nil { - return err - } - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - continue - } - want := InferCloudMountMediaType(info.DisplayDir, lib.Name) - if want == "" || want == lib.Type { - continue - } - if err := c.Repo.DB.WithContext(ctx). - Model(&model.Library{}). - Where("id = ?", lib.ID). - Update("type", want).Error; err != nil { - return err - } - } + // 网盘后端已移除,不再存在 cloud:// 挂载库类型需修正。 return nil } diff --git a/internal/service/session_tracker_test.go b/internal/service/session_tracker_test.go index f42530d..04a431a 100644 --- a/internal/service/session_tracker_test.go +++ b/internal/service/session_tracker_test.go @@ -1,7 +1,6 @@ package service import ( - "strings" "testing" "time" @@ -135,61 +134,6 @@ func TestLogoutKeepsRealtimeLastActivityWithoutOnlineSession(t *testing.T) { } } -func TestBotDevicesIncludesRealtimeSessionOnlyDevices(t *testing.T) { - repos, bot := newBotTestService(t) - user := model.User{Base: model.Base{ID: "u1"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(t.Context(), &user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{TelegramUserID: 9103, ChatID: 9103, UserID: user.ID}).Error; err != nil { - t.Fatal(err) - } - tracker := NewSessionTrackerService(zap.NewNop()) - now := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) - tracker.now = func() time.Time { return now } - tracker.RecordActivity(t.Context(), user.ID, user.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8") - device := NewDeviceService(zap.NewNop(), repos) - device.SetSessionTracker(tracker) - bot.SetDeviceService(device) - - reply := bot.replyDevices(t.Context(), &TelegramMessage{ - From: TelegramUser{ID: 9103, Username: "viewer"}, - Chat: TelegramChat{ID: 9103, Type: "private"}, - }) - - if !strings.Contains(reply.Text, "Apple TV / Yamby") || !strings.Contains(reply.Text, "在线") { - t.Fatalf("reply should include realtime online device, got %q", reply.Text) - } - if !strings.Contains(reply.Text, "06-21 11:00") { - t.Fatalf("reply should use realtime last seen time, got %q", reply.Text) - } -} - -func TestBotUserInfoUsesRealtimeLastLogin(t *testing.T) { - repos, bot := newBotTestService(t) - now := time.Date(2026, 6, 21, 13, 45, 0, 0, time.UTC) - old := now.Add(-6 * time.Hour) - user := model.User{Base: model.Base{ID: "u1"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &old} - if err := repos.User.Create(t.Context(), &user); err != nil { - t.Fatal(err) - } - tracker := NewSessionTrackerService(zap.NewNop()) - tracker.now = func() time.Time { return now } - tracker.RecordActivity(t.Context(), user.ID, user.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8") - device := NewDeviceService(zap.NewNop(), repos) - device.SetSessionTracker(tracker) - bot.SetDeviceService(device) - - reply := bot.cmdMgoUserInfo(t.Context(), []string{"viewer"}) - - if !strings.Contains(reply.Text, "最后登录:2026-06-21 13:45") { - t.Fatalf("reply should use realtime last login, got %q", reply.Text) - } - if !strings.Contains(reply.Text, "设备:1") { - t.Fatalf("reply should count realtime device, got %q", reply.Text) - } -} - func TestRealtimeRecentLoginProtectsCleanupCandidate(t *testing.T) { repos := newSessionTrackerTestRepos(t) now := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) diff --git a/internal/service/site.go b/internal/service/site.go deleted file mode 100644 index 40a38f1..0000000 --- a/internal/service/site.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package service — site management (PT/BT tracker CRUD + connection test). -// -// SiteService owns the lifecycle of Site rows and exposes a cross-site -// search dispatcher that fans out a keyword query to every enabled site's -// adapter, collects results and returns them merged + sorted. -package service - -import ( - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// SiteService manages PT/BT site configurations. -type SiteService struct { - log *zap.Logger - repo *repository.Container - flareSolverrURL string - apiRateLimiter siteAPIRateLimiter -} - -// NewSiteService is the constructor. -func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL string) *SiteService { - return &SiteService{ - log: log, - repo: repo, - flareSolverrURL: flareSolverrURL, - apiRateLimiter: newPersistentSiteAPIRateLimiter(repo), - } -} diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go deleted file mode 100644 index 298962a..0000000 --- a/internal/service/site_adapter.go +++ /dev/null @@ -1,233 +0,0 @@ -// Package service — PT 站点适配器接口及 6 种适配器实现。 -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/helper" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。 -type SiteConfig struct { - SiteID string - Name string - Type string - URL string - AuthType string - Cookie string - APIKey string - AuthHeader string - UserAgent string // 自定义 User-Agent - Timeout time.Duration // 请求超时 - Extra map[string]string // JSON 扩展配置 - FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF) - UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站 - RateLimit bool - rateLimiter siteAPIRateLimiter -} - -// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。 -type SiteSearchResult struct { - SiteName string `json:"site_name"` - Items []TorrentItem `json:"items"` - Total int `json:"total"` - Page int `json:"page"` -} - -// TorrentItem 种子条目。 -type TorrentItem struct { - ID string `json:"id"` - Title string `json:"title"` - Subtitle string `json:"subtitle"` - Labels string `json:"labels,omitempty"` - Category string `json:"category"` - Size int64 `json:"size"` - Seeders int `json:"seeders"` - Leechers int `json:"leechers"` - Snatched int `json:"snatched"` - Free bool `json:"free"` - FreeEndAt *time.Time `json:"free_end_at"` - UploadTime time.Time `json:"upload_time"` - DetailURL string `json:"detail_url"` - DownloadURL string `json:"download_url"` -} - -// TorrentDetail 种子详情。 -type TorrentDetail struct { - ID string `json:"id"` - Title string `json:"title"` - Subtitle string `json:"subtitle"` - Category string `json:"category"` - Size int64 `json:"size"` - Seeders int `json:"seeders"` - Leechers int `json:"leechers"` - Snatched int `json:"snatched"` - Free bool `json:"free"` - FreeEndAt *time.Time `json:"free_end_at"` - UploadTime time.Time `json:"upload_time"` - DetailURL string `json:"detail_url"` - DownloadURL string `json:"download_url"` - InfoHash string `json:"info_hash,omitempty"` - ImdbID string `json:"imdb_id,omitempty"` - Description string `json:"description,omitempty"` - Files []string `json:"files,omitempty"` -} - -// SiteAdapter 站点适配器接口。 -type SiteAdapter interface { - // Authenticate 测试站点认证是否有效。 - Authenticate(ctx context.Context, cfg SiteConfig) error - - // Search 搜索种子。 - Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) - - // Browse 浏览种子列表。 - Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) - - // GetDetail 获取种子详情。 - GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) - - // GetDownloadURL 获取下载链接。 - GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) -} - -// newHTTPClient 创建带有认证头的 HTTP 客户端。 -// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量; -// 否则忽略环境变量直连。 -func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client { - secs := int(timeout.Seconds()) - if secs <= 0 { - secs = 30 - } - return helper.NewSiteHTTPClient(secs, cfg.UseProxy) -} - -func siteRequestHTTPClient(client *http.Client, cfg SiteConfig) *http.Client { - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - if client == nil || cfg.UseProxy || client.Timeout != timeout { - return newHTTPClient(cfg, timeout) - } - return client -} - -// buildRequest 构建带认证的 HTTP 请求。 -func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, body io.Reader) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, method, rawURL, body) - if err != nil { - return nil, err - } - - switch cfg.AuthType { - case "cookie": - if cfg.Cookie != "" { - req.Header.Set("Cookie", cfg.Cookie) - } - case "api_key": - if cfg.APIKey != "" { - if isYemaPTConfig(cfg) { - req.Header.Set("Authorization", cfg.APIKey) - } else { - // M-Team / UNIT3D 等开放 API 的 PT 站点使用 `x-api-key`。 - req.Header.Set("x-api-key", cfg.APIKey) - } - } - case "auth_header": - if cfg.AuthHeader != "" { - parts := strings.SplitN(cfg.AuthHeader, ":", 2) - if len(parts) == 2 { - req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) - } else { - req.Header.Set("Authorization", "Bearer "+cfg.AuthHeader) - } - } - } - - // 使用 SiteConfig 中的 UserAgent(如果提供),否则使用默认值 - userAgent := cfg.UserAgent - if userAgent == "" { - userAgent = model.DefaultUserAgent - } - req.Header.Set("User-Agent", userAgent) - return req, nil -} - -// doRequest 执行 HTTP 请求并返回响应体。 -// 当 cfg.FlareSolverrURL 已配置且方法为 GET 时,通过 FlareSolverr 代理请求 -// 以绕过 Cloudflare/WAF 挑战验证。 -func doRequest(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body io.Reader) ([]byte, int, error) { - // ── FlareSolverr 浏览器模拟路径(仅 GET) ────────────────────────── - if cfg.FlareSolverrURL != "" && method == "GET" { - timeout := int(cfg.Timeout.Seconds()) - if timeout <= 0 { - timeout = 30 - } - pageBody, err := helper.FetchURLWithFlareSolverr( - cfg.FlareSolverrURL, rawURL, cfg.Cookie, timeout, "", nil) - if err != nil { - return nil, 0, fmt.Errorf("flareSolverr: %w", err) - } - return []byte(pageBody), http.StatusOK, nil - } - - // ── 直接 HTTP 请求路径 ───────────────────────────────────────────── - req, err := buildRequest(ctx, method, rawURL, cfg, body) - if err != nil { - return nil, 0, err - } - if body != nil { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - - // 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY - // 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。 - httpClient := siteRequestHTTPClient(client, cfg) - - resp, err := httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, err - } - return data, resp.StatusCode, nil -} - -// ─── 辅助函数 ──────────────────────────────────────────────────────────────── - -// doRequestJSON 执行 JSON 请求。 -func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body []byte) ([]byte, int, error) { - req, err := buildRequest(ctx, method, rawURL, cfg, nil) - if err != nil { - return nil, 0, err - } - req.Header.Set("Content-Type", "application/json") - if body != nil { - req.Body = io.NopCloser(strings.NewReader(string(body))) - req.ContentLength = int64(len(body)) - } - - httpClient := siteRequestHTTPClient(client, cfg) - resp, err := httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, err - } - return data, resp.StatusCode, nil -} diff --git a/internal/service/site_adapter_custom_rss.go b/internal/service/site_adapter_custom_rss.go deleted file mode 100644 index f988a4a..0000000 --- a/internal/service/site_adapter_custom_rss.go +++ /dev/null @@ -1,172 +0,0 @@ -// Package service — custom RSS site adapter. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -// ─── Custom RSS 适配器 ─────────────────────────────────────────────────────── - -// CustomRSSAdapter 自定义 RSS 源适配器。 -type CustomRSSAdapter struct { - client *http.Client -} - -// NewCustomRSSAdapter 创建 Custom RSS 适配器。 -func NewCustomRSSAdapter() *CustomRSSAdapter { - return &CustomRSSAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *CustomRSSAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - // RSS 源通常不需要认证,或者认证通过 URL 参数 - if cfg.URL == "" { - return fmt.Errorf("RSS URL is required") - } - _, status, err := doRequest(ctx, a.client, "GET", cfg.URL, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - return nil -} - -func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - searchURL := cfg.URL - // If extra has search URL template, use it - if searchTpl, ok := cfg.Extra["search_url"]; ok && searchTpl != "" { - searchURL = strings.ReplaceAll(searchTpl, "{keyword}", url.QueryEscape(keyword)) - searchURL = strings.ReplaceAll(searchURL, "{page}", strconv.Itoa(page)) - } - - data, status, err := doRequest(ctx, a.client, "GET", searchURL, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - result, err := parseRSSXML(data, cfg.Name, keyword) - if err != nil { - return nil, fmt.Errorf("parse RSS: %w", err) - } - - if page > 1 { - // Simple pagination for RSS: skip items already seen - start := (page - 1) * 50 - if start < len(result.Items) { - result.Items = result.Items[start:] - } else { - result.Items = []TorrentItem{} - } - } - result.Page = page - - return result, nil -} - -func (a *CustomRSSAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - // RSS browse is essentially the same as search with empty keyword - return a.Search(ctx, cfg, "", page) -} - -func (a *CustomRSSAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - // RSS typically doesn't support detail page; return basic info - return &TorrentDetail{ - ID: id, - Title: id, - }, nil -} - -func (a *CustomRSSAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return id, nil // In RSS, the ID is often the download URL -} - -// parseRSSXML 解析 RSS XML 内容。 -func parseRSSXML(data []byte, siteName, keyword string) (*SiteSearchResult, error) { - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - } - - html := string(data) - // Simple regex-based XML parsing for RSS items - itemRegex := regexp.MustCompile(`(.*?)`) - items := itemRegex.FindAllStringSubmatch(html, -1) - - for i, item := range items { - ri := TorrentItem{} - - // Title - titleRegex := regexp.MustCompile(`(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?`) - if m := titleRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - ri.Title = strings.TrimSpace(m[1]) - } - - // Filter by keyword - if keyword != "" && !strings.Contains(strings.ToLower(ri.Title), strings.ToLower(keyword)) { - continue - } - - ri.ID = strconv.Itoa(i) - - // Link - linkRegex := regexp.MustCompile(`(?:)?`) - if m := linkRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - ri.DetailURL = strings.TrimSpace(m[1]) - ri.DownloadURL = strings.TrimSpace(m[1]) - } - - // Description - descRegex := regexp.MustCompile(`(?:)?`) - if m := descRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - desc := stripHTML(m[1]) - ri.Subtitle = desc - } - - // Size from description - sizeRegex := regexp.MustCompile(`(\d+\.?\d*)\s*(GB|MB|TB|KB)`) - if m := sizeRegex.FindStringSubmatch(item[1]); len(m) >= 3 { - ri.Size = parseSizeString(m[1], m[2]) - } - - // Category - catRegex := regexp.MustCompile(`(?:)?`) - if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - ri.Category = strings.TrimSpace(m[1]) - } - - // Date - dateRegex := regexp.MustCompile(`(?:)?`) - if m := dateRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - for _, layout := range []string{ - time.RFC1123, time.RFC1123Z, time.RFC3339, - "2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00", - } { - if t, err := time.Parse(layout, strings.TrimSpace(m[1])); err == nil { - ri.UploadTime = t - break - } - } - } - - result.Items = append(result.Items, ri) - } - - result.Total = len(result.Items) - return result, nil -} - -// TorrentDetail has a Description field used by RSS adapter. -// (Already defined above) diff --git a/internal/service/site_adapter_discuz.go b/internal/service/site_adapter_discuz.go deleted file mode 100644 index 3c66f2d..0000000 --- a/internal/service/site_adapter_discuz.go +++ /dev/null @@ -1,167 +0,0 @@ -// Package service — Discuz site adapter. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -// ─── Discuz 适配器 ─────────────────────────────────────────────────────────── - -// DiscuzAdapter 基于 Discuz! X 的站点适配器。 -type DiscuzAdapter struct { - client *http.Client -} - -// NewDiscuzAdapter 创建 Discuz 适配器。 -func NewDiscuzAdapter() *DiscuzAdapter { - return &DiscuzAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - u := cfg.URL + "/home.php?mod=space" - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status == http.StatusFound { - return fmt.Errorf("authentication failed: redirected to login") - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - body := string(data) - if strings.Contains(body, "login") && !strings.Contains(body, "我的空间") { - return fmt.Errorf("authentication failed: not logged in") - } - return nil -} - -func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("mod", "forum") - params.Set("srchtxt", keyword) - params.Set("searchsubmit", "true") - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/search.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseDiscuzHTML(string(data), cfg.Name, cfg.URL) -} - -func (a *DiscuzAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - if category != "" { - params.Set("fid", category) - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/forum.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseDiscuzHTML(string(data), cfg.Name, cfg.URL) -} - -func (a *DiscuzAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - u := cfg.URL + "/forum.php?mod=viewthread&tid=" + id - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - html := string(data) - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/forum.php?mod=viewthread&tid=" + id, - } - - // Title - titleRegex := regexp.MustCompile(`]*id="thread_subject"[^>]*>([^<]+)`) - if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Title = strings.TrimSpace(m[1]) - } - - // Extract magnet/torrent links - magnetRegex := regexp.MustCompile(`magnet:\?[^\s"'<>]+`) - if m := magnetRegex.FindString(html); m != "" { - detail.DownloadURL = m - } - torrentRegex := regexp.MustCompile(`(attachment\.php\?aid=\d+)`) - if m := torrentRegex.FindString(html); m != "" && detail.DownloadURL == "" { - detail.DownloadURL = cfg.URL + "/" + m - } - - // Description - descRegex := regexp.MustCompile(`]*class="t_fsz"[^>]*>(.*?)`) - if m := descRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Description = stripHTML(m[1]) - } - - return detail, nil -} - -func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/forum.php?mod=viewthread&tid=" + id, nil -} - -// parseDiscuzHTML 解析 Discuz HTML 响应。 -func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) { - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Page: 1, - } - - // Extract thread links - threadRegex := regexp.MustCompile(`]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)`) - matches := threadRegex.FindAllStringSubmatch(html, -1) - - for _, m := range matches { - item := TorrentItem{} - if m[1] != "" { - item.ID = m[1] - } else { - // Extract tid from URL - tidRegex := regexp.MustCompile(`tid=(\d+)`) - if tidM := tidRegex.FindStringSubmatch(m[0]); len(tidM) >= 2 { - item.ID = tidM[1] - } - } - if item.ID == "" { - continue - } - - item.Title = strings.TrimSpace(m[2]) - item.DetailURL = baseURL + "/forum.php?mod=viewthread&tid=" + item.ID - item.UploadTime = time.Now() - - result.Items = append(result.Items, item) - } - - result.Total = len(result.Items) - return result, nil -} diff --git a/internal/service/site_adapter_factory.go b/internal/service/site_adapter_factory.go deleted file mode 100644 index 3e80682..0000000 --- a/internal/service/site_adapter_factory.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// GetAdapterForType 根据站点类型返回对应的适配器实例。 -func GetAdapterForType(siteType string) SiteAdapter { - switch strings.ToLower(siteType) { - case "nexusphp": - return NewNexusPHPAdapter() - case "gazelle": - return NewGazelleAdapter() - case "unit3d": - return NewUNIT3DAdapter() - case "mteam": - return NewMTeamAdapter() - case "yemapt": - return NewYemaPTAdapter() - case "discuz": - return NewDiscuzAdapter() - case "custom_rss": - return NewCustomRSSAdapter() - default: - return NewNexusPHPAdapter() - } -} - -// NewSiteAdapter 根据站点模型创建对应的适配器。 -func NewSiteAdapter(site *model.Site) SiteAdapter { - if site == nil { - return nil - } - if isYemaPTURL(site.URL) { - return NewYemaPTAdapter() - } - return GetAdapterForType(site.Type) -} diff --git a/internal/service/site_adapter_gazelle.go b/internal/service/site_adapter_gazelle.go deleted file mode 100644 index 9972561..0000000 --- a/internal/service/site_adapter_gazelle.go +++ /dev/null @@ -1,227 +0,0 @@ -// Package service — Gazelle site adapter. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "time" -) - -// ─── Gazelle 适配器 ────────────────────────────────────────────────────────── - -// GazelleAdapter Gazelle 框架适配器(What.cd 开源)。 -type GazelleAdapter struct { - client *http.Client -} - -// NewGazelleAdapter 创建 Gazelle 适配器。 -func NewGazelleAdapter() *GazelleAdapter { - return &GazelleAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *GazelleAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - u := cfg.URL + "/ajax.php?action=index" - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return fmt.Errorf("parse response: %w", err) - } - if statusMsg, ok := result["status"].(string); ok && statusMsg == "failure" { - return fmt.Errorf("authentication failed: %v", result["error"]) - } - return nil -} - -func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("action", "browse") - params.Set("searchstr", keyword) - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/ajax.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseGazelleJSON(data, cfg.Name, cfg.URL) -} - -func (a *GazelleAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("action", "browse") - if category != "" { - params.Set("filter_cat["+category+"]", "1") - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/ajax.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseGazelleJSON(data, cfg.Name, cfg.URL) -} - -func (a *GazelleAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - params := url.Values{} - params.Set("action", "torrent") - params.Set("id", id) - - u := cfg.URL + "/ajax.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - - torrent, ok := resp["torrent"].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("torrent not found") - } - - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/torrents.php?torrentid=" + id, - DownloadURL: cfg.URL + "/torrents.php?action=download&id=" + id, - } - - if v, ok := torrent["groupName"].(string); ok { - detail.Title = v - } - if v, ok := torrent["subName"].(string); ok { - detail.Subtitle = v - } - if v, ok := torrent["size"].(float64); ok { - detail.Size = int64(v) - } - if v, ok := torrent["seeders"].(float64); ok { - detail.Seeders = int(v) - } - if v, ok := torrent["leechers"].(float64); ok { - detail.Leechers = int(v) - } - if v, ok := torrent["snatched"].(float64); ok { - detail.Snatched = int(v) - } - if v, ok := torrent["freeTorrent"].(string); ok && v == "1" { - detail.Free = true - } - if v, ok := torrent["freeTorrent"].(bool); ok { - detail.Free = v - } - if v, ok := torrent["infoHash"].(string); ok { - detail.InfoHash = v - } - if v, ok := torrent["groupDesc"].(string); ok { - detail.Description = stripHTML(v) - } - - return detail, nil -} - -func (a *GazelleAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/torrents.php?action=download&id=" + id, nil -} - -// parseGazelleJSON 解析 Gazelle JSON 响应。 -func parseGazelleJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse JSON: %w", err) - } - - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - } - - if status, ok := resp["status"].(string); ok && status == "failure" { - return result, nil - } - - results, ok := resp["results"].([]interface{}) - if !ok { - return result, nil - } - - for _, r := range results { - torrent, ok := r.(map[string]interface{}) - if !ok { - continue - } - item := TorrentItem{} - if v, ok := torrent["torrentId"].(float64); ok { - item.ID = strconv.Itoa(int(v)) - } - if v, ok := torrent["groupName"].(string); ok { - item.Title = v - } - if v, ok := torrent["artist"].(string); ok { - item.Subtitle = v - } - if v, ok := torrent["category"].(string); ok { - item.Category = v - } - if v, ok := torrent["size"].(float64); ok { - item.Size = int64(v) - } - if v, ok := torrent["seeders"].(float64); ok { - item.Seeders = int(v) - } - if v, ok := torrent["leechers"].(float64); ok { - item.Leechers = int(v) - } - if v, ok := torrent["snatched"].(float64); ok { - item.Snatched = int(v) - } - if v, ok := torrent["freeTorrent"].(string); ok && v == "1" { - item.Free = true - } - if v, ok := torrent["freeTorrent"].(bool); ok { - item.Free = v - } - if v, ok := torrent["time"].(string); ok { - if t, err := time.Parse(time.RFC3339, v); err == nil { - item.UploadTime = t - } - } - - item.DetailURL = baseURL + "/torrents.php?torrentid=" + item.ID - item.DownloadURL = baseURL + "/torrents.php?action=download&id=" + item.ID - result.Items = append(result.Items, item) - } - - if total, ok := resp["totalResults"].(float64); ok { - result.Total = int(total) - } else { - result.Total = len(result.Items) - } - return result, nil -} diff --git a/internal/service/site_adapter_mteam.go b/internal/service/site_adapter_mteam.go deleted file mode 100644 index 4bd9ad0..0000000 --- a/internal/service/site_adapter_mteam.go +++ /dev/null @@ -1,258 +0,0 @@ -// Package service — M-Team site adapter. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -// ─── MTeam 适配器 ──────────────────────────────────────────────────────────── - -// MTeamAdapter MTeam.cc 独立站适配器。 -type MTeamAdapter struct { - client *http.Client -} - -// NewMTeamAdapter 创建 MTeam 适配器。 -func NewMTeamAdapter() *MTeamAdapter { - return &MTeamAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - if strings.TrimSpace(cfg.APIKey) == "" { - return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API") - } - // 与旧版参考实现对齐: - // 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0" - // 或数值 0;兼容 M-Team v3 API 不同版本的返回。 - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil { - return fmt.Errorf("authenticate: %w", err) - } - u := cfg.URL + "/api/torrent/search" - payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}` - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload)) - if err != nil { - return mteamRequestError("authenticate", cfg, err) - } - preview := string(data) - if len(preview) > 400 { - preview = preview[:400] + "..." - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("authentication failed: status %d, body=%s", status, preview) - } - if status >= 300 && status < 400 { - return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview) - } - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parse response: %w (body=%s)", err, preview) - } - if mteamCodeOK(resp["code"]) { - return nil - } - msg, _ := resp["message"].(string) - if msg == "" { - msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"])) - } - return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview) -} - -func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - // 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。 - if page <= 0 { - page = 1 - } - payload := map[string]interface{}{ - "keyword": keyword, - "pageNumber": page, - "pageSize": 50, - } - body, _ := json.Marshal(payload) - - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil { - return nil, err - } - u := cfg.URL + "/api/torrent/search" - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body) - if err != nil { - return nil, mteamRequestError("search", cfg, err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseMTeamJSON(data, cfg.Name, cfg.URL) -} - -func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - if page <= 0 { - page = 1 - } - payload := map[string]interface{}{ - "keyword": "", - "pageNumber": page, - "pageSize": 50, - } - if category != "" { - payload["categories"] = []string{category} - } - body, _ := json.Marshal(payload) - - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil { - return nil, err - } - u := cfg.URL + "/api/torrent/search" - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body) - if err != nil { - return nil, mteamRequestError("browse", cfg, err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseMTeamJSON(data, cfg.Name, cfg.URL) -} - -func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointDetail); err != nil { - return nil, err - } - u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id) - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil) - if err != nil { - return nil, mteamRequestError("detail", cfg, err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - - dataField, ok := resp["data"].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("detail not found") - } - - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/detail/" + id, - } - - if v, ok := dataField["name"].(string); ok { - detail.Title = v - } - if v, ok := dataField["subtitle"].(string); ok { - detail.Subtitle = v - } - if v, ok := dataField["size"].(float64); ok { - detail.Size = int64(v) - } - if v, ok := dataField["status"].(map[string]interface{}); ok { - if seeders, ok := v["seeders"].(float64); ok { - detail.Seeders = int(seeders) - } - if leechers, ok := v["leechers"].(float64); ok { - detail.Leechers = int(leechers) - } - if snatched, ok := v["completed"].(float64); ok { - detail.Snatched = int(snatched) - } - } - if v, ok := dataField["free"].(bool); ok { - detail.Free = v - } - if v, ok := dataField["download"].(string); ok { - detail.DownloadURL = v - } - if v, ok := dataField["description"].(string); ok { - detail.Description = stripHTML(v) - } - - return detail, nil -} - -// GetDownloadURL 解析 M-Team 种子的真实下载链接。 -// -// M-Team v3 流程: -// -// POST /api/torrent/genDlToken?id={tid} (带 x-api-key) -// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."} -// -// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是旧版参考实现 -// _download_torrent_file 方法的子集。 -func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointDownload); err != nil { - return "", err - } - u := cfg.URL + "/api/torrent/genDlToken?id=" + id - // genDlToken 是 POST 但参数走 query string;body 留空。 - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}")) - if err != nil { - return "", mteamRequestError("genDlToken", cfg, err) - } - if status >= 300 { - return "", fmt.Errorf("genDlToken: HTTP %d", status) - } - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return "", fmt.Errorf("genDlToken parse: %w", err) - } - codeStr := "" - switch v := resp["code"].(type) { - case string: - codeStr = v - case float64: - codeStr = strconv.Itoa(int(v)) - } - if codeStr != "0" && codeStr != "200" { - msg, _ := resp["message"].(string) - if msg == "" { - msg = "unknown error" - } - return "", fmt.Errorf("genDlToken: %s", msg) - } - dl, _ := resp["data"].(string) - if dl == "" { - return "", fmt.Errorf("genDlToken: empty data field") - } - return dl, nil -} - -func mteamRequestError(action string, cfg SiteConfig, err error) error { - if err == nil { - return nil - } - if isSiteRequestTimeout(err) { - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - return fmt.Errorf("%s: M-Team API request timed out after %s; check Docker/IPv6/proxy access to api.m-team.cc or increase the site timeout to 45-60s: %w", - action, timeout.Round(time.Second), err) - } - return fmt.Errorf("%s: %w", action, err) -} - -func isSiteRequestTimeout(err error) bool { - if errors.Is(err, context.DeadlineExceeded) { - return true - } - var timeout interface{ Timeout() bool } - return errors.As(err, &timeout) && timeout.Timeout() -} diff --git a/internal/service/site_adapter_mteam_parse.go b/internal/service/site_adapter_mteam_parse.go deleted file mode 100644 index c8917ea..0000000 --- a/internal/service/site_adapter_mteam_parse.go +++ /dev/null @@ -1,135 +0,0 @@ -// Package service — M-Team search response parsing. -package service - -import ( - "encoding/json" - "fmt" - "strconv" - "time" -) - -// parseMTeamJSON 解析 MTeam v3 JSON 响应。 -// -// 响应结构(与旧版参考实现一致): -// -// { -// "code": "0", // 字符串 "0" 表示成功 -// "message": "SUCCESS", -// "data": { -// "total": "123", -// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data" -// } -// } -func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { - // 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。 - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parse JSON: %w", err) - } - - // code 兼容字符串与数字。 - codeStr := "" - switch v := raw["code"].(type) { - case string: - codeStr = v - case float64: - codeStr = strconv.Itoa(int(v)) - } - if codeStr != "" && codeStr != "0" && codeStr != "200" { - msg, _ := raw["message"].(string) - if msg == "" { - msg = fmt.Sprintf("code=%s", codeStr) - } - return nil, fmt.Errorf("mteam: %s", msg) - } - - dataField, _ := raw["data"].(map[string]interface{}) - if dataField == nil { - return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil - } - - // total 兼容字符串与数字。 - total := 0 - switch v := dataField["total"].(type) { - case string: - total, _ = strconv.Atoi(v) - case float64: - total = int(v) - } - - // data.data(v3)优先;兜底兼容旧的 data.lists。 - var rows []interface{} - switch v := dataField["data"].(type) { - case []interface{}: - rows = v - } - if rows == nil { - if v, ok := dataField["lists"].([]interface{}); ok { - rows = v - } - } - - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Total: total, - } - - for _, rawT := range rows { - t, ok := rawT.(map[string]interface{}) - if !ok { - continue - } - item := TorrentItem{} - if v, ok := t["id"].(string); ok { - item.ID = v - } else if v, ok := t["id"].(float64); ok { - item.ID = strconv.Itoa(int(v)) - } - if v, ok := t["name"].(string); ok { - item.Title = v - } - if v, ok := t["subtitle"].(string); ok { - item.Subtitle = v - } - if v, ok := t["category"].(map[string]interface{}); ok { - if name, ok := v["name"].(string); ok { - item.Category = name - } - } - if v, ok := t["size"].(float64); ok { - item.Size = int64(v) - } else if v, ok := t["size"].(string); ok { - // v3 API 把 size 序列化成字符串。 - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - item.Size = n - } - } - if v, ok := t["status"].(map[string]interface{}); ok { - if seeders, ok := v["seeders"].(float64); ok { - item.Seeders = int(seeders) - } - if leechers, ok := v["leechers"].(float64); ok { - item.Leechers = int(leechers) - } - if snatched, ok := v["completed"].(float64); ok { - item.Snatched = int(snatched) - } - } - if v, ok := t["free"].(bool); ok { - item.Free = v - } - if v, ok := t["uploadTime"].(float64); ok { - item.UploadTime = time.Unix(int64(v), 0) - } - - item.DetailURL = baseURL + "/detail/" + item.ID - // 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层 - // 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。 - // 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。 - item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID - result.Items = append(result.Items, item) - } - - return result, nil -} diff --git a/internal/service/site_adapter_nexusphp.go b/internal/service/site_adapter_nexusphp.go deleted file mode 100644 index 1700a45..0000000 --- a/internal/service/site_adapter_nexusphp.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package service — NexusPHP site adapter. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -// ─── NexusPHP 适配器 ───────────────────────────────────────────────────────── - -// NexusPHPAdapter NexusPHP 框架适配器(馒头、HDHome、CHDBits 等)。 -type NexusPHPAdapter struct { - client *http.Client -} - -// NewNexusPHPAdapter 创建 NexusPHP 适配器。 -func NewNexusPHPAdapter() *NexusPHPAdapter { - return &NexusPHPAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - // 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。 - data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - - if status == http.StatusFound { - return fmt.Errorf("authentication failed: redirected to login page") - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("authentication failed: status %d", status) - } - if status >= 400 { - return fmt.Errorf("authentication failed: status %d", status) - } - - body := string(data) - // NexusPHP 登录后页面通常包含 logout 或 userdetails; - // 仅当二者都不存在且明确显示登录表单时才判失败。 - if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") { - return nil - } - if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") { - return fmt.Errorf("authentication failed: not logged in") - } - // 状态码 OK 但页面不含明显标记时不再武断判失败。 - return nil -} - -func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("searchstr", keyword) - params.Set("search", keyword) - params.Set("search_area", "0") - params.Set("search_mode", "0") - params.Set("page", strconv.Itoa(page)) - params.Set("inclbookmarked", "0") - params.Set("incldead", "0") - - u := cfg.URL + "/torrents.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search request: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - body := string(data) - if nexusPHPPageLooksLogin(body) { - return nil, fmt.Errorf("search failed: not logged in or cookie expired") - } - return parseNexusPHPHTML(body, cfg.Name, cfg.URL) -} - -func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - if category != "" { - params.Set("cat", category) - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/torrents.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse request: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - body := string(data) - if nexusPHPPageLooksLogin(body) { - return nil, fmt.Errorf("browse failed: not logged in or cookie expired") - } - return parseNexusPHPHTML(body, cfg.Name, cfg.URL) -} - -func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - u := cfg.URL + "/details.php?id=" + id - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail request: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - return parseNexusPHPDetailHTML(string(data), id, cfg.URL) -} - -func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/download.php?id=" + id, nil -} diff --git a/internal/service/site_adapter_nexusphp_detail.go b/internal/service/site_adapter_nexusphp_detail.go deleted file mode 100644 index 6ce57a8..0000000 --- a/internal/service/site_adapter_nexusphp_detail.go +++ /dev/null @@ -1,43 +0,0 @@ -package service - -import ( - "regexp" - "strconv" - "strings" -) - -// parseNexusPHPDetailHTML 解析种子详情页。 -func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) { - detail := &TorrentDetail{ - ID: id, - DetailURL: baseURL + "/details.php?id=" + id, - } - if m := regexp.MustCompile(`]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Title = strings.TrimSpace(m[1]) - } - if m := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Subtitle = strings.TrimSpace(m[1]) - } - if m := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.InfoHash = strings.TrimSpace(m[1]) - } - if m := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.ImdbID = m[1] - } - if m := regexp.MustCompile(`(?i)size[^<]*\s*]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`).FindStringSubmatch(html); len(m) >= 3 { - detail.Size = parseSizeString(m[1], m[2]) - } - if m := regexp.MustCompile(`seeders[^<]*\s*]*>(\d+)\s*]*>\s*\s*]*>\s*\s*]*>leechers[^<]*\s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 3 { - detail.Seeders, _ = strconv.Atoi(m[1]) - detail.Leechers, _ = strconv.Atoi(m[2]) - } - if m := regexp.MustCompile(`(?i)times completed[^<]*\s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Snatched, _ = strconv.Atoi(m[1]) - } - if m := regexp.MustCompile(`(?i)]*id="kdescr"[^>]*>(.*?)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Description = stripHTML(m[1]) - } - detail.DownloadURL = baseURL + "/download.php?id=" + id - detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费") - return detail, nil -} diff --git a/internal/service/site_adapter_nexusphp_list.go b/internal/service/site_adapter_nexusphp_list.go deleted file mode 100644 index 1d2d553..0000000 --- a/internal/service/site_adapter_nexusphp_list.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "html" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -var ( - nexusPHPFreeLabelRE = regexp.MustCompile(`(?i)(class="[^"]*(?:free|free2|twoupfree|free_download)[^"]*"|促销|免费)`) - nexusPHPRiskLabelRE = regexp.MustCompile(`(?i)(?:class|title|alt)=["'][^"']*\bhr\b[^"']*["']`) -) - -// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。 -func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) { - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Page: 1, - } - - for _, row := range nexusPHPTorrentRows(html) { - item := parseNexusPHPRow(row, baseURL) - if item.ID != "" { - result.Items = append(result.Items, item) - } - } - - result.Total = len(result.Items) - return result, nil -} - -func nexusPHPPageLooksLogin(pageHTML string) bool { - lower := strings.ToLower(pageHTML) - if strings.Contains(lower, "details.php") || strings.Contains(lower, "download.php") { - return false - } - for _, marker := range []string{ - "takelogin.php", - "id=\"loginform\"", - "id='loginform'", - "name=\"loginform\"", - "name='loginform'", - "type=\"password\"", - "type='password'", - } { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - -// parseNexusPHPRow 解析单行种子条目。 -func parseNexusPHPRow(row, baseURL string) TorrentItem { - item := TorrentItem{} - if link := firstNexusPHPLink(row, "details.php"); link != nil { - item.ID = link.query.Get("id") - item.Title = nexusPHPTitleFromLink(*link) - item.Subtitle = nexusPHPSubtitle(row) - item.Labels = nexusPHPRowLabels(row) - item.DetailURL = resolveSiteURL(baseURL, link.href) - } - if link := firstNexusPHPLink(row, "download.php"); link != nil { - item.DownloadURL = resolveSiteURL(baseURL, link.href) - } - if sizeMatches := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`).FindStringSubmatch(row); len(sizeMatches) >= 3 { - item.Size = parseSizeString(sizeMatches[1], sizeMatches[2]) - } - if value, ok := nexusPHPIntByClass(row, "seeders"); ok { - item.Seeders = value - } - if value, ok := nexusPHPIntByClass(row, "leechers"); ok { - item.Leechers = value - } - if value, ok := nexusPHPIntByClass(row, "snatched"); ok { - item.Snatched = value - } - if item.Snatched == 0 { - if m := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`).FindStringSubmatch(row); len(m) >= 2 { - item.Snatched, _ = strconv.Atoi(m[1]) - } - } - item.Free = regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`).MatchString(row) - if m := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`).FindStringSubmatch(row); len(m) >= 2 { - if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil { - item.UploadTime = t - } - } - if m := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`).FindStringSubmatch(row); len(m) >= 3 { - item.Category = strings.TrimSpace(m[2]) - } - return item -} - -type nexusPHPLink struct { - href string - attrs string - text string - query url.Values -} - -func nexusPHPTorrentRows(pageHTML string) []string { - rows := regexp.MustCompile(`(?is)]*>.*?`).FindAllString(pageHTML, -1) - out := make([]string, 0, len(rows)) - for _, row := range rows { - if strings.Contains(strings.ToLower(row), "details.php") { - out = append(out, row) - } - } - return out -} - -func firstNexusPHPLink(row, path string) *nexusPHPLink { - pattern := regexp.MustCompile(`(?is)]*href\s*=\s*["']([^"']*)["'][^>]*)>(.*?)`) - for _, match := range pattern.FindAllStringSubmatch(row, -1) { - if len(match) < 4 { - continue - } - href := html.UnescapeString(strings.TrimSpace(match[2])) - parsed, err := url.Parse(href) - if err != nil || !nexusPHPLinkPathMatches(parsed, path) { - continue - } - return &nexusPHPLink{ - href: href, - attrs: match[1], - text: cleanNexusPHPText(match[3]), - query: parsed.Query(), - } - } - return nil -} - -func nexusPHPLinkPathMatches(parsed *url.URL, want string) bool { - if parsed == nil { - return false - } - path := strings.TrimSpace(parsed.Path) - if path == "" { - path = strings.TrimSpace(parsed.Opaque) - } - path = strings.Trim(strings.ToLower(path), "/") - want = strings.Trim(strings.ToLower(strings.TrimSpace(want)), "/") - if path == "" || want == "" { - return false - } - return path == want || strings.HasSuffix(path, "/"+want) -} - -func nexusPHPTitleFromLink(link nexusPHPLink) string { - for _, attr := range []string{"title", "data-title"} { - if value := htmlAttr(link.attrs, attr); value != "" { - return value - } - } - return link.text -} - -func nexusPHPSubtitle(row string) string { - for _, pattern := range []*regexp.Regexp{ - regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), - regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), - } { - if match := pattern.FindStringSubmatch(row); len(match) >= 2 { - return cleanNexusPHPText(match[1]) - } - } - return "" -} - -func nexusPHPRowLabels(row string) string { - labels := make([]string, 0, 4) - lower := strings.ToLower(row) - add := func(label string) { - for _, existing := range labels { - if existing == label { - return - } - } - labels = append(labels, label) - } - if nexusPHPFreeLabelRE.MatchString(row) { - add("free") - } - if strings.Contains(lower, "hit and run") || strings.Contains(lower, "hit&run") || strings.Contains(lower, "h&r") || - nexusPHPRiskLabelRE.MatchString(row) || - strings.Contains(row, "禁转") || strings.Contains(row, "禁止转载") || strings.Contains(row, "禁下") || strings.Contains(row, "禁止下载") { - add("HR") - } - return strings.Join(labels, " ") -} - -func nexusPHPIntByClass(row, className string) (int, bool) { - pattern := regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)`) - if match := pattern.FindStringSubmatch(row); len(match) >= 2 { - text := cleanNexusPHPText(match[1]) - valueMatch := regexp.MustCompile(`\d+`).FindString(text) - if valueMatch != "" { - value, _ := strconv.Atoi(valueMatch) - return value, true - } - } - return 0, false -} - -func htmlAttr(attrs, name string) string { - pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`) - if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 { - return cleanNexusPHPText(match[1]) - } - return "" -} - -func cleanNexusPHPText(value string) string { - return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ") -} - -func resolveSiteURL(baseURL, href string) string { - base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/") - if err != nil { - return strings.TrimSpace(href) - } - ref, err := url.Parse(strings.TrimSpace(href)) - if err != nil { - return strings.TrimSpace(href) - } - return base.ResolveReference(ref).String() -} diff --git a/internal/service/site_adapter_parse.go b/internal/service/site_adapter_parse.go deleted file mode 100644 index 8e25c50..0000000 --- a/internal/service/site_adapter_parse.go +++ /dev/null @@ -1,51 +0,0 @@ -package service - -import ( - "regexp" - "strconv" - "strings" -) - -func mteamCodeOK(code any) bool { - codeStr := mteamCodeString(code) - return codeStr == "0" || codeStr == "200" -} - -func mteamCodeString(code any) string { - switch v := code.(type) { - case string: - return strings.TrimSpace(v) - case float64: - return strconv.Itoa(int(v)) - case int: - return strconv.Itoa(v) - default: - return "" - } -} - -// parseSizeString 将带单位的字符串转换为字节数。 -func parseSizeString(value string, unit string) int64 { - v, err := strconv.ParseFloat(value, 64) - if err != nil { - return 0 - } - switch strings.TrimSuffix(strings.ToLower(unit), "i") { - case "kb": - return int64(v * 1024) - case "mb": - return int64(v * 1024 * 1024) - case "gb": - return int64(v * 1024 * 1024 * 1024) - case "tb": - return int64(v * 1024 * 1024 * 1024 * 1024) - default: - return int64(v) - } -} - -// stripHTML 移除 HTML 标签。 -func stripHTML(s string) string { - re := regexp.MustCompile(`<[^>]*>`) - return re.ReplaceAllString(s, "") -} diff --git a/internal/service/site_adapter_test.go b/internal/service/site_adapter_test.go deleted file mode 100644 index ae32043..0000000 --- a/internal/service/site_adapter_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestMTeamAuthenticateRequiresAPIKey(t *testing.T) { - adapter := NewMTeamAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: "https://api.m-team.cc", - AuthType: "api_key", - }) - if err == nil || !strings.Contains(err.Error(), "API Access Token") { - t.Fatalf("Authenticate error = %v, want API Access Token hint", err) - } -} - -func TestMTeamAuthenticateUsesOpenAPIKeyHeader(t *testing.T) { - var gotPath string - var gotKey string - var gotPayload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotKey = r.Header.Get("x-api-key") - if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { - t.Fatalf("decode request body: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "token-123", - Timeout: 5 * time.Second, - }) - if err != nil { - t.Fatalf("Authenticate returned error: %v", err) - } - if gotPath != "/api/torrent/search" { - t.Fatalf("path = %q, want /api/torrent/search", gotPath) - } - if gotKey != "token-123" { - t.Fatalf("x-api-key = %q, want token-123", gotKey) - } - if gotPayload["mode"] != "all" || gotPayload["keyword"] != nil { - t.Fatalf("payload = %#v, want mode all without keyword probe", gotPayload) - } -} - -func TestMTeamAuthenticateReportsAPIMessage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":1,"message":"key無效","data":null}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "bad-token", - Timeout: 5 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), "key無效") { - t.Fatalf("Authenticate error = %v, want key invalid message", err) - } -} - -func TestMTeamAuthenticateHonorsConfiguredTimeout(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(2 * time.Second) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - started := time.Now() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "token-123", - Timeout: time.Second, - }) - if err == nil { - t.Fatal("Authenticate error = nil, want timeout") - } - if elapsed := time.Since(started); elapsed >= 1500*time.Millisecond { - t.Fatalf("Authenticate elapsed = %s, want configured timeout to stop before upstream response", elapsed) - } - if !strings.Contains(err.Error(), "M-Team API request timed out") { - t.Fatalf("Authenticate error = %v, want M-Team timeout hint", err) - } -} - -func TestAPISiteDefaultTimeoutIsRaised(t *testing.T) { - if got := siteRequestTimeout("mteam", 15); got != 45*time.Second { - t.Fatalf("mteam timeout = %s, want 45s", got) - } - if got := siteRequestTimeout("yemapt", 0); got != 45*time.Second { - t.Fatalf("yemapt timeout = %s, want 45s", got) - } - if got := siteRequestTimeout("nexusphp", 15); got != 15*time.Second { - t.Fatalf("nexusphp timeout = %s, want 15s", got) - } - if got := siteRequestTimeout("mteam", 60); got != 60*time.Second { - t.Fatalf("custom mteam timeout = %s, want 60s", got) - } -} - -func TestYemaPTAuthenticateUsesAuthorizationHeader(t *testing.T) { - var gotPath string - var gotAuth string - var gotXAPIKey string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotAuth = r.Header.Get("Authorization") - gotXAPIKey = r.Header.Get("x-api-key") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"showType":0,"data":{"id":10,"name":"tester"}}`)) - })) - defer server.Close() - - adapter := NewYemaPTAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - Type: "yemapt", - URL: server.URL, - AuthType: "api_key", - APIKey: "auth-123", - Timeout: 5 * time.Second, - }) - if err != nil { - t.Fatalf("Authenticate returned error: %v", err) - } - if gotPath != "/openApi/user/fetchBasicInfo.json" { - t.Fatalf("path = %q, want /openApi/user/fetchBasicInfo.json", gotPath) - } - if gotAuth != "auth-123" { - t.Fatalf("Authorization = %q, want auth-123", gotAuth) - } - if gotXAPIKey != "" { - t.Fatalf("x-api-key = %q, want empty", gotXAPIKey) - } -} - -func TestYemaPTAuthenticateReportsAPIMessage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":false,"errorCode":403,"errorMessage":"need api auth"}`)) - })) - defer server.Close() - - adapter := NewYemaPTAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - Type: "yemapt", - URL: server.URL, - AuthType: "api_key", - APIKey: "bad-auth", - Timeout: 5 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), "need api auth") { - t.Fatalf("Authenticate error = %v, want need api auth", err) - } -} - -func TestNewSiteAdapterDetectsYemaPTURL(t *testing.T) { - adapter := NewSiteAdapter(&model.Site{ - Type: "nexusphp", - URL: "https://www.yemapt.org", - }) - if _, ok := adapter.(*YemaPTAdapter); !ok { - t.Fatalf("adapter = %T, want *YemaPTAdapter", adapter) - } -} - -func TestBuildRequestAPIKeyHeaderBySite(t *testing.T) { - yemaReq, err := buildRequest(context.Background(), http.MethodGet, "https://www.yemapt.org/openApi/user/fetchBasicInfo.json", SiteConfig{ - Type: "yemapt", - URL: "https://www.yemapt.org", - AuthType: "api_key", - APIKey: "yema-auth", - }, nil) - if err != nil { - t.Fatal(err) - } - if got := yemaReq.Header.Get("Authorization"); got != "yema-auth" { - t.Fatalf("YemaPT Authorization = %q, want yema-auth", got) - } - if got := yemaReq.Header.Get("x-api-key"); got != "" { - t.Fatalf("YemaPT x-api-key = %q, want empty", got) - } - - mteamReq, err := buildRequest(context.Background(), http.MethodGet, "https://api.m-team.cc/api/torrent/search", SiteConfig{ - Type: "mteam", - URL: "https://api.m-team.cc", - AuthType: "api_key", - APIKey: "mteam-auth", - }, nil) - if err != nil { - t.Fatal(err) - } - if got := mteamReq.Header.Get("x-api-key"); got != "mteam-auth" { - t.Fatalf("M-Team x-api-key = %q, want mteam-auth", got) - } - if got := mteamReq.Header.Get("Authorization"); got != "" { - t.Fatalf("M-Team Authorization = %q, want empty", got) - } -} - -func TestNexusPHPSearchUsesSearchstr(t *testing.T) { - var gotQuery string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.RawQuery - _, _ = w.Write([]byte(`
测试资源下载
`)) - })) - defer server.Close() - - adapter := NewNexusPHPAdapter() - result, err := adapter.Search(t.Context(), SiteConfig{ - Name: "Nexus", - URL: server.URL, - AuthType: "cookie", - Cookie: "uid=1; pass=token", - Timeout: 5 * time.Second, - }, "测试", 2) - if err != nil { - t.Fatalf("Search returned error: %v", err) - } - values, err := url.ParseQuery(gotQuery) - if err != nil { - t.Fatal(err) - } - if values.Get("searchstr") != "测试" || values.Get("search") != "测试" || values.Get("page") != "2" { - t.Fatalf("query = %q", gotQuery) - } - if len(result.Items) != 1 || result.Items[0].Title != "测试资源" { - t.Fatalf("items = %#v", result.Items) - } -} - -func TestNexusPHPSearchReportsExpiredCookieLoginPage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`
`)) - })) - defer server.Close() - - adapter := NewNexusPHPAdapter() - _, err := adapter.Search(t.Context(), SiteConfig{ - Name: "Nexus", - URL: server.URL, - AuthType: "cookie", - Cookie: "uid=1; pass=expired", - Timeout: 5 * time.Second, - }, "测试", 1) - if err == nil || !strings.Contains(err.Error(), "cookie expired") { - t.Fatalf("Search error = %v, want cookie expired hint", err) - } -} - -func TestParseNexusPHPHTMLModernRows(t *testing.T) { - page := ` - - - - - - - - - -
电影 - ignored - 副标题 & 描述 - 下载 - 12.5 GiB33499
` - result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example") - if err != nil { - t.Fatal(err) - } - if len(result.Items) != 1 { - t.Fatalf("items = %#v", result.Items) - } - item := result.Items[0] - if item.ID != "456" || item.Title != "Some & Movie 2026 2160p" || item.Subtitle != "副标题 & 描述" { - t.Fatalf("parsed item = %#v", item) - } - if item.DetailURL != "https://pt.example/details.php?id=456&hit=1" || item.DownloadURL != "https://pt.example/download.php?id=456&passkey=abc" { - t.Fatalf("urls = detail %q download %q", item.DetailURL, item.DownloadURL) - } - if item.Seeders != 33 || item.Leechers != 4 || item.Snatched != 99 { - t.Fatalf("stats = %#v", item) - } -} - -func TestParseNexusPHPHTMLCapturesRiskAndPromotionLabels(t *testing.T) { - page := ` - - - - - - -
Some Show免费H&R下载
` - result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example") - if err != nil { - t.Fatal(err) - } - if len(result.Items) != 1 { - t.Fatalf("items = %#v", result.Items) - } - item := result.Items[0] - if !item.Free { - t.Fatalf("item.Free = false, want free promotion detected: %#v", item) - } - if !strings.Contains(item.Labels, "HR") || !strings.Contains(item.Labels, "free") { - t.Fatalf("labels = %q, want HR and free", item.Labels) - } -} - -func TestParseNexusPHPHTMLIgnoresUserDetailsLinks(t *testing.T) { - page := ` - - - -
shukBeta15.5 GiB
问心下载1.5 GiB
` - result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example") - if err != nil { - t.Fatal(err) - } - if len(result.Items) != 1 { - t.Fatalf("items = %#v, want only real torrent details row", result.Items) - } - if result.Items[0].ID != "789" || result.Items[0].Title != "问心 S01 1080p" { - t.Fatalf("parsed item = %#v", result.Items[0]) - } -} - -func TestMTeamAPIRateLimits(t *testing.T) { - search := mteamAPIRateLimits(mteamAPIEndpointSearch) - if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour { - t.Fatalf("search limits = %#v, want 1500/24h", search) - } - detail := mteamAPIRateLimits(mteamAPIEndpointDetail) - if len(detail) != 1 || detail[0].Limit != 100 || detail[0].Window != time.Hour { - t.Fatalf("detail limits = %#v, want 100/1h", detail) - } - download := mteamAPIRateLimits(mteamAPIEndpointDownload) - if len(download) != 2 || - download[0].Limit != 100 || download[0].Window != time.Hour || - download[1].Limit != 1000 || download[1].Window != 24*time.Hour { - t.Fatalf("download limits = %#v, want 100/1h and 1000/24h", download) - } -} - -func TestPersistentSiteAPIRateLimiterPersistsSlidingWindow(t *testing.T) { - db := newServiceTestDB(t, &model.Setting{}) - repos := repository.New(db) - now := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC) - limiter := newPersistentSiteAPIRateLimiter(repos) - limiter.now = func() time.Time { return now } - limit := siteAPIRateLimit{Bucket: "test_1h", Limit: 2, Window: time.Hour} - - if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil { - t.Fatalf("first allow: %v", err) - } - if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil { - t.Fatalf("second allow: %v", err) - } - err := limiter.Allow(t.Context(), "mteam:test", limit) - var limited *siteAPIRateLimitError - if !errors.As(err, &limited) { - t.Fatalf("third allow error = %v, want siteAPIRateLimitError", err) - } - if limited.RetryAfter != time.Hour { - t.Fatalf("retry_after = %v, want 1h", limited.RetryAfter) - } - - restarted := newPersistentSiteAPIRateLimiter(repos) - restarted.now = func() time.Time { return now.Add(30 * time.Minute) } - if err := restarted.Allow(t.Context(), "mteam:test", limit); !errors.As(err, &limited) { - t.Fatalf("restarted allow error = %v, want persisted limit", err) - } - - restarted.now = func() time.Time { return now.Add(time.Hour + time.Second) } - if err := restarted.Allow(t.Context(), "mteam:test", limit); err != nil { - t.Fatalf("allow after window: %v", err) - } -} - -func TestMTeamRateLimitStopsRequestBeforeHTTP(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ - Bucket: "torrent_search_24h", - Limit: 1000, - Window: 24 * time.Hour, - RetryAfter: time.Hour, - }} - _, err := adapter.Search(t.Context(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "token-123", - Timeout: 5 * time.Second, - rateLimiter: limiter, - }, "show", 1) - if err == nil || !strings.Contains(err.Error(), "rate limit") { - t.Fatalf("Search error = %v, want rate limit", err) - } - if got := requests.Load(); got != 0 { - t.Fatalf("HTTP requests = %d, want 0", got) - } - if limiter.calls != 1 { - t.Fatalf("limiter calls = %d, want 1", limiter.calls) - } -} - -type staticSiteAPIRateLimiter struct { - err error - calls int -} - -func (l *staticSiteAPIRateLimiter) Allow(context.Context, string, ...siteAPIRateLimit) error { - l.calls++ - return l.err -} diff --git a/internal/service/site_adapter_unit3d.go b/internal/service/site_adapter_unit3d.go deleted file mode 100644 index f99fb75..0000000 --- a/internal/service/site_adapter_unit3d.go +++ /dev/null @@ -1,198 +0,0 @@ -// Package service — UNIT3D site adapter. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "time" -) - -// ─── UNIT3D 适配器 ─────────────────────────────────────────────────────────── - -// UNIT3DAdapter UNIT3D 框架适配器。 -type UNIT3DAdapter struct { - client *http.Client -} - -// NewUNIT3DAdapter 创建 UNIT3D 适配器。 -func NewUNIT3DAdapter() *UNIT3DAdapter { - return &UNIT3DAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *UNIT3DAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - u := cfg.URL + "/api/torrents?limit=1" - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("authentication failed: status %d", status) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err == nil { - if errMsg, ok := resp["message"].(string); ok { - return fmt.Errorf("authentication failed: %s", errMsg) - } - } - return nil -} - -func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("search", keyword) - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/api/torrents?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseUNIT3DJSON(data, cfg.Name, cfg.URL) -} - -func (a *UNIT3DAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - if category != "" { - params.Set("category", category) - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/api/torrents?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseUNIT3DJSON(data, cfg.Name, cfg.URL) -} - -func (a *UNIT3DAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - u := cfg.URL + "/api/torrents/" + id - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - var torrent map[string]interface{} - if err := json.Unmarshal(data, &torrent); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/torrents/" + id, - } - - if v, ok := torrent["name"].(string); ok { - detail.Title = v - } - if v, ok := torrent["description"].(string); ok { - detail.Description = stripHTML(v) - } - if v, ok := torrent["size"].(float64); ok { - detail.Size = int64(v) - } - if v, ok := torrent["seeders"].(float64); ok { - detail.Seeders = int(v) - } - if v, ok := torrent["leechers"].(float64); ok { - detail.Leechers = int(v) - } - if v, ok := torrent["times_completed"].(float64); ok { - detail.Snatched = int(v) - } - if v, ok := torrent["free"].(bool); ok { - detail.Free = v - } - if v, ok := torrent["info_hash"].(string); ok { - detail.InfoHash = v - } - - detail.DownloadURL = cfg.URL + "/api/torrents/" + id + "/download" - return detail, nil -} - -func (a *UNIT3DAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/api/torrents/" + id + "/download", nil -} - -// parseUNIT3DJSON 解析 UNIT3D JSON 响应。 -func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { - var resp struct { - Data []map[string]interface{} `json:"data"` - Meta struct { - Total int `json:"total"` - CurrentPage int `json:"current_page"` - } `json:"meta"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse JSON: %w", err) - } - - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Page: resp.Meta.CurrentPage, - Total: resp.Meta.Total, - } - - for _, t := range resp.Data { - item := TorrentItem{} - if v, ok := t["id"].(float64); ok { - item.ID = strconv.Itoa(int(v)) - } - if v, ok := t["name"].(string); ok { - item.Title = v - } - if v, ok := t["category"].(map[string]interface{}); ok { - if name, ok := v["name"].(string); ok { - item.Category = name - } - } - if v, ok := t["size"].(float64); ok { - item.Size = int64(v) - } - if v, ok := t["seeders"].(float64); ok { - item.Seeders = int(v) - } - if v, ok := t["leechers"].(float64); ok { - item.Leechers = int(v) - } - if v, ok := t["times_completed"].(float64); ok { - item.Snatched = int(v) - } - if v, ok := t["free"].(bool); ok { - item.Free = v - } - if v, ok := t["created_at"].(string); ok { - if t2, err := time.Parse(time.RFC3339, v); err == nil { - item.UploadTime = t2 - } - } - - item.DetailURL = baseURL + "/torrents/" + item.ID - item.DownloadURL = baseURL + "/api/torrents/" + item.ID + "/download" - result.Items = append(result.Items, item) - } - - return result, nil -} diff --git a/internal/service/site_adapter_yemapt.go b/internal/service/site_adapter_yemapt.go deleted file mode 100644 index d7082f1..0000000 --- a/internal/service/site_adapter_yemapt.go +++ /dev/null @@ -1,92 +0,0 @@ -// Package service — YemaPT site adapter. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" -) - -type YemaPTAdapter struct { - client *http.Client -} - -func NewYemaPTAdapter() *YemaPTAdapter { - return &YemaPTAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *YemaPTAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - if strings.TrimSpace(cfg.APIKey) == "" { - return errors.New("YemaPT 需要填写个人详情页创建的第三方对接专用 API Auth Key") - } - u := strings.TrimRight(cfg.URL, "/") + "/openApi/user/fetchBasicInfo.json" - data, status, err := doRequestJSON(ctx, a.client, http.MethodGet, u, cfg, nil) - if err != nil { - return fmt.Errorf("yemapt authenticate: %w", err) - } - if status != http.StatusOK { - return fmt.Errorf("yemapt authenticate failed: status %d", status) - } - var resp yemaPTAPIResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("yemapt authenticate parse: %w", err) - } - if resp.Success { - return nil - } - if resp.ErrorMessage != "" { - return fmt.Errorf("yemapt authenticate failed: %s", resp.ErrorMessage) - } - if resp.ErrorCode != 0 { - return fmt.Errorf("yemapt authenticate failed: errorCode=%d", resp.ErrorCode) - } - return errors.New("yemapt authenticate failed") -} - -func (a *YemaPTAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - return nil, errYemaPTTorrentOpenAPIUnsupported() -} - -func (a *YemaPTAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - return nil, errYemaPTTorrentOpenAPIUnsupported() -} - -func (a *YemaPTAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - return nil, errYemaPTTorrentOpenAPIUnsupported() -} - -func (a *YemaPTAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return "", errYemaPTTorrentOpenAPIUnsupported() -} - -type yemaPTAPIResponse struct { - Success bool `json:"success"` - ShowType int `json:"showType"` - ErrorCode int `json:"errorCode"` - ErrorMessage string `json:"errorMessage"` - Data json.RawMessage `json:"data"` -} - -func errYemaPTTorrentOpenAPIUnsupported() error { - return errors.New("YemaPT 当前公开 OpenAPI 未提供种子搜索/详情/下载接口") -} - -func isYemaPTConfig(cfg SiteConfig) bool { - return strings.EqualFold(strings.TrimSpace(cfg.Type), "yemapt") || isYemaPTURL(cfg.URL) -} - -func isYemaPTURL(raw string) bool { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return false - } - host := strings.ToLower(u.Hostname()) - return host == "yemapt.org" || strings.HasSuffix(host, ".yemapt.org") -} diff --git a/internal/service/site_connection.go b/internal/service/site_connection.go deleted file mode 100644 index 7ecd469..0000000 --- a/internal/service/site_connection.go +++ /dev/null @@ -1,99 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/helper" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// TestConnection tries to reach the site's base URL with the configured -// credentials and reports success/failure. -// -// 测试逻辑(与旧版参考实现对齐): -// -// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D / -// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常 -// 被 Cloudflare 直接 403 但 API 能正常访问。 -// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的 -// 通用浏览器头 GET 方案。 -// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了 -// BrowserEmulation 时,会自动走 FlareSolverr。 -func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) { - site, err := s.FindByID(ctx, id) - if err != nil || site == nil { - return false, "site not found", err - } - - flareSolverrURL := s.flareSolverrURL - - // ── Path 1: site-aware adapter Authenticate ──────────────────────── - // custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。 - if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" { - cfg := s.siteModelToConfig(site) - actx, cancel := context.WithTimeout(ctx, cfg.Timeout) - defer cancel() - if authErr := adapter.Authenticate(actx, cfg); authErr == nil { - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "ok", - "last_error": "", - "last_check_at": &now, - }).Error - return true, "连接成功", nil - } else { - if site.Type == "mteam" || site.Type == "yemapt" || isYemaPTURL(site.URL) { - s.log.Warn("site adapter authenticate failed", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.Error(authErr)) - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "fail", - "last_error": authErr.Error(), - "last_check_at": &now, - }).Error - return false, authErr.Error(), nil - } - s.log.Warn("site adapter authenticate failed, falling back to generic test", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.Error(authErr)) - // 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会 - } - } - - // ── Path 2: generic GET with browser headers / FlareSolverr ─────── - timeout := int(siteRequestTimeout(site.Type, site.Timeout).Seconds()) - ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log) - if err != nil { - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "fail", - "last_error": err.Error(), - "last_check_at": &now, - }).Error - return false, err.Error(), nil - } - - loginStatus := "ok" - storedError := "" - if !ok { - loginStatus = "fail" - storedError = msg - } - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": loginStatus, - "last_error": storedError, - "last_check_at": &now, - }).Error - return ok, msg, nil -} diff --git a/internal/service/site_crud.go b/internal/service/site_crud.go deleted file mode 100644 index 46a2183..0000000 --- a/internal/service/site_crud.go +++ /dev/null @@ -1,161 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// Create persists a new site. -func (s *SiteService) Create(ctx context.Context, site *model.Site) error { - if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.URL) == "" { - return errors.New("name and url required") - } - site.URL = strings.TrimRight(site.URL, "/") - if site.Type == "" { - site.Type = "nexusphp" - } - if site.AuthType == "" { - site.AuthType = "cookie" - } - return s.repo.DB.WithContext(ctx).Create(site).Error -} - -// List returns every site ordered by created_at. -func (s *SiteService) List(ctx context.Context) ([]model.Site, error) { - var sites []model.Site - err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error - if sites == nil { - sites = []model.Site{} - } - return sites, err -} - -// FindByID returns a single site or nil. -func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, error) { - var site model.Site - err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&site).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return &site, err -} - -// siteUpdatableFields is the whitelist of columns that may be patched via -// the update endpoint. Fields like id, created_at, deleted_at, login_status, -// upload_bytes, download_bytes are excluded to prevent injection. -var siteUpdatableFields = map[string]bool{ - "name": true, - "url": true, - "type": true, - "auth_type": true, - "api_key": true, - "cookie": true, - "auth_header": true, - "user_agent": true, - "rss_url": true, - "timeout": true, - "priority": true, - "use_proxy": true, - "rate_limit": true, - "browser_emulation": true, - "downloader": true, - "enabled": true, - "is_default": true, - "extra": true, -} - -// Update applies a partial patch to an existing site. -func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error { - if id == "" { - return errors.New("site id required") - } - filtered := make(map[string]any, len(updates)) - for k, v := range updates { - if siteUpdatableFields[k] { - filtered[k] = v - } - } - if len(filtered) == 0 { - return errors.New("no valid fields to update") - } - if raw, ok := filtered["url"].(string); ok { - filtered["url"] = strings.TrimRight(strings.TrimSpace(raw), "/") - } - for _, key := range []string{"api_key", "cookie", "auth_header"} { - if raw, ok := filtered[key].(string); ok && strings.TrimSpace(raw) == "" { - delete(filtered, key) - } - } - return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(filtered).Error -} - -// Delete removes a site. -func (s *SiteService) Delete(ctx context.Context, id string) error { - return s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Site{}).Error -} - -// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。 -// 当全局 FlareSolverr 已启用且此站点开启了 BrowserEmulation 时,填充 FlareSolverrURL。 -func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig { - timeout := siteRequestTimeout(s.Type, s.Timeout) - userAgent := s.UserAgent - if userAgent == "" { - userAgent = model.DefaultUserAgent - } - var extra map[string]string - if s.Extra != "" { - _ = json.Unmarshal([]byte(s.Extra), &extra) - } - - // Per-site FlareSolverr opt-in: only when global FlareSolverr is enabled - // AND this site has BrowserEmulation turned on. - flareSolverrURL := "" - if svc.flareSolverrURL != "" && s.BrowserEmulation { - flareSolverrURL = svc.flareSolverrURL - } - - return SiteConfig{ - SiteID: s.ID, - Name: s.Name, - Type: s.Type, - URL: s.URL, - AuthType: s.AuthType, - Cookie: s.Cookie, - APIKey: s.APIKey, - AuthHeader: s.AuthHeader, - UserAgent: userAgent, - Timeout: timeout, - Extra: extra, - FlareSolverrURL: flareSolverrURL, - UseProxy: s.UseProxy, - RateLimit: s.RateLimit, - rateLimiter: svc.apiRateLimiter, - } -} - -func siteRequestTimeout(siteType string, timeoutSeconds int) time.Duration { - timeout := time.Duration(timeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 15 * time.Second - } - if isAPISiteType(siteType) && timeout <= 15*time.Second { - return 45 * time.Second - } - return timeout -} - -func isAPISiteType(siteType string) bool { - switch strings.ToLower(strings.TrimSpace(siteType)) { - case "mteam", "yemapt": - return true - default: - return false - } -} diff --git a/internal/service/site_download.go b/internal/service/site_download.go deleted file mode 100644 index 4e3bc74..0000000 --- a/internal/service/site_download.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "path" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ResolveDownloadURL converts tracker-specific search result URLs into a URL -// that a downloader can fetch directly. M-Team, NexusPHP and similar sites -// often expose a signed/detail endpoint in search results; qBittorrent cannot -// call those APIs with the configured site credentials, so subscriptions need -// the same resolution path as the manual download button. -func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string { - if strings.TrimSpace(raw) == "" { - return raw - } - matched := s.matchSiteForURL(ctx, raw) - if matched == nil { - return raw - } - - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return raw - } - id := u.Query().Get("id") - if id == "" { - return raw - } - adapter := GetAdapterForType(matched.Type) - if adapter == nil { - return raw - } - cfg := s.siteModelToConfig(matched) - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 15 * time.Second - } - resolveCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id) - if err != nil || resolved == "" { - if s.log != nil { - s.log.Warn("resolve PT download URL failed", - zap.String("site", matched.Name), - zap.String("raw", redactSensitiveDownloadURL(raw)), - zap.Error(err)) - } - return raw - } - return resolved -} - -func redactSensitiveDownloadURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "magnet:?xt=***" - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return "[redacted-download-url]" - } - u.RawQuery = "" - u.Fragment = "" - return u.String() -} - -func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { - return nil, "", errors.New("no matching PT site for torrent URL") - } - matched := s.matchSiteForURL(ctx, raw) - cfg := SiteConfig{Timeout: 30 * time.Second} - if matched != nil { - cfg = s.siteModelToConfig(matched) - } - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil) - if err != nil { - return nil, "", err - } - req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*") - client := newHTTPClient(cfg, timeout) - resp, err := client.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode) - } - const maxTorrentSize = 32 << 20 - data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1)) - if err != nil { - return nil, "", err - } - if len(data) == 0 { - return nil, "", errors.New("torrent fetch: empty body") - } - if len(data) > maxTorrentSize { - return nil, "", errors.New("torrent fetch: body too large") - } - if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") { - return nil, "", errors.New("torrent fetch: upstream returned HTML") - } - if torrentInfoHash(data) == "" { - return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent") - } - return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil -} - -func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site { - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return nil - } - host := strings.ToLower(u.Host) - - sites, err := s.List(ctx) - if err != nil || len(sites) == 0 { - return nil - } - for i := range sites { - if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) { - return &sites[i] - } - } - return nil -} - -func siteHostMatches(host, raw string) bool { - if raw == "" { - return false - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return false - } - siteHost := strings.ToLower(u.Host) - return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost) -} - -func torrentFilename(rawURL, disposition string) string { - if disposition != "" { - if _, params, err := mime.ParseMediaType(disposition); err == nil { - if filename := strings.TrimSpace(params["filename"]); filename != "" { - return filename - } - } - } - if u, err := url.Parse(rawURL); err == nil { - if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" { - if !strings.HasSuffix(strings.ToLower(name), ".torrent") { - name += ".torrent" - } - return name - } - } - return "download.torrent" -} diff --git a/internal/service/site_rate_limiter.go b/internal/service/site_rate_limiter.go deleted file mode 100644 index 322cde0..0000000 --- a/internal/service/site_rate_limiter.go +++ /dev/null @@ -1,280 +0,0 @@ -package service - -import ( - "context" - "crypto/sha1" - "encoding/hex" - "encoding/json" - "fmt" - "sort" - "strings" - "sync" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -const ( - mteamAPIEndpointSearch = "torrent_search" - mteamAPIEndpointDetail = "torrent_detail" - mteamAPIEndpointDownload = "torrent_download" - - mteamAPISearchDailyLimit = 1500 -) - -type siteAPIRateLimit struct { - Bucket string - Limit int - Window time.Duration -} - -type siteAPIRateLimiter interface { - Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error -} - -type siteAPIRateLimitError struct { - SiteKey string - Bucket string - Limit int - Window time.Duration - RetryAfter time.Duration -} - -func (e *siteAPIRateLimitError) Error() string { - if e == nil { - return "" - } - return fmt.Sprintf("M-Team API rate limit reached for %s: %d requests per %s, retry after %s", - e.Bucket, e.Limit, formatRateLimitDuration(e.Window), formatRateLimitDuration(e.RetryAfter)) -} - -type persistentSiteAPIRateLimiter struct { - repo *repository.Container - fallback *memorySiteAPIRateLimiter - now func() time.Time - mu sync.Mutex -} - -func newPersistentSiteAPIRateLimiter(repo *repository.Container) *persistentSiteAPIRateLimiter { - return &persistentSiteAPIRateLimiter{ - repo: repo, - fallback: newMemorySiteAPIRateLimiter(time.Now), - now: time.Now, - } -} - -func (l *persistentSiteAPIRateLimiter) Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error { - if err := ctx.Err(); err != nil { - return err - } - if l == nil || l.repo == nil || l.repo.Setting == nil { - if l != nil && l.fallback != nil { - return l.fallback.Allow(ctx, siteKey, limits...) - } - return defaultMemorySiteAPIRateLimiter.Allow(ctx, siteKey, limits...) - } - l.mu.Lock() - defer l.mu.Unlock() - - now := time.Now() - if l.now != nil { - now = l.now() - } - records := make([]siteAPIRateLimitRecord, 0, len(limits)) - for _, limit := range normalizeSiteAPIRateLimits(limits) { - key := siteAPIRateLimitSettingKey(siteKey, limit.Bucket) - raw, err := l.repo.Setting.Get(ctx, key) - if err != nil { - return err - } - timestamps := pruneSiteAPIRateTimestamps(parseSiteAPIRateTimestamps(raw), now, limit.Window) - if err := checkSiteAPIRateLimit(siteKey, limit, timestamps, now); err != nil { - return err - } - records = append(records, siteAPIRateLimitRecord{key: key, timestamps: timestamps}) - } - nowUnix := now.Unix() - for _, record := range records { - next := append(record.timestamps, nowUnix) - if err := l.repo.Setting.Set(ctx, record.key, encodeSiteAPIRateTimestamps(next)); err != nil { - return err - } - } - return nil -} - -type memorySiteAPIRateLimiter struct { - now func() time.Time - mu sync.Mutex - buckets map[string][]int64 -} - -var defaultMemorySiteAPIRateLimiter = newMemorySiteAPIRateLimiter(time.Now) - -func newMemorySiteAPIRateLimiter(now func() time.Time) *memorySiteAPIRateLimiter { - if now == nil { - now = time.Now - } - return &memorySiteAPIRateLimiter{now: now, buckets: map[string][]int64{}} -} - -func (l *memorySiteAPIRateLimiter) Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error { - if err := ctx.Err(); err != nil { - return err - } - if l == nil { - return nil - } - l.mu.Lock() - defer l.mu.Unlock() - - now := l.now() - records := make([]siteAPIRateLimitRecord, 0, len(limits)) - for _, limit := range normalizeSiteAPIRateLimits(limits) { - key := siteAPIRateLimitSettingKey(siteKey, limit.Bucket) - timestamps := pruneSiteAPIRateTimestamps(l.buckets[key], now, limit.Window) - if err := checkSiteAPIRateLimit(siteKey, limit, timestamps, now); err != nil { - return err - } - records = append(records, siteAPIRateLimitRecord{key: key, timestamps: timestamps}) - } - nowUnix := now.Unix() - for _, record := range records { - l.buckets[record.key] = append(record.timestamps, nowUnix) - } - return nil -} - -type siteAPIRateLimitRecord struct { - key string - timestamps []int64 -} - -func reserveMTeamAPIQuota(ctx context.Context, cfg SiteConfig, endpoint string) error { - limits := mteamAPIRateLimits(endpoint) - if len(limits) == 0 { - return nil - } - // M-Team's published API quotas are upstream hard limits, so protect them - // regardless of the generic per-site RateLimit toggle. - limiter := cfg.rateLimiter - if limiter == nil { - limiter = defaultMemorySiteAPIRateLimiter - } - return limiter.Allow(ctx, mteamAPIRateSiteKey(cfg), limits...) -} - -func mteamAPIRateLimits(endpoint string) []siteAPIRateLimit { - switch endpoint { - case mteamAPIEndpointSearch: - return []siteAPIRateLimit{{Bucket: "torrent_search_24h", Limit: mteamAPISearchDailyLimit, Window: 24 * time.Hour}} - case mteamAPIEndpointDetail: - return []siteAPIRateLimit{{Bucket: "torrent_detail_1h", Limit: 100, Window: time.Hour}} - case mteamAPIEndpointDownload: - return []siteAPIRateLimit{ - {Bucket: "torrent_download_1h", Limit: 100, Window: time.Hour}, - {Bucket: "torrent_download_24h", Limit: 1000, Window: 24 * time.Hour}, - } - default: - return nil - } -} - -func mteamAPIRateSiteKey(cfg SiteConfig) string { - base := strings.TrimRight(strings.ToLower(strings.TrimSpace(cfg.URL)), "/") - if base == "" { - base = "mteam" - } - if apiKey := strings.TrimSpace(cfg.APIKey); apiKey != "" { - sum := sha1.Sum([]byte(apiKey)) - return base + "|api:" + hex.EncodeToString(sum[:]) - } - if siteID := strings.TrimSpace(cfg.SiteID); siteID != "" { - return base + "|site:" + siteID - } - if name := strings.TrimSpace(cfg.Name); name != "" { - return base + "|name:" + strings.ToLower(name) - } - return base -} - -func normalizeSiteAPIRateLimits(limits []siteAPIRateLimit) []siteAPIRateLimit { - out := make([]siteAPIRateLimit, 0, len(limits)) - for _, limit := range limits { - limit.Bucket = strings.TrimSpace(limit.Bucket) - if limit.Bucket == "" || limit.Limit <= 0 || limit.Window <= 0 { - continue - } - out = append(out, limit) - } - return out -} - -func checkSiteAPIRateLimit(siteKey string, limit siteAPIRateLimit, timestamps []int64, now time.Time) error { - if len(timestamps) < limit.Limit { - return nil - } - oldest := time.Unix(timestamps[0], 0) - retryAfter := oldest.Add(limit.Window).Sub(now) - if retryAfter < time.Second { - retryAfter = time.Second - } - return &siteAPIRateLimitError{ - SiteKey: siteKey, - Bucket: limit.Bucket, - Limit: limit.Limit, - Window: limit.Window, - RetryAfter: retryAfter, - } -} - -func siteAPIRateLimitSettingKey(siteKey, bucket string) string { - sum := sha1.Sum([]byte(siteKey)) - return "site.api_rate." + hex.EncodeToString(sum[:])[:20] + "." + bucket -} - -func parseSiteAPIRateTimestamps(raw string) []int64 { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - var out []int64 - if err := json.Unmarshal([]byte(raw), &out); err != nil { - return nil - } - return out -} - -func encodeSiteAPIRateTimestamps(values []int64) string { - data, _ := json.Marshal(values) - return string(data) -} - -func pruneSiteAPIRateTimestamps(values []int64, now time.Time, window time.Duration) []int64 { - if len(values) == 0 { - return nil - } - cutoff := now.Add(-window).Unix() - out := make([]int64, 0, len(values)) - for _, value := range values { - if value > cutoff && value <= now.Add(time.Minute).Unix() { - out = append(out, value) - } - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func formatRateLimitDuration(value time.Duration) string { - if value < 0 { - value = 0 - } - value = value.Round(time.Second) - if value%time.Hour == 0 && value >= time.Hour { - return fmt.Sprintf("%dh", int(value/time.Hour)) - } - if value%time.Minute == 0 && value >= time.Minute { - return fmt.Sprintf("%dm", int(value/time.Minute)) - } - return value.String() -} diff --git a/internal/service/site_search.go b/internal/service/site_search.go deleted file mode 100644 index 53bd491..0000000 --- a/internal/service/site_search.go +++ /dev/null @@ -1,209 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SearchResult is one torrent returned by a site adapter search. -type SearchResult struct { - SiteName string `json:"site_name"` - SiteID string `json:"site_id"` - Title string `json:"title"` - Subtitle string `json:"subtitle,omitempty"` - Labels string `json:"labels,omitempty"` - TorrentURL string `json:"torrent_url"` - DownloadURL string `json:"download_url"` - Category string `json:"category,omitempty"` - SearchKeyword string `json:"search_keyword,omitempty"` - Size int64 `json:"size"` - Seeders int `json:"seeders"` - Leechers int `json:"leechers"` - Free bool `json:"free"` -} - -// Search fans out a keyword query to every enabled site and returns -// merged results sorted by seeders descending. -// Uses concurrent search with sync.WaitGroup for performance. -func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) { - if strings.TrimSpace(keyword) == "" { - return []SearchResult{}, nil - } - sites, err := s.List(ctx) - if err != nil { - return nil, err - } - - var ( - mu sync.Mutex - wg sync.WaitGroup - enabledCount int - failedCount int - failureErrs []error - failures []string - results []SearchResult - ) - - for i := range sites { - if !sites[i].Enabled { - continue - } - enabledCount++ - wg.Add(1) - go func(site model.Site) { - defer wg.Done() - - adapter := NewSiteAdapter(&site) - if adapter == nil { - mu.Lock() - failedCount++ - err := fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type) - failureErrs = append(failureErrs, err) - failures = append(failures, err.Error()) - mu.Unlock() - return - } - - cfg := s.siteModelToConfig(&site) - timeout := cfg.Timeout - ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1) - if err != nil { - mu.Lock() - failedCount++ - failureErr := fmt.Errorf("%s: %w", site.Name, err) - failureErrs = append(failureErrs, failureErr) - failures = append(failures, failureErr.Error()) - mu.Unlock() - s.log.Warn("site search failed", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.String("url", site.URL), - zap.String("keyword", keyword), - zap.Duration("timeout", timeout), - zap.Error(err)) - return - } - if result == nil { - return - } - siteResults := siteSearchResultsFromItems(site, result, keyword) - mu.Lock() - results = append(results, siteResults...) - mu.Unlock() - }(sites[i]) - } - wg.Wait() - - // Ensure results is never nil (return [] instead of null in JSON) - if results == nil { - results = []SearchResult{} - } - - // Sort by seeders desc. - sort.Slice(results, func(i, j int) bool { - return results[i].Seeders > results[j].Seeders - }) - if s.log != nil { - s.log.Info("site search completed", - zap.String("keyword", keyword), - zap.Int("enabled_sites", enabledCount), - zap.Int("failed_sites", failedCount), - zap.Int("results_count", len(results))) - } - if enabledCount > 0 && failedCount >= enabledCount && len(results) == 0 { - if len(failureErrs) > 0 { - return results, fmt.Errorf("all enabled sites failed while searching %q: %w", keyword, errors.Join(failureErrs...)) - } - return results, fmt.Errorf("all enabled sites failed while searching %q: %s", keyword, strings.Join(failures, "; ")) - } - return results, nil -} - -// SearchSite runs a keyword search against one configured site, regardless of -// whether the site is enabled globally. This is used by per-site diagnostics in -// the management UI, where the user expects the selected site to be tested -// directly instead of a full fan-out followed by filtering. -func (s *SiteService) SearchSite(ctx context.Context, siteID, keyword string, page int) ([]SearchResult, error) { - if strings.TrimSpace(keyword) == "" { - return []SearchResult{}, nil - } - if page <= 0 { - page = 1 - } - site, err := s.FindByID(ctx, siteID) - if err != nil { - return nil, err - } - if site == nil { - return nil, fmt.Errorf("site not found") - } - adapter := NewSiteAdapter(site) - if adapter == nil { - return nil, fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type) - } - cfg := s.siteModelToConfig(site) - timeout := cfg.Timeout - ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - result, err := adapter.Search(ctxWithTimeout, cfg, keyword, page) - if err != nil { - if s.log != nil { - s.log.Warn("single site search failed", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.String("url", site.URL), - zap.String("keyword", keyword), - zap.Duration("timeout", timeout), - zap.Error(err)) - } - return nil, err - } - out := siteSearchResultsFromItems(*site, result, keyword) - sort.Slice(out, func(i, j int) bool { - return out[i].Seeders > out[j].Seeders - }) - if s.log != nil { - s.log.Info("single site search completed", - zap.String("site", site.Name), - zap.String("keyword", keyword), - zap.Int("results_count", len(out))) - } - return out, nil -} - -func siteSearchResultsFromItems(site model.Site, result *SiteSearchResult, keyword string) []SearchResult { - if result == nil || len(result.Items) == 0 { - return []SearchResult{} - } - out := make([]SearchResult, 0, len(result.Items)) - for _, item := range result.Items { - out = append(out, SearchResult{ - SiteName: site.Name, - SiteID: site.ID, - Title: item.Title, - Subtitle: item.Subtitle, - Labels: item.Labels, - TorrentURL: item.DetailURL, - DownloadURL: item.DownloadURL, - Category: item.Category, - SearchKeyword: keyword, - Size: item.Size, - Seeders: item.Seeders, - Leechers: item.Leechers, - Free: item.Free, - }) - } - return out -} diff --git a/internal/service/site_test.go b/internal/service/site_test.go deleted file mode 100644 index 82894a2..0000000 --- a/internal/service/site_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSiteUpdateKeepsSecretsWhenPatchIsBlank(t *testing.T) { - db := newServiceTestDB(t, &model.Site{}) - svc := NewSiteService(zap.NewNop(), &repository.Container{DB: db}, "") - site := &model.Site{ - Name: "M-Team", - Type: "mteam", - URL: "https://api.m-team.cc", - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - if err := svc.Update(context.Background(), site.ID, map[string]any{ - "url": "https://api.m-team.cc/", - "api_key": "", - "cookie": "", - }); err != nil { - t.Fatal(err) - } - - got, err := svc.FindByID(context.Background(), site.ID) - if err != nil { - t.Fatal(err) - } - if got.APIKey != "token-123" { - t.Fatalf("APIKey = %q, want original token", got.APIKey) - } - if got.URL != "https://api.m-team.cc" { - t.Fatalf("URL = %q, want trimmed URL", got.URL) - } -} - -func TestYemaPTTestConnectionDoesNotFallbackAfterAuthFailure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":false,"errorCode":403,"errorMessage":"need api auth"}`)) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.Site{}) - repos := repository.New(db) - svc := NewSiteService(zap.NewNop(), repos, "") - site := &model.Site{ - Name: "YemaPT", - Type: "yemapt", - URL: server.URL, - AuthType: "api_key", - APIKey: "bad-auth", - Enabled: true, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - ok, msg, err := svc.TestConnection(context.Background(), site.ID) - if err != nil { - t.Fatal(err) - } - if ok { - t.Fatal("TestConnection succeeded after YemaPT auth failure") - } - if !strings.Contains(msg, "need api auth") { - t.Fatalf("message = %q, want need api auth", msg) - } -} - -func TestRedactSensitiveDownloadURL(t *testing.T) { - tests := []struct { - name string - raw string - want string - }{ - { - name: "query secrets", - raw: "https://pt.example/download.php?id=123&passkey=secret#frag", - want: "https://pt.example/download.php", - }, - { - name: "magnet", - raw: "magnet:?xt=urn:btih:abc&dn=movie", - want: "magnet:?xt=***", - }, - { - name: "invalid", - raw: "not a url", - want: "[redacted-download-url]", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := redactSensitiveDownloadURL(tt.raw); got != tt.want { - t.Fatalf("redactSensitiveDownloadURL() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestSiteSearchReturnsErrorWhenAllEnabledSitesFail(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "upstream timeout simulation", http.StatusGatewayTimeout) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}) - repos := repository.New(db) - svc := NewSiteService(zap.NewNop(), repos, "") - site := &model.Site{ - Name: "馒头", - Type: "mteam", - URL: upstream.URL, - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - Timeout: 5, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - results, err := svc.Search(context.Background(), "南部档案 2026") - if err == nil { - t.Fatalf("Search error = nil, want all-sites-failed error; results=%#v", results) - } - if len(results) != 0 { - t.Fatalf("results = %#v, want none on all-sites failure", results) - } - if !strings.Contains(err.Error(), "all enabled sites failed") || !strings.Contains(err.Error(), "馒头") { - t.Fatalf("error = %q, want site failure context", err.Error()) - } -} - -func TestSearchSiteQueriesSelectedSiteEvenWhenDisabled(t *testing.T) { - var gotQuery string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.RawQuery - _, _ = w.Write([]byte(`
Selected Site Result下载
`)) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}) - repos := repository.New(db) - svc := NewSiteService(zap.NewNop(), repos, "") - site := &model.Site{ - Name: "Selected Nexus", - Type: "nexusphp", - URL: upstream.URL, - AuthType: "cookie", - Cookie: "uid=1; pass=token", - Enabled: false, - Timeout: 5, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - results, err := svc.SearchSite(context.Background(), site.ID, "Selected", 1) - if err != nil { - t.Fatalf("SearchSite returned error: %v", err) - } - if !strings.Contains(gotQuery, "searchstr=Selected") { - t.Fatalf("query = %q, want searchstr=Selected", gotQuery) - } - if len(results) != 1 || results[0].SiteID != site.ID || results[0].Title != "Selected Site Result" { - t.Fatalf("results = %#v", results) - } -} diff --git a/internal/service/stats.go b/internal/service/stats.go deleted file mode 100644 index 6ab8728..0000000 --- a/internal/service/stats.go +++ /dev/null @@ -1,163 +0,0 @@ -// Package service — statistics aggregator. -// -// StatsService computes the dashboard numbers for the admin / home page: -// - total libraries, media items, users -// - total disk size and durations -// - top recently-watched media -// - process metadata (CPU / memory) via gopsutil -package service - -import ( - "context" - "runtime" - "time" - - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/mem" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// StatsService computes aggregate stats. -type StatsService struct { - log *zap.Logger - repo *repository.Container - cache *RuntimeCacheService -} - -// NewStatsService is the constructor. -func NewStatsService(log *zap.Logger, repo *repository.Container) *StatsService { - return &StatsService{log: log, repo: repo} -} - -func (s *StatsService) SetRuntimeCache(cache *RuntimeCacheService) *StatsService { - if s != nil { - s.cache = cache - } - return s -} - -// Snapshot is the JSON returned by /api/stats. -type Snapshot struct { - Libraries int64 `json:"libraries"` - MediaCount int64 `json:"media_count"` - UsersCount int64 `json:"users_count"` - TotalSizeBytes int64 `json:"total_size_bytes"` - TotalSeconds int64 `json:"total_seconds"` - RecentlyAdded []model.Media `json:"recently_added"` - Hardware Hardware `json:"hardware"` - GeneratedAt time.Time `json:"generated_at"` -} - -// Hardware is the live CPU / memory / disk readings. -type Hardware struct { - CPUPercent float64 `json:"cpu_percent"` - MemoryUsed uint64 `json:"memory_used"` - MemoryTotal uint64 `json:"memory_total"` - DiskUsed uint64 `json:"disk_used"` - DiskTotal uint64 `json:"disk_total"` - GoVersion string `json:"go_version"` - Goroutines int `json:"goroutines"` -} - -// Compute builds a fresh snapshot. -func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, error) { - const cacheKey = "stats:snapshot:base" - if s.cache != nil { - var cached Snapshot - if s.cache.GetJSON(ctx, cacheKey, &cached) { - cached.GeneratedAt = time.Now() - cached.Hardware = readHardware(dataDir) - return &cached, nil - } - } - snap := &Snapshot{GeneratedAt: time.Now()} - libs, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - libs = FilterDisplayCloudLibraries(ctx, s.repo, libs) - activeLibraryIDs := make([]string, 0, len(libs)) - for _, lib := range libs { - if !lib.Enabled { - continue - } - activeLibraryIDs = append(activeLibraryIDs, lib.ID) - } - snap.Libraries = int64(len(activeLibraryIDs)) - mediaQuery := s.repo.DB.Model(&model.Media{}) - if len(activeLibraryIDs) == 0 { - mediaQuery = mediaQuery.Where("1 = 0") - } else { - mediaQuery = mediaQuery.Where("library_id IN ?", activeLibraryIDs) - } - if err := mediaQuery.Count(&snap.MediaCount).Error; err != nil { - return nil, err - } - if err := s.repo.DB.Model(&model.User{}).Count(&snap.UsersCount).Error; err != nil { - return nil, err - } - type sumRow struct { - Size int64 - Seconds int64 - } - var sum sumRow - sumQuery := s.repo.DB.Model(&model.Media{}) - if len(activeLibraryIDs) == 0 { - sumQuery = sumQuery.Where("1 = 0") - } else { - sumQuery = sumQuery.Where("library_id IN ?", activeLibraryIDs) - } - if err := sumQuery. - Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds"). - Scan(&sum).Error; err != nil { - return nil, err - } - snap.TotalSizeBytes = sum.Size - snap.TotalSeconds = sum.Seconds - - recentQuery := s.repo.DB.Model(&model.Media{}) - if len(activeLibraryIDs) == 0 { - recentQuery = recentQuery.Where("1 = 0") - } else { - recentQuery = recentQuery.Where("library_id IN ?", activeLibraryIDs) - } - if err := recentQuery. - Order("created_at desc").Limit(12). - Find(&snap.RecentlyAdded).Error; err != nil { - return nil, err - } - - if s.cache != nil { - cacheCopy := *snap - cacheCopy.Hardware = Hardware{} - s.cache.SetJSON(ctx, cacheKey, cacheCopy, 10*time.Second) - } - snap.Hardware = readHardware(dataDir) - return snap, nil -} - -func readHardware(dataDir string) Hardware { - hw := Hardware{ - GoVersion: runtime.Version(), - Goroutines: runtime.NumGoroutine(), - } - if usage, err := cpu.Percent(0, false); err == nil && len(usage) > 0 { - hw.CPUPercent = usage[0] - } - if v, err := mem.VirtualMemory(); err == nil { - hw.MemoryUsed = v.Used - hw.MemoryTotal = v.Total - } - if dataDir == "" { - dataDir = "/" - } - if d, err := disk.Usage(dataDir); err == nil { - hw.DiskUsed = d.Used - hw.DiskTotal = d.Total - } - return hw -} diff --git a/internal/service/stats_test.go b/internal/service/stats_test.go deleted file mode 100644 index 7844395..0000000 --- a/internal/service/stats_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestStatsComputeFiltersDisabledLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.User{}) - repos := repository.New(db) - enabled := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} - disabled := &model.Library{Name: "停用库", Path: "/media/disabled", Type: "movie", Enabled: false} - if err := repos.Library.Create(t.Context(), enabled); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), disabled); err != nil { - t.Fatal(err) - } - if err := db.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil { - t.Fatal(err) - } - for _, media := range []*model.Media{ - {LibraryID: enabled.ID, Title: "Visible", Path: "/media/movies/a.mkv", SizeBytes: 100}, - {LibraryID: disabled.ID, Title: "Hidden", Path: "/media/disabled/b.mkv", SizeBytes: 900}, - } { - if err := repos.Media.Upsert(t.Context(), media); err != nil { - t.Fatal(err) - } - } - - snap, err := NewStatsService(zap.NewNop(), repos).Compute(t.Context(), t.TempDir()) - if err != nil { - t.Fatal(err) - } - if snap.Libraries != 1 || snap.MediaCount != 1 || snap.TotalSizeBytes != 100 { - t.Fatalf("stats = libraries=%d media=%d size=%d, want 1/1/100", snap.Libraries, snap.MediaCount, snap.TotalSizeBytes) - } -} diff --git a/internal/service/storage_cloud_resolve.go b/internal/service/storage_cloud_resolve.go deleted file mode 100644 index b816834..0000000 --- a/internal/service/storage_cloud_resolve.go +++ /dev/null @@ -1,277 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudResolveCacheEntry struct { - link *cloud.DirectLink - expiresAt time.Time - hits int - lastHit time.Time -} - -type cloudResolveCall struct { - done chan struct{} - link *cloud.DirectLink - err error -} - -const ( - cloudResolveHotHitThreshold = 3 - cloudResolveBackgroundRefreshMax = 30 * time.Second -) - -// CloudResolve resolves a cloud file reference to a direct link. -// -// clientUA is the User-Agent of the playback client that will follow the 302 -// redirect. Some provider CDN links are bound to the UA used to request them, -// so we resolve with the client's own UA. When clientUA is empty the provider's -// default UA is used. -func (s *StorageConfigService) CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) { - if s == nil { - return nil, errors.New("storage config service unavailable") - } - cacheKey := s.resolveCacheKey(typ, fileRef, clientUA) - if link, ok, refresh := s.cachedResolve(cacheKey, typ); ok { - if refresh { - s.refreshResolveInBackground(cacheKey, typ, fileRef, clientUA) - } - return link, nil - } - if call, owner := s.beginResolve(cacheKey); !owner { - select { - case <-call.done: - if call.err != nil { - return nil, call.err - } - return cloneDirectLink(call.link), nil - case <-ctx.Done(): - return nil, ctx.Err() - } - } else { - defer s.finishResolve(cacheKey, call) - p, err := s.cloudProviderWithUA(ctx, typ, clientUA) - if err != nil { - call.err = err - return nil, err - } - link, err := p.Resolve(ctx, fileRef) - if err != nil { - call.err = err - return nil, err - } - call.link = cloneDirectLink(link) - s.storeResolvedLink(cacheKey, typ, link) - return cloneDirectLink(link), nil - } -} - -func (s *StorageConfigService) resolveCacheKey(typ, fileRef, clientUA string) string { - return strings.TrimSpace(typ) + "\x00" + strings.TrimSpace(fileRef) + "\x00" + strings.TrimSpace(clientUA) -} - -func (s *StorageConfigService) cachedResolve(key, typ string) (*cloud.DirectLink, bool, bool) { - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - if s.resolveCache == nil { - s.resolveCache = make(map[string]cloudResolveCacheEntry) - return nil, false, false - } - entry, ok := s.resolveCache[key] - now := time.Now() - if !ok || now.After(entry.expiresAt) { - if ok { - delete(s.resolveCache, key) - } - return nil, false, false - } - entry.hits++ - entry.lastHit = now - s.resolveCache[key] = entry - refreshWindow := cloudResolveHotRefreshWindow(cloudResolveCacheTTL(typ)) - shouldRefresh := entry.hits >= cloudResolveHotHitThreshold && - refreshWindow > 0 && - now.Add(refreshWindow).After(entry.expiresAt) - return cloneDirectLink(entry.link), true, shouldRefresh -} - -func (s *StorageConfigService) beginResolve(key string) (*cloudResolveCall, bool) { - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - if s.resolveFlight == nil { - s.resolveFlight = make(map[string]*cloudResolveCall) - } - if call := s.resolveFlight[key]; call != nil { - return call, false - } - call := &cloudResolveCall{done: make(chan struct{})} - s.resolveFlight[key] = call - return call, true -} - -func (s *StorageConfigService) finishResolve(key string, call *cloudResolveCall) { - s.resolveMu.Lock() - if current := s.resolveFlight[key]; current == call { - delete(s.resolveFlight, key) - } - s.resolveMu.Unlock() - close(call.done) -} - -func (s *StorageConfigService) refreshResolveInBackground(key, typ, fileRef, clientUA string) { - if s == nil { - return - } - go func() { - call, owner := s.beginResolve(key) - if !owner { - return - } - defer s.finishResolve(key, call) - ctx, cancel := context.WithTimeout(context.Background(), cloudResolveBackgroundRefreshMax) - defer cancel() - p, err := s.cloudProviderWithUA(ctx, typ, clientUA) - if err != nil { - call.err = err - if s.log != nil { - s.log.Debug("refresh cloud direct link failed", zap.String("provider", typ), zap.Error(err)) - } - return - } - link, err := p.Resolve(ctx, fileRef) - if err != nil { - call.err = err - if s.log != nil { - s.log.Debug("refresh cloud direct link failed", zap.String("provider", typ), zap.Error(err)) - } - return - } - call.link = cloneDirectLink(link) - s.storeResolvedLink(key, typ, link) - }() -} - -func (s *StorageConfigService) storeResolvedLink(key, typ string, link *cloud.DirectLink) { - if link == nil || strings.TrimSpace(link.URL) == "" { - return - } - ttl := cloudResolveCacheTTL(typ) - if ttl <= 0 { - return - } - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - if s.resolveCache == nil { - s.resolveCache = make(map[string]cloudResolveCacheEntry) - } - now := time.Now() - hits := 0 - if existing, ok := s.resolveCache[key]; ok { - hits = existing.hits - } - s.resolveCache[key] = cloudResolveCacheEntry{link: cloneDirectLink(link), expiresAt: now.Add(ttl), hits: hits, lastHit: now} -} - -func cloudResolveHotRefreshWindow(ttl time.Duration) time.Duration { - if ttl <= 0 { - return 0 - } - window := ttl / 4 - if window < 15*time.Second { - window = 15 * time.Second - } - if window > 2*time.Minute { - window = 2 * time.Minute - } - return window -} - -func cloudResolveCacheTTL(typ string) time.Duration { - switch typ { - case cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList: - return 2 * time.Minute - default: - return 5 * time.Minute - } -} - -func cloneDirectLink(link *cloud.DirectLink) *cloud.DirectLink { - if link == nil { - return nil - } - out := &cloud.DirectLink{ - URL: link.URL, - Headers: make(map[string]string, len(link.Headers)), - Proxy: link.Proxy, - } - for k, v := range link.Headers { - out.Headers[k] = v - } - return out -} - -func (s *StorageConfigService) clearResolveCacheForType(typ string) { - typ = strings.TrimSpace(typ) - if typ == "" { - return - } - prefix := typ + "\x00" - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - for key := range s.resolveCache { - if strings.HasPrefix(key, prefix) { - delete(s.resolveCache, key) - } - } - for key, call := range s.resolveFlight { - if strings.HasPrefix(key, prefix) && call != nil { - call.err = fmt.Errorf("%s storage config changed", typ) - } - } -} - -func (s *StorageConfigService) CloudResolveUncached(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) { - p, err := s.cloudProviderWithUA(ctx, typ, clientUA) - if err != nil { - return nil, err - } - return p.Resolve(ctx, fileRef) -} - -// cloudProviderWithUA builds a provider, overriding the request UA when a -// non-empty clientUA is supplied. -func (s *StorageConfigService) cloudProviderWithUA(ctx context.Context, typ, clientUA string) (cloud.Provider, error) { - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil { - return nil, fmt.Errorf("%s storage not configured", typ) - } - if !view.Enabled { - return nil, fmt.Errorf("%s storage disabled", typ) - } - cfg := view.Config - if strings.TrimSpace(clientUA) != "" { - // Copy so we never mutate the cached view config. - cp := make(map[string]any, len(cfg)+1) - for k, v := range cfg { - cp[k] = v - } - cp["ua"] = clientUA - cfg = cp - } - return cloud.New(typ, cfg, s.clientForConfig(cfg)) -} diff --git a/internal/service/storage_cloud_text.go b/internal/service/storage_cloud_text.go deleted file mode 100644 index 60d1060..0000000 --- a/internal/service/storage_cloud_text.go +++ /dev/null @@ -1,45 +0,0 @@ -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" -) - -// CloudReadText resolves a small cloud file and returns its text payload. It is -// used for cloud-hosted .strm files: the scanner reads the STRM target once and -// stores the real playback URL, while the media bytes still stay in the cloud. -func (s *StorageConfigService) CloudReadText(ctx context.Context, typ, fileRef string, limit int64) (string, error) { - if limit <= 0 { - limit = 64 << 10 - } - link, err := s.CloudResolve(ctx, typ, fileRef, "") - if err != nil { - return "", err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) - if err != nil { - return "", err - } - for k, v := range link.Headers { - req.Header.Set(k, v) - } - resp, err := s.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("%s: read strm returned http %d", typ, resp.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) - if err != nil { - return "", err - } - if int64(len(body)) > limit { - return "", fmt.Errorf("%s: strm file is too large", typ) - } - return strings.TrimSpace(strings.TrimPrefix(string(body), "\ufeff")), nil -} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go deleted file mode 100644 index a820feb..0000000 --- a/internal/service/storage_config.go +++ /dev/null @@ -1,276 +0,0 @@ -// Package service — external storage configuration management. -// -// StorageConfigService stores connection settings encrypted at rest -// (via CryptoService). It also exposes a Test() probe so the React UI -// can verify the credentials before saving. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// StorageConfigService encrypts + persists external storage configs. -type StorageConfigService struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - client *http.Client - resolveMu sync.Mutex - resolveCache map[string]cloudResolveCacheEntry - resolveFlight map[string]*cloudResolveCall -} - -// NewStorageConfigService is the constructor. -func NewStorageConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *StorageConfigService { - return &StorageConfigService{ - log: log, - repo: repo, - crypto: crypto, - client: &http.Client{Timeout: 120 * time.Second}, - resolveCache: make(map[string]cloudResolveCacheEntry), - resolveFlight: make(map[string]*cloudResolveCall), - } -} - -// StorageInput is the create / update payload accepted by the API. -// Config is a free-form map whose required keys depend on Type. -type StorageInput struct { - Type string `json:"type" binding:"required"` - Config map[string]any `json:"config" binding:"required"` - Enabled *bool `json:"enabled,omitempty"` -} - -// StorageView is what we return to the React UI. The actual ciphertext -// is decoded back to a map (with secret keys still redacted in the -// list endpoint via Redact). -type StorageView struct { - model.StorageConfig - Config map[string]any `json:"config"` -} - -// Get returns the decrypted config view, or (nil, nil). -func (s *StorageConfigService) Get(ctx context.Context, kind string) (*StorageView, error) { - row, err := s.repo.StorageConfig.Get(ctx, kind) - if err != nil { - return nil, err - } - if row == nil { - return nil, nil - } - plain := s.crypto.Decrypt(row.Config) - var cfg map[string]any - _ = json.Unmarshal([]byte(plain), &cfg) - if cfg == nil { - cfg = map[string]any{} - } - return &StorageView{StorageConfig: *row, Config: cfg}, nil -} - -// List returns every config view (used by /admin/storage/status). -func (s *StorageConfigService) List(ctx context.Context) ([]StorageView, error) { - rows, err := s.repo.StorageConfig.List(ctx) - if err != nil { - return nil, err - } - out := make([]StorageView, 0, len(rows)) - for _, r := range rows { - if !IsAdminStorageConfigurable(r.Type) { - continue - } - plain := s.crypto.Decrypt(r.Config) - var cfg map[string]any - _ = json.Unmarshal([]byte(plain), &cfg) - // Redact secrets when listing. - for _, k := range []string{"password", "secret_key", "token", "cookie", "access_key"} { - if v, ok := cfg[k]; ok && fmt.Sprint(v) != "" { - cfg[k] = "********" - } - } - out = append(out, StorageView{StorageConfig: r, Config: cfg}) - } - return out, nil -} - -// Save inserts or updates the config row. -func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*StorageView, error) { - if !validStorageType(in.Type) { - return nil, fmt.Errorf("unsupported storage type %q", in.Type) - } - cfg := cloneStorageConfigMap(in.Config) - if shouldPreserveStorageSecretsOnSave(in.Enabled) { - merged, err := s.mergeExistingStorageSecrets(ctx, in.Type, cfg) - if err != nil { - return nil, err - } - cfg = merged - } - blob, err := json.Marshal(cfg) - if err != nil { - return nil, err - } - cipher := s.crypto.Encrypt(string(blob)) - row := &model.StorageConfig{ - Type: in.Type, - Config: cipher, - Enabled: true, - } - if in.Enabled != nil { - row.Enabled = *in.Enabled - } - if err := s.repo.StorageConfig.Upsert(ctx, row); err != nil { - return nil, err - } - s.clearResolveCacheForType(in.Type) - return s.Get(ctx, in.Type) -} - -// Logout clears saved cloud login credentials, disables the storage backend, -// and removes virtual cloud libraries/media for that provider. It intentionally -// keeps non-secret connection hints such as server / WebDAV URL / timeout so -// the admin can log in again without rebuilding the form. -func (s *StorageConfigService) Logout(ctx context.Context, typ string) (*StorageView, error) { - if !validStorageType(typ) { - return nil, fmt.Errorf("unsupported storage type %q", typ) - } - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil { - return nil, fmt.Errorf("%s storage not configured", typ) - } - cfg := make(map[string]any, len(view.Config)) - for k, v := range view.Config { - if isStorageLoginSecretKey(k) || isDeprecatedStoragePlaybackKey(k) { - continue - } - cfg[k] = v - } - enabled := false - saved, err := s.Save(ctx, StorageInput{Type: typ, Config: cfg, Enabled: &enabled}) - if err != nil { - return nil, err - } - purged, err := s.purgeCloudLibraries(ctx, typ) - if err != nil { - return nil, err - } - if s.log != nil { - s.log.Info("storage logout cleared cloud libraries", - zap.String("storage_type", typ), - zap.Int("libraries_deleted", purged)) - } - return saved, nil -} - -func (s *StorageConfigService) purgeCloudLibraries(ctx context.Context, storageType string) (int, error) { - if s == nil || s.repo == nil || s.repo.Library == nil || s.repo.Media == nil { - return 0, nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - return 0, fmt.Errorf("list libraries: %w", err) - } - var affectedLibs []string - for _, lib := range libs { - if mount, ok := ParseCloudLibraryMount(lib.Path); ok && mount.Provider == storageType { - affectedLibs = append(affectedLibs, lib.ID) - } - } - for _, libID := range affectedLibs { - if err := s.repo.Media.PurgeByLibrary(ctx, libID); err != nil { - if s.log != nil { - s.log.Warn("purge media by library failed", zap.String("library_id", libID), zap.Error(err)) - } - return len(affectedLibs), fmt.Errorf("purge media by library %s: %w", libID, err) - } - } - for _, libID := range affectedLibs { - if err := s.repo.Library.Delete(ctx, libID); err != nil { - if s.log != nil { - s.log.Warn("delete library failed", zap.String("library_id", libID), zap.Error(err)) - } - return len(affectedLibs), fmt.Errorf("delete library %s: %w", libID, err) - } - } - return len(affectedLibs), nil -} - -func isStorageLoginSecretKey(key string) bool { - switch strings.ToLower(strings.TrimSpace(key)) { - case "cookie", "token", "username", "password", "access_key", "secret_key": - return true - default: - return false - } -} - -func isDeprecatedStoragePlaybackKey(key string) bool { - switch strings.ToLower(strings.TrimSpace(key)) { - case "force_302", "force_proxy": - return true - default: - return false - } -} - -func validStorageType(t string) bool { - switch t { - case "alist", "s3", "webdav", cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList: - return true - } - return false -} - -// strr is a tiny helper to avoid importing fmt.Sprint just to coerce -// interface{} → string. (Named "strr" so it doesn't collide with the -// notify channel's `str` helper which already lives in this package.) -func strr(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return strings.TrimSpace(fmt.Sprint(v)) -} - -// DeleteStorage 删除存储配置并清理关联数据 -func (s *StorageConfigService) DeleteStorage(ctx context.Context, storageType string) error { - // 查找配置 - cfg, err := s.repo.StorageConfig.Get(ctx, storageType) - if err != nil || cfg == nil { - return fmt.Errorf("storage config not found: %s", storageType) - } - - affectedLibs, err := s.purgeCloudLibraries(ctx, storageType) - if err != nil { - return err - } - - // 删除存储配置 - if err := s.repo.StorageConfig.Delete(ctx, cfg.ID); err != nil { - return fmt.Errorf("delete storage config: %w", err) - } - - s.log.Info("storage deleted", - zap.String("storage_type", storageType), - zap.Int("libraries_deleted", affectedLibs)) - - return nil -} diff --git a/internal/service/storage_config_cache_test.go b/internal/service/storage_config_cache_test.go deleted file mode 100644 index 37a5e4b..0000000 --- a/internal/service/storage_config_cache_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "fmt" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" -) - -func TestCloudResolveHotCacheRefreshesInBackground(t *testing.T) { - var resolves atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/get" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - n := resolves.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"code":200,"data":{"raw_url":"http://cdn.local/%d.mkv"}}`, n) - })) - defer upstream.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "token", - }, - }); err != nil { - t.Fatal(err) - } - - link, err := storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/1.mkv" || resolves.Load() != 1 { - t.Fatalf("first resolve link=%#v resolves=%d", link, resolves.Load()) - } - for i := 0; i < cloudResolveHotHitThreshold-1; i++ { - link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/1.mkv" || resolves.Load() != 1 { - t.Fatalf("cached resolve link=%#v resolves=%d", link, resolves.Load()) - } - } - - key := storage.resolveCacheKey("openlist", "/Movies/f1.mkv", "Player/1") - storage.resolveMu.Lock() - entry := storage.resolveCache[key] - entry.hits = cloudResolveHotHitThreshold - entry.expiresAt = time.Now().Add(5 * time.Second) - storage.resolveCache[key] = entry - storage.resolveMu.Unlock() - - link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/1.mkv" { - t.Fatalf("hot hit should return cached link immediately, got %s", link.URL) - } - deadline := time.Now().Add(2 * time.Second) - for resolves.Load() < 2 && time.Now().Before(deadline) { - time.Sleep(10 * time.Millisecond) - } - if resolves.Load() < 2 { - t.Fatalf("background refresh did not run, resolves=%d", resolves.Load()) - } - link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/2.mkv" { - t.Fatalf("refreshed link = %s, want second URL", link.URL) - } -} - -func TestCloudResolveCacheTTLUsesShortTTLForCloudPlaybackLinks(t *testing.T) { - for _, typ := range []string{"cloud115", "clouddrive2", "openlist"} { - if got := cloudResolveCacheTTL(typ); got != 2*time.Minute { - t.Fatalf("%s cloud resolve cache ttl = %v, want 2m", typ, got) - } - } -} diff --git a/internal/service/storage_config_cloud.go b/internal/service/storage_config_cloud.go deleted file mode 100644 index a415ac5..0000000 --- a/internal/service/storage_config_cloud.go +++ /dev/null @@ -1,147 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// CloudProvider constructs a cloud-disk provider from the saved (decrypted) -// config for the given type, or returns an error if not configured. -func (s *StorageConfigService) CloudProvider(ctx context.Context, typ string) (cloud.Provider, error) { - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil { - return nil, fmt.Errorf("%s storage not configured", typ) - } - if !view.Enabled { - return nil, fmt.Errorf("%s storage disabled", typ) - } - return cloud.New(typ, view.Config, s.clientForConfig(view.Config)) -} - -// CloudList lists entries under dirID for the configured cloud provider. -func (s *StorageConfigService) CloudList(ctx context.Context, typ, dirID string) ([]cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - return p.List(ctx, dirID) -} - -func (s *StorageConfigService) CloudMkdir(ctx context.Context, typ, parentDir, name string) (*cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - mutable, ok := p.(cloud.MutableProvider) - if !ok { - return nil, fmt.Errorf("%s does not support folder creation", typ) - } - return mutable.Mkdir(ctx, parentDir, name) -} - -func (s *StorageConfigService) CloudRename(ctx context.Context, typ, ref, name string) (*cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - mutable, ok := p.(cloud.MutableProvider) - if !ok { - return nil, fmt.Errorf("%s does not support rename", typ) - } - return mutable.Rename(ctx, ref, name) -} - -func (s *StorageConfigService) CloudMove(ctx context.Context, typ, ref, targetDir, name string) (*cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - movable, ok := p.(cloud.MovableProvider) - if !ok { - return nil, fmt.Errorf("%s does not support move", typ) - } - return movable.Move(ctx, ref, targetDir, name) -} - -// cloudLibraryName maps a provider type to a friendly Chinese library name. -func cloudLibraryName(typ string) string { - switch typ { - case cloud.Type115: - return "115 网盘" - case cloud.TypeCloudDrive2: - return "CloudDrive2" - case cloud.TypeOpenList: - return "OpenList" - default: - return typ - } -} - -// ensureCloudLibrary returns (creating if necessary) the per-provider cloud -// library that owns imported 302 media. -func (s *StorageConfigService) ensureCloudLibrary(ctx context.Context, typ string) (*model.Library, error) { - libs, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - path := "cloud://" + typ - for i := range libs { - if libs[i].Path == path { - return &libs[i], nil - } - } - lib := &model.Library{Name: cloudLibraryName(typ), Path: path, Type: "movie", Enabled: true} - if err := s.repo.Library.Create(ctx, lib); err != nil { - return nil, err - } - return lib, nil -} - -// CloudImport creates (or refreshes) a playable media row backed by a cloud -// file. Playback is served entirely via 302 redirect — the host never streams -// the bytes (unless the provider requires proxy mode). -func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, name string, size int64) (*model.Media, error) { - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - if strings.TrimSpace(fileRef) == "" { - return nil, errors.New("file reference required") - } - lib, err := s.ensureCloudLibrary(ctx, typ) - if err != nil { - return nil, err - } - title := strings.TrimSpace(name) - container := "" - if i := strings.LastIndex(title, "."); i > 0 { - container = strings.ToLower(strings.TrimPrefix(title[i:], ".")) - title = title[:i] - } - if title == "" { - title = fileRef - } - m := &model.Media{ - LibraryID: lib.ID, - Title: title, - Path: cloudMediaPath(typ, fileRef), - SizeBytes: size, - Container: container, - STRMURL: BuildRelativeCloudPlayURL(typ, fileRef), - ScrapeStatus: "pending", - } - if err := s.repo.Media.Upsert(ctx, m); err != nil { - return nil, err - } - return m, nil -} diff --git a/internal/service/storage_config_logout_test.go b/internal/service/storage_config_logout_test.go deleted file mode 100644 index 2b390a5..0000000 --- a/internal/service/storage_config_logout_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestStorageConfigLogoutClearsCredentialsAndCloudLibraries(t *testing.T) { - repos, storage := newStorageUploadTestService(t) - enabled := true - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "url": "http://openlist.test/dav/", - "username": "user", - "password": "pass", - "token": "token", - "timeout_seconds": "120", - "force_302": "true", - }, - Enabled: &enabled, - }); err != nil { - t.Fatalf("save storage: %v", err) - } - cloudLib := model.Library{Name: "OpenList", Path: BuildCloudLibraryPath("openlist", "/TV", "/TV"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &cloudLib); err != nil { - t.Fatalf("create cloud library: %v", err) - } - localLib := model.Library{Name: "Local", Path: t.TempDir(), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &localLib); err != nil { - t.Fatalf("create local library: %v", err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: cloudLib.ID, Title: "Cloud", Path: "cloud://openlist/TV/Movie.mkv"}); err != nil { - t.Fatalf("create cloud media: %v", err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: localLib.ID, Title: "Local", Path: localLib.Path + "/Movie.mkv"}); err != nil { - t.Fatalf("create local media: %v", err) - } - - view, err := storage.Logout(t.Context(), "openlist") - if err != nil { - t.Fatalf("logout: %v", err) - } - if view.Enabled { - t.Fatal("storage should be disabled after logout") - } - for _, key := range []string{"username", "password", "token", "force_302", "force_proxy"} { - if _, ok := view.Config[key]; ok { - t.Fatalf("logout should clear %s, config = %#v", key, view.Config) - } - } - if view.Config["server"] != "http://openlist.test" || view.Config["url"] != "http://openlist.test/dav/" || view.Config["timeout_seconds"] != "120" { - t.Fatalf("logout should keep non-secret connection hints, config = %#v", view.Config) - } - if got, err := repos.Library.FindByID(t.Context(), cloudLib.ID); err != nil { - t.Fatalf("find cloud library: %v", err) - } else if got != nil { - t.Fatalf("cloud library should be removed after logout: %#v", got) - } - if got, err := repos.Library.FindByID(t.Context(), localLib.ID); err != nil { - t.Fatalf("find local library: %v", err) - } else if got == nil { - t.Fatal("local library should remain after cloud logout") - } - var cloudMediaCount int64 - if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", "cloud://openlist/TV/Movie.mkv").Count(&cloudMediaCount).Error; err != nil { - t.Fatalf("count cloud media: %v", err) - } - if cloudMediaCount != 0 { - t.Fatalf("cloud media should be purged after logout, count=%d", cloudMediaCount) - } -} - -func TestStorageConfigListHidesDeprecatedQuarkRows(t *testing.T) { - repos, storage := newStorageUploadTestService(t) - if err := repos.StorageConfig.Upsert(t.Context(), &model.StorageConfig{ - Type: LegacyQuarkProvider, - Config: storage.crypto.Encrypt(`{"cookie":"legacy"}`), - Enabled: true, - }); err != nil { - t.Fatalf("insert legacy quark row: %v", err) - } - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "token": "token", - }, - }); err != nil { - t.Fatalf("save openlist row: %v", err) - } - - rows, err := storage.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 || rows[0].Type != "openlist" { - t.Fatalf("storage list = %#v, want only supported OpenList row", rows) - } -} diff --git a/internal/service/storage_config_probe.go b/internal/service/storage_config_probe.go deleted file mode 100644 index 8f34d3b..0000000 --- a/internal/service/storage_config_probe.go +++ /dev/null @@ -1,164 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// Test runs a connection probe against the supplied (un-saved) config. -// The implementation is best-effort: it issues a single HEAD/PROPFIND -// to verify reachability, not full functionality. -func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error { - cfg := in.Config - if cfg == nil { - return errors.New("config required") - } - client := s.clientForConfig(cfg) - switch in.Type { - case "alist": - server := strings.TrimRight(strr(cfg["server"]), "/") - if server == "" { - return errors.New("alist missing server") - } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/me", nil) - if tok := strr(cfg["token"]); tok != "" { - req.Header.Set("Authorization", tok) - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - return validateStorageProbeStatus("alist", resp.StatusCode) - case cloud.TypeOpenList: - p, err := cloud.New(in.Type, cfg, client) - if err != nil { - return err - } - return p.Ping(ctx) - case "webdav": - u := strr(cfg["url"]) - if u == "" { - return errors.New("webdav missing url") - } - req, _ := http.NewRequestWithContext(ctx, "PROPFIND", u, nil) - if user := strr(cfg["username"]); user != "" { - req.SetBasicAuth(user, strr(cfg["password"])) - } - req.Header.Set("Depth", "0") - resp, err := client.Do(req) - if err != nil { - return decorateStorageTransportError("webdav", u, err) - } - defer resp.Body.Close() - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized { - // 401 with creds means bad creds; with no creds it's reachable. - if user := strr(cfg["username"]); user == "" && resp.StatusCode == http.StatusUnauthorized { - return nil - } - return fmt.Errorf("webdav returned %d", resp.StatusCode) - } - return nil - case "s3": - ep := strr(cfg["endpoint"]) - if ep == "" { - return errors.New("s3 missing endpoint") - } - // We only verify endpoint reachability — full SigV4 is a large - // dependency; the upstream Vue project also stops at this level. - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil) - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - return nil - case cloud.Type115, cloud.TypeCloudDrive2: - p, err := cloud.New(in.Type, cfg, client) - if err != nil { - return err - } - return p.Ping(ctx) - default: - return fmt.Errorf("unsupported storage type %q", in.Type) - } -} - -func (s *StorageConfigService) clientForConfig(cfg map[string]any) *http.Client { - if s == nil || s.client == nil { - return &http.Client{Timeout: 120 * time.Second} - } - timeout := storageTimeoutFromConfig(cfg, s.client.Timeout) - if timeout == s.client.Timeout { - return s.client - } - cp := *s.client - cp.Timeout = timeout - return &cp -} - -func storageTimeoutFromConfig(cfg map[string]any, fallback time.Duration) time.Duration { - if fallback <= 0 { - fallback = 120 * time.Second - } - raw := "" - for _, key := range []string{"timeout_seconds", "webdav_timeout_seconds", "request_timeout_seconds"} { - if value := strr(cfg[key]); value != "" { - raw = value - break - } - } - if raw == "" { - return fallback - } - seconds, err := strconv.Atoi(raw) - if err != nil { - if f, ferr := strconv.ParseFloat(raw, 64); ferr == nil { - seconds = int(f) - } - } - if seconds <= 0 { - return fallback - } - if seconds < 5 { - seconds = 5 - } - if seconds > 600 { - seconds = 600 - } - return time.Duration(seconds) * time.Second -} - -func validateStorageProbeStatus(name string, status int) error { - if status >= 200 && status < 300 { - return nil - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%s authentication failed: http %d;请检查 Token / 用户名密码", name, status) - } - if status >= 300 { - return fmt.Errorf("%s returned %d", name, status) - } - return nil -} - -func decorateStorageTransportError(name, target string, err error) error { - if err == nil { - return nil - } - message := err.Error() - if strings.Contains(message, "server gave HTTP response to HTTPS client") { - return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址;OpenList 默认 WebDAV 通常是 http://host:5244/dav/,管理页面/API 地址通常是 http://host:5244", name, err) - } - if strings.Contains(message, "first record does not look like a TLS handshake") { - return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target) - } - return err -} diff --git a/internal/service/storage_config_probe_test.go b/internal/service/storage_config_probe_test.go deleted file mode 100644 index a94c3c4..0000000 --- a/internal/service/storage_config_probe_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestStorageConfigOpenListHTTPSAgainstHTTPHint(t *testing.T) { - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"code":200}`)) - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - badHTTPS := "https://" + strings.TrimPrefix(openlist.URL, "http://") - err := storage.Test(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": badHTTPS, - }, - }) - if err == nil { - t.Fatal("want protocol mismatch error") - } - if !strings.Contains(err.Error(), "请改用 http://") || !strings.Contains(err.Error(), "server gave HTTP response to HTTPS client") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestStorageConfigOpenListTestRejectsUnauthorizedList(t *testing.T) { - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/dav" { - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("unauthorized")) - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - err := storage.Test(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - }, - }) - if err == nil || !strings.Contains(err.Error(), "http 401") { - t.Fatalf("openlist unauthorized probe error = %v, want http 401", err) - } -} - -func TestStorageConfigOpenListTestUsesAPIListWithToken(t *testing.T) { - var listed bool - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("authorization = %q", r.Header.Get("Authorization")) - } - listed = true - _, _ = w.Write([]byte(`{"code":200,"data":{"content":[],"total":0}}`)) - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - if err := storage.Test(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatalf("openlist API probe: %v", err) - } - if !listed { - t.Fatal("openlist test should probe /api/fs/list") - } -} - -func TestStorageConfigAlistTestRejectsUnauthorized(t *testing.T) { - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/me" { - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - w.WriteHeader(http.StatusUnauthorized) - })) - defer alist.Close() - - _, storage := newStorageUploadTestService(t) - err := storage.Test(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - }, - }) - if err == nil || !strings.Contains(err.Error(), "authentication failed") { - t.Fatalf("alist unauthorized probe error = %v, want authentication failed", err) - } -} - -func TestStorageConfigCloudProviderRejectsDisabledConfig(t *testing.T) { - _, storage := newStorageUploadTestService(t) - enabled := false - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": "http://127.0.0.1:5244/dav", - }, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - _, err := storage.CloudProvider(t.Context(), "openlist") - if err == nil || !strings.Contains(err.Error(), "disabled") { - t.Fatalf("disabled provider error = %v, want disabled", err) - } -} - -func TestStorageConfigSavePreservesExistingSecretWhenFormLeavesItBlank(t *testing.T) { - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "token": "", - "timeout_seconds": "180", - }, - }); err != nil { - t.Fatal(err) - } - view, err := storage.Get(t.Context(), "openlist") - if err != nil { - t.Fatal(err) - } - if view.Config["token"] != "openlist-token" { - t.Fatalf("token = %#v, want preserved token", view.Config["token"]) - } - if view.Config["timeout_seconds"] != "180" { - t.Fatalf("timeout_seconds = %#v, want updated timeout", view.Config["timeout_seconds"]) - } -} diff --git a/internal/service/storage_config_secrets.go b/internal/service/storage_config_secrets.go deleted file mode 100644 index d327fa9..0000000 --- a/internal/service/storage_config_secrets.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package service — storage configuration secret preservation helpers. -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func shouldPreserveStorageSecretsOnSave(enabled *bool) bool { - return enabled == nil || *enabled -} - -func cloneStorageConfigMap(cfg map[string]any) map[string]any { - out := make(map[string]any, len(cfg)) - for k, v := range cfg { - out[k] = v - } - return out -} - -func (s *StorageConfigService) mergeExistingStorageSecrets(ctx context.Context, typ string, cfg map[string]any) (map[string]any, error) { - view, err := s.Get(ctx, typ) - if err != nil || view == nil { - return cfg, err - } - for _, key := range storagePreservedSecretKeys() { - existing := strings.TrimSpace(strr(view.Config[key])) - if existing == "" { - continue - } - incoming, hasIncoming := cfg[key] - if hasIncoming && !isBlankStorageSecret(incoming) { - continue - } - if storageSecretReplacedByAlternative(typ, key, cfg, view.Config) { - continue - } - cfg[key] = existing - } - return cfg, nil -} - -func storagePreservedSecretKeys() []string { - return []string{"password", "secret_key", "token", "cookie", "access_key"} -} - -func isBlankStorageSecret(value any) bool { - text := strings.TrimSpace(strr(value)) - return text == "" || text == "********" -} - -func storageSecretReplacedByAlternative(typ, key string, cfg, existing map[string]any) bool { - switch typ { - case cloud.TypeOpenList: - switch key { - case "token": - return strings.TrimSpace(strr(cfg["username"])) != "" && strings.TrimSpace(strr(cfg["password"])) != "" - case "password": - if strings.TrimSpace(strr(cfg["token"])) != "" { - return true - } - return storagePlainFieldChanged("username", cfg, existing) - } - case "webdav", cloud.TypeCloudDrive2: - if key == "password" { - if strings.TrimSpace(strr(cfg["token"])) != "" { - return true - } - return storagePlainFieldChanged("username", cfg, existing) - } - case "s3": - if key == "secret_key" { - return storagePlainFieldChanged("access_key", cfg, existing) - } - } - return false -} - -func storagePlainFieldChanged(key string, cfg, existing map[string]any) bool { - incoming := strings.TrimSpace(strr(cfg[key])) - if incoming == "" { - return false - } - current := strings.TrimSpace(strr(existing[key])) - return current != "" && incoming != current -} diff --git a/internal/service/storage_test.go b/internal/service/storage_test.go deleted file mode 100644 index b86f532..0000000 --- a/internal/service/storage_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package service - -import ( - "slices" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "go.uber.org/zap" -) - -func TestStorageBreakdownUsesCanonicalLibraryDisplay(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - libs := []model.Library{ - {Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true}, - {Name: "欧美动漫", Path: "/media/动漫/欧美动漫", Type: "tv", Enabled: true}, - {Name: "9KG", Path: "/media/成人/9KG", Type: "movie", Enabled: true}, - } - for i := range libs { - if err := repos.Library.Create(t.Context(), &libs[i]); err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: libs[i].ID, - Title: libs[i].Name, - Path: libs[i].Path + "/item.mkv", - SizeBytes: 1024, - }); err != nil { - t.Fatal(err) - } - } - - breakdown, err := NewStorageService(zap.NewNop(), repos).Compute(t.Context()) - if err != nil { - t.Fatal(err) - } - gotNames := make([]string, 0, len(breakdown.ByLibrary)) - gotTypes := make([]string, 0, len(breakdown.ByLibrary)) - for _, row := range breakdown.ByLibrary { - gotNames = append(gotNames, row.Name) - gotTypes = append(gotTypes, row.Type) - } - if want := []string{"欧美电影", "美漫", "成人"}; !slices.Equal(gotNames, want) { - t.Fatalf("library names = %#v, want %#v", gotNames, want) - } - if want := []string{"movie", "anime", "adult"}; !slices.Equal(gotTypes, want) { - t.Fatalf("library types = %#v, want %#v", gotTypes, want) - } -} diff --git a/internal/service/storage_types.go b/internal/service/storage_types.go deleted file mode 100644 index 72ef2a9..0000000 --- a/internal/service/storage_types.go +++ /dev/null @@ -1,31 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -const LegacyQuarkProvider = "quark" - -func IsAdminStorageConfigurable(typ string) bool { - switch strings.TrimSpace(typ) { - case cloud.TypeOpenList, "alist", "webdav", cloud.TypeCloudDrive2, cloud.Type115: - return true - default: - return false - } -} - -func IsAdminCloudConfigurable(typ string) bool { - switch strings.TrimSpace(typ) { - case cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList: - return true - default: - return false - } -} - -func IsDeprecatedNativeCloudProvider(typ string) bool { - return strings.TrimSpace(typ) == LegacyQuarkProvider -} diff --git a/internal/service/storage_types_test.go b/internal/service/storage_types_test.go deleted file mode 100644 index 4a3643e..0000000 --- a/internal/service/storage_types_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package service - -import "testing" - -func TestAdminStorageConfigurableTypes(t *testing.T) { - for _, typ := range []string{"openlist", "alist", "webdav", "clouddrive2", "cloud115"} { - if !IsAdminStorageConfigurable(typ) { - t.Fatalf("%s should be configurable", typ) - } - } - for _, typ := range []string{"quark", "s3", "", "unknown"} { - if IsAdminStorageConfigurable(typ) { - t.Fatalf("%s should not be configurable", typ) - } - } -} - -func TestAdminCloudConfigurableTypes(t *testing.T) { - for _, typ := range []string{"openlist", "clouddrive2", "cloud115"} { - if !IsAdminCloudConfigurable(typ) { - t.Fatalf("%s should be cloud-configurable", typ) - } - } - for _, typ := range []string{"quark", "alist", "webdav", "s3", ""} { - if IsAdminCloudConfigurable(typ) { - t.Fatalf("%s should not be cloud-configurable", typ) - } - } -} diff --git a/internal/service/storage_upload.go b/internal/service/storage_upload.go deleted file mode 100644 index 12bfb42..0000000 --- a/internal/service/storage_upload.go +++ /dev/null @@ -1,278 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "os" - "path" - "path/filepath" - "strings" -) - -const ( - CloudUploadAutoEnabledKey = "cloud.upload_auto_enabled" - CloudUploadProviderKey = "cloud.upload_provider" - CloudUploadSourceDirKey = "cloud.upload_source_dir" - CloudUploadDestPathKey = "cloud.upload_dest_path" - CloudUploadRecursiveKey = "cloud.upload_recursive" - CloudUploadSidecarsKey = "cloud.upload_sidecars" - CloudUploadOverwriteKey = "cloud.upload_overwrite" - CloudUploadTransferModeKey = "cloud.upload_transfer_mode" - CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds" - CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / OpenList / WebDAV / CloudDrive2;115 原生上传需要分片上传私有接口,建议先用 CloudDrive2、OpenList 或 Alist 桥接后转存。" -) - -type CloudUploadInput struct { - Type string `json:"type"` - SourcePath string `json:"source_path"` - DestPath string `json:"dest_path"` - Recursive bool `json:"recursive"` - IncludeSidecars bool `json:"include_sidecars"` - Overwrite bool `json:"overwrite"` - TransferMode string `json:"transfer_mode"` -} - -type CloudUploadResult struct { - SourcePath string `json:"source_path"` - DestPath string `json:"dest_path"` - Uploaded int `json:"uploaded"` - Moved int `json:"moved,omitempty"` - Skipped int `json:"skipped"` - Bytes int64 `json:"bytes"` - Errors []string `json:"errors,omitempty"` - Items []CloudUploadResultItem `json:"items,omitempty"` -} - -type CloudUploadResultItem struct { - Source string `json:"source"` - Target string `json:"target"` - Action string `json:"action"` // upload / move / skip / error - Size int64 `json:"size,omitempty"` - Reason string `json:"reason,omitempty"` -} - -type storageUploader interface { - ensureDir(ctx context.Context, remoteDir string) error - exists(ctx context.Context, remotePath string) (bool, error) - upload(ctx context.Context, localPath, remotePath string, size int64) error -} - -var cloudUploadSidecarExtensions = map[string]struct{}{ - ".nfo": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, - ".srt": {}, ".ass": {}, ".ssa": {}, ".vtt": {}, ".sub": {}, ".idx": {}, -} - -// UploadLocal copies or moves local media files into an external storage -// backend. Cloud writes are intentionally gated by the per-storage -// transfer_enabled switch so mounting/scanning a cloud provider does not -// accidentally make it writable. -func (s *StorageConfigService) UploadLocal(ctx context.Context, in CloudUploadInput) (*CloudUploadResult, error) { - in.Type = strings.TrimSpace(in.Type) - in.SourcePath = strings.TrimSpace(in.SourcePath) - in.DestPath = normalizeRemotePath(in.DestPath) - if in.SourcePath == "" { - return nil, errors.New("source_path required") - } - view, err := s.Get(ctx, in.Type) - if err != nil { - return nil, err - } - if view == nil || !view.Enabled { - return nil, fmt.Errorf("%s storage not configured", in.Type) - } - if !parseBoolSetting(strr(view.Config["transfer_enabled"]), false) { - return nil, errors.New("cloud transfer is disabled for this storage; enable transfer in external storage settings before writing to cloud") - } - mode := resolveCloudUploadTransferMode(in.TransferMode, strr(view.Config["transfer_mode"]), s.settingValue(ctx, CloudUploadTransferModeKey)) - if mode != TransferCopy && mode != TransferMove { - return nil, errors.New("transfer_mode must be copy or move") - } - in.TransferMode = string(mode) - uploader, err := s.uploaderForView(in.Type, view) - if err != nil { - return nil, err - } - info, err := os.Stat(in.SourcePath) - if err != nil { - return nil, fmt.Errorf("source path not accessible: %w", err) - } - result := &CloudUploadResult{SourcePath: in.SourcePath, DestPath: in.DestPath} - if !info.IsDir() { - s.uploadOne(ctx, uploader, in, in.SourcePath, filepath.Base(in.SourcePath), info.Size(), result) - return result, firstUploadError(result) - } - root := filepath.Clean(in.SourcePath) - walkFn := func(localPath string, entryInfo os.FileInfo, walkErr error) error { - if walkErr != nil { - addUploadError(result, localPath, "", walkErr) - return nil - } - if entryInfo == nil || entryInfo.IsDir() { - if !in.Recursive && filepath.Clean(localPath) != root { - return filepath.SkipDir - } - return nil - } - if !eligibleCloudUploadFile(localPath, in.IncludeSidecars) { - return nil - } - rel, err := filepath.Rel(root, localPath) - if err != nil { - addUploadError(result, localPath, "", err) - return nil - } - s.uploadOne(ctx, uploader, in, localPath, filepath.ToSlash(rel), entryInfo.Size(), result) - return nil - } - if err := filepath.Walk(in.SourcePath, walkFn); err != nil { - return result, err - } - return result, firstUploadError(result) -} - -func (s *StorageConfigService) uploader(ctx context.Context, typ string) (storageUploader, error) { - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil || !view.Enabled { - return nil, fmt.Errorf("%s storage not configured", typ) - } - return s.uploaderForView(typ, view) -} - -func (s *StorageConfigService) uploaderForView(typ string, view *StorageView) (storageUploader, error) { - if view == nil || !view.Enabled { - return nil, fmt.Errorf("%s storage not configured", typ) - } - switch typ { - case "alist": - return newAlistUploader(view.Config), nil - case "openlist": - if strings.TrimSpace(strr(view.Config["server"])) != "" { - return newNamedAlistUploader("openlist", view.Config), nil - } - return newWebDAVUploader(view.Config), nil - case "webdav": - return newWebDAVUploader(view.Config), nil - case "clouddrive2": - return newWebDAVUploader(view.Config), nil - case "s3": - return nil, errors.New("s3 local upload is not implemented yet") - case "cloud115": - return nil, errors.New(CloudUploadUnsupportedProvider) - default: - return nil, fmt.Errorf("unsupported storage type %q", typ) - } -} - -func (s *StorageConfigService) uploadOne(ctx context.Context, uploader storageUploader, in CloudUploadInput, localPath, rel string, size int64, result *CloudUploadResult) { - remotePath := joinRemotePath(in.DestPath, rel) - if err := uploader.ensureDir(ctx, path.Dir(remotePath)); err != nil { - addUploadError(result, localPath, remotePath, err) - return - } - if !in.Overwrite { - exists, err := uploader.exists(ctx, remotePath) - if err != nil { - addUploadError(result, localPath, remotePath, err) - return - } - if exists { - result.Skipped++ - addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "skip", Size: size, Reason: "remote exists"}) - return - } - } - if err := uploader.upload(ctx, localPath, remotePath, size); err != nil { - addUploadError(result, localPath, remotePath, err) - return - } - result.Uploaded++ - result.Bytes += size - action := "upload" - mode := resolveCloudUploadTransferMode(in.TransferMode) - if mode == TransferMove { - if err := os.Remove(localPath); err != nil { - addUploadError(result, localPath, remotePath, fmt.Errorf("uploaded but failed to remove local source: %w", err)) - addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "upload", Size: size, Reason: "source remove failed"}) - return - } - result.Moved++ - action = "move" - } - addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: action, Size: size}) -} - -func (s *StorageConfigService) settingValue(ctx context.Context, key string) string { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return "" - } - v, _ := s.repo.Setting.Get(ctx, key) - return strings.TrimSpace(v) -} - -func resolveCloudUploadTransferMode(values ...string) TransferMode { - for _, value := range values { - switch strings.ToLower(strings.TrimSpace(value)) { - case "move", "移动": - return TransferMove - case "copy", "复制": - return TransferCopy - } - } - return TransferCopy -} - -func eligibleCloudUploadFile(localPath string, includeSidecars bool) bool { - ext := strings.ToLower(filepath.Ext(localPath)) - if _, ok := videoExtensions[ext]; ok { - return true - } - if includeSidecars { - _, ok := cloudUploadSidecarExtensions[ext] - return ok - } - return false -} - -func addUploadError(result *CloudUploadResult, source, target string, err error) { - result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", source, err)) - addUploadItem(result, CloudUploadResultItem{Source: source, Target: target, Action: "error", Reason: err.Error()}) -} - -func addUploadItem(result *CloudUploadResult, item CloudUploadResultItem) { - if len(result.Items) < 200 { - result.Items = append(result.Items, item) - } -} - -func firstUploadError(result *CloudUploadResult) error { - if result.Uploaded > 0 || len(result.Errors) == 0 { - return nil - } - return errors.New(result.Errors[0]) -} - -func normalizeRemotePath(p string) string { - p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") - if p == "" || p == "." { - return "/" - } - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - return path.Clean(p) -} - -func joinRemotePath(base, rel string) string { - parts := []string{normalizeRemotePath(base)} - for _, part := range strings.Split(strings.ReplaceAll(rel, "\\", "/"), "/") { - part = strings.TrimSpace(part) - if part != "" && part != "." { - parts = append(parts, part) - } - } - return path.Clean(path.Join(parts...)) -} diff --git a/internal/service/storage_upload_alist.go b/internal/service/storage_upload_alist.go deleted file mode 100644 index 7e0aebf..0000000 --- a/internal/service/storage_upload_alist.go +++ /dev/null @@ -1,208 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "strings" -) - -type alistUploader struct { - name string - server string - token string - username string - password string - client *http.Client -} - -func newAlistUploader(cfg map[string]any) *alistUploader { - return newNamedAlistUploader("alist", cfg) -} - -func newNamedAlistUploader(name string, cfg map[string]any) *alistUploader { - return &alistUploader{ - name: name, - server: strings.TrimRight(strr(cfg["server"]), "/"), - token: strr(cfg["token"]), - username: strr(cfg["username"]), - password: strr(cfg["password"]), - client: &http.Client{}, - } -} - -func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error { - if a.server == "" { - return fmt.Errorf("%s missing server", a.name) - } - if err := a.ensureToken(ctx); err != nil { - return err - } - remoteDir = normalizeRemotePath(remoteDir) - if remoteDir == "/" { - return nil - } - current := "" - for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") { - current = normalizeRemotePath(path.Join(current, part)) - payload, _ := json.Marshal(map[string]string{"path": current}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/mkdir", bytes.NewReader(payload)) - if err != nil { - return err - } - a.auth(req) - req.Header.Set("Content-Type", "application/json") - resp, err := a.client.Do(req) - if err != nil { - return decorateStorageTransportError(a.name, a.server, err) - } - err = a.checkJSON(resp, "alist mkdir") - if err != nil && !isAlreadyExistsMessage(err.Error()) { - return err - } - } - return nil -} - -func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, error) { - if err := a.ensureToken(ctx); err != nil { - return false, err - } - payload, _ := json.Marshal(map[string]string{"path": normalizeRemotePath(remotePath)}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/get", bytes.NewReader(payload)) - if err != nil { - return false, err - } - a.auth(req) - req.Header.Set("Content-Type", "application/json") - resp, err := a.client.Do(req) - if err != nil { - return false, decorateStorageTransportError(a.name, a.server, err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return false, nil - } - var out struct { - Code int `json:"code"` - Message string `json:"message"` - } - _ = json.NewDecoder(resp.Body).Decode(&out) - return resp.StatusCode >= 200 && resp.StatusCode < 300 && out.Code == 200, nil -} - -func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error { - if err := a.ensureToken(ctx); err != nil { - return err - } - f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload. - if err != nil { - return err - } - defer f.Close() - req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.server+"/api/fs/put", f) - if err != nil { - return err - } - a.auth(req) - req.ContentLength = size - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("File-Path", url.PathEscape(normalizeRemotePath(remotePath))) - resp, err := a.client.Do(req) - if err != nil { - return decorateStorageTransportError(a.name, a.server, err) - } - return a.checkJSON(resp, "alist upload") -} - -func (a *alistUploader) ensureToken(ctx context.Context) error { - if strings.TrimSpace(a.token) != "" { - return nil - } - if strings.TrimSpace(a.username) == "" || a.password == "" { - return nil - } - if a.server == "" { - return fmt.Errorf("%s missing server", a.name) - } - payload, _ := json.Marshal(map[string]string{ - "username": a.username, - "password": a.password, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/auth/login", bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - resp, err := a.client.Do(req) - if err != nil { - return decorateStorageTransportError(a.name, a.server, err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s login: http %d: %s", a.name, resp.StatusCode, strings.TrimSpace(string(body))) - } - var out struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - Token string `json:"token"` - } `json:"data"` - } - if err := json.Unmarshal(body, &out); err != nil { - return fmt.Errorf("%s login: decode response: %w", a.name, err) - } - if out.Code != 0 && out.Code != 200 { - msg := strings.TrimSpace(out.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", out.Code) - } - return fmt.Errorf("%s login: %s", a.name, msg) - } - a.token = strings.TrimSpace(out.Data.Token) - if a.token == "" { - return fmt.Errorf("%s login returned empty token", a.name) - } - return nil -} - -func (a *alistUploader) auth(req *http.Request) { - if a.token != "" { - req.Header.Set("Authorization", a.token) - } -} - -func (a *alistUploader) checkJSON(resp *http.Response, op string) error { - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s: http %d: %s", op, resp.StatusCode, strings.TrimSpace(string(body))) - } - var out struct { - Code int `json:"code"` - Message string `json:"message"` - } - if len(bytes.TrimSpace(body)) == 0 { - return nil - } - if err := json.Unmarshal(body, &out); err != nil { - return nil - } - if out.Code != 0 && out.Code != 200 { - return fmt.Errorf("%s: %s", op, out.Message) - } - return nil -} - -func isAlreadyExistsMessage(message string) bool { - message = strings.ToLower(message) - return strings.Contains(message, "exist") || strings.Contains(message, "已存在") -} diff --git a/internal/service/storage_upload_jobs_test.go b/internal/service/storage_upload_jobs_test.go deleted file mode 100644 index 468c765..0000000 --- a/internal/service/storage_upload_jobs_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) { - var uploaded []string - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _, _ = w.Write([]byte(`{"code":200}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - case "/api/fs/put": - decoded, _ := url.PathUnescape(r.Header.Get("File-Path")) - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200}`)) - default: - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - })) - defer alist.Close() - - repos, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - "token": "token", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Show.S01E01.mkv"), []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - CloudUploadAutoEnabledKey: "true", - CloudUploadProviderKey: "alist", - CloudUploadSourceDirKey: source, - CloudUploadDestPathKey: "/cloud-media", - CloudUploadRecursiveKey: "true", - CloudUploadSidecarsKey: "false", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, storage, NewHub(zap.NewNop()), "") - if err := scheduler.jobUploadLocalToCloud(t.Context()); err != nil { - t.Fatalf("cloud upload job: %v", err) - } - if len(uploaded) != 1 || uploaded[0] != "/cloud-media/Show.S01E01.mkv" { - t.Fatalf("uploaded = %#v", uploaded) - } -} - -func TestStorageConfigUploadLocalToCloudDrive2(t *testing.T) { - var uploaded []string - dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "MKCOL": - w.WriteHeader(http.StatusCreated) - case http.MethodHead: - w.WriteHeader(http.StatusNotFound) - case http.MethodPut: - uploaded = append(uploaded, r.URL.Path) - w.WriteHeader(http.StatusCreated) - default: - t.Fatalf("unexpected method %s %s", r.Method, r.URL.Path) - } - })) - defer dav.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "clouddrive2", - Config: map[string]any{ - "url": dav.URL + "/dav", - "username": "user", - "password": "pass", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "clouddrive2", - SourcePath: source, - DestPath: "/MediaStationGo", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/dav/MediaStationGo/Movie.mkv" { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } -} - -func TestStorageConfigUploadLocalRequiresTransferEnabled(t *testing.T) { - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": "http://alist.test", - "token": "token", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - _, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "alist", - SourcePath: source, - DestPath: "/MediaStationGo", - Recursive: true, - }) - if err == nil || !strings.Contains(err.Error(), "transfer is disabled") { - t.Fatalf("upload error = %v, want transfer disabled", err) - } -} - -func TestStorageConfigUploadLocalMoveDeletesSourceAfterUpload(t *testing.T) { - var uploaded []string - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _, _ = w.Write([]byte(`{"code":200}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - case "/api/fs/put": - decoded, _ := url.PathUnescape(r.Header.Get("File-Path")) - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200}`)) - default: - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - })) - defer alist.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - "token": "token", - "transfer_enabled": "true", - "transfer_mode": "move", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - file := filepath.Join(source, "Movie.mkv") - if err := os.WriteFile(file, []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "alist", - SourcePath: source, - DestPath: "/MediaStationGo", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local move: %v", err) - } - if res.Uploaded != 1 || res.Moved != 1 || len(uploaded) != 1 { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } - if _, err := os.Stat(file); !os.IsNotExist(err) { - t.Fatalf("source should be removed after move upload, stat err=%v", err) - } -} diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go deleted file mode 100644 index d39036f..0000000 --- a/internal/service/storage_upload_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "sort" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestStorageConfigUploadLocalToAlist(t *testing.T) { - var uploaded []string - var authHeaders []string - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _ = json.NewDecoder(r.Body).Decode(&map[string]string{}) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`)) - case "/api/fs/put": - authHeaders = append(authHeaders, r.Header.Get("Authorization")) - decoded, err := url.PathUnescape(r.Header.Get("File-Path")) - if err != nil { - t.Fatalf("decode file path: %v", err) - } - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - default: - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - })) - defer alist.Close() - - repos, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - "token": "alist-token", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "Movie.2026.nfo"), []byte("nfo"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "ignore.txt"), []byte("txt"), 0o644); err != nil { - t.Fatal(err) - } - - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "alist", - SourcePath: source, - DestPath: "/backup", - Recursive: true, - IncludeSidecars: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if res.Uploaded != 2 || res.Skipped != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %+v", res) - } - sort.Strings(uploaded) - want := []string{"/backup/Movie.2026.mkv", "/backup/Movie.2026.nfo"} - if strings.Join(uploaded, "\n") != strings.Join(want, "\n") { - t.Fatalf("uploaded = %#v, want %#v", uploaded, want) - } - for _, header := range authHeaders { - if header != "alist-token" { - t.Fatalf("authorization header = %q", header) - } - } - if got, _ := repos.StorageConfig.Get(t.Context(), "alist"); got == nil { - t.Fatalf("storage config should remain saved") - } -} - -func TestStorageConfigUploadLocalToOpenListAPI(t *testing.T) { - var uploaded []string - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`)) - case "/api/fs/put": - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("authorization = %q", r.Header.Get("Authorization")) - } - decoded, err := url.PathUnescape(r.Header.Get("File-Path")) - if err != nil { - t.Fatalf("decode file path: %v", err) - } - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - default: - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "token": "openlist-token", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "openlist", - SourcePath: source, - DestPath: "/OpenList", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } -} - -func TestStorageConfigUploadLocalToOpenListAPIWithUsernamePassword(t *testing.T) { - var loginSeen bool - var uploaded []string - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/auth/login": - loginSeen = true - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode login body: %v", err) - } - if body["username"] != "alice" || body["password"] != "secret" { - t.Fatalf("login body = %#v", body) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"token":"openlist-session-token"}}`)) - case "/api/fs/mkdir": - if r.Header.Get("Authorization") != "openlist-session-token" { - t.Fatalf("mkdir authorization = %q", r.Header.Get("Authorization")) - } - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/get": - if r.Header.Get("Authorization") != "openlist-session-token" { - t.Fatalf("get authorization = %q", r.Header.Get("Authorization")) - } - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`)) - case "/api/fs/put": - if r.Header.Get("Authorization") != "openlist-session-token" { - t.Fatalf("put authorization = %q", r.Header.Get("Authorization")) - } - decoded, err := url.PathUnescape(r.Header.Get("File-Path")) - if err != nil { - t.Fatalf("decode file path: %v", err) - } - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/dav": - t.Fatal("OpenList username/password upload should use API, not WebDAV") - default: - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "username": "alice", - "password": "secret", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "openlist", - SourcePath: source, - DestPath: "/OpenList", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if !loginSeen { - t.Fatal("expected OpenList API login") - } - if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } -} - -func newStorageUploadTestService(t *testing.T) (*repository.Container, *StorageConfigService) { - t.Helper() - db := newServiceTestDB(t, &model.StorageConfig{}, &model.Setting{}, &model.Library{}, &model.Media{}) - repos := repository.New(db) - log := zap.NewNop() - return repos, NewStorageConfigService(log, repos, NewCryptoService("", log)) -} diff --git a/internal/service/storage_upload_webdav.go b/internal/service/storage_upload_webdav.go deleted file mode 100644 index 53817c6..0000000 --- a/internal/service/storage_upload_webdav.go +++ /dev/null @@ -1,128 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "strings" -) - -type webDAVUploader struct { - base *url.URL - username string - password string - client *http.Client -} - -func newWebDAVUploader(cfg map[string]any) *webDAVUploader { - u, _ := url.Parse(strings.TrimRight(strr(cfg["url"]), "/")) - return &webDAVUploader{ - base: u, - username: strr(cfg["username"]), - password: strr(cfg["password"]), - client: &http.Client{}, - } -} - -func (w *webDAVUploader) ensureDir(ctx context.Context, remoteDir string) error { - if w.base == nil || w.base.Scheme == "" || w.base.Host == "" { - return errors.New("webdav missing url") - } - remoteDir = normalizeRemotePath(remoteDir) - if remoteDir == "/" { - return nil - } - current := "" - for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") { - current = normalizeRemotePath(path.Join(current, part)) - req, err := http.NewRequestWithContext(ctx, "MKCOL", w.urlFor(current), nil) - if err != nil { - return err - } - w.auth(req) - resp, err := w.client.Do(req) - if err != nil { - return decorateStorageTransportError("webdav", w.urlFor(current), err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - continue - } - if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusConflict { - continue - } - return fmt.Errorf("webdav mkdir %s: http %d", current, resp.StatusCode) - } - return nil -} - -func (w *webDAVUploader) exists(ctx context.Context, remotePath string) (bool, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodHead, w.urlFor(remotePath), nil) - if err != nil { - return false, err - } - w.auth(req) - resp, err := w.client.Do(req) - if err != nil { - return false, decorateStorageTransportError("webdav", w.urlFor(remotePath), err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return false, nil - } - return resp.StatusCode >= 200 && resp.StatusCode < 300, nil -} - -func (w *webDAVUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error { - f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload. - if err != nil { - return err - } - defer f.Close() - req, err := http.NewRequestWithContext(ctx, http.MethodPut, w.urlFor(remotePath), f) - if err != nil { - return err - } - w.auth(req) - req.ContentLength = size - resp, err := w.client.Do(req) - if err != nil { - return decorateStorageTransportError("webdav", w.urlFor(remotePath), err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("webdav upload %s: http %d", remotePath, resp.StatusCode) - } - return nil -} - -func (w *webDAVUploader) auth(req *http.Request) { - if w.username != "" { - req.SetBasicAuth(w.username, w.password) - } -} - -func (w *webDAVUploader) urlFor(remotePath string) string { - u := *w.base - basePath := strings.TrimRight(u.EscapedPath(), "/") - segments := make([]string, 0) - if basePath != "" && basePath != "/" { - segments = append(segments, strings.Trim(basePath, "/")) - } - for _, part := range strings.Split(strings.Trim(normalizeRemotePath(remotePath), "/"), "/") { - if part != "" { - segments = append(segments, url.PathEscape(part)) - } - } - u.RawPath = "" - u.Path = "/" + strings.Join(segments, "/") - return u.String() -} diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go deleted file mode 100644 index 0d2628e..0000000 --- a/internal/service/strm_generate.go +++ /dev/null @@ -1,309 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type GenerateSTRMOptions struct { - LibraryID string `json:"library_id"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - Enabled bool `json:"enabled"` - Overwrite bool `json:"overwrite"` - IncludeLocal bool `json:"include_local"` - PreserveTree bool `json:"preserve_tree"` - PlaybackToken string `json:"-"` - SkipSettingsSave bool `json:"-"` -} - -const strmTreeIgnoredItemSampleLimit = 20 - -type GenerateSTRMResult struct { - LibraryID string `json:"library_id"` - OutputDir string `json:"output_dir"` - Generated int `json:"generated"` - Updated int `json:"updated"` - Skipped int `json:"skipped"` - Cleaned int `json:"cleaned"` - Total int `json:"total,omitempty"` - Remaining int `json:"remaining,omitempty"` - BatchLimited bool `json:"batch_limited,omitempty"` - Ignored int `json:"ignored,omitempty"` - Previewed int `json:"previewed,omitempty"` - Errors []string `json:"errors,omitempty"` - IgnoredItems []string `json:"ignored_items,omitempty"` - Items []GenerateSTRMItem `json:"items,omitempty"` - Refresh *STRMRefreshResult `json:"refresh,omitempty"` -} - -type GenerateSTRMItem struct { - MediaID string `json:"media_id"` - Title string `json:"title"` - FilePath string `json:"file_path"` - URL string `json:"url,omitempty"` - Action string `json:"action"` - Reason string `json:"reason,omitempty"` -} - -func (s *STRMService) GenerateForLibrary(ctx context.Context, opts GenerateSTRMOptions) (*GenerateSTRMResult, error) { - if s == nil || s.repo == nil || s.repo.DB == nil { - return nil, errors.New("strm service unavailable") - } - libraryID := strings.TrimSpace(opts.LibraryID) - if libraryID == "" { - return nil, errors.New("library_id required") - } - lib, err := s.repo.Library.FindByID(ctx, libraryID) - if err != nil { - return nil, err - } - if lib == nil { - return nil, errors.New("library not found") - } - outputDir := s.resolveSTRMOutputDir(ctx, lib, opts) - if outputDir == "" || outputDir == "." { - return nil, errors.New("output_dir required") - } - s.saveSTRMGenerationSettings(ctx, outputDir, opts) - if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users. - return nil, err - } - - rows, err := s.librarySTRMMedia(ctx, libraryID) - if err != nil { - return nil, err - } - res := &GenerateSTRMResult{LibraryID: libraryID, OutputDir: outputDir} - expectedFiles := map[string]struct{}{} - for _, media := range rows { - select { - case <-ctx.Done(): - return res, ctx.Err() - default: - } - item := s.generateOne(ctx, *lib, media, outputDir, opts) - res.addItem(item) - if item.FilePath != "" && item.Action != "error" { - expectedFiles[filepath.Clean(item.FilePath)] = struct{}{} - } - } - if opts.Overwrite { - cleaned, err := s.cleanupStaleGeneratedSTRM(ctx, outputDir, expectedFiles) - if err != nil { - res.Errors = append(res.Errors, err.Error()) - } - res.Cleaned += cleaned - } - return res, nil -} - -func (s *STRMService) GenerateForAllLibraries(ctx context.Context, opts GenerateSTRMOptions) (*GenerateSTRMResult, error) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, errors.New("strm service unavailable") - } - libraries, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - baseOutputDir := s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))) - result := &GenerateSTRMResult{LibraryID: "*", OutputDir: baseOutputDir} - for _, lib := range libraries { - select { - case <-ctx.Done(): - return result, ctx.Err() - default: - } - next := opts - next.LibraryID = lib.ID - next.SkipSettingsSave = true - if baseOutputDir != "" && baseOutputDir != "." { - next.OutputDir = filepath.Join(baseOutputDir, strmLibraryOutputSubdir(lib)) - } - part, err := s.GenerateForLibrary(ctx, next) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", lib.Name, err)) - continue - } - result.merge(part) - } - if baseOutputDir != "" && baseOutputDir != "." && s.repo.Setting != nil { - _ = s.repo.Setting.Set(ctx, "strm.output_dir", baseOutputDir) - _ = s.repo.Setting.Set(ctx, "strm.output_scope", "all") - _ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree)) - result.OutputDir = baseOutputDir - } - return result, nil -} - -func (s *STRMService) resolveSTRMOutputDir(ctx context.Context, lib *model.Library, opts GenerateSTRMOptions) string { - outputDir := s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))) - if (outputDir == "" || outputDir == ".") && s.repo.Setting != nil { - if saved, err := s.repo.Setting.Get(ctx, "strm.output_dir"); err == nil { - outputDir = s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(saved))) - } - } - if outputDir == "" || outputDir == "." { - outputDir = s.defaultOutputDir(lib) - } - return strmLibrarySpecificOutputDir(outputDir, lib) -} - -func (s *STRMService) remapLegacySTRMOutputDir(outputDir string) string { - outputDir = filepath.Clean(strings.TrimSpace(outputDir)) - if outputDir == "" || outputDir == "." || s == nil || s.cfg == nil { - return outputDir - } - dataDir := filepath.Clean(strings.TrimSpace(s.cfg.App.DataDir)) - if dataDir == "" || dataDir == "." || sameLibraryPath(dataDir, "/app/data") { - return outputDir - } - legacy := "/app/data" - normalized := strings.TrimRight(cleanPathForVolumeMapping(outputDir), "/") - if sameLibraryPath(normalized, legacy) { - return dataDir - } - if strings.HasPrefix(strings.ToLower(normalized), legacy+"/") { - rel := strings.TrimPrefix(normalized[len(legacy):], "/") - if rel != "" { - return filepath.Join(dataDir, filepath.FromSlash(rel)) - } - } - return outputDir -} - -func (s *STRMService) saveSTRMGenerationSettings(ctx context.Context, outputDir string, opts GenerateSTRMOptions) { - if opts.SkipSettingsSave { - return - } - if strings.TrimSpace(opts.BaseURL) != "" && s.repo.Setting != nil { - baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") - _ = s.repo.Setting.Set(ctx, "app.server_url", baseURL) - _ = s.repo.Setting.Set(ctx, "strm.base_url", baseURL) - } - if s.repo.Setting == nil { - return - } - _ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled)) - _ = s.repo.Setting.Set(ctx, "strm.output_dir", outputDir) - _ = s.repo.Setting.Set(ctx, "strm.output_scope", "library") - _ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree)) -} - -func (s *STRMService) librarySTRMMedia(ctx context.Context, libraryID string) ([]model.Media, error) { - var rows []model.Media - err := s.repo.DB.WithContext(ctx). - Where("library_id = ?", libraryID). - Order("title asc, season_num asc, episode_num asc, created_at asc"). - Find(&rows).Error - return rows, err -} - -func (s *STRMService) defaultOutputDir(lib *model.Library) string { - subdir := strmLibraryOutputSubdir(*lib) - if s != nil && s.cfg != nil && strings.TrimSpace(s.cfg.App.DataDir) != "" { - return filepath.Join(s.cfg.App.DataDir, "strm", subdir) - } - return filepath.Join("data", "strm", subdir) -} - -func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media model.Media, outputDir string, opts GenerateSTRMOptions) GenerateSTRMItem { - item := GenerateSTRMItem{MediaID: media.ID, Title: media.Title} - playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL, opts.PlaybackToken) - if playURL == "" { - item.Action = "skipped" - item.Reason = "no playable strm target" - return item - } - if strings.TrimSpace(media.STRMURL) == "" && !opts.IncludeLocal { - item.Action = "skipped" - item.Reason = "local media skipped" - return item - } - rel := s.strmRelativePath(lib, media) - if opts.PreserveTree { - if treeRel := s.strmTreeRelativePath(media); treeRel != "" { - rel = treeRel - } - } - if rel == "" { - item.Action = "skipped" - item.Reason = "cannot build file name" - return item - } - filePath := filepath.Join(outputDir, rel) - item.FilePath = filePath - item.URL = playURL - if _, err := os.Stat(filePath); err == nil && !opts.Overwrite { - item.Action = "skipped" - item.Reason = "target exists" - return item - } - action := "generated" - if _, err := os.Stat(filePath); err == nil { - action = "updated" - } - if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users. - item.Action = "error" - item.Reason = err.Error() - return item - } - if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players. - item.Action = "error" - item.Reason = err.Error() - return item - } - if err := s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type); err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - item.Action = action - return item -} - -func (r *GenerateSTRMResult) addItem(item GenerateSTRMItem) { - r.Items = append(r.Items, item) - switch item.Action { - case "generated": - r.Generated++ - case "updated": - r.Updated++ - case "skipped": - r.Skipped++ - case "preview": - r.Previewed++ - case "error": - r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", item.Title, item.Reason)) - } -} - -func (r *GenerateSTRMResult) merge(part *GenerateSTRMResult) { - if part == nil { - return - } - if r.OutputDir == "" || r.OutputDir == "." { - r.OutputDir = filepath.Dir(part.OutputDir) - } - r.Generated += part.Generated - r.Updated += part.Updated - r.Skipped += part.Skipped - r.Cleaned += part.Cleaned - r.Previewed += part.Previewed - r.Ignored += part.Ignored - r.Errors = append(r.Errors, part.Errors...) - for _, item := range part.IgnoredItems { - if len(r.IgnoredItems) >= strmTreeIgnoredItemSampleLimit { - break - } - r.IgnoredItems = append(r.IgnoredItems, item) - } - r.Items = append(r.Items, part.Items...) -} diff --git a/internal/service/strm_generate_cleanup.go b/internal/service/strm_generate_cleanup.go deleted file mode 100644 index 9cca145..0000000 --- a/internal/service/strm_generate_cleanup.go +++ /dev/null @@ -1,116 +0,0 @@ -package service - -import ( - "context" - "errors" - "io/fs" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *STRMService) upsertGeneratedRecord(ctx context.Context, media model.Media, filePath, playURL, mediaType string) error { - protocol := "" - if u, err := url.Parse(playURL); err == nil { - protocol = strings.ToLower(u.Scheme) - } - if protocol == "" { - protocol = "http" - } - record := model.STRMRecord{ - Title: media.Title, - URL: playURL, - FilePath: filePath, - Protocol: protocol, - MediaID: media.ID, - MediaType: mediaType, - SeasonNum: media.SeasonNum, - EpisodeNum: media.EpisodeNum, - } - var existing model.STRMRecord - err := s.repo.DB.WithContext(ctx).Where("media_id = ? AND file_path = ?", media.ID, filePath).First(&existing).Error - if err == nil { - existing.Title = record.Title - existing.URL = record.URL - existing.Protocol = record.Protocol - existing.MediaType = record.MediaType - existing.SeasonNum = record.SeasonNum - existing.EpisodeNum = record.EpisodeNum - return s.repo.DB.WithContext(ctx).Save(&existing).Error - } - return s.repo.DB.WithContext(ctx).Create(&record).Error -} - -func (s *STRMService) cleanupStaleGeneratedSTRM(ctx context.Context, outputDir string, expected map[string]struct{}) (int, error) { - outputDir = filepath.Clean(strings.TrimSpace(outputDir)) - if outputDir == "" || outputDir == "." { - return 0, nil - } - cleaned, err := removeStaleSTRMFiles(outputDir, expected) - if err != nil { - return cleaned, err - } - recordsCleaned, err := s.removeStaleSTRMRecords(ctx, outputDir, expected) - return cleaned + recordsCleaned, err -} - -func removeStaleSTRMFiles(outputDir string, expected map[string]struct{}) (int, error) { - cleaned := 0 - err := filepath.WalkDir(outputDir, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return nil - } - if entry.IsDir() || strings.ToLower(filepath.Ext(path)) != ".strm" { - return nil - } - cleanPath := filepath.Clean(path) - if _, ok := expected[cleanPath]; ok { - return nil - } - if err := os.Remove(cleanPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - cleaned++ - return nil - }) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return cleaned, err - } - return cleaned, nil -} - -func (s *STRMService) removeStaleSTRMRecords(ctx context.Context, outputDir string, expected map[string]struct{}) (int, error) { - if s == nil || s.repo == nil || s.repo.DB == nil { - return 0, nil - } - var records []model.STRMRecord - if err := s.repo.DB.WithContext(ctx).Find(&records).Error; err != nil { - return 0, err - } - rootAbs, err := filepath.Abs(outputDir) - if err != nil { - return 0, nil - } - cleaned := 0 - for i := range records { - filePath := filepath.Clean(strings.TrimSpace(records[i].FilePath)) - if filePath == "" { - continue - } - fileAbs, err := filepath.Abs(filePath) - if err != nil || !pathWithin(fileAbs, rootAbs) { - continue - } - if _, ok := expected[filePath]; ok { - continue - } - if err := s.repo.DB.WithContext(ctx).Delete(&records[i]).Error; err != nil { - return cleaned, err - } - cleaned++ - } - return cleaned, nil -} diff --git a/internal/service/strm_output_dir.go b/internal/service/strm_output_dir.go deleted file mode 100644 index 81ec8ac..0000000 --- a/internal/service/strm_output_dir.go +++ /dev/null @@ -1,195 +0,0 @@ -package service - -import ( - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func strmLibraryOutputSubdir(lib model.Library) string { - parts := strmLibraryCategoryParts(lib) - if len(parts) == 0 { - return sanitizeFilename(lib.Name) - } - clean := make([]string, 0, len(parts)) - for _, part := range parts { - if safe := sanitizeFilename(part); safe != "" { - clean = append(clean, safe) - } - } - if len(clean) == 0 { - return sanitizeFilename(lib.Name) - } - return filepath.Join(clean...) -} - -func strmLibrarySpecificOutputDir(base string, lib *model.Library) string { - outputDir := filepath.Clean(strings.TrimSpace(base)) - if outputDir == "" || outputDir == "." || lib == nil { - return outputDir - } - subdir := strmLibraryOutputSubdir(*lib) - if subdir == "" || strmPathHasSuffix(outputDir, subdir) || pathAlreadyEndsWith(outputDir, filepath.Base(subdir)) { - return outputDir - } - if tail := strmMissingCategoryTail(outputDir, subdir); tail != "" { - return filepath.Join(outputDir, tail) - } - return filepath.Join(outputDir, subdir) -} - -func strmMissingCategoryTail(outputDir, subdir string) string { - parts := strmSlashParts(subdir) - if len(parts) < 2 || !pathAlreadyEndsWith(outputDir, parts[0]) { - return "" - } - return filepath.Join(parts[1:]...) -} - -func strmPathHasSuffix(pathValue, suffix string) bool { - pathValue = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))), "/") - suffix = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(suffix))), "/") - if pathValue == "" || suffix == "" || suffix == "." { - return false - } - if strings.EqualFold(pathValue, suffix) { - return true - } - return strings.HasSuffix(strings.ToLower(pathValue), "/"+strings.ToLower(suffix)) -} - -func strmLibraryCategoryParts(lib model.Library) []string { - if parts := strmCategoryPartsFromPath(strmLibraryPathParts(lib.Path)); len(parts) > 0 { - return parts - } - if parts := strmCategoryPartsFromPath(strmNameParts(lib.Name)); len(parts) > 0 { - return parts - } - if root := mediaTypeRootDir(lib.Type); root != "" { - return []string{root} - } - return nil -} - -func strmLibraryPathParts(raw string) []string { - if info, ok := ParseCloudLibraryMount(raw); ok { - return strmSlashParts(info.DisplayDir) - } - clean := cleanPathForVolumeMapping(raw) - clean = strings.Trim(pathAfterWindowsDrivePrefix(clean), "/") - return strmSlashParts(clean) -} - -func strmNameParts(name string) []string { - name = strings.NewReplacer("·", "/", ">", "/", "|", "/", "|", "/", "\\", "/").Replace(name) - return strmSlashParts(name) -} - -func strmSlashParts(raw string) []string { - raw = strings.Trim(strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/")), "/") - if raw == "" || raw == "." { - return nil - } - fields := strings.Split(raw, "/") - parts := make([]string, 0, len(fields)) - for _, part := range fields { - part = strings.TrimSpace(part) - if part != "" && part != "." { - parts = append(parts, part) - } - } - return parts -} - -func strmCategoryPartsFromPath(parts []string) []string { - for i, part := range parts { - if root := strmCanonicalRoot(part); root != "" { - return append([]string{root}, strmSanitizedTail(parts[i+1:])...) - } - if root := strmCategoryRoot(part); root != "" { - return []string{root, strmCanonicalCategory(part)} - } - } - return nil -} - -func strmSanitizedTail(parts []string) []string { - out := make([]string, 0, len(parts)) - for _, part := range parts { - if strings.TrimSpace(part) != "" { - out = append(out, part) - } - } - return out -} - -func strmCanonicalRoot(part string) string { - key := strings.ToLower(strings.TrimSpace(part)) - switch key { - case "电影", "movie", "movies", "film", "films": - return "电影" - case "电视剧", "剧集", "tv", "tvs", "series", "show", "shows": - return "电视剧" - case "动漫", "动画", "anime", "bangumi": - return "动漫" - case "成人", "adult", "adults", "jav", "nsfw", "9kg": - return "成人" - default: - return "" - } -} - -func strmCategoryRoot(part string) string { - key := strings.ToLower(strings.TrimSpace(part)) - switch key { - case "演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "日本电影", "韩国电影": - return "电影" - case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿", "未分类": - return "电视剧" - case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫": - return "动漫" - case "番号": - return "成人" - default: - return "" - } -} - -func strmCanonicalCategory(part string) string { - key := strings.ToLower(strings.TrimSpace(part)) - switch key { - case "音乐会": - return "演唱会" - case "纪录": - return "纪录片" - case "动漫电影": - return "动画电影" - case "国产电影": - return "华语电影" - case "外语电影", "外国电影": - return "欧美电影" - case "日本电影", "韩国电影": - return "日韩电影" - case "日剧", "韩剧": - return "日韩剧" - case "真人秀": - return "综艺" - case "少儿": - return "儿童" - case "国产动漫": - return "国漫" - case "番剧", "日漫", "日本动漫", "日本动画": - return "日番" - case "韩国动漫", "韩国动画": - return "韩漫" - case "欧美动漫", "欧美动画", "西方动画": - return "美漫" - case "其他动漫", "其它动漫": - return "其他" - case "番号": - return "成人" - default: - return strings.TrimSpace(part) - } -} diff --git a/internal/service/strm_output_presets.go b/internal/service/strm_output_presets.go deleted file mode 100644 index 79394e9..0000000 --- a/internal/service/strm_output_presets.go +++ /dev/null @@ -1,79 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -type STRMOutputPreset struct { - Label string `json:"label"` - Path string `json:"path"` - Kind string `json:"kind"` -} - -func STRMOutputPresets(ctx context.Context, repo *repository.Container) ([]STRMOutputPreset, error) { - presets := defaultSTRMOutputPresets() - seen := make(map[string]struct{}, len(presets)) - for _, preset := range presets { - seen[strmOutputPresetKey(preset.Path)] = struct{}{} - } - if repo == nil || repo.Library == nil { - return presets, nil - } - libraries, err := repo.Library.List(ctx) - if err != nil { - return nil, err - } - for i := range libraries { - lib := libraries[i] - if !lib.Enabled { - continue - } - roots, err := repo.Library.ListRoots(ctx, lib.ID) - if err != nil { - return nil, err - } - if len(roots) == 0 { - roots = []model.LibraryRoot{{Path: lib.Path, Enabled: lib.Enabled}} - } - for j := range roots { - root := roots[j] - if !root.Enabled || strings.TrimSpace(root.Path) == "" { - continue - } - if _, ok := ParseCloudLibraryMount(root.Path); ok { - continue - } - pathValue := filepath.Clean(resolveMappedDestinationPath(root.Path)) - if pathValue == "" || pathValue == "." { - continue - } - key := strmOutputPresetKey(pathValue) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - presets = append(presets, STRMOutputPreset{ - Label: lib.Name, - Path: pathValue, - Kind: "library", - }) - } - } - return presets, nil -} - -func defaultSTRMOutputPresets() []STRMOutputPreset { - return []STRMOutputPreset{ - {Label: "STRM 根目录", Path: filepath.Clean("data/strm"), Kind: "default"}, - {Label: "目录树 STRM", Path: filepath.Clean("data/strm/tree"), Kind: "default"}, - } -} - -func strmOutputPresetKey(pathValue string) string { - return strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue))) -} diff --git a/internal/service/strm_output_presets_test.go b/internal/service/strm_output_presets_test.go deleted file mode 100644 index 3648bd1..0000000 --- a/internal/service/strm_output_presets_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSTRMOutputPresetsIncludesDefaultsAndLocalLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - base := t.TempDir() - movieRoot := filepath.Join(base, "strm", "电影") - tvRoot := filepath.Join(base, "strm", "电视剧") - cloudRoot := BuildCloudLibraryPath("openlist", "/电影", "/电影") - disabledRoot := filepath.Join(base, "strm", "动漫") - - libraries := []*model.Library{ - {Name: "电影 STRM", Path: movieRoot, Type: "movie", Enabled: true}, - {Name: "电视剧 STRM", Path: tvRoot, Type: "tv", Enabled: true}, - {Name: "云盘", Path: cloudRoot, Type: "movie", Enabled: true}, - {Name: "停用", Path: disabledRoot, Type: "tv", Enabled: false}, - } - for _, lib := range libraries { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Model(&model.Library{}).Where("id = ?", libraries[3].ID).Update("enabled", false).Error; err != nil { - t.Fatal(err) - } - - presets, err := STRMOutputPresets(t.Context(), repos) - if err != nil { - t.Fatal(err) - } - got := map[string]STRMOutputPreset{} - for _, preset := range presets { - got[preset.Path] = preset - } - - if got[filepath.Clean("data/strm")].Kind != "default" || got[filepath.Clean("data/strm/tree")].Kind != "default" { - t.Fatalf("defaults missing from presets: %#v", presets) - } - if got[movieRoot].Label != "电影 STRM" || got[movieRoot].Kind != "library" { - t.Fatalf("movie preset = %#v, want local library preset", got[movieRoot]) - } - if got[tvRoot].Label != "电视剧 STRM" || got[tvRoot].Kind != "library" { - t.Fatalf("tv preset = %#v, want local library preset", got[tvRoot]) - } - if _, ok := got[cloudRoot]; ok { - t.Fatalf("cloud library should not be an output preset: %#v", presets) - } - if _, ok := got[disabledRoot]; ok { - t.Fatalf("disabled library should not be an output preset: %#v", presets) - } -} - -func TestSTRMOutputPresetsDeduplicatesLibraryRoots(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - root := filepath.Join(t.TempDir(), "strm") - lib := model.Library{Name: "STRM", Path: root, Type: "movie", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &lib, []model.LibraryRoot{ - {Path: root, Enabled: true}, - {Path: root, Enabled: true}, - }); err != nil { - t.Fatal(err) - } - - presets, err := STRMOutputPresets(t.Context(), repos) - if err != nil { - t.Fatal(err) - } - count := 0 - for _, preset := range presets { - if preset.Path == root { - count++ - } - } - if count != 1 { - t.Fatalf("root preset count = %d, presets=%#v", count, presets) - } -} diff --git a/internal/service/strm_proxy.go b/internal/service/strm_proxy.go deleted file mode 100644 index 8d62677..0000000 --- a/internal/service/strm_proxy.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ProxySTRM proxies a STRM target and preserves Range requests for players. -func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Request, w http.ResponseWriter) error { - record, err := s.repo.STRM.FindByID(ctx, id) - if err != nil { - return err - } - if record == nil { - return ErrSTRMNotFound - } - if !model.IsAllowedProtocol(record.Protocol) { - return ErrSTRMProtocolInvalid - } - - targetURL, err := validateSTRMProxyURL(record.URL) - if err != nil { - return err - } - proxyReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), nil) - if err != nil { - return fmt.Errorf("create proxy request: %w", err) - } - copySTRMRequestHeaders(req, proxyReq) - - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(proxyReq) // #nosec G107,G704 -- STRM proxy target is validated by validateSTRMProxyURL before request creation. - if err != nil { - return fmt.Errorf("proxy request failed: %w", err) - } - defer resp.Body.Close() - - copySTRMResponseHeaders(resp, w) - w.WriteHeader(resp.StatusCode) - _, err = io.Copy(w, resp.Body) - return err -} - -func validateSTRMProxyURL(raw string) (*url.URL, error) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil, ErrSTRMURLInvalid - } - switch strings.ToLower(u.Scheme) { - case "http", "https": - default: - return nil, ErrSTRMProtocolInvalid - } - if isPrivateHost(u.Hostname()) { - return nil, ErrSTRMURLInvalid - } - return u, nil -} - -func copySTRMRequestHeaders(src *http.Request, dst *http.Request) { - for _, header := range []string{ - "Range", "If-Range", "If-Match", "If-None-Match", - "If-Modified-Since", "If-Unmodified-Since", - "Accept", "Accept-Encoding", "Accept-Language", - } { - if v := src.Header.Get(header); v != "" { - dst.Header.Set(header, v) - } - } -} - -func copySTRMResponseHeaders(src *http.Response, dst http.ResponseWriter) { - for _, header := range []string{ - "Content-Type", "Content-Length", "Content-Range", - "Accept-Ranges", "Last-Modified", "ETag", - "Cache-Control", "Content-Disposition", - } { - if v := src.Header.Get(header); v != "" { - dst.Header().Set(header, v) - } - } -} diff --git a/internal/service/strm_refresh.go b/internal/service/strm_refresh.go deleted file mode 100644 index 8c06e0d..0000000 --- a/internal/service/strm_refresh.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -type STRMRefreshResult struct { - Requested bool `json:"requested"` - Queued bool `json:"queued"` - Reason string `json:"reason,omitempty"` - ScrapeRequested bool `json:"scrape_requested,omitempty"` - ScrapeQueued bool `json:"scrape_queued,omitempty"` - ScrapeReason string `json:"scrape_reason,omitempty"` - Targets []STRMRefreshTarget `json:"targets,omitempty"` -} - -type STRMRefreshTarget struct { - LibraryID string `json:"library_id"` - RootID string `json:"root_id,omitempty"` - Name string `json:"name"` - Path string `json:"path"` -} - -func FindSTRMRefreshTargets(ctx context.Context, repo *repository.Container, outputDir string) ([]STRMRefreshTarget, error) { - if repo == nil || repo.Library == nil { - return nil, nil - } - outputDir = resolveMappedDestinationPath(strings.TrimSpace(outputDir)) - if outputDir == "" || outputDir == "." { - return nil, nil - } - libraries, err := repo.Library.List(ctx) - if err != nil { - return nil, err - } - targets := make([]STRMRefreshTarget, 0) - seen := map[string]struct{}{} - for i := range libraries { - lib := libraries[i] - if !lib.Enabled { - continue - } - roots, err := repo.Library.ListRoots(ctx, lib.ID) - if err != nil { - return nil, err - } - if len(roots) == 0 && strings.TrimSpace(lib.Path) != "" { - roots = []model.LibraryRoot{{LibraryID: lib.ID, Path: lib.Path, Enabled: lib.Enabled}} - } - for j := range roots { - root := roots[j] - if !root.Enabled || strings.TrimSpace(root.Path) == "" { - continue - } - if _, ok := ParseCloudLibraryMount(root.Path); ok { - continue - } - if !strmRefreshPathMatches(outputDir, root.Path) { - continue - } - key := lib.ID + "\x00" + root.ID + "\x00" + strings.ToLower(filepath.Clean(root.Path)) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - targets = append(targets, STRMRefreshTarget{ - LibraryID: lib.ID, - RootID: root.ID, - Name: lib.Name, - Path: filepath.Clean(root.Path), - }) - } - } - return targets, nil -} - -func strmRefreshPathMatches(outputDir, libraryRoot string) bool { - outputDir = filepath.Clean(strings.TrimSpace(outputDir)) - libraryRoot = filepath.Clean(strings.TrimSpace(libraryRoot)) - if outputDir == "" || outputDir == "." || libraryRoot == "" || libraryRoot == "." { - return false - } - return sameLibraryPath(outputDir, libraryRoot) || pathWithin(outputDir, libraryRoot) || pathWithin(libraryRoot, outputDir) -} diff --git a/internal/service/strm_refresh_test.go b/internal/service/strm_refresh_test.go deleted file mode 100644 index 9f80c76..0000000 --- a/internal/service/strm_refresh_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestFindSTRMRefreshTargetsMatchesNestedLocalRoots(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - base := t.TempDir() - movieRoot := filepath.Join(base, "strm", "电影") - tvRoot := filepath.Join(base, "strm", "电视剧") - otherRoot := filepath.Join(base, "other") - cloudRoot := "cloud://openlist/电影" - - movie := model.Library{Name: "电影 STRM", Path: movieRoot, Type: "movie", Enabled: true} - tv := model.Library{Name: "电视剧 STRM", Path: tvRoot, Type: "tv", Enabled: true} - other := model.Library{Name: "其他", Path: otherRoot, Type: "movie", Enabled: true} - cloud := model.Library{Name: "云盘", Path: cloudRoot, Type: "movie", Enabled: true} - disabled := model.Library{Name: "停用", Path: filepath.Join(base, "strm", "动漫"), Type: "tv", Enabled: false} - for _, lib := range []*model.Library{&movie, &tv, &other, &cloud, &disabled} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil { - t.Fatal(err) - } - - targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(base, "strm")) - if err != nil { - t.Fatal(err) - } - if len(targets) != 2 { - t.Fatalf("targets = %#v, want movie and tv only", targets) - } - got := map[string]bool{} - for _, target := range targets { - got[target.LibraryID] = true - } - if !got[movie.ID] || !got[tv.ID] || got[other.ID] || got[cloud.ID] || got[disabled.ID] { - t.Fatalf("target libraries = %#v", targets) - } -} - -func TestFindSTRMRefreshTargetsDoesNotFallbackToAllLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: filepath.Join(t.TempDir(), "movies"), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - - targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(t.TempDir(), "strm")) - if err != nil { - t.Fatal(err) - } - if len(targets) != 0 { - t.Fatalf("targets = %#v, want no fallback target", targets) - } -} diff --git a/internal/service/strm_repair.go b/internal/service/strm_repair.go deleted file mode 100644 index a5088fe..0000000 --- a/internal/service/strm_repair.go +++ /dev/null @@ -1,145 +0,0 @@ -package service - -import ( - "context" - "errors" - "io/fs" - "net/url" - "os" - "path/filepath" - "strings" -) - -type RepairSTRMOptions struct { - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - DryRun bool `json:"dry_run,omitempty"` -} - -type RepairSTRMResult struct { - OutputDir string `json:"output_dir"` - Repaired int `json:"repaired"` - Previewed int `json:"previewed,omitempty"` - Skipped int `json:"skipped"` - Errors []string `json:"errors,omitempty"` - Items []RepairSTRMItem `json:"items,omitempty"` - Refresh *STRMRefreshResult `json:"refresh,omitempty"` -} - -type RepairSTRMItem struct { - FilePath string `json:"file_path"` - Before string `json:"before,omitempty"` - After string `json:"after,omitempty"` - Action string `json:"action"` - Reason string `json:"reason,omitempty"` -} - -func (s *STRMService) RepairFiles(ctx context.Context, opts RepairSTRMOptions) (*RepairSTRMResult, error) { - outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)) - if outputDir == "" || outputDir == "." { - return nil, errors.New("output_dir required") - } - info, err := os.Stat(outputDir) - if err != nil { - return nil, err - } - if !info.IsDir() { - return nil, errors.New("output_dir must be a directory") - } - result := &RepairSTRMResult{OutputDir: outputDir} - baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") - err = filepath.WalkDir(outputDir, func(filePath string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - result.Errors = append(result.Errors, filePath+": "+walkErr.Error()) - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strm") { - return nil - } - result.addRepairItem(repairSTRMFile(filePath, baseURL, opts.DryRun)) - return nil - }) - if err != nil { - return result, err - } - return result, nil -} - -func repairSTRMFile(filePath, baseURL string, dryRun bool) RepairSTRMItem { - item := RepairSTRMItem{FilePath: filePath} - body, err := os.ReadFile(filePath) // #nosec G304 -- admin-selected STRM repair output directory. - if err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - before := strings.TrimSpace(string(body)) - after, ok := repairedSTRMPlaybackURL(before, baseURL) - if !ok { - item.Action = "skipped" - item.Reason = "unsupported strm target" - return item - } - if after == before { - item.Action = "skipped" - item.Reason = "already current" - return item - } - item.Before = before - item.After = after - if dryRun { - item.Action = "preview" - item.Reason = "repaired" - return item - } - if err := os.WriteFile(filePath, []byte(after+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are player-readable sidecars. - item.Action = "error" - item.Reason = err.Error() - return item - } - item.Action = "repaired" - return item -} - -func repairedSTRMPlaybackURL(raw, baseURL string) (string, bool) { - raw = strings.TrimSpace(raw) - if raw == "" || strings.HasPrefix(raw, "//") { - return "", false - } - parsed, err := url.Parse(raw) - if err != nil || !strmRepairOwnsAPIPath(parsed.Path) { - return "", false - } - apiPath := parsed.EscapedPath() - if apiPath == "" { - apiPath = parsed.Path - } - if parsed.RawQuery != "" { - apiPath += "?" + parsed.RawQuery - } - return buildAbsoluteSTRMAPIURL(baseURL, apiPath, nil), true -} - -func strmRepairOwnsAPIPath(apiPath string) bool { - value := strings.ToLower(strings.TrimSpace(apiPath)) - return strings.HasPrefix(value, "/api/stream/") || strings.HasPrefix(value, "/api/cloud/play/") -} - -func (r *RepairSTRMResult) addRepairItem(item RepairSTRMItem) { - r.Items = append(r.Items, item) - switch item.Action { - case "repaired": - r.Repaired++ - case "preview": - r.Previewed++ - case "skipped": - r.Skipped++ - case "error": - r.Errors = append(r.Errors, item.FilePath+": "+item.Reason) - } -} diff --git a/internal/service/strm_repair_test.go b/internal/service/strm_repair_test.go deleted file mode 100644 index b2daab4..0000000 --- a/internal/service/strm_repair_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestRepairSTRMFilesRewritesOwnedPlaybackURLs(t *testing.T) { - outDir := t.TempDir() - cloudPath := filepath.Join(outDir, "Movies", "A.strm") - streamPath := filepath.Join(outDir, "Shows", "S01E01.strm") - externalPath := filepath.Join(outDir, "External.strm") - for _, path := range []string{cloudPath, streamPath, externalPath} { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - } - if err := os.WriteFile(cloudPath, []byte("http://old.local/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(streamPath, []byte("/api/stream/media-1?token=old\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(externalPath, []byte("https://cdn.example.com/video.m3u8\n"), 0o644); err != nil { - t.Fatal(err) - } - - svc := NewSTRMService(zap.NewNop(), nil, nil) - res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{ - OutputDir: outDir, - BaseURL: "https://media.example.com", - }) - if err != nil { - t.Fatal(err) - } - if res.Repaired != 2 || res.Skipped != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two repaired and one skipped", res) - } - if got := readSTRM(t, cloudPath); got != "https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv" { - t.Fatalf("cloud strm = %q", got) - } - if got := readSTRM(t, streamPath); got != "https://media.example.com/api/stream/media-1?token=old" { - t.Fatalf("stream strm = %q", got) - } - if got := readSTRM(t, externalPath); got != "https://cdn.example.com/video.m3u8" { - t.Fatalf("external strm should not change: %q", got) - } -} - -func TestRepairSTRMFilesDryRunDoesNotWrite(t *testing.T) { - outDir := t.TempDir() - filePath := filepath.Join(outDir, "Movie.strm") - original := "http://old.local/api/cloud/play/openlist?ref=%2FMovie.mkv\n" - if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{ - OutputDir: outDir, - BaseURL: "https://media.example.com", - DryRun: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Previewed != 1 || res.Repaired != 0 || len(res.Items) != 1 { - t.Fatalf("result = %#v, want one repair preview", res) - } - if got := readSTRM(t, filePath); got != strings.TrimSpace(original) { - t.Fatalf("dry run changed file: %q", got) - } -} diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go deleted file mode 100644 index ebf0d05..0000000 --- a/internal/service/strm_svc.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package service — STRM 文件管理服务。 -package service - -import ( - "context" - "errors" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// STRM 错误定义。 -var ( - ErrSTRMNotFound = errors.New("strm record not found") - ErrSTRMProtocolInvalid = errors.New("invalid strm protocol") - ErrSTRMURLInvalid = errors.New("invalid strm url") -) - -// STRMService STRM 文件管理服务。 -type STRMService struct { - log *zap.Logger - repo *repository.Container - cfg *config.Config -} - -// NewSTRMService 创建 STRM 服务。 -func NewSTRMService(log *zap.Logger, repo *repository.Container, cfg *config.Config) *STRMService { - return &STRMService{log: log, repo: repo, cfg: cfg} -} - -// Create 创建 STRM 记录。 -func (s *STRMService) Create(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) { - if err := s.validateSTRM(record); err != nil { - return nil, err - } - - if err := s.repo.STRM.Create(ctx, record); err != nil { - s.log.Error("create strm failed", zap.Error(err)) - return nil, err - } - - return record, nil -} - -// CreateBatch 批量创建 STRM 记录。 -func (s *STRMService) CreateBatch(ctx context.Context, records []model.STRMRecord) (int, error) { - created := 0 - for i := range records { - if err := s.validateSTRM(&records[i]); err != nil { - s.log.Warn("skip invalid strm record", - zap.String("title", records[i].Title), - zap.Error(err), - ) - continue - } - created++ - } - - validRecords := make([]model.STRMRecord, 0, created) - for _, r := range records { - if model.IsAllowedProtocol(r.Protocol) && r.URL != "" { - validRecords = append(validRecords, r) - } - } - - if len(validRecords) == 0 { - return 0, nil - } - - if err := s.repo.STRM.CreateBatch(ctx, validRecords); err != nil { - s.log.Error("batch create strm failed", zap.Error(err)) - return 0, err - } - - return len(validRecords), nil -} - -// GetByID 获取 STRM 记录。 -func (s *STRMService) GetByID(ctx context.Context, id string) (*model.STRMRecord, error) { - record, err := s.repo.STRM.FindByID(ctx, id) - if err != nil { - return nil, err - } - if record == nil { - return nil, ErrSTRMNotFound - } - return record, nil -} - -// List 列出 STRM 记录(支持筛选和分页)。 -func (s *STRMService) List(ctx context.Context, filters map[string]string, page, pageSize int) ([]model.STRMRecord, int64, error) { - offset := (page - 1) * pageSize - if offset < 0 { - offset = 0 - } - - records, total, err := s.repo.STRM.List(ctx, filters, offset, pageSize) - if err != nil { - return nil, 0, err - } - - return records, total, nil -} - -// Update 更新 STRM 记录。 -func (s *STRMService) Update(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) { - existing, err := s.repo.STRM.FindByID(ctx, record.ID) - if err != nil { - return nil, err - } - if existing == nil { - return nil, ErrSTRMNotFound - } - - if record.Protocol != "" { - if !model.IsAllowedProtocol(record.Protocol) { - return nil, ErrSTRMProtocolInvalid - } - } - - if err := s.repo.STRM.Update(ctx, record); err != nil { - s.log.Error("update strm failed", zap.Error(err)) - return nil, err - } - - return record, nil -} - -// Delete 删除 STRM 记录。 -func (s *STRMService) Delete(ctx context.Context, id string) error { - existing, err := s.repo.STRM.FindByID(ctx, id) - if err != nil { - return err - } - if existing == nil { - return ErrSTRMNotFound - } - return s.repo.STRM.Delete(ctx, id) -} - -// GetProtocols 获取支持的协议列表。 -func (s *STRMService) GetProtocols() []string { - return model.AllowedSTRMProtocols -} - -// validateSTRM 验证 STRM 记录。 -func (s *STRMService) validateSTRM(record *model.STRMRecord) error { - if record.Title == "" { - return errors.New("title is required") - } - if record.URL == "" { - return ErrSTRMURLInvalid - } - if !model.IsAllowedProtocol(record.Protocol) { - return ErrSTRMProtocolInvalid - } - - // 标准化协议名 - record.Protocol = strings.ToLower(record.Protocol) - - return nil -} - -// ListByMediaID 获取关联到指定媒体的 STRM 记录。 -func (s *STRMService) ListByMediaID(ctx context.Context, mediaID string) ([]model.STRMRecord, error) { - return s.repo.STRM.FindByMediaID(ctx, mediaID) -} diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go deleted file mode 100644 index 825b51d..0000000 --- a/internal/service/strm_svc_test.go +++ /dev/null @@ -1,504 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}, - {Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.mkv")}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || res.Skipped != 0 { - t.Fatalf("result = %#v, want generated=2 skipped=0", res) - } - libraryOutDir := filepath.Join(outDir, "电影") - if res.OutputDir != libraryOutDir { - t.Fatalf("output dir = %q, want %q", res.OutputDir, libraryOutDir) - } - cloudSTRM := filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm") - localSTRM := filepath.Join(libraryOutDir, "本地电影 (2025)", "本地电影 (2025).strm") - assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/stream/cloud-media?token=strm-token") - assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token") - if got, err := repos.Setting.Get(t.Context(), "app.server_url"); err != nil || got != "http://nas.example:18080" { - t.Fatalf("app.server_url = %q, %v; want generated base url", got, err) - } - if got, err := repos.Setting.Get(t.Context(), "strm.base_url"); err != nil || got != "http://nas.example:18080" { - t.Fatalf("strm.base_url = %q, %v; want generated base url", got, err) - } - - var count int64 - if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("strm record count = %d, want 2", count) - } - - res, err = svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Skipped != 2 { - t.Fatalf("second run skipped = %d, want 2", res.Skipped) - } -} - -func TestGenerateSTRMForLibrarySignsDefaultPlaybackToken(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}, &model.User{}) - repos := repository.New(db) - admin := model.User{Username: "admin", PasswordHash: "x", Role: "admin", Tier: "plus", IsActive: true} - if err := repos.User.Create(t.Context(), &admin); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"} - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - outDir := filepath.Join(t.TempDir(), "strm") - const secret = "test-secret" - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want generated=1 with no errors", res) - } - cloudSTRM := filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm") - got := readSTRM(t, cloudSTRM) - if !strings.HasPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") { - t.Fatalf("generated url = %q, want tokenized /api/stream url", got) - } - token := strings.TrimPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") - claims := &Claims{} - parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { - return []byte(secret), nil - }) - if err != nil || !parsed.Valid { - t.Fatalf("generated token did not validate: %v", err) - } - if claims.UserID != admin.ID || claims.Role != "admin" || claims.Tier != "plus" { - t.Fatalf("claims = %#v, want admin identity", claims) - } - if ttl := time.Until(claims.ExpiresAt.Time); ttl < EmbyTokenDuration-time.Minute { - t.Fatalf("token ttl = %v, want close to %v", ttl, EmbyTokenDuration) - } -} - -func TestGenerateSTRMForLibraryCleanupStaleFilesAndRecords(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"} - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - outDir := filepath.Join(t.TempDir(), "strm") - libraryOutDir := filepath.Join(outDir, "电影") - stalePath := filepath.Join(libraryOutDir, "旧电影", "旧电影.strm") - if err := os.MkdirAll(filepath.Dir(stalePath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(stalePath, []byte("http://old.example/stream\n"), 0o644); err != nil { - t.Fatal(err) - } - staleRecord := model.STRMRecord{Title: "旧电影", URL: "http://old.example/stream", FilePath: stalePath, Protocol: "http", MediaID: "missing-media"} - if err := repos.DB.Create(&staleRecord).Error; err != nil { - t.Fatal(err) - } - - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - Overwrite: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Cleaned == 0 { - t.Fatalf("cleaned = %d, want stale file/record cleaned", res.Cleaned) - } - if _, err := os.Stat(stalePath); !os.IsNotExist(err) { - t.Fatalf("stale strm file should be removed, stat err=%v", err) - } - var count int64 - if err := repos.DB.Model(&model.STRMRecord{}).Where("media_id = ?", "missing-media").Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("stale strm record count = %d, want 0", count) - } - if err := repos.DB.Model(&model.STRMRecord{}).Where("media_id = ?", media.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("current strm record count = %d, want 1", count) - } - assertFileContains(t, filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token") -} - -func TestSTRMLibraryOutputSubdirUsesLibraryCategoryPath(t *testing.T) { - tests := []struct { - name string - lib model.Library - want string - }{ - { - name: "cloud nested tv category", - lib: model.Library{Name: "OpenList · 欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv"}, - want: filepath.Join("电视剧", "欧美剧"), - }, - { - name: "cloud second-level category without root", - lib: model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv"}, - want: filepath.Join("电视剧", "国产剧"), - }, - { - name: "local nested tv category", - lib: model.Library{Name: "欧美剧", Path: `F:\media\电视剧\欧美剧`, Type: "tv"}, - want: filepath.Join("电视剧", "欧美剧"), - }, - { - name: "uncategorized tv category stays uncategorized", - lib: model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/电视剧/未分类", "/电视剧/未分类"), Type: "tv"}, - want: filepath.Join("电视剧", "未分类"), - }, - { - name: "fallback to type root", - lib: model.Library{Name: "Archive", Path: `F:\archive`, Type: "movie"}, - want: "电影", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := strmLibraryOutputSubdir(tt.lib); got != tt.want { - t.Fatalf("strmLibraryOutputSubdir() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestSTRMLibrarySpecificOutputDirPreservesExplicitCategoryRoot(t *testing.T) { - base := filepath.Join(t.TempDir(), "strm", "电视剧") - lib := model.Library{ - Name: "国产剧", - Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), - Type: "tv", - } - - got := strmLibrarySpecificOutputDir(base, &lib) - want := filepath.Join(base, "国产剧") - if got != want { - t.Fatalf("strmLibrarySpecificOutputDir() = %q, want %q", got, want) - } -} - -func TestGenerateSTRMForLibraryUsesCategoryDefaultOutputDir(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - dataDir := t.TempDir() - lib := model.Library{Name: "OpenList · 欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{Base: model.Base{ID: "show-1"}, LibraryID: lib.ID, Title: "第一集", Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=show", SeasonNum: 1, EpisodeNum: 1} - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{App: config.AppConfig{DataDir: dataDir}}) - - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - wantDir := filepath.Join(dataDir, "strm", "电视剧", "欧美剧") - if res.OutputDir != wantDir { - t.Fatalf("output dir = %q, want %q", res.OutputDir, wantDir) - } - assertFileContains(t, filepath.Join(wantDir, "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token") -} - -func TestGenerateSTRMRemapsLegacyAppDataOutputDir(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - dataDir := t.TempDir() - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-media"}, - LibraryID: lib.ID, - Title: "云盘电影", - Year: 2026, - Path: "cloud://openlist/电影/云盘电影.mkv", - STRMURL: "/api/cloud/play/openlist?ref=movie", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "strm.output_dir", "/app/data/strm"); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{App: config.AppConfig{DataDir: dataDir}}) - - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - BaseURL: "http://nas.example:18080", - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - wantDir := filepath.Join(dataDir, "strm", "电影") - if res.OutputDir != wantDir { - t.Fatalf("output dir = %q, want %q", res.OutputDir, wantDir) - } - if got, err := repos.Setting.Get(t.Context(), "strm.output_dir"); err != nil || got != wantDir { - t.Fatalf("saved strm.output_dir = %q, %v; want %q", got, err, wantDir) - } - assertFileContains(t, filepath.Join(wantDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token") -} - -func TestGenerateSTRMForLibraryUsesPathEpisodeFallback(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "ep-1"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep1"}, - {Base: model.Base{ID: "ep-2"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep2"}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || res.Skipped != 0 { - t.Fatalf("result = %#v, want generated=2 skipped=0", res) - } - libraryOutDir := filepath.Join(outDir, "电视剧", "国产剧") - assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E01.strm"), "http://nas.example:18080/api/stream/ep-1?token=strm-token") - assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E02.strm"), "http://nas.example:18080/api/stream/ep-2?token=strm-token") -} - -func TestGenerateSTRMForLibraryPreservesSourceTree(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - Base: model.Base{ID: "ep-1"}, - LibraryID: lib.ID, - Title: "南部档案", - Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E01.mkv", - STRMURL: "/api/cloud/play/openlist?ref=ep1", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PreserveTree: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Skipped != 0 { - t.Fatalf("result = %#v, want generated=1 skipped=0", res) - } - wantPath := filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E01.strm") - assertFileContains(t, wantPath, "http://nas.example:18080/api/stream/ep-1?token=strm-token") - if got, err := repos.Setting.Get(t.Context(), "strm.preserve_tree"); err != nil || got != "true" { - t.Fatalf("strm.preserve_tree = %q, %v; want true", got, err) - } -} - -func TestGenerateSTRMForLibraryCanSkipLocalMedia(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}, - {Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.mkv")}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: false, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Skipped != 1 { - t.Fatalf("result = %#v, want generated=1 skipped=1", res) - } - assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token") - if _, err := os.Stat(filepath.Join(outDir, "电影", "本地电影 (2025)", "本地电影 (2025).strm")); !os.IsNotExist(err) { - t.Fatalf("local media strm should not exist, stat err=%v", err) - } -} - -func TestGenerateSTRMForAllLibrariesWritesPerLibraryFolders(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - movieLib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - tvLib := model.Library{Name: "欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &movieLib); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &tvLib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "movie-1"}, LibraryID: movieLib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}, - {Base: model.Base{ID: "show-1"}, LibraryID: tvLib.ID, Title: "第一集", Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=show", SeasonNum: 1, EpisodeNum: 1}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - outDir := filepath.Join(t.TempDir(), "strm-all") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForAllLibraries(t.Context(), GenerateSTRMOptions{ - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - Overwrite: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 { - t.Fatalf("generated = %d, want 2", res.Generated) - } - assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/movie-1?token=strm-token") - assertFileContains(t, filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token") - var count int64 - if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("active strm record count = %d, want 2", count) - } -} - -func assertFileContains(t *testing.T, path, want string) { - t.Helper() - if got := readSTRM(t, path); got != want { - t.Fatalf("%s = %q, want %q", path, got, want) - } -} - -func readSTRM(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return strings.TrimSpace(string(data)) -} diff --git a/internal/service/strm_tree_cleanup_test.go b/internal/service/strm_tree_cleanup_test.go deleted file mode 100644 index da23821..0000000 --- a/internal/service/strm_tree_cleanup_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" -) - -func TestGenerateSTRMFromTreeOverwriteAndTraversal(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - existing := filepath.Join(outDir, "Movies", "Movie.strm") - if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(existing, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/Movie.mkv", "../escape.mkv"}, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 0 || res.Skipped != 1 || len(res.Errors) != 1 { - t.Fatalf("result = %#v, want existing skipped and traversal rejected", res) - } - if got := readSTRM(t, existing); got != "old" { - t.Fatalf("existing strm = %q, want unchanged", got) - } - - res, err = svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/Movie.mkv"}, - OutputDir: outDir, - Overwrite: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Updated != 1 { - t.Fatalf("updated = %d, want 1", res.Updated) - } - if got := readSTRM(t, existing); got == "old" { - t.Fatalf("existing strm should be overwritten, got %q", got) - } -} - -func TestGenerateSTRMFromTreeCleanupStaleFiles(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - current := filepath.Join(outDir, "Shows", "Show.S01E01.strm") - stale := filepath.Join(outDir, "Shows", "Show.S01E02.strm") - for _, file := range []string{current, stale} { - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Shows/Show.S01E01.mkv"}, - OutputDir: outDir, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want current skipped and one stale file cleaned", res) - } - if _, err := os.Stat(current); err != nil { - t.Fatalf("current strm should remain: %v", err) - } - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("stale strm should be removed, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeCleanupWithOutputPrefixStaysInPrefix(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - current := filepath.Join(outDir, "电影", "欧美电影", "Movie.strm") - staleInPrefix := filepath.Join(outDir, "电影", "欧美电影", "Old.strm") - otherCategory := filepath.Join(outDir, "电视剧", "国产剧", "Show.strm") - for _, file := range []string{current, staleInPrefix, otherCategory} { - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movie.mkv"}, - OutputPrefix: "电影/欧美电影", - OutputDir: outDir, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one stale file cleaned inside prefix only", res) - } - if _, err := os.Stat(staleInPrefix); !os.IsNotExist(err) { - t.Fatalf("stale prefixed strm should be removed, stat err=%v", err) - } - if _, err := os.Stat(otherCategory); err != nil { - t.Fatalf("other category strm should remain: %v", err) - } -} - -func TestGenerateSTRMFromTreeCleanupSkipsWhenNoValidSources(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - existing := filepath.Join(outDir, "Movies", "Movie.strm") - if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(existing, []byte("keep\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/poster.jpg"}, - OutputDir: outDir, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Cleaned != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want cleanup skipped without valid media sources", res) - } - if got := readSTRM(t, existing); got != "keep" { - t.Fatalf("existing strm = %q, want kept", got) - } -} diff --git a/internal/service/strm_tree_import.go b/internal/service/strm_tree_import.go deleted file mode 100644 index c285c83..0000000 --- a/internal/service/strm_tree_import.go +++ /dev/null @@ -1,283 +0,0 @@ -package service - -import ( - "context" - "errors" - "os" - "path" - "path/filepath" - "strings" -) - -type GenerateSTRMTreeOptions struct { - Provider string `json:"provider"` - TreeText string `json:"tree_text,omitempty"` - Paths []string `json:"paths,omitempty"` - SourceRoot string `json:"source_root,omitempty"` - OutputPrefix string `json:"output_prefix,omitempty"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - Overwrite bool `json:"overwrite"` - Cleanup bool `json:"cleanup"` - DryRun bool `json:"dry_run"` - BatchLimit int `json:"batch_limit,omitempty"` - RecognizeRename bool `json:"recognize_rename,omitempty"` - TransferSubtitles bool `json:"transfer_subtitles,omitempty"` - MissingOnly bool `json:"missing_only,omitempty"` -} - -type strmTreeSource struct { - Provider string - Path string - RefPath string - Kind string -} - -const ( - strmTreeSourceKindVideo = "video" - strmTreeSourceKindSubtitle = "subtitle" -) - -type strmTreeSourceCollection struct { - sources []strmTreeSource - ignored []string - ignoredCount int -} - -type strmTreeSourceCollector struct { - fallbackProvider string - transferSubtitles bool - sources []strmTreeSource - subtitles []strmTreeSource - ignored []string - ignoredCount int - seen map[string]struct{} - seenIgnored map[string]struct{} -} - -func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) { - provider := normalizeSTRMTreeProvider(opts.Provider) - if provider == "" { - return nil, errors.New("provider required") - } - outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)) - if outputDir == "" || outputDir == "." { - return nil, errors.New("output_dir required") - } - if !opts.DryRun { - if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players. - return nil, err - } - } - result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir} - collection := collectSTRMTreeSources(opts) - sources := collection.sources - result.Total = len(sources) - result.Ignored = collection.ignoredCount - result.IgnoredItems = collection.ignored - existingRefs, err := s.strmTreeExistingCloudRefs(ctx, opts) - if err != nil { - return nil, err - } - expectedFiles := make(map[string]struct{}) - for i, source := range sources { - select { - case <-ctx.Done(): - return result, ctx.Err() - default: - } - item := s.generateTreeSTRMItem(ctx, outputDir, source, opts, existingRefs) - if item.FilePath != "" && item.Action != "error" { - expectedFiles[filepath.Clean(item.FilePath)] = struct{}{} - } - result.addItem(item) - if strmTreeBatchLimitReached(result, opts.BatchLimit) { - result.Remaining = len(sources) - i - 1 - result.BatchLimited = result.Remaining > 0 - break - } - } - if opts.Cleanup && !opts.DryRun && opts.BatchLimit <= 0 && len(expectedFiles) > 0 { - cleanupDir := outputDir - if prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix); err == nil && prefix != "" { - cleanupDir = filepath.Join(outputDir, prefix) - } - cleaned, err := removeStaleSTRMFiles(cleanupDir, expectedFiles) - result.Cleaned += cleaned - if err != nil { - result.Errors = append(result.Errors, err.Error()) - } - } - return result, nil -} - -func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool { - if result == nil || limit <= 0 { - return false - } - return result.Generated+result.Updated+result.Previewed >= limit -} - -func (s *STRMService) generateTreeSTRMItem(ctx context.Context, outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem { - relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot) - relPath, err := strmTreeOutputRelativePath(relSource) - if opts.RecognizeRename && source.Kind != strmTreeSourceKindSubtitle { - if renamed, renameErr := s.strmTreeRecognizedOutputRelativePath(ctx, source.Path, opts.SourceRoot); renameErr == nil && renamed != "" { - relPath = renamed - err = nil - } - } - if source.Kind == strmTreeSourceKindSubtitle { - relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource) - } - item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))} - if err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix) - if err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - filePath := filepath.Join(outputDir, prefix, relPath) - item.FilePath = filePath - item.URL = absolutizeSTRMURL(BuildRelativeCloudPlayURL(source.Provider, strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)), opts.BaseURL) - if strmTreeSourceAlreadyInLibrary(source, opts, existingRefs) { - item.Action = "skipped" - item.Reason = "already in media library" - return item - } - if _, err := os.Stat(filePath); err == nil && !opts.Overwrite { - item.Action = "skipped" - item.Reason = "target exists" - return item - } - action := "generated" - if _, err := os.Stat(filePath); err == nil { - action = "updated" - } - if opts.DryRun { - item.Action = "preview" - item.Reason = action - return item - } - if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players. - item.Action = "error" - item.Reason = err.Error() - return item - } - if err := os.WriteFile(filePath, []byte(item.URL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players. - item.Action = "error" - item.Reason = err.Error() - return item - } - item.Action = action - return item -} - -func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection { - collector := newSTRMTreeSourceCollector(opts) - for _, value := range opts.Paths { - collector.add(value) - } - treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText) - for _, value := range treeSources { - collector.add(value) - } - for _, value := range treeIgnored { - collector.addIgnoredOrSubtitle(value) - } - collector.finalizeSubtitles() - return collector.collection() -} - -func newSTRMTreeSourceCollector(opts GenerateSTRMTreeOptions) *strmTreeSourceCollector { - return &strmTreeSourceCollector{ - fallbackProvider: normalizeSTRMTreeProvider(opts.Provider), - transferSubtitles: opts.TransferSubtitles, - sources: make([]strmTreeSource, 0, len(opts.Paths)), - subtitles: make([]strmTreeSource, 0), - ignored: make([]string, 0), - seen: map[string]struct{}{}, - seenIgnored: map[string]struct{}{}, - } -} - -func (c *strmTreeSourceCollector) add(value string) { - source := normalizeSTRMTreeSourceWithProvider(value, c.fallbackProvider) - if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) { - c.addIgnoredOrSubtitle(value) - return - } - source.Kind = strmTreeSourceKindVideo - c.addSource(source) -} - -func (c *strmTreeSourceCollector) addIgnoredOrSubtitle(value string) { - if c.transferSubtitles && c.addSubtitleCandidate(value) { - return - } - c.addIgnored(value) -} - -func (c *strmTreeSourceCollector) addSource(source strmTreeSource) { - if source.Kind == "" { - source.Kind = strmTreeSourceKindVideo - } - key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Kind) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath()) - if _, ok := c.seen[key]; ok { - return - } - c.seen[key] = struct{}{} - c.sources = append(c.sources, source) -} - -func (c *strmTreeSourceCollector) addSubtitleCandidate(value string) bool { - source := normalizeSTRMTreeSubtitleSourceWithProvider(value, c.fallbackProvider) - if source.Provider == "" || source.Path == "" { - return false - } - c.subtitles = append(c.subtitles, source) - return true -} - -func (c *strmTreeSourceCollector) addIgnored(value string) { - if ignoredPath, ok := strmTreeIgnoredFileLikeSource(value); ok { - key := strings.ToLower(ignoredPath) - if _, exists := c.seenIgnored[key]; exists { - return - } - c.seenIgnored[key] = struct{}{} - c.ignoredCount++ - if len(c.ignored) < strmTreeIgnoredItemSampleLimit { - c.ignored = append(c.ignored, ignoredPath) - } - } -} - -func (c *strmTreeSourceCollector) finalizeSubtitles() { - if !c.transferSubtitles { - return - } - for _, source := range c.subtitles { - if strmTreeSubtitleMatchesVideo(source, c.sources) { - c.addSource(source) - continue - } - c.addIgnored(source.Path) - } -} - -func (c *strmTreeSourceCollector) collection() strmTreeSourceCollection { - return strmTreeSourceCollection{sources: c.sources, ignored: c.ignored, ignoredCount: c.ignoredCount} -} - -func (s strmTreeSource) cloudRefPath() string { - if strings.TrimSpace(s.RefPath) != "" { - return s.RefPath - } - return s.Path -} diff --git a/internal/service/strm_tree_import_test.go b/internal/service/strm_tree_import_test.go deleted file mode 100644 index baf4f07..0000000 --- a/internal/service/strm_tree_import_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "strconv" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestGenerateSTRMFromTreePaths(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.S01E01.mkv", "/电视剧/国产剧/南部档案/poster.jpg", "/电视剧/国产剧/南部档案/Existing.strm"}, - TreeText: "电视剧\n└── 国产剧\n └── 南部档案\n └── Existing.Tree.strm", - SourceRoot: "/电视剧", - OutputDir: outDir, - BaseURL: "https://media.example.com", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Skipped != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video and ignored sidecar", res) - } - path := filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.S01E01.strm") - got := readSTRM(t, path) - if !strings.HasPrefix(got, "https://media.example.com/api/cloud/play/cloud115?") { - t.Fatalf("strm url = %q, want cloud115 play url", got) - } - if !strings.Contains(got, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.S01E01.mkv") { - t.Fatalf("strm url = %q, missing encoded source ref", got) - } - if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Existing.strm")); !os.IsNotExist(err) { - t.Fatalf("existing .strm source should be ignored by tree generator, stat err=%v", err) - } - if _, err := os.Stat(filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Existing.Tree.strm")); !os.IsNotExist(err) { - t.Fatalf("tree .strm source should be ignored by tree generator, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeRecognizeRenameOutputPaths(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv", "/电影/Dune.Part.Two.2024.2160p.WEB-DL.mkv"}, - SourceRoot: "/电视剧", - OutputDir: outDir, - RecognizeRename: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated renamed STRM files", res) - } - episode := readSTRM(t, filepath.Join(outDir, "南部档案", "Season 01", "南部档案 S01E02.strm")) - if !strings.Contains(episode, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv") { - t.Fatalf("episode strm URL = %q, want original cloud ref preserved", episode) - } - movie := readSTRM(t, filepath.Join(outDir, "Dune Part Two (2024)", "Dune Part Two (2024).strm")) - if !strings.Contains(movie, "ref=%2F%E7%94%B5%E5%BD%B1%2FDune.Part.Two.2024.2160p.WEB-DL.mkv") { - t.Fatalf("movie strm URL = %q, want original cloud ref preserved", movie) - } - if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.strm")); !os.IsNotExist(err) { - t.Fatalf("recognize rename should not leave original episode output path, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "/Movies/BluRay.Stream.2026.m2ts", - "/Movies/Camera.Source.2026.MTS", - "/Movies/DVD.Feature.2026.vob", - "/Movies/Legacy.Video.2026.wmv", - "/Movies/Web.Legacy.2026.flv", - "/Movies/Disc.Image.2026.iso", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 6 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want six common video sources generated", res) - } - for _, name := range []string{ - "BluRay.Stream.2026", - "Camera.Source.2026", - "DVD.Feature.2026", - "Legacy.Video.2026", - "Web.Legacy.2026", - "Disc.Image.2026", - } { - got := readSTRM(t, filepath.Join(outDir, "Movies", name+".strm")) - if !strings.Contains(got, "/api/cloud/play/openlist?") { - t.Fatalf("%s strm url = %q, want cloud play url", name, got) - } - } -} - -func TestGenerateSTRMFromTreeReportsIgnoredFileLikeRows(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "/Movies/A.mkv", - "/Movies/poster.jpg", - "/Movies/fanart.jpg (cover image)", - "/Movies/Disc.Image.2026.iso", - "/Movies/Existing.strm", - }, - TreeText: strings.Join([]string{ - "电视剧", - "└── Show.Name.2026", - " ├── Show.S01E01.mp4", - " └── Show.S01E01.nfo", - " └── Show.S01E01.srt 72 KB", - }, "\n"), - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 3 || res.Ignored != 5 || len(res.IgnoredItems) != 5 { - t.Fatalf("result = %#v, want three generated videos and five ignored sidecars", res) - } - for _, item := range res.IgnoredItems { - if item == "Show.Name.2026" || item == "电视剧/Show.Name.2026" { - t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems) - } - } - if !strings.Contains(strings.Join(res.IgnoredItems, "\n"), "Show.S01E01.nfo") { - t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems) - } -} - -func TestGenerateSTRMFromTreeTransfersMatchingSubtitleLinks(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "/Movies/A.mkv", - "/Movies/A.zh.srt", - "/Movies/Orphan.srt", - }, - TreeText: strings.Join([]string{ - "电视剧", - "└── Show.Name.2026", - " ├── Show.S01E01.mp4", - " ├── Show.S01E01.ass", - " └── Show.S01E02.srt", - }, "\n"), - OutputDir: outDir, - BaseURL: "https://media.example.com", - TransferSubtitles: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 4 || res.Ignored != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two video links, two matching subtitle links, and two orphan subtitles ignored", res) - } - movieSubtitle := readSTRM(t, filepath.Join(outDir, "Movies", "A.zh.srt.strm")) - if !strings.HasPrefix(movieSubtitle, "https://media.example.com/api/cloud/play/openlist?") || - !strings.Contains(movieSubtitle, "ref=%2FMovies%2FA.zh.srt") { - t.Fatalf("subtitle link = %q, want cloud play URL for subtitle source", movieSubtitle) - } - showSubtitle := readSTRM(t, filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E01.ass.strm")) - if !strings.Contains(showSubtitle, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2FShow.Name.2026%2FShow.S01E01.ass") { - t.Fatalf("tree subtitle link = %q, want full tree subtitle ref", showSubtitle) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "Orphan.srt.strm")); !os.IsNotExist(err) { - t.Fatalf("orphan subtitle should not generate a link, stat err=%v", err) - } - if _, err := os.Stat(filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E02.srt.strm")); !os.IsNotExist(err) { - t.Fatalf("subtitle without matching episode should not generate a link, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeLimitsIgnoredItemSamples(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - paths := make([]string, 0, 25) - for i := 0; i < 25; i++ { - paths = append(paths, filepath.ToSlash(filepath.Join("/Movies", "sidecar-"+strconv.Itoa(i)+".nfo"))) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: paths, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Ignored != 25 || len(res.IgnoredItems) != strmTreeIgnoredItemSampleLimit { - t.Fatalf("ignored = %d samples = %d, want 25/%d", res.Ignored, len(res.IgnoredItems), strmTreeIgnoredItemSampleLimit) - } -} - -func TestGenerateSTRMFromTreeDryRunDoesNotWriteOrCleanup(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - stale := filepath.Join(outDir, "Movies", "Old.Movie.strm") - if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(stale, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/New.Movie.2026.mkv"}, - OutputDir: outDir, - Cleanup: true, - DryRun: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Previewed != 1 || res.Generated != 0 || res.Updated != 0 || res.Cleaned != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one preview and no writes", res) - } - if len(res.Items) != 1 || res.Items[0].Action != "preview" || res.Items[0].Reason != "generated" { - t.Fatalf("preview item = %#v, want generated preview", res.Items) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "New.Movie.2026.strm")); !os.IsNotExist(err) { - t.Fatalf("dry run should not write new strm, stat err=%v", err) - } - if got := readSTRM(t, stale); got != "old" { - t.Fatalf("dry run cleanup touched stale file: %q", got) - } -} - -func TestGenerateSTRMFromTreeDryRunDoesNotCreateOutputDir(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "missing-strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/New.Movie.2026.mkv"}, - OutputDir: outDir, - DryRun: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Previewed != 1 { - t.Fatalf("previewed = %d, want 1", res.Previewed) - } - if _, err := os.Stat(outDir); !os.IsNotExist(err) { - t.Fatalf("dry run should not create output dir, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeBatchLimitContinuesAfterExistingFiles(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - opts := GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/A.mkv", "/Movies/B.mkv", "/Movies/C.mkv"}, - OutputDir: outDir, - BatchLimit: 2, - } - - first, err := svc.GenerateFromTree(t.Context(), opts) - if err != nil { - t.Fatal(err) - } - if first.Generated != 2 || first.Skipped != 0 { - t.Fatalf("first batch = %#v, want two generated", first) - } - if first.Total != 3 || first.Remaining != 1 || !first.BatchLimited { - t.Fatalf("first batch progress = total %d remaining %d limited %v, want 3/1/true", first.Total, first.Remaining, first.BatchLimited) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "C.strm")); !os.IsNotExist(err) { - t.Fatalf("first batch should not write third item, stat err=%v", err) - } - - second, err := svc.GenerateFromTree(t.Context(), opts) - if err != nil { - t.Fatal(err) - } - if second.Generated != 1 || second.Skipped != 2 { - t.Fatalf("second batch = %#v, want two existing skips then next generated", second) - } - if second.Total != 3 || second.Remaining != 0 || second.BatchLimited { - t.Fatalf("second batch progress = total %d remaining %d limited %v, want 3/0/false", second.Total, second.Remaining, second.BatchLimited) - } - if got := readSTRM(t, filepath.Join(outDir, "Movies", "C.strm")); !strings.Contains(got, "C.mkv") { - t.Fatalf("second batch C.strm = %q, want generated third item", got) - } -} - -func TestGenerateSTRMFromTreeBatchLimitSkipsCleanup(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - stale := filepath.Join(outDir, "Movies", "stale.strm") - if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(stale, []byte("keep\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/A.mkv", "/Movies/B.mkv"}, - OutputDir: outDir, - BatchLimit: 1, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Cleaned != 0 { - t.Fatalf("batch result = %#v, want one generated and no cleanup", res) - } - if got := readSTRM(t, stale); got != "keep" { - t.Fatalf("batch cleanup should not touch stale file: %q", got) - } -} diff --git a/internal/service/strm_tree_missing.go b/internal/service/strm_tree_missing.go deleted file mode 100644 index a7265af..0000000 --- a/internal/service/strm_tree_missing.go +++ /dev/null @@ -1,73 +0,0 @@ -package service - -import ( - "context" - "errors" - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *STRMService) strmTreeExistingCloudRefs(ctx context.Context, opts GenerateSTRMTreeOptions) (map[string]struct{}, error) { - if !opts.MissingOnly { - return nil, nil - } - if s == nil || s.repo == nil || s.repo.DB == nil { - return nil, errors.New("media library unavailable") - } - var rows []model.Media - if err := s.repo.DB.WithContext(ctx). - Select("path", "strm_url"). - Where("strm_url <> '' OR path LIKE ?", "cloud://%"). - Find(&rows).Error; err != nil { - return nil, err - } - refs := make(map[string]struct{}, len(rows)) - for _, row := range rows { - if typ, ref, ok := parseCloudMediaPlaybackURL(row.STRMURL); ok { - refs[strmTreeCloudRefKey(typ, ref)] = struct{}{} - } - if typ, ref, ok := strmTreeCloudPathTarget(row.Path); ok { - refs[strmTreeCloudRefKey(typ, ref)] = struct{}{} - } - } - return refs, nil -} - -func strmTreeSourceAlreadyInLibrary(source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) bool { - if len(existingRefs) == 0 || source.Provider == "" || source.Path == "" || source.Kind == strmTreeSourceKindSubtitle { - return false - } - ref := strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot) - _, ok := existingRefs[strmTreeCloudRefKey(source.Provider, ref)] - return ok -} - -func strmTreeCloudPathTarget(raw string) (string, string, bool) { - raw = strings.TrimSpace(raw) - if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { - return "", "", false - } - rest := strings.TrimPrefix(raw, "cloud://") - parts := strings.SplitN(rest, "/", 2) - if len(parts) != 2 { - return "", "", false - } - typ := strings.TrimSpace(parts[0]) - ref := strings.TrimSpace(parts[1]) - return typ, ref, typ != "" && ref != "" -} - -func strmTreeCloudRefKey(provider, ref string) string { - return strings.ToLower(normalizeSTRMTreeProvider(provider)) + "\x00" + strmTreeNormalizeCloudRef(ref) -} - -func strmTreeNormalizeCloudRef(ref string) string { - ref = strings.TrimSpace(ref) - if decoded, err := url.PathUnescape(ref); err == nil { - ref = decoded - } - ref = strings.TrimSpace(strings.ReplaceAll(ref, "\\", "/")) - return strings.ToLower(strings.TrimLeft(ref, "/")) -} diff --git a/internal/service/strm_tree_normalize.go b/internal/service/strm_tree_normalize.go deleted file mode 100644 index 2702ae8..0000000 --- a/internal/service/strm_tree_normalize.go +++ /dev/null @@ -1,380 +0,0 @@ -package service - -import ( - "net/url" - "path" - "regexp" - "strings" -) - -var strmTreeListPrefixRE = regexp.MustCompile(`^\s*(?:[-*+•·]\s+|\d{1,4}[\.)、]\s+)`) - -var strmTreeURLPathQueryKeys = []string{"path", "ref", "file", "filepath", "filename", "name"} - -var strmTreeKnownNonVideoExtensions = map[string]struct{}{ - ".ass": {}, - ".bmp": {}, - ".gif": {}, - ".idx": {}, - ".jpeg": {}, - ".jpg": {}, - ".nfo": {}, - ".png": {}, - ".srt": {}, - ".ssa": {}, - ".strm": {}, - ".sub": {}, - ".txt": {}, - ".url": {}, - ".vtt": {}, - ".webp": {}, -} - -var strmTreeSubtitleExtensions = map[string]struct{}{ - ".ass": {}, - ".idx": {}, - ".srt": {}, - ".ssa": {}, - ".sub": {}, - ".vtt": {}, -} - -func normalizeSTRMTreeProvider(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "115", "115pan", "pan115", "cloud115": - return "cloud115" - case "alist", "openlist", "webdav", "clouddrive2", "s3": - return strings.ToLower(strings.TrimSpace(raw)) - default: - return strings.ToLower(strings.TrimSpace(raw)) - } -} - -func normalizeSTRMTreeSource(raw string) string { - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(value), "cloud://") { - parts := strmLibraryPathParts(value) - if len(parts) == 0 { - return "" - } - return "/" + path.Join(parts...) - } - if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" { - if ref := strmTreeSourceFromURL(parsed); ref != "" { - value = ref - } - } - value = trimSTRMTreeMediaMetadata(value) - value = strings.ReplaceAll(value, "\\", "/") - value = pathAfterWindowsDrivePrefix(value) - value = cleanSTRMTreePathDecorations(value) - value = strings.TrimSpace(value) - if value == "" { - return "" - } - if strmTreeHasUnsafeSegment(value) { - return strings.Trim(value, "/") - } - if strings.HasPrefix(value, "/") { - return path.Clean(value) - } - return strings.TrimPrefix(path.Clean("/"+value), "/") -} - -func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeSource { - provider := normalizeSTRMTreeProvider(fallbackProvider) - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return strmTreeSource{} - } - if info, ok := ParseCloudLibraryMount(value); ok { - source := normalizeSTRMTreeSource(info.DisplayDir) - if source == "" { - return strmTreeSource{} - } - ref := normalizeSTRMTreeSource(info.ScanDir) - if ref == "" { - ref = source - } - return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref} - } - if parsed, err := url.Parse(value); err == nil { - if cloudProvider, ref := strmTreeCloudPlaySourceFromURL(parsed); cloudProvider != "" && ref != "" { - source := normalizeSTRMTreeSource(ref) - if source == "" { - return strmTreeSource{} - } - return strmTreeSource{Provider: normalizeSTRMTreeProvider(cloudProvider), Path: source} - } - } - source := normalizeSTRMTreeSource(value) - if source == "" { - return strmTreeSource{} - } - return strmTreeSource{Provider: provider, Path: source} -} - -func normalizeSTRMTreeSubtitleSourceWithProvider(raw, fallbackProvider string) strmTreeSource { - provider := normalizeSTRMTreeProvider(fallbackProvider) - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return strmTreeSource{} - } - if info, ok := ParseCloudLibraryMount(value); ok { - source, sourceOK := strmTreeSubtitleFileLikeSource(info.DisplayDir) - if !sourceOK { - return strmTreeSource{} - } - ref, refOK := strmTreeSubtitleFileLikeSource(info.ScanDir) - if !refOK { - ref = source - } - return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref, Kind: strmTreeSourceKindSubtitle} - } - source, ok := strmTreeSubtitleFileLikeSource(value) - if !ok { - return strmTreeSource{} - } - return strmTreeSource{Provider: provider, Path: source, Kind: strmTreeSourceKindSubtitle} -} - -func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) { - if parsed == nil { - return "", "" - } - parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(parts) < 4 || !strings.EqualFold(parts[0], "api") || !strings.EqualFold(parts[1], "cloud") || !strings.EqualFold(parts[2], "play") { - return "", "" - } - provider := strings.TrimSpace(parts[3]) - if decoded, err := url.PathUnescape(provider); err == nil { - provider = decoded - } - // /api/cloud/play is generated by this app with url.Values, where '+' - // represents a space. Decode the standard query view first so re-importing - // existing STRM links does not turn "My Movie.mkv" into "My+Movie.mkv". - for _, value := range parsed.Query()["ref"] { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return provider, ref - } - } - for _, value := range strmTreeRawQueryValues(parsed.RawQuery, "ref") { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return provider, ref - } - } - return "", "" -} - -func strmTreeSourceFromURL(parsed *url.URL) string { - if parsed == nil { - return "" - } - for _, key := range strmTreeURLPathQueryKeys { - for _, value := range strmTreeRawQueryValues(parsed.RawQuery, key) { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return ref - } - } - for _, value := range parsed.Query()[key] { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return ref - } - } - } - if parsed.Path != "" && strmTreeURLQueryMediaSource(parsed.Path) != "" { - return parsed.Path - } - return "" -} - -func strmTreeRawQueryValues(rawQuery, key string) []string { - key = strings.ToLower(strings.TrimSpace(key)) - if rawQuery == "" || key == "" { - return nil - } - values := make([]string, 0, 1) - for _, pair := range strings.Split(rawQuery, "&") { - if pair == "" { - continue - } - rawKey, rawValue, _ := strings.Cut(pair, "=") - decodedKey, err := url.QueryUnescape(rawKey) - if err != nil { - decodedKey = rawKey - } - if strings.ToLower(strings.TrimSpace(decodedKey)) != key { - continue - } - decodedValue, err := url.PathUnescape(rawValue) - if err != nil { - decodedValue = rawValue - } - values = append(values, decodedValue) - } - return values -} - -func strmTreeURLQueryMediaSource(raw string) string { - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return "" - } - if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" { - value = parsed.Path - } - value = trimSTRMTreeMediaMetadata(value) - if strmTreeSourceIsVideo(value) { - return value - } - return "" -} - -func strmTreeHasUnsafeSegment(value string) bool { - for _, part := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") { - if strings.TrimSpace(part) == ".." { - return true - } - } - return false -} - -func strmTreeSourceIsVideo(source string) bool { - ext := strings.ToLower(path.Ext(trimSTRMTreeMediaMetadata(cleanSTRMTreeItemName(source)))) - if ext == ".strm" { - return false - } - _, ok := videoExtensions[ext] - return ok -} - -func strmTreeIgnoredFileLikeSource(raw string) (string, bool) { - source := normalizeSTRMTreeSource(raw) - if source == "" { - return "", false - } - source = trimSTRMTreeKnownFileMetadata(cleanSTRMTreeItemName(source)) - ext := strings.ToLower(path.Ext(source)) - if ext == "" { - return "", false - } - if ext == ".strm" { - return source, true - } - if _, ok := videoExtensions[ext]; ok { - return "", false - } - if _, ok := strmTreeKnownNonVideoExtensions[ext]; ok { - return source, true - } - return "", false -} - -func strmTreeSubtitleFileLikeSource(raw string) (string, bool) { - source, ok := strmTreeIgnoredFileLikeSource(raw) - if !ok { - return "", false - } - ext := strings.ToLower(path.Ext(source)) - if _, ok := strmTreeSubtitleExtensions[ext]; !ok { - return "", false - } - return source, true -} - -func cleanSTRMTreePathDecorations(value string) string { - if value == "" { - return "" - } - leadingSlash := strings.HasPrefix(value, "/") - parts := strings.Split(value, "/") - for i, part := range parts { - if part == "" { - continue - } - parts[i] = cleanSTRMTreeItemName(part) - } - cleaned := strings.Join(parts, "/") - if leadingSlash && !strings.HasPrefix(cleaned, "/") { - cleaned = "/" + cleaned - } - return cleaned -} - -func cleanSTRMTreeItemName(value string) string { - value = strings.TrimSpace(strings.Trim(value, `"'`)) - for { - before := value - value = strings.TrimSpace(strmTreeListPrefixRE.ReplaceAllString(value, "")) - value = strings.TrimSpace(strings.TrimLeft(value, "\ufeff📁📂📄📃🎬🎞🎥📺🗂️")) - lower := strings.ToLower(value) - for _, marker := range []string{ - "[file]", "[f]", "[文件]", "[檔案]", "[档案]", - "[dir]", "[d]", "[folder]", "[目录]", "[目錄]", "[文件夹]", "[資料夾]", - "(file)", "(f)", "(文件)", "(檔案)", "(档案)", - "(dir)", "(d)", "(folder)", "(目录)", "(目錄)", "(文件夹)", "(資料夾)", - } { - if strings.HasPrefix(lower, marker) { - value = strings.TrimLeft(strings.TrimSpace(value[len(marker):]), "-_:: ") - break - } - } - if value == before { - return value - } - } -} - -func trimSTRMTreeMediaMetadata(value string) string { - return trimSTRMTreeMetadataByExtensions(value, videoExtensions) -} - -func trimSTRMTreeKnownFileMetadata(value string) string { - return trimSTRMTreeMetadataByExtensions(value, videoExtensions, strmTreeKnownNonVideoExtensions) -} - -func trimSTRMTreeMetadataByExtensions(value string, extensionSets ...map[string]struct{}) string { - value = strings.TrimSpace(value) - lower := strings.ToLower(value) - bestEnd := -1 - for _, extensions := range extensionSets { - for ext := range extensions { - from := 0 - for { - idx := strings.Index(lower[from:], ext) - if idx < 0 { - break - } - idx += from - end := idx + len(ext) - if strmTreeMediaExtBoundary(value, end) && end > bestEnd { - bestEnd = end - } - from = idx + 1 - if from >= len(lower) { - break - } - } - } - } - if bestEnd > 0 && bestEnd < len(value) { - return strings.TrimSpace(value[:bestEnd]) - } - return value -} - -func strmTreeMediaExtBoundary(value string, end int) bool { - if end >= len(value) { - return true - } - for _, r := range value[end:] { - if r == '(' || r == '[' || r == '{' || r == '<' || r == '(' || r == '【' || r == ',' || r == ',' { - return true - } - return r == ' ' || r == '\t' || r == '\r' || r == '\n' - } - return true -} diff --git a/internal/service/strm_tree_parse.go b/internal/service/strm_tree_parse.go deleted file mode 100644 index 5f8c5ef..0000000 --- a/internal/service/strm_tree_parse.go +++ /dev/null @@ -1,196 +0,0 @@ -package service - -import ( - "path" - "strings" -) - -func parseSTRMTreeText(raw string) []string { - out, _ := parseSTRMTreeTextWithIgnored(raw) - return out -} - -func parseSTRMTreeTextWithIgnored(raw string) ([]string, []string) { - var out []string - var ignored []string - stack := make([]string, 0, 8) - plainIndents := make([]int, 0, 8) - rootOffset := 0 - for _, line := range strings.Split(raw, "\n") { - line = strings.TrimRight(line, " \t\r") - if strings.TrimSpace(line) == "" { - continue - } - if item, level, ok := parseSTRMTreeLine(line); ok { - plainIndents = plainIndents[:0] - level += rootOffset - stack = stack[:min(level, len(stack))] - if strmTreeItemIsVideoSource(item) { - out = append(out, strmTreeJoinedSource(stack, item)) - continue - } - if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, strmTreeJoinedSource(stack, item)) - continue - } - stack = append(stack, item) - continue - } - if item, level, ok := parseSTRMTreeContinuationLine(line, len(plainIndents) > 0); ok { - plainIndents = plainIndents[:0] - level += rootOffset - stack = stack[:min(level, len(stack))] - if strmTreeItemIsVideoSource(item) { - out = append(out, path.Join(append(append([]string{}, stack...), item)...)) - } else if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, path.Join(append(append([]string{}, stack...), item)...)) - } - continue - } - item := cleanSTRMTreeItemName(line) - if indent := strmTreePlainIndent(line); indent > 0 && !strings.ContainsAny(item, `/\`) { - for len(plainIndents) > 0 && indent <= plainIndents[len(plainIndents)-1] { - plainIndents = plainIndents[:len(plainIndents)-1] - } - level := rootOffset + len(plainIndents) - stack = stack[:min(level, len(stack))] - if strmTreeItemIsVideoSource(item) { - out = append(out, strmTreeJoinedSource(stack, item)) - continue - } - if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, strmTreeJoinedSource(stack, item)) - continue - } - stack = append(stack, item) - plainIndents = append(plainIndents, indent) - continue - } - if strmTreeItemIsVideoSource(item) || strings.ContainsAny(item, `/\`) { - plainIndents = plainIndents[:0] - if strmTreeItemIsVideoSource(item) { - out = append(out, item) - } else if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, item) - } - continue - } - if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, item) - continue - } - stack = []string{item} - plainIndents = plainIndents[:0] - rootOffset = 1 - } - return out, ignored -} - -func parseSTRMTreeLine(line string) (string, int, bool) { - if idx := strings.Index(line, "──"); idx >= 0 { - prefix := line[:idx] - level := strmTreeIndentLevelWithWidth(prefix, 4) - item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("──"):]), "─- ")) - return item, level, item != "" - } - if idx := strings.Index(line, "─"); idx >= 0 { - prefix := line[:idx] - level := strmTreeIndentLevelWithWidth(prefix, 3) - item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("─"):]), "─- ")) - return item, level, item != "" - } - for _, marker := range []string{"|--", "+--", "`--"} { - if idx := strings.Index(line, marker); idx >= 0 { - item := cleanSTRMTreeItemName(line[idx+len(marker):]) - return item, strmTreeIndentLevel(line[:idx]), item != "" - } - } - return "", 0, false -} - -func parseSTRMTreeContinuationLine(line string, plainTreeActive bool) (string, int, bool) { - prefixLen := 0 - hasGuide := false - for _, r := range line { - switch r { - case ' ', '\t', '│', '|': - if r == '│' || r == '|' { - hasGuide = true - } - prefixLen += len(string(r)) - default: - if plainTreeActive && !hasGuide { - return "", 0, false - } - item := cleanSTRMTreeItemName(line[prefixLen:]) - if item == "" || (!strmTreeItemIsVideoSource(item) && !strmTreeItemIsIgnoredFileSource(item)) { - return "", 0, false - } - return item, strmTreeIndentLevel(line[:prefixLen]), true - } - } - return "", 0, false -} - -func strmTreeIndentLevel(prefix string) int { - return strmTreeIndentLevelWithWidth(prefix, 4) -} - -func strmTreeIndentLevelWithWidth(prefix string, width int) int { - if prefix == "" { - return 0 - } - if width <= 0 { - width = 4 - } - verticals := strings.Count(prefix, "│") + strings.Count(prefix, "|") - runeLen := len([]rune(strings.ReplaceAll(strings.ReplaceAll(prefix, "│", " "), "|", " "))) - byWidth := 0 - if runeLen > 0 { - byWidth = (runeLen - 1) / width - } - if verticals > byWidth { - return verticals - } - return byWidth -} - -func strmTreePlainIndent(line string) int { - indent := 0 - for _, r := range line { - switch r { - case ' ': - indent++ - case '\t': - indent += 4 - default: - return indent - } - } - return indent -} - -func strmTreeItemIsVideoSource(item string) bool { - if strmTreeSourceIsVideo(item) { - return true - } - source := normalizeSTRMTreeSourceWithProvider(item, "openlist") - return source.Path != "" && strmTreeSourceIsVideo(source.Path) -} - -func strmTreeItemIsIgnoredFileSource(item string) bool { - _, ok := strmTreeIgnoredFileLikeSource(item) - return ok -} - -func strmTreeJoinedSource(stack []string, item string) string { - if strmTreeItemIsAbsoluteSource(item) { - return item - } - return path.Join(append(append([]string{}, stack...), item)...) -} - -func strmTreeItemIsAbsoluteSource(item string) bool { - value := strings.ToLower(strings.TrimSpace(item)) - return strings.Contains(value, "://") || strings.HasPrefix(value, "/api/") -} diff --git a/internal/service/strm_tree_parse_test.go b/internal/service/strm_tree_parse_test.go deleted file mode 100644 index 36235f8..0000000 --- a/internal/service/strm_tree_parse_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package service - -import ( - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestGenerateSTRMFromTreeTextPreservesRootTree(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电视剧", - "├── 国产剧", - "│ └── 南部档案", - "│ ├── Archives.S01E01.mkv", - "│ └── Archives.S01E01.nfo", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Archives.S01E01.strm")) - if got != "/api/cloud/play/openlist?ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FArchives.S01E01.mkv" { - t.Fatalf("strm url = %q", got) - } -} - -func TestGenerateSTRMFromTreeTextSupportsSingleLineTreeMarkers(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "动漫", - "├─ 国漫", - "│ └─ 凡人修仙传", - "│ └─ Season 01", - "│ └─ Mortal.Journey.S01E01.mp4", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "动漫", "国漫", "凡人修仙传", "Season 01", "Mortal.Journey.S01E01.strm")) - if !strings.Contains(got, "/api/cloud/play/cloud115?") { - t.Fatalf("strm url = %q, want cloud115 play url", got) - } -} - -func TestGenerateSTRMFromTreeTextSupportsConnectorURLSources(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电影", - "├── https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FLinked.Movie.2026.mkv", - "└── cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D/Cloud.Query.2026.mkv?dir=%2Factual%2Fcloud%2FCloud.Query.2026.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated videos from connector URL rows", res) - } - linked := readSTRM(t, filepath.Join(outDir, "Movies", "Linked.Movie.2026.strm")) - if !strings.Contains(linked, "/api/cloud/play/openlist?") || !strings.Contains(linked, "ref=%2FMovies%2FLinked.Movie.2026.mkv") { - t.Fatalf("cloud play connector url = %q, want preserved provider/ref", linked) - } - cloud := readSTRM(t, filepath.Join(outDir, "电影", "别名", "Cloud.Query.2026.strm")) - if !strings.Contains(cloud, "/api/cloud/play/openlist?") || !strings.Contains(cloud, "ref=%2Factual%2Fcloud%2FCloud.Query.2026.mkv") { - t.Fatalf("cloud mount connector url = %q, want display path output and scan dir ref", cloud) - } -} - -func TestGenerateSTRMFromTreeTextSupportsPlainIndentedTree(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电视剧", - " 欧美剧", - " House of the Dragon", - " Season 03", - " House.of.the.Dragon.S03E01.mkv", - " House.of.the.Dragon.S03E02.mkv", - " The Last of Us", - " Season 02", - " The.Last.of.Us.S02E01.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 3 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want three generated videos from plain indented tree", res) - } - got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "Season 03", "House.of.the.Dragon.S03E01.strm")) - if !strings.Contains(got, "House.of.the.Dragon.S03E01.mkv") { - t.Fatalf("strm url = %q, want first plain-indented source ref", got) - } - got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "Season 02", "The.Last.of.Us.S02E01.strm")) - if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") { - t.Fatalf("strm url = %q, want sibling folder source ref", got) - } -} - -func TestGenerateSTRMFromTreeTextSupportsWindowsTreeFileRows(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电视剧", - "├─欧美剧", - "│ ├─House of the Dragon", - "│ │ House.of.the.Dragon.S03E01.mkv", - "│ │ House.of.the.Dragon.S03E02.mkv", - "│ └─The Last of Us", - "│ The.Last.of.Us.S02E01.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 3 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want three generated videos from Windows tree rows", res) - } - got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E02.strm")) - if !strings.Contains(got, "House.of.the.Dragon.S03E02.mkv") { - t.Fatalf("strm url = %q, want vertical-prefix file row ref", got) - } - got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "The.Last.of.Us.S02E01.strm")) - if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") { - t.Fatalf("strm url = %q, want blank-prefix sibling file row ref", got) - } -} - -func TestGenerateSTRMFromTreeStripsDecoratedTreeNames(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "📁 电视剧", - "├── [目录] 欧美剧", - "│ └── (folder) House of the Dragon", - "│ │ 📄 House.of.the.Dragon.S03E01.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - Paths: []string{"/[目录] 动漫/[folder] 日番/[文件] Frieren.S01E01.mp4"}, - SourceRoot: "/动漫", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated videos with decorations stripped", res) - } - showPath := filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E01.strm") - show := readSTRM(t, showPath) - if strings.Contains(showPath, "目录") || strings.Contains(showPath, "folder") || strings.Contains(showPath, "📄") { - t.Fatalf("decorated local path was not cleaned: %q", showPath) - } - if !strings.Contains(show, "House.of.the.Dragon.S03E01.mkv") || strings.Contains(show, "%5B") || strings.Contains(show, "%F0%9F") { - t.Fatalf("decorated tree ref was not cleaned: %q", show) - } - episode := readSTRM(t, filepath.Join(outDir, "日番", "Frieren.S01E01.strm")) - if !strings.Contains(episode, "ref=%2F%E5%8A%A8%E6%BC%AB%2F%E6%97%A5%E7%95%AA%2FFrieren.S01E01.mp4") { - t.Fatalf("decorated direct path ref was not cleaned: %q", episode) - } -} - -func TestGenerateSTRMFromTreeStripsExportedFileMetadata(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电影", - "└── 欧美电影", - " └── Dune.Part.Two.2024.2160p.WEB-DL.mkv 18.6 GB 2024-04-01 12:30", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - Paths: []string{"/电视剧/欧美剧/Show/Season 01/Show.S01E01.mp4 (2.1 GB)"}, - SourceRoot: "/电视剧", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated videos with metadata suffix stripped", res) - } - movie := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.2160p.WEB-DL.strm")) - if !strings.Contains(movie, "Dune.Part.Two.2024.2160p.WEB-DL.mkv") || strings.Contains(movie, "18.6") { - t.Fatalf("movie strm url = %q, want clean media ref without size metadata", movie) - } - episode := readSTRM(t, filepath.Join(outDir, "欧美剧", "Show", "Season 01", "Show.S01E01.strm")) - if !strings.Contains(episode, "Show.S01E01.mp4") || strings.Contains(episode, "2.1") { - t.Fatalf("episode strm url = %q, want clean media ref without size metadata", episode) - } -} diff --git a/internal/service/strm_tree_paths.go b/internal/service/strm_tree_paths.go deleted file mode 100644 index 1df4e5c..0000000 --- a/internal/service/strm_tree_paths.go +++ /dev/null @@ -1,187 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "path" - "path/filepath" - "strconv" - "strings" -) - -func strmTreeRelativeSource(source, sourceRoot string) string { - source = normalizeSTRMTreeSource(source) - root := normalizeRemotePath(sourceRoot) - if root != "/" && strings.HasPrefix(source, root+"/") { - return strings.TrimPrefix(strings.TrimPrefix(source, root), "/") - } - return strings.TrimPrefix(source, "/") -} - -func strmTreeCloudRef(source, sourceRoot string) string { - source = normalizeSTRMTreeSource(source) - if strings.HasPrefix(source, "/") { - return source - } - if strings.TrimSpace(sourceRoot) != "" { - return joinRemotePath(sourceRoot, source) - } - return normalizeRemotePath(source) -} - -func strmTreeOutputRelativePath(source string) (string, error) { - return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false) -} - -func (s *STRMService) strmTreeRecognizedOutputRelativePath(ctx context.Context, source, sourceRoot string) (string, error) { - source = normalizeSTRMTreeSource(source) - season, episode := ParseEpisode(source) - title, year := s.strmTreeCleanQuery(ctx, source) - if season > 0 || episode > 0 { - show := s.strmTreeRecognizedSeriesTitle(ctx, source, sourceRoot, title) - if show == "" { - return "", errors.New("empty recognized series title") - } - if season <= 0 { - season = 1 - } - if episode <= 0 { - return "", errors.New("missing recognized episode number") - } - name := fmt.Sprintf("%s S%02dE%02d.strm", show, season, episode) - return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)), nil - } - if title == "" { - return "", errors.New("empty recognized movie title") - } - movie := sanitizeFilename(titleCaseWords(title)) - if movie == "" { - return "", errors.New("empty recognized movie filename") - } - folder := movie - if year > 0 && !strings.Contains(folder, strconv.Itoa(year)) { - folder = fmt.Sprintf("%s (%d)", movie, year) - } - return filepath.Join(sanitizeFilename(folder), sanitizeFilename(folder)+".strm"), nil -} - -func (s *STRMService) strmTreeRecognizedSeriesTitle(ctx context.Context, source, sourceRoot, fallback string) string { - rel := strmTreeRelativeSource(source, sourceRoot) - parts := strings.Split(strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), "/") - if len(parts) >= 2 { - dir := parts[len(parts)-2] - if strings.TrimSpace(dir) != "" && !seriesSeasonDirRE.MatchString(dir) { - if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" { - return sanitizeFilename(titleCaseWords(title)) - } - return sanitizeFilename(strings.TrimSpace(dir)) - } - } - if len(parts) >= 3 { - dir := parts[len(parts)-3] - if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" { - return sanitizeFilename(titleCaseWords(title)) - } - return sanitizeFilename(strings.TrimSpace(dir)) - } - if fallback != "" { - return sanitizeFilename(titleCaseWords(fallback)) - } - return "" -} - -func (s *STRMService) strmTreeCleanQuery(ctx context.Context, raw string) (string, int) { - if s == nil { - return CleanQuery(raw) - } - return CleanQueryWithRecognition(ctx, s.repo, raw) -} - -func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) { - return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true) -} - -func strmTreeOutputRelativePathWithLinkExtension(source string, allowedExtensions map[string]struct{}, linkExtension string, appendLinkExtension bool) (string, error) { - parts := strings.Split(strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/"), "/") - if len(parts) == 0 { - return "", errors.New("empty source path") - } - out := make([]string, 0, len(parts)) - for i, part := range parts { - part = strings.TrimSpace(part) - if part == "" || part == "." || part == ".." { - return "", fmt.Errorf("unsafe path segment %q", part) - } - if i == len(parts)-1 { - ext := strings.ToLower(path.Ext(part)) - if _, ok := allowedExtensions[ext]; !ok { - return "", fmt.Errorf("unsupported media extension %q", ext) - } - if linkExtension != "" { - if appendLinkExtension { - part += linkExtension - } else { - part = strings.TrimSuffix(part, path.Ext(part)) + linkExtension - } - } - } - safe := sanitizeFilename(part) - if safe == "" { - return "", errors.New("empty sanitized path segment") - } - out = append(out, safe) - } - return filepath.Join(out...), nil -} - -func strmTreeSubtitleMatchesVideo(subtitle strmTreeSource, videos []strmTreeSource) bool { - subDir, subBase := strmTreeDirAndBase(subtitle.Path) - if subBase == "" { - return false - } - for _, video := range videos { - if video.Kind != "" && video.Kind != strmTreeSourceKindVideo { - continue - } - if !strings.EqualFold(subtitle.Provider, video.Provider) { - continue - } - videoDir, videoBase := strmTreeDirAndBase(video.Path) - if !strings.EqualFold(subDir, videoDir) || videoBase == "" { - continue - } - if strings.EqualFold(subBase, videoBase) || strings.HasPrefix(strings.ToLower(subBase), strings.ToLower(videoBase)+".") { - return true - } - } - return false -} - -func strmTreeDirAndBase(source string) (string, string) { - source = normalizeSTRMTreeSource(source) - dir := path.Dir(source) - name := path.Base(source) - base := strings.TrimSuffix(name, path.Ext(name)) - return strings.ToLower(strings.Trim(dir, "/")), strings.ToLower(base) -} - -func strmTreeOutputPrefixPath(prefix string) (string, error) { - parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/") - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if part == "." || part == ".." { - return "", fmt.Errorf("unsafe output prefix segment %q", part) - } - safe := sanitizeFilename(part) - if safe == "" { - return "", errors.New("empty sanitized output prefix segment") - } - out = append(out, safe) - } - return filepath.Join(out...), nil -} diff --git a/internal/service/strm_tree_url_test.go b/internal/service/strm_tree_url_test.go deleted file mode 100644 index 2690e97..0000000 --- a/internal/service/strm_tree_url_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package service - -import ( - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestGenerateSTRMFromTreeUsesMediaPathFromURLQuery(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "https://openlist.example.com/api/fs/get?path=%2FMovies%2FDune.Part.Two.2024.mkv", - "https://openlist.example.com/api/fs/get?path=%2FMovies%2FA%2BB.2026.mkv", - "https://openlist.example.com/api/fs/get?path=/Movies/A+B.Raw.2026.mkv", - "https://openlist.example.com/api/raw?ref=/Shows/Some.Show/S01E01.mp4", - "https://cdn.example.com/media/Movies/Direct.Movie.2026.mkv?token=secret", - "https://cdn.example.com/media/Movies/A+B.Direct.2026.mkv?token=secret", - "https://openlist.example.com/api/fs/get?id=12345", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 6 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want six generated videos and non-media API URL ignored", res) - } - movie := readSTRM(t, filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm")) - if !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") || strings.Contains(movie, "api%2Ffs%2Fget") { - t.Fatalf("movie strm url = %q, want query media path as cloud ref", movie) - } - plusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.2026.strm")) - if !strings.Contains(plusMovie, "ref=%2FMovies%2FA%2BB.2026.mkv") || strings.Contains(plusMovie, "A+B.2026.mkv") { - t.Fatalf("plus movie strm url = %q, want literal plus preserved and encoded in ref", plusMovie) - } - rawPlusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.Raw.2026.strm")) - if !strings.Contains(rawPlusMovie, "ref=%2FMovies%2FA%2BB.Raw.2026.mkv") || strings.Contains(rawPlusMovie, "A+B.Raw.2026.mkv") { - t.Fatalf("raw plus movie strm url = %q, want raw query plus preserved and encoded in ref", rawPlusMovie) - } - show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm")) - if !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") { - t.Fatalf("show strm url = %q, want ref query media path", show) - } - direct := readSTRM(t, filepath.Join(outDir, "media", "Movies", "Direct.Movie.2026.strm")) - if !strings.Contains(direct, "ref=%2Fmedia%2FMovies%2FDirect.Movie.2026.mkv") { - t.Fatalf("direct url strm = %q, want normal URL path media ref", direct) - } - plusDirect := readSTRM(t, filepath.Join(outDir, "media", "Movies", "A+B.Direct.2026.strm")) - if !strings.Contains(plusDirect, "ref=%2Fmedia%2FMovies%2FA%2BB.Direct.2026.mkv") { - t.Fatalf("plus direct url strm = %q, want URL path plus preserved", plusDirect) - } - if _, err := os.Stat(filepath.Join(outDir, "api", "fs", "get.strm")); !os.IsNotExist(err) { - t.Fatalf("non-media API URL should not generate STRM, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreePreservesSourceProvider(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{ - "cloud://openlist/Movies/OpenList.Movie.2026.mkv", - "/api/cloud/play/cloud115?ref=%2FShows%2FCloud115.Show.S01E01.mkv", - "/api/cloud/play/openlist?ref=%2FMovies%2FMy+Space.Movie.2026.mkv", - "https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv", - "/Movies/Fallback.115.Movie.2026.mkv", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 5 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want five generated videos", res) - } - openlist := readSTRM(t, filepath.Join(outDir, "Movies", "OpenList.Movie.2026.strm")) - if !strings.Contains(openlist, "/api/cloud/play/openlist?") || !strings.Contains(openlist, "ref=%2FMovies%2FOpenList.Movie.2026.mkv") { - t.Fatalf("cloud:// source url = %q, want openlist provider and original ref", openlist) - } - cloud115 := readSTRM(t, filepath.Join(outDir, "Shows", "Cloud115.Show.S01E01.strm")) - if !strings.Contains(cloud115, "/api/cloud/play/cloud115?") || !strings.Contains(cloud115, "ref=%2FShows%2FCloud115.Show.S01E01.mkv") { - t.Fatalf("cloud play source url = %q, want cloud115 provider preserved", cloud115) - } - spaceMoviePath := filepath.Join(outDir, "Movies", "My Space.Movie.2026.strm") - spaceMovie := readSTRM(t, spaceMoviePath) - if _, err := os.Stat(filepath.Join(outDir, "Movies", "My+Space.Movie.2026.strm")); !os.IsNotExist(err) { - t.Fatalf("cloud play source should not create literal-plus local path, stat err=%v", err) - } - spaceURL, err := url.Parse(spaceMovie) - if err != nil { - t.Fatalf("parse cloud play source url %q: %v", spaceMovie, err) - } - if got := spaceURL.Query().Get("ref"); got != "/Movies/My Space.Movie.2026.mkv" { - t.Fatalf("cloud play source ref = %q, want decoded space path", got) - } - remoteOpenlist := readSTRM(t, filepath.Join(outDir, "Movies", "Remote.OpenList.Movie.2026.strm")) - if !strings.Contains(remoteOpenlist, "/api/cloud/play/openlist?") || !strings.Contains(remoteOpenlist, "ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv") { - t.Fatalf("absolute cloud play source url = %q, want openlist provider preserved", remoteOpenlist) - } - fallback := readSTRM(t, filepath.Join(outDir, "Movies", "Fallback.115.Movie.2026.strm")) - if !strings.Contains(fallback, "/api/cloud/play/cloud115?") || !strings.Contains(fallback, "ref=%2FMovies%2FFallback.115.Movie.2026.mkv") { - t.Fatalf("plain source url = %q, want fallback cloud115 provider", fallback) - } -} - -func TestGenerateSTRMFromTreeCloudMountUsesScanDirForRef(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{ - "cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95/Alias.Movie.2026.mkv?dir=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "电影", "别名目录", "Alias.Movie.2026.strm")) - if !strings.Contains(got, "/api/cloud/play/openlist?") { - t.Fatalf("strm url = %q, want provider from cloud mount", got) - } - if !strings.Contains(got, "ref=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv") { - t.Fatalf("strm url = %q, want dir scan path as playable ref", got) - } - if strings.Contains(got, "%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95") { - t.Fatalf("strm url = %q, display path leaked into playable ref", got) - } -} - -func TestGenerateSTRMFromTreeDoesNotDedupeDifferentProviders(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{"cloud://openlist/Movies/Same.Movie.mkv", "cloud://cloud115/Movies/Same.Movie.mkv"}, - OutputDir: outDir, - Overwrite: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Updated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want both provider-specific sources processed", res) - } - got := readSTRM(t, filepath.Join(outDir, "Movies", "Same.Movie.strm")) - if !strings.Contains(got, "/api/cloud/play/cloud115?") || !strings.Contains(got, "ref=%2FMovies%2FSame.Movie.mkv") { - t.Fatalf("final strm url = %q, want second provider write to prove it was not deduped", got) - } -} - -func TestGenerateSTRMFromTreeMissingOnlySkipsMediaAlreadyInLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - rows := []model.Media{ - { - Base: model.Base{ID: "existing-strm"}, - Title: "Existing STRM", - Path: "cloud://openlist/Movies/Existing.Path.Copy.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FExisting.Movie.mkv", - }, - { - Base: model.Base{ID: "existing-cloud-path"}, - Title: "Existing Cloud Path", - Path: "cloud://cloud115/Shows/Existing.Show.S01E01.mkv", - }, - { - Base: model.Base{ID: "different-provider"}, - Title: "Different Provider", - Path: "cloud://cloud115/Shows/Same.Provider.mkv", - }, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "cloud://openlist/Movies/Existing.Movie.mkv", - "cloud://openlist/Movies/New.Movie.mkv", - "cloud://cloud115/Shows/Existing.Show.S01E01.mkv", - "cloud://openlist/Shows/Same.Provider.mkv", - }, - OutputDir: outDir, - MissingOnly: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || res.Skipped != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want generated=2 skipped=2", res) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "Existing.Movie.strm")); !os.IsNotExist(err) { - t.Fatalf("existing media should not generate STRM, stat err=%v", err) - } - assertFileContains(t, filepath.Join(outDir, "Movies", "New.Movie.strm"), "/api/cloud/play/openlist?ref=%2FMovies%2FNew.Movie.mkv") - assertFileContains(t, filepath.Join(outDir, "Shows", "Same.Provider.strm"), "/api/cloud/play/openlist?ref=%2FShows%2FSame.Provider.mkv") - for _, item := range res.Items { - if item.Title == "Existing.Movie" && item.Reason != "already in media library" { - t.Fatalf("existing media skip reason = %q", item.Reason) - } - } -} - -func TestGenerateSTRMFromTreeStripsPathListPrefixes(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "- /Movies/Dune.Part.Two.2024.mkv", - "* https://openlist.example.com/api/fs/get?path=%2FShows%2FSome.Show%2FS01E01.mp4", - "1. /Anime/Frieren/Frieren.S01E01.mp4", - "2) /Anime/Frieren/Frieren.S01E02.mp4", - "• /Documentaries/Earth.2026.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 5 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want five generated videos with list prefixes stripped", res) - } - moviePath := filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm") - movie := readSTRM(t, moviePath) - if strings.Contains(moviePath, "- ") || !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") { - t.Fatalf("markdown bullet prefix leaked into movie path/ref: path=%q url=%q", moviePath, movie) - } - show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm")) - if strings.Contains(show, "api%2Ffs%2Fget") || !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") { - t.Fatalf("bullet URL query source was not cleaned: %q", show) - } - if _, err := os.Stat(filepath.Join(outDir, "1. ", "Anime", "Frieren", "Frieren.S01E01.strm")); !os.IsNotExist(err) { - t.Fatalf("numbered prefix should not create a literal prefix directory, stat err=%v", err) - } - if got := readSTRM(t, filepath.Join(outDir, "Documentaries", "Earth.2026.strm")); !strings.Contains(got, "ref=%2FDocumentaries%2FEarth.2026.mkv") { - t.Fatalf("round bullet source ref = %q", got) - } -} - -func TestGenerateSTRMFromTreeOutputPrefixOnlyAffectsLocalPath(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/cloud/Dune.Part.Two.2024.mkv"}, - SourceRoot: "/cloud", - OutputPrefix: "电影/欧美电影", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.strm")) - if strings.Contains(got, "%E7%94%B5%E5%BD%B1") || strings.Contains(got, "%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1") { - t.Fatalf("strm url = %q, output prefix should not be injected into cloud ref", got) - } - if !strings.Contains(got, "ref=%2Fcloud%2FDune.Part.Two.2024.mkv") { - t.Fatalf("strm url = %q, want original cloud ref", got) - } -} - -func TestGenerateSTRMFromTreeRejectsUnsafeOutputPrefix(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/Movie.mkv"}, - OutputPrefix: "../escape", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 0 || len(res.Errors) != 1 { - t.Fatalf("result = %#v, want unsafe prefix rejected", res) - } - if _, err := os.Stat(filepath.Join(outDir, "..", "escape", "Movies", "Movie.strm")); !os.IsNotExist(err) { - t.Fatalf("unsafe prefixed strm should not be written, stat err=%v", err) - } -} diff --git a/internal/service/strm_url.go b/internal/service/strm_url.go deleted file mode 100644 index 6481d0f..0000000 --- a/internal/service/strm_url.go +++ /dev/null @@ -1,196 +0,0 @@ -package service - -import ( - "context" - "fmt" - "net/url" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/golang-jwt/jwt/v5" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL, playbackToken string) string { - if media.ID == "" { - return "" - } - query := url.Values{} - token := strings.TrimSpace(playbackToken) - if token == "" { - token = s.defaultSTRMPlaybackToken(ctx) - } - if token != "" { - query.Set("token", token) - } - return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), query) -} - -func (s *STRMService) defaultSTRMPlaybackToken(ctx context.Context) string { - if s == nil || s.repo == nil || s.repo.User == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Secrets.JWTSecret) == "" { - return "" - } - admin, err := s.repo.User.FirstAdmin(ctx) - if err != nil || admin == nil { - if err != nil && s.log != nil { - s.log.Warn("generate strm playback token failed", zap.Error(err)) - } - return "" - } - token, err := signSTRMPlaybackToken(admin, s.cfg.Secrets.JWTSecret) - if err != nil { - if s.log != nil { - s.log.Warn("sign strm playback token failed", zap.Error(err)) - } - return "" - } - return token -} - -func signSTRMPlaybackToken(u *model.User, secret string) (string, error) { - if u == nil || strings.TrimSpace(u.ID) == "" || strings.TrimSpace(secret) == "" { - return "", ErrSTRMURLInvalid - } - claims := Claims{ - UserID: u.ID, - Role: u.Role, - Tier: u.Tier, - RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)), - Issuer: "mediastationgo", - Subject: u.ID, - }, - } - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return t.SignedString([]byte(secret)) -} - -func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) string { - title := strings.TrimSpace(media.Title) - if title == "" { - title = strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path)) - } - if title == "" { - return "" - } - seriesLike := isSeriesLibraryType(lib.Type) || media.SeasonNum > 0 || media.EpisodeNum > 0 - if seriesLike { - show := inferSeriesNameFromPath(media.Path) - if show == "" { - show = title - } - season := media.SeasonNum - episode := media.EpisodeNum - if season <= 0 || episode <= 0 { - parsedSeason, parsedEpisode := ParseEpisode(media.Path) - if season <= 0 { - season = parsedSeason - } - if episode <= 0 { - episode = parsedEpisode - } - } - if season <= 0 { - season = 1 - } - name := strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path)) - if episode > 0 { - name = fmt.Sprintf("%s - S%02dE%02d", show, season, episode) - } else if strings.TrimSpace(name) == "" { - name = title - } - return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)+".strm") - } - folder := title - if media.Year > 0 && !strings.Contains(folder, strconv.Itoa(media.Year)) { - folder = fmt.Sprintf("%s (%d)", folder, media.Year) - } - safe := sanitizeFilename(folder) - return filepath.Join(safe, safe+".strm") -} - -func (s *STRMService) strmTreeRelativePath(media model.Media) string { - parts := strmLibraryPathParts(media.Path) - if len(parts) == 0 { - return "" - } - parts = strmDropCategoryPrefix(parts) - if len(parts) == 0 { - return "" - } - last := parts[len(parts)-1] - ext := filepath.Ext(last) - if ext == "" { - return "" - } - parts[len(parts)-1] = strings.TrimSuffix(last, ext) + ".strm" - clean := make([]string, 0, len(parts)) - for _, part := range parts { - if safe := sanitizeFilename(part); safe != "" { - clean = append(clean, safe) - } - } - if len(clean) == 0 { - return "" - } - return filepath.Join(clean...) -} - -func strmDropCategoryPrefix(parts []string) []string { - if len(parts) == 0 { - return nil - } - for i, part := range parts { - if strmCanonicalRoot(part) == "" && strmCategoryRoot(part) == "" { - continue - } - next := i + 1 - if strmCanonicalRoot(part) != "" && next < len(parts) && strmCategoryRoot(parts[next]) != "" { - next++ - } - if next < len(parts) { - return append([]string(nil), parts[next:]...) - } - } - return append([]string(nil), parts...) -} - -func absolutizeSTRMURL(raw, baseURL string) string { - raw = strings.TrimSpace(raw) - if raw == "" || strings.HasPrefix(raw, "//") { - return raw - } - u, err := url.Parse(raw) - if err == nil && u.IsAbs() { - return raw - } - return buildAbsoluteSTRMAPIURL(baseURL, raw, nil) -} - -func buildAbsoluteSTRMAPIURL(baseURL, apiPath string, query url.Values) string { - apiPath = "/" + strings.TrimLeft(strings.TrimSpace(apiPath), "/") - if query != nil && len(query) > 0 { - apiPath += "?" + query.Encode() - } - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if baseURL == "" { - return apiPath - } - base, err := url.Parse(baseURL) - if err != nil || base.Scheme == "" || base.Host == "" { - return apiPath - } - target, err := url.Parse(apiPath) - if err != nil { - return apiPath - } - base.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(target.Path, "/") - base.RawQuery = target.RawQuery - base.Fragment = "" - return base.String() -} diff --git a/internal/service/subscription.go b/internal/service/subscription.go deleted file mode 100644 index f4e788e..0000000 --- a/internal/service/subscription.go +++ /dev/null @@ -1,274 +0,0 @@ -// Package service — RSS subscriptions for automated downloads. -// -// SubscriptionService periodically polls every Subscription row, fetches -// the configured RSS / Atom feed, and queues new items into the -// DownloadService. Items are deduplicated by GUID stored as a Setting key -// "subscription..last_guid" so the same episode is never re-queued. -package service - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// SubscriptionService runs the polling loop. -type SubscriptionService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - downloads *DownloadService - site *SiteService - scraper *ScraperService - hub *Hub - notify *NotifyChannelService - mu sync.Mutex - stop chan struct{} - running bool -} - -const ( - defaultSubscriptionPollInterval = 3 * time.Hour - minSubscriptionPollInterval = 3 * time.Hour - subscriptionStartupDelay = defaultSubscriptionPollInterval -) - -// NewSubscriptionService is the constructor. -func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repository.Container, downloads *DownloadService, site *SiteService, hub *Hub) *SubscriptionService { - return &SubscriptionService{ - cfg: cfg, - log: log, - repo: repo, - downloads: downloads, - site: site, - hub: hub, - } -} - -func (s *SubscriptionService) SetScraper(scraper *ScraperService) { - s.scraper = scraper -} - -func (s *SubscriptionService) SetNotifyChannels(notify *NotifyChannelService) { - s.notify = notify -} - -// Start runs the polling loop in the background. -func (s *SubscriptionService) Start(ctx context.Context) { - s.mu.Lock() - if s.running { - s.mu.Unlock() - return - } - stop := make(chan struct{}) - s.stop = stop - s.running = true - s.mu.Unlock() - go s.loop(ctx, stop) -} - -// Stop shuts the loop down. -func (s *SubscriptionService) Stop() { - s.mu.Lock() - if !s.running { - s.mu.Unlock() - return - } - stop := s.stop - s.stop = nil - s.running = false - s.mu.Unlock() - close(stop) -} - -// Create persists a new subscription. -func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscription) error { - if sub.Name == "" || sub.FeedURL == "" { - return errors.New("name and feed_url required") - } - normalizeSubscriptionDefaults(sub) - model.RefreshSubscriptionIdentity(sub) - if duplicate, err := s.subscriptionDuplicate(ctx, sub, ""); err != nil { - return err - } else if duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - enabled := sub.Enabled - if err := s.repo.Subscription.Create(ctx, sub); err != nil { - if duplicate, lookupErr := s.subscriptionDuplicate(ctx, sub, ""); lookupErr == nil && duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - return err - } - if !enabled { - if err := s.repo.DB.WithContext(ctx).Model(sub).Update("enabled", false).Error; err != nil { - return err - } - sub.Enabled = false - } - return nil -} - -func normalizeSubscriptionDefaults(sub *model.Subscription) { - if strings.TrimSpace(sub.SearchMode) == "" { - sub.SearchMode = "keyword" - } - if strings.TrimSpace(sub.Resolution) == "" { - sub.Resolution = "best" - } - if strings.TrimSpace(sub.WashPriority) == "" { - sub.WashPriority = "balanced" - } - if sub.Priority == 0 { - sub.Priority = 50 - } -} - -// List returns every subscription rule. -func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) { - return s.repo.Subscription.List(ctx) -} - -// Delete removes a subscription. -func (s *SubscriptionService) Delete(ctx context.Context, id string) error { - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).First(&sub).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - return err - } - } - if err := s.deleteSubscriptionDownloads(ctx, &sub); err != nil { - return err - } - if s.repo.Setting != nil { - _ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id)) - } - return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Unscoped().Model(&model.Subscription{}).Where("id = ?", id).Update("enabled", false).Error; err != nil { - return err - } - if sub.DeletedAt.Valid { - return nil - } - return tx.Where("id = ?", id).Delete(&model.Subscription{}).Error - }) -} - -// RunNow forces a poll for one subscription, ignoring its schedule. Used -// by the admin UI's "test now" button. -func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) { - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { - return 0, err - } - if sub.ArchivedAt != nil { - if s.log != nil { - s.log.Info("subscription run skipped because it is archived", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("archive_reason", sub.ArchiveReason)) - } - return 0, nil - } - return s.runOne(ctx, &sub) -} - -// loop polls subscription feeds and site-search subscriptions at a conservative -// cadence so tracker APIs are not hammered by every alias keyword. -func (s *SubscriptionService) loop(ctx context.Context, stop <-chan struct{}) { - defer s.markLoopStopped(stop) - interval := s.pollInterval(ctx) - delay := subscriptionStartupDelay - if interval < delay { - delay = interval - } - for { - timer := time.NewTimer(delay) - select { - case <-ctx.Done(): - timer.Stop() - return - case <-stop: - timer.Stop() - return - case <-timer.C: - } - s.runAll(ctx) - // Re-read after every run so changes from the settings page take effect - // without restarting the service. - delay = s.pollInterval(ctx) - } -} - -func (s *SubscriptionService) markLoopStopped(stop <-chan struct{}) { - s.mu.Lock() - defer s.mu.Unlock() - if s.stop == stop { - s.stop = nil - s.running = false - } -} - -func (s *SubscriptionService) pollInterval(ctx context.Context) time.Duration { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return defaultSubscriptionPollInterval - } - raw, err := s.repo.Setting.Get(ctx, "subscription.interval_seconds") - if err != nil { - return defaultSubscriptionPollInterval - } - seconds, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || seconds <= 0 { - return defaultSubscriptionPollInterval - } - interval := time.Duration(seconds) * time.Second - if interval < minSubscriptionPollInterval { - return minSubscriptionPollInterval - } - return interval -} - -func (s *SubscriptionService) runAll(ctx context.Context) { - subs, err := s.repo.Subscription.List(ctx) - if err != nil { - s.log.Warn("subscription list failed", zap.Error(err)) - return - } - if s.log != nil { - s.log.Info("subscription sweep started", zap.Int("count", len(subs))) - } - for i := range subs { - if !subs[i].Enabled { - continue - } - if n, err := s.runOne(ctx, &subs[i]); err != nil { - s.log.Warn("subscription run failed", - zap.String("name", subs[i].Name), zap.Error(err)) - if subscriptionSiteSearchShouldStopOnError(err) { - s.log.Warn("subscription sweep stopped after upstream failure", - zap.String("name", subs[i].Name), zap.Error(err)) - return - } - } else if n > 0 { - s.log.Info("subscription queued items", - zap.String("name", subs[i].Name), zap.Int("count", n)) - } - } -} diff --git a/internal/service/subscription_archive.go b/internal/service/subscription_archive.go deleted file mode 100644 index 76b8a29..0000000 --- a/internal/service/subscription_archive.go +++ /dev/null @@ -1,173 +0,0 @@ -package service - -import ( - "context" - "fmt" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// History returns completed/archived subscription rules. -func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription, error) { - return s.repo.Subscription.History(ctx) -} - -// Restore moves an archived subscription back to the active management list. -// It also clears the per-subscription seen state so an unfinished historical -// rule can match resources again when it is run next. -func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Subscription, error) { - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).First(&sub).Error; err != nil { - return nil, err - } - sub.Enabled = true - sub.ArchivedAt = nil - sub.ArchiveReason = "" - sub.DeletedAt.Valid = false - sub.TotalEpisodes = 0 - normalizeSubscriptionDefaults(&sub) - model.RefreshSubscriptionIdentity(&sub) - if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil { - return nil, err - } else if duplicate != nil { - return nil, newSubscriptionAlreadyExistsError(duplicate.ID) - } - if err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Subscription{}). - Where("id = ?", id). - Updates(map[string]any{ - "enabled": true, - "archived_at": nil, - "archive_reason": "", - "deleted_at": nil, - "identity_key": sub.IdentityKey, - // 重置为 0:此前可能被 feed 低估并锁死(updateSubscriptionTotalEpisodes - // 只增不减,resolveSubscriptionTotalEpisodes 见 >0 即不再回查元数据)。 - // 归零后下次 run 会从 TMDb/豆瓣等权威源重算真实总集数,避免恢复后 - // 因"误判已无缺集"而不再搜索资源。 - "total_episodes": 0, - }).Error; err != nil { - if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil { - return nil, newSubscriptionAlreadyExistsError(duplicate.ID) - } - return nil, err - } - if s.repo.Setting != nil { - _ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id)) - } - var restored model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&restored).Error; err != nil { - return nil, err - } - return &restored, nil -} - -func (s *SubscriptionService) archiveCompletedSubscription(ctx context.Context, sub *model.Subscription, availability LocalAvailability) error { - if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil { - return nil - } - if !subscriptionShouldArchive(sub, availability) { - return nil - } - now := time.Now() - reason := subscriptionArchiveReason(sub, availability) - if err := s.repo.Subscription.Archive(ctx, sub.ID, reason, now); err != nil { - return err - } - sub.Enabled = false - sub.ArchivedAt = &now - sub.ArchiveReason = reason - if s.log != nil { - s.log.Info("subscription completed, moved to history", - zap.String("id", sub.ID), - zap.String("name", sub.Name), - zap.String("reason", reason)) - } - if s.hub != nil { - s.hub.Publish("subscription", map[string]any{ - "id": sub.ID, - "name": sub.Name, - "archived": true, - "reason": reason, - }) - } - return nil -} - -func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvailability) bool { - if sub == nil || subscriptionAllowsWash(sub) || sub.ArchivedAt != nil { - return false - } - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - seriesLike := isSubscriptionSeriesType(mediaType) || len(availability.ExistingEpisodeKeys) > 0 || len(availability.MissingEpisodeKeys) > 0 - if !seriesLike { - return availability.InLibrary || availability.LocalMediaCount > 0 || availability.DownloadedEpisodes > 0 - } - total := trustedSeriesArchiveTotal(sub, availability) - if availability.HasSeriesPack { - if len(availability.ExistingEpisodeKeys) == 0 { - return true - } - return total > 0 && availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0 - } - if total > 0 { - return availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0 - } - return subscriptionLooksSingleEpisode(sub) && availability.DownloadedEpisodes > 0 -} - -func trustedSeriesArchiveTotal(sub *model.Subscription, availability LocalAvailability) int { - total := 0 - if sub != nil { - total = sub.TotalEpisodes - } - if total <= 0 { - total = availability.TotalEpisodes - } - if maxEpisode := maxAvailabilityEpisode(availability.ExistingEpisodeKeys); total > 0 && maxEpisode > total { - return 0 - } - return total -} - -func maxAvailabilityEpisode(keys map[string]struct{}) int { - maxEpisode := 0 - for key := range keys { - var season, episode int - if _, err := fmt.Sscanf(key, "%02dE%03d", &season, &episode); err == nil && episode > maxEpisode { - maxEpisode = episode - } - } - return maxEpisode -} - -func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvailability) string { - if subscriptionAllowsWash(sub) { - return "" - } - if availability.HasSeriesPack { - return "整季资源已加入下载/入库" - } - if availability.TotalEpisodes > 0 { - return fmt.Sprintf("订阅完成:%d/%d", availability.DownloadedEpisodes, availability.TotalEpisodes) - } - if availability.DownloadedEpisodes > 0 { - return "单集订阅已加入下载/入库" - } - return "订阅媒体已加入下载/入库" -} - -func subscriptionLooksSingleEpisode(sub *model.Subscription) bool { - if sub == nil { - return false - } - for _, value := range []string{sub.Name, sub.Filter} { - _, episode := ParseEpisode(value) - if episode > 0 { - return true - } - } - return false -} diff --git a/internal/service/subscription_archive_test.go b/internal/service/subscription_archive_test.go deleted file mode 100644 index b9cef12..0000000 --- a/internal/service/subscription_archive_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneArchivesCompletedMovieRSS(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Dune 2021 1080p WEB-DL - dune-1080-web - magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - var added bool - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if added { - _, _ = w.Write([]byte(`[{"hash":"dunehash","name":"Dune 2021 1080p WEB-DL","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - added = true - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: rss.URL, - Filter: "Dune 2021", - MediaType: "movie", - SavePath: "/downloads/movies", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %d, want 0 after completion", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 1 || history[0].ArchivedAt == nil { - t.Fatalf("history = %#v, want one archived subscription", history) - } -} - -func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Some Show S01E01 自动订阅", - FeedURL: "site-search://search?keyword=Some%20Show%20S01E01", - Filter: "Some Show S01E01", - MediaType: "tv", - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - }); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %d, want 0", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 1 || history[0].ArchiveReason == "" { - t.Fatalf("history = %#v, want archived single episode", history) - } -} - -func TestSubscriptionArchiveKeepsGenericUnknownTotalSeriesActive(t *testing.T) { - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - Filter: "Some Show", - MediaType: "tv", - } - availability := LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - } - if subscriptionShouldArchive(sub, availability) { - t.Fatal("generic series with unknown total should stay active for incremental episodes") - } -} - -func TestSubscriptionArchiveKeepsPartialSeriesWithParentRowActive(t *testing.T) { - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案", - } - availability := LocalAvailability{ - DownloadedEpisodes: 6, - TotalEpisodes: 1, - LocalMediaCount: 7, - InLibrary: true, - HasSeriesPack: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - episodeKey(1, 2): {}, - episodeKey(1, 3): {}, - episodeKey(1, 4): {}, - episodeKey(1, 5): {}, - episodeKey(1, 6): {}, - }, - } - if subscriptionShouldArchive(sub, availability) { - t.Fatal("partial series with a parent/collection row should stay active") - } -} - -func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: "site-search://search?keyword=Dune", - Filter: "Dune 2021", - MediaType: "movie", - Resolution: "2160p", - WashEnabled: true, - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - }); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 { - t.Fatalf("active subscriptions = %d, want wash subscription to stay active", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 0 { - t.Fatalf("history subscriptions = %d, want 0", len(history)) - } -} - -func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - FeedURL: "https://rss.example/feed", - Filter: "南部档案", - MediaType: "tv", - TotalEpisodes: 33, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - archivedAt := time.Now() - if err := repos.Subscription.Archive(t.Context(), sub.ID, "已下载 1/33 集,缺 33 集", archivedAt); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil { - t.Fatal(err) - } - restored, err := svc.Restore(t.Context(), sub.ID) - if err != nil { - t.Fatal(err) - } - if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled { - t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled) - } - if restored.TotalEpisodes != 0 { - t.Fatalf("restored total_episodes = %d, want 0 so it gets recomputed from authoritative metadata", restored.TotalEpisodes) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != sub.ID { - t.Fatalf("active subscriptions = %#v, want restored subscription", active) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 0 { - t.Fatalf("history subscriptions = %d, want 0 after restore", len(history)) - } - seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") - if err != nil { - t.Fatal(err) - } - if seen != "" { - t.Fatalf("seen state = %q, want cleared", seen) - } -} - -func TestRestoreSoftDeletedArchivedSubscriptionReturnsToActive(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Legacy Hidden History 自动订阅", - FeedURL: "https://rss.example/feed", - Filter: "Legacy Hidden History", - MediaType: "tv", - TotalEpisodes: 12, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - archivedAt := time.Now() - if err := repos.Subscription.Archive(t.Context(), sub.ID, "订阅完成:12/12", archivedAt); err != nil { - t.Fatal(err) - } - if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil { - t.Fatal(err) - } - - restored, err := svc.Restore(t.Context(), sub.ID) - if err != nil { - t.Fatal(err) - } - if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled || restored.TotalEpisodes != 0 { - t.Fatalf("restored subscription not reset: %#v", restored) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != sub.ID { - t.Fatalf("active subscriptions = %#v, want restored legacy subscription", active) - } - var deletedCount int64 - if err := db.Unscoped().Model(&model.Subscription{}). - Where("id = ? AND deleted_at IS NOT NULL", sub.ID). - Count(&deletedCount).Error; err != nil { - t.Fatal(err) - } - if deletedCount != 0 { - t.Fatal("restored subscription kept deleted_at set") - } -} diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go deleted file mode 100644 index 90d2e52..0000000 --- a/internal/service/subscription_availability.go +++ /dev/null @@ -1,263 +0,0 @@ -// Package service — subscription local and pending-download availability helpers. -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability { - out := LocalAvailability{ - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - if sub != nil { - out.TotalEpisodes = sub.TotalEpisodes - } - queries := subscriptionAvailabilityQueries(sub) - if len(queries) == 0 { - return s.finalizePendingAvailability(sub, out) - } - root := s.subscriptionBaseSavePath(ctx, sub) - if root != "" { - _ = scanDownloadPathAny(ctx, root, queries, func(path string, season, episode int) bool { - out.LocalMediaCount++ - if refs := episodeRefsFromTitle(path); len(refs) > 0 { - for _, ref := range refs { - out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - } else if episode > 0 { - out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} - } - return true - }) - } - s.addDownloadTaskAvailability(ctx, sub, queries, &out) - s.addLiveTorrentAvailability(ctx, queries, &out) - return s.finalizePendingAvailability(sub, out) -} - -func (s *SubscriptionService) EnrichProgress(ctx context.Context, items []model.Subscription) { - for i := range items { - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, &items[i]), - s.pendingDownloadAvailability(ctx, &items[i]), - ) - applySubscriptionAvailability(&items[i], availability) - } -} - -func (s *SubscriptionService) EnrichManagementProgress(ctx context.Context, items []model.Subscription) { - rows := s.downloadTaskRowsForAvailability(ctx) - for i := range items { - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, &items[i]), - s.pendingDownloadTaskAvailability(ctx, &items[i], rows, false), - ) - applySubscriptionAvailability(&items[i], availability) - } -} - -func applySubscriptionAvailability(sub *model.Subscription, availability LocalAvailability) { - if sub == nil { - return - } - sub.DownloadedEpisodes = availability.DownloadedEpisodes - sub.LocalMediaCount = availability.LocalMediaCount - sub.MissingEpisodes = availability.MissingEpisodes - sub.InLibrary = availability.InLibrary - if sub.TotalEpisodes == 0 { - sub.TotalEpisodes = availability.TotalEpisodes - } -} - -func (s *SubscriptionService) downloadTaskRowsForAvailability(ctx context.Context) []model.DownloadTask { - if s == nil || s.repo == nil || s.repo.Download == nil { - return nil - } - rows, err := s.repo.Download.List(ctx) - if err != nil { - return nil - } - return rows -} - -func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, queries []string, out *LocalAvailability) { - rows := s.downloadTaskRowsForAvailability(ctx) - s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, true, out) -} - -func (s *SubscriptionService) pendingDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, rows []model.DownloadTask, verifyLive bool) LocalAvailability { - out := LocalAvailability{ - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - if sub != nil { - out.TotalEpisodes = sub.TotalEpisodes - } - queries := subscriptionAvailabilityQueries(sub) - if len(queries) == 0 { - return s.finalizePendingAvailability(sub, out) - } - s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, verifyLive, &out) - return s.finalizePendingAvailability(sub, out) -} - -func (s *SubscriptionService) addDownloadTaskRowsAvailability(ctx context.Context, sub *model.Subscription, queries []string, rows []model.DownloadTask, verifyLive bool, out *LocalAvailability) { - if out == nil { - return - } - baseSavePath := s.subscriptionBaseSavePath(ctx, sub) - for _, row := range rows { - if !downloadTaskBlocksReadd(row.Status) { - continue - } - if verifyLive && !s.downloadTaskCountsAsPending(ctx, row) { - continue - } - linkedToSubscription := sub != nil && strings.TrimSpace(row.SubscriptionID) != "" && row.SubscriptionID == sub.ID - if !linkedToSubscription && baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) { - continue - } - if linkedToSubscription { - addTrustedAvailabilityTitle(row.Title, 0, 0, false, out) - continue - } - addAvailabilityTitleAny(row.Title, queries, out) - } -} - -func (s *SubscriptionService) downloadTaskCountsAsPending(ctx context.Context, row model.DownloadTask) bool { - if s == nil || s.downloads == nil { - return true - } - return s.downloads.subscriptionDownloadTaskStillLive(ctx, row) -} - -func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, queries []string, out *LocalAvailability) { - if s == nil || s.downloads == nil || out == nil { - return - } - live, err := s.downloads.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - return - } - for _, torrent := range live { - addAvailabilityTitleAny(torrent.Name, queries, out) - } -} - -func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability { - mediaType := "" - if sub != nil { - mediaType = sub.MediaType - } - if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 { - out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) - out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) - for _, episode := range out.MissingEpisodes { - out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - } else if out.LocalMediaCount > 0 { - out.DownloadedEpisodes = 1 - if out.TotalEpisodes == 0 { - out.TotalEpisodes = 1 - } - } - return out -} - -func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string { - if sub == nil { - return "" - } - base := strings.TrimSpace(sub.SavePath) - if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil { - base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath") - } - return base -} - -func subscriptionName(sub *model.Subscription) string { - if sub == nil { - return "" - } - return sub.Name -} - -func subscriptionFilter(sub *model.Subscription) string { - if sub == nil { - return "" - } - return sub.Filter -} - -func subscriptionAvailabilityQueries(sub *model.Subscription) []string { - if sub == nil { - return nil - } - values := []string{availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))} - for _, keyword := range siteSearchKeywords(sub) { - values = append(values, cleanAvailabilityTitle(keyword)) - } - if original := cleanAvailabilityTitle(sub.OriginalName); original != "" { - values = append(values, original) - } - return compactUniqueStrings(values...) -} - -func subscriptionMediaType(sub *model.Subscription) string { - if sub == nil { - return "" - } - return sub.MediaType -} - -func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool { - savePath = strings.TrimSpace(savePath) - if savePath == "" { - savePath = s.subscriptionBaseSavePath(ctx, sub) - } - query := availabilityQuery(title, subscriptionFilter(sub)) - if savePath == "" || query == "" { - return false - } - wanted := episodeRefsFromTitle(title) - if len(wanted) == 0 { - wantSeason, wantEpisode := ParseEpisode(title) - if wantEpisode > 0 { - wanted = []episodeRef{{Season: wantSeason, Episode: wantEpisode}} - } - } - found := false - foundEpisodes := map[string]struct{}{} - _ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool { - if len(wanted) == 0 { - found = true - return false - } - if episode <= 0 { - return true - } - if season <= 0 { - season = 1 - } - if refs := episodeRefsFromTitle(path); len(refs) > 0 { - for _, ref := range refs { - foundEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - } else { - foundEpisodes[episodeKey(season, episode)] = struct{}{} - } - for _, ref := range wanted { - if _, ok := foundEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { - return true - } - } - found = true - return false - }) - return found -} diff --git a/internal/service/subscription_availability_scan.go b/internal/service/subscription_availability_scan.go deleted file mode 100644 index a4b910a..0000000 --- a/internal/service/subscription_availability_scan.go +++ /dev/null @@ -1,93 +0,0 @@ -package service - -import ( - "context" - "os" - "path/filepath" - "strings" - "unicode" -) - -func sameOrChildPath(pathValue, root string) bool { - pathValue = filepath.Clean(strings.TrimSpace(pathValue)) - root = filepath.Clean(strings.TrimSpace(root)) - if pathValue == "" || root == "" || pathValue == "." || root == "." { - return false - } - if strings.EqualFold(pathValue, root) { - return true - } - rel, err := filepath.Rel(root, pathValue) - if err != nil { - return false - } - return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) -} - -func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error { - return scanDownloadPathAny(ctx, root, []string{query}, visit) -} - -func scanDownloadPathAny(ctx context.Context, root string, queries []string, visit func(path string, season, episode int) bool) error { - if strings.TrimSpace(root) == "" { - return nil - } - info, err := os.Stat(root) - if err != nil || !info.IsDir() { - return nil - } - if len(queries) == 0 { - return nil - } - visited := 0 - return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if ctx.Err() != nil { - return ctx.Err() - } - if d.IsDir() { - if path != root && strings.HasPrefix(filepath.Base(path), ".") { - return filepath.SkipDir - } - return nil - } - if !isDownloadMediaPath(path) { - return nil - } - visited++ - if visited > 10000 { - return filepath.SkipAll - } - if !availabilityTitleMatchesAny(path, queries) { - return nil - } - season, episode := ParseEpisode(path) - if !visit(path, season, episode) { - return filepath.SkipAll - } - return nil - }) -} - -func isDownloadMediaPath(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".!qb", ".part", ".aria2", ".crdownload": - path = strings.TrimSuffix(path, filepath.Ext(path)) - ext = strings.ToLower(filepath.Ext(path)) - } - _, ok := videoExtensions[ext] - return ok -} - -func normalizeAvailabilityComparable(value string) string { - var b strings.Builder - for _, r := range strings.ToLower(value) { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - } - } - return b.String() -} diff --git a/internal/service/subscription_availability_test.go b/internal/service/subscription_availability_test.go deleted file mode 100644 index 5591b3a..0000000 --- a/internal/service/subscription_availability_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package service - -import ( - "context" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionEnrichProgressIncludesPendingDownloads(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:4444444444444444444444444444444444444444", - Title: "Inception 2010 1080p", - SavePath: "/downloads/movies", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - items := []model.Subscription{{ - Name: "Inception 2010", - Filter: "Inception 2010", - MediaType: "movie", - SavePath: "/downloads/movies", - }} - - svc.EnrichProgress(t.Context(), items) - if items[0].InLibrary { - t.Fatal("pending download should not be reported as in-library media") - } - if items[0].DownloadedEpisodes != 1 || items[0].LocalMediaCount != 1 || items[0].TotalEpisodes != 1 { - t.Fatalf("unexpected enriched progress: %+v", items[0]) - } -} - -func TestSubscriptionEnrichManagementProgressSkipsLiveQB(t *testing.T) { - var qbCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&qbCalls, 1) - http.Error(w, "management enrichment should not call qb", http.StatusInternalServerError) - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{}) - repos := repository.New(db) - sub := model.Subscription{ - Base: model.Base{ID: "sub-spy-family"}, - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 2, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:5555555555555555555555555555555555555555", - Title: "间谍过家家 S01E01 1080p", - SavePath: "/downloads/tv", - Status: "downloading", - Progress: 0.4, - }); err != nil { - t.Fatal(err) - } - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - items := []model.Subscription{sub} - - svc.EnrichManagementProgress(t.Context(), items) - if got := atomic.LoadInt32(&qbCalls); got != 0 { - t.Fatalf("management progress called qb %d times, want 0", got) - } - if items[0].DownloadedEpisodes != 1 || items[0].LocalMediaCount != 1 { - t.Fatalf("unexpected management progress: %+v", items[0]) - } -} - -func TestSubscriptionPollIntervalDefaultsAndClampsMinimum(t *testing.T) { - if subscriptionStartupDelay != defaultSubscriptionPollInterval { - t.Fatalf("startup delay = %v, want default poll interval %v", subscriptionStartupDelay, defaultSubscriptionPollInterval) - } - - db := newServiceTestDB(t, &model.Setting{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - if got := svc.pollInterval(t.Context()); got != defaultSubscriptionPollInterval { - t.Fatalf("default poll interval = %v, want %v", got, defaultSubscriptionPollInterval) - } - - if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "1800"); err != nil { - t.Fatal(err) - } - if got := svc.pollInterval(t.Context()); got != minSubscriptionPollInterval { - t.Fatalf("clamped poll interval = %v, want %v", got, minSubscriptionPollInterval) - } - - if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "14400"); err != nil { - t.Fatal(err) - } - if got := svc.pollInterval(t.Context()); got != 4*time.Hour { - t.Fatalf("configured poll interval = %v, want 4h", got) - } -} - -func TestSubscriptionServiceStartIsSingleLoopAndRestartable(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - svc := NewSubscriptionService(nil, zap.NewNop(), nil, nil, nil, nil) - - svc.Start(ctx) - firstStop := subscriptionStopChannel(svc) - if firstStop == nil { - t.Fatal("first Start did not create a stop channel") - } - svc.Start(ctx) - if got := subscriptionStopChannel(svc); got != firstStop { - t.Fatal("second Start should reuse the running loop instead of starting another") - } - - svc.Stop() - svc.Stop() - svc.Start(ctx) - secondStop := subscriptionStopChannel(svc) - if secondStop == nil { - t.Fatal("restart did not create a stop channel") - } - if secondStop == firstStop { - t.Fatal("restart should create a fresh loop after Stop") - } - svc.Stop() -} - -func subscriptionStopChannel(svc *SubscriptionService) chan struct{} { - svc.mu.Lock() - defer svc.mu.Unlock() - return svc.stop -} - -func TestMergeLocalAvailabilityKeepsLargerSeriesTotal(t *testing.T) { - existing := map[string]struct{}{} - for episode := 1; episode <= 6; episode++ { - existing[episodeKey(1, episode)] = struct{}{} - } - - got := mergeLocalAvailability( - LocalAvailability{TotalEpisodes: 1, LocalMediaCount: 1}, - LocalAvailability{TotalEpisodes: 33, LocalMediaCount: 6, ExistingEpisodeKeys: existing}, - ) - if got.TotalEpisodes != 33 { - t.Fatalf("TotalEpisodes = %d, want 33", got.TotalEpisodes) - } - if got.DownloadedEpisodes != 6 { - t.Fatalf("DownloadedEpisodes = %d, want 6", got.DownloadedEpisodes) - } - if len(got.MissingEpisodes) != 27 { - t.Fatalf("missing episodes = %d, want 27", len(got.MissingEpisodes)) - } -} - -func TestSubscriptionLocalAvailabilityMatchesMediaPath(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "Scraped English Title", - Path: "/media/电视剧/国产剧/凡人修仙传/Season 01/凡人修仙传 - S01E146.mkv", - SeasonNum: 1, - EpisodeNum: 146, - }).Error; err != nil { - t.Fatal(err) - } - sub := &model.Subscription{ - Name: "凡人修仙传 年番", - Filter: "凡人修仙传", - MediaType: "tv", - TotalEpisodes: 146, - } - - availability := SubscriptionLocalAvailability(t.Context(), repos, sub) - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 146)]; !ok { - t.Fatalf("missing path-matched E146 key: %#v", availability.ExistingEpisodeKeys) - } - results := []SearchResult{ - {Title: "凡人修仙传 年番 - 146 1080p", DownloadURL: "https://pt/download/146", Seeders: 80}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none because path-matched local episode exists", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityIgnoresDeletedTasks(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:3333333333333333333333333333333333333333", - Title: "间谍过家家 S01E02 1080p", - SavePath: "/downloads/tv", - Status: "deleted", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 3, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; ok { - t.Fatalf("deleted E02 task should not count as available: %#v", availability.ExistingEpisodeKeys) - } - results := []SearchResult{ - {Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 2 || got[0].Episode != 2 || got[1].Episode != 3 { - t.Fatalf("selected %#v, want deleted episode 2 and new episode 3", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityIncludesLiveQBTorrents(t *testing.T) { - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"间谍过家家 S01E01 1080p","state":"downloading","progress":0.2}]`)) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 2, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 1 { - t.Fatalf("downloaded episodes = %d, want 1", availability.DownloadedEpisodes) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 1)]; !ok { - t.Fatalf("missing live qB E01 key: %#v", availability.ExistingEpisodeKeys) - } -} - -func TestSiteSearchDownloadDedupMarksCandidateAvailable(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-nanyang"}, - UserID: "u1", - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt/download/existing", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - Progress: 0, - }); err != nil { - t.Fatal(err) - } - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - siteSvc := NewSiteService(zap.NewNop(), repos, "") - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, siteSvc, NewHub(zap.NewNop())) - state := &siteSearchRunState{ - Keyword: "南部档案", - SeenSet: map[string]struct{}{}, - Availability: LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - }, - } - - title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - DownloadURL: "https://pt/download/existing", - }, - Download: "https://pt/download/existing", - GUID: "site|mteam|nanyang-7-8", - Season: 1, - Episode: 7, - Episodes: []int{7, 8}, - Pack: true, - Score: 80, - }, state) - if err != nil { - t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skipped without error", err) - } - if title != "" { - t.Fatalf("queued title = %q, want empty on dedup", title) - } - for _, episode := range []int{7, 8} { - if _, ok := state.Availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("deduped candidate should mark E%d available: %#v", episode, state.Availability.ExistingEpisodeKeys) - } - } - if len(state.Seen) != 0 { - t.Fatalf("deduped candidate should stay retryable instead of being marked seen: %#v", state.Seen) - } -} diff --git a/internal/service/subscription_availability_titles.go b/internal/service/subscription_availability_titles.go deleted file mode 100644 index 60d495b..0000000 --- a/internal/service/subscription_availability_titles.go +++ /dev/null @@ -1,182 +0,0 @@ -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func addAvailabilityTitle(title, query string, out *LocalAvailability) { - if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" { - return - } - if !availabilityTitleMatchesAny(title, []string{query}) { - return - } - out.LocalMediaCount++ - if refs := episodeRefsFromTitle(title); len(refs) > 0 { - if out.ExistingEpisodeKeys == nil { - out.ExistingEpisodeKeys = map[string]struct{}{} - } - for _, ref := range refs { - out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - return - } - if isSeriesPackTitle(title) { - out.HasSeriesPack = true - } -} - -func addAvailabilityTitleAny(title string, queries []string, out *LocalAvailability) { - if !availabilityTitleMatchesAny(title, queries) { - return - } - addTrustedAvailabilityTitle(title, 0, 0, false, out) -} - -func availabilityTitleMatchesAny(title string, queries []string) bool { - titleKey := normalizeAvailabilityComparable(title) - if titleKey == "" { - return false - } - for _, query := range queries { - queryKey := normalizeAvailabilityComparable(query) - if queryKey == "" { - continue - } - if strings.Contains(titleKey, queryKey) { - return true - } - } - return false -} - -func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *LocalAvailability) { - addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out) -} - -func (s *SubscriptionService) subscriptionCandidateConfirmedAvailable(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate) bool { - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, sub), - s.pendingDownloadAvailability(ctx, sub), - ) - return candidateAvailableInAvailability(sub, candidate, availability) -} - -func candidateAvailableInAvailability(sub *model.Subscription, candidate siteSearchCandidate, availability LocalAvailability) bool { - mediaType := normalizeMediaType(subscriptionMediaType(sub), subscriptionName(sub)+" "+subscriptionFilter(sub), "") - if !isSubscriptionSeriesType(mediaType) { - return availability.LocalMediaCount > 0 || availability.InLibrary - } - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return availability.HasSeriesPack - } - season := candidate.Season - if season <= 0 { - season = 1 - } - for _, episode := range episodes { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(season, episode)]; !ok { - return false - } - } - return true -} - -func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) { - if out == nil { - return - } - if strings.TrimSpace(title) == "" && episode <= 0 && !pack { - return - } - out.LocalMediaCount++ - refs := episodeRefsFromTitle(title) - if len(refs) == 0 && episode > 0 { - if season <= 0 { - season = 1 - } - refs = []episodeRef{{Season: season, Episode: episode}} - } - if len(refs) > 0 { - if out.ExistingEpisodeKeys == nil { - out.ExistingEpisodeKeys = map[string]struct{}{} - } - for _, ref := range refs { - out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - return - } - if episode <= 0 { - season, episode = ParseEpisode(title) - } - if episode > 0 { - if out.ExistingEpisodeKeys == nil { - out.ExistingEpisodeKeys = map[string]struct{}{} - } - out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} - return - } - if pack || isSeriesPackTitle(title) { - out.HasSeriesPack = true - } -} - -func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability { - out := LocalAvailability{ - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - for _, value := range values { - if value.TotalEpisodes > out.TotalEpisodes { - out.TotalEpisodes = value.TotalEpisodes - } - out.LocalMediaCount += value.LocalMediaCount - out.InLibrary = out.InLibrary || value.InLibrary - out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack - for key := range value.ExistingEpisodeKeys { - out.ExistingEpisodeKeys[key] = struct{}{} - } - } - out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) - if out.TotalEpisodes > 0 { - out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) - for _, episode := range out.MissingEpisodes { - out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - } - if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 { - out.DownloadedEpisodes = out.LocalMediaCount - if out.TotalEpisodes == 0 { - out.TotalEpisodes = 1 - } - } - return out -} - -// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。 -// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。 -func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool { - if avail.LocalMediaCount == 0 && !avail.HasSeriesPack { - return false - } - if !isSubscriptionSeriesType(subscriptionMediaType(sub)) { - return true - } - if avail.HasSeriesPack { - return true - } - wantSeason, wantEpisode := ParseEpisode(title) - if wantEpisode <= 0 { - // 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。 - return true - } - if wantSeason <= 0 { - wantSeason = 1 - } - _, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)] - return exists -} diff --git a/internal/service/subscription_candidate_availability_test.go b/internal/service/subscription_candidate_availability_test.go deleted file mode 100644 index 33711d0..0000000 --- a/internal/service/subscription_candidate_availability_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) { - sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3} - results := []SearchResult{ - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - availability := LocalAvailability{ - TotalEpisodes: 3, - LocalMediaCount: 2, - MissingEpisodes: []int{3}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only missing episode 3", got) - } -} - -func TestSelectSiteSearchCandidatesWithUnknownTotalSkipsExistingEpisodes(t *testing.T) { - sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime"} - results := []SearchResult{ - {Title: "葬送的芙莉莲 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - {Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - availability := LocalAvailability{ - LocalMediaCount: 2, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only not-yet-local episode 3", got) - } -} - -func TestSelectSiteSearchCandidatesSingleExistingEpisodeIsSkipped(t *testing.T) { - sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", TotalEpisodes: 3} - results := []SearchResult{ - {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - } - availability := LocalAvailability{ - TotalEpisodes: 3, - LocalMediaCount: 1, - MissingEpisodes: []int{2, 3}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none because E01 already exists", got) - } -} - -func TestSelectSiteSearchCandidatesFullPackUsedAsFallbackWhenLibraryPartiallyExists(t *testing.T) { - // 本地缺第 3 集,站点只有整季全集包(无单集种)。剧集完结后站点常只挂全集包, - // 此时必须用全集包兜底补缺集,否则"补全缺失集"永远匹配为空(用户报告的 bug)。 - sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3} - results := []SearchResult{ - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - } - availability := LocalAvailability{ - TotalEpisodes: 3, - LocalMediaCount: 2, - MissingEpisodes: []int{3}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 { - t.Fatalf("selected %#v, want the full pack as fallback to cover missing episode 3", got) - } - if got[0].Download != "https://pt/download/pack" { - t.Fatalf("selected %#v, want the Complete pack", got) - } -} - -func TestSelectSiteSearchCandidatesPartialSeriesPackDoesNotSatisfySubscription(t *testing.T) { - sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv", TotalEpisodes: 33} - results := []SearchResult{ - {Title: "问心2 S01E07 2160p WEB-DL", DownloadURL: "https://pt/download/7", Seeders: 100}, - } - availability := LocalAvailability{ - TotalEpisodes: 33, - LocalMediaCount: 7, - HasSeriesPack: true, - MissingEpisodes: []int{7}, - ExistingEpisodeKeys: map[string]struct{}{}, - } - for episode := 1; episode <= 6; episode++ { - availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 7 { - t.Fatalf("selected %#v, want missing episode 7 despite local pack marker", got) - } - if stats.LocalSeriesPackPresent { - t.Fatalf("LocalSeriesPackPresent = true, want false for partial series availability") - } -} - -func TestSelectSiteSearchCandidatesIgnoresUnderestimatedLocalTotal(t *testing.T) { - sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv"} - results := []SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100}, - } - existing := map[string]struct{}{} - for episode := 1; episode <= 6; episode++ { - existing[episodeKey(1, episode)] = struct{}{} - } - availability := LocalAvailability{ - TotalEpisodes: 1, - LocalMediaCount: 7, - HasSeriesPack: true, - ExistingEpisodeKeys: existing, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/29-33" { - t.Fatalf("selected %#v, want high-episode candidate despite underestimated local total", got) - } - if stats.SeriesComplete || stats.NotMissingEpisodeSkipped != 0 { - t.Fatalf("stats = %#v, underestimated total must not mark series complete or skip high episodes", stats) - } -} - -func TestSelectSiteSearchCandidatesRangeCanCoverMissingEpisodesAfterExistingStart(t *testing.T) { - sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33} - results := []SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100}, - } - existing := map[string]struct{}{episodeKey(1, 29): {}} - availability := LocalAvailability{ - TotalEpisodes: 33, - LocalMediaCount: 1, - MissingEpisodes: []int{30, 31, 32, 33}, - ExistingEpisodeKeys: existing, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/29-33" { - t.Fatalf("selected %#v, want range candidate because it covers E30-E33", got) - } - if stats.ExistingEpisodeSkipped != 0 || stats.NotMissingEpisodeSkipped != 0 { - t.Fatalf("stats = %#v, range covering missing episodes must not be skipped", stats) - } -} - -func TestSelectSiteSearchCandidatesMissingEpisodeCanMatchSubtitleAlias(t *testing.T) { - sub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv", TotalEpisodes: 12} - results := []SearchResult{ - {Title: "Smoking Behind the Supermarket with You S01E01 1080p", Subtitle: "躲在超市后门抽烟的两人", DownloadURL: "https://pt/download/1", Seeders: 100}, - {Title: "Smoking Behind the Supermarket with You S01E12 1080p", Subtitle: "躲在超市后门抽烟的两人", DownloadURL: "https://pt/download/12", Seeders: 80}, - } - existing := map[string]struct{}{} - for episode := 1; episode <= 11; episode++ { - existing[episodeKey(1, episode)] = struct{}{} - } - availability := LocalAvailability{ - TotalEpisodes: 12, - LocalMediaCount: 11, - MissingEpisodes: []int{12}, - ExistingEpisodeKeys: existing, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 12 || got[0].Download != "https://pt/download/12" { - t.Fatalf("selected %#v, want subtitle-matched missing episode 12", got) - } -} - -func TestSelectSiteSearchCandidatesRelaxesQueryForExistingSeriesMissingEpisodes(t *testing.T) { - sub := &model.Subscription{Name: "翘楚 S01E06 自动订阅", Filter: "翘楚 S01E06", MediaType: "tv", TotalEpisodes: 24} - results := []SearchResult{ - {Title: "Qiao Chu 2026 S01E06 2160p WEB-DL", DownloadURL: "https://pt/download/6", Seeders: 10}, - {Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", DownloadURL: "https://pt/download/21", Seeders: 8}, - {Title: "Ashes to Crown 2026 S01E99 2160p WEB-DL", DownloadURL: "https://pt/download/99", Seeders: 99}, - } - availability := LocalAvailability{ - TotalEpisodes: 24, - LocalMediaCount: 1, - MissingEpisodes: []int{21}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 6): {}}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 21 || got[0].Download != "https://pt/download/21" { - t.Fatalf("selected %#v, want relaxed alias-like missing episode 21 only", got) - } - if stats.QueryMismatch != 3 || stats.RelaxedQueryMatch != 3 || stats.ExistingEpisodeSkipped != 1 || stats.NotMissingEpisodeSkipped != 1 { - t.Fatalf("unexpected relaxed stats: %#v", stats) - } -} - -func TestAddSiteSearchCandidateAvailabilityTracksRelaxedAliasCandidate(t *testing.T) { - sub := &model.Subscription{Name: "翘楚 S01E06 自动订阅", Filter: "翘楚 S01E06", MediaType: "tv", TotalEpisodes: 24} - availability := LocalAvailability{ - TotalEpisodes: 24, - LocalMediaCount: 1, - MissingEpisodes: []int{21}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 6): {}}, - MissingEpisodeKeys: map[string]struct{}{episodeKey(1, 21): {}}, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", - DownloadURL: "https://pt/download/21", - }, - Download: "https://pt/download/21", - GUID: "site|m-team|ashes-to-crown-21", - Season: 1, - Episode: 21, - } - - addSiteSearchCandidateAvailability(candidate, &availability) - availability = NewSubscriptionService(nil, nil, nil, nil, nil, nil).finalizePendingAvailability(sub, availability) - - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 21)]; !ok { - t.Fatalf("missing relaxed alias candidate E21 key: %#v", availability.ExistingEpisodeKeys) - } - got := selectSiteSearchCandidates([]SearchResult{candidate.Item}, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want relaxed alias candidate skipped after dedup availability update", got) - } -} - -func TestAddSiteSearchCandidateAvailabilityTracksEpisodeRange(t *testing.T) { - availability := LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/29-33", - }, - Download: "https://pt/download/29-33", - GUID: "site|m-team|nanyang-29-33", - Season: 1, - Episode: 29, - Episodes: []int{29, 30, 31, 32, 33}, - Pack: true, - } - - addSiteSearchCandidateAvailability(candidate, &availability) - for episode := 29; episode <= 33; episode++ { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("availability missing E%d after range mark: %#v", episode, availability.ExistingEpisodeKeys) - } - } -} - -func TestCandidateAvailableInAvailabilityRequiresFullRangeCoverage(t *testing.T) { - sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33} - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/29-33", - }, - Season: 1, - Episode: 29, - Episodes: []int{29, 30, 31, 32, 33}, - Pack: true, - } - availability := LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 29): {}, episodeKey(1, 30): {}}, - } - - if candidateAvailableInAvailability(sub, candidate, availability) { - t.Fatal("partial range availability must not confirm a deduped subscription candidate") - } - for episode := 31; episode <= 33; episode++ { - availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - if !candidateAvailableInAvailability(sub, candidate, availability) { - t.Fatal("complete range availability should confirm a deduped subscription candidate") - } -} - -func TestShouldSkipExistingTorrentKeepsSeriesRangeCandidate(t *testing.T) { - svc := &SubscriptionService{downloads: &DownloadService{}} - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/29-33", - }, - Season: 1, - Episode: 29, - Episodes: []int{29, 30, 31, 32, 33}, - Pack: true, - } - - if svc.shouldSkipExistingTorrent(t.Context(), "tv", candidate) { - t.Fatal("series range candidate should not be skipped by global torrent-name precheck") - } -} - -func TestSelectSiteSearchCandidatesDoesNotRelaxQueryForMovies(t *testing.T) { - sub := &model.Subscription{Name: "玩具总动员 5 自动订阅", Filter: "玩具总动员 5 2026", MediaType: "movie"} - results := []SearchResult{ - {Title: "Toy Story 4 2019 2160p WEB-DL", DownloadURL: "https://pt/download/wrong", Seeders: 100}, - } - availability := LocalAvailability{} - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want no relaxed movie match", got) - } - if stats.QueryMismatch != 1 || stats.RelaxedQueryMatch != 0 { - t.Fatalf("unexpected stats: %#v", stats) - } -} - -func TestSelectSiteSearchCandidatesSingleExistingMovieIsSkippedWhenNotWashing(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie"} - results := []SearchResult{ - {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/web", Seeders: 90}, - } - availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1} - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none because movie already exists and wash is disabled", got) - } -} diff --git a/internal/service/subscription_candidate_collect.go b/internal/service/subscription_candidate_collect.go deleted file mode 100644 index b7b4184..0000000 --- a/internal/service/subscription_candidate_collect.go +++ /dev/null @@ -1,237 +0,0 @@ -package service - -import ( - "regexp" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -var looseSubscriptionEpisodeRE = regexp.MustCompile(`(?i)(?:^|[\s._\-\[\(])0?(\d{1,3})(?:v\d+)?(?:$|[\s._\-\]\)])`) - -func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, allowQueryMismatch bool, stats *siteSearchSelectionStats) []siteSearchCandidate { - candidates := make([]siteSearchCandidate, 0, len(results)) - for _, item := range results { - matchText := subscriptionSearchResultText(item) - if !subscriptionSearchResultMatchesQuery(sub, item) { - if allowQueryMismatch { - stats.RelaxedQueryMatch++ - } else { - stats.QueryMismatch++ - stats.QueryMismatchExamples = appendLimitedStrings(stats.QueryMismatchExamples, matchText, 5) - continue - } - } - if !matchesSubscriptionRules(sub, matchText) || !matchesSubscriptionTorrentRules(sub, item) { - stats.RuleMismatch++ - continue - } - download := strings.TrimSpace(item.DownloadURL) - if download == "" { - download = strings.TrimSpace(item.TorrentURL) - } - if download == "" { - stats.MissingDownload++ - continue - } - guid := stableSiteSearchGUID(item, download) - if _, ok := seenSet[guid]; ok { - stats.Seen++ - continue - } - refs := subscriptionCandidateEpisodeRefs(sub, matchText) - season, episode := ParseEpisode(matchText) - if episode <= 0 && len(refs) > 0 { - season = refs[0].Season - episode = refs[0].Episode - } - episodes := episodeNumbersFromRefs(refs, season) - score := subscriptionCandidateScore(sub, item) - stats.Prepared++ - candidates = append(candidates, siteSearchCandidate{ - Item: item, - Download: download, - GUID: guid, - Season: season, - Episode: episode, - Episodes: episodes, - Pack: isSeriesPackTitle(item.Title) || len(episodes) > 1, - Score: score, - }) - } - return candidates -} - -func appendLimitedStrings(values []string, value string, limit int) []string { - value = strings.TrimSpace(value) - if value == "" || limit <= 0 || len(values) >= limit { - return values - } - return append(values, value) -} - -func shouldRelaxSiteSearchQueryMatch(sub *model.Subscription, local LocalAvailability) bool { - if sub == nil { - return false - } - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - if !isSubscriptionSeriesType(mediaType) { - return false - } - if local.LocalMediaCount == 0 && len(local.ExistingEpisodeKeys) == 0 { - return false - } - return local.TotalEpisodes > 0 || len(local.MissingEpisodes) > 0 -} - -func subscriptionSearchResultText(item SearchResult) string { - return strings.TrimSpace(strings.Join([]string{item.Title, item.Subtitle, item.Labels}, " ")) -} - -func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, filter *regexp.Regexp, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate { - if seenSet == nil { - seenSet = map[string]struct{}{} - } - candidates := make([]siteSearchCandidate, 0, len(items)) - for _, item := range items { - title := strings.TrimSpace(item.Title) - if title == "" { - continue - } - if filter != nil && !filter.MatchString(title) { - continue - } - searchItem := SearchResult{Title: title} - if !matchesSubscriptionRules(sub, title) || !matchesSubscriptionTorrentRules(sub, searchItem) { - continue - } - download := strings.TrimSpace(item.Enclosure.URL) - if download == "" { - download = strings.TrimSpace(item.Link) - } - if download == "" { - continue - } - guid := stableRSSItemGUID(title, item.GUID, item.Link, item.Enclosure.URL) - if _, ok := seenSet[guid]; ok { - continue - } - searchItem.DownloadURL = download - refs := subscriptionCandidateEpisodeRefs(sub, title) - season, episode := ParseEpisode(title) - if episode <= 0 && len(refs) > 0 { - season = refs[0].Season - episode = refs[0].Episode - } - episodes := episodeNumbersFromRefs(refs, season) - candidates = append(candidates, siteSearchCandidate{ - Item: searchItem, - Download: download, - GUID: guid, - Season: season, - Episode: episode, - Episodes: episodes, - Pack: isSeriesPackTitle(title) || len(episodes) > 1, - Score: subscriptionCandidateScore(sub, searchItem), - }) - } - return selectPreparedSubscriptionCandidates(candidates, sub, local) -} - -func subscriptionCandidateEpisodeRefs(sub *model.Subscription, text string) []episodeRef { - if refs := episodeRefsFromTitle(text); len(refs) > 0 { - return refs - } - if sub == nil || !isSubscriptionSeriesType(sub.MediaType) || isSeriesPackTitle(text) || patSeasonOnly.MatchString(text) { - return nil - } - episode := inferLooseSubscriptionEpisode(maskSubscriptionTitleQueries(sub, text)) - if episode <= 0 { - return nil - } - return []episodeRef{{Season: 1, Episode: episode}} -} - -func maskSubscriptionTitleQueries(sub *model.Subscription, text string) string { - if sub == nil || strings.TrimSpace(text) == "" { - return text - } - out := text - outFold := strings.ToLower(out) - for _, query := range subscriptionTitleMatchQueries(sub) { - query = strings.TrimSpace(query) - if query == "" { - continue - } - queryFold := strings.ToLower(query) - for { - idx := strings.Index(outFold, queryFold) - if idx < 0 { - break - } - out = out[:idx] + strings.Repeat(" ", len(query)) + out[idx+len(query):] - outFold = strings.ToLower(out) - } - } - return out -} - -func inferLooseSubscriptionEpisode(text string) int { - for _, match := range looseSubscriptionEpisodeRE.FindAllStringSubmatchIndex(text, -1) { - if len(match) < 4 || match[2] < 0 || match[3] < 0 { - continue - } - if isDecimalFractionMatch(text, match[2]) { - continue - } - value, err := strconv.Atoi(text[match[2]:match[3]]) - if err != nil || !looksLikeLooseEpisodeNumber(value) { - continue - } - return value - } - return 0 -} - -func isDecimalFractionMatch(text string, digitStart int) bool { - return digitStart >= 2 && text[digitStart-1] == '.' && text[digitStart-2] >= '0' && text[digitStart-2] <= '9' -} - -func looksLikeLooseEpisodeNumber(value int) bool { - switch { - case value <= 0, value > 200: - return false - default: - return true - } -} - -func episodeNumbersFromRefs(refs []episodeRef, fallbackSeason int) []int { - if len(refs) == 0 { - return nil - } - if fallbackSeason <= 0 { - fallbackSeason = refs[0].Season - } - out := make([]int, 0, len(refs)) - seen := map[int]struct{}{} - for _, ref := range refs { - season := ref.Season - if season <= 0 { - season = 1 - } - if fallbackSeason > 0 && season != fallbackSeason { - continue - } - if ref.Episode <= 0 { - continue - } - if _, ok := seen[ref.Episode]; ok { - continue - } - seen[ref.Episode] = struct{}{} - out = append(out, ref.Episode) - } - return out -} diff --git a/internal/service/subscription_candidate_defaults_test.go b/internal/service/subscription_candidate_defaults_test.go deleted file mode 100644 index 122b619..0000000 --- a/internal/service/subscription_candidate_defaults_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesRejectsKeywordOriginWithConflictingYear(t *testing.T) { - sub := &model.Subscription{ - Name: "玩具总动员 5 自动订阅", - Filter: "玩具总动员 5 2026", - MediaType: "movie", - Year: 2026, - } - results := []SearchResult{{ - Title: "Toy Story 4 2019 2160p DSNP WEB-DL", - DownloadURL: "https://pt/download/toy-story-4", - SearchKeyword: "玩具总动员 5", - Seeders: 90, - }} - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, LocalAvailability{}) - if len(got) != 0 { - t.Fatalf("selected %#v, want conflicting-year keyword-origin result rejected", got) - } - if stats.QueryMismatch != 1 || stats.Prepared != 0 { - t.Fatalf("stats = %#v, want query mismatch for conflicting year", stats) - } -} - -func TestSelectSiteSearchCandidatesDoesNotWashByDefault(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "resolution"} - results := []SearchResult{ - {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90}, - {Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/2160" { - t.Fatalf("selected %#v, want default best single result when wash disabled", got) - } -} - -func TestSelectSiteSearchCandidatesWashNeedsExplicitUpgradeCriteria(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashEnabled: true, WashPriority: "resolution"} - local := LocalAvailability{LocalMediaCount: 1, InLibrary: true} - results := []SearchResult{ - {Title: "Inception 2010 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/1080", Seeders: 90}, - {Title: "Inception 2010 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/2160", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local) - if len(got) != 0 { - t.Fatalf("selected %#v, want no wash download without explicit upgrade criteria", got) - } - - sub.Resolution = "2160p" - got = selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local) - if len(got) != 1 || got[0].Download != "https://pt/download/2160" { - t.Fatalf("selected %#v, want explicit 2160p wash candidate", got) - } -} - -func TestSelectSiteSearchCandidatesWashWithoutCriteriaUsesDefaultQuality(t *testing.T) { - sub := &model.Subscription{Name: "Dune 自动订阅", Filter: "Dune 2021", MediaType: "movie", WashEnabled: true, WashPriority: "quality"} - results := []SearchResult{ - {Title: "Dune 2021 2160p REMUX H264 AAC", DownloadURL: "https://pt/download/remux", Seeders: 80}, - {Title: "Dune 2021 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/webdl", Seeders: 60}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/webdl" { - t.Fatalf("selected %#v, want default compatible WEB-DL when wash has no explicit criteria", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultsToOnePreferredVersionPerEpisode(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"} - results := []SearchResult{ - {Title: "House of the Dragon S03E01 1080p HDTV", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 50000}, - {Title: "House of the Dragon S03E01 1080p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100}, - {Title: "House of the Dragon S03E01 2160p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80}, - {Title: "House of the Dragon S03E02 720p HDTV", DownloadURL: "https://pt/download/e02-hdtv", Seeders: 500}, - {Title: "House of the Dragon S03E02 1080p WEBRip", DownloadURL: "https://pt/download/e02-webrip", Seeders: 60}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 2 { - t.Fatalf("selected %d candidates, want one per episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-webdl-2160" { - t.Fatalf("episode 1 selected %q, want best WEB-DL version", got[0].Download) - } - if got[1].Download != "https://pt/download/e02-webrip" { - t.Fatalf("episode 2 selected %q, want WEBRip over high-seeder HDTV", got[1].Download) - } -} - -func TestSelectSiteSearchCandidatesDefaultQualityRecognizesWebDLVariants(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray", Seeders: 900}, - {Title: "Some Show S01E01 2160p WEB.DL H264 AAC", DownloadURL: "https://pt/download/e01-webdotdl", Seeders: 40}, - {Title: "Some Show S01E01 1080p WEB DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdotdl" { - t.Fatalf("selected %#v, want one best WEB-DL variant", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultPrefersWebDLBeforeResolution(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 2160p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray-2160", Seeders: 900}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl-1080" { - t.Fatalf("selected %#v, want one compatible WEB-DL version before higher-resolution BluRay", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultPrefersFreeWithinSameQualityBand(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-nonfree", Seeders: 5000}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free", Seeders: 80, Free: true}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-free" { - t.Fatalf("selected %#v, want free candidate within same quality/resolution band", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterQuality(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-free-hdtv", Seeders: 80, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" { - t.Fatalf("selected %#v, want WEB-DL quality to stay ahead of free HDTV", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterResolution(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true}, - {Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" { - t.Fatalf("selected %#v, want better resolution to stay ahead of free lower-resolution release", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultsToOneLooseNumberedEpisode(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show 01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000}, - {Title: "Some Show 01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100}, - {Title: "Some Show 01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 { - t.Fatalf("selected %d candidates, want one preferred loose-numbered episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-webdl-2160" || got[0].Episode != 1 { - t.Fatalf("selected %#v, want episode 1 best WEB-DL version", got) - } -} - -func TestSelectSiteSearchCandidatesDoesNotTreatTitleNumberAsLooseEpisode(t *testing.T) { - sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2 2023", MediaType: "tv"} - results := []SearchResult{ - {Title: "问心2 2023 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/season", Seeders: 100}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 { - t.Fatalf("selected %#v, want one fallback candidate", got) - } - if got[0].Episode != 0 { - t.Fatalf("episode = %d, want title number not treated as episode", got[0].Episode) - } -} - -func TestSelectSiteSearchCandidatesRejectsRiskyLabelsFromSiteResult(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", Labels: "HR", DownloadURL: "https://pt/download/e01-hr", Seeders: 900}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-safe", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-safe" { - t.Fatalf("selected %#v, want non-HR candidate only", got) - } -} - -func TestSelectSiteSearchCandidatesWashPriorityDoesNotLetFreeOverrideResolution(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv", WashEnabled: true, WashPriority: "resolution"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true}, - {Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" { - t.Fatalf("selected %#v, want wash resolution priority to stay ahead of free lower-resolution release", got) - } -} - -func TestSelectSiteSearchCandidatesRejectsDefaultCompatibilityVersions(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"} - results := []SearchResult{ - {Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900}, - {Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80}, - {Title: "House of the Dragon S03E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" { - t.Fatalf("selected %#v, want compatible WEB-DL only", got) - } -} - -func TestSelectSiteSearchCandidatesKeepsCompatibilityExcludesWithCustomExcludeWords(t *testing.T) { - sub := &model.Subscription{ - Name: "House of the Dragon 自动订阅", - Filter: "House of the Dragon", - MediaType: "tv", - ExcludeWords: "官中,无字幕", - } - results := []SearchResult{ - {Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900}, - {Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" { - t.Fatalf("selected %#v, want custom exclude words to keep default compatible WEB-DL guard", got) - } -} - -func TestSelectSiteSearchCandidatesAvoidsOverlappingEpisodeRanges(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv", WashEnabled: true, WashPriority: "quality"} - availability := LocalAvailability{ - LocalMediaCount: 1, - TotalEpisodes: 3, - MissingEpisodes: []int{1, 2, 3}, - ExistingEpisodeKeys: map[string]struct{}{}, - } - results := []SearchResult{ - {Title: "House of the Dragon S03E01-E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-e02-pack", Seeders: 90}, - {Title: "House of the Dragon S03E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e02-duplicate", Seeders: 80}, - {Title: "House of the Dragon S03E03 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e03", Seeders: 70}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 2 { - t.Fatalf("selected %d candidates, want pack plus non-overlapping episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-e02-pack" || got[1].Download != "https://pt/download/e03" { - t.Fatalf("selected %#v, want overlapping E02 duplicate skipped", got) - } -} - -func TestSelectSiteSearchCandidatesAppliesQualityRules(t *testing.T) { - sub := &model.Subscription{ - Name: "Dune 自动订阅", - Filter: "Dune 2021", - MediaType: "movie", - Resolution: "2160p", - Quality: "remux", - Effects: "hdr", - ExcludeWords: "cam,ts", - } - results := []SearchResult{ - {Title: "Dune 2021 2160p WEB-DL HDR", DownloadURL: "https://pt/download/web", Seeders: 100}, - {Title: "Dune 2021 2160p UHD BluRay REMUX HDR", DownloadURL: "https://pt/download/remux", Seeders: 30}, - {Title: "Dune 2021 2160p REMUX HDR CAM", DownloadURL: "https://pt/download/cam", Seeders: 200}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/remux" { - t.Fatalf("selected %#v, want filtered remux", got) - } -} diff --git a/internal/service/subscription_candidate_select.go b/internal/service/subscription_candidate_select.go deleted file mode 100644 index 187fa69..0000000 --- a/internal/service/subscription_candidate_select.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "sort" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func selectPreparedSubscriptionCandidates(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability) []siteSearchCandidate { - return selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, nil) -} - -func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability, stats *siteSearchSelectionStats) []siteSearchCandidate { - if len(candidates) > 1 { - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].Score != candidates[j].Score { - return candidates[i].Score > candidates[j].Score - } - if candidates[i].Item.Seeders != candidates[j].Item.Seeders { - return candidates[i].Item.Seeders > candidates[j].Item.Seeders - } - return candidates[i].Item.Size > candidates[j].Item.Size - }) - } - if len(candidates) == 0 { - return recordPreparedSelection(nil, stats) - } - - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - if !isSubscriptionSeriesType(mediaType) { - // 非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。 - if !subscriptionAllowsWash(sub) && local.LocalMediaCount > 0 { - if stats != nil { - stats.LocalAlreadySatisfied = true - } - return recordPreparedSelection(nil, stats) - } - return recordPreparedSelection(candidates[:1], stats) - } - - if localSeriesPackSatisfiesSubscription(local) { - if stats != nil { - stats.LocalSeriesPackPresent = true - } - return recordPreparedSelection(nil, stats) - } - if local.LocalMediaCount > 0 { - trustedTotal := trustedAvailabilityTotal(local) - if trustedTotal > 0 && len(local.MissingEpisodes) == 0 { - if stats != nil { - stats.SeriesComplete = true - } - return recordPreparedSelection(nil, stats) - } - missingSet := missingEpisodeSet(local) - onlyMissing := make([]siteSearchCandidate, 0, len(candidates)) - var packFallback *siteSearchCandidate - for i := range candidates { - candidate := candidates[i] - if candidate.Episode <= 0 { - // 整季/全集包(无单集号)。剧集完结后站点常只挂全集包, - // 这里记下来作兜底:当单集候选不足以补齐缺失集时启用, - // 否则"补全缺失集"在站点只有全集包时永远匹配为空。 - if stats != nil { - stats.NoEpisodeSkipped++ - } - if candidate.Pack && packFallback == nil { - packFallback = &candidates[i] - if stats != nil { - stats.PackFallbackAvailable = true - } - } - continue - } - season := candidate.Season - if season <= 0 { - season = 1 - } - if candidateEpisodesAllExist(local.ExistingEpisodeKeys, season, candidate) { - if stats != nil { - stats.ExistingEpisodeSkipped++ - } - continue - } - if trustedTotal > 0 && !candidateCoversMissingEpisode(candidate, missingSet) { - if stats != nil { - stats.NotMissingEpisodeSkipped++ - } - continue - } - onlyMissing = append(onlyMissing, candidate) - } - selected := sortedEpisodeCandidates(onlyMissing) - if len(selected) == 0 && packFallback != nil { - // 没有可用的单集候选,但站点有整季/全集包 → 用包兜底补缺集。 - // 代价是会重下已有集,但用户主动触发补全时这是可接受的。 - if stats != nil { - stats.PackFallbackUsed = true - } - return recordPreparedSelection([]siteSearchCandidate{*packFallback}, stats) - } - return recordPreparedSelection(selected, stats) - } - - for _, candidate := range candidates { - if candidate.Pack { - return recordPreparedSelection([]siteSearchCandidate{candidate}, stats) - } - } - - selected := sortedEpisodeCandidates(candidates) - if len(selected) == 0 { - return recordPreparedSelection(candidates[:1], stats) - } - return recordPreparedSelection(selected, stats) -} - -func candidateEpisodesAllExist(existing map[string]struct{}, season int, candidate siteSearchCandidate) bool { - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return false - } - for _, episode := range episodes { - if _, exists := existing[episodeKey(season, episode)]; !exists { - return false - } - } - return true -} - -func candidateCoversMissingEpisode(candidate siteSearchCandidate, missingSet map[int]struct{}) bool { - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return false - } - for _, episode := range episodes { - if _, missing := missingSet[episode]; missing { - return true - } - } - return false -} - -func candidateEpisodeNumbers(candidate siteSearchCandidate) []int { - if len(candidate.Episodes) > 0 { - return candidate.Episodes - } - if candidate.Episode > 0 { - return []int{candidate.Episode} - } - return nil -} - -func recordPreparedSelection(candidates []siteSearchCandidate, stats *siteSearchSelectionStats) []siteSearchCandidate { - if stats != nil { - stats.Selected = len(candidates) - } - return candidates -} - -func localSeriesPackSatisfiesSubscription(local LocalAvailability) bool { - if !local.HasSeriesPack { - return false - } - total := trustedAvailabilityTotal(local) - if total <= 0 { - return len(local.ExistingEpisodeKeys) == 0 - } - if len(local.MissingEpisodes) > 0 { - return false - } - return len(local.ExistingEpisodeKeys) >= total -} - -func trustedAvailabilityTotal(local LocalAvailability) int { - total := local.TotalEpisodes - if total <= 0 { - return 0 - } - if maxEpisode := maxAvailabilityEpisode(local.ExistingEpisodeKeys); maxEpisode > total { - return 0 - } - return total -} diff --git a/internal/service/subscription_candidate_selection_test.go b/internal/service/subscription_candidate_selection_test.go deleted file mode 100644 index 6fee3bc..0000000 --- a/internal/service/subscription_candidate_selection_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package service - -import ( - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesPrefersSeriesPack(t *testing.T) { - sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家 2022", MediaType: "tv"} - results := []SearchResult{ - {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 80}, - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 50}, - {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 70}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 { - t.Fatalf("selected %d candidates, want 1", len(got)) - } - if got[0].Download != "https://pt/download/pack" || !got[0].Pack { - t.Fatalf("selected %#v, want complete pack", got[0]) - } -} - -func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T) { - sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", WashEnabled: true, WashPriority: "resolution"} - results := []SearchResult{ - {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1a", Seeders: 90}, - {Title: "葬送的芙莉莲 S01E01 2160p", DownloadURL: "https://pt/download/1b", Seeders: 80}, - {Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 70}, - {Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 60}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 3 { - t.Fatalf("selected %d candidates, want 3", len(got)) - } - if got[0].Episode != 1 || got[1].Episode != 2 || got[2].Episode != 3 { - t.Fatalf("episodes = %d,%d,%d; want 1,2,3", got[0].Episode, got[1].Episode, got[2].Episode) - } - if got[0].Download != "https://pt/download/1b" { - t.Fatalf("duplicate episode should keep wash-priority best result, got %q", got[0].Download) - } -} - -func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"} - results := []SearchResult{ - {Title: "Inception 2010 1080p HDTV", DownloadURL: "https://pt/download/1080-hdtv", Seeders: 900}, - {Title: "Inception 2010 2160p WEB-DL", DownloadURL: "https://pt/download/2160-webdl", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/2160-webdl" { - t.Fatalf("selected %#v, want movie best only", got) - } -} - -func TestSelectSiteSearchCandidatesRejectsUnrelatedHighSeederResult(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"} - results := []SearchResult{ - {Title: "Unrelated Movie 2026 2160p", DownloadURL: "https://pt/download/wrong", Seeders: 999}, - {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want title-matched result only", got) - } -} - -func TestSelectSiteSearchCandidatesMatchesTranslatedSubtitle(t *testing.T) { - sub := &model.Subscription{Name: "真人快打2 自动订阅", Filter: "真人快打2 2026", MediaType: "movie", WashPriority: "seeders"} - results := []SearchResult{ - {Title: "Unrelated Movie 2026 2160p", DownloadURL: "https://pt/download/wrong", Seeders: 999}, - {Title: "Mortal Kombat II 2026 1080p WEB-DL", Subtitle: "真人快打2", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want translated subtitle match", got) - } -} - -func TestSelectSiteSearchCandidatesMatchesFeedAlias(t *testing.T) { - sub := &model.Subscription{ - Name: "真人快打2 自动订阅", - FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026", - Filter: "真人快打2 2026", - MediaType: "movie", - } - results := []SearchResult{ - {Title: "Mortal Kombat II 2026 1080p WEB-DL", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want alias-matched result", got) - } -} - -func TestSelectSiteSearchCandidatesMatchesSubscriptionOriginalNameAlias(t *testing.T) { - sub := &model.Subscription{ - Name: "玩具总动员 5 自动订阅", - Filter: "玩具总动员 5 2026", - OriginalName: "Toy Story 5", - Year: 2026, - MediaType: "movie", - } - results := []SearchResult{ - {Title: "Toy Story 5 2026 1080p WEB-DL", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want original-name alias match", got) - } -} - -func TestSelectSiteSearchCandidatesTrustsMatchedSearchKeyword(t *testing.T) { - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - TotalEpisodes: 33, - } - results := []SearchResult{{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/nanyang-29-33", - SearchKeyword: "南部档案 2026", - Seeders: 90, - }} - availability := LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - MissingEpisodes: []int{2, 3, 4, 5}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" || !got[0].Pack { - t.Fatalf("selected %#v, want English pack matched by Chinese search keyword", got) - } - if stats.QueryMismatch != 0 || stats.Prepared != 1 || stats.Selected != 1 { - t.Fatalf("stats = %#v, want keyword-origin match without query mismatch", stats) - } -} - -func TestDedupeSiteSearchResultsKeepsMatchedSearchKeyword(t *testing.T) { - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - TotalEpisodes: 33, - } - results := dedupeSiteSearchResults([]SearchResult{ - { - SiteID: "mteam", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/nanyang-29-33", - SearchKeyword: "Archives The Nanyang Mystery", - Seeders: 80, - Size: 1024, - }, - { - SiteID: "mteam", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/nanyang-29-33", - SearchKeyword: "南部档案 2026", - Seeders: 80, - Size: 1024, - }, - }) - if len(results) != 1 { - t.Fatalf("deduped results = %#v, want one merged result", results) - } - if !strings.Contains(results[0].SearchKeyword, "南部档案 2026") { - t.Fatalf("merged search keyword = %q, missing Chinese keyword", results[0].SearchKeyword) - } - availability := LocalAvailability{TotalEpisodes: 33, MissingEpisodes: []int{29, 30, 31, 32, 33}, ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}}} - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" { - t.Fatalf("selected %#v, want merged keyword candidate", got) - } - if stats.QueryMismatch != 0 || stats.Prepared != 1 { - t.Fatalf("stats = %#v, want merged keyword to avoid query mismatch", stats) - } -} diff --git a/internal/service/subscription_classifier.go b/internal/service/subscription_classifier.go deleted file mode 100644 index fd20a24..0000000 --- a/internal/service/subscription_classifier.go +++ /dev/null @@ -1,172 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) { - mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory) - category := strings.TrimSpace(sub.MediaCategory) - if category == "" { - if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: match.Title + " " + match.OriginalName, - Languages: match.Languages, - Countries: match.Countries, - Genres: match.Genres, - Category: sourceCategory, - }, s.categoryMap()) - if s != nil && s.log != nil && category != "" { - s.log.Info("subscription metadata classified", - zap.String("title", title), - zap.String("matched_title", match.Title), - zap.String("media_type", mediaType), - zap.String("media_category", category), - zap.Int("tmdb_id", match.TMDbID), - zap.Int("bangumi_id", match.BangumiID), - zap.String("douban_id", match.DoubanID), - zap.String("thetvdb_id", match.TheTVDBID)) - } - } - } - if category == "" { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: title + " " + sub.Name + " " + sub.Filter, - Category: sourceCategory, - }, s.categoryMap()) - } - return mediaType, category -} - -func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match { - if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() { - return nil - } - queries := subscriptionMetadataQueries(title, sub) - if len(queries) == 0 { - return nil - } - for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) { - lib := &model.Library{Type: libType, Enabled: true} - for _, query := range queries { - cleaned, year := CleanQueryWithRecognition(ctx, s.repo, query) - if cleaned == "" { - cleaned = strings.TrimSpace(query) - } - for _, candidate := range titleCandidates(cleaned) { - if candidate == "" { - continue - } - match := s.scraper.lookup(ctx, lib, nil, candidate, year) - if match == nil || strings.TrimSpace(match.Title) == "" { - continue - } - if !organizeMetadataMatchTrusted(candidate, year, match) { - continue - } - return match - } - } - } - return nil -} - -func subscriptionMetadataQueries(title string, sub *model.Subscription) []string { - seen := map[string]struct{}{} - out := make([]string, 0, 3) - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - key := strings.ToLower(value) - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - out = append(out, value) - } - add(title) - if sub != nil { - add(sub.Filter) - add(sub.Name) - } - return out -} - -func subscriptionMetadataLibraryTypes(mediaType, title string) []string { - if strings.TrimSpace(mediaType) == "" { - text := strings.ToLower(title) - switch { - case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text): - return []string{"tv", "anime", "movie"} - case containsAnyText(text, "动漫", "动画", "anime", "bangumi"): - return []string{"anime", "tv", "movie"} - case containsAnyText(text, "电影", "movie", "film"): - return []string{"movie", "tv", "anime"} - default: - return []string{"tv", "movie", "anime"} - } - } - switch normalizeMediaType(mediaType, title, "") { - case "movie": - return []string{"movie"} - case "anime": - return []string{"anime", "tv"} - case "tv", "variety": - return []string{"tv", "anime"} - default: - if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) { - return []string{"tv", "anime"} - } - return []string{"movie", "tv", "anime"} - } -} - -func (s *SubscriptionService) categoryMap() map[string]string { - if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil { - return nil - } - return s.cfg.Organizer.Categories -} - -func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string { - if sub == nil { - return "" - } - base := strings.TrimSpace(sub.SavePath) - if base == "" { - base = downloadDefaultSaveRoot(ctx, s.repo) - } - if base == "" { - return "" - } - if !s.isSmartClassifyEnabled(ctx) || category == "" { - return base - } - return downloadSavePathCategoryRoot(base, sanitizeFilename(category)) -} - -func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool { - if s != nil && s.repo != nil && s.repo.Setting != nil { - val, err := s.repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) - if err == nil && val != "" { - return parseBoolSetting(val, true) - } - val, err = s.repo.Setting.Get(ctx, "organizer.smart_classify") - if err == nil && parseBoolSetting(val, false) { - return true - } - } - if s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify { - return true - } - return true -} diff --git a/internal/service/subscription_delete.go b/internal/service/subscription_delete.go deleted file mode 100644 index 115f2fd..0000000 --- a/internal/service/subscription_delete.go +++ /dev/null @@ -1,131 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "gorm.io/gorm" -) - -func (s *SubscriptionService) deleteSubscriptionDownloads(ctx context.Context, sub *model.Subscription) error { - if s == nil || s.repo == nil || s.repo.Download == nil || sub == nil { - return nil - } - rows, err := s.repo.Download.List(ctx) - if err != nil { - return err - } - candidates := make([]model.DownloadTask, 0) - for _, row := range rows { - if subscriptionDeleteMatchesTask(ctx, s, sub, row) { - candidates = append(candidates, row) - } - } - if len(candidates) == 0 { - return nil - } - - var live []QBitTorrent - if s.downloads != nil { - live, _ = s.downloads.listLiveTorrents(ctx, "") - } - deletedHashes := map[string]struct{}{} - for _, task := range candidates { - hash := firstNonEmpty(task.ExternalID, downloadTaskInfoHash(task)) - clientID := strings.TrimSpace(task.DownloadClientID) - if matched, ok := matchingLiveTorrent(task, live); ok { - hash = firstNonEmpty(hash, matched.Hash) - clientID = firstNonEmpty(clientID, matched.ClientID) - } - if hash != "" && s.downloads != nil { - key := strings.ToLower(clientID + ":" + hash) - if _, ok := deletedHashes[key]; !ok { - if err := s.downloads.Delete(ctx, hash, false, clientID); err != nil { - return fmt.Errorf("删除订阅关联下载任务 %q 失败: %w", task.Title, err) - } - deletedHashes[key] = struct{}{} - } - continue - } - markDownloadTaskDeletedByID(ctx, s.repo.DB, task) - } - return nil -} - -func subscriptionDeleteMatchesTask(ctx context.Context, s *SubscriptionService, sub *model.Subscription, task model.DownloadTask) bool { - if strings.TrimSpace(task.Status) != "" && !downloadTaskBlocksReadd(task.Status) { - return false - } - if strings.TrimSpace(task.SubscriptionID) != "" { - return task.SubscriptionID == sub.ID - } - if strings.TrimSpace(sub.UserID) != "" && strings.TrimSpace(task.UserID) != "" && sub.UserID != task.UserID { - return false - } - baseSavePath := s.subscriptionBaseSavePath(ctx, sub) - if baseSavePath != "" && task.SavePath != "" && !sameOrChildPath(task.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, task.SavePath) { - return false - } - query := normalizeAvailabilityComparable(availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))) - if query == "" { - return false - } - title := normalizeAvailabilityComparable(task.Title) - if title == "" { - title = normalizeAvailabilityComparable(publicDownloadTitle(task.URL)) - } - return title != "" && (strings.Contains(title, query) || strings.Contains(query, title)) -} - -func downloadTaskInfoHash(task model.DownloadTask) string { - return torrentURLInfoHash(task.URL) -} - -func matchingLiveTorrentHash(task model.DownloadTask, live []QBitTorrent) string { - if torrent, ok := matchingLiveTorrent(task, live); ok { - return strings.TrimSpace(torrent.Hash) - } - return "" -} - -func matchingLiveTorrent(task model.DownloadTask, live []QBitTorrent) (QBitTorrent, bool) { - for _, torrent := range live { - if strings.TrimSpace(task.DownloadClientID) != "" && task.DownloadClientID != torrent.ClientID { - continue - } - if strings.TrimSpace(task.ExternalID) != "" && strings.EqualFold(task.ExternalID, torrent.Hash) { - return torrent, true - } - } - key := downloadTaskIdentityKey(task.Title) - if key == "" { - key = downloadTaskIdentityKey(publicDownloadTitle(task.URL)) - } - if key == "" { - return QBitTorrent{}, false - } - for _, torrent := range live { - current := downloadTaskIdentityKey(torrent.Name) - if current == "" { - continue - } - if current == key || strings.Contains(current, key) || strings.Contains(key, current) { - return torrent, true - } - } - return QBitTorrent{}, false -} - -func markDownloadTaskDeletedByID(ctx context.Context, db *gorm.DB, task model.DownloadTask) { - if db == nil || strings.TrimSpace(task.ID) == "" { - return - } - _ = db.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", task.ID). - Updates(map[string]any{ - "status": "deleted", - "progress": task.Progress, - }).Error -} diff --git a/internal/service/subscription_downloader_test.go b/internal/service/subscription_downloader_test.go deleted file mode 100644 index f273cfa..0000000 --- a/internal/service/subscription_downloader_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Deleted Downloader Show S01E01 1080p - deleted-downloader-episode-1 - magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Deleted+Downloader+Show+S01E01 - -`)) - })) - defer rss.Close() - - var qbCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&qbCalls, 1) - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "qB deleted", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil { - t.Fatal(err) - } - - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Deleted Downloader Show 自动订阅", - FeedURL: rss.URL, - Filter: "Deleted Downloader Show", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 0 { - t.Fatalf("queued = %d, want 0 when default downloader was deleted", queued) - } - if got := atomic.LoadInt32(&qbCalls); got != 0 { - t.Fatalf("qB calls = %d, want 0 after downloader deletion", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} diff --git a/internal/service/subscription_episode_queries.go b/internal/service/subscription_episode_queries.go deleted file mode 100644 index f5f2586..0000000 --- a/internal/service/subscription_episode_queries.go +++ /dev/null @@ -1,176 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func subscriptionTitleMatchesQuery(sub *model.Subscription, title string) bool { - if strings.TrimSpace(title) == "" { - return false - } - for _, query := range subscriptionTitleMatchQueries(sub) { - if strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) { - return true - } - } - return len(subscriptionTitleMatchQueries(sub)) == 0 -} - -func subscriptionSearchResultMatchesQuery(sub *model.Subscription, item SearchResult) bool { - matchText := subscriptionSearchResultText(item) - if subscriptionTitleMatchesQuery(sub, matchText) { - return true - } - if !subscriptionSearchResultYearCompatible(sub, matchText) { - return false - } - return subscriptionKeywordMatchesQuery(sub, item.SearchKeyword) -} - -func subscriptionSearchResultYearCompatible(sub *model.Subscription, title string) bool { - expected := subscriptionExpectedYear(sub) - if expected <= 0 { - return true - } - years := titleYears(title) - if len(years) == 0 { - return true - } - for _, year := range years { - if year == expected { - return true - } - } - return false -} - -func subscriptionExpectedYear(sub *model.Subscription) int { - if sub == nil { - return 0 - } - if sub.Year > 0 { - return sub.Year - } - for _, value := range []string{sub.Filter, sub.Name, sub.FeedURL} { - for _, year := range titleYears(value) { - return year - } - } - return 0 -} - -func titleYears(value string) []int { - matches := regexp.MustCompile(`\b(19\d{2}|20\d{2})\b`).FindAllString(value, -1) - if len(matches) == 0 { - return nil - } - out := make([]int, 0, len(matches)) - seen := map[int]struct{}{} - for _, match := range matches { - year, err := strconv.Atoi(match) - if err != nil { - continue - } - if _, ok := seen[year]; ok { - continue - } - seen[year] = struct{}{} - out = append(out, year) - } - return out -} - -func subscriptionKeywordMatchesQuery(sub *model.Subscription, keyword string) bool { - keyword = strings.TrimSpace(keyword) - if keyword == "" { - return false - } - normalizedKeyword := normalizeAvailabilityComparable(keyword) - if normalizedKeyword == "" { - return false - } - for _, query := range subscriptionTitleMatchQueries(sub) { - normalizedQuery := normalizeAvailabilityComparable(query) - if normalizedQuery == "" { - continue - } - if strings.Contains(normalizedKeyword, normalizedQuery) || strings.Contains(normalizedQuery, normalizedKeyword) { - return true - } - } - return len(subscriptionTitleMatchQueries(sub)) == 0 -} - -func subscriptionTitleMatchQueries(sub *model.Subscription) []string { - if sub == nil { - return nil - } - values := []string{ - availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), - cleanAvailabilityTitle(subscriptionFilter(sub)), - cleanAvailabilityTitle(subscriptionName(sub)), - } - for _, alias := range subscriptionFeedAliases(sub) { - values = append(values, alias, cleanAvailabilityTitle(alias)) - } - for _, alias := range subscriptionMetadataAliases(sub) { - values = append(values, alias, cleanAvailabilityTitle(alias)) - } - return compactUniqueStrings(values...) -} - -func subscriptionEpisodeMetadataQueries(sub *model.Subscription) []string { - if sub == nil { - return nil - } - raw := []string{ - siteSearchKeyword(sub), - sub.Filter, - sub.Name, - availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), - } - out := make([]string, 0, len(raw)*2) - for _, value := range raw { - value = cleanAvailabilityTitle(value) - if value == "" { - continue - } - if cleaned, _ := CleanQuery(value); cleaned != "" { - out = append(out, cleaned) - } - out = append(out, value) - } - return compactUniqueStrings(out...) -} - -func subscriptionExplicitTMDbID(sub *model.Subscription) int { - if sub == nil { - return 0 - } - values := []string{sub.Name, sub.Filter, sub.FeedURL} - for _, raw := range values { - for _, pattern := range []string{`(?i)\btmdb[_:\-\s=]+(\d{2,})`, `(?i)\btmdbid[_:\-\s=]+(\d{2,})`} { - if m := regexp.MustCompile(pattern).FindStringSubmatch(raw); len(m) >= 2 { - var id int - if _, err := fmt.Sscanf(m[1], "%d", &id); err == nil && id > 0 { - return id - } - } - } - if u, err := url.Parse(raw); err == nil { - for _, key := range []string{"tmdb_id", "tmdb", "tmdbid"} { - var id int - if _, err := fmt.Sscanf(u.Query().Get(key), "%d", &id); err == nil && id > 0 { - return id - } - } - } - } - return 0 -} diff --git a/internal/service/subscription_episode_totals.go b/internal/service/subscription_episode_totals.go deleted file mode 100644 index 500ecbd..0000000 --- a/internal/service/subscription_episode_totals.go +++ /dev/null @@ -1,197 +0,0 @@ -package service - -import ( - "context" - "regexp" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) updateSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, total int) { - if s == nil || s.repo == nil || s.repo.DB == nil || sub == nil || total <= sub.TotalEpisodes { - return - } - sub.TotalEpisodes = total - _ = s.repo.DB.WithContext(ctx).Model(sub).Update("total_episodes", total).Error -} - -func inferRSSTotalEpisodes(items []rssItem, sub *model.Subscription, filter *regexp.Regexp) int { - if !subscriptionShouldInferTotal(sub) { - return 0 - } - maxEpisode := 0 - for _, item := range items { - title := strings.TrimSpace(item.Title) - if title == "" { - continue - } - if filter != nil && !filter.MatchString(title) { - continue - } - if !subscriptionTitleMatchesQuery(sub, title) { - continue - } - if !matchesSubscriptionRules(sub, title) { - continue - } - _, episode := ParseEpisode(title) - if episode > maxEpisode { - maxEpisode = episode - } - } - return maxEpisode -} - -func inferSearchTotalEpisodes(results []SearchResult, sub *model.Subscription) int { - if !subscriptionShouldInferTotal(sub) { - return 0 - } - maxEpisode := 0 - for _, item := range results { - matchText := subscriptionSearchResultText(item) - if !subscriptionSearchResultMatchesQuery(sub, item) { - continue - } - if !matchesSubscriptionRules(sub, matchText) { - continue - } - _, episode := ParseEpisode(matchText) - if episode > maxEpisode { - maxEpisode = episode - } - } - return maxEpisode -} - -func subscriptionShouldInferTotal(sub *model.Subscription) bool { - if sub == nil { - return false - } - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - return isSubscriptionSeriesType(mediaType) -} - -func (s *SubscriptionService) resolveSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, fallback int) int { - if !subscriptionShouldInferTotal(sub) { - return 0 - } - if sub.TotalEpisodes > 0 { - return sub.TotalEpisodes - } - if total := s.resolveSubscriptionMetadataTotalEpisodes(ctx, sub); total > 0 { - return total - } - return fallback -} - -func (s *SubscriptionService) resolveSubscriptionMetadataTotalEpisodes(ctx context.Context, sub *model.Subscription) int { - if s == nil || s.scraper == nil || sub == nil { - return 0 - } - queries := subscriptionEpisodeMetadataQueries(sub) - - // Priority: TMDb -> Douban -> Bangumi -> TheTVDB -> Fanart -> title fallback. - // Fanart.tv is artwork-only in MediaStationGo, so it intentionally does not - // claim episode counts and lets the title fallback handle the final layer. - if s.scraper.tmdb != nil { - if id := subscriptionExplicitTMDbID(sub); id > 0 { - if total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, id); err == nil && total > 0 { - return total - } else if err != nil && s.log != nil { - s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", id), zap.Error(err)) - } - } - for _, query := range queries { - match, err := s.scraper.tmdb.SearchTV(ctx, query, 0) - if err != nil { - if s.log != nil { - s.log.Debug("subscription tmdb search failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if match == nil || match.TMDbID <= 0 { - continue - } - total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, match.TMDbID) - if err != nil { - if s.log != nil { - s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", match.TMDbID), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - if s.scraper.douban != nil { - for _, query := range queries { - total, err := s.scraper.douban.GetEpisodeCount(ctx, query) - if err != nil { - if s.log != nil { - s.log.Debug("subscription douban episode count failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - if s.scraper.bangumi != nil { - for _, query := range queries { - match, err := s.scraper.bangumi.Search(ctx, query) - if err != nil { - if s.log != nil { - s.log.Debug("subscription bangumi search failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if match == nil || match.BangumiID <= 0 { - continue - } - total, err := s.scraper.bangumi.GetEpisodeCount(ctx, match.BangumiID) - if err != nil { - if s.log != nil { - s.log.Debug("subscription bangumi episode count failed", zap.Int("bangumi_id", match.BangumiID), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - if s.scraper.thetvdb != nil { - for _, query := range queries { - match, err := s.scraper.thetvdb.SearchSeries(ctx, query) - if err != nil { - if s.log != nil { - s.log.Debug("subscription thetvdb search failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if match == nil || strings.TrimSpace(match.TheTVDBID) == "" { - continue - } - total, err := s.scraper.thetvdb.GetSeriesEpisodeCount(ctx, match.TheTVDBID) - if err != nil { - if s.log != nil { - s.log.Debug("subscription thetvdb episode count failed", zap.String("thetvdb_id", match.TheTVDBID), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - return 0 -} diff --git a/internal/service/subscription_episode_totals_test.go b/internal/service/subscription_episode_totals_test.go deleted file mode 100644 index c10b17e..0000000 --- a/internal/service/subscription_episode_totals_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestInferSubscriptionTotalEpisodesFromSearchAndRSS(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p"}, - {Title: "Some Show S01E12 1080p"}, - {Title: "Other Show S01E99 1080p"}, - } - if got := inferSearchTotalEpisodes(results, sub); got != 12 { - t.Fatalf("search inferred total = %d, want 12", got) - } - subtitleResults := []SearchResult{ - {Title: "Smoking Behind the Supermarket with You", Subtitle: "躲在超市后门抽烟的两人 S01E12"}, - } - subtitleSub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv"} - if got := inferSearchTotalEpisodes(subtitleResults, subtitleSub); got != 12 { - t.Fatalf("subtitle search inferred total = %d, want 12", got) - } - items := []rssItem{ - {Title: "Some Show S01E02 WEB-DL"}, - {Title: "Some Show S01E10 WEB-DL"}, - } - if got := inferRSSTotalEpisodes(items, sub, compileFilter("Some Show")); got != 10 { - t.Fatalf("rss inferred total = %d, want 10", got) - } -} - -func TestResolveSubscriptionTotalEpisodesPrefersTMDbOverTitleFallback(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/search/tv": - _, _ = w.Write([]byte(`{"results":[{"id":42,"name":"Some Show","first_air_date":"2026-01-01"}]}`)) - case "/tv/42": - _, _ = w.Write([]byte(`{"number_of_episodes":13}`)) - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test" - cfg.Secrets.TMDbAPIProxy = upstream.URL - tmdb := NewTMDbProvider(cfg, zap.NewNop(), nil) - svc := NewSubscriptionService(cfg, zap.NewNop(), nil, nil, nil, NewHub(zap.NewNop())) - svc.SetScraper(NewScraperService(cfg, zap.NewNop(), nil, tmdb, nil, nil, nil, NewHub(zap.NewNop()))) - - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - if got := svc.resolveSubscriptionTotalEpisodes(t.Context(), sub, 10); got != 13 { - t.Fatalf("resolved total = %d, want TMDb total 13", got) - } -} diff --git a/internal/service/subscription_exclude_rules.go b/internal/service/subscription_exclude_rules.go deleted file mode 100644 index 90cd4fe..0000000 --- a/internal/service/subscription_exclude_rules.go +++ /dev/null @@ -1,187 +0,0 @@ -package service - -import ( - "strings" - "unicode" -) - -// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。 -// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。 -const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample,hr,h&r,hit and run,hit&run,hit-and-run,禁转,禁止转载,禁下,禁止下载" - -// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。 -// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。 -const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3" - -func containsAnyToken(titleFold, csv string) bool { - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token != "" && strings.Contains(titleFold, token) { - return true - } - } - return false -} - -// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts" -// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。 -func containsAnyExcludeToken(titleFold, csv string) bool { - for _, token := range excludeWordTokens(csv) { - if matchesExcludeToken(titleFold, token) { - return true - } - } - return false -} - -func excludeWordTokens(csv string) []string { - parts := make([]string, 0) - for _, token := range strings.FieldsFunc(strings.ToLower(csv), isExcludeSeparator) { - token = strings.TrimSpace(token) - if token == "" { - continue - } - parts = append(parts, token) - if shouldExpandDottedExcludeToken(token) { - parts = append(parts, dottedExcludeTokenParts(token)...) - } - } - return parts -} - -func isExcludeSeparator(r rune) bool { - switch r { - case ',', '/', '|', ';', ',', '、', '\n', '\r', '\t': - return true - default: - return false - } -} - -func shouldExpandDottedExcludeToken(token string) bool { - return strings.Count(token, ".") >= 2 -} - -func dottedExcludeTokenParts(token string) []string { - rawParts := strings.Split(token, ".") - parts := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - part = strings.TrimSpace(part) - if len(part) < 2 || isDigitsOnly(part) { - continue - } - parts = append(parts, part) - } - return parts -} - -func isDigitsOnly(value string) bool { - if value == "" { - return false - } - for _, r := range value { - if !unicode.IsDigit(r) { - return false - } - } - return true -} - -func matchesExcludeToken(titleFold, token string) bool { - if token == "" { - return false - } - if isASCIIWordToken(token) { - return matchesWordBoundary(titleFold, token) || matchesReleasePrefixToken(titleFold, token) - } - return strings.Contains(titleFold, token) -} - -func isASCIIWordToken(token string) bool { - for _, r := range token { - if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) { - return false - } - } - return token != "" -} - -// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。 -func matchesWordBoundary(titleFold, token string) bool { - from := 0 - for { - idx := strings.Index(titleFold[from:], token) - if idx < 0 { - return false - } - start := from + idx - end := start + len(token) - leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1]) - rightOK := end >= len(titleFold) || !isASCIIAlnumByte(titleFold[end]) - if leftOK && rightOK { - return true - } - from = start + 1 - if from >= len(titleFold) { - return false - } - } -} - -func matchesReleasePrefixToken(titleFold, token string) bool { - if !isReleasePrefixExcludeToken(token) { - return false - } - from := 0 - for { - idx := strings.Index(titleFold[from:], token) - if idx < 0 { - return false - } - start := from + idx - end := start + len(token) - leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1]) - if leftOK && releasePrefixSuffixOK(token, titleFold[end:]) { - return true - } - from = start + 1 - if from >= len(titleFold) { - return false - } - } -} - -func isReleasePrefixExcludeToken(token string) bool { - switch token { - case "ddp", "dolby": - return true - default: - return false - } -} - -func releasePrefixSuffixOK(token, suffix string) bool { - if suffix == "" { - return false - } - switch token { - case "ddp": - return isASCIIDigitByte(suffix[0]) - case "dolby": - return strings.HasPrefix(suffix, "vision") || - strings.HasPrefix(suffix, "atmos") || - strings.HasPrefix(suffix, "digital") - default: - return false - } -} - -func isASCIIAlnumByte(b byte) bool { - return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || isASCIIDigitByte(b) -} - -func isASCIIDigitByte(b byte) bool { - return b >= '0' && b <= '9' -} diff --git a/internal/service/subscription_guid.go b/internal/service/subscription_guid.go deleted file mode 100644 index 08a474b..0000000 --- a/internal/service/subscription_guid.go +++ /dev/null @@ -1,69 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "strings" -) - -func stableRSSItemGUID(title, guid, link, enclosureURL string) string { - parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))} - for _, raw := range []string{guid, enclosureURL, link} { - if key := stableDownloadURLKey(raw); key != "" { - parts = append(parts, key) - return strings.Join(parts, "|") - } - if raw = strings.TrimSpace(raw); raw != "" { - parts = append(parts, strings.ToLower(raw)) - return strings.Join(parts, "|") - } - } - return strings.Join(parts, "|") -} - -func stableSiteSearchGUID(item SearchResult, download string) string { - parts := []string{ - "site", - strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))), - strings.ToLower(strings.TrimSpace(item.Category)), - strings.ToLower(strings.TrimSpace(item.Title)), - fmt.Sprintf("%d", item.Size), - } - if key := stableDownloadURLKey(download); key != "" { - parts = append(parts, key) - } - return strings.Join(parts, "|") -} - -func stableDownloadURLKey(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - u, err := url.Parse(raw) - if err != nil { - return strings.ToLower(raw) - } - if strings.EqualFold(u.Scheme, "magnet") { - xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt"))) - if xt != "" { - return "magnet:" + xt - } - return strings.ToLower(raw) - } - if u.Host == "" { - return strings.ToLower(raw) - } - q := u.Query() - kept := make([]string, 0, 4) - for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} { - if value := strings.TrimSpace(q.Get(key)); value != "" { - kept = append(kept, key+"="+strings.ToLower(value)) - } - } - base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/")) - if len(kept) > 0 { - return base + "?" + strings.Join(kept, "&") - } - return base -} diff --git a/internal/service/subscription_identity.go b/internal/service/subscription_identity.go deleted file mode 100644 index c11dc6b..0000000 --- a/internal/service/subscription_identity.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -var ErrSubscriptionAlreadyExists = errors.New("subscription already exists") - -type SubscriptionAlreadyExistsError struct { - ExistingID string -} - -func (e *SubscriptionAlreadyExistsError) Error() string { - if e == nil || e.ExistingID == "" { - return ErrSubscriptionAlreadyExists.Error() - } - return fmt.Sprintf("%s: %s", ErrSubscriptionAlreadyExists, e.ExistingID) -} - -func (e *SubscriptionAlreadyExistsError) Unwrap() error { - return ErrSubscriptionAlreadyExists -} - -func newSubscriptionAlreadyExistsError(existingID string) error { - return &SubscriptionAlreadyExistsError{ExistingID: existingID} -} - -func SubscriptionAlreadyExistsID(err error) string { - var conflict *SubscriptionAlreadyExistsError - if errors.As(err, &conflict) && conflict != nil { - return conflict.ExistingID - } - return "" -} - -func (s *SubscriptionService) subscriptionDuplicate(ctx context.Context, sub *model.Subscription, excludeID string) (*model.Subscription, error) { - if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil { - return nil, nil - } - return s.repo.Subscription.FindActiveByIdentity(ctx, sub.UserID, sub.IdentityKey, excludeID) -} - -// Update applies API patch fields while recomputing the functional identity. -// The database partial unique index remains the final concurrency guard. -func (s *SubscriptionService) Update(ctx context.Context, id string, updates map[string]any) error { - if s == nil || s.repo == nil || s.repo.DB == nil { - return errors.New("subscription service unavailable") - } - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { - return err - } - raw, err := json.Marshal(updates) - if err != nil { - return err - } - if err := json.Unmarshal(raw, &sub); err != nil { - return err - } - if sub.Name == "" || sub.FeedURL == "" { - return errors.New("name and feed_url required") - } - normalizeSubscriptionDefaults(&sub) - model.RefreshSubscriptionIdentity(&sub) - if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil { - return err - } else if duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - - updates["search_mode"] = sub.SearchMode - updates["resolution"] = sub.Resolution - updates["wash_priority"] = sub.WashPriority - updates["priority"] = sub.Priority - updates["identity_key"] = sub.IdentityKey - if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}). - Where("id = ?", sub.ID).Updates(updates).Error; err != nil { - if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - return err - } - return nil -} diff --git a/internal/service/subscription_identity_test.go b/internal/service/subscription_identity_test.go deleted file mode 100644 index ca9bcb6..0000000 --- a/internal/service/subscription_identity_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package service - -import ( - "errors" - "path/filepath" - "sync" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/database" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionCreateRejectsConcurrentDuplicateRules(t *testing.T) { - cfg := &config.Config{} - cfg.Database.Type = "sqlite" - cfg.Database.DBPath = filepath.Join(t.TempDir(), "subscriptions.db") - cfg.Database.WALMode = true - cfg.Database.BusyTimeout = 5000 - cfg.Database.MaxOpenConns = 4 - db, err := database.Open(cfg, zap.NewNop()) - if err != nil { - t.Fatal(err) - } - if sqlDB, dbErr := db.DB(); dbErr == nil { - t.Cleanup(func() { _ = sqlDB.Close() }) - } - if err := database.AutoMigrate(db); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - - start := make(chan struct{}) - results := make(chan error, 2) - var wg sync.WaitGroup - for i := 0; i < 2; i++ { - wg.Add(1) - go func() { - defer wg.Done() - <-start - results <- svc.Create(t.Context(), &model.Subscription{ - UserID: "user-1", - Name: "Example Show", - FeedURL: "site-search://search?keyword=Example+Show", - Filter: "Example Show", - MediaType: "tv", - Resolution: "1080p", - Enabled: true, - }) - }() - } - close(start) - wg.Wait() - close(results) - - var created, conflicts int - for createErr := range results { - switch { - case createErr == nil: - created++ - case errors.Is(createErr, ErrSubscriptionAlreadyExists): - conflicts++ - default: - t.Fatalf("unexpected create error: %v", createErr) - } - } - if created != 1 || conflicts != 1 { - t.Fatalf("created=%d conflicts=%d, want 1/1", created, conflicts) - } - var count int64 - if err := db.Model(&model.Subscription{}).Where("archived_at IS NULL").Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("active subscription count = %d, want 1", count) - } -} - -func TestSubscriptionUpdateAndRestoreReturnExistingConflict(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - if err := database.AutoMigrate(db); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - first := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "1080p", Enabled: true} - second := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "2160p", Enabled: true} - if err := svc.Create(t.Context(), first); err != nil { - t.Fatal(err) - } - if err := svc.Create(t.Context(), second); err != nil { - t.Fatal(err) - } - if err := svc.Update(t.Context(), second.ID, map[string]any{"resolution": "1080p"}); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID { - t.Fatalf("update error = %v, want conflict with %s", err, first.ID) - } - - archivedAt := time.Now() - archived := *first - archived.ID = "" - archived.ArchivedAt = &archivedAt - archived.Enabled = false - if err := repos.Subscription.Create(t.Context(), &archived); err != nil { - t.Fatal(err) - } - if _, err := svc.Restore(t.Context(), archived.ID); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID { - t.Fatalf("restore error = %v, want conflict with %s", err, first.ID) - } -} diff --git a/internal/service/subscription_logging.go b/internal/service/subscription_logging.go deleted file mode 100644 index 85973a4..0000000 --- a/internal/service/subscription_logging.go +++ /dev/null @@ -1,54 +0,0 @@ -package service - -import ( - "net/url" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func subscriptionRunLogFields(sub *model.Subscription) []zap.Field { - fields := []zap.Field{} - if sub == nil { - return fields - } - return append(fields, - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("feed_kind", subscriptionFeedKind(sub.FeedURL)), - zap.String("filter", sub.Filter), - zap.String("media_type", sub.MediaType), - zap.String("media_category", sub.MediaCategory), - zap.String("search_mode", sub.SearchMode), - zap.Bool("enabled", sub.Enabled), - zap.Bool("wash_enabled", sub.WashEnabled), - zap.String("wash_priority", sub.WashPriority), - zap.Int("total_episodes", sub.TotalEpisodes), - ) -} - -func appendSubscriptionRunResultFields(fields []zap.Field, queued int, started time.Time) []zap.Field { - return append(fields, - zap.Int("queued", queued), - zap.Int64("duration_ms", time.Since(started).Milliseconds()), - ) -} - -func subscriptionFeedKind(feedURL string) string { - raw := strings.TrimSpace(feedURL) - if raw == "" { - return "empty" - } - lower := strings.ToLower(raw) - if strings.HasPrefix(lower, "site-search://") { - return "site-search" - } - parsed, err := url.Parse(raw) - if err == nil && parsed.Scheme != "" { - return parsed.Scheme - } - return "unknown" -} diff --git a/internal/service/subscription_metadata_prepare.go b/internal/service/subscription_metadata_prepare.go deleted file mode 100644 index 91cd7ef..0000000 --- a/internal/service/subscription_metadata_prepare.go +++ /dev/null @@ -1,110 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) prepareSubscriptionForRun(ctx context.Context, sub *model.Subscription) { - if s == nil || sub == nil { - return - } - normalizeSubscriptionDefaults(sub) - updates := map[string]any{} - s.fillSubscriptionRunMetadata(ctx, sub, updates) - if identityKey := model.SubscriptionIdentityKey(sub); identityKey != sub.IdentityKey { - sub.IdentityKey = identityKey - updates["identity_key"] = identityKey - } - if len(updates) > 0 && s.repo != nil && s.repo.DB != nil { - if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", sub.ID).Updates(updates).Error; err != nil && s.log != nil { - s.log.Debug("subscription metadata prepare persist failed", zap.String("id", sub.ID), zap.Error(err)) - } - } -} - -func (s *SubscriptionService) fillSubscriptionRunMetadata(ctx context.Context, sub *model.Subscription, updates map[string]any) { - if sub == nil { - return - } - if needsSubscriptionMetadataLookup(sub) { - query := subscriptionMetadataPrepareQuery(sub) - lookupCtx, cancel := context.WithTimeout(ctx, 12*time.Second) - defer cancel() - if match := s.lookupSubscriptionMetadata(lookupCtx, strings.TrimSpace(sub.MediaType), query, sub); match != nil { - applySubscriptionMetadataMatch(sub, match, updates) - } - } - if isSubscriptionSeriesType(strings.TrimSpace(sub.MediaType)) && sub.TotalEpisodes <= 0 { - if total := s.resolveSubscriptionTotalEpisodes(ctx, sub, 0); total > 0 { - sub.TotalEpisodes = total - updates["total_episodes"] = total - } - } -} - -func needsSubscriptionMetadataLookup(sub *model.Subscription) bool { - if sub == nil { - return false - } - return strings.TrimSpace(sub.MediaType) == "" || - strings.TrimSpace(sub.OriginalName) == "" || - sub.Year <= 0 -} - -func subscriptionMetadataPrepareQuery(sub *model.Subscription) string { - if sub == nil { - return "" - } - if value := strings.TrimSpace(sub.Filter); value != "" { - return value - } - return strings.TrimSpace(sub.Name) -} - -func applySubscriptionMetadataMatch(sub *model.Subscription, match *Match, updates map[string]any) { - if sub == nil || match == nil { - return - } - if strings.TrimSpace(sub.MediaType) == "" { - if mediaType := normalizeMetadataMatchSubscriptionType(match); mediaType != "" { - sub.MediaType = mediaType - updates["media_type"] = mediaType - } - } - if strings.TrimSpace(sub.OriginalName) == "" { - if value := strings.TrimSpace(match.OriginalName); value != "" { - sub.OriginalName = value - updates["original_name"] = value - } - } - if sub.Year <= 0 && match.Year > 0 { - sub.Year = match.Year - updates["year"] = match.Year - } -} - -func normalizeMetadataMatchSubscriptionType(match *Match) string { - if match == nil { - return "" - } - switch normalizeOrganizeMediaType(match.MediaType) { - case "movie": - return "movie" - case "tv": - return "tv" - case "anime": - return "anime" - case "variety": - return "variety" - case "adult": - return "adult" - default: - return "" - } -} diff --git a/internal/service/subscription_metadata_prepare_test.go b/internal/service/subscription_metadata_prepare_test.go deleted file mode 100644 index 6aeeecb..0000000 --- a/internal/service/subscription_metadata_prepare_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestPrepareSubscriptionForRunFillsSeriesMetadata(t *testing.T) { - var searchedTV bool - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/search/tv": - searchedTV = true - _ = json.NewEncoder(w).Encode(map[string]any{ - "results": []map[string]any{{ - "id": 12345, - "name": "南部档案", - "original_name": "Archives The Nanyang Mystery", - "original_language": "zh", - "origin_country": []string{"CN"}, - "first_air_date": "2026-01-01", - }}, - }) - case "/tv/12345": - _ = json.NewEncoder(w).Encode(map[string]any{"number_of_episodes": 33}) - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = upstream.URL - repos := repository.New(newServiceTestDB(t, &model.Subscription{})) - scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) - svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, nil) - svc.SetScraper(scraper) - - sub := model.Subscription{Name: "南部档案 自动订阅", FeedURL: "site-search://search?keyword=南部档案", Filter: "南部档案 2026", Enabled: true} - if err := repos.Subscription.Create(t.Context(), &sub); err != nil { - t.Fatal(err) - } - - svc.prepareSubscriptionForRun(t.Context(), &sub) - if !searchedTV { - t.Fatal("blank media type subscription should try TV metadata before defaulting to movie") - } - if sub.MediaType != "tv" || sub.OriginalName != "Archives The Nanyang Mystery" || sub.Year != 2026 || sub.TotalEpisodes != 33 { - t.Fatalf("prepared subscription = %#v, want tv metadata with total episodes", sub) - } - - var stored model.Subscription - if err := repos.DB.First(&stored, "id = ?", sub.ID).Error; err != nil { - t.Fatal(err) - } - if stored.MediaType != "tv" || stored.OriginalName != "Archives The Nanyang Mystery" || stored.Year != 2026 || stored.TotalEpisodes != 33 { - t.Fatalf("stored subscription = %#v, want persisted metadata", stored) - } -} - -func TestSubscriptionMetadataLibraryTypesSearchesTVForBlankType(t *testing.T) { - got := subscriptionMetadataLibraryTypes("", "南部档案 2026") - if len(got) < 2 || got[0] != "tv" || got[1] != "movie" { - t.Fatalf("library types = %#v, want tv before movie for blank subscription type", got) - } -} diff --git a/internal/service/subscription_notification.go b/internal/service/subscription_notification.go deleted file mode 100644 index 5a55db7..0000000 --- a/internal/service/subscription_notification.go +++ /dev/null @@ -1,73 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) notifySubscriptionHit(sub *model.Subscription, queued int, resources []string) { - if s == nil || s.notify == nil || sub == nil || queued <= 0 { - return - } - body := fmt.Sprintf("订阅:%s\n新增资源:%d", sub.Name, queued) - if len(resources) > 0 { - body += "\n资源:\n- " + strings.Join(resources, "\n- ") - } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - data := map[string]interface{}{} - if strings.TrimSpace(sub.PosterURL) != "" { - data["poster_url"] = sub.PosterURL - } - if strings.TrimSpace(sub.BackdropURL) != "" { - data["backdrop_url"] = sub.BackdropURL - } - if strings.TrimSpace(sub.MediaType) != "" { - data["media_type"] = sub.MediaType - } - if strings.TrimSpace(sub.MediaCategory) != "" { - data["media_category"] = sub.MediaCategory - } - // 补充媒体通知模板(formatTelegramMediaNotification)所需字段:片名 / 原名 / - // 语言 / 年份 / 评分 / 类型 / 简介 / 外链 / 资源标题(供模板提取季集 + 版本)。 - // 仅填现成可用的,缺失项模板会自动略过。 - if strings.TrimSpace(sub.Name) != "" { - data["title"] = sub.Name - } - if strings.TrimSpace(sub.OriginalName) != "" { - data["original_title"] = sub.OriginalName - } - if strings.TrimSpace(sub.OriginalLanguage) != "" { - data["original_language"] = sub.OriginalLanguage - } - if sub.Year > 0 { - data["year"] = sub.Year - } - if sub.Rating > 0 { - data["rating"] = sub.Rating - } - if strings.TrimSpace(sub.Genres) != "" { - data["genres"] = sub.Genres - } - if strings.TrimSpace(sub.Overview) != "" { - data["overview"] = sub.Overview - } - if id := strings.TrimSpace(sub.IMDBID); id != "" { - data["imdb_url"] = "https://www.imdb.com/title/" + id + "/" - } - if len(resources) > 0 { - data["resource_title"] = resources[0] - } - s.notify.BroadcastEvent(ctx, NotifyEvent{ - Type: EventSubscriptionHit, - Title: "MediaStationGo 订阅命中新资源", - Message: body, - Data: data, - }) - }() -} diff --git a/internal/service/subscription_pack.go b/internal/service/subscription_pack.go deleted file mode 100644 index 02bf2f4..0000000 --- a/internal/service/subscription_pack.go +++ /dev/null @@ -1,23 +0,0 @@ -package service - -import ( - "regexp" - "strings" -) - -var ( - seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) - seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) -) - -func isSeriesPackTitle(title string) bool { - title = strings.TrimSpace(title) - if title == "" { - return false - } - if seriesPackRE.MatchString(title) { - return true - } - _, episode := ParseEpisode(title) - return episode == 0 && seasonOnlyRE.MatchString(title) -} diff --git a/internal/service/subscription_pending_availability_test.go b/internal/service/subscription_pending_availability_test.go deleted file mode 100644 index 5bb621f..0000000 --- a/internal/service/subscription_pending_availability_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionPendingDownloadAvailabilitySkipsUnorganizedEpisodes(t *testing.T) { - root := t.TempDir() - seasonDir := filepath.Join(root, "间谍过家家", "Season 01") - if err := os.MkdirAll(seasonDir, 0o755); err != nil { - t.Fatal(err) - } - for _, name := range []string{ - "间谍过家家 - S01E01.mkv", - "间谍过家家 - S01E02.mkv.!qB", - } { - if err := os.WriteFile(filepath.Join(seasonDir, name), []byte("video"), 0o644); err != nil { - t.Fatal(err) - } - } - - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: root, - TotalEpisodes: 3, - } - svc := NewSubscriptionService(nil, nil, nil, nil, nil, nil) - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 2 { - t.Fatalf("downloaded episodes = %d, want 2", availability.DownloadedEpisodes) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 1)]; !ok { - t.Fatalf("missing pending E01 key: %#v", availability.ExistingEpisodeKeys) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok { - t.Fatalf("missing pending E02 key: %#v", availability.ExistingEpisodeKeys) - } - - results := []SearchResult{ - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only not-yet-downloaded episode 3", got) - } - if !svc.downloadPathHasCandidate(t.Context(), sub, "间谍过家家 S01E02 1080p", root) { - t.Fatal("expected existing pending E02 file to be detected") - } - if svc.downloadPathHasCandidate(t.Context(), sub, "间谍过家家 S01E03 1080p", root) { - t.Fatal("did not expect missing E03 to be detected") - } -} - -func TestSubscriptionPendingDownloadAvailabilityIncludesQueuedTasks(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:2222222222222222222222222222222222222222", - Title: "间谍过家家 S01E02 1080p", - SavePath: "/downloads/tv", - Status: "queued", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 3, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 1 { - t.Fatalf("downloaded episodes = %d, want 1", availability.DownloadedEpisodes) - } - if availability.InLibrary { - t.Fatal("queued download should not be reported as already in library") - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok { - t.Fatalf("missing queued E02 key: %#v", availability.ExistingEpisodeKeys) - } - - results := []SearchResult{ - {Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only not-yet-downloaded episode 3", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForTasks(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "https://pt/download/7-8", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案 2026", - OriginalName: "Archives The Nanyang Mystery", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 2 { - t.Fatalf("downloaded episodes = %d, want 2", availability.DownloadedEpisodes) - } - for _, episode := range []int{7, 8} { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("missing pending E%02d key: %#v", episode, availability.ExistingEpisodeKeys) - } - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/7-8", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want existing alias range to satisfy E07-E08", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForLiveTorrents(t *testing.T) { - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL","save_path":"/downloads/tv","state":"downloading","progress":0.3}]`)) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案 2026", - OriginalName: "Archives The Nanyang Mystery", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 5 { - t.Fatalf("downloaded episodes = %d, want 5", availability.DownloadedEpisodes) - } - for _, episode := range []int{29, 30, 31, 32, 33} { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("missing live E%02d key: %#v", episode, availability.ExistingEpisodeKeys) - } - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want existing live alias range to satisfy E29-E33", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityIncludesLinkedAliasTask(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-qiao-chu"}, - Name: "翘楚 S01E06 自动订阅", - Filter: "翘楚 S01E06", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 24, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt/download/21", - Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 21)]; !ok { - t.Fatalf("missing linked alias E21 key: %#v", availability.ExistingEpisodeKeys) - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", DownloadURL: "https://pt/download/21", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want linked alias task to satisfy E21", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilitySkipsStaleTaskMissingFromQB(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-nanyang"}, - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt/download/stale", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - Progress: 0, - }); err != nil { - t.Fatal(err) - } - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.recordLiveTorrentSnapshot(nil) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 0 { - t.Fatalf("downloaded episodes = %d, want stale task not counted", availability.DownloadedEpisodes) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 7)]; ok { - t.Fatalf("stale E07 task should not count as available: %#v", availability.ExistingEpisodeKeys) - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/7-8", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 7 { - t.Fatalf("selected %#v, want stale missing range to be eligible", got) - } -} diff --git a/internal/service/subscription_planner.go b/internal/service/subscription_planner.go deleted file mode 100644 index b1ebf7f..0000000 --- a/internal/service/subscription_planner.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package service — subscription planning and release candidate selection. -package service - -import "github.com/ShukeBta/MediaStationGo/internal/model" - -type siteSearchCandidate struct { - Item SearchResult - Download string - GUID string - Season int - Episode int - Episodes []int - Pack bool - Score int -} - -type siteSearchSelectionStats struct { - Total int - QueryMismatch int - QueryMismatchExamples []string - RelaxedQueryMatch int - RuleMismatch int - MissingDownload int - Seen int - Prepared int - Selected int - LocalAlreadySatisfied bool - LocalSeriesPackPresent bool - SeriesComplete bool - ExistingEpisodeSkipped int - NotMissingEpisodeSkipped int - NoEpisodeSkipped int - PackFallbackAvailable bool - PackFallbackUsed bool -} - -// SubscriptionPlanner owns release selection decisions for subscriptions: -// rule matching, candidate scoring, and filtering against known availability. -type SubscriptionPlanner struct{} - -func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate { - return SubscriptionPlanner{}.SelectSiteSearchCandidates(results, sub, seenSet, availability...) -} - -func (SubscriptionPlanner) SelectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate { - if sub == nil { - return nil - } - if seenSet == nil { - seenSet = map[string]struct{}{} - } - local := LocalAvailability{} - if len(availability) > 0 { - local = availability[0] - } - candidates, _ := selectSiteSearchCandidatesWithStats(results, sub, seenSet, local) - return candidates -} - -func selectSiteSearchCandidatesWithAvailability(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate { - candidates, _ := selectSiteSearchCandidatesWithStats(results, sub, seenSet, local) - return candidates -} - -func selectSiteSearchCandidatesWithStats(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) ([]siteSearchCandidate, siteSearchSelectionStats) { - stats := siteSearchSelectionStats{Total: len(results)} - if sub == nil { - return nil, stats - } - if seenSet == nil { - seenSet = map[string]struct{}{} - } - candidates := collectSiteSearchCandidates(results, sub, seenSet, false, &stats) - if len(candidates) == 0 && shouldRelaxSiteSearchQueryMatch(sub, local) && stats.QueryMismatch > 0 { - relaxedStats := siteSearchSelectionStats{Total: len(results)} - candidates = collectSiteSearchCandidates(results, sub, seenSet, true, &relaxedStats) - stats.RuleMismatch = relaxedStats.RuleMismatch - stats.MissingDownload = relaxedStats.MissingDownload - stats.Seen = relaxedStats.Seen - stats.Prepared = relaxedStats.Prepared - stats.RelaxedQueryMatch = relaxedStats.RelaxedQueryMatch - } - selected := selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, &stats) - return selected, stats -} diff --git a/internal/service/subscription_rss.go b/internal/service/subscription_rss.go deleted file mode 100644 index f765551..0000000 --- a/internal/service/subscription_rss.go +++ /dev/null @@ -1,79 +0,0 @@ -package service - -import ( - "context" - "encoding/xml" - "fmt" - "io" - "net/http" - "regexp" - "strings" -) - -// rssFeed is the minimal RSS subset we need to decode. -type rssFeed struct { - XMLName xml.Name `xml:"rss"` - Channel struct { - Items []rssItem `xml:"item"` - } `xml:"channel"` -} - -type rssItem struct { - Title string `xml:"title"` - Link string `xml:"link"` - GUID string `xml:"guid"` - Description string `xml:"description"` - Enclosure struct { - URL string `xml:"url,attr"` - } `xml:"enclosure"` -} - -func (s *SubscriptionService) fetch(ctx context.Context, feedURL string) (*rssFeed, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "MediaStationGo/0.1") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("rss %s: %d", feedURL, resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - var f rssFeed - if err := xml.Unmarshal(body, &f); err != nil { - return nil, err - } - return &f, nil -} - -func compileFilter(pat string) *regexp.Regexp { - pat = strings.TrimSpace(pat) - if pat == "" { - return nil - } - if r, err := regexp.Compile("(?i)" + pat); err == nil { - return r - } - return nil -} - -func splitNonEmpty(s string) []string { - if s == "" { - return nil - } - out := make([]string, 0) - for _, p := range strings.Split(s, "\n") { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} diff --git a/internal/service/subscription_rss_defaults_test.go b/internal/service/subscription_rss_defaults_test.go deleted file mode 100644 index c4e15d8..0000000 --- a/internal/service/subscription_rss_defaults_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectRSSSubscriptionCandidatesDefaultKeepsOnlyBestWebDLPerEpisode(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"} - items := []rssItem{ - rssSubscriptionItem("House of the Dragon S03E01 1080p WEB-DL H264 AAC", "https://pt/download/e01-1080"), - rssSubscriptionItem("House of the Dragon S03E01 2160p WEB-DL H264 AAC", "https://pt/download/e01-2160"), - rssSubscriptionItem("House of the Dragon S03E01 720p WEBRip H264 AAC", "https://pt/download/e01-720"), - rssSubscriptionItem("House of the Dragon S03E02 1080p HDTV H264 AAC", "https://pt/download/e02-hdtv"), - rssSubscriptionItem("House of the Dragon S03E02 1080p WEB-DL H264 AAC", "https://pt/download/e02-webdl"), - } - - got := selectRSSSubscriptionCandidates(items, sub, compileFilter(sub.Filter), nil, LocalAvailability{}) - if len(got) != 2 { - t.Fatalf("selected %d candidates, want one best release per episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-2160" { - t.Fatalf("episode 1 selected %q, want 2160p WEB-DL", got[0].Download) - } - if got[1].Download != "https://pt/download/e02-webdl" { - t.Fatalf("episode 2 selected %q, want WEB-DL over HDTV", got[1].Download) - } -} - -func rssSubscriptionItem(title, download string) rssItem { - item := rssItem{Title: title, Link: download, GUID: download} - item.Enclosure.URL = download - return item -} diff --git a/internal/service/subscription_rss_run.go b/internal/service/subscription_rss_run.go deleted file mode 100644 index e9158cb..0000000 --- a/internal/service/subscription_rss_run.go +++ /dev/null @@ -1,188 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type rssSubscriptionRunState struct { - seen []string - seenSet map[string]struct{} - availability LocalAvailability - availabilityQuery string - washOff bool -} - -func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (queued int, err error) { - s.prepareSubscriptionForRun(ctx, sub) - started := time.Now() - if s.log != nil { - s.log.Info("subscription run started", subscriptionRunLogFields(sub)...) - defer func() { - fields := appendSubscriptionRunResultFields(subscriptionRunLogFields(sub), queued, started) - if err != nil { - fields = append(fields, zap.Error(err)) - s.log.Warn("subscription run finished with error", fields...) - return - } - s.log.Info("subscription run finished", fields...) - }() - } - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") { - return s.runSiteSearch(ctx, sub) - } - - feed, err := s.fetch(ctx, sub.FeedURL) - if err != nil { - return 0, err - } - - filter := compileFilter(sub.Filter) - guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID) - seenRaw, _ := s.repo.Setting.Get(ctx, guidKey) - seen := splitNonEmpty(seenRaw) - seenSet := make(map[string]struct{}, len(seen)) - for _, g := range seen { - seenSet[g] = struct{}{} - } - - s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferRSSTotalEpisodes(feed.Channel.Items, sub, filter))) - // RSS 和站点搜索统一使用候选规划:先按订阅规则过滤,再按洗版优先级/集数去重择优。 - // 非洗版订阅成功下载一次即满足,媒体库与下载中任务会作为可用性输入避免重复下载。 - runState := &rssSubscriptionRunState{ - seen: seen, - seenSet: seenSet, - availability: mergeLocalAvailability(SubscriptionLocalAvailability(ctx, s.repo, sub), s.pendingDownloadAvailability(ctx, sub)), - availabilityQuery: availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), - washOff: !subscriptionAllowsWash(sub), - } - candidates := selectRSSSubscriptionCandidates(feed.Channel.Items, sub, filter, runState.seenSet, runState.availability) - queued = s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState) - s.finishRSSSubscriptionRun(ctx, sub, guidKey, runState, queued) - return queued, nil -} - -func (s *SubscriptionService) enqueueRSSSubscriptionCandidates(ctx context.Context, sub *model.Subscription, candidates []siteSearchCandidate, state *rssSubscriptionRunState) int { - queued := 0 - for _, candidate := range candidates { - if s.enqueueRSSSubscriptionCandidate(ctx, sub, candidate, state) { - queued++ - } - } - return queued -} - -func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate, state *rssSubscriptionRunState) bool { - item := candidate.Item - mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, "") - savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) - if s.downloadPathHasCandidate(ctx, sub, item.Title, savePath) { - state.markTitleAvailable(item.Title) - return false - } - if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, candidate.Download, savePath, DownloadTaskMeta{ - SubscriptionID: sub.ID, - Title: firstNonEmpty(item.Title, sub.Name), - PosterURL: sub.PosterURL, - BackdropURL: sub.BackdropURL, - Overview: sub.Overview, - MediaType: mediaType, - MediaCategory: mediaCategory, - AllowExistingLibrary: subscriptionAllowsWash(sub), - }); err != nil { - if IsDownloadDedupError(err) { - if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) { - state.markCandidateAvailable(candidate) - return false - } - if s.log != nil { - s.log.Info("subscription dedup candidate not confirmed available", - zap.String("title", item.Title), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath)) - } - return false - } - s.log.Warn("subscription enqueue failed", - zap.String("title", item.Title), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath), - zap.Error(err)) - return false - } - if s.log != nil { - s.log.Info("rss subscription candidate queued", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("title", item.Title), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath)) - } - state.markTitleAvailable(item.Title) - state.markSeen(candidate.GUID) - return true -} - -func (s *SubscriptionService) finishRSSSubscriptionRun(ctx context.Context, sub *model.Subscription, guidKey string, state *rssSubscriptionRunState, queued int) { - state.availability = s.finalizePendingAvailability(sub, state.availability) - // Remember the last 200 GUIDs so the seen set doesn't grow forever. - if len(state.seen) > 200 { - state.seen = state.seen[len(state.seen)-200:] - } - if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(state.seen, "\n")); err != nil && s.log != nil { - s.log.Warn("subscription seen state update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - - now := time.Now() - if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { - s.log.Warn("subscription last_run_at update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - if err := s.archiveCompletedSubscription(ctx, sub, state.availability); err != nil && s.log != nil { - s.log.Warn("subscription archive check failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - if queued > 0 { - s.hub.Publish("subscription", map[string]any{ - "id": sub.ID, - "name": sub.Name, - "queued": queued, - }) - s.notifySubscriptionHit(sub, queued, nil) - } -} - -func (state *rssSubscriptionRunState) markTitleAvailable(title string) { - if state.washOff { - addAvailabilityTitle(title, state.availabilityQuery, &state.availability) - } -} - -func (state *rssSubscriptionRunState) markCandidateAvailable(candidate siteSearchCandidate) { - if state.washOff { - addSiteSearchCandidateAvailability(candidate, &state.availability) - } -} - -func (state *rssSubscriptionRunState) markSeen(guid string) { - state.seen = append(state.seen, guid) - if state.seenSet != nil { - state.seenSet[guid] = struct{}{} - } -} diff --git a/internal/service/subscription_rules.go b/internal/service/subscription_rules.go deleted file mode 100644 index 2fe52af..0000000 --- a/internal/service/subscription_rules.go +++ /dev/null @@ -1,104 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func matchesSubscriptionRules(sub *model.Subscription, title string) bool { - titleFold := strings.ToLower(title) - if containsAnyExcludeToken(titleFold, defaultExcludeWords) { - return false - } - if sub == nil { - return true - } - if compatibilityExcludes := defaultCompatibilityExcludesForSubscription(sub); compatibilityExcludes != "" && containsAnyExcludeToken(titleFold, compatibilityExcludes) { - return false - } - if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) { - return false - } - if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) { - return false - } - if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) { - return false - } - if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) { - return false - } - if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) { - return false - } - return true -} - -func defaultCompatibilityExcludesForSubscription(sub *model.Subscription) string { - if sub == nil { - return defaultCompatibilityExcludeWords - } - requested := strings.ToLower(strings.Join([]string{sub.Effects, sub.Quality}, ",")) - if strings.TrimSpace(requested) == "" { - return defaultCompatibilityExcludeWords - } - tokens := excludeWordTokens(defaultCompatibilityExcludeWords) - out := make([]string, 0, len(tokens)) - for _, token := range tokens { - if token == "" || compatibilityTokenRequested(requested, token) { - continue - } - out = append(out, token) - } - return strings.Join(out, ",") -} - -func compatibilityTokenRequested(requested, token string) bool { - switch token { - case "dovi", "dv", "dolby vision", "杜比视界": - return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dovi,dv,dolby vision,杜比视界") - case "dolby", "杜比": - return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dolby,dolby vision,杜比,杜比视界,atmos,dolby atmos,杜比全景声") - case "atmos": - return containsAnyToken(requested, "atmos,dolby atmos,杜比全景声") - case "h265", "h.265", "h-265", "h_265", "h 265", "hevc", "x265": - return containsAnyToken(requested, "h265,h.265,h-265,h_265,h 265,hevc,x265") - case "10bit", "10-bit", "10 bit", "hi10p": - return containsAnyToken(requested, "10bit,10-bit,10 bit,hi10p") - case "truehd", "ddp", "dd+", "eac3": - return containsAnyToken(requested, "truehd,ddp,dd+,eac3") - default: - return containsAnyToken(requested, token) - } -} - -func isSubscriptionSeriesType(mediaType string) bool { - switch normalizeMediaType(mediaType, "", "") { - case "tv", "anime", "variety": - return true - default: - return false - } -} - -func subscriptionAllowsWash(sub *model.Subscription) bool { - if sub == nil || !sub.WashEnabled { - return false - } - return subscriptionHasExplicitUpgradeCriteria(sub) -} - -func subscriptionHasExplicitUpgradeCriteria(sub *model.Subscription) bool { - if sub == nil { - return false - } - if value := strings.TrimSpace(strings.ToLower(sub.Resolution)); value != "" && value != "best" { - return true - } - if value := strings.TrimSpace(strings.ToLower(sub.Quality)); value != "" && value != "best" { - return true - } - return strings.TrimSpace(sub.Effects) != "" || - strings.TrimSpace(sub.ReleaseGroups) != "" -} diff --git a/internal/service/subscription_rules_test.go b/internal/service/subscription_rules_test.go deleted file mode 100644 index ebf60aa..0000000 --- a/internal/service/subscription_rules_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestMatchesSubscriptionRulesUserExcludeWords(t *testing.T) { - sub := &model.Subscription{ExcludeWords: "10bit,dolby vision,杜比"} - cases := []struct { - title string - want bool - }{ - {"Movie 2024 1080p WEB-DL", true}, - {"Movie 2024 2160p 10bit HEVC", false}, - {"Movie 2024 2160p Dolby Vision", false}, - {"电影 2024 杜比全景声", false}, - } - for _, c := range cases { - if got := matchesSubscriptionRules(sub, c.title); got != c.want { - t.Errorf("matchesSubscriptionRules(%q) = %v, want %v", c.title, got, c.want) - } - } -} - -func TestMatchesSubscriptionRulesReleaseStyleExcludeWords(t *testing.T) { - cases := []struct { - name string - sub *model.Subscription - title string - }{ - { - name: "default excludes ddp channel suffix", - sub: &model.Subscription{}, - title: "Some Show 2026 S01E01 1080p WEB-DL DDP5.1 H264", - }, - { - name: "default excludes dolby glued word", - sub: &model.Subscription{}, - title: "Some Movie 2026 1080p WEB-DL DolbyVision H264", - }, - { - name: "custom dotted list excludes split tokens", - sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"}, - title: "Some Movie 2026 1080p WEB-DL H265", - }, - { - name: "custom dotted list excludes cjk split token", - sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"}, - title: "某电影 2026 1080p 杜比全景声", - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if matchesSubscriptionRules(c.sub, c.title) { - t.Fatalf("expected exclude words to reject %q", c.title) - } - }) - } -} - -func TestMatchesSubscriptionRulesDefaultExcludesJunkReleases(t *testing.T) { - sub := &model.Subscription{} - for _, title := range []string{ - "Some Movie 2024 CAM", - "Some Movie 2024 HDTS", - "某电影 2024 枪版", - "Some Movie 2024 TELESYNC", - "Some Show 预告", - } { - if matchesSubscriptionRules(sub, title) { - t.Errorf("expected default rules to exclude junk release %q", title) - } - } -} - -func TestMatchesSubscriptionRulesDefaultExcludesRiskyTorrentLabels(t *testing.T) { - sub := &model.Subscription{} - for _, title := range []string{ - "Some Show S01E01 1080p WEB-DL HR", - "Some Show S01E01 1080p WEB-DL H&R", - "Some Show S01E01 1080p WEB-DL Hit and Run", - "Some Show S01E01 1080p WEB-DL 禁转", - "Some Show S01E01 1080p WEB-DL 禁止下载", - } { - if matchesSubscriptionRules(sub, title) { - t.Errorf("expected default rules to exclude risky torrent label %q", title) - } - } -} - -func TestMatchesSubscriptionRulesDefaultExcludesCompatibilityReleases(t *testing.T) { - cases := []struct { - name string - sub *model.Subscription - }{ - {name: "empty exclude words", sub: &model.Subscription{}}, - {name: "legacy frontend defaults", sub: &model.Subscription{ExcludeWords: "cam,ts,tc,枪版"}}, - {name: "custom exclude words", sub: &model.Subscription{ExcludeWords: "官中,无字幕"}}, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - for _, title := range []string{ - "Some Movie 2024 2160p DoVi H.265 10bit", - "Some Movie 2024 2160p H-265", - "Some Movie 2024 2160p H 265 10 bit", - "Some Movie 2024 1080p HEVC", - "Some Movie 2024 1080p x265", - "Some Movie 2024 2160p Dolby Vision Atmos", - "某电影 2024 1080p 杜比全景声", - "Some Anime 2024 1080p Hi10P", - } { - if matchesSubscriptionRules(c.sub, title) { - t.Errorf("expected default compatibility rules to exclude %q", title) - } - } - }) - } -} - -func TestMatchesSubscriptionRulesCustomExcludeWordsKeepCompatibilityDefaults(t *testing.T) { - sub := &model.Subscription{ExcludeWords: "sample"} - title := "Some Movie 2024 2160p DoVi HEVC 10bit" - if matchesSubscriptionRules(sub, title) { - t.Fatalf("custom exclude words should keep default compatibility excludes for %q", title) - } - if matchesSubscriptionRules(sub, "Some Movie 2024 1080p SAMPLE") { - t.Fatal("custom exclude words should still apply") - } -} - -func TestMatchesSubscriptionRulesExplicitEffectsCanRequestCompatibilityFormats(t *testing.T) { - sub := &model.Subscription{Effects: "dolby vision"} - title := "Some Movie 2024 2160p DoVi WEB-DL" - if !matchesSubscriptionRules(sub, title) { - t.Fatalf("explicit requested effects should allow compatibility format for %q", title) - } -} - -func TestMatchesSubscriptionRulesExplicitAtmosDoesNotAllowOtherCompatibilityFormats(t *testing.T) { - sub := &model.Subscription{Effects: "atmos"} - if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Atmos") { - t.Fatal("explicit atmos should allow an Atmos-only release") - } - if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Dolby Atmos") { - t.Fatal("explicit atmos should allow Dolby Atmos wording") - } - if matchesSubscriptionRules(sub, "Some Movie 2024 2160p WEB-DL HEVC 10bit DoVi Atmos") { - t.Fatal("explicit atmos should not also allow DoVi/HEVC/10bit") - } -} - -func TestMatchesSubscriptionRulesWordBoundaryAvoidsFalsePositives(t *testing.T) { - sub := &model.Subscription{} - // "ts" / "cam" / "tc" 作为子串出现在合法标题里时不应被默认排除误伤。 - for _, title := range []string{ - "Tsukihime 2024 1080p WEB-DL", - "Camp Rock 2024 1080p BluRay", - "Catch Me 2024 1080p WEB-DL", - } { - if !matchesSubscriptionRules(sub, title) { - t.Errorf("word-boundary match wrongly excluded %q", title) - } - } -} - -func TestSelectSiteSearchCandidatesSkipsExistingMovieWhenNotWashing(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie"} - results := []SearchResult{ - {Title: "Inception 2010 2160p 10bit Dolby Vision Atmos", DownloadURL: "https://pt/download/dovi", Seeders: 500}, - {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/web", Seeders: 90}, - } - availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1} - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none (movie already in library, wash disabled)", got) - } -} - -func TestSelectSiteSearchCandidatesAllowsMovieWashUpgradeWithExplicitCriteria(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", Resolution: "2160p", WashEnabled: true, WashPriority: "resolution"} - results := []SearchResult{ - {Title: "Inception 2010 2160p REMUX", DownloadURL: "https://pt/download/2160", Seeders: 80}, - {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/1080", Seeders: 200}, - } - availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1} - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/2160" { - t.Fatalf("selected %#v, want 2160p upgrade allowed when washing", got) - } -} - -func TestSubscriptionItemAlreadyAvailable(t *testing.T) { - movieSub := &model.Subscription{MediaType: "movie"} - if !subscriptionItemAlreadyAvailable(movieSub, LocalAvailability{LocalMediaCount: 1}, "Inception 2010 2160p") { - t.Fatal("movie already in library should be reported available") - } - if subscriptionItemAlreadyAvailable(movieSub, LocalAvailability{}, "Inception 2010 2160p") { - t.Fatal("empty library should not be reported available") - } - tvSub := &model.Subscription{MediaType: "tv"} - avail := LocalAvailability{LocalMediaCount: 1, ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 2): {}}} - if !subscriptionItemAlreadyAvailable(tvSub, avail, "Show S01E02 1080p") { - t.Fatal("existing episode should be reported available") - } - if subscriptionItemAlreadyAvailable(tvSub, avail, "Show S01E03 1080p") { - t.Fatal("missing episode should not be reported available") - } -} diff --git a/internal/service/subscription_run_rss_test.go b/internal/service/subscription_run_rss_test.go deleted file mode 100644 index 5170bc3..0000000 --- a/internal/service/subscription_run_rss_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Some Show S01E01 1080p - episode-1 - magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 - - - Some Show S01E01 1080p - episode-1 - magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 - -`)) - })) - defer rss.Close() - - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - FeedURL: rss.URL, - Filter: "Some Show", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } -} - -func TestSubscriptionRunOneSkipsSameEpisodeAddedEarlierInFeed(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Some Show S01E01 1080p - episode-1-a - magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Some+Show+S01E01+1080p - - - Some Show S01E01 WEB-DL - episode-1-b - magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Some+Show+S01E01+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - FeedURL: rss.URL, - Filter: "Some Show", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 12, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } -} diff --git a/internal/service/subscription_run_wash_test.go b/internal/service/subscription_run_wash_test.go deleted file mode 100644 index 321c1f4..0000000 --- a/internal/service/subscription_run_wash_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package service - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneRSSWashQueuesOnlyBestMovieVariant(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Dune 2021 1080p WEB-DL - dune-1080-web - magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL - - - Dune 2021 2160p UHD BluRay REMUX HDR - dune-2160-remux - magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Dune+2021+2160p+REMUX - - - Dune 2021 720p HDTV - dune-720-hdtv - magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Dune+2021+720p+HDTV - -`)) - })) - defer rss.Close() - - var addCalls int32 - var addedTitles []string - addedHashes := make([]string, 0, 3) - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if len(addedHashes) == 0 { - _, _ = w.Write([]byte(`[]`)) - return - } - var items []string - for _, hash := range addedHashes { - items = append(items, `{"hash":"`+hash+`","name":"Dune 2021","state":"downloading","progress":0.1}`) - } - _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) - case "/api/v2/torrents/add": - call := atomic.AddInt32(&addCalls, 1) - _ = r.ParseMultipartForm(10 << 20) - addedTitles = append(addedTitles, r.FormValue("urls")) - addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: rss.URL, - Filter: "Dune 2021", - MediaType: "movie", - Resolution: "2160p", - Quality: "remux", - WashEnabled: true, - WashPriority: "resolution", - SavePath: "/downloads/movies", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1 best movie variant", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - if len(addedTitles) != 1 || !strings.Contains(addedTitles[0], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { - t.Fatalf("added %#v, want 2160p REMUX variant only", addedTitles) - } -} - -func TestSubscriptionRunOneRSSDefaultQueuesOnlyBestEpisodeVariant(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - House of the Dragon S03E01 2160p BluRay H264 AAC - hotd-e01-bluray - magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=House+of+the+Dragon+S03E01+2160p+BluRay - - - House of the Dragon S03E01 1080p WEB-DL H264 AAC - hotd-e01-webdl - magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=House+of+the+Dragon+S03E01+1080p+WEB-DL - - - House of the Dragon S03E01 720p HDTV H264 AAC - hotd-e01-hdtv - magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=House+of+the+Dragon+S03E01+720p+HDTV - -`)) - })) - defer rss.Close() - - var addCalls int32 - var addedURLs []string - addedHashes := make([]string, 0, 3) - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if len(addedHashes) == 0 { - _, _ = w.Write([]byte(`[]`)) - return - } - var items []string - for _, hash := range addedHashes { - items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`) - } - _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) - case "/api/v2/torrents/add": - call := atomic.AddInt32(&addCalls, 1) - _ = r.ParseMultipartForm(10 << 20) - addedURLs = append(addedURLs, r.FormValue("urls")) - addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "House of the Dragon 自动订阅", - FeedURL: rss.URL, - Filter: "House of the Dragon", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want one best episode variant", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { - t.Fatalf("added %#v, want 1080p WEB-DL variant only", addedURLs) - } -} - -func TestSubscriptionRunOneRSSCustomExcludeStillSkipsIncompatibleVariants(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos - hotd-e01-dovi - magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=House+of+the+Dragon+S03E01+2160p+DoVi - - - House of the Dragon S03E01 1080p WEB-DL H264 AAC - hotd-e01-webdl - magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=House+of+the+Dragon+S03E01+1080p+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - var addedURLs []string - addedHashes := make([]string, 0, 2) - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if len(addedHashes) == 0 { - _, _ = w.Write([]byte(`[]`)) - return - } - var items []string - for _, hash := range addedHashes { - items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`) - } - _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) - case "/api/v2/torrents/add": - call := atomic.AddInt32(&addCalls, 1) - _ = r.ParseMultipartForm(10 << 20) - addedURLs = append(addedURLs, r.FormValue("urls")) - addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "House of the Dragon 自动订阅", - FeedURL: rss.URL, - Filter: "House of the Dragon", - MediaType: "tv", - SavePath: "/downloads/tv", - ExcludeWords: "官中,无字幕", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want one compatible WEB-DL variant", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { - t.Fatalf("added %#v, want compatible 1080p WEB-DL only", addedURLs) - } -} diff --git a/internal/service/subscription_score.go b/internal/service/subscription_score.go deleted file mode 100644 index 87090a6..0000000 --- a/internal/service/subscription_score.go +++ /dev/null @@ -1,167 +0,0 @@ -package service - -import ( - "regexp" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -var ( - dolbyVisionTokenRE = regexp.MustCompile(`\bdv\b`) - webDLTokenRE = regexp.MustCompile(`\bweb[\s._-]?dl\b`) - webRipTokenRE = regexp.MustCompile(`\bweb[\s._-]?rip\b`) - bluRayTokenRE = regexp.MustCompile(`\b(?:blu[\s._-]?ray|bdrip|bdremux|uhd[\s._-]?blu[\s._-]?ray)\b`) -) - -const ( - defaultSubscriptionFreePromotionScore = 50_000 - washSubscriptionFreePromotionScore = 25 -) - -func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int { - title := strings.ToLower(subscriptionSearchResultText(item)) - score := item.Seeders - if !subscriptionAllowsWash(sub) { - score += detectDefaultSubscriptionQualityScore(title)*1_000_000 + detectResolutionScore(title)*100_000 - if item.Free { - score += defaultSubscriptionFreePromotionScore - } - return score - } - resolutionScore := detectResolutionScore(title) - qualityScore := detectQualityScore(title) - effectScore := detectEffectScore(title) - - priority := "balanced" - if sub != nil && strings.TrimSpace(sub.WashPriority) != "" { - priority = strings.ToLower(strings.TrimSpace(sub.WashPriority)) - } - switch priority { - case "resolution": - score += resolutionScore*1000 + qualityScore*100 + effectScore*50 - case "quality": - score += qualityScore*1000 + resolutionScore*200 + effectScore*50 - case "effects": - score += effectScore*1000 + resolutionScore*200 + qualityScore*100 - case "seeders": - score += qualityScore*3 + resolutionScore*2 + effectScore - default: - score += resolutionScore*500 + qualityScore*300 + effectScore*150 - } - if item.Free { - score += washSubscriptionFreePromotionScore - } - return score -} - -func containsAnyEffect(titleFold, csv string) bool { - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token == "" { - continue - } - switch token { - case "dolby-vision", "dolby vision", "dv": - if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || dolbyVisionTokenRE.MatchString(titleFold) { - return true - } - default: - if strings.Contains(titleFold, token) { - return true - } - } - } - return false -} - -func titleMatchesResolution(titleFold, resolution string) bool { - switch strings.ToLower(strings.TrimSpace(resolution)) { - case "2160p", "4k", "uhd": - return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd") - case "1080p": - return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd") - case "720p": - return strings.Contains(titleFold, "720p") - default: - return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution))) - } -} - -func titleMatchesQuality(titleFold, quality string) bool { - switch strings.ToLower(strings.TrimSpace(quality)) { - case "webdl", "web-dl": - return webDLTokenRE.MatchString(titleFold) - case "webrip", "web-rip": - return webRipTokenRE.MatchString(titleFold) - case "bluray", "blu-ray": - return bluRayTokenRE.MatchString(titleFold) - case "remux": - return strings.Contains(titleFold, "remux") - case "hdtv": - return strings.Contains(titleFold, "hdtv") - default: - return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality))) - } -} - -func detectDefaultSubscriptionQualityScore(titleFold string) int { - switch { - case titleMatchesQuality(titleFold, "web-dl"): - return 5 - case titleMatchesQuality(titleFold, "web-rip"): - return 4 - case titleMatchesQuality(titleFold, "bluray"), titleMatchesQuality(titleFold, "remux"): - return 3 - case titleMatchesQuality(titleFold, "hdtv"): - return 2 - default: - return 1 - } -} - -func detectResolutionScore(titleFold string) int { - switch { - case titleMatchesResolution(titleFold, "2160p"): - return 4 - case titleMatchesResolution(titleFold, "1080p"): - return 3 - case titleMatchesResolution(titleFold, "720p"): - return 2 - default: - return 1 - } -} - -func detectQualityScore(titleFold string) int { - switch { - case titleMatchesQuality(titleFold, "remux"): - return 5 - case titleMatchesQuality(titleFold, "bluray"): - return 4 - case titleMatchesQuality(titleFold, "web-dl"): - return 3 - case titleMatchesQuality(titleFold, "hdtv"): - return 2 - default: - return 1 - } -} - -func detectEffectScore(titleFold string) int { - score := 0 - if containsAnyEffect(titleFold, "dolby-vision") { - score += 4 - } - if strings.Contains(titleFold, "hdr10+") { - score += 3 - } else if strings.Contains(titleFold, "hdr") { - score += 2 - } - if strings.Contains(titleFold, "atmos") { - score += 2 - } - return score -} diff --git a/internal/service/subscription_site_search.go b/internal/service/subscription_site_search.go deleted file mode 100644 index 12b0244..0000000 --- a/internal/service/subscription_site_search.go +++ /dev/null @@ -1,241 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subscription) (int, error) { - if s.site == nil { - if s.log != nil { - s.log.Warn("site-search subscription service unavailable", subscriptionSiteSearchLogFields(sub, "")...) - } - return 0, errors.New("site search service unavailable") - } - keywords := siteSearchKeywords(sub) - keyword := "" - if len(keywords) > 0 { - keyword = keywords[0] - } - if keyword == "" { - if s.log != nil { - s.log.Warn("site-search subscription keyword missing", subscriptionSiteSearchLogFields(sub, "")...) - } - return 0, errors.New("site-search subscription keyword required") - } - if s.log != nil { - s.log.Info("site-search subscription run started", subscriptionSiteSearchLogFields(sub, keyword)...) - } - - results, err := s.searchSubscriptionSites(ctx, sub, keywords) - if err != nil { - return 0, err - } - if len(results) == 0 { - return s.finishSiteSearchNoResults(sub, keyword) - } - s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferSearchTotalEpisodes(results, sub))) - - guidKey, seen, seenSet := s.loadSiteSearchSeen(ctx, sub) - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, sub), - s.pendingDownloadAvailability(ctx, sub), - ) - candidates, selectionStats := selectSiteSearchCandidatesWithStats(results, sub, seenSet, availability) - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = appendSiteSearchSelectionLogFields(fields, selectionStats) - fields = appendAvailabilityLogFields(fields, availability) - s.log.Info("site-search subscription selection summary", fields...) - } - runState := &siteSearchRunState{ - Keyword: keyword, - Seen: seen, - SeenSet: seenSet, - Availability: availability, - } - queueResult := s.enqueueSiteSearchCandidates(ctx, sub, candidates, runState) - availability = s.finishSiteSearchRun(ctx, sub, guidKey, runState) - return s.handleSiteSearchQueueResult(sub, keyword, queueResult, selectionStats, availability) -} - -func (s *SubscriptionService) finishSiteSearchRun(ctx context.Context, sub *model.Subscription, guidKey string, state *siteSearchRunState) LocalAvailability { - availability := s.finalizePendingAvailability(sub, state.Availability) - seen := trimSiteSearchSeen(state.Seen) - if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")); err != nil && s.log != nil { - s.log.Warn("site-search subscription seen state update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - now := time.Now() - if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { - s.log.Warn("site-search subscription last_run_at update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - if err := s.archiveCompletedSubscription(ctx, sub, availability); err != nil && s.log != nil { - s.log.Warn("site-search subscription archive check failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - return availability -} - -func (s *SubscriptionService) handleSiteSearchQueueResult(sub *model.Subscription, keyword string, queueResult siteSearchQueueResult, selectionStats siteSearchSelectionStats, availability LocalAvailability) (int, error) { - if queueResult.Queued > 0 { - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = appendSiteSearchSelectionLogFields(fields, selectionStats) - fields = appendAvailabilityLogFields(fields, availability) - fields = append(fields, - zap.Int("queued", queueResult.Queued), - zap.Strings("resources", queueResult.Resources), - zap.Bool("archived", sub.ArchivedAt != nil), - zap.String("archive_reason", sub.ArchiveReason), - ) - s.log.Info("site-search subscription queued resources", fields...) - } - s.hub.Publish("subscription", map[string]any{ - "id": sub.ID, - "name": sub.Name, - "queued": queueResult.Queued, - "keyword": keyword, - "resources": queueResult.Resources, - }) - s.notifySubscriptionHit(sub, queueResult.Queued, queueResult.Resources) - return queueResult.Queued, nil - } - if queueResult.LastEnqueueErr != nil { - return 0, fmt.Errorf("找到 PT 资源但加入下载器失败: %w", queueResult.LastEnqueueErr) - } - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = appendSiteSearchSelectionLogFields(fields, selectionStats) - fields = appendAvailabilityLogFields(fields, availability) - fields = append(fields, zap.Int("queued", queueResult.Queued)) - s.log.Info("site-search subscription no candidate queued", fields...) - } - return 0, nil -} - -func (s *SubscriptionService) searchSubscriptionSites(ctx context.Context, sub *model.Subscription, keywords []string) ([]SearchResult, error) { - var ( - results []SearchResult - lastSearchErr error - searchErrors int - ) - for _, searchKeyword := range keywords { - found, err := s.site.Search(ctx, searchKeyword) - if err != nil { - lastSearchErr = err - searchErrors++ - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, searchKeyword) - fields = append(fields, zap.Error(err)) - s.log.Warn("site-search subscription search failed", fields...) - } - if subscriptionSiteSearchShouldStopOnError(err) { - return nil, err - } - continue - } - results = append(results, found...) - if hasUsableSubscriptionSearchResult(found, sub) { - break - } - } - results = dedupeSiteSearchResults(results) - if len(results) == 0 && lastSearchErr != nil && searchErrors == len(keywords) { - return nil, lastSearchErr - } - return results, nil -} - -func subscriptionSiteSearchShouldStopOnError(err error) bool { - if err == nil { - return false - } - var limited *siteAPIRateLimitError - if errors.As(err, &limited) { - return true - } - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return true - } - var timeout interface{ Timeout() bool } - if errors.As(err, &timeout) && timeout.Timeout() { - return true - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "rate limit") || - strings.Contains(msg, "quota") || - strings.Contains(msg, "too many requests") || - strings.Contains(msg, "status 429") || - strings.Contains(msg, "context deadline exceeded") || - strings.Contains(msg, "tls handshake timeout") || - strings.Contains(msg, "i/o timeout") || - strings.Contains(msg, "unexpected eof") || - strings.Contains(msg, "connection reset") || - strings.Contains(msg, "connection refused") || - strings.Contains(msg, "no such host") || - strings.Contains(msg, "temporary failure") -} - -func hasUsableSubscriptionSearchResult(results []SearchResult, sub *model.Subscription) bool { - for _, item := range results { - if !subscriptionSearchResultMatchesQuery(sub, item) { - continue - } - if !matchesSubscriptionRules(sub, subscriptionSearchResultText(item)) { - continue - } - if strings.TrimSpace(firstNonEmpty(item.DownloadURL, item.TorrentURL)) != "" { - return true - } - } - return false -} - -func (s *SubscriptionService) finishSiteSearchNoResults(sub *model.Subscription, keyword string) (int, error) { - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = append(fields, zap.Int("results_count", 0)) - s.log.Info("site-search subscription no results", fields...) - } - now := time.Now() - if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { - s.log.Warn("site-search subscription last_run_at update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - return 0, nil -} - -func (s *SubscriptionService) loadSiteSearchSeen(ctx context.Context, sub *model.Subscription) (string, []string, map[string]struct{}) { - guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID) - seenRaw, _ := s.repo.Setting.Get(ctx, guidKey) - seen := splitNonEmpty(seenRaw) - seenSet := make(map[string]struct{}, len(seen)) - for _, g := range seen { - seenSet[g] = struct{}{} - } - return guidKey, seen, seenSet -} - -func trimSiteSearchSeen(seen []string) []string { - if len(seen) <= 200 { - return seen - } - return seen[len(seen)-200:] -} diff --git a/internal/service/subscription_site_search_enqueue.go b/internal/service/subscription_site_search_enqueue.go deleted file mode 100644 index 8ff6a78..0000000 --- a/internal/service/subscription_site_search_enqueue.go +++ /dev/null @@ -1,164 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type siteSearchRunState struct { - Keyword string - Seen []string - SeenSet map[string]struct{} - Availability LocalAvailability -} - -type siteSearchQueueResult struct { - Queued int - Resources []string - LastEnqueueErr error -} - -func (s *SubscriptionService) enqueueSiteSearchCandidates(ctx context.Context, sub *model.Subscription, candidates []siteSearchCandidate, state *siteSearchRunState) siteSearchQueueResult { - var result siteSearchQueueResult - for _, candidate := range candidates { - title, err := s.enqueueSiteSearchCandidate(ctx, sub, candidate, state) - if err != nil { - result.LastEnqueueErr = err - continue - } - if title == "" { - continue - } - result.Queued++ - result.Resources = append(result.Resources, title) - } - return result -} - -func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate, state *siteSearchRunState) (string, error) { - item := candidate.Item - matchText := subscriptionSearchResultText(item) - mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, matchText, item.Category) - if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) { - state.markCandidateAvailable(candidate) - state.markCandidateSeen(candidate.GUID) - s.logSiteSearchCandidateSkipped(sub, state, candidate, "existing_torrent", mediaType, "", "") - return "", nil - } - - realURL := s.site.ResolveDownloadURL(ctx, candidate.Download) - savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) - if s.downloadPathHasCandidate(ctx, sub, matchText, savePath) { - state.markCandidateAvailable(candidate) - state.markCandidateSeen(candidate.GUID) - s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_path_has_candidate", mediaType, mediaCategory, savePath) - return "", nil - } - - if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{ - SubscriptionID: sub.ID, - Title: firstNonEmpty(item.Title, sub.Name), - PosterURL: sub.PosterURL, - BackdropURL: sub.BackdropURL, - Overview: sub.Overview, - MediaType: mediaType, - MediaCategory: mediaCategory, - SourceCategory: item.Category, - AllowExistingLibrary: subscriptionAllowsWash(sub), - }); err != nil { - if IsDownloadDedupError(err) { - if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) { - state.markCandidateAvailable(candidate) - s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup", mediaType, mediaCategory, savePath) - return "", nil - } - s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup_unconfirmed", mediaType, mediaCategory, savePath) - return "", nil - } - s.logSiteSearchEnqueueFailed(sub, state, candidate, mediaType, mediaCategory, savePath, err) - return "", err - } - - state.markCandidateAvailable(candidate) - state.markCandidateSeen(candidate.GUID) - s.logSiteSearchCandidateQueued(sub, state, candidate, mediaType, mediaCategory, savePath) - return item.Title, nil -} - -func (state *siteSearchRunState) markCandidateAvailable(candidate siteSearchCandidate) { - addSiteSearchCandidateAvailability(candidate, &state.Availability) -} - -func (state *siteSearchRunState) markCandidateSeen(guid string) { - if strings.TrimSpace(guid) == "" { - return - } - state.Seen = append(state.Seen, guid) - if state.SeenSet != nil { - state.SeenSet[guid] = struct{}{} - } -} - -func (s *SubscriptionService) logSiteSearchCandidateSkipped(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, reason, mediaType, mediaCategory, savePath string) { - if s.log == nil { - return - } - fields := subscriptionSiteSearchLogFields(sub, state.Keyword) - fields = append(fields, zap.String("reason", reason)) - fields = appendSiteSearchCandidateLogFields(fields, candidate) - fields = append(fields, zap.String("media_type", mediaType)) - if mediaCategory != "" { - fields = append(fields, zap.String("media_category", mediaCategory)) - } - if savePath != "" { - fields = append(fields, zap.String("save_path", savePath)) - } - s.log.Info("site-search subscription candidate skipped", fields...) -} - -func (s *SubscriptionService) logSiteSearchCandidateQueued(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, mediaType, mediaCategory, savePath string) { - if s.log == nil { - return - } - fields := subscriptionSiteSearchLogFields(sub, state.Keyword) - fields = appendSiteSearchCandidateLogFields(fields, candidate) - fields = append(fields, - zap.Int("score", candidate.Score), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath), - ) - s.log.Info("site-search subscription candidate queued", fields...) -} - -func (s *SubscriptionService) logSiteSearchEnqueueFailed(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, mediaType, mediaCategory, savePath string, err error) { - if s.log == nil { - return - } - fields := subscriptionSiteSearchLogFields(sub, state.Keyword) - fields = appendSiteSearchCandidateLogFields(fields, candidate) - fields = append(fields, - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath), - zap.Error(err), - ) - s.log.Warn("site-search subscription enqueue failed", fields...) -} - -func appendSiteSearchCandidateLogFields(fields []zap.Field, candidate siteSearchCandidate) []zap.Field { - item := candidate.Item - return append(fields, - zap.String("title", item.Title), - zap.String("subtitle", item.Subtitle), - zap.String("site", firstNonEmpty(item.SiteName, item.SiteID)), - zap.String("site_category", item.Category), - zap.Int("season", candidate.Season), - zap.Int("episode", candidate.Episode), - zap.Bool("pack", candidate.Pack), - ) -} diff --git a/internal/service/subscription_site_search_enqueue_test.go b/internal/service/subscription_site_search_enqueue_test.go deleted file mode 100644 index c43e41b..0000000 --- a/internal/service/subscription_site_search_enqueue_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestEnqueueSiteSearchDedupMarksEnglishRangeAvailableForChineseSubscription(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/国产剧", - Status: "queued", - Progress: 0.1, - }); err != nil { - t.Fatal(err) - } - - site := NewSiteService(zap.NewNop(), repos, "") - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, site, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-nanyang"}, - UserID: "u1", - Name: "南部档案 自动订阅", - Filter: "南部档案 2026", - MediaType: "tv", - MediaCategory: "国产剧", - SavePath: "/downloads", - TotalEpisodes: 33, - } - state := &siteSearchRunState{ - Keyword: "南部档案 2026", - SeenSet: map[string]struct{}{}, - Availability: LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - }, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - DownloadURL: "https://pt.example/download?id=new", - }, - Download: "https://pt.example/download?id=new", - GUID: "site|m-team|nanyang-7-8", - Season: 1, - Episode: 7, - Episodes: []int{7, 8}, - Pack: true, - } - - title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, candidate, state) - if err != nil { - t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skip without error", err) - } - if title != "" { - t.Fatalf("title = %q, want empty because candidate was deduped", title) - } - for _, episode := range []int{7, 8} { - if _, ok := state.Availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("availability missing E%d after dedup range: %#v", episode, state.Availability.ExistingEpisodeKeys) - } - } - if _, ok := state.SeenSet[candidate.GUID]; ok { - t.Fatalf("deduped candidate should not be marked seen before the next availability scan") - } -} - -func TestEnqueueSiteSearchDedupLinksEnglishHistoryTaskToChineseSubscription(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=history", - Title: "The Heir 2026 S01E33-E34 2160p WEB-DL", - SavePath: "/downloads/国产剧", - Status: "queued", - Progress: 0.2, - }); err != nil { - t.Fatal(err) - } - - site := NewSiteService(zap.NewNop(), repos, "") - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, site, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-heir"}, - UserID: "u1", - Name: "家业 自动订阅", - Filter: "家业 2026", - MediaType: "tv", - MediaCategory: "国产剧", - SavePath: "/downloads", - TotalEpisodes: 42, - } - state := &siteSearchRunState{ - Keyword: "家业 2026", - SeenSet: map[string]struct{}{}, - Availability: LocalAvailability{ - TotalEpisodes: 42, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - }, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "The Heir 2026 S01E33-E34 2160p WEB-DL", - DownloadURL: "https://pt.example/download?id=new", - }, - Download: "https://pt.example/download?id=new", - GUID: "site|m-team|heir-33-34", - Season: 1, - Episode: 33, - Episodes: []int{33, 34}, - Pack: true, - } - - title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, candidate, state) - if err != nil { - t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skip without error", err) - } - if title != "" { - t.Fatalf("title = %q, want empty because candidate was deduped", title) - } - var rows []model.DownloadTask - if err := db.Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } - if rows[0].SubscriptionID != sub.ID { - t.Fatalf("history task subscription_id = %q, want %q", rows[0].SubscriptionID, sub.ID) - } - if rows[0].MediaType != "tv" || rows[0].MediaCategory != "国产剧" { - t.Fatalf("history task metadata = (%q, %q), want (tv, 国产剧)", rows[0].MediaType, rows[0].MediaCategory) - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - for _, episode := range []int{33, 34} { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("linked history task missing E%d availability: %#v", episode, availability.ExistingEpisodeKeys) - } - } -} diff --git a/internal/service/subscription_site_search_helpers.go b/internal/service/subscription_site_search_helpers.go deleted file mode 100644 index c649124..0000000 --- a/internal/service/subscription_site_search_helpers.go +++ /dev/null @@ -1,203 +0,0 @@ -package service - -import ( - "context" - "net/url" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func subscriptionSiteSearchLogFields(sub *model.Subscription, keyword string) []zap.Field { - fields := []zap.Field{zap.String("keyword", keyword), zap.Strings("search_keywords", siteSearchKeywords(sub))} - if sub == nil { - return fields - } - fields = append(fields, - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("filter", sub.Filter), - zap.String("media_type", sub.MediaType), - zap.String("media_category", sub.MediaCategory), - zap.String("search_mode", sub.SearchMode), - zap.String("imdb_id", sub.IMDBID), - zap.Bool("wash_enabled", sub.WashEnabled), - zap.String("wash_priority", sub.WashPriority), - zap.Int("total_episodes", sub.TotalEpisodes), - ) - return fields -} - -func appendSiteSearchSelectionLogFields(fields []zap.Field, stats siteSearchSelectionStats) []zap.Field { - return append(fields, - zap.Int("results_count", stats.Total), - zap.Int("query_mismatch_count", stats.QueryMismatch), - zap.Strings("query_mismatch_examples", stats.QueryMismatchExamples), - zap.Int("relaxed_query_match_count", stats.RelaxedQueryMatch), - zap.Int("rule_mismatch_count", stats.RuleMismatch), - zap.Int("missing_download_count", stats.MissingDownload), - zap.Int("seen_count", stats.Seen), - zap.Int("prepared_count", stats.Prepared), - zap.Int("selected_count", stats.Selected), - zap.Bool("local_already_satisfied", stats.LocalAlreadySatisfied), - zap.Bool("local_series_pack_present", stats.LocalSeriesPackPresent), - zap.Bool("series_complete", stats.SeriesComplete), - zap.Int("existing_episode_skipped_count", stats.ExistingEpisodeSkipped), - zap.Int("not_missing_episode_skipped_count", stats.NotMissingEpisodeSkipped), - zap.Int("no_episode_skipped_count", stats.NoEpisodeSkipped), - zap.Bool("pack_fallback_available", stats.PackFallbackAvailable), - zap.Bool("pack_fallback_used", stats.PackFallbackUsed), - ) -} - -func appendAvailabilityLogFields(fields []zap.Field, availability LocalAvailability) []zap.Field { - missingSample, missingMore := limitedEpisodeSample(availability.MissingEpisodes, 20) - return append(fields, - zap.Int("local_media_count", availability.LocalMediaCount), - zap.Bool("in_library", availability.InLibrary), - zap.Bool("has_series_pack", availability.HasSeriesPack), - zap.Int("downloaded_episodes", availability.DownloadedEpisodes), - zap.Int("availability_total_episodes", availability.TotalEpisodes), - zap.Int("missing_episode_count", len(availability.MissingEpisodes)), - zap.Ints("missing_episodes", missingSample), - zap.Int("missing_episodes_more", missingMore), - ) -} - -func limitedEpisodeSample(values []int, limit int) ([]int, int) { - if limit <= 0 || len(values) == 0 { - return nil, len(values) - } - if len(values) <= limit { - out := append([]int(nil), values...) - return out, 0 - } - out := append([]int(nil), values[:limit]...) - return out, len(values) - limit -} - -func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool { - if s == nil || s.downloads == nil { - return false - } - if isSubscriptionSeriesType(mediaType) && candidate.Episode > 0 { - return false - } - return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title) -} - -func siteSearchKeywords(sub *model.Subscription) []string { - if sub == nil { - return nil - } - values := make([]string, 0, 8) - if strings.EqualFold(strings.TrimSpace(sub.SearchMode), "imdb") && strings.TrimSpace(sub.IMDBID) != "" { - values = append(values, strings.TrimSpace(sub.IMDBID)) - } - if u, err := url.Parse(sub.FeedURL); err == nil { - if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" { - values = append(values, keyword) - } - } - if strings.TrimSpace(sub.Filter) != "" { - values = append(values, sub.Filter) - } - if len(values) == 0 && strings.TrimSpace(sub.Name) != "" { - values = append(values, sub.Name) - } - values = append(values, subscriptionFeedAliases(sub)...) - values = append(values, subscriptionMetadataAliases(sub)...) - for _, value := range append([]string(nil), values...) { - if cleaned := cleanAvailabilityTitle(value); cleaned != "" { - values = append(values, cleaned) - } - } - return compactUniqueStrings(values...) -} - -func siteSearchKeyword(sub *model.Subscription) string { - keywords := siteSearchKeywords(sub) - if len(keywords) == 0 { - return "" - } - return keywords[0] -} - -func subscriptionFeedAliases(sub *model.Subscription) []string { - if sub == nil { - return nil - } - u, err := url.Parse(sub.FeedURL) - if err != nil { - return nil - } - q := u.Query() - values := make([]string, 0, len(q["alias"])+2) - values = append(values, q["alias"]...) - for _, raw := range q["aliases"] { - for _, part := range strings.FieldsFunc(raw, func(r rune) bool { - return r == '|' || r == '\n' || r == '\r' || r == '\t' - }) { - values = append(values, part) - } - } - return compactUniqueStrings(values...) -} - -func subscriptionMetadataAliases(sub *model.Subscription) []string { - if sub == nil { - return nil - } - title := cleanAvailabilityTitle(firstNonEmpty(sub.Filter, sub.Name)) - return buildSubscribeAliases(title, sub.OriginalName, sub.Year) -} - -func compactUniqueStrings(values ...string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - key := normalizeAvailabilityComparable(value) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, value) - } - return out -} - -func dedupeSiteSearchResults(results []SearchResult) []SearchResult { - if len(results) < 2 { - return results - } - seen := make(map[string]int, len(results)) - out := make([]SearchResult, 0, len(results)) - for _, item := range results { - download := strings.TrimSpace(item.DownloadURL) - if download == "" { - download = strings.TrimSpace(item.TorrentURL) - } - key := stableSiteSearchGUID(item, download) - if idx, ok := seen[key]; ok { - out[idx].SearchKeyword = mergeSearchResultTextHints(out[idx].SearchKeyword, item.SearchKeyword) - out[idx].Subtitle = mergeSearchResultTextHints(out[idx].Subtitle, item.Subtitle) - continue - } - seen[key] = len(out) - out = append(out, item) - } - return out -} - -func mergeSearchResultTextHints(current, next string) string { - return strings.Join(compactUniqueStrings(current, next), "\n") -} diff --git a/internal/service/subscription_site_search_keywords_test.go b/internal/service/subscription_site_search_keywords_test.go deleted file mode 100644 index 3b02d8e..0000000 --- a/internal/service/subscription_site_search_keywords_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package service - -import ( - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSiteSearchKeywordCanUseIMDB(t *testing.T) { - sub := &model.Subscription{Name: "沙丘 自动订阅", Filter: "Dune 2021", SearchMode: "imdb", IMDBID: "tt1160419"} - if got := siteSearchKeyword(sub); got != "tt1160419" { - t.Fatalf("keyword = %q, want imdb id", got) - } -} - -func TestSiteSearchKeywordsIncludeAliasesAndCleanedKeywords(t *testing.T) { - sub := &model.Subscription{ - Name: "真人快打2 自动订阅", - FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026", - Filter: "真人快打2 2026", - } - - got := siteSearchKeywords(sub) - for _, want := range []string{"真人快打2 2026", "Mortal Kombat II 2026", "真人快打2", "Mortal Kombat II"} { - if !containsString(got, want) { - t.Fatalf("keywords = %#v, missing %q", got, want) - } - } - if got[0] != "真人快打2 2026" { - t.Fatalf("primary keyword = %q, want feed keyword first", got[0]) - } -} - -func TestSiteSearchKeywordsUseCleanMetadataAliases(t *testing.T) { - sub := &model.Subscription{ - Name: "玩具总动员 4 自动订阅", - Filter: "玩具总动员 4 2019", - OriginalName: "Toy Story 4", - Year: 2019, - } - - got := siteSearchKeywords(sub) - for _, want := range []string{"玩具总动员 4 2019", "Toy Story 4", "Toy Story 4 2019", "玩具总动员 4"} { - if !containsString(got, want) { - t.Fatalf("keywords = %#v, missing %q", got, want) - } - } - for _, unwanted := range []string{"玩具总动员 4 自动订阅", "玩具总动员 4 自动订阅 2019", "玩具总动员 4 2019 2019"} { - if containsString(got, unwanted) { - t.Fatalf("keywords = %#v, should not contain %q", got, unwanted) - } - } -} - -func TestStableSiteSearchGUIDIgnoresPrivateTokenChanges(t *testing.T) { - item := SearchResult{ - SiteID: "mteam", - Title: "Some Show S01E01 1080p", - Category: "TV", - Size: 1024, - } - first := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=old") - second := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=new") - if first != second { - t.Fatalf("stableSiteSearchGUID changed with token: %q != %q", first, second) - } - if strings.Contains(first, "passkey") || strings.Contains(first, "old") || strings.Contains(first, "new") { - t.Fatalf("stableSiteSearchGUID leaked private token: %q", first) - } -} - -func TestSelectSiteSearchCandidatesWithStatsExplainsFiltering(t *testing.T) { - sub := &model.Subscription{Name: "Stats Show 自动订阅", Filter: "Stats Show", MediaType: "tv"} - seenItem := SearchResult{Title: "Stats Show S01E02 1080p", DownloadURL: "https://pt/download/seen", Seeders: 50} - seenGUID := stableSiteSearchGUID(seenItem, seenItem.DownloadURL) - results := []SearchResult{ - {Title: "Different Show S01E01 1080p", DownloadURL: "https://pt/download/wrong", Seeders: 90}, - {Title: "Stats Show S01E01 CAM", DownloadURL: "https://pt/download/cam", Seeders: 80}, - {Title: "Stats Show S01E02 1080p", Seeders: 70}, - seenItem, - {Title: "Stats Show S01E03 1080p", DownloadURL: "https://pt/download/right", Seeders: 60}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{seenGUID: {}}, LocalAvailability{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want only unfiltered candidate", got) - } - if stats.Total != 5 || - stats.QueryMismatch != 1 || - stats.RuleMismatch != 1 || - stats.MissingDownload != 1 || - stats.Seen != 1 || - stats.Prepared != 1 || - stats.Selected != 1 { - t.Fatalf("unexpected stats: %#v", stats) - } - if len(stats.QueryMismatchExamples) != 1 || stats.QueryMismatchExamples[0] != "Different Show S01E01 1080p" { - t.Fatalf("query mismatch examples = %#v", stats.QueryMismatchExamples) - } -} - -func containsString(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} diff --git a/internal/service/subscription_site_search_limit_test.go b/internal/service/subscription_site_search_limit_test.go deleted file mode 100644 index 38519e4..0000000 --- a/internal/service/subscription_site_search_limit_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package service - -import ( - "errors" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSearchSubscriptionSitesStopsAfterRateLimit(t *testing.T) { - var requests atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.Setting{}) - repos := repository.New(db) - siteSvc := NewSiteService(zap.NewNop(), repos, "") - limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ - Bucket: "torrent_search_24h", - Limit: 1500, - Window: 24 * time.Hour, - RetryAfter: time.Hour, - }} - siteSvc.apiRateLimiter = limiter - if err := siteSvc.Create(t.Context(), &model.Site{ - Name: "馒头", - Type: "mteam", - URL: upstream.URL, - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - Timeout: 5, - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop())) - sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv"} - - _, err := svc.searchSubscriptionSites(t.Context(), sub, []string{"问心2", "问心", "问心2 2023"}) - var limited *siteAPIRateLimitError - if !errors.As(err, &limited) { - t.Fatalf("searchSubscriptionSites error = %v, want siteAPIRateLimitError", err) - } - if limiter.calls != 1 { - t.Fatalf("rate limiter calls = %d, want 1 keyword attempt", limiter.calls) - } - if got := requests.Load(); got != 0 { - t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got) - } -} - -func TestSubscriptionRunAllStopsSweepAfterRateLimit(t *testing.T) { - var requests atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.Setting{}, &model.Subscription{}) - repos := repository.New(db) - siteSvc := NewSiteService(zap.NewNop(), repos, "") - limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ - Bucket: "torrent_search_24h", - Limit: 1500, - Window: 24 * time.Hour, - RetryAfter: time.Hour, - }} - siteSvc.apiRateLimiter = limiter - if err := siteSvc.Create(t.Context(), &model.Site{ - Name: "馒头", - Type: "mteam", - URL: upstream.URL, - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - Timeout: 5, - }); err != nil { - t.Fatal(err) - } - for _, name := range []string{"问心2 自动订阅", "南部档案 自动订阅"} { - sub := &model.Subscription{ - Name: name, - FeedURL: "site-search://search?keyword=" + name, - Filter: name, - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - } - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop())) - - svc.runAll(t.Context()) - if limiter.calls != 1 { - t.Fatalf("rate limiter calls = %d, want sweep to stop after first quota failure", limiter.calls) - } - if got := requests.Load(); got != 0 { - t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got) - } -} - -func TestSubscriptionSiteSearchStopsAfterTransientSiteErrors(t *testing.T) { - for _, errText := range []string{ - `search: Post "https://api.m-team.cc/api/torrent/search": context deadline exceeded`, - `search: Post "https://api.m-team.cc/api/torrent/search": net/http: TLS handshake timeout`, - `search: Post "https://api.m-team.cc/api/torrent/search": unexpected EOF`, - `search: Post "https://api.m-team.cc/api/torrent/search": read tcp 127.0.0.1: connection reset by peer`, - } { - if !subscriptionSiteSearchShouldStopOnError(errors.New(errText)) { - t.Fatalf("subscriptionSiteSearchShouldStopOnError(%q) = false, want true", errText) - } - } - if subscriptionSiteSearchShouldStopOnError(errors.New("temporary parser warning: no matching torrent rows")) { - t.Fatal("non-upstream-failure errors should not stop alias search") - } -} diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go deleted file mode 100644 index a2b50f1..0000000 --- a/internal/service/subscription_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDeleteSubscriptionRemovesDownloaderTaskAndSeenState(t *testing.T) { - const title = "Delete Subscription Show S01E01 1080p" - const hash = "abcdef1234567890abcdef1234567890abcdef12" - var deleteCalls atomic.Int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"` + title + `","state":"downloading","progress":0.2}]`)) - case "/api/v2/torrents/delete": - deleteCalls.Add(1) - if got := r.FormValue("deleteFiles"); got != "false" { - t.Fatalf("deleteFiles = %q, want false", got) - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := downloads.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{Name: "Delete Subscription Show 自动订阅", Filter: "Delete Subscription Show", FeedURL: "https://rss.example/feed", UserID: "u1", SavePath: "/downloads/tv"} - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt.example/download?id=1", - Title: title, - SavePath: "/downloads/tv", - Status: "downloading", - Progress: 0.2, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "guid-1"); err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), sub.ID); err != nil { - t.Fatalf("delete subscription: %v", err) - } - if got := deleteCalls.Load(); got != 1 { - t.Fatalf("qb delete calls = %d, want 1", got) - } - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("download task status = %q, want deleted", updated.Status) - } - seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") - if err != nil { - t.Fatal(err) - } - if seen != "" { - t.Fatalf("seen state = %q, want cleared", seen) - } - var count int64 - if err := db.Model(&model.Subscription{}).Where("id = ?", sub.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("active subscription count = %d, want 0", count) - } - var deleted model.Subscription - if err := db.Unscoped().Where("id = ?", sub.ID).First(&deleted).Error; err != nil { - t.Fatal(err) - } - if deleted.Enabled { - t.Fatal("deleted subscription stayed enabled; active legacy compatibility would show it again") - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %#v, want deleted subscription hidden", active) - } -} - -func TestDeletedDownloadTaskDoesNotBlockSubscriptionReadd(t *testing.T) { - if downloadTaskBlocksReadd("deleted") { - t.Fatal("deleted download task must not block subscription re-add") - } - if downloadTaskBlocksReadd("removed") { - t.Fatal("removed download task must not block subscription re-add") - } -} - -func TestListIncludesEnabledSoftDeletedActiveSubscription(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - sub := &model.Subscription{ - Name: "Hidden Active 自动订阅", - FeedURL: "site-search://search?keyword=Hidden%20Active", - Filter: "Hidden Active", - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil { - t.Fatal(err) - } - - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != sub.ID { - t.Fatalf("active subscriptions = %#v, want soft-deleted enabled subscription recovered", active) - } -} - -func TestDeleteRecoveredSoftDeletedSubscriptionClearsSeenAndHidesIt(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Recovered Hidden 自动订阅", - FeedURL: "site-search://search?keyword=Recovered%20Hidden", - Filter: "Recovered Hidden", - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil { - t.Fatal(err) - } - if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), sub.ID); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %#v, want recovered deleted subscription hidden", active) - } - seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") - if err != nil { - t.Fatal(err) - } - if seen != "" { - t.Fatalf("seen state = %q, want cleared", seen) - } -} diff --git a/internal/service/subscription_torrent_rules.go b/internal/service/subscription_torrent_rules.go deleted file mode 100644 index 3d790ef..0000000 --- a/internal/service/subscription_torrent_rules.go +++ /dev/null @@ -1,56 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const bytesPerGiB = 1024 * 1024 * 1024 - -func matchesSubscriptionTorrentRules(sub *model.Subscription, item SearchResult) bool { - if sub == nil { - return true - } - if sub.MinSeeders > 0 && item.Seeders < sub.MinSeeders { - return false - } - if sub.MaxSeeders > 0 && item.Seeders > sub.MaxSeeders { - return false - } - if !subscriptionSizeInRange(item.Size, sub.MinSizeGB, sub.MaxSizeGB) { - return false - } - if sub.FreeOnly && !subscriptionResultIsFree(item) { - return false - } - return true -} - -func subscriptionSizeInRange(sizeBytes int64, minGB, maxGB float64) bool { - if minGB <= 0 && maxGB <= 0 { - return true - } - if sizeBytes <= 0 { - return false - } - sizeGB := float64(sizeBytes) / bytesPerGiB - if minGB > 0 && sizeGB < minGB { - return false - } - if maxGB > 0 && sizeGB > maxGB { - return false - } - return true -} - -func subscriptionResultIsFree(item SearchResult) bool { - if item.Free { - return true - } - text := strings.ToLower(subscriptionSearchResultText(item)) - return strings.Contains(text, "freeleech") || - strings.Contains(text, "2xfree") || - strings.Contains(text, "免费") || - matchesWordBoundary(text, "free") -} diff --git a/internal/service/subscription_torrent_rules_test.go b/internal/service/subscription_torrent_rules_test.go deleted file mode 100644 index 55e9372..0000000 --- a/internal/service/subscription_torrent_rules_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesAppliesSeederSizeAndFreeRules(t *testing.T) { - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - Filter: "Some Show", - MediaType: "tv", - MinSeeders: 10, - MaxSeeders: 100, - MinSizeGB: 1, - MaxSizeGB: 8, - FreeOnly: true, - } - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/low-seed", Seeders: 3, Size: 2 * bytesPerGiB, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/hot", Seeders: 500, Size: 2 * bytesPerGiB, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/small", Seeders: 50, Size: bytesPerGiB / 2, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/nonfree", Seeders: 50, Size: 2 * bytesPerGiB}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/right", Seeders: 50, Size: 2 * bytesPerGiB, Free: true}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, nil, LocalAvailability{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want only torrent matching seed/size/free rules", got) - } - if stats.RuleMismatch != 4 || stats.Prepared != 1 || stats.Selected != 1 { - t.Fatalf("stats = %#v, want four rule mismatches and one selected", stats) - } -} - -func TestSubscriptionTorrentRulesRecognizeFreeLabels(t *testing.T) { - sub := &model.Subscription{FreeOnly: true} - for _, item := range []SearchResult{ - {Title: "Some Movie 2026 1080p WEB-DL FREE"}, - {Title: "Some Movie 2026 1080p WEB-DL FreeLeech"}, - {Title: "Some Movie 2026 1080p WEB-DL 免费"}, - {Title: "Some Movie 2026 1080p WEB-DL", Free: true}, - } { - if !matchesSubscriptionTorrentRules(sub, item) { - t.Fatalf("expected free rule to accept %#v", item) - } - } - if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) { - t.Fatal("free-only rule accepted non-free result") - } -} - -func TestSubscriptionTorrentRulesRejectUnknownSizeWhenSizeRangeConfigured(t *testing.T) { - sub := &model.Subscription{MinSizeGB: 1} - if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) { - t.Fatal("size range accepted result without size metadata") - } -} diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index f7ac854..8ddf4cf 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -31,24 +31,13 @@ import ( // SubtitleService is the discovery + conversion entry point. type SubtitleService struct { - log *zap.Logger - repo *repository.Container - storage *StorageConfigService + log *zap.Logger + repo *repository.Container } // NewSubtitleService is the constructor. -func NewSubtitleService(log *zap.Logger, repo *repository.Container, storage ...*StorageConfigService) *SubtitleService { - s := &SubtitleService{log: log, repo: repo} - if len(storage) > 0 { - s.storage = storage[0] - } - return s -} - -func (s *SubtitleService) SetStorageConfig(storage *StorageConfigService) { - if s != nil { - s.storage = storage - } +func NewSubtitleService(log *zap.Logger, repo *repository.Container) *SubtitleService { + return &SubtitleService{log: log, repo: repo} } // SubtitleTrack describes one external subtitle file. @@ -79,9 +68,6 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti if m == nil { return nil, errors.New("media not found") } - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://") { - return discoverCloudSubtitles(ctx, s, *m), nil - } dir := filepath.Dir(m.Path) base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)) @@ -148,9 +134,6 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W if err != nil || m == nil { return errors.New("media not found") } - if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok { - return serveCloudSubtitle(ctx, s, *m, typ, ref, name, w) - } abs, err := filepath.Abs(sub) if err != nil { return err @@ -195,9 +178,6 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i if err != nil || m == nil { return errors.New("media not found") } - if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok { - return serveCloudSubtitleRaw(ctx, s, *m, typ, ref, name, w) - } abs, err := filepath.Abs(sub) if err != nil { return err diff --git a/internal/service/subtitle_cloud.go b/internal/service/subtitle_cloud.go deleted file mode 100644 index 242ba0d..0000000 --- a/internal/service/subtitle_cloud.go +++ /dev/null @@ -1,208 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/url" - "path/filepath" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func discoverCloudSubtitles(ctx context.Context, s *SubtitleService, m model.Media) []SubtitleTrack { - if s == nil || s.storage == nil { - return []SubtitleTrack{} - } - typ, mediaRef, ok := cloudSubtitleMediaRef(m) - if !ok { - return []SubtitleTrack{} - } - dirRef, mediaName := splitCloudRef(mediaRef) - if mediaName == "" { - return []SubtitleTrack{} - } - base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName)) - entries, err := s.storage.CloudList(ctx, typ, dirRef) - if err != nil { - if s.log != nil { - s.log.Debug("list cloud subtitles failed", zap.String("provider", typ), zap.String("dir", dirRef), zap.Error(err)) - } - return []SubtitleTrack{} - } - tracks := cloudSubtitleTracks(typ, entries, base, false) - for _, entry := range entries { - if !entry.IsDir || !isSubtitleDirectory(entry.Name) || strings.TrimSpace(entry.ID) == "" { - continue - } - subEntries, err := s.storage.CloudList(ctx, typ, entry.ID) - if err != nil { - continue - } - tracks = append(tracks, cloudSubtitleTracks(typ, subEntries, base, true)...) - } - return tracks -} - -func cloudSubtitleTracks(typ string, entries []cloud.FileEntry, base string, subdir bool) []SubtitleTrack { - tracks := make([]SubtitleTrack, 0) - baseLower := strings.ToLower(base) - for _, entry := range entries { - if entry.IsDir { - continue - } - ext := strings.ToLower(filepath.Ext(entry.Name)) - codec, ok := extToCodec[ext] - if !ok { - continue - } - fullName := strings.TrimSuffix(entry.Name, ext) - if !subdir && !strings.HasPrefix(strings.ToLower(fullName), baseLower) { - continue - } - ref := cloudEntryRef(typ, entry.ID, entry.PickCode) - if ref == "" { - continue - } - lang := detectLang(fullName, base) - tracks = append(tracks, SubtitleTrack{ - Lang: lang, - Label: lang, - Path: buildCloudSubtitlePath(typ, ref, entry.Name), - Codec: codec, - }) - } - return tracks -} - -func cloudSubtitleMediaRef(m model.Media) (typ, ref string, ok bool) { - if info, parsed := ParseCloudLibraryMount(m.Path); parsed && strings.TrimSpace(info.DisplayDir) != "" { - return info.Provider, info.DisplayDir, true - } - if typ, ref, parsed := parseCloudMediaPlaybackURL(m.STRMURL); parsed { - return typ, ref, true - } - return "", "", false -} - -func splitCloudRef(ref string) (dir, name string) { - ref = strings.Trim(strings.ReplaceAll(strings.TrimSpace(ref), "\\", "/"), "/") - if ref == "" { - return "", "" - } - idx := strings.LastIndex(ref, "/") - if idx < 0 { - return "", ref - } - return ref[:idx], ref[idx+1:] -} - -func isSubtitleDirectory(name string) bool { - switch strings.ToLower(strings.TrimSpace(name)) { - case "subs", "sub", ".sub", "subtitles", "subtitle": - return true - default: - return false - } -} - -func buildCloudSubtitlePath(typ, ref, name string) string { - u := url.URL{ - Scheme: "cloud", - Host: strings.TrimSpace(typ), - Path: "/" + strings.TrimLeft(strings.TrimSpace(ref), "/"), - } - q := u.Query() - q.Set("name", strings.TrimSpace(name)) - u.RawQuery = q.Encode() - return u.String() -} - -func parseCloudSubtitlePath(raw string) (typ, ref, name string, ok bool) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || strings.ToLower(u.Scheme) != "cloud" || strings.TrimSpace(u.Host) == "" { - return "", "", "", false - } - ref = strings.TrimLeft(u.EscapedPath(), "/") - if decoded, err := url.PathUnescape(ref); err == nil { - ref = decoded - } - return strings.TrimSpace(u.Host), strings.TrimSpace(ref), strings.TrimSpace(u.Query().Get("name")), ref != "" -} - -func serveCloudSubtitle(ctx context.Context, s *SubtitleService, m model.Media, typ, ref, name string, w io.Writer) error { - if s == nil || s.storage == nil { - return errors.New("cloud storage service unavailable") - } - mediaTyp, _, ok := cloudSubtitleMediaRef(m) - if !ok || mediaTyp != typ { - return ErrCloudPlaybackUnavailable - } - allowed := false - for _, track := range discoverCloudSubtitles(ctx, s, m) { - if track.Path == buildCloudSubtitlePath(typ, ref, name) { - allowed = true - break - } - } - if !allowed { - return fmt.Errorf("path escape") - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 8<<20) - if err != nil { - return err - } - ext := strings.ToLower(filepath.Ext(firstNonEmpty(name, ref))) - switch ext { - case ".vtt": - _, err = io.WriteString(w, body) - case ".srt": - _, err = io.WriteString(w, srtToVTT(body)) - case ".ass", ".ssa": - _, err = io.WriteString(w, assToVTT(body)) - default: - return errors.New("unsupported subtitle format") - } - return err -} - -// serveCloudSubtitleRaw streams a cloud subtitle in its original format -// without WebVTT conversion, for Emby clients that parse by the advertised -// Codec. Shares the same access-control (discovery whitelist) checks as -// serveCloudSubtitle. -func serveCloudSubtitleRaw(ctx context.Context, s *SubtitleService, m model.Media, typ, ref, name string, w io.Writer) error { - if s == nil || s.storage == nil { - return errors.New("cloud storage service unavailable") - } - mediaTyp, _, ok := cloudSubtitleMediaRef(m) - if !ok || mediaTyp != typ { - return ErrCloudPlaybackUnavailable - } - allowed := false - for _, track := range discoverCloudSubtitles(ctx, s, m) { - if track.Path == buildCloudSubtitlePath(typ, ref, name) { - allowed = true - break - } - } - if !allowed { - return fmt.Errorf("path escape") - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 8<<20) - if err != nil { - return err - } - ext := strings.ToLower(filepath.Ext(firstNonEmpty(name, ref))) - switch ext { - case ".vtt", ".srt", ".ass", ".ssa": - _, err = io.WriteString(w, body) - default: - return errors.New("unsupported subtitle format") - } - return err -} diff --git a/internal/service/telegram_admin_codes.go b/internal/service/telegram_admin_codes.go deleted file mode 100644 index 9a86057..0000000 --- a/internal/service/telegram_admin_codes.go +++ /dev/null @@ -1,133 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) replyCapacity(ctx context.Context) telegramCommandReply { - c := s.loadCapacity(ctx) - quota := "未开放" - if c.OpenRegOn { - if c.OpenRegLimit > 0 { - quota = fmt.Sprintf("已开放(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) - } else { - quota = "已开放(不限名额,受授权上限约束)" - } - } - text := fmt.Sprintf("容量 / 状态\n\n授权上限:%d 人(随凭证授权实时变化)\n已用:%d 人\n剩余可注册:%d 人\n开注状态:%s", - c.MaxUsers, c.UsedUsers, c.Remaining(), quota) - return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}} -} - -func (s *TelegramBotService) replyOpenRegMenu(ctx context.Context) telegramCommandReply { - c := s.loadCapacity(ctx) - state := "未开放" - if c.OpenRegOn { - state = fmt.Sprintf("已开放(%d/%d)", c.OpenRegUsed, c.OpenRegLimit) - } - return telegramCommandReply{ - Text: "开注设置\n当前:" + state + "\n选择要开放的名额:", - Buttons: [][]telegramInlineButton{ - {{Text: "5 个", Data: "adm_openreg_set:5"}, {Text: "10 个", Data: "adm_openreg_set:10"}, {Text: "20 个", Data: "adm_openreg_set:20"}}, - {{Text: "不限名额", Data: "adm_openreg_set:0"}, {Text: "关闭注册", Data: "adm_openreg_close"}}, - {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, - }, - } -} - -func (s *TelegramBotService) replyGenCodeMenu() telegramCommandReply { - return telegramCommandReply{ - Text: "生成兑换码\n选择类型与时长:", - Buttons: [][]telegramInlineButton{ - {{Text: "注册码·30天", Data: "gc:register:30"}, {Text: "注册码·永久", Data: "gc:register:0"}}, - {{Text: "续期码·30天", Data: "gc:renew:30"}, {Text: "续期码·90天", Data: "gc:renew:90"}}, - {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, - }, - } -} - -func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMessage, data string) telegramCommandReply { - parts := strings.Split(data, ":") // gc:: - if len(parts) != 3 { - return telegramCommandReply{Text: "参数错误。"} - } - kind := parts[1] - days, _ := strconv.Atoi(parts[2]) - createdBy := "" - if u := s.boundUser(ctx, msg.From.ID); u != nil { - createdBy = u.ID - } - code, err := s.generateCode(ctx, kind, days, 0, createdBy) - if err != nil { - return telegramCommandReply{Text: "生成失败:" + err.Error()} - } - kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind] - dur := "永久" - if days > 0 { - dur = fmt.Sprintf("%d 天", days) - } - return telegramCommandReply{ - Text: fmt.Sprintf("已生成%s(%s):\n\n%s\n\n发给用户在 Bot 中兑换即可。", kindLabel, dur, code.Code), - Buttons: [][]telegramInlineButton{{{Text: "再生成一个", Data: "adm_gencode"}, {Text: "⬅️ 返回菜单", Data: "menu_main"}}}, - } -} - -func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/gencode register|renew 天数 [有效天数] [可用次数]\n示例:/gencode register 30、/gencode renew 90 7 5"} - } - kind := strings.ToLower(strings.TrimSpace(args[0])) - switch kind { - case "reg", "register", "注册码": - kind = model.RegistrationCodeRegister - case "renew", "续期", "续期码": - kind = model.RegistrationCodeRenew - default: - return telegramCommandReply{Text: "类型无效,只支持 register / renew。"} - } - days, err := strconv.Atoi(args[1]) - if err != nil || days < 0 { - return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"} - } - validDays := 0 - if len(args) > 2 { - validDays, err = strconv.Atoi(args[2]) - if err != nil || validDays < 0 { - return telegramCommandReply{Text: "有效天数必须是非负整数。"} - } - } - maxUses := 1 - if len(args) > 3 { - maxUses, err = strconv.Atoi(args[3]) - if err != nil || maxUses <= 0 { - return telegramCommandReply{Text: "可用次数必须是正整数。"} - } - } - createdBy := "" - if u := s.boundUser(ctx, msg.From.ID); u != nil { - createdBy = u.ID - } - code, err := s.generateCodeWithUses(ctx, kind, days, validDays, maxUses, createdBy) - if err != nil { - return telegramCommandReply{Text: "生成失败:" + err.Error()} - } - kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind] - dur := "永久" - if days > 0 { - dur = fmt.Sprintf("%d 天", days) - } - valid := "长期有效" - if validDays > 0 && code.ExpiresAt != nil { - valid = "有效至 " + code.ExpiresAt.Format("2006-01-02 15:04") - } - uses := "单次使用" - if code.EffectiveMaxUses() > 1 { - uses = fmt.Sprintf("最多 %d 次", code.EffectiveMaxUses()) - } - return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s,%s):\n\n%s", kindLabel, dur, valid, uses, code.Code)} -} diff --git a/internal/service/telegram_admin_users.go b/internal/service/telegram_admin_users.go deleted file mode 100644 index 2653af2..0000000 --- a/internal/service/telegram_admin_users.go +++ /dev/null @@ -1,186 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" -) - -func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply { - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - if len(users) == 0 { - return telegramCommandReply{Text: "暂无用户。"} - } - var rows [][]telegramInlineButton - limit := len(users) - if limit > 12 { - limit = 12 - } - for i := 0; i < limit; i++ { - u := users[i] - flag := "" - if !u.IsActive { - flag = "🚫" - } - if u.Role == "admin" { - flag = "👑" - } - rows = append(rows, []telegramInlineButton{{Text: flag + " " + u.Username, Data: "usr:" + u.ID}}) - } - rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}}) - return telegramCommandReply{Text: fmt.Sprintf("用户管理(共 %d 人,显示前 %d)\n点击用户进行操作:", len(users), limit), Buttons: rows} -} - -func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string) telegramCommandReply { - u, err := s.repo.User.FindByID(ctx, userID) - if err != nil || u == nil { - return telegramCommandReply{Text: "用户不存在。"} - } - protected := UserIsProtectedAccount(ctx, s.repo, u) - text := fmt.Sprintf("%s\n角色:%s\n状态:%s\n到期:%s\n防共享警告:%d 次", - u.Username, u.Role, map[bool]string{true: "正常", false: "已禁用"}[u.IsActive], formatExpiry(u.ExpiredAt), u.ShareWarnings) - if protected { - return telegramCommandReply{Text: text + "\n\n(受保护账号,不可禁用/删除)", Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}} - } - banBtn := telegramInlineButton{Text: "🚫 禁用", Data: "uban:" + u.ID} - if !u.IsActive { - banBtn = telegramInlineButton{Text: "✅ 解禁", Data: "uunban:" + u.ID} - } - return telegramCommandReply{ - Text: text, - Buttons: [][]telegramInlineButton{ - {banBtn, {Text: "⏳ 续期30天", Data: "urenew:" + u.ID + ":30"}}, - {{Text: "🗑 删除用户", Data: "udel:" + u.ID}}, - {{Text: "⬅️ 返回", Data: "adm_users"}}, - }, - } -} - -func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, unban bool) telegramCommandReply { - if !unban { - if reason := s.protectReason(ctx, userID); reason != "" { - return telegramCommandReply{Text: reason} - } - } - updates := map[string]any{"is_active": unban} - if unban { - updates["share_warnings"] = 0 - updates["last_share_warn_at"] = nil - } - if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - if unban { - _ = s.repo.UserDevice.SetKickedByUser(ctx, userID, false) - } - return s.replyUserActions(ctx, userID) -} - -func (s *TelegramBotService) replyUserDelete(ctx context.Context, userID string) telegramCommandReply { - if reason := s.protectReason(ctx, userID); reason != "" { - return telegramCommandReply{Text: reason} - } - u, _ := s.repo.User.FindByID(ctx, userID) - _ = s.repo.UserDevice.DeleteByUser(ctx, userID) - if err := s.repo.User.Delete(ctx, userID); err != nil { - return telegramCommandReply{Text: "删除失败:" + err.Error()} - } - name := userID - if u != nil { - name = u.Username - } - return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}} -} - -func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply { - parts := strings.Split(payload, ":") // : - if len(parts) != 2 { - return telegramCommandReply{Text: "参数错误。"} - } - days, _ := strconv.Atoi(parts[1]) - if err := s.applyRenewal(ctx, parts[0], days); err != nil { - return telegramCommandReply{Text: "续期失败:" + err.Error()} - } - return s.replyUserActions(ctx, parts[0]) -} - -func (s *TelegramBotService) cmdUserRenew(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/renew_user 用户名 天数,天数 0 表示永久。"} - } - user, _ := s.repo.User.FindByUsername(ctx, args[0]) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, args[0]) - } - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - days, err := strconv.Atoi(args[1]) - if err != nil || days < 0 { - return telegramCommandReply{Text: "天数必须是非负整数。"} - } - if err := s.applyRenewal(ctx, user.ID, days); err != nil { - return telegramCommandReply{Text: "续期失败:" + err.Error()} - } - return s.replyUserActions(ctx, user.ID) -} - -func (s *TelegramBotService) cmdUserDelete(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/delete_user 用户名 confirm\n为避免误删,最后一个参数必须是 confirm。"} - } - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "删除用户需要确认:/delete_user 用户名 confirm"} - } - user, _ := s.repo.User.FindByUsername(ctx, args[0]) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, args[0]) - } - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - return s.replyUserDelete(ctx, user.ID) -} - -// protectReason returns a non-empty message when a user must not be -// disabled/deleted (admins, default admin and protected-list users). -func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string { - u, err := s.repo.User.FindByID(ctx, userID) - if err != nil || u == nil { - return "用户不存在。" - } - if u.Role == "admin" { - return "管理员账号受保护,不可禁用/删除。" - } - if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID { - return "默认管理员账号受保护,不可禁用/删除。" - } - if _, ok := ProtectedUserIDSet(ctx, s.repo)[u.ID]; ok { - return "该账号在 Bot 保护名单中,不可禁用/删除。" - } - if s.device != nil && s.device.UserRecentlyActive(ctx, u.ID, realtimeSessionTTL) { - return "该账号最近仍有实时活跃会话,为避免误删/误禁用,请先确认用户已下线。" - } - return "" -} - -func (s *TelegramBotService) cmdUserBan(ctx context.Context, args []string, unban bool) telegramCommandReply { - if len(args) == 0 { - if unban { - return telegramCommandReply{Text: "用法:/unban 用户名"} - } - return telegramCommandReply{Text: "用法:/ban 用户名"} - } - user, _ := s.repo.User.FindByUsername(ctx, args[0]) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, args[0]) - } - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - return s.replyUserBan(ctx, user.ID, unban) -} diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go deleted file mode 100644 index 6ea8383..0000000 --- a/internal/service/telegram_api.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "strings" - "time" -) - -func telegramPostForm(ctx context.Context, cfg map[string]string, method string, form url.Values, timeout time.Duration) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, form.Encode(), "application/x-www-form-urlencoded", timeout) -} - -func telegramPostJSON(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, string(body), "application/json", timeout) -} - -func telegramPostMultipart(ctx context.Context, cfg map[string]string, method string, fields map[string]string, fileField, fileName string, file []byte, timeout time.Duration) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - var body bytes.Buffer - writer := multipart.NewWriter(&body) - for key, value := range fields { - if strings.TrimSpace(value) == "" { - continue - } - if err := writer.WriteField(key, value); err != nil { - _ = writer.Close() - return err - } - } - part, err := writer.CreateFormFile(fileField, fileName) - if err != nil { - _ = writer.Close() - return err - } - if _, err := part.Write(file); err != nil { - _ = writer.Close() - return err - } - if err := writer.Close(); err != nil { - return err - } - return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, body.String(), writer.FormDataContentType(), timeout) -} - -func telegramFetchRemotePhoto(ctx context.Context, cfg map[string]string, rawURL string, timeout time.Duration) ([]byte, string, error) { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return nil, "", errors.New("telegram photo url required") - } - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return nil, "", err - } - req.Header.Set("User-Agent", "MediaStationGo/1.0") - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024+1)) - _ = resp.Body.Close() - if readErr != nil { - lastErr = readErr - continue - } - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("photo fetch error %d", resp.StatusCode) - continue - } - if len(body) == 0 { - lastErr = errors.New("photo fetch returned empty body") - continue - } - if len(body) > 10*1024*1024 { - lastErr = errors.New("photo too large") - continue - } - contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) - return body, contentType, nil - } - if lastErr != nil { - return nil, "", lastErr - } - return nil, "", errors.New("photo fetch failed") -} - -func deleteTelegramWebhook(ctx context.Context, cfg map[string]string) error { - payload := map[string]any{ - "drop_pending_updates": false, - } - return telegramPostJSON(ctx, cfg, "deleteWebhook", payload, 15*time.Second) -} - -func telegramDo(client *http.Client, req *http.Request) error { - resp, err := client.Do(req) - if err != nil { - return sanitizeTelegramError(err) - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(body))) - } - return nil -} - -func telegramDoWithFallback(ctx context.Context, cfg map[string]string, method, apiURL, body, contentType string, timeout time.Duration) error { - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, method, apiURL, strings.NewReader(body)) - if err != nil { - return err - } - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - if err := telegramDo(client, req); err != nil { - lastErr = err - continue - } - return nil - } - if lastErr != nil { - return lastErr - } - return errors.New("telegram request failed") -} - -func telegramPostJSONDecode(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration, out any) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(body))) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - _ = resp.Body.Close() - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) - continue - } - if out != nil { - return json.Unmarshal(respBody, out) - } - return nil - } - if lastErr != nil { - return lastErr - } - return errors.New("telegram request failed") -} - -func telegramGetJSONDecode(ctx context.Context, cfg map[string]string, method string, timeout time.Duration, out any) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) - if err != nil { - return err - } - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - _ = resp.Body.Close() - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) - continue - } - if out != nil { - return json.Unmarshal(respBody, out) - } - return nil - } - if lastErr != nil { - return lastErr - } - return errors.New("telegram request failed") -} - -func telegramStringConfigFromAny(cfg map[string]any) map[string]string { - out := make(map[string]string, len(cfg)) - for key, value := range cfg { - out[key] = str(value) - } - normalizeTelegramConfig(out) - return out -} diff --git a/internal/service/telegram_api_client.go b/internal/service/telegram_api_client.go deleted file mode 100644 index 3a88709..0000000 --- a/internal/service/telegram_api_client.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "net/http" - "net/url" - "os" - "regexp" - "strings" - "time" -) - -const defaultTelegramAPIBaseURL = "https://api.telegram.org" - -var telegramTokenPattern = regexp.MustCompile(`bot[0-9]+:[^/\s"'?]+`) - -func telegramAPIBaseURL(cfg map[string]string) string { - base := strings.TrimSpace(cfg["api_base_url"]) - if base == "" { - base = strings.TrimSpace(os.Getenv("MEDIASTATION_TELEGRAM_API_BASE_URL")) - } - if base == "" { - base = defaultTelegramAPIBaseURL - } - return strings.TrimRight(base, "/") -} - -func telegramMethodURL(cfg map[string]string, botToken, method string) (string, error) { - botToken = strings.TrimSpace(botToken) - method = strings.TrimSpace(method) - if botToken == "" { - return "", errors.New("telegram bot_token required") - } - if method == "" { - return "", errors.New("telegram method required") - } - base := telegramAPIBaseURL(cfg) - if _, err := url.ParseRequestURI(base); err != nil { - return "", fmt.Errorf("telegram api_base_url invalid") - } - return fmt.Sprintf("%s/bot%s/%s", base, botToken, method), nil -} - -func telegramHTTPClient(timeout time.Duration, cfg map[string]string) *http.Client { - clients := telegramHTTPClients(timeout, cfg) - return clients[0] -} - -func telegramHTTPClients(timeout time.Duration, cfg map[string]string) []*http.Client { - clients := []*http.Client{} - seen := map[string]bool{} - customAPIBase := telegramUsesCustomAPIBase(cfg) - for _, proxyRaw := range telegramProxyCandidates(cfg) { - proxyURL, err := normalizeProxyURL(proxyRaw, "http") - if err != nil || proxyURL == nil { - continue - } - key := proxyURL.String() - if seen[key] { - continue - } - seen[key] = true - transport := NewExternalTransport() - transport.Proxy = http.ProxyURL(proxyURL) - clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) - } - transport := NewExternalTransport() - if customAPIBase { - transport = NewInternalTransport() - } - clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) - return clients -} - -func telegramProxyCandidates(cfg map[string]string) []string { - out := []string{} - for _, value := range []string{ - cfg["proxy_url"], - os.Getenv("MEDIASTATION_TELEGRAM_PROXY_URL"), - } { - if strings.TrimSpace(value) != "" { - out = append(out, value) - } - } - if len(out) > 0 { - return out - } - if telegramUsesCustomAPIBase(cfg) { - return out - } - for _, value := range []string{ - "http://127.0.0.1:10808", - "http://127.0.0.1:10809", - "http://127.0.0.1:7890", - "http://127.0.0.1:7891", - "http://host.docker.internal:7890", - "http://host.docker.internal:10808", - "http://172.17.0.1:7890", - "http://172.17.0.1:10808", - } { - out = append(out, value) - } - return out -} - -func telegramUsesCustomAPIBase(cfg map[string]string) bool { - return telegramAPIBaseURL(cfg) != defaultTelegramAPIBaseURL -} - -func sanitizeTelegramError(err error) error { - if err == nil { - return nil - } - msg := sanitizeTelegramText(err.Error()) - if strings.Contains(msg, "Client.Timeout exceeded") || strings.Contains(msg, "context deadline exceeded") { - return errors.New("telegram request timeout: 请检查 NAS/Docker 到 Telegram API 的代理、反代或网络连通性") - } - return errors.New(msg) -} - -func sanitizeTelegramText(text string) string { - return telegramTokenPattern.ReplaceAllString(text, "bot") -} diff --git a/internal/service/telegram_api_commands_test.go b/internal/service/telegram_api_commands_test.go deleted file mode 100644 index 302806a..0000000 --- a/internal/service/telegram_api_commands_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestRegisterTelegramBotCommands(t *testing.T) { - var gotPath string - var payloads []struct { - Commands []telegramBotCommand `json:"commands"` - Scope map[string]any `json:"scope"` - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - var payload struct { - Commands []telegramBotCommand `json:"commands"` - Scope map[string]any `json:"scope"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - payloads = append(payloads, payload) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - err := registerTelegramBotCommands(t.Context(), map[string]string{ - "bot_token": "123456:ABC", - "api_base_url": server.URL, - }) - if err != nil { - t.Fatal(err) - } - if gotPath != "/bot123456:ABC/setMyCommands" { - t.Fatalf("path = %q", gotPath) - } - if len(payloads) < 3 { - t.Fatalf("expected default/private/group command registrations, got %d", len(payloads)) - } - if len(payloads[0].Commands) == 0 || payloads[0].Commands[0].Command != "start" { - t.Fatalf("commands not registered: %#v", payloads[0].Commands) - } - var groupCommands []telegramBotCommand - for _, payload := range payloads { - if payload.Scope["type"] == "all_group_chats" { - groupCommands = payload.Commands - break - } - } - if len(groupCommands) == 0 { - t.Fatal("group command scope was not registered") - } - for _, command := range groupCommands { - if command.Command == "users" || command.Command == "status" || command.Command == "cleanup" || command.Command == "register" || command.Command == "redeem" { - t.Fatalf("group commands must not expose private/admin command %q", command.Command) - } - } -} - -func TestDeleteTelegramWebhookBeforePolling(t *testing.T) { - var gotPath string - var payload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - err := deleteTelegramWebhook(t.Context(), map[string]string{ - "bot_token": "123456:ABC", - "api_base_url": server.URL, - }) - if err != nil { - t.Fatal(err) - } - if gotPath != "/bot123456:ABC/deleteWebhook" { - t.Fatalf("path = %q", gotPath) - } - if got := payload["drop_pending_updates"]; got != false { - t.Fatalf("drop_pending_updates = %#v, want false", got) - } -} - -func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) { - privateNames := telegramCommandNames(telegramPrivateBotCommandMenu()) - for _, required := range []string{"setname", "setpass"} { - if !privateNames[required] { - t.Fatalf("private menu should include %s", required) - } - } - for _, hiddenAlias := range []string{"myinfo", "count"} { - if privateNames[hiddenAlias] { - t.Fatalf("private menu should hide compatibility alias %s", hiddenAlias) - } - if !telegramSupportedCommand("/" + hiddenAlias) { - t.Fatalf("compatibility alias /%s should remain executable", hiddenAlias) - } - } - - groupNames := telegramCommandNames(telegramGroupBotCommandMenu()) - for _, forbidden := range []string{"status", "search", "downloads", "stats", "users", "cleanup", "cleanup_rule", "register", "redeem"} { - if groupNames[forbidden] { - t.Fatalf("group menu should not expose %s", forbidden) - } - } - for _, required := range []string{"start", "menu", "help", "account", "signin", "devices", "kick", "hideadult"} { - if !groupNames[required] { - t.Fatalf("group menu should include %s", required) - } - } - adminCommands := telegramAdminBotCommandMenu() - adminNames := telegramCommandNames(adminCommands) - for _, required := range []string{"users", "status", "cleanup_mode", "cleanup_rule", "ucr", "uinfo", "rmemby", "only_rm_record", "renewall", "userip", "auditip", "auditdevice", "auditclient", "udeviceid", "syncunbound", "syncgroupm", "check_ex", "deleted", "embyadmin", "banall", "unbanall", "prouser", "revuser", "embylibs_blockall", "embylibs_unblockall", "proadmin", "revadmin", "backup_db", "restore_from_db"} { - if !adminNames[required] { - t.Fatalf("admin menu should include %s", required) - } - } - for _, hiddenAlias := range []string{"myinfo", "count", "low_activity", "urm", "only_rm_emby", "extraembylibs_blockall", "extraembylibs_unblockall"} { - if adminNames[hiddenAlias] { - t.Fatalf("admin menu should hide compatibility alias %s", hiddenAlias) - } - } - for _, command := range adminCommands { - if strings.Contains(command.Description, "Mgo 兼容") { - t.Fatalf("admin menu command %s should use native Mgo wording: %q", command.Command, command.Description) - } - } - help := telegramMgoAdminCommandHelp() - for _, want := range []string{"用户:", "审计:", "清理:", "权限:", "运维:"} { - if !strings.Contains(help, want) { - t.Fatalf("mgo admin help should include category %q in %q", want, help) - } - } - if strings.Contains(help, "/setpass") { - t.Fatalf("mgo admin help should not include user self-service command /setpass") - } -} - -func telegramCommandNames(commands []telegramBotCommand) map[string]bool { - names := make(map[string]bool, len(commands)) - for _, command := range commands { - names[command.Command] = true - } - return names -} - -func TestTelegramCommandFiltering(t *testing.T) { - if telegramIsCommandText("今天看什么") { - t.Fatal("plain chat message should not be treated as command") - } - if !telegramIsCommandText("/start user pass") { - t.Fatal("/start should be treated as command") - } - if got := telegramCommandName("/hideadult@MediaStationGoBot on"); got != "/hideadult" { - t.Fatalf("telegramCommandName = %q, want /hideadult", got) - } - if telegramSupportedCommand("/签到") { - t.Fatal("unrelated group bot command should not be handled") - } - for _, cmd := range []string{"/signin", "/redeem", "/gencode", "/users", "/renew_user", "/delete_user", "/cleanup_rule"} { - if !telegramSupportedCommand(cmd) { - t.Fatalf("%s should be supported so group slash commands get feedback", cmd) - } - } - for _, cmd := range []string{"/restart", "/update_bot", "/coins", "/red", "/white_channel", "/config"} { - if telegramSupportedCommand(cmd) { - t.Fatalf("%s should not be treated as supported until it has a real Mgo implementation", cmd) - } - } -} - -func TestTelegramSupportedCommandSetMatchesRegistry(t *testing.T) { - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "admin"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - for _, def := range bot.telegramCommandDefinitions(t.Context(), channel, msg) { - for _, alias := range def.Aliases { - if !telegramSupportedCommand(alias) { - t.Fatalf("registered command %s must be in telegramSupportedCommandSet", alias) - } - } - } -} diff --git a/internal/service/telegram_api_proxy_test.go b/internal/service/telegram_api_proxy_test.go deleted file mode 100644 index f7ac75c..0000000 --- a/internal/service/telegram_api_proxy_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package service - -import ( - "net/http" - "strings" - "testing" - "time" -) - -func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) { - got := telegramProxyCandidates(map[string]string{}) - joined := strings.Join(got, ",") - for _, want := range []string{"127.0.0.1:10808", "127.0.0.1:7890", "host.docker.internal:7890", "172.17.0.1:7890"} { - if !strings.Contains(joined, want) { - t.Fatalf("default proxy candidates %q missing %q", joined, want) - } - } -} - -func TestTelegramHTTPClientsCustomAPIBaseSkipsDefaultProxyFallback(t *testing.T) { - clients := telegramHTTPClients(time.Second, map[string]string{ - "api_base_url": "http://127.0.0.1:18080", - }) - if len(clients) != 1 { - t.Fatalf("clients = %d, want direct client only", len(clients)) - } - if got := telegramClientProxyString(t, clients[0]); got != "" { - t.Fatalf("custom api_base_url proxy = %q, want direct", got) - } -} - -func TestTelegramHTTPClientsPreferConfiguredProxy(t *testing.T) { - clients := telegramHTTPClients(time.Second, map[string]string{ - "proxy_url": "http://proxy.example:7890", - }) - if len(clients) == 0 { - t.Fatal("expected telegram clients") - } - if got := telegramClientProxyString(t, clients[0]); got != "http://proxy.example:7890" { - t.Fatalf("first client proxy = %q, want configured proxy", got) - } -} - -func telegramClientProxyString(t *testing.T, client *http.Client) string { - t.Helper() - transport, ok := client.Transport.(*http.Transport) - if !ok || transport.Proxy == nil { - return "" - } - req, err := http.NewRequest(http.MethodGet, defaultTelegramAPIBaseURL, nil) - if err != nil { - t.Fatal(err) - } - proxyURL, err := transport.Proxy(req) - if err != nil { - t.Fatal(err) - } - if proxyURL == nil { - return "" - } - return proxyURL.String() -} diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go deleted file mode 100644 index b6c944b..0000000 --- a/internal/service/telegram_api_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package service - -import ( - "errors" - "strings" - "testing" -) - -func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) { - got, err := telegramMethodURL(map[string]string{ - "api_base_url": "https://tg.example.com/", - }, "123456:ABC-def", "sendMessage") - if err != nil { - t.Fatalf("telegramMethodURL returned error: %v", err) - } - want := "https://tg.example.com/bot123456:ABC-def/sendMessage" - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestSanitizeTelegramErrorRedactsBotToken(t *testing.T) { - err := sanitizeTelegramError(errors.New(`Post "https://api.telegram.org/bot123456:SECRET/sendMessage": context deadline exceeded`)) - if err == nil { - t.Fatal("expected error") - } - msg := err.Error() - if strings.Contains(msg, "SECRET") || strings.Contains(msg, "123456:") { - t.Fatalf("telegram token leaked in error: %s", msg) - } - if !strings.Contains(msg, "timeout") { - t.Fatalf("expected timeout hint, got: %s", msg) - } -} - -func TestValidateTelegramChannelDoesNotRequireLegacyChatID(t *testing.T) { - err := validateChannel(ChannelInput{ - Name: "Telegram", - Type: "telegram", - Config: map[string]any{ - "bot_token": "123456:ABC-def", - "admin_user_ids": "10001", - }, - }) - if err != nil { - t.Fatalf("validateChannel returned error: %v", err) - } -} - -func TestTelegramTargetChatIDsFallsBackToAdmins(t *testing.T) { - got := telegramTargetChatIDs(map[string]string{ - "admin_user_ids": "10001, 10002", - }) - if len(got) != 2 || got[0] != "10001" || got[1] != "10002" { - t.Fatalf("got %#v, want admin user ids", got) - } -} - -func TestNormalizeTelegramChannelMigratesLegacyChatID(t *testing.T) { - input := ChannelInput{ - Name: "Telegram", - Type: "telegram", - Config: map[string]any{ - "chat_id": "-10001", - }, - } - normalizeChannelInput(&input) - if got := str(input.Config["group_chat_id"]); got != "-10001" { - t.Fatalf("group_chat_id = %q, want -10001", got) - } -} - -func TestNormalizeTelegramChannelMigratesLegacyPrivateChatIDToAdmin(t *testing.T) { - cfg := map[string]string{"chat_id": "5812333517"} - normalizeTelegramConfig(cfg) - if got := cfg["admin_user_ids"]; got != "5812333517" { - t.Fatalf("admin_user_ids = %q, want legacy chat_id", got) - } -} - -func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) { - got := telegramTargetChatIDs(map[string]string{ - "chat_id": "5812333517", - }) - if len(got) != 1 || got[0] != "5812333517" { - t.Fatalf("got %#v, want legacy private chat target", got) - } -} diff --git a/internal/service/telegram_api_webhook_test.go b/internal/service/telegram_api_webhook_test.go deleted file mode 100644 index 81ca0a9..0000000 --- a/internal/service/telegram_api_webhook_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -func TestTelegramReplyAutoDeletesSentMessage(t *testing.T) { - requests := make(chan string, 4) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/sendMessage"): - requests <- "sendMessage" - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`)) - case strings.HasSuffix(r.URL.Path, "/deleteMessage"): - requests <- "deleteMessage" - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":true}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - cfg, _ := json.Marshal(map[string]string{ - "bot_token": "123456:ABC-def", - "api_base_url": server.URL, - "auto_delete_seconds": "0", - }) - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)} - if err := bot.reply(context.Background(), channel, 42, telegramCommandReply{Text: "hello"}); err != nil { - t.Fatalf("reply: %v", err) - } - waitForTelegramMethod(t, requests, "sendMessage") - waitForTelegramMethod(t, requests, "deleteMessage") -} - -func TestTelegramGroupCommandSendsPanelInGroup(t *testing.T) { - var payloads []struct { - ChatID any `json:"chat_id"` - Text string `json:"text"` - ReplyMarkup map[string]any `json:"reply_markup"` - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasSuffix(r.URL.Path, "/sendMessage") { - http.NotFound(w, r) - return - } - var payload struct { - ChatID any `json:"chat_id"` - Text string `json:"text"` - ReplyMarkup map[string]any `json:"reply_markup"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode sendMessage: %v", err) - } - payloads = append(payloads, payload) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`)) - })) - defer server.Close() - - repos, bot := newBotTestService(t) - cfg, _ := json.Marshal(map[string]string{ - "bot_token": "123456:ABC-def", - "api_base_url": server.URL, - "group_chat_id": "-100123", - "auto_delete_seconds": "-1", - }) - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil { - t.Fatalf("create channel: %v", err) - } - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 1, - Message: &TelegramMessage{ - MessageID: 55, - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123, Type: "supergroup"}, - Text: "/menu", - }, - }) - if err := bot.HandleWebhook(t.Context(), update); err != nil { - t.Fatalf("handle webhook: %v", err) - } - if len(payloads) != 1 { - t.Fatalf("sendMessage count = %d, payloads=%#v", len(payloads), payloads) - } - if got := fmt.Sprint(payloads[0].ChatID); got != "-100123" { - t.Fatalf("message should stay in group, chat_id=%s payload=%#v", got, payloads[0]) - } - if strings.Contains(payloads[0].Text, "管理员入口") { - t.Fatalf("normal group user must not see admin panel: %#v", payloads[0]) - } -} - -func TestTelegramGroupCallbackIsRejected(t *testing.T) { - var callbackPayloads []struct { - CallbackID string `json:"callback_query_id"` - Text string `json:"text"` - ShowAlert bool `json:"show_alert"` - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasSuffix(r.URL.Path, "/answerCallbackQuery") { - http.NotFound(w, r) - return - } - var payload struct { - CallbackID string `json:"callback_query_id"` - Text string `json:"text"` - ShowAlert bool `json:"show_alert"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode answerCallbackQuery: %v", err) - } - callbackPayloads = append(callbackPayloads, payload) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":true}`)) - })) - defer server.Close() - - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register user: %v", err) - } - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify channel: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9002, - TelegramName: "@viewer", - ChatID: 9002, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - cfg, _ := json.Marshal(map[string]string{ - "bot_token": "123456:ABC-def", - "api_base_url": server.URL, - "group_chat_id": "-100123", - "auto_delete_seconds": "-1", - }) - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil { - t.Fatalf("create channel: %v", err) - } - before, _ := repos.User.FindByID(ctx, user.ID) - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 2, - CallbackQuery: &TelegramCallbackQuery{ - ID: "cb-group", - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Message: &TelegramMessage{MessageID: 56, Chat: TelegramChat{ID: -100123, Type: "supergroup"}}, - Data: "adult_toggle", - }, - }) - if err := bot.HandleWebhook(ctx, update); err != nil { - t.Fatalf("handle webhook: %v", err) - } - if len(callbackPayloads) != 1 { - t.Fatalf("answerCallbackQuery count = %d", len(callbackPayloads)) - } - if !callbackPayloads[0].ShowAlert || !strings.Contains(callbackPayloads[0].Text, "群组内按钮面板已禁用") { - t.Fatalf("unexpected callback answer: %#v", callbackPayloads[0]) - } - after, _ := repos.User.FindByID(ctx, user.ID) - if before == nil || after == nil || before.HideAdult != after.HideAdult { - t.Fatalf("group callback should not mutate user adult visibility: before=%#v after=%#v", before, after) - } -} - -func waitForTelegramMethod(t *testing.T, requests <-chan string, want string) { - t.Helper() - deadline := time.After(2 * time.Second) - for { - select { - case got := <-requests: - if got == want { - return - } - case <-deadline: - t.Fatalf("timed out waiting for telegram %s", want) - } - } -} diff --git a/internal/service/telegram_binding.go b/internal/service/telegram_binding.go deleted file mode 100644 index cb16c66..0000000 --- a/internal/service/telegram_binding.go +++ /dev/null @@ -1,139 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID int) *model.TelegramBinding { - if telegramUserID == 0 { - return nil - } - var binding model.TelegramBinding - err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(telegramUserID)).First(&binding).Error - if err != nil { - return nil - } - return &binding -} - -func (s *TelegramBotService) unbindTelegramUser(ctx context.Context, telegramUserID int) error { - if s == nil || s.repo == nil || s.repo.DB == nil || telegramUserID == 0 { - return nil - } - return s.repo.DB.WithContext(ctx).Unscoped(). - Where("telegram_user_id = ?", int64(telegramUserID)). - Delete(&model.TelegramBinding{}).Error -} - -func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error { - name := strings.TrimSpace(msg.From.FirstName) - if msg.From.Username != "" { - name = "@" + strings.TrimSpace(msg.From.Username) - } - telegramUserID := int64(msg.From.ID) - return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var existing model.TelegramBinding - err := tx.Where("telegram_user_id = ?", telegramUserID).First(&existing).Error - if err == nil { - if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil { - return err - } - if err := tx.Model(&existing).Updates(map[string]any{ - "telegram_name": name, - "chat_id": telegramBindingChatIDForMessage(msg, &existing), - "user_id": userID, - }).Error; telegramBindingUniqueErr(err) { - return errTelegramAccountAlreadyBound - } else if err != nil { - return err - } - return nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if err := tx.Unscoped().Where("telegram_user_id = ?", telegramUserID).Delete(&model.TelegramBinding{}).Error; err != nil { - return err - } - if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil { - return err - } - err = tx.Create(&model.TelegramBinding{ - TelegramUserID: telegramUserID, - TelegramName: name, - ChatID: telegramBindingChatIDForMessage(msg, nil), - UserID: userID, - }).Error - if telegramBindingUniqueErr(err) { - return errTelegramAccountAlreadyBound - } - return err - }) -} - -func telegramBindingChatIDForMessage(msg *TelegramMessage, existing *model.TelegramBinding) int64 { - if msg == nil { - if existing != nil { - return existing.ChatID - } - return 0 - } - if msg.Chat.Type == "" || msg.Chat.Type == "private" { - return int64(msg.Chat.ID) - } - if existing != nil && existing.ChatID > 0 { - return existing.ChatID - } - return int64(msg.From.ID) -} - -func telegramPrivateChatIDFromBinding(binding model.TelegramBinding) int64 { - if binding.ChatID > 0 { - return binding.ChatID - } - return binding.TelegramUserID -} - -func (s *TelegramBotService) replaceTelegramAccountBindingTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error { - return tx.WithContext(ctx).Unscoped(). - Where("user_id = ? AND telegram_user_id <> ?", userID, telegramUserID). - Delete(&model.TelegramBinding{}).Error -} - -func telegramBindingUniqueErr(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "idx_telegram_bindings_user_id_active") || - strings.Contains(msg, "telegram_bindings.user_id") || - (strings.Contains(msg, "unique") && strings.Contains(msg, "telegram_bindings")) -} - -func parseStartCredentials(args []string) (string, string) { - if len(args) >= 2 { - return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " ")) - } - if len(args) == 1 { - raw := strings.TrimSpace(args[0]) - for _, sep := range []string{"-", ":", ":"} { - if parts := strings.SplitN(raw, sep, 2); len(parts) == 2 { - return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) - } - } - } - return "", "" -} - -func userNameOrFallback(user *model.User) string { - if user == nil || strings.TrimSpace(user.Username) == "" { - return "未知用户" - } - return user.Username -} diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go deleted file mode 100644 index f98a22e..0000000 --- a/internal/service/telegram_bot.go +++ /dev/null @@ -1,298 +0,0 @@ -// Package service — Telegram Bot 交互命令服务。 -// -// 处理通过 Telegram Bot API 接收的用户命令,提供系统状态查询、 -// 媒体搜索、下载管理等功能。同时支持 Webhook 和 Long Polling 两种模式。 -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// TelegramUpdate 是 Telegram Bot API 推送的 update 对象。 -type TelegramUpdate struct { - UpdateID int `json:"update_id"` - Message *TelegramMessage `json:"message,omitempty"` - CallbackQuery *TelegramCallbackQuery `json:"callback_query,omitempty"` -} - -// TelegramMessage 是 Telegram 消息对象。 -type TelegramMessage struct { - MessageID int `json:"message_id"` - From TelegramUser `json:"from"` - Chat TelegramChat `json:"chat"` - Text string `json:"text,omitempty"` - Date int `json:"date"` -} - -type TelegramCallbackQuery struct { - ID string `json:"id"` - From TelegramUser `json:"from"` - Message *TelegramMessage `json:"message,omitempty"` - Data string `json:"data,omitempty"` -} - -// TelegramUser 是 Telegram 用户对象。 -type TelegramUser struct { - ID int `json:"id"` - FirstName string `json:"first_name"` - Username string `json:"username,omitempty"` -} - -// TelegramChat 是 Telegram 聊天对象。 -type TelegramChat struct { - ID int `json:"id"` - Type string `json:"type"` -} - -type telegramCommandReply struct { - Text string - Buttons [][]telegramInlineButton -} - -type telegramInlineButton struct { - Text string `json:"text"` - Data string `json:"callback_data"` -} - -// TelegramBotService 处理 Telegram Bot 的交互命令。 -type TelegramBotService struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - auth *AuthService - device *DeviceService - backup *BackupService - - pollingMu sync.Mutex - pollingCancel map[string]context.CancelFunc // bot_token -> cancel - - pendingMu sync.Mutex - pending map[int64]pendingInput // telegram_user_id -> awaited text input -} - -// pendingInput tracks a button-initiated action that awaits the user's next -// text message (e.g. tapping「注册」then sending "用户名 密码"). -type pendingInput struct { - Kind string // register / redeem_register / redeem_renew / setname / setpass / openreg_limit / gencode_user - CreatedAt time.Time -} - -// SetDeviceService wires the device-management service used by the device -// menu (list / kick) and enforcement notifications. -func (s *TelegramBotService) SetDeviceService(d *DeviceService) { s.device = d } - -// SetBackupService wires database backup/restore commands. -func (s *TelegramBotService) SetBackupService(b *BackupService) { s.backup = b } - -// NotifyUserByID sends a Telegram message to the local user identified by -// userID, resolved through their Telegram binding. Used by enforcement to warn -// users before destructive actions. No-op when the user has no binding. -func (s *TelegramBotService) NotifyUserByID(ctx context.Context, userID, text string) { - if userID == "" || strings.TrimSpace(text) == "" { - return - } - var binding model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", userID).First(&binding).Error; err != nil { - return - } - targetChatID := telegramPrivateChatIDFromBinding(binding) - if targetChatID == 0 { - return - } - channel := s.findChannelByChatID(ctx, int(binding.ChatID)) - if channel == nil { - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil || len(channels) == 0 { - return - } - channel = &channels[0] - } - _ = s.reply(ctx, channel, int(targetChatID), telegramCommandReply{Text: text}) -} - -// NewTelegramBotService 创建 Telegram Bot 服务。 -func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *CryptoService, auth *AuthService) *TelegramBotService { - return &TelegramBotService{ - log: log, - repo: repo, - crypto: crypto, - auth: auth, - pollingCancel: make(map[string]context.CancelFunc), - pending: make(map[int64]pendingInput), - } -} - -// TelegramRegistrationSettingKey 控制普通用户是否可以通过 Bot 注册新账号。 -// 默认关闭,只有管理员在系统设置 / Bot 管理命令中显式开启后才允许注册。 -const TelegramRegistrationSettingKey = "telegram.registration_enabled" - -var errTelegramAccountAlreadyBound = errors.New("该媒体账号已绑定其他 Telegram,请联系管理员解绑") - -// registrationEnabled 读取注册开关;默认关闭。 -func (s *TelegramBotService) registrationEnabled(ctx context.Context) bool { - v, err := s.repo.Setting.Get(ctx, TelegramRegistrationSettingKey) - if err != nil { - return false - } - return parseBoolSetting(v, false) -} - -// setRegistrationEnabled 持久化注册开关。 -func (s *TelegramBotService) setRegistrationEnabled(ctx context.Context, enabled bool) error { - return s.repo.Setting.Set(ctx, TelegramRegistrationSettingKey, strconv.FormatBool(enabled)) -} - -// HandleWebhook 处理 Telegram 推送的 Webhook/Polling 消息。 -func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) error { - var update TelegramUpdate - if err := json.Unmarshal(body, &update); err != nil { - return fmt.Errorf("invalid update: %w", err) - } - return s.handleTelegramUpdate(ctx, update, nil) -} - -func (s *TelegramBotService) handleTelegramUpdate(ctx context.Context, update TelegramUpdate, channelHint *model.NotifyChannel) error { - if update.CallbackQuery != nil { - return s.handleCallback(ctx, update.CallbackQuery, channelHint) - } - - if update.Message == nil || update.Message.Text == "" { - return nil - } - - msg := update.Message - text := strings.TrimSpace(msg.Text) - - // Button-initiated text prompts (register / redeem / change name·password / - // open-reg limit) arrive as ordinary messages. Consume them here before the - // command gate so the button-driven menu can collect free-form input. - if !telegramIsCommandText(text) { - if msg.Chat.Type == "" || msg.Chat.Type == "private" { - if channel := s.channelForMessage(ctx, msg, channelHint); channel != nil { - if reply, handled := s.handlePendingText(ctx, channel, msg, text); handled { - if reply.Text != "" { - if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil { - s.log.Error("reply failed", zap.Error(err)) - } - } - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - return nil - } - if looksLikeRedemptionCode(text) { - reply := s.cmdRedeem(ctx, channel, msg, []string{text}) - if reply.Text != "" { - if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil { - s.log.Error("reply failed", zap.Error(err)) - } - } - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - return nil - } - } - } - return nil - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" && !telegramSupportedCommand(telegramCommandName(text)) { - return nil - } - - s.log.Info("telegram command received", - zap.Int("chat_id", msg.Chat.ID), - zap.String("user", msg.From.Username), - zap.String("text", text), - ) - - // 获取该消息可使用的 Telegram 通知渠道配置。群组/频道消息必须来自 - // 已配置的群组/频道;私聊消息会选择一个可验证该用户成员身份的 Bot。 - channel := s.channelForMessage(ctx, msg, channelHint) - if channel == nil { - s.log.Warn("telegram channel not allowed or not configured", - zap.Int("chat_id", msg.Chat.ID), - zap.String("chat_type", msg.Chat.Type), - zap.Int("telegram_user_id", msg.From.ID), - ) - return nil - } - - // 解析并执行命令 - reply, err := s.executeCommand(ctx, channel, msg, text) - if err != nil { - s.log.Error("command failed", zap.Error(err)) - _ = s.replyForMessage(ctx, channel, msg, telegramCommandReply{Text: "命令执行失败: " + err.Error()}) - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - return nil - } - - if reply.Text != "" { - if err := s.replyForMessage(ctx, channel, msg, reply); err != nil { - s.log.Error("reply failed", zap.Error(err)) - } - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - } - - return nil -} - -func telegramIsCommandText(text string) bool { - return strings.HasPrefix(strings.TrimSpace(text), "/") && telegramCommandName(text) != "" -} - -func telegramCommandName(text string) string { - parts := strings.Fields(strings.TrimSpace(text)) - if len(parts) == 0 { - return "" - } - cmd := strings.ToLower(strings.TrimSpace(parts[0])) - if !strings.HasPrefix(cmd, "/") { - return "" - } - if at := strings.Index(cmd, "@"); at > 0 { - cmd = cmd[:at] - } - return cmd -} - -func telegramIsGroupChat(chatType string) bool { - return chatType != "" && chatType != "private" -} - -func telegramPrivateMessageForUser(msg *TelegramMessage) *TelegramMessage { - if msg == nil || !telegramIsGroupChat(msg.Chat.Type) { - return msg - } - copied := *msg - copied.Chat = TelegramChat{ID: msg.From.ID, Type: "private"} - return &copied -} - -func telegramGroupPrivateAdminHint() string { - return "群组内不展示管理面板;管理员可在已绑定群组直接发送文本管理命令,涉及账号凭据的操作仍请私聊 Bot。" -} - -func telegramGroupPrivateUserHint(action string) string { - action = strings.TrimSpace(action) - if action == "" { - action = "此操作" - } - return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。" -} - -func telegramGroupPrivateDeliverySentHint() string { - return "已把你的 Bot 面板/执行结果私聊发送给你。若没收到,请先私聊 Bot 发送 /start。" -} - -func telegramGroupPrivateDeliveryFailedHint() string { - return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 /start,再回群里使用命令。" -} diff --git a/internal/service/telegram_bot_binding_test.go b/internal/service/telegram_bot_binding_test.go deleted file mode 100644 index 9c2209d..0000000 --- a/internal/service/telegram_bot_binding_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestTelegramStartClearsStaleUserBinding(t *testing.T) { - repos, auth, _, _ := newAuthTestServices(t) - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20001, - TelegramName: "@viewer", - ChatID: 20001, - UserID: "deleted-user", - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - - reply := bot.cmdStart(t.Context(), &TelegramMessage{ - From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 20001, Type: "private"}, - }, nil) - - if !strings.Contains(reply.Text, "已不存在") { - t.Fatalf("expected stale binding message, got %q", reply.Text) - } - var count int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 20001).Count(&count).Error; err != nil { - t.Fatalf("count binding: %v", err) - } - if count != 0 { - t.Fatalf("stale binding should be removed, got %d", count) - } -} - -func TestTelegramStartReplacesAccountBindingFromAnotherTelegram(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20001, - TelegramName: "@viewer-one", - ChatID: 20001, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20002"}) - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify channel: %v", err) - } - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil { - t.Fatalf("create notify channel: %v", err) - } - msg := &TelegramMessage{ - From: TelegramUser{ID: 20002, Username: "viewer-two", FirstName: "Viewer Two"}, - Chat: TelegramChat{ID: 20002, Type: "private"}, - } - reply := bot.cmdStart(ctx, msg, []string{"viewer", "secret-pass"}) - - if !strings.Contains(reply.Text, "绑定成功") { - t.Fatalf("expected new telegram account to replace old binding, got %q", reply.Text) - } - var accountBindings int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&accountBindings).Error; err != nil { - t.Fatalf("count account bindings: %v", err) - } - if accountBindings != 1 { - t.Fatalf("account should keep exactly one telegram binding, got %d", accountBindings) - } - if binding := bot.telegramBinding(ctx, 20002); binding == nil || binding.UserID != user.ID { - t.Fatalf("new telegram account should be bound to user, got %#v", binding) - } - if binding := bot.telegramBinding(ctx, 20001); binding != nil { - t.Fatalf("old telegram binding should be removed, got %#v", binding) - } -} - -func TestTelegramStartUnbindsWhenBoundPasswordChanged(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20003, - TelegramName: "@viewer", - ChatID: 20003, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil { - t.Fatalf("reset password: %v", err) - } - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify channel: %v", err) - } - cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20003"}) - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil { - t.Fatalf("create notify channel: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{ - From: TelegramUser{ID: 20003, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 20003, Type: "private"}, - } - reply := bot.cmdStart(ctx, msg, []string{"viewer", "old-password"}) - - if !strings.Contains(reply.Text, "已自动解绑") { - t.Fatalf("expected auto unbind reply, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 20003); binding != nil { - t.Fatal("stale binding should be removed after password mismatch") - } -} - -func TestTelegramSelfSetNameRequiresCurrentPassword(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20004, - TelegramName: "@viewer", - ChatID: 20004, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{From: TelegramUser{ID: 20004, Username: "viewer"}, Chat: TelegramChat{ID: 20004, Type: "private"}} - if reply := bot.selfSetName(ctx, msg, "renamed"); !strings.Contains(reply.Text, "当前密码 新用户名") { - t.Fatalf("expected usage reply, got %q", reply.Text) - } - if reply := bot.selfSetName(ctx, msg, "old-password renamed"); !strings.Contains(reply.Text, "用户名已修改") { - t.Fatalf("expected rename success, got %q", reply.Text) - } - updated, _ := repos.User.FindByID(ctx, user.ID) - if updated == nil || updated.Username != "renamed" { - t.Fatalf("username not updated: %#v", updated) - } -} - -func TestTelegramSelfSetPassWrongCurrentPasswordUnbinds(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20005, - TelegramName: "@viewer", - ChatID: 20005, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{From: TelegramUser{ID: 20005, Username: "viewer"}, Chat: TelegramChat{ID: 20005, Type: "private"}} - reply := bot.selfSetPass(ctx, msg, "wrong-password new-password") - - if !strings.Contains(reply.Text, "已自动解绑") { - t.Fatalf("expected auto unbind reply, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 20005); binding != nil { - t.Fatal("binding should be removed after wrong current password") - } - if _, err := auth.Login(ctx, "viewer", "old-password"); err != nil { - t.Fatalf("old password should remain valid after failed change: %v", err) - } -} - -func TestTelegramSelfSetPassChangesPasswordWithCurrentPassword(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20006, - TelegramName: "@viewer", - ChatID: 20006, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{From: TelegramUser{ID: 20006, Username: "viewer"}, Chat: TelegramChat{ID: 20006, Type: "private"}} - reply := bot.selfSetPass(ctx, msg, "old-password new-password") - - if !strings.Contains(reply.Text, "密码已修改") { - t.Fatalf("expected password change success, got %q", reply.Text) - } - if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) { - t.Fatalf("old password should fail, got %v", err) - } - if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil { - t.Fatalf("new password should login: %v", err) - } - if binding := bot.telegramBinding(ctx, 20006); binding == nil { - t.Fatal("successful password change should keep telegram binding") - } -} - -func TestTelegramBindingFromGroupStoresPrivateUserChatID(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register: %v", err) - } - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{ - From: TelegramUser{ID: 21001, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123456, Type: "group"}, - } - - if err := bot.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - t.Fatalf("upsert binding: %v", err) - } - binding := bot.telegramBinding(ctx, 21001) - if binding == nil { - t.Fatal("binding should be created") - } - if binding.ChatID != 21001 { - t.Fatalf("group binding must store private user chat id, got %d", binding.ChatID) - } -} - -func TestTelegramBindingFromGroupPreservesExistingPrivateChatID(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 21002, - TelegramName: "@viewer", - ChatID: 987654, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("seed binding: %v", err) - } - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{ - From: TelegramUser{ID: 21002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123456, Type: "supergroup"}, - } - - if err := bot.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - t.Fatalf("upsert binding: %v", err) - } - binding := bot.telegramBinding(ctx, 21002) - if binding == nil { - t.Fatal("binding should exist") - } - if binding.ChatID != 987654 { - t.Fatalf("group command must not overwrite existing private chat id, got %d", binding.ChatID) - } -} - -func TestTelegramPrivateNotifyChatIDFallsBackFromLegacyGroupBinding(t *testing.T) { - binding := model.TelegramBinding{ - TelegramUserID: 21003, - ChatID: -100123456, - } - if got := telegramPrivateChatIDFromBinding(binding); got != 21003 { - t.Fatalf("legacy group binding should notify private user chat, got %d", got) - } -} diff --git a/internal/service/telegram_bot_commands_core.go b/internal/service/telegram_bot_commands_core.go deleted file mode 100644 index 7d0d203..0000000 --- a/internal/service/telegram_bot_commands_core.go +++ /dev/null @@ -1,225 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - - "golang.org/x/crypto/bcrypt" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// cmdStart 处理 /start 命令。 -func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - name := msg.From.FirstName - if msg.From.Username != "" { - name = "@" + msg.From.Username - } - if telegramIsGroupChat(msg.Chat.Type) && len(args) > 0 { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")} - } - if len(args) == 0 { - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - user, _ := s.repo.User.FindByID(ctx, binding.UserID) - if user == nil { - _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error - return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n/start 用户名 密码"} - } - status := "未隐藏" - if user.HideAdult { - status = "已隐藏" - } - return telegramCommandReply{ - Text: fmt.Sprintf("MediaStationGo 已绑定\n\n你好 %s,当前账号:%s\n成人目录:%s", name, userNameOrFallback(user), status), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } - } - hint := "如果没有账号,请联系管理员注册。" - if s.openRegEnabled(ctx) { - hint = "如果还没有账号,可直接注册:\n/register 用户名 密码\n或:/register 用户名-密码" - } - return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n" + hint} - } - channel := s.findChannelForMessage(ctx, msg) - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "绑定媒体中心账号")} - } - username, password := parseStartCredentials(args) - if username == "" || password == "" { - return telegramCommandReply{Text: "绑定格式不正确,请使用:\n/start 用户名 密码\n或:/start 用户名-密码"} - } - existingBinding := s.telegramBinding(ctx, msg.From.ID) - user, err := s.repo.User.FindByUsername(ctx, username) - if err != nil || user == nil { - if existingBinding != nil { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前绑定的媒体账号信息已失效,已自动解绑。请使用新的用户名和密码重新绑定。"} - } - return telegramCommandReply{Text: "未找到此用户,请联系管理员注册。"} - } - if !user.IsActive { - return telegramCommandReply{Text: "此账号已被禁用,请联系管理员。"} - } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - if existingBinding != nil && existingBinding.UserID == user.ID { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前绑定账号的密码已失效,已自动解绑。请使用新密码重新绑定。"} - } - return telegramCommandReply{Text: "账号或密码错误。"} - } - if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - return telegramCommandReply{Text: "绑定失败:" + err.Error()} - } - return telegramCommandReply{ - Text: fmt.Sprintf("绑定成功:%s\n\n普通用户只能使用此 Bot 管理自己的成人目录隐藏状态;系统状态、搜索、下载和统计命令仅管理员可用。", user.Username), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } -} - -// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot -// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。 -func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 1 && looksLikeRedemptionCode(args[0]) { - return s.redeemRegisterFlow(ctx, channel, msg, args[0]) - } - if !s.openRegEnabled(ctx) { - return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"} - } - // 开注名额已用尽则拦截(容量随凭证授权实时变化,名额单独计数)。 - if c := s.loadCapacity(ctx); c.Remaining() <= 0 { - return telegramCommandReply{Text: "注册名额已满,请等待管理员重新开放或扩容授权。"} - } - if s.auth == nil { - return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"} - } - if channel == nil { - channel = s.findChannelForMessage(ctx, msg) - } - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "注册账号")} - } - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user != nil { - return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号:%s,无需重复注册。\n如需切换账号请使用 /start 用户名 密码。", userNameOrFallback(user))} - } - } - username, password := parseStartCredentials(args) - if username == "" || password == "" { - return telegramCommandReply{Text: "注册格式不正确,请使用:\n/register 用户名 密码\n或:/register 用户名-密码"} - } - user, _, err := s.auth.Register(ctx, username, password) - if err != nil { - switch { - case errors.Is(err, ErrUsernameTaken): - return telegramCommandReply{Text: "该用户名已被占用,请换一个;如果是你本人的账号,请改用 /start 用户名 密码 绑定。"} - case errors.Is(err, ErrUserLimitReached): - return telegramCommandReply{Text: "注册失败:已达到用户数量上限,请联系管理员。"} - default: - return telegramCommandReply{Text: "注册失败:" + err.Error()} - } - } - // 注册成功,扣减一个开注名额(名额用尽自动关闭注册)。 - s.consumeOpenRegSlot(ctx) - if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - return telegramCommandReply{Text: fmt.Sprintf("账号 %s 注册成功,但自动绑定失败:%s\n请稍后使用 /start %s 密码 重新绑定。", user.Username, err.Error(), user.Username)} - } - return telegramCommandReply{ - Text: fmt.Sprintf("注册并绑定成功:%s\n\n你现在可以用此账号登录网页与第三方客户端。普通用户只能在此 Bot 管理成人目录显隐;其他功能仅管理员可用。", user.Username), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } -} - -// cmdRegistrationToggle handles /registration and /openreg. It uses the same -// quota-aware open-registration state as the inline Bot menu. -func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(strings.TrimSpace(args[0]), "status") { - c := s.loadCapacity(ctx) - state := "已关闭" - if c.OpenRegOn { - if c.OpenRegLimit > 0 { - state = fmt.Sprintf("已开启(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) - } else { - state = "已开启(不限名额,受授权上限约束)" - } - } - return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n剩余可注册:%d 人。\n\n开启:/registration on 10\n不限:/registration on 0\n关闭:/registration off", state, c.Remaining())} - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "on", "true", "1", "open", "enable", "enabled", "开启", "打开", "开": - limit := 0 - if len(args) > 1 { - n, err := strconv.Atoi(strings.TrimSpace(args[1])) - if err != nil || n < 0 { - return telegramCommandReply{Text: "名额必须是非负整数,0 表示不限名额。"} - } - limit = n - } - if err := s.openRegistration(ctx, limit); err != nil { - return telegramCommandReply{Text: "开启失败:" + err.Error()} - } - label := "不限名额" - if limit > 0 { - label = fmt.Sprintf("%d 个名额", limit) - } - return telegramCommandReply{Text: "普通用户 Bot 注册功能已开启:" + label + "。"} - case "off", "false", "0", "close", "disable", "disabled", "关闭", "关": - if err := s.closeRegistration(ctx); err != nil { - return telegramCommandReply{Text: "关闭失败:" + err.Error()} - } - return telegramCommandReply{Text: "普通用户 Bot 注册功能已关闭。"} - default: - return telegramCommandReply{Text: "参数无效,请使用 /registration on [名额] 或 /registration off。"} - } -} - -// cmdStatus 处理 /status 命令。 -func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - channel := s.findChannelForMessage(ctx, msg) - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "使用成人目录隐藏开关")} - } - binding := s.telegramBinding(ctx, msg.From.ID) - if binding == nil { - return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} - } - user, err := s.repo.User.FindByID(ctx, binding.UserID) - if err != nil || user == nil { - return telegramCommandReply{Text: "绑定用户不存在,请重新 /start 绑定。"} - } - next := true - if len(args) > 0 { - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "off", "false", "0", "show", "显示", "关闭": - next = false - case "on", "true", "1", "hide", "隐藏", "开启": - next = true - default: - next = !user.HideAdult - } - } else { - next = !user.HideAdult - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"hide_adult": next}); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - status := map[bool]string{true: "已隐藏", false: "已显示"}[next] - return telegramCommandReply{ - Text: "成人目录" + status + "。此设置会同步影响网页与第三方客户端。", - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[next], - Data: "adult_toggle", - }}}, - } -} diff --git a/internal/service/telegram_bot_commands_help.go b/internal/service/telegram_bot_commands_help.go deleted file mode 100644 index 6390753..0000000 --- a/internal/service/telegram_bot_commands_help.go +++ /dev/null @@ -1,81 +0,0 @@ -package service - -import "context" - -// cmdHelp 处理 /help 命令。 -func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string { - channel := s.findChannelForMessage(ctx, msg) - if telegramIsGroupChat(msg.Chat.Type) { - adminHint := "" - if s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - adminHint = "\n\n管理员可在已绑定群组直接发送文本管理命令;管理面板和账号凭据操作请私聊 Bot。" - } - return "MediaStationGo 群组可用命令\n\n" + - "/menu — 打开群组自助菜单\n" + - "/account — 查看账号状态\n" + - "/signin — 签到\n" + - "/devices — 查看登录设备\n" + - "/kick all|编号 — 踢下线设备\n" + - "/hideadult on|off — 隐藏或显示成人目录\n\n" + - "绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" + - adminHint - } - if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - register := "" - if s.openRegEnabled(ctx) { - register = "/register 用户名 密码 — 注册新账号\n" - } - return "MediaStationGo 用户命令\n\n" + - register + - "/start 用户名 密码 — 绑定账号\n" + - "/account — 查看账号状态\n" + - "/signin — 签到\n" + - "/devices — 查看登录设备\n" + - "/kick all|编号 — 踢下线设备\n" + - "/setname 当前密码 新用户名 — 修改用户名\n" + - "/setpass 当前密码 新密码 — 修改密码\n" + - "/redeem 兑换码 — 注册或续期兑换\n" + - "/hideadult on|off — 隐藏或显示成人目录\n\n" + - "系统状态、搜索、下载列表与统计命令仅管理员可用。" - } - return "MediaStationGo 命令列表\n\n" + - "/start — 开始使用\n" + - "/help — 帮助信息\n" + - "/account / /devices / /kick all|编号 — 用户自助设备管理\n" + - "/signin / /redeem 兑换码 — 签到与兑换\n" + - "/setname 当前密码 新用户名 / /setpass 当前密码 新密码 — 用户自助改名改密\n" + - "/register 用户名 密码 — 注册新账号(需管理员开启)\n" + - "/registration on [名额]|off — 开启/关闭普通用户注册(管理员)\n" + - "/capacity / /users — 容量与用户管理(管理员)\n" + - "/gencode register|renew 天数 [有效天数] — 生成兑换码(管理员)\n" + - "/renew_user 用户名 天数 / /delete_user 用户名 confirm — 续期/删除用户(管理员)\n" + - "/unbind 用户1 用户2 — 批量解绑 Telegram 绑定(管理员)\n" + - "/unbind_duplicates / /unbind_inactive 天数 — 清理重复/无效绑定或久未登录绑定(管理员)\n" + - "/antishare on play=3 login=3 warn=2 — 防共享策略(管理员)\n" + - "/cleanup run — 预览保号清理候选(管理员)\n" + - "/cleanup run confirm — 确认清理候选账号(管理员)\n" + - "/cleanup on|off — 保号规则开关(管理员)\n" + - "/cleanup_rule list|add|edit|修改|del|enable|disable — Mgo 保号规则(管理员)\n" + - "/ban 用户名 / /unban 用户名 — 禁用/解禁用户(管理员)\n" + - "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" + - "/status — 系统运行状态\n" + - "/search 关键词 — 搜索媒体库\n" + - "/downloads — 下载列表\n" + - "/stats — 媒体库统计\n\n" + - telegramMgoAdminCommandHelp() + "\n\n" + - "自动推送事件:\n" + - "• 订阅命中新资源\n" + - "• 下载任务完成\n" + - "• 刮削失败告警\n" + - "• 系统异常通知" -} - -func telegramMgoAdminCommandHelp() string { - return "Mgo 管理命令(管理员可用,已注册到命令栏):\n" + - "用户:/ucr 用户名 密码 [天数] 创建账号;/uinfo 用户名 查询账号;/rmemby 用户名 confirm 删除账号;/only_rm_record tg:ID|用户名 仅删 Bot 绑定;/renewall 天数 confirm 批量续期。\n" + - "审计:/userip 用户名 查用户 IP;/auditip IP 按 IP 审计;/auditdevice 关键词 按终端设备审计;/auditclient 关键词 按客户端审计;/udeviceid 设备ID 按设备指纹审计。\n" + - "清理:/syncunbound 检查未绑定账号;/syncgroupm 校验群成员;/check_ex 检查过期账号;/deleted 按保号规则预览清理候选。\n" + - "权限:/embyadmin 用户名 on|off 设置管理员;/banall confirm//unbanall confirm 批量禁用/解禁;/prouser 用户名//revuser 用户名 管理保护名单;/embylibs_blockall//embylibs_unblockall 批量禁用/开放媒体库权限。\n" + - "运维:/proadmin TelegramID//revadmin TelegramID 管理 Bot 管理员;/backup_db 备份数据库;/restore_from_db 文件名 confirm 恢复数据库。\n" + - "说明:重复别名如 /low_activity、/urm 仍可兼容识别,但不显示在命令栏。" -} diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go deleted file mode 100644 index d506f41..0000000 --- a/internal/service/telegram_bot_user_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package service - -import ( - "encoding/json" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestTelegramUpdateActionableDispatchesCallbackQuery(t *testing.T) { - if !telegramUpdateActionable(TelegramUpdate{CallbackQuery: &TelegramCallbackQuery{Data: "adult_toggle"}}) { - t.Fatal("callback_query update must be dispatched, otherwise inline buttons break") - } - if !telegramUpdateActionable(TelegramUpdate{Message: &TelegramMessage{Text: "/help"}}) { - t.Fatal("text command message must be dispatched") - } - if telegramUpdateActionable(TelegramUpdate{}) { - t.Fatal("empty update must be skipped") - } - if telegramUpdateActionable(TelegramUpdate{Message: &TelegramMessage{}}) { - t.Fatal("message without text must be skipped") - } -} - -func TestTelegramCallbackTogglesAdultVisibility(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register user: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 30001, - TelegramName: "@viewer", - ChatID: 30001, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify_channels: %v", err) - } - // 配置一个绑定该 Telegram 用户的渠道(无 bot_token,避免测试触发网络请求)。 - cfg, _ := json.Marshal(map[string]string{"admin_user_ids": "30001"}) - if err := repos.DB.Create(&model.NotifyChannel{ - Name: "Telegram", - Type: "telegram", - Enabled: true, - Config: string(cfg), - }).Error; err != nil { - t.Fatalf("create channel: %v", err) - } - - before, err := repos.User.FindByID(ctx, user.ID) - if err != nil || before == nil { - t.Fatalf("load user before toggle: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 1, - CallbackQuery: &TelegramCallbackQuery{ - ID: "cb1", - From: TelegramUser{ID: 30001, Username: "viewer", FirstName: "Viewer"}, - Message: &TelegramMessage{MessageID: 5, Chat: TelegramChat{ID: 30001, Type: "private"}}, - Data: "adult_toggle", - }, - }) - // reply 因 bot_token 为空会返回错误,但成人目录状态应已在数据库中被切换。 - _ = bot.HandleWebhook(ctx, update) - - updated, err := repos.User.FindByID(ctx, user.ID) - if err != nil || updated == nil { - t.Fatalf("reload user: %v", err) - } - if updated.HideAdult == before.HideAdult { - t.Fatalf("adult_toggle callback should have flipped HideAdult (was %v)", before.HideAdult) - } -} - -func TestTelegramRegisterRespectsAdminToggle(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - if err := repos.DB.AutoMigrate(&model.Setting{}, &model.NotifyChannel{}); err != nil { - t.Fatalf("migrate: %v", err) - } - // 预置一个管理员,确保通过 Bot 注册的用户是普通角色而非首个管理员。 - if _, _, err := auth.Register(ctx, "rootadmin", "admin-pass"); err != nil { - t.Fatalf("seed admin: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - // 把注册者放进 admin_user_ids,即可让 telegramUserCanBind 通过(私聊场景, - // 无需走 getChatMember 网络校验);注册流程本身不依赖角色。 - cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "999"}) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)} - - msg := &TelegramMessage{From: TelegramUser{ID: 999, Username: "newbie", FirstName: "Newbie"}, Chat: TelegramChat{ID: 999, Type: "private"}} - - // 默认关闭:拒绝且不创建用户。 - if reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"}); !strings.Contains(reply.Text, "未开放") { - t.Fatalf("registration disabled by default, got %q", reply.Text) - } - if u, _ := repos.User.FindByUsername(ctx, "newbie"); u != nil { - t.Fatal("no user should be created while registration disabled") - } - - // 管理员开启后注册成功并自动绑定。 - if err := bot.setRegistrationEnabled(ctx, true); err != nil { - t.Fatalf("enable registration: %v", err) - } - reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"}) - if !strings.Contains(reply.Text, "注册并绑定成功") { - t.Fatalf("expected success reply, got %q", reply.Text) - } - created, err := repos.User.FindByUsername(ctx, "newbie") - if err != nil || created == nil { - t.Fatalf("user should be created after enabling: %v", err) - } - if created.Role != "user" { - t.Fatalf("bot-registered account should be a regular user, got role %q", created.Role) - } - if binding := bot.telegramBinding(ctx, 999); binding == nil || binding.UserID != created.ID { - t.Fatalf("telegram should be bound to the newly registered user") - } - - // 重复注册:已绑定 → 提示无需重复注册。 - if reply := bot.cmdRegister(ctx, channel, msg, []string{"another", "pass-2"}); !strings.Contains(reply.Text, "无需重复注册") { - t.Fatalf("expected already-bound reply, got %q", reply.Text) - } -} - -func TestTelegramGroupHidesAdminPanelFromRegularUsers(t *testing.T) { - ctx := t.Context() - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`} - msg := &TelegramMessage{ - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123, Type: "supergroup"}, - } - - menu := bot.mainMenu(ctx, channel, msg) - if strings.Contains(menu.Text, "管理员") || telegramReplyHasButtonPrefix(menu, "adm_") { - t.Fatalf("regular group user must not see admin panel: text=%q buttons=%#v", menu.Text, menu.Buttons) - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/users") - if err != nil { - t.Fatal(err) - } - if reply.Text != "" || len(reply.Buttons) != 0 { - t.Fatalf("regular group user admin command should be ignored, got %#v", reply) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/start viewer secret-pass") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "请私聊 Bot") { - t.Fatalf("group credential command should point to private chat, got %q", reply.Text) - } -} - -func TestTelegramGroupAdminMenuExposesButtonsOnlyToAdmins(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`} - msg := &TelegramMessage{ - From: TelegramUser{ID: 9001, Username: "admin", FirstName: "Admin"}, - Chat: TelegramChat{ID: -100123, Type: "group"}, - } - - menu := bot.mainMenu(ctx, channel, msg) - if !telegramReplyHasButtonPrefix(menu, "adm_") { - t.Fatalf("admin group menu should expose admin buttons, got %#v", menu.Buttons) - } - if !strings.Contains(menu.Text, "管理员入口") { - t.Fatalf("admin group menu should label admin section, got %q", menu.Text) - } - - reply, handled := bot.handleMenuCallback(ctx, channel, msg, "adm_users") - if !handled { - t.Fatal("admin callback should be handled") - } - if !strings.Contains(reply.Text, "用户管理") { - t.Fatalf("group admin callback should render admin panel, got %#v", reply) - } - - normal := &TelegramMessage{ - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123, Type: "group"}, - } - normalMenu := bot.mainMenu(ctx, channel, normal) - if telegramReplyHasButtonPrefix(normalMenu, "adm_") || strings.Contains(normalMenu.Text, "管理员入口") { - t.Fatalf("normal group user must not see admin controls: %#v", normalMenu) - } - normalReply, handled := bot.handleMenuCallback(ctx, channel, normal, "adm_users") - if !handled || normalReply.Text != "" || len(normalReply.Buttons) != 0 { - t.Fatalf("normal group user must not use admin callbacks: %#v handled=%v", normalReply, handled) - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/users") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "用户管理") { - t.Fatalf("bound group admin text command should run, got %q", reply.Text) - } - if len(reply.Buttons) == 0 { - t.Fatalf("group admin text command should expose admin action buttons: %#v", reply.Buttons) - } -} - -func TestTelegramPollingChannelHintWinsForPrivateMessages(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - msg := &TelegramMessage{ - From: TelegramUser{ID: 9101, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 9101, Type: "private"}, - } - bad := model.NotifyChannel{Name: "BadToken", Type: "telegram", Enabled: true, Config: `{"bot_token":"bad","admin_user_ids":"9101"}`} - good := model.NotifyChannel{Name: "GoodToken", Type: "telegram", Enabled: true, Config: `{"bot_token":"good","admin_user_ids":"9101"}`} - if err := repos.DB.Create(&bad).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&good).Error; err != nil { - t.Fatal(err) - } - - if first := bot.findChannelForMessage(ctx, msg); first == nil || first.ID != bad.ID { - t.Fatalf("setup expected normal private lookup to pick first channel, got %#v", first) - } - if hinted := bot.channelForMessage(ctx, msg, &good); hinted == nil || hinted.ID != good.ID { - t.Fatalf("polling channel hint should route replies through the token that received the update, got %#v", hinted) - } -} - -func TestTelegramMgoCompatibleUserCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{TelegramUserID: 9102, ChatID: 9102, UserID: user.ID}).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9102"}`} - msg := &TelegramMessage{ - From: TelegramUser{ID: 9102, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 9102, Type: "private"}, - } - - info, err := bot.executeCommand(ctx, channel, msg, "/myinfo") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(info.Text, "我的账号") { - t.Fatalf("/myinfo should show account info, got %q", info.Text) - } - count, err := bot.executeCommand(ctx, channel, msg, "/count") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(count.Text, "媒体库统计") { - t.Fatalf("/count should show library counts, got %q", count.Text) - } -} - -func telegramReplyHasButtonPrefix(reply telegramCommandReply, prefix string) bool { - for _, row := range reply.Buttons { - for _, button := range row { - if strings.HasPrefix(button.Data, prefix) { - return true - } - } - } - return false -} diff --git a/internal/service/telegram_callback.go b/internal/service/telegram_callback.go deleted file mode 100644 index 08816ae..0000000 --- a/internal/service/telegram_callback.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCallbackQuery, channelHint *model.NotifyChannel) error { - if cb == nil || cb.Message == nil { - return nil - } - msg := *cb.Message - msg.From = cb.From - channel := s.channelForMessage(ctx, &msg, channelHint) - if channel == nil { - channel = s.findChannelByChatID(ctx, cb.Message.Chat.ID) - } - // 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。 - if telegramIsGroupChat(cb.Message.Chat.Type) { - s.answerCallbackWithText(ctx, channel, cb.ID, "为了隐私,群组内按钮面板已禁用。请私聊 Bot 或在群里发送 /menu,我会把面板私聊给你。", true) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return nil - } - if cb.Message.Chat.Type == "private" && cb.Message.Chat.ID != cb.From.ID { - s.answerCallbackWithText(ctx, channel, cb.ID, "这个面板不属于你,请发送 /menu 打开自己的面板。", true) - return nil - } - s.answerCallback(ctx, channel, cb.ID) - data := strings.TrimSpace(cb.Data) - if data == "adult_toggle" { - reply := s.cmdHideAdult(ctx, &msg, nil) - if reply.Text != "" { - err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return err - } - return nil - } - if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled { - if reply.Text != "" { - err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return err - } - } - return nil -} - -// answerCallback 应答 Telegram 回调查询,关闭按钮上的加载提示。 -func (s *TelegramBotService) answerCallback(ctx context.Context, channel *model.NotifyChannel, callbackID string) { - s.answerCallbackWithText(ctx, channel, callbackID, "", false) -} - -func (s *TelegramBotService) answerCallbackWithText(ctx context.Context, channel *model.NotifyChannel, callbackID, text string, showAlert bool) { - if channel == nil || strings.TrimSpace(callbackID) == "" { - return - } - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" { - return - } - payload := map[string]interface{}{ - "callback_query_id": callbackID, - } - if strings.TrimSpace(text) != "" { - payload["text"] = text - payload["show_alert"] = showAlert - } - if err := telegramPostJSON(ctx, cfg, "answerCallbackQuery", payload, 8*time.Second); err != nil { - s.log.Debug("telegram answerCallbackQuery failed", zap.Error(sanitizeTelegramError(err))) - } -} diff --git a/internal/service/telegram_channel_resolve.go b/internal/service/telegram_channel_resolve.go deleted file mode 100644 index af30068..0000000 --- a/internal/service/telegram_channel_resolve.go +++ /dev/null @@ -1,160 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。 -func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int) *model.NotifyChannel { - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - return nil - } - target := strconv.Itoa(chatID) - for _, ch := range channels { - if !ch.Enabled { - continue - } - configStr := ch.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - continue - } - if cfg["chat_id"] == target || cfg["command_chat_id"] == target || - cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target { - return &ch - } - } - if len(channels) == 1 && channels[0].Enabled { - return &channels[0] - } - return nil -} - -func (s *TelegramBotService) findChannelForMessage(ctx context.Context, msg *TelegramMessage) *model.NotifyChannel { - if msg == nil { - return nil - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" { - return s.findChannelByChatID(ctx, msg.Chat.ID) - } - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - return nil - } - var first *model.NotifyChannel - for i := range channels { - ch := channels[i] - if !ch.Enabled { - continue - } - if first == nil { - first = &ch - } - if s.telegramUserIsAdmin(ctx, &ch, msg.From.ID) || s.telegramUserCanBind(ctx, &ch, msg.From.ID) { - return &ch - } - } - return first -} - -func (s *TelegramBotService) channelForMessage(ctx context.Context, msg *TelegramMessage, hint *model.NotifyChannel) *model.NotifyChannel { - if hint == nil { - return s.findChannelForMessage(ctx, msg) - } - if msg == nil { - return hint - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(hint, msg.Chat.ID) { - return nil - } - return hint -} - -func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, chatID int) bool { - if channel == nil { - return false - } - configStr := channel.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - return false - } - target := strconv.Itoa(chatID) - for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { - if configured := strings.TrimSpace(cfg[key]); configured != "" && configured == target { - return true - } - } - if strings.TrimSpace(cfg["group_chat_id"]) != "" || strings.TrimSpace(cfg["channel_chat_id"]) != "" || strings.TrimSpace(cfg["command_chat_id"]) != "" { - return false - } - return strings.TrimSpace(cfg["chat_id"]) == target -} - -func (s *TelegramBotService) telegramUserIDConfigured(channel *model.NotifyChannel, telegramUserID int) bool { - if channel == nil || telegramUserID == 0 { - return false - } - cfg := s.telegramChannelConfig(channel) - target := strconv.Itoa(telegramUserID) - for _, value := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - if value == target { - return true - } - } - if strings.TrimSpace(cfg["admin_user_ids"]) == "" && strings.TrimSpace(cfg["chat_id"]) == target { - return true - } - return false -} - -func (s *TelegramBotService) telegramChannelConfig(channel *model.NotifyChannel) map[string]string { - return telegramConfigFromChannel(s.crypto, channel) -} - -func telegramConfigFromChannel(crypto *CryptoService, channel *model.NotifyChannel) map[string]string { - if channel == nil { - return map[string]string{} - } - configStr := channel.Config - if crypto != nil && configStr != "" { - configStr = crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil || cfg == nil { - return map[string]string{} - } - normalizeTelegramConfig(cfg) - return cfg -} - -func normalizeTelegramConfig(cfg map[string]string) { - if cfg == nil { - return - } - chatID := strings.TrimSpace(cfg["chat_id"]) - if chatID == "" { - return - } - if strings.HasPrefix(chatID, "-") { - if strings.TrimSpace(cfg["group_chat_id"]) == "" && strings.TrimSpace(cfg["channel_chat_id"]) == "" && strings.TrimSpace(cfg["command_chat_id"]) == "" { - cfg["group_chat_id"] = chatID - } - return - } - if strings.TrimSpace(cfg["admin_user_ids"]) == "" { - cfg["admin_user_ids"] = chatID - } -} diff --git a/internal/service/telegram_cleanup_rules.go b/internal/service/telegram_cleanup_rules.go deleted file mode 100644 index 6103b83..0000000 --- a/internal/service/telegram_cleanup_rules.go +++ /dev/null @@ -1,220 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" -) - -func (s *TelegramBotService) currentCleanupRules(ctx context.Context) []accountCleanupRule { - cfg := loadBotConfig(ctx, s.repo) - return cfg.AccountCleanupRules -} - -func (s *TelegramBotService) saveCleanupRules(ctx context.Context, rules []accountCleanupRule) error { - raw, err := json.Marshal(normalizeCleanupRules(rules)) - if err != nil { - return err - } - return s.repo.Setting.Set(ctx, SettingAccountCleanupRules, string(raw)) -} - -func parseCommandBool(value string) (bool, bool) { - switch strings.ToLower(strings.TrimSpace(value)) { - case "on", "true", "1", "yes", "enable", "enabled", "开启", "开": - return true, true - case "off", "false", "0", "no", "disable", "disabled", "关闭", "关": - return false, true - default: - return false, false - } -} - -func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) { - if len(args) < 2 { - return accountCleanupRule{}, fmt.Errorf("新增规则参数不足") - } - rule := accountCleanupRule{ - Type: strings.ToLower(strings.TrimSpace(args[0])), - ID: strings.TrimSpace(args[1]), - Enabled: true, - WindowDaysMin: 3, - WindowDaysMax: 5, - MinHours: 6, - MinCount: 1, - } - switch rule.Type { - case "watch_hours": - name, values := cleanupRuleNameAndValues(args[2:], 3) - rule.Name = name - if len(values) >= 3 { - rule.WindowDaysMin, _ = strconv.Atoi(values[0]) - rule.WindowDaysMax, _ = strconv.Atoi(values[1]) - rule.MinHours, _ = strconv.ParseFloat(values[2], 64) - if rule.Name == "" { - rule.Name = fmt.Sprintf("%d~%d 天观看满 %s 小时", rule.WindowDaysMin, rule.WindowDaysMax, formatRuleHours(rule.MinHours)) - } - } - case "recent_login": - name, values := cleanupRuleNameAndValues(args[2:], 1) - rule.Name = name - if len(values) >= 1 { - rule.WindowDaysMax, _ = strconv.Atoi(values[0]) - if rule.Name == "" { - rule.Name = fmt.Sprintf("%d 天内登录", rule.WindowDaysMax) - } - } - case "signin_streak", "account_age_grace": - name, values := cleanupRuleNameAndValues(args[2:], 1) - rule.Name = name - if len(values) >= 1 { - rule.MinCount, _ = strconv.Atoi(values[0]) - if rule.Name == "" { - if rule.Type == "signin_streak" { - rule.Name = fmt.Sprintf("连续签到 %d 天", rule.MinCount) - } else { - rule.Name = fmt.Sprintf("新号宽限 %d 天", rule.MinCount) - } - } - } - default: - return accountCleanupRule{}, fmt.Errorf("不支持的规则类型:%s", rule.Type) - } - normalized := normalizeCleanupRules([]accountCleanupRule{rule}) - if len(normalized) == 0 { - return accountCleanupRule{}, fmt.Errorf("规则无效") - } - return normalized[0], nil -} - -func cleanupRuleNameAndValues(args []string, numericCount int) (string, []string) { - if len(args) == 0 { - return "", nil - } - if len(args) >= numericCount && cleanupRuleValuesAreNumeric(args[:numericCount]) { - return "", args - } - return strings.TrimSpace(args[0]), args[1:] -} - -func cleanupRuleValuesAreNumeric(values []string) bool { - for _, value := range values { - if _, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err != nil { - return false - } - } - return true -} - -func formatCleanupRules(rules []accountCleanupRule) string { - if len(rules) == 0 { - return "保号规则\n\n暂无规则。" - } - var sb strings.Builder - sb.WriteString("保号规则\n") - for i, r := range rules { - state := map[bool]string{true: "启用", false: "停用"}[r.Enabled] - detail := cleanupRuleDetail(r) - parts := []string{ - fmt.Sprintf("\n%d. %s", i+1, r.ID), - } - if shouldShowCleanupRuleName(r, detail) { - parts = append(parts, r.Name) - } - parts = append(parts, cleanupRuleTypeLabel(r.Type), state) - if detail != "" { - parts = append(parts, detail) - } - sb.WriteString(strings.Join(parts, " · ")) - } - return sb.String() -} - -func shouldShowCleanupRuleName(r accountCleanupRule, detail string) bool { - name := strings.TrimSpace(r.Name) - if name == "" || strings.EqualFold(name, r.ID) { - return false - } - if detail != "" && strings.EqualFold(name, detail) { - return false - } - return true -} - -func cleanupRuleDetail(r accountCleanupRule) string { - switch r.Type { - case "watch_hours": - return fmt.Sprintf("%d~%d 天 %s 小时", r.WindowDaysMin, r.WindowDaysMax, formatRuleHours(r.MinHours)) - case "recent_login": - return fmt.Sprintf("%d 天内登录", r.WindowDaysMax) - case "signin_streak": - return fmt.Sprintf("连续签到 %d 天", r.MinCount) - case "account_age_grace": - return fmt.Sprintf("新号宽限 %d 天", r.MinCount) - default: - return "" - } -} - -func formatRuleHours(hours float64) string { - if hours == float64(int(hours)) { - return strconv.Itoa(int(hours)) - } - return fmt.Sprintf("%.1f", hours) -} - -func cleanupRuleTypeLabel(t string) string { - switch t { - case "watch_hours": - return "观看时长" - case "recent_login": - return "最近登录" - case "signin_streak": - return "连续签到" - case "account_age_grace": - return "新号宽限" - default: - return t - } -} - -func cleanupRuleHelp() string { - return "Mgo 保号规则命令\n\n" + - "/cleanup_rule list — 查看规则\n" + - "/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6\n" + - "/cleanup_rule add recent_login login_7d 七天内登录 7\n" + - "/cleanup_rule add signin_streak sign_3 连续签到3天 3\n" + - "/cleanup_rule add account_age_grace new_7d 新号宽限7天 7\n" + - "/cleanup_rule edit 规则类型 规则ID 名称 参数... — 修改同 ID 规则\n" + - "/cleanup_rule 修改 规则类型 规则ID 名称 参数... — 中文修改入口\n" + - "/cleanup_rule enable 规则ID / disable 规则ID\n" + - "/cleanup_rule del 规则ID\n\n" + - "保号模式固定为:满足任意一条启用规则即保留;全部不满足才会清理。" -} - -func onOff(b bool) string { - return map[bool]string{true: "已开启", false: "已关闭"}[b] -} - -func toggleLabel(name string, enabled bool) string { - if enabled { - return "关闭" + name - } - return "开启" + name -} - -func cleanupModeLabel(mode string) string { - return "满足任意一条" -} - -func countEnabledCleanupRules(rules []accountCleanupRule) int { - n := 0 - for _, r := range rules { - if r.Enabled { - n++ - } - } - return n -} diff --git a/internal/service/telegram_cleanup_rules_test.go b/internal/service/telegram_cleanup_rules_test.go deleted file mode 100644 index 47f5fdf..0000000 --- a/internal/service/telegram_cleanup_rules_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package service - -import ( - "strings" - "testing" -) - -func TestParseCleanupRuleCommandWithNamedWatchHours(t *testing.T) { - rule, err := parseCleanupRuleCommand([]string{"watch_hours", "watch_3_5d_6h", "观看3到5天满6小时", "3", "5", "6"}) - if err != nil { - t.Fatal(err) - } - if rule.Type != "watch_hours" || rule.ID != "watch_3_5d_6h" || rule.Name != "观看3到5天满6小时" { - t.Fatalf("unexpected rule identity: %+v", rule) - } - if !rule.Enabled || rule.WindowDaysMin != 3 || rule.WindowDaysMax != 5 || rule.MinHours != 6 { - t.Fatalf("unexpected watch-hours rule values: %+v", rule) - } -} - -func TestFormatCleanupRulesShowsUsefulDetails(t *testing.T) { - text := formatCleanupRules([]accountCleanupRule{{ - ID: "login_7d", - Type: "recent_login", - Name: "七天内登录", - Enabled: true, - WindowDaysMax: 7, - }}) - for _, want := range []string{"保号规则", "login_7d", "七天内登录", "最近登录", "启用"} { - if !strings.Contains(text, want) { - t.Fatalf("formatCleanupRules() missing %q in %q", want, text) - } - } -} diff --git a/internal/service/telegram_command_menus.go b/internal/service/telegram_command_menus.go deleted file mode 100644 index e7aefbc..0000000 --- a/internal/service/telegram_command_menus.go +++ /dev/null @@ -1,158 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" -) - -func telegramSupportedCommand(cmd string) bool { - cmd = telegramCommandName(cmd) - if cmd == "" { - return false - } - _, ok := telegramSupportedCommandSet[cmd] - return ok -} - -var telegramSupportedCommandSet = map[string]struct{}{ - "/start": {}, "/menu": {}, "/cancel": {}, "/help": {}, "/hideadult": {}, "/hide_adult": {}, "/adult": {}, - "/account": {}, "/me": {}, "/myinfo": {}, "/count": {}, "/signin": {}, "/checkin": {}, "/devices": {}, "/kick": {}, "/setname": {}, "/rename": {}, "/setpass": {}, "/passwd": {}, "/password": {}, - "/redeem": {}, "/redeem_register": {}, "/redeem_renew": {}, - "/register": {}, "/reg": {}, "/signup": {}, "/registration": {}, "/reg_switch": {}, "/openreg": {}, - "/capacity": {}, "/users": {}, "/kk": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {}, "/unbind": {}, "/unbind_duplicates": {}, "/unbind_inactive": {}, - "/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {}, - "/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {}, - "/renew": {}, "/ucr": {}, "/uinfo": {}, "/rmemby": {}, "/urm": {}, "/only_rm_emby": {}, "/only_rm_record": {}, - "/userip": {}, "/udeviceid": {}, "/auditip": {}, "/auditdevice": {}, "/auditclient": {}, - "/renewall": {}, "/callall": {}, "/syncunbound": {}, "/syncgroupm": {}, "/kick_not_emby": {}, "/scan_embyname": {}, - "/check_ex": {}, "/deleted": {}, "/low_activity": {}, "/uranks": {}, "/days_ranks": {}, "/week_ranks": {}, - "/embyadmin": {}, "/unbanall": {}, "/banall": {}, "/embylibs_unblockall": {}, "/embylibs_blockall": {}, - "/extraembylibs_unblockall": {}, "/extraembylibs_blockall": {}, "/proadmin": {}, "/revadmin": {}, - "/backup_db": {}, "/restore_from_db": {}, "/prouser": {}, "/revuser": {}, -} - -type telegramBotCommand struct { - Command string `json:"command"` - Description string `json:"description"` -} - -func telegramBotCommandMenu() []telegramBotCommand { - return telegramPrivateBotCommandMenu() -} - -func telegramPrivateBotCommandMenu() []telegramBotCommand { - return []telegramBotCommand{ - {Command: "start", Description: "绑定账号或打开主菜单"}, - {Command: "menu", Description: "打开功能菜单"}, - {Command: "help", Description: "查看命令帮助"}, - {Command: "account", Description: "查看账号状态"}, - {Command: "signin", Description: "签到"}, - {Command: "devices", Description: "查看登录设备"}, - {Command: "kick", Description: "踢下线设备"}, - {Command: "setname", Description: "修改用户名(需当前密码)"}, - {Command: "setpass", Description: "修改密码(需当前密码)"}, - {Command: "hideadult", Description: "隐藏/显示成人媒体库"}, - {Command: "redeem", Description: "兑换注册码或续期码"}, - {Command: "register", Description: "注册新账号"}, - } -} - -func telegramGroupBotCommandMenu() []telegramBotCommand { - return []telegramBotCommand{ - {Command: "start", Description: "打开群组自助菜单"}, - {Command: "menu", Description: "打开群组自助菜单"}, - {Command: "help", Description: "查看群组可用命令"}, - {Command: "account", Description: "查看账号状态"}, - {Command: "signin", Description: "签到"}, - {Command: "devices", Description: "查看登录设备"}, - {Command: "kick", Description: "踢下线设备"}, - {Command: "hideadult", Description: "隐藏/显示成人媒体库"}, - } -} - -func telegramAdminBotCommandMenu() []telegramBotCommand { - commands := append([]telegramBotCommand{}, telegramPrivateBotCommandMenu()...) - commands = append(commands, - telegramBotCommand{Command: "status", Description: "系统运行状态(管理员)"}, - telegramBotCommand{Command: "search", Description: "搜索媒体库(管理员)"}, - telegramBotCommand{Command: "downloads", Description: "下载列表(管理员)"}, - telegramBotCommand{Command: "stats", Description: "媒体库统计(管理员)"}, - telegramBotCommand{Command: "users", Description: "用户管理(管理员)"}, - telegramBotCommand{Command: "cleanup", Description: "保号清理预览/确认(管理员)"}, - telegramBotCommand{Command: "cleanup_mode", Description: "查看固定保号模式(管理员)"}, - telegramBotCommand{Command: "cleanup_rule", Description: "Mgo保号规则管理(管理员)"}, - ) - commands = append(commands, telegramMgoAdminBotCommandMenu()...) - return commands -} - -func telegramMgoAdminBotCommandMenu() []telegramBotCommand { - return []telegramBotCommand{ - // 用户管理:保留 Sakura/Mgo 常用命令名,剔除 /urm、/only_rm_emby 等重复别名。 - {Command: "ucr", Description: "Mgo用户: 创建账号"}, - {Command: "uinfo", Description: "Mgo用户: 查询账号"}, - {Command: "rmemby", Description: "Mgo用户: 删除账号"}, - {Command: "only_rm_record", Description: "Mgo用户: 仅删Bot绑定"}, - {Command: "renewall", Description: "Mgo用户: 批量续期"}, - - // 审计:按 IP、设备指纹、客户端和 Telegram 绑定信息排查共享。 - {Command: "userip", Description: "Mgo审计: 查询用户IP"}, - {Command: "auditip", Description: "Mgo审计: 按IP审计"}, - {Command: "auditdevice", Description: "Mgo审计: 按设备审计"}, - {Command: "auditclient", Description: "Mgo审计: 按客户端审计"}, - {Command: "udeviceid", Description: "Mgo审计: 按设备ID审计"}, - - // 清理:/low_activity 是 /deleted 的兼容别名,不显示在命令栏。 - {Command: "syncunbound", Description: "Mgo清理: 未绑定账号"}, - {Command: "syncgroupm", Description: "Mgo清理: 校验群成员"}, - {Command: "check_ex", Description: "Mgo清理: 检查过期账号"}, - {Command: "deleted", Description: "Mgo清理: 保号清理预览"}, - - // 权限:批量禁用、保护用户、媒体库播放权限。 - {Command: "embyadmin", Description: "Mgo权限: 设置管理员"}, - {Command: "banall", Description: "Mgo权限: 批量禁用用户"}, - {Command: "unbanall", Description: "Mgo权限: 批量解禁用户"}, - {Command: "prouser", Description: "Mgo权限: 加入保护名单"}, - {Command: "revuser", Description: "Mgo权限: 移出保护名单"}, - {Command: "embylibs_blockall", Description: "Mgo权限: 批量禁用媒体库"}, - {Command: "embylibs_unblockall", Description: "Mgo权限: 批量开放媒体库"}, - - // 运维:Bot 管理员与数据库备份恢复。 - {Command: "proadmin", Description: "Mgo运维: 添加Bot管理员"}, - {Command: "revadmin", Description: "Mgo运维: 移除Bot管理员"}, - {Command: "backup_db", Description: "Mgo运维: 备份数据库"}, - {Command: "restore_from_db", Description: "Mgo运维: 恢复数据库"}, - } -} - -func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) error { - if strings.TrimSpace(cfg["bot_token"]) == "" { - return nil - } - normalizeTelegramConfig(cfg) - if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), nil); err != nil { - return err - } - if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), map[string]interface{}{"type": "all_private_chats"}); err != nil { - return err - } - if err := telegramSetBotCommands(ctx, cfg, telegramGroupBotCommandMenu(), map[string]interface{}{"type": "all_group_chats"}); err != nil { - return err - } - _ = telegramSetBotCommands(ctx, cfg, telegramAdminBotCommandMenu(), map[string]interface{}{"type": "all_chat_administrators"}) - - adminCommands := telegramAdminBotCommandMenu() - for _, adminID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - _ = telegramSetBotCommands(ctx, cfg, adminCommands, map[string]interface{}{"type": "chat", "chat_id": adminID}) - } - return nil -} - -func telegramSetBotCommands(ctx context.Context, cfg map[string]string, commands []telegramBotCommand, scope map[string]interface{}) error { - payload := map[string]interface{}{"commands": commands} - if scope != nil { - payload["scope"] = scope - } - return telegramPostJSON(ctx, cfg, "setMyCommands", payload, 15*time.Second) -} diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go deleted file mode 100644 index 6bdb682..0000000 --- a/internal/service/telegram_commands.go +++ /dev/null @@ -1,224 +0,0 @@ -// Package service — Telegram command registry and dispatch. -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type telegramCommandHandler func(args []string) (telegramCommandReply, error) - -type telegramCommandDefinition struct { - Aliases []string - AdminOnly bool - AdminOnlyText string - GroupAllowed bool - Handle telegramCommandHandler -} - -func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition { - adminOnly := "此命令仅管理员可用。" - defs := s.telegramCoreCommandDefinitions(ctx, channel, msg) - defs = append(defs, s.telegramSelfServiceCommandDefinitions(ctx, channel, msg)...) - defs = append(defs, s.telegramAdminCoreCommandDefinitions(ctx, msg, adminOnly)...) - defs = append(defs, s.telegramMgoUserCommandDefinitions(ctx, adminOnly)...) - defs = append(defs, s.telegramMgoAuditCommandDefinitions(ctx, adminOnly)...) - defs = append(defs, s.telegramMgoMaintenanceCommandDefinitions(ctx, channel, adminOnly)...) - defs = append(defs, s.telegramMgoPolicyCommandDefinitions(ctx, channel, adminOnly)...) - return defs -} - -func (s *TelegramBotService) telegramCoreCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - if len(args) == 0 { - return s.mainMenu(ctx, channel, msg), nil - } - return s.cmdStart(ctx, msg, args), nil - }}, - {Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - return s.mainMenu(ctx, channel, msg), nil - }}, - {Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - s.takePending(int64(msg.From.ID)) - return telegramCommandReply{Text: "已取消当前操作。"}, nil - }}, - {Aliases: []string{"/help"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil - }}, - } -} - -func (s *TelegramBotService) telegramSelfServiceCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }}, - {Aliases: []string{"/account", "/me", "/myinfo"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }}, - {Aliases: []string{"/count"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }}, - {Aliases: []string{"/signin", "/checkin"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }}, - {Aliases: []string{"/devices"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }}, - {Aliases: []string{"/kick"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }}, - {Aliases: []string{"/setname", "/rename"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetName(ctx, msg, args), nil }}, - {Aliases: []string{"/setpass", "/passwd", "/password"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetPass(ctx, msg, args), nil }}, - {Aliases: []string{"/redeem"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeem(ctx, channel, msg, args), nil }}, - {Aliases: []string{"/redeem_register"}, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdRedeemRegister(ctx, channel, msg, args), nil - }}, - {Aliases: []string{"/redeem_renew"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeemRenew(ctx, msg, args), nil }}, - {Aliases: []string{"/register", "/reg", "/signup"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegister(ctx, channel, msg, args), nil }}, - } -} - -func (s *TelegramBotService) telegramAdminCoreCommandDefinitions(ctx context.Context, msg *TelegramMessage, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/registration", "/reg_switch", "/openreg"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegistrationToggle(ctx, args), nil }}, - {Aliases: []string{"/capacity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyCapacity(ctx), nil }}, - {Aliases: []string{"/users", "/kk"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyUserList(ctx), nil }}, - {Aliases: []string{"/gencode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdGenCode(ctx, msg, args), nil }}, - {Aliases: []string{"/renew_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }}, - {Aliases: []string{"/delete_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserDelete(ctx, args), nil }}, - {Aliases: []string{"/unbind"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbind(ctx, args), nil }}, - {Aliases: []string{"/unbind_duplicates"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindDuplicates(ctx), nil }}, - {Aliases: []string{"/unbind_inactive"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindInactive(ctx, args), nil }}, - {Aliases: []string{"/devicepolicy", "/policy"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDevicePolicy(ctx, args), nil }}, - {Aliases: []string{"/antishare"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdAntiShare(ctx, args), nil }}, - {Aliases: []string{"/cleanup"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, args), nil }}, - {Aliases: []string{"/cleanup_mode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupMode(ctx, args), nil }}, - {Aliases: []string{"/cleanup_rule"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupRule(ctx, args), nil }}, - {Aliases: []string{"/ban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, false), nil }}, - {Aliases: []string{"/unban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, true), nil }}, - {Aliases: []string{"/status"}, AdminOnly: true, AdminOnlyText: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。", Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStatus(ctx) }}, - {Aliases: []string{"/search"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSearch(ctx, args) }}, - {Aliases: []string{"/downloads"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDownloads(ctx) }}, - {Aliases: []string{"/stats"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }}, - {Aliases: []string{"/renew"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }}, - } -} - -func (s *TelegramBotService) telegramMgoUserCommandDefinitions(ctx context.Context, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/ucr"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCreateUser(ctx, args), nil }}, - {Aliases: []string{"/uinfo"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoUserInfo(ctx, args), nil }}, - {Aliases: []string{"/rmemby", "/urm", "/only_rm_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoDeleteUser(ctx, args), nil }}, - {Aliases: []string{"/only_rm_record"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoOnlyRemoveRecord(ctx, args), nil }}, - {Aliases: []string{"/userip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoUserIP(ctx, args), nil }}, - } -} - -func (s *TelegramBotService) telegramMgoAuditCommandDefinitions(ctx context.Context, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/udeviceid"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "udeviceid", args), nil - }}, - {Aliases: []string{"/auditip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "auditip", args), nil - }}, - {Aliases: []string{"/auditdevice"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "auditdevice", args), nil - }}, - {Aliases: []string{"/auditclient"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "auditclient", args), nil - }}, - } -} - -func (s *TelegramBotService) telegramMgoMaintenanceCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/renewall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRenewAll(ctx, args), nil }}, - {Aliases: []string{"/callall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCallAll(ctx, channel, args), nil }}, - {Aliases: []string{"/syncunbound"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoSyncUnbound(ctx, args), nil }}, - {Aliases: []string{"/syncgroupm"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoSyncGroup(ctx, channel, args), nil - }}, - {Aliases: []string{"/kick_not_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoUnsupported("群内无号用户清理", "/syncgroupm 可检查已绑定账号是否仍在群内;Telegram Bot API 无法枚举全部群成员,因此不能可靠找出“在群但无号”的用户。"), nil - }}, - {Aliases: []string{"/scan_embyname"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoScanNames(ctx), nil }}, - {Aliases: []string{"/check_ex"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCheckExpired(ctx, args), nil }}, - {Aliases: []string{"/deleted", "/low_activity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, []string{"run"}), nil }}, - {Aliases: []string{"/uranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRanks(ctx, 0, true), nil }}, - {Aliases: []string{"/days_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoRanks(ctx, 24*time.Hour, false), nil - }}, - {Aliases: []string{"/week_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoRanks(ctx, 7*24*time.Hour, false), nil - }}, - } -} - -func (s *TelegramBotService) telegramMgoPolicyCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/embyadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoAdminRole(ctx, args), nil }}, - {Aliases: []string{"/unbanall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBanAll(ctx, true, args), nil }}, - {Aliases: []string{"/banall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBanAll(ctx, false, args), nil }}, - {Aliases: []string{"/embylibs_unblockall", "/extraembylibs_unblockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoMediaAccessAll(ctx, true), nil - }}, - {Aliases: []string{"/embylibs_blockall", "/extraembylibs_blockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoMediaAccessAll(ctx, false), nil - }}, - {Aliases: []string{"/proadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoBotAdmin(ctx, channel, args, true), nil - }}, - {Aliases: []string{"/revadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoBotAdmin(ctx, channel, args, false), nil - }}, - {Aliases: []string{"/backup_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBackupDB(ctx), nil }}, - {Aliases: []string{"/restore_from_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRestoreDB(ctx, args), nil }}, - {Aliases: []string{"/prouser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoProtectedUser(ctx, args, true), nil - }}, - {Aliases: []string{"/revuser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoProtectedUser(ctx, args, false), nil - }}, - } -} - -func (s *TelegramBotService) telegramCommandRegistry(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) map[string]telegramCommandDefinition { - defs := s.telegramCommandDefinitions(ctx, channel, msg) - registry := make(map[string]telegramCommandDefinition, len(defs)*2) - for _, def := range defs { - for _, alias := range def.Aliases { - registry[alias] = def - } - } - return registry -} - -// executeCommand parses and dispatches Telegram commands through a registry so -// adding a command does not grow a monolithic switch. -func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) { - parts := strings.Fields(text) - if len(parts) == 0 { - return telegramCommandReply{}, nil - } - - cmd := telegramCommandName(parts[0]) - args := parts[1:] - if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) { - return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「绑定群组 ID」或「绑定频道 ID」。"}, nil - } - - def, ok := s.telegramCommandRegistry(ctx, channel, msg)[cmd] - if !ok { - return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil - } - if telegramIsGroupChat(msg.Chat.Type) && !def.GroupAllowed { - if !def.AdminOnly || !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - return telegramCommandReply{}, nil - } - } - if def.AdminOnly && !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - return telegramCommandReply{Text: def.AdminOnlyText}, nil - } - reply, err := def.Handle(args) - if telegramIsGroupChat(msg.Chat.Type) && def.AdminOnly && !def.GroupAllowed { - if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - reply.Buttons = nil - } - } - return reply, err -} diff --git a/internal/service/telegram_device_policy.go b/internal/service/telegram_device_policy.go deleted file mode 100644 index 79eeb57..0000000 --- a/internal/service/telegram_device_policy.go +++ /dev/null @@ -1,252 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" -) - -func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply { - cfg := loadBotConfig(ctx, s.repo) - text := fmt.Sprintf( - "设备策略\n\n① 防共享:%s\n 并发播放终端上限 %d / 登录终端上限 %d;同一终端多个 App 只算 1 台,App 作为登录渠道记录。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup run 预览候选\n/cleanup run confirm 确认清理\n/cleanup on|off\n/cleanup_rule list|add|edit|修改|del|enable|disable\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。", - onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold, - onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), countEnabledCleanupRules(cfg.AccountCleanupRules)) - return telegramCommandReply{ - Text: text, - Buttons: [][]telegramInlineButton{ - {{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}}, - {{Text: toggleLabel("保号规则", cfg.AccountCleanupEnabled), Data: "dp_toggle:cleanup"}}, - {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, - }, - } -} - -func (s *TelegramBotService) cmdDevicePolicy(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "status") { - return s.replyDevicePolicy(ctx) - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "run", "sweep": - return s.cmdCleanup(ctx, []string{"run"}) - default: - return telegramCommandReply{Text: "用法:/devicepolicy 查看策略,或使用 /antishare、/cleanup、/cleanup_rule 管理。"} - } -} - -func (s *TelegramBotService) cmdAntiShare(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "status") { - return s.replyDevicePolicy(ctx) - } - enabled, ok := parseCommandBool(args[0]) - if !ok { - return telegramCommandReply{Text: "用法:/antishare on|off [play=3] [login=3] [warn=2],login 表示登录终端设备上限,同一终端多个 App 不重复计数。"} - } - if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - for _, arg := range args[1:] { - key, value, ok := strings.Cut(arg, "=") - if !ok { - continue - } - n, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || n < 1 { - continue - } - switch strings.ToLower(strings.TrimSpace(key)) { - case "play", "maxplay", "播放": - _ = s.repo.Setting.Set(ctx, SettingMaxConcurrentPlay, strconv.Itoa(n)) - case "login", "client", "clients", "登录": - _ = s.repo.Setting.Set(ctx, SettingMaxLoggedClients, strconv.Itoa(n)) - case "warn", "warnings", "警告": - _ = s.repo.Setting.Set(ctx, SettingWarnThreshold, strconv.Itoa(n)) - } - } - return s.replyDevicePolicy(ctx) -} - -func (s *TelegramBotService) cmdCleanup(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "status") { - return s.replyDevicePolicy(ctx) - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "on", "true", "1", "开启", "enable": - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - return telegramCommandReply{Text: "开启失败:" + err.Error()} - } - return s.replyDevicePolicy(ctx) - case "off", "false", "0", "关闭", "disable": - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "false"); err != nil { - return telegramCommandReply{Text: "关闭失败:" + err.Error()} - } - return s.replyDevicePolicy(ctx) - case "run", "sweep", "巡检", "preview", "预览": - device := s.device - if device == nil { - device = NewDeviceService(s.log, s.repo) - } - if len(args) > 1 && isCleanupConfirmArg(args[1]) { - cfg := loadBotConfig(ctx, s.repo) - if !cfg.AccountCleanupEnabled { - return telegramCommandReply{Text: "保号规则未开启,不会清理账号。"} - } - if countEnabledCleanupRules(cfg.AccountCleanupRules) == 0 { - return telegramCommandReply{Text: "没有启用的保号规则,不会清理账号。"} - } - removed, err := device.SweepAccountCleanup(ctx) - if err != nil { - return telegramCommandReply{Text: "确认清理失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("保号规则确认清理完成,已清理 %d 个账号。", removed)} - } - candidates, err := device.PreviewAccountCleanup(ctx) - if err != nil { - return telegramCommandReply{Text: "巡检预览失败:" + err.Error()} - } - return telegramCommandReply{Text: s.formatCleanupPreview(ctx, candidates)} - default: - return telegramCommandReply{Text: "用法:/cleanup on|off、/cleanup run 预览、/cleanup run confirm 确认清理"} - } -} - -func isCleanupConfirmArg(arg string) bool { - switch strings.ToLower(strings.TrimSpace(arg)) { - case "confirm", "yes", "delete", "确认", "清理", "删除": - return true - default: - return false - } -} - -func (s *TelegramBotService) formatCleanupPreview(ctx context.Context, candidates []accountCleanupCandidate) string { - cfg := loadBotConfig(ctx, s.repo) - if !cfg.AccountCleanupEnabled { - return "保号规则未开启,不会清理账号。" - } - if countEnabledCleanupRules(cfg.AccountCleanupRules) == 0 { - return "没有启用的保号规则,不会清理账号。" - } - if len(candidates) == 0 { - return "保号规则预览完成:没有需要清理的账号。" - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("保号规则预览\n\n将清理候选:%d 个账号。\n当前只是预览,未删除任何账号。\n\n", len(candidates))) - limit := len(candidates) - if limit > 10 { - limit = 10 - } - for i := 0; i < limit; i++ { - candidate := candidates[i] - sb.WriteString(fmt.Sprintf("%d. %s\n%s\n", i+1, escapeHTML(candidate.Username), escapeHTML(candidate.Details))) - } - if len(candidates) > limit { - sb.WriteString(fmt.Sprintf("……另有 %d 个候选未展示。\n", len(candidates)-limit)) - } - sb.WriteString("\n确认无误后再执行:/cleanup run confirm") - return sb.String() -} - -func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply { - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupKeepMode, "any"); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupRequiredCount, "1"); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - reply := s.replyDevicePolicy(ctx) - reply.Text = "Mgo 保号模式固定为:满足任意一条启用规则即保留;只有全部规则都不满足才进入清理候选。\n\n" + reply.Text - return reply -} - -func (s *TelegramBotService) cmdCleanupRule(ctx context.Context, args []string) telegramCommandReply { - rules := s.currentCleanupRules(ctx) - if len(args) == 0 { - return telegramCommandReply{Text: formatCleanupRules(rules)} - } - action := strings.ToLower(strings.TrimSpace(args[0])) - switch action { - case "list", "ls", "status": - return telegramCommandReply{Text: formatCleanupRules(rules)} - case "help", "?", "帮助": - return telegramCommandReply{Text: cleanupRuleHelp()} - case "del", "delete", "rm": - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/cleanup_rule del 规则ID"} - } - next := make([]accountCleanupRule, 0, len(rules)) - removed := false - for _, r := range rules { - if r.ID == args[1] { - removed = true - continue - } - next = append(next, r) - } - if !removed { - return telegramCommandReply{Text: "未找到该规则。"} - } - if err := s.saveCleanupRules(ctx, next); err != nil { - return telegramCommandReply{Text: "保存失败:" + err.Error()} - } - return telegramCommandReply{Text: "已删除规则。\n\n" + formatCleanupRules(next)} - case "enable", "on", "disable", "off": - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/cleanup_rule enable|disable 规则ID"} - } - enable := action == "enable" || action == "on" - changed := false - for i := range rules { - if rules[i].ID == args[1] { - rules[i].Enabled = enable - changed = true - } - } - if !changed { - return telegramCommandReply{Text: "未找到该规则。"} - } - if err := s.saveCleanupRules(ctx, rules); err != nil { - return telegramCommandReply{Text: "保存失败:" + err.Error()} - } - return telegramCommandReply{Text: "已更新规则状态。\n\n" + formatCleanupRules(rules)} - case "add", "set", "edit", "update", "修改", "更新", "改": - rule, err := parseCleanupRuleCommand(args[1:]) - if err != nil { - return telegramCommandReply{Text: err.Error() + "\n\n" + cleanupRuleHelp()} - } - updated := false - for i := range rules { - if rules[i].ID == rule.ID { - rules[i] = rule - updated = true - break - } - } - if !updated { - rules = append(rules, rule) - } - rules = normalizeCleanupRules(rules) - if err := s.saveCleanupRules(ctx, rules); err != nil { - return telegramCommandReply{Text: "保存失败:" + err.Error()} - } - actionText := "已新增规则。" - if updated { - actionText = "已更新规则。" - } - return telegramCommandReply{Text: actionText + "\n\n" + formatCleanupRules(rules)} - default: - return telegramCommandReply{Text: cleanupRuleHelp()} - } -} - -func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply { - cfg := loadBotConfig(ctx, s.repo) - switch which { - case "antishare": - _ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled)) - case "cleanup": - _ = s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, strconv.FormatBool(!cfg.AccountCleanupEnabled)) - } - return s.replyDevicePolicy(ctx) -} diff --git a/internal/service/telegram_membership.go b/internal/service/telegram_membership.go deleted file mode 100644 index 37f3c30..0000000 --- a/internal/service/telegram_membership.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { - if s.telegramUserIDConfigured(channel, telegramUserID) { - return true - } - binding := s.telegramBinding(ctx, telegramUserID) - if binding == nil { - return false - } - user, err := s.repo.User.FindByID(ctx, binding.UserID) - return err == nil && user != nil && user.Role == "admin" && user.IsActive -} - -// telegramBindDecision 表示成员资格校验的三态结果:通过 / 明确不通过 / -// 无法验证(getChatMember 出错,如 Bot 不在群、群 ID 失效、网络或代理不可达)。 -// 区分「明确不是成员」和「查不了」,是为了避免把验证失败误报成「你不在群」。 -type telegramBindDecision int - -const ( - bindDenied telegramBindDecision = iota // 已查实:不在任何绑定群组/频道 - bindAllowed // 管理员,或查实是某绑定群组/频道成员 - bindUnverifiable // 配了群组/频道但 getChatMember 全部失败 -) - -// telegramMembership 表示单个 chat 的成员资格三态。 -type telegramMembership int - -const ( - membershipNo telegramMembership = iota // 查实不是成员(left/kicked 等) - membershipYes // 查实是成员 - membershipUnknown // getChatMember 出错,无法判定 -) - -func (s *TelegramBotService) telegramUserBindDecision(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) telegramBindDecision { - if telegramUserID == 0 || channel == nil { - return bindDenied - } - if s.telegramUserIDConfigured(channel, telegramUserID) { - return bindAllowed - } - chatIDs := s.telegramMembershipChatIDs(channel) - if len(chatIDs) == 0 { - return bindDenied - } - sawUnknown := false - for _, chatID := range chatIDs { - switch s.telegramChatMembership(ctx, channel, chatID, telegramUserID) { - case membershipYes: - return bindAllowed - case membershipUnknown: - sawUnknown = true - } - } - if sawUnknown { - return bindUnverifiable - } - return bindDenied -} - -// telegramUserCanBind 是 telegramUserBindDecision 的布尔包装,供尽力而为的场景 -// 使用(如私聊时挑选可用渠道):只有查实通过才返回 true。 -func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { - return s.telegramUserBindDecision(ctx, channel, telegramUserID) == bindAllowed -} - -// telegramBindRejectText 根据三态结果生成面向用户的提示。action 形如「兑换注册账号」 -// 「绑定媒体中心账号」。bindUnverifiable 时不再误导用户「你不在群」,而是提示 -// 管理员检查 Bot 权限与群组 ID。 -func telegramBindRejectText(decision telegramBindDecision, action string) string { - if decision == bindUnverifiable { - return fmt.Sprintf("暂时无法验证你的群组/频道成员身份,%s未成功。这通常是因为 Bot 未加入绑定群组、在频道中不是管理员,或群组 ID 配置有误(如超级群需带 -100 前缀)。请联系管理员检查 Bot 权限与「绑定群组/频道 ID」。", action) - } - return fmt.Sprintf("当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法%s。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。", action) -} - -func (s *TelegramBotService) telegramChatMembership(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) telegramMembership { - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" || chatID == "" || telegramUserID == 0 { - return membershipUnknown - } - payload := map[string]interface{}{ - "chat_id": chatID, - "user_id": telegramUserID, - } - var result struct { - OK bool `json:"ok"` - Result struct { - Status string `json:"status"` - } `json:"result"` - } - if err := telegramPostJSONDecode(ctx, cfg, "getChatMember", payload, 15*time.Second, &result); err != nil { - s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(sanitizeTelegramError(err))) - return membershipUnknown - } - if !result.OK { - return membershipUnknown - } - switch strings.ToLower(result.Result.Status) { - case "creator", "administrator", "member", "restricted": - return membershipYes - default: - return membershipNo - } -} - -// telegramUserIsChatMember 是 telegramChatMembership 的布尔包装,仅在查实是成员时 -// 返回 true(查不了也视为非成员,供尽力而为的场景使用)。 -func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool { - return s.telegramChatMembership(ctx, channel, chatID, telegramUserID) == membershipYes -} diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go deleted file mode 100644 index 064d543..0000000 --- a/internal/service/telegram_menu.go +++ /dev/null @@ -1,219 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// pendingTTL bounds how long a button-initiated text prompt stays valid. -const pendingTTL = 5 * time.Minute - -func (s *TelegramBotService) setPending(userID int64, kind string) { - s.pendingMu.Lock() - s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()} - s.pendingMu.Unlock() -} - -func (s *TelegramBotService) takePending(userID int64) (pendingInput, bool) { - s.pendingMu.Lock() - defer s.pendingMu.Unlock() - p, ok := s.pending[userID] - if ok { - delete(s.pending, userID) - } - if ok && time.Since(p.CreatedAt) > pendingTTL { - return pendingInput{}, false - } - return p, ok -} - -// boundUser resolves the local user bound to a Telegram account, or nil. -func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int) *model.User { - binding := s.telegramBinding(ctx, telegramUserID) - if binding == nil { - return nil - } - u, _ := s.repo.User.FindByID(ctx, binding.UserID) - return u -} - -// handleMenuCallback routes inline-button taps. Returns (reply, handled). -func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) { - isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID) - isGroup := telegramIsGroupChat(msg.Chat.Type) - if reply, handled := s.handleUserMenuCallback(ctx, channel, msg, data, isGroup); handled { - return reply, true - } - if !isAdmin { - if isGroup { - return telegramCommandReply{}, true - } - return telegramCommandReply{Text: "此功能仅管理员可用。"}, true - } - return s.handleAdminMenuCallback(ctx, msg, data) -} - -func (s *TelegramBotService) handleUserMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string, isGroup bool) (telegramCommandReply, bool) { - switch { - case data == "noop": - return telegramCommandReply{}, true - case data == "menu_main": - return s.mainMenu(ctx, channel, msg), true - case data == "act_account": - return s.replyAccount(ctx, msg), true - case data == "act_signin": - return s.replySignIn(ctx, msg), true - case data == "act_devices": - return s.replyDevices(ctx, msg), true - case strings.HasPrefix(data, "kick:"): - return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true - } - return s.handlePrivatePromptMenuCallback(ctx, msg, data, isGroup) -} - -func (s *TelegramBotService) handlePrivatePromptMenuCallback(ctx context.Context, msg *TelegramMessage, data string, isGroup bool) (telegramCommandReply, bool) { - switch data { - case "act_bind": - return telegramPrivateOnlyMenuReply(isGroup, "绑定账号", "请发送:/start 用户名 密码 绑定已有账号。"), true - case "act_register": - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("注册账号")}, true - } - if !s.openRegEnabled(ctx) { - return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true - } - s.setPending(int64(msg.From.ID), "register") - return telegramCommandReply{Text: "请发送新账号的 用户名 密码(空格分隔),例如:alice mypass123"}, true - case "act_redeem_register": - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码注册")}, true - } - s.setPending(int64(msg.From.ID), "redeem_register") - return telegramCommandReply{Text: "请发送你的注册兑换码,例如:ABCD2345EFGH\n(兑换后会要求设置用户名密码)"}, true - case "act_redeem_renew": - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码续期")}, true - } - s.setPending(int64(msg.From.ID), "redeem_renew") - return telegramCommandReply{Text: "请发送你的续期兑换码,将为当前绑定账号续期。"}, true - case "act_setname": - return s.setPendingPrivatePrompt(msg, isGroup, "修改用户名", "setname", "请发送:当前密码 新用户名。"), true - case "act_setpass": - return s.setPendingPrivatePrompt(msg, isGroup, "修改密码", "setpass", "请发送:当前密码 新密码(新密码至少 6 位)。"), true - } - return telegramCommandReply{}, false -} - -func telegramPrivateOnlyMenuReply(isGroup bool, action, privateText string) telegramCommandReply { - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint(action)} - } - return telegramCommandReply{Text: privateText} -} - -func (s *TelegramBotService) setPendingPrivatePrompt(msg *TelegramMessage, isGroup bool, action, kind, text string) telegramCommandReply { - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint(action)} - } - s.setPending(int64(msg.From.ID), kind) - return telegramCommandReply{Text: text} -} - -func (s *TelegramBotService) handleAdminMenuCallback(ctx context.Context, msg *TelegramMessage, data string) (telegramCommandReply, bool) { - if reply, handled := s.handleAdminRegistrationCallback(ctx, msg, data); handled { - return reply, true - } - if reply, handled := s.handleAdminUserCallback(ctx, data); handled { - return reply, true - } - switch { - case data == "adm_capacity": - return s.replyCapacity(ctx), true - case data == "adm_devicepolicy": - return s.replyDevicePolicy(ctx), true - case data == "adm_mgo_commands": - return telegramCommandReply{Text: telegramMgoAdminCommandHelp(), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}, true - case strings.HasPrefix(data, "dp_toggle:"): - return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true - } - return telegramCommandReply{}, false -} - -func (s *TelegramBotService) handleAdminRegistrationCallback(ctx context.Context, msg *TelegramMessage, data string) (telegramCommandReply, bool) { - switch { - case data == "adm_openreg": - return s.replyOpenRegMenu(ctx), true - case data == "adm_openreg_close": - _ = s.closeRegistration(ctx) - return telegramCommandReply{Text: "已关闭注册。"}, true - case strings.HasPrefix(data, "adm_openreg_set:"): - n, _ := strconv.Atoi(strings.TrimPrefix(data, "adm_openreg_set:")) - if err := s.openRegistration(ctx, n); err != nil { - return telegramCommandReply{Text: "开注失败:" + err.Error()}, true - } - label := "不限" - if n > 0 { - label = fmt.Sprintf("%d 个名额", n) - } - return telegramCommandReply{Text: "已开放注册:" + label + "。"}, true - case data == "adm_gencode": - return s.replyGenCodeMenu(), true - case strings.HasPrefix(data, "gc:"): - return s.replyGenCode(ctx, msg, data), true - } - return telegramCommandReply{}, false -} - -func (s *TelegramBotService) handleAdminUserCallback(ctx context.Context, data string) (telegramCommandReply, bool) { - switch { - case data == "adm_users": - return s.replyUserList(ctx), true - case strings.HasPrefix(data, "usr:"): - return s.replyUserActions(ctx, strings.TrimPrefix(data, "usr:")), true - case strings.HasPrefix(data, "uban:"): - return s.replyUserBan(ctx, strings.TrimPrefix(data, "uban:"), false), true - case strings.HasPrefix(data, "uunban:"): - return s.replyUserBan(ctx, strings.TrimPrefix(data, "uunban:"), true), true - case strings.HasPrefix(data, "udel:"): - return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true - case strings.HasPrefix(data, "urenew:"): - return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true - } - return telegramCommandReply{}, false -} - -// handlePendingText consumes a button-initiated text prompt. Returns (reply, -// handled). handled=false means there was no pending prompt for this user. -func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, bool) { - p, ok := s.takePending(int64(msg.From.ID)) - if !ok { - return telegramCommandReply{}, false - } - switch p.Kind { - case "register": - return s.cmdRegister(ctx, channel, msg, strings.Fields(text)), true - case "redeem_register": - return s.redeemRegisterFlow(ctx, channel, msg, text), true - case "redeem_renew": - return s.redeemRenewFlow(ctx, msg, text), true - case "setname": - return s.selfSetName(ctx, msg, text), true - case "setpass": - return s.selfSetPass(ctx, msg, text), true - case "openreg_limit": - n, err := strconv.Atoi(strings.TrimSpace(text)) - if err != nil || n < 0 { - return telegramCommandReply{Text: "请输入有效的非负整数。"}, true - } - if err := s.openRegistration(ctx, n); err != nil { - return telegramCommandReply{Text: "开注失败:" + err.Error()}, true - } - return telegramCommandReply{Text: fmt.Sprintf("已开放注册:%d 个名额。", n)}, true - } - return telegramCommandReply{}, false -} diff --git a/internal/service/telegram_menu_layout.go b/internal/service/telegram_menu_layout.go deleted file mode 100644 index 851ca67..0000000 --- a/internal/service/telegram_menu_layout.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// mainMenu builds the button-based menu, tailored to the user's binding and -// admin status. Ordinary users only see self-service actions; admins get an -// extra management section. -func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply { - isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID) - user := s.boundUser(ctx, msg.From.ID) - if telegramIsGroupChat(msg.Chat.Type) { - return s.groupMainMenu(isAdmin, user) - } - return s.privateMainMenu(ctx, isAdmin, user) -} - -func (s *TelegramBotService) groupMainMenu(isAdmin bool, user *model.User) telegramCommandReply { - header := "MediaStationGo 群组自助菜单\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。" - var rows [][]telegramInlineButton - if user != nil { - header = telegramUserMenuHeader("MediaStationGo 群组自助菜单", user) - rows = telegramBoundUserMenuRows(user, false) - } - if isAdmin { - header += "\n\n管理员入口" - rows = append(rows, telegramAdminMenuRows()...) - } - return telegramCommandReply{Text: header, Buttons: rows} -} - -func (s *TelegramBotService) privateMainMenu(ctx context.Context, isAdmin bool, user *model.User) telegramCommandReply { - header := "MediaStationGo\n\n你还没有绑定媒体中心账号。" - rows := s.privateUnboundMenuRows(ctx) - if user != nil { - header = telegramUserMenuHeader("MediaStationGo", user) - rows = telegramBoundUserMenuRows(user, true) - } - if isAdmin { - rows = append(rows, telegramAdminMenuRows()...) - } - return telegramCommandReply{Text: header, Buttons: rows} -} - -func (s *TelegramBotService) privateUnboundMenuRows(ctx context.Context) [][]telegramInlineButton { - rows := [][]telegramInlineButton{{{Text: "🔗 绑定账号", Data: "act_bind"}}} - if s.openRegEnabled(ctx) { - rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}}) - } - return append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}}) -} - -func telegramUserMenuHeader(title string, user *model.User) string { - return fmt.Sprintf("%s\n\n账号:%s\n到期:%s\n成人目录:%s", - title, user.Username, formatExpiry(user.ExpiredAt), telegramAdultVisibilityLabel(user.HideAdult)) -} - -func telegramAdultVisibilityLabel(hidden bool) string { - if hidden { - return "已隐藏" - } - return "已显示" -} - -func telegramAdultToggleText(hidden bool) string { - if hidden { - return "🔞 显示成人目录" - } - return "🔞 隐藏成人目录" -} - -func telegramBoundUserMenuRows(user *model.User, includePrivateActions bool) [][]telegramInlineButton { - rows := [][]telegramInlineButton{ - { - {Text: "👤 我的账号", Data: "act_account"}, - {Text: "📅 签到", Data: "act_signin"}, - }, - { - {Text: "📱 我的设备", Data: "act_devices"}, - {Text: telegramAdultToggleText(user.HideAdult), Data: "adult_toggle"}, - }, - } - if includePrivateActions { - rows = append(rows, - []telegramInlineButton{ - {Text: "✏️ 改用户名", Data: "act_setname"}, - {Text: "🔑 改密码", Data: "act_setpass"}, - }, - []telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}}, - ) - } - return rows -} - -func telegramAdminMenuRows() [][]telegramInlineButton { - return [][]telegramInlineButton{ - {{Text: "—— 管理员 ——", Data: "noop"}}, - { - {Text: "📊 容量/状态", Data: "adm_capacity"}, - {Text: "👥 用户管理", Data: "adm_users"}, - }, - { - {Text: "🔓 开注设置", Data: "adm_openreg"}, - {Text: "🎟 生成兑换码", Data: "adm_gencode"}, - }, - { - {Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}, - {Text: "🛠 管理命令", Data: "adm_mgo_commands"}, - }, - } -} diff --git a/internal/service/telegram_mgo_admin.go b/internal/service/telegram_mgo_admin.go deleted file mode 100644 index 728d50f..0000000 --- a/internal/service/telegram_mgo_admin.go +++ /dev/null @@ -1,159 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoAdminRole(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/embyadmin 用户名 on|off"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - enable := parseOnOff(args[1]) - if enable == nil { - return telegramCommandReply{Text: "第二个参数请使用 on/off。"} - } - if !*enable { - if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == user.ID { - return telegramCommandReply{Text: "默认管理员不可降级。"} - } - } - role := "user" - if *enable { - role = "admin" - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"role": role}); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已将 %s 角色设置为 %s。", user.Username, role)} -} - -func (s *TelegramBotService) cmdMgoMediaAccessAll(ctx context.Context, allow bool) telegramCommandReply { - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - updated := 0 - for _, user := range users { - if user.Role == "admin" { - continue - } - perm, err := s.repo.Permission.FindByUserID(ctx, user.ID) - if err != nil { - continue - } - if perm == nil { - perm = DefaultPermissions(user.ID) - perm.CanPlayMedia = allow - if err := s.repo.Permission.Create(ctx, perm); err != nil { - continue - } - } - if err := s.repo.DB.WithContext(ctx).Model(&model.UserPermission{}). - Where("user_id = ?", user.ID). - Update("can_play_media", allow).Error; err == nil { - updated++ - } - } - state := "关闭" - if allow { - state = "开启" - } - return telegramCommandReply{Text: fmt.Sprintf("已为普通用户%s媒体播放权限:%d 个。", state, updated)} -} - -func (s *TelegramBotService) cmdMgoBotAdmin(ctx context.Context, channel *model.NotifyChannel, args []string, add bool) telegramCommandReply { - if channel == nil { - return telegramCommandReply{Text: "Telegram 渠道不存在。"} - } - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/proadmin TelegramID 或 /revadmin TelegramID"} - } - tgID := strings.TrimPrefix(strings.TrimSpace(args[0]), "tg:") - if _, err := strconv.ParseInt(tgID, 10, 64); err != nil { - return telegramCommandReply{Text: "TelegramID 必须是数字。"} - } - cfg := s.telegramChannelConfig(channel) - ids := telegramConfiguredUserIDs(cfg["admin_user_ids"]) - seen := make(map[string]bool, len(ids)+1) - var next []string - for _, id := range ids { - if id == tgID { - seen[id] = true - if add { - next = append(next, id) - } - continue - } - if id != "" { - next = append(next, id) - } - } - if add && !seen[tgID] { - next = append(next, tgID) - } - cfg["admin_user_ids"] = strings.Join(next, ",") - raw, _ := json.Marshal(cfg) - updated := *channel - updated.Config = string(raw) - if s.crypto != nil { - updated.Config = s.crypto.Encrypt(updated.Config) - } - if err := s.repo.NotifyChannel.Update(ctx, &updated); err != nil { - return telegramCommandReply{Text: "更新管理员列表失败:" + err.Error()} - } - if add { - return telegramCommandReply{Text: "已添加 Bot 管理员:" + tgID + ""} - } - return telegramCommandReply{Text: "已移除 Bot 管理员:" + tgID + ""} -} - -func (s *TelegramBotService) cmdMgoProtectedUser(ctx context.Context, args []string, protect bool) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "list") { - return s.cmdMgoProtectedUserList(ctx) - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - ids := ProtectedUserIDSet(ctx, s.repo) - if protect { - ids[user.ID] = struct{}{} - if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil { - return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已加入保护名单:%s。\n该用户不会被 Bot 自动清理、批量禁用或删除。", user.Username)} - } - delete(ids, user.ID) - if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil { - return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已移出保护名单:%s。", user.Username)} -} - -func (s *TelegramBotService) cmdMgoProtectedUserList(ctx context.Context) telegramCommandReply { - ids := ProtectedUserIDSet(ctx, s.repo) - if len(ids) == 0 { - return telegramCommandReply{Text: "保护名单为空。管理员和默认管理员始终自动保护。"} - } - names := make([]string, 0, len(ids)) - for id := range ids { - if user, _ := s.repo.User.FindByID(ctx, id); user != nil { - names = append(names, user.Username) - } else { - names = append(names, id+"(用户不存在)") - } - } - sort.Strings(names) - return telegramCommandReply{Text: fmt.Sprintf("保护名单:%d 个。\n%s", len(names), telegramInlineCodeList(names))} -} diff --git a/internal/service/telegram_mgo_audit.go b/internal/service/telegram_mgo_audit.go deleted file mode 100644 index b696852..0000000 --- a/internal/service/telegram_mgo_audit.go +++ /dev/null @@ -1,133 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoSyncUnbound(ctx context.Context, args []string) telegramCommandReply { - var users []model.User - if err := s.repo.DB.WithContext(ctx). - Where("role <> ?", "admin"). - Where("NOT EXISTS (SELECT 1 FROM telegram_bindings WHERE telegram_bindings.user_id = users.id AND telegram_bindings.deleted_at IS NULL)"). - Order("created_at asc").Find(&users).Error; err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") { - deleted := 0 - for _, user := range users { - if UserIsProtectedAccount(ctx, s.repo, &user) { - continue - } - _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID) - if err := s.repo.User.Delete(ctx, user.ID); err == nil { - deleted++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("已删除未绑定 Bot 的普通用户:%d 个。", deleted)} - } - if len(users) == 0 { - return telegramCommandReply{Text: "没有未绑定 Bot 的普通用户。"} - } - names := make([]string, 0, minInt(len(users), 20)) - for i, user := range users { - if i >= 20 { - break - } - names = append(names, user.Username) - } - return telegramCommandReply{Text: fmt.Sprintf("未绑定 Bot 的普通用户:%d 个。\n%s\n\n如需删除:/syncunbound delete confirm", len(users), telegramInlineCodeList(names))} -} - -func (s *TelegramBotService) cmdMgoCheckExpired(ctx context.Context, args []string) telegramCommandReply { - now := time.Now() - var users []model.User - if err := s.repo.DB.WithContext(ctx).Where("expired_at IS NOT NULL AND expired_at < ?", now).Order("expired_at asc").Find(&users).Error; err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(args) >= 2 && strings.EqualFold(args[0], "disable") && strings.EqualFold(args[1], "confirm") { - disabled := 0 - for _, user := range users { - if UserIsProtectedAccount(ctx, s.repo, &user) { - continue - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"is_active": false}); err == nil { - disabled++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("已禁用过期普通用户:%d 个。", disabled)} - } - if len(users) == 0 { - return telegramCommandReply{Text: "没有过期用户。"} - } - lines := make([]string, 0, minInt(len(users), 20)) - for i, user := range users { - if i >= 20 { - break - } - lines = append(lines, fmt.Sprintf("%s(%s)", user.Username, formatExpiry(user.ExpiredAt))) - } - return telegramCommandReply{Text: fmt.Sprintf("过期用户:%d 个。\n%s\n\n如需禁用:/check_ex disable confirm", len(users), telegramInlineCodeList(lines))} -} - -func (s *TelegramBotService) cmdMgoScanNames(ctx context.Context) telegramCommandReply { - var rows []struct { - Username string - Count int64 - } - if err := s.repo.DB.WithContext(ctx).Table("users"). - Select("LOWER(username) AS username, COUNT(*) AS count"). - Group("LOWER(username)").Having("COUNT(*) > 1").Scan(&rows).Error; err != nil { - return telegramCommandReply{Text: "扫描失败:" + err.Error()} - } - if len(rows) == 0 { - return telegramCommandReply{Text: "未发现同名用户记录。"} - } - var out []string - for _, row := range rows { - out = append(out, fmt.Sprintf("%s x%d", row.Username, row.Count)) - } - return telegramCommandReply{Text: "同名用户记录\n" + telegramInlineCodeList(out)} -} - -func (s *TelegramBotService) cmdMgoRanks(ctx context.Context, window time.Duration, byDuration bool) telegramCommandReply { - since := time.Now().Add(-window) - title := "播放次数排行" - selectExpr := "COUNT(*) AS score" - if byDuration { - title = "观影时长排行" - selectExpr = "COALESCE(SUM(position_ms), 0) AS score" - } - q := s.repo.DB.WithContext(ctx).Table("playback_histories"). - Select("users.username, " + selectExpr). - Joins("JOIN users ON users.id = playback_histories.user_id"). - Group("users.username"). - Order("score DESC"). - Limit(10) - if window > 0 { - q = q.Where("playback_histories.watched_at >= ?", since) - } - var rows []struct { - Username string - Score int64 - } - if err := q.Scan(&rows).Error; err != nil { - return telegramCommandReply{Text: "排行查询失败:" + err.Error()} - } - if len(rows) == 0 { - return telegramCommandReply{Text: "暂无排行数据。"} - } - var out []string - for i, row := range rows { - score := fmt.Sprintf("%d 次", row.Score) - if byDuration { - score = humanDurationFromMillis(row.Score) - } - out = append(out, fmt.Sprintf("%d. %s — %s", i+1, row.Username, score)) - } - return telegramCommandReply{Text: "" + title + "\n\n" + strings.Join(out, "\n") + ""} -} diff --git a/internal/service/telegram_mgo_backup.go b/internal/service/telegram_mgo_backup.go deleted file mode 100644 index 7ba58c9..0000000 --- a/internal/service/telegram_mgo_backup.go +++ /dev/null @@ -1,49 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" -) - -func (s *TelegramBotService) cmdMgoBackupDB(ctx context.Context) telegramCommandReply { - if s.backup == nil { - return telegramCommandReply{Text: "备份服务暂不可用。"} - } - info, err := s.backup.Create(ctx) - if err != nil { - return telegramCommandReply{Text: "数据库备份失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("数据库备份完成:%s\n大小:%d bytes", info.Filename, info.Size)} -} - -func (s *TelegramBotService) cmdMgoRestoreDB(ctx context.Context, args []string) telegramCommandReply { - if s.backup == nil { - return telegramCommandReply{Text: "备份服务暂不可用。"} - } - if len(args) == 0 || strings.EqualFold(args[0], "list") { - items, err := s.backup.List() - if err != nil { - return telegramCommandReply{Text: "读取备份列表失败:" + err.Error()} - } - if len(items) == 0 { - return telegramCommandReply{Text: "暂无数据库备份。可先使用 /backup_db 创建。"} - } - lines := make([]string, 0, minInt(len(items), 10)) - for i, item := range items { - if i >= 10 { - break - } - lines = append(lines, fmt.Sprintf("%s(%d bytes)", item.Filename, item.Size)) - } - return telegramCommandReply{Text: "可恢复备份:\n" + telegramInlineCodeList(lines) + "\n\n恢复需要确认:/restore_from_db 文件名 confirm"} - } - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "恢复数据库会覆盖当前数据,需要确认:/restore_from_db 文件名 confirm"} - } - filename := strings.TrimSpace(args[0]) - if err := s.backup.Restore(ctx, filename); err != nil { - return telegramCommandReply{Text: "恢复失败:" + err.Error()} - } - return telegramCommandReply{Text: "数据库已从备份恢复,请重启 MediaStationGo 后生效。"} -} diff --git a/internal/service/telegram_mgo_bulk.go b/internal/service/telegram_mgo_bulk.go deleted file mode 100644 index e06f554..0000000 --- a/internal/service/telegram_mgo_bulk.go +++ /dev/null @@ -1,94 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoRenewAll(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "批量续期需要确认:/renewall 天数 confirm"} - } - days, err := strconv.Atoi(args[0]) - if err != nil || days < 0 { - return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"} - } - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - var count int - for _, user := range users { - if user.Role == "admin" { - continue - } - if err := s.applyRenewal(ctx, user.ID, days); err == nil { - count++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("批量续期完成:%d 个普通用户。", count)} -} - -func (s *TelegramBotService) cmdMgoBanAll(ctx context.Context, active bool, args []string) telegramCommandReply { - if len(args) == 0 || !strings.EqualFold(args[len(args)-1], "confirm") { - action := "banall" - if active { - action = "unbanall" - } - return telegramCommandReply{Text: fmt.Sprintf("批量操作需要确认:/%s confirm", action)} - } - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - var count int - for _, user := range users { - if !active && UserIsProtectedAccount(ctx, s.repo, &user) { - continue - } - if active && user.Role == "admin" { - continue - } - updates := map[string]any{"is_active": active} - if active { - updates["share_warnings"] = 0 - updates["last_share_warn_at"] = nil - } - if err := s.repo.User.UpdateFields(ctx, user.ID, updates); err == nil { - _ = s.repo.UserDevice.SetKickedByUser(ctx, user.ID, !active) - count++ - } - } - if active { - return telegramCommandReply{Text: fmt.Sprintf("已解禁普通用户:%d 个。", count)} - } - return telegramCommandReply{Text: fmt.Sprintf("已禁用普通用户:%d 个。", count)} -} - -func (s *TelegramBotService) cmdMgoCallAll(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply { - message := strings.TrimSpace(strings.Join(args, " ")) - if message == "" { - return telegramCommandReply{Text: "用法:/callall 消息内容"} - } - if strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) == "" { - return telegramCommandReply{Text: "当前 Telegram 渠道未配置 bot_token,无法群发。"} - } - var bindings []model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Find(&bindings).Error; err != nil { - return telegramCommandReply{Text: "读取绑定失败:" + err.Error()} - } - sent := 0 - for _, binding := range bindings { - if binding.ChatID == 0 { - continue - } - if err := s.reply(ctx, channel, int(binding.ChatID), telegramCommandReply{Text: message}); err == nil { - sent++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("群发完成:成功发送 %d 个绑定用户。", sent)} -} diff --git a/internal/service/telegram_mgo_compat.go b/internal/service/telegram_mgo_compat.go deleted file mode 100644 index 46f2188..0000000 --- a/internal/service/telegram_mgo_compat.go +++ /dev/null @@ -1,104 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoUnsupported(name, replacement string) telegramCommandReply { - text := fmt.Sprintf("%s 已识别,但当前 Telegram Bot API 无法完整复刻该行为。", name) - if replacement != "" { - text += "\n请使用:" + replacement - } - return telegramCommandReply{Text: text} -} - -func (s *TelegramBotService) findMgoBotUser(ctx context.Context, target string) *model.User { - target = strings.TrimSpace(strings.TrimPrefix(target, "@")) - if target == "" { - return nil - } - if user, _ := s.repo.User.FindByUsername(ctx, target); user != nil { - return user - } - if user, _ := s.repo.User.FindByID(ctx, target); user != nil { - return user - } - if tgRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { - if tgID, err := strconv.ParseInt(tgRaw, 10, 64); err == nil { - var binding model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", tgID).First(&binding).Error; err == nil { - user, _ := s.repo.User.FindByID(ctx, binding.UserID) - return user - } - } - } - return nil -} - -func activeLabel(user *model.User) string { - if user == nil { - return "未知" - } - if !user.IsActive { - return "已禁用" - } - if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) { - return "已过期" - } - return "正常" -} - -func formatOptionalTime(t *time.Time) string { - if t == nil { - return "-" - } - return t.Format("2006-01-02 15:04") -} - -func blankDash(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "-" - } - return value -} - -func telegramInlineCodeList(items []string) string { - if len(items) == 0 { - return "" - } - return "" + strings.Join(items, "、") + "" -} - -func parseOnOff(raw string) *bool { - value := strings.ToLower(strings.TrimSpace(raw)) - switch value { - case "on", "true", "1", "yes", "enable", "enabled", "开启", "开": - v := true - return &v - case "off", "false", "0", "no", "disable", "disabled", "关闭", "关": - v := false - return &v - default: - return nil - } -} - -func humanDurationFromMillis(ms int64) string { - if ms <= 0 { - return "0 分钟" - } - totalMinutes := ms / 1000 / 60 - hours := totalMinutes / 60 - minutes := totalMinutes % 60 - if hours == 0 { - return fmt.Sprintf("%d 分钟", minutes) - } - return fmt.Sprintf("%d 小时 %d 分钟", hours, minutes) -} diff --git a/internal/service/telegram_mgo_compat_test.go b/internal/service/telegram_mgo_compat_test.go deleted file mode 100644 index bf3abee..0000000 --- a/internal/service/telegram_mgo_compat_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package service - -import ( - "strings" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestMgoBotUserManagementAndAuditCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - if err := repos.User.Create(ctx, &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9401"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9401, Username: "admin"}, Chat: TelegramChat{ID: 9401, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/ucr viewer secret-pass 30") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已创建用户") { - t.Fatalf("expected user creation, got %q", reply.Text) - } - viewer, err := repos.User.FindByUsername(ctx, "viewer") - if err != nil || viewer == nil { - t.Fatalf("viewer should exist: %v", err) - } - if err := repos.UserDevice.Create(ctx, &model.UserDevice{ - UserID: viewer.ID, - DeviceID: "dev-abc", - DeviceName: "Windows PC", - Client: "Infuse", - LastIP: "1.2.3.4", - FirstSeenAt: time.Now(), - LastSeenAt: time.Now(), - }); err != nil { - t.Fatal(err) - } - - for _, tc := range []struct { - command string - want string - }{ - {"/uinfo viewer", "用户信息"}, - {"/userip viewer", "1.2.3.4"}, - {"/auditip 1.2.3", "viewer"}, - {"/auditdevice Windows", "viewer"}, - {"/auditclient Infuse", "viewer"}, - {"/udeviceid dev-abc", "viewer"}, - } { - t.Run(tc.command, func(t *testing.T) { - reply, err := bot.executeCommand(ctx, channel, msg, tc.command) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, tc.want) { - t.Fatalf("%s expected %q in %q", tc.command, tc.want, reply.Text) - } - }) - } -} - -func TestMgoBotBatchAndPermissionCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9501"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9501, Username: "admin"}, Chat: TelegramChat{ID: 9501, Type: "private"}} - users := []*model.User{ - {Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}, - {Username: "viewer1", PasswordHash: "x", Role: "user", IsActive: true}, - {Username: "viewer2", PasswordHash: "x", Role: "user", IsActive: true}, - {Username: "viewer3", PasswordHash: "x", Role: "user", IsActive: true}, - } - for _, user := range users { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if user.Role != "admin" && user.Username != "viewer3" { - if err := repos.Permission.Create(ctx, DefaultPermissions(user.ID)); err != nil { - t.Fatal(err) - } - } - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/renewall 7 confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "批量续期完成") { - t.Fatalf("expected renewall success, got %q", reply.Text) - } - renewed, _ := repos.User.FindByUsername(ctx, "viewer1") - if renewed.ExpiredAt == nil { - t.Fatal("renewall should set expiry for normal users") - } - - if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_blockall"); err != nil || !strings.Contains(reply.Text, "关闭媒体播放权限") { - t.Fatalf("expected blockall success, reply=%q err=%v", reply.Text, err) - } - perm, _ := repos.Permission.FindByUserID(ctx, users[1].ID) - if perm == nil || perm.CanPlayMedia { - t.Fatal("embylibs_blockall should disable media playback for normal users") - } - perm, _ = repos.Permission.FindByUserID(ctx, users[3].ID) - if perm == nil || perm.CanPlayMedia { - t.Fatal("embylibs_blockall should create disabled media playback permissions when missing") - } - if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_unblockall"); err != nil || !strings.Contains(reply.Text, "开启媒体播放权限") { - t.Fatalf("expected unblockall success, reply=%q err=%v", reply.Text, err) - } - perm, _ = repos.Permission.FindByUserID(ctx, users[1].ID) - if perm == nil || !perm.CanPlayMedia { - t.Fatal("embylibs_unblockall should enable media playback for normal users") - } - - if reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm"); err != nil || !strings.Contains(reply.Text, "已禁用普通用户") { - t.Fatalf("expected banall success, reply=%q err=%v", reply.Text, err) - } - banned, _ := repos.User.FindByUsername(ctx, "viewer2") - if banned.IsActive { - t.Fatal("banall should disable normal users") - } - if reply, err = bot.executeCommand(ctx, channel, msg, "/unbanall confirm"); err != nil || !strings.Contains(reply.Text, "已解禁普通用户") { - t.Fatalf("expected unbanall success, reply=%q err=%v", reply.Text, err) - } - unbanned, _ := repos.User.FindByUsername(ctx, "viewer2") - if !unbanned.IsActive { - t.Fatal("unbanall should re-enable normal users") - } -} - -func TestMgoBotSyncExpiryAndBotAdminCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9601"}`} - if err := repos.NotifyChannel.Create(ctx, channel); err != nil { - t.Fatal(err) - } - msg := &TelegramMessage{From: TelegramUser{ID: 9601, Username: "admin"}, Chat: TelegramChat{ID: 9601, Type: "private"}} - past := time.Now().Add(-24 * time.Hour) - if err := repos.User.Create(ctx, &model.User{Username: "expired", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &past}); err != nil { - t.Fatal(err) - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/syncunbound") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "expired") { - t.Fatalf("syncunbound should list unbound users, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/check_ex") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "expired") { - t.Fatalf("check_ex should list expired users, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/proadmin 9602") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已添加") { - t.Fatalf("proadmin should update channel config, got %q", reply.Text) - } - updated, _ := repos.NotifyChannel.FindByID(ctx, channel.ID) - cfg := bot.telegramChannelConfig(updated) - if !strings.Contains(cfg["admin_user_ids"], "9602") { - t.Fatalf("expected admin ids to include 9602, got %#v", cfg) - } -} - -func TestMgoBotProtectedUsersAndBackupCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - cfg := &config.Config{} - cfg.App.DataDir = t.TempDir() - bot.SetBackupService(NewBackupService(cfg, zap.NewNop(), repos.DB)) - - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9701"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9701, Username: "admin"}, Chat: TelegramChat{ID: 9701, Type: "private"}} - users := []*model.User{ - {Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}, - {Username: "safe", PasswordHash: "x", Role: "user", IsActive: true}, - {Username: "normal", PasswordHash: "x", Role: "user", IsActive: true}, - } - for _, user := range users { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/prouser safe") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已加入保护名单") { - t.Fatalf("expected protect success, got %q", reply.Text) - } - if reason := bot.protectReason(ctx, users[1].ID); !strings.Contains(reason, "保护名单") { - t.Fatalf("protected user should have protect reason, got %q", reason) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已禁用普通用户") { - t.Fatalf("expected banall success, got %q", reply.Text) - } - protected, _ := repos.User.FindByUsername(ctx, "safe") - normal, _ := repos.User.FindByUsername(ctx, "normal") - if !protected.IsActive { - t.Fatal("protected user should not be disabled by banall") - } - if normal.IsActive { - t.Fatal("normal user should be disabled by banall") - } - reply, err = bot.executeCommand(ctx, channel, msg, "/revuser safe") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已移出保护名单") { - t.Fatalf("expected unprotect success, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/backup_db") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "数据库备份完成") { - t.Fatalf("backup_db should create backup, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/restore_from_db list") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "mediastation_") { - t.Fatalf("restore list should show backup, got %q", reply.Text) - } -} - -func TestMgoBotAliasesAndSyncGroupGuards(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - if err := repos.User.Create(ctx, &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9801"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9801, Username: "admin"}, Chat: TelegramChat{ID: 9801, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/kk") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "用户管理") { - t.Fatalf("/kk should map to user management, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/syncgroupm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "未配置可校验成员") { - t.Fatalf("syncgroupm should explain missing group config, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/kick_not_emby") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "无法枚举全部群成员") { - t.Fatalf("kick_not_emby should explain Telegram limitation, got %q", reply.Text) - } -} diff --git a/internal/service/telegram_mgo_membership.go b/internal/service/telegram_mgo_membership.go deleted file mode 100644 index 0804483..0000000 --- a/internal/service/telegram_mgo_membership.go +++ /dev/null @@ -1,92 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoSyncGroup(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply { - chatIDs := s.telegramMembershipChatIDs(channel) - if len(chatIDs) == 0 { - return telegramCommandReply{Text: "未配置可校验成员的群组/频道 ID。请在 Telegram 通知渠道设置 group_chat_id 或 channel_chat_id。"} - } - if strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) == "" { - return telegramCommandReply{Text: "当前 Telegram 渠道未配置 bot_token,无法校验群成员。"} - } - var bindings []model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Find(&bindings).Error; err != nil { - return telegramCommandReply{Text: "读取绑定失败:" + err.Error()} - } - type staleBinding struct { - User model.User - Binding model.TelegramBinding - } - var stale []staleBinding - for _, binding := range bindings { - if binding.TelegramUserID == 0 || binding.UserID == "" { - continue - } - user, _ := s.repo.User.FindByID(ctx, binding.UserID) - if user == nil || UserIsProtectedAccount(ctx, s.repo, user) { - continue - } - // 仅当所有绑定群组/频道都「查实不是成员」时才判定为可清理; - // getChatMember 出错(membershipUnknown)时保守跳过,避免误删。 - confirmedNo := true - for _, chatID := range chatIDs { - if s.telegramChatMembership(ctx, channel, chatID, int(binding.TelegramUserID)) != membershipNo { - confirmedNo = false - break - } - } - if confirmedNo { - stale = append(stale, staleBinding{User: *user, Binding: binding}) - } - } - if len(stale) == 0 { - return telegramCommandReply{Text: "所有已绑定账号都仍在配置的群组/频道中。"} - } - if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") { - deleted := 0 - for _, item := range stale { - _ = s.repo.UserDevice.DeleteByUser(ctx, item.User.ID) - if err := s.repo.User.Delete(ctx, item.User.ID); err == nil { - deleted++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("已删除不在群组/频道中的普通账号:%d 个。", deleted)} - } - names := make([]string, 0, minInt(len(stale), 20)) - for i, item := range stale { - if i >= 20 { - break - } - names = append(names, fmt.Sprintf("%s(tg:%d)", item.User.Username, item.Binding.TelegramUserID)) - } - return telegramCommandReply{Text: fmt.Sprintf("不在配置群组/频道中的绑定账号:%d 个。\n%s\n\n删除需确认:/syncgroupm delete confirm", len(stale), telegramInlineCodeList(names))} -} - -func (s *TelegramBotService) telegramMembershipChatIDs(channel *model.NotifyChannel) []string { - cfg := s.telegramChannelConfig(channel) - seen := map[string]struct{}{} - var out []string - for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { - value := strings.TrimSpace(cfg[key]) - if value == "" { - continue - } - if _, ok := seen[value]; !ok { - seen[value] = struct{}{} - out = append(out, value) - } - } - if len(out) == 0 { - if value := strings.TrimSpace(cfg["chat_id"]); strings.HasPrefix(value, "-") { - out = append(out, value) - } - } - return out -} diff --git a/internal/service/telegram_mgo_users.go b/internal/service/telegram_mgo_users.go deleted file mode 100644 index 552ed3b..0000000 --- a/internal/service/telegram_mgo_users.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoCreateUser(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/ucr 用户名 密码 [天数],天数 0 表示永久。"} - } - if s.auth == nil { - return telegramCommandReply{Text: "注册服务暂不可用。"} - } - user, _, err := s.auth.Register(ctx, args[0], args[1]) - if err != nil { - return telegramCommandReply{Text: "创建失败:" + err.Error()} - } - days := 0 - if len(args) >= 3 { - parsed, err := strconv.Atoi(args[2]) - if err != nil || parsed < 0 { - return telegramCommandReply{Text: "账号已创建,但天数无效。请用 /renew 用户名 天数 调整。"} - } - days = parsed - if err := s.applyRenewal(ctx, user.ID, days); err != nil { - return telegramCommandReply{Text: "账号已创建,但续期失败:" + err.Error()} - } - } - return telegramCommandReply{Text: fmt.Sprintf("已创建用户:%s\n到期:%s", user.Username, formatExpiry(s.userExpiry(ctx, user.ID)))} -} - -func (s *TelegramBotService) cmdMgoUserInfo(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/uinfo 用户名"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - s.applyRealtimeUserActivity(ctx, user) - devices, _ := s.listUserDevices(ctx, user.ID) - var historyCount int64 - _ = s.repo.DB.WithContext(ctx).Model(&model.PlaybackHistory{}).Where("user_id = ?", user.ID).Count(&historyCount).Error - var binding model.TelegramBinding - tg := "未绑定" - if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", user.ID).First(&binding).Error; err == nil { - tg = fmt.Sprintf("tg:%d", binding.TelegramUserID) - if binding.TelegramName != "" { - tg += " " + binding.TelegramName - } - } - return telegramCommandReply{Text: fmt.Sprintf( - "用户信息\n\n用户名:%s\n角色:%s\n状态:%s\n到期:%s\nTelegram:%s\n设备:%d\n播放记录:%d\n最后登录:%s", - user.Username, user.Role, activeLabel(user), formatExpiry(user.ExpiredAt), tg, len(devices), historyCount, formatOptionalTime(user.LastLoginAt), - )} -} - -func (s *TelegramBotService) applyRealtimeUserActivity(ctx context.Context, user *model.User) { - if s == nil || user == nil || s.device == nil || s.device.sessions == nil { - return - } - users := []model.User{*user} - s.device.sessions.ApplyToUsers(ctx, users) - *user = users[0] -} - -func (s *TelegramBotService) cmdMgoDeleteUser(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "删除用户需要确认:/rmemby 用户名 confirm 或 /urm 用户名 confirm"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - if reason := s.protectReason(ctx, user.ID); reason != "" { - return telegramCommandReply{Text: reason} - } - _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID) - if err := s.repo.User.Delete(ctx, user.ID); err != nil { - return telegramCommandReply{Text: "删除失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", user.Username)} -} - -func (s *TelegramBotService) cmdMgoOnlyRemoveRecord(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/only_rm_record tg:123456 或 /only_rm_record 用户名,只删除 Telegram 绑定记录。"} - } - target := strings.TrimSpace(args[0]) - var removed int64 - if raw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { - tgID, err := strconv.ParseInt(raw, 10, 64) - if err != nil || tgID == 0 { - return telegramCommandReply{Text: "Telegram ID 无效。"} - } - removed, err = s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID) - if err != nil { - return telegramCommandReply{Text: "删除绑定失败:" + err.Error()} - } - } else { - user := s.findMgoBotUser(ctx, target) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID) - if err != nil { - return telegramCommandReply{Text: "删除绑定失败:" + err.Error()} - } - removed = n - } - return telegramCommandReply{Text: fmt.Sprintf("已删除 Telegram 绑定记录:%d 条。", removed)} -} - -func (s *TelegramBotService) cmdMgoUserIP(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/userip 用户名"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - devices, err := s.listUserDevices(ctx, user.ID) - if err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(devices) == 0 { - return telegramCommandReply{Text: "该用户暂无设备/IP记录。"} - } - var out []string - for i, d := range devices { - if i >= 20 { - break - } - out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s", i+1, blankDash(d.LastIP), blankDash(d.DeviceName), blankDash(d.Client), d.LastSeenAt.Format("2006-01-02 15:04"))) - } - return telegramCommandReply{Text: "" + user.Username + " 的设备/IP\n\n" + strings.Join(out, "\n") + ""} -} - -func (s *TelegramBotService) cmdMgoAuditDevices(ctx context.Context, mode string, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: fmt.Sprintf("用法:/%s 关键词", mode)} - } - keyword := strings.TrimSpace(strings.Join(args, " ")) - var rows []struct { - Username string - DeviceID string - DeviceName string - Client string - LastIP string - LastSeenAt time.Time - } - q := s.repo.DB.WithContext(ctx).Table("user_devices"). - Select("users.username, user_devices.device_id, user_devices.device_name, user_devices.client, user_devices.last_ip, user_devices.last_seen_at"). - Joins("JOIN users ON users.id = user_devices.user_id"). - Order("user_devices.last_seen_at desc"). - Limit(20) - switch mode { - case "auditip": - q = q.Where("user_devices.last_ip LIKE ?", "%"+keyword+"%") - case "auditdevice": - q = q.Where("user_devices.device_name LIKE ? OR user_devices.device_id LIKE ?", "%"+keyword+"%", "%"+keyword+"%") - case "auditclient": - q = q.Where("user_devices.client LIKE ?", "%"+keyword+"%") - case "udeviceid": - q = q.Where("user_devices.device_id LIKE ?", "%"+keyword+"%") - } - if err := q.Scan(&rows).Error; err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(rows) == 0 { - return telegramCommandReply{Text: "没有匹配记录。"} - } - var out []string - for i, r := range rows { - out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s / %s", i+1, r.Username, blankDash(r.LastIP), blankDash(r.DeviceName), blankDash(r.Client), r.LastSeenAt.Format("2006-01-02 15:04"))) - } - return telegramCommandReply{Text: "审计结果\n\n" + strings.Join(out, "\n") + ""} -} diff --git a/internal/service/telegram_polling.go b/internal/service/telegram_polling.go deleted file mode 100644 index 399f1b3..0000000 --- a/internal/service/telegram_polling.go +++ /dev/null @@ -1,208 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// TelegramPollingStartResult describes what happened when local long polling -// was requested. The admin UI uses it to avoid a silent "started" toast when -// no Telegram channel can actually poll. -type TelegramPollingStartResult struct { - Message string `json:"message"` - Started int `json:"started"` - AlreadyRunning int `json:"already_running"` - Skipped int `json:"skipped"` - Errors []string `json:"errors,omitempty"` -} - -// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。 -func (s *TelegramBotService) StartPolling(ctx context.Context) TelegramPollingStartResult { - result := TelegramPollingStartResult{Message: "telegram polling started"} - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - s.log.Error("failed to list telegram channels for polling", zap.Error(err)) - result.Message = "failed to list telegram channels" - result.Errors = append(result.Errors, err.Error()) - return result - } - if len(channels) == 0 { - result.Message = "no telegram channels configured" - result.Errors = append(result.Errors, "没有配置 Telegram 通知渠道") - return result - } - - for _, ch := range channels { - if !ch.Enabled { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": 通知渠道未启用") - continue - } - configStr := ch.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var rawCfg map[string]any - if err := json.Unmarshal([]byte(configStr), &rawCfg); err != nil { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": Telegram 配置解析失败: "+err.Error()) - continue - } - cfg := telegramStringConfigFromAny(rawCfg) - botToken := cfg["bot_token"] - if botToken == "" { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": Telegram Bot Token 为空") - continue - } - s.pollingMu.Lock() - if _, running := s.pollingCancel[botToken]; running { - s.pollingMu.Unlock() - result.AlreadyRunning++ - continue - } - s.pollingMu.Unlock() - - if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - if err := deleteTelegramWebhook(ctx, cfg); err != nil { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": "+sanitizeTelegramError(err).Error()) - continue - } - - s.pollingMu.Lock() - if _, running := s.pollingCancel[botToken]; running { - s.pollingMu.Unlock() - result.AlreadyRunning++ - continue - } - pollCtx, cancel := context.WithCancel(context.Background()) - s.pollingCancel[botToken] = cancel - s.pollingMu.Unlock() - - channel := ch - go s.pollLoop(pollCtx, cfg, &channel) - result.Started++ - s.log.Info("started telegram polling", zap.String("channel", ch.Name)) - } - if result.Started == 0 && result.AlreadyRunning == 0 { - result.Message = "no enabled telegram channels started" - } - return result -} - -// StopPolling 停止所有 Telegram 长轮询。 -func (s *TelegramBotService) StopPolling() int { - s.pollingMu.Lock() - defer s.pollingMu.Unlock() - stopped := 0 - for token, cancel := range s.pollingCancel { - cancel() - delete(s.pollingCancel, token) - stopped++ - } - s.log.Info("telegram polling stopped") - return stopped -} - -// pollLoop 对单个 Bot Token 执行长轮询。 -func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string, channel *model.NotifyChannel) { - var offset int64 = 0 - pollURL, err := telegramMethodURL(cfg, cfg["bot_token"], "getUpdates") - if err != nil { - s.log.Warn("telegram polling config invalid", zap.Error(err)) - return - } - clients := telegramHTTPClients(45*time.Second, cfg) - - for { - select { - case <-ctx.Done(): - return - default: - } - - reqBody, _ := json.Marshal(map[string]interface{}{ - "offset": offset, - "timeout": 30, - }) - respBody, err := telegramPollingRequest(ctx, clients, pollURL, string(reqBody)) - if err != nil { - s.log.Debug("telegram polling failed", zap.Error(err)) - time.Sleep(5 * time.Second) - continue - } - - var result struct { - OK bool `json:"ok"` - Result []TelegramUpdate `json:"result"` - } - if err := json.Unmarshal(respBody, &result); err != nil || !result.OK { - time.Sleep(3 * time.Second) - continue - } - - for _, upd := range result.Result { - if upd.UpdateID >= int(offset) { - offset = int64(upd.UpdateID) + 1 - } - if !telegramUpdateActionable(upd) { - continue - } - go func(u TelegramUpdate) { - handlerCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - _ = s.handleTelegramUpdate(handlerCtx, u, channel) - }(upd) - } - } -} - -// telegramUpdateActionable 判断一条 update 是否需要分发处理。 -// 长轮询默认会返回 message 与 callback_query 两类更新;命令消息需有文本, -// 而内联按钮回调(callback_query)必须被分发,否则成人目录显隐开关会失效。 -func telegramUpdateActionable(upd TelegramUpdate) bool { - if upd.CallbackQuery != nil { - return true - } - return upd.Message != nil && upd.Message.Text != "" -} - -func telegramPollingRequest(ctx context.Context, clients []*http.Client, pollURL, body string) ([]byte, error) { - var lastErr error - for _, client := range clients { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, strings.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - respBody, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) - continue - } - return respBody, nil - } - if lastErr != nil { - return nil, lastErr - } - return nil, errors.New("telegram polling failed") -} diff --git a/internal/service/telegram_redeem.go b/internal/service/telegram_redeem.go deleted file mode 100644 index dc9862f..0000000 --- a/internal/service/telegram_redeem.go +++ /dev/null @@ -1,185 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "gorm.io/gorm" -) - -var ( - errRegistrationCodeAlreadyUsed = errors.New("registration code already used") - errRegistrationCodeExpired = errors.New("registration code expired") -) - -func (s *TelegramBotService) cmdRedeem(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "请发送:/redeem 兑换码\n未绑定账号时自动尝试注册码;已绑定账号时自动尝试续期码。"} - } - code := strings.Join(args, " ") - if s.boundUser(ctx, msg.From.ID) == nil { - return s.redeemRegisterFlow(ctx, channel, msg, code) - } - return s.redeemRenewFlow(ctx, msg, code) -} - -func (s *TelegramBotService) cmdRedeemRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "请发送:/redeem_register 注册兑换码"} - } - return s.redeemRegisterFlow(ctx, channel, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) cmdRedeemRenew(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "请发送:/redeem_renew 续期兑换码"} - } - return s.redeemRenewFlow(ctx, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply { - if channel == nil { - channel = s.findChannelForMessage(ctx, msg) - } - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "兑换注册账号")} - } - rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister) - if rc == nil { - return telegramCommandReply{Text: errMsg} - } - if s.auth == nil { - return telegramCommandReply{Text: "注册服务暂不可用。"} - } - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - if u, _ := s.repo.User.FindByID(ctx, binding.UserID); u != nil { - return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 %s,无需再用注册码。", u.Username)} - } - } - user, password, claimedCode, err := s.createUserFromRegistrationCode(ctx, rc.Code) - if err != nil { - if errors.Is(err, errRegistrationCodeAlreadyUsed) { - return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} - } - if errors.Is(err, errRegistrationCodeExpired) { - return telegramCommandReply{Text: "兑换码已过期。"} - } - if errors.Is(err, ErrUserLimitReached) { - return telegramCommandReply{Text: "注册失败:用户数量已达授权上限。"} - } - return telegramCommandReply{Text: "注册失败:" + err.Error()} - } - if claimedCode == nil { - return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} - } - _ = s.upsertTelegramBinding(ctx, msg, user.ID) - return telegramCommandReply{ - Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:%s\n密码:%s\n到期:%s\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。", - user.Username, password, formatExpiry(s.userExpiry(ctx, user.ID))), - Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}, - } -} - -func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context, rawCode string) (*model.User, string, *model.RegistrationCode, error) { - code := normalizeRedemptionCode(rawCode) - if code == "" { - return nil, "", nil, errRegistrationCodeAlreadyUsed - } - password := randomCode(10) - var created model.User - var claimed model.RegistrationCode - err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", code, model.RegistrationCodeRegister). - First(&claimed).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errRegistrationCodeAlreadyUsed - } - return err - } - if claimed.IsExpired() { - return errRegistrationCodeExpired - } - var count int64 - if err := tx.Model(&model.User{}).Count(&count).Error; err != nil { - return err - } - if count >= LicensedMaxUsers(ctx, s.repo) { - return ErrUserLimitReached - } - hash, err := hashPassword(password) - if err != nil { - return err - } - codePrefix := strings.ToLower(claimed.Code) - if len(codePrefix) > 8 { - codePrefix = codePrefix[:8] - } - created = model.User{ - Username: "u" + codePrefix, - PasswordHash: hash, - Role: "user", - Tier: "free", - HideAdult: true, - ExpiredAt: renewExpiry(nil, claimed.DurationDays), - } - if err := tx.Create(&created).Error; err != nil { - return err - } - if err := tx.Create(DefaultPermissions(created.ID)).Error; err != nil { - return err - } - now := time.Now() - res := tx.Model(&model.RegistrationCode{}). - Where("id = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", claimed.ID). - Updates(map[string]any{ - "used_by_user_id": created.ID, - "used_count": gorm.Expr("used_count + 1"), - "used_at": gorm.Expr("CASE WHEN used_count + 1 >= CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END THEN ? ELSE used_at END", now), - }) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return errRegistrationCodeAlreadyUsed - } - claimed.UsedByUserID = created.ID - claimed.UsedCount++ - if claimed.UsedCount >= claimed.EffectiveMaxUses() { - claimed.UsedAt = &now - } - return nil - }) - if err != nil { - return nil, "", nil, err - } - return &created, password, &claimed, nil -} - -func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号再续期。"} - } - rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRenew) - if rc == nil { - return telegramCommandReply{Text: errMsg} - } - if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil { - return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} - } - if err := s.applyRenewal(ctx, user.ID, rc.DurationDays); err != nil { - return telegramCommandReply{Text: "续期失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("续期成功 ✅ 当前到期:%s", formatExpiry(s.userExpiry(ctx, user.ID)))} -} - -func (s *TelegramBotService) userExpiry(ctx context.Context, userID string) *time.Time { - if u, _ := s.repo.User.FindByID(ctx, userID); u != nil { - return u.ExpiredAt - } - return nil -} diff --git a/internal/service/telegram_reply.go b/internal/service/telegram_reply.go deleted file mode 100644 index f710cd2..0000000 --- a/internal/service/telegram_reply.go +++ /dev/null @@ -1,127 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const defaultTelegramMessageDeleteDelay = 120 * time.Second - -type telegramSendMessageResponse struct { - OK bool `json:"ok"` - Result struct { - MessageID int `json:"message_id"` - } `json:"result"` -} - -// reply 通过 Telegram Bot API 发送回复消息。 -func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error { - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" { - return fmt.Errorf("bot_token not configured") - } - - payload := map[string]interface{}{ - "chat_id": strconv.Itoa(chatID), - "text": reply.Text, - "parse_mode": "HTML", - } - if len(reply.Buttons) > 0 { - keyboard := make([][]map[string]string, 0, len(reply.Buttons)) - for _, row := range reply.Buttons { - buttons := make([]map[string]string, 0, len(row)) - for _, button := range row { - buttons = append(buttons, map[string]string{ - "text": button.Text, - "callback_data": button.Data, - }) - } - keyboard = append(keyboard, buttons) - } - payload["reply_markup"] = map[string]interface{}{"inline_keyboard": keyboard} - } - var sent telegramSendMessageResponse - if err := telegramPostJSONDecode(ctx, cfg, "sendMessage", payload, 15*time.Second, &sent); err != nil { - return err - } - if sent.Result.MessageID > 0 { - s.scheduleTelegramMessageDelete(cfg, chatID, sent.Result.MessageID) - } - return nil -} - -func (s *TelegramBotService) replyForMessage(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, reply telegramCommandReply) error { - if msg == nil { - return nil - } - if strings.TrimSpace(reply.Text) == "" { - return nil - } - return s.reply(ctx, channel, msg.Chat.ID, reply) -} - -func (s *TelegramBotService) deleteTelegramSourceMessage(channel *model.NotifyChannel, chatID, messageID int) { - if messageID <= 0 { - return - } - s.scheduleTelegramMessageDelete(s.telegramChannelConfig(channel), chatID, messageID) -} - -func (s *TelegramBotService) scheduleTelegramMessageDelete(cfg map[string]string, chatID, messageID int) { - if chatID == 0 || messageID <= 0 || strings.TrimSpace(cfg["bot_token"]) == "" { - return - } - delay := telegramMessageDeleteDelay(cfg) - if delay < 0 { - return - } - cfgCopy := make(map[string]string, len(cfg)) - for k, v := range cfg { - cfgCopy[k] = v - } - go func() { - if delay > 0 { - timer := time.NewTimer(delay) - defer timer.Stop() - <-timer.C - } - deleteCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - err := telegramPostJSON(deleteCtx, cfgCopy, "deleteMessage", map[string]interface{}{ - "chat_id": strconv.Itoa(chatID), - "message_id": messageID, - }, 10*time.Second) - if err != nil && s.log != nil { - s.log.Debug("telegram deleteMessage failed", - zap.Int("chat_id", chatID), - zap.Int("message_id", messageID), - zap.Error(sanitizeTelegramError(err)), - ) - } - }() -} - -func telegramMessageDeleteDelay(cfg map[string]string) time.Duration { - for _, key := range []string{"auto_delete_seconds", "message_delete_seconds", "delete_after_seconds"} { - raw := strings.TrimSpace(cfg[key]) - if raw == "" { - continue - } - seconds, err := strconv.Atoi(raw) - if err != nil { - continue - } - if seconds < 0 { - return -1 - } - return time.Duration(seconds) * time.Second - } - return defaultTelegramMessageDeleteDelay -} diff --git a/internal/service/telegram_stats.go b/internal/service/telegram_stats.go deleted file mode 100644 index faf62b9..0000000 --- a/internal/service/telegram_stats.go +++ /dev/null @@ -1,224 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// cmdStatus 处理 /status 命令。 -func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandReply, error) { - libraryIDs, err := s.activeTelegramStatsLibraryIDs(ctx) - if err != nil { - return telegramCommandReply{}, err - } - var mediaCount int64 - s.mediaStatsQuery(libraryIDs).Count(&mediaCount) - - var totalSize int64 - if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil { - return telegramCommandReply{}, err - } - totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024 - - return telegramCommandReply{Text: fmt.Sprintf( - "系统运行状态\n\n"+ - "🎬 媒体总数: %d\n"+ - "💾 存储占用: %.1f GB", - mediaCount, totalSizeGB, - )}, nil -} - -// cmdSearch 处理 /search 命令。 -func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (telegramCommandReply, error) { - if len(args) == 0 { - return telegramCommandReply{Text: "请提供搜索关键词\n例: /search 哥斯拉"}, nil - } - - keyword := strings.Join(args, " ") - var results []model.Media - err := s.repo.DB.Where("title LIKE ?", "%"+keyword+"%"). - Order("year DESC").Limit(8). - Find(&results).Error - if err != nil { - return telegramCommandReply{}, err - } - - if len(results) == 0 { - return telegramCommandReply{Text: fmt.Sprintf("未找到与 %s 相关的媒体", keyword)}, nil - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("搜索: %s\n\n", keyword)) - for i, m := range results { - year := "" - if m.Year > 0 { - year = fmt.Sprintf(" (%d)", m.Year) - } - ep := "" - if m.SeasonNum > 0 && m.EpisodeNum > 0 { - ep = fmt.Sprintf(" S%02dE%02d", m.SeasonNum, m.EpisodeNum) - } - sb.WriteString(fmt.Sprintf("%d. %s%s%s — %s\n", i+1, m.Title, year, ep, formatSize(m.SizeBytes))) - } - - return telegramCommandReply{Text: sb.String()}, nil -} - -// cmdDownloads 处理 /downloads 命令。 -func (s *TelegramBotService) cmdDownloads(ctx context.Context) (telegramCommandReply, error) { - type Row struct { - Title string - Status string - } - var rows []Row - if err := s.repo.DB.Raw( - "SELECT COALESCE(NULLIF(title,''),'下载任务') as title, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8", - ).Scan(&rows).Error; err != nil { - return telegramCommandReply{}, err - } - - if len(rows) == 0 { - return telegramCommandReply{Text: "当前没有下载任务。"}, nil - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("下载任务 (%d)\n\n", len(rows))) - for _, r := range rows { - icon := "⏳" - switch r.Status { - case "completed": - icon = "✅" - case "downloading": - icon = "📥" - case "error": - icon = "❌" - } - name := strings.TrimSpace(r.Title) - if name == "" { - name = "下载任务" - } - if len(name) > 60 { - name = name[:57] + "..." - } - sb.WriteString(fmt.Sprintf("%s %s\n", icon, name)) - } - - return telegramCommandReply{Text: sb.String()}, nil -} - -// cmdStats 处理 /stats 命令。 -func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply, error) { - libs, err := s.activeTelegramStatsLibraries(ctx) - if err != nil { - return telegramCommandReply{}, err - } - libraryIDs := make([]string, 0, len(libs)) - for _, lib := range libs { - libraryIDs = append(libraryIDs, lib.ID) - } - var totalMedia int64 - s.mediaStatsQuery(libraryIDs).Count(&totalMedia) - - var totalSize int64 - if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil { - return telegramCommandReply{}, err - } - - type LibStat struct { - Name string - Type string - Count int64 - } - stats := make([]LibStat, 0, len(libs)) - for _, lib := range libs { - var count int64 - if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil { - return telegramCommandReply{}, err - } - stats = append(stats, LibStat{Name: lib.Name, Type: lib.Type, Count: count}) - } - - var sb strings.Builder - sb.WriteString("媒体库统计\n\n") - sb.WriteString(fmt.Sprintf("📚 总数: %d\n", totalMedia)) - sb.WriteString(fmt.Sprintf("💾 大小: %s\n", formatSize(totalSize))) - - if len(stats) > 0 { - sb.WriteString("\n各库分布:\n") - for _, l := range stats { - icon := "🎬" - switch l.Type { - case "tv": - icon = "📺" - case "anime": - icon = "🍥" - case "music": - icon = "🎵" - } - sb.WriteString(fmt.Sprintf("%s %s: %d\n", icon, l.Name, l.Count)) - } - } - - return telegramCommandReply{Text: sb.String()}, nil -} - -func (s *TelegramBotService) activeTelegramStatsLibraries(ctx context.Context) ([]model.Library, error) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - libs = FilterDisplayCloudLibraries(ctx, s.repo, libs) - out := libs[:0] - for _, lib := range libs { - if lib.Enabled { - out = append(out, lib) - } - } - return out, nil -} - -func (s *TelegramBotService) activeTelegramStatsLibraryIDs(ctx context.Context) ([]string, error) { - libs, err := s.activeTelegramStatsLibraries(ctx) - if err != nil { - return nil, err - } - ids := make([]string, 0, len(libs)) - for _, lib := range libs { - ids = append(ids, lib.ID) - } - return ids, nil -} - -func (s *TelegramBotService) mediaStatsQuery(libraryIDs []string) *gorm.DB { - q := s.repo.DB.Model(&model.Media{}) - if len(libraryIDs) == 0 { - return q.Where("1 = 0") - } - return q.Where("library_id IN ?", libraryIDs) -} - -// formatSize 格式化字节数为可读字符串。 -func formatSize(bytes int64) string { - if bytes <= 0 { - return "0 B" - } - units := []string{"B", "KB", "MB", "GB", "TB"} - v := float64(bytes) - i := 0 - for v >= 1024 && i < len(units)-1 { - v /= 1024 - i++ - } - if i == 0 { - return fmt.Sprintf("%.0f %s", v, units[i]) - } - return fmt.Sprintf("%.1f %s", v, units[i]) -} diff --git a/internal/service/telegram_unbind.go b/internal/service/telegram_unbind.go deleted file mode 100644 index d496eee..0000000 --- a/internal/service/telegram_unbind.go +++ /dev/null @@ -1,224 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdUnbind(ctx context.Context, args []string) telegramCommandReply { - targets := parseTelegramUnbindTargets(args) - if len(targets) == 0 { - return telegramCommandReply{Text: "用法:/unbind 用户名1 用户名2\n也支持逗号分隔,或使用 tg:TelegramID 按 Telegram ID 解绑。此命令只解绑 Bot,不删除媒体账号。"} - } - var removed int64 - var done []string - var skipped []string - var missing []string - for _, target := range targets { - if tgIDRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { - tgID, err := strconv.ParseInt(tgIDRaw, 10, 64) - if err != nil || tgID == 0 { - missing = append(missing, target) - continue - } - n, err := s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID) - if err != nil { - return telegramCommandReply{Text: "解绑失败:" + err.Error()} - } - if n == 0 { - missing = append(missing, target) - continue - } - removed += n - done = append(done, target) - continue - } - - user, _ := s.repo.User.FindByUsername(ctx, target) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, target) - } - if user == nil { - missing = append(missing, target) - continue - } - if user.Role == "admin" { - skipped = append(skipped, user.Username+"(管理员)") - continue - } - n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID) - if err != nil { - return telegramCommandReply{Text: "解绑失败:" + err.Error()} - } - if n == 0 { - missing = append(missing, user.Username+"(未绑定)") - continue - } - removed += n - done = append(done, user.Username) - } - return formatUnbindResult("批量解绑完成", removed, done, skipped, missing) -} - -func (s *TelegramBotService) cmdUnbindDuplicates(ctx context.Context) telegramCommandReply { - if s == nil || s.repo == nil || s.repo.DB == nil { - return telegramCommandReply{Text: "仓库不可用。"} - } - var bindings []model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Order("updated_at desc, created_at desc").Find(&bindings).Error; err != nil { - return telegramCommandReply{Text: "读取绑定失败:" + err.Error()} - } - seenTelegram := make(map[int64]string) - seenUser := make(map[string]string) - var removeIDs []string - var removedLabels []string - for _, binding := range bindings { - remove := false - if binding.UserID == "" || binding.TelegramUserID == 0 { - remove = true - } else if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user == nil { - remove = true - } else if _, ok := seenTelegram[binding.TelegramUserID]; ok { - remove = true - } else if _, ok := seenUser[binding.UserID]; ok { - remove = true - } - if remove { - removeIDs = append(removeIDs, binding.ID) - removedLabels = append(removedLabels, fmt.Sprintf("tg:%d", binding.TelegramUserID)) - continue - } - seenTelegram[binding.TelegramUserID] = binding.ID - seenUser[binding.UserID] = binding.ID - } - if len(removeIDs) == 0 { - return telegramCommandReply{Text: "未发现重复或无效绑定。"} - } - n, err := s.deleteTelegramBindings(ctx, "id IN ?", removeIDs) - if err != nil { - return telegramCommandReply{Text: "清理失败:" + err.Error()} - } - return formatUnbindResult("重复/无效绑定清理完成", n, removedLabels, nil, nil) -} - -func (s *TelegramBotService) cmdUnbindInactive(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/unbind_inactive 天数\n例如 /unbind_inactive 30 会解绑 30 天未登录的普通用户 Bot 绑定,不删除账号。"} - } - days, err := strconv.Atoi(strings.TrimSpace(args[0])) - if err != nil || days < 1 { - return telegramCommandReply{Text: "天数必须是大于 0 的整数。"} - } - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) - recentWindow := time.Duration(days) * 24 * time.Hour - var userIDs []string - var done []string - for _, user := range users { - if user.Role == "admin" { - continue - } - lastActive := user.CreatedAt - if user.LastLoginAt != nil { - lastActive = *user.LastLoginAt - } - if lastActive.IsZero() || lastActive.After(cutoff) { - continue - } - if s.device != nil && s.device.UserRecentlyActive(ctx, user.ID, recentWindow) { - continue - } - var count int64 - _ = s.repo.DB.WithContext(ctx).Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error - if count == 0 { - continue - } - userIDs = append(userIDs, user.ID) - done = append(done, user.Username) - } - if len(userIDs) == 0 { - return telegramCommandReply{Text: fmt.Sprintf("未发现 %d 天未登录且已绑定 Bot 的普通用户。", days)} - } - n, err := s.deleteTelegramBindings(ctx, "user_id IN ?", userIDs) - if err != nil { - return telegramCommandReply{Text: "解绑失败:" + err.Error()} - } - return formatUnbindResult(fmt.Sprintf("已解绑 %d 天未登录用户", days), n, done, nil, nil) -} - -func parseTelegramUnbindTargets(args []string) []string { - seen := make(map[string]struct{}) - var targets []string - for _, arg := range args { - for _, part := range strings.FieldsFunc(arg, func(r rune) bool { - return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t' - }) { - part = strings.TrimSpace(part) - if part == "" { - continue - } - key := strings.ToLower(part) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - targets = append(targets, part) - } - } - return targets -} - -func (s *TelegramBotService) deleteTelegramBindings(ctx context.Context, query string, args ...interface{}) (int64, error) { - if s == nil || s.repo == nil || s.repo.DB == nil { - return 0, nil - } - tx := s.repo.DB.WithContext(ctx).Unscoped().Where(query, args...).Delete(&model.TelegramBinding{}) - return tx.RowsAffected, tx.Error -} - -func formatUnbindResult(title string, removed int64, done, skipped, missing []string) telegramCommandReply { - var sb strings.Builder - sb.WriteString("") - sb.WriteString(title) - sb.WriteString("\n\n") - sb.WriteString(fmt.Sprintf("已解绑:%d 条绑定", removed)) - if len(done) > 0 { - sb.WriteString("\n目标:") - sb.WriteString(formatShortList(done, 12)) - } - if len(skipped) > 0 { - sb.WriteString("\n跳过:") - sb.WriteString(formatShortList(skipped, 8)) - } - if len(missing) > 0 { - sb.WriteString("\n未找到/未绑定:") - sb.WriteString(formatShortList(missing, 8)) - } - return telegramCommandReply{Text: sb.String()} -} - -func formatShortList(items []string, limit int) string { - if len(items) == 0 { - return "" - } - if limit < 1 { - limit = 1 - } - out := items - if len(out) > limit { - out = out[:limit] - } - text := "" + strings.Join(out, "、") + "" - if len(items) > limit { - text += fmt.Sprintf(" 等 %d 项", len(items)) - } - return text -} diff --git a/internal/service/telegram_user_self.go b/internal/service/telegram_user_self.go deleted file mode 100644 index 3588129..0000000 --- a/internal/service/telegram_user_self.go +++ /dev/null @@ -1,241 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} - } - if len(args) == 0 { - return telegramCommandReply{Text: "请指定要踢下线的设备:/kick all 或 /kick 设备编号。先用 /devices 查看编号。"} - } - target := strings.TrimSpace(args[0]) - if strings.EqualFold(target, "all") || target == "全部" { - if s.device != nil { - if err := s.device.KickAllDevices(ctx, user.ID); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - } else if err := s.repo.UserDevice.SetKickedByUser(ctx, user.ID, true); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - return telegramCommandReply{Text: "已踢下线此账号的全部设备。"} - } - devices, _ := s.listUserDevices(ctx, user.ID) - if len(devices) == 0 { - return telegramCommandReply{Text: "当前没有记录到登录设备。"} - } - var chosen *model.UserDevice - if n, err := strconv.Atoi(target); err == nil && n >= 1 && n <= len(devices) { - chosen = &devices[n-1] - } else { - for i := range devices { - if devices[i].ID == target || devices[i].DeviceID == target { - chosen = &devices[i] - break - } - } - } - if chosen == nil { - return telegramCommandReply{Text: "未找到该设备。请用 /devices 查看设备编号后重试。"} - } - if s.device != nil { - if err := s.device.KickDevice(ctx, user.ID, chosen.DeviceID); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - } else if fp := strings.TrimSpace(chosen.Fingerprint); fp != "" { - if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - } else if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已踢下线:%s。", deviceLabel(chosen.DeviceName, chosen.Client))} -} - -func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "请发送:/setname 当前密码 新用户名"} - } - return s.selfSetName(ctx, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) cmdSetPass(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "请发送:/setpass 当前密码 新密码"} - } - return s.selfSetPass(ctx, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} - } - streak := 0 - if rec, _ := s.repo.SignIn.Get(ctx, user.ID); rec != nil { - streak = rec.StreakDays - } - devices, _ := s.listUserDevices(ctx, user.ID) - text := fmt.Sprintf("我的账号\n\n用户名:%s\n状态:%s\n到期:%s\n连续签到:%d 天\n登录设备:%d 台", - user.Username, - map[bool]string{true: "正常", false: "已禁用"}[user.IsActive], - formatExpiry(user.ExpiredAt), streak, len(devices)) - return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}} -} - -func (s *TelegramBotService) replySignIn(ctx context.Context, msg *TelegramMessage) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号后再签到。"} - } - res, err := s.signIn(ctx, user.ID) - if err != nil { - return telegramCommandReply{Text: "签到失败:" + err.Error()} - } - if res.AlreadySigned { - return telegramCommandReply{Text: fmt.Sprintf("今天已经签到过啦~\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)} - } - return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)} -} - -func (s *TelegramBotService) replyDevices(ctx context.Context, msg *TelegramMessage) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - devices, _ := s.listUserDevices(ctx, user.ID) - if len(devices) == 0 { - return telegramCommandReply{Text: "当前没有记录到登录设备。"} - } - var sb strings.Builder - sb.WriteString("我的登录设备\n点击下方按钮可一键踢下线:\n") - var rows [][]telegramInlineButton - for i, d := range devices { - status := "" - if d.Kicked { - status = "(已踢下线)" - } else if d.Playing { - status = "(播放中)" - } else if d.Online { - status = "(在线)" - } - sb.WriteString(fmt.Sprintf("\n%d. %s%s\n 最近活跃:%s", i+1, deviceLabel(d.DeviceName, d.Client), status, d.LastSeenAt.Format("01-02 15:04"))) - if !d.Kicked && !strings.HasPrefix(d.ID, "rt:") { - rows = append(rows, []telegramInlineButton{{Text: "🚫 踢下线:" + deviceLabel(d.DeviceName, d.Client), Data: "kick:" + d.ID}}) - } - } - rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}}) - return telegramCommandReply{Text: sb.String(), Buttons: rows} -} - -func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage, deviceRowID string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - var d model.UserDevice - if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil { - return telegramCommandReply{Text: "未找到该设备。"} - } - if s.device != nil { - if err := s.device.KickDevice(ctx, user.ID, d.DeviceID); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - } else if fp := strings.TrimSpace(d.Fingerprint); fp != "" { - if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - } else if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - return s.replyDevices(ctx, msg) -} - -func (s *TelegramBotService) listUserDevices(ctx context.Context, userID string) ([]model.UserDevice, error) { - if s.device != nil { - return s.device.ListDevices(ctx, userID) - } - return s.repo.UserDevice.ListByUser(ctx, userID) -} - -func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - currentPassword, newName := splitCurrentPasswordAndValue(input) - if currentPassword == "" || newName == "" { - return telegramCommandReply{Text: "请发送:当前密码 新用户名。"} - } - newName = strings.TrimSpace(newName) - if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") { - return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"} - } - if reply, ok := s.verifyTelegramSelfPassword(ctx, msg, user, currentPassword); !ok { - return reply - } - if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID { - return telegramCommandReply{Text: "该用户名已被占用,请换一个。"} - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"username": newName}); err != nil { - return telegramCommandReply{Text: "修改失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 %s。请用新用户名登录。", newName)} -} - -func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - currentPassword, newPass := splitCurrentPasswordAndValue(input) - if currentPassword == "" || newPass == "" { - return telegramCommandReply{Text: "请发送:当前密码 新密码。"} - } - newPass = strings.TrimSpace(newPass) - if s.auth == nil { - return telegramCommandReply{Text: "服务暂不可用。"} - } - if err := s.auth.ChangePassword(ctx, user.ID, currentPassword, newPass); err != nil { - if errors.Is(err, ErrInvalidCredentials) { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"} - } - return telegramCommandReply{Text: "修改失败:" + err.Error()} - } - if s.device != nil { - _ = s.device.KickAllDevices(ctx, user.ID) - } - return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"} -} - -func splitCurrentPasswordAndValue(input string) (string, string) { - fields := strings.Fields(strings.TrimSpace(input)) - if len(fields) < 2 { - return "", "" - } - return fields[0], strings.TrimSpace(strings.Join(fields[1:], " ")) -} - -func (s *TelegramBotService) verifyTelegramSelfPassword(ctx context.Context, msg *TelegramMessage, user *model.User, currentPassword string) (telegramCommandReply, bool) { - if s.auth == nil { - return telegramCommandReply{Text: "服务暂不可用。"}, false - } - if err := s.auth.VerifyPassword(ctx, user.ID, currentPassword); err != nil { - if errors.Is(err, ErrInvalidCredentials) { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"}, false - } - return telegramCommandReply{Text: "验证失败:" + err.Error()}, false - } - return telegramCommandReply{}, true -} diff --git a/internal/service/telegram_webhook_admin.go b/internal/service/telegram_webhook_admin.go deleted file mode 100644 index c3a70da..0000000 --- a/internal/service/telegram_webhook_admin.go +++ /dev/null @@ -1,31 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" -) - -// SetWebhook 注册 Telegram Bot Webhook URL。 -func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error { - cfg := map[string]string{"bot_token": botToken} - if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - payload := map[string]interface{}{ - "url": webhookURL, - "allowed_updates": []string{"message", "callback_query"}, - } - return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second) -} - -// GetWebhookInfo 获取 Webhook 配置信息。 -func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) { - cfg := map[string]string{"bot_token": botToken} - var result map[string]interface{} - if err := telegramGetJSONDecode(ctx, cfg, "getWebhookInfo", 10*time.Second, &result); err != nil { - return nil, err - } - return result, nil -} diff --git a/internal/service/transmission_adp.go b/internal/service/transmission_adp.go deleted file mode 100644 index b609d5b..0000000 --- a/internal/service/transmission_adp.go +++ /dev/null @@ -1,220 +0,0 @@ -// Package service — Transmission 下载适配器。 -// -// TransmissionAdapter 实现了 DownloadAdapter 接口,通过 Transmission RPC API -// 管理下载任务。 -package service - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "strings" - "sync" - "time" -) - -// TransmissionAdapter 是 Transmission 的 DownloadAdapter 实现。 -type TransmissionAdapter struct { - mu sync.Mutex - cfg DownloadClientConfig - client *http.Client - tag int - sessionID string -} - -// NewTransmissionAdapter 创建新的 Transmission 适配器。 -func NewTransmissionAdapter() *TransmissionAdapter { - return &TransmissionAdapter{ - client: NewInternalHTTPClient(20 * time.Second), - } -} - -// AddTorrent 通过 URL 添加种子。 -func (a *TransmissionAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{"filename": torrentURL} - return a.addTorrentLocked(ctx, args, savePath) -} - -// AddTorrentFile submits application-fetched .torrent bytes through -// Transmission's base64 metainfo field. This keeps private tracker cookies and -// signed URLs inside MediaStationGo instead of asking Transmission to refetch. -func (a *TransmissionAdapter) AddTorrentFile(ctx context.Context, data []byte, _ string, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{"metainfo": base64.StdEncoding.EncodeToString(data)} - return a.addTorrentLocked(ctx, args, savePath) -} - -func (a *TransmissionAdapter) addTorrentLocked(ctx context.Context, args map[string]interface{}, savePath string) (string, error) { - if savePath != "" { - args["download-dir"] = savePath - } - resp, err := a.rpcLocked(ctx, "torrent-add", args) - if err != nil { - return "", err - } - if added, ok := resp.Arguments["torrent-added"].(map[string]interface{}); ok { - if hashStr, ok := added["hashString"].(string); ok { - return hashStr, nil - } - } - if dup, ok := resp.Arguments["torrent-duplicate"].(map[string]interface{}); ok { - if hashStr, ok := dup["hashString"].(string); ok { - return hashStr, nil - } - } - return "", nil -} - -// AddMagnet 通过磁力链接添加种子。 -func (a *TransmissionAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) { - return a.AddTorrent(ctx, magnet, savePath) -} - -// Pause 暂停种子。 -func (a *TransmissionAdapter) Pause(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "torrent-stop", map[string]interface{}{ - "ids": []string{hash}, - }) - return err -} - -// Resume 恢复种子。 -func (a *TransmissionAdapter) Resume(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "torrent-start", map[string]interface{}{ - "ids": []string{hash}, - }) - return err -} - -// Remove 删除种子。 -func (a *TransmissionAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "torrent-remove", map[string]interface{}{ - "ids": []string{hash}, - "delete-local-data": deleteFiles, - }) - return err -} - -// List 列出种子。 -func (a *TransmissionAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{ - "fields": []string{ - "hashString", "name", "totalSize", "percentDone", - "rateDownload", "rateUpload", "status", "downloadDir", - "peersSendingToUs", "peersGettingFromUs", "addedDate", - "doneDate", "labels", "isStalled", - }, - } - resp, err := a.rpcLocked(ctx, "torrent-get", args) - if err != nil { - return nil, err - } - - torrentsRaw, ok := resp.Arguments["torrents"].([]interface{}) - if !ok { - return nil, nil - } - - result := make([]TorrentInfo, 0, len(torrentsRaw)) - for _, tr := range torrentsRaw { - t, ok := tr.(map[string]interface{}) - if !ok { - continue - } - - hash, _ := t["hashString"].(string) - name, _ := t["name"].(string) - size := toInt64(t["totalSize"]) - progress := toFloat64(t["percentDone"]) - dlSpeed := toInt64(t["rateDownload"]) - upSpeed := toInt64(t["rateUpload"]) - savePath, _ := t["downloadDir"].(string) - numSeeds := int(toInt64(t["peersSendingToUs"])) - numLeechs := int(toInt64(t["peersGettingFromUs"])) - addedOn := int64(toFloat64(t["addedDate"])) - - // Transmission 状态码转字符串 - status := int(toFloat64(t["status"])) - state := canonicalTorrentState(transmissionStateStr(status), progress) - - // 过滤 - if filter != "" && !strings.EqualFold(state, filter) { - continue - } - - result = append(result, TorrentInfo{ - Hash: hash, - Name: name, - Size: size, - Progress: normalizedTorrentProgress(progress), - DLSpeed: dlSpeed, - UPSpeed: upSpeed, - State: state, - SavePath: savePath, - NumSeeds: numSeeds, - NumLeechs: numLeechs, - AddedOn: time.Unix(addedOn, 0), - Tags: toJSONLabels(t["labels"]), - ContentPath: downloaderPayloadPath(savePath, name), - CompletionOn: toInt64(t["doneDate"]), - }) - } - return result, nil -} - -// GetInfo 获取单个种子信息。 -func (a *TransmissionAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{ - "ids": []string{hash}, - "fields": []string{ - "hashString", "name", "totalSize", "percentDone", - "rateDownload", "rateUpload", "status", "downloadDir", - "peersSendingToUs", "peersGettingFromUs", "addedDate", "doneDate", "labels", - }, - } - resp, err := a.rpcLocked(ctx, "torrent-get", args) - if err != nil { - return nil, err - } - torrentsRaw, ok := resp.Arguments["torrents"].([]interface{}) - if !ok || len(torrentsRaw) == 0 { - return nil, fmt.Errorf("torrent %s not found", hash) - } - t, ok := torrentsRaw[0].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("torrent %s: invalid response", hash) - } - - status := int(toFloat64(t["status"])) - info := &TorrentInfo{ - Hash: hash, - Name: strVal(t["name"]), - Size: toInt64(t["totalSize"]), - Progress: normalizedTorrentProgress(toFloat64(t["percentDone"])), - DLSpeed: toInt64(t["rateDownload"]), - UPSpeed: toInt64(t["rateUpload"]), - State: canonicalTorrentState(transmissionStateStr(status), toFloat64(t["percentDone"])), - SavePath: strVal(t["downloadDir"]), - NumSeeds: int(toInt64(t["peersSendingToUs"])), - NumLeechs: int(toInt64(t["peersGettingFromUs"])), - AddedOn: time.Unix(int64(toFloat64(t["addedDate"])), 0), - Tags: toJSONLabels(t["labels"]), - ContentPath: downloaderPayloadPath(strVal(t["downloadDir"]), strVal(t["name"])), - CompletionOn: toInt64(t["doneDate"]), - } - return info, nil -} diff --git a/internal/service/transmission_parse.go b/internal/service/transmission_parse.go deleted file mode 100644 index d8a4c63..0000000 --- a/internal/service/transmission_parse.go +++ /dev/null @@ -1,100 +0,0 @@ -package service - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" -) - -// transmissionStateStr 将 Transmission 状态码转为可读字符串。 -func transmissionStateStr(status int) string { - switch status { - case 0: - return "stopped" - case 1: - return "check_pending" - case 2: - return "checking" - case 3: - return "download_pending" - case 4: - return "downloading" - case 5: - return "seed_pending" - case 6: - return "seeding" - default: - return "unknown" - } -} - -// toInt64 安全地将 interface{} 转为 int64。 -func toInt64(v interface{}) int64 { - switch val := v.(type) { - case float64: - return int64(val) - case int: - return int64(val) - case int64: - return val - case json.Number: - n, _ := val.Int64() - return n - case string: - n, _ := strconv.ParseInt(val, 10, 64) - return n - default: - return 0 - } -} - -// toFloat64 安全地将 interface{} 转为 float64。 -func toFloat64(v interface{}) float64 { - switch val := v.(type) { - case float64: - return val - case int: - return float64(val) - case int64: - return float64(val) - case json.Number: - n, _ := val.Float64() - return n - case string: - n, _ := strconv.ParseFloat(val, 64) - return n - default: - return 0 - } -} - -// strVal 安全地提取字符串。 -func strVal(v interface{}) string { - if v == nil { - return "" - } - s, ok := v.(string) - if ok { - return s - } - return fmt.Sprintf("%v", v) -} - -// toJSONLabels 将 Transmission labels 转为逗号分隔字符串。 -func toJSONLabels(v interface{}) string { - if v == nil { - return "" - } - arr, ok := v.([]interface{}) - if !ok { - return "" - } - labels := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - labels = append(labels, s) - } - } - return strings.Join(labels, ",") -} diff --git a/internal/service/transmission_rpc.go b/internal/service/transmission_rpc.go deleted file mode 100644 index f17cbb1..0000000 --- a/internal/service/transmission_rpc.go +++ /dev/null @@ -1,141 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" -) - -// transmissionRPCRequest 是 Transmission RPC 请求的通用结构。 -type transmissionRPCRequest struct { - Method string `json:"method"` - Arguments map[string]interface{} `json:"arguments"` - Tag int `json:"tag,omitempty"` -} - -// transmissionRPCResponse 是 Transmission RPC 响应的通用结构。 -type transmissionRPCResponse struct { - Result string `json:"result"` - Arguments map[string]interface{} `json:"arguments"` - Tag int `json:"tag"` -} - -// Initialize 配置并初始化 Transmission RPC 连接。 -func (a *TransmissionAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error { - a.mu.Lock() - defer a.mu.Unlock() - endpoint, err := normalizeDownloadClientEndpoint("transmission", cfg.Host) - if err != nil { - return err - } - cfg.Host = endpoint - a.cfg = cfg - a.sessionID = "" - a.tag = 0 - return a.pingLocked(ctx) -} - -// Ping 测试连接。 -func (a *TransmissionAdapter) Ping(ctx context.Context) error { - a.mu.Lock() - defer a.mu.Unlock() - return a.pingLocked(ctx) -} - -// pingLocked 内部 ping 实现(调用者必须持有锁)。 -func (a *TransmissionAdapter) pingLocked(ctx context.Context) error { - rpcURL, err := downloadClientRPCURL("transmission", a.cfg.Host) - if err != nil { - return err - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, rpcURL, nil) - if err != nil { - return err - } - if a.cfg.Username != "" { - req.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - resp, err := a.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - _, _ = io.Copy(io.Discard, resp.Body) - if resp.StatusCode == 409 { - // 正常:需要 CSRF token - a.sessionID = resp.Header.Get("X-Transmission-Session-Id") - return nil - } - if resp.StatusCode >= 400 { - return fmt.Errorf("transmission rpc: %d", resp.StatusCode) - } - return nil -} - -// rpcLocked 发送 RPC 请求(调用者必须持有锁)。 -func (a *TransmissionAdapter) rpcLocked(ctx context.Context, method string, args map[string]interface{}) (*transmissionRPCResponse, error) { - rpcURL, err := downloadClientRPCURL("transmission", a.cfg.Host) - if err != nil { - return nil, err - } - - a.tag++ - body, err := json.Marshal(transmissionRPCRequest{ - Method: method, - Arguments: args, - Tag: a.tag, - }) - if err != nil { - return nil, err - } - - for attempt := 0; attempt < 2; attempt++ { - res, retry, err := func() (*transmissionRPCResponse, bool, error) { - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) - if err != nil { - return nil, false, err - } - req.Header.Set("Content-Type", "application/json") - if a.sessionID != "" { - req.Header.Set("X-Transmission-Session-Id", a.sessionID) - } - if a.cfg.Username != "" { - req.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - - resp, err := a.client.Do(req) - if err != nil { - return nil, false, err - } - defer resp.Body.Close() - - if resp.StatusCode == 409 { - a.sessionID = resp.Header.Get("X-Transmission-Session-Id") - return nil, true, nil - } - if resp.StatusCode >= 400 { - raw, _ := io.ReadAll(resp.Body) - return nil, false, fmt.Errorf("transmission rpc error: %d: %s", resp.StatusCode, string(raw)) - } - - var result transmissionRPCResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, false, err - } - if result.Result != "success" { - return nil, false, fmt.Errorf("transmission rpc result: %s", result.Result) - } - return &result, false, nil - }() - if err != nil { - return nil, err - } - if !retry { - return res, nil - } - } - return nil, fmt.Errorf("transmission: failed after CSRF retry") -} diff --git a/internal/service/watcher.go b/internal/service/watcher.go index 7a37bfc..47cc4cd 100644 --- a/internal/service/watcher.go +++ b/internal/service/watcher.go @@ -108,9 +108,6 @@ func (w *WatcherService) Refresh(ctx context.Context) error { if !root.Enabled { continue } - if _, _, ok := parseCloudLibraryPath(root.Path); ok { - continue - } watchRoot, info, err := resolveAccessibleMappedPath(root.Path) if err != nil || !info.IsDir() { w.log.Warn("watch path inaccessible", diff --git a/web/index.html b/web/index.html index ce08c73..948f2b0 100644 --- a/web/index.html +++ b/web/index.html @@ -4,16 +4,17 @@ - - - + + + + - MediaStationGo + MMTL
diff --git a/web/package.json b/web/package.json index a9e8bcf..68cae85 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "mediastationgo-web", + "name": "mmtl-web", "private": true, "version": "0.1.0", "type": "module", diff --git a/web/public/brand/logo-192.png b/web/public/brand/logo-192.png new file mode 100644 index 0000000000000000000000000000000000000000..674b95049a32e5b976fca96897424b0297136434 GIT binary patch literal 61940 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 literal 0 HcmV?d00001 diff --git a/web/public/brand/logo-512.png b/web/public/brand/logo-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7daa368b73c1587b11e184344cbc1ebbbc00b5ad GIT binary patch literal 380764 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®2F%QYJQORgoHU7g*HYl<4YbALD5Q=1+#m#*_z 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

@e6o|f7_%6>K2}NYb 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

mD~>uO_i6sVn!+kxY1=zA3prQy{G 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 Hu0mjf^GeXz literal 0 HcmV?d00001 diff --git a/web/public/brand/logo.png b/web/public/brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a19a8b2dd7e7627285b829ebc9ce2b2fc161b482 GIT binary patch literal 1432978 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%0DMfobI)QNQVm{M=Ofp2yI>;b%yA`4E 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