diff --git a/docs/decision_log.md b/docs/decision_log.md index 37a3a98..b74ae9c 100644 --- a/docs/decision_log.md +++ b/docs/decision_log.md @@ -189,6 +189,12 @@ Wikidata、再捕捉快照」工作流實跑後,469 位歌手全數 在上游查證補齊,快照即完整,本地覆蓋層已無存在事實; `data/manual/` 層保留(供日後黃金標準編碼)。 +- **`fetch-lyrics` 加原始署名 fallback**:以第一位 primary + 歌手查詢兩 API 皆落空時,改以拆解前的原始署名字串(缺漏 + 報表既有的 `artist_credit`)重查一次;與主查詢同為精確 + 查詢,API 順序不變。理由:二重唱/雙掛名歌曲在歌詞 API + 目錄以合體名義建檔(Dan + Shay、Lil Baby & DaBaby), + 單人名查詢必落空;手動模擬證實 fallback 三首全中。 - **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。 mixed 是先導研究「男/女/混合團體」單一欄位的殘留, 正式設計拆成 gender+type 後從未定義其指涉;署名一律 diff --git a/tools/src/pop_fem_audit_tools/fetch_lyrics.py b/tools/src/pop_fem_audit_tools/fetch_lyrics.py index 7489180..93ea6bf 100644 --- a/tools/src/pop_fem_audit_tools/fetch_lyrics.py +++ b/tools/src/pop_fem_audit_tools/fetch_lyrics.py @@ -11,10 +11,17 @@ given as a positional command-line argument. The working store is only read, never written; the ``build-db`` subcommand assembles the captured files into the store on the next rebuild. -A song that every API misses is reported in the missing lyrics -CSV, also given as a positional command-line argument, which is -rewritten on every run to reflect the current status. Misses -are expected and do not fail the run. +Each song is first queried with the name of its primary-role +artist with the lowest position. When every API misses that +query and the song's full artist credit differs from that +artist name, the same APIs are queried again with the artist +credit, to catch songs cataloged only under a joint credit such +as "Dan + Shay". + +A song that every API misses on both queries is reported in the +missing lyrics CSV, also given as a positional command-line +argument, which is rewritten on every run to reflect the +current status. Misses are expected and do not fail the run. """ import argparse import csv @@ -287,6 +294,9 @@ def main(argv: list[str] | None = None) -> int: artist: str = query_artist(session, song.id) result: tuple[str, str] | None = fetcher.fetch( artist, song.title) + if result is None and song.artist_credit != artist: + result = fetcher.fetch( + song.artist_credit, song.title) if result is None: misses.append(MissingLyrics( song_id=song.id, title=song.title, diff --git a/tools/tests/test_fetch_lyrics.py b/tools/tests/test_fetch_lyrics.py index 37f1992..430cc86 100644 --- a/tools/tests/test_fetch_lyrics.py +++ b/tools/tests/test_fetch_lyrics.py @@ -67,17 +67,32 @@ class TestFetchLyrics(unittest.TestCase): :param songs: The (title, artist) pairs. :return: None. """ + self.__seed_with_credit( + [(title, artist, artist) for title, artist in songs]) + + def __seed_with_credit( + self, songs: list[tuple[str, str, str]]) -> None: + """Create the schema and the fixture songs. + + Each song gets a single primary artist at position 0 and + an independently given artist credit; the song IDs are + assigned in list order starting from 1. + + :param songs: The (title, artist, artist_credit) triples. + :return: None. + """ Base.metadata.create_all(self.__ds.engine) session: Session = self.__ds.get_db() try: artists: dict[str, Artist] = {} title: str artist: str - for title, artist in songs: + credit: str + for title, artist, credit in songs: if artist not in artists: artists[artist] = Artist(name=artist) song: Song = Song(title=title, - artist_credit=artist) + artist_credit=credit) session.add(song) session.add(SongArtist(song=song, artist=artists[artist], @@ -183,6 +198,46 @@ class TestFetchLyrics(unittest.TestCase): self.__provenance) self.assertEqual(rows[1][:2], ["1", "lrclib"]) + def test_credit_fallback_hit(self) -> None: + """Test that a joint credit is queried after both miss.""" + self.__seed_with_credit( + [("Tequila", "Dan", "Dan + Shay")]) + urlopen: mock.Mock + with mock.patch( + "urllib.request.urlopen", + side_effect=[ + self.__not_found(), + self.__not_found(), + self.__not_found(), + self.__response( + {"plainLyrics": "Tequila\n"})]) as urlopen: + status: int = self.__run_fetch()[0] + self.assertEqual(status, 0) + self.assertEqual(urlopen.call_count, 4) + urls: list[str] = [x[0][0].full_url + for x in urlopen.call_args_list] + self.assertIn("Dan%20%2B%20Shay", urls[2]) + self.assertIn("artist_name=Dan+%2B+Shay", urls[3]) + self.assertEqual( + (self.__lyrics / "1.txt") + .read_text(encoding="utf-8"), + "Tequila\n") + rows: list[list[str]] = self.__read_rows( + self.__provenance) + self.assertEqual(rows[1][:2], ["1", "lrclib"]) + + def test_credit_fallback_skipped_when_same(self) -> None: + """Test that a matching credit skips the second round.""" + self.__seed([("Hello", "Adele")]) + urlopen: mock.Mock + with mock.patch( + "urllib.request.urlopen", + side_effect=[self.__not_found(), + self.__not_found()]) as urlopen: + status: int = self.__run_fetch()[0] + self.assertEqual(status, 0) + self.assertEqual(urlopen.call_count, 2) + def test_both_miss(self) -> None: """Test that a double miss reports the song as missing.""" self.__seed([("Hello", "Adele")])