refactor(lyrics-cache): 重构歌词缓存读取写入逻辑并同步新增歌词缓存
- 抽取歌词缓存读取和写入为独立函数 _read_existing_lyrics_cache 与 _write_existing_lyrics_cache - load_existing_l2_candidates 中改用新缓存读写函数,增加缓存加载日志和进度输出 - 处理下载异常时保证已下载缓存的歌词及时持久化,避免丢失数据 - 新增 sync_inserted_lyrics_cache 函数实现新增歌词缓存同步写入功能 - 后台写入线程写入数据库后调用歌词缓存同步,记录新增条数日志 - 修改批量写入队列数据结构,传递缓存同步相关数据 - 优化去重统计日志显示格式,新增 format_dedup_stats 函数统一格式化 - L2审核界面新增 skip 筛选选项,调整相关前端和后端代码支持该状态 - 移除 L2 审核界面中保存按钮,避免误操作手动保存 - 补充单元测试覆盖缓存同步、缓存读取异常处理和去重统计日志格式输出
Showing
4 changed files
with
195 additions
and
35 deletions
| ... | @@ -722,14 +722,19 @@ def _decode_lyric_content(content: bytes) -> str: | ... | @@ -722,14 +722,19 @@ def _decode_lyric_content(content: bytes) -> str: |
| 722 | return content.decode('utf-8', errors='replace') | 722 | return content.decode('utf-8', errors='replace') |
| 723 | 723 | ||
| 724 | 724 | ||
| 725 | def load_existing_l2_candidates( | 725 | def _write_existing_lyrics_cache(cache_path: Path, cache_items: dict[tuple[str, str], dict]) -> None: |
| 726 | rows: list[dict], | 726 | cache_path.parent.mkdir(parents=True, exist_ok=True) |
| 727 | cache_path: Path, | 727 | tmp_path = cache_path.with_suffix(cache_path.suffix + '.tmp') |
| 728 | downloader=download_file, | 728 | with tmp_path.open('w', encoding='utf-8') as f: |
| 729 | ) -> tuple[list[LyricRecord], dict[str, int]]: | 729 | for item in cache_items.values(): |
| 730 | """从目标库 lyrics_url 行构建 L2 候选;本地缓存已下载歌词,避免重启全量拉 OSS。""" | 730 | f.write(json.dumps(item, ensure_ascii=False) + '\n') |
| 731 | tmp_path.replace(cache_path) | ||
| 732 | |||
| 733 | |||
| 734 | def _read_existing_lyrics_cache(cache_path: Path) -> dict[tuple[str, str], dict]: | ||
| 731 | cached_by_key: dict[tuple[str, str], dict] = {} | 735 | cached_by_key: dict[tuple[str, str], dict] = {} |
| 732 | if cache_path.exists(): | 736 | if not cache_path.exists(): |
| 737 | return cached_by_key | ||
| 733 | with cache_path.open('r', encoding='utf-8') as f: | 738 | with cache_path.open('r', encoding='utf-8') as f: |
| 734 | for line in f: | 739 | for line in f: |
| 735 | line = line.strip() | 740 | line = line.strip() |
| ... | @@ -744,15 +749,67 @@ def load_existing_l2_candidates( | ... | @@ -744,15 +749,67 @@ def load_existing_l2_candidates( |
| 744 | lyrics = item.get('lyrics') | 749 | lyrics = item.get('lyrics') |
| 745 | if record_id and url and isinstance(lyrics, str) and lyrics.strip(): | 750 | if record_id and url and isinstance(lyrics, str) and lyrics.strip(): |
| 746 | cached_by_key[(record_id, url)] = item | 751 | cached_by_key[(record_id, url)] = item |
| 752 | return cached_by_key | ||
| 753 | |||
| 754 | |||
| 755 | def sync_inserted_lyrics_cache(cache_path: Path, inserted_rows: list[tuple[tuple, dict]]) -> int: | ||
| 756 | """把本次已成功插入目标库的歌词同步到 L2 启动缓存。""" | ||
| 757 | if not inserted_rows: | ||
| 758 | return 0 | ||
| 759 | |||
| 760 | cached_by_key = _read_existing_lyrics_cache(cache_path) | ||
| 761 | added = 0 | ||
| 762 | for tuple_row, source_row in inserted_rows: | ||
| 763 | url = tuple_row[OSS_LYRIC_INDEX] | ||
| 764 | lyrics = source_row.get('lyrics_txt_content') | ||
| 765 | if not url or not str(url).startswith(('http://', 'https://')): | ||
| 766 | continue | ||
| 767 | if not lyrics or not str(lyrics).strip(): | ||
| 768 | continue | ||
| 769 | |||
| 770 | record_id = str(tuple_row[0]) | ||
| 771 | key = (record_id, str(url)) | ||
| 772 | if key not in cached_by_key: | ||
| 773 | added += 1 | ||
| 774 | cached_by_key[key] = { | ||
| 775 | 'id': record_id, | ||
| 776 | 'url': str(url), | ||
| 777 | 'lyrics': str(lyrics), | ||
| 778 | 'name': tuple_row[1], | ||
| 779 | 'singer': tuple_row[33], | ||
| 780 | 'lyricist': tuple_row[2], | ||
| 781 | 'composer': tuple_row[3], | ||
| 782 | } | ||
| 783 | |||
| 784 | if added: | ||
| 785 | _write_existing_lyrics_cache(cache_path, cached_by_key) | ||
| 786 | return added | ||
| 787 | |||
| 788 | |||
| 789 | def load_existing_l2_candidates( | ||
| 790 | rows: list[dict], | ||
| 791 | cache_path: Path, | ||
| 792 | downloader=download_file, | ||
| 793 | ) -> tuple[list[LyricRecord], dict[str, int]]: | ||
| 794 | """从目标库 lyrics_url 行构建 L2 候选;本地缓存已下载歌词,避免重启全量拉 OSS。""" | ||
| 795 | cached_by_key = _read_existing_lyrics_cache(cache_path) | ||
| 747 | 796 | ||
| 748 | records: list[LyricRecord] = [] | 797 | records: list[LyricRecord] = [] |
| 749 | next_cache: dict[tuple[str, str], dict] = {} | 798 | next_cache: dict[tuple[str, str], dict] = {} |
| 750 | stats = {'cached': 0, 'downloaded': 0, 'failed': 0} | 799 | stats = {'cached': 0, 'downloaded': 0, 'failed': 0} |
| 751 | 800 | ||
| 752 | for row in rows: | 801 | rows_with_url = [ |
| 802 | row for row in rows | ||
| 803 | if row.get('lyrics_url') and str(row.get('lyrics_url')).startswith(('http://', 'https://')) | ||
| 804 | ] | ||
| 805 | logger.info( | ||
| 806 | f"目标库歌词缓存加载开始: rows={len(rows)}, 有效URL={len(rows_with_url)}, " | ||
| 807 | f"已有缓存={len(cached_by_key)}, 缓存文件={cache_path}" | ||
| 808 | ) | ||
| 809 | |||
| 810 | try: | ||
| 811 | for idx, row in enumerate(rows_with_url, start=1): | ||
| 753 | url = row.get('lyrics_url') | 812 | url = row.get('lyrics_url') |
| 754 | if not url or not str(url).startswith(('http://', 'https://')): | ||
| 755 | continue | ||
| 756 | 813 | ||
| 757 | record_id = str(row['id']) | 814 | record_id = str(row['id']) |
| 758 | key = (record_id, str(url)) | 815 | key = (record_id, str(url)) |
| ... | @@ -785,12 +842,13 @@ def load_existing_l2_candidates( | ... | @@ -785,12 +842,13 @@ def load_existing_l2_candidates( |
| 785 | composer=row.get('composer') or item.get('composer'), | 842 | composer=row.get('composer') or item.get('composer'), |
| 786 | )) | 843 | )) |
| 787 | 844 | ||
| 788 | cache_path.parent.mkdir(parents=True, exist_ok=True) | 845 | if idx % 50 == 0 or idx == len(rows_with_url): |
| 789 | tmp_path = cache_path.with_suffix(cache_path.suffix + '.tmp') | 846 | logger.info( |
| 790 | with tmp_path.open('w', encoding='utf-8') as f: | 847 | f"目标库歌词缓存进度: {idx}/{len(rows_with_url)} | " |
| 791 | for item in next_cache.values(): | 848 | f"命中={stats['cached']} 下载={stats['downloaded']} 失败={stats['failed']}" |
| 792 | f.write(json.dumps(item, ensure_ascii=False) + '\n') | 849 | ) |
| 793 | tmp_path.replace(cache_path) | 850 | finally: |
| 851 | _write_existing_lyrics_cache(cache_path, next_cache) | ||
| 794 | 852 | ||
| 795 | return records, stats | 853 | return records, stats |
| 796 | 854 | ||
| ... | @@ -807,6 +865,15 @@ def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]: | ... | @@ -807,6 +865,15 @@ def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]: |
| 807 | return False, None | 865 | return False, None |
| 808 | 866 | ||
| 809 | 867 | ||
| 868 | def format_dedup_stats(stats: dict) -> str: | ||
| 869 | return ( | ||
| 870 | f"L1命中={stats['l1_dup']} | " | ||
| 871 | f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | " | ||
| 872 | f"L2新词={stats['l2_new']} | 作者合并={stats['author_merged']} | " | ||
| 873 | f"最终跳过={stats['skipped']}" | ||
| 874 | ) | ||
| 875 | |||
| 876 | |||
| 810 | class L2CandidateIndex: | 877 | class L2CandidateIndex: |
| 811 | """L2 歌词候选召回索引,最终判定仍交给 DuplicateChecker。""" | 878 | """L2 歌词候选召回索引,最终判定仍交给 DuplicateChecker。""" |
| 812 | 879 | ||
| ... | @@ -1693,7 +1760,7 @@ def main(): | ... | @@ -1693,7 +1760,7 @@ def main(): |
| 1693 | if item is None: | 1760 | if item is None: |
| 1694 | db_write_queue.task_done() | 1761 | db_write_queue.task_done() |
| 1695 | break | 1762 | break |
| 1696 | batch_data, staging_data, batch_rows, batch_size_actual, batch_start, author_merge_tasks = item | 1763 | batch_data, staging_data, batch_cache_rows, batch_size_actual, batch_start, author_merge_tasks = item |
| 1697 | try: | 1764 | try: |
| 1698 | with target_conn.cursor() as cursor: | 1765 | with target_conn.cursor() as cursor: |
| 1699 | if batch_data: | 1766 | if batch_data: |
| ... | @@ -1708,6 +1775,12 @@ def main(): | ... | @@ -1708,6 +1775,12 @@ def main(): |
| 1708 | batch_inserted = len(batch_data) | 1775 | batch_inserted = len(batch_data) |
| 1709 | with stats_lock: | 1776 | with stats_lock: |
| 1710 | stats['inserted'] += batch_inserted | 1777 | stats['inserted'] += batch_inserted |
| 1778 | cache_added = sync_inserted_lyrics_cache( | ||
| 1779 | CACHE_DIR / f'{TARGET_TABLE_NAME}_existing_lyrics.jsonl', | ||
| 1780 | batch_cache_rows, | ||
| 1781 | ) | ||
| 1782 | if cache_added: | ||
| 1783 | logger.info(f"目标库歌词缓存同步新增: {cache_added} 条") | ||
| 1711 | except Exception as e: | 1784 | except Exception as e: |
| 1712 | logger.error(f"批量写入失败 (offset {batch_start}): {e}") | 1785 | logger.error(f"批量写入失败 (offset {batch_start}): {e}") |
| 1713 | target_conn.rollback() | 1786 | target_conn.rollback() |
| ... | @@ -1720,6 +1793,11 @@ def main(): | ... | @@ -1720,6 +1793,11 @@ def main(): |
| 1720 | target_conn.commit() | 1793 | target_conn.commit() |
| 1721 | with stats_lock: | 1794 | with stats_lock: |
| 1722 | stats['inserted'] += 1 | 1795 | stats['inserted'] += 1 |
| 1796 | if row_i < len(batch_cache_rows): | ||
| 1797 | sync_inserted_lyrics_cache( | ||
| 1798 | CACHE_DIR / f'{TARGET_TABLE_NAME}_existing_lyrics.jsonl', | ||
| 1799 | [batch_cache_rows[row_i]], | ||
| 1800 | ) | ||
| 1723 | except Exception as e2: | 1801 | except Exception as e2: |
| 1724 | with stats_lock: | 1802 | with stats_lock: |
| 1725 | stats['errors'] += 1 | 1803 | stats['errors'] += 1 |
| ... | @@ -1739,7 +1817,7 @@ def main(): | ... | @@ -1739,7 +1817,7 @@ def main(): |
| 1739 | pbar_db.set_postfix( | 1817 | pbar_db.set_postfix( |
| 1740 | ins=stats['inserted'], | 1818 | ins=stats['inserted'], |
| 1741 | err=stats['errors'], | 1819 | err=stats['errors'], |
| 1742 | l1=stats['l1_dup'], | 1820 | l1_hit=stats['l1_dup'], |
| 1743 | l2=stats['l2_dup'], | 1821 | l2=stats['l2_dup'], |
| 1744 | rev=stats['l2_review'], | 1822 | rev=stats['l2_review'], |
| 1745 | auth=stats['author_merged'], | 1823 | auth=stats['author_merged'], |
| ... | @@ -1872,6 +1950,11 @@ def main(): | ... | @@ -1872,6 +1950,11 @@ def main(): |
| 1872 | 1950 | ||
| 1873 | # 按顺序组装 batch_data | 1951 | # 按顺序组装 batch_data |
| 1874 | batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map] | 1952 | batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map] |
| 1953 | batch_cache_rows = [ | ||
| 1954 | (results_map[i], new_rows[i]) | ||
| 1955 | for i in range(len(new_rows)) | ||
| 1956 | if i in results_map | ||
| 1957 | ] | ||
| 1875 | 1958 | ||
| 1876 | # ===== 步骤3:构建 staging tuples ===== | 1959 | # ===== 步骤3:构建 staging tuples ===== |
| 1877 | if not args.skip_dedup and not args.review_result_csv: | 1960 | if not args.skip_dedup and not args.review_result_csv: |
| ... | @@ -1900,7 +1983,7 @@ def main(): | ... | @@ -1900,7 +1983,7 @@ def main(): |
| 1900 | # ===== 步骤4:交给后台线程写入 DB(主线程继续下一批次 OSS)===== | 1983 | # ===== 步骤4:交给后台线程写入 DB(主线程继续下一批次 OSS)===== |
| 1901 | if not args.skip_dedup: | 1984 | if not args.skip_dedup: |
| 1902 | mark_rows_in_l1_index(batch_rows, l1_index) | 1985 | mark_rows_in_l1_index(batch_rows, l1_index) |
| 1903 | db_write_queue.put((batch_data, staging_data, batch_rows, batch_size_actual, batch_start, author_merge_tasks)) | 1986 | db_write_queue.put((batch_data, staging_data, batch_cache_rows, batch_size_actual, batch_start, author_merge_tasks)) |
| 1904 | 1987 | ||
| 1905 | # 所有批次已入队;等待后台 DB 写入收尾后停止线程 | 1988 | # 所有批次已入队;等待后台 DB 写入收尾后停止线程 |
| 1906 | stop_db_writer(db_write_queue, db_writer_thread) | 1989 | stop_db_writer(db_write_queue, db_writer_thread) |
| ... | @@ -1915,10 +1998,7 @@ def main(): | ... | @@ -1915,10 +1998,7 @@ def main(): |
| 1915 | logger.info("导入完成!") | 1998 | logger.info("导入完成!") |
| 1916 | logger.info(f"总计: {total} | 插入: {stats['inserted']} | 错误: {stats['errors']}") | 1999 | logger.info(f"总计: {total} | 插入: {stats['inserted']} | 错误: {stats['errors']}") |
| 1917 | if not args.skip_dedup: | 2000 | if not args.skip_dedup: |
| 1918 | logger.info(f"去重统计: L1跳过={stats['l1_dup']} | " | 2001 | logger.info(f"去重统计: {format_dedup_stats(stats)}") |
| 1919 | f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | " | ||
| 1920 | f"L2新词={stats['l2_new']} | 作者合并={stats['author_merged']} | " | ||
| 1921 | f"跳过={stats['skipped']}") | ||
| 1922 | if dedup_report: | 2002 | if dedup_report: |
| 1923 | logger.info(f"去重报告: {dedup_report.filepath}") | 2003 | logger.info(f"去重报告: {dedup_report.filepath}") |
| 1924 | if review_report: | 2004 | if review_report: | ... | ... |
| ... | @@ -466,6 +466,7 @@ | ... | @@ -466,6 +466,7 @@ |
| 466 | <option value="new">new</option> | 466 | <option value="new">new</option> |
| 467 | <option value="merge">merge</option> | 467 | <option value="merge">merge</option> |
| 468 | <option value="review">review</option> | 468 | <option value="review">review</option> |
| 469 | <option value="skip">skip</option> | ||
| 469 | </select> | 470 | </select> |
| 470 | <input id="searchInput" type="search" placeholder="搜索歌名 / ID"> | 471 | <input id="searchInput" type="search" placeholder="搜索歌名 / ID"> |
| 471 | <label for="pageSizeSelect">每页</label> | 472 | <label for="pageSizeSelect">每页</label> |
| ... | @@ -858,7 +859,6 @@ | ... | @@ -858,7 +859,6 @@ |
| 858 | ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'} | 859 | ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'} |
| 859 | </button>`).join('')} | 860 | </button>`).join('')} |
| 860 | <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> | 861 | <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> |
| 861 | <button id="saveReviewBtn" class="secondary">保存</button> | ||
| 862 | <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> | 862 | <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> |
| 863 | </div> | 863 | </div> |
| 864 | `} | 864 | `} |
| ... | @@ -906,14 +906,6 @@ | ... | @@ -906,14 +906,6 @@ |
| 906 | renderDetail(); | 906 | renderDetail(); |
| 907 | }); | 907 | }); |
| 908 | } | 908 | } |
| 909 | const saveBtn = document.getElementById('saveReviewBtn'); | ||
| 910 | if (saveBtn) { | ||
| 911 | saveBtn.addEventListener('click', () => { | ||
| 912 | if (!candidate) return; | ||
| 913 | setReview(candidate, { note: document.getElementById('reviewNote')?.value || '' }); | ||
| 914 | renderDetail(); | ||
| 915 | }); | ||
| 916 | } | ||
| 917 | const deleteBtn = document.getElementById('deleteReviewBtn'); | 909 | const deleteBtn = document.getElementById('deleteReviewBtn'); |
| 918 | if (deleteBtn) { | 910 | if (deleteBtn) { |
| 919 | deleteBtn.addEventListener('click', async () => { | 911 | deleteBtn.addEventListener('click', async () => { | ... | ... |
| ... | @@ -174,8 +174,8 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int, de | ... | @@ -174,8 +174,8 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int, de |
| 174 | # hits 模式下只展示有命中的记录(merge/review) | 174 | # hits 模式下只展示有命中的记录(merge/review) |
| 175 | if mode == "hits": | 175 | if mode == "hits": |
| 176 | where_clauses.append("dedup_action IN ('merge', 'review')") | 176 | where_clauses.append("dedup_action IN ('merge', 'review')") |
| 177 | # 决策筛选:new / merge / review | 177 | # 决策筛选:new / merge / review / skip |
| 178 | if decision and decision in ('new', 'merge', 'review'): | 178 | if decision and decision in ('new', 'merge', 'review', 'skip'): |
| 179 | where_clauses.append("dedup_action = %s") | 179 | where_clauses.append("dedup_action = %s") |
| 180 | params.append(decision) | 180 | params.append(decision) |
| 181 | where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" | 181 | where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" |
| ... | @@ -640,7 +640,7 @@ def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, pa | ... | @@ -640,7 +640,7 @@ def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, pa |
| 640 | if mode == "hits": | 640 | if mode == "hits": |
| 641 | groups = [g for g in groups if g.get("has_hit")] | 641 | groups = [g for g in groups if g.get("has_hit")] |
| 642 | # 决策筛选 | 642 | # 决策筛选 |
| 643 | if decision and decision in ('new', 'merge', 'review', 'duplicate'): | 643 | if decision and decision in ('new', 'merge', 'review', 'duplicate', 'skip'): |
| 644 | groups = [g for g in groups if g.get("decision") == decision or (decision == 'merge' and g.get("decision") == 'merge')] | 644 | groups = [g for g in groups if g.get("decision") == decision or (decision == 'merge' and g.get("decision") == 'merge')] |
| 645 | filtered = [group for group in groups if _matches_term(group, term)] | 645 | filtered = [group for group in groups if _matches_term(group, term)] |
| 646 | start = max(0, (page - 1) * page_size) | 646 | start = max(0, (page - 1) * page_size) | ... | ... |
| ... | @@ -43,6 +43,8 @@ from import_hk_songs import ( | ... | @@ -43,6 +43,8 @@ from import_hk_songs import ( |
| 43 | process_lyrics, | 43 | process_lyrics, |
| 44 | DedupReport, | 44 | DedupReport, |
| 45 | L2CandidateIndex, | 45 | L2CandidateIndex, |
| 46 | format_dedup_stats, | ||
| 47 | sync_inserted_lyrics_cache, | ||
| 46 | load_approved_import_ids, | 48 | load_approved_import_ids, |
| 47 | load_existing_l2_candidates, | 49 | load_existing_l2_candidates, |
| 48 | ) | 50 | ) |
| ... | @@ -140,6 +142,26 @@ class TestPendingL1Index: | ... | @@ -140,6 +142,26 @@ class TestPendingL1Index: |
| 140 | assert len(l1_index) == 1 | 142 | assert len(l1_index) == 1 |
| 141 | 143 | ||
| 142 | 144 | ||
| 145 | class TestDedupStatsLog: | ||
| 146 | """测试去重统计日志文案""" | ||
| 147 | |||
| 148 | def test_l1_counter_is_labeled_as_match_not_skip(self): | ||
| 149 | stats = { | ||
| 150 | 'l1_dup': 2, | ||
| 151 | 'l2_dup': 2, | ||
| 152 | 'l2_review': 23, | ||
| 153 | 'l2_new': 1725, | ||
| 154 | 'author_merged': 1, | ||
| 155 | 'skipped': 0, | ||
| 156 | } | ||
| 157 | |||
| 158 | message = format_dedup_stats(stats) | ||
| 159 | |||
| 160 | assert 'L1命中=2' in message | ||
| 161 | assert 'L1跳过' not in message | ||
| 162 | assert '最终跳过=0' in message | ||
| 163 | |||
| 164 | |||
| 143 | class TestDbWriterShutdown: | 165 | class TestDbWriterShutdown: |
| 144 | """测试后台 DB 写入线程的优雅停止""" | 166 | """测试后台 DB 写入线程的优雅停止""" |
| 145 | 167 | ||
| ... | @@ -213,6 +235,72 @@ class TestExistingLyricsCache: | ... | @@ -213,6 +235,72 @@ class TestExistingLyricsCache: |
| 213 | assert records[0].lyrics == '缓存里的歌词\n第二行' | 235 | assert records[0].lyrics == '缓存里的歌词\n第二行' |
| 214 | assert stats == {'cached': 1, 'downloaded': 1, 'failed': 0} | 236 | assert stats == {'cached': 1, 'downloaded': 1, 'failed': 0} |
| 215 | 237 | ||
| 238 | def test_load_existing_l2_candidates_persists_successes_before_later_download_exception(self, tmp_path): | ||
| 239 | cache_path = tmp_path / 'lyrics_cache.jsonl' | ||
| 240 | rows = [ | ||
| 241 | { | ||
| 242 | 'id': 100, | ||
| 243 | 'lyrics_url': 'https://oss.example.test/100.txt', | ||
| 244 | 'name': '第一首', | ||
| 245 | 'singer': '歌手', | ||
| 246 | 'lyricist': '词', | ||
| 247 | 'composer': '曲', | ||
| 248 | }, | ||
| 249 | { | ||
| 250 | 'id': 101, | ||
| 251 | 'lyrics_url': 'https://oss.example.test/101.txt', | ||
| 252 | 'name': '第二首', | ||
| 253 | 'singer': '歌手', | ||
| 254 | 'lyricist': '词', | ||
| 255 | 'composer': '曲', | ||
| 256 | }, | ||
| 257 | ] | ||
| 258 | |||
| 259 | def downloader(url, timeout=10): | ||
| 260 | if url.endswith('/101.txt'): | ||
| 261 | raise RuntimeError('network interrupted') | ||
| 262 | return '已下载歌词'.encode('utf-8'), 'text/plain' | ||
| 263 | |||
| 264 | with pytest.raises(RuntimeError, match='network interrupted'): | ||
| 265 | load_existing_l2_candidates(rows, cache_path, downloader=downloader) | ||
| 266 | |||
| 267 | cache_lines = cache_path.read_text(encoding='utf-8').splitlines() | ||
| 268 | assert len(cache_lines) == 1 | ||
| 269 | cached = json.loads(cache_lines[0]) | ||
| 270 | assert cached['id'] == '100' | ||
| 271 | assert cached['lyrics'] == '已下载歌词' | ||
| 272 | |||
| 273 | def test_sync_inserted_lyrics_cache_adds_committed_inserted_rows(self, tmp_path): | ||
| 274 | cache_path = tmp_path / 'lyrics_cache.jsonl' | ||
| 275 | cache_path.write_text( | ||
| 276 | json.dumps({ | ||
| 277 | 'id': '100', | ||
| 278 | 'url': 'https://oss.example.test/100.txt', | ||
| 279 | 'lyrics': '已有歌词', | ||
| 280 | }, ensure_ascii=False) + '\n', | ||
| 281 | encoding='utf-8', | ||
| 282 | ) | ||
| 283 | tuple_row = [None] * 45 | ||
| 284 | tuple_row[0] = 200 | ||
| 285 | tuple_row[1] = '新歌' | ||
| 286 | tuple_row[2] = '新词' | ||
| 287 | tuple_row[3] = '新曲' | ||
| 288 | tuple_row[8] = 'https://oss.example.test/200.txt' | ||
| 289 | tuple_row[33] = '新歌手' | ||
| 290 | source_row = {'lyrics_txt_content': '新歌词正文'} | ||
| 291 | |||
| 292 | added = sync_inserted_lyrics_cache(cache_path, [(tuple(tuple_row), source_row)]) | ||
| 293 | |||
| 294 | cache_items = [ | ||
| 295 | json.loads(line) | ||
| 296 | for line in cache_path.read_text(encoding='utf-8').splitlines() | ||
| 297 | ] | ||
| 298 | assert added == 1 | ||
| 299 | assert len(cache_items) == 2 | ||
| 300 | assert cache_items[1]['id'] == '200' | ||
| 301 | assert cache_items[1]['url'] == 'https://oss.example.test/200.txt' | ||
| 302 | assert cache_items[1]['lyrics'] == '新歌词正文' | ||
| 303 | |||
| 216 | 304 | ||
| 217 | # ==================================================================== | 305 | # ==================================================================== |
| 218 | # L1 元数据去重 check_l1 | 306 | # L1 元数据去重 check_l1 | ... | ... |
-
Please register or sign in to post a comment