Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a2166c93 | ||
|
|
d0f65a7ddf | ||
|
|
66be33a393 | ||
|
|
2cbd6acf7a | ||
|
|
dc682ff5b9 | ||
|
|
9dc10db549 | ||
|
|
979c2f8761 | ||
|
|
8816b74b2d | ||
|
|
09702b4015 | ||
|
|
877304161a | ||
|
|
70dfbf9f7c | ||
|
|
46b24e10a7 | ||
|
|
bf3c042254 | ||
|
|
2e7c11f978 | ||
|
|
635648683b | ||
|
|
0aa93c0778 | ||
|
|
b9ce1eb08a | ||
|
|
9eed6917ab | ||
|
|
9a089f6c6b |
@@ -5,28 +5,17 @@
|
|||||||
- LLM analysis runs via Python scripts calling the Anthropic
|
- LLM analysis runs via Python scripts calling the Anthropic
|
||||||
Messages API: model `claude-sonnet-4-6`, `temperature=0`,
|
Messages API: model `claude-sonnet-4-6`, `temperature=0`,
|
||||||
thinking disabled, Batch API where possible.
|
thinking disabled, Batch API where possible.
|
||||||
- Prompt definition files live in
|
- Prompt definition files live in `prompts/<task>_v<N>.md` and
|
||||||
`prompts/<track>-<step>-<substep>-<task>.md` (e.g.
|
are passed verbatim as the system prompt.
|
||||||
01-01-01-tag.md; the substep number fixes the execution
|
- Every LLM step runs the same definition file twice, then a
|
||||||
order; no version suffix -- versions live in git history)
|
separate arbitration step reconciles the two outputs
|
||||||
and are passed verbatim as the system prompt.
|
("2 runs + 1 arbitration"). If arbitration output is
|
||||||
- LLM steps whose outputs are item-by-item comparable
|
unexpected, revise the definition file and repeat the whole
|
||||||
(convergence, coding) run the same definition file twice,
|
cycle; never patch results by hand.
|
||||||
then a separate arbitration step settles only the
|
- Every execution is archived self-contained under
|
||||||
script-computed disagreements ("2 runs + 1 arbitration").
|
`runs/<phase>/<date>-<prompt-version>/`: prompt snapshot,
|
||||||
Free-generation steps run twice and both outputs are pooled,
|
raw outputs of both runs, arbitration output, and `meta.json`
|
||||||
unarbitrated. If arbitration output is unexpected, revise
|
(model ID, parameters, timestamps, batch IDs).
|
||||||
the definition file and repeat that cycle; never patch
|
|
||||||
results by hand.
|
|
||||||
- Each run of a step is archived self-contained under the
|
|
||||||
destination directory given explicitly on the `run-llm`
|
|
||||||
command line (by convention `runs/<definition-file>/run<N>/`):
|
|
||||||
prompt snapshot, raw output, and `meta.json` (model ID,
|
|
||||||
parameters, timestamps, batch ID). The two runs of a step
|
|
||||||
are two separate invocations of `run-llm`. An arbitration
|
|
||||||
pass is a step of its own with its own archive. Replacing
|
|
||||||
an existing run archive requires an explicit flag;
|
|
||||||
superseded runs live in git history.
|
|
||||||
- Scripts read the API key from the `ANTHROPIC_API_KEY`
|
- Scripts read the API key from the `ANTHROPIC_API_KEY`
|
||||||
environment variable (`.env`, gitignored).
|
environment variable (`.env`, gitignored).
|
||||||
|
|
||||||
@@ -47,4 +36,4 @@
|
|||||||
cites); `runs/` holds raw audit records. The paper cites
|
cites); `runs/` holds raw audit records. The paper cites
|
||||||
`results/` only.
|
`results/` only.
|
||||||
- Any change to a definition file, the codebook, or the plan is
|
- Any change to a definition file, the codebook, or the plan is
|
||||||
recorded in `docs/decision-log.md` with date and reason.
|
recorded in `docs/decision_log.md` with date and reason.
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ Hot 100(2016–2025)為例的內容分析。
|
|||||||
|
|
||||||
## 目錄結構
|
## 目錄結構
|
||||||
|
|
||||||
見 `docs/project-structure.md`。研究步驟規劃見
|
見 `docs/project_structure.md`。研究步驟規劃見
|
||||||
`docs/research-plan.md`;方法細節見 `docs/methodology.md`;
|
`docs/research_plan.md`;方法細節見 `docs/methodology.md`;
|
||||||
人工編碼手冊見 `docs/codebook.md`;決策日誌見
|
人工編碼手冊見 `docs/codebook.md`;決策日誌見
|
||||||
`docs/decision-log.md`。
|
`docs/decision_log.md`。
|
||||||
|
|
||||||
## 重現方式
|
## 重現方式
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ Hot 100(2016–2025)為例的內容分析。
|
|||||||
`pip install -e tools/`。
|
`pip install -e tools/`。
|
||||||
2. 自 `tools/.env.example` 建立 `tools/.env`,寫入
|
2. 自 `tools/.env.example` 建立 `tools/.env`,寫入
|
||||||
Anthropic API 金鑰。
|
Anthropic API 金鑰。
|
||||||
3. 依 `docs/research-plan.md` 的階段順序,於 `tools/` 目錄下
|
3. 依 `docs/research_plan.md` 的階段順序,於 `tools/` 目錄下
|
||||||
執行子命令,必要輸入以位置引數、選擇性輸入以選項給定(如
|
執行子命令,必要輸入以位置引數、選擇性輸入以選項給定(如
|
||||||
`pop-fem-audit-tools build-db
|
`pop-fem-audit-tools build-db
|
||||||
../data/source/yearend_hot100_2016_2025.csv
|
../data/source/yearend_hot100_2016_2025.csv
|
||||||
@@ -26,9 +26,8 @@ Hot 100(2016–2025)為例的內容分析。
|
|||||||
--lyrics-dir ../data/captures/lyrics
|
--lyrics-dir ../data/captures/lyrics
|
||||||
--wikidata-csv ../data/captures/artists-wikidata.csv`)。
|
--wikidata-csv ../data/captures/artists-wikidata.csv`)。
|
||||||
LLM 步驟使用 `claude-sonnet-4-6`、temperature=0、
|
LLM 步驟使用 `claude-sonnet-4-6`、temperature=0、
|
||||||
thinking 關閉;輸出可逐項比對的步驟獨立執行兩次,由
|
thinking 關閉;每步驟獨立執行兩次後由仲裁步驟合併
|
||||||
比對程式算出分歧、仲裁步驟裁決(「2+1」協定),自由
|
(「2+1」協定)。
|
||||||
生成步驟兩次執行進池不仲裁。
|
|
||||||
4. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於
|
4. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於
|
||||||
`runs/`,可逐筆稽核。論文引用的最終資料表在 `results/`。
|
`runs/`,可逐筆稽核。論文引用的最終資料表在 `results/`。
|
||||||
|
|
||||||
|
|||||||
@@ -1,884 +0,0 @@
|
|||||||
song_id,source,method,acquired_at,note
|
|
||||||
192,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
193,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
194,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
187,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
151,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
195,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
196,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
197,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
198,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
199,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
200,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
201,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
202,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
203,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
204,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
205,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
206,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
207,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
208,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
209,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
146,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
212,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
214,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
215,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
224,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
228,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
245,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
247,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
143,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
251,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
252,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
263,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
267,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
272,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
275,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
277,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
145,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
278,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
279,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
280,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
281,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
261,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
283,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
285,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
286,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
287,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
288,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
289,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
290,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
291,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
293,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
296,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
300,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
302,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
303,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
223,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
307,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
310,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
314,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
315,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
317,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
320,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
323,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
328,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
330,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
331,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
333,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
335,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
337,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
341,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
342,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
344,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
347,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
348,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
353,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
354,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
356,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
220,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
360,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
361,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
362,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
364,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
367,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
332,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
368,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
369,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
370,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
371,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
372,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
373,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
374,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
375,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
376,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
377,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
378,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
379,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
380,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
381,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
382,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
383,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
384,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
385,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
386,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
387,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
388,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
389,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
390,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
391,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
392,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
393,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
394,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
395,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
396,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
397,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
398,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
399,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
295,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
400,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
401,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
402,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
403,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
404,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
405,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
406,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
407,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
408,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
409,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
410,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
411,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
412,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
413,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
339,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
414,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
415,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
416,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
417,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
418,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
419,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
420,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
421,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
422,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
423,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
424,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
425,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
426,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
427,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
428,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
429,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
311,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
430,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
431,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
432,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
433,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
434,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
435,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
436,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
437,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
438,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
439,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
440,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
441,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
442,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
443,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
444,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
445,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
446,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
447,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
448,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
449,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
450,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
451,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
452,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
453,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
454,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
455,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
456,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
457,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
458,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
459,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
460,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
461,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
462,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
463,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
464,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
465,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
466,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
467,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
468,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
469,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
470,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
471,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
472,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
473,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
474,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
475,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
476,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
477,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
478,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
479,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
480,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
481,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
482,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
483,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
484,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
485,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
486,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
487,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
488,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
489,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
490,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
491,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
492,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
493,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
494,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
495,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
496,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
497,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
498,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
499,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
500,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
501,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
502,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
503,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
504,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
505,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
506,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
507,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
508,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
509,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
510,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
511,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
512,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
513,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
514,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
515,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
516,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
517,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
518,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
519,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
520,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
521,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
522,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
523,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
524,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
525,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
526,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
527,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
528,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
529,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
530,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
531,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
532,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
533,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
534,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
535,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
536,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
537,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
538,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
539,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
540,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
541,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
542,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
543,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
544,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
545,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
546,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
547,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
548,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
549,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
550,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
551,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
552,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
553,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
554,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
555,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
556,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
557,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
558,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
559,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
560,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
561,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
562,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
563,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
564,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
565,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
566,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
567,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
568,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
569,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
570,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
571,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
572,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
573,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
574,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
575,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
576,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
577,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
578,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
579,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
580,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
581,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
582,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
583,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
584,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
585,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
586,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
587,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
588,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
589,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
590,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
591,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
592,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
593,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
594,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
595,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
596,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
597,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
598,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
599,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
600,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
601,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
602,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
603,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
604,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
605,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
606,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
607,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
608,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
609,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
610,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
611,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
612,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
613,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
614,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
615,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
616,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
617,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
618,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
619,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
620,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
621,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
622,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
623,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
624,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
625,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
626,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
627,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
628,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
629,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
630,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
631,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
632,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
633,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
634,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
635,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
636,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
637,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
638,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
639,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
640,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
641,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
642,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
643,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
644,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
645,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
646,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
647,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
648,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
649,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
650,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
651,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
652,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
653,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
654,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
655,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
656,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
657,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
658,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
659,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
660,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
661,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
662,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
663,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
664,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
665,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
666,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
667,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
668,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
669,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
670,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
671,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
672,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
673,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
674,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
675,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
676,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
677,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
678,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
679,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
680,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
681,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
682,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
683,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
684,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
685,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
686,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
687,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
688,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
689,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
690,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
691,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
692,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
693,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
694,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
695,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
696,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
697,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
698,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
699,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
700,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
701,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
702,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
703,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
704,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
705,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
706,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
707,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
708,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
709,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
710,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
711,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
712,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
713,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
714,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
715,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
716,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
717,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
718,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
719,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
720,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
721,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
722,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
723,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
724,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
725,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
726,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
727,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
728,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
729,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
730,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
731,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
732,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
733,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
734,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
735,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
736,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
737,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
738,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
739,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
740,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
741,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
742,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
743,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
744,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
745,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
746,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
747,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
748,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
749,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
750,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
751,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
752,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
753,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
754,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
755,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
756,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
757,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
758,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
759,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
760,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
761,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
762,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
763,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
764,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
765,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
766,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
767,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
768,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
769,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
770,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
771,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
772,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
773,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
774,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
775,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
776,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
777,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
778,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
779,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
780,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
781,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
782,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
783,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
784,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
785,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
786,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
787,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
788,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
789,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
790,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
791,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
792,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
793,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
794,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
795,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
796,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
797,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
798,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
799,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
800,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
801,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
802,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
803,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
804,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
805,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
806,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
807,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
808,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
809,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
810,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
811,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
812,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
813,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
814,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
815,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
816,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
817,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
818,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
819,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
820,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
821,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
822,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
823,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
824,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
825,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
826,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
827,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
828,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
829,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
830,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
831,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
832,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
833,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
834,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
835,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
836,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
837,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
838,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
839,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
840,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
841,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
842,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
843,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
844,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
845,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
846,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
847,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
848,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
849,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
850,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
851,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
852,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
853,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
854,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
855,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
856,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
857,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
858,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
859,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
860,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
861,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
862,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
863,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
864,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
865,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
866,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
867,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
868,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
869,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
870,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
871,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
872,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
873,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
874,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
875,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
876,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
877,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
878,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
879,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
880,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
881,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
882,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
883,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
|
||||||
1,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
2,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
3,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
4,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
5,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
6,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
7,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
8,lrclib,api-fetch,2026-08-04,
|
|
||||||
9,lrclib,api-fetch,2026-08-04,
|
|
||||||
10,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
11,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
12,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
13,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
14,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
15,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
16,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
17,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
18,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
19,lrclib,api-fetch,2026-08-04,
|
|
||||||
20,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
21,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
22,lrclib,api-fetch,2026-08-04,
|
|
||||||
23,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
24,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
25,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
26,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
27,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
28,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
29,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
30,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
31,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
32,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
33,lrclib,api-fetch,2026-08-04,
|
|
||||||
34,lrclib,api-fetch,2026-08-04,
|
|
||||||
35,lrclib,api-fetch,2026-08-04,
|
|
||||||
36,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
37,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
38,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
39,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
40,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
41,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
42,lrclib,api-fetch,2026-08-04,
|
|
||||||
43,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
44,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
45,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
46,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
47,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
48,lrclib,api-fetch,2026-08-04,
|
|
||||||
49,lrclib,api-fetch,2026-08-04,
|
|
||||||
50,lrclib,api-fetch,2026-08-04,
|
|
||||||
51,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
52,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
53,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
54,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
55,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
56,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
57,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
58,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
59,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
60,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
61,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
62,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
63,lrclib,api-fetch,2026-08-04,
|
|
||||||
64,lrclib,api-fetch,2026-08-04,
|
|
||||||
65,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
66,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
67,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
68,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
69,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
70,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
71,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
72,lrclib,api-fetch,2026-08-04,
|
|
||||||
73,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
74,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
75,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
76,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
77,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
78,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
79,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
80,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
81,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
82,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
83,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
84,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
85,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
86,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
87,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
88,lrclib,api-fetch,2026-08-04,
|
|
||||||
89,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
90,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
91,lrclib,api-fetch,2026-08-04,
|
|
||||||
92,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
93,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
94,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
95,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
96,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
97,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
98,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
99,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
100,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
101,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
102,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
103,lrclib,api-fetch,2026-08-04,
|
|
||||||
104,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
105,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
106,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
107,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
108,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
109,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
110,lrclib,api-fetch,2026-08-04,
|
|
||||||
111,lrclib,api-fetch,2026-08-04,
|
|
||||||
112,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
113,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
114,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
115,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
116,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
117,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
118,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
119,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
120,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
121,lrclib,api-fetch,2026-08-04,
|
|
||||||
122,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
123,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
124,lrclib,api-fetch,2026-08-04,
|
|
||||||
125,lrclib,api-fetch,2026-08-04,
|
|
||||||
126,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
127,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
128,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
129,lrclib,api-fetch,2026-08-04,
|
|
||||||
130,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
131,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
132,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
133,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
134,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
135,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
136,lrclib,api-fetch,2026-08-04,
|
|
||||||
137,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
138,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
139,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
140,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
141,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
142,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
144,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
147,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
148,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
149,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
150,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
152,lrclib,api-fetch,2026-08-04,
|
|
||||||
153,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
154,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
155,lrclib,api-fetch,2026-08-04,
|
|
||||||
156,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
157,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
158,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
159,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
160,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
161,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
162,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
163,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
164,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
165,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
166,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
167,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
168,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
169,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
170,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
171,lrclib,api-fetch,2026-08-04,
|
|
||||||
172,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
173,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
174,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
175,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
176,lrclib,api-fetch,2026-08-04,
|
|
||||||
177,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
178,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
179,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
180,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
181,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
182,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
183,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
184,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
185,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
186,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
188,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
189,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
190,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
191,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
210,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
211,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
213,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
216,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
217,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
218,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
219,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
221,lrclib,api-fetch,2026-08-04,
|
|
||||||
222,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
225,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
226,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
227,lrclib,api-fetch,2026-08-04,
|
|
||||||
229,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
230,lrclib,api-fetch,2026-08-04,
|
|
||||||
231,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
232,lrclib,api-fetch,2026-08-04,
|
|
||||||
233,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
234,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
235,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
236,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
237,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
238,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
239,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
240,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
241,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
242,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
243,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
244,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
246,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
248,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
249,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
250,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
253,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
254,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
255,lrclib,api-fetch,2026-08-04,
|
|
||||||
256,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
257,lrclib,api-fetch,2026-08-04,
|
|
||||||
258,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
259,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
260,lrclib,api-fetch,2026-08-04,
|
|
||||||
262,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
264,lrclib,api-fetch,2026-08-04,
|
|
||||||
265,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
266,lrclib,api-fetch,2026-08-04,
|
|
||||||
268,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
269,lrclib,api-fetch,2026-08-04,
|
|
||||||
270,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
271,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
273,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
274,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
276,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
282,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
284,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
292,lrclib,api-fetch,2026-08-04,
|
|
||||||
294,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
297,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
298,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
299,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
301,lrclib,api-fetch,2026-08-04,
|
|
||||||
304,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
305,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
308,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
309,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
312,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
313,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
316,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
318,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
319,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
321,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
322,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
324,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
325,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
326,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
327,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
329,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
334,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
336,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
338,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
340,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
343,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
345,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
346,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
349,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
350,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
351,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
355,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
357,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
358,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
359,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
365,lrclib,api-fetch,2026-08-04,
|
|
||||||
366,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
306,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
352,lrclib,api-fetch,2026-08-04,
|
|
||||||
363,lyrics.ovh,api-fetch,2026-08-04,
|
|
||||||
|
+469
-469
@@ -1,470 +1,470 @@
|
|||||||
Name,Wikidata QID,Gender,Type,Genre,Country,Songs
|
Name,Wikidata QID,Gender,Type,Genre,Country,Songs
|
||||||
070 Shake,Q54861040,female,solo,hip-hop; cloud rap; alternative hip-hop,United States,Escapism (2023#48)
|
070 Shake,,,,,,Escapism (2023#48)
|
||||||
2 Chainz,Q129668,male,solo,Southern hip-hop; trap music; gangsta rap; dirty south; Atlanta hip-hop; hardcore hip-hop,United States,Big Bank (2018#63)
|
2 Chainz,,,,,,Big Bank (2018#63)
|
||||||
21 Savage,Q25095399,male,solo,hip-hop; trap music,United Kingdom,A Lot (2019#42)|Bank Account (2017#48)|Bartier Cardi (2018#61)|Creepin' (2023#5)|Good Good (2024#79)|Jimmy Cooks (2022#33)|Knife Talk (2021#86/2022#60)|Peaches & Eggplants (2023#94)|Redrum (2024#42)|Rich Flex (2023#14)|Rockstar (2017#56/2018#5)|Spin Bout U (2023#43)
|
21 Savage,,,,,,A Lot (2019#42)|Bank Account (2017#48)|Bartier Cardi (2018#61)|Creepin' (2023#5)|Good Good (2024#79)|Jimmy Cooks (2022#33)|Knife Talk (2021#86/2022#60)|Peaches & Eggplants (2023#94)|Redrum (2024#42)|Rich Flex (2023#14)|Rockstar (2017#56/2018#5)|Spin Bout U (2023#43)
|
||||||
24kGoldn,Q83751918,male,solo,pop rap,United States,Mood (2020#47/2021#4)
|
24kGoldn,,,,,,Mood (2020#47/2021#4)
|
||||||
42 Dugg,Q95694810,male,solo,Michigan rap; Midwest hip-hop; trap music; gangsta rap,United States,We Paid (2020#57)
|
42 Dugg,,,,,,We Paid (2020#57)
|
||||||
4batz,Q124313062,male,solo,contemporary R&B,United States,Act II: Date @ 8 (2024#80)
|
4batz,,,,,,Act II: Date @ 8 (2024#80)
|
||||||
5 Seconds of Summer,Q4641147,,group,pop rock; pop-punk,Australia,Youngblood (2018#36/2019#33)
|
5 Seconds of Summer,,,,,,Youngblood (2018#36/2019#33)
|
||||||
50 Cent,Q6060,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop,United States,The Woo (2020#78)
|
50 Cent,,,,,,The Woo (2020#78)
|
||||||
6ix9ine,Q44479972,male,solo,hip-hop; East Coast hip-hop; gangsta rap; trap metal; hardcore hip-hop; SoundCloud rap; punk rap; drill; trap music; urbano music; reggaeton; Brooklyn drill,United States,FEFE (2018#31)|Gummo (2018#56)|Swervin (2019#82)
|
6ix9ine,,,,,,FEFE (2018#31)|Gummo (2018#56)|Swervin (2019#82)
|
||||||
6LACK,Q27886640,male,solo,alternative R&B; contemporary R&B; trap music; cloud rap; industrial music; soul; hip-hop soul,United States,Calling My Phone (2021#33)
|
6LACK,,,,,,Calling My Phone (2021#33)
|
||||||
A Boogie wit da Hoodie,Q27927826,male,solo,hip-hop; trap music,United States,Drowning (2017#81)|Look Back At It (2019#41)|Swervin (2019#82)
|
A Boogie wit da Hoodie,,,,,,Drowning (2017#81)|Look Back At It (2019#41)|Swervin (2019#82)
|
||||||
A$AP Ferg,Q15648254,male,solo,East Coast hip-hop; trap music,United States,Plain Jane (2018#74)
|
A$AP Ferg,,,,,,Plain Jane (2018#74)
|
||||||
A$AP Rocky,Q129910,male,solo,East Coast hip-hop; psychedelic rap; experimental hip-hop; alternative hip-hop; cloud rap; trap music,United States,No Limit (2018#30)
|
A$AP Rocky,,,,,,No Limit (2018#30)
|
||||||
Adassa,Q576181,female,solo,reggaeton; urban contemporary; pop music,United States,We Don't Talk About Bruno (2022#24)
|
Adassa,,,,,,We Don't Talk About Bruno (2022#24)
|
||||||
Adele,Q23215,female,solo,pop music; blue-eyed soul; rhythm and blues; soul; jazz; rock music,United Kingdom,Easy On Me (2022#4)|Hello (2016#7)|Oh My God (2022#72)|Send My Love (To Your New Lover) (2016#26)|Water Under The Bridge (2017#88)|When We Were Young (2016#83)
|
Adele,,,,,,Easy On Me (2022#4)|Hello (2016#7)|Oh My God (2022#72)|Send My Love (To Your New Lover) (2016#26)|Water Under The Bridge (2017#88)|When We Were Young (2016#83)
|
||||||
AJR,Q16154058,,group,indie pop; alternative pop; electropop,United States,Bang! (2021#56)
|
AJR,,,,,,Bang! (2021#56)
|
||||||
Alessia Cara,Q20090091,female,solo,contemporary R&B; indie pop; pop music; soul; alternative R&B,Italy,1-800-273-8255 (2017#31/2018#84)|Here (2016#39)|Scars To Your Beautiful (2017#30)|Stay (2017#17)
|
Alessia Cara,,,,,,1-800-273-8255 (2017#31/2018#84)|Here (2016#39)|Scars To Your Beautiful (2017#30)|Stay (2017#17)
|
||||||
Alex Warren,Q121842162,male,solo,pop music,United States,Ordinary (2025#7)
|
Alex Warren,,,,,,Ordinary (2025#7)
|
||||||
Aminé,Q27830860,male,solo,West Coast hip-hop; alternative hip-hop; trap music; contemporary R&B,United States,Caroline (2017#60)
|
Aminé,,,,,,Caroline (2017#60)
|
||||||
Anderson .Paak,Q20810369,male,solo,contemporary R&B; hip-hop; soul; funk; pop music,United States,Leave The Door Open (2021#7)|Smokin Out The Window (2022#41)
|
Anderson .Paak,,,,,,Leave The Door Open (2021#7)|Smokin Out The Window (2022#41)
|
||||||
Andrew Choi,Q15229870,male,solo,K-pop; pop music; contemporary R&B,South Korea,Free (2025#92)|Soda Pop (2025#61)|Your Idol (2025#54)
|
Andrew Choi,,,,,,Free (2025#92)|Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
Anne-Marie,Q25396976,female,solo,pop music; contemporary R&B,United Kingdom,Friends (2018#26)|Rockabye (2017#44)
|
Anne-Marie,,,,,,Friends (2018#26)|Rockabye (2017#44)
|
||||||
Aqua,Q213388,,group,pop music; bubblegum music; Eurodance,Denmark,Barbie World (2023#46)
|
Aqua,,,,,,Barbie World (2023#46)
|
||||||
Ariana Grande,Q151892,female,solo,pop music; contemporary R&B; electronic dance music,United States,"34+35 (2021#21)|7 Rings (2019#7)|Boyfriend (2019#98)|Break Up With Your Girlfriend, I'm Bored (2019#36)|Breathin (2019#72)|Dangerous Woman (2016#36)|Die For You (2023#7)|God Is A Woman (2018#62)|Into You (2016#51)|No Tears Left To Cry (2018#20)|Positions (2021#14)|pov (2021#87)|Rain On Me (2020#48)|Save Your Tears (2021#2/2022#40)|Side To Side (2016#77/2017#43)|Stuck With U (2020#80)|Thank U, Next (2019#12)|We Can't Be Friends (Wait For Your Love) (2024#26)|Yes, And? (2024#92)"
|
Ariana Grande,,,,,,"34+35 (2021#21)|7 Rings (2019#7)|Boyfriend (2019#98)|Break Up With Your Girlfriend, I'm Bored (2019#36)|Breathin (2019#72)|Dangerous Woman (2016#36)|Die For You (2023#7)|God Is A Woman (2018#62)|Into You (2016#51)|No Tears Left To Cry (2018#20)|Positions (2021#14)|pov (2021#87)|Rain On Me (2020#48)|Save Your Tears (2021#2/2022#40)|Side To Side (2016#77/2017#43)|Stuck With U (2020#80)|Thank U, Next (2019#12)|We Can't Be Friends (Wait For Your Love) (2024#26)|Yes, And? (2024#92)"
|
||||||
Arizona Zervas,Q75124792,male,solo,hip-hop; contemporary R&B; pop music,United States,Roxanne (2020#16)
|
Arizona Zervas,,,,,,Roxanne (2020#16)
|
||||||
Artemas,Q125176874,male,solo,alternative pop,United Kingdom,I Like The Way You Kiss Me (2024#63)
|
Artemas,,,,,,I Like The Way You Kiss Me (2024#63)
|
||||||
ATR Son Son,Q140814072,male,solo,hip-hop,United States,Rags2Riches (2020#90)
|
ATR Son Son,,,,,,Rags2Riches (2020#90)
|
||||||
Audrey Nuna,Q105538243,female,solo,K-pop; pop music; hip-hop; contemporary R&B; trap music,United States,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
Audrey Nuna,,,,,,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||||
Ava Max,Q56755505,female,solo,dance-pop,United States,Kings & Queens (2021#73)|Sweet But Psycho (2019#23)
|
Ava Max,,,,,,Kings & Queens (2021#73)|Sweet But Psycho (2019#23)
|
||||||
Avery Anna,Q131401753,female,solo,pop rock; country music,United States,Indigo (2025#49)
|
Avery Anna,,,,,,Indigo (2025#49)
|
||||||
Ayo,Q55860029,male,solo,hip-hop,United States,Rolex (2017#61)
|
Ayo,,,,,,Rolex (2017#61)
|
||||||
AzChike,Q131417489,male,solo,hip-hop,United States,Peekaboo (2025#53)
|
AzChike,,,,,,Peekaboo (2025#53)
|
||||||
Bad Bunny,Q44333953,male,solo,Latin trap; reggaeton,Puerto Rico,Baile Inolvidable (2025#70)|Dakiti (2021#28)|Despues de La Playa (2022#78)|DTMF (2025#51)|Efecto (2022#69)|EOO (2025#77)|I Like It (2018#7/2019#69)|Me Porto Bonito (2022#20)|MIA (2019#44)|Monaco (2024#82)|Moscow Mule (2022#44)|Nuevayol (2025#89)|Ojitos Lindos (2022#87)|Party (2022#77)|Te Bote (2018#81)|Titi Me Pregunto (2022#22)|Un x100to (2023#49)|Where She Goes (2023#72)|Yonaguni (2021#83)
|
Bad Bunny,,,,,,Baile Inolvidable (2025#70)|Dakiti (2021#28)|Despues de La Playa (2022#78)|DTMF (2025#51)|Efecto (2022#69)|EOO (2025#77)|I Like It (2018#7/2019#69)|Me Porto Bonito (2022#20)|MIA (2019#44)|Monaco (2024#82)|Moscow Mule (2022#44)|Nuevayol (2025#89)|Ojitos Lindos (2022#87)|Party (2022#77)|Te Bote (2018#81)|Titi Me Pregunto (2022#22)|Un x100to (2023#49)|Where She Goes (2023#72)|Yonaguni (2021#83)
|
||||||
Bailey Zimmerman,Q112581616,male,solo,country music,United States,All The Way (2025#33)|Backup Plan (2025#80)|Fall In Love (2022#54)|Religiously (2023#42)|Rock And A Hard Place (2022#70/2023#16)|Where It Ends (2024#87)
|
Bailey Zimmerman,,,,,,All The Way (2025#33)|Backup Plan (2025#80)|Fall In Love (2022#54)|Religiously (2023#42)|Rock And A Hard Place (2022#70/2023#16)|Where It Ends (2024#87)
|
||||||
Bastille,Q689,,group,synth-pop,United Kingdom,Happier (2018#80/2019#6)
|
Bastille,,,,,,Happier (2018#80/2019#6)
|
||||||
Bazzi,Q48720231,male,solo,pop music,United States,Beautiful (2019#95)|Mine (2018#21)
|
Bazzi,,,,,,Beautiful (2019#95)|Mine (2018#21)
|
||||||
beabadoobee,Q66817366,female,solo,indie rock; space rock; alternative rock; slacker rock; indie pop; folk-pop; bedroom pop,Philippines,Death Bed (2020#43)
|
beabadoobee,,,,,,Death Bed (2020#43)
|
||||||
Bebe Rexha,Q16185856,female,solo,pop music; contemporary R&B,United States,"I'm Good (Blue) (2023#10)|Me, Myself & I (2016#19)|Meant To Be (2018#3)"
|
Bebe Rexha,,,,,,"I'm Good (Blue) (2023#10)|Me, Myself & I (2016#19)|Meant To Be (2018#3)"
|
||||||
Becky G,Q1393997,female,solo,pop music; hip-hop; Latin music; Latin pop; reggaeton,United States,MAMIII (2022#59)
|
Becky G,,,,,,MAMIII (2022#59)
|
||||||
BENEE,Q61749159,female,solo,alternative pop; soul,New Zealand,Supalonely (2020#84)
|
BENEE,,,,,,Supalonely (2020#84)
|
||||||
benny blanco,Q818048,male,solo,pop music,United States,Eastside (2018#77/2019#17)|Lonely (2021#65)
|
benny blanco,,,,,,Eastside (2018#77/2019#17)|Lonely (2021#65)
|
||||||
Benson Boone,Q110008128,male,solo,pop music; pop rock,United States,Beautiful Things (2024#3/2025#6)|Mystical Magical (2025#47)|Slow It Down (2024#41)|Sorry I'm Here For Someone Else (2025#37)
|
Benson Boone,,,,,,Beautiful Things (2024#3/2025#6)|Mystical Magical (2025#47)|Slow It Down (2024#41)|Sorry I'm Here For Someone Else (2025#37)
|
||||||
Beyoncé,Q36153,female,solo,pop music; contemporary R&B; country music,United States,Break My Soul (2022#38)|Cuff It (2023#29)|Mi Gente (2017#50/2018#99)|Sorry (2016#71)|Texas Hold 'Em (2024#32)
|
Beyoncé,,,,,,Break My Soul (2022#38)|Cuff It (2023#29)|Mi Gente (2017#50/2018#99)|Sorry (2016#71)|Texas Hold 'Em (2024#32)
|
||||||
Big Sean,Q367634,male,solo,hip-hop; Midwest hip-hop; Michigan rap; pop rap,United States,Big Bank (2018#63)|Bounce Back (2017#35)|Feels (2017#74)
|
Big Sean,,,,,,Big Bank (2018#63)|Bounce Back (2017#35)|Feels (2017#74)
|
||||||
BigXthaPlug,Q124750337,male,solo,rapping; hip-hop,United States,All The Way (2025#33)
|
BigXthaPlug,,,,,,All The Way (2025#33)
|
||||||
Billie Eilish,Q29564107,female,solo,electropop; indie pop; alternative pop,United States,Bad Guy (2019#4/2020#46)|Birds Of A Feather (2024#15/2025#5)|Bury A Friend (2019#73)|Everything I Wanted (2020#18)|Happier Than Ever (2021#94)|Lunch (2024#48)|Therefore I Am (2021#25)|What Was I Made For? (2023#81/2024#46)|When The Party's Over (2019#67)|Wildflower (2024#89/2025#26)
|
Billie Eilish,,,,,,Bad Guy (2019#4/2020#46)|Birds Of A Feather (2024#15/2025#5)|Bury A Friend (2019#73)|Everything I Wanted (2020#18)|Happier Than Ever (2021#94)|Lunch (2024#48)|Therefore I Am (2021#25)|What Was I Made For? (2023#81/2024#46)|When The Party's Over (2019#67)|Wildflower (2024#89/2025#26)
|
||||||
Billy Ray Cyrus,Q231259,male,solo,country music; country pop; country rock; country blues,United States,Old Town Road (2019#1)
|
Billy Ray Cyrus,,,,,,Old Town Road (2019#1)
|
||||||
Bipolar Sunshine,Q16727521,male,solo,pop music,United Kingdom,Middle (2016#80)
|
Bipolar Sunshine,,,,,,Middle (2016#80)
|
||||||
Bizarrap,Q99937928,male,solo,Latin trap; reggaeton,Argentina,"Bzrp Music Sessions, Vol. 53 (2023#96)"
|
Bizarrap,,,,,,"Bzrp Music Sessions, Vol. 53 (2023#96)"
|
||||||
Black Eyed Peas,Q134541,,group,alternative hip-hop; pop rap; hip-hop; pop music; Latin hip-hop,United States,RITMO (Bad Boys For Life) (2020#50)
|
Black Eyed Peas,,,,,,RITMO (Bad Boys For Life) (2020#50)
|
||||||
blackbear,Q22974007,male,solo,pop music; emo; contemporary R&B; hip-hop,United States,Do Re Mi (2017#98)|Hot Girl Bummer (2020#26)|My Ex's Best Friend (2021#23)
|
blackbear,,,,,,Do Re Mi (2017#98)|Hot Girl Bummer (2020#26)|My Ex's Best Friend (2021#23)
|
||||||
Blake Shelton,Q94831,male,solo,country music,United States,God's Country (2019#53)|Nobody But You (2020#52)|Pour Me A Drink (2024#76)
|
Blake Shelton,,,,,,God's Country (2019#53)|Nobody But You (2020#52)|Pour Me A Drink (2024#76)
|
||||||
Blanco Brown,Q64736729,male,solo,country rap; trap music; hip-hop,United States,Just The Way (2021#88)|The Git Up (2019#56)
|
Blanco Brown,,,,,,Just The Way (2021#88)|The Git Up (2019#56)
|
||||||
BlocBoy JB,Q49481016,male,solo,hip-hop; trap music,United States,Look Alive (2018#23)
|
BlocBoy JB,,,,,,Look Alive (2018#23)
|
||||||
Blueface,Q60463134,male,solo,hip-hop; trap music; West Coast hip-hop; gangsta rap,United States,Thotiana (2019#47)
|
Blueface,,,,,,Thotiana (2019#47)
|
||||||
Bobby Helms,Q888554,male,solo,country music,United States,Jingle Bell Rock (2022#86/2023#68/2024#68/2025#81)
|
Bobby Helms,,,,,,Jingle Bell Rock (2022#86/2023#68/2024#68/2025#81)
|
||||||
Bomba Estéreo,Q887911,,group,cumbia,Colombia,Ojitos Lindos (2022#87)
|
Bomba Estéreo,,,,,,Ojitos Lindos (2022#87)
|
||||||
Bradley Cooper,Q205707,male,solo,,United States,Shallow (2019#19)
|
Bradley Cooper,,,,,,Shallow (2019#19)
|
||||||
Brandon Lake,Q100314823,male,solo,contemporary Christian music; Christian rock,United States,Hard Fought Hallelujah (2025#84)
|
Brandon Lake,,,,,,Hard Fought Hallelujah (2025#84)
|
||||||
Brenda Lee,Q230925,female,solo,rock and roll; country music; pop music; rockabilly,United States,Rockin' Around The Christmas Tree (2021#92/2022#80/2023#60/2024#59/2025#67)
|
Brenda Lee,,,,,,Rockin' Around The Christmas Tree (2021#92/2022#80/2023#60/2024#59/2025#67)
|
||||||
Brendon Urie,Q1047474,male,solo,pop rock; alternative rock,United States,ME! (2019#43)
|
Brendon Urie,,,,,,ME! (2019#43)
|
||||||
Brett Young,Q25189679,male,solo,country music,United States,In Case You Didn't Know (2017#57)|Mercy (2018#95)
|
Brett Young,,,,,,In Case You Didn't Know (2017#57)|Mercy (2018#95)
|
||||||
BRS Kash,Q105409004,male,solo,hip-hop,United States,Throat Baby (Go Baby) (2021#97)
|
BRS Kash,,,,,,Throat Baby (Go Baby) (2021#97)
|
||||||
Bruno Mars,Q1450,male,solo,pop music; contemporary R&B; hip-hop; new wave; reggae; funk,United States,24K Magic (2017#16)|APT. (2025#9)|Die With A Smile (2024#62/2025#1)|Finesse (2018#14)|Leave The Door Open (2021#7)|Please Me (2019#37)|Smokin Out The Window (2022#41)|That's What I Like (2017#3)|Wake Up In The Sky (2019#51)
|
Bruno Mars,,,,,,24K Magic (2017#16)|APT. (2025#9)|Die With A Smile (2024#62/2025#1)|Finesse (2018#14)|Leave The Door Open (2021#7)|Please Me (2019#37)|Smokin Out The Window (2022#41)|That's What I Like (2017#3)|Wake Up In The Sky (2019#51)
|
||||||
Bryson Tiller,Q21066641,male,solo,contemporary R&B; hip-hop; trap music; soul,United States,Don't (2016#35)|Exchange (2016#53)|Whatever She Wants (2024#58)|Wild Thoughts (2017#18)
|
Bryson Tiller,,,,,,Don't (2016#35)|Exchange (2016#53)|Whatever She Wants (2024#58)|Wild Thoughts (2017#18)
|
||||||
BTS,Q13580495,,group,K-pop; dance-pop; Korean hip-hop,South Korea,Butter (2021#11)|Dynamite (2020#38/2021#41)
|
BTS,,,,,,Butter (2021#11)|Dynamite (2020#38/2021#41)
|
||||||
Burl Ives,Q315723,male,solo,folk music,United States,A Holly Jolly Christmas (2022#89/2023#71/2024#94)
|
Burl Ives,,,,,,A Holly Jolly Christmas (2022#89/2023#71/2024#94)
|
||||||
Calboy,Q66085272,male,solo,hip-hop,United States,Envy Me (2019#59)
|
Calboy,,,,,,Envy Me (2019#59)
|
||||||
Calvin Harris,Q81637,male,solo,electronic dance music; electronic pop; acid house,United Kingdom,Feels (2017#74)|One Kiss (2018#68)|Slide (2017#71)|This Is What You Came For (2016#17)
|
Calvin Harris,,,,,,Feels (2017#74)|One Kiss (2018#68)|Slide (2017#71)|This Is What You Came For (2016#17)
|
||||||
Camila Cabello,Q18810940,female,solo,pop music; contemporary R&B; Latin pop; Gothic pop,Cuba,Bad Things (2017#41)|Bam Bam (2022#66)|Beautiful (2019#95)|Havana (2017#96/2018#4)|I Know What You Did Last Summer (2016#86)|My Oh My (2020#37)|Never Be The Same (2018#18)|Senorita (2019#15/2020#44)
|
Camila Cabello,,,,,,Bad Things (2017#41)|Bam Bam (2022#66)|Beautiful (2019#95)|Havana (2017#96/2018#4)|I Know What You Did Last Summer (2016#86)|My Oh My (2020#37)|Never Be The Same (2018#18)|Senorita (2019#15/2020#44)
|
||||||
Cardi B,Q29033668,female,solo,hip-hop; pop music; pop rap,United States,Bartier Cardi (2018#61)|Be Careful (2018#59)|Bodak Yellow (Money Moves) (2017#24/2018#54)|Clout (2019#87)|Finesse (2018#14)|Girls Like You (2018#10/2019#22)|I Like It (2018#7/2019#69)|Money (2019#38)|MotorSport (2018#34)|No Limit (2018#30)|Please Me (2019#37)|Put It On Da Floor Again (2023#98)|Taki Taki (2019#57)|Tomorrow 2 (2023#70)|Up (2021#26)|WAP (2020#24)
|
Cardi B,,,,,,Bartier Cardi (2018#61)|Be Careful (2018#59)|Bodak Yellow (Money Moves) (2017#24/2018#54)|Clout (2019#87)|Finesse (2018#14)|Girls Like You (2018#10/2019#22)|I Like It (2018#7/2019#69)|Money (2019#38)|MotorSport (2018#34)|No Limit (2018#30)|Please Me (2019#37)|Put It On Da Floor Again (2023#98)|Taki Taki (2019#57)|Tomorrow 2 (2023#70)|Up (2021#26)|WAP (2020#24)
|
||||||
Carly Pearce,Q29913337,female,solo,country music,United States,I Hope You're Happy Now (2020#73)
|
Carly Pearce,,,,,,I Hope You're Happy Now (2020#73)
|
||||||
Carolina Gaitán,Q5753201,female,solo,,Colombia,We Don't Talk About Bruno (2022#24)
|
Carolina Gaitán,,,,,,We Don't Talk About Bruno (2022#24)
|
||||||
Carrie Underwood,Q215546,female,solo,pop music; country music; country pop; country rock,United States,I'm Gonna Love You (2025#78)|If I Didn't Love You (2021#85)|The Fighter (2017#100)
|
Carrie Underwood,,,,,,I'm Gonna Love You (2025#78)|If I Didn't Love You (2021#85)|The Fighter (2017#100)
|
||||||
Cash Cobain,Q125989634,male,solo,hip-hop; drill; Jersey drill; pop rap; East Coast hip-hop; trap music,United States,Somebody Loves Me (2025#85)
|
Cash Cobain,,,,,,Somebody Loves Me (2025#85)
|
||||||
Casper Mágico,Q60333702,male,solo,Latin trap; reggaeton,United States,Te Bote (2018#81)
|
Casper Mágico,,,,,,Te Bote (2018#81)
|
||||||
Chance the Rapper,Q12470060,male,solo,hip-hop; alternative hip-hop; contemporary R&B; conscious hip-hop; alternative R&B; gospel music,United States,Holy (2021#35)|I'm The One (2017#12)|No Brainer (2018#73)
|
Chance the Rapper,,,,,,Holy (2021#35)|I'm The One (2017#12)|No Brainer (2018#73)
|
||||||
Chappell Roan,Q110281146,female,solo,synth-pop; pop music; electronic music,United States,"Good Luck, Babe! (2024#18/2025#31)|Hot To Go! (2024#53)|Pink Pony Club (2025#10)"
|
Chappell Roan,,,,,,"Good Luck, Babe! (2024#18/2025#31)|Hot To Go! (2024#53)|Pink Pony Club (2025#10)"
|
||||||
Charlie Puth,Q19810390,male,solo,pop music; contemporary R&B,United States,Attention (2017#22)|How Long (2018#65)|I Hope (2020#12/2021#40)|One Call Away (2016#43)|See You Again (2016#99)|We Don't Talk Anymore (2016#50)
|
Charlie Puth,,,,,,Attention (2017#22)|How Long (2018#65)|I Hope (2020#12/2021#40)|One Call Away (2016#43)|See You Again (2016#99)|We Don't Talk Anymore (2016#50)
|
||||||
Chase Rice,Q5087189,male,solo,country music,United States,Drinkin' Beer. Talkin' God. Amen. (2021#99)|Eyes On You (2019#96)
|
Chase Rice,,,,,,Drinkin' Beer. Talkin' God. Amen. (2021#99)|Eyes On You (2019#96)
|
||||||
Cheat Codes,Q23978754,,group,electronic music; electronic dance music; tropical house; dancehall,United States,No Promises (2017#91)
|
Cheat Codes,,,,,,No Promises (2017#91)
|
||||||
Chencho Corleone,Q113625582,male,solo,reggaeton,United States,Me Porto Bonito (2022#20)
|
Chencho Corleone,,,,,,Me Porto Bonito (2022#20)
|
||||||
Childish Gambino,Q1239933,male,solo,hip-hop; funk; contemporary R&B; soul; psychedelic music; indie pop; comedy hip-hop,United States,Redbone (2017#25)|This Is America (2018#51)
|
Childish Gambino,,,,,,Redbone (2017#25)|This Is America (2018#51)
|
||||||
Chris Brown,Q155700,male,solo,contemporary R&B; pop music; hip-hop,United States,Back To Sleep (2016#89)|Freaky Friday (2018#55)|Go Crazy (2020#39/2021#19)|No Guidance (2019#21/2020#36)|Residuals (2025#63)|Superhero (Heroes & Villains) (2023#38)|Under The Influence (2023#17)
|
Chris Brown,,,,,,Back To Sleep (2016#89)|Freaky Friday (2018#55)|Go Crazy (2020#39/2021#19)|No Guidance (2019#21/2020#36)|Residuals (2025#63)|Superhero (Heroes & Villains) (2023#38)|Under The Influence (2023#17)
|
||||||
Chris Stapleton,Q5108148,male,solo,country music,United States,Say Something (2018#85)|Starting Over (2021#53)|White Horse (2024#77)|You Should Probably Leave (2022#79)
|
Chris Stapleton,,,,,,Say Something (2018#85)|Starting Over (2021#53)|White Horse (2024#77)|You Should Probably Leave (2022#79)
|
||||||
Chris Young,Q952176,male,solo,country music,United States,Famous Friends (2021#69)
|
Chris Young,,,,,,Famous Friends (2021#69)
|
||||||
City Girls,Q60658858,,group,hip-hop,United States,Act Up (2019#68)
|
City Girls,,,,,,Act Up (2019#68)
|
||||||
CJ,Q103868817,male,solo,drill; trap music; East Coast hip-hop; hip-hop,United States,Whoopty (2021#50)
|
CJ,,,,,,Whoopty (2021#50)
|
||||||
CKay,Q98312451,male,solo,Afrobeats,Nigeria,Love Nwantiti (Ah Ah Ah) (2022#55)
|
CKay,,,,,,Love Nwantiti (Ah Ah Ah) (2022#55)
|
||||||
Clean Bandit,Q11838808,,group,electropop,United Kingdom,Rockabye (2017#44)
|
Clean Bandit,,,,,,Rockabye (2017#44)
|
||||||
Cody Johnson,Q17916913,male,solo,country music; country rock,United States,'Til You Can't (2022#34)|I'm Gonna Love You (2025#78)|The Painter (2024#98)
|
Cody Johnson,,,,,,'Til You Can't (2022#34)|I'm Gonna Love You (2025#78)|The Painter (2024#98)
|
||||||
Coi Leray,Q106376206,female,solo,hip-hop; pop music,United States,No More Parties (2021#79)|Players (2023#33)
|
Coi Leray,,,,,,No More Parties (2021#79)|Players (2023#33)
|
||||||
Coldplay,Q45188,,group,alternative rock; pop music,United Kingdom,Adventure Of A Lifetime (2016#95)|Hymn For The Weekend (2016#73)|Something Just Like This (2017#5)
|
Coldplay,,,,,,Adventure Of A Lifetime (2016#95)|Hymn For The Weekend (2016#73)|Something Just Like This (2017#5)
|
||||||
Cole Swindell,Q15101510,male,solo,country music,United States,Never Say Never (2022#92)|She Had Me At Heads Carolina (2022#46)|Single Saturday Night (2021#95)
|
Cole Swindell,,,,,,Never Say Never (2022#92)|She Had Me At Heads Carolina (2022#46)|Single Saturday Night (2021#95)
|
||||||
Corey Kent,Q113452679,male,solo,country music,United States,Wild As Her (2023#93)
|
Corey Kent,,,,,,Wild As Her (2023#93)
|
||||||
Cris MJ,Q111425015,male,solo,reggaeton,Chile,Gata Only (2024#52)
|
Cris MJ,,,,,,Gata Only (2024#52)
|
||||||
D.R.A.M.,Q25138504,male,solo,Southern hip-hop; contemporary R&B; trap music,United States,Broccoli (2016#34/2017#70)
|
D.R.A.M.,,,,,,Broccoli (2016#34/2017#70)
|
||||||
DaBaby,Q62107457,male,solo,hip-hop; trap music; pop rap; Southern hip-hop,United States,Baby (2019#84)|Beat Box (2021#44)|BOP (2020#29)|Cash Shit (2019#90)|Cry Baby (2021#77)|For The Night (2020#49/2021#32)|My Oh My (2020#37)|Rockstar (2020#5)|Suge (2019#24)|Whats Poppin (2020#13)
|
DaBaby,,,,,,Baby (2019#84)|Beat Box (2021#44)|BOP (2020#29)|Cash Shit (2019#90)|Cry Baby (2021#77)|For The Night (2020#49/2021#32)|My Oh My (2020#37)|Rockstar (2020#5)|Suge (2019#24)|Whats Poppin (2020#13)
|
||||||
Daddy Yankee,Q272591,male,solo,reggaeton; Latin hip-hop,United States,Con Calma (2019#65)|Despacito (2017#2)|Dura (2018#93)
|
Daddy Yankee,,,,,,Con Calma (2019#65)|Despacito (2017#2)|Dura (2018#93)
|
||||||
Daft Punk,Q185828,,group,house music; electronic music; French house; synth-pop; electronic rock; electronica; nu-disco; dance music; leftfield house,France,I Feel It Coming (2017#34)|Starboy (2016#58/2017#20)
|
Daft Punk,,,,,,I Feel It Coming (2017#34)|Starboy (2016#58/2017#20)
|
||||||
Dan Smyers,Q57630595,male,solo,country music,United States,"10,000 Hours (2020#23)|All To Myself (2019#97)|Glad You Exist (2021#64)|Speechless (2019#35)|Tequila (2018#32/2019#91)"
|
Dan Smyers,,,,,,"10,000 Hours (2020#23)|All To Myself (2019#97)|Glad You Exist (2021#64)|Speechless (2019#35)|Tequila (2018#32/2019#91)"
|
||||||
Daniel Caesar,Q29589083,male,solo,contemporary R&B; soul; neo soul,Canada,Peaches (2021#10)
|
Daniel Caesar,,,,,,Peaches (2021#10)
|
||||||
Danny Chung,Q140814119,male,solo,hip-hop; K-pop,United States,Soda Pop (2025#61)|Your Idol (2025#54)
|
Danny Chung,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
Darell,Q56467651,male,solo,reggaeton; Latin trap,United States,Te Bote (2018#81)
|
Darell,,,,,,Te Bote (2018#81)
|
||||||
Dasha,Q125157260,female,solo,country pop,United States,Austin (2024#27)
|
Dasha,,,,,,Austin (2024#27)
|
||||||
David Guetta,Q8298,male,solo,electronic dance music; house music; electroclash; electro house,France,I'm Good (Blue) (2023#10)
|
David Guetta,,,,,,I'm Good (Blue) (2023#10)
|
||||||
David Kushner,Q117745879,male,solo,,United States,Daylight (2023#62)
|
David Kushner,,,,,,Daylight (2023#62)
|
||||||
Daya,Q21005558,female,solo,pop music,United States,"Don't Let Me Down (2016#8)|Hide Away (2016#84)|Sit Still, Look Pretty (2016#78)"
|
Daya,,,,,,"Don't Let Me Down (2016#8)|Hide Away (2016#84)|Sit Still, Look Pretty (2016#78)"
|
||||||
dazy,Q140814120,male,solo,pop music,,Sunroof (2022#29)
|
dazy,,,,,,Sunroof (2022#29)
|
||||||
Dean Lewis,Q29905254,male,solo,pop music,Australia,Be Alright (2019#54)
|
Dean Lewis,,,,,,Be Alright (2019#54)
|
||||||
Demi Lovato,Q41173,genderfluid,solo,pop rock; pop music; contemporary R&B; power pop; dance-pop; teen pop; electropop; soul; pop-punk; nu metal,United States,No Promises (2017#91)|Sorry Not Sorry (2017#47/2018#64)
|
Demi Lovato,,,,,,No Promises (2017#91)|Sorry Not Sorry (2017#47/2018#64)
|
||||||
Desiigner,Q23091498,male,solo,hip-hop; East Coast hip-hop; trap music; mumble rap; hardcore hip-hop,United States,Panda (2016#6)|Tiimmy Turner (2016#98)
|
Desiigner,,,,,,Panda (2016#6)|Tiimmy Turner (2016#98)
|
||||||
Diane Guerrero,Q17166318,female,solo,,United States,We Don't Talk About Bruno (2022#24)
|
Diane Guerrero,,,,,,We Don't Talk About Bruno (2022#24)
|
||||||
Diplo,Q533781,male,solo,electronic dance music; trap music; hip-hop; pop music; moombahton; electropop; house music; EDM trap music,United States,Close To Me (2019#58)
|
Diplo,,,,,,Close To Me (2019#58)
|
||||||
DJ Khaled,Q80805,male,solo,hip-hop; pop rap,United States,Every Chance I Get (2021#59)|For Free (2016#59)|I'm The One (2017#12)|No Brainer (2018#73)|Popstar (2020#66)|Wild Thoughts (2017#18)
|
DJ Khaled,,,,,,Every Chance I Get (2021#59)|For Free (2016#59)|I'm The One (2017#12)|No Brainer (2018#73)|Popstar (2020#66)|Wild Thoughts (2017#18)
|
||||||
DJ Snake,Q16728588,male,solo,electronic dance music; EDM trap music,France,Lean On (2016#85)|Let Me Love You (2016#47/2017#46)|Middle (2016#80)|Taki Taki (2019#57)
|
DJ Snake,,,,,,Lean On (2016#85)|Let Me Love You (2016#47/2017#46)|Middle (2016#80)|Taki Taki (2019#57)
|
||||||
Djo,Q27064031,male,solo,synth-pop,United States,End Of Beginning (2024#47)
|
Djo,,,,,,End Of Beginning (2024#47)
|
||||||
DNCE,Q21648421,,group,pop rock,United States,Cake By The Ocean (2016#18)
|
DNCE,,,,,,Cake By The Ocean (2016#18)
|
||||||
Doechii,Q106629186,female,solo,hip-hop; contemporary R&B; pop music; alternative hip-hop; pop rap,United States,Anxiety (2025#39)|Denial Is A River (2025#75)|What It Is (Block Boy) (2023#76)
|
Doechii,,,,,,Anxiety (2025#39)|Denial Is A River (2025#75)|What It Is (Block Boy) (2023#76)
|
||||||
Doja Cat,Q51120673,female,solo,contemporary R&B; pop music; electronic music; indie pop; hip-hop,United States,34+35 (2021#21)|Agora Hills (2024#17)|Best Friend (2021#29)|Get Into It (Yuh) (2022#68)|I Like You (A Happier Song) (2022#26/2023#75)|Juicy (2020#87)|Kiss Me More (2021#6/2022#90)|Need To Know (2021#46/2022#18)|Paint The Town Red (2023#52/2024#21)|Say So (2020#11)|Streets (2021#67)|Vegas (2022#47)|Woman (2022#21)|You Right (2021#43/2022#45)
|
Doja Cat,,,,,,34+35 (2021#21)|Agora Hills (2024#17)|Best Friend (2021#29)|Get Into It (Yuh) (2022#68)|I Like You (A Happier Song) (2022#26/2023#75)|Juicy (2020#87)|Kiss Me More (2021#6/2022#90)|Need To Know (2021#46/2022#18)|Paint The Town Red (2023#52/2024#21)|Say So (2020#11)|Streets (2021#67)|Vegas (2022#47)|Woman (2022#21)|You Right (2021#43/2022#45)
|
||||||
Don Toliver,Q56513383,male,solo,hip-hop; trap music; contemporary R&B,United States,Lemonade (2021#51)|No Pole (2025#97)
|
Don Toliver,,,,,,Lemonade (2021#51)|No Pole (2025#97)
|
||||||
Dove Cameron,Q13560423,female,solo,pop music; alternative pop; soul,United States,Boyfriend (2022#51)
|
Dove Cameron,,,,,,Boyfriend (2022#51)
|
||||||
Drake,Q33240,male,solo,hip-hop; contemporary R&B; pop music; trap music,Canada,Act II: Date @ 8 (2024#80)|Both (2017#83)|Controlla (2016#41)|Fake Love (2017#37)|First Person Shooter (2024#61)|For Free (2016#59)|God's Plan (2018#1)|Going Bad (2019#18)|Hotline Bling (2016#24)|I'm Upset (2018#86)|IDGAF (2024#83)|In My Feelings (2018#9)|Jimmy Cooks (2022#33)|Jumpman (2016#37)|Knife Talk (2021#86/2022#60)|Laugh Now Cry Later (2020#41/2021#45)|Life Is Good (2020#7)|Look Alive (2018#23)|Meltdown (2023#97)|MIA (2019#44)|Money In The Grave (2019#34)|Nice For What (2018#11)|No Guidance (2019#21/2020#36)|Nokia (2025#24)|Nonstop (2018#52)|One Dance (2016#3)|Passionfruit (2017#65)|Pop Style (2016#82)|Popstar (2020#66)|Rich Baby Daddy (2024#45)|Rich Flex (2023#14)|Search & Rescue (2023#45)|Somebody Loves Me (2025#85)|Spin Bout U (2023#43)|Too Good (2016#29)|Toosie Slide (2020#32)|Wait For U (2022#11/2023#83)|Walk It Talk It (2018#43)|Wants And Needs (2021#47)|Way 2 Sexy (2021#48)|What's Next (2021#68)|Work (2016#4)|Yes Indeed (2018#25)|You're Mines Still (2021#58)
|
Drake,,,,,,Act II: Date @ 8 (2024#80)|Both (2017#83)|Controlla (2016#41)|Fake Love (2017#37)|First Person Shooter (2024#61)|For Free (2016#59)|God's Plan (2018#1)|Going Bad (2019#18)|Hotline Bling (2016#24)|I'm Upset (2018#86)|IDGAF (2024#83)|In My Feelings (2018#9)|Jimmy Cooks (2022#33)|Jumpman (2016#37)|Knife Talk (2021#86/2022#60)|Laugh Now Cry Later (2020#41/2021#45)|Life Is Good (2020#7)|Look Alive (2018#23)|Meltdown (2023#97)|MIA (2019#44)|Money In The Grave (2019#34)|Nice For What (2018#11)|No Guidance (2019#21/2020#36)|Nokia (2025#24)|Nonstop (2018#52)|One Dance (2016#3)|Passionfruit (2017#65)|Pop Style (2016#82)|Popstar (2020#66)|Rich Baby Daddy (2024#45)|Rich Flex (2023#14)|Search & Rescue (2023#45)|Somebody Loves Me (2025#85)|Spin Bout U (2023#43)|Too Good (2016#29)|Toosie Slide (2020#32)|Wait For U (2022#11/2023#83)|Walk It Talk It (2018#43)|Wants And Needs (2021#47)|Way 2 Sexy (2021#48)|What's Next (2021#68)|Work (2016#4)|Yes Indeed (2018#25)|You're Mines Still (2021#58)
|
||||||
Dua Lipa,Q21914464,female,solo,pop music; post-disco; house music; funktronica; indie pop; dream pop; synth-pop,United Kingdom,Break My Heart (2020#33)|Cold Heart (PNAU Remix) (2022#10)|Dance The Night (2023#35/2024#100)|Don't Start Now (2020#4)|Houdini (2024#43)|IDGAF (2018#98)|Levitating (2021#1/2022#42)|New Rules (2018#16)|One Kiss (2018#68)|Sweetest Pie (2022#62)|We're Good (2021#90)
|
Dua Lipa,,,,,,Break My Heart (2020#33)|Cold Heart (PNAU Remix) (2022#10)|Dance The Night (2023#35/2024#100)|Don't Start Now (2020#4)|Houdini (2024#43)|IDGAF (2018#98)|Levitating (2021#1/2022#42)|New Rules (2018#16)|One Kiss (2018#68)|Sweetest Pie (2022#62)|We're Good (2021#90)
|
||||||
Duncan Laurence,Q60758679,male,solo,pop music,Kingdom of the Netherlands,Arcade (2021#82)
|
Duncan Laurence,,,,,,Arcade (2021#82)
|
||||||
Dustin Lynch,Q5317118,male,solo,country music,United States,Small Town Boy (2017#94)
|
Dustin Lynch,,,,,,Small Town Boy (2017#94)
|
||||||
Ed Sheeran,Q47447,male,solo,pop music; folk music; hip-hop; dance music; soul; rock music,United Kingdom,Bad Habits (2021#15/2022#13)|Bam Bam (2022#66)|Beautiful People (2019#50)|Castle On The Hill (2017#40)|Eyes Closed (2023#64)|I Don't Care (2019#16)|Perfect (2018#2)|Shape Of You (2017#1/2018#71)|Shivers (2022#5)
|
Ed Sheeran,,,,,,Bad Habits (2021#15/2022#13)|Bam Bam (2022#66)|Beautiful People (2019#50)|Castle On The Hill (2017#40)|Eyes Closed (2023#64)|I Don't Care (2019#16)|Perfect (2018#2)|Shape Of You (2017#1/2018#71)|Shivers (2022#5)
|
||||||
EJAE,Q135108208,female,solo,K-pop; pop music; contemporary R&B,South Korea,Free (2025#92)|Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
EJAE,,,,,,Free (2025#92)|Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||||
Ella Langley,Q127559972,female,solo,country music,United States,Weren't For The Wind (2025#71)
|
Ella Langley,,,,,,Weren't For The Wind (2025#71)
|
||||||
Ella Mai,Q29033458,female,solo,contemporary R&B,United Kingdom,Boo'd Up (2018#15)|Trip (2018#92/2019#80)
|
Ella Mai,,,,,,Boo'd Up (2018#15)|Trip (2018#92/2019#80)
|
||||||
Elle King,Q5364665,female,solo,country music; Americana; blues rock; alternative country,United States,Ex's & Oh's (2016#63)
|
Elle King,,,,,,Ex's & Oh's (2016#63)
|
||||||
Ellie Goulding,Q31164,female,solo,synth-pop; electropop; indie pop; folktronica; alternative rock; alternative music; dream pop; indietronica,United Kingdom,Close To Me (2019#58)|On My Mind (2016#81)
|
Ellie Goulding,,,,,,Close To Me (2019#58)|On My Mind (2016#81)
|
||||||
Elton John,Q2808,male,solo,rock music; pop rock; glam rock; soft rock; rhythm and blues,United Kingdom,Cold Heart (PNAU Remix) (2022#10)
|
Elton John,,,,,,Cold Heart (PNAU Remix) (2022#10)
|
||||||
Em Beihold,Q111333139,female,solo,pop music,United States,Numb Little Bug (2022#32)
|
Em Beihold,,,,,,Numb Little Bug (2022#32)
|
||||||
Emilee,Q98635221,female,solo,pop music,United States,ily (2020#95)
|
Emilee,,,,,,ily (2020#95)
|
||||||
Eminem,Q5608,male,solo,hip-hop; horrorcore; hardcore hip-hop; comedy hip-hop; Midwest hip-hop; rap rock,United States,Godzilla (2020#62)|Houdini (2024#39)
|
Eminem,,,,,,Godzilla (2020#62)|Houdini (2024#39)
|
||||||
Encanto Cast,Q140814124,,,,United States,We Don't Talk About Bruno (2022#24)
|
Encanto Cast,,,,,,We Don't Talk About Bruno (2022#24)
|
||||||
Eric Church,Q975911,male,solo,country music,United States,Does To Me (2020#93)|Hell Of A View (2021#91)
|
Eric Church,,,,,,Does To Me (2020#93)|Hell Of A View (2021#91)
|
||||||
ERNEST,Q110442717,male,solo,country music,United States,Cowgirls (2024#29)|Flower Shops (2022#96)
|
ERNEST,,,,,,Cowgirls (2024#29)|Flower Shops (2022#96)
|
||||||
Eslabón Armado,Q112702265,,group,regional Mexican,United States,Ella Baila Sola (2023#26)
|
Eslabón Armado,,,,,,Ella Baila Sola (2023#26)
|
||||||
Fat Joe,Q309888,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop; boom bap; mafioso rap,United States,All The Way Up (2016#87)
|
Fat Joe,,,,,,All The Way Up (2016#87)
|
||||||
Fetty Wap,Q19361511,male,solo,hip-hop; trap music; pop rap,United States,679 (2016#54)|All In My Head (Flex) (2016#93)
|
Fetty Wap,,,,,,679 (2016#54)|All In My Head (Flex) (2016#93)
|
||||||
Fifth Harmony,Q3087893,,group,pop music,United States,All In My Head (Flex) (2016#93)|Work From Home (2016#16)
|
Fifth Harmony,,,,,,All In My Head (Flex) (2016#93)|Work From Home (2016#16)
|
||||||
Fifty Fifty,Q116731010,,group,K-pop; J-pop,South Korea,Cupid (2023#44)
|
Fifty Fifty,,,,,,Cupid (2023#44)
|
||||||
Flipp Dinero,Q57584556,male,solo,hip-hop,United States,Leave Me Alone (2019#71)
|
Flipp Dinero,,,,,,Leave Me Alone (2019#71)
|
||||||
Flo Milli,Q89352069,female,solo,hip-hop,United States,Never Lose Me (2024#49)
|
Flo Milli,,,,,,Never Lose Me (2024#49)
|
||||||
Flo Rida,Q213538,male,solo,hip-hop; pop music; dance music; electronic dance music; Southern hip-hop; contemporary R&B; house music; electro hop; electro house; electropop; electro; EDM trap music; trap music; bass music; crunk; pop rap; dirty south; dance-pop; hip house; Miami bass; electronic music; techno; trance,United States,My House (2016#14)
|
Flo Rida,,,,,,My House (2016#14)
|
||||||
Florida Georgia Line,Q3644642,,group,country music,United States,Drinkin' Beer. Talkin' God. Amen. (2021#99)|H.O.L.Y. (2016#49)|Lil Bit (2021#70)|Meant To Be (2018#3)|Simple (2018#82)|Talk You Out Of It (2019#94)
|
Florida Georgia Line,,,,,,Drinkin' Beer. Talkin' God. Amen. (2021#99)|H.O.L.Y. (2016#49)|Lil Bit (2021#70)|Meant To Be (2018#3)|Simple (2018#82)|Talk You Out Of It (2019#94)
|
||||||
FloyyMenor,Q125819969,male,solo,reggaeton,Chile,Gata Only (2024#52)
|
FloyyMenor,,,,,,Gata Only (2024#52)
|
||||||
Flume,Q4038837,male,solo,electronica; trip hop; future bass,Australia,Never Be Like You (2016#60)
|
Flume,,,,,,Never Be Like You (2016#60)
|
||||||
Frank Ocean,Q357645,male,solo,alternative R&B; psychedelic soul; progressive soul; contemporary R&B; pop music; soul; hip-hop; avant-pop,United States,Slide (2017#71)
|
Frank Ocean,,,,,,Slide (2017#71)
|
||||||
French Montana,Q1455261,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop,Morocco,All The Way Up (2016#87)|Unforgettable (2017#15)
|
French Montana,,,,,,All The Way Up (2016#87)|Unforgettable (2017#15)
|
||||||
Fuerza Regida,Q118466501,,group,regional Mexican; banda music; corrido; corridos tumbados; sierreña; norteño,United States,Bebe Dame (2023#73)|Tu Boda (2025#87)
|
Fuerza Regida,,,,,,Bebe Dame (2023#73)|Tu Boda (2025#87)
|
||||||
Future,Q3445057,male,solo,Southern hip-hop; trap music; mumble rap; contemporary R&B,United States,Cold (2017#69)|Jumpman (2016#37)|King's Dead (2018#79)|Life Is Good (2020#7)|Like That (2024#14)|Low Life (2016#30)|Mask Off (2017#14)|Puffin On Zootiez (2022#84)|Pushin P (2022#48)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)|Wait For U (2022#11/2023#83)|Way 2 Sexy (2021#48)|Wicked (2016#97)
|
Future,,,,,,Cold (2017#69)|Jumpman (2016#37)|King's Dead (2018#79)|Life Is Good (2020#7)|Like That (2024#14)|Low Life (2016#30)|Mask Off (2017#14)|Puffin On Zootiez (2022#84)|Pushin P (2022#48)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)|Wait For U (2022#11/2023#83)|Way 2 Sexy (2021#48)|Wicked (2016#97)
|
||||||
G-Eazy,Q5511893,male,solo,hip-hop,United States,"Him & I (2018#45)|Me, Myself & I (2016#19)|No Limit (2018#30)"
|
G-Eazy,,,,,,"Him & I (2018#45)|Me, Myself & I (2016#19)|No Limit (2018#30)"
|
||||||
Gabby Barrett,Q53912835,female,solo,country pop,United States,I Hope (2020#12/2021#40)|The Good Ones (2021#62)
|
Gabby Barrett,,,,,,I Hope (2020#12/2021#40)|The Good Ones (2021#62)
|
||||||
GAYLE,Q109768702,female,solo,pop-punk; pop rock; power pop; emo pop; alternative pop; alternative rock,United States,abcdefu (2022#17)
|
GAYLE,,,,,,abcdefu (2022#17)
|
||||||
Gigi Perez,Q130235359,female,solo,indie folk,United States,Sailor Song (2025#36)
|
Gigi Perez,,,,,,Sailor Song (2025#36)
|
||||||
Giveon,Q96418479,male,solo,contemporary R&B,United States,Heartbreak Anniversary (2021#31)|Peaches (2021#10)
|
Giveon,,,,,,Heartbreak Anniversary (2021#31)|Peaches (2021#10)
|
||||||
Glass Animals,Q16839267,,group,indie rock; psychedelic pop; indietronica; alternative pop; electronic rock; psychedelic rock,United Kingdom,Heat Waves (2021#16/2022#1)
|
Glass Animals,,,,,,Heat Waves (2021#16/2022#1)
|
||||||
GloRilla,Q114132368,female,solo,hip-hop; trap music; crunk; Memphis rap; Southern hip-hop,United States,Sticky (2025#41)|Tomorrow 2 (2023#70)|Wanna Be (2024#40)|Whatchu Kno About Me (2025#48)|Yeah Glo! (2024#44)
|
GloRilla,,,,,,Sticky (2025#41)|Tomorrow 2 (2023#70)|Wanna Be (2024#40)|Whatchu Kno About Me (2025#48)|Yeah Glo! (2024#44)
|
||||||
gnash,Q23978226,male,solo,pop music; hip-hop; alternative R&B; indie folk,United States,I Hate U I Love U (2016#38)|Lights Down Low (2018#66)
|
gnash,,,,,,I Hate U I Love U (2016#38)|Lights Down Low (2018#66)
|
||||||
Gracie Abrams,Q106301562,female,solo,bedroom pop; pop music,United States,That's So True (2025#14)
|
Gracie Abrams,,,,,,That's So True (2025#14)
|
||||||
Grey,Q28451953,,group,electronic music; pop music; future bass,United States,Starving (2016#94/2017#82)|The Middle (2018#8)
|
Grey,,,,,,Starving (2016#94/2017#82)|The Middle (2018#8)
|
||||||
Grupo Frontera,Q118141487,,group,regional Mexican; norteño; cumbia norteña mexicana,United States,Bebe Dame (2023#73)|Un x100to (2023#49)
|
Grupo Frontera,,,,,,Bebe Dame (2023#73)|Un x100to (2023#49)
|
||||||
Gucci Mane,Q206032,male,solo,Southern hip-hop; trap music; gangsta rap; hardcore hip-hop; dirty south; Atlanta hip-hop; dirty rap,United States,Black Beatles (2017#19)|Both (2017#83)|I Get The Bag (2017#93/2018#72)|Slippery (2017#86)|Wake Up In The Sky (2019#51)
|
Gucci Mane,,,,,,Black Beatles (2017#19)|Both (2017#83)|I Get The Bag (2017#93/2018#72)|Slippery (2017#86)|Wake Up In The Sky (2019#51)
|
||||||
Gunna,Q55613105,male,solo,hip-hop; trap music; Southern hip-hop; contemporary R&B,United States,Drip Too Hard (2019#26)|Fukumean (2023#31/2024#85)|Hot (2020#72)|Lemonade (2021#51)|Pushin P (2022#48)|What Happened To Virgil (2022#83)
|
Gunna,,,,,,Drip Too Hard (2019#26)|Fukumean (2023#31/2024#85)|Hot (2020#72)|Lemonade (2021#51)|Pushin P (2022#48)|What Happened To Virgil (2022#83)
|
||||||
Gus Dapperton,Q51858412,male,solo,pop music,United States,Supalonely (2020#84)
|
Gus Dapperton,,,,,,Supalonely (2020#84)
|
||||||
Gwen Stefani,Q483379,female,solo,pop music; contemporary R&B; ska; electronica; new wave; rock music,United States,Nobody But You (2020#52)
|
Gwen Stefani,,,,,,Nobody But You (2020#52)
|
||||||
H.E.R.,Q30639849,female,solo,contemporary R&B,United States,Slide (2020#99)
|
H.E.R.,,,,,,Slide (2020#99)
|
||||||
Hailee Steinfeld,Q231726,female,solo,pop music; dance-pop,United States,Starving (2016#94/2017#82)
|
Hailee Steinfeld,,,,,,Starving (2016#94/2017#82)
|
||||||
Halsey,Q18534249,female,solo,pop music; electropop; synth-pop; art pop; alternative pop; alternative rock; alternative R&B; indie pop; contemporary R&B,United States,Bad At Love (2018#27)|Closer (2016#10/2017#7)|Eastside (2018#77/2019#17)|Him & I (2018#45)|Now Or Never (2017#59)|Without Me (2019#3)|You Should Be Sad (2020#82)
|
Halsey,,,,,,Bad At Love (2018#27)|Closer (2016#10/2017#7)|Eastside (2018#77/2019#17)|Him & I (2018#45)|Now Or Never (2017#59)|Without Me (2019#3)|You Should Be Sad (2020#82)
|
||||||
HARDY,Q63341424,male,solo,country music,United States,Truck Bed (2024#67)|Wait In The Truck (2023#54)
|
HARDY,,,,,,Truck Bed (2024#67)|Wait In The Truck (2023#54)
|
||||||
Harry Styles,Q3626966,male,solo,soft rock; pop music; rock music; Britpop; traditional folk music,United Kingdom,Adore You (2020#6)|As It Was (2022#2/2023#15)|Late Night Talking (2022#25)|Sign Of The Times (2017#87)|Watermelon Sugar (2020#20)
|
Harry Styles,,,,,,Adore You (2020#6)|As It Was (2022#2/2023#15)|Late Night Talking (2022#25)|Sign Of The Times (2017#87)|Watermelon Sugar (2020#20)
|
||||||
Hozier,Q16233232,male,solo,blues; blues rock; rhythm and blues; traditional folk music; soul; indie rock,Ireland,Too Sweet (2024#10/2025#34)
|
Hozier,,,,,,Too Sweet (2024#10/2025#34)
|
||||||
Hudson Westbrook,Q134125831,male,solo,country music,United States,House Again (2025#95)
|
Hudson Westbrook,,,,,,House Again (2025#95)
|
||||||
iann dior,Q65631841,male,solo,emo rap; pop rap; rap rock; pop rock,United States,Mood (2020#47/2021#4)
|
iann dior,,,,,,Mood (2020#47/2021#4)
|
||||||
Ice Spice,Q114114977,female,solo,Bronx drill; hip-hop; East Coast hip-hop; Jersey club; pop rap,United States,"Barbie World (2023#46)|Boy's A Liar, Pt. 2 (2023#20)|Karma (2023#27)|Princess Diana (2023#69)"
|
Ice Spice,,,,,,"Barbie World (2023#46)|Boy's A Liar, Pt. 2 (2023#20)|Karma (2023#27)|Princess Diana (2023#69)"
|
||||||
Imagine Dragons,Q391348,,group,pop rock; electropop; alternative rock; new wave,United States,Believer (2017#9/2018#100)|Enemy (2022#15)|Natural (2018#69)|Sucker For Pain (2016#68)|Thunder (2017#51/2018#22)|Whatever It Takes (2018#37)
|
Imagine Dragons,,,,,,Believer (2017#9/2018#100)|Enemy (2022#15)|Natural (2018#69)|Sucker For Pain (2016#68)|Thunder (2017#51/2018#22)|Whatever It Takes (2018#37)
|
||||||
Infared,Q140814135,male,solo,hip-hop,,All The Way Up (2016#87)
|
Infared,,,,,,All The Way Up (2016#87)
|
||||||
Internet Money,Q99691610,,,hip-hop,United States,Lemonade (2021#51)
|
Internet Money,,,,,,Lemonade (2021#51)
|
||||||
J Balvin,Q966845,male,solo,reggaeton; Latin pop; Latin trap,Colombia,I Like It (2018#7/2019#69)|Mi Gente (2017#50/2018#99)|RITMO (Bad Boys For Life) (2020#50)|X (2018#90)
|
J Balvin,,,,,,I Like It (2018#7/2019#69)|Mi Gente (2017#50/2018#99)|RITMO (Bad Boys For Life) (2020#50)|X (2018#90)
|
||||||
J. Cole,Q204018,male,solo,hip-hop; Southern hip-hop; pop rap; East Coast hip-hop; conscious hip-hop,United States,All My Life (2023#25)|First Person Shooter (2024#61)|Middle Child (2019#25)|The London (2019#64)
|
J. Cole,,,,,,All My Life (2023#25)|First Person Shooter (2024#61)|Middle Child (2019#25)|The London (2019#64)
|
||||||
Jack Harlow,Q59727812,male,solo,hip-hop; pop rap,United States,First Class (2022#6)|Industry Baby (2021#24/2022#16)|Lovin On Me (2024#5)|Whats Poppin (2020#13)
|
Jack Harlow,,,,,,First Class (2022#6)|Industry Baby (2021#24/2022#16)|Lovin On Me (2024#5)|Whats Poppin (2020#13)
|
||||||
Jake Scott,Q140814152,male,solo,pop music,United States,She Likes It (2022#91)
|
Jake Scott,,,,,,She Likes It (2022#91)
|
||||||
James Arthur,Q717626,male,solo,pop music,United Kingdom,Say You Won't Let Go (2017#11)
|
James Arthur,,,,,,Say You Won't Let Go (2017#11)
|
||||||
James Bay,Q17811637,male,solo,rock music; pop music; soul; Americana,United Kingdom,Let It Go (2016#44)
|
James Bay,,,,,,Let It Go (2016#44)
|
||||||
James Blake,Q350362,male,solo,pop music; electronica; post-dubstep; electronic dance music,United Kingdom,King's Dead (2018#79)
|
James Blake,,,,,,King's Dead (2018#79)
|
||||||
Jason Aldean,Q195439,male,solo,country music,United States,Got What I Got (2020#60)|If I Didn't Love You (2021#85)|Trouble With A Heartbreak (2022#88)|Try That In A Small Town (2023#66)|You Make It Easy (2018#70)
|
Jason Aldean,,,,,,Got What I Got (2020#60)|If I Didn't Love You (2021#85)|Trouble With A Heartbreak (2022#88)|Try That In A Small Town (2023#66)|You Make It Easy (2018#70)
|
||||||
Jason Derulo,Q243639,male,solo,pop music; rhythm and blues; contemporary R&B; hip-hop,United States,Savage Love (Laxed - Siren Beat) (2020#35)|Swalla (2017#85)
|
Jason Derulo,,,,,,Savage Love (Laxed - Siren Beat) (2020#35)|Swalla (2017#85)
|
||||||
Jawsh 685,Q96472235,male,solo,pop music,New Zealand,Savage Love (Laxed - Siren Beat) (2020#35)
|
Jawsh 685,,,,,,Savage Love (Laxed - Siren Beat) (2020#35)
|
||||||
Jay Rock,Q152632,male,solo,West Coast hip-hop,United States,King's Dead (2018#79)
|
Jay Rock,,,,,,King's Dead (2018#79)
|
||||||
Jay Z,Q62766,male,solo,East Coast hip-hop; mafioso rap; gangsta rap; hardcore hip-hop,United States,All The Way Up (2016#87)|Pop Style (2016#82)
|
Jay Z,,,,,,All The Way Up (2016#87)|Pop Style (2016#82)
|
||||||
Jelly Roll,Q6176881,male,solo,Southern hip-hop; country rap; country music; hardcore hip-hop,United States,Hard Fought Hallelujah (2025#84)|I Am Not Okay (2024#75/2025#79)|Liar (2025#86)|Need A Favor (2023#34/2024#64)|Save Me (2024#65)|Wild Ones (2024#36)
|
Jelly Roll,,,,,,Hard Fought Hallelujah (2025#84)|I Am Not Okay (2024#75/2025#79)|Liar (2025#86)|Need A Favor (2023#34/2024#64)|Save Me (2024#65)|Wild Ones (2024#36)
|
||||||
Jeremih,Q472891,male,solo,contemporary R&B,United States,Oui (2016#55)
|
Jeremih,,,,,,Oui (2016#55)
|
||||||
Jessica Darrow,Q109718598,non-binary,solo,,United States,Surface Pressure (2022#53)
|
Jessica Darrow,,,,,,Surface Pressure (2022#53)
|
||||||
Jessie Murph,Q122157373,female,solo,country rock; country pop; pop music,United States,Blue Strips (2025#52)|High Road (2024#81/2025#55)|Wild Ones (2024#36)
|
Jessie Murph,,,,,,Blue Strips (2025#52)|High Road (2024#81/2025#55)|Wild Ones (2024#36)
|
||||||
Jhay Cortez,Q75997191,male,solo,Latin trap; reggaeton; Latin R&B,United States,Dakiti (2021#28)
|
Jhay Cortez,,,,,,Dakiti (2021#28)
|
||||||
Jhené Aiko,Q15929781,female,solo,hip-hop; pop music; rhythm and blues; soul; alternative R&B; neo soul; contemporary R&B,United States,P*$$y Fairy (OTW) (2020#94)
|
Jhené Aiko,,,,,,P*$$y Fairy (OTW) (2020#94)
|
||||||
JID,Q29588830,male,solo,hip-hop,United States,Enemy (2022#15)
|
JID,,,,,,Enemy (2022#15)
|
||||||
Jimin,Q20947093,male,solo,K-pop,South Korea,Who (2025#57)
|
Jimin,,,,,,Who (2025#57)
|
||||||
JNR CHOI,Q111507858,male,solo,hip-hop; trap music; world music,United Kingdom,To The Moon! (2022#97)
|
JNR CHOI,,,,,,To The Moon! (2022#97)
|
||||||
John Legend,Q44857,male,solo,soul; pop music; contemporary R&B,United States,Like I'm Gonna Lose You (2016#42)
|
John Legend,,,,,,Like I'm Gonna Lose You (2016#42)
|
||||||
Joji,Q23826617,male,solo,contemporary R&B; alternative R&B; trip hop; lo-fi music; surreal humour; satirical music; satire; comedy hip-hop; black comedy; alternative rock; comedy music,Japan,Glimpse Of Us (2022#52)
|
Joji,,,,,,Glimpse Of Us (2022#52)
|
||||||
Jon Bellion,Q18044284,male,solo,pop music; contemporary R&B; hip-hop; indie rock; pop rap; alternative hip-hop; indie pop; alternative rock; dance-pop; soul,United States,All Time Low (2017#75)
|
Jon Bellion,,,,,,All Time Low (2017#75)
|
||||||
Jon Pardi,Q6271354,male,solo,country music,United States,Last Night Lonely (2022#95)
|
Jon Pardi,,,,,,Last Night Lonely (2022#95)
|
||||||
Jonas Brothers,Q134233,,group,pop music; pop rock,United States,Leave Before You Love Me (2021#63)|Only Human (2019#78/2020#77)|Sucker (2019#10)
|
Jonas Brothers,,,,,,Leave Before You Love Me (2021#63)|Only Human (2019#78/2020#77)|Sucker (2019#10)
|
||||||
Jordan Davis,Q44274417,male,solo,country pop; country music; pop music,United States,Buy Dirt (2022#49)|Next Thing You Know (2023#47)|What My World Spins Around (2023#91)
|
Jordan Davis,,,,,,Buy Dirt (2022#49)|Next Thing You Know (2023#47)|What My World Spins Around (2023#91)
|
||||||
JP Saxe,Q86009464,male,solo,pop music,Canada,If The World Was Ending (2020#56)
|
JP Saxe,,,,,,If The World Was Ending (2020#56)
|
||||||
Juice WRLD,Q52151598,male,solo,hip-hop; emo rap; alternative rock; trap music; pop rap; SoundCloud rap; contemporary R&B; alternative hip-hop; mumble rap,United States,Bandit (2020#63)|Come & Go (2020#54)|Godzilla (2020#62)|Lucid Dreams (2018#12/2019#48)|Robbery (2019#100)|Suicidal (2020#75)|Wishing Well (2020#92)
|
Juice WRLD,,,,,,Bandit (2020#63)|Come & Go (2020#54)|Godzilla (2020#62)|Lucid Dreams (2018#12/2019#48)|Robbery (2019#100)|Suicidal (2020#75)|Wishing Well (2020#92)
|
||||||
Juicy J,Q602779,male,solo,Southern hip-hop; gangsta rap; trap music; dirty rap; Memphis rap; dirty south; horrorcore; hardcore hip-hop; crunk,United States,Powerglide (2018#97)
|
Juicy J,,,,,,Powerglide (2018#97)
|
||||||
Julia Michaels,Q20051697,female,solo,pop music; electronic dance music,United States,If The World Was Ending (2020#56)|Issues (2017#29)
|
Julia Michaels,,,,,,If The World Was Ending (2020#56)|Issues (2017#29)
|
||||||
Jung Kook,Q22338877,male,solo,K-pop,South Korea,Seven (2023#82)
|
Jung Kook,,,,,,Seven (2023#82)
|
||||||
Justin Bieber,Q34086,male,solo,pop music; contemporary R&B; tropical house; reggaeton; contemporary country,Canada,"10,000 Hours (2020#23)|Anyone (2021#74)|Cold Water (2016#25)|Daisies (2025#60)|Despacito (2017#2)|Essence (2021#60/2022#64)|Ghost (2022#8)|Holy (2021#35)|I Don't Care (2019#16)|I'm The One (2017#12)|Intentions (2020#17)|Let Me Love You (2016#47/2017#46)|Lonely (2021#65)|Love Yourself (2016#1)|No Brainer (2018#73)|Peaches (2021#10)|Sorry (2016#2)|Stay (2021#12/2022#3)|Stuck With U (2020#80)|What Do You Mean? (2016#31)|Yummy (2020#58)"
|
Justin Bieber,,,,,,"10,000 Hours (2020#23)|Anyone (2021#74)|Cold Water (2016#25)|Daisies (2025#60)|Despacito (2017#2)|Essence (2021#60/2022#64)|Ghost (2022#8)|Holy (2021#35)|I Don't Care (2019#16)|I'm The One (2017#12)|Intentions (2020#17)|Let Me Love You (2016#47/2017#46)|Lonely (2021#65)|Love Yourself (2016#1)|No Brainer (2018#73)|Peaches (2021#10)|Sorry (2016#2)|Stay (2021#12/2022#3)|Stuck With U (2020#80)|What Do You Mean? (2016#31)|Yummy (2020#58)"
|
||||||
Justin Timberlake,Q43432,male,solo,pop music; contemporary R&B,United States,Can't Stop The Feeling! (2016#9/2017#49)|Say Something (2018#85)
|
Justin Timberlake,,,,,,Can't Stop The Feeling! (2016#9/2017#49)|Say Something (2018#85)
|
||||||
JVKE,Q114865231,male,solo,pop music,United States,Golden Hour (2023#41)
|
JVKE,,,,,,Golden Hour (2023#41)
|
||||||
Kacey Musgraves,Q2783855,female,solo,country music; folk music,United States,I Remember Everything (2023#74/2024#9)
|
Kacey Musgraves,,,,,,I Remember Everything (2023#74/2024#9)
|
||||||
Kai,Q6347342,female,solo,pop music,Canada,Never Be Like You (2016#60)
|
Kai,,,,,,Never Be Like You (2016#60)
|
||||||
Kali Uchis,Q19004484,female,solo,contemporary R&B,United States,Telepatia (2021#49)
|
Kali Uchis,,,,,,Telepatia (2021#49)
|
||||||
Kaliii,Q115633164,female,solo,hip-hop; trap music; Southern hip-hop,United States,Area Codes (2023#88)
|
Kaliii,,,,,,Area Codes (2023#88)
|
||||||
Kane Brown,Q21620939,male,solo,country music,United States,Be Like That (2020#88)|Bury Me In Georgia (2023#89)|Famous Friends (2021#69)|Good As You (2019#86)|Heaven (2018#53)|Homesick (2020#89)|Like I Love Country Music (2022#85)|Miles On It (2024#34)|One Mississippi (2022#99)|One Thing Right (2019#89)|Thank God (2023#23)|What Ifs (2017#72)
|
Kane Brown,,,,,,Be Like That (2020#88)|Bury Me In Georgia (2023#89)|Famous Friends (2021#69)|Good As You (2019#86)|Heaven (2018#53)|Homesick (2020#89)|Like I Love Country Music (2022#85)|Miles On It (2024#34)|One Mississippi (2022#99)|One Thing Right (2019#89)|Thank God (2023#23)|What Ifs (2017#72)
|
||||||
Kanye West,Q15935,male,solo,hip-hop; pop music; electro; soul; gospel music; progressive rap; art pop; pop rap; hip-hop soul; chipmunk soul; contemporary R&B,United States,Carnival (2024#38)|Pop Style (2016#82)
|
Kanye West,,,,,,Carnival (2024#38)|Pop Style (2016#82)
|
||||||
Karol G,Q22001324,female,solo,reggaeton; Latin pop; Latin R&B,Colombia,MAMIII (2022#59)|Provenza (2022#63)|TQG (2023#65)
|
Karol G,,,,,,MAMIII (2022#59)|Provenza (2022#63)|TQG (2023#65)
|
||||||
Kate Bush,Q636,female,solo,art pop; progressive pop; singer-songwriter music; baroque pop; experimental pop; art rock; pop rock,United Kingdom,Running Up That Hill (A Deal With God) (2022#23)
|
Kate Bush,,,,,,Running Up That Hill (A Deal With God) (2022#23)
|
||||||
Katelyn Brown,Q140814175,female,solo,pop music,United States,Thank God (2023#23)
|
Katelyn Brown,,,,,,Thank God (2023#23)
|
||||||
Katy Perry,Q42493,female,solo,pop music; rock music; pop rock; dance music; synth-pop; electropop; gospel music,United States,Chained To The Rhythm (2017#73)|Con Calma (2019#65)|Feels (2017#74)
|
Katy Perry,,,,,,Chained To The Rhythm (2017#73)|Con Calma (2019#65)|Feels (2017#74)
|
||||||
Kehlani,Q20657541,non-binary,solo,contemporary R&B; pop music; neo soul; hip-hop,United States,Folded (2025#98)
|
Kehlani,,,,,,Folded (2025#98)
|
||||||
Keith Urban,Q315547,male,solo,country music,Australia,One Too Many (2021#81)|The Fighter (2017#100)
|
Keith Urban,,,,,,One Too Many (2021#81)|The Fighter (2017#100)
|
||||||
Kendrick Lamar,Q130798,male,solo,West Coast hip-hop; progressive rap; alternative hip-hop; jazz rap; conscious hip-hop; gangsta rap; hardcore hip-hop; jazz; pop music,United States,30 For 30 (2025#21)|All The Stars (2018#47)|DNA. (2017#62)|Don't Wanna Know (2017#38)|Euphoria (2024#66)|Humble. (2017#4)|King's Dead (2018#79)|Like That (2024#14)|Love. (2018#50)|Loyalty. (2017#66)|Luther (2025#2)|Not Like Us (2024#6/2025#17)|Peekaboo (2025#53)|Pray For Me (2018#40)|Squabble Up (2025#20)|TV Off (2025#15)
|
Kendrick Lamar,,,,,,30 For 30 (2025#21)|All The Stars (2018#47)|DNA. (2017#62)|Don't Wanna Know (2017#38)|Euphoria (2024#66)|Humble. (2017#4)|King's Dead (2018#79)|Like That (2024#14)|Love. (2018#50)|Loyalty. (2017#66)|Luther (2025#2)|Not Like Us (2024#6/2025#17)|Peekaboo (2025#53)|Pray For Me (2018#40)|Squabble Up (2025#20)|TV Off (2025#15)
|
||||||
Kenny Chesney,Q467526,male,solo,country music,United States,Get Along (2018#87)
|
Kenny Chesney,,,,,,Get Along (2018#87)
|
||||||
Kent Jones,Q25190082,male,solo,pop rap; contemporary R&B; pop music,United States,Don't Mind (2016#48)
|
Kent Jones,,,,,,Don't Mind (2016#48)
|
||||||
Kesha,Q33605,female,solo,electropop; dance-pop; synth-pop; pop rock; pop rap; rock music; dance music; country music,United States,Praying (2017#67)
|
Kesha,,,,,,Praying (2017#67)
|
||||||
Kevin Gates,Q6396353,male,solo,hip-hop; gangsta rap; hardcore hip-hop; dirty south; Southern hip-hop,United States,2 Phones (2016#57)|Really Really (2016#92)
|
Kevin Gates,,,,,,2 Phones (2016#57)|Really Really (2016#92)
|
||||||
Kevin Woo,Q6397789,male,solo,K-pop,United States,Soda Pop (2025#61)|Your Idol (2025#54)
|
Kevin Woo,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
Khalid,Q28816483,male,solo,contemporary R&B,United States,1-800-273-8255 (2017#31/2018#84)|Be Like That (2020#88)|Beautiful People (2019#50)|Better (2019#20)|Eastside (2018#77/2019#17)|Location (2017#21)|Love Lies (2018#19/2019#88)|Talk (2019#8)|Young Dumb & Broke (2017#78/2018#67)
|
Khalid,,,,,,1-800-273-8255 (2017#31/2018#84)|Be Like That (2020#88)|Beautiful People (2019#50)|Better (2019#20)|Eastside (2018#77/2019#17)|Location (2017#21)|Love Lies (2018#19/2019#88)|Talk (2019#8)|Young Dumb & Broke (2017#78/2018#67)
|
||||||
Kid Cudi,Q299138,male,solo,Midwest hip-hop; contemporary R&B; alternative hip-hop; pop rap; alternative rock; trip hop; neo-psychedelia; punk rock; grunge; emo rap; psychedelic rap; psychedelic hip-hop,United States,The Scotts (2020#86)
|
Kid Cudi,,,,,,The Scotts (2020#86)
|
||||||
Kiiara,Q23042767,female,solo,pop music; electropop; alternative R&B; experimental pop,United States,Gold (2016#52)
|
Kiiara,,,,,,Gold (2016#52)
|
||||||
Kim Petras,Q60964,trans woman,solo,pop music,Germany,Unholy (2022#98/2023#11)
|
Kim Petras,,,,,,Unholy (2022#98/2023#11)
|
||||||
Kodak Black,Q22005867,male,solo,hip-hop; trap music; SoundCloud rap; gangsta rap; Southern hip-hop; mumble rap,United States,Drowning (2017#81)|Super Gremlin (2022#9)|Tunnel Vision (2017#55)|Wake Up In The Sky (2019#51)|What It Is (Block Boy) (2023#76)|ZEZE (2019#31)
|
Kodak Black,,,,,,Drowning (2017#81)|Super Gremlin (2022#9)|Tunnel Vision (2017#55)|Wake Up In The Sky (2019#51)|What It Is (Block Boy) (2023#76)|ZEZE (2019#31)
|
||||||
Koe Wetzel,Q107434048,male,solo,country music; rock music,United States,High Road (2024#81/2025#55)
|
Koe Wetzel,,,,,,High Road (2024#81/2025#55)
|
||||||
Kygo,Q16845512,male,solo,tropical house; deep house; downtempo; progressive house; house music; electronic dance music,Norway,It Ain't Me (2017#27)
|
Kygo,,,,,,It Ain't Me (2017#27)
|
||||||
Kyla,Q25190133,female,solo,UK funky,United Kingdom,One Dance (2016#3)
|
Kyla,,,,,,One Dance (2016#3)
|
||||||
KYLE,Q28606665,male,solo,West Coast hip-hop; pop rap,United States,iSpy (2017#28)
|
KYLE,,,,,,iSpy (2017#28)
|
||||||
Lady Gaga,Q19848,female,solo,electropop; dance-pop; electronic dance music; art pop; pop music; synth-pop,United States,Abracadabra (2025#56)|Bloody Mary (2023#99)|Die With A Smile (2024#62/2025#1)|Rain On Me (2020#48)|Shallow (2019#19)
|
Lady Gaga,,,,,,Abracadabra (2025#56)|Bloody Mary (2023#99)|Die With A Smile (2024#62/2025#1)|Rain On Me (2020#48)|Shallow (2019#19)
|
||||||
Lainey Wilson,Q106378786,female,solo,country music; folk music,United States,Heart Like A Truck (2023#58)|Never Say Never (2022#92)|Save Me (2024#65)|Things A Man Oughta Know (2021#96)|Wait In The Truck (2023#54)|Watermelon Moonshine (2023#100)
|
Lainey Wilson,,,,,,Heart Like A Truck (2023#58)|Never Say Never (2022#92)|Save Me (2024#65)|Things A Man Oughta Know (2021#96)|Wait In The Truck (2023#54)|Watermelon Moonshine (2023#100)
|
||||||
Latto,Q65029930,female,solo,hip-hop; trap music; pop music; pop rap,United States,Big Energy (2022#7)|Put It On Da Floor Again (2023#98)|Seven (2023#82)
|
Latto,,,,,,Big Energy (2022#7)|Put It On Da Floor Again (2023#98)|Seven (2023#82)
|
||||||
Lauren Alaina,Q271796,female,solo,country music; folk music,United States,What Ifs (2017#72)
|
Lauren Alaina,,,,,,What Ifs (2017#72)
|
||||||
Lauren Daigle,Q18685595,female,solo,contemporary Christian music,United States,You Say (2019#60)
|
Lauren Daigle,,,,,,You Say (2019#60)
|
||||||
Lauren Spencer-Smith,Q110630787,female,solo,pop music,Canada,Fingers Crossed (2022#75)
|
Lauren Spencer-Smith,,,,,,Fingers Crossed (2022#75)
|
||||||
Lauv,Q30122224,male,solo,electronic music; pop music,United States,I Like Me Better (2018#35)
|
Lauv,,,,,,I Like Me Better (2018#35)
|
||||||
Lee Brice,Q1811729,male,solo,country music,United States,I Hope You're Happy Now (2020#73)|One Of Them Girls (2020#68)|Rumor (2019#81)
|
Lee Brice,,,,,,I Hope You're Happy Now (2020#73)|One Of Them Girls (2020#68)|Rumor (2019#81)
|
||||||
Lefty Gunplay,Q131374668,male,solo,hip-hop; West Coast hip-hop; gangsta rap,United States,TV Off (2025#15)
|
Lefty Gunplay,,,,,,TV Off (2025#15)
|
||||||
Leon Thomas,Q714375,male,solo,contemporary R&B; pop music; soul; hip-hop,United States,Mutt (2025#22)
|
Leon Thomas,,,,,,Mutt (2025#22)
|
||||||
Lewis Capaldi,Q42315001,male,solo,blue-eyed soul; pop music; pop rock; alternative rock; soft rock,United Kingdom,Before You Go (2020#21)|Someone You Loved (2019#27/2020#10)
|
Lewis Capaldi,,,,,,Before You Go (2020#21)|Someone You Loved (2019#27/2020#10)
|
||||||
Liam Payne,Q2756349,male,solo,pop music; contemporary R&B,United Kingdom,Strip That Down (2017#36)
|
Liam Payne,,,,,,Strip That Down (2017#36)
|
||||||
Lil Baby,Q50527563,male,solo,hip-hop; trap music; Southern hip-hop,United States,Baby (2019#84)|Close Friends (2019#74)|Drip Too Hard (2019#26)|Emotionally Scarred (2020#74)|Every Chance I Get (2021#59)|For The Night (2020#49/2021#32)|In A Minute (2022#43)|On Me (2021#36)|Sum 2 Prove (2020#79)|The Bigger Picture (2020#76)|Wants And Needs (2021#47)|We Paid (2020#57)|Woah (2020#42)|Yes Indeed (2018#25)
|
Lil Baby,,,,,,Baby (2019#84)|Close Friends (2019#74)|Drip Too Hard (2019#26)|Emotionally Scarred (2020#74)|Every Chance I Get (2021#59)|For The Night (2020#49/2021#32)|In A Minute (2022#43)|On Me (2021#36)|Sum 2 Prove (2020#79)|The Bigger Picture (2020#76)|Wants And Needs (2021#47)|We Paid (2020#57)|Woah (2020#42)|Yes Indeed (2018#25)
|
||||||
Lil Dicky,Q16876006,male,solo,hip-hop; pop rap; comedy hip-hop,United States,Freaky Friday (2018#55)
|
Lil Dicky,,,,,,Freaky Friday (2018#55)
|
||||||
Lil Durk,Q2899818,male,solo,Midwest hip-hop; trap music; drill; gangsta rap; hardcore hip-hop; Chicago hip-hop; Chicago drill,United States,All My Life (2023#25)|Back In Blood (2021#39)|Broadway Girls (2022#81)|Every Chance I Get (2021#59)|Laugh Now Cry Later (2020#41/2021#45)|No More Parties (2021#79)|What Happened To Virgil (2022#83)
|
Lil Durk,,,,,,All My Life (2023#25)|Back In Blood (2021#39)|Broadway Girls (2022#81)|Every Chance I Get (2021#59)|Laugh Now Cry Later (2020#41/2021#45)|No More Parties (2021#79)|What Happened To Virgil (2022#83)
|
||||||
Lil Mosey,Q55819454,male,solo,hip-hop; pop rap; trap music,United States,Blueberry Faygo (2020#27)
|
Lil Mosey,,,,,,Blueberry Faygo (2020#27)
|
||||||
Lil Nas X,Q62591281,male,solo,hip-hop; pop rap; country rap; trap music; pop music; rock music; pop rock; country music; Southern hip-hop,United States,Industry Baby (2021#24/2022#16)|Montero (Call Me By Your Name) (2021#9)|Old Town Road (2019#1)|Panini (2019#40/2020#71)|Thats What I Want (2022#14)
|
Lil Nas X,,,,,,Industry Baby (2021#24/2022#16)|Montero (Call Me By Your Name) (2021#9)|Old Town Road (2019#1)|Panini (2019#40/2020#71)|Thats What I Want (2022#14)
|
||||||
Lil Pump,Q38002101,male,solo,hip-hop; trap music; SoundCloud rap; punk rap; Southern hip-hop,United States,Gucci Gang (2018#44)
|
Lil Pump,,,,,,Gucci Gang (2018#44)
|
||||||
Lil Tecca,Q65054660,male,solo,hip-hop; trap music,United States,Dark Thoughts (2025#96)|Ran$om (2019#28)
|
Lil Tecca,,,,,,Dark Thoughts (2025#96)|Ran$om (2019#28)
|
||||||
Lil Tjay,Q61670761,male,solo,hip-hop,United States,Calling My Phone (2021#33)|Mood Swings (2020#81)|Pop Out (2019#45)
|
Lil Tjay,,,,,,Calling My Phone (2021#33)|Mood Swings (2020#81)|Pop Out (2019#45)
|
||||||
Lil Uzi Vert,Q23771950,non-binary,solo,hip-hop; emo rap; trap music; SoundCloud rap; punk rap; psychedelic rap; rage; cloud rap; psychedelic hip-hop; East Coast hip-hop,United States,Bad And Boujee (2017#6)|Everybody (2024#71)|Just Wanna Rock (2023#28)|XO TOUR Llif3 (2017#13)
|
Lil Uzi Vert,,,,,,Bad And Boujee (2017#6)|Everybody (2024#71)|Just Wanna Rock (2023#28)|XO TOUR Llif3 (2017#13)
|
||||||
Lil Wayne,Q15615,male,solo,Southern hip-hop; bounce music; gangsta rap; trap music; pop rap; dirty south; hardcore hip-hop,United States,I'm The One (2017#12)|Sticky (2025#41)|Sucker For Pain (2016#68)|Whats Poppin (2020#13)
|
Lil Wayne,,,,,,I'm The One (2017#12)|Sticky (2025#41)|Sucker For Pain (2016#68)|Whats Poppin (2020#13)
|
||||||
Lil Yachty,Q23772141,male,solo,hip-hop; trap music; pop rap; mumble rap; cloud rap; Southern hip-hop,United States,Broccoli (2016#34/2017#70)|iSpy (2017#28)
|
Lil Yachty,,,,,,Broccoli (2016#34/2017#70)|iSpy (2017#28)
|
||||||
Lizzo,Q16232225,female,solo,hip-hop; soul; contemporary R&B; gospel music,United States,About Damn Time (2022#12)|Good As Hell (2020#31)|Truth Hurts (2019#13/2020#55)
|
Lizzo,,,,,,About Damn Time (2022#12)|Good As Hell (2020#31)|Truth Hurts (2019#13/2020#55)
|
||||||
Logic,Q6667429,male,solo,hip-hop; pop rap; conscious hip-hop; East Coast hip-hop,United States,1-800-273-8255 (2017#31/2018#84)|Sucker For Pain (2016#68)
|
Logic,,,,,,1-800-273-8255 (2017#31/2018#84)|Sucker For Pain (2016#68)
|
||||||
Lola Young,Q115869427,female,solo,pop music; soul; contemporary R&B,United Kingdom,Like Him (2025#62)|Messy (2025#28)
|
Lola Young,,,,,,Like Him (2025#62)|Messy (2025#28)
|
||||||
Luis Fonsi,Q1387726,male,solo,pop music; Latin pop; ballad; reggaeton,United States,Despacito (2017#2)
|
Luis Fonsi,,,,,,Despacito (2017#2)
|
||||||
Lukas Graham,Q300935,,group,soul; pop music; pop rock; funk,Denmark,7 Years (2016#12)
|
Lukas Graham,,,,,,7 Years (2016#12)
|
||||||
Luke Bryan,Q166197,male,solo,country music,United States,Buy Dirt (2022#49)|Knockin' Boots (2019#79)|One Margarita (2020#70)
|
Luke Bryan,,,,,,Buy Dirt (2022#49)|Knockin' Boots (2019#79)|One Margarita (2020#70)
|
||||||
Luke Combs,Q23884740,male,solo,country music,United States,"Ain't No Love In Oklahoma (2024#37)|Backup Plan (2025#80)|Beautiful Crazy (2019#46)|Beer Never Broke My Heart (2019#63)|Better Together (2021#57)|Does To Me (2020#93)|Doin' This (2022#71)|Even Though I'm Leaving (2020#85)|Fast Car (2023#8/2024#23)|Forever After All (2021#18)|Going, Going, Gone (2023#59)|Hurricane (2017#76)|Love You Anyway (2023#36)|Lovin' On You (2020#97)|One Number Away (2018#96)|The Kind Of Love We Make (2022#36)|Where The Wild Things Are (2024#70)"
|
Luke Combs,,,,,,"Ain't No Love In Oklahoma (2024#37)|Backup Plan (2025#80)|Beautiful Crazy (2019#46)|Beer Never Broke My Heart (2019#63)|Better Together (2021#57)|Does To Me (2020#93)|Doin' This (2022#71)|Even Though I'm Leaving (2020#85)|Fast Car (2023#8/2024#23)|Forever After All (2021#18)|Going, Going, Gone (2023#59)|Hurricane (2017#76)|Love You Anyway (2023#36)|Lovin' On You (2020#97)|One Number Away (2018#96)|The Kind Of Love We Make (2022#36)|Where The Wild Things Are (2024#70)"
|
||||||
Machine Gun Kelly,Q1354843,male,solo,hip-hop; pop rap; rap rock; pop-punk; alternative rock; alternative hip-hop,United States,Bad Things (2017#41)|My Ex's Best Friend (2021#23)
|
Machine Gun Kelly,,,,,,Bad Things (2017#41)|My Ex's Best Friend (2021#23)
|
||||||
Maddie,Q54607228,female,solo,country music,United States,Die From A Broken Heart (2020#65)
|
Maddie,,,,,,Die From A Broken Heart (2020#65)
|
||||||
Mae Muller,Q83696883,female,solo,pop music; rhythm and blues,United Kingdom,Better Days (2022#73)
|
Mae Muller,,,,,,Better Days (2022#73)
|
||||||
Major Lazer,Q2604214,,group,dancehall; moombahton; reggae fusion; trap music; electronic dance music; hip-hop; electro house; rocksteady,United States,Cold Water (2016#25)|Lean On (2016#85)
|
Major Lazer,,,,,,Cold Water (2016#25)|Lean On (2016#85)
|
||||||
Maren Morris,Q21622122,female,solo,country music; country pop; folk rock; folk music,United States,Chasing After You (2021#61)|Circles Around This Town (2022#100)|The Bones (2020#9)|The Middle (2018#8)
|
Maren Morris,,,,,,Chasing After You (2021#61)|Circles Around This Town (2022#100)|The Bones (2020#9)|The Middle (2018#8)
|
||||||
Mariah Carey,Q41076,female,solo,contemporary R&B; pop music; adult contemporary music; dance-pop; hip-hop,United States,All I Want For Christmas Is You (2020#67/2021#78/2022#65/2023#55/2024#54/2025#59)
|
Mariah Carey,,,,,,All I Want For Christmas Is You (2020#67/2021#78/2022#65/2023#55/2024#54/2025#59)
|
||||||
Mariah the Scientist,Q84356665,female,solo,alternative R&B,United States,Burning Blue (2025#58)
|
Mariah the Scientist,,,,,,Burning Blue (2025#58)
|
||||||
Marian Hill,Q20858093,,group,electronic music,United States,Down (2017#90)
|
Marian Hill,,,,,,Down (2017#90)
|
||||||
Maroon 5,Q182223,,group,alternative rock; funk rock; pop rock; pop music,United States,Beautiful Mistakes (2021#34)|Cold (2017#69)|Don't Wanna Know (2017#38)|Girls Like You (2018#10/2019#22)|Memories (2020#8)|Wait (2018#58)|What Lovers Do (2017#97/2018#89)
|
Maroon 5,,,,,,Beautiful Mistakes (2021#34)|Cold (2017#69)|Don't Wanna Know (2017#38)|Girls Like You (2018#10/2019#22)|Memories (2020#8)|Wait (2018#58)|What Lovers Do (2017#97/2018#89)
|
||||||
Marshmello,Q26334949,male,solo,electronic dance music,United States,Come & Go (2020#54)|Friends (2018#26)|Happier (2018#80/2019#6)|Leave Before You Love Me (2021#63)|Miles On It (2024#34)|One Thing Right (2019#89)|Wolves (2018#60)
|
Marshmello,,,,,,Come & Go (2020#54)|Friends (2018#26)|Happier (2018#80/2019#6)|Leave Before You Love Me (2021#63)|Miles On It (2024#34)|One Thing Right (2019#89)|Wolves (2018#60)
|
||||||
Masked Wolf,Q105490126,male,solo,hip-hop,Australia,Astronaut In The Ocean (2021#20)
|
Masked Wolf,,,,,,Astronaut In The Ocean (2021#20)
|
||||||
Mauro Castillo,Q16605580,male,solo,salsa,Colombia,We Don't Talk About Bruno (2022#24)
|
Mauro Castillo,,,,,,We Don't Talk About Bruno (2022#24)
|
||||||
MAX,Q1190160,male,solo,pop music,United States,Lights Down Low (2018#66)
|
MAX,,,,,,Lights Down Low (2018#66)
|
||||||
Meek Mill,Q1897911,male,solo,East Coast hip-hop; hardcore hip-hop; gangsta rap; trap music; hip-hop,United States,Going Bad (2019#18)
|
Meek Mill,,,,,,Going Bad (2019#18)
|
||||||
Megan Moroney,Q116786312,female,solo,country music; folk music,United States,Am I Okay? (2025#66)|Tennessee Orange (2023#67)
|
Megan Moroney,,,,,,Am I Okay? (2025#66)|Tennessee Orange (2023#67)
|
||||||
Megan Thee Stallion,Q59268408,female,solo,hip-hop; trap music; dirty rap; pop rap; pop music,United States,34+35 (2021#21)|Beautiful Mistakes (2021#34)|Body (2021#54)|Cash Shit (2019#90)|Cry Baby (2021#77)|Hot Girl Summer (2019#93)|Savage (2020#15)|Sweetest Pie (2022#62)|Thot Shit (2021#71)|Wanna Be (2024#40)|WAP (2020#24)
|
Megan Thee Stallion,,,,,,34+35 (2021#21)|Beautiful Mistakes (2021#34)|Body (2021#54)|Cash Shit (2019#90)|Cry Baby (2021#77)|Hot Girl Summer (2019#93)|Savage (2020#15)|Sweetest Pie (2022#62)|Thot Shit (2021#71)|Wanna Be (2024#40)|WAP (2020#24)
|
||||||
Meghan Trainor,Q17403494,female,solo,pop music; contemporary R&B; dance-pop; soul; doo-wop,United States,Like I'm Gonna Lose You (2016#42)|Made You Look (2023#53)|Me Too (2016#62)|No (2016#45)
|
Meghan Trainor,,,,,,Like I'm Gonna Lose You (2016#42)|Made You Look (2023#53)|Me Too (2016#62)|No (2016#45)
|
||||||
Metro Boomin,Q16235273,male,solo,hip-hop; trap music,United States,Creepin' (2023#5)|Like That (2024#14)|Ric Flair Drip (2018#38)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)
|
Metro Boomin,,,,,,Creepin' (2023#5)|Like That (2024#14)|Ric Flair Drip (2018#38)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)
|
||||||
Migos,Q15777045,,group,Southern hip-hop; trap music; mumble rap,United States,Bad And Boujee (2017#6)|I Get The Bag (2017#93/2018#72)|MotorSport (2018#34)|Pure Water (2019#55)|Slide (2017#71)|Slippery (2017#86)|Stir Fry (2018#48)|T-Shirt (2017#52)|Walk It Talk It (2018#43)
|
Migos,,,,,,Bad And Boujee (2017#6)|I Get The Bag (2017#93/2018#72)|MotorSport (2018#34)|Pure Water (2019#55)|Slide (2017#71)|Slippery (2017#86)|Stir Fry (2018#48)|T-Shirt (2017#52)|Walk It Talk It (2018#43)
|
||||||
Miguel,Q73463,male,solo,contemporary R&B; alternative R&B; hip-hop; pop music; soul,United States,Sky Walker (2018#75)|Sure Thing (2023#24)
|
Miguel,,,,,,Sky Walker (2018#75)|Sure Thing (2023#24)
|
||||||
Mike Posner,Q268284,male,solo,pop music,United States,I Took A Pill In Ibiza (2016#15)
|
Mike Posner,,,,,,I Took A Pill In Ibiza (2016#15)
|
||||||
Miley Cyrus,Q4235,genderfluid,solo,pop music; country music; pop rock; dance-pop; electropop; rock music; hip-hop; teen pop; country pop,United States,Flowers (2023#2/2024#69)|Malibu (2017#89)
|
Miley Cyrus,,,,,,Flowers (2023#2/2024#69)|Malibu (2017#89)
|
||||||
Miranda Lambert,Q231233,female,solo,country music; folk music,United States,Bluebird (2020#91)
|
Miranda Lambert,,,,,,Bluebird (2020#91)
|
||||||
MNEK,Q6716905,male,solo,pop music,United Kingdom,Never Forget You (2016#46)
|
MNEK,,,,,,Never Forget You (2016#46)
|
||||||
Moneybagg Yo,Q38459032,male,solo,hip-hop; trap music; gangsta rap; Memphis rap; Southern hip-hop,United States,Said Sum (2020#98)|Time Today (2021#76)|Wockesha (2021#42)
|
Moneybagg Yo,,,,,,Said Sum (2020#98)|Time Today (2021#76)|Wockesha (2021#42)
|
||||||
Mooski,Q113554625,male,solo,hip-hop,United States,Track Star (2021#75)
|
Mooski,,,,,,Track Star (2021#75)
|
||||||
Morgan Wallen,Q29561472,male,solo,country music; country pop; bro-country; country rap,United States,20 Cigarettes (2025#90)|Ain't That Some (2023#92)|Broadway Girls (2022#81)|Chasin' You (2020#34)|Cowgirls (2024#29)|Everything I Love (2023#56)|Flower Shops (2022#96)|I Ain't Coming Back (2025#72)|I Got Better (2025#40)|I Had Some Help (2024#4/2025#8)|I Wrote The Book (2023#95)|I'm The Problem (2025#13)|Just In Case (2025#18)|Last Night (2023#1/2024#28)|Lies Lies Lies (2024#50)|Love Somebody (2025#11)|More Than My Hometown (2020#96)|One Thing At A Time (2023#37)|Sand In My Boots (2022#58)|Smile (2025#64)|Superman (2025#99)|Thinkin' Bout Me (2023#19/2024#31)|Thought You Should Know (2023#22)|Wasted On You (2022#19)|What I Want (2025#27)|Whiskey Glasses (2019#52)|You Proof (2022#27/2023#12)
|
Morgan Wallen,,,,,,20 Cigarettes (2025#90)|Ain't That Some (2023#92)|Broadway Girls (2022#81)|Chasin' You (2020#34)|Cowgirls (2024#29)|Everything I Love (2023#56)|Flower Shops (2022#96)|I Ain't Coming Back (2025#72)|I Got Better (2025#40)|I Had Some Help (2024#4/2025#8)|I Wrote The Book (2023#95)|I'm The Problem (2025#13)|Just In Case (2025#18)|Last Night (2023#1/2024#28)|Lies Lies Lies (2024#50)|Love Somebody (2025#11)|More Than My Hometown (2020#96)|One Thing At A Time (2023#37)|Sand In My Boots (2022#58)|Smile (2025#64)|Superman (2025#99)|Thinkin' Bout Me (2023#19/2024#31)|Thought You Should Know (2023#22)|Wasted On You (2022#19)|What I Want (2025#27)|Whiskey Glasses (2019#52)|You Proof (2022#27/2023#12)
|
||||||
Muni Long,Q7245622,female,solo,contemporary R&B; pop music,United States,Hrs And Hrs (2022#57)|Made For Me (2024#56)
|
Muni Long,,,,,,Hrs And Hrs (2022#57)|Made For Me (2024#56)
|
||||||
Murda Beatz,Q27970613,male,solo,hip-hop,Canada,FEFE (2018#31)
|
Murda Beatz,,,,,,FEFE (2018#31)
|
||||||
Mustard,Q15941115,male,solo,hip-hop; ratchet music; West Coast hip-hop; contemporary R&B,United States,Ballin' (2020#25)|High Fashion (2020#40)|Pure Water (2019#55)
|
Mustard,,,,,,Ballin' (2020#25)|High Fashion (2020#40)|Pure Water (2019#55)
|
||||||
Myles Smith,Q126076580,male,solo,folk-pop; folk music; pop music; folk rock,United Kingdom,Stargazing (2024#73/2025#29)
|
Myles Smith,,,,,,Stargazing (2024#73/2025#29)
|
||||||
Måneskin,Q56884561,,group,pop rock; glam rock,Italy,Beggin' (2021#66)
|
Måneskin,,,,,,Beggin' (2021#66)
|
||||||
MØ,Q10670047,female,solo,indie pop; electronic music,Kingdom of Denmark,Cold Water (2016#25)|Lean On (2016#85)
|
MØ,,,,,,Cold Water (2016#25)|Lean On (2016#85)
|
||||||
N*E*R*D,Q941293,,group,funk rock; bossa nova; orchestral pop; hip-hop; skate punk,United States,Lemon (2018#83)
|
N*E*R*D,,,,,,Lemon (2018#83)
|
||||||
Natanael Cano,Q104904549,male,solo,corridos tumbados; regional Mexican,Mexico,PRC (2023#90)
|
Natanael Cano,,,,,,PRC (2023#90)
|
||||||
Nate Smith,Q110707312,male,solo,country music; country rock,United States,Bulletproof (2024#96)|World On Fire (2024#90)
|
Nate Smith,,,,,,Bulletproof (2024#96)|World On Fire (2024#90)
|
||||||
NAV,Q28831246,male,solo,hip-hop; cloud rap; trap music; contemporary R&B,Canada,Lemonade (2021#51)
|
NAV,,,,,,Lemonade (2021#51)
|
||||||
Neckwav,Q140814188,male,solo,contemporary R&B,South Korea,Soda Pop (2025#61)|Your Idol (2025#54)
|
Neckwav,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
NEIKED,Q26703294,,group,,Sweden,Better Days (2022#73)
|
NEIKED,,,,,,Better Days (2022#73)
|
||||||
Nelly,Q218992,male,solo,Midwest hip-hop; pop rap; contemporary R&B; pop music; country rap; country music,United States,Lil Bit (2021#70)
|
Nelly,,,,,,Lil Bit (2021#70)
|
||||||
Netón Vega,Q129208676,male,solo,regional Mexican; corridos tumbados,Mexico,Loco (2025#100)
|
Netón Vega,,,,,,Loco (2025#100)
|
||||||
NF,Q19571648,male,solo,hip-hop,United States,Let You Down (2018#29)
|
NF,,,,,,Let You Down (2018#29)
|
||||||
Niall Horan,Q775231,male,solo,pop music; pop rock; folk-pop,Ireland,Slow Hands (2017#32)
|
Niall Horan,,,,,,Slow Hands (2017#32)
|
||||||
Nick Jonas,Q201656,male,solo,pop music; rock music; contemporary R&B,United States,Close (2016#66)
|
Nick Jonas,,,,,,Close (2016#66)
|
||||||
Nicki Minaj,Q162202,female,solo,hip-hop; pop music; contemporary R&B; trap music; dance music,Trinidad and Tobago,Barbie World (2023#46)|Big Bank (2018#63)|Down In The DM (2016#69)|Everybody (2024#71)|FEFE (2018#31)|FTCU (2024#88)|Hot Girl Summer (2019#93)|MotorSport (2018#34)|Plain Jane (2018#74)|Princess Diana (2023#69)|Rake It Up (2017#53)|Side To Side (2016#77/2017#43)|Super Freaky Girl (2022#56/2023#79)|Swalla (2017#85)
|
Nicki Minaj,,,,,,Barbie World (2023#46)|Big Bank (2018#63)|Down In The DM (2016#69)|Everybody (2024#71)|FEFE (2018#31)|FTCU (2024#88)|Hot Girl Summer (2019#93)|MotorSport (2018#34)|Plain Jane (2018#74)|Princess Diana (2023#69)|Rake It Up (2017#53)|Side To Side (2016#77/2017#43)|Super Freaky Girl (2022#56/2023#79)|Swalla (2017#85)
|
||||||
Nicky Jam,Q1374560,male,solo,reggaeton; Latin trap,United States,Te Bote (2018#81)|X (2018#90)
|
Nicky Jam,,,,,,Te Bote (2018#81)|X (2018#90)
|
||||||
Nicky Youre,Q112666811,male,solo,indie pop,United States,Sunroof (2022#29)
|
Nicky Youre,,,,,,Sunroof (2022#29)
|
||||||
Niko Moon,Q67399825,male,solo,country music,United States,Good Time (2021#84)
|
Niko Moon,,,,,,Good Time (2021#84)
|
||||||
Nio García,Q107358694,male,solo,Latin trap; reggaeton,United States,Te Bote (2018#81)
|
Nio García,,,,,,Te Bote (2018#81)
|
||||||
NLE Choppa,Q64009351,male,solo,trap music; hip-hop; drill; Memphis rap; gangsta rap; Southern hip-hop,United States,Shotta Flow (2019#92)|Walk Em Down (2020#100)
|
NLE Choppa,,,,,,Shotta Flow (2019#92)|Walk Em Down (2020#100)
|
||||||
Noah Kahan,Q53832755,male,solo,folk-pop,United States,Dial Drunk (2023#80)|Stick Season (2024#11)
|
Noah Kahan,,,,,,Dial Drunk (2023#80)|Stick Season (2024#11)
|
||||||
Normani,Q28148022,female,solo,pop music; contemporary R&B,United States,Dancing With A Stranger (2019#14)|Love Lies (2018#19/2019#88)
|
Normani,,,,,,Dancing With A Stranger (2019#14)|Love Lies (2018#19/2019#88)
|
||||||
O.T. Genasis,Q18623749,male,solo,hip-hop; trap music,United States,Cut It (2016#91)
|
O.T. Genasis,,,,,,Cut It (2016#91)
|
||||||
Offset,Q30612480,male,solo,hip-hop; trap music; Southern hip-hop,United States,Clout (2019#87)|Ric Flair Drip (2018#38)|Taste (2018#28)|ZEZE (2019#31)
|
Offset,,,,,,Clout (2019#87)|Ric Flair Drip (2018#38)|Taste (2018#28)|ZEZE (2019#31)
|
||||||
Old Dominion,Q20685018,,group,country pop,United States,Memory Lane (2023#87)|One Man Band (2020#59)
|
Old Dominion,,,,,,Memory Lane (2023#87)|One Man Band (2020#59)
|
||||||
Oliver Anthony Music,Q121391237,male,solo,country music; folk music,United States,Rich Men North Of Richmond (2023#78)
|
Oliver Anthony Music,,,,,,Rich Men North Of Richmond (2023#78)
|
||||||
Olivia O'Brien,Q23978275,female,solo,dance-pop; pop music,United States,I Hate U I Love U (2016#38)
|
Olivia O'Brien,,,,,,I Hate U I Love U (2016#38)
|
||||||
Olivia Rodrigo,Q63243883,female,solo,indie pop; pop music; rock music,United States,Deja Vu (2021#13)|Drivers License (2021#8)|Good 4 U (2021#5/2022#31)|Traitor (2021#38)|Vampire (2023#30/2024#57)
|
Olivia Rodrigo,,,,,,Deja Vu (2021#13)|Drivers License (2021#8)|Good 4 U (2021#5/2022#31)|Traitor (2021#38)|Vampire (2023#30/2024#57)
|
||||||
One Direction,Q146027,,group,dance-pop; teen pop; pop rock,United Kingdom,Perfect (2016#100)
|
One Direction,,,,,,Perfect (2016#100)
|
||||||
OneRepublic,Q1438730,,group,pop rock; alternative rock; cello rock,United States,I Ain't Worried (2022#37)
|
OneRepublic,,,,,,I Ain't Worried (2022#37)
|
||||||
Ozuna,Q28739901,male,solo,reggaeton; Latin trap; Latin pop; dancehall,United States,Taki Taki (2019#57)|Te Bote (2018#81)
|
Ozuna,,,,,,Taki Taki (2019#57)|Te Bote (2018#81)
|
||||||
P!nk,Q160009,female,solo,pop music; pop rock; contemporary R&B,United States,Just Like Fire (2016#33)|One Too Many (2021#81)|Walk Me Home (2019#99)|What About Us (2017#84)
|
P!nk,,,,,,Just Like Fire (2016#33)|One Too Many (2021#81)|Walk Me Home (2019#99)|What About Us (2017#84)
|
||||||
Panic! At The Disco,Q277551,,group,pop rock; synth-pop; pop-punk; baroque pop; emo pop; alternative rock,United States,"Hey Look Ma, I Made It (2019#61)|High Hopes (2019#11)"
|
Panic! At The Disco,,,,,,"Hey Look Ma, I Made It (2019#61)|High Hopes (2019#11)"
|
||||||
Parker McCollum,Q20684755,male,solo,Americana; country music,United States,Burn It Down (2024#84)|Handle On You (2023#85)
|
Parker McCollum,,,,,,Burn It Down (2024#84)|Handle On You (2023#85)
|
||||||
Parmalee,Q7139162,,group,country music,United States,Just The Way (2021#88)|Take My Name (2022#82)
|
Parmalee,,,,,,Just The Way (2021#88)|Take My Name (2022#82)
|
||||||
PARTYNEXTDOOR,Q15929398,male,solo,Canadian hip-hop; alternative R&B,Canada,Somebody Loves Me (2025#85)
|
PARTYNEXTDOOR,,,,,,Somebody Loves Me (2025#85)
|
||||||
Paul Russell,Q122932071,male,solo,hip-hop; contemporary R&B; pop rap,United States,Lil Boo Thang (2024#78)
|
Paul Russell,,,,,,Lil Boo Thang (2024#78)
|
||||||
Peso Pluma,Q117242724,male,solo,regional Mexican; corridos tumbados; reggaeton; Latin trap; cumbia norteña mexicana,Mexico,Ella Baila Sola (2023#26)|La Bebe (2023#40)|Por Las Noches (2023#86)|PRC (2023#90)
|
Peso Pluma,,,,,,Ella Baila Sola (2023#26)|La Bebe (2023#40)|Por Las Noches (2023#86)|PRC (2023#90)
|
||||||
Pharrell Williams,Q14313,male,solo,contemporary R&B; hip-hop; pop music; funk; rock music; pop rap,United States,Feels (2017#74)
|
Pharrell Williams,,,,,,Feels (2017#74)
|
||||||
Pinkfong,Q55735607,,,children's music,South Korea,Baby Shark (2019#75)
|
Pinkfong,,,,,,Baby Shark (2019#75)
|
||||||
PinkPantheress,Q107570416,female,solo,pop music; bedroom pop; dance music; alternative pop; drum and bass; 2-step garage; jungle; hyperpop,United Kingdom,"Boy's A Liar, Pt. 2 (2023#20)"
|
PinkPantheress,,,,,,"Boy's A Liar, Pt. 2 (2023#20)"
|
||||||
Playboi Carti,Q27671080,male,solo,Southern hip-hop; trap music; mumble rap; cloud rap; rage; punk rap; alternative hip-hop; plugg music; lo-fi music; SoundCloud rap,United States,Carnival (2024#38)|FE!N (2024#97)|Magnolia (2017#79)|Rather Lie (2025#91)|Timeless (2025#16)|Type Shit (2024#51)
|
Playboi Carti,,,,,,Carnival (2024#38)|FE!N (2024#97)|Magnolia (2017#79)|Rather Lie (2025#91)|Timeless (2025#16)|Type Shit (2024#51)
|
||||||
PnB Rock,Q28155029,male,solo,hip-hop; contemporary R&B; trap music; pop music; pop rap,United States,Everyday We Lit (2017#95)
|
PnB Rock,,,,,,Everyday We Lit (2017#95)
|
||||||
Polo G,Q62953486,male,solo,hip-hop; drill; trap music; conscious hip-hop; Midwest hip-hop; Chicago hip-hop,United States,Better Days (2022#73)|Pop Out (2019#45)|Rapstar (2021#30)
|
Polo G,,,,,,Better Days (2022#73)|Pop Out (2019#45)|Rapstar (2021#30)
|
||||||
Pooh Shiesty,Q105105274,male,solo,trap music; Southern hip-hop; drill; hardcore hip-hop; gangsta rap; Memphis rap,United States,Back In Blood (2021#39)|Beat Box (2021#44)
|
Pooh Shiesty,,,,,,Back In Blood (2021#39)|Beat Box (2021#44)
|
||||||
Pop Smoke,Q81698554,male,solo,East Coast hip-hop; Brooklyn drill; trap music; gangsta rap; drill; hip-hop,United States,Dior (2020#83)|For The Night (2020#49/2021#32)|Mood Swings (2020#81)|The Woo (2020#78)|What You Know Bout Love (2021#22)
|
Pop Smoke,,,,,,Dior (2020#83)|For The Night (2020#49/2021#32)|Mood Swings (2020#81)|The Woo (2020#78)|What You Know Bout Love (2021#22)
|
||||||
Portugal. The Man,Q606879,,group,pop music; indie rock,United States,Feel It Still (2017#45/2018#33)
|
Portugal. The Man,,,,,,Feel It Still (2017#45/2018#33)
|
||||||
Post Malone,Q21621919,male,solo,hip-hop; pop music; country music,United States,Better Now (2018#13/2019#32)|Chemical (2023#57)|Circles (2019#62/2020#2)|Congratulations (2017#10)|Dial Drunk (2023#80)|Fortnight (2024#22)|Goodbyes (2019#30)|I Ain't Coming Back (2025#72)|I Fall Apart (2018#39)|I Had Some Help (2024#4/2025#8)|I Like You (A Happier Song) (2022#26/2023#75)|One Right Now (2022#30)|Pour Me A Drink (2024#76)|Psycho (2018#6)|Rockstar (2017#56/2018#5)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|White Iverson (2016#65)|Wow. (2019#5)
|
Post Malone,,,,,,Better Now (2018#13/2019#32)|Chemical (2023#57)|Circles (2019#62/2020#2)|Congratulations (2017#10)|Dial Drunk (2023#80)|Fortnight (2024#22)|Goodbyes (2019#30)|I Ain't Coming Back (2025#72)|I Fall Apart (2018#39)|I Had Some Help (2024#4/2025#8)|I Like You (A Happier Song) (2022#26/2023#75)|One Right Now (2022#30)|Pour Me A Drink (2024#76)|Psycho (2018#6)|Rockstar (2017#56/2018#5)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|White Iverson (2016#65)|Wow. (2019#5)
|
||||||
Powfu,Q89069821,male,solo,hip-hop; lo-fi music; power pop; rock music,Canada,Death Bed (2020#43)
|
Powfu,,,,,,Death Bed (2020#43)
|
||||||
Project Pat,Q593741,male,solo,Memphis rap; gangsta rap; hardcore hip-hop; Southern hip-hop; crunk; dirty south; trap music; horrorcore,United States,Knife Talk (2021#86/2022#60)
|
Project Pat,,,,,,Knife Talk (2021#86/2022#60)
|
||||||
Quavo,Q30072039,male,solo,hip-hop; trap music,United States,Congratulations (2017#10)|I'm The One (2017#12)|Intentions (2020#17)|No Brainer (2018#73)|Strip That Down (2017#36)
|
Quavo,,,,,,Congratulations (2017#10)|I'm The One (2017#12)|Intentions (2020#17)|No Brainer (2018#73)|Strip That Down (2017#36)
|
||||||
Rae Sremmurd,Q18123226,,group,hip-hop; trap music; pop music; pop rap,United States,Black Beatles (2017#19)|Powerglide (2018#97)|Swang (2017#64)
|
Rae Sremmurd,,,,,,Black Beatles (2017#19)|Powerglide (2018#97)|Swang (2017#64)
|
||||||
Rauw Alejandro,Q61586861,male,solo,contemporary R&B; urban contemporary; Latin R&B; reggaeton; Latin trap; Latin pop; salsa,United States,Party (2022#77)|Todo de Ti (2021#100)
|
Rauw Alejandro,,,,,,Party (2022#77)|Todo de Ti (2021#100)
|
||||||
Ravyn Lenae,Q28962463,female,solo,contemporary R&B; alternative R&B; neo soul,United States,Love Me Not (2025#30)
|
Ravyn Lenae,,,,,,Love Me Not (2025#30)
|
||||||
RAYE,Q28071144,female,solo,contemporary R&B,United Kingdom,Escapism (2023#48)
|
RAYE,,,,,,Escapism (2023#48)
|
||||||
REI AMI,Q104842557,female,solo,K-pop; pop music; hip-hop; contemporary R&B,South Korea,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
REI AMI,,,,,,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||||
Rema,Q65071082,male,solo,Afrobeats,Nigeria,Calm Down (2023#6)
|
Rema,,,,,,Calm Down (2023#6)
|
||||||
Remy Boyz,Q63405888,,group,hip-hop,United States,679 (2016#54)
|
Remy Boyz,,,,,,679 (2016#54)
|
||||||
Remy Ma,Q275437,female,solo,East Coast hip-hop,United States,All The Way Up (2016#87)
|
Remy Ma,,,,,,All The Way Up (2016#87)
|
||||||
Rhenzy Feliz,Q34344805,male,solo,,United States,We Don't Talk About Bruno (2022#24)
|
Rhenzy Feliz,,,,,,We Don't Talk About Bruno (2022#24)
|
||||||
Rich The Kid,Q18637908,male,solo,hip-hop; trap music; mumble rap; plugg music; Southern hip-hop,United States,Carnival (2024#38)|Plug Walk (2018#57)
|
Rich The Kid,,,,,,Carnival (2024#38)|Plug Walk (2018#57)
|
||||||
Rick Ross,Q297831,male,solo,Southern hip-hop; gangsta rap; mafioso rap; trap music; hardcore hip-hop; dirty south,United States,Money In The Grave (2019#34)
|
Rick Ross,,,,,,Money In The Grave (2019#34)
|
||||||
Rihanna,Q36844,female,solo,pop music; dancehall; contemporary R&B; electronic dance music; adult contemporary music,Barbados,Lemon (2018#83)|Lift Me Up (2023#63)|Love On The Brain (2017#33)|Loyalty. (2017#66)|Needed Me (2016#13)|This Is What You Came For (2016#17)|Too Good (2016#29)|Wild Thoughts (2017#18)|Work (2016#4)
|
Rihanna,,,,,,Lemon (2018#83)|Lift Me Up (2023#63)|Love On The Brain (2017#33)|Loyalty. (2017#66)|Needed Me (2016#13)|This Is What You Came For (2016#17)|Too Good (2016#29)|Wild Thoughts (2017#18)|Work (2016#4)
|
||||||
Riley Green,Q57418818,male,solo,country music,United States,Worst Way (2025#35)
|
Riley Green,,,,,,Worst Way (2025#35)
|
||||||
Ritt Momney,Q105105308,male,solo,folk music; indie pop; synth-pop,United States,Put Your Records On (2021#93)
|
Ritt Momney,,,,,,Put Your Records On (2021#93)
|
||||||
Rod Wave,Q78754352,male,solo,hip-hop; contemporary R&B; soul; trap music,United States,Heart On Ice (2020#51)|Rags2Riches (2020#90)|Tombstone (2021#98)
|
Rod Wave,,,,,,Heart On Ice (2020#51)|Rags2Riches (2020#90)|Tombstone (2021#98)
|
||||||
Roddy Ricch,Q59209423,male,solo,West Coast hip-hop; trap music; pop rap; gangsta rap; hardcore hip-hop; hip-hop,United States,Ballin' (2020#25)|High Fashion (2020#40)|Late At Night (2021#72)|Rockstar (2020#5)|The Box (2020#3)|The Woo (2020#78)|Walk Em Down (2020#100)
|
Roddy Ricch,,,,,,Ballin' (2020#25)|High Fashion (2020#40)|Late At Night (2021#72)|Rockstar (2020#5)|The Box (2020#3)|The Woo (2020#78)|Walk Em Down (2020#100)
|
||||||
ROSÉ,Q27655344,female,solo,K-pop; dance-pop; hip-hop; electronic dance music,New Zealand,APT. (2025#9)
|
ROSÉ,,,,,,APT. (2025#9)
|
||||||
Rozes,Q22955580,female,solo,pop music; dance music; electronic dance music,United States,Roses (2016#27)
|
Rozes,,,,,,Roses (2016#27)
|
||||||
Russell Dickerson,Q24572267,male,solo,country music,United States,Happen To Me (2025#69)|She Likes It (2022#91)
|
Russell Dickerson,,,,,,Happen To Me (2025#69)|She Likes It (2022#91)
|
||||||
Ruth B,Q22056665,female,solo,pop music,Canada,Lost Boy (2016#76)
|
Ruth B,,,,,,Lost Boy (2016#76)
|
||||||
Ryan Hurd,Q42224127,male,solo,country music,United States,Chasing After You (2021#61)
|
Ryan Hurd,,,,,,Chasing After You (2021#61)
|
||||||
Sabrina Carpenter,Q7396400,female,solo,pop music; teen pop; dance-pop; contemporary R&B; electropop; country pop,United States,Bed Chem (2025#45)|Espresso (2024#7/2025#12)|Feather (2024#25)|Manchild (2025#38)|Please Please Please (2024#16/2025#50)|Taste (2024#86/2025#19)
|
Sabrina Carpenter,,,,,,Bed Chem (2025#45)|Espresso (2024#7/2025#12)|Feather (2024#25)|Manchild (2025#38)|Please Please Please (2024#16/2025#50)|Taste (2024#86/2025#19)
|
||||||
SAINt JHN,Q52274581,male,solo,hip-hop; trap music; contemporary R&B,United States,Roses (2020#19)
|
SAINt JHN,,,,,,Roses (2020#19)
|
||||||
Sam Barber,Q124428137,male,solo,country music,United States,Indigo (2025#49)
|
Sam Barber,,,,,,Indigo (2025#49)
|
||||||
Sam Hunt,Q16223171,male,solo,country music,United States,Body Like A Back Road (2017#8)|Hard To Forget (2020#69)
|
Sam Hunt,,,,,,Body Like A Back Road (2017#8)|Hard To Forget (2020#69)
|
||||||
Sam Smith,Q15123969,non-binary,solo,soul; contemporary R&B; pop music; rhythm and blues,United Kingdom,Dancing With A Stranger (2019#14)|How Do You Sleep? (2019#83)|Too Good At Goodbyes (2017#77/2018#49)|Unholy (2022#98/2023#11)
|
Sam Smith,,,,,,Dancing With A Stranger (2019#14)|How Do You Sleep? (2019#83)|Too Good At Goodbyes (2017#77/2018#49)|Unholy (2022#98/2023#11)
|
||||||
Sam Tompkins,Q112155955,male,solo,pop music,United Kingdom,To The Moon! (2022#97)
|
Sam Tompkins,,,,,,To The Moon! (2022#97)
|
||||||
samUIL Lee,Q140814222,male,solo,J-pop,South Korea,Soda Pop (2025#61)|Your Idol (2025#54)
|
samUIL Lee,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
Saweetie,Q50486592,female,solo,hip-hop,United States,Best Friend (2021#29)|My Type (2019#76)
|
Saweetie,,,,,,Best Friend (2021#29)|My Type (2019#76)
|
||||||
Scotty McCreery,Q255969,male,solo,country music,United States,Damn Strait (2022#93)
|
Scotty McCreery,,,,,,Damn Strait (2022#93)
|
||||||
Sean Paul,Q200577,male,solo,dancehall; pop music,Jamaica,Cheap Thrills (2016#11)|Rockabye (2017#44)
|
Sean Paul,,,,,,Cheap Thrills (2016#11)|Rockabye (2017#44)
|
||||||
Selena Gomez,Q83287,female,solo,pop music; dance-pop; electropop; pop rock; contemporary R&B; alternative R&B,United States,Back To You (2018#41)|Calm Down (2023#6)|Hands To Myself (2016#56)|It Ain't Me (2017#27)|Lose You To Love Me (2020#30)|Same Old Love (2016#40)|Taki Taki (2019#57)|We Don't Talk Anymore (2016#50)|Wolves (2018#60)
|
Selena Gomez,,,,,,Back To You (2018#41)|Calm Down (2023#6)|Hands To Myself (2016#56)|It Ain't Me (2017#27)|Lose You To Love Me (2020#30)|Same Old Love (2016#40)|Taki Taki (2019#57)|We Don't Talk Anymore (2016#50)|Wolves (2018#60)
|
||||||
Sexyy Red,Q119842023,female,solo,hip-hop; trap music; dirty rap; Midwest hip-hop,United States,Get It Sexyy (2024#55)|Rich Baby Daddy (2024#45)|Sticky (2025#41)|Whatchu Kno About Me (2025#48)
|
Sexyy Red,,,,,,Get It Sexyy (2024#55)|Rich Baby Daddy (2024#45)|Sticky (2025#41)|Whatchu Kno About Me (2025#48)
|
||||||
Shaboozey,Q125295750,male,solo,alternative country; country music; hip-hop; Americana,United States,A Bar Song (Tipsy) (2024#2/2025#3)|Good News (2025#23)
|
Shaboozey,,,,,,A Bar Song (Tipsy) (2024#2/2025#3)|Good News (2025#23)
|
||||||
SHAED,Q60643444,,group,electropop; indie pop,United States,Trampoline (2019#70/2020#53)
|
SHAED,,,,,,Trampoline (2019#70/2020#53)
|
||||||
Shakira,Q34424,female,solo,pop music; Latin pop; pop rock; Latin rock; dance music; folk music; world music; contemporary R&B; rock music,Spain,"Bzrp Music Sessions, Vol. 53 (2023#96)|TQG (2023#65)"
|
Shakira,,,,,,"Bzrp Music Sessions, Vol. 53 (2023#96)|TQG (2023#65)"
|
||||||
Shawn Mendes,Q17198340,male,solo,folk-pop; pop music; pop rock,Canada,I Know What You Did Last Summer (2016#86)|If I Can't Have You (2019#29)|In My Blood (2018#46)|Mercy (2017#54)|Senorita (2019#15/2020#44)|Stitches (2016#23)|There's Nothing Holdin' Me Back (2017#23)|Treat You Better (2016#28/2017#92)
|
Shawn Mendes,,,,,,I Know What You Did Last Summer (2016#86)|If I Can't Have You (2019#29)|In My Blood (2018#46)|Mercy (2017#54)|Senorita (2019#15/2020#44)|Stitches (2016#23)|There's Nothing Holdin' Me Back (2017#23)|Treat You Better (2016#28/2017#92)
|
||||||
Shay Mooney,Q116536996,male,solo,country music,United States,"10,000 Hours (2020#23)|All To Myself (2019#97)|Glad You Exist (2021#64)|Speechless (2019#35)|Tequila (2018#32/2019#91)"
|
Shay Mooney,,,,,,"10,000 Hours (2020#23)|All To Myself (2019#97)|Glad You Exist (2021#64)|Speechless (2019#35)|Tequila (2018#32/2019#91)"
|
||||||
Sheck Wes,Q56513324,male,solo,East Coast hip-hop; trap music,United States,Mo Bamba (2019#49)
|
Sheck Wes,,,,,,Mo Bamba (2019#49)
|
||||||
Sia,Q181484,female,solo,electropop; indie pop,Australia,Cheap Thrills (2016#11)
|
Sia,,,,,,Cheap Thrills (2016#11)
|
||||||
Silentó,Q20354248,male,solo,hip-hop; pop rap; trap music; comedy hip-hop; snap music; pop music,United States,Watch Me (2016#88)
|
Silentó,,,,,,Watch Me (2016#88)
|
||||||
Skip Marley,Q21621948,male,solo,reggae; pop music; hip-hop; rock music,Jamaica,Chained To The Rhythm (2017#73)
|
Skip Marley,,,,,,Chained To The Rhythm (2017#73)
|
||||||
Snow,Q94708,male,solo,dancehall; reggae; hip-hop; reggae fusion,Canada,Con Calma (2019#65)
|
Snow,,,,,,Con Calma (2019#65)
|
||||||
Social House,Q65117469,,group,pop music,United States,Boyfriend (2019#98)
|
Social House,,,,,,Boyfriend (2019#98)
|
||||||
sombr,Q133444448,male,solo,alternative pop; bedroom pop,United States,Back To Friends (2025#44)|Undressed (2025#42)
|
sombr,,,,,,Back To Friends (2025#44)|Undressed (2025#42)
|
||||||
SpotemGottem,Q105105325,male,solo,mumble rap; trap music; drill; Southern hip-hop,United States,Beat Box (2021#44)
|
SpotemGottem,,,,,,Beat Box (2021#44)
|
||||||
StaySolidRocky,Q95918251,male,solo,hip-hop,United States,Party Girl (2020#64)
|
StaySolidRocky,,,,,,Party Girl (2020#64)
|
||||||
Stephanie Beatriz,Q16832110,female,solo,,United States,We Don't Talk About Bruno (2022#24)
|
Stephanie Beatriz,,,,,,We Don't Talk About Bruno (2022#24)
|
||||||
Stephen Sanchez,Q113023532,male,solo,pop music,United States,Until I Found You (2023#50)
|
Stephen Sanchez,,,,,,Until I Found You (2023#50)
|
||||||
Steve Lacy,Q56733980,male,solo,alternative R&B,United States,Bad Habit (2022#28/2023#39)
|
Steve Lacy,,,,,,Bad Habit (2022#28/2023#39)
|
||||||
Summer Walker,Q61959478,female,solo,contemporary R&B,United States,Good Good (2024#79)|Heart Of A Woman (2025#94)
|
Summer Walker,,,,,,Good Good (2024#79)|Heart Of A Woman (2025#94)
|
||||||
Surf Mesa,Q93771412,male,solo,tropical house,United States,ily (2020#95)
|
Surf Mesa,,,,,,ily (2020#95)
|
||||||
Surfaces,Q76330429,,group,pop music,United States,Sunday Best (2020#61)
|
Surfaces,,,,,,Sunday Best (2020#61)
|
||||||
Swae Lee,Q30103025,male,solo,hip-hop; trap music,United States,Be Like That (2020#88)|Close To Me (2019#58)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|Unforgettable (2017#15)
|
Swae Lee,,,,,,Be Like That (2020#88)|Close To Me (2019#58)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|Unforgettable (2017#15)
|
||||||
SZA,Q16210722,female,solo,contemporary R&B; alternative R&B; neo soul; pop music; hip-hop,United States,30 For 30 (2025#21)|All The Stars (2018#47)|BMF (2025#76)|Good Days (2021#52)|I Hate U (2022#50)|Kill Bill (2023#3)|Kiss Me More (2021#6/2022#90)|Love Galore (2017#80)|Luther (2025#2)|Nobody Gets Me (2023#77)|Rich Baby Daddy (2024#45)|Saturn (2024#19)|Shirt (2023#51)|Snooze (2023#9/2024#20)|What Lovers Do (2017#97/2018#89)
|
SZA,,,,,,30 For 30 (2025#21)|All The Stars (2018#47)|BMF (2025#76)|Good Days (2021#52)|I Hate U (2022#50)|Kill Bill (2023#3)|Kiss Me More (2021#6/2022#90)|Love Galore (2017#80)|Luther (2025#2)|Nobody Gets Me (2023#77)|Rich Baby Daddy (2024#45)|Saturn (2024#19)|Shirt (2023#51)|Snooze (2023#9/2024#20)|What Lovers Do (2017#97/2018#89)
|
||||||
Tae,Q54607285,female,solo,country music,United States,Die From A Broken Heart (2020#65)
|
Tae,,,,,,Die From A Broken Heart (2020#65)
|
||||||
Tate McRae,Q95129121,female,solo,pop music; alternative pop; dance-pop,Canada,Exes (2024#93)|Greedy (2024#13)|Revolving Door (2025#82)|She's All I Wanna Be (2022#94)|Sports Car (2025#46)|What I Want (2025#27)|You Broke Me First. (2021#37)
|
Tate McRae,,,,,,Exes (2024#93)|Greedy (2024#13)|Revolving Door (2025#82)|She's All I Wanna Be (2022#94)|Sports Car (2025#46)|What I Want (2025#27)|You Broke Me First. (2021#37)
|
||||||
Taylor Swift,Q26876,female,solo,pop music; country music,United States,All Too Well (Taylor's Version) (2022#76)|Anti-Hero (2023#4)|Cruel Summer (2023#18/2024#12)|Delicate (2018#24)|Down Bad (2024#99)|Fortnight (2024#22)|I Can Do It With A Broken Heart (2024#35)|I Don't Wanna Live Forever (Fifty Shades Darker) (2017#26)|Is It Over Now? (Taylor's Version) [From The Vault] (2024#33)|Karma (2023#27)|Lavender Haze (2023#32)|Look What You Made Me Do (2017#39)|ME! (2019#43)|Wildest Dreams (2016#79)|Willow (2021#55)|You Need To Calm Down (2019#39)
|
Taylor Swift,,,,,,All Too Well (Taylor's Version) (2022#76)|Anti-Hero (2023#4)|Cruel Summer (2023#18/2024#12)|Delicate (2018#24)|Down Bad (2024#99)|Fortnight (2024#22)|I Can Do It With A Broken Heart (2024#35)|I Don't Wanna Live Forever (Fifty Shades Darker) (2017#26)|Is It Over Now? (Taylor's Version) [From The Vault] (2024#33)|Karma (2023#27)|Lavender Haze (2023#32)|Look What You Made Me Do (2017#39)|ME! (2019#43)|Wildest Dreams (2016#79)|Willow (2021#55)|You Need To Calm Down (2019#39)
|
||||||
Teddy Swims,Q105542333,male,solo,country music; pop music; rhythm and blues; soul,United States,Bad Dreams (2025#74)|Lose Control (2024#1/2025#4)
|
Teddy Swims,,,,,,Bad Dreams (2025#74)|Lose Control (2024#1/2025#4)
|
||||||
Tems,Q72759173,female,solo,alternative R&B; Afrobeats,Nigeria,Essence (2021#60/2022#64)|Wait For U (2022#11/2023#83)
|
Tems,,,,,,Essence (2021#60/2022#64)|Wait For U (2022#11/2023#83)
|
||||||
Teo,Q55860035,male,solo,hip-hop,United States,Rolex (2017#61)
|
Teo,,,,,,Rolex (2017#61)
|
||||||
The Chainsmokers,Q7721993,,group,electronic dance music,United States,Closer (2016#10/2017#7)|Don't Let Me Down (2016#8)|Paris (2017#42)|Roses (2016#27)|Something Just Like This (2017#5)
|
The Chainsmokers,,,,,,Closer (2016#10/2017#7)|Don't Let Me Down (2016#8)|Paris (2017#42)|Roses (2016#27)|Something Just Like This (2017#5)
|
||||||
The Kid LAROI,Q82890761,male,solo,Australian hip-hop; contemporary R&B; pop music; emo rap,Australia,Stay (2021#12/2022#3)|Without You (2021#17)
|
The Kid LAROI,,,,,,Stay (2021#12/2022#3)|Without You (2021#17)
|
||||||
The Marías,Q85807852,,group,indie pop; psychedelic soul; dream pop; synth-pop,United States,No One Noticed (2025#32)
|
The Marías,,,,,,No One Noticed (2025#32)
|
||||||
The Weeknd,Q2121062,male,solo,alternative R&B; pop music; electropop,Canada,Blinding Lights (2020#1/2021#3)|Call Out My Name (2018#78)|Can't Feel My Face (2016#72)|Creepin' (2023#5)|Cry For Me (2025#73)|Die For You (2023#7)|Heartless (2020#28)|I Feel It Coming (2017#34)|In The Night (2016#61)|Low Life (2016#30)|One Right Now (2022#30)|Pray For Me (2018#40)|Rather Lie (2025#91)|Save Your Tears (2021#2/2022#40)|Starboy (2016#58/2017#20)|Take My Breath (2021#89)|The Hills (2016#32)|Timeless (2025#16)|You Right (2021#43/2022#45)
|
The Weeknd,,,,,,Blinding Lights (2020#1/2021#3)|Call Out My Name (2018#78)|Can't Feel My Face (2016#72)|Creepin' (2023#5)|Cry For Me (2025#73)|Die For You (2023#7)|Heartless (2020#28)|I Feel It Coming (2017#34)|In The Night (2016#61)|Low Life (2016#30)|One Right Now (2022#30)|Pray For Me (2018#40)|Rather Lie (2025#91)|Save Your Tears (2021#2/2022#40)|Starboy (2016#58/2017#20)|Take My Breath (2021#89)|The Hills (2016#32)|Timeless (2025#16)|You Right (2021#43/2022#45)
|
||||||
Thomas Rhett,Q7793491,male,solo,country music,United States,After All The Bars Are Closed (2025#88)|Die A Happy Man (2016#64)|Look What God Gave Her (2019#85)|Marry Me (2018#76)|What's Your Country Song (2021#80)
|
Thomas Rhett,,,,,,After All The Bars Are Closed (2025#88)|Die A Happy Man (2016#64)|Look What God Gave Her (2019#85)|Marry Me (2018#76)|What's Your Country Song (2021#80)
|
||||||
Tim McGraw,Q356487,male,solo,country music,United States,Humble And Kind (2016#96)
|
Tim McGraw,,,,,,Humble And Kind (2016#96)
|
||||||
Tommy Richman,Q125778611,male,solo,pop rap; contemporary R&B,United States,Million Dollar Baby (2024#8)
|
Tommy Richman,,,,,,Million Dollar Baby (2024#8)
|
||||||
Tones And I,Q62887629,female,solo,indie pop; electronic dance music; pop music,Australia,Dance Monkey (2020#14)
|
Tones And I,,,,,,Dance Monkey (2020#14)
|
||||||
Toosii,Q106725126,male,solo,hip-hop,United States,Favorite Song (2023#21)
|
Toosii,,,,,,Favorite Song (2023#21)
|
||||||
Tory Lanez,Q18353558,male,solo,hip-hop; contemporary R&B,Canada,Luv (2016#70)|Say It (2016#74)|Whats Poppin (2020#13)
|
Tory Lanez,,,,,,Luv (2016#70)|Say It (2016#74)|Whats Poppin (2020#13)
|
||||||
Tove Lo,Q15948790,female,solo,pop music; indie pop; synth-pop; electropop,Sweden,Close (2016#66)
|
Tove Lo,,,,,,Close (2016#66)
|
||||||
Travis Scott,Q13605596,male,solo,hip-hop; trap music; pop rap; psychedelic rap; rock music; Southern hip-hop; contemporary R&B; psychedelic music; pop rock; alternative rock; alternative hip-hop; alternative R&B; psychedelic rock; psychedelic hip-hop,United States,Antidote (2016#75)|FE!N (2024#97)|Goosebumps (2017#68)|HIGHEST IN THE ROOM (2020#45)|Love Galore (2017#80)|Meltdown (2023#97)|Sicko Mode (2018#42/2019#9)|Sky Walker (2018#75)|The London (2019#64)|The Scotts (2020#86)|Type Shit (2024#51)|ZEZE (2019#31)
|
Travis Scott,,,,,,Antidote (2016#75)|FE!N (2024#97)|Goosebumps (2017#68)|HIGHEST IN THE ROOM (2020#45)|Love Galore (2017#80)|Meltdown (2023#97)|Sicko Mode (2018#42/2019#9)|Sky Walker (2018#75)|The London (2019#64)|The Scotts (2020#86)|Type Shit (2024#51)|ZEZE (2019#31)
|
||||||
Trevor Daniel,Q81738417,male,solo,contemporary R&B; pop music,United States,Falling (2020#22)
|
Trevor Daniel,,,,,,Falling (2020#22)
|
||||||
Tucker Wetmore,Q124754909,male,solo,country music,United States,Wind Up Missin' You (2024#95)
|
Tucker Wetmore,,,,,,Wind Up Missin' You (2024#95)
|
||||||
twenty one pilots,Q7857806,,group,reggae; alternative rock; electropop; alternative hip-hop; rap rock; indie pop; pop rock,United States,Heathens (2016#21/2017#58)|Ride (2016#20)|Stressed Out (2016#5)
|
twenty one pilots,,,,,,Heathens (2016#21/2017#58)|Ride (2016#20)|Stressed Out (2016#5)
|
||||||
Ty Dolla $ign,Q7859785,male,solo,contemporary R&B; West Coast hip-hop; trap music; hip-hop,United States,Carnival (2024#38)|Hot Girl Summer (2019#93)|Psycho (2018#6)|Sucker For Pain (2016#68)|Swalla (2017#85)|Work From Home (2016#16)
|
Ty Dolla $ign,,,,,,Carnival (2024#38)|Hot Girl Summer (2019#93)|Psycho (2018#6)|Sucker For Pain (2016#68)|Swalla (2017#85)|Work From Home (2016#16)
|
||||||
Tyga,Q358087,male,solo,West Coast hip-hop; pop rap,United States,Juicy (2020#87)|Taste (2018#28)
|
Tyga,,,,,,Juicy (2020#87)|Taste (2018#28)
|
||||||
Tyla,Q118105408,female,solo,contemporary R&B; pop music; Amapiano; new jack swing,South Africa,Water (2024#24)
|
Tyla,,,,,,Water (2024#24)
|
||||||
Tyler Cole,Q140814245,male,solo,,United States,Meet Me At Our Spot (2022#74)
|
Tyler Cole,,,,,,Meet Me At Our Spot (2022#74)
|
||||||
Tyler Hubbard,Q20740966,male,solo,country music,United States,5 Foot 9 (2022#67)|Dancin' In The Country (2023#61)
|
Tyler Hubbard,,,,,,5 Foot 9 (2022#67)|Dancin' In The Country (2023#61)
|
||||||
"Tyler, The Creator",Q167635,male,solo,alternative hip-hop; neo soul; West Coast hip-hop; jazz rap; hardcore hip-hop; horrorcore,United States,Like Him (2025#62)|Sticky (2025#41)
|
"Tyler, The Creator",,,,,,Like Him (2025#62)|Sticky (2025#41)
|
||||||
Usher,Q165911,male,solo,contemporary R&B; pop music; soul; hip-hop; dance music; dance-pop; crunk,United States,Good Good (2024#79)|No Limit (2016#90)
|
Usher,,,,,,Good Good (2024#79)|No Limit (2016#90)
|
||||||
Victoria Monét,Q19864989,female,solo,pop music; contemporary R&B; hip-hop,United States,On My Mama (2024#91)
|
Victoria Monét,,,,,,On My Mama (2024#91)
|
||||||
Walker Hayes,Q7962267,male,solo,country music,United States,AA (2022#61)|Fancy Like (2021#27/2022#35)
|
Walker Hayes,,,,,,AA (2022#61)|Fancy Like (2021#27/2022#35)
|
||||||
Warren Zeiders,Q120986980,male,solo,country music,United States,Pretty Little Poison (2024#60)
|
Warren Zeiders,,,,,,Pretty Little Poison (2024#60)
|
||||||
Wham!,Q744087,,group,pop music; new wave; blue-eyed soul,United Kingdom,Last Christmas (2023#84/2024#74/2025#65)
|
Wham!,,,,,,Last Christmas (2023#84/2024#74/2025#65)
|
||||||
WILLOW,Q298209,female,solo,indie pop; alternative R&B; experimental pop; neo soul; hip-hop; electronic dance music; pop music; dream pop; contemporary R&B; rock music; indie rock; progressive rock; pop-punk; alternative rock; alternative pop; jazz,United States,Meet Me At Our Spot (2022#74)
|
WILLOW,,,,,,Meet Me At Our Spot (2022#74)
|
||||||
Willy William,Q17016749,male,solo,house music; reggaeton; zouk,France,Mi Gente (2017#50/2018#99)
|
Willy William,,,,,,Mi Gente (2017#50/2018#99)
|
||||||
Wiz Khalifa,Q117139,male,solo,East Coast hip-hop; pop rap,United States,See You Again (2016#99)|Sucker For Pain (2016#68)
|
Wiz Khalifa,,,,,,See You Again (2016#99)|Sucker For Pain (2016#68)
|
||||||
Wizkid,Q16233663,male,solo,Afrobeats; dancehall; rhythm and blues,Nigeria,Essence (2021#60/2022#64)|One Dance (2016#3)
|
Wizkid,,,,,,Essence (2021#60/2022#64)|One Dance (2016#3)
|
||||||
X Ambassadors,Q15963313,,group,alternative rock,United States,Sucker For Pain (2016#68)|Unsteady (2016#67)
|
X Ambassadors,,,,,,Sucker For Pain (2016#68)|Unsteady (2016#67)
|
||||||
Xavi,Q124219723,male,solo,regional Mexican; corridos tumbados,United States,La Diabla (2024#72)
|
Xavi,,,,,,La Diabla (2024#72)
|
||||||
XXXTENTACION,Q28561969,male,solo,hip-hop; emo rap; SoundCloud rap; rap rock; lo-fi music; hardcore hip-hop; alternative rock; lofi hip-hop; indie rock; nu metal; cloud rap; trap music; hardcore punk; trap metal; punk rock; rock music; rap metal; alternative R&B; contemporary R&B; punk rap; heavy metal music,United States,Changes (2018#94)|Look At Me! (2017#99)|Moonlight (2018#88)|Sad! (2018#17)
|
XXXTENTACION,,,,,,Changes (2018#94)|Look At Me! (2017#99)|Moonlight (2018#88)|Sad! (2018#17)
|
||||||
Yeat,Q108761978,male,solo,experimental hip-hop; rage; trap music,United States,IDGAF (2024#83)
|
Yeat,,,,,,IDGAF (2024#83)
|
||||||
YFN Lucci,Q28000403,male,solo,hip-hop,United States,Everyday We Lit (2017#95)
|
YFN Lucci,,,,,,Everyday We Lit (2017#95)
|
||||||
YG,Q3076050,male,solo,West Coast hip-hop; gangsta rap; dirty rap; hardcore hip-hop,United States,Big Bank (2018#63)|Slide (2020#99)
|
YG,,,,,,Big Bank (2018#63)|Slide (2020#99)
|
||||||
YK Osiris,Q66095815,male,solo,contemporary R&B; hip-hop,United States,Worth It (2019#77)
|
YK Osiris,,,,,,Worth It (2019#77)
|
||||||
Yng Lvcas,Q117834547,male,solo,reggaeton,Mexico,La Bebe (2023#40)
|
Yng Lvcas,,,,,,La Bebe (2023#40)
|
||||||
YNW Melly,Q56276347,male,solo,Southern hip-hop; trap music; contemporary R&B; gangsta rap; drill; hardcore hip-hop,United States,Murder On My Mind (2019#66)|Suicidal (2020#75)
|
YNW Melly,,,,,,Murder On My Mind (2019#66)|Suicidal (2020#75)
|
||||||
Yo Gotti,Q586919,male,solo,Southern hip-hop; trap music; dirty south; hardcore hip-hop; gangsta rap; Memphis rap,United States,Down In The DM (2016#69)|Rake It Up (2017#53)
|
Yo Gotti,,,,,,Down In The DM (2016#69)|Rake It Up (2017#53)
|
||||||
Young Dolph,Q23541285,male,solo,Southern hip-hop; gangsta rap; trap music; Memphis rap; dirty south; hardcore hip-hop,United States,Cut It (2016#91)
|
Young Dolph,,,,,,Cut It (2016#91)
|
||||||
Young Nudy,Q62036530,male,solo,hip-hop,United States,Peaches & Eggplants (2023#94)
|
Young Nudy,,,,,,Peaches & Eggplants (2023#94)
|
||||||
Young Thug,Q15637814,male,solo,Southern hip-hop; trap music; mumble rap; progressive rap; SoundCloud rap,United States,Go Crazy (2020#39/2021#19)|Goodbyes (2019#30)|Havana (2017#96/2018#4)|Hot (2020#72)|No Limit (2016#90)|Pushin P (2022#48)|The London (2019#64)|Way 2 Sexy (2021#48)
|
Young Thug,,,,,,Go Crazy (2020#39/2021#19)|Goodbyes (2019#30)|Havana (2017#96/2018#4)|Hot (2020#72)|No Limit (2016#90)|Pushin P (2022#48)|The London (2019#64)|Way 2 Sexy (2021#48)
|
||||||
YoungBoy Never Broke Again,Q36490532,male,solo,hip-hop; Southern hip-hop; gangsta rap; trap music,United States,Bandit (2020#63)|Outside Today (2018#91)
|
YoungBoy Never Broke Again,,,,,,Bandit (2020#63)|Outside Today (2018#91)
|
||||||
Yung Bleu,Q105105248,male,solo,contemporary R&B; hip-hop,United States,You're Mines Still (2021#58)
|
Yung Bleu,,,,,,You're Mines Still (2021#58)
|
||||||
Zacari,Q68593407,male,solo,contemporary R&B; hip-hop; alternative hip-hop; alternative R&B,United States,Love. (2018#50)
|
Zacari,,,,,,Love. (2018#50)
|
||||||
Zach Bryan,Q106770023,male,solo,alternative country; red dirt; country music,United States,I Remember Everything (2023#74/2024#9)|Pink Skies (2024#30)|Something In The Orange (2022#39/2023#13)
|
Zach Bryan,,,,,,I Remember Everything (2023#74/2024#9)|Pink Skies (2024#30)|Something In The Orange (2022#39/2023#13)
|
||||||
Zach Top,Q126414316,male,solo,country music,United States,I Never Lie (2025#43)
|
Zach Top,,,,,,I Never Lie (2025#43)
|
||||||
Zara Larsson,Q4177928,female,solo,dance-pop; pop music; electropop,Sweden,Never Forget You (2016#46)
|
Zara Larsson,,,,,,Never Forget You (2016#46)
|
||||||
Zay Hilfigerrr,Q55859289,male,solo,hip-hop,United States,Juju On That Beat (TZ Anthem) (2017#63)
|
Zay Hilfigerrr,,,,,,Juju On That Beat (TZ Anthem) (2017#63)
|
||||||
Zayion McCall,Q55859293,male,solo,hip-hop,United States,Juju On That Beat (TZ Anthem) (2017#63)
|
Zayion McCall,,,,,,Juju On That Beat (TZ Anthem) (2017#63)
|
||||||
Zayn,Q3626950,male,solo,pop music; contemporary R&B; dance-pop; alternative R&B,United Kingdom,I Don't Wanna Live Forever (Fifty Shades Darker) (2017#26)|Pillowtalk (2016#22)
|
Zayn,,,,,,I Don't Wanna Live Forever (Fifty Shades Darker) (2017#26)|Pillowtalk (2016#22)
|
||||||
Zedd,Q21088,male,solo,electronic dance music; house music; electro house,Russia,Starving (2016#94/2017#82)|Stay (2017#17)|The Middle (2018#8)
|
Zedd,,,,,,Starving (2016#94/2017#82)|Stay (2017#17)|The Middle (2018#8)
|
||||||
Óscar Maydon,Q130241052,male,solo,corridos tumbados,Mexico,Tu Boda (2025#87)
|
Óscar Maydon,,,,,,Tu Boda (2025#87)
|
||||||
|
|||||||
|
@@ -1,383 +0,0 @@
|
|||||||
# 決策日誌
|
|
||||||
|
|
||||||
每筆記錄:日期、決策、理由。定義檔(`prompts/`)、codebook、
|
|
||||||
研究計畫的任何修訂都必須在此留下記錄。
|
|
||||||
|
|
||||||
## 2026-07-30
|
|
||||||
|
|
||||||
- **語料擴大為 2016–2025 十年**(先導研究為 2018–2025)。
|
|
||||||
理由:框架完整、可看年度趨勢。
|
|
||||||
- **全文不與先導研究比較**:論文僅呈現正式研究結果,數字
|
|
||||||
一律以正式研究為準;先導研究只作為 codebook v0 與假說的
|
|
||||||
內部來源,記於本日誌,不進入論文敘事。
|
|
||||||
- **分析管線採 API script,不用 Claude Code subagent**。理由:
|
|
||||||
實測證實 subagent 會繼承 CLAUDE.md 與環境資訊,context 無法
|
|
||||||
僅憑定義檔重現;API 呼叫的輸入完全受控且可稽核。
|
|
||||||
- **模型釘定 `claude-sonnet-4-6`、temperature=0、thinking 關閉**。
|
|
||||||
理由:該組合非決定性最低;條件 A/B 盲點實驗以模型為研究
|
|
||||||
對象,須與 pilot 的 Sonnet 家族銜接。Fable 5 不採用(成本、
|
|
||||||
thinking 不可關閉且不可稽核、無 temperature、混淆核心主張)。
|
|
||||||
- **工具性環節的模型於階段 3 以校準樣本實測後決定**:同批
|
|
||||||
校準樣本以 Sonnet 4.6 與 Opus 5 各跑一次,對照人工黃金標準
|
|
||||||
比較一致率,據以決定是否於特定環節升級。
|
|
||||||
- **codebook 採 directed content analysis**:理論骨架
|
|
||||||
(top-down)+開放碼歸納通道(bottom-up),並加做 LLM 純歸納
|
|
||||||
輪與黃金標準的映射分析。映射分析方法須在看到結果前寫入
|
|
||||||
`methodology.md`。
|
|
||||||
- **完整歌詞不進 git**(版權);API 金鑰走 `.env`。
|
|
||||||
- **專案目錄維持原名 `pop-fem-audit`**:API 管線下目錄名不會
|
|
||||||
進入模型輸入,無污染疑慮。
|
|
||||||
|
|
||||||
## 2026-07-31
|
|
||||||
|
|
||||||
- **專案英文名定為「A Feminist Audit of Pop Music」**:明示
|
|
||||||
女性主義立場,「audit」兼指對歌曲與對 LLM 標籤系統的稽核,
|
|
||||||
並與縮寫 `pop-fem-audit` 對應。
|
|
||||||
- **程式碼收整為 `tools/` src-layout 子專案**:發行名
|
|
||||||
`pop-fem-audit-tools`、import 套件名 `pop_fem_audit_tools`、
|
|
||||||
description「Tools for A Feminist Audit of Pop Music.」;
|
|
||||||
以 `pip install -e tools/` 安裝、`python -m
|
|
||||||
pop_fem_audit_tools.run_llm` 執行;相依套件記於
|
|
||||||
`pyproject.toml`(`requirements.txt` 移除);Sphinx 文件
|
|
||||||
暫緩。理由:後續多支程式將共用程式碼,套件化後測試可用
|
|
||||||
正常 import;目錄名 `tools/` 經無脈絡的獨立 subagent 命名
|
|
||||||
評估選出,最誠實反映「服務研究的輔助工具」定位——研究
|
|
||||||
本體在根目錄的 prompts/、runs/、results/,程式只是配套。
|
|
||||||
- **CLI 入口改為套件層級 dispatcher**:dispatcher 為單一
|
|
||||||
入口,`run_llm.py` 的 entry point 移除;有兩種等價呼叫
|
|
||||||
形式——`python -m pop_fem_audit_tools run-llm ...` 與
|
|
||||||
console script `pop-fem-audit-tools run-llm ...`
|
|
||||||
(`[project.scripts]`)。理由:後續多支工具共用單一入口,
|
|
||||||
`--help` 可列出全部子命令,重現文件穩定。
|
|
||||||
- **資料儲存架構定案**:commit 判準——凡能由「committed 輸入+
|
|
||||||
committed 程式」決定性再生者不 commit;源頭、外部捕捉、人工
|
|
||||||
著作以文字格式 commit。工作儲存採 SQLite 單檔
|
|
||||||
(`tools/instance/`,generated、不進 git;歌詞全文入 DB 故無
|
|
||||||
版權疑慮),schema 以 SQLAlchemy 2.0 typed ORM 定義,設定經
|
|
||||||
pydantic-settings(`.env`)。`results/` 報表 CSV 為「可再生仍
|
|
||||||
commit」的唯一例外,理由:論文引用穩定性、審稿人零門檻、
|
|
||||||
撰稿期數字變動可 diff。(本條經多輪辯證後定案,推翻 Claude
|
|
||||||
最初的全 CSV 方案。)
|
|
||||||
- **資料模型**:songs、chart_entries(1—N)、artists、
|
|
||||||
song_artists(M—N:角色、署名順序)、lyrics(1—1);領域
|
|
||||||
不變量檢查內建 `build-db`,違規即失敗。
|
|
||||||
- **歌手背景擴充與防火牆**:歌手資料擴為實體表(QID、性別、
|
|
||||||
型態、曲風、國籍),women-power 候選的歌手另做深度背景
|
|
||||||
(族裔以公開自我認同為準、音樂場景),供人工解讀與論文
|
|
||||||
討論;**歌手背景絕不進 LLM 輸入**(歌詞-only),避免光環
|
|
||||||
偏誤污染條件 A/B 實驗。
|
|
||||||
- **沿用先導研究歌詞捕捉**(lyrics.json,684 首,2018–2025):
|
|
||||||
只匯入識別欄位與歌詞本文,pilot 分析欄位不匯入;以
|
|
||||||
(year, rank) 精確匹配。出處記於 `lyrics-provenance.csv`:
|
|
||||||
source(原始 API)與 method(pilot-import / api-fetch)
|
|
||||||
兩層;取得日期不可考者不假造,僅記可證上界。
|
|
||||||
- **`run_llm` 改走 pydantic-settings 統一設定**(刪手寫 .env
|
|
||||||
parser),與 `config.py` / `database.py` 一致。
|
|
||||||
- **`lyrics` 表併入 `songs.lyrics` nullable 欄位**(資料模型由
|
|
||||||
五表改四表)。理由:1—1 關係在此量級下獨立成表只有正規化
|
|
||||||
慣性,nullable 欄位更簡單;「未取得」以 NULL 表達,語意
|
|
||||||
等價。
|
|
||||||
|
|
||||||
## 2026-08-01
|
|
||||||
|
|
||||||
- **引擎獨立性以 PostgreSQL/SQLite 為範圍**(MySQL 的 VARCHAR
|
|
||||||
長度限制不處理)。
|
|
||||||
- **`build-db` 的重置改用逐表 DELETE,不再 drop/create**:
|
|
||||||
schema 生命週期歸 migration 管,build-db 只管資料。連帶
|
|
||||||
效果:重置成為純 DML,全程單一交易、驗證通過才 commit——
|
|
||||||
建置失敗時前一版資料完好。首次執行仍以 create_all
|
|
||||||
(checkfirst)補缺表。
|
|
||||||
- **song/artist ID 由 build-db 顯式指派**(首次出現順序 1、2、
|
|
||||||
3…),不依賴 autoincrement——PostgreSQL 的 sequence 在
|
|
||||||
DELETE 後不重置,顯式指派讓重建決定性跨引擎成立。
|
|
||||||
- **禁止文字 SQL statement,一律經 SQLAlchemy ORM/Core API**。
|
|
||||||
唯一記錄在案的例外:SQLite 的 `PRAGMA foreign_keys=ON`
|
|
||||||
(官方建議作法,無非文字 API 可用;SQLite 的 FK 旗標為
|
|
||||||
逐連線設定)。裁定其歸屬為連線組態,實作於 `database.py`
|
|
||||||
的 `__create_engine`——建 engine 時對 SQLite 註冊 connect
|
|
||||||
listener,所有消費者全程生效,不再由 build-db 各自註冊。
|
|
||||||
|
|
||||||
## 2026-08-02
|
|
||||||
|
|
||||||
- **`import-lyrics` 不設為子命令,pilot 歌詞改以私人腳本
|
|
||||||
匯入**。理由:pilot 捕捉檔不隨論文發布,子命令
|
|
||||||
形式會在發布的 CLI 裡留下讀者無法執行的死命令——要交待的
|
|
||||||
是「沿用 pilot 捕捉」的事實(記於 lyrics-provenance.csv 與
|
|
||||||
論文方法節),不是工具本身;工具移出專案,發布管線即
|
|
||||||
「讀者可完整執行的程序」。讀者重現歌詞的路徑為
|
|
||||||
`fetch-lyrics`,API 漂移造成的差異屬捕捉層的已承認限制。
|
|
||||||
provenance 的 method 值域定為
|
|
||||||
pilot-import / api-fetch / manual。
|
|
||||||
- **資料路徑全面改為顯式 CLI 引數**——必要運算元為位置
|
|
||||||
引數、選擇性輸入為選項(build-db 吃榜單 CSV 一個位置引數,
|
|
||||||
歌詞目錄、Wikidata 快照、overrides 為 `--lyrics-dir`/
|
|
||||||
`--wikidata-csv`/`--overrides-csv` 三個選項;fetch-lyrics
|
|
||||||
吃歌詞目錄、provenance、缺漏報表三個位置引數;fetch-artists
|
|
||||||
吃快照檔;run-llm 吃 runs 目錄),命令與 CWD 無關,且每次
|
|
||||||
執行觸碰的檔案完整見於指令本身。省略選項即不載入該捕捉層;
|
|
||||||
給了選項而路徑不存在即建置失敗,取代原本的默默跳過。理由:
|
|
||||||
settings 的 `.env` 依 pydantic 慣例讀自 CWD(約定
|
|
||||||
`tools/`),資料路徑原以 repo 根為 CWD,兩者衝突;顯式引數
|
|
||||||
消滅隱性 CWD 契約,亦拒絕以父目錄推導兄弟檔案的隱性慣例。
|
|
||||||
- **`data/` 依生命週期分三層**:`source/`(源頭,手放後不動)、
|
|
||||||
`captures/`(外部捕捉,只由 fetch 命令與私人匯入腳本寫入)、
|
|
||||||
`manual/`(人工著作,只由研究者手寫;之後的黃金標準編碼
|
|
||||||
亦居此)。理由:三種生命週期混住一層,目錄無法傳達「誰可以
|
|
||||||
寫哪裡」;顯式引數化後搬遷零程式改動。
|
|
||||||
- **源頭榜單 CSV 全面對齊 Billboard 年終榜發布版**(修正
|
|
||||||
203 列)。語料源頭定義為「年終榜發布當時的印法」,以
|
|
||||||
Wayback Machine 發布時快照逐字轉錄(2016–2018 原沿
|
|
||||||
Wikipedia 改寫慣例);年終原文殘缺或有疑問者,以當年週榜
|
|
||||||
掛名為準(唯一適用例:2016#87〈All the Way Up〉)。
|
|
||||||
- **歌名正規化採 canonical artist credit 對照表**:同一首歌
|
|
||||||
因署名字串寫法不同而分裂者,以明示對照表合併;歌曲身分=
|
|
||||||
(原始歌名,對照後署名字串),不解析署名——拆解機器不進
|
|
||||||
歌曲身分判定,對照表即完整可稽核清單。
|
|
||||||
- **歌手名單拆解規則定案**:署名列出成員的團體只留成員
|
|
||||||
(冒號與括號兩型);名字內含連接詞的單一藝人入保護名單;
|
|
||||||
規則無法表達的個案入例外表;人名連寫的二人組拆成個人;
|
|
||||||
僅以團名掛名的團體維持單一實體,屬性留待 fetch-artists
|
|
||||||
與人工 overrides。完整清單見 `build_db.py`。
|
|
||||||
- **歌手名正規化**:歌手名大小寫不敏感歸併、存首見拼法;
|
|
||||||
正名表(casefold 鍵→官方拼法)存官方拼法,含跨拼法合併
|
|
||||||
ye→Kanye West。
|
|
||||||
- **`data/` 增設 `derived/` 衍生層**:`build-db` 建置工作儲存
|
|
||||||
的同一動作產出兩張人讀報表(songs.csv、artists.csv)並
|
|
||||||
commit——與 SQLite 同交易語意、驗證通過才寫檔,稽核鏈
|
|
||||||
「committed 輸入+程式→CSV」無中間空缺;為「可再生仍
|
|
||||||
commit」的第二例外。報表不含資料庫 ID(內部參照,資料
|
|
||||||
改版會重排,不供引用);關係以內嵌字串呈現——`/` 連同
|
|
||||||
一首歌的多次上榜、`|` 連不同歌曲——欄位一律字母序。
|
|
||||||
- **歌手正名表全面改存官方拼法**:變音符號一律恢復
|
|
||||||
(Billboard 印法慣性去符號,共 12 筆,如 Aminé、Jhené
|
|
||||||
Aiko、Silentó);二人組拆名改存成員全名(Dan Smyers、
|
|
||||||
Shay Mooney);官方大小寫 3 筆(Cris MJ、Mariah the
|
|
||||||
Scientist、Surf Mesa)。官方拼法逐筆以 DSP 官方頁查證
|
|
||||||
(維基百科/Wikidata 的大小寫是其自家規範,不作準);
|
|
||||||
查證亦確認 Tones And I 的 chart 印法即官方寫法,不改。
|
|
||||||
- **`fetch-artists` 解析演算法定案**:改用 WDQS 的 SPARQL
|
|
||||||
索引查詢(不依賴搜尋排名、不用 LCASE——實測會逾時)。
|
|
||||||
步驟:(1) 歌手名與 label/alias 精確比對(@en+@mul 兩層)
|
|
||||||
取候選,型態限人類∪音樂團體子樹∪original cast;(2) 唯一
|
|
||||||
即定案;(3) 歧義以上榜歌曲佐證——歌名精確比對取歌曲條目
|
|
||||||
的 P175 演出者(含其 P527 成員)與候選交集;(4) 再落空則
|
|
||||||
反向錨定:候選(含所屬團體)演出過的歌曲以 casefold 比對
|
|
||||||
歌名;(5) 均落空記查無並續行。每步須恰一命中才選定,
|
|
||||||
實測零錯選。Last-resort 釘定表僅一筆(Pinkfong——唯一
|
|
||||||
P31 為品牌的上榜演出者,型態閘門依設計排除)。暫時性
|
|
||||||
故障(429/5xx/讀取逾時)退避重試,重試盡記 error 列;
|
|
||||||
not found 專指程序查證後無所得。全量實跑 469 位全數
|
|
||||||
解析且與人工查證的 QID 逐筆相符;首次快照隨本次入
|
|
||||||
捕捉層。
|
|
||||||
- **先補完 Wikidata、再捕捉快照**:歌手資料查證做在上游——
|
|
||||||
以 subagent(`artist-wikidata-updater` 定義檔)逐位查證
|
|
||||||
快照欄位(P21、P31、P136、P27/P495,新條目加 P106)並
|
|
||||||
草擬附來源的 QuickStatements,由研究者本人審摘要、以本人
|
|
||||||
帳號執行;編輯沉澱後重跑 `fetch-artists`,Wikidata 容不下
|
|
||||||
的判斷才入 overrides。理由:修在上游全網受益,編輯史與
|
|
||||||
來源公開可稽核,快照乾淨、overrides 縮小;論文方法節揭露
|
|
||||||
「捕捉前經研究者查證補完」。性別以公開自我認同為準,
|
|
||||||
推測不確定且無佐證者列疑慮清單;查證屬資料策展而非
|
|
||||||
分析,以 Claude Code 輔助、不走分析 API;QS 批次以
|
|
||||||
私人工作檔留存,不隨論文發布。
|
|
||||||
|
|
||||||
## 2026-08-04
|
|
||||||
|
|
||||||
- **移除人工 overrides 層**:`build-db` 刪去
|
|
||||||
`--overrides-csv` 選項與套用邏輯,文件同步移除
|
|
||||||
`data/manual/artists_overrides.csv`。理由:「先補完
|
|
||||||
Wikidata、再捕捉快照」工作流實跑後,469 位歌手全數
|
|
||||||
在上游查證補齊,快照即完整,本地覆蓋層已無存在事實;
|
|
||||||
`data/manual/` 層保留(供日後黃金標準編碼)。
|
|
||||||
- **`fetch-lyrics` 加原始署名 fallback**:以第一位 primary
|
|
||||||
歌手查詢兩 API 皆落空時,改以拆解前的原始署名字串(缺漏
|
|
||||||
報表既有的 `artist_credit`)重查一次;與主查詢同為精確
|
|
||||||
查詢,API 順序不變。理由:二重唱/雙掛名歌曲在歌詞 API
|
|
||||||
目錄以合體名義建檔(Dan + Shay、Lil Baby & DaBaby),
|
|
||||||
單人名查詢必落空;手動模擬證實 fallback 三首全中。
|
|
||||||
- **`build-db` 匯入重構為兩個 job class**:`SongImporter`
|
|
||||||
(榜單 CSV→歌曲身分去重→songs+chart_entries)與
|
|
||||||
`ArtistImporter`(自資料庫依 song ID 讀回署名→拆解→正名
|
|
||||||
→去重登記→artists+song_artists),與 `CSVExporter` 同形
|
|
||||||
(建構子收 session、單一公開入口);命名取 import/export
|
|
||||||
對稱,不用 loader(有「載入記憶體」聯想,實為寫入持久
|
|
||||||
儲存)。歌手登記改於歌曲全數入庫後第二階段進行,兩塊
|
|
||||||
之間不再共享記憶體狀態;各 job 專用的純函數與規則表
|
|
||||||
(身分判定、拆解、正名)隨行入 class 作公開 staticmethod
|
|
||||||
/class 常數,「哪個函式屬哪個工作」由 class 歸屬直接
|
|
||||||
表達。flush 定為匯入工作的完工契約——entry method 返回
|
|
||||||
時自身寫入已可查詢,不再由呼叫者補 flush。捕捉層套用
|
|
||||||
(歌詞、Wikidata 快照)隨後同型打包為 `CaptureImporter`
|
|
||||||
(單一入口收兩個可省略路徑)。實測重構前後工作儲存
|
|
||||||
dump 與衍生報表逐位元組相同。
|
|
||||||
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
|
||||||
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
|
||||||
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
|
||||||
拆成個人後,男女混合是歌曲層(song_artists+各歌手
|
|
||||||
gender)可推導的事實,不屬歌手實體。非人非團體者
|
|
||||||
(Pinkfong)type 留空由人工判定,維持現狀。
|
|
||||||
- **獨立驗證步驟解散,不變量檢查歸屬各匯入工作**:榜單
|
|
||||||
覆蓋((year, rank) 網格恰好齊全、無缺漏、無多出、無
|
|
||||||
重複)檢在 `SongImporter` 匯入結尾;署名解析結果(至少
|
|
||||||
一人、含 primary、名字非空白)檢在 `ArtistImporter` 逐筆
|
|
||||||
解析後立即失敗。違規拋 `BuildError` 走既有的失敗路徑,
|
|
||||||
`find_violations` 刪除。理由:檢查跟著產生資料的工作走,
|
|
||||||
main 不再有驗證分支;逐筆即時失敗使錯誤指向出錯的那筆
|
|
||||||
署名。
|
|
||||||
- **Markdown 檔名一律以 dash 連接**(decision-log.md、
|
|
||||||
research-plan.md、project-structure.md 等;與 data/ 層
|
|
||||||
CSV 檔名慣例一致),定義檔命名慣例同步改為
|
|
||||||
`prompts/<task>-v<N>.md`(如 screen-v1.md),版本庫外的
|
|
||||||
私人工作檔一併改名;`conference_abstract.md` 改名並搬入
|
|
||||||
`paper/`——它是本次年會實際送出的摘要,與全文同屬投稿
|
|
||||||
血脈,不是 `docs/` 的內部工作文件。全 repo 指涉同步更新。
|
|
||||||
- **fetch-lyrics 移除缺漏報表**:刪去 `missing_csv` 位置引數
|
|
||||||
與缺漏報表 CSV 輸出。理由:該報表只寫不讀(指令從未讀取
|
|
||||||
它),缺漏數字每次執行皆由工作儲存比對歌詞目錄重新算出;
|
|
||||||
worklist 角色由重跑冪等指令本身承擔;完整性已由 provenance
|
|
||||||
CSV 與 build-db 統計數字佐證;該檔案不承載任何無法重新
|
|
||||||
產生的資訊。
|
|
||||||
- **新增 `export-llm-input` 子命令**:由工作儲存產出
|
|
||||||
`run-llm` 的 JSONL 輸入檔(每筆 `id`+`content`);此為
|
|
||||||
「歌手背景絕不進 LLM 輸入、歌詞-only」防火牆的執行點
|
|
||||||
——內容只有歌詞,歌曲身分以 `song-<ID>` 不透明鍵放
|
|
||||||
`custom_id`,不入訊息本體;ID 由 build-db 決定性指派
|
|
||||||
故檔案可再生,依 commit 判準不進 git(且含歌詞全文,
|
|
||||||
版權亦不許);固定匯出全部歌曲,缺歌詞即失敗;單一
|
|
||||||
位置引數收輸出檔路徑,文件範例輸出至 `tools/instance/`。
|
|
||||||
- **指令模組集中為 `commands` sub-package**:五個子命令模組
|
|
||||||
移入 `pop_fem_audit_tools.commands`,`__init__` 以
|
|
||||||
`from .run_llm import main as run_llm_command` 逐條登記為
|
|
||||||
指令清單,`__main__` 只消費此 façade;`_command` 後綴避免
|
|
||||||
與子模組同名遮蔽,測試仍以模組屬性風格使用;基礎設施模組
|
|
||||||
(config/database/models/utils)留頂層。理由:「指令
|
|
||||||
vs 共用底層」由目錄結構直接表達,與「哪個函式屬哪個工作」
|
|
||||||
的歸屬原則同型。
|
|
||||||
- **自然編碼管線四步驟定案**:自由標註(tag,×2 全進池不
|
|
||||||
仲裁——自由詞彙兩次輸出無共享比對單位,仲裁即再生成)→
|
|
||||||
自然收斂(merge,2+1)→ 強制收斂至 50 內(cap,2+1)→
|
|
||||||
以定稿詞彙表全量編碼(code,2+1,附引述,逐首仲裁看
|
|
||||||
歌詞)。收斂步驟不看歌詞(目標是產生 codebook 而非編
|
|
||||||
碼);收斂仲裁的紀律:程式先算共識核(凍結)與分歧清
|
|
||||||
單,仲裁只裁分歧、不得引入新概念。編碼回歸採 LLM 直接
|
|
||||||
編碼為主儀器,理由:唯此能逐標籤附歌詞引述(偏差檢視的
|
|
||||||
依據)、標籤是模型對歌曲的直接斷言(批判對象乾淨、不混
|
|
||||||
收斂軌跡假影)、詞彙表出自模型自身故無語意干涉;軌跡
|
|
||||||
機械對映降為診斷副產品。此設計為對前導研究「映回後未
|
|
||||||
對照歌詞」限制的明文改良。
|
|
||||||
- **2+1 協定適用原則重述**:由「每個 LLM 步驟」修正為
|
|
||||||
「輸出可逐項機械比對的步驟」;自由生成步驟改為兩次執行
|
|
||||||
進池。CLAUDE.md 同步修訂。理由:自由詞彙步驟強行仲裁
|
|
||||||
等於第三次生成,無稽核意義;可比步驟的一致率(逐詞塊、
|
|
||||||
逐首)才是有定義的穩定性證據。
|
|
||||||
- **提示詞只定格式、不定語意**:LLM 定義檔不含任何主題
|
|
||||||
定義、判準、範例;研究者判準只住 codebook。理由:研究
|
|
||||||
對象是模型以網路語料知識背景所做的自然編碼,先給定義
|
|
||||||
即消毒了批判對象。此原則之落實:粒度鎖定 thematic
|
|
||||||
keywords(前導研究三粒度比較之繼承,執行前鎖定防事後
|
|
||||||
擇優);收斂輸入不附頻次(頻率的分析角色由編碼步驟承
|
|
||||||
擔;頻次誘使頻率剪枝與高頻主題細分)。引述歌詞證據為
|
|
||||||
必要輸出——檢視編碼偏差的依據,不屬語意干涉。
|
|
||||||
- **「女性力量」單目標篩選降級為取樣補漏網**:候選集由
|
|
||||||
自然編碼(code 步驟)浮現;單目標篩選僅用於把模型「被
|
|
||||||
明示提醒後認得出」的歌撈進人工審視範圍,量測漏標方向
|
|
||||||
的偏差,不進偏差統計的定義。「全編碼+標籤詞提示」混合設計
|
|
||||||
取消——既非自然編碼亦非深度判準,量到的東西
|
|
||||||
無法解釋。screen 逐首 2+1、不給定義,標籤詞用
|
|
||||||
`women-power-and-empowerment`。認清:此編碼是任意的——
|
|
||||||
基於研究目的由研究者決定,不是由資料產生(先導研究中的
|
|
||||||
來歷已不可考,不宣稱由下而上湧現)。選用理由:複合詞
|
|
||||||
同時涵蓋力量宣示與賦權論述兩半概念空間,補漏網寧廣勿
|
|
||||||
窄;「empowerment」正是研究對象的市場語彙;不押注單一
|
|
||||||
時代慣用語。同義詞清單不採——每多一詞即研究者多塑形
|
|
||||||
一分。已知限制:雙管詞語意模糊(無從分辨觸發自哪半),
|
|
||||||
僅作召回之用,結果永不進統計的分子分母。標籤詞是
|
|
||||||
screen 提示中唯一的語意種子,屬研究者的儀器選擇,據實
|
|
||||||
揭露。
|
|
||||||
- **定義檔命名定案:軌-步前綴、不帶版本號**:
|
|
||||||
`prompts/<軌>-<步>-<task>.md`——軌 01=由下而上自然編碼、
|
|
||||||
02=預先決定的 women-power-and-empowerment 篩選:
|
|
||||||
01-01-tag、01-02-merge、01-03-cap、01-04-code、
|
|
||||||
02-01-screen;仲裁檔同 prefix 加 `-arb`。檔名不帶版本
|
|
||||||
號——版本即 git 歷史,失敗的版本不保留;每次執行的定義
|
|
||||||
檔快照隨 runs/ 自我完備,`runs/` 目錄改為
|
|
||||||
`<定義檔名>/`——同理不編日期:只存一份,重跑即取代,
|
|
||||||
被取代的執行在 git 歷史,時間戳在 meta.json。四次收斂執行(merge ×2、cap ×2)
|
|
||||||
各存合併記錄 JSON 隨該次執行入 runs/。
|
|
||||||
- **`run-llm` 降階為純執行器**:原內建的 2+1 編排(文字級
|
|
||||||
一致性判定、固定模板仲裁)拆除;run-llm 只負責「一份
|
|
||||||
定義檔+一份輸入 JSONL,跑 N 次(預設 2、仲裁場合 1),
|
|
||||||
歸檔 `runs/<定義檔名>/`(重跑即取代)」。理由:一致性
|
|
||||||
判定須逐步驟、結構性(分組比對、標籤集合比對),屬
|
|
||||||
確定性腳本的工作;仲裁輸入由比對腳本建構,本身成為
|
|
||||||
runs/ 可稽核工件;執行器變小易測。比對/進池/裁決
|
|
||||||
套用子命令另行實作。
|
|
||||||
- **`run-llm` 一次呼叫即一次執行**:`--runs` 與 `--run` 皆不存
|
|
||||||
在,完整命令形狀為 `run-llm <定義檔> <輸入檔> <歸檔目錄>`——三個
|
|
||||||
必要運算元皆為位置引數(沿 2026-08-02「必要運算元為位置引數」慣
|
|
||||||
例),歸檔目錄如 `runs/01-01-tag/run1`,工具內部零 run 概念,
|
|
||||||
meta 不記 run 編號;「獨立執行兩次」=重現命令清單上的兩行命令,
|
|
||||||
run 身分只活在命令清單與目錄佈局約定,不在工具內。理由:兩次執行
|
|
||||||
互相獨立是方法學宣稱,其證據應由執行結構與重現命令清單自明,不應
|
|
||||||
要求讀者讀工具原始碼;歸檔目的地為顯式引數,亦是「資料路徑全面顯
|
|
||||||
式化、消滅隱性推導」原則的貫徹。比對子命令驗證兩 run 的定義檔與
|
|
||||||
輸入 SHA 一致、缺 run 即失敗。目標目錄已存在即拒絕執行,重跑須明
|
|
||||||
示 `--replace`。
|
|
||||||
- **收斂仲裁演算法定案(逐對裁決)**:不做組對組匹配
|
|
||||||
(兩組是否「同一組」無原則性答案),比較化約為詞對的
|
|
||||||
共組關係——共識塊(交集細分)→ 分歧塊對枚舉 → 仲裁
|
|
||||||
逐對二元裁決(輸入匿名,不含執行別、共識與歌詞)→
|
|
||||||
union-find 遞移重組(固定邊集的連通元件唯一,與順序
|
|
||||||
無關,決定性成立)。仲裁自身亦 2+1:獨立執行兩次,
|
|
||||||
逐對比對,兩次相同即定案,相異者送 `-arb-arb` 終局票
|
|
||||||
(單次,依協定終局)——每個分歧對等同三票多數決,
|
|
||||||
仲裁鏈至此終止。邊界判斷之裁決為單次記錄性決定,不
|
|
||||||
宣稱可再生(可重現性依計畫定義=程序透明+可稽核)。
|
|
||||||
遞移導出的合併(共識「分開」的塊經第三塊橋接併組)為
|
|
||||||
等價關係語意的邏輯結論,照單接受並逐筆記錄供稽核。
|
|
||||||
一致率低於門檻(暫訂 50%,首輪實跑後校準)即不仲裁
|
|
||||||
——視為定義檔約束不足,修訂後重跑該循環。cap 仲裁後
|
|
||||||
組數略超 50 可接受,如實記錄。演算法明文於
|
|
||||||
`methodology.md`,供讀者依循重現。
|
|
||||||
- **收斂執行不命名,組名全權歸命名鏈(命名 2+1)**:
|
|
||||||
merge、cap 的執行輸出改為無名分組(陣列之陣列)——
|
|
||||||
既然命名鏈存在,收斂執行自取的名字毫無用處,徒增
|
|
||||||
兩次執行間「同組不同名」的假分歧。最終分組定案後,
|
|
||||||
`-name` 命名步驟獨立執行兩次對全部組自由命名——逐組
|
|
||||||
比對,同名即定案(單次執行內名字唯一,故定名間必無
|
|
||||||
撞名);異名送 `-name-arb` 擇一仲裁(匿名呈現兩候選,
|
|
||||||
輸出限於候選並避讓已定名,機械可驗證)。理由:組名
|
|
||||||
承重——merge 名為 cap 的輸入詞、cap 名為 code 的編碼
|
|
||||||
類目——凡承重的語意判斷皆受 2+1。cap 的輸入即 merge
|
|
||||||
定案後的組名清單,原始關鍵字不下傳——如同證據不進
|
|
||||||
收斂,上游細節不污染下游步驟。
|
|
||||||
- **定義檔輸出形狀統一與去署名**:LLM 輸出統一為單層
|
|
||||||
dict/array——tag、code:關鍵字→引述;merge、cap:
|
|
||||||
無名分組(陣列之陣列);code-arb:保留關鍵字→仲裁者自己的引述
|
|
||||||
(剔除即不列,剔除集合由程式以鍵差推得);screen 系:
|
|
||||||
引述陣列,非空即「有」(present 布林刪除,判斷與依據
|
|
||||||
合一)。仲裁輸入一律不含執行別(assigned_by、
|
|
||||||
run1/run2 刪除)——標示何方主張會誘使仲裁者揣測
|
|
||||||
「哪次較可信」,而非就文本裁決。收斂組名採自由命名
|
|
||||||
(不限取自成員詞),取命名貼合度;可追溯性由軌跡歸檔
|
|
||||||
承擔。
|
|
||||||
- **定義檔改三層編號**:
|
|
||||||
`prompts/<軌>-<步>-<次步>-<task>.md`——次步為步內執行
|
|
||||||
順序,明定讀者依循的先後(如 01-02-01-merge →
|
|
||||||
01-02-02-merge-arb → 01-02-03-merge-arb-arb →
|
|
||||||
01-02-04-merge-name → 01-02-05-merge-name-arb)。後綴
|
|
||||||
`-arb` 仲裁、`-arb-arb` 終局票、`-name` 命名、
|
|
||||||
`-name-arb` 命名擇一仲裁。
|
|
||||||
- **screen 標籤詞改為 `women-power`(來歷考據定案)**:
|
|
||||||
考據先導研究的 local agent 存檔:其第一步指令含數十個
|
|
||||||
範例 thematic keywords,其中即有 women-power——為當時
|
|
||||||
協作的 Claude Code 依研究者長期表達的關注主動加入
|
|
||||||
(研究者端播種,非明示指定);先導標籤
|
|
||||||
women-power-and-empowerment 則是第三步強制合併
|
|
||||||
women-power+empowerment 兩個關鍵字的管線人工產物
|
|
||||||
(研究者數月來誤以為是 women-power+women-empowerment
|
|
||||||
之併,至此方明)。修正:探針指向被播種的概念本詞
|
|
||||||
women-power,不用合併假影;research-plan 的「來歷不可
|
|
||||||
考」敘述同步改為考據結果。附帶認清:先導第一步並非零
|
|
||||||
語意提示——此即正式研究「只定格式、不定語意」設計所
|
|
||||||
矯正者。
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# 決策日誌
|
||||||
|
|
||||||
|
每筆記錄:日期、決策、理由。定義檔(`prompts/`)、codebook、
|
||||||
|
研究計畫的任何修訂都必須在此留下記錄。
|
||||||
|
|
||||||
|
## 2026-07-30
|
||||||
|
|
||||||
|
- **語料擴大為 2016–2025 十年**(先導研究為 2018–2025)。
|
||||||
|
理由:框架完整、可看年度趨勢。
|
||||||
|
- **全文不與先導研究比較**:論文僅呈現正式研究結果,數字
|
||||||
|
一律以正式研究為準;先導研究只作為 codebook v0 與假說的
|
||||||
|
內部來源,記於本日誌,不進入論文敘事。
|
||||||
|
- **分析管線採 API script,不用 Claude Code subagent**。理由:
|
||||||
|
實測證實 subagent 會繼承 CLAUDE.md 與環境資訊,context 無法
|
||||||
|
僅憑定義檔重現;API 呼叫的輸入完全受控且可稽核。
|
||||||
|
- **模型釘定 `claude-sonnet-4-6`、temperature=0、thinking 關閉**。
|
||||||
|
理由:該組合非決定性最低;條件 A/B 盲點實驗以模型為研究
|
||||||
|
對象,須與 pilot 的 Sonnet 家族銜接。Fable 5 不採用(成本、
|
||||||
|
thinking 不可關閉且不可稽核、無 temperature、混淆核心主張)。
|
||||||
|
- **工具性環節的模型於階段 3 以校準樣本實測後決定**:同批
|
||||||
|
校準樣本以 Sonnet 4.6 與 Opus 5 各跑一次,對照人工黃金標準
|
||||||
|
比較一致率,據以決定是否於特定環節升級。
|
||||||
|
- **codebook 採 directed content analysis**:理論骨架
|
||||||
|
(top-down)+開放碼歸納通道(bottom-up),並加做 LLM 純歸納
|
||||||
|
輪與黃金標準的映射分析。映射分析方法須在看到結果前寫入
|
||||||
|
`methodology.md`。
|
||||||
|
- **完整歌詞不進 git**(版權);API 金鑰走 `.env`。
|
||||||
|
- **專案目錄維持原名 `pop-fem-audit`**:API 管線下目錄名不會
|
||||||
|
進入模型輸入,無污染疑慮。
|
||||||
|
|
||||||
|
## 2026-07-31
|
||||||
|
|
||||||
|
- **專案英文名定為「A Feminist Audit of Pop Music」**:明示
|
||||||
|
女性主義立場,「audit」兼指對歌曲與對 LLM 標籤系統的稽核,
|
||||||
|
並與縮寫 `pop-fem-audit` 對應。
|
||||||
|
- **程式碼收整為 `tools/` src-layout 子專案**:發行名
|
||||||
|
`pop-fem-audit-tools`、import 套件名 `pop_fem_audit_tools`、
|
||||||
|
description「Tools for A Feminist Audit of Pop Music.」;
|
||||||
|
以 `pip install -e tools/` 安裝、`python -m
|
||||||
|
pop_fem_audit_tools.run_llm` 執行;相依套件記於
|
||||||
|
`pyproject.toml`(`requirements.txt` 移除);Sphinx 文件
|
||||||
|
暫緩。理由:後續多支程式將共用程式碼,套件化後測試可用
|
||||||
|
正常 import;目錄名 `tools/` 經無脈絡的獨立 subagent 命名
|
||||||
|
評估選出,最誠實反映「服務研究的輔助工具」定位——研究
|
||||||
|
本體在根目錄的 prompts/、runs/、results/,程式只是配套。
|
||||||
|
- **CLI 入口改為套件層級 dispatcher**:dispatcher 為單一
|
||||||
|
入口,`run_llm.py` 的 entry point 移除;有兩種等價呼叫
|
||||||
|
形式——`python -m pop_fem_audit_tools run-llm ...` 與
|
||||||
|
console script `pop-fem-audit-tools run-llm ...`
|
||||||
|
(`[project.scripts]`)。理由:後續多支工具共用單一入口,
|
||||||
|
`--help` 可列出全部子命令,重現文件穩定。
|
||||||
|
- **資料儲存架構定案**:commit 判準——凡能由「committed 輸入+
|
||||||
|
committed 程式」決定性再生者不 commit;源頭、外部捕捉、人工
|
||||||
|
著作以文字格式 commit。工作儲存採 SQLite 單檔
|
||||||
|
(`tools/instance/`,generated、不進 git;歌詞全文入 DB 故無
|
||||||
|
版權疑慮),schema 以 SQLAlchemy 2.0 typed ORM 定義,設定經
|
||||||
|
pydantic-settings(`.env`)。`results/` 報表 CSV 為「可再生仍
|
||||||
|
commit」的唯一例外,理由:論文引用穩定性、審稿人零門檻、
|
||||||
|
撰稿期數字變動可 diff。(本條經多輪辯證後定案,推翻 Claude
|
||||||
|
最初的全 CSV 方案。)
|
||||||
|
- **資料模型**:songs、chart_entries(1—N)、artists、
|
||||||
|
song_artists(M—N:角色、署名順序)、lyrics(1—1);領域
|
||||||
|
不變量檢查內建 `build-db`,違規即失敗。
|
||||||
|
- **歌手背景擴充與防火牆**:歌手資料擴為實體表(QID、性別、
|
||||||
|
型態、曲風、國籍),women-power 候選的歌手另做深度背景
|
||||||
|
(族裔以公開自我認同為準、音樂場景),供人工解讀與論文
|
||||||
|
討論;**歌手背景絕不進 LLM 輸入**(歌詞-only),避免光環
|
||||||
|
偏誤污染條件 A/B 實驗。
|
||||||
|
- **沿用先導研究歌詞捕捉**(lyrics.json,684 首,2018–2025):
|
||||||
|
只匯入識別欄位與歌詞本文,pilot 分析欄位不匯入;以
|
||||||
|
(year, rank) 精確匹配。出處記於 `lyrics_provenance.csv`:
|
||||||
|
source(原始 API)與 method(pilot-import / api-fetch)
|
||||||
|
兩層;取得日期不可考者不假造,僅記可證上界。
|
||||||
|
- **`run_llm` 改走 pydantic-settings 統一設定**(刪手寫 .env
|
||||||
|
parser),與 `config.py` / `database.py` 一致。
|
||||||
|
- **`lyrics` 表併入 `songs.lyrics` nullable 欄位**(資料模型由
|
||||||
|
五表改四表)。理由:1—1 關係在此量級下獨立成表只有正規化
|
||||||
|
慣性,nullable 欄位更簡單;「未取得」以 NULL 表達,語意
|
||||||
|
等價。
|
||||||
|
|
||||||
|
## 2026-08-01
|
||||||
|
|
||||||
|
- **引擎獨立性以 PostgreSQL/SQLite 為範圍**(MySQL 的 VARCHAR
|
||||||
|
長度限制不處理)。
|
||||||
|
- **`build-db` 的重置改用逐表 DELETE,不再 drop/create**:
|
||||||
|
schema 生命週期歸 migration 管,build-db 只管資料。連帶
|
||||||
|
效果:重置成為純 DML,全程單一交易、驗證通過才 commit——
|
||||||
|
建置失敗時前一版資料完好。首次執行仍以 create_all
|
||||||
|
(checkfirst)補缺表。
|
||||||
|
- **song/artist ID 由 build-db 顯式指派**(首次出現順序 1、2、
|
||||||
|
3…),不依賴 autoincrement——PostgreSQL 的 sequence 在
|
||||||
|
DELETE 後不重置,顯式指派讓重建決定性跨引擎成立。
|
||||||
|
- **禁止文字 SQL statement,一律經 SQLAlchemy ORM/Core API**。
|
||||||
|
唯一記錄在案的例外:SQLite 的 `PRAGMA foreign_keys=ON`
|
||||||
|
(官方建議作法,無非文字 API 可用;SQLite 的 FK 旗標為
|
||||||
|
逐連線設定)。裁定其歸屬為連線組態,實作於 `database.py`
|
||||||
|
的 `__create_engine`——建 engine 時對 SQLite 註冊 connect
|
||||||
|
listener,所有消費者全程生效,不再由 build-db 各自註冊。
|
||||||
|
|
||||||
|
## 2026-08-02
|
||||||
|
|
||||||
|
- **`import-lyrics` 不設為子命令,pilot 歌詞改以 `excludes/`
|
||||||
|
私人腳本匯入**。理由:pilot 捕捉檔不隨論文發布,子命令
|
||||||
|
形式會在發布的 CLI 裡留下讀者無法執行的死命令——要交待的
|
||||||
|
是「沿用 pilot 捕捉」的事實(記於 lyrics_provenance.csv 與
|
||||||
|
論文方法節),不是工具本身;工具移出專案,發布管線即
|
||||||
|
「讀者可完整執行的程序」。讀者重現歌詞的路徑為
|
||||||
|
`fetch-lyrics`,API 漂移造成的差異屬捕捉層的已承認限制。
|
||||||
|
provenance 的 method 值域定為
|
||||||
|
pilot-import / api-fetch / manual。
|
||||||
|
- **資料路徑全面改為顯式 CLI 引數**——必要運算元為位置
|
||||||
|
引數、選擇性輸入為選項(build-db 吃榜單 CSV 一個位置引數,
|
||||||
|
歌詞目錄、Wikidata 快照、overrides 為 `--lyrics-dir`/
|
||||||
|
`--wikidata-csv`/`--overrides-csv` 三個選項;fetch-lyrics
|
||||||
|
吃歌詞目錄、provenance、缺漏報表三個位置引數;fetch-artists
|
||||||
|
吃快照檔;run-llm 吃 runs 目錄),命令與 CWD 無關,且每次
|
||||||
|
執行觸碰的檔案完整見於指令本身。省略選項即不載入該捕捉層;
|
||||||
|
給了選項而路徑不存在即建置失敗,取代原本的默默跳過。理由:
|
||||||
|
settings 的 `.env` 依 pydantic 慣例讀自 CWD(約定
|
||||||
|
`tools/`),資料路徑原以 repo 根為 CWD,兩者衝突;顯式引數
|
||||||
|
消滅隱性 CWD 契約,亦拒絕以父目錄推導兄弟檔案的隱性慣例。
|
||||||
|
- **`data/` 依生命週期分三層**:`source/`(源頭,手放後不動)、
|
||||||
|
`captures/`(外部捕捉,只由 fetch 命令與私人匯入腳本寫入)、
|
||||||
|
`manual/`(人工著作,只由研究者手寫;之後的黃金標準編碼
|
||||||
|
亦居此)。理由:三種生命週期混住一層,目錄無法傳達「誰可以
|
||||||
|
寫哪裡」;顯式引數化後搬遷零程式改動。
|
||||||
|
- **源頭榜單 CSV 全面對齊 Billboard 年終榜發布版**(修正
|
||||||
|
203 列)。語料源頭定義為「年終榜發布當時的印法」,以
|
||||||
|
Wayback Machine 發布時快照逐字轉錄(2016–2018 原沿
|
||||||
|
Wikipedia 改寫慣例);年終原文殘缺或有疑問者,以當年週榜
|
||||||
|
掛名為準(唯一適用例:2016#87〈All the Way Up〉)。
|
||||||
|
- **歌名正規化採 canonical artist credit 對照表**:同一首歌
|
||||||
|
因署名字串寫法不同而分裂者,以明示對照表合併;歌曲身分=
|
||||||
|
(原始歌名,對照後署名字串),不解析署名——拆解機器不進
|
||||||
|
歌曲身分判定,對照表即完整可稽核清單。
|
||||||
|
- **歌手名單拆解規則定案**:署名列出成員的團體只留成員
|
||||||
|
(冒號與括號兩型);名字內含連接詞的單一藝人入保護名單;
|
||||||
|
規則無法表達的個案入例外表;人名連寫的二人組拆成個人;
|
||||||
|
僅以團名掛名的團體維持單一實體,屬性留待 fetch-artists
|
||||||
|
與人工 overrides。完整清單見 `build_db.py`。
|
||||||
|
- **歌手名正規化**:歌手名大小寫不敏感歸併、存首見拼法;
|
||||||
|
正名表(casefold 鍵→官方拼法)存官方拼法,含跨拼法合併
|
||||||
|
ye→Kanye West。
|
||||||
|
- **`data/` 增設 `derived/` 衍生層**:`build-db` 建置工作儲存
|
||||||
|
的同一動作產出兩張人讀報表(songs.csv、artists.csv)並
|
||||||
|
commit——與 SQLite 同交易語意、驗證通過才寫檔,稽核鏈
|
||||||
|
「committed 輸入+程式→CSV」無中間空缺;為「可再生仍
|
||||||
|
commit」的第二例外。報表不含資料庫 ID(內部參照,資料
|
||||||
|
改版會重排,不供引用);關係以內嵌字串呈現——`/` 連同
|
||||||
|
一首歌的多次上榜、`|` 連不同歌曲——欄位一律字母序。
|
||||||
|
- **歌手正名表全面改存官方拼法**:變音符號一律恢復
|
||||||
|
(Billboard 印法慣性去符號,共 12 筆,如 Aminé、Jhené
|
||||||
|
Aiko、Silentó);二人組拆名改存成員全名(Dan Smyers、
|
||||||
|
Shay Mooney);官方大小寫 3 筆(Cris MJ、Mariah the
|
||||||
|
Scientist、Surf Mesa)。官方拼法逐筆以 DSP 官方頁查證
|
||||||
|
(維基百科/Wikidata 的大小寫是其自家規範,不作準);
|
||||||
|
查證亦確認 Tones And I 的 chart 印法即官方寫法,不改。
|
||||||
|
- **`fetch-artists` 解析演算法定案**:改用 WDQS 的 SPARQL
|
||||||
|
索引查詢(不依賴搜尋排名、不用 LCASE——實測會逾時)。
|
||||||
|
步驟:(1) 歌手名與 label/alias 精確比對(@en+@mul 兩層)
|
||||||
|
取候選,型態限人類∪音樂團體子樹∪original cast;(2) 唯一
|
||||||
|
即定案;(3) 歧義以上榜歌曲佐證——歌名精確比對取歌曲條目
|
||||||
|
的 P175 演出者(含其 P527 成員)與候選交集;(4) 再落空則
|
||||||
|
反向錨定:候選(含所屬團體)演出過的歌曲以 casefold 比對
|
||||||
|
歌名;(5) 均落空記查無並續行。每步須恰一命中才選定,
|
||||||
|
實測零錯選。Last-resort 釘定表僅一筆(Pinkfong——唯一
|
||||||
|
P31 為品牌的上榜演出者,型態閘門依設計排除)。暫時性
|
||||||
|
故障(429/5xx/讀取逾時)退避重試,重試盡記 error 列;
|
||||||
|
not found 專指程序查證後無所得。全量實跑 469 位全數
|
||||||
|
解析且與人工查證的 QID 逐筆相符;首次快照隨本次入
|
||||||
|
捕捉層。
|
||||||
|
- **先補完 Wikidata、再捕捉快照**:歌手資料查證做在上游——
|
||||||
|
以 subagent(`artist-wikidata-updater` 定義檔)逐位查證
|
||||||
|
快照欄位(P21、P31、P136、P27/P495,新條目加 P106)並
|
||||||
|
草擬附來源的 QuickStatements,由研究者本人審摘要、以本人
|
||||||
|
帳號執行;編輯沉澱後重跑 `fetch-artists`,Wikidata 容不下
|
||||||
|
的判斷才入 overrides。理由:修在上游全網受益,編輯史與
|
||||||
|
來源公開可稽核,快照乾淨、overrides 縮小;論文方法節揭露
|
||||||
|
「捕捉前經研究者查證補完」。性別以公開自我認同為準,
|
||||||
|
推測不確定且無佐證者列疑慮清單;查證屬資料策展而非
|
||||||
|
分析,以 Claude Code 輔助、不走分析 API;QS 批次存
|
||||||
|
`excludes/`(私人工作檔,不隨論文發布)。
|
||||||
|
|
||||||
|
## 2026-08-04
|
||||||
|
|
||||||
|
- **移除人工 overrides 層**:`build-db` 刪去
|
||||||
|
`--overrides-csv` 選項與套用邏輯,文件同步移除
|
||||||
|
`data/manual/artists_overrides.csv`。理由:「先補完
|
||||||
|
Wikidata、再捕捉快照」工作流實跑後,469 位歌手全數
|
||||||
|
在上游查證補齊,快照即完整,本地覆蓋層已無存在事實;
|
||||||
|
`data/manual/` 層保留(供日後黃金標準編碼)。
|
||||||
|
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
||||||
|
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
||||||
|
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
||||||
|
拆成個人後,男女混合是歌曲層(song_artists+各歌手
|
||||||
|
gender)可推導的事實,不屬歌手實體。非人非團體者
|
||||||
|
(Pinkfong)type 留空由人工判定,維持現狀。
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# 方法細節
|
|
||||||
|
|
||||||
(全文方法節底稿。演算法在執行前寫定;任何修訂記入
|
|
||||||
`decision-log.md`。定義檔全文見 `prompts/`,執行紀錄見
|
|
||||||
`runs/`。)
|
|
||||||
|
|
||||||
## 自然編碼管線總覽
|
|
||||||
|
|
||||||
四步驟:自由標註(tag)→ 自然收斂(merge)→ 強制收斂
|
|
||||||
(cap)→ 全量編碼(code),另設「女性力量」單目標篩選
|
|
||||||
(screen)作黃金標準取樣的補漏網。歌詞只出現在 tag、code、
|
|
||||||
screen 與各仲裁步驟;merge、cap 及其仲裁、命名皆不接觸
|
|
||||||
歌詞。設計原則見 `research-plan.md`;本檔記載可重現的
|
|
||||||
演算法細節。
|
|
||||||
|
|
||||||
## 收斂步驟(merge、cap)的 2+1 比對與仲裁
|
|
||||||
|
|
||||||
兩次獨立執行對同一批輸入詞各產生一個分組。比對不做
|
|
||||||
「組對組」的匹配——兩個組是否為「同一組的變體」無原則性
|
|
||||||
答案——而是把比較化約為「詞對的共組關係」:
|
|
||||||
|
|
||||||
1. **共識塊(交集細分)**:兩次執行都放在同組的詞歸為
|
|
||||||
同一塊。即以「(第一次的組, 第二次的組)」二元組為鍵
|
|
||||||
分桶,一桶一塊。此步為純集合運算。
|
|
||||||
2. **分歧塊對枚舉**:每個塊完整落在各次執行的恰一組內,
|
|
||||||
故「兩塊在某次執行中是否同組」定義良好。逐塊對檢查:
|
|
||||||
兩次執行答案相同者為共識(同組或分開,直接定案);
|
|
||||||
不同者列入分歧清單。
|
|
||||||
3. **逐對仲裁(自身 2+1)**:分歧塊對送 LLM 仲裁
|
|
||||||
(`01-02-02-merge-arb.md`、`01-03-02-cap-arb.md`)。
|
|
||||||
仲裁者只看兩塊的內容詞,逐對二元裁決「是否同一
|
|
||||||
主題」;輸入不含執行別(避免「猜哪一次較可信」的
|
|
||||||
偏誤),不含共識部份,不含歌詞。仲裁自身獨立執行
|
|
||||||
兩次,逐對比對:兩次裁決相同即定案;相異的塊對送
|
|
||||||
終局票(`01-02-03-merge-arb-arb.md`、
|
|
||||||
`01-03-03-cap-arb-arb.md`,單次執行,依協定為終局)
|
|
||||||
——每個分歧塊對等同三票多數決,仲裁鏈至此終止。
|
|
||||||
送入仲裁的塊對即模型自身不穩定的邊界判斷,其裁決為
|
|
||||||
單次記錄性決定,不宣稱可再生;可重現性依計畫定義為
|
|
||||||
「程序透明+可稽核」,裁決與其輸入全程歸檔。
|
|
||||||
4. **確定性重組**:以每個「同組」裁決為一條邊,最終
|
|
||||||
分組=圖的連通元件(union-find)。固定邊集的連通
|
|
||||||
元件唯一,與處理順序無關,故重組決定性成立。
|
|
||||||
遞移性後果照單全收:兩個共識「分開」的塊可能經第三
|
|
||||||
塊橋接而併入同組——此為等價關係語意的邏輯結論,
|
|
||||||
比對程式將此類「遞移導出的合併」逐筆記錄於歸檔,
|
|
||||||
供稽核。仲裁的結果空間因此大於「兩次執行擇一」:
|
|
||||||
可能比兩次都粗(多對皆裁同組),也可能比兩次都細
|
|
||||||
(多對皆裁分開)。
|
|
||||||
5. **一致率與保險絲**:比對程式計算塊對層級的一致率並
|
|
||||||
記入歸檔。低於門檻(暫訂 50%,首輪實跑後校準)即
|
|
||||||
不進行仲裁——兩次分組面目全非說明定義檔約束不足,
|
|
||||||
依協定修訂定義檔並重跑該循環。
|
|
||||||
6. **cap 的上限容忍**:仲裁後組數可能略超 50(多對裁
|
|
||||||
「分開」時)。略微超過可接受,如實記錄,不強行
|
|
||||||
壓縮。
|
|
||||||
7. **組名定案(收斂不命名+命名 2+1)**:merge、cap 的
|
|
||||||
執行輸出不含組名(無名分組,陣列之陣列)——命名鏈
|
|
||||||
既然存在,收斂執行自取的名字只會製造兩次執行間
|
|
||||||
「同組不同名」的假分歧。組名承重——merge 的組名是
|
|
||||||
cap 的輸入詞,cap 的組名是 code 對歌詞編碼的類目——
|
|
||||||
故與其他語意判斷同受 2+1。程序:(1) 最終分組定案後,
|
|
||||||
命名步驟(`01-02-04-merge-name.md`、
|
|
||||||
`01-03-04-cap-name.md`)獨立執行兩次,輸入為不透明
|
|
||||||
組 ID 對成員詞,對全部組自由命名,僅受格式約束
|
|
||||||
(小寫連字號、單次執行內不重複);(2) 逐組比對:兩次
|
|
||||||
同名即定案(單次執行內名字唯一,故定名間必無撞名);
|
|
||||||
(3) 異名的組送擇一仲裁(`01-02-05-merge-name-arb.md`、
|
|
||||||
`01-03-05-cap-name-arb.md`)——匿名呈現兩候選,逐組
|
|
||||||
擇一,輸出限於候選並避讓已定名,機械可驗證。cap 的
|
|
||||||
輸入即 merge 定案後的組名清單。
|
|
||||||
|
|
||||||
### 收斂步驟的資料流(比對子命令的輸入輸出契約)
|
|
||||||
|
|
||||||
以 merge 為例(cap 完全同構,檔名換為 01-03 系):
|
|
||||||
|
|
||||||
1. 兩次執行的原始輸出:`runs/01-02-01-merge/run1/output.jsonl`
|
|
||||||
與 `run2/output.jsonl`,各含一個無名分組(陣列之陣列)。
|
|
||||||
2. 比對子命令讀入兩份分組,先驗證兩者為同一輸入詞集的
|
|
||||||
完整分割(缺詞、多詞、重複即失敗),再計算共識塊與
|
|
||||||
分歧塊對,產出仲裁輸入檔——即
|
|
||||||
`01-02-02-merge-arb.md` 所收的 JSON:
|
|
||||||
- `blocks`:塊 ID → 成員詞。**塊 ID 的指派決定性**:
|
|
||||||
全部塊先按「各塊字典序最小的成員詞」排序,依序編為
|
|
||||||
b1、b2、…;塊內成員詞亦按字典序排列。
|
|
||||||
- `pairs`:分歧塊對清單,每對內部按塊 ID 序、清單
|
|
||||||
整體按 (第一元素, 第二元素) 字典序排列。
|
|
||||||
- 僅分歧塊對入列;共識(同組或分開)不送仲裁,由
|
|
||||||
比對子命令直接寫入共識紀錄檔。
|
|
||||||
同時產出:共識紀錄(共識塊、共識同組對、共識分開對)
|
|
||||||
與塊對一致率(含保險絲判定)。
|
|
||||||
3. 仲裁輸入檔以 run-llm 跑兩次
|
|
||||||
(`runs/01-02-02-merge-arb/run1`、`run2`);比對
|
|
||||||
子命令逐對比對兩份裁決,兩票相同即定案,相異的塊對
|
|
||||||
依同一契約組成終局票輸入檔(`blocks` 僅含涉事塊、
|
|
||||||
ID 沿用原編號),跑
|
|
||||||
`runs/01-02-03-merge-arb-arb/run1`。
|
|
||||||
4. 裁決套用子命令彙整三票結果,以 union-find 重組出
|
|
||||||
最終分組,並寫出:最終分組檔(無名,陣列之陣列,
|
|
||||||
組序與組內成員皆字典序)、遞移導出合併的紀錄、
|
|
||||||
收斂軌跡(原始詞 → 最終組)。
|
|
||||||
5. 最終分組轉為不透明組 ID(依組序編 g1、g2、…)進入
|
|
||||||
命名鏈(見第 7 條);命名定案後,組名清單(字典序)
|
|
||||||
即下一步的輸入。
|
|
||||||
6. 以上中間檔全部隨 `runs/` 歸檔;一切排序規則固定,
|
|
||||||
故給定相同的兩份執行輸出與相同的裁決,全流程輸出
|
|
||||||
逐位元組可再生。
|
|
||||||
|
|
||||||
## 編碼步驟(code、screen)的 2+1 比對與仲裁
|
|
||||||
|
|
||||||
- **code**:逐首比對兩次執行的標籤集合(引述不參與
|
|
||||||
比對)。兩次皆有的標籤為共識保留、兩次皆無為共識
|
|
||||||
不標;單邊標籤送仲裁(`01-04-02-code-arb.md`)——仲裁者
|
|
||||||
看歌詞全文與該標籤的引述(不含執行別),裁決保留者
|
|
||||||
附仲裁者自己的引述,剔除者不列。剔除集合=送裁鍵減
|
|
||||||
輸出鍵,由程式推得。
|
|
||||||
- **screen**:輸出即引述陣列,非空=有、空=無。僅
|
|
||||||
「一有一無」的歌送仲裁(`02-01-02-screen-arb.md`),
|
|
||||||
輸入為歌詞加主張「有」方的引述(不記名),輸出同為
|
|
||||||
引述陣列。
|
|
||||||
- 仲裁者的引述可能與原引述不同:仲裁是對歌詞的重新
|
|
||||||
判讀,其引述是該裁決自身的依據,非轉抄。
|
|
||||||
|
|
||||||
## 軌跡對映(診斷用)
|
|
||||||
|
|
||||||
沿收斂軌跡的機械對映:原始關鍵字 → merge 組 → cap 組,
|
|
||||||
純程式查表,決定性。以其結果與 code 直接編碼的差異率
|
|
||||||
作為「收斂軌跡扭曲」的診斷量,不作主結果。
|
|
||||||
|
|
||||||
## 全管線的交接契約
|
|
||||||
|
|
||||||
每一步的輸出如何變成下一步的輸入,皆為確定性程序,規則
|
|
||||||
明定如下(收斂步驟內部的交接見上節):
|
|
||||||
|
|
||||||
- **歌詞輸入檔(tag、code、screen 共用)**:
|
|
||||||
`export-llm-input` 自工作儲存產出,每筆
|
|
||||||
`{"id": "song-<ID>", "content": <歌詞>}`,依歌曲 ID
|
|
||||||
升序。三個讀歌詞的步驟共用同一檔,SHA-256 記入各步
|
|
||||||
meta。
|
|
||||||
- **tag → merge**:兩次執行的全部關鍵字取聯集、逐字串
|
|
||||||
精確去重、字典序排列成 JSON 陣列,即 merge 的輸入。
|
|
||||||
進池同時寫出處記錄(關鍵字 →(執行別,歌曲 ID)
|
|
||||||
清單),供軌跡對映回到歌曲;出處記錄不進任何 LLM
|
|
||||||
輸入。
|
|
||||||
- **merge 定案 → cap**:merge 定案組名以字典序排成
|
|
||||||
JSON 陣列,即 cap 的輸入(見上節第 5 點)。
|
|
||||||
- **cap 定案 → code 定義檔**:定案組名以字典序逐行填入
|
|
||||||
`01-04-01-code.md` 的詞彙表節(逐字),檔案隨 git
|
|
||||||
commit 後方可執行——code 的定義檔因此自我完備,
|
|
||||||
論文附錄可直接引用。
|
|
||||||
- **code 兩次執行 → code-arb**:逐首比對標籤集合
|
|
||||||
(鍵集合,引述不參與比對);僅有分歧的歌入仲裁輸入
|
|
||||||
JSONL,依歌曲 ID 升序,每筆 `id` 沿用 `song-<ID>`、
|
|
||||||
`content` 為固定鍵序序列化的
|
|
||||||
`{"lyrics": …, "disagreements": …}`,disagreements
|
|
||||||
鍵按字典序。
|
|
||||||
- **code 定案**:每首歌的最終標籤=共識標籤 ∪ 仲裁保留
|
|
||||||
標籤,寫入逐首紀錄檔(歌依 ID 升序、標籤按字典序,
|
|
||||||
各標籤附其定案時的引述與來源層——共識或仲裁)。
|
|
||||||
- **screen 兩次執行 → screen-arb**:僅「一有一無」的歌
|
|
||||||
入仲裁輸入 JSONL(依 ID 升序),`content` 為
|
|
||||||
`{"lyrics": …, "evidence": <肯定方引述>}`。
|
|
||||||
- **screen 定案**:命中集合=兩次皆有 ∪ 仲裁裁定有。
|
|
||||||
- **命名鏈的輸入構成**:`-name` 輸入的組 ID 依最終分組
|
|
||||||
之組序(上節第 5 點)編 g1、g2、…,組內成員字典序;
|
|
||||||
`-name-arb` 輸入中每組的兩個候選名**按字典序排列**
|
|
||||||
——不按執行別,避免順序洩漏何方所取;`taken` 為已
|
|
||||||
定案名的字典序清單。
|
|
||||||
- **女性力量候選集**:於 cap 詞彙表定案後、黃金標準
|
|
||||||
編碼開始前,由研究者指認詞彙表中屬「女性力量」概念
|
|
||||||
域的組(指認及理由記入決策日誌),候選集=code 定案
|
|
||||||
標籤含該等組者 ∪ screen 命中者。
|
|
||||||
- **序列化通則**:所有中間檔為 UTF-8 JSON,鍵序與元素
|
|
||||||
序皆依上列規則明定,無時間戳、無隨機成分;解析一律
|
|
||||||
偵測重複鍵,違規即失敗。JSONL 一律以換行字元(\n)
|
|
||||||
切行——歌詞含 U+2028 等 Unicode 行分隔符,
|
|
||||||
`str.splitlines()` 類的通用切行會截斷 JSON 字串
|
|
||||||
(實測踩中)。給定相同的 LLM 執行輸出,
|
|
||||||
全部交接產物逐位元組可再生。
|
|
||||||
|
|
||||||
## 執行與稽核
|
|
||||||
|
|
||||||
- 每一步驟以 `run-llm <定義檔> <輸入檔> <歸檔目錄>`
|
|
||||||
執行;獨立執行兩次=重現命令清單上的兩行命令,各自
|
|
||||||
歸檔(`runs/<定義檔名>/run1`、`run2`),仲裁與命名
|
|
||||||
各為獨立步驟、獨立歸檔。
|
|
||||||
- 比對、裁決套用、重組、對映皆為確定性程式(子命令),
|
|
||||||
其輸入輸出檔隨 runs/ 歸檔,JSON 解析一律偵測重複鍵,
|
|
||||||
違規即失敗。
|
|
||||||
- Batch API 的每筆請求自含全部脈絡且互不可見(平台
|
|
||||||
契約),歌與歌之間的獨立性由此成立;兩次執行的獨立
|
|
||||||
性由「兩次呼叫、兩個批次、兩份歸檔」的執行結構自明。
|
|
||||||
|
|
||||||
## 映射分析方法
|
|
||||||
|
|
||||||
(依 2026-07-30 決策,於看到結果前寫定;待黃金標準
|
|
||||||
編碼展開前補入。)
|
|
||||||
@@ -9,13 +9,15 @@ pop-fem-audit/
|
|||||||
├── CLAUDE.md # 極簡工作規範(subagent 會讀到,
|
├── CLAUDE.md # 極簡工作規範(subagent 會讀到,
|
||||||
│ # 絕不放理論、codebook、預期結果)
|
│ # 絕不放理論、codebook、預期結果)
|
||||||
├── .gitignore # captures/lyrics/、.env、scratch
|
├── .gitignore # captures/lyrics/、.env、scratch
|
||||||
|
├── conference_abstract.md # pilot 摘要(投稿版)
|
||||||
├── data/ # 依生命週期分層(文字格式)
|
├── data/ # 依生命週期分層(文字格式)
|
||||||
│ ├── source/ # 源頭:手放後不動
|
│ ├── source/ # 源頭:手放後不動
|
||||||
│ │ └── yearend_hot100_2016_2025.csv # 原始榜單
|
│ │ └── yearend_hot100_2016_2025.csv # 原始榜單
|
||||||
│ ├── captures/ # 外部捕捉:只由 fetch 命令與
|
│ ├── captures/ # 外部捕捉:只由 fetch 命令與
|
||||||
│ │ │ # 私人匯入腳本寫入
|
│ │ │ # 私人匯入腳本寫入
|
||||||
│ │ ├── artists-wikidata.csv # Wikidata 快照
|
│ │ ├── artists-wikidata.csv # Wikidata 快照
|
||||||
│ │ ├── lyrics-provenance.csv # 歌詞出處
|
│ │ ├── lyrics_provenance.csv # 歌詞出處
|
||||||
|
│ │ ├── lyrics_missing.csv # 歌詞缺漏報表
|
||||||
│ │ └── lyrics/ # 歌詞 .txt 快取
|
│ │ └── lyrics/ # 歌詞 .txt 快取
|
||||||
│ │ # (gitignored,版權)
|
│ │ # (gitignored,版權)
|
||||||
│ ├── manual/ # 人工著作:只由研究者手寫
|
│ ├── manual/ # 人工著作:只由研究者手寫
|
||||||
@@ -24,9 +26,7 @@ pop-fem-audit/
|
|||||||
│ ├── songs.csv # 歌曲報表(人讀;進 git)
|
│ ├── songs.csv # 歌曲報表(人讀;進 git)
|
||||||
│ └── artists.csv # 歌手報表(人讀;進 git)
|
│ └── artists.csv # 歌手報表(人讀;進 git)
|
||||||
├── prompts/ # LLM 定義檔(逐字作為 system prompt)
|
├── prompts/ # LLM 定義檔(逐字作為 system prompt)
|
||||||
│ └── <軌>-<步>-<次步>-<task>.md # 01-01-01-tag.md、
|
│ └── <task>_v<N>.md # 版本化:screen_v1.md、judge_v2.md…
|
||||||
│ # 01-02-02-merge-arb.md…
|
|
||||||
│ # 不帶版本號,版本即 git 歷史
|
|
||||||
├── tools/ # 輔助工具子專案(src-layout)
|
├── tools/ # 輔助工具子專案(src-layout)
|
||||||
│ ├── pyproject.toml # 發行名 pop-fem-audit-tools;
|
│ ├── pyproject.toml # 發行名 pop-fem-audit-tools;
|
||||||
│ │ # pip install -e tools/ 安裝
|
│ │ # pip install -e tools/ 安裝
|
||||||
@@ -36,45 +36,37 @@ pop-fem-audit/
|
|||||||
│ │ # gitignored;含歌詞全文)
|
│ │ # gitignored;含歌詞全文)
|
||||||
│ ├── src/pop_fem_audit_tools/
|
│ ├── src/pop_fem_audit_tools/
|
||||||
│ │ ├── __main__.py # 套件 CLI 進入點(分派子命令)
|
│ │ ├── __main__.py # 套件 CLI 進入點(分派子命令)
|
||||||
│ │ ├── commands/ # CLI 子命令模組(登記於 __init__)
|
│ │ ├── build_db.py # build the SQLite working store
|
||||||
│ │ │ ├── build_db.py # build the SQLite working store
|
│ │ │ # from the inputs
|
||||||
│ │ │ │ # from the inputs
|
|
||||||
│ │ │ ├── export_llm_input.py # export the LLM input JSONL
|
|
||||||
│ │ │ │ # (lyrics only) from the
|
|
||||||
│ │ │ │ # working store
|
|
||||||
│ │ │ ├── fetch_artists.py # fetch artist metadata from
|
|
||||||
│ │ │ │ # Wikidata into the snapshot CSV
|
|
||||||
│ │ │ ├── fetch_lyrics.py # fetch missing lyrics from the
|
|
||||||
│ │ │ │ # public APIs into the lyrics dir
|
|
||||||
│ │ │ └── run_llm.py # API 執行器:一份定義檔+一份輸入
|
|
||||||
│ │ │ # →歸檔至指定目錄(Batch API);
|
|
||||||
│ │ │ # 比對與仲裁編排由獨立子命令承擔
|
|
||||||
│ │ ├── config.py # pydantic-settings 設定(.env)
|
│ │ ├── config.py # pydantic-settings 設定(.env)
|
||||||
│ │ ├── database.py # SQLAlchemy engine / session / Base
|
│ │ ├── database.py # SQLAlchemy engine / session / Base
|
||||||
│ │ ├── models.py # SQLAlchemy ORM 資料模型
|
│ │ ├── fetch_artists.py # fetch artist metadata from
|
||||||
│ │ └── utils.py # 共用工具(format_duration)
|
│ │ │ # Wikidata into the snapshot CSV
|
||||||
|
│ │ ├── fetch_lyrics.py # fetch missing lyrics from the
|
||||||
|
│ │ │ # public APIs into the lyrics dir
|
||||||
|
│ │ └── run_llm.py # API runner:2+1 協定、Batch API、
|
||||||
|
│ │ # 寫入引數指定的 runs 目錄;執行
|
||||||
|
│ │ # 方式 pop-fem-audit-tools run-llm
|
||||||
│ └── tests/ # 單元測試(unittest)
|
│ └── tests/ # 單元測試(unittest)
|
||||||
├── runs/ # 現行執行的完整稽核紀錄(進 git;
|
├── runs/ # 每次執行的完整稽核紀錄(進 git)
|
||||||
│ │ # 重跑同一 run 須明示 --replace)
|
│ └── <階段>/<日期>-<定義檔版本>/
|
||||||
│ └── <定義檔名>/ # 仲裁步驟居自己的 <task>-arb/
|
│ ├── prompt.md # 當次定義檔快照(自我完備)
|
||||||
│ └── run<N>/ # 每個 run 一份自我完備歸檔
|
│ ├── run1.jsonl # 第一次執行原始輸出
|
||||||
│ ├── prompt.md # 當次定義檔快照(自我完備)
|
│ ├── run2.jsonl # 第二次執行原始輸出
|
||||||
│ ├── output.jsonl # 該次執行原始輸出
|
│ ├── arbitration.jsonl # 仲裁輸出
|
||||||
│ └── meta.json # model ID、temperature、時間戳、
|
│ └── meta.json # model ID、temperature、時間戳、
|
||||||
│ # batch ID、token 用量
|
│ # batch ID、一致率
|
||||||
│ # (一致率由比對子命令記錄)
|
|
||||||
├── results/ # 論文引用的報表 CSV(export 產出;
|
├── results/ # 論文引用的報表 CSV(export 產出;
|
||||||
│ # 「可再生仍 commit」的唯一例外)
|
│ # 「可再生仍 commit」的唯一例外)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── research-plan.md # 研究步驟規劃(本檔之姊妹篇)
|
│ ├── research_plan.md # 研究步驟規劃(本檔之姊妹篇)
|
||||||
│ ├── project-structure.md # 本檔
|
│ ├── project_structure.md # 本檔
|
||||||
│ ├── codebook.md # 人工編碼手冊(版本由 git 管理)
|
│ ├── codebook.md # 人工編碼手冊(版本由 git 管理)
|
||||||
│ ├── decision-log.md # 決策日誌:每次改定義檔的原因
|
│ ├── decision_log.md # 決策日誌:每次改定義檔的原因
|
||||||
│ └── methodology.md # 方法細節(全文方法節底稿;
|
│ └── methodology.md # 方法細節(全文方法節底稿;
|
||||||
│ # 映射分析方法須在看結果前寫定)
|
│ # 映射分析方法須在看結果前寫定)
|
||||||
└── paper/
|
└── paper/
|
||||||
├── abstract.md # 摘要
|
└── full_paper.md # 全文
|
||||||
└── full-paper.md # 全文
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 設計理由
|
## 設計理由
|
||||||
@@ -83,15 +75,14 @@ pop-fem-audit/
|
|||||||
meta,讀者不需 git 考古即可稽核任一筆結果。
|
meta,讀者不需 git 考古即可稽核任一筆結果。
|
||||||
- **`runs/`(原始稽核資料)與 `results/`(最終表)分離**:
|
- **`runs/`(原始稽核資料)與 `results/`(最終表)分離**:
|
||||||
論文只引 `results/`,其來源可回溯至 `runs/`。
|
論文只引 `results/`,其來源可回溯至 `runs/`。
|
||||||
- **`prompts/` 檔名不帶版本號**:版本即 git 歷史,失敗的
|
- **`prompts/` 用檔名版本化**(不只靠 git 歷史):定義檔版本是
|
||||||
版本不保留;論文引用的單位是 `runs/` 內隨執行保存的定義檔
|
論文附錄的引用單位,須可直接指名(如 judge_v2.md)。
|
||||||
快照(每個執行目錄自我完備),不需檔名可指的版本名。
|
|
||||||
- **Commit 判準**:能由「committed 輸入+程式」決定性再生者不
|
- **Commit 判準**:能由「committed 輸入+程式」決定性再生者不
|
||||||
commit(SQLite 工作儲存、LLM 輸入檔);源頭、捕捉、人工著作
|
commit(SQLite 工作儲存、LLM 輸入檔);源頭、捕捉、人工著作
|
||||||
一律以文字 commit。「可再生仍 commit」的例外有二:
|
一律以文字 commit。「可再生仍 commit」的例外有二:
|
||||||
`results/` 報表(引用穩定性、審稿人零門檻、撰稿期可 diff)
|
`results/` 報表(引用穩定性、審稿人零門檻、撰稿期可 diff)
|
||||||
與 `data/derived/` 人讀報表(與工作儲存同一動作產出,稽核
|
與 `data/derived/` 人讀報表(與工作儲存同一動作產出,稽核
|
||||||
鏈無中間空缺)。詳見 `research-plan.md`「資料儲存與模型」。
|
鏈無中間空缺)。詳見 `research_plan.md`「資料儲存與模型」。
|
||||||
- **設定**經 pydantic-settings 統一:`.env`(gitignored,範本
|
- **設定**經 pydantic-settings 統一:`.env`(gitignored,範本
|
||||||
`tools/.env.example`)供應 `SQLALCHEMY_DATABASE_URL` 與
|
`tools/.env.example`)供應 `SQLALCHEMY_DATABASE_URL` 與
|
||||||
`ANTHROPIC_API_KEY`,絕不寫入 repo。
|
`ANTHROPIC_API_KEY`,絕不寫入 repo。
|
||||||
@@ -1,29 +1,20 @@
|
|||||||
# 研究步驟規劃
|
# 研究步驟規劃
|
||||||
|
|
||||||
(2026-07-30 討論定案、2026-07-31 增補資料架構;
|
(2026-07-30 討論定案、2026-07-31 增補資料架構;
|
||||||
後續變更請記入 `decision-log.md`)
|
後續變更請記入 `decision_log.md`)
|
||||||
|
|
||||||
## 總體框架
|
## 總體框架
|
||||||
|
|
||||||
- **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料
|
- **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料
|
||||||
共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究
|
共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究
|
||||||
做比較**;全文數字一律以正式研究
|
(conference_abstract.md)做比較**;全文數字一律以正式研究
|
||||||
結果為準。先導研究僅作為 codebook v0 與假說的內部來源,
|
結果為準。先導研究僅作為 codebook v0 與假說的內部來源,
|
||||||
記於決策日誌,不進入論文敘事。
|
記於決策日誌,不進入論文敘事。
|
||||||
- **執行原則**:主會話只做討論;所有分析由 deterministic script
|
- **執行原則**:主會話只做討論;所有分析由 deterministic script
|
||||||
執行。LLM 步驟以 Python script 呼叫 Anthropic Messages API
|
執行。LLM 步驟以 Python script 呼叫 Anthropic Messages API
|
||||||
(個人 Console 帳號、Batch API 五折),定義檔逐字作為 system
|
(個人 Console 帳號、Batch API 五折),定義檔逐字作為 system
|
||||||
prompt。2+1 協定依輸出可比性適用:輸出可逐項機械比對的步驟
|
prompt。每個 LLM 步驟「同一定義檔獨立執行兩次 + 一次仲裁」
|
||||||
(收斂、編碼)「同一定義檔獨立執行兩次+一次仲裁」,仲裁
|
(2+1),仲裁結果不符預期則修訂定義檔重跑整個循環。
|
||||||
只裁程式算出的分歧清單;自由生成步驟(首步自由標註)兩次
|
|
||||||
執行全數進池、不仲裁——自由詞彙兩次輸出不共享比對單位,
|
|
||||||
無物可裁。仲裁結果不符預期則修訂定義檔重跑該循環。
|
|
||||||
- **提示詞只定格式、不定語意**:研究對象是通用 LLM 以其
|
|
||||||
網路語料知識背景所做的自然編碼,編碼結果本身是批判對象。
|
|
||||||
LLM 定義檔只規定任務形狀(輸入、數量範圍、輸出格式),
|
|
||||||
不給任何主題的定義、判準或範例;研究者的深度判準只寫在
|
|
||||||
codebook(人工黃金標準用),兩者不互相滲透。LLM 編碼
|
|
||||||
一律要求逐標籤引述歌詞原句,作為檢視編碼偏差的依據。
|
|
||||||
- **模型**:`claude-sonnet-4-6`、temperature=0、thinking 關閉
|
- **模型**:`claude-sonnet-4-6`、temperature=0、thinking 關閉
|
||||||
(此組合非決定性最低、成本合理)。條件 A/B 盲點實驗為
|
(此組合非決定性最低、成本合理)。條件 A/B 盲點實驗為
|
||||||
自足設計:同語料、同模型、僅提示不同,對照黃金標準。
|
自足設計:同語料、同模型、僅提示不同,對照黃金標準。
|
||||||
@@ -65,15 +56,15 @@
|
|||||||
建置失敗。
|
建置失敗。
|
||||||
- **歌手背景防火牆**:歌手背景資料只進人工解讀階段(證據表、
|
- **歌手背景防火牆**:歌手背景資料只進人工解讀階段(證據表、
|
||||||
論文討論),**絕不進 LLM 輸入**——LLM 分類的 user message
|
論文討論),**絕不進 LLM 輸入**——LLM 分類的 user message
|
||||||
維持歌詞-only,避免光環偏誤污染條件 A/B 實驗。「女性力量」
|
維持歌詞-only,避免光環偏誤污染條件 A/B 實驗。women-power
|
||||||
候選歌曲的歌手另做深度背景(族裔以公開自我認同為準、音樂
|
候選歌曲的歌手另做深度背景(族裔以公開自我認同為準、音樂
|
||||||
場景),script 輔助、人工核定。
|
場景),script 輔助、人工核定。
|
||||||
- **Pilot 歌詞沿用(私人匯入,不進發布管線)**:先導研究
|
- **Pilot 歌詞沿用(私人匯入,不進發布管線)**:先導研究
|
||||||
捕捉檔(lyrics.json,684 首,2018–2025)以私人腳本
|
捕捉檔(lyrics.json,684 首,2018–2025)以 `excludes/` 的
|
||||||
匯入歌詞快取——只取識別欄位與歌詞本文,以
|
私人腳本匯入歌詞快取——只取識別欄位與歌詞本文,以
|
||||||
(year, rank) 精確匹配 song_id。匯入工具不屬於專案交付物
|
(year, rank) 精確匹配 song_id。匯入工具不屬於專案交付物
|
||||||
(讀者拿不到其輸入),讀者的重現路徑純粹是 `fetch-lyrics`;
|
(讀者拿不到其輸入),讀者的重現路徑純粹是 `fetch-lyrics`;
|
||||||
沿用之**事實**記於 `data/captures/lyrics-provenance.csv`(進 git):
|
沿用之**事實**記於 `data/captures/lyrics_provenance.csv`(進 git):
|
||||||
`source`(原始 API)與 `method`
|
`source`(原始 API)與 `method`
|
||||||
(pilot-import / api-fetch / manual)兩層,取得日期不可考者
|
(pilot-import / api-fetch / manual)兩層,取得日期不可考者
|
||||||
不假造,僅記可證上界。
|
不假造,僅記可證上界。
|
||||||
@@ -83,72 +74,19 @@
|
|||||||
`export-llm-input`(階段 2 前補上);之後再加報表 export
|
`export-llm-input`(階段 2 前補上);之後再加報表 export
|
||||||
與統計。
|
與統計。
|
||||||
|
|
||||||
## 自然編碼管線(2026-08-04 定案)
|
|
||||||
|
|
||||||
主題編碼採四步驟管線,歌詞只出現在第 1、4 步;第 2、3 步
|
|
||||||
是純概念整理(目標是產生 codebook 詞彙表,不是編碼)。
|
|
||||||
|
|
||||||
1. **自由標註(tag)**:逐首歌請模型標註 thematic
|
|
||||||
keywords,附歌詞引述;提示零語意內容。獨立執行兩次,
|
|
||||||
兩次輸出全數進池(記錄執行別),不仲裁。粒度鎖定
|
|
||||||
thematic keywords:前導研究三粒度比較(keywords 過碎、
|
|
||||||
themes 過早抽象)之繼承,於執行前鎖定,防止事後擇優。
|
|
||||||
2. **自然收斂(merge)**:輸入為池中純去重關鍵字清單——
|
|
||||||
無歌詞、無頻次、無歌曲出處——模型按自身理解合併近似
|
|
||||||
概念。2+1:兩次收斂處理同一批輸入詞,程式算出共識塊
|
|
||||||
與分歧塊對,逐對仲裁(仲裁自身亦 2+1,第三票終局)後
|
|
||||||
以連通元件決定性重組;收斂執行不命名,組名由命名步驟
|
|
||||||
2+1 定案(演算法詳見 `methodology.md`)。
|
|
||||||
3. **強制收斂(cap)**:同第 2 步形態,輸入為 merge
|
|
||||||
定案後的組名清單(原始關鍵字不下傳),限制併至 50 個
|
|
||||||
以內;仲裁後略超 50 可接受,如實記錄。頻次不入收斂:頻率的分析角色由第 4 步編碼承擔;
|
|
||||||
池中頻次含跨執行噪音;頻次會誘使模型以頻率剪枝(喪失
|
|
||||||
稀有主題)並把高頻大主題切細。代價(特異主題佔名額)
|
|
||||||
已知並接受,換取主題多樣性與純語意歸併的可辯護性。
|
|
||||||
4. **編碼(code)**:以定稿詞彙表對全部歌曲 2+1 編碼——
|
|
||||||
模型讀歌詞、逐標籤附引述;逐首計一致率,分歧逐首仲裁
|
|
||||||
(仲裁者看歌詞與兩邊引述)。此為主儀器,「女性力量」
|
|
||||||
候選集由此浮現;亦是對前導研究「映回後未對照歌詞」
|
|
||||||
限制的明文改良。沿收斂軌跡的機械對映(純程式)保留為
|
|
||||||
零成本診斷副產品,量測收斂軌跡的扭曲,不作主結果。
|
|
||||||
|
|
||||||
另設**「女性力量」單目標篩選(screen)**:單目標提示、
|
|
||||||
附引述、全量執行,僅作為黃金標準取樣的補漏網——把「模型
|
|
||||||
被明示提醒後認得出」的歌撈進人工審視範圍,以量測模型漏標
|
|
||||||
方向的偏差;不進偏差統計的分子分母定義。逐首 2+1(有/無
|
|
||||||
+引述,逐首仲裁);同樣不給定義,提示僅含標籤詞與任務
|
|
||||||
形狀。標籤詞用 `women-power`——研究者任意決定的先驗
|
|
||||||
主題,即本研究的論文主題本身,不由資料產生。其結果僅作
|
|
||||||
召回之用,永不進任何統計的分子分母。標籤詞是 screen
|
|
||||||
提示中唯一的語意種子,屬研究者的儀器選擇,據實揭露。
|
|
||||||
|
|
||||||
定義檔命名 `prompts/<軌>-<步>-<次步>-<task>.md`——軌
|
|
||||||
01=由下而上自然編碼、02=預先決定的 women-power
|
|
||||||
篩選;步為軌內步驟序,次步為
|
|
||||||
步內執行順序,讀者依編號先後依循(如 01-02-01-merge.md →
|
|
||||||
01-02-02-merge-arb.md → 01-02-03-merge-arb-arb.md →
|
|
||||||
01-02-04-merge-name.md → 01-02-05-merge-name-arb.md)。
|
|
||||||
後綴 `-arb` 為仲裁、`-arb-arb` 為仲裁之終局票、`-name`
|
|
||||||
為命名、`-name-arb` 為命名擇一仲裁。檔名不帶
|
|
||||||
版本號——版本即 git 歷史,失敗的版本不保留,需要回看的
|
|
||||||
舊版都在 git history;每次執行的定義檔快照隨 `runs/`
|
|
||||||
自我完備。收斂執行的輸出本身即分組記錄(無名分組
|
|
||||||
JSON),與全部中間交接檔同隨 `runs/` 歸檔(交接契約見
|
|
||||||
`methodology.md`)。
|
|
||||||
|
|
||||||
## 階段與時程(全文截稿 2026-08-15)
|
## 階段與時程(全文截稿 2026-08-15)
|
||||||
|
|
||||||
| 階段 | 內容 | 方式 | 時程 |
|
| 階段 | 內容 | 方式 | 時程 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 0 | 基礎建設:git init、目錄結構、.gitignore、決策日誌、runner script(含 Batch API)、codebook v0 骨架 | script + 討論 | 7/30–7/31 |
|
| 0 | 基礎建設:git init、目錄結構、.gitignore、決策日誌、runner script(含 Batch API)、codebook v0 骨架 | script + 討論 | 7/30–7/31 |
|
||||||
| 1 | 資料準備:`run_llm` 改走統一設定 → `build-db`(解析榜單成 songs/chart_entries/artists/song_artists)→ `import-lyrics`(pilot 2018–2025)→ `fetch-lyrics`(2016–17 與缺漏,Lyrics.ovh / LRCLIB)→ `fetch-artists`(Wikidata 快照)→ `export-llm-input` | 子命令 | 7/31–8/3 |
|
| 1 | 資料準備:`run_llm` 改走統一設定 → `build-db`(解析榜單成 songs/chart_entries/artists/song_artists)→ `import-lyrics`(pilot 2018–2025)→ `fetch-lyrics`(2016–17 與缺漏,Lyrics.ovh / LRCLIB)→ `fetch-artists`(Wikidata 快照)→ `export-llm-input` | 子命令 | 7/31–8/3 |
|
||||||
| 2 | 自然編碼管線:tag ×2 進池 → merge 2+1 → cap 2+1 → 詞彙表定稿 → code 2+1(全 883 首,附引述);另跑 screen 補漏網 | API + script | 8/4–8/7 |
|
| 2 | 候選篩選:全部唯一歌曲高召回 women-power 候選篩選(寧可多抓,人工剔除) | API 2+1 | 8/2–8/3 |
|
||||||
| 3 | 黃金標準:依 codebook 人工逐首判定 genuine/peripheral/fake,附引用歌詞證據表(LLM 只做摘錄,不給判定建議);先以 10–15 首校準樣本試編並修訂 codebook 後凍結;同批校準樣本實測 Sonnet 4.6 vs Opus 5 一致率 | 人工 + script 輔助 | 8/5–8/9 |
|
| 3 | 黃金標準:依 codebook 人工逐首判定 genuine/peripheral/fake,附引用歌詞證據表(LLM 只做摘錄,不給判定建議);先以 10–15 首校準樣本試編並修訂 codebook 後凍結;同批校準樣本實測 Sonnet 4.6 vs Opus 5 一致率 | 人工 + script 輔助 | 8/4–8/8 |
|
||||||
| 4 | 受控比較(盲點實驗):條件 A(詞彙層提示)vs 條件 B(框架感知提示),各 2+1,對照黃金標準計算假陽/假陰率 | API | 8/8–8/11 |
|
| 4 | 受控比較(盲點實驗):條件 A(詞彙層提示)vs 條件 B(框架感知提示),各 2+1,對照黃金標準計算假陽/假陰率 | API | 8/6–8/9 |
|
||||||
| 4' | 映射分析:自然編碼結果(第 4 步)與黃金標準交叉表;軌跡對映 vs 直接編碼的扭曲診斷(分析方法先寫入 methodology.md 再看結果) | script | 與 4 並行 |
|
| 4' | 由下而上歸納輪:LLM 對候選歌曲做無理論主題編碼(定義檔不含女性主義詞彙),與黃金標準做映射分析(交叉表;分析方法先寫入 methodology.md 再看結果) | API 2+1 + script | 與 4 並行 |
|
||||||
| 5 | pussy 修辭分析:script 找含詞歌曲 → LLM 分類(METONYM/ANATOMICAL/COWARD/OTHER + 說話者/指涉對象)2+1 → 人工核定 | script + API | 8/9–8/11 |
|
| 5 | pussy 修辭分析:script 找含詞歌曲 → LLM 分類(METONYM/ANATOMICAL/COWARD/OTHER + 說話者/指涉對象)2+1 → 人工核定 | script + API | 8/8–8/10 |
|
||||||
| 6 | 統計與圖表:一致率(kappa)、污染率、類型分布、年度趨勢、映射交叉表 | scripts | 8/11–8/12 |
|
| 6 | 統計與圖表:一致率(kappa)、污染率、類型分布、年度趨勢、映射交叉表 | scripts | 8/10–8/11 |
|
||||||
| 7 | 撰寫全文+內部審稿(subagent 以審稿人視角挑毛病,特別是嘻哈女性主義/respectability politics 一題) | 討論 + subagent | 8/11–8/14 |
|
| 7 | 撰寫全文+內部審稿(subagent 以審稿人視角挑毛病,特別是嘻哈女性主義/respectability politics 一題) | 討論 + subagent | 8/10–8/14 |
|
||||||
|
|
||||||
關鍵路徑:階段 3 人工編碼(僅研究者本人可做),排週末與晚間分批。
|
關鍵路徑:階段 3 人工編碼(僅研究者本人可做),排週末與晚間分批。
|
||||||
|
|
||||||
@@ -165,10 +103,14 @@ JSON),與全部中間交接檔同隨 `runs/` 歸檔(交接契約見
|
|||||||
與「貶低其他女性/以男性指標定義權力」。
|
與「貶低其他女性/以男性指標定義權力」。
|
||||||
- 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼;
|
- 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼;
|
||||||
中途修訂須記決策日誌並回檢已編歌曲。
|
中途修訂須記決策日誌並回檢已編歌曲。
|
||||||
|
- Positionality statement 寫入論文(53 歲、長年婦運者、資深
|
||||||
|
工程師、離開學術圈多年、因整理榜單而起)。
|
||||||
|
|
||||||
## 其他已定案事項
|
## 其他已定案事項
|
||||||
|
|
||||||
- 歌詞受版權保護:完整歌詞不進 git(`data/captures/lyrics/` gitignored),
|
- 歌詞受版權保護:完整歌詞不進 git(`data/captures/lyrics/` gitignored),
|
||||||
論文與 repo 只留分類所引摘錄。
|
論文與 repo 只留分類所引摘錄。
|
||||||
|
- 工程支援(寫 script、style check、審稿)用公司訂閱的
|
||||||
|
Claude Code;進論文的分析 token 由個人 API 帳號支付。
|
||||||
- Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、
|
- Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、
|
||||||
無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。
|
無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
You are annotating song lyrics for a content-analysis corpus.
|
|
||||||
|
|
||||||
Input: the complete lyrics of one song.
|
|
||||||
|
|
||||||
Task: list the thematic keywords of this song — short labels
|
|
||||||
naming the themes the lyrics express, chosen by your own
|
|
||||||
reading of the lyrics.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Give between 5 and 10 thematic keywords.
|
|
||||||
- Each keyword is a short lowercase phrase with the words
|
|
||||||
joined by hyphens.
|
|
||||||
- For each keyword, quote 1 to 3 verbatim lines of the lyrics
|
|
||||||
that ground it; do not give a keyword you cannot ground.
|
|
||||||
|
|
||||||
Output a single JSON object mapping each keyword to its list
|
|
||||||
of quotes, and nothing else:
|
|
||||||
|
|
||||||
{
|
|
||||||
"first-keyword": ["quoted line", "another quoted line"],
|
|
||||||
"second-keyword": ["quoted line"]
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
You are consolidating a vocabulary of thematic keywords that
|
|
||||||
were independently annotated on the songs of a corpus.
|
|
||||||
|
|
||||||
Input: a JSON array of thematic keywords, deduplicated and
|
|
||||||
unordered.
|
|
||||||
|
|
||||||
Task: merge keywords that express the same or nearly the same
|
|
||||||
theme, by your own understanding of what the keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Every input keyword must appear in exactly one group.
|
|
||||||
- Do not invent keywords that are not in the input.
|
|
||||||
- Merge only what you consider the same theme; a group may be
|
|
||||||
a single keyword.
|
|
||||||
|
|
||||||
Output a single JSON array of groups — each group an array of
|
|
||||||
its member keywords — and nothing else:
|
|
||||||
|
|
||||||
[
|
|
||||||
["keyword", "another-keyword"],
|
|
||||||
["keyword"]
|
|
||||||
]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
You are arbitrating between two independent consolidations of
|
|
||||||
the same vocabulary of thematic keywords from a song corpus.
|
|
||||||
|
|
||||||
Where the two consolidations agree, the result has been
|
|
||||||
settled by script and is not shown. You rule on the
|
|
||||||
disagreements: pairs of keyword blocks that one consolidation
|
|
||||||
merged into one theme and the other kept apart.
|
|
||||||
|
|
||||||
Input: a JSON object. "blocks" maps a block id to the
|
|
||||||
keywords of that block; "pairs" lists the block-id pairs in
|
|
||||||
question:
|
|
||||||
|
|
||||||
{
|
|
||||||
"blocks": {
|
|
||||||
"b1": ["keyword", "another-keyword"],
|
|
||||||
"b2": ["keyword"],
|
|
||||||
"b3": ["keyword"]
|
|
||||||
},
|
|
||||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: for each listed pair, decide whether the two blocks
|
|
||||||
express the same theme, by your own understanding of what the
|
|
||||||
keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Judge every listed pair, each on its own merits from the
|
|
||||||
block contents alone.
|
|
||||||
- Judge only the listed pairs.
|
|
||||||
|
|
||||||
Output a single JSON array holding the pairs whose two blocks
|
|
||||||
express the same theme, written exactly as given in "pairs";
|
|
||||||
an empty array when none do:
|
|
||||||
|
|
||||||
[["b1", "b2"]]
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
You are casting the deciding judgment on disagreements about
|
|
||||||
a consolidated vocabulary of thematic keywords from a song
|
|
||||||
corpus: for each pair of keyword blocks in question, earlier
|
|
||||||
independent judgments disagreed on whether the two blocks
|
|
||||||
express the same theme.
|
|
||||||
|
|
||||||
Input: a JSON object. "blocks" maps a block id to the
|
|
||||||
keywords of that block; "pairs" lists the block-id pairs in
|
|
||||||
question:
|
|
||||||
|
|
||||||
{
|
|
||||||
"blocks": {
|
|
||||||
"b1": ["keyword", "another-keyword"],
|
|
||||||
"b2": ["keyword"],
|
|
||||||
"b3": ["keyword"]
|
|
||||||
},
|
|
||||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: for each listed pair, decide whether the two blocks
|
|
||||||
express the same theme, by your own understanding of what the
|
|
||||||
keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Judge every listed pair, each on its own merits from the
|
|
||||||
block contents alone.
|
|
||||||
- Judge only the listed pairs.
|
|
||||||
|
|
||||||
Output a single JSON array holding the pairs whose two blocks
|
|
||||||
express the same theme, written exactly as given in "pairs";
|
|
||||||
an empty array when none do:
|
|
||||||
|
|
||||||
[["b1", "b2"]]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
You are naming the theme groups of a consolidated vocabulary
|
|
||||||
of thematic keywords from a song corpus.
|
|
||||||
|
|
||||||
Input: a JSON object mapping an opaque group id to the member
|
|
||||||
keywords of that group:
|
|
||||||
|
|
||||||
{
|
|
||||||
"g1": ["keyword", "another-keyword"],
|
|
||||||
"g2": ["keyword"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: give each group a name that best names the theme its
|
|
||||||
members gather, by your own understanding of what the
|
|
||||||
keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Each name is a short lowercase phrase with the words joined
|
|
||||||
by hyphens.
|
|
||||||
- Names must be unique across the groups.
|
|
||||||
|
|
||||||
Output a single JSON object mapping each group id to its
|
|
||||||
name, and nothing else:
|
|
||||||
|
|
||||||
{
|
|
||||||
"g1": "group-name",
|
|
||||||
"g2": "another-group-name"
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
You are arbitrating between two independent namings of the
|
|
||||||
theme groups of a consolidated vocabulary of thematic
|
|
||||||
keywords from a song corpus.
|
|
||||||
|
|
||||||
For each group in question, the two namings proposed
|
|
||||||
different names. You choose between them.
|
|
||||||
|
|
||||||
Input: a JSON object. "groups" maps an opaque group id to
|
|
||||||
its member keywords and its two candidate names; "taken"
|
|
||||||
lists names that are already in use:
|
|
||||||
|
|
||||||
{
|
|
||||||
"groups": {
|
|
||||||
"g1": {
|
|
||||||
"members": ["keyword", "another-keyword"],
|
|
||||||
"candidates": ["one-name", "other-name"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taken": ["existing-name"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: for each group, choose the candidate that better names
|
|
||||||
the theme its members gather, by your own understanding of
|
|
||||||
what the keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Choose only from that group's two candidates.
|
|
||||||
- Choices must be unique across the groups and must not
|
|
||||||
reuse any name in "taken".
|
|
||||||
|
|
||||||
Output a single JSON object mapping each group id to the
|
|
||||||
chosen name, and nothing else:
|
|
||||||
|
|
||||||
{
|
|
||||||
"g1": "one-name"
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
You are consolidating a vocabulary of thematic keywords
|
|
||||||
from a song corpus further, to a fixed maximum number of
|
|
||||||
themes.
|
|
||||||
|
|
||||||
Input: a JSON array of thematic keywords, deduplicated and
|
|
||||||
unordered.
|
|
||||||
|
|
||||||
Task: merge keywords that express the same or a similar
|
|
||||||
theme, by your own understanding of what the keywords mean,
|
|
||||||
until at most 50 groups remain.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Every input keyword must appear in exactly one group.
|
|
||||||
- Do not invent keywords that are not in the input.
|
|
||||||
- Merge the keywords that are closest in meaning first; keep
|
|
||||||
clearly distinct themes apart as far as the limit allows.
|
|
||||||
A group may be a single keyword.
|
|
||||||
|
|
||||||
Output a single JSON array of groups — each group an array of
|
|
||||||
its member keywords — and nothing else:
|
|
||||||
|
|
||||||
[
|
|
||||||
["keyword", "another-keyword"],
|
|
||||||
["keyword"]
|
|
||||||
]
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
You are arbitrating between two independent consolidations
|
|
||||||
that each reduced the same vocabulary of thematic keywords
|
|
||||||
from a song corpus to a fixed maximum number of themes.
|
|
||||||
|
|
||||||
Where the two consolidations agree, the result has been
|
|
||||||
settled by script and is not shown. You rule on the
|
|
||||||
disagreements: pairs of keyword blocks that one consolidation
|
|
||||||
merged into one theme and the other kept apart.
|
|
||||||
|
|
||||||
Input: a JSON object. "blocks" maps a block id to the
|
|
||||||
keywords of that block; "pairs" lists the block-id pairs in
|
|
||||||
question:
|
|
||||||
|
|
||||||
{
|
|
||||||
"blocks": {
|
|
||||||
"b1": ["keyword", "another-keyword"],
|
|
||||||
"b2": ["keyword"],
|
|
||||||
"b3": ["keyword"]
|
|
||||||
},
|
|
||||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: for each listed pair, decide whether the two blocks
|
|
||||||
express the same theme, by your own understanding of what the
|
|
||||||
keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Judge every listed pair, each on its own merits from the
|
|
||||||
block contents alone.
|
|
||||||
- Judge only the listed pairs.
|
|
||||||
|
|
||||||
Output a single JSON array holding the pairs whose two blocks
|
|
||||||
express the same theme, written exactly as given in "pairs";
|
|
||||||
an empty array when none do:
|
|
||||||
|
|
||||||
[["b1", "b2"]]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
You are casting the deciding judgment on disagreements about
|
|
||||||
a vocabulary of thematic keywords from a song corpus that was
|
|
||||||
consolidated to a fixed maximum number of themes: for each
|
|
||||||
pair of keyword blocks in question, earlier independent
|
|
||||||
judgments disagreed on whether the two blocks express the
|
|
||||||
same theme.
|
|
||||||
|
|
||||||
Input: a JSON object. "blocks" maps a block id to the
|
|
||||||
keywords of that block; "pairs" lists the block-id pairs in
|
|
||||||
question:
|
|
||||||
|
|
||||||
{
|
|
||||||
"blocks": {
|
|
||||||
"b1": ["keyword", "another-keyword"],
|
|
||||||
"b2": ["keyword"],
|
|
||||||
"b3": ["keyword"]
|
|
||||||
},
|
|
||||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: for each listed pair, decide whether the two blocks
|
|
||||||
express the same theme, by your own understanding of what the
|
|
||||||
keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Judge every listed pair, each on its own merits from the
|
|
||||||
block contents alone.
|
|
||||||
- Judge only the listed pairs.
|
|
||||||
|
|
||||||
Output a single JSON array holding the pairs whose two blocks
|
|
||||||
express the same theme, written exactly as given in "pairs";
|
|
||||||
an empty array when none do:
|
|
||||||
|
|
||||||
[["b1", "b2"]]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
You are naming the theme groups of a vocabulary of thematic
|
|
||||||
keywords from a song corpus that was consolidated to a fixed
|
|
||||||
maximum number of themes.
|
|
||||||
|
|
||||||
Input: a JSON object mapping an opaque group id to the member
|
|
||||||
keywords of that group:
|
|
||||||
|
|
||||||
{
|
|
||||||
"g1": ["keyword", "another-keyword"],
|
|
||||||
"g2": ["keyword"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: give each group a name that best names the theme its
|
|
||||||
members gather, by your own understanding of what the
|
|
||||||
keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Each name is a short lowercase phrase with the words joined
|
|
||||||
by hyphens.
|
|
||||||
- Names must be unique across the groups.
|
|
||||||
|
|
||||||
Output a single JSON object mapping each group id to its
|
|
||||||
name, and nothing else:
|
|
||||||
|
|
||||||
{
|
|
||||||
"g1": "group-name",
|
|
||||||
"g2": "another-group-name"
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
You are arbitrating between two independent namings of the
|
|
||||||
theme groups of a vocabulary of thematic keywords from a song
|
|
||||||
corpus that was consolidated to a fixed maximum number of
|
|
||||||
themes.
|
|
||||||
|
|
||||||
For each group in question, the two namings proposed
|
|
||||||
different names. You choose between them.
|
|
||||||
|
|
||||||
Input: a JSON object. "groups" maps an opaque group id to
|
|
||||||
its member keywords and its two candidate names; "taken"
|
|
||||||
lists names that are already in use:
|
|
||||||
|
|
||||||
{
|
|
||||||
"groups": {
|
|
||||||
"g1": {
|
|
||||||
"members": ["keyword", "another-keyword"],
|
|
||||||
"candidates": ["one-name", "other-name"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taken": ["existing-name"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: for each group, choose the candidate that better names
|
|
||||||
the theme its members gather, by your own understanding of
|
|
||||||
what the keywords mean.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Choose only from that group's two candidates.
|
|
||||||
- Choices must be unique across the groups and must not
|
|
||||||
reuse any name in "taken".
|
|
||||||
|
|
||||||
Output a single JSON object mapping each group id to the
|
|
||||||
chosen name, and nothing else:
|
|
||||||
|
|
||||||
{
|
|
||||||
"g1": "one-name"
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
You are coding song lyrics against a fixed vocabulary of
|
|
||||||
thematic keywords.
|
|
||||||
|
|
||||||
Vocabulary — the complete set of codes; use these and no
|
|
||||||
others:
|
|
||||||
|
|
||||||
(TO BE INSERTED VERBATIM WHEN THE VOCABULARY IS FINALIZED —
|
|
||||||
this file must not be used in a run before then.)
|
|
||||||
|
|
||||||
Input: the complete lyrics of one song.
|
|
||||||
|
|
||||||
Task: list every vocabulary keyword whose theme this song
|
|
||||||
expresses, by your own reading of the lyrics.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Use only keywords from the vocabulary, spelled exactly as
|
|
||||||
given.
|
|
||||||
- A song may match any number of keywords, including none.
|
|
||||||
- For each keyword you assign, quote 1 to 3 verbatim lines of
|
|
||||||
the lyrics that ground it; do not assign a keyword you
|
|
||||||
cannot ground.
|
|
||||||
|
|
||||||
Output a single JSON object mapping each assigned keyword to
|
|
||||||
its list of quotes (an empty object when no keyword applies),
|
|
||||||
and nothing else:
|
|
||||||
|
|
||||||
{
|
|
||||||
"first-keyword": ["quoted line", "another quoted line"],
|
|
||||||
"second-keyword": ["quoted line"]
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
You are arbitrating between two independent codings of the
|
|
||||||
same song against the same fixed vocabulary of thematic
|
|
||||||
keywords.
|
|
||||||
|
|
||||||
The keywords the two codings agree on have been settled by
|
|
||||||
script and are not shown. You rule only on the
|
|
||||||
disagreements: the keywords assigned by one coding but not
|
|
||||||
the other.
|
|
||||||
|
|
||||||
Input: a JSON object with the lyrics of the song and the
|
|
||||||
disagreements — each disagreement maps a keyword to the lyric
|
|
||||||
quotes the assigning coding gave as evidence:
|
|
||||||
|
|
||||||
{
|
|
||||||
"lyrics": "...",
|
|
||||||
"disagreements": {
|
|
||||||
"first-keyword": ["quoted line", "another quoted line"],
|
|
||||||
"second-keyword": ["quoted line"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: for each disagreement keyword, decide whether the song
|
|
||||||
expresses that theme, by your own reading of the lyrics and
|
|
||||||
of the quoted evidence.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Rule on every listed keyword: keep it or drop it.
|
|
||||||
- Keep a keyword only when you can ground it in verbatim
|
|
||||||
lines of the lyrics; quote them.
|
|
||||||
- Do not add any keyword that is not listed.
|
|
||||||
|
|
||||||
Output a single JSON object mapping each kept keyword to your
|
|
||||||
list of quotes, and nothing else. A dropped keyword is simply
|
|
||||||
left out; output an empty object when nothing is kept:
|
|
||||||
|
|
||||||
{
|
|
||||||
"kept-keyword": ["quoted line", "another quoted line"]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
You are screening song lyrics for one specific theme.
|
|
||||||
|
|
||||||
Input: the complete lyrics of one song.
|
|
||||||
|
|
||||||
Task: judge whether this song expresses the theme
|
|
||||||
"women-power", by your own understanding of
|
|
||||||
that label.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- If the song expresses the theme, quote 1 to 3 verbatim
|
|
||||||
lines of the lyrics that ground the judgment; if you cannot
|
|
||||||
ground it in a quote, the answer is no.
|
|
||||||
|
|
||||||
Output a single JSON array and nothing else: the quoted
|
|
||||||
lines when the song expresses the theme, or an empty array
|
|
||||||
when it does not:
|
|
||||||
|
|
||||||
["quoted line", "another quoted line"]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
You are arbitrating a screening of song lyrics for one
|
|
||||||
specific theme: two independent screenings of the same song
|
|
||||||
disagreed on whether it expresses the theme
|
|
||||||
"women-power".
|
|
||||||
|
|
||||||
Input: a JSON object with the lyrics of the song and the
|
|
||||||
lyric quotes that the affirming screening gave as evidence:
|
|
||||||
|
|
||||||
{
|
|
||||||
"lyrics": "...",
|
|
||||||
"evidence": ["quoted line", "another quoted line"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Task: decide whether the song expresses the theme, by your
|
|
||||||
own understanding of the label, reading the lyrics and the
|
|
||||||
quoted evidence.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- If the song expresses the theme, quote 1 to 3 verbatim
|
|
||||||
lines of the lyrics that ground the judgment; if you cannot
|
|
||||||
ground it in a quote, the answer is no.
|
|
||||||
|
|
||||||
Output a single JSON array and nothing else: the quoted
|
|
||||||
lines when the song expresses the theme, or an empty array
|
|
||||||
when it does not:
|
|
||||||
|
|
||||||
["quoted line", "another quoted line"]
|
|
||||||
@@ -49,12 +49,6 @@ fetch-lyrics
|
|||||||
Fetch lyrics, for ``build-db`` to merge the fetched lyrics into the database. Lyrics are not committed into the repository due to copyright issue. Check ``pop-fem-audit-tools fetch-lyrics -h`` for complete instructions on its usage.
|
Fetch lyrics, for ``build-db`` to merge the fetched lyrics into the database. Lyrics are not committed into the repository due to copyright issue. Check ``pop-fem-audit-tools fetch-lyrics -h`` for complete instructions on its usage.
|
||||||
|
|
||||||
|
|
||||||
export-llm-input
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Export song data for ``run-llm`` to talk to LLM. Check ``pop-fem-audit-tools export-llm-input -h`` for complete instructions on its usage.
|
|
||||||
|
|
||||||
|
|
||||||
run-llm
|
run-llm
|
||||||
-------
|
-------
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
pop\_fem\_audit\_tools.commands package
|
|
||||||
=======================================
|
|
||||||
|
|
||||||
Submodules
|
|
||||||
----------
|
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.commands.build\_db module
|
|
||||||
------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: pop_fem_audit_tools.commands.build_db
|
|
||||||
:members:
|
|
||||||
:show-inheritance:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.commands.export\_llm\_input module
|
|
||||||
---------------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: pop_fem_audit_tools.commands.export_llm_input
|
|
||||||
:members:
|
|
||||||
:show-inheritance:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.commands.fetch\_artists module
|
|
||||||
-----------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: pop_fem_audit_tools.commands.fetch_artists
|
|
||||||
:members:
|
|
||||||
:show-inheritance:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.commands.fetch\_lyrics module
|
|
||||||
----------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: pop_fem_audit_tools.commands.fetch_lyrics
|
|
||||||
:members:
|
|
||||||
:show-inheritance:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.commands.run\_llm module
|
|
||||||
-----------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: pop_fem_audit_tools.commands.run_llm
|
|
||||||
:members:
|
|
||||||
:show-inheritance:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
Module contents
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: pop_fem_audit_tools.commands
|
|
||||||
:members:
|
|
||||||
:show-inheritance:
|
|
||||||
:undoc-members:
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
pop\_fem\_audit\_tools package
|
pop\_fem\_audit\_tools package
|
||||||
==============================
|
==============================
|
||||||
|
|
||||||
Subpackages
|
|
||||||
-----------
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 4
|
|
||||||
|
|
||||||
pop_fem_audit_tools.commands
|
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
pop\_fem\_audit\_tools.build\_db module
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools.build_db
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.config module
|
pop\_fem\_audit\_tools.config module
|
||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
@@ -28,6 +28,22 @@ pop\_fem\_audit\_tools.database module
|
|||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
|
||||||
|
pop\_fem\_audit\_tools.fetch\_artists module
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools.fetch_artists
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
pop\_fem\_audit\_tools.fetch\_lyrics module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools.fetch_lyrics
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.models module
|
pop\_fem\_audit\_tools.models module
|
||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
@@ -36,10 +52,10 @@ pop\_fem\_audit\_tools.models module
|
|||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
|
||||||
pop\_fem\_audit\_tools.utils module
|
pop\_fem\_audit\_tools.run\_llm module
|
||||||
-----------------------------------
|
--------------------------------------
|
||||||
|
|
||||||
.. automodule:: pop_fem_audit_tools.utils
|
.. automodule:: pop_fem_audit_tools.run_llm
|
||||||
:members:
|
:members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
|||||||
@@ -15,23 +15,21 @@ from collections.abc import Callable
|
|||||||
from importlib.machinery import ModuleSpec
|
from importlib.machinery import ModuleSpec
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from .commands import (
|
from pop_fem_audit_tools import (
|
||||||
build_db_command,
|
build_db,
|
||||||
export_llm_input_command,
|
fetch_artists,
|
||||||
fetch_artists_command,
|
fetch_lyrics,
|
||||||
fetch_lyrics_command,
|
run_llm,
|
||||||
run_llm_command,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
MODULE_PROG: str = "python -m pop_fem_audit_tools"
|
MODULE_PROG: str = "python -m pop_fem_audit_tools"
|
||||||
"""The program name when run with ``python -m``."""
|
"""The program name when run with ``python -m``."""
|
||||||
|
|
||||||
SUBCOMMANDS: dict[str, Callable[[list[str] | None], int]] = {
|
SUBCOMMANDS: dict[str, Callable[[list[str] | None], int]] = {
|
||||||
"build-db": build_db_command,
|
"build-db": build_db.main,
|
||||||
"export-llm-input": export_llm_input_command,
|
"fetch-artists": fetch_artists.main,
|
||||||
"fetch-artists": fetch_artists_command,
|
"fetch-lyrics": fetch_lyrics.main,
|
||||||
"fetch-lyrics": fetch_lyrics_command,
|
"run-llm": run_llm.main,
|
||||||
"run-llm": run_llm_command,
|
|
||||||
}
|
}
|
||||||
"""The dispatch table from the subcommand name to the tool main."""
|
"""The dispatch table from the subcommand name to the tool main."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,748 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
"""The builder of the SQLite working store.
|
||||||
|
|
||||||
|
Rebuilds the working store from scratch out of the committed
|
||||||
|
inputs: the year-end chart CSV and the output directory for the
|
||||||
|
review CSV files, given as the two positional command-line
|
||||||
|
arguments, and the optional capture inputs, each given as an
|
||||||
|
option: the lyrics cache directory and the Wikidata artist
|
||||||
|
snapshot CSV. An omitted option
|
||||||
|
leaves its capture layer unloaded; a given option whose path does
|
||||||
|
not exist fails the build. Missing tables
|
||||||
|
are created on a fresh store; existing tables are never altered,
|
||||||
|
as the schema lifecycle belongs to the migrations. Every rebuild
|
||||||
|
deletes all the rows, loads the data, and validates it in one
|
||||||
|
transaction, committed only after the data passes the validation
|
||||||
|
invariants; a failed build leaves the previous store contents
|
||||||
|
intact.
|
||||||
|
|
||||||
|
The rebuild is deterministic: the builder assigns the song and
|
||||||
|
artist IDs itself, as 1, 2, 3, ... in the first-occurrence file
|
||||||
|
order, so the IDs are reproducible across rebuilds on every
|
||||||
|
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
|
||||||
|
identity key resolved from the parsed artist name (see
|
||||||
|
`resolve_artist_identity`): the case-folded name, or, when that
|
||||||
|
case-folded name is listed in ``CANONICAL_ARTIST_NAMES``, the
|
||||||
|
case-folded canonical spelling, so letter-case variants and
|
||||||
|
alternate spellings mapped to the same canonical name all
|
||||||
|
collapse onto a single artist row. The stored artist name is the
|
||||||
|
first-seen spelling, except for the names listed in
|
||||||
|
``CANONICAL_ARTIST_NAMES``, which always store the canonical
|
||||||
|
spelling regardless of which variant is seen first.
|
||||||
|
|
||||||
|
On a successful build, two review CSV files, ``songs.csv`` and
|
||||||
|
``artists.csv``, are (re)written under the given output directory,
|
||||||
|
mirroring the stored songs and artists without their IDs; see
|
||||||
|
`CSVExporter`. A failed build leaves any existing review CSV
|
||||||
|
files untouched, matching the store rollback.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Self
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .database import Base, ds
|
||||||
|
from .models import (
|
||||||
|
Artist,
|
||||||
|
ChartEntry,
|
||||||
|
Role,
|
||||||
|
Song,
|
||||||
|
SongArtist,
|
||||||
|
)
|
||||||
|
|
||||||
|
YEARS: Sequence[int] = range(2016, 2026)
|
||||||
|
"""The expected chart years."""
|
||||||
|
RANKS_PER_YEAR: int = 100
|
||||||
|
"""The expected number of ranks on the chart of each year."""
|
||||||
|
ARTIST_FIELDS: dict[str, str] = {
|
||||||
|
"qid": "wikidata_qid",
|
||||||
|
"gender": "gender",
|
||||||
|
"type": "type",
|
||||||
|
"genre": "genre",
|
||||||
|
"country": "country",
|
||||||
|
}
|
||||||
|
"""The artist CSV columns mapped to the Artist attributes."""
|
||||||
|
FEATURING_PATTERN: re.Pattern[str] = re.compile(
|
||||||
|
r" featuring | feat\. ", re.IGNORECASE)
|
||||||
|
"""The pattern splitting the primary and featured sides."""
|
||||||
|
DELIMITER_PATTERN: re.Pattern[str] = re.compile(
|
||||||
|
r", | & | \+ | / |(?i: and | x | with )")
|
||||||
|
"""The pattern splitting the artist names within a side."""
|
||||||
|
COLON_PATTERN: re.Pattern[str] = re.compile(r": ")
|
||||||
|
"""The pattern separating a group prefix from its members in a
|
||||||
|
"<group>: <members>" credit."""
|
||||||
|
PAREN_MEMBERS_PATTERN: re.Pattern[str] = re.compile(
|
||||||
|
r"^.+ \((?P<members>.+)\)$")
|
||||||
|
"""The pattern separating a group name from its members in a
|
||||||
|
"<group> (<members>)" credit spanning the whole credit."""
|
||||||
|
DUET_WITH_PATTERN: re.Pattern[str] = re.compile(
|
||||||
|
r" Duet With ", re.IGNORECASE)
|
||||||
|
"""The pattern normalizing the "Duet With" co-billing connector
|
||||||
|
to the plain "with" delimiter."""
|
||||||
|
PROTECTED_ARTIST_NAMES: tuple[str, ...] = (
|
||||||
|
"Tyler, The Creator",
|
||||||
|
"Lil Nas X",
|
||||||
|
"Tones And I",
|
||||||
|
)
|
||||||
|
"""The exact artist names guarded from the delimiter splitting,
|
||||||
|
because each contains a delimiter word or punctuation as part of
|
||||||
|
the name itself."""
|
||||||
|
EXCEPTION_CREDITS: dict[str, list[tuple[str, Role]]] = {
|
||||||
|
"SpotemGottem Featuring Pooh Shiesty Or DaBaby": [
|
||||||
|
("SpotemGottem", Role.PRIMARY),
|
||||||
|
("Pooh Shiesty", Role.FEATURED),
|
||||||
|
("DaBaby", Role.FEATURED),
|
||||||
|
],
|
||||||
|
"THE SCOTTS, Travis Scott & Kid Cudi": [
|
||||||
|
("Travis Scott", Role.PRIMARY),
|
||||||
|
("Kid Cudi", Role.PRIMARY),
|
||||||
|
],
|
||||||
|
"Drake Featuring The Throne": [
|
||||||
|
("Drake", Role.PRIMARY),
|
||||||
|
("Jay Z", Role.FEATURED),
|
||||||
|
("Kanye West", Role.FEATURED),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
"""The single-credit exceptions parsed by an explicit lookup
|
||||||
|
rather than by the general rules, because the credit text alone
|
||||||
|
does not spell out the correct member split."""
|
||||||
|
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."""
|
||||||
|
CANONICAL_ARTIST_NAMES: dict[str, str] = {
|
||||||
|
"beyonce": "Beyoncé",
|
||||||
|
"5 seconds of summer": "5 Seconds of Summer",
|
||||||
|
"a boogie wit da hoodie": "A Boogie wit da Hoodie",
|
||||||
|
"benny blanco": "benny blanco",
|
||||||
|
"blackbear": "blackbear",
|
||||||
|
"chance the rapper": "Chance the Rapper",
|
||||||
|
"xxxtentacion": "XXXTENTACION",
|
||||||
|
"maneskin": "Måneskin",
|
||||||
|
"rose": "ROSÉ",
|
||||||
|
"mo": "MØ",
|
||||||
|
"wizkid": "Wizkid",
|
||||||
|
"ye": "Kanye West",
|
||||||
|
"amine": "Aminé",
|
||||||
|
"bomba estereo": "Bomba Estéreo",
|
||||||
|
"carolina gaitan": "Carolina Gaitán",
|
||||||
|
"casper magico": "Casper Mágico",
|
||||||
|
"eslabon armado": "Eslabón Armado",
|
||||||
|
"jhene aiko": "Jhené Aiko",
|
||||||
|
"neton vega": "Netón Vega",
|
||||||
|
"nio garcia": "Nio García",
|
||||||
|
"oscar maydon": "Óscar Maydon",
|
||||||
|
"silento": "Silentó",
|
||||||
|
"the marias": "The Marías",
|
||||||
|
"victoria monet": "Victoria Monét",
|
||||||
|
"dan": "Dan Smyers",
|
||||||
|
"shay": "Shay Mooney",
|
||||||
|
"cris mj": "Cris MJ",
|
||||||
|
"mariah the scientist": "Mariah the Scientist",
|
||||||
|
"surf mesa": "Surf Mesa",
|
||||||
|
}
|
||||||
|
"""The canonical artist spellings, keyed by the case-folded
|
||||||
|
identity."""
|
||||||
|
|
||||||
|
|
||||||
|
class BuildError(Exception):
|
||||||
|
"""An error that fails the build."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||||
|
"""Parse the command-line arguments.
|
||||||
|
|
||||||
|
:param argv: The command-line arguments, or None for
|
||||||
|
``sys.argv``.
|
||||||
|
:return: The parsed arguments.
|
||||||
|
"""
|
||||||
|
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||||
|
description="Rebuild the SQLite working store from the"
|
||||||
|
" committed inputs.")
|
||||||
|
parser.add_argument(
|
||||||
|
"chart_csv", type=Path,
|
||||||
|
help="the year-end chart CSV file")
|
||||||
|
parser.add_argument(
|
||||||
|
"derived_dir", type=Path,
|
||||||
|
help="the output directory for the review CSV files")
|
||||||
|
parser.add_argument(
|
||||||
|
"--lyrics-dir", type=Path, default=None,
|
||||||
|
help="the lyrics cache directory to load")
|
||||||
|
parser.add_argument(
|
||||||
|
"--wikidata-csv", type=Path, default=None,
|
||||||
|
help="the Wikidata artist snapshot CSV file to apply")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
|
||||||
|
"""Parse a combined artist credit into artists and roles.
|
||||||
|
|
||||||
|
A credit listed in ``EXCEPTION_CREDITS`` is looked up verbatim,
|
||||||
|
because its correct split is not derivable from the credit
|
||||||
|
text alone. Otherwise the credit first reduces to an
|
||||||
|
effective credit: a "<group>: <members>" prefix (split at the
|
||||||
|
first ": ") drops the group and keeps the members; failing
|
||||||
|
that, a "<group> (<members>)" suffix spanning the whole credit
|
||||||
|
drops the group and keeps the members. The "Duet With"
|
||||||
|
connector, case-insensitively, then normalizes to "with". The
|
||||||
|
effective credit splits into a primary side and a featured
|
||||||
|
side on the word "featuring" or "feat.", case-insensitively;
|
||||||
|
without them, every artist is primary. Each side splits into
|
||||||
|
artist names on the delimiters ", ", " & ", " + ", " / "
|
||||||
|
(literally) and " and ", " x ", " with " (case-insensitively),
|
||||||
|
except for the names listed in ``PROTECTED_ARTIST_NAMES``,
|
||||||
|
which are never split even though each contains a delimiter
|
||||||
|
word or punctuation.
|
||||||
|
|
||||||
|
Known limitation: a compound act name that contains one of the
|
||||||
|
delimiters, other than the protected names, is over-split.
|
||||||
|
|
||||||
|
:param credit: The combined artist credit string.
|
||||||
|
:return: The (name, role) pairs in credit order, primary side
|
||||||
|
first, with the role ``Role.PRIMARY`` or
|
||||||
|
``Role.FEATURED``.
|
||||||
|
"""
|
||||||
|
if credit in EXCEPTION_CREDITS:
|
||||||
|
return list(EXCEPTION_CREDITS[credit])
|
||||||
|
effective: str = credit
|
||||||
|
colon_match: re.Match[str] | None = COLON_PATTERN.search(
|
||||||
|
effective)
|
||||||
|
if colon_match is not None:
|
||||||
|
effective = effective[colon_match.end():]
|
||||||
|
else:
|
||||||
|
paren_match: re.Match[str] | None = \
|
||||||
|
PAREN_MEMBERS_PATTERN.match(effective)
|
||||||
|
if paren_match is not None:
|
||||||
|
effective = paren_match.group("members")
|
||||||
|
effective = DUET_WITH_PATTERN.sub(" with ", effective)
|
||||||
|
placeholders: dict[str, str] = {}
|
||||||
|
index: int
|
||||||
|
protected: str
|
||||||
|
for index, protected in enumerate(PROTECTED_ARTIST_NAMES):
|
||||||
|
if protected in effective:
|
||||||
|
placeholder: str = f"{index}"
|
||||||
|
placeholders[placeholder] = protected
|
||||||
|
effective = effective.replace(protected, placeholder)
|
||||||
|
sides: list[str] = FEATURING_PATTERN.split(
|
||||||
|
effective, maxsplit=1)
|
||||||
|
pairs: list[tuple[str, Role]] = []
|
||||||
|
role: Role
|
||||||
|
side: str
|
||||||
|
for side, role in zip(sides, (Role.PRIMARY, Role.FEATURED)):
|
||||||
|
token: str
|
||||||
|
for token in DELIMITER_PATTERN.split(side):
|
||||||
|
name: str = token.strip()
|
||||||
|
placeholder = ""
|
||||||
|
original: str
|
||||||
|
for placeholder, original in placeholders.items():
|
||||||
|
name = name.replace(placeholder, original)
|
||||||
|
if name != "":
|
||||||
|
pairs.append((name, role))
|
||||||
|
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 resolve_artist_identity(name: str) -> tuple[str, str]:
|
||||||
|
"""Resolve the dedup key and the stored spelling of a name.
|
||||||
|
|
||||||
|
The name's case-folded form is looked up in
|
||||||
|
``CANONICAL_ARTIST_NAMES`` first; when it is listed there, the
|
||||||
|
dedup key is the canonical spelling case-folded and the stored
|
||||||
|
spelling is the canonical spelling, so every variant of the
|
||||||
|
name, canonical or not, resolves to the same identity.
|
||||||
|
Otherwise the dedup key is the name case-folded and the stored
|
||||||
|
spelling is the given name.
|
||||||
|
|
||||||
|
:param name: An artist name, as parsed from a credit.
|
||||||
|
:return: A tuple of the dedup key and the stored spelling.
|
||||||
|
"""
|
||||||
|
folded: str = name.casefold()
|
||||||
|
canonical: str | None = CANONICAL_ARTIST_NAMES.get(folded)
|
||||||
|
if canonical is not None:
|
||||||
|
return canonical.casefold(), canonical
|
||||||
|
return folded, name
|
||||||
|
|
||||||
|
|
||||||
|
def create_song(session: Session, song_id: int, title: str,
|
||||||
|
credit: str, artists: dict[str, Artist]) -> Song:
|
||||||
|
"""Create a song with its parsed artist credits.
|
||||||
|
|
||||||
|
The song takes the given ID. An artist parsed out of the
|
||||||
|
credit is matched against the known artists by its identity
|
||||||
|
key (see `resolve_artist_identity`); a newly seen one takes
|
||||||
|
the ID following the known artists, keyed by its identity key,
|
||||||
|
and its stored name is the resolved stored spelling. An
|
||||||
|
artist duplicated within the credit, by its identity key, is
|
||||||
|
kept only at its first occurrence, with a warning to the
|
||||||
|
standard error.
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
:param song_id: The song ID to assign.
|
||||||
|
:param title: The song title.
|
||||||
|
:param credit: The combined artist credit string.
|
||||||
|
:param artists: The known artists by identity key, updated
|
||||||
|
with the newly created ones as an observable side effect.
|
||||||
|
:return: The created song, added to the session.
|
||||||
|
"""
|
||||||
|
song: Song = Song(id=song_id, title=title,
|
||||||
|
artist_credit=credit)
|
||||||
|
session.add(song)
|
||||||
|
seen: set[str] = set()
|
||||||
|
position: int = 0
|
||||||
|
name: str
|
||||||
|
role: Role
|
||||||
|
for name, role in parse_artist_credit(credit):
|
||||||
|
key: str
|
||||||
|
stored_name: str
|
||||||
|
key, stored_name = resolve_artist_identity(name)
|
||||||
|
if key in seen:
|
||||||
|
print(f"warning: {credit}: duplicated artist"
|
||||||
|
f" \"{name}\"", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
if key not in artists:
|
||||||
|
artists[key] = Artist(id=len(artists) + 1,
|
||||||
|
name=stored_name)
|
||||||
|
session.add(SongArtist(song=song, artist=artists[key],
|
||||||
|
role=role, position=position))
|
||||||
|
position += 1
|
||||||
|
return song
|
||||||
|
|
||||||
|
|
||||||
|
def load_chart(session: Session, path: Path) -> None:
|
||||||
|
"""Load the chart CSV into songs, chart entries, and credits.
|
||||||
|
|
||||||
|
A song repeated across the rows is stored once, matched by its
|
||||||
|
identity key (see `song_identity`); every row yields one chart
|
||||||
|
entry. The stored title is the raw title; the stored artist
|
||||||
|
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 path: The chart CSV file with the columns year, rank,
|
||||||
|
title, and artist.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
"""
|
||||||
|
songs: dict[tuple[str, str], Song] = {}
|
||||||
|
artists: dict[str, Artist] = {}
|
||||||
|
with open(path, encoding="utf-8", newline="") as file:
|
||||||
|
row: dict[str, str]
|
||||||
|
for row in csv.DictReader(file):
|
||||||
|
key: tuple[str, str] = song_identity(
|
||||||
|
row["title"], row["artist"])
|
||||||
|
if key not in songs:
|
||||||
|
title: str
|
||||||
|
credit: str
|
||||||
|
title, credit = key
|
||||||
|
songs[key] = create_song(
|
||||||
|
session, len(songs) + 1, title, credit,
|
||||||
|
artists)
|
||||||
|
session.add(ChartEntry(year=int(row["year"]),
|
||||||
|
rank=int(row["rank"]),
|
||||||
|
song=songs[key]))
|
||||||
|
|
||||||
|
|
||||||
|
def load_lyrics(session: Session, directory: Path) -> None:
|
||||||
|
"""Load the cached lyrics files into the matching songs.
|
||||||
|
|
||||||
|
A file whose stem is not an existing song ID is skipped with
|
||||||
|
a warning to the standard error.
|
||||||
|
|
||||||
|
:param session: The database session, with the songs flushed.
|
||||||
|
:param directory: The existing lyrics cache directory with
|
||||||
|
one ``<song_id>.txt`` file per song.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When a lyrics file cannot be read.
|
||||||
|
"""
|
||||||
|
for path in sorted(directory.glob("*.txt")):
|
||||||
|
song: Song | None = None
|
||||||
|
if path.stem.isdigit():
|
||||||
|
song = session.get(Song, int(path.stem))
|
||||||
|
if song is None:
|
||||||
|
print(f"warning: {path}: no song with ID"
|
||||||
|
f" \"{path.stem}\"", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
song.lyrics = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_artist_csv(session: Session, path: Path) -> None:
|
||||||
|
"""Apply an artist attribute CSV onto the artist rows.
|
||||||
|
|
||||||
|
Artists match by exact name. Only the non-empty cells are
|
||||||
|
applied, field by field. The note column is ignored.
|
||||||
|
|
||||||
|
:param session: The database session, with the artists
|
||||||
|
flushed.
|
||||||
|
:param path: The CSV file with the columns name, qid, gender,
|
||||||
|
type, genre, country, and note.
|
||||||
|
:return: None.
|
||||||
|
:raises BuildError: When a name matches no artist.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
"""
|
||||||
|
with open(path, encoding="utf-8", newline="") as file:
|
||||||
|
row: dict[str, str]
|
||||||
|
for row in csv.DictReader(file):
|
||||||
|
artist: Artist | None = session.scalar(
|
||||||
|
sa.select(Artist)
|
||||||
|
.where(Artist.name == row["name"]))
|
||||||
|
if artist is None:
|
||||||
|
raise BuildError(
|
||||||
|
f"{path}: no artist named \"{row['name']}\"")
|
||||||
|
column: str
|
||||||
|
attribute: str
|
||||||
|
for column, attribute in ARTIST_FIELDS.items():
|
||||||
|
if row.get(column):
|
||||||
|
setattr(artist, attribute, row[column])
|
||||||
|
|
||||||
|
|
||||||
|
def find_violations(session: Session, years: Iterable[int],
|
||||||
|
ranks_per_year: int) -> list[str]:
|
||||||
|
"""Find the invariant violations in the loaded data.
|
||||||
|
|
||||||
|
The invariants: the chart entries cover each expected year
|
||||||
|
and rank exactly once and nothing else, every song has at
|
||||||
|
least one primary artist, and every artist name is non-empty.
|
||||||
|
|
||||||
|
:param session: The database session with the loaded data
|
||||||
|
flushed.
|
||||||
|
:param years: The expected chart years.
|
||||||
|
:param ranks_per_year: The expected number of ranks per year.
|
||||||
|
:return: The violation messages, empty when the data is
|
||||||
|
valid.
|
||||||
|
"""
|
||||||
|
violations: list[str] = []
|
||||||
|
expected: set[tuple[int, int]] = {
|
||||||
|
(year, rank) for year in years
|
||||||
|
for rank in range(1, ranks_per_year + 1)}
|
||||||
|
actual: set[tuple[int, int]] = {
|
||||||
|
(x.year, x.rank)
|
||||||
|
for x in session.scalars(sa.select(ChartEntry))}
|
||||||
|
year: int
|
||||||
|
rank: int
|
||||||
|
for year, rank in sorted(expected - actual):
|
||||||
|
violations.append(
|
||||||
|
f"missing chart entry: year {year} rank {rank}")
|
||||||
|
for year, rank in sorted(actual - expected):
|
||||||
|
violations.append(
|
||||||
|
f"unexpected chart entry: year {year} rank {rank}")
|
||||||
|
primary_ids: set[int] = set(session.scalars(
|
||||||
|
sa.select(SongArtist.song_id)
|
||||||
|
.where(SongArtist.role == Role.PRIMARY)))
|
||||||
|
song: Song
|
||||||
|
for song in session.scalars(sa.select(Song).order_by(Song.id)):
|
||||||
|
if song.id not in primary_ids:
|
||||||
|
violations.append(
|
||||||
|
f"song {song.id} \"{song.title}\" has no primary"
|
||||||
|
" artist")
|
||||||
|
artist: Artist
|
||||||
|
for artist in session.scalars(sa.select(Artist)):
|
||||||
|
if artist.name.strip() == "":
|
||||||
|
violations.append(
|
||||||
|
f"artist {artist.id} has an empty name")
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StoreCounts:
|
||||||
|
"""The row counts of the working store, for the build summary."""
|
||||||
|
|
||||||
|
songs: int
|
||||||
|
"""The number of the songs."""
|
||||||
|
chart_entries: int
|
||||||
|
"""The number of the chart entries."""
|
||||||
|
artists: int
|
||||||
|
"""The number of the artists."""
|
||||||
|
credits: int
|
||||||
|
"""The number of the song-artist credits."""
|
||||||
|
songs_with_lyrics: int
|
||||||
|
"""The number of the songs with lyrics."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, session: Session) -> Self:
|
||||||
|
"""Counts the loaded rows and returns the counts.
|
||||||
|
|
||||||
|
:param session: The database session with the loaded data
|
||||||
|
flushed.
|
||||||
|
:return: The row counts of the working store.
|
||||||
|
"""
|
||||||
|
def count(selectable: sa.Select[tuple[int]]) -> int:
|
||||||
|
value: int | None = session.scalar(selectable)
|
||||||
|
assert value is not None
|
||||||
|
return value
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
songs=count(
|
||||||
|
sa.select(sa.func.count()).select_from(Song)),
|
||||||
|
chart_entries=count(
|
||||||
|
sa.select(sa.func.count())
|
||||||
|
.select_from(ChartEntry)),
|
||||||
|
artists=count(
|
||||||
|
sa.select(sa.func.count()).select_from(Artist)),
|
||||||
|
credits=count(
|
||||||
|
sa.select(sa.func.count())
|
||||||
|
.select_from(SongArtist)),
|
||||||
|
songs_with_lyrics=count(
|
||||||
|
sa.select(sa.func.count()).select_from(Song)
|
||||||
|
.where(Song.lyrics.is_not(None))))
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_engine(engine: sa.Engine) -> None:
|
||||||
|
"""Prepare a SQLite engine for a build.
|
||||||
|
|
||||||
|
For a file-based SQLite engine, the parent directory of the
|
||||||
|
database file is created when missing. A non-SQLite engine is
|
||||||
|
left untouched.
|
||||||
|
|
||||||
|
:param engine: The database engine.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if engine.url.get_backend_name() != "sqlite":
|
||||||
|
return
|
||||||
|
database: str | None = engine.url.database
|
||||||
|
if database is not None and database != ":memory:":
|
||||||
|
Path(database).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_store(session: Session) -> None:
|
||||||
|
"""Delete all the rows from every table of the store.
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
model: type[Base]
|
||||||
|
for model in (SongArtist, ChartEntry, Song, Artist):
|
||||||
|
session.execute(sa.delete(model))
|
||||||
|
|
||||||
|
|
||||||
|
class CSVExporter:
|
||||||
|
"""Writes the review CSV files mirroring the working store."""
|
||||||
|
|
||||||
|
__SONGS_HEADER: tuple[str, ...] = ("Title", "Artists", "Positions")
|
||||||
|
"""The header row of ``songs.csv``, for human readers."""
|
||||||
|
__ARTISTS_HEADER: tuple[str, ...] = (
|
||||||
|
"Name", "Wikidata QID", "Gender", "Type", "Genre", "Country",
|
||||||
|
"Songs")
|
||||||
|
"""The header row of ``artists.csv``, for human readers."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session, derived_dir: Path) -> None:
|
||||||
|
"""Initialize the exporter.
|
||||||
|
|
||||||
|
:param session: The database session with the loaded data
|
||||||
|
flushed.
|
||||||
|
:param derived_dir: The output directory for the review CSV
|
||||||
|
files.
|
||||||
|
"""
|
||||||
|
self.__session: Session = session
|
||||||
|
self.__derived_dir: Path = derived_dir
|
||||||
|
|
||||||
|
def write(self) -> None:
|
||||||
|
"""Write the review CSV files mirroring the loaded data.
|
||||||
|
|
||||||
|
Fully overwrites ``songs.csv`` and ``artists.csv`` under the
|
||||||
|
output directory, creating it when missing, with normal
|
||||||
|
minimal CSV quoting. Neither file carries a song or an
|
||||||
|
artist ID; a multi-valued field is a plain joined string,
|
||||||
|
itself CSV-quoted as a whole only when its content requires
|
||||||
|
it: "/" joins the chart appearances of one song, and "|"
|
||||||
|
joins the distinct songs credited to one artist.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When a CSV file cannot be written.
|
||||||
|
"""
|
||||||
|
self.__derived_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.__write_csv(
|
||||||
|
self.__derived_dir / "songs.csv", self.__SONGS_HEADER,
|
||||||
|
self.__songs_rows())
|
||||||
|
self.__write_csv(
|
||||||
|
self.__derived_dir / "artists.csv", self.__ARTISTS_HEADER,
|
||||||
|
self.__artists_rows())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __write_csv(path: Path, header: Sequence[str],
|
||||||
|
rows: Iterable[Sequence[str]]) -> None:
|
||||||
|
"""Write a CSV file with LF line endings, fully overwritten.
|
||||||
|
|
||||||
|
:param path: The output CSV file.
|
||||||
|
:param header: The header row.
|
||||||
|
:param rows: The data rows, in the given order.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be written.
|
||||||
|
"""
|
||||||
|
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||||
|
writer: Any = csv.writer(file, lineterminator="\n")
|
||||||
|
writer.writerow(header)
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __sorted_chart_entries(song: Song) -> list[ChartEntry]:
|
||||||
|
"""Sort the chart entries of a song by year then rank.
|
||||||
|
|
||||||
|
:param song: The song with its chart entries loaded.
|
||||||
|
:return: The chart entries, ordered by year then rank.
|
||||||
|
"""
|
||||||
|
return sorted(
|
||||||
|
song.chart_entries, key=lambda x: (x.year, x.rank))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __song_positions(cls, song: Song) -> str:
|
||||||
|
"""Format the chart positions of a song for the songs.csv
|
||||||
|
value.
|
||||||
|
|
||||||
|
:param song: The song with its chart entries loaded.
|
||||||
|
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||||
|
joined by "/".
|
||||||
|
"""
|
||||||
|
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||||
|
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __formatted_song_positions(cls, song: Song) -> str:
|
||||||
|
"""Format the chart positions of a song for the artists.csv
|
||||||
|
value.
|
||||||
|
|
||||||
|
:param song: The song with its chart entries loaded.
|
||||||
|
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||||
|
joined by "/".
|
||||||
|
"""
|
||||||
|
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||||
|
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||||
|
|
||||||
|
def __songs_rows(self) -> list[list[str]]:
|
||||||
|
"""Build the sorted data rows of ``songs.csv``.
|
||||||
|
|
||||||
|
:return: The rows, sorted by the case-folded title, then
|
||||||
|
the case-folded artist credit.
|
||||||
|
"""
|
||||||
|
songs: list[Song] = sorted(
|
||||||
|
self.__session.scalars(sa.select(Song)),
|
||||||
|
key=lambda x: (x.title.casefold(),
|
||||||
|
x.artist_credit.casefold()))
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
song: Song
|
||||||
|
for song in songs:
|
||||||
|
row: list[str] = [
|
||||||
|
song.title, song.artist_credit,
|
||||||
|
self.__song_positions(song)]
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __artist_songs(cls, artist: Artist) -> str:
|
||||||
|
"""Format the credited songs for the artists.csv value.
|
||||||
|
|
||||||
|
:param artist: The artist with its song credits loaded.
|
||||||
|
:return: The credited songs, each formatted as
|
||||||
|
"TITLE (YEAR#RANK[/YEAR#RANK...])" with its chart
|
||||||
|
appearances, sorted alphabetically case-folded by
|
||||||
|
title, joined by "|".
|
||||||
|
"""
|
||||||
|
songs: list[Song] = sorted(
|
||||||
|
(x.song for x in artist.song_artists),
|
||||||
|
key=lambda x: x.title.casefold())
|
||||||
|
entries: list[str] = [
|
||||||
|
f"{song.title} ({cls.__formatted_song_positions(song)})"
|
||||||
|
for song in songs]
|
||||||
|
return "|".join(entries)
|
||||||
|
|
||||||
|
def __artists_rows(self) -> list[list[str]]:
|
||||||
|
"""Build the sorted data rows of ``artists.csv``.
|
||||||
|
|
||||||
|
:return: The rows, sorted by the case-folded name.
|
||||||
|
"""
|
||||||
|
artists: list[Artist] = sorted(
|
||||||
|
self.__session.scalars(sa.select(Artist)),
|
||||||
|
key=lambda x: x.name.casefold())
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
artist: Artist
|
||||||
|
for artist in artists:
|
||||||
|
row: list[str] = [
|
||||||
|
artist.name, artist.wikidata_qid or "",
|
||||||
|
artist.gender or "", artist.type or "",
|
||||||
|
artist.genre or "", artist.country or "",
|
||||||
|
self.__artist_songs(artist)]
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Rebuild the SQLite working store from the inputs.
|
||||||
|
|
||||||
|
:param argv: The command-line arguments, or None for
|
||||||
|
``sys.argv``.
|
||||||
|
:return: The exit status: 0 on success, non-zero on failure.
|
||||||
|
"""
|
||||||
|
args: argparse.Namespace = parse_args(argv)
|
||||||
|
engine: sa.Engine = ds.engine
|
||||||
|
prepare_engine(engine)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
session: Session = ds.get_db()
|
||||||
|
counts: StoreCounts
|
||||||
|
try:
|
||||||
|
reset_store(session)
|
||||||
|
load_chart(session, args.chart_csv)
|
||||||
|
session.flush()
|
||||||
|
if args.lyrics_dir is not None:
|
||||||
|
if not args.lyrics_dir.is_dir():
|
||||||
|
raise BuildError(
|
||||||
|
f"{args.lyrics_dir}: no such directory")
|
||||||
|
load_lyrics(session, args.lyrics_dir)
|
||||||
|
if args.wikidata_csv is not None:
|
||||||
|
apply_artist_csv(session, args.wikidata_csv)
|
||||||
|
session.flush()
|
||||||
|
violations: list[str] = find_violations(
|
||||||
|
session, YEARS, RANKS_PER_YEAR)
|
||||||
|
if len(violations) > 0:
|
||||||
|
session.rollback()
|
||||||
|
for violation in violations:
|
||||||
|
print(f"error: {violation}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
counts = StoreCounts.get_instance(session)
|
||||||
|
CSVExporter(session, args.derived_dir).write()
|
||||||
|
session.commit()
|
||||||
|
except (OSError, BuildError) as error:
|
||||||
|
session.rollback()
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
print(f"done: {counts.songs} songs,"
|
||||||
|
f" {counts.chart_entries} chart entries,"
|
||||||
|
f" {counts.artists} artists,"
|
||||||
|
f" {counts.credits} credits,"
|
||||||
|
f" {counts.songs_with_lyrics} songs with lyrics",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 0
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Tools for A Feminist Audit of Pop Music.
|
|
||||||
# Copyright 2026 imacat. All rights reserved.
|
|
||||||
# Authors:
|
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
|
||||||
"""The registry of the CLI subcommands."""
|
|
||||||
from .build_db import main as build_db_command
|
|
||||||
from .export_llm_input import main as export_llm_input_command
|
|
||||||
from .fetch_artists import main as fetch_artists_command
|
|
||||||
from .fetch_lyrics import main as fetch_lyrics_command
|
|
||||||
from .run_llm import main as run_llm_command
|
|
||||||
@@ -1,829 +0,0 @@
|
|||||||
# Tools for A Feminist Audit of Pop Music.
|
|
||||||
# Copyright 2026 imacat. All rights reserved.
|
|
||||||
# Authors:
|
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
|
||||||
"""The builder of the SQLite working store.
|
|
||||||
|
|
||||||
Rebuilds the working store from scratch out of the committed
|
|
||||||
inputs: the year-end chart CSV and the output directory for the
|
|
||||||
review CSV files, given as the two positional command-line
|
|
||||||
arguments, and the optional capture inputs, each given as an
|
|
||||||
option: the lyrics cache directory and the Wikidata artist
|
|
||||||
snapshot CSV. An omitted option
|
|
||||||
leaves its capture layer unloaded; a given option whose path does
|
|
||||||
not exist fails the build. Missing tables
|
|
||||||
are created on a fresh store; existing tables are never altered,
|
|
||||||
as the schema lifecycle belongs to the migrations. Every rebuild
|
|
||||||
deletes all the rows, loads the data, and validates it in one
|
|
||||||
transaction, committed only after the data passes the validation
|
|
||||||
invariants; a failed build leaves the previous store contents
|
|
||||||
intact.
|
|
||||||
|
|
||||||
The rebuild is deterministic: the builder assigns the song and
|
|
||||||
artist IDs itself, as 1, 2, 3, ... in the first-occurrence file
|
|
||||||
order, so the IDs are reproducible across rebuilds on every
|
|
||||||
database engine, given the frozen input file.
|
|
||||||
|
|
||||||
A song is identified by its raw title together with its artist
|
|
||||||
credit, the credit canonicalized through
|
|
||||||
``SongImporter.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 identity key resolved from the parsed
|
|
||||||
artist name (see `ArtistImporter.resolve_artist_identity`): the
|
|
||||||
case-folded name, or, when that case-folded name is listed in
|
|
||||||
``ArtistImporter.CANONICAL_ARTIST_NAMES``, the case-folded
|
|
||||||
canonical spelling, so letter-case variants and alternate
|
|
||||||
spellings mapped to the same canonical name all collapse onto a
|
|
||||||
single artist row. The stored artist name is the first-seen
|
|
||||||
spelling, except for the names listed in
|
|
||||||
``ArtistImporter.CANONICAL_ARTIST_NAMES``, which always store the
|
|
||||||
canonical spelling regardless of which variant is seen first.
|
|
||||||
|
|
||||||
On a successful build, two review CSV files, ``songs.csv`` and
|
|
||||||
``artists.csv``, are (re)written under the given output directory,
|
|
||||||
mirroring the stored songs and artists without their IDs; see
|
|
||||||
`CSVExporter`. A failed build leaves any existing review CSV
|
|
||||||
files untouched, matching the store rollback.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections import Counter
|
|
||||||
from collections.abc import Iterable, Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Self
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ..database import Base, ds
|
|
||||||
from ..models import (
|
|
||||||
Artist,
|
|
||||||
ChartEntry,
|
|
||||||
Role,
|
|
||||||
Song,
|
|
||||||
SongArtist,
|
|
||||||
)
|
|
||||||
|
|
||||||
ARTIST_FIELDS: dict[str, str] = {
|
|
||||||
"qid": "wikidata_qid",
|
|
||||||
"gender": "gender",
|
|
||||||
"type": "type",
|
|
||||||
"genre": "genre",
|
|
||||||
"country": "country",
|
|
||||||
}
|
|
||||||
"""The artist CSV columns mapped to the Artist attributes."""
|
|
||||||
|
|
||||||
|
|
||||||
class BuildError(Exception):
|
|
||||||
"""An error that fails the build."""
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
||||||
"""Parse the command-line arguments.
|
|
||||||
|
|
||||||
:param argv: The command-line arguments, or None for
|
|
||||||
``sys.argv``.
|
|
||||||
:return: The parsed arguments.
|
|
||||||
"""
|
|
||||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
|
||||||
description="Rebuild the SQLite working store from the"
|
|
||||||
" committed inputs.")
|
|
||||||
parser.add_argument(
|
|
||||||
"chart_csv", type=Path,
|
|
||||||
help="the year-end chart CSV file")
|
|
||||||
parser.add_argument(
|
|
||||||
"derived_dir", type=Path,
|
|
||||||
help="the output directory for the review CSV files")
|
|
||||||
parser.add_argument(
|
|
||||||
"--lyrics-dir", type=Path, default=None,
|
|
||||||
help="the lyrics cache directory to load")
|
|
||||||
parser.add_argument(
|
|
||||||
"--wikidata-csv", type=Path, default=None,
|
|
||||||
help="the Wikidata artist snapshot CSV file to apply")
|
|
||||||
return parser.parse_args(argv)
|
|
||||||
|
|
||||||
|
|
||||||
class SongImporter:
|
|
||||||
"""The song-import job: loads the chart CSV into songs and
|
|
||||||
chart entries."""
|
|
||||||
|
|
||||||
YEARS: Sequence[int] = range(2016, 2026)
|
|
||||||
"""The expected chart years."""
|
|
||||||
RANKS_PER_YEAR: int = 100
|
|
||||||
"""The expected number of ranks on the chart of each year."""
|
|
||||||
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."""
|
|
||||||
|
|
||||||
def __init__(self, session: Session) -> None:
|
|
||||||
"""Initialize the importer.
|
|
||||||
|
|
||||||
:param session: The database session.
|
|
||||||
"""
|
|
||||||
self.__session: Session = session
|
|
||||||
self.__songs: dict[tuple[str, str], Song] = {}
|
|
||||||
|
|
||||||
def import_songs(self, path: Path) -> None:
|
|
||||||
"""Load the chart CSV into songs and chart entries.
|
|
||||||
|
|
||||||
A song repeated across the rows is stored once, matched by
|
|
||||||
its identity key (see `song_identity`); every row yields
|
|
||||||
one chart entry. The stored title is the raw title; the
|
|
||||||
stored artist credit is the canonical credit from the
|
|
||||||
identity key. The songs take the IDs 1, 2, 3, ... in the
|
|
||||||
first-occurrence row order. When the method returns, the
|
|
||||||
imported songs and chart entries are queryable in the
|
|
||||||
session.
|
|
||||||
|
|
||||||
:param path: The chart CSV file with the columns year,
|
|
||||||
rank, title, and artist.
|
|
||||||
:return: None.
|
|
||||||
:raises BuildError: When the chart entries do not cover
|
|
||||||
each of ``YEARS`` and each rank from 1 to
|
|
||||||
``RANKS_PER_YEAR`` exactly once.
|
|
||||||
:raises OSError: When the file cannot be read.
|
|
||||||
"""
|
|
||||||
counts: Counter[tuple[int, int]] = Counter()
|
|
||||||
with open(path, encoding="utf-8", newline="") as file:
|
|
||||||
row: dict[str, str]
|
|
||||||
for row in csv.DictReader(file):
|
|
||||||
key: tuple[str, str] = self.song_identity(
|
|
||||||
row["title"], row["artist"])
|
|
||||||
if key not in self.__songs:
|
|
||||||
title: str
|
|
||||||
credit: str
|
|
||||||
title, credit = key
|
|
||||||
song: Song = Song(
|
|
||||||
id=len(self.__songs) + 1, title=title,
|
|
||||||
artist_credit=credit)
|
|
||||||
self.__session.add(song)
|
|
||||||
self.__songs[key] = song
|
|
||||||
year: int = int(row["year"])
|
|
||||||
rank: int = int(row["rank"])
|
|
||||||
counts[(year, rank)] += 1
|
|
||||||
if counts[(year, rank)] == 1:
|
|
||||||
self.__session.add(ChartEntry(
|
|
||||||
year=year, rank=rank,
|
|
||||||
song=self.__songs[key]))
|
|
||||||
self.__session.flush()
|
|
||||||
self.__check_chart_coverage(counts)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __check_chart_coverage(
|
|
||||||
cls, counts: Counter[tuple[int, int]]) -> None:
|
|
||||||
"""Verify the chart entries cover the expected grid exactly.
|
|
||||||
|
|
||||||
:param counts: The number of chart entries seen for each
|
|
||||||
(year, rank) pair.
|
|
||||||
:return: None.
|
|
||||||
:raises BuildError: When a (year, rank) pair from the
|
|
||||||
expected grid is missing, an unexpected pair is
|
|
||||||
present, or a pair is duplicated.
|
|
||||||
"""
|
|
||||||
expected: set[tuple[int, int]] = {
|
|
||||||
(year, rank) for year in cls.YEARS
|
|
||||||
for rank in range(1, cls.RANKS_PER_YEAR + 1)}
|
|
||||||
actual: set[tuple[int, int]] = set(counts)
|
|
||||||
violations: list[str] = []
|
|
||||||
year: int
|
|
||||||
rank: int
|
|
||||||
for year, rank in sorted(expected - actual):
|
|
||||||
violations.append(
|
|
||||||
f"missing chart entry: year {year} rank {rank}")
|
|
||||||
for year, rank in sorted(actual - expected):
|
|
||||||
violations.append(
|
|
||||||
f"unexpected chart entry: year {year} rank {rank}")
|
|
||||||
for year, rank in sorted(counts):
|
|
||||||
if counts[(year, rank)] > 1:
|
|
||||||
violations.append(
|
|
||||||
f"duplicated chart entry: year {year} rank"
|
|
||||||
f" {rank}")
|
|
||||||
if len(violations) > 0:
|
|
||||||
raise BuildError("\n".join(violations))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
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, SongImporter.CANONICAL_ARTIST_CREDITS.get(
|
|
||||||
credit, credit)
|
|
||||||
|
|
||||||
|
|
||||||
class ArtistImporter:
|
|
||||||
"""The artist-import job: parses the stored songs' artist
|
|
||||||
credits into artists and song-artist credits."""
|
|
||||||
|
|
||||||
FEATURING_PATTERN: re.Pattern[str] = re.compile(
|
|
||||||
r" featuring | feat\. ", re.IGNORECASE)
|
|
||||||
"""The pattern splitting the primary and featured sides."""
|
|
||||||
DELIMITER_PATTERN: re.Pattern[str] = re.compile(
|
|
||||||
r", | & | \+ | / |(?i: and | x | with )")
|
|
||||||
"""The pattern splitting the artist names within a side."""
|
|
||||||
COLON_PATTERN: re.Pattern[str] = re.compile(r": ")
|
|
||||||
"""The pattern separating a group prefix from its members in a
|
|
||||||
"<group>: <members>" credit."""
|
|
||||||
PAREN_MEMBERS_PATTERN: re.Pattern[str] = re.compile(
|
|
||||||
r"^.+ \((?P<members>.+)\)$")
|
|
||||||
"""The pattern separating a group name from its members in a
|
|
||||||
"<group> (<members>)" credit spanning the whole credit."""
|
|
||||||
DUET_WITH_PATTERN: re.Pattern[str] = re.compile(
|
|
||||||
r" Duet With ", re.IGNORECASE)
|
|
||||||
"""The pattern normalizing the "Duet With" co-billing connector
|
|
||||||
to the plain "with" delimiter."""
|
|
||||||
PROTECTED_ARTIST_NAMES: tuple[str, ...] = (
|
|
||||||
"Tyler, The Creator",
|
|
||||||
"Lil Nas X",
|
|
||||||
"Tones And I",
|
|
||||||
)
|
|
||||||
"""The exact artist names guarded from the delimiter splitting,
|
|
||||||
because each contains a delimiter word or punctuation as part
|
|
||||||
of the name itself."""
|
|
||||||
EXCEPTION_CREDITS: dict[str, list[tuple[str, Role]]] = {
|
|
||||||
"SpotemGottem Featuring Pooh Shiesty Or DaBaby": [
|
|
||||||
("SpotemGottem", Role.PRIMARY),
|
|
||||||
("Pooh Shiesty", Role.FEATURED),
|
|
||||||
("DaBaby", Role.FEATURED),
|
|
||||||
],
|
|
||||||
"THE SCOTTS, Travis Scott & Kid Cudi": [
|
|
||||||
("Travis Scott", Role.PRIMARY),
|
|
||||||
("Kid Cudi", Role.PRIMARY),
|
|
||||||
],
|
|
||||||
"Drake Featuring The Throne": [
|
|
||||||
("Drake", Role.PRIMARY),
|
|
||||||
("Jay Z", Role.FEATURED),
|
|
||||||
("Kanye West", Role.FEATURED),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
"""The single-credit exceptions parsed by an explicit lookup
|
|
||||||
rather than by the general rules, because the credit text alone
|
|
||||||
does not spell out the correct member split."""
|
|
||||||
CANONICAL_ARTIST_NAMES: dict[str, str] = {
|
|
||||||
"beyonce": "Beyoncé",
|
|
||||||
"5 seconds of summer": "5 Seconds of Summer",
|
|
||||||
"a boogie wit da hoodie": "A Boogie wit da Hoodie",
|
|
||||||
"benny blanco": "benny blanco",
|
|
||||||
"blackbear": "blackbear",
|
|
||||||
"chance the rapper": "Chance the Rapper",
|
|
||||||
"xxxtentacion": "XXXTENTACION",
|
|
||||||
"maneskin": "Måneskin",
|
|
||||||
"rose": "ROSÉ",
|
|
||||||
"mo": "MØ",
|
|
||||||
"wizkid": "Wizkid",
|
|
||||||
"ye": "Kanye West",
|
|
||||||
"amine": "Aminé",
|
|
||||||
"bomba estereo": "Bomba Estéreo",
|
|
||||||
"carolina gaitan": "Carolina Gaitán",
|
|
||||||
"casper magico": "Casper Mágico",
|
|
||||||
"eslabon armado": "Eslabón Armado",
|
|
||||||
"jhene aiko": "Jhené Aiko",
|
|
||||||
"neton vega": "Netón Vega",
|
|
||||||
"nio garcia": "Nio García",
|
|
||||||
"oscar maydon": "Óscar Maydon",
|
|
||||||
"silento": "Silentó",
|
|
||||||
"the marias": "The Marías",
|
|
||||||
"victoria monet": "Victoria Monét",
|
|
||||||
"dan": "Dan Smyers",
|
|
||||||
"shay": "Shay Mooney",
|
|
||||||
"cris mj": "Cris MJ",
|
|
||||||
"mariah the scientist": "Mariah the Scientist",
|
|
||||||
"surf mesa": "Surf Mesa",
|
|
||||||
}
|
|
||||||
"""The canonical artist spellings, keyed by the case-folded
|
|
||||||
identity."""
|
|
||||||
|
|
||||||
def __init__(self, session: Session) -> None:
|
|
||||||
"""Initialize the importer.
|
|
||||||
|
|
||||||
:param session: The database session.
|
|
||||||
"""
|
|
||||||
self.__session: Session = session
|
|
||||||
self.__artists: dict[str, Artist] = {}
|
|
||||||
|
|
||||||
def import_artists(self) -> None:
|
|
||||||
"""Parse the stored songs' credits into artists and
|
|
||||||
song-artist credits.
|
|
||||||
|
|
||||||
Reads the songs back from the database in ``Song.id`` order,
|
|
||||||
including any songs pending in the same session, and for
|
|
||||||
each song parses ``Song.artist_credit`` (see
|
|
||||||
`parse_artist_credit`). An artist parsed out of a credit is
|
|
||||||
matched against the known artists by its identity key (see
|
|
||||||
`resolve_artist_identity`); a newly seen one takes the ID
|
|
||||||
following the known artists, keyed by its identity key,
|
|
||||||
assigned in first-seen order across the songs, and its
|
|
||||||
stored name is the resolved stored spelling. An artist
|
|
||||||
duplicated within one song's credit, by its identity key,
|
|
||||||
is kept only at its first occurrence within that credit,
|
|
||||||
with a warning to the standard error. When the method
|
|
||||||
returns, the imported artists and credits are queryable in
|
|
||||||
the session.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
song: Song
|
|
||||||
for song in self.__session.scalars(
|
|
||||||
sa.select(Song).order_by(Song.id)):
|
|
||||||
self.__import_song_artists(song)
|
|
||||||
self.__session.flush()
|
|
||||||
|
|
||||||
def __import_song_artists(self, song: Song) -> None:
|
|
||||||
"""Parse and store the artist credits of one song.
|
|
||||||
|
|
||||||
:param song: The song with its stored artist credit.
|
|
||||||
:return: None.
|
|
||||||
:raises BuildError: When the parsed credit has no primary
|
|
||||||
artist or contains a blank artist name (see
|
|
||||||
`__check_parsed_credit`).
|
|
||||||
"""
|
|
||||||
parsed: list[tuple[str, Role]] = self.parse_artist_credit(
|
|
||||||
song.artist_credit)
|
|
||||||
self.__check_parsed_credit(song, parsed)
|
|
||||||
seen: set[str] = set()
|
|
||||||
position: int = 0
|
|
||||||
name: str
|
|
||||||
role: Role
|
|
||||||
for name, role in parsed:
|
|
||||||
key: str
|
|
||||||
stored_name: str
|
|
||||||
key, stored_name = self.resolve_artist_identity(name)
|
|
||||||
if key in seen:
|
|
||||||
print(f"warning: {song.artist_credit}: duplicated"
|
|
||||||
f" artist \"{name}\"", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
if key not in self.__artists:
|
|
||||||
self.__artists[key] = Artist(
|
|
||||||
id=len(self.__artists) + 1, name=stored_name)
|
|
||||||
self.__session.add(SongArtist(
|
|
||||||
song=song, artist=self.__artists[key], role=role,
|
|
||||||
position=position))
|
|
||||||
position += 1
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __check_parsed_credit(
|
|
||||||
song: Song, parsed: list[tuple[str, Role]]) -> None:
|
|
||||||
"""Verify a song's parsed artist credit is well-formed.
|
|
||||||
|
|
||||||
:param song: The song whose credit was parsed.
|
|
||||||
:param parsed: The (name, role) pairs parsed from
|
|
||||||
``song.artist_credit``.
|
|
||||||
:return: None.
|
|
||||||
:raises BuildError: When ``parsed`` is empty, has no
|
|
||||||
``Role.PRIMARY`` entry, or contains a name blank after
|
|
||||||
stripping.
|
|
||||||
"""
|
|
||||||
role: Role
|
|
||||||
if len(parsed) == 0 or not any(
|
|
||||||
role == Role.PRIMARY for _, role in parsed):
|
|
||||||
raise BuildError(
|
|
||||||
f"song {song.id} \"{song.artist_credit}\": no"
|
|
||||||
" primary artist parsed")
|
|
||||||
name: str
|
|
||||||
for name, role in parsed:
|
|
||||||
if name.strip() == "":
|
|
||||||
raise BuildError(
|
|
||||||
f"song {song.id} \"{song.artist_credit}\":"
|
|
||||||
" blank artist name parsed")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
|
|
||||||
"""Parse a combined artist credit into artists and roles.
|
|
||||||
|
|
||||||
A credit listed in ``EXCEPTION_CREDITS`` is looked up
|
|
||||||
verbatim, because its correct split is not derivable from
|
|
||||||
the credit text alone. Otherwise the credit first reduces
|
|
||||||
to an effective credit: a "<group>: <members>" prefix
|
|
||||||
(split at the first ": ") drops the group and keeps the
|
|
||||||
members; failing that, a "<group> (<members>)" suffix
|
|
||||||
spanning the whole credit drops the group and keeps the
|
|
||||||
members. The "Duet With" connector, case-insensitively,
|
|
||||||
then normalizes to "with". The effective credit splits
|
|
||||||
into a primary side and a featured side on the word
|
|
||||||
"featuring" or "feat.", case-insensitively; without them,
|
|
||||||
every artist is primary. Each side splits into artist
|
|
||||||
names on the delimiters ", ", " & ", " + ", " / "
|
|
||||||
(literally) and " and ", " x ", " with "
|
|
||||||
(case-insensitively), except for the names listed in
|
|
||||||
``PROTECTED_ARTIST_NAMES``, which are never split even
|
|
||||||
though each contains a delimiter word or punctuation.
|
|
||||||
|
|
||||||
Known limitation: a compound act name that contains one of
|
|
||||||
the delimiters, other than the protected names, is
|
|
||||||
over-split.
|
|
||||||
|
|
||||||
:param credit: The combined artist credit string.
|
|
||||||
:return: The (name, role) pairs in credit order, primary
|
|
||||||
side first, with the role ``Role.PRIMARY`` or
|
|
||||||
``Role.FEATURED``.
|
|
||||||
"""
|
|
||||||
if credit in ArtistImporter.EXCEPTION_CREDITS:
|
|
||||||
return list(ArtistImporter.EXCEPTION_CREDITS[credit])
|
|
||||||
effective: str = credit
|
|
||||||
colon_match: re.Match[str] | None = \
|
|
||||||
ArtistImporter.COLON_PATTERN.search(effective)
|
|
||||||
if colon_match is not None:
|
|
||||||
effective = effective[colon_match.end():]
|
|
||||||
else:
|
|
||||||
paren_match: re.Match[str] | None = \
|
|
||||||
ArtistImporter.PAREN_MEMBERS_PATTERN.match(
|
|
||||||
effective)
|
|
||||||
if paren_match is not None:
|
|
||||||
effective = paren_match.group("members")
|
|
||||||
effective = ArtistImporter.DUET_WITH_PATTERN.sub(
|
|
||||||
" with ", effective)
|
|
||||||
placeholders: dict[str, str] = {}
|
|
||||||
index: int
|
|
||||||
protected: str
|
|
||||||
for index, protected in enumerate(
|
|
||||||
ArtistImporter.PROTECTED_ARTIST_NAMES):
|
|
||||||
if protected in effective:
|
|
||||||
placeholder: str = f"{index}"
|
|
||||||
placeholders[placeholder] = protected
|
|
||||||
effective = effective.replace(protected, placeholder)
|
|
||||||
sides: list[str] = ArtistImporter.FEATURING_PATTERN.split(
|
|
||||||
effective, maxsplit=1)
|
|
||||||
pairs: list[tuple[str, Role]] = []
|
|
||||||
role: Role
|
|
||||||
side: str
|
|
||||||
for side, role in zip(sides, (Role.PRIMARY, Role.FEATURED)):
|
|
||||||
token: str
|
|
||||||
for token in ArtistImporter.DELIMITER_PATTERN.split(
|
|
||||||
side):
|
|
||||||
name: str = token.strip()
|
|
||||||
placeholder = ""
|
|
||||||
original: str
|
|
||||||
for placeholder, original in placeholders.items():
|
|
||||||
name = name.replace(placeholder, original)
|
|
||||||
if name != "":
|
|
||||||
pairs.append((name, role))
|
|
||||||
return pairs
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_artist_identity(name: str) -> tuple[str, str]:
|
|
||||||
"""Resolve the dedup key and the stored spelling of a name.
|
|
||||||
|
|
||||||
The name's case-folded form is looked up in
|
|
||||||
``CANONICAL_ARTIST_NAMES`` first; when it is listed there,
|
|
||||||
the dedup key is the canonical spelling case-folded and the
|
|
||||||
stored spelling is the canonical spelling, so every variant
|
|
||||||
of the name, canonical or not, resolves to the same
|
|
||||||
identity. Otherwise the dedup key is the name case-folded
|
|
||||||
and the stored spelling is the given name.
|
|
||||||
|
|
||||||
:param name: An artist name, as parsed from a credit.
|
|
||||||
:return: A tuple of the dedup key and the stored spelling.
|
|
||||||
"""
|
|
||||||
folded: str = name.casefold()
|
|
||||||
canonical: str | None = \
|
|
||||||
ArtistImporter.CANONICAL_ARTIST_NAMES.get(folded)
|
|
||||||
if canonical is not None:
|
|
||||||
return canonical.casefold(), canonical
|
|
||||||
return folded, name
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureImporter:
|
|
||||||
"""The capture-import job: applies the optional capture-layer
|
|
||||||
inputs onto the stored songs and artists."""
|
|
||||||
|
|
||||||
def __init__(self, session: Session) -> None:
|
|
||||||
"""Initialize the importer.
|
|
||||||
|
|
||||||
:param session: The database session.
|
|
||||||
"""
|
|
||||||
self.__session: Session = session
|
|
||||||
|
|
||||||
def import_captures(self, lyrics_dir: Path | None,
|
|
||||||
wikidata_csv: Path | None) -> None:
|
|
||||||
"""Apply the optional capture-layer inputs onto the store.
|
|
||||||
|
|
||||||
A None input leaves its capture layer unloaded. When
|
|
||||||
``lyrics_dir`` is given, its cached lyrics files load into
|
|
||||||
the matching songs (see `__load_lyrics`). When
|
|
||||||
``wikidata_csv`` is given, it applies onto the artist rows
|
|
||||||
(see `__apply_artist_csv`). When the method returns, the
|
|
||||||
applied changes are queryable in the session.
|
|
||||||
|
|
||||||
:param lyrics_dir: The lyrics cache directory to load, or
|
|
||||||
None to skip the lyrics capture layer.
|
|
||||||
:param wikidata_csv: The Wikidata artist snapshot CSV file
|
|
||||||
to apply, or None to skip the artist capture layer.
|
|
||||||
:return: None.
|
|
||||||
:raises BuildError: When ``lyrics_dir`` does not exist, or
|
|
||||||
a name in ``wikidata_csv`` matches no artist.
|
|
||||||
:raises OSError: When a capture file cannot be read.
|
|
||||||
"""
|
|
||||||
if lyrics_dir is not None:
|
|
||||||
if not lyrics_dir.is_dir():
|
|
||||||
raise BuildError(
|
|
||||||
f"{lyrics_dir}: no such directory")
|
|
||||||
self.__load_lyrics(lyrics_dir)
|
|
||||||
if wikidata_csv is not None:
|
|
||||||
self.__apply_artist_csv(wikidata_csv)
|
|
||||||
self.__session.flush()
|
|
||||||
|
|
||||||
def __load_lyrics(self, directory: Path) -> None:
|
|
||||||
"""Load the cached lyrics files into the matching songs.
|
|
||||||
|
|
||||||
A file whose stem is not an existing song ID is skipped
|
|
||||||
with a warning to the standard error.
|
|
||||||
|
|
||||||
:param directory: The existing lyrics cache directory with
|
|
||||||
one ``<song_id>.txt`` file per song.
|
|
||||||
:return: None.
|
|
||||||
:raises OSError: When a lyrics file cannot be read.
|
|
||||||
"""
|
|
||||||
for path in sorted(directory.glob("*.txt")):
|
|
||||||
song: Song | None = None
|
|
||||||
if path.stem.isdigit():
|
|
||||||
song = self.__session.get(Song, int(path.stem))
|
|
||||||
if song is None:
|
|
||||||
print(f"warning: {path}: no song with ID"
|
|
||||||
f" \"{path.stem}\"", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
song.lyrics = path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def __apply_artist_csv(self, path: Path) -> None:
|
|
||||||
"""Apply an artist attribute CSV onto the artist rows.
|
|
||||||
|
|
||||||
Artists match by exact name. Only the non-empty cells are
|
|
||||||
applied, field by field. The note column is ignored.
|
|
||||||
|
|
||||||
:param path: The CSV file with the columns name, qid,
|
|
||||||
gender, type, genre, country, and note.
|
|
||||||
:return: None.
|
|
||||||
:raises BuildError: When a name matches no artist.
|
|
||||||
:raises OSError: When the file cannot be read.
|
|
||||||
"""
|
|
||||||
with open(path, encoding="utf-8", newline="") as file:
|
|
||||||
row: dict[str, str]
|
|
||||||
for row in csv.DictReader(file):
|
|
||||||
artist: Artist | None = self.__session.scalar(
|
|
||||||
sa.select(Artist)
|
|
||||||
.where(Artist.name == row["name"]))
|
|
||||||
if artist is None:
|
|
||||||
raise BuildError(
|
|
||||||
f"{path}: no artist named"
|
|
||||||
f" \"{row['name']}\"")
|
|
||||||
column: str
|
|
||||||
attribute: str
|
|
||||||
for column, attribute in ARTIST_FIELDS.items():
|
|
||||||
if row.get(column):
|
|
||||||
setattr(artist, attribute, row[column])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StoreCounts:
|
|
||||||
"""The row counts of the working store, for the build summary."""
|
|
||||||
|
|
||||||
songs: int
|
|
||||||
"""The number of the songs."""
|
|
||||||
chart_entries: int
|
|
||||||
"""The number of the chart entries."""
|
|
||||||
artists: int
|
|
||||||
"""The number of the artists."""
|
|
||||||
credits: int
|
|
||||||
"""The number of the song-artist credits."""
|
|
||||||
songs_with_lyrics: int
|
|
||||||
"""The number of the songs with lyrics."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_instance(cls, session: Session) -> Self:
|
|
||||||
"""Counts the loaded rows and returns the counts.
|
|
||||||
|
|
||||||
:param session: The database session with the loaded data
|
|
||||||
flushed.
|
|
||||||
:return: The row counts of the working store.
|
|
||||||
"""
|
|
||||||
def count(selectable: sa.Select[tuple[int]]) -> int:
|
|
||||||
value: int | None = session.scalar(selectable)
|
|
||||||
assert value is not None
|
|
||||||
return value
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
songs=count(
|
|
||||||
sa.select(sa.func.count()).select_from(Song)),
|
|
||||||
chart_entries=count(
|
|
||||||
sa.select(sa.func.count())
|
|
||||||
.select_from(ChartEntry)),
|
|
||||||
artists=count(
|
|
||||||
sa.select(sa.func.count()).select_from(Artist)),
|
|
||||||
credits=count(
|
|
||||||
sa.select(sa.func.count())
|
|
||||||
.select_from(SongArtist)),
|
|
||||||
songs_with_lyrics=count(
|
|
||||||
sa.select(sa.func.count()).select_from(Song)
|
|
||||||
.where(Song.lyrics.is_not(None))))
|
|
||||||
|
|
||||||
|
|
||||||
def reset_store(session: Session) -> None:
|
|
||||||
"""Delete all the rows from every table of the store.
|
|
||||||
|
|
||||||
:param session: The database session.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
model: type[Base]
|
|
||||||
for model in (SongArtist, ChartEntry, Song, Artist):
|
|
||||||
session.execute(sa.delete(model))
|
|
||||||
|
|
||||||
|
|
||||||
class CSVExporter:
|
|
||||||
"""Writes the review CSV files mirroring the working store."""
|
|
||||||
|
|
||||||
__SONGS_HEADER: tuple[str, ...] = ("Title", "Artists", "Positions")
|
|
||||||
"""The header row of ``songs.csv``, for human readers."""
|
|
||||||
__ARTISTS_HEADER: tuple[str, ...] = (
|
|
||||||
"Name", "Wikidata QID", "Gender", "Type", "Genre", "Country",
|
|
||||||
"Songs")
|
|
||||||
"""The header row of ``artists.csv``, for human readers."""
|
|
||||||
|
|
||||||
def __init__(self, session: Session, derived_dir: Path) -> None:
|
|
||||||
"""Initialize the exporter.
|
|
||||||
|
|
||||||
:param session: The database session with the loaded data
|
|
||||||
flushed.
|
|
||||||
:param derived_dir: The output directory for the review CSV
|
|
||||||
files.
|
|
||||||
"""
|
|
||||||
self.__session: Session = session
|
|
||||||
self.__derived_dir: Path = derived_dir
|
|
||||||
|
|
||||||
def write(self) -> None:
|
|
||||||
"""Write the review CSV files mirroring the loaded data.
|
|
||||||
|
|
||||||
Fully overwrites ``songs.csv`` and ``artists.csv`` under the
|
|
||||||
output directory, creating it when missing, with normal
|
|
||||||
minimal CSV quoting. Neither file carries a song or an
|
|
||||||
artist ID; a multi-valued field is a plain joined string,
|
|
||||||
itself CSV-quoted as a whole only when its content requires
|
|
||||||
it: "/" joins the chart appearances of one song, and "|"
|
|
||||||
joins the distinct songs credited to one artist.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
:raises OSError: When a CSV file cannot be written.
|
|
||||||
"""
|
|
||||||
self.__derived_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.__write_csv(
|
|
||||||
self.__derived_dir / "songs.csv", self.__SONGS_HEADER,
|
|
||||||
self.__songs_rows())
|
|
||||||
self.__write_csv(
|
|
||||||
self.__derived_dir / "artists.csv", self.__ARTISTS_HEADER,
|
|
||||||
self.__artists_rows())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __write_csv(path: Path, header: Sequence[str],
|
|
||||||
rows: Iterable[Sequence[str]]) -> None:
|
|
||||||
"""Write a CSV file with LF line endings, fully overwritten.
|
|
||||||
|
|
||||||
:param path: The output CSV file.
|
|
||||||
:param header: The header row.
|
|
||||||
:param rows: The data rows, in the given order.
|
|
||||||
:return: None.
|
|
||||||
:raises OSError: When the file cannot be written.
|
|
||||||
"""
|
|
||||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
|
||||||
writer: Any = csv.writer(file)
|
|
||||||
writer.writerow(header)
|
|
||||||
writer.writerows(rows)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __sorted_chart_entries(song: Song) -> list[ChartEntry]:
|
|
||||||
"""Sort the chart entries of a song by year then rank.
|
|
||||||
|
|
||||||
:param song: The song with its chart entries loaded.
|
|
||||||
:return: The chart entries, ordered by year then rank.
|
|
||||||
"""
|
|
||||||
return sorted(
|
|
||||||
song.chart_entries, key=lambda x: (x.year, x.rank))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __song_positions(cls, song: Song) -> str:
|
|
||||||
"""Format the chart positions of a song for the songs.csv
|
|
||||||
value.
|
|
||||||
|
|
||||||
:param song: The song with its chart entries loaded.
|
|
||||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
|
||||||
joined by "/".
|
|
||||||
"""
|
|
||||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
|
||||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __formatted_song_positions(cls, song: Song) -> str:
|
|
||||||
"""Format the chart positions of a song for the artists.csv
|
|
||||||
value.
|
|
||||||
|
|
||||||
:param song: The song with its chart entries loaded.
|
|
||||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
|
||||||
joined by "/".
|
|
||||||
"""
|
|
||||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
|
||||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
|
||||||
|
|
||||||
def __songs_rows(self) -> list[list[str]]:
|
|
||||||
"""Build the sorted data rows of ``songs.csv``.
|
|
||||||
|
|
||||||
:return: The rows, sorted by the case-folded title, then
|
|
||||||
the case-folded artist credit.
|
|
||||||
"""
|
|
||||||
songs: list[Song] = sorted(
|
|
||||||
self.__session.scalars(sa.select(Song)),
|
|
||||||
key=lambda x: (x.title.casefold(),
|
|
||||||
x.artist_credit.casefold()))
|
|
||||||
rows: list[list[str]] = []
|
|
||||||
song: Song
|
|
||||||
for song in songs:
|
|
||||||
row: list[str] = [
|
|
||||||
song.title, song.artist_credit,
|
|
||||||
self.__song_positions(song)]
|
|
||||||
rows.append(row)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __artist_songs(cls, artist: Artist) -> str:
|
|
||||||
"""Format the credited songs for the artists.csv value.
|
|
||||||
|
|
||||||
:param artist: The artist with its song credits loaded.
|
|
||||||
:return: The credited songs, each formatted as
|
|
||||||
"TITLE (YEAR#RANK[/YEAR#RANK...])" with its chart
|
|
||||||
appearances, sorted alphabetically case-folded by
|
|
||||||
title, joined by "|".
|
|
||||||
"""
|
|
||||||
songs: list[Song] = sorted(
|
|
||||||
(x.song for x in artist.song_artists),
|
|
||||||
key=lambda x: x.title.casefold())
|
|
||||||
entries: list[str] = [
|
|
||||||
f"{song.title} ({cls.__formatted_song_positions(song)})"
|
|
||||||
for song in songs]
|
|
||||||
return "|".join(entries)
|
|
||||||
|
|
||||||
def __artists_rows(self) -> list[list[str]]:
|
|
||||||
"""Build the sorted data rows of ``artists.csv``.
|
|
||||||
|
|
||||||
:return: The rows, sorted by the case-folded name.
|
|
||||||
"""
|
|
||||||
artists: list[Artist] = sorted(
|
|
||||||
self.__session.scalars(sa.select(Artist)),
|
|
||||||
key=lambda x: x.name.casefold())
|
|
||||||
rows: list[list[str]] = []
|
|
||||||
artist: Artist
|
|
||||||
for artist in artists:
|
|
||||||
row: list[str] = [
|
|
||||||
artist.name, artist.wikidata_qid or "",
|
|
||||||
artist.gender or "", artist.type or "",
|
|
||||||
artist.genre or "", artist.country or "",
|
|
||||||
self.__artist_songs(artist)]
|
|
||||||
rows.append(row)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
"""Rebuild the SQLite working store from the inputs.
|
|
||||||
|
|
||||||
:param argv: The command-line arguments, or None for
|
|
||||||
``sys.argv``.
|
|
||||||
:return: The exit status: 0 on success, non-zero on failure.
|
|
||||||
"""
|
|
||||||
args: argparse.Namespace = parse_args(argv)
|
|
||||||
Base.metadata.create_all(ds.engine)
|
|
||||||
session: Session = ds.get_db()
|
|
||||||
counts: StoreCounts
|
|
||||||
try:
|
|
||||||
reset_store(session)
|
|
||||||
SongImporter(session).import_songs(args.chart_csv)
|
|
||||||
ArtistImporter(session).import_artists()
|
|
||||||
CaptureImporter(session).import_captures(
|
|
||||||
args.lyrics_dir, args.wikidata_csv)
|
|
||||||
counts = StoreCounts.get_instance(session)
|
|
||||||
CSVExporter(session, args.derived_dir).write()
|
|
||||||
session.commit()
|
|
||||||
except (OSError, BuildError) as error:
|
|
||||||
session.rollback()
|
|
||||||
print(f"error: {error}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
print(f"done: {counts.songs} songs,"
|
|
||||||
f" {counts.chart_entries} chart entries,"
|
|
||||||
f" {counts.artists} artists,"
|
|
||||||
f" {counts.credits} credits,"
|
|
||||||
f" {counts.songs_with_lyrics} songs with lyrics",
|
|
||||||
file=sys.stderr)
|
|
||||||
return 0
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Tools for A Feminist Audit of Pop Music.
|
|
||||||
# Copyright 2026 imacat. All rights reserved.
|
|
||||||
# Authors:
|
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
|
||||||
"""The exporter of the LLM input JSONL file.
|
|
||||||
|
|
||||||
Exports the songs from the SQLite working store into the JSONL
|
|
||||||
input file for the ``run-llm`` subcommand, given as the positional
|
|
||||||
command-line argument. This is the enforcement point of the
|
|
||||||
project's lyrics-only firewall: the output carries only the
|
|
||||||
lyrics text of each song, identified by an opaque song key; no
|
|
||||||
title, artist, or chart data crosses into the LLM input.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ..database import ds
|
|
||||||
from ..models import Song
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
||||||
"""Parse the command-line arguments.
|
|
||||||
|
|
||||||
:param argv: The command-line arguments, or None for
|
|
||||||
``sys.argv``.
|
|
||||||
:return: The parsed arguments.
|
|
||||||
"""
|
|
||||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
|
||||||
description="Export the LLM input JSONL file (lyrics"
|
|
||||||
" only) from the SQLite working store.")
|
|
||||||
parser.add_argument(
|
|
||||||
"output_jsonl", type=Path,
|
|
||||||
help="the JSONL output file")
|
|
||||||
return parser.parse_args(argv)
|
|
||||||
|
|
||||||
|
|
||||||
def build_lines(session: Session) -> list[str]:
|
|
||||||
"""Build the JSONL lines of every song's lyrics.
|
|
||||||
|
|
||||||
:param session: The database session.
|
|
||||||
:return: The JSON lines, one per song, ordered by song ID.
|
|
||||||
:raises ValueError: When a song has no lyrics.
|
|
||||||
"""
|
|
||||||
lines: list[str] = []
|
|
||||||
song: Song
|
|
||||||
for song in session.scalars(sa.select(Song).order_by(Song.id)):
|
|
||||||
if song.lyrics is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"song {song.id} \"{song.title}\": no lyrics")
|
|
||||||
record: dict[str, str] = {
|
|
||||||
"id": f"song-{song.id}", "content": song.lyrics}
|
|
||||||
lines.append(json.dumps(record, ensure_ascii=False))
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
"""Export the LLM input JSONL file from the working store.
|
|
||||||
|
|
||||||
:param argv: The command-line arguments, or None for
|
|
||||||
``sys.argv``.
|
|
||||||
:return: The exit status: 0 on success, non-zero on failure.
|
|
||||||
"""
|
|
||||||
args: argparse.Namespace = parse_args(argv)
|
|
||||||
session: Session = ds.get_db()
|
|
||||||
lines: list[str]
|
|
||||||
try:
|
|
||||||
lines = build_lines(session)
|
|
||||||
except (OSError, sa.exc.SQLAlchemyError, ValueError) as error:
|
|
||||||
print(f"error: {error}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
args.output_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(args.output_jsonl, "w", encoding="utf-8") as file:
|
|
||||||
line: str
|
|
||||||
for line in lines:
|
|
||||||
file.write(line + "\n")
|
|
||||||
print(f"done: {len(lines)} songs exported", file=sys.stderr)
|
|
||||||
return 0
|
|
||||||
+22
-4
@@ -34,10 +34,9 @@ from typing import Any, Literal, TextIO
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import VERSION
|
from . import VERSION
|
||||||
from ..database import ds
|
from .database import ds
|
||||||
from ..models import Artist, Song, SongArtist
|
from .models import Artist, Song, SongArtist
|
||||||
from ..utils import format_duration
|
|
||||||
|
|
||||||
API_URL: str = "https://www.wikidata.org/w/api.php"
|
API_URL: str = "https://www.wikidata.org/w/api.php"
|
||||||
"""The URL of the Wikidata API endpoint."""
|
"""The URL of the Wikidata API endpoint."""
|
||||||
@@ -780,6 +779,25 @@ def write_snapshot(file: TextIO) -> None:
|
|||||||
writer.writerows(ordered)
|
writer.writerows(ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
"""Format an elapsed duration for the closing summary line.
|
||||||
|
|
||||||
|
:param seconds: The elapsed duration, in seconds.
|
||||||
|
:return: The duration formatted ``mm:ss``, or ``h:mm:ss``
|
||||||
|
once it reaches one hour.
|
||||||
|
"""
|
||||||
|
total: int = round(seconds)
|
||||||
|
hours: int
|
||||||
|
remainder: int
|
||||||
|
hours, remainder = divmod(total, 3600)
|
||||||
|
minutes: int
|
||||||
|
secs: int
|
||||||
|
minutes, secs = divmod(remainder, 60)
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||||
|
return f"{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Fetch the artist metadata from Wikidata.
|
"""Fetch the artist metadata from Wikidata.
|
||||||
|
|
||||||
+63
-22
@@ -11,15 +11,10 @@ given as a positional command-line argument. The working store
|
|||||||
is only read, never written; the ``build-db`` subcommand
|
is only read, never written; the ``build-db`` subcommand
|
||||||
assembles the captured files into the store on the next rebuild.
|
assembles the captured files into the store on the next rebuild.
|
||||||
|
|
||||||
Each song is first queried with the name of its primary-role
|
A song that every API misses is reported in the missing lyrics
|
||||||
artist with the lowest position. When every API misses that
|
CSV, also given as a positional command-line argument, which is
|
||||||
query and the song's full artist credit differs from that
|
rewritten on every run to reflect the current status. Misses
|
||||||
artist name, the same APIs are queried again with the artist
|
are expected and do not fail the run.
|
||||||
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 on the
|
|
||||||
standard error, but does not fail the run; misses are expected.
|
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
@@ -30,24 +25,27 @@ import time
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import ds
|
from .database import ds
|
||||||
from ..models import (
|
from .models import (
|
||||||
Artist,
|
Artist,
|
||||||
Role,
|
Role,
|
||||||
Song,
|
Song,
|
||||||
SongArtist,
|
SongArtist,
|
||||||
)
|
)
|
||||||
from ..utils import format_duration
|
|
||||||
|
|
||||||
PROVENANCE_FIELDS: Sequence[str] = (
|
PROVENANCE_FIELDS: Sequence[str] = (
|
||||||
"song_id", "source", "method", "acquired_at", "note")
|
"song_id", "source", "method", "acquired_at", "note")
|
||||||
"""The header columns of the lyrics provenance CSV file."""
|
"""The header columns of the lyrics provenance CSV file."""
|
||||||
|
MISSING_FIELDS: Sequence[str] = (
|
||||||
|
"song_id", "title", "artist_credit", "reason")
|
||||||
|
"""The header columns of the missing lyrics report CSV file."""
|
||||||
USER_AGENT: str = ("pop-fem-audit-tools"
|
USER_AGENT: str = ("pop-fem-audit-tools"
|
||||||
" (https://github.com/imacat/pop-fem-audit)")
|
" (https://github.com/imacat/pop-fem-audit)")
|
||||||
"""The User-Agent header sent on every HTTP request."""
|
"""The User-Agent header sent on every HTTP request."""
|
||||||
@@ -57,6 +55,28 @@ SLEEP_SECONDS: float = 1.0
|
|||||||
"""The delay between consecutive HTTP requests, in seconds."""
|
"""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:
|
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||||
"""Parse the command-line arguments.
|
"""Parse the command-line arguments.
|
||||||
|
|
||||||
@@ -73,6 +93,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"provenance_csv", type=Path,
|
"provenance_csv", type=Path,
|
||||||
help="the lyrics provenance CSV file")
|
help="the lyrics provenance CSV file")
|
||||||
|
parser.add_argument(
|
||||||
|
"missing_csv", type=Path,
|
||||||
|
help="the missing lyrics report CSV file")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +244,27 @@ def append_provenance(path: Path, song_id: int,
|
|||||||
datetime.date.today().isoformat(), ""])
|
datetime.date.today().isoformat(), ""])
|
||||||
|
|
||||||
|
|
||||||
|
def write_missing(path: Path,
|
||||||
|
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 path: The missing lyrics report CSV file.
|
||||||
|
:param misses: The report entries of the songs still without
|
||||||
|
lyrics.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be written.
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8",
|
||||||
|
newline="") as file:
|
||||||
|
writer: Any = csv.writer(file)
|
||||||
|
writer.writerow(MISSING_FIELDS)
|
||||||
|
writer.writerows(x.to_row() for x in misses)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Fetch the missing song lyrics from the public APIs.
|
"""Fetch the missing song lyrics from the public APIs.
|
||||||
|
|
||||||
@@ -229,11 +273,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
:return: The exit status: 0 on success, misses included,
|
:return: The exit status: 0 on success, misses included,
|
||||||
non-zero on a setup error.
|
non-zero on a setup error.
|
||||||
"""
|
"""
|
||||||
started: float = time.monotonic()
|
|
||||||
args: argparse.Namespace = parse_args(argv)
|
args: argparse.Namespace = parse_args(argv)
|
||||||
fetcher: LyricsFetcher = LyricsFetcher()
|
fetcher: LyricsFetcher = LyricsFetcher()
|
||||||
fetched: int = 0
|
fetched: int = 0
|
||||||
missed: int = 0
|
misses: list[MissingLyrics] = []
|
||||||
session: Session = ds.get_db()
|
session: Session = ds.get_db()
|
||||||
try:
|
try:
|
||||||
song: Song
|
song: Song
|
||||||
@@ -244,11 +287,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
artist: str = query_artist(session, song.id)
|
artist: str = query_artist(session, song.id)
|
||||||
result: tuple[str, str] | None = fetcher.fetch(
|
result: tuple[str, str] | None = fetcher.fetch(
|
||||||
artist, song.title)
|
artist, song.title)
|
||||||
if result is None and song.artist_credit != artist:
|
|
||||||
result = fetcher.fetch(
|
|
||||||
song.artist_credit, song.title)
|
|
||||||
if result is None:
|
if result is None:
|
||||||
missed += 1
|
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",
|
print(f"song {song.id} \"{song.title}\": miss",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
@@ -261,14 +304,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
fetched += 1
|
fetched += 1
|
||||||
print(f"song {song.id} \"{song.title}\": {source}",
|
print(f"song {song.id} \"{song.title}\": {source}",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
|
write_missing(args.missing_csv, misses)
|
||||||
except (OSError, sa.exc.SQLAlchemyError) as error:
|
except (OSError, sa.exc.SQLAlchemyError) as error:
|
||||||
print(f"error: {error}", file=sys.stderr)
|
print(f"error: {error}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
attempted: int = fetched + missed
|
print(f"done: {fetched} fetched, {len(misses)} missed",
|
||||||
elapsed: str = format_duration(time.monotonic() - started)
|
|
||||||
print(f"Done. Fetched lyrics for {fetched}/{attempted}"
|
|
||||||
f" songs. {elapsed} elapsed.",
|
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
+236
-92
@@ -4,25 +4,19 @@
|
|||||||
# Authors:
|
# Authors:
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
||||||
# AI assistance: Claude Code (Anthropic)
|
# AI assistance: Claude Code (Anthropic)
|
||||||
"""The generic batch executor for one LLM analysis step.
|
"""The generic batch runner for one LLM analysis step.
|
||||||
|
|
||||||
One invocation is one definition file plus one input, sent to the
|
Sends every input item to the Anthropic Messages Batch API twice with
|
||||||
Anthropic Messages Batch API exactly once, archived self-contained
|
the same system prompt, reconciles the disagreeing items with a third
|
||||||
under the destination directory given by the three positional
|
arbitration batch, and archives every artifact self-contained under
|
||||||
command-line arguments: prompt, input, archive_dir. The tool
|
``<runs_dir>/<phase>/<YYYYMMDD-HHMM>-<prompt-stem>/``, where the
|
||||||
knows nothing about run counts or protocols: run identity --
|
base directory of the run archives is given as the positional
|
||||||
run1/run2, arbitration -- lives entirely in the caller's command
|
command-line argument.
|
||||||
list, per the research plan. A rerun of an already existing
|
|
||||||
destination requires ``--replace``; any other directory is never
|
|
||||||
touched.
|
|
||||||
|
|
||||||
Comparing runs and reconciling disagreements are the responsibility
|
|
||||||
of separate subcommands, not this one.
|
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import enum
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
@@ -32,13 +26,26 @@ from typing import Any, Self
|
|||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
|
||||||
from ..config import get_settings
|
from .config import get_settings
|
||||||
|
|
||||||
MODEL: str = "claude-sonnet-4-6"
|
MODEL: str = "claude-sonnet-4-6"
|
||||||
TEMPERATURE: float = 0.0
|
TEMPERATURE: float = 0.0
|
||||||
THINKING: dict[str, str] = {"type": "disabled"}
|
THINKING: dict[str, str] = {"type": "disabled"}
|
||||||
SCRIPT_VERSION: str = "run_llm.py 3.0.0"
|
SCRIPT_VERSION: str = "run_llm.py 1.0.0"
|
||||||
POLL_INTERVAL_SECONDS: float = 60.0
|
POLL_INTERVAL_SECONDS: float = 60.0
|
||||||
|
ARBITRATION_TEMPLATE: str = (
|
||||||
|
"<item>\n{content}\n</item>\n"
|
||||||
|
"<run1>\n{run1}\n</run1>\n"
|
||||||
|
"<run2>\n{run2}\n</run2>")
|
||||||
|
|
||||||
|
|
||||||
|
class Source(enum.StrEnum):
|
||||||
|
"""The source of a final record."""
|
||||||
|
|
||||||
|
AGREED = "agreed"
|
||||||
|
"""The record takes the run text the two runs agreed on."""
|
||||||
|
ARBITRATION = "arbitration"
|
||||||
|
"""The record takes the arbitration output."""
|
||||||
|
|
||||||
|
|
||||||
class InputFormatError(Exception):
|
class InputFormatError(Exception):
|
||||||
@@ -167,26 +174,28 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
:return: The parsed arguments.
|
:return: The parsed arguments.
|
||||||
"""
|
"""
|
||||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||||
description="Run one LLM definition file against one input"
|
description="Run one LLM step: 2 runs + 1 arbitration.")
|
||||||
" and archive the result.")
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"prompt", type=Path,
|
"runs_dir", type=Path,
|
||||||
|
help="the base directory of the run archives")
|
||||||
|
parser.add_argument(
|
||||||
|
"--prompt", required=True, type=Path,
|
||||||
help="the prompt definition file, used as the system prompt")
|
help="the prompt definition file, used as the system prompt")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"input", type=Path,
|
"--arbitration-prompt", required=True, type=Path,
|
||||||
|
help="the arbitration prompt definition file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input", required=True, type=Path,
|
||||||
help="the JSONL input file with \"id\" and \"content\"")
|
help="the JSONL input file with \"id\" and \"content\"")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"archive_dir", type=Path,
|
"--phase", required=True,
|
||||||
help="the destination archive directory")
|
help="the phase name for the archive directory")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--max-tokens", type=int, default=2048,
|
"--max-tokens", type=int, default=2048,
|
||||||
help="the maximum output tokens per request (default 2048)")
|
help="the maximum output tokens per request (default 2048)")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run", action="store_true",
|
"--dry-run", action="store_true",
|
||||||
help="validate and archive without calling the API")
|
help="validate and archive without calling the API")
|
||||||
parser.add_argument(
|
|
||||||
"--replace", action="store_true",
|
|
||||||
help="replace an already existing archive directory")
|
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -247,6 +256,19 @@ def build_request(item: InputItem, system_prompt: str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_arbitration_content(content: str, run1_text: str,
|
||||||
|
run2_text: str) -> str:
|
||||||
|
"""Build the arbitration user message for one item.
|
||||||
|
|
||||||
|
:param content: The original item content.
|
||||||
|
:param run1_text: The run-1 output text.
|
||||||
|
:param run2_text: The run-2 output text.
|
||||||
|
:return: The user message text.
|
||||||
|
"""
|
||||||
|
return ARBITRATION_TEMPLATE.format(
|
||||||
|
content=content, run1=run1_text, run2=run2_text)
|
||||||
|
|
||||||
|
|
||||||
def submit_batch(client: anthropic.Anthropic,
|
def submit_batch(client: anthropic.Anthropic,
|
||||||
requests: list[dict[str, Any]]) -> str:
|
requests: list[dict[str, Any]]) -> str:
|
||||||
"""Submit one message batch.
|
"""Submit one message batch.
|
||||||
@@ -292,22 +314,6 @@ def usage_to_dict(usage: Any) -> dict[str, Any]:
|
|||||||
if v is not None}
|
if v is not None}
|
||||||
|
|
||||||
|
|
||||||
def sum_usage(results: Results) -> dict[str, int]:
|
|
||||||
"""Sum the token usage of every succeeded result.
|
|
||||||
|
|
||||||
:param results: The result records, keyed by item ID.
|
|
||||||
:return: The summed integer usage fields.
|
|
||||||
"""
|
|
||||||
totals: dict[str, int] = {}
|
|
||||||
for result in results.values():
|
|
||||||
if result.usage is None:
|
|
||||||
continue
|
|
||||||
for key, value in result.usage.items():
|
|
||||||
if isinstance(value, int):
|
|
||||||
totals[key] = totals.get(key, 0) + value
|
|
||||||
return totals
|
|
||||||
|
|
||||||
|
|
||||||
def collect_results(client: anthropic.Anthropic,
|
def collect_results(client: anthropic.Anthropic,
|
||||||
batch_id: str) -> Results:
|
batch_id: str) -> Results:
|
||||||
"""Collect the results of an ended batch.
|
"""Collect the results of an ended batch.
|
||||||
@@ -337,25 +343,78 @@ def find_failures(item_ids: list[str],
|
|||||||
if x not in results or results[x].is_failure]
|
if x not in results or results[x].is_failure]
|
||||||
|
|
||||||
|
|
||||||
def create_archive_dir(directory: Path, replace: bool) -> Path:
|
def split_by_agreement(
|
||||||
"""Create the archive directory.
|
items: list[InputItem], run1: Results, run2: Results,
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
"""Split the item IDs into agreed and disagreeing ones.
|
||||||
|
|
||||||
Only this directory is ever created or removed; no other
|
Two outputs agree when their texts are identical after strip().
|
||||||
directory is ever touched.
|
|
||||||
|
|
||||||
:param directory: The destination archive directory.
|
:param items: The input items.
|
||||||
:param replace: Whether to remove an already existing archive
|
:param run1: The run-1 results, keyed by item ID.
|
||||||
directory before creating it.
|
:param run2: The run-2 results, keyed by item ID.
|
||||||
:return: The created archive directory.
|
:return: A tuple of the agreed item IDs and the disagreeing item
|
||||||
:raises FileExistsError: When the archive directory already
|
IDs, both in input order.
|
||||||
exists and ``replace`` is False.
|
|
||||||
"""
|
"""
|
||||||
|
agreed: list[str] = []
|
||||||
|
disagreed: list[str] = []
|
||||||
|
for item in items:
|
||||||
|
text1: str | None = run1[item.id].text
|
||||||
|
text2: str | None = run2[item.id].text
|
||||||
|
assert text1 is not None and text2 is not None
|
||||||
|
if text1.strip() == text2.strip():
|
||||||
|
agreed.append(item.id)
|
||||||
|
else:
|
||||||
|
disagreed.append(item.id)
|
||||||
|
return agreed, disagreed
|
||||||
|
|
||||||
|
|
||||||
|
def build_final_records(
|
||||||
|
items: list[InputItem], run1: Results, arbitration: Results,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Assemble the final records, one per item, in input order.
|
||||||
|
|
||||||
|
An arbitrated item takes the arbitration output; an agreed item
|
||||||
|
takes the agreed (stripped) run text.
|
||||||
|
|
||||||
|
:param items: The input items.
|
||||||
|
:param run1: The run-1 results, keyed by item ID.
|
||||||
|
:param arbitration: The arbitration results, keyed by item ID.
|
||||||
|
:return: The final records with "id", "text", and "source".
|
||||||
|
"""
|
||||||
|
records: list[dict[str, str]] = []
|
||||||
|
for item in items:
|
||||||
|
text: str | None
|
||||||
|
if item.id in arbitration:
|
||||||
|
text = arbitration[item.id].text
|
||||||
|
assert text is not None
|
||||||
|
records.append({"id": item.id, "text": text,
|
||||||
|
"source": Source.ARBITRATION})
|
||||||
|
else:
|
||||||
|
text = run1[item.id].text
|
||||||
|
assert text is not None
|
||||||
|
records.append({"id": item.id, "text": text.strip(),
|
||||||
|
"source": Source.AGREED})
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def create_archive_dir(runs_root: Path, phase: str, prompt_path: Path,
|
||||||
|
now: datetime) -> Path:
|
||||||
|
"""Create the archive directory for this execution.
|
||||||
|
|
||||||
|
:param runs_root: The root directory of the run archives.
|
||||||
|
:param phase: The phase name.
|
||||||
|
:param prompt_path: The path of the prompt definition file.
|
||||||
|
:param now: The local timestamp of this execution.
|
||||||
|
:return: The created archive directory.
|
||||||
|
:raises FileExistsError: When the directory already exists.
|
||||||
|
"""
|
||||||
|
stem: str = prompt_path.stem
|
||||||
|
name: str = f"{now.strftime('%Y%m%d-%H%M')}-{stem}"
|
||||||
|
directory: Path = runs_root / phase / name
|
||||||
if directory.exists():
|
if directory.exists():
|
||||||
if not replace:
|
raise FileExistsError(
|
||||||
raise FileExistsError(
|
f"archive directory {directory} already exists")
|
||||||
f"{directory} already exists; pass --replace to"
|
|
||||||
" replace it")
|
|
||||||
shutil.rmtree(directory)
|
|
||||||
directory.mkdir(parents=True)
|
directory.mkdir(parents=True)
|
||||||
return directory
|
return directory
|
||||||
|
|
||||||
@@ -387,14 +446,17 @@ def write_json(path: Path, data: dict[str, Any]) -> None:
|
|||||||
def write_meta(path: Path, meta: dict[str, Any]) -> None:
|
def write_meta(path: Path, meta: dict[str, Any]) -> None:
|
||||||
"""Write the metadata to the ``meta.json`` file.
|
"""Write the metadata to the ``meta.json`` file.
|
||||||
|
|
||||||
The ``BatchInfo`` value under ``batch`` is written as a plain
|
The ``BatchInfo`` values under ``batches`` are written as
|
||||||
JSON object.
|
plain JSON objects.
|
||||||
|
|
||||||
:param path: The path of the ``meta.json`` file.
|
:param path: The path of the ``meta.json`` file.
|
||||||
:param meta: The metadata to write.
|
:param meta: The metadata to write.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
write_json(path, {**meta, "batch": asdict(meta["batch"])})
|
write_json(path, {
|
||||||
|
**meta,
|
||||||
|
"batches": {k: asdict(v)
|
||||||
|
for k, v in meta["batches"].items()}})
|
||||||
|
|
||||||
|
|
||||||
def sha256_of(path: Path) -> str:
|
def sha256_of(path: Path) -> str:
|
||||||
@@ -415,14 +477,13 @@ def now_iso() -> str:
|
|||||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
def execute_run(
|
def execute_runs(
|
||||||
client: anthropic.Anthropic, items: list[InputItem],
|
client: anthropic.Anthropic, items: list[InputItem],
|
||||||
system_prompt: str, max_tokens: int,
|
system_prompt: str, max_tokens: int, meta: dict[str, Any],
|
||||||
meta: dict[str, Any],
|
) -> tuple[Results, Results]:
|
||||||
) -> Results:
|
"""Submit the two identical runs and await their results.
|
||||||
"""Submit the batch of this run and await its results.
|
|
||||||
|
|
||||||
The batch ID and timestamps are recorded into the metadata as an
|
The batch IDs and timestamps are recorded into the metadata as an
|
||||||
observable side effect.
|
observable side effect.
|
||||||
|
|
||||||
:param client: The Anthropic client.
|
:param client: The Anthropic client.
|
||||||
@@ -430,22 +491,72 @@ def execute_run(
|
|||||||
:param system_prompt: The system prompt text.
|
:param system_prompt: The system prompt text.
|
||||||
:param max_tokens: The maximum output tokens per request.
|
:param max_tokens: The maximum output tokens per request.
|
||||||
:param meta: The metadata to record the batch bookkeeping into.
|
:param meta: The metadata to record the batch bookkeeping into.
|
||||||
:return: The results of this run, keyed by item ID.
|
:return: The results of run 1 and run 2, keyed by item ID.
|
||||||
"""
|
"""
|
||||||
requests: list[dict[str, Any]] = [
|
requests: list[dict[str, Any]] = [
|
||||||
build_request(x, system_prompt, max_tokens) for x in items]
|
build_request(x, system_prompt, max_tokens) for x in items]
|
||||||
|
infos: dict[str, BatchInfo] = {}
|
||||||
|
for run_name in ("run1", "run2"):
|
||||||
|
info: BatchInfo = BatchInfo(
|
||||||
|
batch_id=submit_batch(client, requests),
|
||||||
|
submitted_at=now_iso())
|
||||||
|
infos[run_name] = info
|
||||||
|
meta["batches"][run_name] = info
|
||||||
|
print(f"{run_name}: submitted batch {info.batch_id}",
|
||||||
|
file=sys.stderr)
|
||||||
|
batches: dict[str, Any] = poll_batches(
|
||||||
|
client, [x.batch_id for x in infos.values()])
|
||||||
|
for info in infos.values():
|
||||||
|
info.ended_at = batches[info.batch_id].ended_at.isoformat()
|
||||||
|
return (collect_results(client, infos["run1"].batch_id),
|
||||||
|
collect_results(client, infos["run2"].batch_id))
|
||||||
|
|
||||||
|
|
||||||
|
def execute_arbitration(
|
||||||
|
client: anthropic.Anthropic, items: list[InputItem],
|
||||||
|
disagreed: list[str], run1: Results, run2: Results,
|
||||||
|
system_prompt: str, max_tokens: int, meta: dict[str, Any],
|
||||||
|
) -> Results:
|
||||||
|
"""Submit the arbitration batch and await its results.
|
||||||
|
|
||||||
|
The batch ID and timestamps are recorded into the metadata as an
|
||||||
|
observable side effect.
|
||||||
|
|
||||||
|
:param client: The Anthropic client.
|
||||||
|
:param items: The input items.
|
||||||
|
:param disagreed: The disagreeing item IDs.
|
||||||
|
:param run1: The run-1 results, keyed by item ID.
|
||||||
|
:param run2: The run-2 results, keyed by item ID.
|
||||||
|
:param system_prompt: The arbitration system prompt text.
|
||||||
|
:param max_tokens: The maximum output tokens per request.
|
||||||
|
:param meta: The metadata to record the batch bookkeeping into.
|
||||||
|
:return: The arbitration results, keyed by item ID.
|
||||||
|
"""
|
||||||
|
content_by_id: dict[str, str] = {
|
||||||
|
x.id: x.content for x in items}
|
||||||
|
requests: list[dict[str, Any]] = []
|
||||||
|
for item_id in disagreed:
|
||||||
|
text1: str | None = run1[item_id].text
|
||||||
|
text2: str | None = run2[item_id].text
|
||||||
|
assert text1 is not None and text2 is not None
|
||||||
|
requests.append(build_request(
|
||||||
|
InputItem(id=item_id,
|
||||||
|
content=build_arbitration_content(
|
||||||
|
content_by_id[item_id], text1, text2)),
|
||||||
|
system_prompt, max_tokens))
|
||||||
info: BatchInfo = BatchInfo(
|
info: BatchInfo = BatchInfo(
|
||||||
batch_id=submit_batch(client, requests),
|
batch_id=submit_batch(client, requests),
|
||||||
submitted_at=now_iso())
|
submitted_at=now_iso())
|
||||||
meta["batch"] = info
|
meta["batches"]["arbitration"] = info
|
||||||
print(f"submitted batch {info.batch_id}", file=sys.stderr)
|
print(f"arbitration: submitted batch {info.batch_id}",
|
||||||
|
file=sys.stderr)
|
||||||
batches: dict[str, Any] = poll_batches(client, [info.batch_id])
|
batches: dict[str, Any] = poll_batches(client, [info.batch_id])
|
||||||
info.ended_at = batches[info.batch_id].ended_at.isoformat()
|
info.ended_at = batches[info.batch_id].ended_at.isoformat()
|
||||||
return collect_results(client, info.batch_id)
|
return collect_results(client, info.batch_id)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Run one LLM definition file against one input and archive it.
|
"""Run one LLM step end-to-end.
|
||||||
|
|
||||||
:param argv: The command-line arguments, or None for ``sys.argv``.
|
:param argv: The command-line arguments, or None for ``sys.argv``.
|
||||||
:return: The exit status: 0 on success, non-zero on failure.
|
:return: The exit status: 0 on success, non-zero on failure.
|
||||||
@@ -454,56 +565,89 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
try:
|
try:
|
||||||
items: list[InputItem] = load_items(args.input)
|
items: list[InputItem] = load_items(args.input)
|
||||||
prompt_text: str = args.prompt.read_text(encoding="utf-8")
|
prompt_text: str = args.prompt.read_text(encoding="utf-8")
|
||||||
|
arbitration_text: str = args.arbitration_prompt.read_text(
|
||||||
|
encoding="utf-8")
|
||||||
except (OSError, InputFormatError) as error:
|
except (OSError, InputFormatError) as error:
|
||||||
print(f"error: {error}", file=sys.stderr)
|
print(f"error: {error}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
try:
|
try:
|
||||||
archive_dir: Path = create_archive_dir(
|
run_dir: Path = create_archive_dir(
|
||||||
args.archive_dir, args.replace)
|
args.runs_dir, args.phase, args.prompt,
|
||||||
|
datetime.now())
|
||||||
except FileExistsError as error:
|
except FileExistsError as error:
|
||||||
print(f"error: {error}", file=sys.stderr)
|
print(f"error: {error}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
meta_path: Path = archive_dir / "meta.json"
|
meta_path: Path = run_dir / "meta.json"
|
||||||
(archive_dir / "prompt.md").write_bytes(args.prompt.read_bytes())
|
(run_dir / "prompt.md").write_bytes(args.prompt.read_bytes())
|
||||||
|
(run_dir / "arbitration_prompt.md").write_bytes(
|
||||||
|
args.arbitration_prompt.read_bytes())
|
||||||
meta: dict[str, Any] = {
|
meta: dict[str, Any] = {
|
||||||
"script_version": SCRIPT_VERSION,
|
|
||||||
"model": MODEL,
|
"model": MODEL,
|
||||||
"temperature": TEMPERATURE,
|
"temperature": TEMPERATURE,
|
||||||
"thinking": THINKING,
|
|
||||||
"max_tokens": args.max_tokens,
|
"max_tokens": args.max_tokens,
|
||||||
|
"thinking": THINKING,
|
||||||
"prompt_path": str(args.prompt),
|
"prompt_path": str(args.prompt),
|
||||||
"prompt_sha256": sha256_of(args.prompt),
|
"prompt_sha256": sha256_of(args.prompt),
|
||||||
"input_path": str(args.input),
|
"arbitration_prompt_path": str(args.arbitration_prompt),
|
||||||
"input_sha256": sha256_of(args.input),
|
"arbitration_prompt_sha256": sha256_of(
|
||||||
|
args.arbitration_prompt),
|
||||||
|
"batches": {},
|
||||||
"item_count": len(items),
|
"item_count": len(items),
|
||||||
|
"agreed_count": None,
|
||||||
|
"agreement_rate": None,
|
||||||
"dry_run": args.dry_run,
|
"dry_run": args.dry_run,
|
||||||
"started_at": now_iso(),
|
"script_version": SCRIPT_VERSION,
|
||||||
"batch": None,
|
|
||||||
"usage": {},
|
|
||||||
}
|
}
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
write_json(meta_path, meta)
|
write_meta(meta_path, meta)
|
||||||
print(json.dumps(
|
print(json.dumps(
|
||||||
build_request(items[0], prompt_text, args.max_tokens),
|
build_request(items[0], prompt_text, args.max_tokens),
|
||||||
ensure_ascii=False, indent=2))
|
ensure_ascii=False, indent=2))
|
||||||
print(f"dry run: archive created at {archive_dir}",
|
print(f"dry run: archive created at {run_dir}",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
client: anthropic.Anthropic = anthropic.Anthropic(
|
client: anthropic.Anthropic = anthropic.Anthropic(
|
||||||
api_key=get_settings().ANTHROPIC_API_KEY)
|
api_key=get_settings().ANTHROPIC_API_KEY)
|
||||||
results: Results = execute_run(
|
run1: Results
|
||||||
|
run2: Results
|
||||||
|
run1, run2 = execute_runs(
|
||||||
client, items, prompt_text, args.max_tokens, meta)
|
client, items, prompt_text, args.max_tokens, meta)
|
||||||
item_ids: list[str] = [x.id for x in items]
|
item_ids: list[str] = [x.id for x in items]
|
||||||
write_jsonl(
|
write_jsonl(run_dir / "run1.jsonl",
|
||||||
archive_dir / "output.jsonl",
|
[run1[x].to_record() for x in item_ids if x in run1])
|
||||||
[results[x].to_record() for x in item_ids if x in results])
|
write_jsonl(run_dir / "run2.jsonl",
|
||||||
meta["usage"] = sum_usage(results)
|
[run2[x].to_record() for x in item_ids if x in run2])
|
||||||
write_meta(meta_path, meta)
|
failed: set[str] = (set(find_failures(item_ids, run1))
|
||||||
failed: list[str] = find_failures(item_ids, results)
|
| set(find_failures(item_ids, run2)))
|
||||||
if len(failed) > 0:
|
if len(failed) > 0:
|
||||||
print(f"error: failed items: {', '.join(failed)}",
|
write_meta(meta_path, meta)
|
||||||
|
names: str = ", ".join(x for x in item_ids if x in failed)
|
||||||
|
print(f"error: failed items: {names}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
agreed: list[str]
|
||||||
|
disagreed: list[str]
|
||||||
|
agreed, disagreed = split_by_agreement(items, run1, run2)
|
||||||
|
meta["agreed_count"] = len(agreed)
|
||||||
|
meta["agreement_rate"] = len(agreed) / len(items)
|
||||||
|
arbitration: Results = {}
|
||||||
|
if len(disagreed) > 0:
|
||||||
|
arbitration = execute_arbitration(
|
||||||
|
client, items, disagreed, run1, run2, arbitration_text,
|
||||||
|
args.max_tokens, meta)
|
||||||
|
write_jsonl(run_dir / "arbitration.jsonl",
|
||||||
|
[arbitration[x].to_record() for x in disagreed
|
||||||
|
if x in arbitration])
|
||||||
|
arb_failed: list[str] = find_failures(disagreed, arbitration)
|
||||||
|
if len(arb_failed) > 0:
|
||||||
|
write_meta(meta_path, meta)
|
||||||
|
names = ", ".join(arb_failed)
|
||||||
|
print(f"error: failed arbitration items: {names}",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
print(f"done: {len(items)} items;"
|
write_jsonl(run_dir / "final.jsonl",
|
||||||
f" archived to {archive_dir}", file=sys.stderr)
|
build_final_records(items, run1, arbitration))
|
||||||
|
write_meta(meta_path, meta)
|
||||||
|
print(f"done: {len(items)} items, {len(agreed)} agreed,"
|
||||||
|
f" {len(disagreed)} arbitrated; archived to {run_dir}",
|
||||||
|
file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Tools for A Feminist Audit of Pop Music.
|
|
||||||
# Copyright 2026 imacat. All rights reserved.
|
|
||||||
# Authors:
|
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
|
||||||
"""The shared utilities of the package."""
|
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds: float) -> str:
|
|
||||||
"""Format an elapsed duration for the closing summary line.
|
|
||||||
|
|
||||||
:param seconds: The elapsed duration, in seconds.
|
|
||||||
:return: The duration formatted ``mm:ss``, or ``h:mm:ss``
|
|
||||||
once it reaches one hour.
|
|
||||||
"""
|
|
||||||
total: int = round(seconds)
|
|
||||||
hours: int
|
|
||||||
remainder: int
|
|
||||||
hours, remainder = divmod(total, 3600)
|
|
||||||
minutes: int
|
|
||||||
secs: int
|
|
||||||
minutes, secs = divmod(remainder, 60)
|
|
||||||
if hours > 0:
|
|
||||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
||||||
return f"{minutes:02d}:{secs:02d}"
|
|
||||||
@@ -15,8 +15,7 @@ from unittest import mock
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from pop_fem_audit_tools import config
|
from pop_fem_audit_tools import build_db, config
|
||||||
from pop_fem_audit_tools.commands import build_db
|
|
||||||
from pop_fem_audit_tools.database import DataSource
|
from pop_fem_audit_tools.database import DataSource
|
||||||
from pop_fem_audit_tools.models import (
|
from pop_fem_audit_tools.models import (
|
||||||
Artist,
|
Artist,
|
||||||
@@ -31,14 +30,13 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
|
|
||||||
def test_plain_solo(self) -> None:
|
def test_plain_solo(self) -> None:
|
||||||
"""Test a plain solo artist credit."""
|
"""Test a plain solo artist credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(build_db.parse_artist_credit("Adele"),
|
||||||
build_db.ArtistImporter.parse_artist_credit("Adele"),
|
[("Adele", Role.PRIMARY)])
|
||||||
[("Adele", Role.PRIMARY)])
|
|
||||||
|
|
||||||
def test_featuring_with_and(self) -> None:
|
def test_featuring_with_and(self) -> None:
|
||||||
"""Test a featuring credit with an "and" delimiter."""
|
"""Test a featuring credit with an "and" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Drake featuring Wizkid and Kyla"),
|
"Drake featuring Wizkid and Kyla"),
|
||||||
[("Drake", Role.PRIMARY),
|
[("Drake", Role.PRIMARY),
|
||||||
("Wizkid", Role.FEATURED),
|
("Wizkid", Role.FEATURED),
|
||||||
@@ -47,7 +45,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_comma_and_ampersand(self) -> None:
|
def test_comma_and_ampersand(self) -> None:
|
||||||
"""Test a credit with comma and ampersand delimiters."""
|
"""Test a credit with comma and ampersand delimiters."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Lady Gaga, Bradley Cooper & BloodPop"),
|
"Lady Gaga, Bradley Cooper & BloodPop"),
|
||||||
[("Lady Gaga", Role.PRIMARY),
|
[("Lady Gaga", Role.PRIMARY),
|
||||||
("Bradley Cooper", Role.PRIMARY),
|
("Bradley Cooper", Role.PRIMARY),
|
||||||
@@ -56,23 +54,21 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_x_delimiter(self) -> None:
|
def test_x_delimiter(self) -> None:
|
||||||
"""Test the "x" delimiter."""
|
"""Test the "x" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit("KAROL G x Nicki Minaj"),
|
||||||
"KAROL G x Nicki Minaj"),
|
|
||||||
[("KAROL G", Role.PRIMARY),
|
[("KAROL G", Role.PRIMARY),
|
||||||
("Nicki Minaj", Role.PRIMARY)])
|
("Nicki Minaj", Role.PRIMARY)])
|
||||||
|
|
||||||
def test_plus_delimiter(self) -> None:
|
def test_plus_delimiter(self) -> None:
|
||||||
"""Test the "+" delimiter."""
|
"""Test the "+" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit("Marshmello + Halsey"),
|
||||||
"Marshmello + Halsey"),
|
|
||||||
[("Marshmello", Role.PRIMARY),
|
[("Marshmello", Role.PRIMARY),
|
||||||
("Halsey", Role.PRIMARY)])
|
("Halsey", Role.PRIMARY)])
|
||||||
|
|
||||||
def test_with_delimiter(self) -> None:
|
def test_with_delimiter(self) -> None:
|
||||||
"""Test the "with" delimiter."""
|
"""Test the "with" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Kane Brown with Lauren Alaina"),
|
"Kane Brown with Lauren Alaina"),
|
||||||
[("Kane Brown", Role.PRIMARY),
|
[("Kane Brown", Role.PRIMARY),
|
||||||
("Lauren Alaina", Role.PRIMARY)])
|
("Lauren Alaina", Role.PRIMARY)])
|
||||||
@@ -80,7 +76,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_feat_abbreviation(self) -> None:
|
def test_feat_abbreviation(self) -> None:
|
||||||
"""Test that "Feat." splits the featured side."""
|
"""Test that "Feat." splits the featured side."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Ariana Grande Feat. Doja Cat"
|
"Ariana Grande Feat. Doja Cat"
|
||||||
" & Megan Thee Stallion"),
|
" & Megan Thee Stallion"),
|
||||||
[("Ariana Grande", Role.PRIMARY),
|
[("Ariana Grande", Role.PRIMARY),
|
||||||
@@ -90,7 +86,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_case_insensitive_featuring(self) -> None:
|
def test_case_insensitive_featuring(self) -> None:
|
||||||
"""Test that "Featuring" splits case-insensitively."""
|
"""Test that "Featuring" splits case-insensitively."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"24kGoldn Featuring iann dior"),
|
"24kGoldn Featuring iann dior"),
|
||||||
[("24kGoldn", Role.PRIMARY),
|
[("24kGoldn", Role.PRIMARY),
|
||||||
("iann dior", Role.FEATURED)])
|
("iann dior", Role.FEATURED)])
|
||||||
@@ -98,7 +94,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_group(self) -> None:
|
def test_colon_prefix_group(self) -> None:
|
||||||
"""Test that a colon-prefixed group name is dropped."""
|
"""Test that a colon-prefixed group name is dropped."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"¥$: Ye & Ty Dolla $ign Featuring Rich The Kid"
|
"¥$: Ye & Ty Dolla $ign Featuring Rich The Kid"
|
||||||
" & Playboi Carti"),
|
" & Playboi Carti"),
|
||||||
[("Ye", Role.PRIMARY),
|
[("Ye", Role.PRIMARY),
|
||||||
@@ -109,7 +105,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_with_ampersand_in_prefix(self) -> None:
|
def test_colon_prefix_with_ampersand_in_prefix(self) -> None:
|
||||||
"""Test a colon prefix that itself contains "&"."""
|
"""Test a colon prefix that itself contains "&"."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Rumi & JINU: EJAE & Andrew Choi"),
|
"Rumi & JINU: EJAE & Andrew Choi"),
|
||||||
[("EJAE", Role.PRIMARY),
|
[("EJAE", Role.PRIMARY),
|
||||||
("Andrew Choi", Role.PRIMARY)])
|
("Andrew Choi", Role.PRIMARY)])
|
||||||
@@ -117,7 +113,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_comma_list(self) -> None:
|
def test_colon_prefix_comma_list(self) -> None:
|
||||||
"""Test a colon-prefixed comma-separated member list."""
|
"""Test a colon-prefixed comma-separated member list."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"HUNTR/X: EJAE, Audrey Nuna & REI AMI"),
|
"HUNTR/X: EJAE, Audrey Nuna & REI AMI"),
|
||||||
[("EJAE", Role.PRIMARY),
|
[("EJAE", Role.PRIMARY),
|
||||||
("Audrey Nuna", Role.PRIMARY),
|
("Audrey Nuna", Role.PRIMARY),
|
||||||
@@ -126,7 +122,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_five_members(self) -> None:
|
def test_colon_prefix_five_members(self) -> None:
|
||||||
"""Test a colon-prefixed five-member list."""
|
"""Test a colon-prefixed five-member list."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Saja Boys: Andrew Choi, Neckwav, Danny Chung,"
|
"Saja Boys: Andrew Choi, Neckwav, Danny Chung,"
|
||||||
" Kevin Woo & samUIL Lee"),
|
" Kevin Woo & samUIL Lee"),
|
||||||
[("Andrew Choi", Role.PRIMARY),
|
[("Andrew Choi", Role.PRIMARY),
|
||||||
@@ -138,7 +134,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_duo(self) -> None:
|
def test_colon_prefix_duo(self) -> None:
|
||||||
"""Test a colon-prefixed two-member list."""
|
"""Test a colon-prefixed two-member list."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"THE ANXIETY: WILLOW & Tyler Cole"),
|
"THE ANXIETY: WILLOW & Tyler Cole"),
|
||||||
[("WILLOW", Role.PRIMARY),
|
[("WILLOW", Role.PRIMARY),
|
||||||
("Tyler Cole", Role.PRIMARY)])
|
("Tyler Cole", Role.PRIMARY)])
|
||||||
@@ -147,7 +143,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that a parenthesized member list replaces the
|
"""Test that a parenthesized member list replaces the
|
||||||
group name spanning the whole credit."""
|
group name spanning the whole credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Silk Sonic (Bruno Mars & Anderson .Paak)"),
|
"Silk Sonic (Bruno Mars & Anderson .Paak)"),
|
||||||
[("Bruno Mars", Role.PRIMARY),
|
[("Bruno Mars", Role.PRIMARY),
|
||||||
("Anderson .Paak", Role.PRIMARY)])
|
("Anderson .Paak", Role.PRIMARY)])
|
||||||
@@ -156,12 +152,12 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Duet With" is a co-billing connector like
|
"""Test that "Duet With" is a co-billing connector like
|
||||||
"with", dropping the word "Duet" entirely."""
|
"with", dropping the word "Duet" entirely."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Blake Shelton Duet With Gwen Stefani"),
|
"Blake Shelton Duet With Gwen Stefani"),
|
||||||
[("Blake Shelton", Role.PRIMARY),
|
[("Blake Shelton", Role.PRIMARY),
|
||||||
("Gwen Stefani", Role.PRIMARY)])
|
("Gwen Stefani", Role.PRIMARY)])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Keith Urban Duet With P!nk"),
|
"Keith Urban Duet With P!nk"),
|
||||||
[("Keith Urban", Role.PRIMARY),
|
[("Keith Urban", Role.PRIMARY),
|
||||||
("P!nk", Role.PRIMARY)])
|
("P!nk", Role.PRIMARY)])
|
||||||
@@ -169,13 +165,12 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_slash_delimiter(self) -> None:
|
def test_slash_delimiter(self) -> None:
|
||||||
"""Test the " / " co-billing delimiter."""
|
"""Test the " / " co-billing delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Cole Swindell / Lainey Wilson"),
|
"Cole Swindell / Lainey Wilson"),
|
||||||
[("Cole Swindell", Role.PRIMARY),
|
[("Cole Swindell", Role.PRIMARY),
|
||||||
("Lainey Wilson", Role.PRIMARY)])
|
("Lainey Wilson", Role.PRIMARY)])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit("Zayn / Taylor Swift"),
|
||||||
"Zayn / Taylor Swift"),
|
|
||||||
[("Zayn", Role.PRIMARY),
|
[("Zayn", Role.PRIMARY),
|
||||||
("Taylor Swift", Role.PRIMARY)])
|
("Taylor Swift", Role.PRIMARY)])
|
||||||
|
|
||||||
@@ -183,8 +178,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Lil Nas X" is guarded from the " x "
|
"""Test that "Lil Nas X" is guarded from the " x "
|
||||||
delimiter split."""
|
delimiter split."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit("Lil Nas X & Jack Harlow"),
|
||||||
"Lil Nas X & Jack Harlow"),
|
|
||||||
[("Lil Nas X", Role.PRIMARY),
|
[("Lil Nas X", Role.PRIMARY),
|
||||||
("Jack Harlow", Role.PRIMARY)])
|
("Jack Harlow", Role.PRIMARY)])
|
||||||
|
|
||||||
@@ -192,7 +186,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Tyler, The Creator" is guarded from the
|
"""Test that "Tyler, The Creator" is guarded from the
|
||||||
comma delimiter split."""
|
comma delimiter split."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Tyler, The Creator Featuring GloRilla, Sexyy Red"
|
"Tyler, The Creator Featuring GloRilla, Sexyy Red"
|
||||||
" & Lil Wayne"),
|
" & Lil Wayne"),
|
||||||
[("Tyler, The Creator", Role.PRIMARY),
|
[("Tyler, The Creator", Role.PRIMARY),
|
||||||
@@ -204,14 +198,13 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Tones And I" is guarded from the " and "
|
"""Test that "Tones And I" is guarded from the " and "
|
||||||
delimiter split."""
|
delimiter split."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit("Tones And I"),
|
||||||
"Tones And I"),
|
|
||||||
[("Tones And I", Role.PRIMARY)])
|
[("Tones And I", Role.PRIMARY)])
|
||||||
|
|
||||||
def test_exception_spotemgottem(self) -> None:
|
def test_exception_spotemgottem(self) -> None:
|
||||||
"""Test the SpotemGottem exception-table credit."""
|
"""Test the SpotemGottem exception-table credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"SpotemGottem Featuring Pooh Shiesty Or DaBaby"),
|
"SpotemGottem Featuring Pooh Shiesty Or DaBaby"),
|
||||||
[("SpotemGottem", Role.PRIMARY),
|
[("SpotemGottem", Role.PRIMARY),
|
||||||
("Pooh Shiesty", Role.FEATURED),
|
("Pooh Shiesty", Role.FEATURED),
|
||||||
@@ -220,7 +213,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_exception_the_scotts(self) -> None:
|
def test_exception_the_scotts(self) -> None:
|
||||||
"""Test the THE SCOTTS exception-table credit."""
|
"""Test the THE SCOTTS exception-table credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"THE SCOTTS, Travis Scott & Kid Cudi"),
|
"THE SCOTTS, Travis Scott & Kid Cudi"),
|
||||||
[("Travis Scott", Role.PRIMARY),
|
[("Travis Scott", Role.PRIMARY),
|
||||||
("Kid Cudi", Role.PRIMARY)])
|
("Kid Cudi", Role.PRIMARY)])
|
||||||
@@ -229,7 +222,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test the Drake Featuring The Throne exception-table
|
"""Test the Drake Featuring The Throne exception-table
|
||||||
credit."""
|
credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit(
|
||||||
"Drake Featuring The Throne"),
|
"Drake Featuring The Throne"),
|
||||||
[("Drake", Role.PRIMARY),
|
[("Drake", Role.PRIMARY),
|
||||||
("Jay Z", Role.FEATURED),
|
("Jay Z", Role.FEATURED),
|
||||||
@@ -239,8 +232,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that the existing "+" delimiter split is
|
"""Test that the existing "+" delimiter split is
|
||||||
unaffected by the new rules."""
|
unaffected by the new rules."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.ArtistImporter.parse_artist_credit(
|
build_db.parse_artist_credit("Dan + Shay"),
|
||||||
"Dan + Shay"),
|
|
||||||
[("Dan", Role.PRIMARY), ("Shay", Role.PRIMARY)])
|
[("Dan", Role.PRIMARY), ("Shay", Role.PRIMARY)])
|
||||||
|
|
||||||
|
|
||||||
@@ -274,10 +266,8 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
self.__ds: DataSource = DataSource()
|
self.__ds: DataSource = DataSource()
|
||||||
patchers: list[Any] = [
|
patchers: list[Any] = [
|
||||||
mock.patch.object(build_db, "ds", self.__ds),
|
mock.patch.object(build_db, "ds", self.__ds),
|
||||||
mock.patch.object(
|
mock.patch.object(build_db, "YEARS", [2016, 2017]),
|
||||||
build_db.SongImporter, "YEARS", [2016, 2017]),
|
mock.patch.object(build_db, "RANKS_PER_YEAR", 2)]
|
||||||
mock.patch.object(
|
|
||||||
build_db.SongImporter, "RANKS_PER_YEAR", 2)]
|
|
||||||
for patcher in patchers:
|
for patcher in patchers:
|
||||||
patcher.start()
|
patcher.start()
|
||||||
self.addCleanup(patcher.stop)
|
self.addCleanup(patcher.stop)
|
||||||
@@ -581,53 +571,6 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
list(session.scalars(sa.select(ChartEntry))), [])
|
list(session.scalars(sa.select(ChartEntry))), [])
|
||||||
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||||
|
|
||||||
def test_duplicate_chart_entry_fails(self) -> None:
|
|
||||||
"""Test that a duplicated (year, rank) row fails without
|
|
||||||
partial data, even though the set of distinct (year, rank)
|
|
||||||
pairs still covers the expected grid exactly."""
|
|
||||||
self.__write_chart(
|
|
||||||
"year,rank,title,artist\n"
|
|
||||||
"2016,1,Hello,Adele\n"
|
|
||||||
"2016,1,Hello,Adele\n"
|
|
||||||
"2016,2,One Dance,Drake featuring Wizkid\n"
|
|
||||||
"2017,1,One Dance,Drake featuring Wizkid\n"
|
|
||||||
"2017,2,Shape of You,Ed Sheeran\n")
|
|
||||||
status: int
|
|
||||||
stderr: str
|
|
||||||
status, stderr = self.__run_build()
|
|
||||||
self.assertNotEqual(status, 0)
|
|
||||||
self.assertIn(
|
|
||||||
"duplicated chart entry: year 2016 rank 1", stderr)
|
|
||||||
session: Session = self.__session()
|
|
||||||
self.assertEqual(
|
|
||||||
list(session.scalars(sa.select(ChartEntry))), [])
|
|
||||||
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
|
||||||
|
|
||||||
def test_blank_artist_name_fails(self) -> None:
|
|
||||||
"""Test that a parsed credit with a name blank after
|
|
||||||
stripping fails the build."""
|
|
||||||
with mock.patch.object(
|
|
||||||
build_db.ArtistImporter, "parse_artist_credit",
|
|
||||||
return_value=[("Adele", Role.PRIMARY),
|
|
||||||
(" ", Role.FEATURED)]):
|
|
||||||
status: int
|
|
||||||
stderr: str
|
|
||||||
status, stderr = self.__run_build()
|
|
||||||
self.assertNotEqual(status, 0)
|
|
||||||
self.assertIn("blank artist name parsed", stderr)
|
|
||||||
|
|
||||||
def test_no_primary_artist_fails(self) -> None:
|
|
||||||
"""Test that a parsed credit without a primary artist
|
|
||||||
fails the build."""
|
|
||||||
with mock.patch.object(
|
|
||||||
build_db.ArtistImporter, "parse_artist_credit",
|
|
||||||
return_value=[("Wizkid", Role.FEATURED)]):
|
|
||||||
status: int
|
|
||||||
stderr: str
|
|
||||||
status, stderr = self.__run_build()
|
|
||||||
self.assertNotEqual(status, 0)
|
|
||||||
self.assertIn("no primary artist parsed", stderr)
|
|
||||||
|
|
||||||
def test_lyrics_loaded(self) -> None:
|
def test_lyrics_loaded(self) -> None:
|
||||||
"""Test loading the lyrics cache into the songs."""
|
"""Test loading the lyrics cache into the songs."""
|
||||||
self.__lyrics.mkdir()
|
self.__lyrics.mkdir()
|
||||||
@@ -788,14 +731,11 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
"id", self.__read_csv_header("artists.csv"))
|
"id", self.__read_csv_header("artists.csv"))
|
||||||
|
|
||||||
def test_derived_csvs_have_crlf_line_endings(self) -> None:
|
def test_derived_csvs_have_lf_line_endings(self) -> None:
|
||||||
"""Test that the derived CSV files use CRLF line
|
"""Test that the derived CSV files use LF line endings."""
|
||||||
endings."""
|
|
||||||
self.assertEqual(self.__run_build()[0], 0)
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
raw: bytes = (self.__derived / "songs.csv").read_bytes()
|
raw: bytes = (self.__derived / "songs.csv").read_bytes()
|
||||||
self.assertGreater(raw.count(b"\r\n"), 0)
|
self.assertNotIn(b"\r", raw)
|
||||||
self.assertEqual(raw.count(b"\r"), raw.count(b"\r\n"))
|
|
||||||
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n"))
|
|
||||||
|
|
||||||
def test_failed_build_leaves_derived_csvs_untouched(self) -> None:
|
def test_failed_build_leaves_derived_csvs_untouched(self) -> None:
|
||||||
"""Test that a failed build does not touch the existing
|
"""Test that a failed build does not touch the existing
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
# Tools for A Feminist Audit of Pop Music.
|
|
||||||
# Copyright 2026 imacat. All rights reserved.
|
|
||||||
# Authors:
|
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
|
||||||
"""Unit tests for the LLM input exporter module."""
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from contextlib import redirect_stderr
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from pop_fem_audit_tools import config
|
|
||||||
from pop_fem_audit_tools.commands import export_llm_input
|
|
||||||
from pop_fem_audit_tools.database import Base, DataSource
|
|
||||||
from pop_fem_audit_tools.models import (
|
|
||||||
Artist,
|
|
||||||
Role,
|
|
||||||
Song,
|
|
||||||
SongArtist,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestExportLlmInput(unittest.TestCase):
|
|
||||||
"""Test cases for the LLM input exporter."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
"""Create a temporary working store for the tests."""
|
|
||||||
tmp: tempfile.TemporaryDirectory[str] \
|
|
||||||
= tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(tmp.cleanup)
|
|
||||||
self.__dir: Path = Path(tmp.name)
|
|
||||||
self.__output: Path = self.__dir / "llm-input.jsonl"
|
|
||||||
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
|
||||||
config.set_settings(config.Settings(
|
|
||||||
SQLALCHEMY_DATABASE_URL=url,
|
|
||||||
ANTHROPIC_API_KEY="test-key"))
|
|
||||||
self.__ds: DataSource = DataSource()
|
|
||||||
patcher: Any = mock.patch.object(
|
|
||||||
export_llm_input, "ds", self.__ds)
|
|
||||||
patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
def __seed(
|
|
||||||
self, songs: list[tuple[str, str, str | None]]) -> None:
|
|
||||||
"""Create the schema and the fixture songs.
|
|
||||||
|
|
||||||
Each song gets a single primary artist at position 0,
|
|
||||||
whose name equals the song's artist credit; the song IDs
|
|
||||||
are assigned in list order starting from 1.
|
|
||||||
|
|
||||||
:param songs: The (title, artist, lyrics) triples.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
Base.metadata.create_all(self.__ds.engine)
|
|
||||||
session: Session = self.__ds.get_db()
|
|
||||||
try:
|
|
||||||
title: str
|
|
||||||
artist: str
|
|
||||||
lyrics: str | None
|
|
||||||
for title, artist, lyrics in songs:
|
|
||||||
artist_row: Artist = Artist(name=artist)
|
|
||||||
song: Song = Song(
|
|
||||||
title=title, artist_credit=artist,
|
|
||||||
lyrics=lyrics)
|
|
||||||
session.add(song)
|
|
||||||
session.add(SongArtist(
|
|
||||||
song=song, artist=artist_row,
|
|
||||||
role=Role.PRIMARY, position=0))
|
|
||||||
session.commit()
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def __run_export(self) -> tuple[int, str]:
|
|
||||||
"""Run the exporter with the standard error captured.
|
|
||||||
|
|
||||||
:return: A tuple of the exit status and the standard
|
|
||||||
error.
|
|
||||||
"""
|
|
||||||
stderr: io.StringIO = io.StringIO()
|
|
||||||
with redirect_stderr(stderr):
|
|
||||||
status: int = export_llm_input.main(
|
|
||||||
[str(self.__output)])
|
|
||||||
return status, stderr.getvalue()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __read_records(path: Path) -> list[dict[str, str]]:
|
|
||||||
"""Read the JSONL records of a file.
|
|
||||||
|
|
||||||
:param path: The JSONL file.
|
|
||||||
:return: The parsed records, in file order.
|
|
||||||
"""
|
|
||||||
with open(path, encoding="utf-8") as file:
|
|
||||||
return [json.loads(line) for line in file
|
|
||||||
if line.strip() != ""]
|
|
||||||
|
|
||||||
def test_exports_songs_ordered_by_id(self) -> None:
|
|
||||||
"""Test that the songs export in ID order, one per line."""
|
|
||||||
self.__seed([
|
|
||||||
("Hello", "Adele", "hello lyrics\n"),
|
|
||||||
("Umbrella", "Rihanna", "umbrella lyrics\n")])
|
|
||||||
status: int
|
|
||||||
stderr: str
|
|
||||||
status, stderr = self.__run_export()
|
|
||||||
self.assertEqual(status, 0)
|
|
||||||
records: list[dict[str, str]] = self.__read_records(
|
|
||||||
self.__output)
|
|
||||||
self.assertEqual(records, [
|
|
||||||
{"id": "song-1", "content": "hello lyrics\n"},
|
|
||||||
{"id": "song-2", "content": "umbrella lyrics\n"}])
|
|
||||||
self.assertIn("done: 2 songs exported", stderr)
|
|
||||||
|
|
||||||
def test_preserves_non_ascii_lyrics(self) -> None:
|
|
||||||
"""Test that non-ASCII lyrics are written verbatim."""
|
|
||||||
self.__seed([("Song", "Artist", "非英文歌詞\n")])
|
|
||||||
self.assertEqual(self.__run_export()[0], 0)
|
|
||||||
self.assertEqual(
|
|
||||||
self.__output.read_text(encoding="utf-8"),
|
|
||||||
json.dumps(
|
|
||||||
{"id": "song-1", "content": "非英文歌詞\n"},
|
|
||||||
ensure_ascii=False) + "\n")
|
|
||||||
self.assertIn("非英文歌詞", self.__output.read_text(
|
|
||||||
encoding="utf-8"))
|
|
||||||
|
|
||||||
def test_no_artist_or_title_data_in_output(self) -> None:
|
|
||||||
"""Test the lyrics-only firewall: no title or artist
|
|
||||||
leaks into the output."""
|
|
||||||
self.__seed([(
|
|
||||||
"Confidential Title", "Confidential Artist",
|
|
||||||
"plain lyrics\n")])
|
|
||||||
self.assertEqual(self.__run_export()[0], 0)
|
|
||||||
content: str = self.__output.read_text(encoding="utf-8")
|
|
||||||
self.assertNotIn("Confidential Title", content)
|
|
||||||
self.assertNotIn("Confidential Artist", content)
|
|
||||||
records: list[dict[str, str]] = self.__read_records(
|
|
||||||
self.__output)
|
|
||||||
self.assertEqual(records, [
|
|
||||||
{"id": "song-1", "content": "plain lyrics\n"}])
|
|
||||||
|
|
||||||
def test_missing_lyrics_fails(self) -> None:
|
|
||||||
"""Test that a song without lyrics fails without writing
|
|
||||||
a partial file."""
|
|
||||||
self.__seed([
|
|
||||||
("Hello", "Adele", "hello lyrics\n"),
|
|
||||||
("Silent", "Nobody", None)])
|
|
||||||
status: int
|
|
||||||
stderr: str
|
|
||||||
status, stderr = self.__run_export()
|
|
||||||
self.assertEqual(status, 1)
|
|
||||||
self.assertIn(
|
|
||||||
"error: song 2 \"Silent\": no lyrics", stderr)
|
|
||||||
self.assertFalse(self.__output.exists())
|
|
||||||
|
|
||||||
def test_creates_parent_directory(self) -> None:
|
|
||||||
"""Test that the output file's parent directory is
|
|
||||||
created when missing."""
|
|
||||||
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
|
||||||
nested: Path = self.__dir / "nested" / "dir" / "out.jsonl"
|
|
||||||
status: int = export_llm_input.main([str(nested)])
|
|
||||||
self.assertEqual(status, 0)
|
|
||||||
self.assertTrue(nested.exists())
|
|
||||||
records: list[dict[str, str]] = self.__read_records(
|
|
||||||
nested)
|
|
||||||
self.assertEqual(records, [
|
|
||||||
{"id": "song-1", "content": "hello lyrics\n"}])
|
|
||||||
@@ -16,8 +16,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from pop_fem_audit_tools import config
|
from pop_fem_audit_tools import config, fetch_artists
|
||||||
from pop_fem_audit_tools.commands import fetch_artists
|
|
||||||
from pop_fem_audit_tools.database import Base, DataSource
|
from pop_fem_audit_tools.database import Base, DataSource
|
||||||
from pop_fem_audit_tools.models import Artist, Role, Song, SongArtist
|
from pop_fem_audit_tools.models import Artist, Role, Song, SongArtist
|
||||||
|
|
||||||
@@ -588,6 +587,17 @@ class TestFetchArtists(unittest.TestCase):
|
|||||||
urlopen.assert_not_called()
|
urlopen.assert_not_called()
|
||||||
self.assertIn("error:", stderr)
|
self.assertIn("error:", stderr)
|
||||||
|
|
||||||
|
def test_format_duration_under_hour(self) -> None:
|
||||||
|
"""Test the mm:ss format for a duration under one hour."""
|
||||||
|
self.assertEqual(
|
||||||
|
fetch_artists.format_duration(205), "03:25")
|
||||||
|
|
||||||
|
def test_format_duration_over_hour(self) -> None:
|
||||||
|
"""Test the h:mm:ss format once the duration reaches an
|
||||||
|
hour."""
|
||||||
|
self.assertEqual(
|
||||||
|
fetch_artists.format_duration(6439), "1:47:19")
|
||||||
|
|
||||||
def test_summary_line_exact_shape(self) -> None:
|
def test_summary_line_exact_shape(self) -> None:
|
||||||
"""Test the exact wording and timing of the summary
|
"""Test the exact wording and timing of the summary
|
||||||
line."""
|
line."""
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from pop_fem_audit_tools import config
|
from pop_fem_audit_tools import config, fetch_lyrics
|
||||||
from pop_fem_audit_tools.commands import fetch_lyrics
|
|
||||||
from pop_fem_audit_tools.database import Base, DataSource
|
from pop_fem_audit_tools.database import Base, DataSource
|
||||||
from pop_fem_audit_tools.models import (
|
from pop_fem_audit_tools.models import (
|
||||||
Artist,
|
Artist,
|
||||||
@@ -33,6 +32,9 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
PROVENANCE_HEADER: list[str] = [
|
PROVENANCE_HEADER: list[str] = [
|
||||||
"song_id", "source", "method", "acquired_at", "note"]
|
"song_id", "source", "method", "acquired_at", "note"]
|
||||||
"""The expected header row of the provenance CSV file."""
|
"""The expected header row of the provenance CSV file."""
|
||||||
|
MISSING_HEADER: list[str] = [
|
||||||
|
"song_id", "title", "artist_credit", "reason"]
|
||||||
|
"""The expected header row of the missing report CSV file."""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
"""Create a temporary capture directory with the store."""
|
"""Create a temporary capture directory with the store."""
|
||||||
@@ -42,7 +44,8 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
self.__dir: Path = Path(tmp.name)
|
self.__dir: Path = Path(tmp.name)
|
||||||
self.__lyrics: Path = self.__dir / "lyrics"
|
self.__lyrics: Path = self.__dir / "lyrics"
|
||||||
self.__provenance: Path = \
|
self.__provenance: Path = \
|
||||||
self.__dir / "lyrics-provenance.csv"
|
self.__dir / "lyrics_provenance.csv"
|
||||||
|
self.__missing: Path = self.__dir / "lyrics_missing.csv"
|
||||||
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
||||||
config.set_settings(config.Settings(
|
config.set_settings(config.Settings(
|
||||||
SQLALCHEMY_DATABASE_URL=url,
|
SQLALCHEMY_DATABASE_URL=url,
|
||||||
@@ -64,32 +67,17 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
:param songs: The (title, artist) pairs.
|
:param songs: The (title, artist) pairs.
|
||||||
:return: None.
|
: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)
|
Base.metadata.create_all(self.__ds.engine)
|
||||||
session: Session = self.__ds.get_db()
|
session: Session = self.__ds.get_db()
|
||||||
try:
|
try:
|
||||||
artists: dict[str, Artist] = {}
|
artists: dict[str, Artist] = {}
|
||||||
title: str
|
title: str
|
||||||
artist: str
|
artist: str
|
||||||
credit: str
|
for title, artist in songs:
|
||||||
for title, artist, credit in songs:
|
|
||||||
if artist not in artists:
|
if artist not in artists:
|
||||||
artists[artist] = Artist(name=artist)
|
artists[artist] = Artist(name=artist)
|
||||||
song: Song = Song(title=title,
|
song: Song = Song(title=title,
|
||||||
artist_credit=credit)
|
artist_credit=artist)
|
||||||
session.add(song)
|
session.add(song)
|
||||||
session.add(SongArtist(song=song,
|
session.add(SongArtist(song=song,
|
||||||
artist=artists[artist],
|
artist=artists[artist],
|
||||||
@@ -130,7 +118,8 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
stderr: io.StringIO = io.StringIO()
|
stderr: io.StringIO = io.StringIO()
|
||||||
with redirect_stderr(stderr):
|
with redirect_stderr(stderr):
|
||||||
status: int = fetch_lyrics.main(
|
status: int = fetch_lyrics.main(
|
||||||
[str(self.__lyrics), str(self.__provenance)])
|
[str(self.__lyrics), str(self.__provenance),
|
||||||
|
str(self.__missing)])
|
||||||
return status, stderr.getvalue()
|
return status, stderr.getvalue()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -171,8 +160,7 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
["1", "lyrics.ovh", "api-fetch"])
|
["1", "lyrics.ovh", "api-fetch"])
|
||||||
self.assertNotEqual(rows[1][3], "")
|
self.assertNotEqual(rows[1][3], "")
|
||||||
self.assertEqual(rows[1][4], "")
|
self.assertEqual(rows[1][4], "")
|
||||||
self.assertIn(
|
self.assertIn("1 fetched, 0 missed", stderr)
|
||||||
"Done. Fetched lyrics for 1/1 songs.", stderr)
|
|
||||||
|
|
||||||
def test_lrclib_fallback(self) -> None:
|
def test_lrclib_fallback(self) -> None:
|
||||||
"""Test that an ovh miss falls back to an LRCLIB hit."""
|
"""Test that an ovh miss falls back to an LRCLIB hit."""
|
||||||
@@ -195,46 +183,6 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
self.__provenance)
|
self.__provenance)
|
||||||
self.assertEqual(rows[1][:2], ["1", "lrclib"])
|
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:
|
def test_both_miss(self) -> None:
|
||||||
"""Test that a double miss reports the song as missing."""
|
"""Test that a double miss reports the song as missing."""
|
||||||
self.__seed([("Hello", "Adele")])
|
self.__seed([("Hello", "Adele")])
|
||||||
@@ -248,9 +196,11 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertFalse((self.__lyrics / "1.txt").exists())
|
self.assertFalse((self.__lyrics / "1.txt").exists())
|
||||||
self.assertFalse(self.__provenance.exists())
|
self.assertFalse(self.__provenance.exists())
|
||||||
self.assertIn("song 1 \"Hello\": miss", stderr)
|
rows: list[list[str]] = self.__read_rows(self.__missing)
|
||||||
self.assertIn(
|
self.assertEqual(len(rows), 2)
|
||||||
"Done. Fetched lyrics for 0/1 songs.", stderr)
|
self.assertEqual(rows[0], self.MISSING_HEADER)
|
||||||
|
self.assertEqual(rows[1][:3], ["1", "Hello", "Adele"])
|
||||||
|
self.assertIn("0 fetched, 1 missed", stderr)
|
||||||
|
|
||||||
def test_cached_song_skipped(self) -> None:
|
def test_cached_song_skipped(self) -> None:
|
||||||
"""Test that a cached song triggers no HTTP request."""
|
"""Test that a cached song triggers no HTTP request."""
|
||||||
@@ -267,6 +217,8 @@ class TestFetchLyrics(unittest.TestCase):
|
|||||||
(self.__lyrics / "1.txt")
|
(self.__lyrics / "1.txt")
|
||||||
.read_text(encoding="utf-8"),
|
.read_text(encoding="utf-8"),
|
||||||
"cached\n")
|
"cached\n")
|
||||||
|
rows: list[list[str]] = self.__read_rows(self.__missing)
|
||||||
|
self.assertEqual(rows, [self.MISSING_HEADER])
|
||||||
|
|
||||||
def test_url_encoding(self) -> None:
|
def test_url_encoding(self) -> None:
|
||||||
"""Test the percent-encoding of the artist and title."""
|
"""Test the percent-encoding of the artist and title."""
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import unittest
|
|||||||
from contextlib import redirect_stderr, redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from pop_fem_audit_tools import __main__
|
from pop_fem_audit_tools import __main__, run_llm
|
||||||
from pop_fem_audit_tools.commands import run_llm
|
|
||||||
|
|
||||||
|
|
||||||
class TestDispatcher(unittest.TestCase):
|
class TestDispatcher(unittest.TestCase):
|
||||||
|
|||||||
+188
-134
@@ -3,7 +3,7 @@
|
|||||||
# Authors:
|
# Authors:
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
||||||
# AI assistance: Claude Code (Anthropic)
|
# AI assistance: Claude Code (Anthropic)
|
||||||
"""Unit tests for the run_llm batch executor module."""
|
"""Unit tests for the run_llm batch runner module."""
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -14,8 +14,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from pop_fem_audit_tools import config
|
from pop_fem_audit_tools import config, run_llm
|
||||||
from pop_fem_audit_tools.commands import run_llm
|
|
||||||
|
|
||||||
|
|
||||||
class RunLLMTestCase(unittest.TestCase):
|
class RunLLMTestCase(unittest.TestCase):
|
||||||
@@ -180,6 +179,62 @@ class TestRequestBuilding(RunLLMTestCase):
|
|||||||
self.assertEqual(params["messages"],
|
self.assertEqual(params["messages"],
|
||||||
[{"role": "user", "content": "the lyrics"}])
|
[{"role": "user", "content": "the lyrics"}])
|
||||||
|
|
||||||
|
def test_arbitration_content(self) -> None:
|
||||||
|
"""Test the arbitration user message format."""
|
||||||
|
content: str = run_llm.build_arbitration_content(
|
||||||
|
"the item", "output one", "output two")
|
||||||
|
self.assertEqual(content,
|
||||||
|
"<item>\nthe item\n</item>\n"
|
||||||
|
"<run1>\noutput one\n</run1>\n"
|
||||||
|
"<run2>\noutput two\n</run2>")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgreement(RunLLMTestCase):
|
||||||
|
"""Test cases for the agreement computation."""
|
||||||
|
|
||||||
|
def test_split_by_agreement(self) -> None:
|
||||||
|
"""Test splitting items into agreed and disagreeing ones."""
|
||||||
|
items: list[run_llm.InputItem] = [
|
||||||
|
run_llm.InputItem(id="a", content="one"),
|
||||||
|
run_llm.InputItem(id="b", content="two"),
|
||||||
|
run_llm.InputItem(id="c", content="three")]
|
||||||
|
run1: run_llm.Results = {
|
||||||
|
"a": run_llm.BatchResult(id="a", text="same\n"),
|
||||||
|
"b": run_llm.BatchResult(id="b", text="left"),
|
||||||
|
"c": run_llm.BatchResult(id="c", text=" padded ")}
|
||||||
|
run2: run_llm.Results = {
|
||||||
|
"a": run_llm.BatchResult(id="a", text="same"),
|
||||||
|
"b": run_llm.BatchResult(id="b", text="right"),
|
||||||
|
"c": run_llm.BatchResult(id="c", text="padded")}
|
||||||
|
agreed: list[str]
|
||||||
|
disagreed: list[str]
|
||||||
|
agreed, disagreed = run_llm.split_by_agreement(
|
||||||
|
items, run1, run2)
|
||||||
|
self.assertEqual(agreed, ["a", "c"])
|
||||||
|
self.assertEqual(disagreed, ["b"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinalRecords(RunLLMTestCase):
|
||||||
|
"""Test cases for the final record assembly."""
|
||||||
|
|
||||||
|
def test_build_final_records(self) -> None:
|
||||||
|
"""Test assembling final records from runs and arbitration."""
|
||||||
|
items: list[run_llm.InputItem] = [
|
||||||
|
run_llm.InputItem(id="a", content="one"),
|
||||||
|
run_llm.InputItem(id="b", content="two")]
|
||||||
|
run1: run_llm.Results = {
|
||||||
|
"a": run_llm.BatchResult(id="a", text="agreed text\n"),
|
||||||
|
"b": run_llm.BatchResult(id="b", text="left")}
|
||||||
|
arbitration: run_llm.Results = {
|
||||||
|
"b": run_llm.BatchResult(id="b",
|
||||||
|
text="arbitrated text")}
|
||||||
|
records: list[dict[str, str]] = run_llm.build_final_records(
|
||||||
|
items, run1, arbitration)
|
||||||
|
self.assertEqual(records, [
|
||||||
|
{"id": "a", "text": "agreed text", "source": "agreed"},
|
||||||
|
{"id": "b", "text": "arbitrated text",
|
||||||
|
"source": "arbitration"}])
|
||||||
|
|
||||||
|
|
||||||
class TestCollectResults(RunLLMTestCase):
|
class TestCollectResults(RunLLMTestCase):
|
||||||
"""Test cases for the batch result collection."""
|
"""Test cases for the batch result collection."""
|
||||||
@@ -210,20 +265,6 @@ class TestCollectResults(RunLLMTestCase):
|
|||||||
run_llm.find_failures(["a", "b", "c"], results),
|
run_llm.find_failures(["a", "b", "c"], results),
|
||||||
["b", "c"])
|
["b", "c"])
|
||||||
|
|
||||||
def test_sum_usage(self) -> None:
|
|
||||||
"""Test summing the token usage across results."""
|
|
||||||
results: run_llm.Results = {
|
|
||||||
"a": run_llm.BatchResult(
|
|
||||||
id="a", text="fine",
|
|
||||||
usage={"input_tokens": 10, "output_tokens": 5}),
|
|
||||||
"b": run_llm.BatchResult(
|
|
||||||
id="b", text="fine",
|
|
||||||
usage={"input_tokens": 3, "output_tokens": 2}),
|
|
||||||
"c": run_llm.BatchResult(id="c", error="errored")}
|
|
||||||
self.assertEqual(
|
|
||||||
run_llm.sum_usage(results),
|
|
||||||
{"input_tokens": 13, "output_tokens": 7})
|
|
||||||
|
|
||||||
|
|
||||||
class TestArchive(RunLLMTestCase):
|
class TestArchive(RunLLMTestCase):
|
||||||
"""Test cases for the archive directory handling."""
|
"""Test cases for the archive directory handling."""
|
||||||
@@ -233,43 +274,23 @@ class TestArchive(RunLLMTestCase):
|
|||||||
self.__dir: Path = self._make_temp_dir()
|
self.__dir: Path = self._make_temp_dir()
|
||||||
|
|
||||||
def test_create_archive_dir(self) -> None:
|
def test_create_archive_dir(self) -> None:
|
||||||
"""Test the archive directory creation."""
|
"""Test the archive directory naming and creation."""
|
||||||
target: Path = self.__dir / "01-01-tag" / "run1"
|
now: datetime = datetime(2026, 7, 30, 20, 5)
|
||||||
directory: Path = run_llm.create_archive_dir(target, False)
|
directory: Path = run_llm.create_archive_dir(
|
||||||
|
self.__dir, "coding", Path("prompts/gender_v3.md"), now)
|
||||||
self.assertTrue(directory.is_dir())
|
self.assertTrue(directory.is_dir())
|
||||||
self.assertEqual(directory, target)
|
|
||||||
|
|
||||||
def test_existing_archive_dir_rejected_without_replace(
|
|
||||||
self) -> None:
|
|
||||||
"""Test that an existing archive is rejected by default."""
|
|
||||||
target: Path = self.__dir / "01-01-tag" / "run1"
|
|
||||||
run_llm.create_archive_dir(target, False)
|
|
||||||
with self.assertRaises(FileExistsError):
|
|
||||||
run_llm.create_archive_dir(target, False)
|
|
||||||
|
|
||||||
def test_existing_archive_dir_replaced(self) -> None:
|
|
||||||
"""Test that --replace replaces an existing archive."""
|
|
||||||
target: Path = self.__dir / "01-01-tag" / "run1"
|
|
||||||
first: Path = run_llm.create_archive_dir(target, False)
|
|
||||||
(first / "stale.txt").write_text("stale", encoding="utf-8")
|
|
||||||
second: Path = run_llm.create_archive_dir(target, True)
|
|
||||||
self.assertEqual(first, second)
|
|
||||||
self.assertFalse((second / "stale.txt").exists())
|
|
||||||
|
|
||||||
def test_replace_leaves_sibling_dir_untouched(self) -> None:
|
|
||||||
"""Test that replacing run2 does not touch run1."""
|
|
||||||
run1: Path = run_llm.create_archive_dir(
|
|
||||||
self.__dir / "01-01-tag" / "run1", False)
|
|
||||||
(run1 / "output.jsonl").write_text(
|
|
||||||
"run1 data", encoding="utf-8")
|
|
||||||
run2: Path = run_llm.create_archive_dir(
|
|
||||||
self.__dir / "01-01-tag" / "run2", False)
|
|
||||||
(run2 / "stale.jsonl").write_text("stale", encoding="utf-8")
|
|
||||||
run_llm.create_archive_dir(
|
|
||||||
self.__dir / "01-01-tag" / "run2", True)
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(run1 / "output.jsonl").read_text(encoding="utf-8"),
|
directory,
|
||||||
"run1 data")
|
self.__dir / "coding" / "20260730-2005-gender_v3")
|
||||||
|
|
||||||
|
def test_existing_archive_dir_rejected(self) -> None:
|
||||||
|
"""Test that an existing archive directory is rejected."""
|
||||||
|
now: datetime = datetime(2026, 7, 30, 20, 5)
|
||||||
|
run_llm.create_archive_dir(
|
||||||
|
self.__dir, "coding", Path("gender_v3.md"), now)
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
run_llm.create_archive_dir(
|
||||||
|
self.__dir, "coding", Path("gender_v3.md"), now)
|
||||||
|
|
||||||
def test_write_jsonl(self) -> None:
|
def test_write_jsonl(self) -> None:
|
||||||
"""Test writing records as JSON Lines."""
|
"""Test writing records as JSON Lines."""
|
||||||
@@ -291,38 +312,54 @@ class TestMainFlow(RunLLMTestCase):
|
|||||||
"""Create a temporary directory with the input files."""
|
"""Create a temporary directory with the input files."""
|
||||||
directory: Path = self._make_temp_dir()
|
directory: Path = self._make_temp_dir()
|
||||||
self.__runs: Path = directory / "runs"
|
self.__runs: Path = directory / "runs"
|
||||||
self.__archive_dir: Path = self.__runs / "task_v1" / "run1"
|
prompt: Path = directory / "task_v1.md"
|
||||||
self.__prompt: Path = directory / "task_v1.md"
|
prompt.write_text("The task prompt.\n", encoding="utf-8")
|
||||||
self.__prompt.write_text(
|
arbitration: Path = directory / "task_arbitration_v1.md"
|
||||||
"The task prompt.\n", encoding="utf-8")
|
arbitration.write_text(
|
||||||
|
"The arbitration prompt.\n", encoding="utf-8")
|
||||||
self.__input: Path = directory / "items.jsonl"
|
self.__input: Path = directory / "items.jsonl"
|
||||||
self.__input.write_text(
|
self.__input.write_text(
|
||||||
'{"id": "a", "content": "first item"}\n'
|
'{"id": "a", "content": "first item"}\n'
|
||||||
'{"id": "b", "content": "second item"}\n',
|
'{"id": "b", "content": "second item"}\n',
|
||||||
encoding="utf-8")
|
encoding="utf-8")
|
||||||
self.__argv: list[str] = [
|
self.__argv: list[str] = [
|
||||||
str(self.__prompt), str(self.__input),
|
str(self.__runs),
|
||||||
str(self.__archive_dir)]
|
"--prompt", str(prompt),
|
||||||
|
"--arbitration-prompt", str(arbitration),
|
||||||
|
"--input", str(self.__input),
|
||||||
|
"--phase", "coding"]
|
||||||
self.__settings: config.Settings = config.Settings(
|
self.__settings: config.Settings = config.Settings(
|
||||||
SQLALCHEMY_DATABASE_URL="sqlite://",
|
SQLALCHEMY_DATABASE_URL="sqlite://",
|
||||||
ANTHROPIC_API_KEY="test-key")
|
ANTHROPIC_API_KEY="test-key")
|
||||||
config.set_settings(self.__settings)
|
config.set_settings(self.__settings)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __make_client(entries: list[Any]) -> mock.Mock:
|
def __make_client(run1: list[Any], run2: list[Any],
|
||||||
"""Create a mock Anthropic client serving canned results.
|
arbitration: list[Any] | None = None) \
|
||||||
|
-> mock.Mock:
|
||||||
|
"""Create a mock Anthropic client serving canned batch results.
|
||||||
|
|
||||||
:param entries: The result entries of the single run batch.
|
:param run1: The result entries of the run-1 batch.
|
||||||
|
:param run2: The result entries of the run-2 batch.
|
||||||
|
:param arbitration: The result entries of the arbitration
|
||||||
|
batch.
|
||||||
:return: The mock client.
|
:return: The mock client.
|
||||||
"""
|
"""
|
||||||
client: mock.Mock = mock.Mock()
|
client: mock.Mock = mock.Mock()
|
||||||
client.messages.batches.create.return_value = mock.Mock(
|
batch_ids: list[str] = ["batch_run1", "batch_run2",
|
||||||
id="batch_run1")
|
"batch_arb"]
|
||||||
|
client.messages.batches.create.side_effect = [
|
||||||
|
mock.Mock(id=x) for x in batch_ids]
|
||||||
ended: mock.Mock = mock.Mock()
|
ended: mock.Mock = mock.Mock()
|
||||||
ended.processing_status = "ended"
|
ended.processing_status = "ended"
|
||||||
ended.ended_at = datetime(2026, 7, 30, 20, 0)
|
ended.ended_at = datetime(2026, 7, 30, 20, 0)
|
||||||
client.messages.batches.retrieve.return_value = ended
|
client.messages.batches.retrieve.return_value = ended
|
||||||
client.messages.batches.results.return_value = iter(entries)
|
results: dict[str, list[Any]] = {
|
||||||
|
"batch_run1": run1, "batch_run2": run2,
|
||||||
|
"batch_arb": arbitration if arbitration is not None
|
||||||
|
else []}
|
||||||
|
client.messages.batches.results.side_effect = (
|
||||||
|
lambda batch_id: iter(results[batch_id]))
|
||||||
return client
|
return client
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -342,6 +379,16 @@ class TestMainFlow(RunLLMTestCase):
|
|||||||
status: int = run_llm.main(argv)
|
status: int = run_llm.main(argv)
|
||||||
return status, stdout.getvalue()
|
return status, stdout.getvalue()
|
||||||
|
|
||||||
|
def __archive_dir(self) -> Path:
|
||||||
|
"""Locate the single archive directory of the run.
|
||||||
|
|
||||||
|
:return: The archive directory.
|
||||||
|
"""
|
||||||
|
directories: list[Path] = list(
|
||||||
|
(self.__runs / "coding").iterdir())
|
||||||
|
self.assertEqual(len(directories), 1)
|
||||||
|
return directories[0]
|
||||||
|
|
||||||
def test_dry_run(self) -> None:
|
def test_dry_run(self) -> None:
|
||||||
"""Test the dry-run behavior."""
|
"""Test the dry-run behavior."""
|
||||||
status: int
|
status: int
|
||||||
@@ -349,107 +396,114 @@ class TestMainFlow(RunLLMTestCase):
|
|||||||
status, stdout = self.__run_main(
|
status, stdout = self.__run_main(
|
||||||
self.__argv + ["--dry-run"])
|
self.__argv + ["--dry-run"])
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
run_dir: Path = self.__archive_dir
|
run_dir: Path = self.__archive_dir()
|
||||||
self.assertEqual((run_dir / "prompt.md").read_text(
|
self.assertEqual((run_dir / "prompt.md").read_text(
|
||||||
encoding="utf-8"), "The task prompt.\n")
|
encoding="utf-8"), "The task prompt.\n")
|
||||||
|
self.assertEqual(
|
||||||
|
(run_dir / "arbitration_prompt.md").read_text(
|
||||||
|
encoding="utf-8"), "The arbitration prompt.\n")
|
||||||
meta: dict[str, Any] = json.loads(
|
meta: dict[str, Any] = json.loads(
|
||||||
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||||
self.assertTrue(meta["dry_run"])
|
self.assertTrue(meta["dry_run"])
|
||||||
self.assertEqual(meta["item_count"], 2)
|
self.assertEqual(meta["item_count"], 2)
|
||||||
self.assertEqual(meta["model"], "claude-sonnet-4-6")
|
self.assertEqual(meta["model"], "claude-sonnet-4-6")
|
||||||
self.assertNotIn("run", meta)
|
self.assertFalse((run_dir / "run1.jsonl").exists())
|
||||||
self.assertFalse((run_dir / "output.jsonl").exists())
|
|
||||||
request: dict[str, Any] = json.loads(stdout)
|
request: dict[str, Any] = json.loads(stdout)
|
||||||
self.assertEqual(request["custom_id"], "a")
|
self.assertEqual(request["custom_id"], "a")
|
||||||
self.assertEqual(request["params"]["system"],
|
self.assertEqual(request["params"]["system"],
|
||||||
"The task prompt.\n")
|
"The task prompt.\n")
|
||||||
|
|
||||||
def test_run_produces_output_file(self) -> None:
|
def test_all_agreed_skips_arbitration(self) -> None:
|
||||||
"""Test that a run submits one batch and writes output."""
|
"""Test that full agreement skips the arbitration batch."""
|
||||||
client: mock.Mock = self.__make_client(
|
client: mock.Mock = self.__make_client(
|
||||||
[self._make_success_entry("a", "answer a"),
|
run1=[self._make_success_entry("a", "answer a"),
|
||||||
self._make_success_entry("b", "answer b")])
|
self._make_success_entry("b", "answer b")],
|
||||||
|
run2=[self._make_success_entry("a", "answer a\n"),
|
||||||
|
self._make_success_entry("b", "answer b")])
|
||||||
status: int = self.__run_main(self.__argv, client)[0]
|
status: int = self.__run_main(self.__argv, client)[0]
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
client.messages.batches.create.call_count, 1)
|
client.messages.batches.create.call_count, 2)
|
||||||
run_dir: Path = self.__archive_dir
|
run_dir: Path = self.__archive_dir()
|
||||||
self.assertTrue((run_dir / "output.jsonl").exists())
|
self.assertEqual(
|
||||||
output: list[dict[str, Any]] = [
|
(run_dir / "arbitration.jsonl").read_text(
|
||||||
json.loads(x) for x in (run_dir / "output.jsonl")
|
encoding="utf-8"), "")
|
||||||
|
final: list[dict[str, Any]] = [
|
||||||
|
json.loads(x) for x in (run_dir / "final.jsonl")
|
||||||
.read_text(encoding="utf-8").splitlines()]
|
.read_text(encoding="utf-8").splitlines()]
|
||||||
self.assertEqual(output[0]["text"], "answer a")
|
self.assertEqual(final, [
|
||||||
|
{"id": "a", "text": "answer a", "source": "agreed"},
|
||||||
|
{"id": "b", "text": "answer b", "source": "agreed"}])
|
||||||
meta: dict[str, Any] = json.loads(
|
meta: dict[str, Any] = json.loads(
|
||||||
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||||
self.assertNotIn("run", meta)
|
self.assertEqual(meta["agreed_count"], 2)
|
||||||
self.assertEqual(meta["batch"]["batch_id"], "batch_run1")
|
self.assertEqual(meta["agreement_rate"], 1.0)
|
||||||
self.assertEqual(meta["usage"],
|
self.assertNotIn("arbitration", meta["batches"])
|
||||||
{"input_tokens": 20, "output_tokens": 10})
|
|
||||||
|
|
||||||
def test_existing_archive_rejected_without_replace(self) -> None:
|
def test_disagreement_triggers_arbitration(self) -> None:
|
||||||
"""Test that an existing archive without --replace fails."""
|
"""Test that disagreeing items go through arbitration."""
|
||||||
run_dir: Path = self.__archive_dir
|
|
||||||
run_dir.mkdir(parents=True)
|
|
||||||
(run_dir / "stale.jsonl").write_text(
|
|
||||||
"stale", encoding="utf-8")
|
|
||||||
status: int = self.__run_main(
|
|
||||||
self.__argv + ["--dry-run"])[0]
|
|
||||||
self.assertEqual(status, 1)
|
|
||||||
self.assertTrue((run_dir / "stale.jsonl").exists())
|
|
||||||
|
|
||||||
def test_rerun_replaces_archive_with_flag(self) -> None:
|
|
||||||
"""Test that --replace replaces a pre-existing archive."""
|
|
||||||
run_dir: Path = self.__archive_dir
|
|
||||||
run_dir.mkdir(parents=True)
|
|
||||||
(run_dir / "stale.jsonl").write_text(
|
|
||||||
"stale", encoding="utf-8")
|
|
||||||
client: mock.Mock = self.__make_client(
|
client: mock.Mock = self.__make_client(
|
||||||
[self._make_success_entry("a", "answer a"),
|
run1=[self._make_success_entry("a", "answer a"),
|
||||||
self._make_success_entry("b", "answer b")])
|
self._make_success_entry("b", "answer b1")],
|
||||||
status: int = self.__run_main(
|
run2=[self._make_success_entry("a", "answer a"),
|
||||||
self.__argv + ["--replace"], client)[0]
|
self._make_success_entry("b", "answer b2")],
|
||||||
self.assertEqual(status, 0)
|
arbitration=[
|
||||||
self.assertFalse((run_dir / "stale.jsonl").exists())
|
self._make_success_entry("b", "answer b final")])
|
||||||
self.assertTrue((run_dir / "output.jsonl").exists())
|
status: int = self.__run_main(self.__argv, client)[0]
|
||||||
|
|
||||||
def test_replace_leaves_sibling_dir_untouched(self) -> None:
|
|
||||||
"""Test that replacing run2 does not affect run1's archive."""
|
|
||||||
run1_dir: Path = self.__archive_dir
|
|
||||||
run1_dir.mkdir(parents=True)
|
|
||||||
(run1_dir / "output.jsonl").write_text(
|
|
||||||
"run1 data", encoding="utf-8")
|
|
||||||
run2_dir: Path = self.__runs / "task_v1" / "run2"
|
|
||||||
run2_dir.mkdir(parents=True)
|
|
||||||
(run2_dir / "output.jsonl").write_text(
|
|
||||||
"stale run2 data", encoding="utf-8")
|
|
||||||
client: mock.Mock = self.__make_client(
|
|
||||||
[self._make_success_entry("a", "answer a"),
|
|
||||||
self._make_success_entry("b", "answer b")])
|
|
||||||
argv: list[str] = [
|
|
||||||
str(self.__prompt), str(self.__input),
|
|
||||||
str(run2_dir), "--replace"]
|
|
||||||
status: int = self.__run_main(argv, client)[0]
|
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(run1_dir / "output.jsonl").read_text(encoding="utf-8"),
|
client.messages.batches.create.call_count, 3)
|
||||||
"run1 data")
|
arb_call: mock.call = \
|
||||||
self.assertNotEqual(
|
client.messages.batches.create.call_args_list[2]
|
||||||
(run2_dir / "output.jsonl").read_text(encoding="utf-8"),
|
arb_requests: list[dict[str, Any]] = \
|
||||||
"stale run2 data")
|
arb_call.kwargs["requests"]
|
||||||
|
self.assertEqual(len(arb_requests), 1)
|
||||||
|
self.assertEqual(arb_requests[0]["custom_id"], "b")
|
||||||
|
self.assertEqual(arb_requests[0]["params"]["system"],
|
||||||
|
"The arbitration prompt.\n")
|
||||||
|
self.assertEqual(
|
||||||
|
arb_requests[0]["params"]["messages"][0]["content"],
|
||||||
|
"<item>\nsecond item\n</item>\n"
|
||||||
|
"<run1>\nanswer b1\n</run1>\n"
|
||||||
|
"<run2>\nanswer b2\n</run2>")
|
||||||
|
run_dir: Path = self.__archive_dir()
|
||||||
|
arb_lines: list[str] = (run_dir / "arbitration.jsonl") \
|
||||||
|
.read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(len(arb_lines), 1)
|
||||||
|
self.assertEqual(json.loads(arb_lines[0])["text"],
|
||||||
|
"answer b final")
|
||||||
|
final: list[dict[str, Any]] = [
|
||||||
|
json.loads(x) for x in (run_dir / "final.jsonl")
|
||||||
|
.read_text(encoding="utf-8").splitlines()]
|
||||||
|
self.assertEqual(final, [
|
||||||
|
{"id": "a", "text": "answer a", "source": "agreed"},
|
||||||
|
{"id": "b", "text": "answer b final",
|
||||||
|
"source": "arbitration"}])
|
||||||
|
meta: dict[str, Any] = json.loads(
|
||||||
|
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(meta["agreed_count"], 1)
|
||||||
|
self.assertEqual(meta["agreement_rate"], 0.5)
|
||||||
|
self.assertEqual(meta["batches"]["arbitration"]["batch_id"],
|
||||||
|
"batch_arb")
|
||||||
|
|
||||||
def test_run_failure_exits_non_zero(self) -> None:
|
def test_run_failure_exits_non_zero(self) -> None:
|
||||||
"""Test that a failed item aborts with a non-zero status."""
|
"""Test that a failed item aborts with a non-zero status."""
|
||||||
client: mock.Mock = self.__make_client(
|
client: mock.Mock = self.__make_client(
|
||||||
[self._make_success_entry("a", "answer a"),
|
run1=[self._make_success_entry("a", "answer a"),
|
||||||
self._make_error_entry("b", "invalid_request_error")])
|
self._make_error_entry(
|
||||||
|
"b", "invalid_request_error")],
|
||||||
|
run2=[self._make_success_entry("a", "answer a"),
|
||||||
|
self._make_success_entry("b", "answer b")])
|
||||||
status: int = self.__run_main(self.__argv, client)[0]
|
status: int = self.__run_main(self.__argv, client)[0]
|
||||||
self.assertEqual(status, 1)
|
self.assertEqual(status, 1)
|
||||||
run_dir: Path = self.__archive_dir
|
run_dir: Path = self.__archive_dir()
|
||||||
self.assertTrue((run_dir / "output.jsonl").exists())
|
self.assertTrue((run_dir / "run1.jsonl").exists())
|
||||||
|
self.assertTrue((run_dir / "run2.jsonl").exists())
|
||||||
self.assertTrue((run_dir / "meta.json").exists())
|
self.assertTrue((run_dir / "meta.json").exists())
|
||||||
output_lines: list[str] = (run_dir / "output.jsonl") \
|
self.assertFalse((run_dir / "final.jsonl").exists())
|
||||||
|
run1_lines: list[str] = (run_dir / "run1.jsonl") \
|
||||||
.read_text(encoding="utf-8").splitlines()
|
.read_text(encoding="utf-8").splitlines()
|
||||||
self.assertEqual(json.loads(output_lines[1]),
|
self.assertEqual(json.loads(run1_lines[1]),
|
||||||
{"id": "b",
|
{"id": "b",
|
||||||
"error": "invalid_request_error"})
|
"error": "invalid_request_error"})
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Tools for A Feminist Audit of Pop Music.
|
|
||||||
# Copyright 2026 imacat. All rights reserved.
|
|
||||||
# Authors:
|
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
|
||||||
"""Unit tests for the shared utilities module."""
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from pop_fem_audit_tools import utils
|
|
||||||
|
|
||||||
|
|
||||||
class TestUtils(unittest.TestCase):
|
|
||||||
"""Test cases for the shared utilities."""
|
|
||||||
|
|
||||||
def test_format_duration_under_hour(self) -> None:
|
|
||||||
"""Test the mm:ss format for a duration under one hour."""
|
|
||||||
self.assertEqual(utils.format_duration(205), "03:25")
|
|
||||||
|
|
||||||
def test_format_duration_over_hour(self) -> None:
|
|
||||||
"""Test the h:mm:ss format once the duration reaches an
|
|
||||||
hour."""
|
|
||||||
self.assertEqual(utils.format_duration(6439), "1:47:19")
|
|
||||||
Reference in New Issue
Block a user