chore(config): 支持切换音眼导入状态表以兼容 records2
- 在配置中添加 YINYAN_IMPORT_TABLE 环境变量,支持 records 和 records2 两张表 - 增加对 YINYAN_IMPORT_TABLE 合法值的校验,防止配置错误 - 修改写入和查询相关函数以适配动态表名 - 优化 upsert 逻辑,针对 records2 版本避免多条同 song_id 记录一同更新 - run_etl.py 中打印日志增加当前使用的导入表信息 - writer.py、runner.py 调整查询及更新逻辑以支持动态导入表切换 - oss.py 新增下载歌词文本函数,确保即使目标 OSS 包含歌词文件也重新下载内容 - runner.py 优化歌词上传流程,优先转存音眼歌词 URL,失败后回退爬虫端歌词文本 - 增加多处歌词和封面判断逻辑,支持基于音眼歌词 URL 判断歌词可用性 - 添加单元测试覆盖眼眼歌词转存优先逻辑及导入状态表切换功能验证
Showing
10 changed files
with
261 additions
and
50 deletions
| ... | @@ -13,6 +13,9 @@ TARGET_DB_PASSWORD= | ... | @@ -13,6 +13,9 @@ TARGET_DB_PASSWORD= |
| 13 | TARGET_DB_NAME= | 13 | TARGET_DB_NAME= |
| 14 | TARGET_TABLE_NAME=hk_songs | 14 | TARGET_TABLE_NAME=hk_songs |
| 15 | 15 | ||
| 16 | # run_etl.py 默认读取并回写的导入状态表:yinyan_song_records 或 yinyan_song_records2 | ||
| 17 | YINYAN_IMPORT_TABLE=yinyan_song_records | ||
| 18 | |||
| 16 | # OSS 配置 | 19 | # OSS 配置 |
| 17 | OSS_ACCESS_KEY_ID= | 20 | OSS_ACCESS_KEY_ID= |
| 18 | OSS_ACCESS_KEY_SECRET= | 21 | OSS_ACCESS_KEY_SECRET= | ... | ... |
| ... | @@ -48,6 +48,14 @@ PLATFORM_KUGOU = '2' | ... | @@ -48,6 +48,14 @@ PLATFORM_KUGOU = '2' |
| 48 | PLATFORM_NETEASE = '4' | 48 | PLATFORM_NETEASE = '4' |
| 49 | PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] | 49 | PLATFORMS = [PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE] |
| 50 | 50 | ||
| 51 | YINYAN_IMPORT_TABLE = os.environ.get('YINYAN_IMPORT_TABLE', 'yinyan_song_records').strip() | ||
| 52 | _ALLOWED_YINYAN_IMPORT_TABLES = {'yinyan_song_records', 'yinyan_song_records2'} | ||
| 53 | if YINYAN_IMPORT_TABLE not in _ALLOWED_YINYAN_IMPORT_TABLES: | ||
| 54 | raise ValueError( | ||
| 55 | f"YINYAN_IMPORT_TABLE must be one of {sorted(_ALLOWED_YINYAN_IMPORT_TABLES)}, " | ||
| 56 | f"got {YINYAN_IMPORT_TABLE!r}" | ||
| 57 | ) | ||
| 58 | |||
| 51 | BATCH_SIZE = 1000 | 59 | BATCH_SIZE = 1000 |
| 52 | BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) | 60 | BACKFILL_BATCH_SIZE = int(os.environ.get('BACKFILL_BATCH_SIZE', '5000')) |
| 53 | HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) | 61 | HTTP_POOL_MAXSIZE = int(os.environ.get('HTTP_POOL_MAXSIZE', '128')) | ... | ... |
| ... | @@ -85,6 +85,15 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s | ... | @@ -85,6 +85,15 @@ def transfer_url(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: s |
| 85 | return f"{base_url.rstrip('/')}/{oss_key}" | 85 | return f"{base_url.rstrip('/')}/{oss_key}" |
| 86 | 86 | ||
| 87 | 87 | ||
| 88 | def download_text_url(url: str | None, base_url: str) -> str: | ||
| 89 | """下载歌词等文本 URL;即使源文件已在目标 OSS,也实际读取其内容。""" | ||
| 90 | url = _clean_url(url) | ||
| 91 | if not url: | ||
| 92 | return '' | ||
| 93 | content = _download_content(url, base_url) | ||
| 94 | return content.decode('utf-8-sig') | ||
| 95 | |||
| 96 | |||
| 88 | def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> tuple[str, str]: | 97 | def transfer_url_with_md5(url: str | None, oss_key: str, bucket: oss2.Bucket, base_url: str) -> tuple[str, str]: |
| 89 | """ | 98 | """ |
| 90 | 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 | 99 | 将音频 URL 转存到 OSS,并基于下载到的音频字节计算 MD5。 | ... | ... |
| ... | @@ -43,8 +43,13 @@ from .writer import ( | ... | @@ -43,8 +43,13 @@ from .writer import ( |
| 43 | update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers, | 43 | update_qq_song_singers, update_kugou_song_singers, update_netease_song_singers, |
| 44 | fetch_qq_songs_with_invalid_covers, replace_qq_song, replace_yinyan_song_record, | 44 | fetch_qq_songs_with_invalid_covers, replace_qq_song, replace_yinyan_song_record, |
| 45 | delete_qq_song_and_yinyan_record, mark_yinyan_record_resource_failed, | 45 | delete_qq_song_and_yinyan_record, mark_yinyan_record_resource_failed, |
| 46 | YINYAN_IMPORT_FAILURE_UNKNOWN, YINYAN_IMPORT_FAILURE_NO_LYRIC, | ||
| 47 | YINYAN_IMPORT_FAILURE_NO_COVER, YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER, | ||
| 48 | YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING, YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER, | ||
| 49 | YINYAN_IMPORT_FAILURE_COVER_TRANSFER, YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER, | ||
| 50 | YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER, YINYAN_IMPORT_FAILURE_UNEXPECTED, | ||
| 46 | ) | 51 | ) |
| 47 | from .oss import transfer_url, transfer_url_with_md5, build_oss_key | 52 | from .oss import transfer_url, transfer_url_with_md5, download_text_url, build_oss_key |
| 48 | from .utils import split_title_version, upload_plain_lyric_to_bucket | 53 | from .utils import split_title_version, upload_plain_lyric_to_bucket |
| 49 | from .lyric import ensure_newlines | 54 | from .lyric import ensure_newlines |
| 50 | 55 | ||
| ... | @@ -59,6 +64,10 @@ QQ_MISSING_ALBUM_COVER = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000.jpg | ... | @@ -59,6 +64,10 @@ QQ_MISSING_ALBUM_COVER = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000.jpg |
| 59 | class RequiredAssetTransferError(RuntimeError): | 64 | class RequiredAssetTransferError(RuntimeError): |
| 60 | """音频、封面或歌词未成功落到目标 OSS,当前歌曲不得入库。""" | 65 | """音频、封面或歌词未成功落到目标 OSS,当前歌曲不得入库。""" |
| 61 | 66 | ||
| 67 | def __init__(self, missing: list[str]): | ||
| 68 | self.missing = tuple(missing) | ||
| 69 | super().__init__(f"required asset transfer failed: {', '.join(missing)}") | ||
| 70 | |||
| 62 | 71 | ||
| 63 | def _run_io_tasks(tasks: dict) -> dict: | 72 | def _run_io_tasks(tasks: dict) -> dict: |
| 64 | if not tasks: | 73 | if not tasks: |
| ... | @@ -88,13 +97,25 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: | ... | @@ -88,13 +97,25 @@ def _safe_transfer_audio(url, oss_key, bucket, base_url) -> tuple[str, str]: |
| 88 | return '', '' # 不将未转存的音频外链写入 crawler | 97 | return '', '' # 不将未转存的音频外链写入 crawler |
| 89 | 98 | ||
| 90 | 99 | ||
| 91 | def _safe_upload_lyric(platform: str, unique_id: str, lyric: str | None, fallback_url: str | None, bucket, base_url: str) -> str: | 100 | def _safe_upload_lyric( |
| 101 | platform: str, | ||
| 102 | unique_id: str, | ||
| 103 | spider_lyric: str | None, | ||
| 104 | hk_lyrics_url: str | None, | ||
| 105 | bucket, | ||
| 106 | base_url: str, | ||
| 107 | ) -> str: | ||
| 108 | """优先将音眼歌词 URL 转存到 crawler OSS;spider 歌词文本仅作回退。""" | ||
| 109 | if str(hk_lyrics_url or '').strip(): | ||
| 92 | try: | 110 | try: |
| 93 | uploaded_url = upload_plain_lyric_to_bucket(platform, unique_id, lyric or '', bucket, base_url) | 111 | hk_lyric = download_text_url(hk_lyrics_url, base_url) |
| 112 | uploaded_url = upload_plain_lyric_to_bucket(platform, unique_id, hk_lyric, bucket, base_url) | ||
| 94 | if uploaded_url: | 113 | if uploaded_url: |
| 95 | return uploaded_url | 114 | return uploaded_url |
| 96 | fallback_url = str(fallback_url or '') | 115 | except Exception as e: |
| 97 | return fallback_url if fallback_url.startswith(base_url.rstrip('/') + '/') else '' | 116 | log.warning('HK lyric transfer failed for %s/%s: %s', platform, unique_id, e) |
| 117 | try: | ||
| 118 | return upload_plain_lyric_to_bucket(platform, unique_id, spider_lyric or '', bucket, base_url) | ||
| 98 | except Exception as e: | 119 | except Exception as e: |
| 99 | log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e) | 120 | log.warning("Lyric upload failed for %s/%s: %s", platform, unique_id, e) |
| 100 | return '' | 121 | return '' |
| ... | @@ -109,7 +130,31 @@ def _require_primary_assets(assets: dict) -> None: | ... | @@ -109,7 +130,31 @@ def _require_primary_assets(assets: dict) -> None: |
| 109 | ) if not value | 130 | ) if not value |
| 110 | ] | 131 | ] |
| 111 | if missing: | 132 | if missing: |
| 112 | raise RequiredAssetTransferError(f"required asset transfer failed: {', '.join(missing)}") | 133 | raise RequiredAssetTransferError(missing) |
| 134 | |||
| 135 | |||
| 136 | def _selection_failure_code(reason: str) -> int: | ||
| 137 | if 'lyric' in reason and 'cover' in reason: | ||
| 138 | return YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER | ||
| 139 | if 'lyric' in reason: | ||
| 140 | return YINYAN_IMPORT_FAILURE_NO_LYRIC | ||
| 141 | if 'cover' in reason: | ||
| 142 | return YINYAN_IMPORT_FAILURE_NO_COVER | ||
| 143 | return YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING | ||
| 144 | |||
| 145 | |||
| 146 | def _asset_failure_code(missing: tuple[str, ...]) -> int: | ||
| 147 | if len(missing) != 1: | ||
| 148 | return YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER | ||
| 149 | return { | ||
| 150 | 'audio': YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER, | ||
| 151 | 'cover': YINYAN_IMPORT_FAILURE_COVER_TRANSFER, | ||
| 152 | 'lyric': YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER, | ||
| 153 | }.get(missing[0], YINYAN_IMPORT_FAILURE_UNKNOWN) | ||
| 154 | |||
| 155 | |||
| 156 | def _failed_pending(pending: dict, failure_code: int) -> dict: | ||
| 157 | return {**pending, 'failure_code': failure_code} | ||
| 113 | 158 | ||
| 114 | 159 | ||
| 115 | def _json_default(value): | 160 | def _json_default(value): |
| ... | @@ -130,8 +175,12 @@ def _qq_cover_source(hk_row: dict, song_data: dict) -> str: | ... | @@ -130,8 +175,12 @@ def _qq_cover_source(hk_row: dict, song_data: dict) -> str: |
| 130 | return hk_cover if _is_usable_qq_cover(hk_cover) else (song_data.get('cover') or '') | 175 | return hk_cover if _is_usable_qq_cover(hk_cover) else (song_data.get('cover') or '') |
| 131 | 176 | ||
| 132 | 177 | ||
| 133 | def _has_usable_qq_lyric(song_data: dict) -> bool: | 178 | def _has_usable_hk_lyric(hk_row: dict) -> bool: |
| 134 | return bool(str(song_data.get('lyric') or '').strip()) | 179 | return bool(str(hk_row.get('lyrics_url') or '').strip()) |
| 180 | |||
| 181 | |||
| 182 | def _has_usable_qq_lyric(song_data: dict, hk_row: dict) -> bool: | ||
| 183 | return _has_usable_hk_lyric(hk_row) or bool(str(song_data.get('lyric') or '').strip()) | ||
| 135 | 184 | ||
| 136 | 185 | ||
| 137 | def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool: | 186 | def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool: |
| ... | @@ -139,8 +188,8 @@ def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool: | ... | @@ -139,8 +188,8 @@ def _has_kugou_cover(hk_row: dict, song_data: dict) -> bool: |
| 139 | return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) | 188 | return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) |
| 140 | 189 | ||
| 141 | 190 | ||
| 142 | def _has_kugou_lyric(song_data: dict) -> bool: | 191 | def _has_kugou_lyric(song_data: dict, hk_row: dict) -> bool: |
| 143 | return bool(str(song_data.get('lyric') or '').strip()) | 192 | return _has_usable_hk_lyric(hk_row) or bool(str(song_data.get('lyric') or '').strip()) |
| 144 | 193 | ||
| 145 | 194 | ||
| 146 | def _has_netease_cover(hk_row: dict, song_data: dict) -> bool: | 195 | def _has_netease_cover(hk_row: dict, song_data: dict) -> bool: |
| ... | @@ -148,8 +197,8 @@ def _has_netease_cover(hk_row: dict, song_data: dict) -> bool: | ... | @@ -148,8 +197,8 @@ def _has_netease_cover(hk_row: dict, song_data: dict) -> bool: |
| 148 | return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) | 197 | return bool((hk_row.get('cover_url') or '').strip() or (song_data.get('cover') or '').strip()) |
| 149 | 198 | ||
| 150 | 199 | ||
| 151 | def _has_netease_lyric(song_data: dict) -> bool: | 200 | def _has_netease_lyric(song_data: dict, hk_row: dict) -> bool: |
| 152 | return bool(str(song_data.get('lyric') or '').strip()) | 201 | return _has_usable_hk_lyric(hk_row) or bool(str(song_data.get('lyric') or '').strip()) |
| 153 | 202 | ||
| 154 | 203 | ||
| 155 | def _select_qq_import_record( | 204 | def _select_qq_import_record( |
| ... | @@ -163,7 +212,7 @@ def _select_qq_import_record( | ... | @@ -163,7 +212,7 @@ def _select_qq_import_record( |
| 163 | current_song = songs_by_mid.get(current_record['platform_unique_key']) | 212 | current_song = songs_by_mid.get(current_record['platform_unique_key']) |
| 164 | if not current_song: | 213 | if not current_song: |
| 165 | return None, 'spider data missing' | 214 | return None, 'spider data missing' |
| 166 | needs_lyric = not _has_usable_qq_lyric(current_song) | 215 | needs_lyric = not _has_usable_qq_lyric(current_song, hk_row) |
| 167 | needs_cover = not _is_usable_qq_cover(_qq_cover_source(hk_row, current_song)) or not current_song.get('album_id') | 216 | needs_cover = not _is_usable_qq_cover(_qq_cover_source(hk_row, current_song)) or not current_song.get('album_id') |
| 168 | if not needs_lyric and not needs_cover: | 217 | if not needs_lyric and not needs_cover: |
| 169 | return current_record, 'ok' | 218 | return current_record, 'ok' |
| ... | @@ -172,7 +221,7 @@ def _select_qq_import_record( | ... | @@ -172,7 +221,7 @@ def _select_qq_import_record( |
| 172 | song = songs_by_mid.get(candidate['platform_unique_key']) | 221 | song = songs_by_mid.get(candidate['platform_unique_key']) |
| 173 | if not song: | 222 | if not song: |
| 174 | continue | 223 | continue |
| 175 | if needs_lyric and not _has_usable_qq_lyric(song): | 224 | if needs_lyric and not _has_usable_qq_lyric(song, hk_row): |
| 176 | continue | 225 | continue |
| 177 | if needs_cover and (not song.get('album_id') or not _is_usable_qq_cover(song.get('cover'))): | 226 | if needs_cover and (not song.get('album_id') or not _is_usable_qq_cover(song.get('cover'))): |
| 178 | continue | 227 | continue |
| ... | @@ -197,7 +246,7 @@ def _select_kugou_import_record( | ... | @@ -197,7 +246,7 @@ def _select_kugou_import_record( |
| 197 | current_song = songs_by_id.get(song_id) | 246 | current_song = songs_by_id.get(song_id) |
| 198 | if not current_song: | 247 | if not current_song: |
| 199 | return None, 'spider data missing' | 248 | return None, 'spider data missing' |
| 200 | needs_lyric = not _has_kugou_lyric(current_song) | 249 | needs_lyric = not _has_kugou_lyric(current_song, hk_row) |
| 201 | needs_cover = not _has_kugou_cover(hk_row, current_song) | 250 | needs_cover = not _has_kugou_cover(hk_row, current_song) |
| 202 | if not needs_lyric and not needs_cover: | 251 | if not needs_lyric and not needs_cover: |
| 203 | return current_record, 'ok' | 252 | return current_record, 'ok' |
| ... | @@ -207,7 +256,7 @@ def _select_kugou_import_record( | ... | @@ -207,7 +256,7 @@ def _select_kugou_import_record( |
| 207 | song = songs_by_id.get(cid) | 256 | song = songs_by_id.get(cid) |
| 208 | if not song: | 257 | if not song: |
| 209 | continue | 258 | continue |
| 210 | if needs_lyric and not _has_kugou_lyric(song): | 259 | if needs_lyric and not _has_kugou_lyric(song, hk_row): |
| 211 | continue | 260 | continue |
| 212 | if needs_cover and not _has_kugou_cover(hk_row, song): | 261 | if needs_cover and not _has_kugou_cover(hk_row, song): |
| 213 | continue | 262 | continue |
| ... | @@ -232,7 +281,7 @@ def _select_netease_import_record( | ... | @@ -232,7 +281,7 @@ def _select_netease_import_record( |
| 232 | current_song = songs_by_id.get(song_id) | 281 | current_song = songs_by_id.get(song_id) |
| 233 | if not current_song: | 282 | if not current_song: |
| 234 | return None, 'spider data missing' | 283 | return None, 'spider data missing' |
| 235 | needs_lyric = not _has_netease_lyric(current_song) | 284 | needs_lyric = not _has_netease_lyric(current_song, hk_row) |
| 236 | needs_cover = not _has_netease_cover(hk_row, current_song) | 285 | needs_cover = not _has_netease_cover(hk_row, current_song) |
| 237 | if not needs_lyric and not needs_cover: | 286 | if not needs_lyric and not needs_cover: |
| 238 | return current_record, 'ok' | 287 | return current_record, 'ok' |
| ... | @@ -242,7 +291,7 @@ def _select_netease_import_record( | ... | @@ -242,7 +291,7 @@ def _select_netease_import_record( |
| 242 | song = songs_by_id.get(cid) | 291 | song = songs_by_id.get(cid) |
| 243 | if not song: | 292 | if not song: |
| 244 | continue | 293 | continue |
| 245 | if needs_lyric and not _has_netease_lyric(song): | 294 | if needs_lyric and not _has_netease_lyric(song, hk_row): |
| 246 | continue | 295 | continue |
| 247 | if needs_cover and not _has_netease_cover(hk_row, song): | 296 | if needs_cover and not _has_netease_cover(hk_row, song): |
| 248 | continue | 297 | continue |
| ... | @@ -653,6 +702,7 @@ def _prepare_import_payload( | ... | @@ -653,6 +702,7 @@ def _prepare_import_payload( |
| 653 | payload = preparer(hk_row, pr, bucket, base_url, song_data, singer_list) | 702 | payload = preparer(hk_row, pr, bucket, base_url, song_data, singer_list) |
| 654 | payload['yinyan_record'] = { | 703 | payload['yinyan_record'] = { |
| 655 | 'song_id': int(pending['song_id']), | 704 | 'song_id': int(pending['song_id']), |
| 705 | 'source_record_id': int(pending['record_id']), | ||
| 656 | 'record_id': int(pr['record_id']), | 706 | 'record_id': int(pr['record_id']), |
| 657 | 'platform': platform, | 707 | 'platform': platform, |
| 658 | 'platform_song_id': int(payload['platform_song_id']), | 708 | 'platform_song_id': int(payload['platform_song_id']), |
| ... | @@ -1800,7 +1850,11 @@ def run( | ... | @@ -1800,7 +1850,11 @@ def run( |
| 1800 | src_id = int(pending['song_id']) | 1850 | src_id = int(pending['song_id']) |
| 1801 | platform = str(pending['platform']) | 1851 | platform = str(pending['platform']) |
| 1802 | hk_row = hk_by_song.get(src_id) | 1852 | hk_row = hk_by_song.get(src_id) |
| 1803 | if not hk_row or platform not in platforms: | 1853 | if platform not in platforms: |
| 1854 | continue | ||
| 1855 | if not hk_row: | ||
| 1856 | log.warning('Pending yinyan record has no usable hk_songs row: song_id=%s', src_id) | ||
| 1857 | rejected_pending.append(_failed_pending(pending, YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING)) | ||
| 1804 | continue | 1858 | continue |
| 1805 | pr = pr_by_state_key.get((src_id, int(pending['record_id']), platform)) | 1859 | pr = pr_by_state_key.get((src_id, int(pending['record_id']), platform)) |
| 1806 | if not pr: | 1860 | if not pr: |
| ... | @@ -1808,6 +1862,7 @@ def run( | ... | @@ -1808,6 +1862,7 @@ def run( |
| 1808 | "Pending yinyan record not found in source records: song_id=%s record_id=%s platform=%s", | 1862 | "Pending yinyan record not found in source records: song_id=%s record_id=%s platform=%s", |
| 1809 | src_id, pending['record_id'], platform, | 1863 | src_id, pending['record_id'], platform, |
| 1810 | ) | 1864 | ) |
| 1865 | rejected_pending.append(_failed_pending(pending, YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING)) | ||
| 1811 | continue | 1866 | continue |
| 1812 | if platform == PLATFORM_QQ: | 1867 | if platform == PLATFORM_QQ: |
| 1813 | selected, reason = _select_qq_import_record( | 1868 | selected, reason = _select_qq_import_record( |
| ... | @@ -1828,7 +1883,7 @@ def run( | ... | @@ -1828,7 +1883,7 @@ def run( |
| 1828 | 'Skip import (%s): platform=%s source_song_id=%s record_id=%s', | 1883 | 'Skip import (%s): platform=%s source_song_id=%s record_id=%s', |
| 1829 | reason, platform, src_id, pending['record_id'], | 1884 | reason, platform, src_id, pending['record_id'], |
| 1830 | ) | 1885 | ) |
| 1831 | rejected_pending.append(pending) | 1886 | rejected_pending.append(_failed_pending(pending, _selection_failure_code(reason))) |
| 1832 | continue | 1887 | continue |
| 1833 | if selected['record_id'] != pr['record_id']: | 1888 | if selected['record_id'] != pr['record_id']: |
| 1834 | log.info( | 1889 | log.info( |
| ... | @@ -1859,21 +1914,23 @@ def run( | ... | @@ -1859,21 +1914,23 @@ def run( |
| 1859 | total_ok += 1 | 1914 | total_ok += 1 |
| 1860 | else: | 1915 | else: |
| 1861 | total_err += 1 | 1916 | total_err += 1 |
| 1862 | asset_failed_pending.append(pending) | 1917 | asset_failed_pending.append( |
| 1918 | _failed_pending(pending, YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING) | ||
| 1919 | ) | ||
| 1863 | log.warning( | 1920 | log.warning( |
| 1864 | "Skip song because payload is empty: %s platform %s song_id=%s", | 1921 | "Skip song because payload is empty: %s platform %s song_id=%s", |
| 1865 | hk_row.get('name'), pr['platform'], pending['song_id'], | 1922 | hk_row.get('name'), pr['platform'], pending['song_id'], |
| 1866 | ) | 1923 | ) |
| 1867 | except RequiredAssetTransferError as e: | 1924 | except RequiredAssetTransferError as e: |
| 1868 | total_err += 1 | 1925 | total_err += 1 |
| 1869 | asset_failed_pending.append(pending) | 1926 | asset_failed_pending.append(_failed_pending(pending, _asset_failure_code(e.missing))) |
| 1870 | log.warning( | 1927 | log.warning( |
| 1871 | "Skip song because required asset transfer failed: %s platform %s: %s", | 1928 | "Skip song because required asset transfer failed: %s platform %s: %s", |
| 1872 | hk_row.get('name'), pr['platform'], e, | 1929 | hk_row.get('name'), pr['platform'], e, |
| 1873 | ) | 1930 | ) |
| 1874 | except Exception as e: | 1931 | except Exception as e: |
| 1875 | total_err += 1 | 1932 | total_err += 1 |
| 1876 | asset_failed_pending.append(pending) | 1933 | asset_failed_pending.append(_failed_pending(pending, YINYAN_IMPORT_FAILURE_UNEXPECTED)) |
| 1877 | log.error( | 1934 | log.error( |
| 1878 | "Skip song because unexpected error: %s platform %s: %s", | 1935 | "Skip song because unexpected error: %s platform %s: %s", |
| 1879 | hk_row.get('name'), pr['platform'], e, | 1936 | hk_row.get('name'), pr['platform'], e, |
| ... | @@ -1886,6 +1943,7 @@ def run( | ... | @@ -1886,6 +1943,7 @@ def run( |
| 1886 | for pending in discarded_pending: | 1943 | for pending in discarded_pending: |
| 1887 | mark_yinyan_record_resource_failed( | 1944 | mark_yinyan_record_resource_failed( |
| 1888 | pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), | 1945 | pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), |
| 1946 | int(pending['failure_code']), | ||
| 1889 | ) | 1947 | ) |
| 1890 | pg_conn.commit() | 1948 | pg_conn.commit() |
| 1891 | if discarded_pending: | 1949 | if discarded_pending: |
| ... | @@ -1897,6 +1955,7 @@ def run( | ... | @@ -1897,6 +1955,7 @@ def run( |
| 1897 | for pending in discarded_pending: | 1955 | for pending in discarded_pending: |
| 1898 | mark_yinyan_record_resource_failed( | 1956 | mark_yinyan_record_resource_failed( |
| 1899 | pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), | 1957 | pg_cur, int(pending['song_id']), int(pending['record_id']), str(pending['platform']), |
| 1958 | int(pending['failure_code']), | ||
| 1900 | ) | 1959 | ) |
| 1901 | pg_conn.commit() | 1960 | pg_conn.commit() |
| 1902 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) | 1961 | mark_hk_songs_deleted(hk_conn, [int(pending['song_id']) for pending in discarded_pending]) | ... | ... |
| 1 | import uuid | 1 | import uuid |
| 2 | 2 | ||
| 3 | from .config import YINYAN_IMPORT_TABLE | ||
| 4 | |||
| 3 | _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') | 5 | _SINGER_INDEX_VALUES = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ#') |
| 4 | _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} | 6 | _SINGER_SEX_VALUES = {'M', 'F', 'C', 'U'} |
| 5 | _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} | 7 | _SINGER_AREA_VALUES = {'华语', '欧美', '韩国', '日本', '其他'} |
| 6 | # 资源转存失败哨兵值:platform_song_id 设为 -1 表示 OSS 失败,区别于 NULL(待导入)和正整数(成功) | 8 | # platform_song_id 负数为失败码;NULL 表示待导入,正整数表示成功。 |
| 7 | PLATFORM_SONG_ID_RESOURCE_FAILED = -1 | 9 | YINYAN_IMPORT_FAILURE_UNKNOWN = -1 |
| 10 | YINYAN_IMPORT_FAILURE_NO_LYRIC = -2 | ||
| 11 | YINYAN_IMPORT_FAILURE_NO_COVER = -3 | ||
| 12 | YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER = -4 | ||
| 13 | YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING = -5 | ||
| 14 | YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER = -6 | ||
| 15 | YINYAN_IMPORT_FAILURE_COVER_TRANSFER = -7 | ||
| 16 | YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER = -8 | ||
| 17 | YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER = -9 | ||
| 18 | YINYAN_IMPORT_FAILURE_UNEXPECTED = -10 | ||
| 19 | |||
| 20 | YINYAN_IMPORT_FAILURE_CODES = { | ||
| 21 | YINYAN_IMPORT_FAILURE_UNKNOWN: 'unknown', | ||
| 22 | YINYAN_IMPORT_FAILURE_NO_LYRIC: 'no_usable_lyric', | ||
| 23 | YINYAN_IMPORT_FAILURE_NO_COVER: 'no_usable_cover', | ||
| 24 | YINYAN_IMPORT_FAILURE_NO_LYRIC_AND_COVER: 'no_usable_lyric_and_cover', | ||
| 25 | YINYAN_IMPORT_FAILURE_SOURCE_DATA_MISSING: 'source_or_spider_data_missing', | ||
| 26 | YINYAN_IMPORT_FAILURE_AUDIO_TRANSFER: 'audio_oss_transfer_failed', | ||
| 27 | YINYAN_IMPORT_FAILURE_COVER_TRANSFER: 'cover_oss_transfer_failed', | ||
| 28 | YINYAN_IMPORT_FAILURE_LYRIC_TRANSFER: 'lyric_oss_transfer_failed', | ||
| 29 | YINYAN_IMPORT_FAILURE_MULTIPLE_ASSET_TRANSFER: 'multiple_oss_transfers_failed', | ||
| 30 | YINYAN_IMPORT_FAILURE_UNEXPECTED: 'unexpected_error', | ||
| 31 | } | ||
| 8 | 32 | ||
| 9 | 33 | ||
| 10 | def _singer_index(value) -> str: | 34 | def _singer_index(value) -> str: |
| ... | @@ -90,9 +114,9 @@ def fetch_existing_yinyan_song_ids(cur) -> set[int]: | ... | @@ -90,9 +114,9 @@ def fetch_existing_yinyan_song_ids(cur) -> set[int]: |
| 90 | 114 | ||
| 91 | def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: | 115 | def fetch_pending_yinyan_song_records(cur, limit: int) -> list[dict]: |
| 92 | cur.execute( | 116 | cur.execute( |
| 93 | """ | 117 | f""" |
| 94 | SELECT song_id, record_id, platform | 118 | SELECT song_id, record_id, platform |
| 95 | FROM yinyan_song_records | 119 | FROM {YINYAN_IMPORT_TABLE} |
| 96 | WHERE is_yinyan_push = FALSE | 120 | WHERE is_yinyan_push = FALSE |
| 97 | AND platform IS NOT NULL | 121 | AND platform IS NOT NULL |
| 98 | AND platform_song_id IS NULL | 122 | AND platform_song_id IS NULL |
| ... | @@ -142,23 +166,31 @@ def update_yinyan_record_platforms(cur, records: list[dict]) -> None: | ... | @@ -142,23 +166,31 @@ def update_yinyan_record_platforms(cur, records: list[dict]) -> None: |
| 142 | 166 | ||
| 143 | def upsert_yinyan_song_records(cur, records: list[dict]) -> None: | 167 | def upsert_yinyan_song_records(cur, records: list[dict]) -> None: |
| 144 | """Mark pre-initialized yinyan song-record rows as pushed to crawler. | 168 | """Mark pre-initialized yinyan song-record rows as pushed to crawler. |
| 145 | Matches only on song_id so singer-fallback can use a different record_id than initialized. | 169 | 精确定位原始待处理关系,避免 records2 同一 song_id 的多条录音被一起更新。 |
| 146 | """ | 170 | """ |
| 147 | if not records: | 171 | if not records: |
| 148 | return | 172 | return |
| 149 | values_sql = ', '.join(['(%s::bigint, %s::bigint, %s::varchar, %s::bigint)'] * len(records)) | 173 | values_sql = ', '.join( |
| 174 | ['(%s::bigint, %s::bigint, %s::bigint, %s::varchar, %s::bigint)'] * len(records) | ||
| 175 | ) | ||
| 150 | params = [] | 176 | params = [] |
| 151 | for r in records: | 177 | for r in records: |
| 152 | params.extend([r['song_id'], r['record_id'], r['platform'], r['platform_song_id']]) | 178 | params.extend([ |
| 179 | r['song_id'], r.get('source_record_id', r['record_id']), r['record_id'], | ||
| 180 | r['platform'], r['platform_song_id'], | ||
| 181 | ]) | ||
| 182 | record_id_assignment = '' if YINYAN_IMPORT_TABLE == 'yinyan_song_records2' else 'record_id = v.record_id,' | ||
| 153 | cur.execute( | 183 | cur.execute( |
| 154 | f""" | 184 | f""" |
| 155 | UPDATE yinyan_song_records AS ysr | 185 | UPDATE {YINYAN_IMPORT_TABLE} AS ysr |
| 156 | SET platform = v.platform, | 186 | SET platform = v.platform, |
| 157 | platform_song_id = v.platform_song_id, | 187 | platform_song_id = v.platform_song_id, |
| 158 | record_id = v.record_id, | 188 | {record_id_assignment} |
| 159 | is_yinyan_push = TRUE | 189 | is_yinyan_push = TRUE |
| 160 | FROM (VALUES {values_sql}) AS v(song_id, record_id, platform, platform_song_id) | 190 | FROM (VALUES {values_sql}) AS v(song_id, source_record_id, record_id, platform, platform_song_id) |
| 161 | WHERE ysr.song_id = v.song_id | 191 | WHERE ysr.song_id = v.song_id |
| 192 | AND ysr.record_id = v.source_record_id | ||
| 193 | AND ysr.platform = v.platform | ||
| 162 | AND ysr.is_yinyan_push = FALSE | 194 | AND ysr.is_yinyan_push = FALSE |
| 163 | """, | 195 | """, |
| 164 | tuple(params), | 196 | tuple(params), |
| ... | @@ -254,15 +286,23 @@ def delete_qq_song_and_yinyan_record(cur, song_id: int, platform_song_id: int, s | ... | @@ -254,15 +286,23 @@ def delete_qq_song_and_yinyan_record(cur, song_id: int, platform_song_id: int, s |
| 254 | ) | 286 | ) |
| 255 | 287 | ||
| 256 | 288 | ||
| 257 | def mark_yinyan_record_resource_failed(cur, song_id: int, record_id: int, platform: str) -> None: | 289 | def mark_yinyan_record_resource_failed( |
| 258 | """保留未导入状态,并将 platform_song_id 置为哨兵值 -1,避免后续批次重复处理。""" | 290 | cur, |
| 291 | song_id: int, | ||
| 292 | record_id: int, | ||
| 293 | platform: str, | ||
| 294 | failure_code: int = YINYAN_IMPORT_FAILURE_UNKNOWN, | ||
| 295 | ) -> None: | ||
| 296 | """保留未导入状态,并以负数 platform_song_id 记录具体失败类型。""" | ||
| 297 | if failure_code not in YINYAN_IMPORT_FAILURE_CODES: | ||
| 298 | failure_code = YINYAN_IMPORT_FAILURE_UNKNOWN | ||
| 259 | cur.execute( | 299 | cur.execute( |
| 260 | """ | 300 | f""" |
| 261 | UPDATE yinyan_song_records | 301 | UPDATE {YINYAN_IMPORT_TABLE} |
| 262 | SET is_yinyan_push = FALSE, platform_song_id = %s | 302 | SET is_yinyan_push = FALSE, platform_song_id = %s |
| 263 | WHERE song_id = %s AND record_id = %s AND platform = %s AND is_yinyan_push = FALSE | 303 | WHERE song_id = %s AND record_id = %s AND platform = %s AND is_yinyan_push = FALSE |
| 264 | """, | 304 | """, |
| 265 | (PLATFORM_SONG_ID_RESOURCE_FAILED, song_id, record_id, platform), | 305 | (failure_code, song_id, record_id, platform), |
| 266 | ) | 306 | ) |
| 267 | 307 | ||
| 268 | 308 | ... | ... |
| 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, YINYAN_IMPORT_TABLE |
| 4 | from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, initialize_yinyan_song_records2, run, backfill_empty_singers, backfill_qq_invalid_covers | 4 | from etl_to_crawler.runner import backfill_yinyan_record_platforms, initialize_yinyan_song_records, initialize_yinyan_song_records2, run, backfill_empty_singers, backfill_qq_invalid_covers |
| 5 | 5 | ||
| 6 | PLATFORM_MAP = { | 6 | PLATFORM_MAP = { |
| ... | @@ -33,7 +33,7 @@ if __name__ == '__main__': | ... | @@ -33,7 +33,7 @@ if __name__ == '__main__': |
| 33 | else: | 33 | else: |
| 34 | platforms = [PLATFORM_MAP[args.platform]] | 34 | platforms = [PLATFORM_MAP[args.platform]] |
| 35 | 35 | ||
| 36 | print(f"Starting ETL for platforms: {platforms}") | 36 | print(f"Starting ETL for platforms: {platforms}, import table: {YINYAN_IMPORT_TABLE}") |
| 37 | if args.backfill_yinyan_platforms: | 37 | if args.backfill_yinyan_platforms: |
| 38 | backfill_yinyan_record_platforms(max_batches=args.max_batches) | 38 | backfill_yinyan_record_platforms(max_batches=args.max_batches) |
| 39 | elif args.init_yinyan_records: | 39 | elif args.init_yinyan_records: | ... | ... |
| 1 | from unittest.mock import MagicMock, patch | 1 | from unittest.mock import MagicMock, patch |
| 2 | import math | 2 | import math |
| 3 | import requests | 3 | import requests |
| 4 | from etl_to_crawler.oss import transfer_url, transfer_url_with_md5 | 4 | from etl_to_crawler.oss import download_text_url, transfer_url, transfer_url_with_md5 |
| 5 | 5 | ||
| 6 | 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" |
| 7 | OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3" | 7 | OTHER_URL = "https://hikoon-data-platform.oss-cn-beijing.aliyuncs.com/qq-audio/abc.mp3" |
| ... | @@ -13,6 +13,15 @@ def test_already_archive_dev_returns_as_is(): | ... | @@ -13,6 +13,15 @@ def test_already_archive_dev_returns_as_is(): |
| 13 | assert result == ARCHIVE_URL | 13 | assert result == ARCHIVE_URL |
| 14 | bucket.put_object.assert_not_called() | 14 | bucket.put_object.assert_not_called() |
| 15 | 15 | ||
| 16 | |||
| 17 | def test_download_text_url_reads_source_even_when_it_is_target_oss(): | ||
| 18 | with patch('etl_to_crawler.oss._http_get') as mock_get: | ||
| 19 | mock_get.return_value.content = b'\xef\xbb\xbf[00:01.00]lyric' | ||
| 20 | mock_get.return_value.raise_for_status = MagicMock() | ||
| 21 | result = download_text_url(ARCHIVE_URL, BASE_URL) | ||
| 22 | |||
| 23 | assert result == '[00:01.00]lyric' | ||
| 24 | |||
| 16 | def test_empty_url_returns_empty(): | 25 | def test_empty_url_returns_empty(): |
| 17 | bucket = MagicMock() | 26 | bucket = MagicMock() |
| 18 | result = transfer_url('', "any/key.mp3", bucket, BASE_URL) | 27 | result = transfer_url('', "any/key.mp3", bucket, BASE_URL) | ... | ... |
| ... | @@ -78,11 +78,33 @@ def test_required_asset_validation_rejects_partial_transfer(): | ... | @@ -78,11 +78,33 @@ def test_required_asset_validation_rejects_partial_transfer(): |
| 78 | }) | 78 | }) |
| 79 | 79 | ||
| 80 | 80 | ||
| 81 | def test_lyric_upload_does_not_fall_back_to_external_source_url(monkeypatch): | 81 | def test_lyric_upload_prefers_hk_url_and_forces_oss_transfer(monkeypatch): |
| 82 | monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', lambda *args: '') | 82 | download = MagicMock(return_value='[00:01.00]HK lyric') |
| 83 | monkeypatch.setattr(runner, 'download_text_url', download) | ||
| 84 | upload = MagicMock(return_value='https://archive.example/crawler/lyric/qq/mid.txt') | ||
| 85 | monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', upload) | ||
| 86 | bucket = object() | ||
| 87 | |||
| 88 | result = runner._safe_upload_lyric( | ||
| 89 | 'qq', 'mid', 'spider lyric', 'https://source.example/lyric.txt', bucket, 'https://archive.example', | ||
| 90 | ) | ||
| 91 | |||
| 92 | assert result == 'https://archive.example/crawler/lyric/qq/mid.txt' | ||
| 93 | download.assert_called_once_with('https://source.example/lyric.txt', 'https://archive.example') | ||
| 94 | upload.assert_called_once_with( | ||
| 95 | 'qq', 'mid', '[00:01.00]HK lyric', bucket, 'https://archive.example', | ||
| 96 | ) | ||
| 97 | |||
| 98 | |||
| 99 | def test_lyric_upload_falls_back_to_spider_text_when_hk_transfer_fails(monkeypatch): | ||
| 100 | monkeypatch.setattr(runner, 'download_text_url', MagicMock(side_effect=TimeoutError('timeout'))) | ||
| 101 | upload = MagicMock(return_value='https://archive.example/crawler/lyric/qq/mid.txt') | ||
| 102 | monkeypatch.setattr(runner, 'upload_plain_lyric_to_bucket', upload) | ||
| 103 | |||
| 83 | assert runner._safe_upload_lyric( | 104 | assert runner._safe_upload_lyric( |
| 84 | 'qq', 'mid', '', 'https://source.example/lyric.txt', object(), 'https://archive.example', | 105 | 'qq', 'mid', 'spider lyric', 'https://source.example/lyric.txt', object(), 'https://archive.example', |
| 85 | ) == '' | 106 | ) == 'https://archive.example/crawler/lyric/qq/mid.txt' |
| 107 | assert upload.call_args.args[2] == 'spider lyric' | ||
| 86 | 108 | ||
| 87 | 109 | ||
| 88 | def test_qq_cover_source_ignores_missing_album_placeholder_from_hk(): | 110 | def test_qq_cover_source_ignores_missing_album_placeholder_from_hk(): |
| ... | @@ -131,6 +153,25 @@ def test_select_qq_import_record_replaces_missing_lyric_with_same_source_candida | ... | @@ -131,6 +153,25 @@ def test_select_qq_import_record_replaces_missing_lyric_with_same_source_candida |
| 131 | assert reason == 'ok' | 153 | assert reason == 'ok' |
| 132 | 154 | ||
| 133 | 155 | ||
| 156 | def test_select_qq_import_record_accepts_hk_lyric_when_spider_lyric_is_empty(): | ||
| 157 | current = {'record_id': 10, 'platform_unique_key': 'without-spider-lyric'} | ||
| 158 | selected, reason = runner._select_qq_import_record( | ||
| 159 | {'lyrics_url': 'https://archive-dev.example/lyrics/song.txt', 'cover_url': 'https://example.com/cover.jpg'}, | ||
| 160 | current, | ||
| 161 | [current], | ||
| 162 | { | ||
| 163 | 'without-spider-lyric': { | ||
| 164 | 'album_id': 100, | ||
| 165 | 'cover': 'https://example.com/cover.jpg', | ||
| 166 | 'lyric': '', | ||
| 167 | }, | ||
| 168 | }, | ||
| 169 | ) | ||
| 170 | |||
| 171 | assert selected == current | ||
| 172 | assert reason == 'ok' | ||
| 173 | |||
| 174 | |||
| 134 | def test_select_qq_import_record_returns_none_when_no_candidate_satisfies_missing_fields(): | 175 | def test_select_qq_import_record_returns_none_when_no_candidate_satisfies_missing_fields(): |
| 135 | current = {'record_id': 10, 'platform_unique_key': 'bad-mid'} | 176 | current = {'record_id': 10, 'platform_unique_key': 'bad-mid'} |
| 136 | result, reason = runner._select_qq_import_record( | 177 | result, reason = runner._select_qq_import_record( | ... | ... |
| 1 | from unittest.mock import MagicMock, call | 1 | from unittest.mock import MagicMock, call |
| 2 | import etl_to_crawler.writer as writer | ||
| 2 | from etl_to_crawler.writer import ( | 3 | from etl_to_crawler.writer import ( |
| 3 | fetch_pending_yinyan_song_records, | 4 | fetch_pending_yinyan_song_records, |
| 4 | fetch_yinyan_records_missing_platform, | 5 | fetch_yinyan_records_missing_platform, |
| ... | @@ -69,12 +70,19 @@ def test_mark_yinyan_record_resource_failed_keeps_unpushed_state_and_excludes_re | ... | @@ -69,12 +70,19 @@ def test_mark_yinyan_record_resource_failed_keeps_unpushed_state_and_excludes_re |
| 69 | mark_yinyan_record_resource_failed(cur, 10, 100, '1') | 70 | mark_yinyan_record_resource_failed(cur, 10, 100, '1') |
| 70 | 71 | ||
| 71 | sql, params = cur.execute.call_args[0] | 72 | sql, params = cur.execute.call_args[0] |
| 72 | assert 'UPDATE yinyan_song_records' in sql | 73 | assert f'UPDATE {writer.YINYAN_IMPORT_TABLE}' in sql |
| 73 | assert 'is_yinyan_push = FALSE' in sql | 74 | assert 'is_yinyan_push = FALSE' in sql |
| 74 | assert 'platform_song_id = %s' in sql | 75 | assert 'platform_song_id = %s' in sql |
| 75 | assert params == (-1, 10, 100, '1') | 76 | assert params == (-1, 10, 100, '1') |
| 76 | 77 | ||
| 77 | 78 | ||
| 79 | def test_mark_yinyan_record_resource_failed_writes_specific_failure_code(): | ||
| 80 | cur = MagicMock() | ||
| 81 | mark_yinyan_record_resource_failed(cur, 10, 100, '1', writer.YINYAN_IMPORT_FAILURE_NO_LYRIC) | ||
| 82 | |||
| 83 | assert cur.execute.call_args[0][1] == (-2, 10, 100, '1') | ||
| 84 | |||
| 85 | |||
| 78 | def test_upsert_qq_singers_empty_does_nothing(): | 86 | def test_upsert_qq_singers_empty_does_nothing(): |
| 79 | cur = MagicMock() | 87 | cur = MagicMock() |
| 80 | upsert_qq_singers(cur, []) | 88 | upsert_qq_singers(cur, []) |
| ... | @@ -97,16 +105,21 @@ def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): | ... | @@ -97,16 +105,21 @@ def test_upsert_yinyan_song_records_marks_existing_relation_as_pushed(): |
| 97 | ]) | 105 | ]) |
| 98 | 106 | ||
| 99 | sql, params = cur.execute.call_args[0] | 107 | sql, params = cur.execute.call_args[0] |
| 100 | assert 'UPDATE yinyan_song_records' in sql | 108 | assert f'UPDATE {writer.YINYAN_IMPORT_TABLE}' in sql |
| 101 | assert 'UPDATE yinyan_song_records AS ysr' in sql | 109 | assert f'UPDATE {writer.YINYAN_IMPORT_TABLE} AS ysr' in sql |
| 102 | assert 'SET platform = v.platform' in sql | 110 | assert 'SET platform = v.platform' in sql |
| 103 | assert 'platform_song_id = v.platform_song_id' in sql | 111 | assert 'platform_song_id = v.platform_song_id' in sql |
| 112 | if writer.YINYAN_IMPORT_TABLE == 'yinyan_song_records2': | ||
| 113 | assert 'record_id = v.record_id' not in sql | ||
| 114 | else: | ||
| 104 | assert 'record_id = v.record_id' in sql | 115 | assert 'record_id = v.record_id' in sql |
| 105 | assert 'is_yinyan_push = TRUE' in sql | 116 | assert 'is_yinyan_push = TRUE' in sql |
| 106 | assert 'FROM (VALUES (%s::bigint, %s::bigint, %s::varchar, %s::bigint), (%s::bigint, %s::bigint, %s::varchar, %s::bigint))' in sql | 117 | assert 'source_record_id' in sql |
| 107 | assert 'ysr.song_id = v.song_id' in sql | 118 | assert 'ysr.song_id = v.song_id' in sql |
| 119 | assert 'ysr.record_id = v.source_record_id' in sql | ||
| 120 | assert 'ysr.platform = v.platform' in sql | ||
| 108 | assert 'ysr.is_yinyan_push = FALSE' in sql | 121 | assert 'ysr.is_yinyan_push = FALSE' in sql |
| 109 | assert params == (10, 100, '1', 1000, 11, 101, '2', 1001) | 122 | assert params == (10, 100, 100, '1', 1000, 11, 101, 101, '2', 1001) |
| 110 | 123 | ||
| 111 | 124 | ||
| 112 | def test_insert_yinyan_song_records_initializes_unpushed_rows(): | 125 | def test_insert_yinyan_song_records_initializes_unpushed_rows(): |
| ... | @@ -193,6 +206,23 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im | ... | @@ -193,6 +206,23 @@ def test_fetch_pending_yinyan_song_records_reads_platform_for_single_platform_im |
| 193 | ] | 206 | ] |
| 194 | 207 | ||
| 195 | 208 | ||
| 209 | def test_import_state_queries_can_target_records2(monkeypatch): | ||
| 210 | monkeypatch.setattr(writer, 'YINYAN_IMPORT_TABLE', 'yinyan_song_records2') | ||
| 211 | cur = MagicMock() | ||
| 212 | cur.fetchall.return_value = [] | ||
| 213 | |||
| 214 | writer.fetch_pending_yinyan_song_records(cur, 100) | ||
| 215 | assert 'FROM yinyan_song_records2' in cur.execute.call_args[0][0] | ||
| 216 | |||
| 217 | writer.upsert_yinyan_song_records(cur, [ | ||
| 218 | {'song_id': 10, 'record_id': 100, 'platform': '1', 'platform_song_id': 1000}, | ||
| 219 | ]) | ||
| 220 | assert 'UPDATE yinyan_song_records2 AS ysr' in cur.execute.call_args[0][0] | ||
| 221 | |||
| 222 | writer.mark_yinyan_record_resource_failed(cur, 10, 100, '1') | ||
| 223 | assert 'UPDATE yinyan_song_records2' in cur.execute.call_args[0][0] | ||
| 224 | |||
| 225 | |||
| 196 | def test_update_yinyan_record_platforms_fills_only_null_platform_rows(): | 226 | def test_update_yinyan_record_platforms_fills_only_null_platform_rows(): |
| 197 | cur = MagicMock() | 227 | cur = MagicMock() |
| 198 | update_yinyan_record_platforms(cur, [ | 228 | update_yinyan_record_platforms(cur, [ | ... | ... |
失败码映射.md
0 → 100644
| 1 | | **失败码** | **含义** | | ||
| 2 | | ---------- | ---------------------------- | | ||
| 3 | | -1 | 未分类/未知失败 | | ||
| 4 | | -2 | 没有可用歌词 | | ||
| 5 | | -3 | 没有可用封面 | | ||
| 6 | | -4 | 歌词和封面均不可用 | | ||
| 7 | | -5 | HK、音眼源库或spider数据缺失 | | ||
| 8 | | -6 | 音频转存OSS失败 | | ||
| 9 | | -7 | 封面转存OSS失败 | | ||
| 10 | | -8 | 歌词转存OSS失败 | | ||
| 11 | | -9 | 多项必需资源转存失败 | | ||
| 12 | | -10 | 未预期程序异常 | | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
-
Please register or sign in to post a comment