feat(crawler): 增加QQ无效专辑封面回填功能
- 在 run_etl.py 添加 backfill-qq-invalid-covers 命令行选项 - 配置文件中新增 HTTP_TRANSFER_RETRIES 和 RETRY_BACKOFF_SECONDS - oss.py 中实现下载资源的重试逻辑,避免超时和连接断开失败 - runner.py 中引入资源必要性校验,确保音频、封面、歌词均成功转存 - 实现根据歌词和封面有效性选择更合适的QQ录音记录进行替换 - 实现 backfill_qq_invalid_covers 函数,处理无专辑占位封面的QQ歌曲 - 无候选的QQ歌曲在crawler和yinyan记录中删除,并软删除 HK 歌曲 - 资源转存失败的记录在yinyan_song_records标记,避免重复处理 - run函数中增加对QQ、酷狗、网易备选录音的支持和筛选 - 增加对应的数据库操作函数支持替换、删除以及状态标记 - 测试覆盖下载重试、资源转存失败处理及选择替代录音逻辑 - 所有下载失败均返回空URL,不阻断流程以保证稳定性 - 异常处理增强,确保未成功上传资源的歌曲不入库 - 未匹配到HK歌曲的情况增加日志警告及延迟重试机制
Showing
9 changed files
with
681 additions
and
25 deletions
| ... | @@ -51,4 +51,6 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] | ... | @@ -51,4 +51,6 @@ PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] |
| 51 | BATCH_SIZE = 100 | 51 | BATCH_SIZE = 100 |
| 52 | BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) | 52 | BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) |
| 53 | HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) | 53 | HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) |
| 54 | HTTP_TRANSFER_RETRIES = int(os.environ.get('HTTP_TRANSFER_RETRIES', '3')) | ||
| 55 | HTTP_TRANSFER_RETRY_BACKOFF_SECONDS = float(os.environ.get('HTTP_TRANSFER_RETRY_BACKOFF_SECONDS', '1')) | ||
| 54 | OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE))) | 56 | OSS_CONNECTION_POOL_SIZE = int(os.environ.get('OSS_CONNECTION_POOL_SIZE', str(HTTP_POOL_MAXSIZE))) | ... | ... |
| 1 | import requests | 1 | import requests |
| 2 | import oss2 | 2 | import oss2 |
| 3 | import math | 3 | import math |
| 4 | import time | ||
| 4 | from urllib.parse import urlparse | 5 | from urllib.parse import urlparse |
| 5 | from requests.adapters import HTTPAdapter | 6 | from requests.adapters import HTTPAdapter |
| 6 | from .config import HTTP_POOL_MAXSIZE, OSS_CONFIG | 7 | from .config import ( |
| 8 | HTTP_POOL_MAXSIZE, HTTP_TRANSFER_RETRIES, HTTP_TRANSFER_RETRY_BACKOFF_SECONDS, OSS_CONFIG, | ||
| 9 | ) | ||
| 7 | from .utils import compute_audio_md5 | 10 | from .utils import compute_audio_md5 |
| 8 | 11 | ||
| 9 | _HTTP_SESSION = requests.Session() | 12 | _HTTP_SESSION = requests.Session() |
| ... | @@ -47,6 +50,25 @@ def _download_url(url: str, base_url: str) -> str: | ... | @@ -47,6 +50,25 @@ def _download_url(url: str, base_url: str) -> str: |
| 47 | return f"{download_base_url.rstrip('/')}{url[len(rewrite_from.rstrip('/')):]}" | 50 | return f"{download_base_url.rstrip('/')}{url[len(rewrite_from.rstrip('/')):]}" |
| 48 | 51 | ||
| 49 | 52 | ||
| 53 | def _download_content(url: str, base_url: str) -> bytes: | ||
| 54 | """下载资源;针对超时、连接中断和服务端错误进行有限重试。""" | ||
| 55 | download_url = _download_url(url, base_url) | ||
| 56 | attempts = max(1, HTTP_TRANSFER_RETRIES) | ||
| 57 | for attempt in range(attempts): | ||
| 58 | try: | ||
| 59 | resp = _http_get(download_url, timeout=30) | ||
| 60 | resp.raise_for_status() | ||
| 61 | return resp.content | ||
| 62 | except requests.RequestException as exc: | ||
| 63 | status_code = getattr(getattr(exc, 'response', None), 'status_code', None) | ||
| 64 | # 4xx(429 除外)是确定性失败,不浪费时间重试。 | ||
| 65 | if status_code is not None and 400 <= status_code < 500 and status_code != 429: | ||
| 66 | raise | ||
| 67 | if attempt == attempts - 1: | ||
| 68 | raise | ||
| 69 | time.sleep(HTTP_TRANSFER_RETRY_BACKOFF_SECONDS * (2 ** attempt)) | ||
| 70 | |||
| 71 | |||
| 50 | def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str: | 72 | def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> str: |
| 51 | """ | 73 | """ |
| 52 | 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。 | 74 | 将 url 指向的文件转移到 archive-dev OSS 的 oss_key 路径。 |
| ... | @@ -58,9 +80,8 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s | ... | @@ -58,9 +80,8 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s |
| 58 | return '' | 80 | return '' |
| 59 | if _is_target_oss_url(url, base_url): | 81 | if _is_target_oss_url(url, base_url): |
| 60 | return url | 82 | return url |
| 61 | resp = _http_get(_download_url(url, base_url), timeout=30) | 83 | content = _download_content(url, base_url) |
| 62 | resp.raise_for_status() | 84 | bucket.put_object(oss_key, content) |
| 63 | bucket.put_object(oss_key, resp.content) | ||
| 64 | return f"{base_url.rstrip('/')}/{oss_key}" | 85 | return f"{base_url.rstrip('/')}/{oss_key}" |
| 65 | 86 | ||
| 66 | 87 | ||
| ... | @@ -74,10 +95,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba | ... | @@ -74,10 +95,9 @@ def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, ba |
| 74 | return '', '' | 95 | return '', '' |
| 75 | if _is_target_oss_url(url, base_url): | 96 | if _is_target_oss_url(url, base_url): |
| 76 | return url, '' | 97 | return url, '' |
| 77 | resp = _http_get(_download_url(url, base_url), timeout=30) | 98 | content = _download_content(url, base_url) |
| 78 | resp.raise_for_status() | 99 | audio_md5 = compute_audio_md5(content) |
| 79 | audio_md5 = compute_audio_md5(resp.content) | 100 | bucket.put_object(oss_key, content) |
| 80 | bucket.put_object(oss_key, resp.content) | ||
| 81 | return f"{base_url.rstrip('/')}/{oss_key}", audio_md5 | 101 | return f"{base_url.rstrip('/')}/{oss_key}", audio_md5 |
| 82 | 102 | ||
| 83 | 103 | ... | ... |
| ... | @@ -111,6 +111,21 @@ def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list | ... | @@ -111,6 +111,21 @@ def fetch_hk_songs_by_source_ids(conn: pymysql.Connection, source_song_ids: list |
| 111 | } | 111 | } |
| 112 | 112 | ||
| 113 | 113 | ||
| 114 | def mark_hk_songs_deleted(conn: pymysql.Connection, source_song_ids: list[int]) -> int: | ||
| 115 | """将无法导入的源歌曲在 hk_songs_test 中软删除。""" | ||
| 116 | if not source_song_ids: | ||
| 117 | return 0 | ||
| 118 | unique_ids = list(dict.fromkeys(int(song_id) for song_id in source_song_ids)) | ||
| 119 | placeholders = ','.join(['%s'] * len(unique_ids)) | ||
| 120 | with conn.cursor() as cur: | ||
| 121 | cur.execute( | ||
| 122 | f"UPDATE hk_songs_test SET deleted = '1' " | ||
| 123 | f"WHERE source_song_id IN ({placeholders}) AND deleted = '0'", | ||
| 124 | unique_ids, | ||
| 125 | ) | ||
| 126 | return cur.rowcount | ||
| 127 | |||
| 128 | |||
| 114 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: | 129 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: |
| 115 | if not song_ids: | 130 | if not song_ids: |
| 116 | return [] | 131 | return [] | ... | ... |
| ... | @@ -9,6 +9,7 @@ from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, ge | ... | @@ -9,6 +9,7 @@ from .connections import get_hk_songs_conn, get_source_conn, get_spider_conn, ge |
| 9 | from .reader import ( | 9 | from .reader import ( |
| 10 | iter_hk_songs_batches, | 10 | iter_hk_songs_batches, |
| 11 | fetch_hk_songs_by_source_ids, | 11 | fetch_hk_songs_by_source_ids, |
| 12 | mark_hk_songs_deleted, | ||
| 12 | fetch_platform_records, | 13 | fetch_platform_records, |
| 13 | fetch_all_platform_records, | 14 | fetch_all_platform_records, |
| 14 | fetch_platform_records_by_record_ids, | 15 | fetch_platform_records_by_record_ids, |
| ... | @@ -36,6 +37,8 @@ from .writer import ( | ... | @@ -36,6 +37,8 @@ from .writer import ( |
| 36 | upsert_netease_singer_songs, upsert_netease_singer_albums, | 37 | upsert_netease_singer_songs, upsert_netease_singer_albums, |
| 37 | fetch_qq_songs_missing_singers, fetch_kugou_songs_missing_singers, fetch_netease_songs_missing_singers, | 38 | fetch_qq_songs_missing_singers, fetch_kugou_songs_missing_singers, fetch_netease_songs_missing_singers, |
| 38 | update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers, | 39 | update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers, |
| 40 | fetch_qq_songs_with_invalid_covers, replace_qq_song, replace_yinyan_song_record, | ||
| 41 | delete_qq_song_and_yinyan_record, mark_yinyan_record_resource_failed, | ||
| 39 | ) | 42 | ) |
| 40 | from .oss import transfer_url, transfer_url_with_md5, build_oss_key | 43 | from .oss import transfer_url, transfer_url_with_md5, build_oss_key |
| 41 | from .utils import split_title_version, upload_plain_lyric_to_bucket | 44 | from .utils import split_title_version, upload_plain_lyric_to_bucket |
| ... | @@ -46,6 +49,11 @@ log = logging.getLogger(__name__) | ... | @@ -46,6 +49,11 @@ log = logging.getLogger(__name__) |
| 46 | PROVIDER_YINYAN = 'yinyan' | 49 | PROVIDER_YINYAN = 'yinyan' |
| 47 | MAX_RESOURCE_WORKERS = 8 | 50 | MAX_RESOURCE_WORKERS = 8 |
| 48 | MAX_IMPORT_WORKERS = 16 | 51 | MAX_IMPORT_WORKERS = 16 |
| 52 | QQ_MISSING_ALBUM_COVER = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000.jpg' | ||
| 53 | |||
| 54 | |||
| 55 | class RequiredAssetTransferError(RuntimeError): | ||
| 56 | """音频、封面或歌词未成功落到目标 OSS,当前歌曲不得入库。""" | ||
| 49 | 57 | ||
| 50 | 58 | ||
| 51 | def _run_io_tasks(tasks: dict) -> dict: | 59 | def _run_io_tasks(tasks: dict) -> dict: |
| ... | @@ -65,7 +73,7 @@ def _safe_transfer(url, oss_key, bucket, base_url): | ... | @@ -65,7 +73,7 @@ def _safe_transfer(url, oss_key, bucket, base_url): |
| 65 | return transfer_url(url, oss_key, bucket, base_url) | 73 | return transfer_url(url, oss_key, bucket, base_url) |
| 66 | except Exception as e: | 74 | except Exception as e: |
| 67 | log.warning("OSS transfer failed for %s: %s", url, e) | 75 | log.warning("OSS transfer failed for %s: %s", url, e) |
| 68 | return url # 失败时保留原 URL,不阻断流程 | 76 | return '' # 不将未转存的外链写入 crawler |
| 69 | 77 | ||
| 70 | 78 | ||
| 71 | def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: | 79 | def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: |
| ... | @@ -73,15 +81,31 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: | ... | @@ -73,15 +81,31 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: |
| 73 | return transfer_url_with_md5(url, oss_key, bucket, base_url) | 81 | return transfer_url_with_md5(url, oss_key, bucket, base_url) |
| 74 | except Exception as e: | 82 | except Exception as e: |
| 75 | log.warning("Audio transfer failed for %s: %s", url, e) | 83 | log.warning("Audio transfer failed for %s: %s", url, e) |
| 76 | return url or '', '' | 84 | return '', '' # 不将未转存的音频外链写入 crawler |
| 77 | 85 | ||
| 78 | 86 | ||
| 79 | def _safe_upload_lyric(platform: str, unique_id: str, lyric: str | None, fallback_url: str | None, bucket, base_url: str) -> str: | 87 | def _safe_upload_lyric(platform: str, unique_id: str, lyric: str | None, fallback_url: str | None, bucket, base_url: str) -> str: |
| 80 | try: | 88 | try: |
| 81 | return upload_plain_lyric_to_bucket(platform, unique_id, lyric or '', bucket, base_url) or (fallback_url or '') | 89 | uploaded_url = upload_plain_lyric_to_bucket(platform, unique_id, lyric or '', bucket, base_url) |
| 90 | if uploaded_url: | ||
| 91 | return uploaded_url | ||
| 92 | fallback_url = str(fallback_url or '') | ||
| 93 | return fallback_url if fallback_url.startswith(base_url.rstrip('/') + '/') else '' | ||
| 82 | except Exception as e: | 94 | except Exception as e: |
| 83 | log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e) | 95 | log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e) |
| 84 | return fallback_url or '' | 96 | return '' |
| 97 | |||
| 98 | |||
| 99 | def _require_primary_assets(assets: dict) -> None: | ||
| 100 | """音频、歌曲封面、歌词均为入库必需资源,任一失败即拒绝整条记录。""" | ||
| 101 | audio_url, _ = assets.get('audio', ('', '')) | ||
| 102 | missing = [ | ||
| 103 | name for name, value in ( | ||
| 104 | ('audio', audio_url), ('cover', assets.get('cover')), ('lyric', assets.get('lyric')), | ||
| 105 | ) if not value | ||
| 106 | ] | ||
| 107 | if missing: | ||
| 108 | raise RequiredAssetTransferError(f"required asset transfer failed: {', '.join(missing)}") | ||
| 85 | 109 | ||
| 86 | 110 | ||
| 87 | def _json_default(value): | 111 | def _json_default(value): |
| ... | @@ -92,6 +116,123 @@ def _source_json(data: dict) -> str: | ... | @@ -92,6 +116,123 @@ def _source_json(data: dict) -> str: |
| 92 | return json.dumps(data, ensure_ascii=False, default=_json_default) | 116 | return json.dumps(data, ensure_ascii=False, default=_json_default) |
| 93 | 117 | ||
| 94 | 118 | ||
| 119 | def _is_usable_qq_cover(url: str | None) -> bool: | ||
| 120 | return bool(url and str(url).strip() and str(url).strip() != QQ_MISSING_ALBUM_COVER) | ||
| 121 | |||
| 122 | |||
| 123 | def _qq_cover_source(hk_row: dict, song_data: dict) -> str: | ||
| 124 | """HK 封面优先;QQ 的 M000 占位图视为无封面,改用当前录音的封面。""" | ||
| 125 | hk_cover = hk_row.get('cover_url') | ||
| 126 | return hk_cover if _is_usable_qq_cover(hk_cover) else (song_data.get('cover') or '') | ||
| 127 | |||
| 128 | |||
| 129 | def _has_usable_qq_lyric(song_data: dict) -> bool: | ||
| 130 | return bool(str(song_data.get('lyric') or '').strip()) | ||
| 131 | |||
| 132 | |||
| 133 | def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool: | ||
| 134 | """酷狗封面来源:hk_row.cover_url 优先,回退到 spider 录音封面。""" | ||
| 135 | return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) | ||
| 136 | |||
| 137 | |||
| 138 | def _has_kugou_lyric(song_data: dict) -> bool: | ||
| 139 | return bool(str(song_data.get('lyric') or '').strip()) | ||
| 140 | |||
| 141 | |||
| 142 | def _has_netease_cover(hk_row: dict, song_data: dict) -> bool: | ||
| 143 | """网易封面来源:hk_row.cover_url 优先,回退到 spider 录音封面。""" | ||
| 144 | return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) | ||
| 145 | |||
| 146 | |||
| 147 | def _has_netease_lyric(song_data: dict) -> bool: | ||
| 148 | return bool(str(song_data.get('lyric') or '').strip()) | ||
| 149 | |||
| 150 | |||
| 151 | def _select_qq_import_record( | ||
| 152 | hk_row: dict, | ||
| 153 | current_record: dict, | ||
| 154 | candidate_records: list[dict], | ||
| 155 | songs_by_mid: dict[str, dict], | ||
| 156 | ) -> dict | None: | ||
| 157 | """为导入选择 QQ 录音;当前录音的歌词或封面不可用时,改选同源的合格候选。""" | ||
| 158 | current_song = songs_by_mid.get(current_record['platform_unique_key']) | ||
| 159 | if not current_song: | ||
| 160 | return None | ||
| 161 | needs_lyric = not _has_usable_qq_lyric(current_song) | ||
| 162 | needs_cover = not _is_usable_qq_cover(_qq_cover_source(hk_row, current_song)) or not current_song.get('album_id') | ||
| 163 | if not needs_lyric and not needs_cover: | ||
| 164 | return current_record | ||
| 165 | |||
| 166 | for candidate in candidate_records: | ||
| 167 | song = songs_by_mid.get(candidate['platform_unique_key']) | ||
| 168 | if not song: | ||
| 169 | continue | ||
| 170 | if needs_lyric and not _has_usable_qq_lyric(song): | ||
| 171 | continue | ||
| 172 | if needs_cover and (not song.get('album_id') or not _is_usable_qq_cover(song.get('cover'))): | ||
| 173 | continue | ||
| 174 | return candidate | ||
| 175 | return None | ||
| 176 | |||
| 177 | |||
| 178 | def _select_kugou_import_record( | ||
| 179 | hk_row: dict, | ||
| 180 | current_record: dict, | ||
| 181 | candidate_records: list[dict], | ||
| 182 | songs_by_id: dict[int, dict], | ||
| 183 | ) -> dict | None: | ||
| 184 | """为导入选择酷狗录音;当前录音的歌词或封面不可用时,改选同源的合格候选。""" | ||
| 185 | song_id = int(current_record['platform_unique_key']) | ||
| 186 | current_song = songs_by_id.get(song_id) | ||
| 187 | if not current_song: | ||
| 188 | return None | ||
| 189 | needs_lyric = not _has_kugou_lyric(current_song) | ||
| 190 | needs_cover = not _has_kugou_cover(hk_row, current_song) | ||
| 191 | if not needs_lyric and not needs_cover: | ||
| 192 | return current_record | ||
| 193 | |||
| 194 | for candidate in candidate_records: | ||
| 195 | cid = int(candidate['platform_unique_key']) | ||
| 196 | song = songs_by_id.get(cid) | ||
| 197 | if not song: | ||
| 198 | continue | ||
| 199 | if needs_lyric and not _has_kugou_lyric(song): | ||
| 200 | continue | ||
| 201 | if needs_cover and not _has_kugou_cover(hk_row, song): | ||
| 202 | continue | ||
| 203 | return candidate | ||
| 204 | return None | ||
| 205 | |||
| 206 | |||
| 207 | def _select_netease_import_record( | ||
| 208 | hk_row: dict, | ||
| 209 | current_record: dict, | ||
| 210 | candidate_records: list[dict], | ||
| 211 | songs_by_id: dict[int, dict], | ||
| 212 | ) -> dict | None: | ||
| 213 | """为导入选择网易录音;当前录音的歌词或封面不可用时,改选同源的合格候选。""" | ||
| 214 | song_id = int(current_record['platform_unique_key']) | ||
| 215 | current_song = songs_by_id.get(song_id) | ||
| 216 | if not current_song: | ||
| 217 | return None | ||
| 218 | needs_lyric = not _has_netease_lyric(current_song) | ||
| 219 | needs_cover = not _has_netease_cover(hk_row, current_song) | ||
| 220 | if not needs_lyric and not needs_cover: | ||
| 221 | return current_record | ||
| 222 | |||
| 223 | for candidate in candidate_records: | ||
| 224 | cid = int(candidate['platform_unique_key']) | ||
| 225 | song = songs_by_id.get(cid) | ||
| 226 | if not song: | ||
| 227 | continue | ||
| 228 | if needs_lyric and not _has_netease_lyric(song): | ||
| 229 | continue | ||
| 230 | if needs_cover and not _has_netease_cover(hk_row, song): | ||
| 231 | continue | ||
| 232 | return candidate | ||
| 233 | return None | ||
| 234 | |||
| 235 | |||
| 95 | def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, singer_list: list[dict]) -> dict: | 236 | def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, singer_list: list[dict]) -> dict: |
| 96 | mid = pr['platform_unique_key'] | 237 | mid = pr['platform_unique_key'] |
| 97 | song_id_int = sp['id'] | 238 | song_id_int = sp['id'] |
| ... | @@ -101,7 +242,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing | ... | @@ -101,7 +242,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing |
| 101 | hk_row['audio_url'], build_oss_key('qq', 'audio', mid + '.mp3'), bucket, base_url | 242 | hk_row['audio_url'], build_oss_key('qq', 'audio', mid + '.mp3'), bucket, base_url |
| 102 | ), | 243 | ), |
| 103 | 'cover': lambda: _safe_transfer( | 244 | 'cover': lambda: _safe_transfer( |
| 104 | hk_row.get('cover_url') or sp.get('cover', ''), | 245 | _qq_cover_source(hk_row, sp), |
| 105 | build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), | 246 | build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), |
| 106 | bucket, base_url, | 247 | bucket, base_url, |
| 107 | ), | 248 | ), |
| ... | @@ -120,6 +261,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing | ... | @@ -120,6 +261,7 @@ def _prepare_qq_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, sing |
| 120 | ) | 261 | ) |
| 121 | ) | 262 | ) |
| 122 | assets = _run_io_tasks(tasks) | 263 | assets = _run_io_tasks(tasks) |
| 264 | _require_primary_assets(assets) | ||
| 123 | audio_url, audio_md5 = assets['audio'] | 265 | audio_url, audio_md5 = assets['audio'] |
| 124 | cover_url = assets['cover'] | 266 | cover_url = assets['cover'] |
| 125 | lyric_url = assets['lyric'] | 267 | lyric_url = assets['lyric'] |
| ... | @@ -248,6 +390,7 @@ def _prepare_kugou_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, s | ... | @@ -248,6 +390,7 @@ def _prepare_kugou_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, s |
| 248 | ) | 390 | ) |
| 249 | ) | 391 | ) |
| 250 | assets = _run_io_tasks(tasks) | 392 | assets = _run_io_tasks(tasks) |
| 393 | _require_primary_assets(assets) | ||
| 251 | audio_url, audio_md5 = assets['audio'] | 394 | audio_url, audio_md5 = assets['audio'] |
| 252 | cover_url = assets['cover'] | 395 | cover_url = assets['cover'] |
| 253 | lyric_url = assets['lyric'] | 396 | lyric_url = assets['lyric'] |
| ... | @@ -359,6 +502,7 @@ def _prepare_netease_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, | ... | @@ -359,6 +502,7 @@ def _prepare_netease_payload(hk_row: dict, pr: dict, bucket, base_url, sp: dict, |
| 359 | ) | 502 | ) |
| 360 | ) | 503 | ) |
| 361 | assets = _run_io_tasks(tasks) | 504 | assets = _run_io_tasks(tasks) |
| 505 | _require_primary_assets(assets) | ||
| 362 | audio_url, audio_md5 = assets['audio'] | 506 | audio_url, audio_md5 = assets['audio'] |
| 363 | cover_url = assets['cover'] | 507 | cover_url = assets['cover'] |
| 364 | lyric_url = assets['lyric'] | 508 | lyric_url = assets['lyric'] |
| ... | @@ -571,7 +715,7 @@ def _process_qq( | ... | @@ -571,7 +715,7 @@ def _process_qq( |
| 571 | bucket, base_url | 715 | bucket, base_url |
| 572 | ), | 716 | ), |
| 573 | 'cover': lambda: _safe_transfer( | 717 | 'cover': lambda: _safe_transfer( |
| 574 | hk_row.get('cover_url') or sp.get('cover', ''), | 718 | _qq_cover_source(hk_row, sp), |
| 575 | build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), | 719 | build_oss_key('qq', 'cover', str(song_id_int) + '.jpg'), |
| 576 | bucket, base_url | 720 | bucket, base_url |
| 577 | ), | 721 | ), |
| ... | @@ -592,6 +736,7 @@ def _process_qq( | ... | @@ -592,6 +736,7 @@ def _process_qq( |
| 592 | ) | 736 | ) |
| 593 | ) | 737 | ) |
| 594 | assets = _run_io_tasks(tasks) | 738 | assets = _run_io_tasks(tasks) |
| 739 | _require_primary_assets(assets) | ||
| 595 | audio_url, audio_md5 = assets['audio'] | 740 | audio_url, audio_md5 = assets['audio'] |
| 596 | cover_url = assets['cover'] | 741 | cover_url = assets['cover'] |
| 597 | lyric_url = assets['lyric'] | 742 | lyric_url = assets['lyric'] |
| ... | @@ -747,6 +892,7 @@ def _process_kugou( | ... | @@ -747,6 +892,7 @@ def _process_kugou( |
| 747 | ) | 892 | ) |
| 748 | ) | 893 | ) |
| 749 | assets = _run_io_tasks(tasks) | 894 | assets = _run_io_tasks(tasks) |
| 895 | _require_primary_assets(assets) | ||
| 750 | audio_url, audio_md5 = assets['audio'] | 896 | audio_url, audio_md5 = assets['audio'] |
| 751 | cover_url = assets['cover'] | 897 | cover_url = assets['cover'] |
| 752 | lyric_url = assets['lyric'] | 898 | lyric_url = assets['lyric'] |
| ... | @@ -881,6 +1027,7 @@ def _process_netease( | ... | @@ -881,6 +1027,7 @@ def _process_netease( |
| 881 | ) | 1027 | ) |
| 882 | ) | 1028 | ) |
| 883 | assets = _run_io_tasks(tasks) | 1029 | assets = _run_io_tasks(tasks) |
| 1030 | _require_primary_assets(assets) | ||
| 884 | audio_url, audio_md5 = assets['audio'] | 1031 | audio_url, audio_md5 = assets['audio'] |
| 885 | cover_url = assets['cover'] | 1032 | cover_url = assets['cover'] |
| 886 | lyric_url = assets['lyric'] | 1033 | lyric_url = assets['lyric'] |
| ... | @@ -1051,6 +1198,19 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None | ... | @@ -1051,6 +1198,19 @@ def initialize_yinyan_song_records(platforms: list[str], max_batches: int | None |
| 1051 | insert_yinyan_song_records(pg_cur, init_rows) | 1198 | insert_yinyan_song_records(pg_cur, init_rows) |
| 1052 | pg_conn.commit() | 1199 | pg_conn.commit() |
| 1053 | total += len(init_rows) | 1200 | total += len(init_rows) |
| 1201 | |||
| 1202 | # 本批次中无法匹配到任何录音的歌曲 → 软删 hk_songs_test | ||
| 1203 | matched_src_ids = {int(hk_row['source_song_id']) for hk_row in batch | ||
| 1204 | if hk_row.get('source_song_id') and int(hk_row['source_song_id']) in pr_by_song} | ||
| 1205 | unmatched_ids = [ | ||
| 1206 | int(hk_row['source_song_id']) for hk_row in batch | ||
| 1207 | if hk_row.get('source_song_id') and int(hk_row['source_song_id']) not in matched_src_ids | ||
| 1208 | and int(hk_row['source_song_id']) not in existing_song_ids | ||
| 1209 | ] | ||
| 1210 | if unmatched_ids: | ||
| 1211 | mark_hk_songs_deleted(hk_conn, unmatched_ids) | ||
| 1212 | hk_conn.commit() | ||
| 1213 | log.info("Init: marked %d unmatched songs as deleted", len(unmatched_ids)) | ||
| 1054 | finally: | 1214 | finally: |
| 1055 | hk_conn.close() | 1215 | hk_conn.close() |
| 1056 | src_conn.close() | 1216 | src_conn.close() |
| ... | @@ -1340,6 +1500,114 @@ def backfill_empty_singers(platforms: list[str], max_batches: int | None = None) | ... | @@ -1340,6 +1500,114 @@ def backfill_empty_singers(platforms: list[str], max_batches: int | None = None) |
| 1340 | pg_conn.close() | 1500 | pg_conn.close() |
| 1341 | 1501 | ||
| 1342 | 1502 | ||
| 1503 | def _is_target_oss_url(url: str | None, base_url: str) -> bool: | ||
| 1504 | return bool(url and str(url).startswith(base_url.rstrip('/') + '/')) | ||
| 1505 | |||
| 1506 | |||
| 1507 | def backfill_qq_invalid_covers(max_batches: int | None = None) -> None: | ||
| 1508 | """替换 QQ 无专辑占位封面的录音;无候选时删除 crawler 记录并软删 HK 歌曲。""" | ||
| 1509 | hk_conn = get_hk_songs_conn() | ||
| 1510 | src_conn = get_source_conn() | ||
| 1511 | spider_conn = get_spider_conn() | ||
| 1512 | pg_conn = get_pg_conn() | ||
| 1513 | bucket = get_oss_bucket() | ||
| 1514 | base_url = OSS_CONFIG['base_url'] | ||
| 1515 | batch_index = replaced = filtered = retry_later = 0 | ||
| 1516 | pbar = tqdm(desc='backfill-qq-invalid-covers') | ||
| 1517 | try: | ||
| 1518 | while max_batches is None or batch_index < max_batches: | ||
| 1519 | with pg_conn.cursor() as pg_cur: | ||
| 1520 | invalid_rows = fetch_qq_songs_with_invalid_covers( | ||
| 1521 | pg_cur, QQ_MISSING_ALBUM_COVER, BATCH_SIZE, | ||
| 1522 | ) | ||
| 1523 | if not invalid_rows: | ||
| 1524 | break | ||
| 1525 | |||
| 1526 | source_ids = [row['song_id'] for row in invalid_rows] | ||
| 1527 | hk_by_song = fetch_hk_songs_by_source_ids(hk_conn, source_ids) | ||
| 1528 | records_by_song: dict[int, list[dict]] = {} | ||
| 1529 | for record in fetch_all_platform_records(src_conn, source_ids): | ||
| 1530 | if record['platform'] == PLATFORM_QQ: | ||
| 1531 | records_by_song.setdefault(int(record['source_song_id']), []).append(record) | ||
| 1532 | |||
| 1533 | mids = list({record['platform_unique_key'] for records in records_by_song.values() for record in records}) | ||
| 1534 | songs_by_mid = fetch_qq_songs(spider_conn, mids) if mids else {} | ||
| 1535 | qq_song_ids = [song['id'] for song in songs_by_mid.values()] | ||
| 1536 | singers_by_song_id = fetch_qq_singers(spider_conn, qq_song_ids) if qq_song_ids else {} | ||
| 1537 | |||
| 1538 | replacements: list[tuple[dict, dict, dict]] = [] | ||
| 1539 | removals: list[dict] = [] | ||
| 1540 | for row in invalid_rows: | ||
| 1541 | hk_row = hk_by_song.get(row['song_id']) | ||
| 1542 | candidates = [ | ||
| 1543 | record for record in records_by_song.get(row['song_id'], []) | ||
| 1544 | if (song := songs_by_mid.get(record['platform_unique_key'])) | ||
| 1545 | and song.get('album_id') | ||
| 1546 | and _is_usable_qq_cover(song.get('cover')) | ||
| 1547 | ] | ||
| 1548 | if not candidates: | ||
| 1549 | removals.append(row) | ||
| 1550 | continue | ||
| 1551 | if not hk_row: | ||
| 1552 | log.warning('QQ cover backfill cannot find hk_songs_test row: source_song_id=%s', row['song_id']) | ||
| 1553 | retry_later += 1 | ||
| 1554 | continue | ||
| 1555 | |||
| 1556 | payload = None | ||
| 1557 | selected_candidate = None | ||
| 1558 | for candidate in candidates: | ||
| 1559 | song = songs_by_mid[candidate['platform_unique_key']] | ||
| 1560 | candidate_payload = _prepare_qq_payload( | ||
| 1561 | hk_row, candidate, bucket, base_url, song, | ||
| 1562 | singers_by_song_id.get(song['id'], []), | ||
| 1563 | ) | ||
| 1564 | cover = candidate_payload['songs'][0]['cover'] | ||
| 1565 | if _is_target_oss_url(cover, base_url): | ||
| 1566 | payload = candidate_payload | ||
| 1567 | selected_candidate = candidate | ||
| 1568 | break | ||
| 1569 | log.warning( | ||
| 1570 | 'QQ cover replacement download failed: source_song_id=%s candidate_mid=%s', | ||
| 1571 | row['song_id'], candidate['platform_unique_key'], | ||
| 1572 | ) | ||
| 1573 | if payload is None: | ||
| 1574 | retry_later += 1 | ||
| 1575 | continue | ||
| 1576 | replacements.append((row, selected_candidate, payload)) | ||
| 1577 | |||
| 1578 | with pg_conn.cursor() as pg_cur: | ||
| 1579 | for row, candidate, payload in replacements: | ||
| 1580 | song = payload['songs'][0] | ||
| 1581 | upsert_qq_singers(pg_cur, payload['singers']) | ||
| 1582 | upsert_qq_albums(pg_cur, payload['albums']) | ||
| 1583 | replace_qq_song(pg_cur, row['platform_song_id'], song) | ||
| 1584 | pg_cur.execute('DELETE FROM crawler_qqmusic_singer_songs WHERE song_id = %s', (row['id'],)) | ||
| 1585 | upsert_qq_singer_songs(pg_cur, [(sg['singer_id'], row['id']) for sg in payload['singers']]) | ||
| 1586 | upsert_qq_singer_albums(pg_cur, payload['singer_albums']) | ||
| 1587 | replace_yinyan_song_record( | ||
| 1588 | pg_cur, row['song_id'], row['platform_song_id'], | ||
| 1589 | int(candidate['record_id']), int(song['platform_song_id']), | ||
| 1590 | ) | ||
| 1591 | for row in removals: | ||
| 1592 | delete_qq_song_and_yinyan_record(pg_cur, row['song_id'], row['platform_song_id'], row['id']) | ||
| 1593 | pg_conn.commit() | ||
| 1594 | |||
| 1595 | if removals: | ||
| 1596 | mark_hk_songs_deleted(hk_conn, [row['song_id'] for row in removals]) | ||
| 1597 | hk_conn.commit() | ||
| 1598 | replaced += len(replacements) | ||
| 1599 | filtered += len(removals) | ||
| 1600 | batch_index += 1 | ||
| 1601 | pbar.update(1) | ||
| 1602 | finally: | ||
| 1603 | pbar.close() | ||
| 1604 | hk_conn.close() | ||
| 1605 | src_conn.close() | ||
| 1606 | spider_conn.close() | ||
| 1607 | pg_conn.close() | ||
| 1608 | log.info('QQ invalid cover backfill: replaced=%d filtered=%d retry_later=%d', replaced, filtered, retry_later) | ||
| 1609 | |||
| 1610 | |||
| 1343 | def run( | 1611 | def run( |
| 1344 | platforms: list[str], | 1612 | platforms: list[str], |
| 1345 | max_batches: int | None = None, | 1613 | max_batches: int | None = None, |
| ... | @@ -1372,10 +1640,37 @@ def run( | ... | @@ -1372,10 +1640,37 @@ def run( |
| 1372 | key = (int(pr['source_song_id']), int(pr['record_id']), pr['platform']) | 1640 | key = (int(pr['source_song_id']), int(pr['record_id']), pr['platform']) |
| 1373 | pr_by_state_key[key] = pr | 1641 | pr_by_state_key[key] = pr |
| 1374 | 1642 | ||
| 1643 | # 各平台在当前录音歌词或封面不可用时,需要同源的其他录音作为候选。 | ||
| 1644 | all_source_ids = [int(pending['song_id']) for pending in pending_records] | ||
| 1645 | all_candidates = fetch_all_platform_records(src_conn, all_source_ids) if all_source_ids else [] | ||
| 1646 | qq_records_by_song: dict[int, list[dict]] = {} | ||
| 1647 | kugou_records_by_song: dict[int, list[dict]] = {} | ||
| 1648 | netease_records_by_song: dict[int, list[dict]] = {} | ||
| 1649 | for candidate in all_candidates: | ||
| 1650 | sid = int(candidate['source_song_id']) | ||
| 1651 | if candidate['platform'] == PLATFORM_QQ: | ||
| 1652 | qq_records_by_song.setdefault(sid, []).append(candidate) | ||
| 1653 | elif candidate['platform'] == PLATFORM_KUGOU: | ||
| 1654 | kugou_records_by_song.setdefault(sid, []).append(candidate) | ||
| 1655 | elif candidate['platform'] == PLATFORM_NETEASE: | ||
| 1656 | netease_records_by_song.setdefault(sid, []).append(candidate) | ||
| 1657 | |||
| 1375 | # ── 批量预取 spider 数据(整批一次 IN 查询,不逐首调用)────────── | 1658 | # ── 批量预取 spider 数据(整批一次 IN 查询,不逐首调用)────────── |
| 1376 | qq_mids = list({pr['platform_unique_key'] for pr in platform_records if pr['platform'] == PLATFORM_QQ}) | 1659 | qq_mids = list({ |
| 1377 | kugou_ids = list({int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_KUGOU}) | 1660 | pr['platform_unique_key'] for pr in platform_records if pr['platform'] == PLATFORM_QQ |
| 1378 | netease_ids = list({int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_NETEASE}) | 1661 | } | { |
| 1662 | pr['platform_unique_key'] for records in qq_records_by_song.values() for pr in records | ||
| 1663 | }) | ||
| 1664 | kugou_ids = list({ | ||
| 1665 | int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_KUGOU | ||
| 1666 | } | { | ||
| 1667 | int(pr['platform_unique_key']) for records in kugou_records_by_song.values() for pr in records | ||
| 1668 | }) | ||
| 1669 | netease_ids = list({ | ||
| 1670 | int(pr['platform_unique_key']) for pr in platform_records if pr['platform'] == PLATFORM_NETEASE | ||
| 1671 | } | { | ||
| 1672 | int(pr['platform_unique_key']) for records in netease_records_by_song.values() for pr in records | ||
| 1673 | }) | ||
| 1379 | 1674 | ||
| 1380 | qq_songs_map = fetch_qq_songs(spider_conn, qq_mids) if qq_mids else {} | 1675 | qq_songs_map = fetch_qq_songs(spider_conn, qq_mids) if qq_mids else {} |
| 1381 | kugou_songs_map = fetch_kugou_songs(spider_conn, kugou_ids) if kugou_ids else {} | 1676 | kugou_songs_map = fetch_kugou_songs(spider_conn, kugou_ids) if kugou_ids else {} |
| ... | @@ -1398,6 +1693,7 @@ def run( | ... | @@ -1398,6 +1693,7 @@ def run( |
| 1398 | } | 1693 | } |
| 1399 | 1694 | ||
| 1400 | prepare_inputs = [] | 1695 | prepare_inputs = [] |
| 1696 | rejected_pending: list[dict] = [] | ||
| 1401 | for pending in pending_records: | 1697 | for pending in pending_records: |
| 1402 | src_id = int(pending['song_id']) | 1698 | src_id = int(pending['song_id']) |
| 1403 | platform = str(pending['platform']) | 1699 | platform = str(pending['platform']) |
| ... | @@ -1411,9 +1707,37 @@ def run( | ... | @@ -1411,9 +1707,37 @@ def run( |
| 1411 | src_id, pending['record_id'], platform, | 1707 | src_id, pending['record_id'], platform, |
| 1412 | ) | 1708 | ) |
| 1413 | continue | 1709 | continue |
| 1710 | if platform == PLATFORM_QQ: | ||
| 1711 | selected = _select_qq_import_record( | ||
| 1712 | hk_row, pr, qq_records_by_song.get(src_id, []), qq_songs_map, | ||
| 1713 | ) | ||
| 1714 | elif platform == PLATFORM_KUGOU: | ||
| 1715 | selected = _select_kugou_import_record( | ||
| 1716 | hk_row, pr, kugou_records_by_song.get(src_id, []), kugou_songs_map, | ||
| 1717 | ) | ||
| 1718 | elif platform == PLATFORM_NETEASE: | ||
| 1719 | selected = _select_netease_import_record( | ||
| 1720 | hk_row, pr, netease_records_by_song.get(src_id, []), netease_songs_map, | ||
| 1721 | ) | ||
| 1722 | else: | ||
| 1723 | selected = pr | ||
| 1724 | if selected is None: | ||
| 1725 | log.warning( | ||
| 1726 | 'Skip import without usable lyric/cover candidate: platform=%s source_song_id=%s record_id=%s', | ||
| 1727 | platform, src_id, pending['record_id'], | ||
| 1728 | ) | ||
| 1729 | rejected_pending.append(pending) | ||
| 1730 | continue | ||
| 1731 | if selected['record_id'] != pr['record_id']: | ||
| 1732 | log.info( | ||
| 1733 | 'Replace import record: platform=%s source_song_id=%s old_record_id=%s new_record_id=%s', | ||
| 1734 | platform, src_id, pr['record_id'], selected['record_id'], | ||
| 1735 | ) | ||
| 1736 | pr = selected | ||
| 1414 | prepare_inputs.append((pending, hk_row, pr)) | 1737 | prepare_inputs.append((pending, hk_row, pr)) |
| 1415 | 1738 | ||
| 1416 | payloads = [] | 1739 | payloads = [] |
| 1740 | asset_failed_pending: list[dict] = [] | ||
| 1417 | max_workers = min(MAX_IMPORT_WORKERS, len(prepare_inputs)) if prepare_inputs else 0 | 1741 | max_workers = min(MAX_IMPORT_WORKERS, len(prepare_inputs)) if prepare_inputs else 0 |
| 1418 | if max_workers: | 1742 | if max_workers: |
| 1419 | with ThreadPoolExecutor(max_workers=max_workers) as executor: | 1743 | with ThreadPoolExecutor(max_workers=max_workers) as executor: |
| ... | @@ -1433,16 +1757,48 @@ def run( | ... | @@ -1433,16 +1757,48 @@ def run( |
| 1433 | total_ok += 1 | 1757 | total_ok += 1 |
| 1434 | else: | 1758 | else: |
| 1435 | total_err += 1 | 1759 | total_err += 1 |
| 1760 | asset_failed_pending.append(pending) | ||
| 1761 | log.warning( | ||
| 1762 | "Skip song because payload is empty: %s platform %s song_id=%s", | ||
| 1763 | hk_row.get('name'), pr['platform'], pending['song_id'], | ||
| 1764 | ) | ||
| 1765 | except RequiredAssetTransferError as e: | ||
| 1766 | total_err += 1 | ||
| 1767 | asset_failed_pending.append(pending) | ||
| 1768 | log.warning( | ||
| 1769 | "Skip song because required asset transfer failed: %s platform %s: %s", | ||
| 1770 | hk_row.get('name'), pr['platform'], e, | ||
| 1771 | ) | ||
| 1436 | except Exception as e: | 1772 | except Exception as e: |
| 1437 | total_err += 1 | 1773 | total_err += 1 |
| 1438 | log.error("Error preparing song %s platform %s: %s", | 1774 | asset_failed_pending.append(pending) |
| 1439 | hk_row.get('name'), pr['platform'], e) | 1775 | log.error( |
| 1776 | "Skip song because unexpected error: %s platform %s: %s", | ||
| 1777 | hk_row.get('name'), pr['platform'], e, | ||
| 1778 | ) | ||
| 1440 | 1779 | ||
| 1780 | discarded_pending = rejected_pending + asset_failed_pending | ||
| 1441 | if payloads: | 1781 | if payloads: |
| 1442 | with pg_conn.cursor() as pg_cur: | 1782 | with pg_conn.cursor() as pg_cur: |
| 1443 | _write_import_payloads(pg_cur, payloads) | 1783 | _write_import_payloads(pg_cur, payloads) |
| 1784 | for pending in discarded_pending: | ||
| 1785 | mark_yinyan_record_resource_failed( | ||
| 1786 | pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), | ||
| 1787 | ) | ||
| 1444 | pg_conn.commit() | 1788 | pg_conn.commit() |
| 1789 | if discarded_pending: | ||
| 1790 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) | ||
| 1791 | hk_conn.commit() | ||
| 1445 | imported.extend(payload['result'] for payload in payloads) | 1792 | imported.extend(payload['result'] for payload in payloads) |
| 1793 | elif discarded_pending: | ||
| 1794 | with pg_conn.cursor() as pg_cur: | ||
| 1795 | for pending in discarded_pending: | ||
| 1796 | mark_yinyan_record_resource_failed( | ||
| 1797 | pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), | ||
| 1798 | ) | ||
| 1799 | pg_conn.commit() | ||
| 1800 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) | ||
| 1801 | hk_conn.commit() | ||
| 1446 | else: | 1802 | else: |
| 1447 | log.error("No import payloads were prepared in this batch; stopping to avoid retry loop") | 1803 | log.error("No import payloads were prepared in this batch; stopping to avoid retry loop") |
| 1448 | break | 1804 | break | ... | ... |
| ... | @@ -3,6 +3,7 @@ import uuid | ... | @@ -3,6 +3,7 @@ import uuid |
| 3 | _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') | 3 | _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') |
| 4 | _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} | 4 | _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} |
| 5 | _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} | 5 | _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} |
| 6 | RESOURCE_TRANSFER_FAILED = 'resource_transfer_failed' | ||
| 6 | 7 | ||
| 7 | 8 | ||
| 8 | def _singer_index(value) -> str: | 9 | def _singer_index(value) -> str: |
| ... | @@ -49,10 +50,11 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: | ... | @@ -49,10 +50,11 @@ def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: |
| 49 | FROM yinyan_song_records | 50 | FROM yinyan_song_records |
| 50 | WHERE is_yinyan_push = FALSE | 51 | WHERE is_yinyan_push = FALSE |
| 51 | AND platform IS NOT NULL | 52 | AND platform IS NOT NULL |
| 53 | AND COALESCE(recording_id, '') != %s | ||
| 52 | ORDER BY song_id | 54 | ORDER BY song_id |
| 53 | LIMIT %s | 55 | LIMIT %s |
| 54 | """, | 56 | """, |
| 55 | (limit,), | 57 | (RESOURCE_TRANSFER_FAILED, limit), |
| 56 | ) | 58 | ) |
| 57 | rows = cur.fetchall() | 59 | rows = cur.fetchall() |
| 58 | return [{'song_id': row[0], 'record_id': row[1], 'platform': row[2]} for row in rows] | 60 | return [{'song_id': row[0], 'record_id': row[1], 'platform': row[2]} for row in rows] |
| ... | @@ -135,6 +137,90 @@ def fetch_qq_songs_missing_singers(cur, limit: int) -> list[dict]: | ... | @@ -135,6 +137,90 @@ def fetch_qq_songs_missing_singers(cur, limit: int) -> list[dict]: |
| 135 | return [{'id': str(row[0]), 'platform_song_id': row[1], 'mid': row[2]} for row in rows] | 137 | return [{'id': str(row[0]), 'platform_song_id': row[1], 'mid': row[2]} for row in rows] |
| 136 | 138 | ||
| 137 | 139 | ||
| 140 | def fetch_qq_songs_with_invalid_covers(cur, invalid_cover: str, limit: int) -> list[dict]: | ||
| 141 | """返回已导入、但仍使用 QQ 无专辑占位封面的歌曲及其源歌曲状态。""" | ||
| 142 | cur.execute( | ||
| 143 | """ | ||
| 144 | SELECT qs.id, qs.platform_song_id, qs.mid, qs.name, | ||
| 145 | ysr.song_id, ysr.record_id | ||
| 146 | FROM crawler_qqmusic_songs qs | ||
| 147 | JOIN yinyan_song_records ysr | ||
| 148 | ON ysr.platform = '1' AND ysr.platform_song_id = qs.platform_song_id | ||
| 149 | WHERE qs.cover = %s | ||
| 150 | AND qs.album_id IS NULL | ||
| 151 | AND ysr.is_yinyan_push = TRUE | ||
| 152 | ORDER BY ysr.song_id | ||
| 153 | LIMIT %s | ||
| 154 | """, | ||
| 155 | (invalid_cover, limit), | ||
| 156 | ) | ||
| 157 | return [ | ||
| 158 | { | ||
| 159 | 'id': str(row[0]), 'platform_song_id': int(row[1]), 'mid': row[2], | ||
| 160 | 'name': row[3], 'song_id': int(row[4]), 'record_id': int(row[5]), | ||
| 161 | } | ||
| 162 | for row in cur.fetchall() | ||
| 163 | ] | ||
| 164 | |||
| 165 | |||
| 166 | def replace_qq_song(cur, old_platform_song_id: int, song: dict) -> None: | ||
| 167 | """用同一源歌曲下的另一条 QQ 录音更新既有 crawler 歌曲。""" | ||
| 168 | cur.execute( | ||
| 169 | """ | ||
| 170 | UPDATE crawler_qqmusic_songs | ||
| 171 | SET platform_song_id = %s, mid = %s, album_id = %s, cover = %s, | ||
| 172 | title = %s, name = %s, duration = %s, lyric = %s, | ||
| 173 | composer_name = %s, lyricist_name = %s, url = %s, audio_md5 = %s, | ||
| 174 | lyric_url = %s, platform_index_url = %s, published_at = %s, | ||
| 175 | album = %s::json, singers = %s::jsonb, version = %s, | ||
| 176 | provider_name = %s, crawler_source_data = %s::json, updated_at = NOW() | ||
| 177 | WHERE platform_song_id = %s | ||
| 178 | """, | ||
| 179 | ( | ||
| 180 | song['platform_song_id'], song['mid'], song.get('album_id'), song.get('cover', ''), | ||
| 181 | song.get('title', ''), song.get('name', ''), song.get('duration', 0) or 0, | ||
| 182 | song.get('lyric'), song.get('composer_name'), song.get('lyricist_name'), | ||
| 183 | song.get('url', ''), song.get('audio_md5'), song.get('lyric_url'), | ||
| 184 | song.get('platform_index_url'), song.get('published_at'), song.get('album_json'), | ||
| 185 | song.get('singers_json', '[]'), song.get('version'), song.get('provider_name'), | ||
| 186 | song.get('crawler_source_data'), old_platform_song_id, | ||
| 187 | ), | ||
| 188 | ) | ||
| 189 | |||
| 190 | |||
| 191 | def replace_yinyan_song_record(cur, song_id: int, old_platform_song_id: int, record_id: int, platform_song_id: int) -> None: | ||
| 192 | cur.execute( | ||
| 193 | """ | ||
| 194 | UPDATE yinyan_song_records | ||
| 195 | SET record_id = %s, platform_song_id = %s, platform = '1' | ||
| 196 | WHERE song_id = %s AND platform = '1' AND platform_song_id = %s | ||
| 197 | """, | ||
| 198 | (record_id, platform_song_id, song_id, old_platform_song_id), | ||
| 199 | ) | ||
| 200 | |||
| 201 | |||
| 202 | def delete_qq_song_and_yinyan_record(cur, song_id: int, platform_song_id: int, song_uuid: str) -> None: | ||
| 203 | """移除无法找到替代录音的 crawler 数据及其导入状态。""" | ||
| 204 | cur.execute("DELETE FROM crawler_qqmusic_singer_songs WHERE song_id = %s", (song_uuid,)) | ||
| 205 | cur.execute("DELETE FROM crawler_qqmusic_songs WHERE platform_song_id = %s", (platform_song_id,)) | ||
| 206 | cur.execute( | ||
| 207 | "DELETE FROM yinyan_song_records WHERE song_id = %s AND platform = '1' AND platform_song_id = %s", | ||
| 208 | (song_id, platform_song_id), | ||
| 209 | ) | ||
| 210 | |||
| 211 | |||
| 212 | def mark_yinyan_record_resource_failed(cur, song_id: int, record_id: int, platform: str) -> None: | ||
| 213 | """保留未导入状态,并标记资源转存失败,避免后续批次重复处理。""" | ||
| 214 | cur.execute( | ||
| 215 | """ | ||
| 216 | UPDATE yinyan_song_records | ||
| 217 | SET is_yinyan_push = FALSE, recording_id = %s | ||
| 218 | WHERE song_id = %s AND record_id = %s AND platform = %s AND is_yinyan_push = FALSE | ||
| 219 | """, | ||
| 220 | (RESOURCE_TRANSFER_FAILED, song_id, record_id, platform), | ||
| 221 | ) | ||
| 222 | |||
| 223 | |||
| 138 | def fetch_kugou_songs_missing_singers(cur, limit: int) -> list[dict]: | 224 | def fetch_kugou_songs_missing_singers(cur, limit: int) -> list[dict]: |
| 139 | cur.execute( | 225 | cur.execute( |
| 140 | """ | 226 | """ | ... | ... |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | import argparse | 2 | import argparse |
| 3 | from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS | 3 | from etl_to_crawler.config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, PLATFORMS |
| 4 | from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run, backfill_empty_singers | 4 | from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, run, backfill_empty_singers, backfill_qq_invalid_covers |
| 5 | 5 | ||
| 6 | PLATFORM_MAP = { | 6 | PLATFORM_MAP = { |
| 7 | 'qq': PLATFORM_QQ, | 7 | 'qq': PLATFORM_QQ, |
| ... | @@ -22,6 +22,8 @@ if __name__ == '__main__': | ... | @@ -22,6 +22,8 @@ if __name__ == '__main__': |
| 22 | help='只回填 yinyan_song_records 中为空的 platform 平台代码') | 22 | help='只回填 yinyan_song_records 中为空的 platform 平台代码') |
| 23 | parser.add_argument('--backfill-empty-singers', action='store_true', | 23 | parser.add_argument('--backfill-empty-singers', action='store_true', |
| 24 | help='回填三平台 singers 为空的歌曲记录') | 24 | help='回填三平台 singers 为空的歌曲记录') |
| 25 | parser.add_argument('--backfill-qq-invalid-covers', action='store_true', | ||
| 26 | help='替换 QQ 无专辑占位封面的录音;无候选时删除 crawler 记录并软删 hk_songs_test') | ||
| 25 | args = parser.parse_args() | 27 | args = parser.parse_args() |
| 26 | 28 | ||
| 27 | if args.platform == 'all': | 29 | if args.platform == 'all': |
| ... | @@ -36,5 +38,7 @@ if __name__ == '__main__': | ... | @@ -36,5 +38,7 @@ if __name__ == '__main__': |
| 36 | initialize_yinyan_song_records(platforms, max_batches=args.max_batches) | 38 | initialize_yinyan_song_records(platforms, max_batches=args.max_batches) |
| 37 | elif args.backfill_empty_singers: | 39 | elif args.backfill_empty_singers: |
| 38 | backfill_empty_singers(platforms, max_batches=args.max_batches) | 40 | backfill_empty_singers(platforms, max_batches=args.max_batches) |
| 41 | elif args.backfill_qq_invalid_covers: | ||
| 42 | backfill_qq_invalid_covers(max_batches=args.max_batches) | ||
| 39 | else: | 43 | else: |
| 40 | run(platforms, max_batches=args.max_batches) | 44 | run(platforms, max_batches=args.max_batches) | ... | ... |
| 1 | from unittest.mock import MagicMock, patch | 1 | from unittest.mock import MagicMock, patch |
| 2 | import math | 2 | import math |
| 3 | import requests | ||
| 3 | from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 | 4 | from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 |
| 4 | 5 | ||
| 5 | ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" | 6 | ARCHIVE_URL = "https://archive-dev.oss-cn-beijing.aliyuncs.com/some/path.mp3" |
| ... | @@ -75,3 +76,33 @@ def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): | ... | @@ -75,3 +76,33 @@ def test_transfer_url_with_md5_hashes_downloaded_content_before_upload(): |
| 75 | bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) | 76 | bucket.put_object.assert_called_once_with("crawler/qq/audio/abc.mp3", fake_content) |
| 76 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" | 77 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" |
| 77 | assert audio_md5 == "04d43544b267629d9089eaed3b847a99" | 78 | assert audio_md5 == "04d43544b267629d9089eaed3b847a99" |
| 79 | |||
| 80 | |||
| 81 | def test_audio_transfer_retries_timeout_then_uploads(): | ||
| 82 | bucket = MagicMock() | ||
| 83 | response = MagicMock(content=b'audio_bytes') | ||
| 84 | response.raise_for_status.return_value = None | ||
| 85 | with patch('etl_to_crawler.oss._http_get', side_effect=[requests.ReadTimeout('slow'), response]) as mock_get: | ||
| 86 | with patch('etl_to_crawler.oss.time.sleep') as mock_sleep: | ||
| 87 | result, _ = transfer_url_with_md5(OTHER_URL, 'crawler/qq/audio/abc.mp3', bucket, BASE_URL) | ||
| 88 | |||
| 89 | assert mock_get.call_count == 2 | ||
| 90 | mock_sleep.assert_called_once() | ||
| 91 | assert result == f"{BASE_URL}/crawler/qq/audio/abc.mp3" | ||
| 92 | |||
| 93 | |||
| 94 | def test_transfer_does_not_retry_not_found_response(): | ||
| 95 | bucket = MagicMock() | ||
| 96 | response = MagicMock(status_code=404) | ||
| 97 | error = requests.HTTPError(response=response) | ||
| 98 | with patch('etl_to_crawler.oss._http_get', side_effect=error) as mock_get: | ||
| 99 | with patch('etl_to_crawler.oss.time.sleep') as mock_sleep: | ||
| 100 | try: | ||
| 101 | transfer_url_with_md5(OTHER_URL, 'crawler/qq/audio/abc.mp3', bucket, BASE_URL) | ||
| 102 | except requests.HTTPError: | ||
| 103 | pass | ||
| 104 | else: | ||
| 105 | raise AssertionError('expected HTTPError') | ||
| 106 | |||
| 107 | mock_get.assert_called_once() | ||
| 108 | mock_sleep.assert_not_called() | ... | ... |
| 1 | from unittest.mock import MagicMock | 1 | from unittest.mock import MagicMock |
| 2 | import time | 2 | import time |
| 3 | import pytest | ||
| 3 | 4 | ||
| 4 | from etl_to_crawler import runner | 5 | from etl_to_crawler import runner |
| 5 | 6 | ||
| ... | @@ -35,6 +36,12 @@ class _Connection: | ... | @@ -35,6 +36,12 @@ class _Connection: |
| 35 | def close(self): | 36 | def close(self): |
| 36 | return None | 37 | return None |
| 37 | 38 | ||
| 39 | def cursor(self): | ||
| 40 | return _Cursor() | ||
| 41 | |||
| 42 | def commit(self): | ||
| 43 | return None | ||
| 44 | |||
| 38 | 45 | ||
| 39 | def test_run_io_tasks_returns_named_results(): | 46 | def test_run_io_tasks_returns_named_results(): |
| 40 | def slow(value): | 47 | def slow(value): |
| ... | @@ -52,6 +59,86 @@ def test_run_io_tasks_returns_named_results(): | ... | @@ -52,6 +59,86 @@ def test_run_io_tasks_returns_named_results(): |
| 52 | } | 59 | } |
| 53 | 60 | ||
| 54 | 61 | ||
| 62 | def test_safe_audio_transfer_returns_empty_url_after_final_failure(monkeypatch): | ||
| 63 | monkeypatch.setattr(runner, 'transfer_url_with_md5', MagicMock(side_effect=TimeoutError('timeout'))) | ||
| 64 | assert runner._safe_transfer_audio('https://source.example/audio.mp3', 'audio.mp3', object(), 'https://archive.example') == ('', '') | ||
| 65 | |||
| 66 | |||
| 67 | def test_safe_transfer_returns_empty_url_after_final_failure(monkeypatch): | ||
| 68 | monkeypatch.setattr(runner, 'transfer_url', MagicMock(side_effect=TimeoutError('timeout'))) | ||
| 69 | assert runner._safe_transfer('https://source.example/cover.jpg', 'cover.jpg', object(), 'https://archive.example') == '' | ||
| 70 | |||
| 71 | |||
| 72 | def test_required_asset_validation_rejects_partial_transfer(): | ||
| 73 | with pytest.raises(runner.RequiredAssetTransferError, match='audio, lyric'): | ||
| 74 | runner._require_primary_assets({ | ||
| 75 | 'audio': ('', ''), | ||
| 76 | 'cover': 'https://archive.example/cover.jpg', | ||
| 77 | 'lyric': '', | ||
| 78 | }) | ||
| 79 | |||
| 80 | |||
| 81 | def test_lyric_upload_does_not_fall_back_to_external_source_url(monkeypatch): | ||
| 82 | monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', lambda *args: '') | ||
| 83 | assert runner._safe_upload_lyric( | ||
| 84 | 'qq', 'mid', '', 'https://source.example/lyric.txt', object(), 'https://archive.example', | ||
| 85 | ) == '' | ||
| 86 | |||
| 87 | |||
| 88 | def test_qq_cover_source_ignores_missing_album_placeholder_from_hk(): | ||
| 89 | assert runner._qq_cover_source( | ||
| 90 | {'cover_url': runner.QQ_MISSING_ALBUM_COVER}, | ||
| 91 | {'cover': 'https://example.com/valid-qq-cover.jpg'}, | ||
| 92 | ) == 'https://example.com/valid-qq-cover.jpg' | ||
| 93 | |||
| 94 | |||
| 95 | def test_qq_cover_source_keeps_valid_hk_cover(): | ||
| 96 | assert runner._qq_cover_source( | ||
| 97 | {'cover_url': 'https://example.com/hk-cover.jpg'}, | ||
| 98 | {'cover': 'https://example.com/qq-cover.jpg'}, | ||
| 99 | ) == 'https://example.com/hk-cover.jpg' | ||
| 100 | |||
| 101 | |||
| 102 | def test_select_qq_import_record_replaces_missing_album_cover_with_same_source_candidate(): | ||
| 103 | current = {'record_id': 10, 'platform_unique_key': 'bad-mid'} | ||
| 104 | candidate = {'record_id': 20, 'platform_unique_key': 'good-mid'} | ||
| 105 | selected = runner._select_qq_import_record( | ||
| 106 | {'cover_url': runner.QQ_MISSING_ALBUM_COVER}, | ||
| 107 | current, | ||
| 108 | [current, candidate], | ||
| 109 | { | ||
| 110 | 'bad-mid': {'album_id': 0, 'cover': runner.QQ_MISSING_ALBUM_COVER, 'lyric': '歌词'}, | ||
| 111 | 'good-mid': {'album_id': 100, 'cover': 'https://example.com/good.jpg', 'lyric': '歌词'}, | ||
| 112 | }, | ||
| 113 | ) | ||
| 114 | assert selected == candidate | ||
| 115 | |||
| 116 | |||
| 117 | def test_select_qq_import_record_replaces_missing_lyric_with_same_source_candidate(): | ||
| 118 | current = {'record_id': 10, 'platform_unique_key': 'without-lyric'} | ||
| 119 | candidate = {'record_id': 20, 'platform_unique_key': 'with-lyric'} | ||
| 120 | selected = runner._select_qq_import_record( | ||
| 121 | {'cover_url': 'https://example.com/hk-cover.jpg'}, | ||
| 122 | current, | ||
| 123 | [current, candidate], | ||
| 124 | { | ||
| 125 | 'without-lyric': {'album_id': 100, 'cover': 'https://example.com/current.jpg', 'lyric': ''}, | ||
| 126 | 'with-lyric': {'album_id': 100, 'cover': 'https://example.com/candidate.jpg', 'lyric': '有效歌词'}, | ||
| 127 | }, | ||
| 128 | ) | ||
| 129 | assert selected == candidate | ||
| 130 | |||
| 131 | |||
| 132 | def test_select_qq_import_record_returns_none_when_no_candidate_satisfies_missing_fields(): | ||
| 133 | current = {'record_id': 10, 'platform_unique_key': 'bad-mid'} | ||
| 134 | assert runner._select_qq_import_record( | ||
| 135 | {'cover_url': runner.QQ_MISSING_ALBUM_COVER}, | ||
| 136 | current, | ||
| 137 | [current], | ||
| 138 | {'bad-mid': {'album_id': 0, 'cover': runner.QQ_MISSING_ALBUM_COVER, 'lyric': ''}}, | ||
| 139 | ) is None | ||
| 140 | |||
| 141 | |||
| 55 | def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | 142 | def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): |
| 56 | pg_conn = _PgConnection() | 143 | pg_conn = _PgConnection() |
| 57 | prepare = MagicMock(return_value={ | 144 | prepare = MagicMock(return_value={ |
| ... | @@ -95,10 +182,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | ... | @@ -95,10 +182,22 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): |
| 95 | 'pub_time': '2021-01-01', | 182 | 'pub_time': '2021-01-01', |
| 96 | }] | 183 | }] |
| 97 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) | 184 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: platform_records) |
| 98 | monkeypatch.setattr(runner, 'fetch_all_platform_records', MagicMock()) | 185 | monkeypatch.setattr(runner, 'fetch_all_platform_records', lambda conn, song_ids: [ |
| 186 | { | ||
| 187 | 'source_song_id': 10, | ||
| 188 | 'record_id': 200, | ||
| 189 | 'platform': '2', | ||
| 190 | 'platform_unique_key': '200', | ||
| 191 | 'platform_mid': 'kg-hash', | ||
| 192 | 'album_audio_id': None, | ||
| 193 | 'is_main_version': 1, | ||
| 194 | 'is_high': 0, | ||
| 195 | 'pub_time': '2021-01-01', | ||
| 196 | }, | ||
| 197 | ]) | ||
| 99 | monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records) | 198 | monkeypatch.setattr(runner, 'fetch_platform_records_by_record_ids', lambda conn, record_ids: platform_records) |
| 100 | monkeypatch.setattr(runner, 'fetch_kugou_songs', lambda conn, song_ids: { | 199 | monkeypatch.setattr(runner, 'fetch_kugou_songs', lambda conn, song_ids: { |
| 101 | 200: {'id': 200}, | 200 | 200: {'id': 200, 'lyric': '歌词', 'cover': 'https://example.com/cover.jpg'}, |
| 102 | }) | 201 | }) |
| 103 | monkeypatch.setattr(runner, 'fetch_kugou_singers', lambda conn, song_ids: {}) | 202 | monkeypatch.setattr(runner, 'fetch_kugou_singers', lambda conn, song_ids: {}) |
| 104 | monkeypatch.setattr(runner, '_prepare_import_payload', prepare) | 203 | monkeypatch.setattr(runner, '_prepare_import_payload', prepare) |
| ... | @@ -108,7 +207,6 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): | ... | @@ -108,7 +207,6 @@ def test_run_imports_only_pending_yinyan_platform_record(monkeypatch): |
| 108 | 207 | ||
| 109 | prepare.assert_called_once() | 208 | prepare.assert_called_once() |
| 110 | write_payloads.assert_called_once() | 209 | write_payloads.assert_called_once() |
| 111 | runner.fetch_all_platform_records.assert_not_called() | ||
| 112 | assert pg_conn.commits == 1 | 210 | assert pg_conn.commits == 1 |
| 113 | 211 | ||
| 114 | 212 | ||
| ... | @@ -213,6 +311,7 @@ def test_process_qq_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -213,6 +311,7 @@ def test_process_qq_builds_album_json_for_song_insert(monkeypatch): |
| 213 | monkeypatch.setattr(runner, 'fetch_qq_singers', lambda conn, song_ids: {}) | 311 | monkeypatch.setattr(runner, 'fetch_qq_singers', lambda conn, song_ids: {}) |
| 214 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | 312 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) |
| 215 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) | 313 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) |
| 314 | monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt') | ||
| 216 | monkeypatch.setattr(runner, 'upsert_qq_singers', lambda cur, singers: None) | 315 | monkeypatch.setattr(runner, 'upsert_qq_singers', lambda cur, singers: None) |
| 217 | monkeypatch.setattr(runner, 'upsert_qq_albums', lambda cur, albums: None) | 316 | monkeypatch.setattr(runner, 'upsert_qq_albums', lambda cur, albums: None) |
| 218 | monkeypatch.setattr(runner, 'upsert_qq_songs', lambda cur, songs: inserted_songs.extend(songs)) | 317 | monkeypatch.setattr(runner, 'upsert_qq_songs', lambda cur, songs: inserted_songs.extend(songs)) |
| ... | @@ -275,6 +374,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): | ... | @@ -275,6 +374,7 @@ def test_process_netease_builds_album_json_for_song_insert(monkeypatch): |
| 275 | monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) | 374 | monkeypatch.setattr(runner, 'fetch_netease_singers', lambda conn, song_ids: {}) |
| 276 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | 375 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) |
| 277 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) | 376 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) |
| 377 | monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt') | ||
| 278 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) | 378 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) |
| 279 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) | 379 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) |
| 280 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | 380 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) |
| ... | @@ -317,6 +417,7 @@ def test_process_netease_uses_prefetched_song_and_singers(monkeypatch): | ... | @@ -317,6 +417,7 @@ def test_process_netease_uses_prefetched_song_and_singers(monkeypatch): |
| 317 | monkeypatch.setattr(runner, 'fetch_netease_singers', fetch_singers) | 417 | monkeypatch.setattr(runner, 'fetch_netease_singers', fetch_singers) |
| 318 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) | 418 | monkeypatch.setattr(runner, '_safe_transfer', lambda url, oss_key, bucket, base_url: url) |
| 319 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) | 419 | monkeypatch.setattr(runner, '_safe_transfer_audio', lambda url, oss_key, bucket, base_url: (url, 'audio-md5')) |
| 420 | monkeypatch.setattr(runner, '_safe_upload_lyric', lambda *args: 'https://bucket.example.com/lyric.txt') | ||
| 320 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) | 421 | monkeypatch.setattr(runner, 'upsert_netease_singers', lambda cur, singers: None) |
| 321 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) | 422 | monkeypatch.setattr(runner, 'upsert_netease_albums', lambda cur, albums: None) |
| 322 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | 423 | monkeypatch.setattr(runner, 'upsert_netease_songs', lambda cur, songs: inserted_songs.extend(songs)) | ... | ... |
| ... | @@ -15,6 +15,9 @@ from etl_to_crawler.writer import ( | ... | @@ -15,6 +15,9 @@ from etl_to_crawler.writer import ( |
| 15 | upsert_qq_songs, | 15 | upsert_qq_songs, |
| 16 | upsert_qq_singer_songs, | 16 | upsert_qq_singer_songs, |
| 17 | upsert_yinyan_song_records, | 17 | upsert_yinyan_song_records, |
| 18 | replace_qq_song, | ||
| 19 | delete_qq_song_and_yinyan_record, | ||
| 20 | mark_yinyan_record_resource_failed, | ||
| 18 | ) | 21 | ) |
| 19 | 22 | ||
| 20 | 23 | ||
| ... | @@ -31,6 +34,44 @@ def test_upsert_qq_singers_executes_insert(): | ... | @@ -31,6 +34,44 @@ def test_upsert_qq_singers_executes_insert(): |
| 31 | assert 'ON CONFLICT' in args[0][0] | 34 | assert 'ON CONFLICT' in args[0][0] |
| 32 | 35 | ||
| 33 | 36 | ||
| 37 | def test_replace_qq_song_updates_existing_row_by_old_platform_song_id(): | ||
| 38 | cur = MagicMock() | ||
| 39 | replace_qq_song(cur, 100, { | ||
| 40 | 'platform_song_id': 200, 'mid': 'new-mid', 'album_id': 20, | ||
| 41 | 'cover': 'https://oss.example/cover.jpg', 'title': '标题', 'name': '歌名', | ||
| 42 | 'duration': 180, 'singers_json': '[]', 'provider_name': 'yinyan', | ||
| 43 | 'crawler_source_data': '{}', | ||
| 44 | }) | ||
| 45 | |||
| 46 | sql, params = cur.execute.call_args[0] | ||
| 47 | assert 'UPDATE crawler_qqmusic_songs' in sql | ||
| 48 | assert 'platform_song_id = %s' in sql | ||
| 49 | assert params[-1] == 100 | ||
| 50 | assert params[0] == 200 | ||
| 51 | |||
| 52 | |||
| 53 | def test_delete_qq_song_and_yinyan_record_cleans_song_relations_and_state(): | ||
| 54 | cur = MagicMock() | ||
| 55 | delete_qq_song_and_yinyan_record(cur, 10, 100, 'song-uuid') | ||
| 56 | |||
| 57 | assert cur.execute.call_count == 3 | ||
| 58 | calls = cur.execute.call_args_list | ||
| 59 | assert 'crawler_qqmusic_singer_songs' in calls[0].args[0] | ||
| 60 | assert 'crawler_qqmusic_songs' in calls[1].args[0] | ||
| 61 | assert 'yinyan_song_records' in calls[2].args[0] | ||
| 62 | |||
| 63 | |||
| 64 | def test_mark_yinyan_record_resource_failed_keeps_unpushed_state_and_excludes_retry(): | ||
| 65 | cur = MagicMock() | ||
| 66 | mark_yinyan_record_resource_failed(cur, 10, 100, '1') | ||
| 67 | |||
| 68 | sql, params = cur.execute.call_args[0] | ||
| 69 | assert 'UPDATE yinyan_song_records' in sql | ||
| 70 | assert 'is_yinyan_push = FALSE' in sql | ||
| 71 | assert 'recording_id = %s' in sql | ||
| 72 | assert params == ('resource_transfer_failed', 10, 100, '1') | ||
| 73 | |||
| 74 | |||
| 34 | def test_upsert_qq_singers_empty_does_nothing(): | 75 | def test_upsert_qq_singers_empty_does_nothing(): |
| 35 | cur = MagicMock() | 76 | cur = MagicMock() |
| 36 | upsert_qq_singers(cur, []) | 77 | upsert_qq_singers(cur, []) |
| ... | @@ -103,7 +144,7 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im | ... | @@ -103,7 +144,7 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im |
| 103 | sql, params = cur.execute.call_args[0] | 144 | sql, params = cur.execute.call_args[0] |
| 104 | assert 'SELECT song_id, record_id, platform' in sql | 145 | assert 'SELECT song_id, record_id, platform' in sql |
| 105 | assert 'platform IS NOT NULL' in sql | 146 | assert 'platform IS NOT NULL' in sql |
| 106 | assert params == (500,) | 147 | assert params == ('resource_transfer_failed', 500) |
| 107 | assert rows == [ | 148 | assert rows == [ |
| 108 | {'song_id': 10, 'record_id': 100, 'platform': '1'}, | 149 | {'song_id': 10, 'record_id': 100, 'platform': '1'}, |
| 109 | {'song_id': 11, 'record_id': 101, 'platform': '2'}, | 150 | {'song_id': 11, 'record_id': 101, 'platform': '2'}, | ... | ... |
-
Please register or sign in to post a comment