Merge same-song chart rows via a canonical artist credit table

This commit is contained in:
2026-08-04 15:15:49 +08:00
parent e15c988bf4
commit 6a91bc73eb
3 changed files with 73 additions and 7 deletions
+4
View File
@@ -129,3 +129,7 @@
Wayback Machine 發布時快照逐字轉錄(20162018 原沿 Wayback Machine 發布時快照逐字轉錄(20162018 原沿
Wikipedia 改寫慣例);年終原文殘缺或有疑問者,以當年週榜 Wikipedia 改寫慣例);年終原文殘缺或有疑問者,以當年週榜
掛名為準(唯一適用例:2016#87〈All the Way Up〉)。 掛名為準(唯一適用例:2016#87〈All the Way Up〉)。
- **歌名正規化採 canonical artist credit 對照表**:同一首歌
因署名字串寫法不同而分裂者,以明示對照表合併;歌曲身分=
(原始歌名,對照後署名字串),不解析署名——拆解機器不進
歌曲身分判定,對照表即完整可稽核清單。
+41 -7
View File
@@ -23,6 +23,13 @@ The rebuild is deterministic: the builder assigns the song and
artist IDs itself, as 1, 2, 3, ... in the first-occurrence file artist IDs itself, as 1, 2, 3, ... in the first-occurrence file
order, so the IDs are reproducible across rebuilds on every order, so the IDs are reproducible across rebuilds on every
database engine, given the frozen input file. database engine, given the frozen input file.
A song is identified by its raw title together with its artist
credit, the credit canonicalized through
``CANONICAL_ARTIST_CREDITS``; a credit listed there collapses onto
the same song as its canonical form, and the stored artist credit
is always the canonical form. Artist deduplication is by the
exact parsed artist name, as printed on the chart.
""" """
import argparse import argparse
import csv import csv
@@ -63,6 +70,11 @@ FEATURING_PATTERN: re.Pattern[str] = re.compile(
DELIMITER_PATTERN: re.Pattern[str] = re.compile( DELIMITER_PATTERN: re.Pattern[str] = re.compile(
r", | & | \+ |(?i: and | x | with )") r", | & | \+ |(?i: and | x | with )")
"""The pattern splitting the artist names within a side.""" """The pattern splitting the artist names within a side."""
CANONICAL_ARTIST_CREDITS: dict[str, str] = {
"benny blanco, Halsey & Khalid": "Benny Blanco, Halsey & Khalid",
}
"""The canonical artist credit spellings, keyed by a variant
credit string."""
class BuildError(Exception): class BuildError(Exception):
@@ -125,6 +137,22 @@ def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
return pairs return pairs
def song_identity(title: str, credit: str) -> tuple[str, str]:
"""Compute the identity key of a chart row.
The key pairs the raw title with the artist credit,
canonicalized through ``CANONICAL_ARTIST_CREDITS``; a credit
absent from the table maps to itself. Two chart rows denote
the same song iff their identity keys are equal.
:param title: The song title as printed on the chart.
:param credit: The combined artist credit string.
:return: The identity key: the raw title paired with the
canonical artist credit.
"""
return title, CANONICAL_ARTIST_CREDITS.get(credit, credit)
def create_song(session: Session, song_id: int, title: str, def create_song(session: Session, song_id: int, title: str,
credit: str, artists: dict[str, Artist]) -> Song: credit: str, artists: dict[str, Artist]) -> Song:
"""Create a song with its parsed artist credits. """Create a song with its parsed artist credits.
@@ -166,10 +194,12 @@ def create_song(session: Session, song_id: int, title: str,
def load_chart(session: Session, path: Path) -> None: def load_chart(session: Session, path: Path) -> None:
"""Load the chart CSV into songs, chart entries, and credits. """Load the chart CSV into songs, chart entries, and credits.
A song repeated across the rows is stored once, keyed by its A song repeated across the rows is stored once, matched by its
exact title and artist credit; every row yields one chart identity key (see `song_identity`); every row yields one chart
entry. The songs and the artists take the IDs 1, 2, 3, ... entry. The stored title is the raw title; the stored artist
in the first-occurrence row order. credit is the canonical credit from the identity key. The
songs and the artists take the IDs 1, 2, 3, ... in the
first-occurrence row order.
:param session: The database session. :param session: The database session.
:param path: The chart CSV file with the columns year, rank, :param path: The chart CSV file with the columns year, rank,
@@ -182,11 +212,15 @@ def load_chart(session: Session, path: Path) -> None:
with open(path, encoding="utf-8", newline="") as file: with open(path, encoding="utf-8", newline="") as file:
row: dict[str, str] row: dict[str, str]
for row in csv.DictReader(file): for row in csv.DictReader(file):
key: tuple[str, str] = (row["title"], row["artist"]) key: tuple[str, str] = song_identity(
row["title"], row["artist"])
if key not in songs: if key not in songs:
title: str
credit: str
title, credit = key
songs[key] = create_song( songs[key] = create_song(
session, len(songs) + 1, row["title"], session, len(songs) + 1, title, credit,
row["artist"], artists) artists)
session.add(ChartEntry(year=int(row["year"]), session.add(ChartEntry(year=int(row["year"]),
rank=int(row["rank"]), rank=int(row["rank"]),
song=songs[key])) song=songs[key]))
+28
View File
@@ -191,6 +191,34 @@ class TestBuildDB(unittest.TestCase):
self.assertIn("4 artists", stderr) self.assertIn("4 artists", stderr)
self.assertIn("4 credits", stderr) self.assertIn("4 credits", stderr)
def test_dedup_credit_variant(self) -> None:
"""Test that a credit variant listed in
``CANONICAL_ARTIST_CREDITS`` merges into one song, storing
the canonical credit."""
self.__write_chart(
"year,rank,title,artist\n"
"2016,1,Eastside,\"benny blanco, Halsey & Khalid\"\n"
"2016,2,filler,Filler Artist\n"
"2017,1,Eastside,\"Benny Blanco, Halsey & Khalid\"\n"
"2017,2,filler,Filler Artist\n")
status: int
stderr: str
status, stderr = self.__run_build()
self.assertEqual(status, 0)
session: Session = self.__session()
songs: list[Song] = list(session.scalars(
sa.select(Song).where(Song.title == "Eastside")))
self.assertEqual(len(songs), 1)
self.assertEqual(songs[0].artist_credit,
"Benny Blanco, Halsey & Khalid")
self.assertEqual(
sorted((x.year, x.rank)
for x in songs[0].chart_entries),
[(2016, 1), (2017, 1)])
self.assertEqual(
[x.artist.name for x in songs[0].song_artists],
["Benny Blanco", "Halsey", "Khalid"])
def test_first_run_on_fresh_store(self) -> None: def test_first_run_on_fresh_store(self) -> None:
"""Test that a build on a fresh store creates the tables.""" """Test that a build on a fresh store creates the tables."""
self.assertFalse((self.__dir / "store.sqlite3").exists()) self.assertFalse((self.__dir / "store.sqlite3").exists())