Refactor internal data passing to dataclasses and enums, and remove unneeded defensive code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:13:31 +08:00
co-authored by Claude Fable 5
parent 8d223177cd
commit e0eb343ce0
11 changed files with 540 additions and 333 deletions
+42 -18
View File
@@ -27,6 +27,7 @@ import time
import urllib.parse
import urllib.request
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -36,6 +37,7 @@ from sqlalchemy.orm import Session
from .database import ds
from .models import (
Artist,
Role,
Song,
SongArtist,
)
@@ -61,6 +63,28 @@ SLEEP_SECONDS: float = 1.0
"""The delay between consecutive HTTP requests, in seconds."""
@dataclass
class MissingLyrics:
"""One row of the missing lyrics report CSV file."""
song_id: int
"""The song ID."""
title: str
"""The song title."""
artist_credit: str
"""The artist credit of the song."""
reason: str
"""The reason the lyrics are missing."""
def to_row(self) -> list[str]:
"""Return this report entry as a CSV row.
:return: The row values, in the column order.
"""
return [str(self.song_id), self.title,
self.artist_credit, self.reason]
def parse_args(argv: list[str] | None) -> argparse.Namespace:
"""Parse the command-line arguments.
@@ -160,21 +184,23 @@ class LyricsFetcher:
return None
def query_artist(session: Session, song_id: int) -> str | None:
def query_artist(session: Session, song_id: int) -> str:
"""Find the artist name to query the APIs with.
:param session: The database session.
:param song_id: The song ID.
:return: The name of the primary-role artist with the lowest
position, or None when the song has no primary artist.
position.
"""
return session.scalar(
name: str | None = session.scalar(
sa.select(Artist.name)
.join(SongArtist, SongArtist.artist_id == Artist.id)
.where(SongArtist.song_id == song_id,
SongArtist.role == "primary")
SongArtist.role == Role.PRIMARY)
.order_by(SongArtist.position)
.limit(1))
assert name is not None
return name
def save_lyrics(song_id: int, lyrics: str) -> None:
@@ -213,15 +239,14 @@ def append_provenance(song_id: int, source: str) -> None:
datetime.date.today().isoformat(), ""])
def write_missing(misses: Sequence[Sequence[Any]]) -> None:
def write_missing(misses: Sequence[MissingLyrics]) -> None:
"""Rewrite the missing lyrics report CSV file.
The previous content is replaced, so the file reflects the
current misses only.
:param misses: The rows of the songs still without lyrics,
each with the song ID, the title, the artist credit, and
the reason.
:param misses: The report entries of the songs still without
lyrics.
:return: None.
:raises OSError: When the file cannot be written.
"""
@@ -230,7 +255,7 @@ def write_missing(misses: Sequence[Sequence[Any]]) -> None:
newline="") as file:
writer: Any = csv.writer(file)
writer.writerow(MISSING_FIELDS)
writer.writerows(misses)
writer.writerows(x.to_row() for x in misses)
def main(argv: list[str] | None = None) -> int:
@@ -244,7 +269,7 @@ def main(argv: list[str] | None = None) -> int:
parse_args(argv)
fetcher: LyricsFetcher = LyricsFetcher()
fetched: int = 0
misses: list[tuple[int, str, str, str]] = []
misses: list[MissingLyrics] = []
session: Session = ds.get_db()
try:
song: Song
@@ -252,15 +277,14 @@ def main(argv: list[str] | None = None) -> int:
sa.select(Song).order_by(Song.id)):
if (LYRICS_DIR / f"{song.id}.txt").exists():
continue
artist: str | None = query_artist(session, song.id)
result: tuple[str, str] | None = None
reason: str = "no primary artist"
if artist is not None:
result = fetcher.fetch(artist, song.title)
reason = "not found"
artist: str = query_artist(session, song.id)
result: tuple[str, str] | None = fetcher.fetch(
artist, song.title)
if result is None:
misses.append((song.id, song.title,
song.artist_credit, reason))
misses.append(MissingLyrics(
song_id=song.id, title=song.title,
artist_credit=song.artist_credit,
reason="not found"))
print(f"song {song.id} \"{song.title}\": miss",
file=sys.stderr)
continue