Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
964006ec6d | ||
|
|
0c82da7a1d | ||
|
|
93a4f808bf | ||
|
|
7a58f37d9f | ||
|
|
b727284ef3 | ||
|
|
396848e898 | ||
|
|
55926b8dc4 | ||
|
|
ee6a1b88c5 | ||
|
|
1a7519becd | ||
|
|
a534b1b70b | ||
|
|
8fdd21f8a2 | ||
|
|
c561330bd0 | ||
|
|
64c62eb394 | ||
|
|
0d3cd69a0f | ||
|
|
81975b72e4 | ||
|
|
1b21471845 | ||
|
|
15f207ffc6 | ||
|
|
93aa06703b | ||
|
|
8fdd25f569 | ||
|
|
00b5c208ec | ||
|
|
c81bfcf51d | ||
|
|
942a74c86c | ||
|
|
1f95f0231d | ||
|
|
ae0e9d0a08 | ||
|
|
6a91bc73eb | ||
|
|
e15c988bf4 | ||
|
|
f36a30411d | ||
|
|
5d5e4fac50 | ||
|
|
e9027472f4 | ||
|
|
e0eb343ce0 | ||
|
|
8d223177cd | ||
|
|
f9ab79f0c2 | ||
|
|
b68393ab01 | ||
|
|
12ace6f45a | ||
|
|
9a42b463a3 |
@@ -1,2 +0,0 @@
|
|||||||
# Copy to .env and fill in. .env is gitignored.
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
data/source/*.csv -text
|
||||||
+4
-6
@@ -1,14 +1,11 @@
|
|||||||
# Copyrighted lyrics cache — never commit full lyrics
|
# Copyrighted lyrics cache — never commit full lyrics
|
||||||
data/lyrics/
|
data/captures/lyrics/
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Python
|
# Claude Code local settings (machine/user-specific, not research content)
|
||||||
__pycache__/
|
.claude/
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -17,3 +14,4 @@ venv/
|
|||||||
# OS / Dropbox artifacts
|
# OS / Dropbox artifacts
|
||||||
*.conflicted copy*
|
*.conflicted copy*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
excludes/
|
||||||
|
|||||||
@@ -1,44 +1,43 @@
|
|||||||
# Project Workflow Rules
|
# Project Conventions
|
||||||
|
|
||||||
Keep this file minimal. It is injected into every Claude Code
|
## Analysis pipeline
|
||||||
subagent; NEVER put research domain knowledge, theory, coding
|
|
||||||
criteria, or expected results here. Domain material lives in
|
|
||||||
`docs/` and is read only when explicitly needed.
|
|
||||||
|
|
||||||
## Roles
|
|
||||||
|
|
||||||
- The main Claude Code session is for discussion, planning, and
|
|
||||||
writing only. All analysis is executed by deterministic
|
|
||||||
scripts.
|
|
||||||
- Claude Code subagents are used only for engineering support
|
|
||||||
(writing scripts, style checks, reviews) — never as research
|
|
||||||
instruments.
|
|
||||||
|
|
||||||
## Analysis pipeline (paper-bound work)
|
|
||||||
|
|
||||||
- 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 `prompts/<task>_v<N>.md` and
|
- Prompt definition files live in
|
||||||
are passed verbatim as the system prompt.
|
`prompts/<track>-<step>-<substep>-<task>.md` (e.g.
|
||||||
- Every LLM step runs the same definition file twice, then a
|
01-01-01-tag.md; the substep number fixes the execution
|
||||||
separate arbitration step reconciles the two outputs
|
order; no version suffix -- versions live in git history)
|
||||||
("2 runs + 1 arbitration"). If arbitration output is
|
and are passed verbatim as the system prompt.
|
||||||
unexpected, revise the definition file and repeat the whole
|
- LLM steps whose outputs are item-by-item comparable
|
||||||
cycle; never patch results by hand.
|
(convergence, coding) run the same definition file twice,
|
||||||
- Every execution is archived self-contained under
|
then a separate arbitration step settles only the
|
||||||
`runs/<phase>/<date>-<prompt-version>/`: prompt snapshot,
|
script-computed disagreements ("2 runs + 1 arbitration").
|
||||||
raw outputs of both runs, arbitration output, and `meta.json`
|
Free-generation steps run twice and both outputs are pooled,
|
||||||
(model ID, parameters, timestamps, batch IDs).
|
unarbitrated. If arbitration output is unexpected, revise
|
||||||
|
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). Never hardcode
|
environment variable (`.env`, gitignored).
|
||||||
keys.
|
|
||||||
|
|
||||||
## Data rules
|
## Data rules
|
||||||
|
|
||||||
- `data/yearend_hot100_2016_2025.csv` is the only hand-placed
|
- `data/source/` holds the immutable hand-placed raw files;
|
||||||
raw file; everything else in `data/` is script-derived.
|
`data/captures/` is written only by the fetch commands and the
|
||||||
- Full lyrics are copyrighted: they stay in `data/lyrics/`
|
private import script; `data/manual/` is written only by the
|
||||||
|
user's own hand; `data/derived/` is written only by the
|
||||||
|
`build-db` subcommand.
|
||||||
|
- Full lyrics are copyrighted: they stay in `data/captures/lyrics/`
|
||||||
(gitignored) and must never be committed or reproduced in
|
(gitignored) and must never be committed or reproduced in
|
||||||
full anywhere in the repo.
|
full anywhere in the repo.
|
||||||
|
|
||||||
@@ -48,4 +47,4 @@ criteria, or expected results here. Domain material lives in
|
|||||||
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,24 +7,33 @@ 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`。
|
||||||
|
|
||||||
## 重現方式
|
## 重現方式
|
||||||
|
|
||||||
1. 準備 Python 3.10+ 環境,安裝 `requirements.txt`。
|
1. 準備 Python 3.14+ 環境,安裝分析管線套件:
|
||||||
2. 將 Anthropic API 金鑰寫入 `.env`(格式見 `.env.example`)。
|
`pip install -e tools/`。
|
||||||
3. 依 `docs/research_plan.md` 的階段順序執行 `scripts/` 下的
|
2. 自 `tools/.env.example` 建立 `tools/.env`,寫入
|
||||||
程式。LLM 步驟使用 `claude-sonnet-4-6`、temperature=0、
|
Anthropic API 金鑰。
|
||||||
thinking 關閉;每步驟獨立執行兩次後由仲裁步驟合併
|
3. 依 `docs/research-plan.md` 的階段順序,於 `tools/` 目錄下
|
||||||
(「2+1」協定)。
|
執行子命令,必要輸入以位置引數、選擇性輸入以選項給定(如
|
||||||
|
`pop-fem-audit-tools build-db
|
||||||
|
../data/source/yearend_hot100_2016_2025.csv
|
||||||
|
../data/derived
|
||||||
|
--lyrics-dir ../data/captures/lyrics
|
||||||
|
--wikidata-csv ../data/captures/artists-wikidata.csv`)。
|
||||||
|
LLM 步驟使用 `claude-sonnet-4-6`、temperature=0、
|
||||||
|
thinking 關閉;輸出可逐項比對的步驟獨立執行兩次,由
|
||||||
|
比對程式算出分歧、仲裁步驟裁決(「2+1」協定),自由
|
||||||
|
生成步驟兩次執行進池不仲裁。
|
||||||
4. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於
|
4. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於
|
||||||
`runs/`,可逐筆稽核。論文引用的最終資料表在 `results/`。
|
`runs/`,可逐筆稽核。論文引用的最終資料表在 `results/`。
|
||||||
|
|
||||||
注意:歌詞受版權保護,`data/lyrics/` 不隨 repo 發布,須自行
|
注意:歌詞受版權保護,`data/captures/lyrics/` 不隨 repo 發布,須自行
|
||||||
以 `scripts/` 中的抓取程式重建。
|
以 `tools/` 中的抓取程式重建。
|
||||||
|
|
||||||
## 授權
|
## 授權
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
name,qid,gender,type,genre,country,note
|
||||||
|
070 Shake,Q54861040,female,solo,hip-hop; cloud rap; alternative hip-hop,United States,American rapper and singer
|
||||||
|
2 Chainz,Q129668,male,solo,Southern hip-hop; trap music; gangsta rap; dirty south; Atlanta hip-hop; hardcore hip-hop,United States,American rapper
|
||||||
|
21 Savage,Q25095399,male,solo,hip-hop; trap music,United Kingdom,British-born rapper (born 1992)
|
||||||
|
24kGoldn,Q83751918,male,solo,pop rap,United States,American rapper
|
||||||
|
42 Dugg,Q95694810,male,solo,Michigan rap; Midwest hip-hop; trap music; gangsta rap,United States,American rapper
|
||||||
|
4batz,Q124313062,male,solo,contemporary R&B,United States,American singer (born 2003)
|
||||||
|
5 Seconds of Summer,Q4641147,,group,pop rock; pop-punk,Australia,Australian pop rock band
|
||||||
|
50 Cent,Q6060,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop,United States,American rapper (born 1975)
|
||||||
|
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,American rapper from New York (born 1996)
|
||||||
|
6LACK,Q27886640,male,solo,alternative R&B; contemporary R&B; trap music; cloud rap; industrial music; soul; hip-hop soul,United States,American singer-songwriter and rapper (born 1992)
|
||||||
|
A Boogie wit da Hoodie,Q27927826,male,solo,hip-hop; trap music,United States,American rapper (born 1995)
|
||||||
|
A$AP Ferg,Q15648254,male,solo,East Coast hip-hop; trap music,United States,American rapper (born 1988)
|
||||||
|
A$AP Rocky,Q129910,male,solo,East Coast hip-hop; psychedelic rap; experimental hip-hop; alternative hip-hop; cloud rap; trap music,United States,American rapper (born 1988)
|
||||||
|
Adassa,Q576181,female,solo,reggaeton; urban contemporary; pop music,United States,American singer-songwriter
|
||||||
|
Adele,Q23215,female,solo,pop music; blue-eyed soul; rhythm and blues; soul; jazz; rock music,United Kingdom,English singer-songwriter (born 1988)
|
||||||
|
AJR,Q16154058,,group,indie pop; alternative pop; electropop,United States,American indie pop band
|
||||||
|
Alessia Cara,Q20090091,female,solo,contemporary R&B; indie pop; pop music; soul; alternative R&B,Italy,"Italian-Canadian singer, songwriter and actress"
|
||||||
|
Alex Warren,Q121842162,male,solo,pop music,United States,"American singer, songwriter, YouTuber, and influencer"
|
||||||
|
Aminé,Q27830860,male,solo,West Coast hip-hop; alternative hip-hop; trap music; contemporary R&B,United States,"American rapper, singer, and songwriter"
|
||||||
|
Anderson .Paak,Q20810369,male,solo,contemporary R&B; hip-hop; soul; funk; pop music,United States,"American singer, rapper, drummer, and record producer (born 1986)"
|
||||||
|
Andrew Choi,Q15229870,male,solo,K-pop; pop music; contemporary R&B,South Korea,South Korean singer and songwriter
|
||||||
|
Anne-Marie,Q25396976,female,solo,pop music; contemporary R&B,United Kingdom,British singer (born 1990)
|
||||||
|
Aqua,Q213388,,group,pop music; bubblegum music; Eurodance,Denmark,Danish-Norwegian music group
|
||||||
|
Ariana Grande,Q151892,female,solo,pop music; contemporary R&B; electronic dance music,United States,"American singer, songwriter, and actress (born 1993)"
|
||||||
|
Arizona Zervas,Q75124792,male,solo,hip-hop; contemporary R&B; pop music,United States,American singer and rapper (born 1995)
|
||||||
|
Artemas,Q125176874,male,solo,alternative pop,United Kingdom,British singer-songwriter and record producer
|
||||||
|
ATR Son Son,Q140814072,male,solo,hip-hop,United States,American rapper
|
||||||
|
Audrey Nuna,Q105538243,female,solo,K-pop; pop music; hip-hop; contemporary R&B; trap music,United States,South Korean-American singer and rapper
|
||||||
|
Ava Max,Q56755505,female,solo,dance-pop,United States,American singer and songwriter (born 1994)
|
||||||
|
Avery Anna,Q131401753,female,solo,pop rock; country music,United States,American singer
|
||||||
|
Ayo,Q55860029,male,solo,hip-hop,United States,American singer and dancer
|
||||||
|
AzChike,Q131417489,male,solo,hip-hop,United States,American rapper
|
||||||
|
Bad Bunny,Q44333953,male,solo,Latin trap; reggaeton,Puerto Rico,"Puerto Rican rapper, singer, and record producer (born 1994)"
|
||||||
|
Bailey Zimmerman,Q112581616,male,solo,country music,United States,American singer
|
||||||
|
Bastille,Q689,,group,synth-pop,United Kingdom,English indie pop band
|
||||||
|
Bazzi,Q48720231,male,solo,pop music,United States,American-born Lebanese singer-songwriter (born 1997)
|
||||||
|
beabadoobee,Q66817366,female,solo,indie rock; space rock; alternative rock; slacker rock; indie pop; folk-pop; bedroom pop,Philippines,Filipino singer
|
||||||
|
Bebe Rexha,Q16185856,female,solo,pop music; contemporary R&B,United States,American singer
|
||||||
|
Becky G,Q1393997,female,solo,pop music; hip-hop; Latin music; Latin pop; reggaeton,United States,American singer and actress (born 1997)
|
||||||
|
BENEE,Q61749159,female,solo,alternative pop; soul,New Zealand,New Zealand singer-songwriter
|
||||||
|
benny blanco,Q818048,male,solo,pop music,United States,American pop music producer (born 1988)
|
||||||
|
Benson Boone,Q110008128,male,solo,pop music; pop rock,United States,American singer
|
||||||
|
Beyoncé,Q36153,female,solo,pop music; contemporary R&B; country music,United States,"American singer, songwriter, record producer, and dancer (born 1981)"
|
||||||
|
Big Sean,Q367634,male,solo,hip-hop; Midwest hip-hop; Michigan rap; pop rap,United States,American rapper (born 1988)
|
||||||
|
BigXthaPlug,Q124750337,male,solo,rapping; hip-hop,United States,American rapper
|
||||||
|
Billie Eilish,Q29564107,female,solo,electropop; indie pop; alternative pop,United States,American singer-songwriter (born 2001)
|
||||||
|
Billy Ray Cyrus,Q231259,male,solo,country music; country pop; country rock; country blues,United States,American country singer and actor
|
||||||
|
Bipolar Sunshine,Q16727521,male,solo,pop music,United Kingdom,British musician
|
||||||
|
Bizarrap,Q99937928,male,solo,Latin trap; reggaeton,Argentina,Argentine DJ and producer (born 1998)
|
||||||
|
Black Eyed Peas,Q134541,,group,alternative hip-hop; pop rap; hip-hop; pop music; Latin hip-hop,United States,American hip hop group from California
|
||||||
|
blackbear,Q22974007,male,solo,pop music; emo; contemporary R&B; hip-hop,United States,"American singer, songwriter, and record producer (born 1990)"
|
||||||
|
Blake Shelton,Q94831,male,solo,country music,United States,American country music singer and television personality
|
||||||
|
Blanco Brown,Q64736729,male,solo,country rap; trap music; hip-hop,United States,American rapper and singer
|
||||||
|
BlocBoy JB,Q49481016,male,solo,hip-hop; trap music,United States,American rapper from Tennessee
|
||||||
|
Blueface,Q60463134,male,solo,hip-hop; trap music; West Coast hip-hop; gangsta rap,United States,American rapper (born 1997)
|
||||||
|
Bobby Helms,Q888554,male,solo,country music,United States,American male singer (1933–1997)
|
||||||
|
Bomba Estéreo,Q887911,,group,cumbia,Colombia,Colombian band
|
||||||
|
Bradley Cooper,Q205707,male,solo,,United States,American actor (born 1975)
|
||||||
|
Brandon Lake,Q100314823,male,solo,contemporary Christian music; Christian rock,United States,American Christian musician
|
||||||
|
Brenda Lee,Q230925,female,solo,rock and roll; country music; pop music; rockabilly,United States,American singer and recording artist (born 1944)
|
||||||
|
Brendon Urie,Q1047474,male,solo,pop rock; alternative rock,United States,American singer and musician
|
||||||
|
Brett Young,Q25189679,male,solo,country music,United States,American country musician
|
||||||
|
BRS Kash,Q105409004,male,solo,hip-hop,United States,American rapper
|
||||||
|
Bruno Mars,Q1450,male,solo,pop music; contemporary R&B; hip-hop; new wave; reggae; funk,United States,American singer-songwriter and record producer (born 1985)
|
||||||
|
Bryson Tiller,Q21066641,male,solo,contemporary R&B; hip-hop; trap music; soul,United States,American singer and rapper (born 1993)
|
||||||
|
BTS,Q13580495,,group,K-pop; dance-pop; Korean hip-hop,South Korea,South Korean musical group; boy band
|
||||||
|
Burl Ives,Q315723,male,solo,folk music,United States,American musician (1909–1995)
|
||||||
|
Calboy,Q66085272,male,solo,hip-hop,United States,American rapper (born 1999)
|
||||||
|
Calvin Harris,Q81637,male,solo,electronic dance music; electronic pop; acid house,United Kingdom,Scottish electronic music producer and DJ (born 1984)
|
||||||
|
Camila Cabello,Q18810940,female,solo,pop music; contemporary R&B; Latin pop; Gothic pop,Cuba,Cuban-Mexican-American singer
|
||||||
|
Cardi B,Q29033668,female,solo,hip-hop; pop music; pop rap,United States,American rapper (born 1992)
|
||||||
|
Carly Pearce,Q29913337,female,solo,country music,United States,American country music singer-songwriter
|
||||||
|
Carolina Gaitán,Q5753201,female,solo,,Colombia,Colombian actress and singer
|
||||||
|
Carrie Underwood,Q215546,female,solo,pop music; country music; country pop; country rock,United States,American singer and actor (born 1983)
|
||||||
|
Cash Cobain,Q125989634,male,solo,hip-hop; drill; Jersey drill; pop rap; East Coast hip-hop; trap music,United States,American rapper and record producer
|
||||||
|
Casper Mágico,Q60333702,male,solo,Latin trap; reggaeton,United States,Puerto Rican rapper
|
||||||
|
Chance the Rapper,Q12470060,male,solo,hip-hop; alternative hip-hop; contemporary R&B; conscious hip-hop; alternative R&B; gospel music,United States,"American rapper, singer and record producer (born 1993)"
|
||||||
|
Chappell Roan,Q110281146,female,solo,synth-pop; pop music; electronic music,United States,American singer (born 1998)
|
||||||
|
Charlie Puth,Q19810390,male,solo,pop music; contemporary R&B,United States,"American singer, songwriter, and record producer"
|
||||||
|
Chase Rice,Q5087189,male,solo,country music,United States,American singer-songwriter and football player
|
||||||
|
Cheat Codes,Q23978754,,group,electronic music; electronic dance music; tropical house; dancehall,United States,electronic music DJ trio
|
||||||
|
Chencho Corleone,Q113625582,male,solo,reggaeton,United States,Puerto Rican singer (born 1979)
|
||||||
|
Childish Gambino,Q1239933,male,solo,hip-hop; funk; contemporary R&B; soul; psychedelic music; indie pop; comedy hip-hop,United States,"American actor, comedian, musician, and filmmaker (born 1983)"
|
||||||
|
Chris Brown,Q155700,male,solo,contemporary R&B; pop music; hip-hop,United States,American singer and rapper (born 1989)
|
||||||
|
Chris Stapleton,Q5108148,male,solo,country music,United States,American country musician
|
||||||
|
Chris Young,Q952176,male,solo,country music,United States,American country music artist
|
||||||
|
City Girls,Q60658858,,group,hip-hop,United States,American musical duo
|
||||||
|
CJ,Q103868817,male,solo,drill; trap music; East Coast hip-hop; hip-hop,United States,American rapper
|
||||||
|
CKay,Q98312451,male,solo,Afrobeats,Nigeria,Nigerian singer-songwriter
|
||||||
|
Clean Bandit,Q11838808,,group,electropop,United Kingdom,English electronic music group
|
||||||
|
Cody Johnson,Q17916913,male,solo,country music; country rock,United States,American country singer and songwriter
|
||||||
|
Coi Leray,Q106376206,female,solo,hip-hop; pop music,United States,American rapper and singer
|
||||||
|
Coldplay,Q45188,,group,alternative rock; pop music,United Kingdom,British rock band
|
||||||
|
Cole Swindell,Q15101510,male,solo,country music,United States,American singer-songwriter
|
||||||
|
Corey Kent,Q113452679,male,solo,country music,United States,American singer
|
||||||
|
Cris MJ,Q111425015,male,solo,reggaeton,Chile,Chilean singer-songwriter
|
||||||
|
D.R.A.M.,Q25138504,male,solo,Southern hip-hop; contemporary R&B; trap music,United States,American rapper and singer (born 1988)
|
||||||
|
DaBaby,Q62107457,male,solo,hip-hop; trap music; pop rap; Southern hip-hop,United States,American rapper (born 1991)
|
||||||
|
Daddy Yankee,Q272591,male,solo,reggaeton; Latin hip-hop,United States,Puerto Rican rapper and actor (born 1976)
|
||||||
|
Daft Punk,Q185828,,group,house music; electronic music; French house; synth-pop; electronic rock; electronica; nu-disco; dance music; leftfield house,France,French electronic music band
|
||||||
|
Dan Smyers,Q57630595,male,solo,country music,United States,songwriter and member of the duo Dan + Shay
|
||||||
|
Daniel Caesar,Q29589083,male,solo,contemporary R&B; soul; neo soul,Canada,Canadian R&B singer
|
||||||
|
Danny Chung,Q140814119,male,solo,hip-hop; K-pop,United States,"Korean-American rapper, singer and songwriter"
|
||||||
|
Darell,Q56467651,male,solo,reggaeton; Latin trap,United States,Puerto Rican rapper and singer
|
||||||
|
Dasha,Q125157260,female,solo,country pop,United States,American country singer-songwriter
|
||||||
|
David Guetta,Q8298,male,solo,electronic dance music; house music; electroclash; electro house,France,French DJ and electronic music producer (born 1967)
|
||||||
|
David Kushner,Q117745879,male,solo,,United States,American male singer-songwriter
|
||||||
|
Daya,Q21005558,female,solo,pop music,United States,American singer
|
||||||
|
dazy,Q140814120,male,solo,pop music,,Los Angeles-based record producer and musician
|
||||||
|
Dean Lewis,Q29905254,male,solo,pop music,Australia,Australian singer-songwriter (born 1987)
|
||||||
|
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,"American singer, songwriter, and actress (born 1992)"
|
||||||
|
Desiigner,Q23091498,male,solo,hip-hop; East Coast hip-hop; trap music; mumble rap; hardcore hip-hop,United States,American rapper and singer (born 1997)
|
||||||
|
Diane Guerrero,Q17166318,female,solo,,United States,American actress
|
||||||
|
Diplo,Q533781,male,solo,electronic dance music; trap music; hip-hop; pop music; moombahton; electropop; house music; EDM trap music,United States,American record producer (born 1978)
|
||||||
|
DJ Khaled,Q80805,male,solo,hip-hop; pop rap,United States,American DJ and rapper (born 1975)
|
||||||
|
DJ Snake,Q16728588,male,solo,electronic dance music; EDM trap music,France,French record producer (born 1986)
|
||||||
|
Djo,Q27064031,male,solo,synth-pop,United States,American actor and musician (born 1992)
|
||||||
|
DNCE,Q21648421,,group,pop rock,United States,American pop band
|
||||||
|
Doechii,Q106629186,female,solo,hip-hop; contemporary R&B; pop music; alternative hip-hop; pop rap,United States,American rapper and singer
|
||||||
|
Doja Cat,Q51120673,female,solo,contemporary R&B; pop music; electronic music; indie pop; hip-hop,United States,American singer and rapper (born 1995)
|
||||||
|
Don Toliver,Q56513383,male,solo,hip-hop; trap music; contemporary R&B,United States,American singer and rapper (born 1994)
|
||||||
|
Dove Cameron,Q13560423,female,solo,pop music; alternative pop; soul,United States,American actress and singer
|
||||||
|
Drake,Q33240,male,solo,hip-hop; contemporary R&B; pop music; trap music,Canada,Canadian-American rapper and singer
|
||||||
|
Dua Lipa,Q21914464,female,solo,pop music; post-disco; house music; funktronica; indie pop; dream pop; synth-pop,United Kingdom,British singer-songwriter (born 1995)
|
||||||
|
Duncan Laurence,Q60758679,male,solo,pop music,Kingdom of the Netherlands,Dutch singer
|
||||||
|
Dustin Lynch,Q5317118,male,solo,country music,United States,American singer
|
||||||
|
Ed Sheeran,Q47447,male,solo,pop music; folk music; hip-hop; dance music; soul; rock music,United Kingdom,English singer-songwriter and guitarist (born 1991)
|
||||||
|
EJAE,Q135108208,female,solo,K-pop; pop music; contemporary R&B,South Korea,South Korean-American singer and songwriter (born 1991)
|
||||||
|
Ella Langley,Q127559972,female,solo,country music,United States,American country singer
|
||||||
|
Ella Mai,Q29033458,female,solo,contemporary R&B,United Kingdom,British singer
|
||||||
|
Elle King,Q5364665,female,solo,country music; Americana; blues rock; alternative country,United States,"American singer, songwriter, and actress"
|
||||||
|
Ellie Goulding,Q31164,female,solo,synth-pop; electropop; indie pop; folktronica; alternative rock; alternative music; dream pop; indietronica,United Kingdom,English singer and songwriter (born 1986)
|
||||||
|
Elton John,Q2808,male,solo,rock music; pop rock; glam rock; soft rock; rhythm and blues,United Kingdom,"British singer, songwriter and musician (born 1947)"
|
||||||
|
Em Beihold,Q111333139,female,solo,pop music,United States,American singer-songwriter
|
||||||
|
Emilee,Q98635221,female,solo,pop music,United States,"American singer, star of TikTok and influencer"
|
||||||
|
Eminem,Q5608,male,solo,hip-hop; horrorcore; hardcore hip-hop; comedy hip-hop; Midwest hip-hop; rap rock,United States,"American rapper, record producer and actor"
|
||||||
|
Encanto Cast,Q140814124,,,,United States,ensemble cast credited on songs from the 2021 Disney animated film Encanto
|
||||||
|
Eric Church,Q975911,male,solo,country music,United States,American country singer
|
||||||
|
ERNEST,Q110442717,male,solo,country music,United States,American hip hop and country musician
|
||||||
|
Eslabón Armado,Q112702265,,group,regional Mexican,United States,American hispanic radio music group
|
||||||
|
Fat Joe,Q309888,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop; boom bap; mafioso rap,United States,American rapper
|
||||||
|
Fetty Wap,Q19361511,male,solo,hip-hop; trap music; pop rap,United States,American rapper and singer (born 1991)
|
||||||
|
Fifth Harmony,Q3087893,,group,pop music,United States,American girl group
|
||||||
|
Fifty Fifty,Q116731010,,group,K-pop; J-pop,South Korea,South Korean girl group
|
||||||
|
Flipp Dinero,Q57584556,male,solo,hip-hop,United States,American rapper
|
||||||
|
Flo Milli,Q89352069,female,solo,hip-hop,United States,American rapper
|
||||||
|
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,American rapper and singer (born 1979)
|
||||||
|
Florida Georgia Line,Q3644642,,group,country music,United States,American country music duo
|
||||||
|
FloyyMenor,Q125819969,male,solo,reggaeton,Chile,Chilean singer-songwriter
|
||||||
|
Flume,Q4038837,male,solo,electronica; trip hop; future bass,Australia,Australian music producer
|
||||||
|
Frank Ocean,Q357645,male,solo,alternative R&B; psychedelic soul; progressive soul; contemporary R&B; pop music; soul; hip-hop; avant-pop,United States,"American singer, songwriter, and rapper"
|
||||||
|
French Montana,Q1455261,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop,Morocco,Moroccan and American rapper
|
||||||
|
Fuerza Regida,Q118466501,,group,regional Mexican; banda music; corrido; corridos tumbados; sierreña; norteño,United States,regional Mexican group
|
||||||
|
Future,Q3445057,male,solo,Southern hip-hop; trap music; mumble rap; contemporary R&B,United States,American rapper (born 1983)
|
||||||
|
G-Eazy,Q5511893,male,solo,hip-hop,United States,American rapper
|
||||||
|
Gabby Barrett,Q53912835,female,solo,country pop,United States,American country singer
|
||||||
|
GAYLE,Q109768702,female,solo,pop-punk; pop rock; power pop; emo pop; alternative pop; alternative rock,United States,American singer-songwriter
|
||||||
|
Gigi Perez,Q130235359,female,solo,indie folk,United States,American singer-songwriter
|
||||||
|
Giveon,Q96418479,male,solo,contemporary R&B,United States,American R&B singer
|
||||||
|
Glass Animals,Q16839267,,group,indie rock; psychedelic pop; indietronica; alternative pop; electronic rock; psychedelic rock,United Kingdom,English indie rock band
|
||||||
|
GloRilla,Q114132368,female,solo,hip-hop; trap music; crunk; Memphis rap; Southern hip-hop,United States,American rapper
|
||||||
|
gnash,Q23978226,male,solo,pop music; hip-hop; alternative R&B; indie folk,United States,American musician
|
||||||
|
Gracie Abrams,Q106301562,female,solo,bedroom pop; pop music,United States,American singer-songwriter (born 1999)
|
||||||
|
Grey,Q28451953,,group,electronic music; pop music; future bass,United States,American electronic music group consisting of brothers Kyle and Michael Trewartha
|
||||||
|
Grupo Frontera,Q118141487,,group,regional Mexican; norteño; cumbia norteña mexicana,United States,Mexican-American musical band
|
||||||
|
Gucci Mane,Q206032,male,solo,Southern hip-hop; trap music; gangsta rap; hardcore hip-hop; dirty south; Atlanta hip-hop; dirty rap,United States,American rapper and music executive (born 1980)
|
||||||
|
Gunna,Q55613105,male,solo,hip-hop; trap music; Southern hip-hop; contemporary R&B,United States,American rapper (born 1993)
|
||||||
|
Gus Dapperton,Q51858412,male,solo,pop music,United States,American singer-songwriter
|
||||||
|
Gwen Stefani,Q483379,female,solo,pop music; contemporary R&B; ska; electronica; new wave; rock music,United States,American singer
|
||||||
|
H.E.R.,Q30639849,female,solo,contemporary R&B,United States,American R&B singer
|
||||||
|
Hailee Steinfeld,Q231726,female,solo,pop music; dance-pop,United States,American actress and singer-songwriter (born 1996)
|
||||||
|
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,American singer-songwriter (born 1994)
|
||||||
|
HARDY,Q63341424,male,solo,country music,United States,American country singer
|
||||||
|
Harry Styles,Q3626966,male,solo,soft rock; pop music; rock music; Britpop; traditional folk music,United Kingdom,"English singer, songwriter and actor"
|
||||||
|
Hozier,Q16233232,male,solo,blues; blues rock; rhythm and blues; traditional folk music; soul; indie rock,Ireland,Irish musician and singer
|
||||||
|
Hudson Westbrook,Q134125831,male,solo,country music,United States,American singer
|
||||||
|
iann dior,Q65631841,male,solo,emo rap; pop rap; rap rock; pop rock,United States,American rapper and singer
|
||||||
|
Ice Spice,Q114114977,female,solo,Bronx drill; hip-hop; East Coast hip-hop; Jersey club; pop rap,United States,American rapper (born 2000)
|
||||||
|
Imagine Dragons,Q391348,,group,pop rock; electropop; alternative rock; new wave,United States,American pop rock band
|
||||||
|
Infared,Q140814135,male,solo,hip-hop,,hip-hop songwriter and rapper featured on the 2016 single All the Way Up
|
||||||
|
Internet Money,Q99691610,,,hip-hop,United States,American record label and record producer collective
|
||||||
|
J Balvin,Q966845,male,solo,reggaeton; Latin pop; Latin trap,Colombia,Colombian singer
|
||||||
|
J. Cole,Q204018,male,solo,hip-hop; Southern hip-hop; pop rap; East Coast hip-hop; conscious hip-hop,United States,American rapper and record producer (born 1985)
|
||||||
|
Jack Harlow,Q59727812,male,solo,hip-hop; pop rap,United States,American rapper and singer (born 1998)
|
||||||
|
Jake Scott,Q140814152,male,solo,pop music,United States,American singer-songwriter
|
||||||
|
James Arthur,Q717626,male,solo,pop music,United Kingdom,British singer (born 1988)
|
||||||
|
James Bay,Q17811637,male,solo,rock music; pop music; soul; Americana,United Kingdom,British singer-songwriter
|
||||||
|
James Blake,Q350362,male,solo,pop music; electronica; post-dubstep; electronic dance music,United Kingdom,English singer-songwriter (born 1988)
|
||||||
|
Jason Aldean,Q195439,male,solo,country music,United States,American country singer
|
||||||
|
Jason Derulo,Q243639,male,solo,pop music; rhythm and blues; contemporary R&B; hip-hop,United States,American singer-songwriter
|
||||||
|
Jawsh 685,Q96472235,male,solo,pop music,New Zealand,New Zealand music producer
|
||||||
|
Jay Rock,Q152632,male,solo,West Coast hip-hop,United States,American rapper (born 1985)
|
||||||
|
Jay Z,Q62766,male,solo,East Coast hip-hop; mafioso rap; gangsta rap; hardcore hip-hop,United States,"American rapper, record producer, and entrepreneur"
|
||||||
|
Jelly Roll,Q6176881,male,solo,Southern hip-hop; country rap; country music; hardcore hip-hop,United States,American rapper (born 1984)
|
||||||
|
Jeremih,Q472891,male,solo,contemporary R&B,United States,American R&B singer (born 1987)
|
||||||
|
Jessica Darrow,Q109718598,non-binary,solo,,United States,American actor and singer
|
||||||
|
Jessie Murph,Q122157373,female,solo,country rock; country pop; pop music,United States,American singer
|
||||||
|
Jhay Cortez,Q75997191,male,solo,Latin trap; reggaeton; Latin R&B,United States,Puerto Rican singer
|
||||||
|
Jhené Aiko,Q15929781,female,solo,hip-hop; pop music; rhythm and blues; soul; alternative R&B; neo soul; contemporary R&B,United States,American R&B singer (born 1988)
|
||||||
|
JID,Q29588830,male,solo,hip-hop,United States,"American rapper, singer, and songwriter from Georgia"
|
||||||
|
Jimin,Q20947093,male,solo,K-pop,South Korea,South Korean singer and dancer (BTS)
|
||||||
|
JNR CHOI,Q111507858,male,solo,hip-hop; trap music; world music,United Kingdom,Gambian-British rapper and singer
|
||||||
|
John Legend,Q44857,male,solo,soul; pop music; contemporary R&B,United States,American singer-songwriter
|
||||||
|
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,Japanese-Australian entertainer (born 1992)
|
||||||
|
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,"American singer, rapper, songwriter, and record producer"
|
||||||
|
Jon Pardi,Q6271354,male,solo,country music,United States,American country singer
|
||||||
|
Jonas Brothers,Q134233,,group,pop music; pop rock,United States,American pop rock band
|
||||||
|
Jordan Davis,Q44274417,male,solo,country pop; country music; pop music,United States,American country music singer and songwriter
|
||||||
|
JP Saxe,Q86009464,male,solo,pop music,Canada,Canadian singer and musician
|
||||||
|
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,American rapper and singer (1998–2019)
|
||||||
|
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,American record producer and rapper (born 1975)
|
||||||
|
Julia Michaels,Q20051697,female,solo,pop music; electronic dance music,United States,American singer and songwriter (born 1993)
|
||||||
|
Jung Kook,Q22338877,male,solo,K-pop,South Korea,South Korean singer
|
||||||
|
Justin Bieber,Q34086,male,solo,pop music; contemporary R&B; tropical house; reggaeton; contemporary country,Canada,Canadian singer-songwriter (born 1994)
|
||||||
|
Justin Timberlake,Q43432,male,solo,pop music; contemporary R&B,United States,American singer and actor (born 1981)
|
||||||
|
JVKE,Q114865231,male,solo,pop music,United States,American singer and producer
|
||||||
|
Kacey Musgraves,Q2783855,female,solo,country music; folk music,United States,American singer (born 1988)
|
||||||
|
Kai,Q6347342,female,solo,pop music,Canada,Canadian singer and songwriter
|
||||||
|
Kali Uchis,Q19004484,female,solo,contemporary R&B,United States,American singer and songwriter (born 1994)
|
||||||
|
Kaliii,Q115633164,female,solo,hip-hop; trap music; Southern hip-hop,United States,American rapper
|
||||||
|
Kane Brown,Q21620939,male,solo,country music,United States,American country music singer
|
||||||
|
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,American rapper and record producer
|
||||||
|
Karol G,Q22001324,female,solo,reggaeton; Latin pop; Latin R&B,Colombia,Colombian singer and songwriter
|
||||||
|
Kate Bush,Q636,female,solo,art pop; progressive pop; singer-songwriter music; baroque pop; experimental pop; art rock; pop rock,United Kingdom,"English singer, pianist and songwriter (born 1958)"
|
||||||
|
Katelyn Brown,Q140814175,female,solo,pop music,United States,American singer
|
||||||
|
Katy Perry,Q42493,female,solo,pop music; rock music; pop rock; dance music; synth-pop; electropop; gospel music,United States,American singer
|
||||||
|
Kehlani,Q20657541,non-binary,solo,contemporary R&B; pop music; neo soul; hip-hop,United States,American singer-songwriter (born 1995)
|
||||||
|
Keith Urban,Q315547,male,solo,country music,Australia,Australian-American musician
|
||||||
|
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,American rapper (born 1987)
|
||||||
|
Kenny Chesney,Q467526,male,solo,country music,United States,American country musician
|
||||||
|
Kent Jones,Q25190082,male,solo,pop rap; contemporary R&B; pop music,United States,American singer and rapper (born 1993)
|
||||||
|
Kesha,Q33605,female,solo,electropop; dance-pop; synth-pop; pop rock; pop rap; rock music; dance music; country music,United States,American singer and songwriter (born 1987)
|
||||||
|
Kevin Gates,Q6396353,male,solo,hip-hop; gangsta rap; hardcore hip-hop; dirty south; Southern hip-hop,United States,American rapper
|
||||||
|
Kevin Woo,Q6397789,male,solo,K-pop,United States,Korean-American singer
|
||||||
|
Khalid,Q28816483,male,solo,contemporary R&B,United States,American singer (born 1998)
|
||||||
|
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,"American rapper, singer, record producer and actor (born 1984)"
|
||||||
|
Kiiara,Q23042767,female,solo,pop music; electropop; alternative R&B; experimental pop,United States,American singer and songwriter
|
||||||
|
Kim Petras,Q60964,trans woman,solo,pop music,Germany,German singer
|
||||||
|
Kodak Black,Q22005867,male,solo,hip-hop; trap music; SoundCloud rap; gangsta rap; Southern hip-hop; mumble rap,United States,American rapper (born. 1997)
|
||||||
|
Koe Wetzel,Q107434048,male,solo,country music; rock music,United States,American musician
|
||||||
|
Kygo,Q16845512,male,solo,tropical house; deep house; downtempo; progressive house; house music; electronic dance music,Norway,Norwegian DJ and music producer
|
||||||
|
Kyla,Q25190133,female,solo,UK funky,United Kingdom,British singer
|
||||||
|
KYLE,Q28606665,male,solo,West Coast hip-hop; pop rap,United States,American rapper and singer
|
||||||
|
Lady Gaga,Q19848,female,solo,electropop; dance-pop; electronic dance music; art pop; pop music; synth-pop,United States,"American singer, songwriter, and actress (born 1986)"
|
||||||
|
Lainey Wilson,Q106378786,female,solo,country music; folk music,United States,American singer-songwriter
|
||||||
|
Latto,Q65029930,female,solo,hip-hop; trap music; pop music; pop rap,United States,American rapper (born 1998)
|
||||||
|
Lauren Alaina,Q271796,female,solo,country music; folk music,United States,American singer & songwriter (born 1994)
|
||||||
|
Lauren Daigle,Q18685595,female,solo,contemporary Christian music,United States,American singer and songwriter (born 1991)
|
||||||
|
Lauren Spencer-Smith,Q110630787,female,solo,pop music,Canada,Canadian singer and songwriter
|
||||||
|
Lauv,Q30122224,male,solo,electronic music; pop music,United States,American musician
|
||||||
|
Lee Brice,Q1811729,male,solo,country music,United States,American country singer
|
||||||
|
Lefty Gunplay,Q131374668,male,solo,hip-hop; West Coast hip-hop; gangsta rap,United States,American rapper
|
||||||
|
Leon Thomas,Q714375,male,solo,contemporary R&B; pop music; soul; hip-hop,United States,"American actor, singer, songwriter and producer"
|
||||||
|
Lewis Capaldi,Q42315001,male,solo,blue-eyed soul; pop music; pop rock; alternative rock; soft rock,United Kingdom,Scottish singer-songwriter (born 1996)
|
||||||
|
Liam Payne,Q2756349,male,solo,pop music; contemporary R&B,United Kingdom,English singer and songwriter (1993–2024)
|
||||||
|
Lil Baby,Q50527563,male,solo,hip-hop; trap music; Southern hip-hop,United States,American rapper (born 1994)
|
||||||
|
Lil Dicky,Q16876006,male,solo,hip-hop; pop rap; comedy hip-hop,United States,American rapper and comedian (born 1988)
|
||||||
|
Lil Durk,Q2899818,male,solo,Midwest hip-hop; trap music; drill; gangsta rap; hardcore hip-hop; Chicago hip-hop; Chicago drill,United States,American rapper
|
||||||
|
Lil Mosey,Q55819454,male,solo,hip-hop; pop rap; trap music,United States,American rapper and singer-songwriter
|
||||||
|
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,"American rapper, singer and songwriter (born 1999)"
|
||||||
|
Lil Pump,Q38002101,male,solo,hip-hop; trap music; SoundCloud rap; punk rap; Southern hip-hop,United States,American rapper
|
||||||
|
Lil Tecca,Q65054660,male,solo,hip-hop; trap music,United States,American rapper and singer-songwriter
|
||||||
|
Lil Tjay,Q61670761,male,solo,hip-hop,United States,American rapper
|
||||||
|
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,American rapper
|
||||||
|
Lil Wayne,Q15615,male,solo,Southern hip-hop; bounce music; gangsta rap; trap music; pop rap; dirty south; hardcore hip-hop,United States,American rapper and singer
|
||||||
|
Lil Yachty,Q23772141,male,solo,hip-hop; trap music; pop rap; mumble rap; cloud rap; Southern hip-hop,United States,American rapper and singer
|
||||||
|
Lizzo,Q16232225,female,solo,hip-hop; soul; contemporary R&B; gospel music,United States,American singer and rapper (born 1988)
|
||||||
|
Logic,Q6667429,male,solo,hip-hop; pop rap; conscious hip-hop; East Coast hip-hop,United States,American rapper from Maryland
|
||||||
|
Lola Young,Q115869427,female,solo,pop music; soul; contemporary R&B,United Kingdom,British singer-songwriter (born 2001)
|
||||||
|
Luis Fonsi,Q1387726,male,solo,pop music; Latin pop; ballad; reggaeton,United States,Puerto Rican singer
|
||||||
|
Lukas Graham,Q300935,,group,soul; pop music; pop rock; funk,Denmark,Danish band
|
||||||
|
Luke Bryan,Q166197,male,solo,country music,United States,American country music singer
|
||||||
|
Luke Combs,Q23884740,male,solo,country music,United States,American country singer
|
||||||
|
Machine Gun Kelly,Q1354843,male,solo,hip-hop; pop rap; rap rock; pop-punk; alternative rock; alternative hip-hop,United States,American musician (born 1990)
|
||||||
|
Maddie,Q54607228,female,solo,country music,United States,"Singer, part of Maddie & Tae"
|
||||||
|
Mae Muller,Q83696883,female,solo,pop music; rhythm and blues,United Kingdom,British singer
|
||||||
|
Major Lazer,Q2604214,,group,dancehall; moombahton; reggae fusion; trap music; electronic dance music; hip-hop; electro house; rocksteady,United States,Jamaican-American electronic music band
|
||||||
|
Maren Morris,Q21622122,female,solo,country music; country pop; folk rock; folk music,United States,American country singer
|
||||||
|
Mariah Carey,Q41076,female,solo,contemporary R&B; pop music; adult contemporary music; dance-pop; hip-hop,United States,American singer (born 1969)
|
||||||
|
Mariah the Scientist,Q84356665,female,solo,alternative R&B,United States,American singer (born 1997)
|
||||||
|
Marian Hill,Q20858093,,group,electronic music,United States,American musical duo
|
||||||
|
Maroon 5,Q182223,,group,alternative rock; funk rock; pop rock; pop music,United States,American pop rock band
|
||||||
|
Marshmello,Q26334949,male,solo,electronic dance music,United States,American DJ and electronic music producer (born 1992)
|
||||||
|
Masked Wolf,Q105490126,male,solo,hip-hop,Australia,Australian rapper
|
||||||
|
Mauro Castillo,Q16605580,male,solo,salsa,Colombia,"Colombian singer, composer, and actor"
|
||||||
|
MAX,Q1190160,male,solo,pop music,United States,American singer
|
||||||
|
Meek Mill,Q1897911,male,solo,East Coast hip-hop; hardcore hip-hop; gangsta rap; trap music; hip-hop,United States,American rapper
|
||||||
|
Megan Moroney,Q116786312,female,solo,country music; folk music,United States,American country music singer
|
||||||
|
Megan Thee Stallion,Q59268408,female,solo,hip-hop; trap music; dirty rap; pop rap; pop music,United States,"American rapper, singer and songwriter"
|
||||||
|
Meghan Trainor,Q17403494,female,solo,pop music; contemporary R&B; dance-pop; soul; doo-wop,United States,American singer-songwriter (born 1993)
|
||||||
|
Metro Boomin,Q16235273,male,solo,hip-hop; trap music,United States,American record producer (born 1993)
|
||||||
|
Migos,Q15777045,,group,Southern hip-hop; trap music; mumble rap,United States,American hip hop group
|
||||||
|
Miguel,Q73463,male,solo,contemporary R&B; alternative R&B; hip-hop; pop music; soul,United States,American R&B singer
|
||||||
|
Mike Posner,Q268284,male,solo,pop music,United States,American musician
|
||||||
|
Miley Cyrus,Q4235,genderfluid,solo,pop music; country music; pop rock; dance-pop; electropop; rock music; hip-hop; teen pop; country pop,United States,"American singer, actress, and television personality (born 1992)"
|
||||||
|
Miranda Lambert,Q231233,female,solo,country music; folk music,United States,American country singer
|
||||||
|
MNEK,Q6716905,male,solo,pop music,United Kingdom,British singer
|
||||||
|
Moneybagg Yo,Q38459032,male,solo,hip-hop; trap music; gangsta rap; Memphis rap; Southern hip-hop,United States,American rapper from Tennessee
|
||||||
|
Mooski,Q113554625,male,solo,hip-hop,United States,Musical artist
|
||||||
|
Morgan Wallen,Q29561472,male,solo,country music; country pop; bro-country; country rap,United States,American country singer
|
||||||
|
Muni Long,Q7245622,female,solo,contemporary R&B; pop music,United States,American singer
|
||||||
|
Murda Beatz,Q27970613,male,solo,hip-hop,Canada,Canadian record producer (born 1994)
|
||||||
|
Mustard,Q15941115,male,solo,hip-hop; ratchet music; West Coast hip-hop; contemporary R&B,United States,American record producer
|
||||||
|
Myles Smith,Q126076580,male,solo,folk-pop; folk music; pop music; folk rock,United Kingdom,British singer
|
||||||
|
Måneskin,Q56884561,,group,pop rock; glam rock,Italy,Italian rock band
|
||||||
|
MØ,Q10670047,female,solo,indie pop; electronic music,Kingdom of Denmark,Danish singer and songwriter
|
||||||
|
N*E*R*D,Q941293,,group,funk rock; bossa nova; orchestral pop; hip-hop; skate punk,United States,American rock and hip-hop band
|
||||||
|
Natanael Cano,Q104904549,male,solo,corridos tumbados; regional Mexican,Mexico,Mexican rapper and singer
|
||||||
|
Nate Smith,Q110707312,male,solo,country music; country rock,United States,"American country music singer, musician and songwriter"
|
||||||
|
NAV,Q28831246,male,solo,hip-hop; cloud rap; trap music; contemporary R&B,Canada,"Canadian rapper, singer, and record producer (born 1989)"
|
||||||
|
Neckwav,Q140814188,male,solo,contemporary R&B,South Korea,South Korean R&B singer
|
||||||
|
NEIKED,Q26703294,,group,,Sweden,Swedish artist collective
|
||||||
|
Nelly,Q218992,male,solo,Midwest hip-hop; pop rap; contemporary R&B; pop music; country rap; country music,United States,"American rapper, singer and actor (born 1974)"
|
||||||
|
Netón Vega,Q129208676,male,solo,regional Mexican; corridos tumbados,Mexico,Mexican singer-songwriter
|
||||||
|
NF,Q19571648,male,solo,hip-hop,United States,American rapper from Michigan
|
||||||
|
Niall Horan,Q775231,male,solo,pop music; pop rock; folk-pop,Ireland,Irish singer-songwriter
|
||||||
|
Nick Jonas,Q201656,male,solo,pop music; rock music; contemporary R&B,United States,"American singer, actor and musician"
|
||||||
|
Nicki Minaj,Q162202,female,solo,hip-hop; pop music; contemporary R&B; trap music; dance music,Trinidad and Tobago,Trinidadian rapper (born 1982)
|
||||||
|
Nicky Jam,Q1374560,male,solo,reggaeton; Latin trap,United States,American singer
|
||||||
|
Nicky Youre,Q112666811,male,solo,indie pop,United States,American singer-songwriter
|
||||||
|
Niko Moon,Q67399825,male,solo,country music,United States,musical artist
|
||||||
|
Nio García,Q107358694,male,solo,Latin trap; reggaeton,United States,Puerto Rican singer
|
||||||
|
NLE Choppa,Q64009351,male,solo,trap music; hip-hop; drill; Memphis rap; gangsta rap; Southern hip-hop,United States,American rapper from Tennessee (born 2002)
|
||||||
|
Noah Kahan,Q53832755,male,solo,folk-pop,United States,American singer-songwriter (born 1997)
|
||||||
|
Normani,Q28148022,female,solo,pop music; contemporary R&B,United States,American singer and dancer
|
||||||
|
O.T. Genasis,Q18623749,male,solo,hip-hop; trap music,United States,American rapper
|
||||||
|
Offset,Q30612480,male,solo,hip-hop; trap music; Southern hip-hop,United States,American rapper
|
||||||
|
Old Dominion,Q20685018,,group,country pop,United States,American country pop music group (2007–)
|
||||||
|
Oliver Anthony Music,Q121391237,male,solo,country music; folk music,United States,American singer-songwriter
|
||||||
|
Olivia O'Brien,Q23978275,female,solo,dance-pop; pop music,United States,American singer
|
||||||
|
Olivia Rodrigo,Q63243883,female,solo,indie pop; pop music; rock music,United States,American singer and actress (born 2003)
|
||||||
|
One Direction,Q146027,,group,dance-pop; teen pop; pop rock,United Kingdom,English-Irish musical group; boy band
|
||||||
|
OneRepublic,Q1438730,,group,pop rock; alternative rock; cello rock,United States,American pop rock band
|
||||||
|
Ozuna,Q28739901,male,solo,reggaeton; Latin trap; Latin pop; dancehall,United States,Puerto Rican singer
|
||||||
|
P!nk,Q160009,female,solo,pop music; pop rock; contemporary R&B,United States,"American singer, songwriter, and actress (born 1979)"
|
||||||
|
Panic! At The Disco,Q277551,,group,pop rock; synth-pop; pop-punk; baroque pop; emo pop; alternative rock,United States,American musical group; rock solo project band
|
||||||
|
Parker McCollum,Q20684755,male,solo,Americana; country music,United States,American musician
|
||||||
|
Parmalee,Q7139162,,group,country music,United States,American country music band
|
||||||
|
PARTYNEXTDOOR,Q15929398,male,solo,Canadian hip-hop; alternative R&B,Canada,Canadian singer (born 1993)
|
||||||
|
Paul Russell,Q122932071,male,solo,hip-hop; contemporary R&B; pop rap,United States,American singer
|
||||||
|
Peso Pluma,Q117242724,male,solo,regional Mexican; corridos tumbados; reggaeton; Latin trap; cumbia norteña mexicana,Mexico,Mexican singer (born 1999)
|
||||||
|
Pharrell Williams,Q14313,male,solo,contemporary R&B; hip-hop; pop music; funk; rock music; pop rap,United States,American record producer and songwriter
|
||||||
|
Pinkfong,Q55735607,,,children's music,South Korea,Korean children's entertainment brand
|
||||||
|
PinkPantheress,Q107570416,female,solo,pop music; bedroom pop; dance music; alternative pop; drum and bass; 2-step garage; jungle; hyperpop,United Kingdom,British singer-songwriter
|
||||||
|
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,American rapper
|
||||||
|
PnB Rock,Q28155029,male,solo,hip-hop; contemporary R&B; trap music; pop music; pop rap,United States,American rapper (1991–2022)
|
||||||
|
Polo G,Q62953486,male,solo,hip-hop; drill; trap music; conscious hip-hop; Midwest hip-hop; Chicago hip-hop,United States,American rapper
|
||||||
|
Pooh Shiesty,Q105105274,male,solo,trap music; Southern hip-hop; drill; hardcore hip-hop; gangsta rap; Memphis rap,United States,American rapper from Tennessee (born. 1999)
|
||||||
|
Pop Smoke,Q81698554,male,solo,East Coast hip-hop; Brooklyn drill; trap music; gangsta rap; drill; hip-hop,United States,American rapper (1999–2020)
|
||||||
|
Portugal. The Man,Q606879,,group,pop music; indie rock,United States,American rock band
|
||||||
|
Post Malone,Q21621919,male,solo,hip-hop; pop music; country music,United States,American musician (born 1995)
|
||||||
|
Powfu,Q89069821,male,solo,hip-hop; lo-fi music; power pop; rock music,Canada,Canadian rapper and singer
|
||||||
|
Project Pat,Q593741,male,solo,Memphis rap; gangsta rap; hardcore hip-hop; Southern hip-hop; crunk; dirty south; trap music; horrorcore,United States,American rapper
|
||||||
|
Quavo,Q30072039,male,solo,hip-hop; trap music,United States,American rapper
|
||||||
|
Rae Sremmurd,Q18123226,,group,hip-hop; trap music; pop music; pop rap,United States,American hip hop duo
|
||||||
|
Rauw Alejandro,Q61586861,male,solo,contemporary R&B; urban contemporary; Latin R&B; reggaeton; Latin trap; Latin pop; salsa,United States,Puerto Rican singer and songwriter
|
||||||
|
Ravyn Lenae,Q28962463,female,solo,contemporary R&B; alternative R&B; neo soul,United States,American singer
|
||||||
|
RAYE,Q28071144,female,solo,contemporary R&B,United Kingdom,British singer-songwriter
|
||||||
|
REI AMI,Q104842557,female,solo,K-pop; pop music; hip-hop; contemporary R&B,South Korea,South Korean singer and rapper
|
||||||
|
Rema,Q65071082,male,solo,Afrobeats,Nigeria,Nigerian singer-songwriter and rapper (2000–)
|
||||||
|
Remy Boyz,Q63405888,,group,hip-hop,United States,musical group
|
||||||
|
Remy Ma,Q275437,female,solo,East Coast hip-hop,United States,American rapper
|
||||||
|
Rhenzy Feliz,Q34344805,male,solo,,United States,American actor
|
||||||
|
Rich The Kid,Q18637908,male,solo,hip-hop; trap music; mumble rap; plugg music; Southern hip-hop,United States,American rapper
|
||||||
|
Rick Ross,Q297831,male,solo,Southern hip-hop; gangsta rap; mafioso rap; trap music; hardcore hip-hop; dirty south,United States,American rapper and record executive
|
||||||
|
Rihanna,Q36844,female,solo,pop music; dancehall; contemporary R&B; electronic dance music; adult contemporary music,Barbados,Barbadian singer (born 1988)
|
||||||
|
Riley Green,Q57418818,male,solo,country music,United States,American singer/songwriter
|
||||||
|
Ritt Momney,Q105105308,male,solo,folk music; indie pop; synth-pop,United States,American singer-songwriter from Utah
|
||||||
|
Rod Wave,Q78754352,male,solo,hip-hop; contemporary R&B; soul; trap music,United States,American rapper and singer-songwriter (born 1998)
|
||||||
|
Roddy Ricch,Q59209423,male,solo,West Coast hip-hop; trap music; pop rap; gangsta rap; hardcore hip-hop; hip-hop,United States,American rapper (born 1998)
|
||||||
|
ROSÉ,Q27655344,female,solo,K-pop; dance-pop; hip-hop; electronic dance music,New Zealand,South Korean-New Zealand singer (born 1997)
|
||||||
|
Rozes,Q22955580,female,solo,pop music; dance music; electronic dance music,United States,American singer
|
||||||
|
Russell Dickerson,Q24572267,male,solo,country music,United States,American country singer-songwriter
|
||||||
|
Ruth B,Q22056665,female,solo,pop music,Canada,Canadian singer-songwriter
|
||||||
|
Ryan Hurd,Q42224127,male,solo,country music,United States,American country singer
|
||||||
|
Sabrina Carpenter,Q7396400,female,solo,pop music; teen pop; dance-pop; contemporary R&B; electropop; country pop,United States,American singer-songwriter and actress (born 1999)
|
||||||
|
SAINt JHN,Q52274581,male,solo,hip-hop; trap music; contemporary R&B,United States,Guyanese-American rapper and singer
|
||||||
|
Sam Barber,Q124428137,male,solo,country music,United States,American singer-songwriter
|
||||||
|
Sam Hunt,Q16223171,male,solo,country music,United States,American singer and songwriter
|
||||||
|
Sam Smith,Q15123969,non-binary,solo,soul; contemporary R&B; pop music; rhythm and blues,United Kingdom,British singer-songwriter
|
||||||
|
Sam Tompkins,Q112155955,male,solo,pop music,United Kingdom,British singer-songwriter
|
||||||
|
samUIL Lee,Q140814222,male,solo,J-pop,South Korea,"South Korean singer, songwriter and record producer"
|
||||||
|
Saweetie,Q50486592,female,solo,hip-hop,United States,American rapper (born 1993)
|
||||||
|
Scotty McCreery,Q255969,male,solo,country music,United States,American country music singer
|
||||||
|
Sean Paul,Q200577,male,solo,dancehall; pop music,Jamaica,Jamaican dancehall singer and rapper (born 1973)
|
||||||
|
Selena Gomez,Q83287,female,solo,pop music; dance-pop; electropop; pop rock; contemporary R&B; alternative R&B,United States,American singer and actress (born 1992)
|
||||||
|
Sexyy Red,Q119842023,female,solo,hip-hop; trap music; dirty rap; Midwest hip-hop,United States,American rapper (born 1998)
|
||||||
|
Shaboozey,Q125295750,male,solo,alternative country; country music; hip-hop; Americana,United States,American singer (born 1995)
|
||||||
|
SHAED,Q60643444,,group,electropop; indie pop,United States,American musical group
|
||||||
|
Shakira,Q34424,female,solo,pop music; Latin pop; pop rock; Latin rock; dance music; folk music; world music; contemporary R&B; rock music,Spain,"Colombian singer, songwriter, and actress"
|
||||||
|
Shawn Mendes,Q17198340,male,solo,folk-pop; pop music; pop rock,Canada,Canadian singer
|
||||||
|
Shay Mooney,Q116536996,male,solo,country music,United States,"American musician and songwriter, part of Dan + Shay"
|
||||||
|
Sheck Wes,Q56513324,male,solo,East Coast hip-hop; trap music,United States,American rapper (born 1998)
|
||||||
|
Sia,Q181484,female,solo,electropop; indie pop,Australia,Australian singer and songwriter (born 1975)
|
||||||
|
Silentó,Q20354248,male,solo,hip-hop; pop rap; trap music; comedy hip-hop; snap music; pop music,United States,American rapper and singer (born 1998)
|
||||||
|
Skip Marley,Q21621948,male,solo,reggae; pop music; hip-hop; rock music,Jamaica,Jamaican singer and songwriter
|
||||||
|
Snow,Q94708,male,solo,dancehall; reggae; hip-hop; reggae fusion,Canada,Canadian dancehall rapper (born 1969)
|
||||||
|
Social House,Q65117469,,group,pop music,United States,American pop duo
|
||||||
|
sombr,Q133444448,male,solo,alternative pop; bedroom pop,United States,American singer-songwriter
|
||||||
|
SpotemGottem,Q105105325,male,solo,mumble rap; trap music; drill; Southern hip-hop,United States,American rapper
|
||||||
|
StaySolidRocky,Q95918251,male,solo,hip-hop,United States,American rapper
|
||||||
|
Stephanie Beatriz,Q16832110,female,solo,,United States,Argentine-American actress
|
||||||
|
Stephen Sanchez,Q113023532,male,solo,pop music,United States,American singer-songwriter
|
||||||
|
Steve Lacy,Q56733980,male,solo,alternative R&B,United States,"American singer, songwriter, guitarist, and record producer (born 1998)"
|
||||||
|
Summer Walker,Q61959478,female,solo,contemporary R&B,United States,American R&B singer
|
||||||
|
Surf Mesa,Q93771412,male,solo,tropical house,United States,American record producer and multi-instrumentalist
|
||||||
|
Surfaces,Q76330429,,group,pop music,United States,American musical duo
|
||||||
|
Swae Lee,Q30103025,male,solo,hip-hop; trap music,United States,American rapper and singer-songwriter (born 1993)
|
||||||
|
SZA,Q16210722,female,solo,contemporary R&B; alternative R&B; neo soul; pop music; hip-hop,United States,American singer
|
||||||
|
Tae,Q54607285,female,solo,country music,United States,"Singer, part of Maddie & Tae"
|
||||||
|
Tate McRae,Q95129121,female,solo,pop music; alternative pop; dance-pop,Canada,"Canadian singer, songwriter, and dancer"
|
||||||
|
Taylor Swift,Q26876,female,solo,pop music; country music,United States,American singer-songwriter (born 1989)
|
||||||
|
Teddy Swims,Q105542333,male,solo,country music; pop music; rhythm and blues; soul,United States,American singer-songwriter
|
||||||
|
Tems,Q72759173,female,solo,alternative R&B; Afrobeats,Nigeria,Nigerian singer-songwriter (born 1995)
|
||||||
|
Teo,Q55860035,male,solo,hip-hop,United States,American singer and dancer
|
||||||
|
The Chainsmokers,Q7721993,,group,electronic dance music,United States,American electronic music duo
|
||||||
|
The Kid LAROI,Q82890761,male,solo,Australian hip-hop; contemporary R&B; pop music; emo rap,Australia,Australian rapper and singer (born 2003)
|
||||||
|
The Marías,Q85807852,,group,indie pop; psychedelic soul; dream pop; synth-pop,United States,American band
|
||||||
|
The Weeknd,Q2121062,male,solo,alternative R&B; pop music; electropop,Canada,Canadian singer and songwriter (born 1990)
|
||||||
|
Thomas Rhett,Q7793491,male,solo,country music,United States,American country singer
|
||||||
|
Tim McGraw,Q356487,male,solo,country music,United States,American country singer and actor (born 1967)
|
||||||
|
Tommy Richman,Q125778611,male,solo,pop rap; contemporary R&B,United States,American singer
|
||||||
|
Tones And I,Q62887629,female,solo,indie pop; electronic dance music; pop music,Australia,"Australian singer, songwriter and record producer"
|
||||||
|
Toosii,Q106725126,male,solo,hip-hop,United States,American rapper from New York
|
||||||
|
Tory Lanez,Q18353558,male,solo,hip-hop; contemporary R&B,Canada,Canadian rapper and singer (born 1992)
|
||||||
|
Tove Lo,Q15948790,female,solo,pop music; indie pop; synth-pop; electropop,Sweden,Swedish singer
|
||||||
|
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,"American rapper, singer and record producer"
|
||||||
|
Trevor Daniel,Q81738417,male,solo,contemporary R&B; pop music,United States,American singer-songwriter
|
||||||
|
Tucker Wetmore,Q124754909,male,solo,country music,United States,American country music artist
|
||||||
|
twenty one pilots,Q7857806,,group,reggae; alternative rock; electropop; alternative hip-hop; rap rock; indie pop; pop rock,United States,American musical duo
|
||||||
|
Ty Dolla $ign,Q7859785,male,solo,contemporary R&B; West Coast hip-hop; trap music; hip-hop,United States,American singer and rapper (born 1982)
|
||||||
|
Tyga,Q358087,male,solo,West Coast hip-hop; pop rap,United States,American rapper
|
||||||
|
Tyla,Q118105408,female,solo,contemporary R&B; pop music; Amapiano; new jack swing,South Africa,South African singer-songwriter
|
||||||
|
Tyler Cole,Q140814245,male,solo,,United States,"American singer, songwriter and record producer"
|
||||||
|
Tyler Hubbard,Q20740966,male,solo,country music,United States,American singer
|
||||||
|
"Tyler, The Creator",Q167635,male,solo,alternative hip-hop; neo soul; West Coast hip-hop; jazz rap; hardcore hip-hop; horrorcore,United States,American rapper and record producer (born 1991)
|
||||||
|
Usher,Q165911,male,solo,contemporary R&B; pop music; soul; hip-hop; dance music; dance-pop; crunk,United States,American R&B singer (born 1978)
|
||||||
|
Victoria Monét,Q19864989,female,solo,pop music; contemporary R&B; hip-hop,United States,American singer and songwriter (born 1989)
|
||||||
|
Walker Hayes,Q7962267,male,solo,country music,United States,American musician
|
||||||
|
Warren Zeiders,Q120986980,male,solo,country music,United States,American country musician
|
||||||
|
Wham!,Q744087,,group,pop music; new wave; blue-eyed soul,United Kingdom,British pop band
|
||||||
|
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,American singer and actress (born 2000)
|
||||||
|
Willy William,Q17016749,male,solo,house music; reggaeton; zouk,France,"French DJ, record producer, and singer (born 1981)"
|
||||||
|
Wiz Khalifa,Q117139,male,solo,East Coast hip-hop; pop rap,United States,"American rapper, singer and actor (born 1987)"
|
||||||
|
Wizkid,Q16233663,male,solo,Afrobeats; dancehall; rhythm and blues,Nigeria,Nigerian singer and songwriter (born 1990)
|
||||||
|
X Ambassadors,Q15963313,,group,alternative rock,United States,"American rock band from Ithaca, New York"
|
||||||
|
Xavi,Q124219723,male,solo,regional Mexican; corridos tumbados,United States,American singer
|
||||||
|
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,American rapper (1998–2018)
|
||||||
|
Yeat,Q108761978,male,solo,experimental hip-hop; rage; trap music,United States,"American rapper, singer-songwriter, and record producer (born 2000)"
|
||||||
|
YFN Lucci,Q28000403,male,solo,hip-hop,United States,American rapper (born 1991)
|
||||||
|
YG,Q3076050,male,solo,West Coast hip-hop; gangsta rap; dirty rap; hardcore hip-hop,United States,American rapper and actor
|
||||||
|
YK Osiris,Q66095815,male,solo,contemporary R&B; hip-hop,United States,"American rapper, singer, and songwriter"
|
||||||
|
Yng Lvcas,Q117834547,male,solo,reggaeton,Mexico,Mexican singer and rapper
|
||||||
|
YNW Melly,Q56276347,male,solo,Southern hip-hop; trap music; contemporary R&B; gangsta rap; drill; hardcore hip-hop,United States,American rapper
|
||||||
|
Yo Gotti,Q586919,male,solo,Southern hip-hop; trap music; dirty south; hardcore hip-hop; gangsta rap; Memphis rap,United States,American rapper
|
||||||
|
Young Dolph,Q23541285,male,solo,Southern hip-hop; gangsta rap; trap music; Memphis rap; dirty south; hardcore hip-hop,United States,American rapper (1985–2021)
|
||||||
|
Young Nudy,Q62036530,male,solo,hip-hop,United States,American rapper (born 1992)
|
||||||
|
Young Thug,Q15637814,male,solo,Southern hip-hop; trap music; mumble rap; progressive rap; SoundCloud rap,United States,American rapper and singer
|
||||||
|
YoungBoy Never Broke Again,Q36490532,male,solo,hip-hop; Southern hip-hop; gangsta rap; trap music,United States,American rapper (born 1999)
|
||||||
|
Yung Bleu,Q105105248,male,solo,contemporary R&B; hip-hop,United States,American rapper and singer
|
||||||
|
Zacari,Q68593407,male,solo,contemporary R&B; hip-hop; alternative hip-hop; alternative R&B,United States,"American singer, songwriter, and multi-instrumentalist (born 1994)"
|
||||||
|
Zach Bryan,Q106770023,male,solo,alternative country; red dirt; country music,United States,American country musician
|
||||||
|
Zach Top,Q126414316,male,solo,country music,United States,American singer/songwriter
|
||||||
|
Zara Larsson,Q4177928,female,solo,dance-pop; pop music; electropop,Sweden,Swedish singer and songwriter (born 1997)
|
||||||
|
Zay Hilfigerrr,Q55859289,male,solo,hip-hop,United States,American rapper
|
||||||
|
Zayion McCall,Q55859293,male,solo,hip-hop,United States,
|
||||||
|
Zayn,Q3626950,male,solo,pop music; contemporary R&B; dance-pop; alternative R&B,United Kingdom,English singer
|
||||||
|
Zedd,Q21088,male,solo,electronic dance music; house music; electro house,Russia,German electronic music producer (born 1989)
|
||||||
|
Óscar Maydon,Q130241052,male,solo,corridos tumbados,Mexico,Mexican singer
|
||||||
|
@@ -0,0 +1,884 @@
|
|||||||
|
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,
|
||||||
|
@@ -0,0 +1,470 @@
|
|||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
24kGoldn,Q83751918,male,solo,pop rap,United States,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)
|
||||||
|
4batz,Q124313062,male,solo,contemporary R&B,United States,Act II: Date @ 8 (2024#80)
|
||||||
|
5 Seconds of Summer,Q4641147,,group,pop rock; pop-punk,Australia,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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$AP Ferg,Q15648254,male,solo,East Coast hip-hop; trap music,United States,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)
|
||||||
|
Adassa,Q576181,female,solo,reggaeton; urban contemporary; pop music,United States,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)
|
||||||
|
AJR,Q16154058,,group,indie pop; alternative pop; electropop,United States,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)
|
||||||
|
Alex Warren,Q121842162,male,solo,pop music,United States,Ordinary (2025#7)
|
||||||
|
Aminé,Q27830860,male,solo,West Coast hip-hop; alternative hip-hop; trap music; contemporary R&B,United States,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)
|
||||||
|
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)
|
||||||
|
Anne-Marie,Q25396976,female,solo,pop music; contemporary R&B,United Kingdom,Friends (2018#26)|Rockabye (2017#44)
|
||||||
|
Aqua,Q213388,,group,pop music; bubblegum music; Eurodance,Denmark,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)"
|
||||||
|
Arizona Zervas,Q75124792,male,solo,hip-hop; contemporary R&B; pop music,United States,Roxanne (2020#16)
|
||||||
|
Artemas,Q125176874,male,solo,alternative pop,United Kingdom,I Like The Way You Kiss Me (2024#63)
|
||||||
|
ATR Son Son,Q140814072,male,solo,hip-hop,United States,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)
|
||||||
|
Ava Max,Q56755505,female,solo,dance-pop,United States,Kings & Queens (2021#73)|Sweet But Psycho (2019#23)
|
||||||
|
Avery Anna,Q131401753,female,solo,pop rock; country music,United States,Indigo (2025#49)
|
||||||
|
Ayo,Q55860029,male,solo,hip-hop,United States,Rolex (2017#61)
|
||||||
|
AzChike,Q131417489,male,solo,hip-hop,United States,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)
|
||||||
|
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)
|
||||||
|
Bastille,Q689,,group,synth-pop,United Kingdom,Happier (2018#80/2019#6)
|
||||||
|
Bazzi,Q48720231,male,solo,pop music,United States,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)
|
||||||
|
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)"
|
||||||
|
Becky G,Q1393997,female,solo,pop music; hip-hop; Latin music; Latin pop; reggaeton,United States,MAMIII (2022#59)
|
||||||
|
BENEE,Q61749159,female,solo,alternative pop; soul,New Zealand,Supalonely (2020#84)
|
||||||
|
benny blanco,Q818048,male,solo,pop music,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
BigXthaPlug,Q124750337,male,solo,rapping; hip-hop,United States,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)
|
||||||
|
Billy Ray Cyrus,Q231259,male,solo,country music; country pop; country rock; country blues,United States,Old Town Road (2019#1)
|
||||||
|
Bipolar Sunshine,Q16727521,male,solo,pop music,United Kingdom,Middle (2016#80)
|
||||||
|
Bizarrap,Q99937928,male,solo,Latin trap; reggaeton,Argentina,"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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Blanco Brown,Q64736729,male,solo,country rap; trap music; hip-hop,United States,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)
|
||||||
|
Blueface,Q60463134,male,solo,hip-hop; trap music; West Coast hip-hop; gangsta rap,United States,Thotiana (2019#47)
|
||||||
|
Bobby Helms,Q888554,male,solo,country music,United States,Jingle Bell Rock (2022#86/2023#68/2024#68/2025#81)
|
||||||
|
Bomba Estéreo,Q887911,,group,cumbia,Colombia,Ojitos Lindos (2022#87)
|
||||||
|
Bradley Cooper,Q205707,male,solo,,United States,Shallow (2019#19)
|
||||||
|
Brandon Lake,Q100314823,male,solo,contemporary Christian music; Christian rock,United States,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)
|
||||||
|
Brendon Urie,Q1047474,male,solo,pop rock; alternative rock,United States,ME! (2019#43)
|
||||||
|
Brett Young,Q25189679,male,solo,country music,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
BTS,Q13580495,,group,K-pop; dance-pop; Korean hip-hop,South Korea,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)
|
||||||
|
Calboy,Q66085272,male,solo,hip-hop,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Carly Pearce,Q29913337,female,solo,country music,United States,I Hope You're Happy Now (2020#73)
|
||||||
|
Carolina Gaitán,Q5753201,female,solo,,Colombia,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)
|
||||||
|
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)
|
||||||
|
Casper Mágico,Q60333702,male,solo,Latin trap; reggaeton,United States,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)
|
||||||
|
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)"
|
||||||
|
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)
|
||||||
|
Chase Rice,Q5087189,male,solo,country music,United States,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)
|
||||||
|
Chencho Corleone,Q113625582,male,solo,reggaeton,United States,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)
|
||||||
|
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 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 Young,Q952176,male,solo,country music,United States,Famous Friends (2021#69)
|
||||||
|
City Girls,Q60658858,,group,hip-hop,United States,Act Up (2019#68)
|
||||||
|
CJ,Q103868817,male,solo,drill; trap music; East Coast hip-hop; hip-hop,United States,Whoopty (2021#50)
|
||||||
|
CKay,Q98312451,male,solo,Afrobeats,Nigeria,Love Nwantiti (Ah Ah Ah) (2022#55)
|
||||||
|
Clean Bandit,Q11838808,,group,electropop,United Kingdom,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)
|
||||||
|
Coi Leray,Q106376206,female,solo,hip-hop; pop music,United States,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)
|
||||||
|
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)
|
||||||
|
Corey Kent,Q113452679,male,solo,country music,United States,Wild As Her (2023#93)
|
||||||
|
Cris MJ,Q111425015,male,solo,reggaeton,Chile,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)
|
||||||
|
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)
|
||||||
|
Daddy Yankee,Q272591,male,solo,reggaeton; Latin hip-hop,United States,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)
|
||||||
|
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)"
|
||||||
|
Daniel Caesar,Q29589083,male,solo,contemporary R&B; soul; neo soul,Canada,Peaches (2021#10)
|
||||||
|
Danny Chung,Q140814119,male,solo,hip-hop; K-pop,United States,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
|
Darell,Q56467651,male,solo,reggaeton; Latin trap,United States,Te Bote (2018#81)
|
||||||
|
Dasha,Q125157260,female,solo,country pop,United States,Austin (2024#27)
|
||||||
|
David Guetta,Q8298,male,solo,electronic dance music; house music; electroclash; electro house,France,I'm Good (Blue) (2023#10)
|
||||||
|
David Kushner,Q117745879,male,solo,,United States,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)"
|
||||||
|
dazy,Q140814120,male,solo,pop music,,Sunroof (2022#29)
|
||||||
|
Dean Lewis,Q29905254,male,solo,pop music,Australia,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)
|
||||||
|
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)
|
||||||
|
Diane Guerrero,Q17166318,female,solo,,United States,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)
|
||||||
|
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 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)
|
||||||
|
Djo,Q27064031,male,solo,synth-pop,United States,End Of Beginning (2024#47)
|
||||||
|
DNCE,Q21648421,,group,pop rock,United States,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)
|
||||||
|
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)
|
||||||
|
Don Toliver,Q56513383,male,solo,hip-hop; trap music; contemporary R&B,United States,Lemonade (2021#51)|No Pole (2025#97)
|
||||||
|
Dove Cameron,Q13560423,female,solo,pop music; alternative pop; soul,United States,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)
|
||||||
|
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)
|
||||||
|
Duncan Laurence,Q60758679,male,solo,pop music,Kingdom of the Netherlands,Arcade (2021#82)
|
||||||
|
Dustin Lynch,Q5317118,male,solo,country music,United States,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)
|
||||||
|
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)
|
||||||
|
Ella Langley,Q127559972,female,solo,country music,United States,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)
|
||||||
|
Elle King,Q5364665,female,solo,country music; Americana; blues rock; alternative country,United States,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)
|
||||||
|
Elton John,Q2808,male,solo,rock music; pop rock; glam rock; soft rock; rhythm and blues,United Kingdom,Cold Heart (PNAU Remix) (2022#10)
|
||||||
|
Em Beihold,Q111333139,female,solo,pop music,United States,Numb Little Bug (2022#32)
|
||||||
|
Emilee,Q98635221,female,solo,pop music,United States,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)
|
||||||
|
Encanto Cast,Q140814124,,,,United States,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)
|
||||||
|
ERNEST,Q110442717,male,solo,country music,United States,Cowgirls (2024#29)|Flower Shops (2022#96)
|
||||||
|
Eslabón Armado,Q112702265,,group,regional Mexican,United States,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)
|
||||||
|
Fetty Wap,Q19361511,male,solo,hip-hop; trap music; pop rap,United States,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)
|
||||||
|
Fifty Fifty,Q116731010,,group,K-pop; J-pop,South Korea,Cupid (2023#44)
|
||||||
|
Flipp Dinero,Q57584556,male,solo,hip-hop,United States,Leave Me Alone (2019#71)
|
||||||
|
Flo Milli,Q89352069,female,solo,hip-hop,United States,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)
|
||||||
|
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)
|
||||||
|
FloyyMenor,Q125819969,male,solo,reggaeton,Chile,Gata Only (2024#52)
|
||||||
|
Flume,Q4038837,male,solo,electronica; trip hop; future bass,Australia,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)
|
||||||
|
French Montana,Q1455261,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop,Morocco,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)
|
||||||
|
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)
|
||||||
|
G-Eazy,Q5511893,male,solo,hip-hop,United States,"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)
|
||||||
|
GAYLE,Q109768702,female,solo,pop-punk; pop rock; power pop; emo pop; alternative pop; alternative rock,United States,abcdefu (2022#17)
|
||||||
|
Gigi Perez,Q130235359,female,solo,indie folk,United States,Sailor Song (2025#36)
|
||||||
|
Giveon,Q96418479,male,solo,contemporary R&B,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Gracie Abrams,Q106301562,female,solo,bedroom pop; pop music,United States,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)
|
||||||
|
Grupo Frontera,Q118141487,,group,regional Mexican; norteño; cumbia norteña mexicana,United States,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)
|
||||||
|
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)
|
||||||
|
Gus Dapperton,Q51858412,male,solo,pop music,United States,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)
|
||||||
|
H.E.R.,Q30639849,female,solo,contemporary R&B,United States,Slide (2020#99)
|
||||||
|
Hailee Steinfeld,Q231726,female,solo,pop music; dance-pop,United States,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)
|
||||||
|
HARDY,Q63341424,male,solo,country music,United States,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)
|
||||||
|
Hozier,Q16233232,male,solo,blues; blues rock; rhythm and blues; traditional folk music; soul; indie rock,Ireland,Too Sweet (2024#10/2025#34)
|
||||||
|
Hudson Westbrook,Q134125831,male,solo,country music,United States,House Again (2025#95)
|
||||||
|
iann dior,Q65631841,male,solo,emo rap; pop rap; rap rock; pop rock,United States,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)"
|
||||||
|
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)
|
||||||
|
Infared,Q140814135,male,solo,hip-hop,,All The Way Up (2016#87)
|
||||||
|
Internet Money,Q99691610,,,hip-hop,United States,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. 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)
|
||||||
|
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)
|
||||||
|
Jake Scott,Q140814152,male,solo,pop music,United States,She Likes It (2022#91)
|
||||||
|
James Arthur,Q717626,male,solo,pop music,United Kingdom,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 Blake,Q350362,male,solo,pop music; electronica; post-dubstep; electronic dance music,United Kingdom,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 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)
|
||||||
|
Jawsh 685,Q96472235,male,solo,pop music,New Zealand,Savage Love (Laxed - Siren Beat) (2020#35)
|
||||||
|
Jay Rock,Q152632,male,solo,West Coast hip-hop,United States,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)
|
||||||
|
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)
|
||||||
|
Jeremih,Q472891,male,solo,contemporary R&B,United States,Oui (2016#55)
|
||||||
|
Jessica Darrow,Q109718598,non-binary,solo,,United States,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)
|
||||||
|
Jhay Cortez,Q75997191,male,solo,Latin trap; reggaeton; Latin R&B,United States,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)
|
||||||
|
JID,Q29588830,male,solo,hip-hop,United States,Enemy (2022#15)
|
||||||
|
Jimin,Q20947093,male,solo,K-pop,South Korea,Who (2025#57)
|
||||||
|
JNR CHOI,Q111507858,male,solo,hip-hop; trap music; world music,United Kingdom,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)
|
||||||
|
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)
|
||||||
|
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 Pardi,Q6271354,male,solo,country music,United States,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)
|
||||||
|
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)
|
||||||
|
JP Saxe,Q86009464,male,solo,pop music,Canada,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)
|
||||||
|
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)
|
||||||
|
Julia Michaels,Q20051697,female,solo,pop music; electronic dance music,United States,If The World Was Ending (2020#56)|Issues (2017#29)
|
||||||
|
Jung Kook,Q22338877,male,solo,K-pop,South Korea,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 Timberlake,Q43432,male,solo,pop music; contemporary R&B,United States,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)
|
||||||
|
Kacey Musgraves,Q2783855,female,solo,country music; folk music,United States,I Remember Everything (2023#74/2024#9)
|
||||||
|
Kai,Q6347342,female,solo,pop music,Canada,Never Be Like You (2016#60)
|
||||||
|
Kali Uchis,Q19004484,female,solo,contemporary R&B,United States,Telepatia (2021#49)
|
||||||
|
Kaliii,Q115633164,female,solo,hip-hop; trap music; Southern hip-hop,United States,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)
|
||||||
|
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)
|
||||||
|
Karol G,Q22001324,female,solo,reggaeton; Latin pop; Latin R&B,Colombia,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)
|
||||||
|
Katelyn Brown,Q140814175,female,solo,pop music,United States,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)
|
||||||
|
Kehlani,Q20657541,non-binary,solo,contemporary R&B; pop music; neo soul; hip-hop,United States,Folded (2025#98)
|
||||||
|
Keith Urban,Q315547,male,solo,country music,Australia,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)
|
||||||
|
Kenny Chesney,Q467526,male,solo,country music,United States,Get Along (2018#87)
|
||||||
|
Kent Jones,Q25190082,male,solo,pop rap; contemporary R&B; pop music,United States,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)
|
||||||
|
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 Woo,Q6397789,male,solo,K-pop,United States,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)
|
||||||
|
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)
|
||||||
|
Kiiara,Q23042767,female,solo,pop music; electropop; alternative R&B; experimental pop,United States,Gold (2016#52)
|
||||||
|
Kim Petras,Q60964,trans woman,solo,pop music,Germany,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)
|
||||||
|
Koe Wetzel,Q107434048,male,solo,country music; rock music,United States,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)
|
||||||
|
Kyla,Q25190133,female,solo,UK funky,United Kingdom,One Dance (2016#3)
|
||||||
|
KYLE,Q28606665,male,solo,West Coast hip-hop; pop rap,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Lauren Alaina,Q271796,female,solo,country music; folk music,United States,What Ifs (2017#72)
|
||||||
|
Lauren Daigle,Q18685595,female,solo,contemporary Christian music,United States,You Say (2019#60)
|
||||||
|
Lauren Spencer-Smith,Q110630787,female,solo,pop music,Canada,Fingers Crossed (2022#75)
|
||||||
|
Lauv,Q30122224,male,solo,electronic music; pop music,United States,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)
|
||||||
|
Lefty Gunplay,Q131374668,male,solo,hip-hop; West Coast hip-hop; gangsta rap,United States,TV Off (2025#15)
|
||||||
|
Leon Thomas,Q714375,male,solo,contemporary R&B; pop music; soul; hip-hop,United States,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)
|
||||||
|
Liam Payne,Q2756349,male,solo,pop music; contemporary R&B,United Kingdom,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 Dicky,Q16876006,male,solo,hip-hop; pop rap; comedy hip-hop,United States,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 Mosey,Q55819454,male,solo,hip-hop; pop rap; trap music,United States,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 Pump,Q38002101,male,solo,hip-hop; trap music; SoundCloud rap; punk rap; Southern hip-hop,United States,Gucci Gang (2018#44)
|
||||||
|
Lil Tecca,Q65054660,male,solo,hip-hop; trap music,United States,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 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 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 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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Lola Young,Q115869427,female,solo,pop music; soul; contemporary R&B,United Kingdom,Like Him (2025#62)|Messy (2025#28)
|
||||||
|
Luis Fonsi,Q1387726,male,solo,pop music; Latin pop; ballad; reggaeton,United States,Despacito (2017#2)
|
||||||
|
Lukas Graham,Q300935,,group,soul; pop music; pop rock; funk,Denmark,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 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)"
|
||||||
|
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)
|
||||||
|
Maddie,Q54607228,female,solo,country music,United States,Die From A Broken Heart (2020#65)
|
||||||
|
Mae Muller,Q83696883,female,solo,pop music; rhythm and blues,United Kingdom,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)
|
||||||
|
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)
|
||||||
|
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 the Scientist,Q84356665,female,solo,alternative R&B,United States,Burning Blue (2025#58)
|
||||||
|
Marian Hill,Q20858093,,group,electronic music,United States,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)
|
||||||
|
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)
|
||||||
|
Masked Wolf,Q105490126,male,solo,hip-hop,Australia,Astronaut In The Ocean (2021#20)
|
||||||
|
Mauro Castillo,Q16605580,male,solo,salsa,Colombia,We Don't Talk About Bruno (2022#24)
|
||||||
|
MAX,Q1190160,male,solo,pop music,United States,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)
|
||||||
|
Megan Moroney,Q116786312,female,solo,country music; folk music,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Mike Posner,Q268284,male,solo,pop music,United States,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)
|
||||||
|
Miranda Lambert,Q231233,female,solo,country music; folk music,United States,Bluebird (2020#91)
|
||||||
|
MNEK,Q6716905,male,solo,pop music,United Kingdom,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)
|
||||||
|
Mooski,Q113554625,male,solo,hip-hop,United States,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)
|
||||||
|
Muni Long,Q7245622,female,solo,contemporary R&B; pop music,United States,Hrs And Hrs (2022#57)|Made For Me (2024#56)
|
||||||
|
Murda Beatz,Q27970613,male,solo,hip-hop,Canada,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)
|
||||||
|
Myles Smith,Q126076580,male,solo,folk-pop; folk music; pop music; folk rock,United Kingdom,Stargazing (2024#73/2025#29)
|
||||||
|
Måneskin,Q56884561,,group,pop rock; glam rock,Italy,Beggin' (2021#66)
|
||||||
|
MØ,Q10670047,female,solo,indie pop; electronic music,Kingdom of Denmark,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)
|
||||||
|
Natanael Cano,Q104904549,male,solo,corridos tumbados; regional Mexican,Mexico,PRC (2023#90)
|
||||||
|
Nate Smith,Q110707312,male,solo,country music; country rock,United States,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)
|
||||||
|
Neckwav,Q140814188,male,solo,contemporary R&B,South Korea,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
|
NEIKED,Q26703294,,group,,Sweden,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)
|
||||||
|
Netón Vega,Q129208676,male,solo,regional Mexican; corridos tumbados,Mexico,Loco (2025#100)
|
||||||
|
NF,Q19571648,male,solo,hip-hop,United States,Let You Down (2018#29)
|
||||||
|
Niall Horan,Q775231,male,solo,pop music; pop rock; folk-pop,Ireland,Slow Hands (2017#32)
|
||||||
|
Nick Jonas,Q201656,male,solo,pop music; rock music; contemporary R&B,United States,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)
|
||||||
|
Nicky Jam,Q1374560,male,solo,reggaeton; Latin trap,United States,Te Bote (2018#81)|X (2018#90)
|
||||||
|
Nicky Youre,Q112666811,male,solo,indie pop,United States,Sunroof (2022#29)
|
||||||
|
Niko Moon,Q67399825,male,solo,country music,United States,Good Time (2021#84)
|
||||||
|
Nio García,Q107358694,male,solo,Latin trap; reggaeton,United States,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)
|
||||||
|
Noah Kahan,Q53832755,male,solo,folk-pop,United States,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)
|
||||||
|
O.T. Genasis,Q18623749,male,solo,hip-hop; trap music,United States,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)
|
||||||
|
Old Dominion,Q20685018,,group,country pop,United States,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)
|
||||||
|
Olivia O'Brien,Q23978275,female,solo,dance-pop; pop music,United States,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)
|
||||||
|
One Direction,Q146027,,group,dance-pop; teen pop; pop rock,United Kingdom,Perfect (2016#100)
|
||||||
|
OneRepublic,Q1438730,,group,pop rock; alternative rock; cello rock,United States,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)
|
||||||
|
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)
|
||||||
|
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)"
|
||||||
|
Parker McCollum,Q20684755,male,solo,Americana; country music,United States,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)
|
||||||
|
PARTYNEXTDOOR,Q15929398,male,solo,Canadian hip-hop; alternative R&B,Canada,Somebody Loves Me (2025#85)
|
||||||
|
Paul Russell,Q122932071,male,solo,hip-hop; contemporary R&B; pop rap,United States,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)
|
||||||
|
Pharrell Williams,Q14313,male,solo,contemporary R&B; hip-hop; pop music; funk; rock music; pop rap,United States,Feels (2017#74)
|
||||||
|
Pinkfong,Q55735607,,,children's music,South Korea,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)"
|
||||||
|
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)
|
||||||
|
PnB Rock,Q28155029,male,solo,hip-hop; contemporary R&B; trap music; pop music; pop rap,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Portugal. The Man,Q606879,,group,pop music; indie rock,United States,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)
|
||||||
|
Powfu,Q89069821,male,solo,hip-hop; lo-fi music; power pop; rock music,Canada,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)
|
||||||
|
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)
|
||||||
|
Rae Sremmurd,Q18123226,,group,hip-hop; trap music; pop music; pop rap,United States,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)
|
||||||
|
Ravyn Lenae,Q28962463,female,solo,contemporary R&B; alternative R&B; neo soul,United States,Love Me Not (2025#30)
|
||||||
|
RAYE,Q28071144,female,solo,contemporary R&B,United Kingdom,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)
|
||||||
|
Rema,Q65071082,male,solo,Afrobeats,Nigeria,Calm Down (2023#6)
|
||||||
|
Remy Boyz,Q63405888,,group,hip-hop,United States,679 (2016#54)
|
||||||
|
Remy Ma,Q275437,female,solo,East Coast hip-hop,United States,All The Way Up (2016#87)
|
||||||
|
Rhenzy Feliz,Q34344805,male,solo,,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Riley Green,Q57418818,male,solo,country music,United States,Worst Way (2025#35)
|
||||||
|
Ritt Momney,Q105105308,male,solo,folk music; indie pop; synth-pop,United States,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)
|
||||||
|
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)
|
||||||
|
ROSÉ,Q27655344,female,solo,K-pop; dance-pop; hip-hop; electronic dance music,New Zealand,APT. (2025#9)
|
||||||
|
Rozes,Q22955580,female,solo,pop music; dance music; electronic dance music,United States,Roses (2016#27)
|
||||||
|
Russell Dickerson,Q24572267,male,solo,country music,United States,Happen To Me (2025#69)|She Likes It (2022#91)
|
||||||
|
Ruth B,Q22056665,female,solo,pop music,Canada,Lost Boy (2016#76)
|
||||||
|
Ryan Hurd,Q42224127,male,solo,country music,United States,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)
|
||||||
|
SAINt JHN,Q52274581,male,solo,hip-hop; trap music; contemporary R&B,United States,Roses (2020#19)
|
||||||
|
Sam Barber,Q124428137,male,solo,country music,United States,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 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 Tompkins,Q112155955,male,solo,pop music,United Kingdom,To The Moon! (2022#97)
|
||||||
|
samUIL Lee,Q140814222,male,solo,J-pop,South Korea,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||||
|
Saweetie,Q50486592,female,solo,hip-hop,United States,Best Friend (2021#29)|My Type (2019#76)
|
||||||
|
Scotty McCreery,Q255969,male,solo,country music,United States,Damn Strait (2022#93)
|
||||||
|
Sean Paul,Q200577,male,solo,dancehall; pop music,Jamaica,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
SHAED,Q60643444,,group,electropop; indie pop,United States,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)"
|
||||||
|
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)
|
||||||
|
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)"
|
||||||
|
Sheck Wes,Q56513324,male,solo,East Coast hip-hop; trap music,United States,Mo Bamba (2019#49)
|
||||||
|
Sia,Q181484,female,solo,electropop; indie pop,Australia,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)
|
||||||
|
Skip Marley,Q21621948,male,solo,reggae; pop music; hip-hop; rock music,Jamaica,Chained To The Rhythm (2017#73)
|
||||||
|
Snow,Q94708,male,solo,dancehall; reggae; hip-hop; reggae fusion,Canada,Con Calma (2019#65)
|
||||||
|
Social House,Q65117469,,group,pop music,United States,Boyfriend (2019#98)
|
||||||
|
sombr,Q133444448,male,solo,alternative pop; bedroom pop,United States,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)
|
||||||
|
StaySolidRocky,Q95918251,male,solo,hip-hop,United States,Party Girl (2020#64)
|
||||||
|
Stephanie Beatriz,Q16832110,female,solo,,United States,We Don't Talk About Bruno (2022#24)
|
||||||
|
Stephen Sanchez,Q113023532,male,solo,pop music,United States,Until I Found You (2023#50)
|
||||||
|
Steve Lacy,Q56733980,male,solo,alternative R&B,United States,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)
|
||||||
|
Surf Mesa,Q93771412,male,solo,tropical house,United States,ily (2020#95)
|
||||||
|
Surfaces,Q76330429,,group,pop music,United States,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)
|
||||||
|
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)
|
||||||
|
Tae,Q54607285,female,solo,country music,United States,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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Tems,Q72759173,female,solo,alternative R&B; Afrobeats,Nigeria,Essence (2021#60/2022#64)|Wait For U (2022#11/2023#83)
|
||||||
|
Teo,Q55860035,male,solo,hip-hop,United States,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 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 Marías,Q85807852,,group,indie pop; psychedelic soul; dream pop; synth-pop,United States,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)
|
||||||
|
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)
|
||||||
|
Tim McGraw,Q356487,male,solo,country music,United States,Humble And Kind (2016#96)
|
||||||
|
Tommy Richman,Q125778611,male,solo,pop rap; contemporary R&B,United States,Million Dollar Baby (2024#8)
|
||||||
|
Tones And I,Q62887629,female,solo,indie pop; electronic dance music; pop music,Australia,Dance Monkey (2020#14)
|
||||||
|
Toosii,Q106725126,male,solo,hip-hop,United States,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)
|
||||||
|
Tove Lo,Q15948790,female,solo,pop music; indie pop; synth-pop; electropop,Sweden,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)
|
||||||
|
Trevor Daniel,Q81738417,male,solo,contemporary R&B; pop music,United States,Falling (2020#22)
|
||||||
|
Tucker Wetmore,Q124754909,male,solo,country music,United States,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)
|
||||||
|
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)
|
||||||
|
Tyga,Q358087,male,solo,West Coast hip-hop; pop rap,United States,Juicy (2020#87)|Taste (2018#28)
|
||||||
|
Tyla,Q118105408,female,solo,contemporary R&B; pop music; Amapiano; new jack swing,South Africa,Water (2024#24)
|
||||||
|
Tyler Cole,Q140814245,male,solo,,United States,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, 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)
|
||||||
|
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)
|
||||||
|
Victoria Monét,Q19864989,female,solo,pop music; contemporary R&B; hip-hop,United States,On My Mama (2024#91)
|
||||||
|
Walker Hayes,Q7962267,male,solo,country music,United States,AA (2022#61)|Fancy Like (2021#27/2022#35)
|
||||||
|
Warren Zeiders,Q120986980,male,solo,country music,United States,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)
|
||||||
|
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)
|
||||||
|
Willy William,Q17016749,male,solo,house music; reggaeton; zouk,France,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)
|
||||||
|
Wizkid,Q16233663,male,solo,Afrobeats; dancehall; rhythm and blues,Nigeria,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)
|
||||||
|
Xavi,Q124219723,male,solo,regional Mexican; corridos tumbados,United States,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)
|
||||||
|
Yeat,Q108761978,male,solo,experimental hip-hop; rage; trap music,United States,IDGAF (2024#83)
|
||||||
|
YFN Lucci,Q28000403,male,solo,hip-hop,United States,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)
|
||||||
|
YK Osiris,Q66095815,male,solo,contemporary R&B; hip-hop,United States,Worth It (2019#77)
|
||||||
|
Yng Lvcas,Q117834547,male,solo,reggaeton,Mexico,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)
|
||||||
|
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)
|
||||||
|
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 Nudy,Q62036530,male,solo,hip-hop,United States,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)
|
||||||
|
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)
|
||||||
|
Yung Bleu,Q105105248,male,solo,contemporary R&B; hip-hop,United States,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)
|
||||||
|
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 Top,Q126414316,male,solo,country music,United States,I Never Lie (2025#43)
|
||||||
|
Zara Larsson,Q4177928,female,solo,dance-pop; pop music; electropop,Sweden,Never Forget You (2016#46)
|
||||||
|
Zay Hilfigerrr,Q55859289,male,solo,hip-hop,United States,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)
|
||||||
|
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)
|
||||||
|
Zedd,Q21088,male,solo,electronic dance music; house music; electro house,Russia,Starving (2016#94/2017#82)|Stay (2017#17)|The Middle (2018#8)
|
||||||
|
Óscar Maydon,Q130241052,male,solo,corridos tumbados,Mexico,Tu Boda (2025#87)
|
||||||
|
@@ -0,0 +1,884 @@
|
|||||||
|
Title,Artists,Positions
|
||||||
|
'Til You Can't,Cody Johnson,2022#34
|
||||||
|
1-800-273-8255,Logic Featuring Alessia Cara & Khalid,2017#31/2018#84
|
||||||
|
"10,000 Hours",Dan + Shay & Justin Bieber,2020#23
|
||||||
|
2 Phones,Kevin Gates,2016#57
|
||||||
|
20 Cigarettes,Morgan Wallen,2025#90
|
||||||
|
24K Magic,Bruno Mars,2017#16
|
||||||
|
30 For 30,SZA With Kendrick Lamar,2025#21
|
||||||
|
34+35,Ariana Grande Feat. Doja Cat & Megan Thee Stallion,2021#21
|
||||||
|
5 Foot 9,Tyler Hubbard,2022#67
|
||||||
|
679,Fetty Wap Featuring Remy Boyz,2016#54
|
||||||
|
7 Rings,Ariana Grande,2019#7
|
||||||
|
7 Years,Lukas Graham,2016#12
|
||||||
|
A Bar Song (Tipsy),Shaboozey,2024#2/2025#3
|
||||||
|
A Holly Jolly Christmas,Burl Ives,2022#89/2023#71/2024#94
|
||||||
|
A Lot,21 Savage,2019#42
|
||||||
|
AA,Walker Hayes,2022#61
|
||||||
|
abcdefu,GAYLE,2022#17
|
||||||
|
About Damn Time,Lizzo,2022#12
|
||||||
|
Abracadabra,Lady Gaga,2025#56
|
||||||
|
Act II: Date @ 8,4batz Featuring Drake,2024#80
|
||||||
|
Act Up,City Girls,2019#68
|
||||||
|
Adore You,Harry Styles,2020#6
|
||||||
|
Adventure Of A Lifetime,Coldplay,2016#95
|
||||||
|
After All The Bars Are Closed,Thomas Rhett,2025#88
|
||||||
|
Agora Hills,Doja Cat,2024#17
|
||||||
|
Ain't No Love In Oklahoma,Luke Combs,2024#37
|
||||||
|
Ain't That Some,Morgan Wallen,2023#92
|
||||||
|
All I Want For Christmas Is You,Mariah Carey,2020#67/2021#78/2022#65/2023#55/2024#54/2025#59
|
||||||
|
All In My Head (Flex),Fifth Harmony Featuring Fetty Wap,2016#93
|
||||||
|
All My Life,Lil Durk Featuring J. Cole,2023#25
|
||||||
|
All The Stars,Kendrick Lamar & SZA,2018#47
|
||||||
|
All The Way,BigXthaPlug Featuring Bailey Zimmerman,2025#33
|
||||||
|
All The Way Up,"Fat Joe, Remy Ma & Jay Z Featuring French Montana & Infared",2016#87
|
||||||
|
All Time Low,Jon Bellion,2017#75
|
||||||
|
All To Myself,Dan + Shay,2019#97
|
||||||
|
All Too Well (Taylor's Version),Taylor Swift,2022#76
|
||||||
|
Am I Okay?,Megan Moroney,2025#66
|
||||||
|
Anti-Hero,Taylor Swift,2023#4
|
||||||
|
Antidote,Travis Scott,2016#75
|
||||||
|
Anxiety,Doechii,2025#39
|
||||||
|
Anyone,Justin Bieber,2021#74
|
||||||
|
APT.,ROSE & Bruno Mars,2025#9
|
||||||
|
Arcade,Duncan Laurence,2021#82
|
||||||
|
Area Codes,Kaliii,2023#88
|
||||||
|
As It Was,Harry Styles,2022#2/2023#15
|
||||||
|
Astronaut In The Ocean,Masked Wolf,2021#20
|
||||||
|
Attention,Charlie Puth,2017#22
|
||||||
|
Austin,Dasha,2024#27
|
||||||
|
Baby,Lil Baby & DaBaby,2019#84
|
||||||
|
Baby Shark,Pinkfong,2019#75
|
||||||
|
Back In Blood,Pooh Shiesty Featuring Lil Durk,2021#39
|
||||||
|
Back To Friends,sombr,2025#44
|
||||||
|
Back To Sleep,Chris Brown,2016#89
|
||||||
|
Back To You,Selena Gomez,2018#41
|
||||||
|
Backup Plan,Bailey Zimmerman & Luke Combs,2025#80
|
||||||
|
Bad And Boujee,Migos Featuring Lil Uzi Vert,2017#6
|
||||||
|
Bad At Love,Halsey,2018#27
|
||||||
|
Bad Dreams,Teddy Swims,2025#74
|
||||||
|
Bad Guy,Billie Eilish,2019#4/2020#46
|
||||||
|
Bad Habit,Steve Lacy,2022#28/2023#39
|
||||||
|
Bad Habits,Ed Sheeran,2021#15/2022#13
|
||||||
|
Bad Things,Machine Gun Kelly x Camila Cabello,2017#41
|
||||||
|
Baile Inolvidable,Bad Bunny,2025#70
|
||||||
|
Ballin',Mustard & Roddy Ricch,2020#25
|
||||||
|
Bam Bam,Camila Cabello Featuring Ed Sheeran,2022#66
|
||||||
|
Bandit,Juice WRLD & YoungBoy Never Broke Again,2020#63
|
||||||
|
Bang!,AJR,2021#56
|
||||||
|
Bank Account,21 Savage,2017#48
|
||||||
|
Barbie World,Nicki Minaj & Ice Spice With Aqua,2023#46
|
||||||
|
Bartier Cardi,Cardi B Featuring 21 Savage,2018#61
|
||||||
|
Be Alright,Dean Lewis,2019#54
|
||||||
|
Be Careful,Cardi B,2018#59
|
||||||
|
Be Like That,Kane Brown With Swae Lee & Khalid,2020#88
|
||||||
|
Beat Box,SpotemGottem Featuring Pooh Shiesty Or DaBaby,2021#44
|
||||||
|
Beautiful,Bazzi Featuring Camila Cabello,2019#95
|
||||||
|
Beautiful Crazy,Luke Combs,2019#46
|
||||||
|
Beautiful Mistakes,Maroon 5 Featuring Megan Thee Stallion,2021#34
|
||||||
|
Beautiful People,Ed Sheeran Featuring Khalid,2019#50
|
||||||
|
Beautiful Things,Benson Boone,2024#3/2025#6
|
||||||
|
Bebe Dame,Fuerza Regida X Grupo Frontera,2023#73
|
||||||
|
Bed Chem,Sabrina Carpenter,2025#45
|
||||||
|
Beer Never Broke My Heart,Luke Combs,2019#63
|
||||||
|
Before You Go,Lewis Capaldi,2020#21
|
||||||
|
Beggin',Maneskin,2021#66
|
||||||
|
Believer,Imagine Dragons,2017#9/2018#100
|
||||||
|
Best Friend,Saweetie Featuring Doja Cat,2021#29
|
||||||
|
Better,Khalid,2019#20
|
||||||
|
Better Days,NEIKED X Mae Muller X Polo G,2022#73
|
||||||
|
Better Now,Post Malone,2018#13/2019#32
|
||||||
|
Better Together,Luke Combs,2021#57
|
||||||
|
Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",2018#63
|
||||||
|
Big Energy,Latto,2022#7
|
||||||
|
Birds Of A Feather,Billie Eilish,2024#15/2025#5
|
||||||
|
Black Beatles,Rae Sremmurd Featuring Gucci Mane,2017#19
|
||||||
|
Blinding Lights,The Weeknd,2020#1/2021#3
|
||||||
|
Bloody Mary,Lady Gaga,2023#99
|
||||||
|
Blue Strips,Jessie Murph,2025#52
|
||||||
|
Blueberry Faygo,Lil Mosey,2020#27
|
||||||
|
Bluebird,Miranda Lambert,2020#91
|
||||||
|
BMF,SZA,2025#76
|
||||||
|
Bodak Yellow (Money Moves),Cardi B,2017#24/2018#54
|
||||||
|
Body,Megan Thee Stallion,2021#54
|
||||||
|
Body Like A Back Road,Sam Hunt,2017#8
|
||||||
|
Boo'd Up,Ella Mai,2018#15
|
||||||
|
BOP,DaBaby,2020#29
|
||||||
|
Both,Gucci Mane Featuring Drake,2017#83
|
||||||
|
Bounce Back,Big Sean,2017#35
|
||||||
|
"Boy's A Liar, Pt. 2",PinkPantheress & Ice Spice,2023#20
|
||||||
|
Boyfriend,Ariana Grande & Social House,2019#98
|
||||||
|
Boyfriend,Dove Cameron,2022#51
|
||||||
|
Break My Heart,Dua Lipa,2020#33
|
||||||
|
Break My Soul,Beyonce,2022#38
|
||||||
|
"Break Up With Your Girlfriend, I'm Bored",Ariana Grande,2019#36
|
||||||
|
Breathin,Ariana Grande,2019#72
|
||||||
|
Broadway Girls,Lil Durk Featuring Morgan Wallen,2022#81
|
||||||
|
Broccoli,D.R.A.M. Featuring Lil Yachty,2016#34/2017#70
|
||||||
|
Bulletproof,Nate Smith,2024#96
|
||||||
|
Burn It Down,Parker McCollum,2024#84
|
||||||
|
Burning Blue,Mariah The Scientist,2025#58
|
||||||
|
Bury A Friend,Billie Eilish,2019#73
|
||||||
|
Bury Me In Georgia,Kane Brown,2023#89
|
||||||
|
Butter,BTS,2021#11
|
||||||
|
Buy Dirt,Jordan Davis Featuring Luke Bryan,2022#49
|
||||||
|
"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,2023#96
|
||||||
|
Cake By The Ocean,DNCE,2016#18
|
||||||
|
Call Out My Name,The Weeknd,2018#78
|
||||||
|
Calling My Phone,Lil Tjay Featuring 6LACK,2021#33
|
||||||
|
Calm Down,Rema & Selena Gomez,2023#6
|
||||||
|
Can't Feel My Face,The Weeknd,2016#72
|
||||||
|
Can't Stop The Feeling!,Justin Timberlake,2016#9/2017#49
|
||||||
|
Carnival,¥$: Ye & Ty Dolla $ign Featuring Rich The Kid & Playboi Carti,2024#38
|
||||||
|
Caroline,Amine,2017#60
|
||||||
|
Cash Shit,Megan Thee Stallion Featuring DaBaby,2019#90
|
||||||
|
Castle On The Hill,Ed Sheeran,2017#40
|
||||||
|
Chained To The Rhythm,Katy Perry Featuring Skip Marley,2017#73
|
||||||
|
Changes,XXXTENTACION,2018#94
|
||||||
|
Chasin' You,Morgan Wallen,2020#34
|
||||||
|
Chasing After You,Ryan Hurd With Maren Morris,2021#61
|
||||||
|
Cheap Thrills,Sia Featuring Sean Paul,2016#11
|
||||||
|
Chemical,Post Malone,2023#57
|
||||||
|
Circles,Post Malone,2019#62/2020#2
|
||||||
|
Circles Around This Town,Maren Morris,2022#100
|
||||||
|
Close,Nick Jonas Featuring Tove Lo,2016#66
|
||||||
|
Close Friends,Lil Baby,2019#74
|
||||||
|
Close To Me,Ellie Goulding X Diplo Featuring Swae Lee,2019#58
|
||||||
|
Closer,The Chainsmokers Featuring Halsey,2016#10/2017#7
|
||||||
|
Clout,Offset Featuring Cardi B,2019#87
|
||||||
|
Cold,Maroon 5 Featuring Future,2017#69
|
||||||
|
Cold Heart (PNAU Remix),Elton John & Dua Lipa,2022#10
|
||||||
|
Cold Water,Major Lazer Featuring Justin Bieber & MO,2016#25
|
||||||
|
Come & Go,Juice WRLD x Marshmello,2020#54
|
||||||
|
Con Calma,Daddy Yankee & Katy Perry Featuring Snow,2019#65
|
||||||
|
Congratulations,Post Malone Featuring Quavo,2017#10
|
||||||
|
Controlla,Drake,2016#41
|
||||||
|
Cowgirls,Morgan Wallen Featuring ERNEST,2024#29
|
||||||
|
Creepin',"Metro Boomin, The Weeknd & 21 Savage",2023#5
|
||||||
|
Cruel Summer,Taylor Swift,2023#18/2024#12
|
||||||
|
Cry Baby,Megan Thee Stallion Featuring DaBaby,2021#77
|
||||||
|
Cry For Me,The Weeknd,2025#73
|
||||||
|
Cuff It,Beyonce,2023#29
|
||||||
|
Cupid,Fifty Fifty,2023#44
|
||||||
|
Cut It,O.T. Genasis Featuring Young Dolph,2016#91
|
||||||
|
Daisies,Justin Bieber,2025#60
|
||||||
|
Dakiti,Bad Bunny & Jhay Cortez,2021#28
|
||||||
|
Damn Strait,Scotty McCreery,2022#93
|
||||||
|
Dance Monkey,Tones And I,2020#14
|
||||||
|
Dance The Night,Dua Lipa,2023#35/2024#100
|
||||||
|
Dancin' In The Country,Tyler Hubbard,2023#61
|
||||||
|
Dancing With A Stranger,Sam Smith & Normani,2019#14
|
||||||
|
Dangerous Woman,Ariana Grande,2016#36
|
||||||
|
Dark Thoughts,Lil Tecca,2025#96
|
||||||
|
Daylight,David Kushner,2023#62
|
||||||
|
Death Bed,Powfu Featuring beabadoobee,2020#43
|
||||||
|
Deja Vu,Olivia Rodrigo,2021#13
|
||||||
|
Delicate,Taylor Swift,2018#24
|
||||||
|
Denial Is A River,Doechii,2025#75
|
||||||
|
Despacito,Luis Fonsi & Daddy Yankee Featuring Justin Bieber,2017#2
|
||||||
|
Despues de La Playa,Bad Bunny,2022#78
|
||||||
|
Dial Drunk,Noah Kahan With Post Malone,2023#80
|
||||||
|
Die A Happy Man,Thomas Rhett,2016#64
|
||||||
|
Die For You,The Weeknd & Ariana Grande,2023#7
|
||||||
|
Die From A Broken Heart,Maddie & Tae,2020#65
|
||||||
|
Die With A Smile,Lady Gaga & Bruno Mars,2024#62/2025#1
|
||||||
|
Dior,Pop Smoke,2020#83
|
||||||
|
DNA.,Kendrick Lamar,2017#62
|
||||||
|
Do Re Mi,Blackbear,2017#98
|
||||||
|
Does To Me,Luke Combs Featuring Eric Church,2020#93
|
||||||
|
Doin' This,Luke Combs,2022#71
|
||||||
|
Don't,Bryson Tiller,2016#35
|
||||||
|
Don't Let Me Down,The Chainsmokers Featuring Daya,2016#8
|
||||||
|
Don't Mind,Kent Jones,2016#48
|
||||||
|
Don't Start Now,Dua Lipa,2020#4
|
||||||
|
Don't Wanna Know,Maroon 5 Featuring Kendrick Lamar,2017#38
|
||||||
|
Down,Marian Hill,2017#90
|
||||||
|
Down Bad,Taylor Swift,2024#99
|
||||||
|
Down In The DM,Yo Gotti Featuring Nicki Minaj,2016#69
|
||||||
|
Drinkin' Beer. Talkin' God. Amen.,Chase Rice Featuring Florida Georgia Line,2021#99
|
||||||
|
Drip Too Hard,Lil Baby & Gunna,2019#26
|
||||||
|
Drivers License,Olivia Rodrigo,2021#8
|
||||||
|
Drowning,A Boogie Wit da Hoodie Featuring Kodak Black,2017#81
|
||||||
|
DTMF,Bad Bunny,2025#51
|
||||||
|
Dura,Daddy Yankee,2018#93
|
||||||
|
Dynamite,BTS,2020#38/2021#41
|
||||||
|
Eastside,"Benny Blanco, Halsey & Khalid",2018#77/2019#17
|
||||||
|
Easy On Me,Adele,2022#4
|
||||||
|
Efecto,Bad Bunny,2022#69
|
||||||
|
Ella Baila Sola,Eslabon Armado X Peso Pluma,2023#26
|
||||||
|
Emotionally Scarred,Lil Baby,2020#74
|
||||||
|
End Of Beginning,Djo,2024#47
|
||||||
|
Enemy,Imagine Dragons X JID,2022#15
|
||||||
|
Envy Me,Calboy,2019#59
|
||||||
|
EOO,Bad Bunny,2025#77
|
||||||
|
Escapism,RAYE Featuring 070 Shake,2023#48
|
||||||
|
Espresso,Sabrina Carpenter,2024#7/2025#12
|
||||||
|
Essence,Wizkid Featuring Justin Bieber & Tems,2021#60/2022#64
|
||||||
|
Euphoria,Kendrick Lamar,2024#66
|
||||||
|
Even Though I'm Leaving,Luke Combs,2020#85
|
||||||
|
Every Chance I Get,DJ Khaled Featuring Lil Baby & Lil Durk,2021#59
|
||||||
|
Everybody,Nicki Minaj Featuring Lil Uzi Vert,2024#71
|
||||||
|
Everyday We Lit,YFN Lucci Featuring PnB Rock,2017#95
|
||||||
|
Everything I Love,Morgan Wallen,2023#56
|
||||||
|
Everything I Wanted,Billie Eilish,2020#18
|
||||||
|
Ex's & Oh's,Elle King,2016#63
|
||||||
|
Exchange,Bryson Tiller,2016#53
|
||||||
|
Exes,Tate McRae,2024#93
|
||||||
|
Eyes Closed,Ed Sheeran,2023#64
|
||||||
|
Eyes On You,Chase Rice,2019#96
|
||||||
|
Fake Love,Drake,2017#37
|
||||||
|
Fall In Love,Bailey Zimmerman,2022#54
|
||||||
|
Falling,Trevor Daniel,2020#22
|
||||||
|
Famous Friends,Chris Young + Kane Brown,2021#69
|
||||||
|
Fancy Like,Walker Hayes,2021#27/2022#35
|
||||||
|
Fast Car,Luke Combs,2023#8/2024#23
|
||||||
|
Favorite Song,Toosii,2023#21
|
||||||
|
FE!N,Travis Scott Featuring Playboi Carti,2024#97
|
||||||
|
Feather,Sabrina Carpenter,2024#25
|
||||||
|
Feel It Still,Portugal. The Man,2017#45/2018#33
|
||||||
|
Feels,"Calvin Harris Featuring Pharrell Williams, Katy Perry & Big Sean",2017#74
|
||||||
|
FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,2018#31
|
||||||
|
Finesse,Bruno Mars & Cardi B,2018#14
|
||||||
|
Fingers Crossed,Lauren Spencer-Smith,2022#75
|
||||||
|
First Class,Jack Harlow,2022#6
|
||||||
|
First Person Shooter,Drake Featuring J. Cole,2024#61
|
||||||
|
Flower Shops,ERNEST Featuring Morgan Wallen,2022#96
|
||||||
|
Flowers,Miley Cyrus,2023#2/2024#69
|
||||||
|
Folded,Kehlani,2025#98
|
||||||
|
For Free,DJ Khaled Featuring Drake,2016#59
|
||||||
|
For The Night,Pop Smoke Featuring Lil Baby & DaBaby,2020#49/2021#32
|
||||||
|
Forever After All,Luke Combs,2021#18
|
||||||
|
Fortnight,Taylor Swift Featuring Post Malone,2024#22
|
||||||
|
Freaky Friday,Lil Dicky Featuring Chris Brown,2018#55
|
||||||
|
Free,Rumi & JINU: EJAE & Andrew Choi,2025#92
|
||||||
|
Friends,Marshmello & Anne-Marie,2018#26
|
||||||
|
FTCU,Nicki Minaj,2024#88
|
||||||
|
Fukumean,Gunna,2023#31/2024#85
|
||||||
|
Gata Only,FloyyMenor X Cris Mj,2024#52
|
||||||
|
Get Along,Kenny Chesney,2018#87
|
||||||
|
Get Into It (Yuh),Doja Cat,2022#68
|
||||||
|
Get It Sexyy,Sexyy Red,2024#55
|
||||||
|
Ghost,Justin Bieber,2022#8
|
||||||
|
Girls Like You,Maroon 5 Featuring Cardi B,2018#10/2019#22
|
||||||
|
Glad You Exist,Dan + Shay,2021#64
|
||||||
|
Glimpse Of Us,Joji,2022#52
|
||||||
|
Go Crazy,Chris Brown & Young Thug,2020#39/2021#19
|
||||||
|
God Is A Woman,Ariana Grande,2018#62
|
||||||
|
God's Country,Blake Shelton,2019#53
|
||||||
|
God's Plan,Drake,2018#1
|
||||||
|
Godzilla,Eminem Featuring Juice WRLD,2020#62
|
||||||
|
Going Bad,Meek Mill Featuring Drake,2019#18
|
||||||
|
"Going, Going, Gone",Luke Combs,2023#59
|
||||||
|
Gold,Kiiara,2016#52
|
||||||
|
Golden,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#25
|
||||||
|
Golden Hour,JVKE,2023#41
|
||||||
|
Good 4 U,Olivia Rodrigo,2021#5/2022#31
|
||||||
|
Good As Hell,Lizzo,2020#31
|
||||||
|
Good As You,Kane Brown,2019#86
|
||||||
|
Good Days,SZA,2021#52
|
||||||
|
Good Good,"Usher, Summer Walker & 21 Savage",2024#79
|
||||||
|
"Good Luck, Babe!",Chappell Roan,2024#18/2025#31
|
||||||
|
Good News,Shaboozey,2025#23
|
||||||
|
Good Time,Niko Moon,2021#84
|
||||||
|
Goodbyes,Post Malone Featuring Young Thug,2019#30
|
||||||
|
Goosebumps,Travis Scott,2017#68
|
||||||
|
Got What I Got,Jason Aldean,2020#60
|
||||||
|
Greedy,Tate McRae,2024#13
|
||||||
|
Gucci Gang,Lil Pump,2018#44
|
||||||
|
Gummo,6ix9ine,2018#56
|
||||||
|
H.O.L.Y.,Florida Georgia Line,2016#49
|
||||||
|
Handle On You,Parker McCollum,2023#85
|
||||||
|
Hands To Myself,Selena Gomez,2016#56
|
||||||
|
Happen To Me,Russell Dickerson,2025#69
|
||||||
|
Happier,Marshmello & Bastille,2018#80/2019#6
|
||||||
|
Happier Than Ever,Billie Eilish,2021#94
|
||||||
|
Hard Fought Hallelujah,Brandon Lake X Jelly Roll,2025#84
|
||||||
|
Hard To Forget,Sam Hunt,2020#69
|
||||||
|
Havana,Camila Cabello Featuring Young Thug,2017#96/2018#4
|
||||||
|
Heart Like A Truck,Lainey Wilson,2023#58
|
||||||
|
Heart Of A Woman,Summer Walker,2025#94
|
||||||
|
Heart On Ice,Rod Wave,2020#51
|
||||||
|
Heartbreak Anniversary,Giveon,2021#31
|
||||||
|
Heartless,The Weeknd,2020#28
|
||||||
|
Heat Waves,Glass Animals,2021#16/2022#1
|
||||||
|
Heathens,twenty one pilots,2016#21/2017#58
|
||||||
|
Heaven,Kane Brown,2018#53
|
||||||
|
Hell Of A View,Eric Church,2021#91
|
||||||
|
Hello,Adele,2016#7
|
||||||
|
Here,Alessia Cara,2016#39
|
||||||
|
"Hey Look Ma, I Made It",Panic! At The Disco,2019#61
|
||||||
|
Hide Away,Daya,2016#84
|
||||||
|
High Fashion,Roddy Ricch Featuring Mustard,2020#40
|
||||||
|
High Hopes,Panic! At The Disco,2019#11
|
||||||
|
High Road,Koe Wetzel & Jessie Murph,2024#81/2025#55
|
||||||
|
HIGHEST IN THE ROOM,Travis Scott,2020#45
|
||||||
|
Him & I,G-Eazy & Halsey,2018#45
|
||||||
|
Holy,Justin Bieber Featuring Chance The Rapper,2021#35
|
||||||
|
Homesick,Kane Brown,2020#89
|
||||||
|
Hot,Young Thug Featuring Gunna,2020#72
|
||||||
|
Hot Girl Bummer,blackbear,2020#26
|
||||||
|
Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",2019#93
|
||||||
|
Hot To Go!,Chappell Roan,2024#53
|
||||||
|
Hotline Bling,Drake,2016#24
|
||||||
|
Houdini,Dua Lipa,2024#43
|
||||||
|
Houdini,Eminem,2024#39
|
||||||
|
House Again,Hudson Westbrook,2025#95
|
||||||
|
How Do You Sleep?,Sam Smith,2019#83
|
||||||
|
How It's Done,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#68
|
||||||
|
How Long,Charlie Puth,2018#65
|
||||||
|
Hrs And Hrs,Muni Long,2022#57
|
||||||
|
Humble And Kind,Tim McGraw,2016#96
|
||||||
|
Humble.,Kendrick Lamar,2017#4
|
||||||
|
Hurricane,Luke Combs,2017#76
|
||||||
|
Hymn For The Weekend,Coldplay,2016#73
|
||||||
|
I Ain't Coming Back,Morgan Wallen Featuring Post Malone,2025#72
|
||||||
|
I Ain't Worried,OneRepublic,2022#37
|
||||||
|
I Am Not Okay,Jelly Roll,2024#75/2025#79
|
||||||
|
I Can Do It With A Broken Heart,Taylor Swift,2024#35
|
||||||
|
I Don't Care,Ed Sheeran & Justin Bieber,2019#16
|
||||||
|
I Don't Wanna Live Forever (Fifty Shades Darker),Zayn / Taylor Swift,2017#26
|
||||||
|
I Fall Apart,Post Malone,2018#39
|
||||||
|
I Feel It Coming,The Weeknd Featuring Daft Punk,2017#34
|
||||||
|
I Get The Bag,Gucci Mane Featuring Migos,2017#93/2018#72
|
||||||
|
I Got Better,Morgan Wallen,2025#40
|
||||||
|
I Had Some Help,Post Malone Featuring Morgan Wallen,2024#4/2025#8
|
||||||
|
I Hate U,SZA,2022#50
|
||||||
|
I Hate U I Love U,gnash Featuring Olivia O'Brien,2016#38
|
||||||
|
I Hope,Gabby Barrett Featuring Charlie Puth,2020#12/2021#40
|
||||||
|
I Hope You're Happy Now,Carly Pearce & Lee Brice,2020#73
|
||||||
|
I Know What You Did Last Summer,Shawn Mendes & Camila Cabello,2016#86
|
||||||
|
I Like It,"Cardi B, Bad Bunny & J Balvin",2018#7/2019#69
|
||||||
|
I Like Me Better,Lauv,2018#35
|
||||||
|
I Like The Way You Kiss Me,Artemas,2024#63
|
||||||
|
I Like You (A Happier Song),Post Malone Featuring Doja Cat,2022#26/2023#75
|
||||||
|
I Never Lie,Zach Top,2025#43
|
||||||
|
I Remember Everything,Zach Bryan Featuring Kacey Musgraves,2023#74/2024#9
|
||||||
|
I Took A Pill In Ibiza,Mike Posner,2016#15
|
||||||
|
I Wrote The Book,Morgan Wallen,2023#95
|
||||||
|
I'm Gonna Love You,Cody Johnson & Carrie Underwood,2025#78
|
||||||
|
I'm Good (Blue),David Guetta & Bebe Rexha,2023#10
|
||||||
|
I'm The One,"DJ Khaled Featuring Justin Bieber, Quavo, Chance The Rapper & Lil Wayne",2017#12
|
||||||
|
I'm The Problem,Morgan Wallen,2025#13
|
||||||
|
I'm Upset,Drake,2018#86
|
||||||
|
IDGAF,Drake Featuring Yeat,2024#83
|
||||||
|
IDGAF,Dua Lipa,2018#98
|
||||||
|
If I Can't Have You,Shawn Mendes,2019#29
|
||||||
|
If I Didn't Love You,Jason Aldean & Carrie Underwood,2021#85
|
||||||
|
If The World Was Ending,JP Saxe Featuring Julia Michaels,2020#56
|
||||||
|
ily,surf mesa Featuring Emilee,2020#95
|
||||||
|
In A Minute,Lil Baby,2022#43
|
||||||
|
In Case You Didn't Know,Brett Young,2017#57
|
||||||
|
In My Blood,Shawn Mendes,2018#46
|
||||||
|
In My Feelings,Drake,2018#9
|
||||||
|
In The Night,The Weeknd,2016#61
|
||||||
|
Indigo,Sam Barber Featuring Avery Anna,2025#49
|
||||||
|
Industry Baby,Lil Nas X & Jack Harlow,2021#24/2022#16
|
||||||
|
Intentions,Justin Bieber Featuring Quavo,2020#17
|
||||||
|
Into You,Ariana Grande,2016#51
|
||||||
|
Is It Over Now? (Taylor's Version) [From The Vault],Taylor Swift,2024#33
|
||||||
|
iSpy,KYLE Featuring Lil Yachty,2017#28
|
||||||
|
Issues,Julia Michaels,2017#29
|
||||||
|
It Ain't Me,Kygo x Selena Gomez,2017#27
|
||||||
|
Jimmy Cooks,Drake Featuring 21 Savage,2022#33
|
||||||
|
Jingle Bell Rock,Bobby Helms,2022#86/2023#68/2024#68/2025#81
|
||||||
|
Juicy,Doja Cat & Tyga,2020#87
|
||||||
|
Juju On That Beat (TZ Anthem),Zay Hilfigerrr & Zayion McCall,2017#63
|
||||||
|
Jumpman,Drake & Future,2016#37
|
||||||
|
Just In Case,Morgan Wallen,2025#18
|
||||||
|
Just Like Fire,P!nk,2016#33
|
||||||
|
Just The Way,Parmalee x Blanco Brown,2021#88
|
||||||
|
Just Wanna Rock,Lil Uzi Vert,2023#28
|
||||||
|
Karma,Taylor Swift Featuring Ice Spice,2023#27
|
||||||
|
Kill Bill,SZA,2023#3
|
||||||
|
King's Dead,"Jay Rock, Kendrick Lamar, Future & James Blake",2018#79
|
||||||
|
Kings & Queens,Ava Max,2021#73
|
||||||
|
Kiss Me More,Doja Cat Featuring SZA,2021#6/2022#90
|
||||||
|
Knife Talk,Drake Featuring 21 Savage & Project Pat,2021#86/2022#60
|
||||||
|
Knockin' Boots,Luke Bryan,2019#79
|
||||||
|
La Bebe,Yng Lvcas x Peso Pluma,2023#40
|
||||||
|
La Diabla,Xavi,2024#72
|
||||||
|
Last Christmas,Wham!,2023#84/2024#74/2025#65
|
||||||
|
Last Night,Morgan Wallen,2023#1/2024#28
|
||||||
|
Last Night Lonely,Jon Pardi,2022#95
|
||||||
|
Late At Night,Roddy Ricch,2021#72
|
||||||
|
Late Night Talking,Harry Styles,2022#25
|
||||||
|
Laugh Now Cry Later,Drake Featuring Lil Durk,2020#41/2021#45
|
||||||
|
Lavender Haze,Taylor Swift,2023#32
|
||||||
|
Lean On,Major Lazer & DJ Snake Featuring MO,2016#85
|
||||||
|
Leave Before You Love Me,Marshmello X Jonas Brothers,2021#63
|
||||||
|
Leave Me Alone,Flipp Dinero,2019#71
|
||||||
|
Leave The Door Open,Silk Sonic (Bruno Mars & Anderson .Paak),2021#7
|
||||||
|
Lemon,N*E*R*D & Rihanna,2018#83
|
||||||
|
Lemonade,Internet Money & Gunna Featuring Don Toliver & NAV,2021#51
|
||||||
|
Let It Go,James Bay,2016#44
|
||||||
|
Let Me Love You,DJ Snake Featuring Justin Bieber,2016#47/2017#46
|
||||||
|
Let You Down,NF,2018#29
|
||||||
|
Levitating,Dua Lipa,2021#1/2022#42
|
||||||
|
Liar,Jelly Roll,2025#86
|
||||||
|
Lies Lies Lies,Morgan Wallen,2024#50
|
||||||
|
Life Is Good,Future Featuring Drake,2020#7
|
||||||
|
Lift Me Up,Rihanna,2023#63
|
||||||
|
Lights Down Low,MAX Featuring gnash,2018#66
|
||||||
|
Like Him,"Tyler, The Creator Featuring Lola Young",2025#62
|
||||||
|
Like I Love Country Music,Kane Brown,2022#85
|
||||||
|
Like I'm Gonna Lose You,Meghan Trainor Featuring John Legend,2016#42
|
||||||
|
Like That,"Future, Metro Boomin & Kendrick Lamar",2024#14
|
||||||
|
Lil Bit,Nelly & Florida Georgia Line,2021#70
|
||||||
|
Lil Boo Thang,Paul Russell,2024#78
|
||||||
|
Location,Khalid,2017#21
|
||||||
|
Loco,Neton Vega,2025#100
|
||||||
|
Lonely,Justin Bieber & benny blanco,2021#65
|
||||||
|
Look Alive,BlocBoy JB Featuring Drake,2018#23
|
||||||
|
Look At Me!,XXXTentacion,2017#99
|
||||||
|
Look Back At It,A Boogie Wit da Hoodie,2019#41
|
||||||
|
Look What God Gave Her,Thomas Rhett,2019#85
|
||||||
|
Look What You Made Me Do,Taylor Swift,2017#39
|
||||||
|
Lose Control,Teddy Swims,2024#1/2025#4
|
||||||
|
Lose You To Love Me,Selena Gomez,2020#30
|
||||||
|
Lost Boy,Ruth B,2016#76
|
||||||
|
Love Galore,SZA Featuring Travis Scott,2017#80
|
||||||
|
Love Lies,Khalid & Normani,2018#19/2019#88
|
||||||
|
Love Me Not,Ravyn Lenae,2025#30
|
||||||
|
Love Nwantiti (Ah Ah Ah),CKay,2022#55
|
||||||
|
Love On The Brain,Rihanna,2017#33
|
||||||
|
Love Somebody,Morgan Wallen,2025#11
|
||||||
|
Love You Anyway,Luke Combs,2023#36
|
||||||
|
Love Yourself,Justin Bieber,2016#1
|
||||||
|
Love.,Kendrick Lamar Featuring Zacari,2018#50
|
||||||
|
Lovin On Me,Jack Harlow,2024#5
|
||||||
|
Lovin' On You,Luke Combs,2020#97
|
||||||
|
Low Life,Future Featuring The Weeknd,2016#30
|
||||||
|
Loyalty.,Kendrick Lamar Featuring Rihanna,2017#66
|
||||||
|
Lucid Dreams,Juice WRLD,2018#12/2019#48
|
||||||
|
Lunch,Billie Eilish,2024#48
|
||||||
|
Luther,Kendrick Lamar & SZA,2025#2
|
||||||
|
Luv,Tory Lanez,2016#70
|
||||||
|
Made For Me,Muni Long,2024#56
|
||||||
|
Made You Look,Meghan Trainor,2023#53
|
||||||
|
Magnolia,Playboi Carti,2017#79
|
||||||
|
Malibu,Miley Cyrus,2017#89
|
||||||
|
MAMIII,Becky G X Karol G,2022#59
|
||||||
|
Manchild,Sabrina Carpenter,2025#38
|
||||||
|
Marry Me,Thomas Rhett,2018#76
|
||||||
|
Mask Off,Future,2017#14
|
||||||
|
Me Porto Bonito,Bad Bunny & Chencho Corleone,2022#20
|
||||||
|
Me Too,Meghan Trainor,2016#62
|
||||||
|
ME!,Taylor Swift Featuring Brendon Urie,2019#43
|
||||||
|
"Me, Myself & I",G-Eazy x Bebe Rexha,2016#19
|
||||||
|
Meant To Be,Bebe Rexha & Florida Georgia Line,2018#3
|
||||||
|
Meet Me At Our Spot,THE ANXIETY: WILLOW & Tyler Cole,2022#74
|
||||||
|
Meltdown,Travis Scott Featuring Drake,2023#97
|
||||||
|
Memories,Maroon 5,2020#8
|
||||||
|
Memory Lane,Old Dominion,2023#87
|
||||||
|
Mercy,Brett Young,2018#95
|
||||||
|
Mercy,Shawn Mendes,2017#54
|
||||||
|
Messy,Lola Young,2025#28
|
||||||
|
Mi Gente,J Balvin & Willy William Featuring Beyonce,2017#50/2018#99
|
||||||
|
MIA,Bad Bunny Featuring Drake,2019#44
|
||||||
|
Middle,DJ Snake Featuring Bipolar Sunshine,2016#80
|
||||||
|
Middle Child,J. Cole,2019#25
|
||||||
|
Miles On It,Marshmello & Kane Brown,2024#34
|
||||||
|
Million Dollar Baby,Tommy Richman,2024#8
|
||||||
|
Mine,Bazzi,2018#21
|
||||||
|
Mo Bamba,Sheck Wes,2019#49
|
||||||
|
Monaco,Bad Bunny,2024#82
|
||||||
|
Money,Cardi B,2019#38
|
||||||
|
Money In The Grave,Drake Featuring Rick Ross,2019#34
|
||||||
|
Montero (Call Me By Your Name),Lil Nas X,2021#9
|
||||||
|
Mood,24kGoldn Featuring iann dior,2020#47/2021#4
|
||||||
|
Mood Swings,Pop Smoke Featuring Lil Tjay,2020#81
|
||||||
|
Moonlight,XXXTENTACION,2018#88
|
||||||
|
More Than My Hometown,Morgan Wallen,2020#96
|
||||||
|
Moscow Mule,Bad Bunny,2022#44
|
||||||
|
MotorSport,"Migos, Nicki Minaj & Cardi B",2018#34
|
||||||
|
Murder On My Mind,YNW Melly,2019#66
|
||||||
|
Mutt,Leon Thomas,2025#22
|
||||||
|
My Ex's Best Friend,Machine Gun Kelly X blackbear,2021#23
|
||||||
|
My House,Flo Rida,2016#14
|
||||||
|
My Oh My,Camila Cabello Featuring DaBaby,2020#37
|
||||||
|
My Type,Saweetie,2019#76
|
||||||
|
Mystical Magical,Benson Boone,2025#47
|
||||||
|
Natural,Imagine Dragons,2018#69
|
||||||
|
Need A Favor,Jelly Roll,2023#34/2024#64
|
||||||
|
Need To Know,Doja Cat,2021#46/2022#18
|
||||||
|
Needed Me,Rihanna,2016#13
|
||||||
|
Never Be Like You,Flume Featuring Kai,2016#60
|
||||||
|
Never Be The Same,Camila Cabello,2018#18
|
||||||
|
Never Forget You,Zara Larsson & MNEK,2016#46
|
||||||
|
Never Lose Me,Flo Milli,2024#49
|
||||||
|
Never Say Never,Cole Swindell / Lainey Wilson,2022#92
|
||||||
|
New Rules,Dua Lipa,2018#16
|
||||||
|
Next Thing You Know,Jordan Davis,2023#47
|
||||||
|
Nice For What,Drake,2018#11
|
||||||
|
No,Meghan Trainor,2016#45
|
||||||
|
No Brainer,"DJ Khaled Featuring Justin Bieber, Chance The Rapper & Quavo",2018#73
|
||||||
|
No Guidance,Chris Brown Featuring Drake,2019#21/2020#36
|
||||||
|
No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,2018#30
|
||||||
|
No Limit,Usher Featuring Young Thug,2016#90
|
||||||
|
No More Parties,Coi Leray Featuring Lil Durk,2021#79
|
||||||
|
No One Noticed,The Marias,2025#32
|
||||||
|
No Pole,Don Toliver,2025#97
|
||||||
|
No Promises,Cheat Codes Featuring Demi Lovato,2017#91
|
||||||
|
No Tears Left To Cry,Ariana Grande,2018#20
|
||||||
|
Nobody But You,Blake Shelton Duet With Gwen Stefani,2020#52
|
||||||
|
Nobody Gets Me,SZA,2023#77
|
||||||
|
Nokia,Drake,2025#24
|
||||||
|
Nonstop,Drake,2018#52
|
||||||
|
Not Like Us,Kendrick Lamar,2024#6/2025#17
|
||||||
|
Now Or Never,Halsey,2017#59
|
||||||
|
Nuevayol,Bad Bunny,2025#89
|
||||||
|
Numb Little Bug,Em Beihold,2022#32
|
||||||
|
Oh My God,Adele,2022#72
|
||||||
|
Ojitos Lindos,Bad Bunny & Bomba Estereo,2022#87
|
||||||
|
Old Town Road,Lil Nas X Featuring Billy Ray Cyrus,2019#1
|
||||||
|
On Me,Lil Baby,2021#36
|
||||||
|
On My Mama,Victoria Monet,2024#91
|
||||||
|
On My Mind,Ellie Goulding,2016#81
|
||||||
|
One Call Away,Charlie Puth,2016#43
|
||||||
|
One Dance,Drake Featuring WizKid & Kyla,2016#3
|
||||||
|
One Kiss,Calvin Harris & Dua Lipa,2018#68
|
||||||
|
One Man Band,Old Dominion,2020#59
|
||||||
|
One Margarita,Luke Bryan,2020#70
|
||||||
|
One Mississippi,Kane Brown,2022#99
|
||||||
|
One Number Away,Luke Combs,2018#96
|
||||||
|
One Of Them Girls,Lee Brice,2020#68
|
||||||
|
One Right Now,Post Malone & The Weeknd,2022#30
|
||||||
|
One Thing At A Time,Morgan Wallen,2023#37
|
||||||
|
One Thing Right,Marshmello & Kane Brown,2019#89
|
||||||
|
One Too Many,Keith Urban Duet With P!nk,2021#81
|
||||||
|
Only Human,Jonas Brothers,2019#78/2020#77
|
||||||
|
Ordinary,Alex Warren,2025#7
|
||||||
|
Oui,Jeremih,2016#55
|
||||||
|
Outside Today,YoungBoy Never Broke Again,2018#91
|
||||||
|
P*$$y Fairy (OTW),Jhene Aiko,2020#94
|
||||||
|
Paint The Town Red,Doja Cat,2023#52/2024#21
|
||||||
|
Panda,Desiigner,2016#6
|
||||||
|
Panini,Lil Nas X,2019#40/2020#71
|
||||||
|
Paris,The Chainsmokers,2017#42
|
||||||
|
Party,Bad Bunny & Rauw Alejandro,2022#77
|
||||||
|
Party Girl,StaySolidRocky,2020#64
|
||||||
|
Passionfruit,Drake,2017#65
|
||||||
|
Peaches,Justin Bieber Featuring Daniel Caesar & Giveon,2021#10
|
||||||
|
Peaches & Eggplants,Young Nudy Featuring 21 Savage,2023#94
|
||||||
|
Peekaboo,Kendrick Lamar Featuring AzChike,2025#53
|
||||||
|
Perfect,Ed Sheeran,2018#2
|
||||||
|
Perfect,One Direction,2016#100
|
||||||
|
Pillowtalk,Zayn,2016#22
|
||||||
|
Pink Pony Club,Chappell Roan,2025#10
|
||||||
|
Pink Skies,Zach Bryan,2024#30
|
||||||
|
Plain Jane,A$AP Ferg Featuring Nicki Minaj,2018#74
|
||||||
|
Players,Coi Leray,2023#33
|
||||||
|
Please Me,Cardi B & Bruno Mars,2019#37
|
||||||
|
Please Please Please,Sabrina Carpenter,2024#16/2025#50
|
||||||
|
Plug Walk,Rich The Kid,2018#57
|
||||||
|
Pop Out,Polo G Featuring Lil Tjay,2019#45
|
||||||
|
Pop Style,Drake Featuring The Throne,2016#82
|
||||||
|
Popstar,DJ Khaled Featuring Drake,2020#66
|
||||||
|
Por Las Noches,Peso Pluma,2023#86
|
||||||
|
Positions,Ariana Grande,2021#14
|
||||||
|
Pour Me A Drink,Post Malone Featuring Blake Shelton,2024#76
|
||||||
|
pov,Ariana Grande,2021#87
|
||||||
|
Powerglide,Rae Sremmurd & Juicy J,2018#97
|
||||||
|
Pray For Me,The Weeknd & Kendrick Lamar,2018#40
|
||||||
|
Praying,Kesha,2017#67
|
||||||
|
PRC,Peso Pluma X Natanael Cano,2023#90
|
||||||
|
Pretty Little Poison,Warren Zeiders,2024#60
|
||||||
|
Princess Diana,Ice Spice & Nicki Minaj,2023#69
|
||||||
|
Provenza,Karol G,2022#63
|
||||||
|
Psycho,Post Malone Featuring Ty Dolla $ign,2018#6
|
||||||
|
Puffin On Zootiez,Future,2022#84
|
||||||
|
Pure Water,Mustard & Migos,2019#55
|
||||||
|
Pushin P,Gunna & Future Featuring Young Thug,2022#48
|
||||||
|
Put It On Da Floor Again,Latto Featuring Cardi B,2023#98
|
||||||
|
Put Your Records On,Ritt Momney,2021#93
|
||||||
|
Rags2Riches,Rod Wave Featuring ATR Son Son,2020#90
|
||||||
|
Rain On Me,Lady Gaga & Ariana Grande,2020#48
|
||||||
|
Rake It Up,Yo Gotti Featuring Nicki Minaj,2017#53
|
||||||
|
Ran$om,Lil Tecca,2019#28
|
||||||
|
Rapstar,Polo G,2021#30
|
||||||
|
Rather Lie,Playboi Carti & The Weeknd,2025#91
|
||||||
|
Really Really,Kevin Gates,2016#92
|
||||||
|
Redbone,Childish Gambino,2017#25
|
||||||
|
Redrum,21 Savage,2024#42
|
||||||
|
Religiously,Bailey Zimmerman,2023#42
|
||||||
|
Residuals,Chris Brown,2025#63
|
||||||
|
Revolving Door,Tate McRae,2025#82
|
||||||
|
Ric Flair Drip,Offset & Metro Boomin,2018#38
|
||||||
|
Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,2024#45
|
||||||
|
Rich Flex,Drake & 21 Savage,2023#14
|
||||||
|
Rich Men North Of Richmond,Oliver Anthony Music,2023#78
|
||||||
|
Ride,twenty one pilots,2016#20
|
||||||
|
RITMO (Bad Boys For Life),Black Eyed Peas X J Balvin,2020#50
|
||||||
|
Robbery,Juice WRLD,2019#100
|
||||||
|
Rock And A Hard Place,Bailey Zimmerman,2022#70/2023#16
|
||||||
|
Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie,2017#44
|
||||||
|
Rockin' Around The Christmas Tree,Brenda Lee,2021#92/2022#80/2023#60/2024#59/2025#67
|
||||||
|
Rockstar,DaBaby Featuring Roddy Ricch,2020#5
|
||||||
|
Rockstar,Post Malone Featuring 21 Savage,2017#56/2018#5
|
||||||
|
Rolex,Ayo & Teo,2017#61
|
||||||
|
Roses,SAINt JHN,2020#19
|
||||||
|
Roses,The Chainsmokers Featuring Rozes,2016#27
|
||||||
|
Roxanne,Arizona Zervas,2020#16
|
||||||
|
Rumor,Lee Brice,2019#81
|
||||||
|
Running Up That Hill (A Deal With God),Kate Bush,2022#23
|
||||||
|
Sad!,XXXTENTACION,2018#17
|
||||||
|
Said Sum,Moneybagg Yo,2020#98
|
||||||
|
Sailor Song,Gigi Perez,2025#36
|
||||||
|
Same Old Love,Selena Gomez,2016#40
|
||||||
|
Sand In My Boots,Morgan Wallen,2022#58
|
||||||
|
Saturn,SZA,2024#19
|
||||||
|
Savage,Megan Thee Stallion,2020#15
|
||||||
|
Savage Love (Laxed - Siren Beat),Jawsh 685 x Jason Derulo,2020#35
|
||||||
|
Save Me,Jelly Roll With Lainey Wilson,2024#65
|
||||||
|
Save Your Tears,The Weeknd & Ariana Grande,2021#2/2022#40
|
||||||
|
Say It,Tory Lanez,2016#74
|
||||||
|
Say So,Doja Cat,2020#11
|
||||||
|
Say Something,Justin Timberlake Featuring Chris Stapleton,2018#85
|
||||||
|
Say You Won't Let Go,James Arthur,2017#11
|
||||||
|
Scars To Your Beautiful,Alessia Cara,2017#30
|
||||||
|
Search & Rescue,Drake,2023#45
|
||||||
|
See You Again,Wiz Khalifa Featuring Charlie Puth,2016#99
|
||||||
|
Send My Love (To Your New Lover),Adele,2016#26
|
||||||
|
Senorita,Shawn Mendes & Camila Cabello,2019#15/2020#44
|
||||||
|
Seven,Jung Kook Featuring Latto,2023#82
|
||||||
|
Shallow,Lady Gaga & Bradley Cooper,2019#19
|
||||||
|
Shape Of You,Ed Sheeran,2017#1/2018#71
|
||||||
|
She Had Me At Heads Carolina,Cole Swindell,2022#46
|
||||||
|
She Likes It,Russell Dickerson & Jake Scott,2022#91
|
||||||
|
She's All I Wanna Be,Tate McRae,2022#94
|
||||||
|
Shirt,SZA,2023#51
|
||||||
|
Shivers,Ed Sheeran,2022#5
|
||||||
|
Shotta Flow,NLE Choppa,2019#92
|
||||||
|
Sicko Mode,Travis Scott,2018#42/2019#9
|
||||||
|
Side To Side,Ariana Grande Featuring Nicki Minaj,2016#77/2017#43
|
||||||
|
Sign Of The Times,Harry Styles,2017#87
|
||||||
|
Simple,Florida Georgia Line,2018#82
|
||||||
|
Single Saturday Night,Cole Swindell,2021#95
|
||||||
|
"Sit Still, Look Pretty",Daya,2016#78
|
||||||
|
Sky Walker,Miguel Featuring Travis Scott,2018#75
|
||||||
|
Slide,Calvin Harris Featuring Frank Ocean & Migos,2017#71
|
||||||
|
Slide,H.E.R. Featuring YG,2020#99
|
||||||
|
Slippery,Migos Featuring Gucci Mane,2017#86
|
||||||
|
Slow Hands,Niall Horan,2017#32
|
||||||
|
Slow It Down,Benson Boone,2024#41
|
||||||
|
Small Town Boy,Dustin Lynch,2017#94
|
||||||
|
Smile,Morgan Wallen,2025#64
|
||||||
|
Smokin Out The Window,Silk Sonic (Bruno Mars & Anderson .Paak),2022#41
|
||||||
|
Snooze,SZA,2023#9/2024#20
|
||||||
|
Soda Pop,"Saja Boys: Andrew Choi, Neckwav, Danny Chung, Kevin Woo & samUIL Lee",2025#61
|
||||||
|
Somebody Loves Me,"PARTYNEXTDOOR, Drake & Cash Cobain",2025#85
|
||||||
|
Someone You Loved,Lewis Capaldi,2019#27/2020#10
|
||||||
|
Something In The Orange,Zach Bryan,2022#39/2023#13
|
||||||
|
Something Just Like This,The Chainsmokers & Coldplay,2017#5
|
||||||
|
Sorry,Beyonce,2016#71
|
||||||
|
Sorry,Justin Bieber,2016#2
|
||||||
|
Sorry I'm Here For Someone Else,Benson Boone,2025#37
|
||||||
|
Sorry Not Sorry,Demi Lovato,2017#47/2018#64
|
||||||
|
Speechless,Dan + Shay,2019#35
|
||||||
|
Spin Bout U,Drake & 21 Savage,2023#43
|
||||||
|
Sports Car,Tate McRae,2025#46
|
||||||
|
Squabble Up,Kendrick Lamar,2025#20
|
||||||
|
Starboy,The Weeknd Featuring Daft Punk,2016#58/2017#20
|
||||||
|
Stargazing,Myles Smith,2024#73/2025#29
|
||||||
|
Starting Over,Chris Stapleton,2021#53
|
||||||
|
Starving,Hailee Steinfeld & Grey Featuring Zedd,2016#94/2017#82
|
||||||
|
Stay,The Kid LAROI & Justin Bieber,2021#12/2022#3
|
||||||
|
Stay,Zedd & Alessia Cara,2017#17
|
||||||
|
Stick Season,Noah Kahan,2024#11
|
||||||
|
Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",2025#41
|
||||||
|
Stir Fry,Migos,2018#48
|
||||||
|
Stitches,Shawn Mendes,2016#23
|
||||||
|
Streets,Doja Cat,2021#67
|
||||||
|
Stressed Out,twenty one pilots,2016#5
|
||||||
|
Strip That Down,Liam Payne Featuring Quavo,2017#36
|
||||||
|
Stuck With U,Ariana Grande & Justin Bieber,2020#80
|
||||||
|
Sucker,Jonas Brothers,2019#10
|
||||||
|
Sucker For Pain,"Lil Wayne, Wiz Khalifa & Imagine Dragons With Logic & Ty Dolla $ign Feat. X Ambassadors",2016#68
|
||||||
|
Suge,DaBaby,2019#24
|
||||||
|
Suicidal,YNW Melly & Juice WRLD,2020#75
|
||||||
|
Sum 2 Prove,Lil Baby,2020#79
|
||||||
|
Sunday Best,Surfaces,2020#61
|
||||||
|
Sunflower (Spider-Man: Into The Spider-Verse),Post Malone & Swae Lee,2019#2
|
||||||
|
Sunroof,Nicky Youre & dazy,2022#29
|
||||||
|
Supalonely,BENEE Featuring Gus Dapperton,2020#84
|
||||||
|
Super Freaky Girl,Nicki Minaj,2022#56/2023#79
|
||||||
|
Super Gremlin,Kodak Black,2022#9
|
||||||
|
Superhero (Heroes & Villains),"Metro Boomin, Future & Chris Brown",2023#38
|
||||||
|
Superman,Morgan Wallen,2025#99
|
||||||
|
Sure Thing,Miguel,2023#24
|
||||||
|
Surface Pressure,Jessica Darrow,2022#53
|
||||||
|
Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,2017#85
|
||||||
|
Swang,Rae Sremmurd,2017#64
|
||||||
|
Sweet But Psycho,Ava Max,2019#23
|
||||||
|
Sweetest Pie,Megan Thee Stallion & Dua Lipa,2022#62
|
||||||
|
Swervin,A Boogie Wit da Hoodie Featuring 6ix9ine,2019#82
|
||||||
|
T-Shirt,Migos,2017#52
|
||||||
|
Take My Breath,The Weeknd,2021#89
|
||||||
|
Take My Name,Parmalee,2022#82
|
||||||
|
Takedown,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#93
|
||||||
|
Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",2019#57
|
||||||
|
Talk,Khalid,2019#8
|
||||||
|
Talk You Out Of It,Florida Georgia Line,2019#94
|
||||||
|
Taste,Sabrina Carpenter,2024#86/2025#19
|
||||||
|
Taste,Tyga Featuring Offset,2018#28
|
||||||
|
Te Bote,"Casper Magico, Nio Garcia, Darell, Nicky Jam, Ozuna & Bad Bunny",2018#81
|
||||||
|
Telepatia,Kali Uchis,2021#49
|
||||||
|
Tennessee Orange,Megan Moroney,2023#67
|
||||||
|
Tequila,Dan + Shay,2018#32/2019#91
|
||||||
|
Texas Hold 'Em,Beyonce,2024#32
|
||||||
|
Thank God,Kane Brown With Katelyn Brown,2023#23
|
||||||
|
"Thank U, Next",Ariana Grande,2019#12
|
||||||
|
That's So True,Gracie Abrams,2025#14
|
||||||
|
That's What I Like,Bruno Mars,2017#3
|
||||||
|
Thats What I Want,Lil Nas X,2022#14
|
||||||
|
The Bigger Picture,Lil Baby,2020#76
|
||||||
|
The Bones,Maren Morris,2020#9
|
||||||
|
The Box,Roddy Ricch,2020#3
|
||||||
|
The Fighter,Keith Urban Featuring Carrie Underwood,2017#100
|
||||||
|
The Git Up,Blanco Brown,2019#56
|
||||||
|
The Good Ones,Gabby Barrett,2021#62
|
||||||
|
The Hills,The Weeknd,2016#32
|
||||||
|
The Kind Of Love We Make,Luke Combs,2022#36
|
||||||
|
The London,"Young Thug, J. Cole & Travis Scott",2019#64
|
||||||
|
The Middle,"Zedd, Maren Morris & Grey",2018#8
|
||||||
|
The Painter,Cody Johnson,2024#98
|
||||||
|
The Scotts,"THE SCOTTS, Travis Scott & Kid Cudi",2020#86
|
||||||
|
The Woo,Pop Smoke Featuring 50 Cent & Roddy Ricch,2020#78
|
||||||
|
There's Nothing Holdin' Me Back,Shawn Mendes,2017#23
|
||||||
|
Therefore I Am,Billie Eilish,2021#25
|
||||||
|
Things A Man Oughta Know,Lainey Wilson,2021#96
|
||||||
|
Thinkin' Bout Me,Morgan Wallen,2023#19/2024#31
|
||||||
|
This Is America,Childish Gambino,2018#51
|
||||||
|
This Is What You Came For,Calvin Harris Featuring Rihanna,2016#17
|
||||||
|
Thot Shit,Megan Thee Stallion,2021#71
|
||||||
|
Thotiana,Blueface,2019#47
|
||||||
|
Thought You Should Know,Morgan Wallen,2023#22
|
||||||
|
Throat Baby (Go Baby),BRS Kash,2021#97
|
||||||
|
Thunder,Imagine Dragons,2017#51/2018#22
|
||||||
|
Tiimmy Turner,Desiigner,2016#98
|
||||||
|
Time Today,Moneybagg Yo,2021#76
|
||||||
|
Timeless,The Weeknd & Playboi Carti,2025#16
|
||||||
|
Titi Me Pregunto,Bad Bunny,2022#22
|
||||||
|
To The Moon!,JNR CHOI & Sam Tompkins,2022#97
|
||||||
|
Todo de Ti,Rauw Alejandro,2021#100
|
||||||
|
Tombstone,Rod Wave,2021#98
|
||||||
|
Tomorrow 2,GloRilla & Cardi B,2023#70
|
||||||
|
Too Good,Drake Featuring Rihanna,2016#29
|
||||||
|
Too Good At Goodbyes,Sam Smith,2017#77/2018#49
|
||||||
|
Too Sweet,Hozier,2024#10/2025#34
|
||||||
|
Toosie Slide,Drake,2020#32
|
||||||
|
TQG,Karol G x Shakira,2023#65
|
||||||
|
Track Star,Mooski,2021#75
|
||||||
|
Traitor,Olivia Rodrigo,2021#38
|
||||||
|
Trampoline,SHAED,2019#70/2020#53
|
||||||
|
Treat You Better,Shawn Mendes,2016#28/2017#92
|
||||||
|
Trip,Ella Mai,2018#92/2019#80
|
||||||
|
Trouble With A Heartbreak,Jason Aldean,2022#88
|
||||||
|
Truck Bed,HARDY,2024#67
|
||||||
|
Truth Hurts,Lizzo,2019#13/2020#55
|
||||||
|
Try That In A Small Town,Jason Aldean,2023#66
|
||||||
|
Tu Boda,Oscar Maydon & Fuerza Regida,2025#87
|
||||||
|
Tunnel Vision,Kodak Black,2017#55
|
||||||
|
TV Off,Kendrick Lamar Featuring Lefty Gunplay,2025#15
|
||||||
|
Type Shit,"Future, Metro Boomin, Travis Scott & Playboi Carti",2024#51
|
||||||
|
Un x100to,Grupo Frontera X Bad Bunny,2023#49
|
||||||
|
Under The Influence,Chris Brown,2023#17
|
||||||
|
Undressed,sombr,2025#42
|
||||||
|
Unforgettable,French Montana Featuring Swae Lee,2017#15
|
||||||
|
Unholy,Sam Smith & Kim Petras,2022#98/2023#11
|
||||||
|
Unsteady,X Ambassadors,2016#67
|
||||||
|
Until I Found You,Stephen Sanchez,2023#50
|
||||||
|
Up,Cardi B,2021#26
|
||||||
|
Vampire,Olivia Rodrigo,2023#30/2024#57
|
||||||
|
Vegas,Doja Cat,2022#47
|
||||||
|
Wait,Maroon 5,2018#58
|
||||||
|
Wait For U,Future Featuring Drake & Tems,2022#11/2023#83
|
||||||
|
Wait In The Truck,HARDY Featuring Lainey Wilson,2023#54
|
||||||
|
Wake Up In The Sky,Gucci Mane X Bruno Mars X Kodak Black,2019#51
|
||||||
|
Walk Em Down,NLE Choppa Featuring Roddy Ricch,2020#100
|
||||||
|
Walk It Talk It,Migos Featuring Drake,2018#43
|
||||||
|
Walk Me Home,P!nk,2019#99
|
||||||
|
Wanna Be,GloRilla & Megan Thee Stallion,2024#40
|
||||||
|
Wants And Needs,Drake Featuring Lil Baby,2021#47
|
||||||
|
WAP,Cardi B Featuring Megan Thee Stallion,2020#24
|
||||||
|
Wasted On You,Morgan Wallen,2022#19
|
||||||
|
Watch Me,Silento,2016#88
|
||||||
|
Water,Tyla,2024#24
|
||||||
|
Water Under The Bridge,Adele,2017#88
|
||||||
|
Watermelon Moonshine,Lainey Wilson,2023#100
|
||||||
|
Watermelon Sugar,Harry Styles,2020#20
|
||||||
|
Way 2 Sexy,Drake Featuring Future & Young Thug,2021#48
|
||||||
|
We Can't Be Friends (Wait For Your Love),Ariana Grande,2024#26
|
||||||
|
We Don't Talk About Bruno,"Carolina Gaitan, Mauro Castillo, Adassa, Rhenzy Feliz, Diane Guerrero, Stephanie Beatriz & Encanto Cast",2022#24
|
||||||
|
We Don't Talk Anymore,Charlie Puth Featuring Selena Gomez,2016#50
|
||||||
|
We Paid,Lil Baby & 42 Dugg,2020#57
|
||||||
|
We're Good,Dua Lipa,2021#90
|
||||||
|
Weren't For The Wind,Ella Langley,2025#71
|
||||||
|
What About Us,P!nk,2017#84
|
||||||
|
What Do You Mean?,Justin Bieber,2016#31
|
||||||
|
What Happened To Virgil,Lil Durk Featuring Gunna,2022#83
|
||||||
|
What I Want,Morgan Wallen Featuring Tate McRae,2025#27
|
||||||
|
What Ifs,Kane Brown Featuring Lauren Alaina,2017#72
|
||||||
|
What It Is (Block Boy),Doechii Featuring Kodak Black,2023#76
|
||||||
|
What It Sounds Like,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#83
|
||||||
|
What Lovers Do,Maroon 5 Featuring SZA,2017#97/2018#89
|
||||||
|
What My World Spins Around,Jordan Davis,2023#91
|
||||||
|
What Was I Made For?,Billie Eilish,2023#81/2024#46
|
||||||
|
What You Know Bout Love,Pop Smoke,2021#22
|
||||||
|
What's Next,Drake,2021#68
|
||||||
|
What's Your Country Song,Thomas Rhett,2021#80
|
||||||
|
Whatchu Kno About Me,GloRilla & Sexyy Red,2025#48
|
||||||
|
Whatever It Takes,Imagine Dragons,2018#37
|
||||||
|
Whatever She Wants,Bryson Tiller,2024#58
|
||||||
|
Whats Poppin,"Jack Harlow Featuring DaBaby, Tory Lanez & Lil Wayne",2020#13
|
||||||
|
When The Party's Over,Billie Eilish,2019#67
|
||||||
|
When We Were Young,Adele,2016#83
|
||||||
|
Where It Ends,Bailey Zimmerman,2024#87
|
||||||
|
Where She Goes,Bad Bunny,2023#72
|
||||||
|
Where The Wild Things Are,Luke Combs,2024#70
|
||||||
|
Whiskey Glasses,Morgan Wallen,2019#52
|
||||||
|
White Horse,Chris Stapleton,2024#77
|
||||||
|
White Iverson,Post Malone,2016#65
|
||||||
|
Who,Jimin,2025#57
|
||||||
|
Whoopty,CJ,2021#50
|
||||||
|
Wicked,Future,2016#97
|
||||||
|
Wild As Her,Corey Kent,2023#93
|
||||||
|
Wild Ones,Jessie Murph & Jelly Roll,2024#36
|
||||||
|
Wild Thoughts,DJ Khaled Featuring Rihanna & Bryson Tiller,2017#18
|
||||||
|
Wildest Dreams,Taylor Swift,2016#79
|
||||||
|
Wildflower,Billie Eilish,2024#89/2025#26
|
||||||
|
Willow,Taylor Swift,2021#55
|
||||||
|
Wind Up Missin' You,Tucker Wetmore,2024#95
|
||||||
|
Wishing Well,Juice WRLD,2020#92
|
||||||
|
Without Me,Halsey,2019#3
|
||||||
|
Without You,The Kid LAROI,2021#17
|
||||||
|
Woah,Lil Baby,2020#42
|
||||||
|
Wockesha,Moneybagg Yo,2021#42
|
||||||
|
Wolves,Selena Gomez X Marshmello,2018#60
|
||||||
|
Woman,Doja Cat,2022#21
|
||||||
|
Work,Rihanna Featuring Drake,2016#4
|
||||||
|
Work From Home,Fifth Harmony Featuring Ty Dolla $ign,2016#16
|
||||||
|
World On Fire,Nate Smith,2024#90
|
||||||
|
Worst Way,Riley Green,2025#35
|
||||||
|
Worth It,YK Osiris,2019#77
|
||||||
|
Wow.,Post Malone,2019#5
|
||||||
|
X,Nicky Jam x J Balvin,2018#90
|
||||||
|
XO TOUR Llif3,Lil Uzi Vert,2017#13
|
||||||
|
Yeah Glo!,GloRilla,2024#44
|
||||||
|
Yes Indeed,Lil Baby & Drake,2018#25
|
||||||
|
"Yes, And?",Ariana Grande,2024#92
|
||||||
|
Yonaguni,Bad Bunny,2021#83
|
||||||
|
You Broke Me First.,Tate McRae,2021#37
|
||||||
|
You Make It Easy,Jason Aldean,2018#70
|
||||||
|
You Need To Calm Down,Taylor Swift,2019#39
|
||||||
|
You Proof,Morgan Wallen,2022#27/2023#12
|
||||||
|
You Right,Doja Cat & The Weeknd,2021#43/2022#45
|
||||||
|
You Say,Lauren Daigle,2019#60
|
||||||
|
You Should Be Sad,Halsey,2020#82
|
||||||
|
You Should Probably Leave,Chris Stapleton,2022#79
|
||||||
|
You're Mines Still,Yung Bleu Featuring Drake,2021#58
|
||||||
|
Young Dumb & Broke,Khalid,2017#78/2018#67
|
||||||
|
Youngblood,5 Seconds Of Summer,2018#36/2019#33
|
||||||
|
Your Idol,"Saja Boys: Andrew Choi, Neckwav, Danny Chung, Kevin Woo & samUIL Lee",2025#54
|
||||||
|
Yummy,Justin Bieber,2020#58
|
||||||
|
ZEZE,Kodak Black Featuring Travis Scott & Offset,2019#31
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
|||||||
|
# 決策日誌
|
||||||
|
|
||||||
|
每筆記錄:日期、決策、理由。定義檔(`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 的「來歷不可
|
||||||
|
考」敘述同步改為考據結果。附帶認清:先導第一步並非零
|
||||||
|
語意提示——此即正式研究「只定格式、不定語意」設計所
|
||||||
|
矯正者。
|
||||||
@@ -1,29 +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 管線下目錄名不會
|
|
||||||
進入模型輸入,無污染疑慮。
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# 方法細節
|
||||||
|
|
||||||
|
(全文方法節底稿。演算法在執行前寫定;任何修訂記入
|
||||||
|
`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 決策,於看到結果前寫定;待黃金標準
|
||||||
|
編碼展開前補入。)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 專案目錄結構
|
||||||
|
|
||||||
|
(2026-07-30 討論定案;2026-07-31 更新為 tools/ 子專案與
|
||||||
|
SQLite 工作儲存架構)
|
||||||
|
|
||||||
|
```
|
||||||
|
pop-fem-audit/
|
||||||
|
├── README.md # 專案說明、重現步驟
|
||||||
|
├── CLAUDE.md # 極簡工作規範(subagent 會讀到,
|
||||||
|
│ # 絕不放理論、codebook、預期結果)
|
||||||
|
├── .gitignore # captures/lyrics/、.env、scratch
|
||||||
|
├── data/ # 依生命週期分層(文字格式)
|
||||||
|
│ ├── source/ # 源頭:手放後不動
|
||||||
|
│ │ └── yearend_hot100_2016_2025.csv # 原始榜單
|
||||||
|
│ ├── captures/ # 外部捕捉:只由 fetch 命令與
|
||||||
|
│ │ │ # 私人匯入腳本寫入
|
||||||
|
│ │ ├── artists-wikidata.csv # Wikidata 快照
|
||||||
|
│ │ ├── lyrics-provenance.csv # 歌詞出處
|
||||||
|
│ │ └── lyrics/ # 歌詞 .txt 快取
|
||||||
|
│ │ # (gitignored,版權)
|
||||||
|
│ ├── manual/ # 人工著作:只由研究者手寫
|
||||||
|
│ │ # (黃金標準編碼等)
|
||||||
|
│ └── derived/ # 衍生:只由 build-db 寫入
|
||||||
|
│ ├── songs.csv # 歌曲報表(人讀;進 git)
|
||||||
|
│ └── artists.csv # 歌手報表(人讀;進 git)
|
||||||
|
├── prompts/ # LLM 定義檔(逐字作為 system prompt)
|
||||||
|
│ └── <軌>-<步>-<次步>-<task>.md # 01-01-01-tag.md、
|
||||||
|
│ # 01-02-02-merge-arb.md…
|
||||||
|
│ # 不帶版本號,版本即 git 歷史
|
||||||
|
├── tools/ # 輔助工具子專案(src-layout)
|
||||||
|
│ ├── pyproject.toml # 發行名 pop-fem-audit-tools;
|
||||||
|
│ │ # pip install -e tools/ 安裝
|
||||||
|
│ ├── README.rst LICENSE MANIFEST.in .env.example .gitignore
|
||||||
|
│ ├── docs/ # Sphinx API 文件
|
||||||
|
│ ├── instance/ # SQLite 工作儲存(generated、
|
||||||
|
│ │ # gitignored;含歌詞全文)
|
||||||
|
│ ├── src/pop_fem_audit_tools/
|
||||||
|
│ │ ├── __main__.py # 套件 CLI 進入點(分派子命令)
|
||||||
|
│ │ ├── commands/ # CLI 子命令模組(登記於 __init__)
|
||||||
|
│ │ │ ├── build_db.py # build the SQLite working store
|
||||||
|
│ │ │ │ # 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)
|
||||||
|
│ │ ├── database.py # SQLAlchemy engine / session / Base
|
||||||
|
│ │ ├── models.py # SQLAlchemy ORM 資料模型
|
||||||
|
│ │ └── utils.py # 共用工具(format_duration)
|
||||||
|
│ └── tests/ # 單元測試(unittest)
|
||||||
|
├── runs/ # 現行執行的完整稽核紀錄(進 git;
|
||||||
|
│ │ # 重跑同一 run 須明示 --replace)
|
||||||
|
│ └── <定義檔名>/ # 仲裁步驟居自己的 <task>-arb/
|
||||||
|
│ └── run<N>/ # 每個 run 一份自我完備歸檔
|
||||||
|
│ ├── prompt.md # 當次定義檔快照(自我完備)
|
||||||
|
│ ├── output.jsonl # 該次執行原始輸出
|
||||||
|
│ └── meta.json # model ID、temperature、時間戳、
|
||||||
|
│ # batch ID、token 用量
|
||||||
|
│ # (一致率由比對子命令記錄)
|
||||||
|
├── results/ # 論文引用的報表 CSV(export 產出;
|
||||||
|
│ # 「可再生仍 commit」的唯一例外)
|
||||||
|
├── docs/
|
||||||
|
│ ├── research-plan.md # 研究步驟規劃(本檔之姊妹篇)
|
||||||
|
│ ├── project-structure.md # 本檔
|
||||||
|
│ ├── codebook.md # 人工編碼手冊(版本由 git 管理)
|
||||||
|
│ ├── decision-log.md # 決策日誌:每次改定義檔的原因
|
||||||
|
│ └── methodology.md # 方法細節(全文方法節底稿;
|
||||||
|
│ # 映射分析方法須在看結果前寫定)
|
||||||
|
└── paper/
|
||||||
|
├── abstract.md # 摘要
|
||||||
|
└── full-paper.md # 全文
|
||||||
|
```
|
||||||
|
|
||||||
|
## 設計理由
|
||||||
|
|
||||||
|
- **`runs/` 自我完備**:每個執行目錄含定義檔快照 + 原始輸出 +
|
||||||
|
meta,讀者不需 git 考古即可稽核任一筆結果。
|
||||||
|
- **`runs/`(原始稽核資料)與 `results/`(最終表)分離**:
|
||||||
|
論文只引 `results/`,其來源可回溯至 `runs/`。
|
||||||
|
- **`prompts/` 檔名不帶版本號**:版本即 git 歷史,失敗的
|
||||||
|
版本不保留;論文引用的單位是 `runs/` 內隨執行保存的定義檔
|
||||||
|
快照(每個執行目錄自我完備),不需檔名可指的版本名。
|
||||||
|
- **Commit 判準**:能由「committed 輸入+程式」決定性再生者不
|
||||||
|
commit(SQLite 工作儲存、LLM 輸入檔);源頭、捕捉、人工著作
|
||||||
|
一律以文字 commit。「可再生仍 commit」的例外有二:
|
||||||
|
`results/` 報表(引用穩定性、審稿人零門檻、撰稿期可 diff)
|
||||||
|
與 `data/derived/` 人讀報表(與工作儲存同一動作產出,稽核
|
||||||
|
鏈無中間空缺)。詳見 `research-plan.md`「資料儲存與模型」。
|
||||||
|
- **設定**經 pydantic-settings 統一:`.env`(gitignored,範本
|
||||||
|
`tools/.env.example`)供應 `SQLALCHEMY_DATABASE_URL` 與
|
||||||
|
`ANTHROPIC_API_KEY`,絕不寫入 repo。
|
||||||
|
- **CLAUDE.md 極簡**:實測證實 Claude Code subagent 會繼承專案
|
||||||
|
CLAUDE.md 全文,故其中只放工作流程規則,領域知識一律放
|
||||||
|
`docs/`(subagent 不會自動讀到)。
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 專案目錄結構
|
|
||||||
|
|
||||||
(2026-07-30 討論定案版;分析管線已改為 API script 路線,
|
|
||||||
定義檔不再放 `.claude/agents/`)
|
|
||||||
|
|
||||||
```
|
|
||||||
pop-fem-audit/
|
|
||||||
├── README.md # 專案說明、重現步驟
|
|
||||||
├── CLAUDE.md # 極簡工作規範(subagent 會讀到,
|
|
||||||
│ # 絕不放理論、codebook、預期結果)
|
|
||||||
├── .gitignore # data/lyrics/、.env、scratch
|
|
||||||
├── conference_abstract.md # pilot 摘要(投稿版)
|
|
||||||
├── data/
|
|
||||||
│ ├── yearend_hot100_2016_2025.csv # 原始榜單(唯一手放原始檔)
|
|
||||||
│ ├── songs_unique.csv # 衍生:去重歌曲表(song id)
|
|
||||||
│ ├── artists_gender.csv # 衍生:演唱者性別後設資料
|
|
||||||
│ └── lyrics/ # 歌詞快取(gitignored,版權)
|
|
||||||
├── prompts/ # LLM 定義檔(逐字作為 system prompt)
|
|
||||||
│ └── <task>_v<N>.md # 版本化:screen_v1.md、judge_v2.md…
|
|
||||||
├── scripts/ # deterministic Python scripts
|
|
||||||
│ │ # (runner、抓歌詞、統計、圖表)
|
|
||||||
│ └── run_llm.py # API runner:2+1 協定、Batch API、
|
|
||||||
│ # 自動寫入 runs/
|
|
||||||
├── runs/ # 每次執行的完整稽核紀錄(進 git)
|
|
||||||
│ └── <階段>/<日期>-<定義檔版本>/
|
|
||||||
│ ├── prompt.md # 當次定義檔快照(自我完備)
|
|
||||||
│ ├── run1.jsonl # 第一次執行原始輸出
|
|
||||||
│ ├── run2.jsonl # 第二次執行原始輸出
|
|
||||||
│ ├── arbitration.jsonl # 仲裁輸出
|
|
||||||
│ └── meta.json # model ID、temperature、時間戳、
|
|
||||||
│ # batch ID、一致率
|
|
||||||
├── results/ # 仲裁後最終衍生表(論文引用來源)
|
|
||||||
├── docs/
|
|
||||||
│ ├── research_plan.md # 研究步驟規劃(本檔之姊妹篇)
|
|
||||||
│ ├── project_structure.md # 本檔
|
|
||||||
│ ├── codebook.md # 人工編碼手冊(版本由 git 管理)
|
|
||||||
│ ├── decision_log.md # 決策日誌:每次改定義檔的原因
|
|
||||||
│ └── methodology.md # 方法細節(全文方法節底稿;
|
|
||||||
│ # 映射分析方法須在看結果前寫定)
|
|
||||||
└── paper/
|
|
||||||
└── full_paper.md # 全文
|
|
||||||
```
|
|
||||||
|
|
||||||
## 設計理由
|
|
||||||
|
|
||||||
- **`runs/` 自我完備**:每個執行目錄含定義檔快照 + 原始輸出 +
|
|
||||||
meta,讀者不需 git 考古即可稽核任一筆結果。
|
|
||||||
- **`runs/`(原始稽核資料)與 `results/`(最終表)分離**:
|
|
||||||
論文只引 `results/`,其來源可回溯至 `runs/`。
|
|
||||||
- **`prompts/` 用檔名版本化**(不只靠 git 歷史):定義檔版本是
|
|
||||||
論文附錄的引用單位,須可直接指名(如 judge_v2.md)。
|
|
||||||
- **API 金鑰**放 `.env`(gitignored),script 由環境變數讀取,
|
|
||||||
絕不寫入 repo。
|
|
||||||
- **CLAUDE.md 極簡**:實測證實 Claude Code subagent 會繼承專案
|
|
||||||
CLAUDE.md 全文,故其中只放工作流程規則,領域知識一律放
|
|
||||||
`docs/`(subagent 不會自動讀到)。
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 研究步驟規劃
|
||||||
|
|
||||||
|
(2026-07-30 討論定案、2026-07-31 增補資料架構;
|
||||||
|
後續變更請記入 `decision-log.md`)
|
||||||
|
|
||||||
|
## 總體框架
|
||||||
|
|
||||||
|
- **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料
|
||||||
|
共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究
|
||||||
|
做比較**;全文數字一律以正式研究
|
||||||
|
結果為準。先導研究僅作為 codebook v0 與假說的內部來源,
|
||||||
|
記於決策日誌,不進入論文敘事。
|
||||||
|
- **執行原則**:主會話只做討論;所有分析由 deterministic script
|
||||||
|
執行。LLM 步驟以 Python script 呼叫 Anthropic Messages API
|
||||||
|
(個人 Console 帳號、Batch API 五折),定義檔逐字作為 system
|
||||||
|
prompt。2+1 協定依輸出可比性適用:輸出可逐項機械比對的步驟
|
||||||
|
(收斂、編碼)「同一定義檔獨立執行兩次+一次仲裁」,仲裁
|
||||||
|
只裁程式算出的分歧清單;自由生成步驟(首步自由標註)兩次
|
||||||
|
執行全數進池、不仲裁——自由詞彙兩次輸出不共享比對單位,
|
||||||
|
無物可裁。仲裁結果不符預期則修訂定義檔重跑該循環。
|
||||||
|
- **提示詞只定格式、不定語意**:研究對象是通用 LLM 以其
|
||||||
|
網路語料知識背景所做的自然編碼,編碼結果本身是批判對象。
|
||||||
|
LLM 定義檔只規定任務形狀(輸入、數量範圍、輸出格式),
|
||||||
|
不給任何主題的定義、判準或範例;研究者的深度判準只寫在
|
||||||
|
codebook(人工黃金標準用),兩者不互相滲透。LLM 編碼
|
||||||
|
一律要求逐標籤引述歌詞原句,作為檢視編碼偏差的依據。
|
||||||
|
- **模型**:`claude-sonnet-4-6`、temperature=0、thinking 關閉
|
||||||
|
(此組合非決定性最低、成本合理)。條件 A/B 盲點實驗為
|
||||||
|
自足設計:同語料、同模型、僅提示不同,對照黃金標準。
|
||||||
|
其他工具性環節於階段 3 以校準樣本實測 Sonnet 4.6 vs Opus 5
|
||||||
|
的一致率後決定是否升級。
|
||||||
|
- **信度與效度分工**:2+1 協定量測穩定性(intra-rater
|
||||||
|
reliability,報告一致率/kappa);效度靠理論 codebook 與
|
||||||
|
人工黃金標準終審。可重現性定義為「程序透明+可稽核」:
|
||||||
|
公開定義檔、記錄 model ID 與執行時間、保存全部原始輸出。
|
||||||
|
|
||||||
|
## 資料儲存與模型(2026-07-31 定案)
|
||||||
|
|
||||||
|
- **Commit 判準**:凡能由「committed 的輸入+committed 的程式」
|
||||||
|
決定性再生者,不 commit;凡不能者——源頭資料、外部世界的
|
||||||
|
捕捉(Wikidata 快照、LLM 原始輸出)、人工著作(核定、編碼)
|
||||||
|
——一律以文字格式 commit。格式跟著層次走,不跟著偏好走。
|
||||||
|
- **分層**:
|
||||||
|
- 源頭:原始榜單 CSV(進 git)。
|
||||||
|
- 捕捉:Wikidata 快照 CSV、你的編碼
|
||||||
|
CSV、`runs/` JSONL(皆進 git);歌詞 `.txt` 快取
|
||||||
|
(版權因素 gitignored,為已知的稽核缺口)。
|
||||||
|
- 工作儲存:SQLite 單檔(`tools/instance/`,generated、
|
||||||
|
不進 git),SQLAlchemy 2.0 typed ORM 定義 schema,
|
||||||
|
設定經 pydantic-settings(`.env` 供應
|
||||||
|
`SQLALCHEMY_DATABASE_URL` 與 `ANTHROPIC_API_KEY`)。
|
||||||
|
歌詞全文入 DB(不進 git 故無版權疑慮)。
|
||||||
|
- 衍生:`build-db` 建置工作儲存的同一動作產出人讀報表
|
||||||
|
(`data/derived/` 的 songs.csv、artists.csv,進 git)——
|
||||||
|
與 SQLite 同交易語意,稽核鏈無中間空缺;不含資料庫 ID。
|
||||||
|
- 報表:論文引用的最終表由 export 產出 CSV 進 `results/`。
|
||||||
|
衍生與報表為「可再生仍 commit」的兩個例外,理由:引用
|
||||||
|
穩定性(十年尺度的環境會腐化)、審稿人零門檻、撰稿期
|
||||||
|
數字變動可 diff、供人工檢視。
|
||||||
|
- **資料模型**:`songs`(歌曲實體,含 lyrics nullable 欄位)、
|
||||||
|
`chart_entries`(1 歌—N 筆榜單紀錄)、`artists`(歌手實體:
|
||||||
|
Wikidata QID、性別、型態、曲風、國籍)、`song_artists`
|
||||||
|
(M—N 關聯:角色、署名順序)。領域不變量(恰 1000 筆榜單、
|
||||||
|
每歌至少一 primary 歌手等)檢查內建於 `build-db`,違規即
|
||||||
|
建置失敗。
|
||||||
|
- **歌手背景防火牆**:歌手背景資料只進人工解讀階段(證據表、
|
||||||
|
論文討論),**絕不進 LLM 輸入**——LLM 分類的 user message
|
||||||
|
維持歌詞-only,避免光環偏誤污染條件 A/B 實驗。「女性力量」
|
||||||
|
候選歌曲的歌手另做深度背景(族裔以公開自我認同為準、音樂
|
||||||
|
場景),script 輔助、人工核定。
|
||||||
|
- **Pilot 歌詞沿用(私人匯入,不進發布管線)**:先導研究
|
||||||
|
捕捉檔(lyrics.json,684 首,2018–2025)以私人腳本
|
||||||
|
匯入歌詞快取——只取識別欄位與歌詞本文,以
|
||||||
|
(year, rank) 精確匹配 song_id。匯入工具不屬於專案交付物
|
||||||
|
(讀者拿不到其輸入),讀者的重現路徑純粹是 `fetch-lyrics`;
|
||||||
|
沿用之**事實**記於 `data/captures/lyrics-provenance.csv`(進 git):
|
||||||
|
`source`(原始 API)與 `method`
|
||||||
|
(pilot-import / api-fetch / manual)兩層,取得日期不可考者
|
||||||
|
不假造,僅記可證上界。
|
||||||
|
- **子命令**(`pop-fem-audit-tools <cmd>` 或
|
||||||
|
`python -m pop_fem_audit_tools <cmd>`):`run-llm`、
|
||||||
|
`build-db`、`fetch-lyrics`、`fetch-artists`(皆已完成)、
|
||||||
|
`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)
|
||||||
|
|
||||||
|
| 階段 | 內容 | 方式 | 時程 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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 |
|
||||||
|
| 2 | 自然編碼管線:tag ×2 進池 → merge 2+1 → cap 2+1 → 詞彙表定稿 → code 2+1(全 883 首,附引述);另跑 screen 補漏網 | API + script | 8/4–8/7 |
|
||||||
|
| 3 | 黃金標準:依 codebook 人工逐首判定 genuine/peripheral/fake,附引用歌詞證據表(LLM 只做摘錄,不給判定建議);先以 10–15 首校準樣本試編並修訂 codebook 後凍結;同批校準樣本實測 Sonnet 4.6 vs Opus 5 一致率 | 人工 + script 輔助 | 8/5–8/9 |
|
||||||
|
| 4 | 受控比較(盲點實驗):條件 A(詞彙層提示)vs 條件 B(框架感知提示),各 2+1,對照黃金標準計算假陽/假陰率 | API | 8/8–8/11 |
|
||||||
|
| 4' | 映射分析:自然編碼結果(第 4 步)與黃金標準交叉表;軌跡對映 vs 直接編碼的扭曲診斷(分析方法先寫入 methodology.md 再看結果) | script | 與 4 並行 |
|
||||||
|
| 5 | pussy 修辭分析:script 找含詞歌曲 → LLM 分類(METONYM/ANATOMICAL/COWARD/OTHER + 說話者/指涉對象)2+1 → 人工核定 | script + API | 8/9–8/11 |
|
||||||
|
| 6 | 統計與圖表:一致率(kappa)、污染率、類型分布、年度趨勢、映射交叉表 | scripts | 8/11–8/12 |
|
||||||
|
| 7 | 撰寫全文+內部審稿(subagent 以審稿人視角挑毛病,特別是嘻哈女性主義/respectability politics 一題) | 討論 + subagent | 8/11–8/14 |
|
||||||
|
|
||||||
|
關鍵路徑:階段 3 人工編碼(僅研究者本人可做),排週末與晚間分批。
|
||||||
|
|
||||||
|
## Codebook 原則
|
||||||
|
|
||||||
|
- Directed content analysis:理論骨架(top-down)+紀律化歸納通道
|
||||||
|
(bottom-up:`other-fake` 開放碼 + memo + 決策日誌)。
|
||||||
|
- 三層結構:理論構念層(Banet-Weiser、Gill、Goldman、McRobbie、
|
||||||
|
Fredrickson & Roberts、Connell、Mulvey)→ 操作判準層(權力來源
|
||||||
|
測試、frame vs vocabulary、決策樹)→ 類別定義層(A–E 納入/
|
||||||
|
排除判準、例句、near-miss 反例)。
|
||||||
|
- 判準層須明文回應嘻哈女性主義(Joan Morgan、Gwendolyn Pough)
|
||||||
|
與性積極女性主義的 respectability politics 質疑:區分「性表達」
|
||||||
|
與「貶低其他女性/以男性指標定義權力」。
|
||||||
|
- 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼;
|
||||||
|
中途修訂須記決策日誌並回檢已編歌曲。
|
||||||
|
|
||||||
|
## 其他已定案事項
|
||||||
|
|
||||||
|
- 歌詞受版權保護:完整歌詞不進 git(`data/captures/lyrics/` gitignored),
|
||||||
|
論文與 repo 只留分類所引摘錄。
|
||||||
|
- Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、
|
||||||
|
無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 研究步驟規劃
|
|
||||||
|
|
||||||
(2026-07-30 討論定案版;後續變更請記入 `decision_log.md`)
|
|
||||||
|
|
||||||
## 總體框架
|
|
||||||
|
|
||||||
- **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料
|
|
||||||
共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究
|
|
||||||
(conference_abstract.md)做比較**;全文數字一律以正式研究
|
|
||||||
結果為準。先導研究僅作為 codebook v0 與假說的內部來源,
|
|
||||||
記於決策日誌,不進入論文敘事。
|
|
||||||
- **執行原則**:主會話只做討論;所有分析由 deterministic script
|
|
||||||
執行。LLM 步驟以 Python script 呼叫 Anthropic Messages API
|
|
||||||
(個人 Console 帳號、Batch API 五折),定義檔逐字作為 system
|
|
||||||
prompt。每個 LLM 步驟「同一定義檔獨立執行兩次 + 一次仲裁」
|
|
||||||
(2+1),仲裁結果不符預期則修訂定義檔重跑整個循環。
|
|
||||||
- **模型**:`claude-sonnet-4-6`、temperature=0、thinking 關閉
|
|
||||||
(此組合非決定性最低、成本合理)。條件 A/B 盲點實驗為
|
|
||||||
自足設計:同語料、同模型、僅提示不同,對照黃金標準。
|
|
||||||
其他工具性環節於階段 3 以校準樣本實測 Sonnet 4.6 vs Opus 5
|
|
||||||
的一致率後決定是否升級。
|
|
||||||
- **信度與效度分工**:2+1 協定量測穩定性(intra-rater
|
|
||||||
reliability,報告一致率/kappa);效度靠理論 codebook 與
|
|
||||||
人工黃金標準終審。可重現性定義為「程序透明+可稽核」:
|
|
||||||
公開定義檔、記錄 model ID 與執行時間、保存全部原始輸出。
|
|
||||||
|
|
||||||
## 階段與時程(全文截稿 2026-08-15)
|
|
||||||
|
|
||||||
| 階段 | 內容 | 方式 | 時程 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 0 | 基礎建設:git init、目錄結構、.gitignore、決策日誌、runner script(含 Batch API)、codebook v0 骨架 | script + 討論 | 7/30–7/31 |
|
|
||||||
| 1 | 資料準備:去重唯一歌曲表(song id)、抓歌詞(Lyrics.ovh / LRCLIB,缺漏留 log)、演唱者性別表(Wikidata + 人工核對) | scripts | 7/31–8/2 |
|
|
||||||
| 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/4–8/8 |
|
|
||||||
| 4 | 受控比較(盲點實驗):條件 A(詞彙層提示)vs 條件 B(框架感知提示),各 2+1,對照黃金標準計算假陽/假陰率 | API | 8/6–8/9 |
|
|
||||||
| 4' | 由下而上歸納輪:LLM 對候選歌曲做無理論主題編碼(定義檔不含女性主義詞彙),與黃金標準做映射分析(交叉表;分析方法先寫入 methodology.md 再看結果) | API 2+1 + script | 與 4 並行 |
|
|
||||||
| 5 | pussy 修辭分析:script 找含詞歌曲 → LLM 分類(METONYM/ANATOMICAL/COWARD/OTHER + 說話者/指涉對象)2+1 → 人工核定 | script + API | 8/8–8/10 |
|
|
||||||
| 6 | 統計與圖表:一致率(kappa)、污染率、類型分布、年度趨勢、映射交叉表 | scripts | 8/10–8/11 |
|
|
||||||
| 7 | 撰寫全文+內部審稿(subagent 以審稿人視角挑毛病,特別是嘻哈女性主義/respectability politics 一題) | 討論 + subagent | 8/10–8/14 |
|
|
||||||
|
|
||||||
關鍵路徑:階段 3 人工編碼(僅研究者本人可做),排週末與晚間分批。
|
|
||||||
|
|
||||||
## Codebook 原則
|
|
||||||
|
|
||||||
- Directed content analysis:理論骨架(top-down)+紀律化歸納通道
|
|
||||||
(bottom-up:`other-fake` 開放碼 + memo + 決策日誌)。
|
|
||||||
- 三層結構:理論構念層(Banet-Weiser、Gill、Goldman、McRobbie、
|
|
||||||
Fredrickson & Roberts、Connell、Mulvey)→ 操作判準層(權力來源
|
|
||||||
測試、frame vs vocabulary、決策樹)→ 類別定義層(A–E 納入/
|
|
||||||
排除判準、例句、near-miss 反例)。
|
|
||||||
- 判準層須明文回應嘻哈女性主義(Joan Morgan、Gwendolyn Pough)
|
|
||||||
與性積極女性主義的 respectability politics 質疑:區分「性表達」
|
|
||||||
與「貶低其他女性/以男性指標定義權力」。
|
|
||||||
- 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼;
|
|
||||||
中途修訂須記決策日誌並回檢已編歌曲。
|
|
||||||
- Positionality statement 寫入論文(53 歲、長年婦運者、資深
|
|
||||||
工程師、離開學術圈多年、因整理榜單而起)。
|
|
||||||
|
|
||||||
## 其他已定案事項
|
|
||||||
|
|
||||||
- 歌詞受版權保護:完整歌詞不進 git(`data/lyrics/` gitignored),
|
|
||||||
論文與 repo 只留分類所引摘錄。
|
|
||||||
- 工程支援(寫 script、style check、審稿)用公司訂閱的
|
|
||||||
Claude Code;進論文的分析 token 由個人 API 帳號支付。
|
|
||||||
- Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、
|
|
||||||
無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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"]
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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"]]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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"]]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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"]
|
||||||
|
]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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"]]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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"]]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 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 SQLAlchemy database URL.
|
||||||
|
SQLALCHEMY_DATABASE_URL="postgresql://user:password@host/db"
|
||||||
|
# The Anthropic API key
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
*.egg-info
|
||||||
|
.pytest_cache
|
||||||
|
venv
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.claude
|
||||||
|
|
||||||
|
.scannerwork
|
||||||
|
sonar-project.properties
|
||||||
|
|
||||||
|
.env
|
||||||
|
instance
|
||||||
|
test_temp.py
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
|
||||||
|
include .env.example
|
||||||
|
recursive-include docs *
|
||||||
|
recursive-exclude docs/build *
|
||||||
|
recursive-include tests *
|
||||||
|
recursive-exclude tests *.pyc
|
||||||
|
exclude tests/test_temp.py
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
=======================================
|
||||||
|
Tools for A Feminist Audit of Pop Music
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
|
||||||
|
Description
|
||||||
|
===========
|
||||||
|
|
||||||
|
This is a collection of supporting tools for the conference paper "流行音樂中「女性力量」語彙的挪用與污染——以 Billboard Year-End Hot 100(2016–2025)為例的內容分析".
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
Use ``pip`` to install these tools.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
% pip install .
|
||||||
|
|
||||||
|
This will install the ``pop-fem-audit-tools`` script in the Python environment.
|
||||||
|
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
% pop-fem-audit-tools {command} [options] [arguments]
|
||||||
|
|
||||||
|
Runs a specific tool command, where "command" can be:
|
||||||
|
|
||||||
|
|
||||||
|
build-db
|
||||||
|
--------
|
||||||
|
|
||||||
|
Initialize or rebuild the database from the CSV Billboard ranking table, parse songs and its artists, and output song and artist lists for references. Check ``pop-fem-audit-tools build-db -h`` for complete instructions on its usage.
|
||||||
|
|
||||||
|
|
||||||
|
fetch-artists
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Fetch artist data from Wikidata, for ``build-db`` to merge the fetched data into the database. Check ``pop-fem-audit-tools fetch-artists -h`` for complete instructions on its usage.
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
-------
|
||||||
|
|
||||||
|
A general command that runs specific LLM instructions with the Anthropic API. The API key must be present in the ``.env`` file in the working directory. Check ``pop-fem-audit-tools run-llm -h`` for complete instructions on its usage.
|
||||||
|
|
||||||
|
|
||||||
|
Copyright
|
||||||
|
=========
|
||||||
|
|
||||||
|
Copyright (c) 2026 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
Authors
|
||||||
|
=======
|
||||||
|
|
||||||
|
| imacat
|
||||||
|
| imacat@mail.imacat.idv.tw
|
||||||
|
| 2026/7/31
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line, and also
|
||||||
|
# from the environment for the first two.
|
||||||
|
SPHINXOPTS ?=
|
||||||
|
SPHINXBUILD ?= sphinx-build
|
||||||
|
SOURCEDIR = source
|
||||||
|
BUILDDIR = build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
pushd %~dp0
|
||||||
|
|
||||||
|
REM Command file for Sphinx documentation
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" (
|
||||||
|
set SPHINXBUILD=sphinx-build
|
||||||
|
)
|
||||||
|
set SOURCEDIR=source
|
||||||
|
set BUILDDIR=build
|
||||||
|
|
||||||
|
%SPHINXBUILD% >NUL 2>NUL
|
||||||
|
if errorlevel 9009 (
|
||||||
|
echo.
|
||||||
|
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||||
|
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||||
|
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||||
|
echo.may add the Sphinx directory to PATH.
|
||||||
|
echo.
|
||||||
|
echo.If you don't have Sphinx installed, grab it from
|
||||||
|
echo.https://www.sphinx-doc.org/
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
|
||||||
|
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:help
|
||||||
|
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
|
||||||
|
:end
|
||||||
|
popd
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Configuration file for the Sphinx documentation builder.
|
||||||
|
#
|
||||||
|
# For the full list of built-in configuration values, see the documentation:
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath('../../src/'))
|
||||||
|
import pop_fem_audit_tools
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
|
project = 'Tools for A Feminist Audit of Pop Music'
|
||||||
|
copyright = '2026, imacat'
|
||||||
|
author = 'imacat'
|
||||||
|
release = pop_fem_audit_tools.VERSION
|
||||||
|
|
||||||
|
# -- General configuration ---------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|
||||||
|
extensions = ["sphinx.ext.autodoc"]
|
||||||
|
|
||||||
|
templates_path = ['_templates']
|
||||||
|
exclude_patterns = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||||
|
|
||||||
|
html_theme = 'nature'
|
||||||
|
html_static_path = ['_static']
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.. Tools for A Feminist Audit of Pop Music documentation master file, created by
|
||||||
|
sphinx-quickstart on Fri Jul 31 10:16:41 2026.
|
||||||
|
You can adapt this file completely to your liking, but it should at least
|
||||||
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
|
Tools for A Feminist Audit of Pop Music documentation
|
||||||
|
=====================================================
|
||||||
|
|
||||||
|
This is a collection of supporting tools for the conference paper "流行音樂中「女性力量」語彙的挪用與污染——以 Billboard Year-End Hot 100(2016–2025)為例的內容分析".
|
||||||
|
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contents:
|
||||||
|
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
==================
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`modindex`
|
||||||
|
* :ref:`search`
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
src
|
||||||
|
===
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
pop_fem_audit_tools
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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:
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
pop\_fem\_audit\_tools package
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Subpackages
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
pop_fem_audit_tools.commands
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
pop\_fem\_audit\_tools.config module
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools.config
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
pop\_fem\_audit\_tools.database module
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools.database
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
pop\_fem\_audit\_tools.models module
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools.models
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
pop\_fem\_audit\_tools.utils module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools.utils
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: pop_fem_audit_tools
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pop-fem-audit-tools"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "Tools for A Feminist Audit of Pop Music."
|
||||||
|
readme = "README.rst"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
authors = [
|
||||||
|
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
|
||||||
|
]
|
||||||
|
keywords = [
|
||||||
|
"content analysis",
|
||||||
|
"feminism",
|
||||||
|
"gender studies",
|
||||||
|
"popular music",
|
||||||
|
"lyrics",
|
||||||
|
"Billboard",
|
||||||
|
"LLM",
|
||||||
|
"computational social science",
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Environment :: Console",
|
||||||
|
"Intended Audience :: Science/Research",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Natural Language :: English",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Topic :: Scientific/Engineering :: Information Analysis",
|
||||||
|
"Topic :: Sociology",
|
||||||
|
"Topic :: Text Processing :: Linguistic",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"pydantic-settings >= 2",
|
||||||
|
"SQLAlchemy >= 2",
|
||||||
|
"anthropic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pop-fem-audit-tools = "pop_fem_audit_tools.__main__:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.dynamic]
|
||||||
|
version = {attr = "pop_fem_audit_tools.VERSION"}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
# AI assistance: Claude Code (Anthropic)
|
||||||
|
"""Tools for A Feminist Audit of Pop Music."""
|
||||||
|
|
||||||
|
|
||||||
|
VERSION: str = "0.0.0"
|
||||||
|
"""The package version."""
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
# AI assistance: Claude Code (Anthropic)
|
||||||
|
"""The package-level command-line entry point.
|
||||||
|
|
||||||
|
Dispatches ``python -m pop_fem_audit_tools <subcommand>`` or the
|
||||||
|
console script ``pop-fem-audit-tools <subcommand>`` to the main
|
||||||
|
function of the corresponding tool module.
|
||||||
|
"""
|
||||||
|
import os.path
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from importlib.machinery import ModuleSpec
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from .commands import (
|
||||||
|
build_db_command,
|
||||||
|
export_llm_input_command,
|
||||||
|
fetch_artists_command,
|
||||||
|
fetch_lyrics_command,
|
||||||
|
run_llm_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODULE_PROG: str = "python -m pop_fem_audit_tools"
|
||||||
|
"""The program name when run with ``python -m``."""
|
||||||
|
|
||||||
|
SUBCOMMANDS: dict[str, Callable[[list[str] | None], int]] = {
|
||||||
|
"build-db": build_db_command,
|
||||||
|
"export-llm-input": export_llm_input_command,
|
||||||
|
"fetch-artists": fetch_artists_command,
|
||||||
|
"fetch-lyrics": fetch_lyrics_command,
|
||||||
|
"run-llm": run_llm_command,
|
||||||
|
}
|
||||||
|
"""The dispatch table from the subcommand name to the tool main."""
|
||||||
|
|
||||||
|
|
||||||
|
def prog() -> str:
|
||||||
|
"""Return the program name shown in the usage messages.
|
||||||
|
|
||||||
|
:return: ``python -m pop_fem_audit_tools`` when run with
|
||||||
|
``python -m``, or the basename of ``sys.argv[0]`` when run
|
||||||
|
as a console script.
|
||||||
|
"""
|
||||||
|
if sys.argv[0].endswith("__main__.py"):
|
||||||
|
return MODULE_PROG
|
||||||
|
return os.path.basename(sys.argv[0])
|
||||||
|
|
||||||
|
|
||||||
|
def usage() -> str:
|
||||||
|
"""Return the usage text listing the available subcommands.
|
||||||
|
|
||||||
|
:return: The usage text.
|
||||||
|
"""
|
||||||
|
lines: list[str] = [
|
||||||
|
f"usage: {prog()} <subcommand> [<argument>...]",
|
||||||
|
"",
|
||||||
|
"subcommands:"]
|
||||||
|
lines.extend(f" {x}" for x in SUBCOMMANDS)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Dispatch a subcommand to its tool module.
|
||||||
|
|
||||||
|
:param argv: The command-line arguments, or None for ``sys.argv``.
|
||||||
|
:return: The exit status: the status of the subcommand, 0 for the
|
||||||
|
usage help, or non-zero on a usage error.
|
||||||
|
"""
|
||||||
|
args: list[str] = sys.argv[1:] if argv is None else argv
|
||||||
|
if len(args) == 0:
|
||||||
|
print("error: missing subcommand", file=sys.stderr)
|
||||||
|
print(usage(), file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args[0] in ("-h", "--help"):
|
||||||
|
print(usage())
|
||||||
|
return 0
|
||||||
|
if args[0] not in SUBCOMMANDS:
|
||||||
|
print(f"error: unknown subcommand \"{args[0]}\"",
|
||||||
|
file=sys.stderr)
|
||||||
|
print(usage(), file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
prog_backup: str = sys.argv[0]
|
||||||
|
main_module: ModuleType = sys.modules["__main__"]
|
||||||
|
spec_backup: ModuleSpec | None = main_module.__spec__
|
||||||
|
sys.argv[0] = f"{prog()} {args[0]}"
|
||||||
|
main_module.__spec__ = None
|
||||||
|
try:
|
||||||
|
return SUBCOMMANDS[args[0]](args[1:])
|
||||||
|
finally:
|
||||||
|
sys.argv[0] = prog_backup
|
||||||
|
main_module.__spec__ = spec_backup
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,840 @@
|
|||||||
|
# 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 fetcher of the artist metadata.
|
||||||
|
|
||||||
|
Fetches the metadata of the artists without a snapshot row from
|
||||||
|
Wikidata into the capture layer: the Wikidata artist snapshot
|
||||||
|
CSV, given as the positional command-line argument. The working
|
||||||
|
store is only read, never written; the ``build-db`` subcommand
|
||||||
|
assembles the captured files into the store on the next rebuild.
|
||||||
|
|
||||||
|
Every fetched row is meant for later human verification: the
|
||||||
|
description of the resolved item is recorded in the note column
|
||||||
|
so that a bad match can be spotted. An unresolved artist or an
|
||||||
|
error on one artist is noted on its row and does not fail the
|
||||||
|
run.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import asdict, dataclass, field, fields
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, TextIO
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import VERSION
|
||||||
|
from ..database import ds
|
||||||
|
from ..models import Artist, Song, SongArtist
|
||||||
|
from ..utils import format_duration
|
||||||
|
|
||||||
|
API_URL: str = "https://www.wikidata.org/w/api.php"
|
||||||
|
"""The URL of the Wikidata API endpoint."""
|
||||||
|
SPARQL_URL: str = "https://query.wikidata.org/sparql"
|
||||||
|
"""The URL of the Wikidata Query Service SPARQL endpoint."""
|
||||||
|
USER_AGENT: str = (
|
||||||
|
f"pop-fem-audit-tools/{VERSION}"
|
||||||
|
" (https://github.com/imacat/pop-fem-audit;"
|
||||||
|
" mailto:imacat@mail.imacat.idv.tw)")
|
||||||
|
"""The User-Agent header sent on every HTTP request."""
|
||||||
|
TIMEOUT: float = 30.0
|
||||||
|
"""The timeout of an API HTTP request, in seconds."""
|
||||||
|
SPARQL_TIMEOUT: float = 90.0
|
||||||
|
"""The timeout of a SPARQL HTTP request, in seconds.
|
||||||
|
|
||||||
|
Higher than the API timeout: the WDQS server aborts a slow
|
||||||
|
query at 60 seconds, and a lower client timeout would race
|
||||||
|
that server-side abort and misclassify a slow-but-answerable
|
||||||
|
query as a client-side timeout instead of letting the server's
|
||||||
|
own HTTP error response arrive and enter the retry path."""
|
||||||
|
SLEEP_SECONDS: float = 1.0
|
||||||
|
"""The delay between consecutive HTTP requests, in seconds."""
|
||||||
|
MAX_ATTEMPTS: int = 5
|
||||||
|
"""The maximum number of attempts on a transient error."""
|
||||||
|
RETRY_SECONDS: float = 15.0
|
||||||
|
"""The back-off unit on a transient error, in seconds;
|
||||||
|
multiplied by the attempt number already made."""
|
||||||
|
RETRY_STATUSES: frozenset[int] = frozenset({429, 500, 502, 503})
|
||||||
|
"""The HTTP statuses that are retried with a back-off."""
|
||||||
|
MAX_STAGE1_TITLES: int = 3
|
||||||
|
"""The maximum number of charted titles used for the stage-1 song
|
||||||
|
corroboration."""
|
||||||
|
HUMAN_QID: str = "Q5"
|
||||||
|
"""The Wikidata item ID of "human"."""
|
||||||
|
ENSEMBLE_QID: str = "Q2088357"
|
||||||
|
"""The Wikidata item ID of "musical ensemble"."""
|
||||||
|
ORIGINAL_CAST_QID: str = "Q106497009"
|
||||||
|
"""The Wikidata item ID of "original cast"."""
|
||||||
|
GROUP_KEYWORDS: Sequence[str] = ("band", "group", "duo", "trio")
|
||||||
|
"""The label keywords that suggest a musical ensemble, covering
|
||||||
|
labels like "boy band" and "girl group"."""
|
||||||
|
NOTE_NOT_FOUND: str = "not found"
|
||||||
|
"""The note sentinel of an artist without a resolved Wikidata
|
||||||
|
item, written to the snapshot and read back for the
|
||||||
|
classification."""
|
||||||
|
PINNED_QIDS: dict[str, str] = {
|
||||||
|
"Pinkfong": "Q55735607",
|
||||||
|
}
|
||||||
|
"""The last-resort pinned item IDs, keyed by the artist name.
|
||||||
|
|
||||||
|
Each entry is for an artist the algorithm documented on
|
||||||
|
``ArtistFetcher`` is structurally unable to resolve, with its
|
||||||
|
justification recorded here:
|
||||||
|
|
||||||
|
- "Pinkfong": the only charting act whose item is typed as a
|
||||||
|
brand (P31 = Q431289), which the type gate (human / musical
|
||||||
|
ensemble / original cast) excludes by design.
|
||||||
|
|
||||||
|
A pinned name skips the candidate retrieval and corroboration
|
||||||
|
steps; its item ID is used directly."""
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistType(enum.StrEnum):
|
||||||
|
"""The decided artist type of a snapshot row."""
|
||||||
|
|
||||||
|
SOLO = "solo"
|
||||||
|
"""A solo artist: a human."""
|
||||||
|
GROUP = "group"
|
||||||
|
"""A musical ensemble."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArtistSnapshot:
|
||||||
|
"""One row of the Wikidata artist snapshot CSV file."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""The artist name."""
|
||||||
|
qid: str = ""
|
||||||
|
"""The Wikidata item ID, or empty when unresolved."""
|
||||||
|
gender: str = ""
|
||||||
|
"""The gender label, or empty when unresolved."""
|
||||||
|
type: str = ""
|
||||||
|
"""The artist type, an ``ArtistType`` value, or empty for the
|
||||||
|
human to decide."""
|
||||||
|
genre: str = ""
|
||||||
|
"""The genre labels, joined with ``; ``."""
|
||||||
|
country: str = ""
|
||||||
|
"""The country label, or empty when unresolved."""
|
||||||
|
note: str = ""
|
||||||
|
"""The note for human verification: the description of the
|
||||||
|
resolved item, ``not found``, or ``error: <reason>``."""
|
||||||
|
|
||||||
|
def to_row(self) -> dict[str, str]:
|
||||||
|
"""Return this snapshot as a CSV row.
|
||||||
|
|
||||||
|
:return: The row values, keyed by the column name.
|
||||||
|
"""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
SNAPSHOT_FIELDS: Sequence[str] = tuple(
|
||||||
|
x.name for x in fields(ArtistSnapshot))
|
||||||
|
"""The header columns of the Wikidata artist snapshot CSV file."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArtistClaims:
|
||||||
|
"""The item-ID claim targets and description of a Wikidata
|
||||||
|
artist item."""
|
||||||
|
|
||||||
|
gender_ids: list[str] = field(default_factory=list)
|
||||||
|
"""The item IDs of the gender targets."""
|
||||||
|
instance_of_ids: list[str] = field(default_factory=list)
|
||||||
|
"""The item IDs of the instance-of targets."""
|
||||||
|
genre_ids: list[str] = field(default_factory=list)
|
||||||
|
"""The item IDs of the genre targets."""
|
||||||
|
country_ids: list[str] = field(default_factory=list)
|
||||||
|
"""The item IDs of the country-of-citizenship targets."""
|
||||||
|
origin_country_ids: list[str] = field(default_factory=list)
|
||||||
|
"""The item IDs of the country-of-origin targets."""
|
||||||
|
description: str = ""
|
||||||
|
"""The English description of the item, or empty when
|
||||||
|
absent."""
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExhausted(Exception):
|
||||||
|
"""The retries on a transient error are exhausted.
|
||||||
|
|
||||||
|
A transient error is a retryable HTTP status (429, 500,
|
||||||
|
502, or 503) or a read timeout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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="Fetch the artist metadata from Wikidata"
|
||||||
|
" into the capture layer.")
|
||||||
|
parser.add_argument(
|
||||||
|
"wikidata_csv", type=Path,
|
||||||
|
help="the Wikidata artist snapshot CSV file")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistFetcher:
|
||||||
|
"""A fetcher of artist metadata from Wikidata.
|
||||||
|
|
||||||
|
An artist name is resolved to a Wikidata item ID through the
|
||||||
|
Wikidata Query Service (SPARQL), not the search API, so that
|
||||||
|
every step is a deterministic indexed lookup with no ranking
|
||||||
|
and no case folding of the label text itself:
|
||||||
|
|
||||||
|
1. Candidate retrieval: the items whose ``rdfs:label`` or
|
||||||
|
``skos:altLabel`` exactly equals the artist name at
|
||||||
|
``@en`` or ``@mul`` (the multilingual default layer),
|
||||||
|
restricted to a human (P31 = Q5), a musical ensemble
|
||||||
|
(P31/P279* = Q2088357), or an original cast
|
||||||
|
(P31 = Q106497009).
|
||||||
|
2. A single candidate is selected outright.
|
||||||
|
3. With multiple candidates, stage 1 corroborates with up to
|
||||||
|
the first 3 charted titles: the song items whose label or
|
||||||
|
alias exactly equals a title at ``@en`` or ``@mul`` are
|
||||||
|
looked up with their P175 performers and, optionally,
|
||||||
|
those performers' P527 members. The candidates are
|
||||||
|
intersected with the performers, and, only when that
|
||||||
|
intersection is empty, with the members; a stage succeeds
|
||||||
|
only when the intersection has exactly one item.
|
||||||
|
4. Stage 2 is an anchored, case-insensitive fallback: every
|
||||||
|
song performed by a candidate, directly or via a parent
|
||||||
|
group, is compared against the charted titles with a
|
||||||
|
casefold match on the song label, without a language
|
||||||
|
restriction; a single matching candidate is selected.
|
||||||
|
5. Zero candidates, or no stage narrowing to exactly one
|
||||||
|
item, leaves the artist unresolved.
|
||||||
|
|
||||||
|
As a last resort, a name listed in ``PINNED_QIDS`` uses its
|
||||||
|
pinned item ID directly, skipping every step above.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Construct the fetcher."""
|
||||||
|
self.__sent: int = 0
|
||||||
|
"""The number of the HTTP requests already sent."""
|
||||||
|
|
||||||
|
def fetch(self, name: str,
|
||||||
|
titles: Sequence[str]) -> ArtistSnapshot:
|
||||||
|
"""Fetch the metadata of an artist.
|
||||||
|
|
||||||
|
The note carries the description of the resolved item
|
||||||
|
for human verification. ``not found`` is reserved for
|
||||||
|
the algorithm genuinely finding nothing for the artist.
|
||||||
|
Any HTTP, network, or decoding error -- including the
|
||||||
|
retries on a transient error being exhausted -- yields a
|
||||||
|
snapshot with what was resolved so far and the note
|
||||||
|
``error: <reason>``.
|
||||||
|
|
||||||
|
:param name: The artist name to resolve.
|
||||||
|
:param titles: The charted song titles credited to the
|
||||||
|
artist, used for the multi-candidate resolution.
|
||||||
|
:return: The snapshot of the artist.
|
||||||
|
"""
|
||||||
|
snapshot: ArtistSnapshot = ArtistSnapshot(name=name)
|
||||||
|
try:
|
||||||
|
qid: str | None = self.__resolve_qid(name, titles)
|
||||||
|
if qid is None:
|
||||||
|
snapshot.note = NOTE_NOT_FOUND
|
||||||
|
return snapshot
|
||||||
|
snapshot.qid = qid
|
||||||
|
self.__resolve(snapshot)
|
||||||
|
except (RetryExhausted, OSError, ValueError) as error:
|
||||||
|
snapshot.note = f"error: {error}"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def __resolve_qid(self, name: str,
|
||||||
|
titles: Sequence[str]) -> str | None:
|
||||||
|
"""Resolve an artist name to a Wikidata item ID.
|
||||||
|
|
||||||
|
:param name: The artist name.
|
||||||
|
:param titles: The charted song titles credited to the
|
||||||
|
artist.
|
||||||
|
:return: The pinned item ID from ``PINNED_QIDS`` when the
|
||||||
|
name is listed there; otherwise the resolved item
|
||||||
|
ID, or None when the algorithm documented on this
|
||||||
|
class does not narrow to exactly one item.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
if name in PINNED_QIDS:
|
||||||
|
return PINNED_QIDS[name]
|
||||||
|
candidates: list[str] = self.__candidates(name)
|
||||||
|
if len(candidates) == 0:
|
||||||
|
return None
|
||||||
|
if len(candidates) == 1:
|
||||||
|
return candidates[0]
|
||||||
|
selected: str | None = self.__stage1(candidates, titles)
|
||||||
|
if selected is not None:
|
||||||
|
return selected
|
||||||
|
return self.__stage2(candidates, titles)
|
||||||
|
|
||||||
|
def __candidates(self, name: str) -> list[str]:
|
||||||
|
"""Retrieve the Wikidata items matching an artist name.
|
||||||
|
|
||||||
|
:param name: The artist name.
|
||||||
|
:return: The item IDs of the human, musical-ensemble, or
|
||||||
|
original-cast items whose label or alias exactly
|
||||||
|
equals the name.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
query: str = f"""
|
||||||
|
SELECT DISTINCT ?item WHERE {{
|
||||||
|
VALUES ?name {{ {self.__literals([name])} }}
|
||||||
|
{{ ?item rdfs:label ?name }}
|
||||||
|
UNION {{ ?item skos:altLabel ?name }}
|
||||||
|
{{
|
||||||
|
?item wdt:P31 wd:{HUMAN_QID}
|
||||||
|
}} UNION {{
|
||||||
|
?item wdt:P31/wdt:P279* wd:{ENSEMBLE_QID}
|
||||||
|
}} UNION {{
|
||||||
|
?item wdt:P31 wd:{ORIGINAL_CAST_QID}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, str]] = self.__sparql(query)
|
||||||
|
return [self.__qid(x["item"]) for x in rows
|
||||||
|
if "item" in x]
|
||||||
|
|
||||||
|
def __stage1(self, candidates: Sequence[str],
|
||||||
|
titles: Sequence[str]) -> str | None:
|
||||||
|
"""Corroborate the candidates with the charted titles.
|
||||||
|
|
||||||
|
:param candidates: The candidate item IDs.
|
||||||
|
:param titles: The charted song titles credited to the
|
||||||
|
artist.
|
||||||
|
:return: The single candidate among the performers of a
|
||||||
|
matching song, or, only when no such single
|
||||||
|
candidate exists, among those performers' group
|
||||||
|
members; None when neither intersection has exactly
|
||||||
|
one item, or there are no titles to try.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
subset: Sequence[str] = titles[:MAX_STAGE1_TITLES]
|
||||||
|
if len(subset) == 0:
|
||||||
|
return None
|
||||||
|
query: str = f"""
|
||||||
|
SELECT DISTINCT ?performer ?member WHERE {{
|
||||||
|
VALUES ?title {{ {self.__literals(subset)} }}
|
||||||
|
{{ ?song rdfs:label ?title }}
|
||||||
|
UNION {{ ?song skos:altLabel ?title }}
|
||||||
|
?song wdt:P175 ?performer .
|
||||||
|
OPTIONAL {{ ?performer wdt:P527 ?member . }}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, str]] = self.__sparql(query)
|
||||||
|
performers: set[str] = {
|
||||||
|
self.__qid(x["performer"]) for x in rows
|
||||||
|
if "performer" in x}
|
||||||
|
hit: set[str] = set(candidates) & performers
|
||||||
|
if len(hit) == 1:
|
||||||
|
return next(iter(hit))
|
||||||
|
members: set[str] = {
|
||||||
|
self.__qid(x["member"]) for x in rows
|
||||||
|
if "member" in x}
|
||||||
|
hit = set(candidates) & members
|
||||||
|
if len(hit) == 1:
|
||||||
|
return next(iter(hit))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __stage2(self, candidates: Sequence[str],
|
||||||
|
titles: Sequence[str]) -> str | None:
|
||||||
|
"""Rescue a single candidate by a case-insensitive match.
|
||||||
|
|
||||||
|
:param candidates: The candidate item IDs.
|
||||||
|
:param titles: The charted song titles credited to the
|
||||||
|
artist.
|
||||||
|
:return: The single candidate with a charted song among
|
||||||
|
the songs it, or a parent group, performs, matched
|
||||||
|
case-insensitively against the song label; None when
|
||||||
|
no such single candidate exists.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
values: str = " ".join(f"wd:{x}" for x in candidates)
|
||||||
|
query: str = f"""
|
||||||
|
SELECT DISTINCT ?cand ?label WHERE {{
|
||||||
|
VALUES ?cand {{ {values} }}
|
||||||
|
{{ ?song wdt:P175 ?cand }}
|
||||||
|
UNION {{ ?g wdt:P527 ?cand . ?song wdt:P175 ?g }}
|
||||||
|
?song rdfs:label ?label .
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, str]] = self.__sparql(query)
|
||||||
|
folded: set[str] = {x.casefold() for x in titles}
|
||||||
|
hits: set[str] = {
|
||||||
|
self.__qid(x["cand"]) for x in rows
|
||||||
|
if "cand" in x and "label" in x
|
||||||
|
and x["label"].casefold() in folded}
|
||||||
|
if len(hits) == 1:
|
||||||
|
return next(iter(hits))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __resolve(self, snapshot: ArtistSnapshot) -> None:
|
||||||
|
"""Resolve the claims of an artist into the snapshot.
|
||||||
|
|
||||||
|
:param snapshot: The snapshot, with the QID set.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
claims: ArtistClaims = self.__get_claims(snapshot.qid)
|
||||||
|
snapshot.note = claims.description
|
||||||
|
country_ids: list[str] = claims.country_ids
|
||||||
|
if len(country_ids) == 0:
|
||||||
|
country_ids = claims.origin_country_ids
|
||||||
|
labels: dict[str, str] = self.__get_labels(
|
||||||
|
claims.gender_ids + claims.instance_of_ids
|
||||||
|
+ claims.genre_ids + country_ids)
|
||||||
|
if len(claims.gender_ids) > 0:
|
||||||
|
snapshot.gender = labels.get(claims.gender_ids[0], "")
|
||||||
|
snapshot.type = self.__artist_type(
|
||||||
|
claims.instance_of_ids, labels)
|
||||||
|
snapshot.genre = "; ".join(
|
||||||
|
labels[x] for x in claims.genre_ids if x in labels)
|
||||||
|
if len(country_ids) > 0:
|
||||||
|
snapshot.country = labels.get(country_ids[0], "")
|
||||||
|
|
||||||
|
def __get_claims(self, qid: str) -> ArtistClaims:
|
||||||
|
"""Fetch the claims and the description of a Wikidata
|
||||||
|
item.
|
||||||
|
|
||||||
|
:param qid: The item ID.
|
||||||
|
:return: The item-ID targets of the gender, instance-of,
|
||||||
|
genre, and country properties, and the English
|
||||||
|
description.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
data: Any = self.__get_json({
|
||||||
|
"action": "wbgetentities", "ids": qid,
|
||||||
|
"props": "claims|descriptions", "languages": "en",
|
||||||
|
"format": "json"})
|
||||||
|
entity: Any = None
|
||||||
|
if isinstance(data, dict) \
|
||||||
|
and isinstance(data.get("entities"), dict):
|
||||||
|
entity = data["entities"].get(qid)
|
||||||
|
claims: Any = entity.get("claims") \
|
||||||
|
if isinstance(entity, dict) else None
|
||||||
|
if not isinstance(claims, dict):
|
||||||
|
claims = {}
|
||||||
|
return ArtistClaims(
|
||||||
|
gender_ids=self.__targets(claims.get("P21")),
|
||||||
|
instance_of_ids=self.__targets(claims.get("P31")),
|
||||||
|
genre_ids=self.__targets(claims.get("P136")),
|
||||||
|
country_ids=self.__targets(claims.get("P27")),
|
||||||
|
origin_country_ids=self.__targets(claims.get("P495")),
|
||||||
|
description=self.__description(entity))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __description(entity: Any) -> str:
|
||||||
|
"""Extract the English description of a Wikidata entity.
|
||||||
|
|
||||||
|
:param entity: The entity data, or None.
|
||||||
|
:return: The description, or the empty string when
|
||||||
|
absent.
|
||||||
|
"""
|
||||||
|
descriptions: Any = entity.get("descriptions") \
|
||||||
|
if isinstance(entity, dict) else None
|
||||||
|
if not isinstance(descriptions, dict):
|
||||||
|
return ""
|
||||||
|
description: Any = descriptions.get("en")
|
||||||
|
if isinstance(description, dict) \
|
||||||
|
and isinstance(description.get("value"), str):
|
||||||
|
return description["value"]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __targets(statements: Any) -> list[str]:
|
||||||
|
"""Extract the item-ID targets of the property statements.
|
||||||
|
|
||||||
|
:param statements: The statements of a property, or None.
|
||||||
|
:return: The item IDs of the statement targets.
|
||||||
|
"""
|
||||||
|
if not isinstance(statements, list):
|
||||||
|
return []
|
||||||
|
ids: list[str] = []
|
||||||
|
statement: Any
|
||||||
|
for statement in statements:
|
||||||
|
if not isinstance(statement, dict):
|
||||||
|
continue
|
||||||
|
snak: Any = statement.get("mainsnak")
|
||||||
|
if not isinstance(snak, dict):
|
||||||
|
continue
|
||||||
|
datavalue: Any = snak.get("datavalue")
|
||||||
|
if not isinstance(datavalue, dict):
|
||||||
|
continue
|
||||||
|
value: Any = datavalue.get("value")
|
||||||
|
if isinstance(value, dict) \
|
||||||
|
and isinstance(value.get("id"), str):
|
||||||
|
ids.append(value["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def __get_labels(self, qids: Sequence[str]) \
|
||||||
|
-> dict[str, str]:
|
||||||
|
"""Resolve item IDs to their English labels in one batch.
|
||||||
|
|
||||||
|
:param qids: The item IDs, duplicates allowed.
|
||||||
|
:return: The English labels, keyed by the item ID; the
|
||||||
|
items without an English label are left out.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
unique: list[str] = list(dict.fromkeys(qids))
|
||||||
|
if len(unique) == 0:
|
||||||
|
return {}
|
||||||
|
data: Any = self.__get_json({
|
||||||
|
"action": "wbgetentities", "ids": "|".join(unique),
|
||||||
|
"props": "labels", "languages": "en",
|
||||||
|
"format": "json"})
|
||||||
|
entities: Any = data.get("entities") \
|
||||||
|
if isinstance(data, dict) else None
|
||||||
|
if not isinstance(entities, dict):
|
||||||
|
return {}
|
||||||
|
labels: dict[str, str] = {}
|
||||||
|
qid: str
|
||||||
|
for qid in unique:
|
||||||
|
entity: Any = entities.get(qid)
|
||||||
|
if not isinstance(entity, dict) \
|
||||||
|
or not isinstance(entity.get("labels"), dict):
|
||||||
|
continue
|
||||||
|
label: Any = entity["labels"].get("en")
|
||||||
|
if isinstance(label, dict) \
|
||||||
|
and isinstance(label.get("value"), str):
|
||||||
|
labels[qid] = label["value"]
|
||||||
|
return labels
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __artist_type(type_ids: Sequence[str],
|
||||||
|
labels: dict[str, str]) \
|
||||||
|
-> ArtistType | Literal[""]:
|
||||||
|
"""Derive the artist type from the instance-of targets.
|
||||||
|
|
||||||
|
:param type_ids: The item IDs of the instance-of targets.
|
||||||
|
:param labels: The English labels, keyed by the item ID.
|
||||||
|
:return: ``ArtistType.SOLO`` for a human,
|
||||||
|
``ArtistType.GROUP`` for a musical ensemble, or the
|
||||||
|
empty string for the human to decide.
|
||||||
|
"""
|
||||||
|
if HUMAN_QID in type_ids:
|
||||||
|
return ArtistType.SOLO
|
||||||
|
qid: str
|
||||||
|
for qid in type_ids:
|
||||||
|
label: str = labels.get(qid, "").lower()
|
||||||
|
if any(x in label for x in GROUP_KEYWORDS):
|
||||||
|
return ArtistType.GROUP
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def __sparql(self, query: str) -> list[dict[str, str]]:
|
||||||
|
"""Run a SPARQL query against the Wikidata Query Service.
|
||||||
|
|
||||||
|
:param query: The SPARQL query text.
|
||||||
|
:return: The result bindings, each variable name mapped
|
||||||
|
to its bound value.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
url: str = (f"{SPARQL_URL}?"
|
||||||
|
f"{urllib.parse.urlencode({'query': query})}")
|
||||||
|
request: urllib.request.Request = urllib.request.Request(
|
||||||
|
url, headers={
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"Accept": "application/sparql-results+json"})
|
||||||
|
body: bytes = self.__send(
|
||||||
|
request, timeout=SPARQL_TIMEOUT)
|
||||||
|
data: Any = json.loads(body)
|
||||||
|
bindings: Any = None
|
||||||
|
if isinstance(data, dict) \
|
||||||
|
and isinstance(data.get("results"), dict):
|
||||||
|
bindings = data["results"].get("bindings")
|
||||||
|
if not isinstance(bindings, list):
|
||||||
|
return []
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
binding: Any
|
||||||
|
for binding in bindings:
|
||||||
|
if not isinstance(binding, dict):
|
||||||
|
continue
|
||||||
|
row: dict[str, str] = {}
|
||||||
|
key: str
|
||||||
|
cell: Any
|
||||||
|
for key, cell in binding.items():
|
||||||
|
if isinstance(cell, dict) \
|
||||||
|
and isinstance(cell.get("value"), str):
|
||||||
|
row[key] = cell["value"]
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def __get_json(self, params: dict[str, str]) -> Any:
|
||||||
|
"""Send a GET request to the API and return the JSON body.
|
||||||
|
|
||||||
|
:param params: The query parameters.
|
||||||
|
:return: The parsed JSON body.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
:raises ValueError: On a JSON decoding error.
|
||||||
|
"""
|
||||||
|
url: str = f"{API_URL}?{urllib.parse.urlencode(params)}"
|
||||||
|
request: urllib.request.Request = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": USER_AGENT})
|
||||||
|
return json.loads(self.__send(request))
|
||||||
|
|
||||||
|
def __send(self, request: urllib.request.Request,
|
||||||
|
timeout: float = TIMEOUT) -> bytes:
|
||||||
|
"""Send an HTTP request, retrying on a transient error.
|
||||||
|
|
||||||
|
Consecutive requests are separated by a fixed delay. A
|
||||||
|
transient error -- a response with a retryable HTTP
|
||||||
|
status, or a read timeout -- is retried with an
|
||||||
|
increasing back-off, up to ``MAX_ATTEMPTS`` attempts in
|
||||||
|
total.
|
||||||
|
|
||||||
|
:param request: The prepared HTTP request.
|
||||||
|
:param timeout: The read timeout, in seconds.
|
||||||
|
:return: The raw response body.
|
||||||
|
:raises OSError: On a non-retryable HTTP or network
|
||||||
|
error.
|
||||||
|
:raises RetryExhausted: When the retries on a
|
||||||
|
transient error are exhausted.
|
||||||
|
"""
|
||||||
|
if self.__sent > 0:
|
||||||
|
time.sleep(SLEEP_SECONDS)
|
||||||
|
self.__sent += 1
|
||||||
|
attempt: int = 1
|
||||||
|
reason: str
|
||||||
|
cause: BaseException
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
request, timeout=timeout) as response:
|
||||||
|
return response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if error.code not in RETRY_STATUSES:
|
||||||
|
raise
|
||||||
|
reason = str(error)
|
||||||
|
cause = error
|
||||||
|
except TimeoutError as error:
|
||||||
|
reason = str(error) or "timed out"
|
||||||
|
cause = error
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
if not isinstance(error.reason, TimeoutError):
|
||||||
|
raise
|
||||||
|
reason = str(error.reason) or "timed out"
|
||||||
|
cause = error
|
||||||
|
if attempt >= MAX_ATTEMPTS:
|
||||||
|
raise RetryExhausted(
|
||||||
|
f"retries exhausted ({reason})") from cause
|
||||||
|
time.sleep(RETRY_SECONDS * attempt)
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __literals(texts: Sequence[str]) -> str:
|
||||||
|
"""Build the SPARQL literals of texts at both languages.
|
||||||
|
|
||||||
|
:param texts: The texts to embed as string literals.
|
||||||
|
:return: The literals, each text once tagged ``@en`` and
|
||||||
|
once tagged ``@mul``, space-separated.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
text: str
|
||||||
|
for text in texts:
|
||||||
|
escaped: str = ArtistFetcher.__escape(text)
|
||||||
|
parts.append(f'"{escaped}"@en')
|
||||||
|
parts.append(f'"{escaped}"@mul')
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __escape(text: str) -> str:
|
||||||
|
"""Escape a text for embedding as a SPARQL string literal.
|
||||||
|
|
||||||
|
:param text: The text to embed.
|
||||||
|
:return: The text with the backslashes and double quotes
|
||||||
|
escaped.
|
||||||
|
"""
|
||||||
|
return text.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __qid(uri: str) -> str:
|
||||||
|
"""Extract the item ID from a Wikidata entity URI.
|
||||||
|
|
||||||
|
:param uri: The entity URI.
|
||||||
|
:return: The item ID, the last path segment of the URI.
|
||||||
|
"""
|
||||||
|
return uri.rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def read_snapshot_rows(file: TextIO) -> list[dict[str, str]]:
|
||||||
|
"""Read the current rows of a snapshot CSV file handle.
|
||||||
|
|
||||||
|
:param file: The open, seekable snapshot CSV file.
|
||||||
|
:return: The rows, keyed by the column name.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
"""
|
||||||
|
file.seek(0)
|
||||||
|
reader: csv.DictReader[str] = csv.DictReader(file)
|
||||||
|
return list(reader)
|
||||||
|
|
||||||
|
|
||||||
|
def read_artist_titles(session: Session,
|
||||||
|
artist_id: int) -> list[str]:
|
||||||
|
"""Read the charted song titles credited to an artist.
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
:param artist_id: The artist ID.
|
||||||
|
:return: The song titles credited to the artist, ordered by
|
||||||
|
the song ID, with the duplicate titles removed.
|
||||||
|
"""
|
||||||
|
titles: Sequence[str] = session.scalars(
|
||||||
|
sa.select(Song.title)
|
||||||
|
.join(SongArtist, SongArtist.song_id == Song.id)
|
||||||
|
.where(SongArtist.artist_id == artist_id)
|
||||||
|
.order_by(Song.id)).all()
|
||||||
|
return list(dict.fromkeys(titles))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_snapshot_header(file: TextIO) -> None:
|
||||||
|
"""Write the snapshot CSV header row if the file is empty.
|
||||||
|
|
||||||
|
:param file: The open, seekable snapshot CSV file.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be written.
|
||||||
|
"""
|
||||||
|
file.seek(0, os.SEEK_END)
|
||||||
|
if file.tell() == 0:
|
||||||
|
csv.writer(file).writerow(SNAPSHOT_FIELDS)
|
||||||
|
file.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def append_row(file: TextIO, snapshot: ArtistSnapshot) -> None:
|
||||||
|
"""Append a snapshot row to a snapshot CSV file handle.
|
||||||
|
|
||||||
|
:param file: The open snapshot CSV file, opened for append.
|
||||||
|
:param snapshot: The snapshot of an artist.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be written.
|
||||||
|
"""
|
||||||
|
csv.DictWriter(file, SNAPSHOT_FIELDS).writerow(
|
||||||
|
snapshot.to_row())
|
||||||
|
file.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def write_snapshot(file: TextIO) -> None:
|
||||||
|
"""Rewrite a snapshot CSV file handle sorted by artist name.
|
||||||
|
|
||||||
|
Reads back the current rows and rewrites the header and the
|
||||||
|
rows ordered by the case-folded artist name, matching the
|
||||||
|
convention of the derived ``artists.csv``.
|
||||||
|
|
||||||
|
:param file: The open, seekable snapshot CSV file.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be read or written.
|
||||||
|
"""
|
||||||
|
ordered: list[dict[str, str]] = sorted(
|
||||||
|
read_snapshot_rows(file),
|
||||||
|
key=lambda row: row["name"].casefold())
|
||||||
|
file.seek(0)
|
||||||
|
file.truncate()
|
||||||
|
writer: csv.DictWriter[str] = csv.DictWriter(
|
||||||
|
file, SNAPSHOT_FIELDS)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Fetch the artist metadata from Wikidata.
|
||||||
|
|
||||||
|
:param argv: The command-line arguments, or None for
|
||||||
|
``sys.argv``.
|
||||||
|
:return: The exit status: 0 on success, misses and errors
|
||||||
|
included, non-zero on a setup error.
|
||||||
|
"""
|
||||||
|
started: float = time.monotonic()
|
||||||
|
args: argparse.Namespace = parse_args(argv)
|
||||||
|
fetcher: ArtistFetcher = ArtistFetcher()
|
||||||
|
fetched: int = 0
|
||||||
|
not_found: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
session: Session = ds.get_db()
|
||||||
|
try:
|
||||||
|
args.wikidata_csv.parent.mkdir(
|
||||||
|
parents=True, exist_ok=True)
|
||||||
|
with open(args.wikidata_csv, "a+", encoding="utf-8",
|
||||||
|
newline="") as csv_file:
|
||||||
|
done: set[str] = {x["name"] for x in
|
||||||
|
read_snapshot_rows(csv_file)}
|
||||||
|
ensure_snapshot_header(csv_file)
|
||||||
|
artist: Artist
|
||||||
|
for artist in session.scalars(
|
||||||
|
sa.select(Artist).order_by(Artist.id)):
|
||||||
|
if artist.name in done:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
titles: list[str] = read_artist_titles(
|
||||||
|
session, artist.id)
|
||||||
|
snapshot: ArtistSnapshot = fetcher.fetch(
|
||||||
|
artist.name, titles)
|
||||||
|
append_row(csv_file, snapshot)
|
||||||
|
status: str = snapshot.qid
|
||||||
|
if snapshot.note == NOTE_NOT_FOUND:
|
||||||
|
not_found += 1
|
||||||
|
status = "not found"
|
||||||
|
elif snapshot.note.startswith("error: "):
|
||||||
|
errors += 1
|
||||||
|
status = snapshot.note
|
||||||
|
else:
|
||||||
|
fetched += 1
|
||||||
|
print(f"artist \"{artist.name}\": {status}",
|
||||||
|
file=sys.stderr)
|
||||||
|
write_snapshot(csv_file)
|
||||||
|
except (OSError, sa.exc.SQLAlchemyError) as error:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
attempted: int = fetched + not_found + errors
|
||||||
|
elapsed: str = format_duration(time.monotonic() - started)
|
||||||
|
print(f"Done. Resolved {fetched}/{attempted} artists."
|
||||||
|
f" {elapsed} elapsed.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# 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 fetcher of the missing song lyrics.
|
||||||
|
|
||||||
|
Fetches the lyrics of the songs without a cache file from the
|
||||||
|
public lyrics APIs, Lyrics.ovh and LRCLIB, into the capture
|
||||||
|
layer: the lyrics cache directory and the provenance CSV, each
|
||||||
|
given as a positional command-line argument. The working store
|
||||||
|
is only read, never written; the ``build-db`` subcommand
|
||||||
|
assembles the captured files into the store on the next rebuild.
|
||||||
|
|
||||||
|
Each song is first queried with the name of its primary-role
|
||||||
|
artist with the lowest position. When every API misses that
|
||||||
|
query and the song's full artist credit differs from that
|
||||||
|
artist name, the same APIs are queried again with the artist
|
||||||
|
credit, to catch songs cataloged only under a joint credit such
|
||||||
|
as "Dan + Shay".
|
||||||
|
|
||||||
|
A song that every API misses on both queries is reported on the
|
||||||
|
standard error, but does not fail the run; misses are expected.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import ds
|
||||||
|
from ..models import (
|
||||||
|
Artist,
|
||||||
|
Role,
|
||||||
|
Song,
|
||||||
|
SongArtist,
|
||||||
|
)
|
||||||
|
from ..utils import format_duration
|
||||||
|
|
||||||
|
PROVENANCE_FIELDS: Sequence[str] = (
|
||||||
|
"song_id", "source", "method", "acquired_at", "note")
|
||||||
|
"""The header columns of the lyrics provenance CSV file."""
|
||||||
|
USER_AGENT: str = ("pop-fem-audit-tools"
|
||||||
|
" (https://github.com/imacat/pop-fem-audit)")
|
||||||
|
"""The User-Agent header sent on every HTTP request."""
|
||||||
|
TIMEOUT: float = 30.0
|
||||||
|
"""The timeout of an HTTP request, in seconds."""
|
||||||
|
SLEEP_SECONDS: float = 1.0
|
||||||
|
"""The delay between consecutive HTTP requests, in seconds."""
|
||||||
|
|
||||||
|
|
||||||
|
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="Fetch the missing song lyrics from the"
|
||||||
|
" public lyrics APIs into the capture layer.")
|
||||||
|
parser.add_argument(
|
||||||
|
"lyrics_dir", type=Path,
|
||||||
|
help="the lyrics cache directory")
|
||||||
|
parser.add_argument(
|
||||||
|
"provenance_csv", type=Path,
|
||||||
|
help="the lyrics provenance CSV file")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
class LyricsFetcher:
|
||||||
|
"""A fetcher of song lyrics from the public lyrics APIs."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Construct the fetcher."""
|
||||||
|
self.__sent: int = 0
|
||||||
|
"""The number of the HTTP requests already sent."""
|
||||||
|
|
||||||
|
def fetch(self, artist: str, title: str) \
|
||||||
|
-> tuple[str, str] | None:
|
||||||
|
"""Fetch the lyrics of a song.
|
||||||
|
|
||||||
|
The APIs are tried in order, Lyrics.ovh and then LRCLIB,
|
||||||
|
stopping at the first hit. An HTTP or network error on
|
||||||
|
an API counts as a miss on that API.
|
||||||
|
|
||||||
|
:param artist: The artist name to query with.
|
||||||
|
:param title: The song title to query with.
|
||||||
|
:return: The lyrics text and the source name, or None
|
||||||
|
when every API misses.
|
||||||
|
"""
|
||||||
|
lyrics: str | None = self.__fetch_ovh(artist, title)
|
||||||
|
if lyrics is not None:
|
||||||
|
return lyrics, "lyrics.ovh"
|
||||||
|
lyrics = self.__fetch_lrclib(artist, title)
|
||||||
|
if lyrics is not None:
|
||||||
|
return lyrics, "lrclib"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __fetch_ovh(self, artist: str, title: str) -> str | None:
|
||||||
|
"""Fetch the lyrics of a song from Lyrics.ovh.
|
||||||
|
|
||||||
|
:param artist: The artist name to query with.
|
||||||
|
:param title: The song title to query with.
|
||||||
|
:return: The lyrics text, or None on a miss.
|
||||||
|
"""
|
||||||
|
url: str = ("https://api.lyrics.ovh/v1/"
|
||||||
|
f"{urllib.parse.quote(artist, safe='')}/"
|
||||||
|
f"{urllib.parse.quote(title, safe='')}")
|
||||||
|
data: Any = self.__get_json(url)
|
||||||
|
if isinstance(data, dict) \
|
||||||
|
and isinstance(data.get("lyrics"), str) \
|
||||||
|
and data["lyrics"] != "":
|
||||||
|
return data["lyrics"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __fetch_lrclib(self, artist: str, title: str) \
|
||||||
|
-> str | None:
|
||||||
|
"""Fetch the lyrics of a song from LRCLIB.
|
||||||
|
|
||||||
|
:param artist: The artist name to query with.
|
||||||
|
:param title: The song title to query with.
|
||||||
|
:return: The plain lyrics text, or None on a miss.
|
||||||
|
"""
|
||||||
|
query: str = urllib.parse.urlencode(
|
||||||
|
{"artist_name": artist, "track_name": title})
|
||||||
|
url: str = f"https://lrclib.net/api/get?{query}"
|
||||||
|
data: Any = self.__get_json(url)
|
||||||
|
if isinstance(data, dict) \
|
||||||
|
and isinstance(data.get("plainLyrics"), str) \
|
||||||
|
and data["plainLyrics"] != "":
|
||||||
|
return data["plainLyrics"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __get_json(self, url: str) -> Any:
|
||||||
|
"""Send a GET request and return the parsed JSON body.
|
||||||
|
|
||||||
|
Consecutive requests are separated by a fixed delay.
|
||||||
|
|
||||||
|
:param url: The URL to request.
|
||||||
|
:return: The parsed JSON body, or None on any HTTP,
|
||||||
|
network, or decoding error.
|
||||||
|
"""
|
||||||
|
if self.__sent > 0:
|
||||||
|
time.sleep(SLEEP_SECONDS)
|
||||||
|
self.__sent += 1
|
||||||
|
request: urllib.request.Request = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
request, timeout=TIMEOUT) as response:
|
||||||
|
return json.load(response)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def query_artist(session: Session, song_id: int) -> str:
|
||||||
|
"""Find the artist name to query the APIs with.
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
:param song_id: The song ID.
|
||||||
|
:return: The name of the primary-role artist with the lowest
|
||||||
|
position.
|
||||||
|
"""
|
||||||
|
name: str | None = session.scalar(
|
||||||
|
sa.select(Artist.name)
|
||||||
|
.join(SongArtist, SongArtist.artist_id == Artist.id)
|
||||||
|
.where(SongArtist.song_id == song_id,
|
||||||
|
SongArtist.role == Role.PRIMARY)
|
||||||
|
.order_by(SongArtist.position)
|
||||||
|
.limit(1))
|
||||||
|
assert name is not None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def save_lyrics(lyrics_dir: Path, song_id: int,
|
||||||
|
lyrics: str) -> None:
|
||||||
|
"""Write the lyrics of a song into the cache directory.
|
||||||
|
|
||||||
|
The cache directory is created when missing.
|
||||||
|
|
||||||
|
:param lyrics_dir: The lyrics cache directory.
|
||||||
|
:param song_id: The song ID.
|
||||||
|
:param lyrics: The lyrics text.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be written.
|
||||||
|
"""
|
||||||
|
lyrics_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(lyrics_dir / f"{song_id}.txt").write_text(
|
||||||
|
lyrics, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def append_provenance(path: Path, song_id: int,
|
||||||
|
source: str) -> None:
|
||||||
|
"""Append a provenance row for a fetched lyrics file.
|
||||||
|
|
||||||
|
The CSV file is created with the header row when missing.
|
||||||
|
|
||||||
|
:param path: The lyrics provenance CSV file.
|
||||||
|
:param song_id: The song ID.
|
||||||
|
:param source: The source name of the fetched lyrics.
|
||||||
|
:return: None.
|
||||||
|
:raises OSError: When the file cannot be written.
|
||||||
|
"""
|
||||||
|
is_new: bool = not path.exists()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "a", encoding="utf-8",
|
||||||
|
newline="") as file:
|
||||||
|
writer: Any = csv.writer(file)
|
||||||
|
if is_new:
|
||||||
|
writer.writerow(PROVENANCE_FIELDS)
|
||||||
|
writer.writerow([song_id, source, "api-fetch",
|
||||||
|
datetime.date.today().isoformat(), ""])
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Fetch the missing song lyrics from the public APIs.
|
||||||
|
|
||||||
|
:param argv: The command-line arguments, or None for
|
||||||
|
``sys.argv``.
|
||||||
|
:return: The exit status: 0 on success, misses included,
|
||||||
|
non-zero on a setup error.
|
||||||
|
"""
|
||||||
|
started: float = time.monotonic()
|
||||||
|
args: argparse.Namespace = parse_args(argv)
|
||||||
|
fetcher: LyricsFetcher = LyricsFetcher()
|
||||||
|
fetched: int = 0
|
||||||
|
missed: int = 0
|
||||||
|
session: Session = ds.get_db()
|
||||||
|
try:
|
||||||
|
song: Song
|
||||||
|
for song in session.scalars(
|
||||||
|
sa.select(Song).order_by(Song.id)):
|
||||||
|
if (args.lyrics_dir / f"{song.id}.txt").exists():
|
||||||
|
continue
|
||||||
|
artist: str = query_artist(session, song.id)
|
||||||
|
result: tuple[str, str] | None = fetcher.fetch(
|
||||||
|
artist, song.title)
|
||||||
|
if result is None and song.artist_credit != artist:
|
||||||
|
result = fetcher.fetch(
|
||||||
|
song.artist_credit, song.title)
|
||||||
|
if result is None:
|
||||||
|
missed += 1
|
||||||
|
print(f"song {song.id} \"{song.title}\": miss",
|
||||||
|
file=sys.stderr)
|
||||||
|
continue
|
||||||
|
lyrics: str
|
||||||
|
source: str
|
||||||
|
lyrics, source = result
|
||||||
|
save_lyrics(args.lyrics_dir, song.id, lyrics)
|
||||||
|
append_provenance(args.provenance_csv, song.id,
|
||||||
|
source)
|
||||||
|
fetched += 1
|
||||||
|
print(f"song {song.id} \"{song.title}\": {source}",
|
||||||
|
file=sys.stderr)
|
||||||
|
except (OSError, sa.exc.SQLAlchemyError) as error:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
attempted: int = fetched + missed
|
||||||
|
elapsed: str = format_duration(time.monotonic() - started)
|
||||||
|
print(f"Done. Fetched lyrics for {fetched}/{attempted}"
|
||||||
|
f" songs. {elapsed} elapsed.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
||||||
|
# AI assistance: Claude Code (Anthropic)
|
||||||
|
"""The generic batch executor for one LLM analysis step.
|
||||||
|
|
||||||
|
One invocation is one definition file plus one input, sent to the
|
||||||
|
Anthropic Messages Batch API exactly once, archived self-contained
|
||||||
|
under the destination directory given by the three positional
|
||||||
|
command-line arguments: prompt, input, archive_dir. The tool
|
||||||
|
knows nothing about run counts or protocols: run identity --
|
||||||
|
run1/run2, arbitration -- lives entirely in the caller's command
|
||||||
|
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 hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Self
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
MODEL: str = "claude-sonnet-4-6"
|
||||||
|
TEMPERATURE: float = 0.0
|
||||||
|
THINKING: dict[str, str] = {"type": "disabled"}
|
||||||
|
SCRIPT_VERSION: str = "run_llm.py 3.0.0"
|
||||||
|
POLL_INTERVAL_SECONDS: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class InputFormatError(Exception):
|
||||||
|
"""An error in the JSONL input file."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InputItem:
|
||||||
|
"""An input item of the LLM step."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
"""The item ID."""
|
||||||
|
content: str
|
||||||
|
"""The item content."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, data: Any, path: Path,
|
||||||
|
number: int) -> Self:
|
||||||
|
"""Validate one parsed JSONL record as an input item.
|
||||||
|
|
||||||
|
:param data: The parsed JSON value of the line.
|
||||||
|
:param path: The path of the JSONL input file, for the
|
||||||
|
messages.
|
||||||
|
:param number: The line number, for the messages.
|
||||||
|
:return: The validated input item.
|
||||||
|
:raises InputFormatError: When the record is malformed.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise InputFormatError(
|
||||||
|
f"{path}: line {number}: not a JSON object")
|
||||||
|
if set(data.keys()) != {"id", "content"}:
|
||||||
|
raise InputFormatError(
|
||||||
|
f"{path}: line {number}: keys must be exactly"
|
||||||
|
" \"id\" and \"content\"")
|
||||||
|
if not isinstance(data["id"], str) or data["id"] == "":
|
||||||
|
raise InputFormatError(
|
||||||
|
f"{path}: line {number}: \"id\" must be a"
|
||||||
|
" non-empty string")
|
||||||
|
if not isinstance(data["content"], str):
|
||||||
|
raise InputFormatError(
|
||||||
|
f"{path}: line {number}: \"content\" must be a"
|
||||||
|
" string")
|
||||||
|
return cls(id=data["id"], content=data["content"])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchResult:
|
||||||
|
"""One batch result record."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
"""The item ID."""
|
||||||
|
text: str | None = None
|
||||||
|
"""The output text of a succeeded result."""
|
||||||
|
stop_reason: str | None = None
|
||||||
|
"""The stop reason of a succeeded result."""
|
||||||
|
usage: dict[str, Any] | None = None
|
||||||
|
"""The token usage of a succeeded result."""
|
||||||
|
error: str | None = None
|
||||||
|
"""The error code of a failed result."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_failure(self) -> bool:
|
||||||
|
"""Whether this result is a failure.
|
||||||
|
|
||||||
|
:return: True when the result carries an error, or False
|
||||||
|
when it succeeded.
|
||||||
|
"""
|
||||||
|
return self.error is not None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, entry: Any) -> Self:
|
||||||
|
"""Create the result record of a batch result entry.
|
||||||
|
|
||||||
|
A succeeded entry yields the text, the stop reason, and
|
||||||
|
the usage; any other entry yields the error code.
|
||||||
|
|
||||||
|
:param entry: The batch result entry.
|
||||||
|
:return: The result record.
|
||||||
|
"""
|
||||||
|
result: Any = entry.result
|
||||||
|
match result.type:
|
||||||
|
case "succeeded":
|
||||||
|
message: Any = result.message
|
||||||
|
text: str = "".join(
|
||||||
|
x.text for x in message.content
|
||||||
|
if x.type == "text")
|
||||||
|
return cls(id=entry.custom_id, text=text,
|
||||||
|
stop_reason=message.stop_reason,
|
||||||
|
usage=usage_to_dict(message.usage))
|
||||||
|
case "errored":
|
||||||
|
return cls(id=entry.custom_id,
|
||||||
|
error=result.error.error.type)
|
||||||
|
case other:
|
||||||
|
return cls(id=entry.custom_id, error=str(other))
|
||||||
|
|
||||||
|
def to_record(self) -> dict[str, Any]:
|
||||||
|
"""Return this result as an archive JSONL record.
|
||||||
|
|
||||||
|
:return: The record, with the None fields omitted.
|
||||||
|
"""
|
||||||
|
return {k: v for k, v in asdict(self).items()
|
||||||
|
if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
type Results = dict[str, BatchResult]
|
||||||
|
"""The batch result records, keyed by item ID."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchInfo:
|
||||||
|
"""The bookkeeping of one submitted message batch."""
|
||||||
|
|
||||||
|
batch_id: str
|
||||||
|
"""The batch ID."""
|
||||||
|
submitted_at: str
|
||||||
|
"""The submission time, in ISO 8601 format."""
|
||||||
|
ended_at: str | None = None
|
||||||
|
"""The end time, in ISO 8601 format, or None while the batch
|
||||||
|
is still processing."""
|
||||||
|
|
||||||
|
|
||||||
|
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="Run one LLM definition file against one input"
|
||||||
|
" and archive the result.")
|
||||||
|
parser.add_argument(
|
||||||
|
"prompt", type=Path,
|
||||||
|
help="the prompt definition file, used as the system prompt")
|
||||||
|
parser.add_argument(
|
||||||
|
"input", type=Path,
|
||||||
|
help="the JSONL input file with \"id\" and \"content\"")
|
||||||
|
parser.add_argument(
|
||||||
|
"archive_dir", type=Path,
|
||||||
|
help="the destination archive directory")
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-tokens", type=int, default=2048,
|
||||||
|
help="the maximum output tokens per request (default 2048)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def load_items(path: Path) -> list[InputItem]:
|
||||||
|
"""Load and validate the JSONL input items.
|
||||||
|
|
||||||
|
:param path: The path of the JSONL input file.
|
||||||
|
:return: The input items, in file order.
|
||||||
|
:raises InputFormatError: When a line is malformed, an ID is
|
||||||
|
duplicated, or the file contains no item.
|
||||||
|
:raises OSError: When the file cannot be read.
|
||||||
|
"""
|
||||||
|
items: list[InputItem] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
with open(path, encoding="utf-8") as file:
|
||||||
|
for number, line in enumerate(file, start=1):
|
||||||
|
if line.strip() == "":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data: Any = json.loads(line)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise InputFormatError(
|
||||||
|
f"{path}: line {number}: malformed JSON: {error}")
|
||||||
|
item: InputItem = InputItem.get_instance(
|
||||||
|
data, path, number)
|
||||||
|
if item.id in seen:
|
||||||
|
raise InputFormatError(
|
||||||
|
f"{path}: line {number}: duplicated ID"
|
||||||
|
f" \"{item.id}\"")
|
||||||
|
seen.add(item.id)
|
||||||
|
items.append(item)
|
||||||
|
if len(items) == 0:
|
||||||
|
raise InputFormatError(f"{path}: no input items")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def build_request(item: InputItem, system_prompt: str,
|
||||||
|
max_tokens: int) -> dict[str, Any]:
|
||||||
|
"""Build one Message Batches request for an input item.
|
||||||
|
|
||||||
|
:param item: The input item.
|
||||||
|
:param system_prompt: The system prompt text.
|
||||||
|
:param max_tokens: The maximum output tokens.
|
||||||
|
:return: The batch request with "custom_id" and "params".
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"custom_id": item.id,
|
||||||
|
"params": {
|
||||||
|
"model": MODEL,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": TEMPERATURE,
|
||||||
|
"thinking": THINKING,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": item.content},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def submit_batch(client: anthropic.Anthropic,
|
||||||
|
requests: list[dict[str, Any]]) -> str:
|
||||||
|
"""Submit one message batch.
|
||||||
|
|
||||||
|
:param client: The Anthropic client.
|
||||||
|
:param requests: The batch requests.
|
||||||
|
:return: The batch ID.
|
||||||
|
"""
|
||||||
|
return client.messages.batches.create(requests=requests).id
|
||||||
|
|
||||||
|
|
||||||
|
def poll_batches(client: anthropic.Anthropic,
|
||||||
|
batch_ids: list[str]) -> dict[str, Any]:
|
||||||
|
"""Poll the batches until every one of them has ended.
|
||||||
|
|
||||||
|
Progress is printed to the standard error every poll.
|
||||||
|
|
||||||
|
:param client: The Anthropic client.
|
||||||
|
:param batch_ids: The batch IDs to poll.
|
||||||
|
:return: The final batch object of each batch, keyed by batch ID.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
batches: dict[str, Any] = {
|
||||||
|
x: client.messages.batches.retrieve(x) for x in batch_ids}
|
||||||
|
pending: list[str] = [
|
||||||
|
x for x in batch_ids
|
||||||
|
if batches[x].processing_status != "ended"]
|
||||||
|
for batch_id in batch_ids:
|
||||||
|
status: str = batches[batch_id].processing_status
|
||||||
|
print(f"batch {batch_id}: {status}", file=sys.stderr)
|
||||||
|
if len(pending) == 0:
|
||||||
|
return batches
|
||||||
|
time.sleep(POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def usage_to_dict(usage: Any) -> dict[str, Any]:
|
||||||
|
"""Convert a usage object to a plain dictionary.
|
||||||
|
|
||||||
|
:param usage: The usage object of a message.
|
||||||
|
:return: The usage as a dictionary, without null entries.
|
||||||
|
"""
|
||||||
|
return {k: v for k, v in usage.model_dump().items()
|
||||||
|
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,
|
||||||
|
batch_id: str) -> Results:
|
||||||
|
"""Collect the results of an ended batch.
|
||||||
|
|
||||||
|
:param client: The Anthropic client.
|
||||||
|
:param batch_id: The batch ID.
|
||||||
|
:return: The result records, keyed by custom ID.
|
||||||
|
"""
|
||||||
|
results: Results = {}
|
||||||
|
for entry in client.messages.batches.results(batch_id):
|
||||||
|
results[entry.custom_id] = BatchResult.get_instance(entry)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def find_failures(item_ids: list[str],
|
||||||
|
results: Results) -> list[str]:
|
||||||
|
"""Find the item IDs that failed in a result set.
|
||||||
|
|
||||||
|
An item failed when it is missing from the results or when its
|
||||||
|
record is a failure.
|
||||||
|
|
||||||
|
:param item_ids: The item IDs to check, in order.
|
||||||
|
:param results: The result records, keyed by item ID.
|
||||||
|
:return: The failed item IDs, in the given order.
|
||||||
|
"""
|
||||||
|
return [x for x in item_ids
|
||||||
|
if x not in results or results[x].is_failure]
|
||||||
|
|
||||||
|
|
||||||
|
def create_archive_dir(directory: Path, replace: bool) -> Path:
|
||||||
|
"""Create the archive directory.
|
||||||
|
|
||||||
|
Only this directory is ever created or removed; no other
|
||||||
|
directory is ever touched.
|
||||||
|
|
||||||
|
:param directory: The destination archive directory.
|
||||||
|
:param replace: Whether to remove an already existing archive
|
||||||
|
directory before creating it.
|
||||||
|
:return: The created archive directory.
|
||||||
|
:raises FileExistsError: When the archive directory already
|
||||||
|
exists and ``replace`` is False.
|
||||||
|
"""
|
||||||
|
if directory.exists():
|
||||||
|
if not replace:
|
||||||
|
raise FileExistsError(
|
||||||
|
f"{directory} already exists; pass --replace to"
|
||||||
|
" replace it")
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
|
||||||
|
"""Write records to a file as JSON Lines.
|
||||||
|
|
||||||
|
:param path: The path of the file to write.
|
||||||
|
:param records: The records, one per line.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
with open(path, "w", encoding="utf-8") as file:
|
||||||
|
for record in records:
|
||||||
|
file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
"""Write data to a file as pretty-printed JSON.
|
||||||
|
|
||||||
|
:param path: The path of the file to write.
|
||||||
|
:param data: The data to write.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write_meta(path: Path, meta: dict[str, Any]) -> None:
|
||||||
|
"""Write the metadata to the ``meta.json`` file.
|
||||||
|
|
||||||
|
The ``BatchInfo`` value under ``batch`` is written as a plain
|
||||||
|
JSON object.
|
||||||
|
|
||||||
|
:param path: The path of the ``meta.json`` file.
|
||||||
|
:param meta: The metadata to write.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
write_json(path, {**meta, "batch": asdict(meta["batch"])})
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_of(path: Path) -> str:
|
||||||
|
"""Calculate the SHA-256 digest of a file.
|
||||||
|
|
||||||
|
:param path: The path of the file.
|
||||||
|
:return: The hexadecimal SHA-256 digest.
|
||||||
|
"""
|
||||||
|
with open(path, "rb") as file:
|
||||||
|
return hashlib.file_digest(file, "sha256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
"""Return the current local time in ISO 8601 format.
|
||||||
|
|
||||||
|
:return: The current local time with the timezone offset.
|
||||||
|
"""
|
||||||
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def execute_run(
|
||||||
|
client: anthropic.Anthropic, items: list[InputItem],
|
||||||
|
system_prompt: str, max_tokens: int,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
) -> Results:
|
||||||
|
"""Submit the batch of this run 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 system_prompt: The system prompt text.
|
||||||
|
:param max_tokens: The maximum output tokens per request.
|
||||||
|
:param meta: The metadata to record the batch bookkeeping into.
|
||||||
|
:return: The results of this run, keyed by item ID.
|
||||||
|
"""
|
||||||
|
requests: list[dict[str, Any]] = [
|
||||||
|
build_request(x, system_prompt, max_tokens) for x in items]
|
||||||
|
info: BatchInfo = BatchInfo(
|
||||||
|
batch_id=submit_batch(client, requests),
|
||||||
|
submitted_at=now_iso())
|
||||||
|
meta["batch"] = info
|
||||||
|
print(f"submitted batch {info.batch_id}", file=sys.stderr)
|
||||||
|
batches: dict[str, Any] = poll_batches(client, [info.batch_id])
|
||||||
|
info.ended_at = batches[info.batch_id].ended_at.isoformat()
|
||||||
|
return collect_results(client, info.batch_id)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run one LLM definition file against one input and archive it.
|
||||||
|
|
||||||
|
: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)
|
||||||
|
try:
|
||||||
|
items: list[InputItem] = load_items(args.input)
|
||||||
|
prompt_text: str = args.prompt.read_text(encoding="utf-8")
|
||||||
|
except (OSError, InputFormatError) as error:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
archive_dir: Path = create_archive_dir(
|
||||||
|
args.archive_dir, args.replace)
|
||||||
|
except FileExistsError as error:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
meta_path: Path = archive_dir / "meta.json"
|
||||||
|
(archive_dir / "prompt.md").write_bytes(args.prompt.read_bytes())
|
||||||
|
meta: dict[str, Any] = {
|
||||||
|
"script_version": SCRIPT_VERSION,
|
||||||
|
"model": MODEL,
|
||||||
|
"temperature": TEMPERATURE,
|
||||||
|
"thinking": THINKING,
|
||||||
|
"max_tokens": args.max_tokens,
|
||||||
|
"prompt_path": str(args.prompt),
|
||||||
|
"prompt_sha256": sha256_of(args.prompt),
|
||||||
|
"input_path": str(args.input),
|
||||||
|
"input_sha256": sha256_of(args.input),
|
||||||
|
"item_count": len(items),
|
||||||
|
"dry_run": args.dry_run,
|
||||||
|
"started_at": now_iso(),
|
||||||
|
"batch": None,
|
||||||
|
"usage": {},
|
||||||
|
}
|
||||||
|
if args.dry_run:
|
||||||
|
write_json(meta_path, meta)
|
||||||
|
print(json.dumps(
|
||||||
|
build_request(items[0], prompt_text, args.max_tokens),
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
print(f"dry run: archive created at {archive_dir}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
client: anthropic.Anthropic = anthropic.Anthropic(
|
||||||
|
api_key=get_settings().ANTHROPIC_API_KEY)
|
||||||
|
results: Results = execute_run(
|
||||||
|
client, items, prompt_text, args.max_tokens, meta)
|
||||||
|
item_ids: list[str] = [x.id for x in items]
|
||||||
|
write_jsonl(
|
||||||
|
archive_dir / "output.jsonl",
|
||||||
|
[results[x].to_record() for x in item_ids if x in results])
|
||||||
|
meta["usage"] = sum_usage(results)
|
||||||
|
write_meta(meta_path, meta)
|
||||||
|
failed: list[str] = find_failures(item_ids, results)
|
||||||
|
if len(failed) > 0:
|
||||||
|
print(f"error: failed items: {', '.join(failed)}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"done: {len(items)} items;"
|
||||||
|
f" archived to {archive_dir}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 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 configuration.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""The configuration settings."""
|
||||||
|
app_name: str = "Tools for A Feminist Audit of Pop Music"
|
||||||
|
"""The application name."""
|
||||||
|
admin_email: str = "imacat@mail.imacat.idv.tw"
|
||||||
|
"""The administrator email address."""
|
||||||
|
SQLALCHEMY_DATABASE_URL: str
|
||||||
|
"""The SQLAlchemy database URL."""
|
||||||
|
ANTHROPIC_API_KEY: str
|
||||||
|
"""The Anthropic API key."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
"""The model configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
__settings: Settings | None = None
|
||||||
|
"""The configuration settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Returns the configuration settings.
|
||||||
|
|
||||||
|
:return: The configuration settings.
|
||||||
|
"""
|
||||||
|
global __settings
|
||||||
|
if __settings is None:
|
||||||
|
__settings = Settings()
|
||||||
|
return __settings
|
||||||
|
|
||||||
|
|
||||||
|
def set_settings(settings: Settings) -> None:
|
||||||
|
"""Sets the configuration settings.
|
||||||
|
|
||||||
|
:param settings: The configuration settings.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
global __settings
|
||||||
|
__settings = settings
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 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 database connection.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from functools import cached_property
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
||||||
|
|
||||||
|
from .config import Settings, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""The base data model."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataSource:
|
||||||
|
"""A data source."""
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def engine(self) -> sa.Engine:
|
||||||
|
"""Returns the database engine.
|
||||||
|
|
||||||
|
:return: The database engine.
|
||||||
|
"""
|
||||||
|
settings: Settings = get_settings()
|
||||||
|
return self.__create_engine(settings.SQLALCHEMY_DATABASE_URL)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def __session_local(self) -> sessionmaker:
|
||||||
|
"""Returns the callable to connect and return the database session.
|
||||||
|
|
||||||
|
:return: The callable to connect and return the database session.
|
||||||
|
"""
|
||||||
|
return sessionmaker(
|
||||||
|
autocommit=False, autoflush=False, bind=self.engine)
|
||||||
|
|
||||||
|
def get_db(self) -> Session:
|
||||||
|
"""Connects and returns the database session.
|
||||||
|
|
||||||
|
:return: The database session.
|
||||||
|
"""
|
||||||
|
return self.__session_local()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __create_engine(cls, url: str) -> sa.Engine:
|
||||||
|
"""Constructs and returns the database engine.
|
||||||
|
|
||||||
|
The foreign key enforcement is enabled on every connection
|
||||||
|
of a SQLite engine.
|
||||||
|
|
||||||
|
:param url: The SQLAlchemy database URL.
|
||||||
|
:return: The database engine.
|
||||||
|
"""
|
||||||
|
url = cls.__resolve_sqlite_relative_url(url)
|
||||||
|
engine: sa.Engine
|
||||||
|
if url == "sqlite://":
|
||||||
|
engine = sa.create_engine(
|
||||||
|
url, connect_args={"check_same_thread": False},
|
||||||
|
poolclass=sa.StaticPool)
|
||||||
|
else:
|
||||||
|
engine = sa.create_engine(url)
|
||||||
|
if engine.url.get_backend_name() == "sqlite":
|
||||||
|
sa.event.listen(engine, "connect",
|
||||||
|
cls.__enable_sqlite_foreign_keys)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __enable_sqlite_foreign_keys(dbapi_connection: Any,
|
||||||
|
_: Any) -> None:
|
||||||
|
"""Enables the foreign key enforcement on a new connection.
|
||||||
|
|
||||||
|
:param dbapi_connection: The DBAPI connection.
|
||||||
|
:param _: The connection record (unused).
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
cursor: Any = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __resolve_sqlite_relative_url(url: str) -> str:
|
||||||
|
"""Resolves the SQLite relative URL to the instance folder.
|
||||||
|
|
||||||
|
:param url: The SQLAlchemy database URL.
|
||||||
|
:return: The resolved SQLAlchemy database URL.
|
||||||
|
"""
|
||||||
|
if not url.startswith("sqlite:///"):
|
||||||
|
return url
|
||||||
|
path: Path = Path(url[len("sqlite:///"):])
|
||||||
|
if path.is_absolute():
|
||||||
|
return url
|
||||||
|
base: Path = Path(__file__).parent.parent
|
||||||
|
if base.name == "src":
|
||||||
|
base = base.parent
|
||||||
|
path = base / "instance" / path
|
||||||
|
return f"sqlite:///{path}"
|
||||||
|
|
||||||
|
|
||||||
|
ds: DataSource = DataSource()
|
||||||
|
"""The data source."""
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 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 data models.
|
||||||
|
|
||||||
|
The schema covers the year-end chart data: songs with their
|
||||||
|
lyrics, their yearly chart entries, the individual artists, and
|
||||||
|
the song-artist credits with the role and order.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import enum
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Role(enum.StrEnum):
|
||||||
|
"""The role of an artist on a song credit."""
|
||||||
|
|
||||||
|
PRIMARY = "primary"
|
||||||
|
"""The primary artist role."""
|
||||||
|
FEATURED = "featured"
|
||||||
|
"""The featured artist role."""
|
||||||
|
|
||||||
|
|
||||||
|
class Song(Base):
|
||||||
|
"""A song, identified by its title and combined artist credit."""
|
||||||
|
__tablename__ = "songs"
|
||||||
|
"""The table name."""
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
"""The song ID."""
|
||||||
|
title: Mapped[str] = mapped_column()
|
||||||
|
"""The song title."""
|
||||||
|
artist_credit: Mapped[str] = mapped_column()
|
||||||
|
"""The combined artist credit string as printed on the chart."""
|
||||||
|
lyrics: Mapped[str | None]
|
||||||
|
"""The lyrics text, when available."""
|
||||||
|
chart_entries: Mapped[list[ChartEntry]] \
|
||||||
|
= relationship(back_populates="song")
|
||||||
|
"""The chart entries of the song."""
|
||||||
|
song_artists: Mapped[list[SongArtist]] \
|
||||||
|
= relationship(back_populates="song")
|
||||||
|
"""The song-artist credits of the song."""
|
||||||
|
__table_args__ = (sa.UniqueConstraint(title, artist_credit),)
|
||||||
|
"""The table-level constraints."""
|
||||||
|
|
||||||
|
|
||||||
|
class ChartEntry(Base):
|
||||||
|
"""An entry of a song on the year-end chart."""
|
||||||
|
__tablename__ = "chart_entries"
|
||||||
|
"""The table name."""
|
||||||
|
|
||||||
|
year: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
"""The chart year."""
|
||||||
|
rank: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
"""The rank of the song on the chart of the year."""
|
||||||
|
song_id: Mapped[int] = mapped_column(sa.ForeignKey(Song.id))
|
||||||
|
"""The ID of the charted song."""
|
||||||
|
song: Mapped[Song] = relationship(back_populates="chart_entries")
|
||||||
|
"""The charted song."""
|
||||||
|
|
||||||
|
|
||||||
|
class Artist(Base):
|
||||||
|
"""An individual artist."""
|
||||||
|
__tablename__ = "artists"
|
||||||
|
"""The table name."""
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
"""The artist ID."""
|
||||||
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
|
"""The artist name."""
|
||||||
|
wikidata_qid: Mapped[str | None]
|
||||||
|
"""The Wikidata QID of the artist."""
|
||||||
|
gender: Mapped[str | None]
|
||||||
|
"""The gender of the artist."""
|
||||||
|
type: Mapped[str | None]
|
||||||
|
"""The artist type: solo or group."""
|
||||||
|
genre: Mapped[str | None]
|
||||||
|
"""The music genre of the artist."""
|
||||||
|
country: Mapped[str | None]
|
||||||
|
"""The country of the artist."""
|
||||||
|
song_artists: Mapped[list[SongArtist]] \
|
||||||
|
= relationship(back_populates="artist")
|
||||||
|
"""The song-artist credits of the artist."""
|
||||||
|
|
||||||
|
|
||||||
|
class SongArtist(Base):
|
||||||
|
"""A credit of an artist on a song, with the role and order."""
|
||||||
|
__tablename__ = "song_artists"
|
||||||
|
"""The table name."""
|
||||||
|
|
||||||
|
song_id: Mapped[int] = mapped_column(sa.ForeignKey(Song.id),
|
||||||
|
primary_key=True)
|
||||||
|
"""The ID of the credited song."""
|
||||||
|
artist_id: Mapped[int] = mapped_column(
|
||||||
|
sa.ForeignKey(Artist.id), primary_key=True)
|
||||||
|
"""The ID of the credited artist."""
|
||||||
|
role: Mapped[str] = mapped_column()
|
||||||
|
"""The role of the artist, a ``Role`` value."""
|
||||||
|
position: Mapped[int]
|
||||||
|
"""The 0-based position of the artist in the credit order."""
|
||||||
|
song: Mapped[Song] = relationship(back_populates="song_artists")
|
||||||
|
"""The credited song."""
|
||||||
|
artist: Mapped[Artist] \
|
||||||
|
= relationship(back_populates="song_artists")
|
||||||
|
"""The credited artist."""
|
||||||
|
__table_args__ = (
|
||||||
|
sa.CheckConstraint(role.in_([x.value for x in Role]),
|
||||||
|
name="ck_song_artists_role"),)
|
||||||
|
"""The table-level constraints."""
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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}"
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
"""Unit tests for the working store builder module."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from pop_fem_audit_tools import config
|
||||||
|
from pop_fem_audit_tools.commands import build_db
|
||||||
|
from pop_fem_audit_tools.database import DataSource
|
||||||
|
from pop_fem_audit_tools.models import (
|
||||||
|
Artist,
|
||||||
|
ChartEntry,
|
||||||
|
Role,
|
||||||
|
Song,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseArtistCredit(unittest.TestCase):
|
||||||
|
"""Test cases for the artist credit parser."""
|
||||||
|
|
||||||
|
def test_plain_solo(self) -> None:
|
||||||
|
"""Test a plain solo artist credit."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit("Adele"),
|
||||||
|
[("Adele", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_featuring_with_and(self) -> None:
|
||||||
|
"""Test a featuring credit with an "and" delimiter."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Drake featuring Wizkid and Kyla"),
|
||||||
|
[("Drake", Role.PRIMARY),
|
||||||
|
("Wizkid", Role.FEATURED),
|
||||||
|
("Kyla", Role.FEATURED)])
|
||||||
|
|
||||||
|
def test_comma_and_ampersand(self) -> None:
|
||||||
|
"""Test a credit with comma and ampersand delimiters."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Lady Gaga, Bradley Cooper & BloodPop"),
|
||||||
|
[("Lady Gaga", Role.PRIMARY),
|
||||||
|
("Bradley Cooper", Role.PRIMARY),
|
||||||
|
("BloodPop", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_x_delimiter(self) -> None:
|
||||||
|
"""Test the "x" delimiter."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"KAROL G x Nicki Minaj"),
|
||||||
|
[("KAROL G", Role.PRIMARY),
|
||||||
|
("Nicki Minaj", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_plus_delimiter(self) -> None:
|
||||||
|
"""Test the "+" delimiter."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Marshmello + Halsey"),
|
||||||
|
[("Marshmello", Role.PRIMARY),
|
||||||
|
("Halsey", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_with_delimiter(self) -> None:
|
||||||
|
"""Test the "with" delimiter."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Kane Brown with Lauren Alaina"),
|
||||||
|
[("Kane Brown", Role.PRIMARY),
|
||||||
|
("Lauren Alaina", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_feat_abbreviation(self) -> None:
|
||||||
|
"""Test that "Feat." splits the featured side."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Ariana Grande Feat. Doja Cat"
|
||||||
|
" & Megan Thee Stallion"),
|
||||||
|
[("Ariana Grande", Role.PRIMARY),
|
||||||
|
("Doja Cat", Role.FEATURED),
|
||||||
|
("Megan Thee Stallion", Role.FEATURED)])
|
||||||
|
|
||||||
|
def test_case_insensitive_featuring(self) -> None:
|
||||||
|
"""Test that "Featuring" splits case-insensitively."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"24kGoldn Featuring iann dior"),
|
||||||
|
[("24kGoldn", Role.PRIMARY),
|
||||||
|
("iann dior", Role.FEATURED)])
|
||||||
|
|
||||||
|
def test_colon_prefix_group(self) -> None:
|
||||||
|
"""Test that a colon-prefixed group name is dropped."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"¥$: Ye & Ty Dolla $ign Featuring Rich The Kid"
|
||||||
|
" & Playboi Carti"),
|
||||||
|
[("Ye", Role.PRIMARY),
|
||||||
|
("Ty Dolla $ign", Role.PRIMARY),
|
||||||
|
("Rich The Kid", Role.FEATURED),
|
||||||
|
("Playboi Carti", Role.FEATURED)])
|
||||||
|
|
||||||
|
def test_colon_prefix_with_ampersand_in_prefix(self) -> None:
|
||||||
|
"""Test a colon prefix that itself contains "&"."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Rumi & JINU: EJAE & Andrew Choi"),
|
||||||
|
[("EJAE", Role.PRIMARY),
|
||||||
|
("Andrew Choi", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_colon_prefix_comma_list(self) -> None:
|
||||||
|
"""Test a colon-prefixed comma-separated member list."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"HUNTR/X: EJAE, Audrey Nuna & REI AMI"),
|
||||||
|
[("EJAE", Role.PRIMARY),
|
||||||
|
("Audrey Nuna", Role.PRIMARY),
|
||||||
|
("REI AMI", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_colon_prefix_five_members(self) -> None:
|
||||||
|
"""Test a colon-prefixed five-member list."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Saja Boys: Andrew Choi, Neckwav, Danny Chung,"
|
||||||
|
" Kevin Woo & samUIL Lee"),
|
||||||
|
[("Andrew Choi", Role.PRIMARY),
|
||||||
|
("Neckwav", Role.PRIMARY),
|
||||||
|
("Danny Chung", Role.PRIMARY),
|
||||||
|
("Kevin Woo", Role.PRIMARY),
|
||||||
|
("samUIL Lee", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_colon_prefix_duo(self) -> None:
|
||||||
|
"""Test a colon-prefixed two-member list."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"THE ANXIETY: WILLOW & Tyler Cole"),
|
||||||
|
[("WILLOW", Role.PRIMARY),
|
||||||
|
("Tyler Cole", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_parenthesized_members(self) -> None:
|
||||||
|
"""Test that a parenthesized member list replaces the
|
||||||
|
group name spanning the whole credit."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Silk Sonic (Bruno Mars & Anderson .Paak)"),
|
||||||
|
[("Bruno Mars", Role.PRIMARY),
|
||||||
|
("Anderson .Paak", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_duet_with_connector(self) -> None:
|
||||||
|
"""Test that "Duet With" is a co-billing connector like
|
||||||
|
"with", dropping the word "Duet" entirely."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Blake Shelton Duet With Gwen Stefani"),
|
||||||
|
[("Blake Shelton", Role.PRIMARY),
|
||||||
|
("Gwen Stefani", Role.PRIMARY)])
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Keith Urban Duet With P!nk"),
|
||||||
|
[("Keith Urban", Role.PRIMARY),
|
||||||
|
("P!nk", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_slash_delimiter(self) -> None:
|
||||||
|
"""Test the " / " co-billing delimiter."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Cole Swindell / Lainey Wilson"),
|
||||||
|
[("Cole Swindell", Role.PRIMARY),
|
||||||
|
("Lainey Wilson", Role.PRIMARY)])
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Zayn / Taylor Swift"),
|
||||||
|
[("Zayn", Role.PRIMARY),
|
||||||
|
("Taylor Swift", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_protected_name_lil_nas_x(self) -> None:
|
||||||
|
"""Test that "Lil Nas X" is guarded from the " x "
|
||||||
|
delimiter split."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Lil Nas X & Jack Harlow"),
|
||||||
|
[("Lil Nas X", Role.PRIMARY),
|
||||||
|
("Jack Harlow", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_protected_name_tyler_the_creator(self) -> None:
|
||||||
|
"""Test that "Tyler, The Creator" is guarded from the
|
||||||
|
comma delimiter split."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Tyler, The Creator Featuring GloRilla, Sexyy Red"
|
||||||
|
" & Lil Wayne"),
|
||||||
|
[("Tyler, The Creator", Role.PRIMARY),
|
||||||
|
("GloRilla", Role.FEATURED),
|
||||||
|
("Sexyy Red", Role.FEATURED),
|
||||||
|
("Lil Wayne", Role.FEATURED)])
|
||||||
|
|
||||||
|
def test_protected_name_tones_and_i(self) -> None:
|
||||||
|
"""Test that "Tones And I" is guarded from the " and "
|
||||||
|
delimiter split."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Tones And I"),
|
||||||
|
[("Tones And I", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_exception_spotemgottem(self) -> None:
|
||||||
|
"""Test the SpotemGottem exception-table credit."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"SpotemGottem Featuring Pooh Shiesty Or DaBaby"),
|
||||||
|
[("SpotemGottem", Role.PRIMARY),
|
||||||
|
("Pooh Shiesty", Role.FEATURED),
|
||||||
|
("DaBaby", Role.FEATURED)])
|
||||||
|
|
||||||
|
def test_exception_the_scotts(self) -> None:
|
||||||
|
"""Test the THE SCOTTS exception-table credit."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"THE SCOTTS, Travis Scott & Kid Cudi"),
|
||||||
|
[("Travis Scott", Role.PRIMARY),
|
||||||
|
("Kid Cudi", Role.PRIMARY)])
|
||||||
|
|
||||||
|
def test_exception_drake_featuring_the_throne(self) -> None:
|
||||||
|
"""Test the Drake Featuring The Throne exception-table
|
||||||
|
credit."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Drake Featuring The Throne"),
|
||||||
|
[("Drake", Role.PRIMARY),
|
||||||
|
("Jay Z", Role.FEATURED),
|
||||||
|
("Kanye West", Role.FEATURED)])
|
||||||
|
|
||||||
|
def test_plus_delimiter_unaffected(self) -> None:
|
||||||
|
"""Test that the existing "+" delimiter split is
|
||||||
|
unaffected by the new rules."""
|
||||||
|
self.assertEqual(
|
||||||
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Dan + Shay"),
|
||||||
|
[("Dan", Role.PRIMARY), ("Shay", Role.PRIMARY)])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildDB(unittest.TestCase):
|
||||||
|
"""Test cases for the working store build."""
|
||||||
|
|
||||||
|
CHART_CSV: str = (
|
||||||
|
"year,rank,title,artist\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")
|
||||||
|
"""The default chart CSV fixture: 2 years with 2 ranks each."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Create a temporary data directory with the fixtures."""
|
||||||
|
tmp: tempfile.TemporaryDirectory[str] \
|
||||||
|
= tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
self.__dir: Path = Path(tmp.name)
|
||||||
|
self.__chart: Path = self.__dir / "chart.csv"
|
||||||
|
self.__derived: Path = self.__dir / "derived"
|
||||||
|
self.__lyrics: Path = self.__dir / "lyrics"
|
||||||
|
self.__wikidata: Path = \
|
||||||
|
self.__dir / "artists_wikidata.csv"
|
||||||
|
self.__write_chart(self.CHART_CSV)
|
||||||
|
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()
|
||||||
|
patchers: list[Any] = [
|
||||||
|
mock.patch.object(build_db, "ds", self.__ds),
|
||||||
|
mock.patch.object(
|
||||||
|
build_db.SongImporter, "YEARS", [2016, 2017]),
|
||||||
|
mock.patch.object(
|
||||||
|
build_db.SongImporter, "RANKS_PER_YEAR", 2)]
|
||||||
|
for patcher in patchers:
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def __write_chart(self, content: str) -> None:
|
||||||
|
"""Write the chart CSV fixture.
|
||||||
|
|
||||||
|
:param content: The CSV content.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__chart.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
def __run_build(self, *options: str) -> tuple[int, str]:
|
||||||
|
"""Run the build with the standard error captured.
|
||||||
|
|
||||||
|
:param options: The additional command-line options.
|
||||||
|
:return: A tuple of the exit status and the standard
|
||||||
|
error.
|
||||||
|
"""
|
||||||
|
stderr: io.StringIO = io.StringIO()
|
||||||
|
with redirect_stderr(stderr):
|
||||||
|
status: int = build_db.main(
|
||||||
|
[str(self.__chart), str(self.__derived), *options])
|
||||||
|
return status, stderr.getvalue()
|
||||||
|
|
||||||
|
def __read_csv_rows(self, name: str) -> list[list[str]]:
|
||||||
|
"""Read the data rows of a derived CSV file.
|
||||||
|
|
||||||
|
:param name: The CSV file name under the derived directory.
|
||||||
|
:return: The data rows, the header row excluded.
|
||||||
|
"""
|
||||||
|
with open(self.__derived / name, encoding="utf-8",
|
||||||
|
newline="") as file:
|
||||||
|
rows: list[list[str]] = list(csv.reader(file))
|
||||||
|
return rows[1:]
|
||||||
|
|
||||||
|
def __read_csv_header(self, name: str) -> list[str]:
|
||||||
|
"""Read the header row of a derived CSV file.
|
||||||
|
|
||||||
|
:param name: The CSV file name under the derived directory.
|
||||||
|
:return: The header row.
|
||||||
|
"""
|
||||||
|
with open(self.__derived / name, encoding="utf-8",
|
||||||
|
newline="") as file:
|
||||||
|
return next(csv.reader(file))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __split_joined_field(value: str,
|
||||||
|
separator: str = "|") -> list[str]:
|
||||||
|
"""Split a joined field into its entries.
|
||||||
|
|
||||||
|
:param value: The joined field value.
|
||||||
|
:param separator: The join separator.
|
||||||
|
:return: The entries, in the given order.
|
||||||
|
"""
|
||||||
|
return value.split(separator)
|
||||||
|
|
||||||
|
def __session(self) -> Session:
|
||||||
|
"""Open a database session closed on test cleanup.
|
||||||
|
|
||||||
|
:return: The database session.
|
||||||
|
"""
|
||||||
|
session: Session = self.__ds.get_db()
|
||||||
|
self.addCleanup(session.close)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def __song_titles(self) -> dict[int, str]:
|
||||||
|
"""Read the song titles keyed by their IDs.
|
||||||
|
|
||||||
|
:return: The song titles, keyed by the song IDs.
|
||||||
|
"""
|
||||||
|
session: Session = self.__session()
|
||||||
|
return {x.id: x.title
|
||||||
|
for x in session.scalars(sa.select(Song))}
|
||||||
|
|
||||||
|
def test_dedup_repeated_song(self) -> None:
|
||||||
|
"""Test that a song repeated across years is stored once."""
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(
|
||||||
|
len(list(session.scalars(sa.select(Song)))), 3)
|
||||||
|
song: Song | None = session.scalar(
|
||||||
|
sa.select(Song).where(Song.title == "One Dance"))
|
||||||
|
assert song is not None
|
||||||
|
self.assertEqual(sorted((x.year, x.rank)
|
||||||
|
for x in song.chart_entries),
|
||||||
|
[(2016, 2), (2017, 1)])
|
||||||
|
self.assertEqual([(x.artist.name, x.role, x.position)
|
||||||
|
for x in song.song_artists],
|
||||||
|
[("Drake", Role.PRIMARY, 0),
|
||||||
|
("Wizkid", Role.FEATURED, 1)])
|
||||||
|
self.assertIn("3 songs", stderr)
|
||||||
|
self.assertIn("4 chart entries", stderr)
|
||||||
|
self.assertIn("4 artists", stderr)
|
||||||
|
self.assertIn("4 credits", stderr)
|
||||||
|
|
||||||
|
def test_dedup_credit_variant(self) -> None:
|
||||||
|
"""Test that a credit variant listed in
|
||||||
|
``CANONICAL_ARTIST_CREDITS`` merges into one song, storing
|
||||||
|
the canonical credit, with the canonical artist spelling
|
||||||
|
applied to the artist listed in ``CANONICAL_ARTIST_NAMES``."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Eastside,\"benny blanco, Halsey & Khalid\"\n"
|
||||||
|
"2016,2,filler,Filler Artist\n"
|
||||||
|
"2017,1,Eastside,\"Benny Blanco, Halsey & Khalid\"\n"
|
||||||
|
"2017,2,filler,Filler Artist\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
songs: list[Song] = list(session.scalars(
|
||||||
|
sa.select(Song).where(Song.title == "Eastside")))
|
||||||
|
self.assertEqual(len(songs), 1)
|
||||||
|
self.assertEqual(songs[0].artist_credit,
|
||||||
|
"Benny Blanco, Halsey & Khalid")
|
||||||
|
self.assertEqual(
|
||||||
|
sorted((x.year, x.rank)
|
||||||
|
for x in songs[0].chart_entries),
|
||||||
|
[(2016, 1), (2017, 1)])
|
||||||
|
self.assertEqual(
|
||||||
|
[x.artist.name for x in songs[0].song_artists],
|
||||||
|
["benny blanco", "Halsey", "Khalid"])
|
||||||
|
|
||||||
|
def test_dedup_artist_name_case_variant(self) -> None:
|
||||||
|
"""Test that an artist name case variant merges into one
|
||||||
|
artist, keeping the first-seen spelling."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Song One,Marshmello\n"
|
||||||
|
"2016,2,filler,Filler Artist\n"
|
||||||
|
"2017,1,Song Two,marshmello\n"
|
||||||
|
"2017,2,filler,Filler Artist\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
artists: list[Artist] = list(session.scalars(
|
||||||
|
sa.select(Artist).where(Artist.name.ilike("marsh%"))))
|
||||||
|
self.assertEqual(len(artists), 1)
|
||||||
|
self.assertEqual(artists[0].name, "Marshmello")
|
||||||
|
self.assertEqual(
|
||||||
|
{x.title for x in
|
||||||
|
{y.song for y in artists[0].song_artists}},
|
||||||
|
{"Song One", "Song Two"})
|
||||||
|
|
||||||
|
def test_canonical_artist_name_restored(self) -> None:
|
||||||
|
"""Test that a canonical spelling is stored even for a
|
||||||
|
single, non-canonically spelled occurrence."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Beggin',Maneskin\n"
|
||||||
|
"2016,2,filler,Filler Artist\n"
|
||||||
|
"2017,1,filler2,Filler Artist Two\n"
|
||||||
|
"2017,2,filler3,Filler Artist Three\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
artist: Artist | None = session.scalar(
|
||||||
|
sa.select(Artist).where(Artist.name.ilike("m%nesk%")))
|
||||||
|
assert artist is not None
|
||||||
|
self.assertEqual(artist.name, "Måneskin")
|
||||||
|
|
||||||
|
def test_canonical_ye_resolves_to_kanye_west(self) -> None:
|
||||||
|
"""Test that a "Ye" credit and a "Kanye West" credit merge
|
||||||
|
into a single artist row named "Kanye West", credited on
|
||||||
|
both songs."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Song One,Ye\n"
|
||||||
|
"2016,2,Song Two,Kanye West\n"
|
||||||
|
"2017,1,filler2,Filler Artist Two\n"
|
||||||
|
"2017,2,filler3,Filler Artist Three\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
artists: list[Artist] = list(session.scalars(
|
||||||
|
sa.select(Artist).where(Artist.name == "Kanye West")))
|
||||||
|
self.assertEqual(len(artists), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{x.title for x in
|
||||||
|
{y.song for y in artists[0].song_artists}},
|
||||||
|
{"Song One", "Song Two"})
|
||||||
|
|
||||||
|
def test_canonical_silento_restores_diacritic(self) -> None:
|
||||||
|
"""Test that a "Silento" credit stores the diacritic-
|
||||||
|
restored canonical spelling "Silentó"."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Watch Me,Silento\n"
|
||||||
|
"2016,2,filler,Filler Artist\n"
|
||||||
|
"2017,1,filler2,Filler Artist Two\n"
|
||||||
|
"2017,2,filler3,Filler Artist Three\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
artist: Artist | None = session.scalar(
|
||||||
|
sa.select(Artist).where(Artist.name.ilike("silent%")))
|
||||||
|
assert artist is not None
|
||||||
|
self.assertEqual(artist.name, "Silentó")
|
||||||
|
|
||||||
|
def test_canonical_dan_and_shay_full_names(self) -> None:
|
||||||
|
"""Test that a "Dan + Shay" credit stores the canonical
|
||||||
|
full names "Dan Smyers" and "Shay Mooney"."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Tequila,Dan + Shay\n"
|
||||||
|
"2016,2,filler,Filler Artist\n"
|
||||||
|
"2017,1,filler2,Filler Artist Two\n"
|
||||||
|
"2017,2,filler3,Filler Artist Three\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
song: Song | None = session.scalar(
|
||||||
|
sa.select(Song).where(Song.title == "Tequila"))
|
||||||
|
assert song is not None
|
||||||
|
self.assertEqual(
|
||||||
|
[x.artist.name for x in song.song_artists],
|
||||||
|
["Dan Smyers", "Shay Mooney"])
|
||||||
|
|
||||||
|
def test_canonical_surf_mesa_case_restored(self) -> None:
|
||||||
|
"""Test that a "surf mesa" credit stores the canonical
|
||||||
|
case styling "Surf Mesa"."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Ily,surf mesa\n"
|
||||||
|
"2016,2,filler,Filler Artist\n"
|
||||||
|
"2017,1,filler2,Filler Artist Two\n"
|
||||||
|
"2017,2,filler3,Filler Artist Three\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
artist: Artist | None = session.scalar(
|
||||||
|
sa.select(Artist).where(Artist.name.ilike("surf%")))
|
||||||
|
assert artist is not None
|
||||||
|
self.assertEqual(artist.name, "Surf Mesa")
|
||||||
|
|
||||||
|
def test_first_run_on_fresh_store(self) -> None:
|
||||||
|
"""Test that a build on a fresh store creates the tables."""
|
||||||
|
self.assertFalse((self.__dir / "store.sqlite3").exists())
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(
|
||||||
|
len(list(session.scalars(sa.select(Song)))), 3)
|
||||||
|
|
||||||
|
def test_deterministic_ids(self) -> None:
|
||||||
|
"""Test that two rebuilds assign the same song IDs."""
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
titles: dict[int, str] = self.__song_titles()
|
||||||
|
self.assertEqual(titles, {1: "Hello", 2: "One Dance",
|
||||||
|
3: "Shape of You"})
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
self.assertEqual(self.__song_titles(), titles)
|
||||||
|
|
||||||
|
def test_failed_build_keeps_previous(self) -> None:
|
||||||
|
"""Test that a failed build keeps the previous contents."""
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
titles: dict[int, str] = self.__song_titles()
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Hello,Adele\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("year 2016 rank 2", stderr)
|
||||||
|
self.assertEqual(self.__song_titles(), titles)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(
|
||||||
|
len(list(session.scalars(sa.select(ChartEntry)))), 4)
|
||||||
|
|
||||||
|
def test_missing_rank_fails(self) -> None:
|
||||||
|
"""Test that a missing rank fails without partial data."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Hello,Adele\n"
|
||||||
|
"2016,2,One Dance,Drake featuring Wizkid\n"
|
||||||
|
"2017,1,One Dance,Drake featuring Wizkid\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("year 2017 rank 2", stderr)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(
|
||||||
|
list(session.scalars(sa.select(ChartEntry))), [])
|
||||||
|
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:
|
||||||
|
"""Test loading the lyrics cache into the songs."""
|
||||||
|
self.__lyrics.mkdir()
|
||||||
|
(self.__lyrics / "1.txt").write_text(
|
||||||
|
"Hello, it's me\n", encoding="utf-8")
|
||||||
|
(self.__lyrics / "999.txt").write_text(
|
||||||
|
"orphan\n", encoding="utf-8")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--lyrics-dir", str(self.__lyrics))
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("999", stderr)
|
||||||
|
self.assertIn("1 songs with lyrics", stderr)
|
||||||
|
session: Session = self.__session()
|
||||||
|
song: Song | None = session.get(Song, 1)
|
||||||
|
assert song is not None
|
||||||
|
self.assertEqual(song.title, "Hello")
|
||||||
|
self.assertEqual(song.lyrics, "Hello, it's me\n")
|
||||||
|
|
||||||
|
def test_omitted_options_skip_capture_layers(self) -> None:
|
||||||
|
"""Test that omitted options leave the layers unloaded."""
|
||||||
|
self.__lyrics.mkdir()
|
||||||
|
(self.__lyrics / "1.txt").write_text(
|
||||||
|
"Hello, it's me\n", encoding="utf-8")
|
||||||
|
self.__wikidata.write_text(
|
||||||
|
"name,qid,gender,type,genre,country,note\n"
|
||||||
|
"Adele,Q2831,female,solo,pop,GB,\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("0 songs with lyrics", stderr)
|
||||||
|
session: Session = self.__session()
|
||||||
|
song: Song | None = session.get(Song, 1)
|
||||||
|
assert song is not None
|
||||||
|
self.assertIsNone(song.lyrics)
|
||||||
|
artist: Artist | None = session.scalar(
|
||||||
|
sa.select(Artist).where(Artist.name == "Adele"))
|
||||||
|
assert artist is not None
|
||||||
|
self.assertIsNone(artist.wikidata_qid)
|
||||||
|
self.assertIsNone(artist.gender)
|
||||||
|
|
||||||
|
def test_missing_lyrics_dir_fails(self) -> None:
|
||||||
|
"""Test that a given but missing lyrics directory fails."""
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--lyrics-dir", str(self.__lyrics))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn(f"error: {self.__lyrics}", stderr)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||||
|
|
||||||
|
def test_missing_wikidata_csv_fails(self) -> None:
|
||||||
|
"""Test that a given but missing snapshot CSV fails."""
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build(
|
||||||
|
"--wikidata-csv", str(self.__wikidata))
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("error:", stderr)
|
||||||
|
self.assertIn(str(self.__wikidata), stderr)
|
||||||
|
session: Session = self.__session()
|
||||||
|
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||||
|
|
||||||
|
REVIEW_CHART_CSV: str = (
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,banana,Artist B\n"
|
||||||
|
"2016,2,\"Me, Myself & I\",Billie\n"
|
||||||
|
"2017,1,\"Me, Myself & I\",Billie\n"
|
||||||
|
"2017,2,Apple,Artist A\n")
|
||||||
|
"""The chart CSV fixture exercising the review CSV ordering,
|
||||||
|
positions, and the outer-field comma quoting."""
|
||||||
|
|
||||||
|
def test_derived_dir_created_when_missing(self) -> None:
|
||||||
|
"""Test that the derived directory is created when
|
||||||
|
missing."""
|
||||||
|
self.assertFalse(self.__derived.exists())
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
self.assertTrue(self.__derived.is_dir())
|
||||||
|
self.assertTrue((self.__derived / "songs.csv").is_file())
|
||||||
|
self.assertTrue((self.__derived / "artists.csv").is_file())
|
||||||
|
|
||||||
|
def test_songs_csv_written(self) -> None:
|
||||||
|
"""Test the songs.csv header, the rows, the title
|
||||||
|
ordering, and the "/"-joined "positions" value."""
|
||||||
|
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__read_csv_header("songs.csv"),
|
||||||
|
["Title", "Artists", "Positions"])
|
||||||
|
rows: list[list[str]] = self.__read_csv_rows("songs.csv")
|
||||||
|
self.assertEqual(
|
||||||
|
[row[:2] for row in rows],
|
||||||
|
[["Apple", "Artist A"],
|
||||||
|
["banana", "Artist B"],
|
||||||
|
["Me, Myself & I", "Billie"]])
|
||||||
|
self.assertEqual(
|
||||||
|
[self.__split_joined_field(row[2], "/") for row in rows],
|
||||||
|
[["2017#2"], ["2016#1"], ["2016#2", "2017#1"]])
|
||||||
|
|
||||||
|
def test_songs_csv_uses_minimal_quoting(self) -> None:
|
||||||
|
"""Test that songs.csv uses normal minimal CSV quoting: a
|
||||||
|
numeric-looking title is written unquoted, like every other
|
||||||
|
field not otherwise requiring quoting."""
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,679,Artist A\n"
|
||||||
|
"2016,2,filler,Filler Artist\n"
|
||||||
|
"2017,1,filler2,Filler Artist Two\n"
|
||||||
|
"2017,2,filler3,Filler Artist Three\n")
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
content: str = (self.__derived / "songs.csv").read_text(
|
||||||
|
encoding="utf-8")
|
||||||
|
self.assertIn("679,Artist A,2016#1", content)
|
||||||
|
|
||||||
|
def test_artists_csv_written(self) -> None:
|
||||||
|
"""Test the artists.csv header, the rows, the name
|
||||||
|
ordering, and the "|"-joined "TITLE (YEAR#RANK...)" song
|
||||||
|
encoding."""
|
||||||
|
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
self.assertEqual(
|
||||||
|
self.__read_csv_header("artists.csv"),
|
||||||
|
["Name", "Wikidata QID", "Gender", "Type", "Genre",
|
||||||
|
"Country", "Songs"])
|
||||||
|
rows: list[list[str]] = self.__read_csv_rows("artists.csv")
|
||||||
|
self.assertEqual(
|
||||||
|
[row[0] for row in rows],
|
||||||
|
["Artist A", "Artist B", "Billie"])
|
||||||
|
self.assertEqual(
|
||||||
|
[self.__split_joined_field(row[6]) for row in rows],
|
||||||
|
[["Apple (2017#2)"],
|
||||||
|
["banana (2016#1)"],
|
||||||
|
["Me, Myself & I (2016#2/2017#1)"]])
|
||||||
|
|
||||||
|
def test_artists_csv_songs_field_quoted_for_comma_title(
|
||||||
|
self) -> None:
|
||||||
|
"""Test that the outer "Songs" field is CSV-quoted as a
|
||||||
|
whole when the joined value contains a comma, from a
|
||||||
|
comma-bearing credited title."""
|
||||||
|
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
content: str = (self.__derived / "artists.csv").read_text(
|
||||||
|
encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
'"Me, Myself & I (2016#2/2017#1)"', content)
|
||||||
|
self.assertIn("Apple (2017#2)\n", content)
|
||||||
|
|
||||||
|
def test_derived_csvs_have_no_ids(self) -> None:
|
||||||
|
"""Test that neither derived CSV file exposes a song or an
|
||||||
|
artist ID column."""
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
self.assertNotIn(
|
||||||
|
"id", self.__read_csv_header("songs.csv"))
|
||||||
|
self.assertNotIn(
|
||||||
|
"id", self.__read_csv_header("artists.csv"))
|
||||||
|
|
||||||
|
def test_derived_csvs_have_crlf_line_endings(self) -> None:
|
||||||
|
"""Test that the derived CSV files use CRLF line
|
||||||
|
endings."""
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
raw: bytes = (self.__derived / "songs.csv").read_bytes()
|
||||||
|
self.assertGreater(raw.count(b"\r\n"), 0)
|
||||||
|
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:
|
||||||
|
"""Test that a failed build does not touch the existing
|
||||||
|
derived CSV files."""
|
||||||
|
self.assertEqual(self.__run_build()[0], 0)
|
||||||
|
songs_before: str = (self.__derived / "songs.csv").read_text(
|
||||||
|
encoding="utf-8")
|
||||||
|
artists_before: str = \
|
||||||
|
(self.__derived / "artists.csv").read_text(
|
||||||
|
encoding="utf-8")
|
||||||
|
self.__write_chart(
|
||||||
|
"year,rank,title,artist\n"
|
||||||
|
"2016,1,Hello,Adele\n")
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_build()
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.__derived / "songs.csv").read_text(
|
||||||
|
encoding="utf-8"),
|
||||||
|
songs_before)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.__derived / "artists.csv").read_text(
|
||||||
|
encoding="utf-8"),
|
||||||
|
artists_before)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# 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"}])
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
"""Unit tests for the artist metadata fetcher module."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
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 fetch_artists
|
||||||
|
from pop_fem_audit_tools.database import Base, DataSource
|
||||||
|
from pop_fem_audit_tools.models import Artist, Role, Song, SongArtist
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchArtists(unittest.TestCase):
|
||||||
|
"""Test cases for the artist metadata fetcher."""
|
||||||
|
|
||||||
|
HEADER: list[str] = [
|
||||||
|
"name", "qid", "gender", "type", "genre",
|
||||||
|
"country", "note"]
|
||||||
|
"""The expected header row of the snapshot CSV file."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Create a temporary capture directory with the store."""
|
||||||
|
tmp: tempfile.TemporaryDirectory[str] \
|
||||||
|
= tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
self.__dir: Path = Path(tmp.name)
|
||||||
|
self.__snapshot: Path = \
|
||||||
|
self.__dir / "artists_wikidata.csv"
|
||||||
|
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()
|
||||||
|
patchers: list[Any] = [
|
||||||
|
mock.patch.object(fetch_artists, "ds", self.__ds),
|
||||||
|
mock.patch.object(fetch_artists, "SLEEP_SECONDS",
|
||||||
|
0.0),
|
||||||
|
mock.patch.object(fetch_artists, "RETRY_SECONDS",
|
||||||
|
0.0)]
|
||||||
|
for patcher in patchers:
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def __seed(self, names: list[str]) -> dict[str, int]:
|
||||||
|
"""Create the schema and the fixture artists.
|
||||||
|
|
||||||
|
:param names: The artist names.
|
||||||
|
:return: The created artist IDs, keyed by the name.
|
||||||
|
"""
|
||||||
|
Base.metadata.create_all(self.__ds.engine)
|
||||||
|
session: Session = self.__ds.get_db()
|
||||||
|
ids: dict[str, int] = {}
|
||||||
|
try:
|
||||||
|
name: str
|
||||||
|
for name in names:
|
||||||
|
artist: Artist = Artist(name=name)
|
||||||
|
session.add(artist)
|
||||||
|
session.flush()
|
||||||
|
ids[name] = artist.id
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def __seed_song(self, artist_id: int, title: str) -> None:
|
||||||
|
"""Add a charted song credited to an artist.
|
||||||
|
|
||||||
|
:param artist_id: The artist ID.
|
||||||
|
:param title: The song title.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
session: Session = self.__ds.get_db()
|
||||||
|
try:
|
||||||
|
song: Song = Song(title=title, artist_credit=title)
|
||||||
|
session.add(song)
|
||||||
|
session.flush()
|
||||||
|
session.add(SongArtist(
|
||||||
|
song_id=song.id, artist_id=artist_id,
|
||||||
|
role=Role.PRIMARY.value, position=0))
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __response(payload: dict[str, Any]) -> mock.MagicMock:
|
||||||
|
"""Build a fake HTTP response with a JSON body.
|
||||||
|
|
||||||
|
:param payload: The JSON payload of the response body.
|
||||||
|
:return: The fake response, usable as a context manager.
|
||||||
|
"""
|
||||||
|
response: mock.MagicMock = mock.MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.read.return_value \
|
||||||
|
= json.dumps(payload).encode("utf-8")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __http_error(status: int) -> urllib.error.HTTPError:
|
||||||
|
"""Build an HTTP error.
|
||||||
|
|
||||||
|
:param status: The HTTP status code.
|
||||||
|
:return: The HTTP error.
|
||||||
|
"""
|
||||||
|
return urllib.error.HTTPError(
|
||||||
|
"https://example.com/", status, "Error", None, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __claim(qid: str) -> dict[str, Any]:
|
||||||
|
"""Build a claim statement with an item-ID target.
|
||||||
|
|
||||||
|
:param qid: The item ID of the statement target.
|
||||||
|
:return: The claim statement.
|
||||||
|
"""
|
||||||
|
return {"mainsnak": {"snaktype": "value",
|
||||||
|
"datavalue": {"value": {"id": qid}}}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __labels(labels: dict[str, str]) -> dict[str, Any]:
|
||||||
|
"""Build a label query response payload.
|
||||||
|
|
||||||
|
:param labels: The English labels, keyed by the item ID.
|
||||||
|
:return: The response payload.
|
||||||
|
"""
|
||||||
|
return {"entities": {
|
||||||
|
x: {"labels": {"en": {"value": y}}}
|
||||||
|
for x, y in labels.items()}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __claims(qid: str, claims: dict[str, Any],
|
||||||
|
description: str) -> dict[str, Any]:
|
||||||
|
"""Build a claims-and-description response payload.
|
||||||
|
|
||||||
|
:param qid: The item ID.
|
||||||
|
:param claims: The claim statements, keyed by the
|
||||||
|
property.
|
||||||
|
:param description: The English description.
|
||||||
|
:return: The response payload.
|
||||||
|
"""
|
||||||
|
return {"entities": {qid: {
|
||||||
|
"claims": claims,
|
||||||
|
"descriptions": {"en": {"value": description}}}}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __sparql(rows: list[dict[str, dict[str, str]]]) \
|
||||||
|
-> dict[str, Any]:
|
||||||
|
"""Build a SPARQL query response payload.
|
||||||
|
|
||||||
|
:param rows: The result bindings.
|
||||||
|
:return: The response payload.
|
||||||
|
"""
|
||||||
|
return {"results": {"bindings": rows}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __uri(qid: str) -> dict[str, str]:
|
||||||
|
"""Build a SPARQL URI binding cell for an item ID.
|
||||||
|
|
||||||
|
:param qid: The item ID.
|
||||||
|
:return: The binding cell.
|
||||||
|
"""
|
||||||
|
return {"type": "uri",
|
||||||
|
"value": f"http://www.wikidata.org/entity/{qid}"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __literal(value: str) -> dict[str, str]:
|
||||||
|
"""Build a SPARQL literal binding cell.
|
||||||
|
|
||||||
|
:param value: The literal value.
|
||||||
|
:return: The binding cell.
|
||||||
|
"""
|
||||||
|
return {"type": "literal", "value": value}
|
||||||
|
|
||||||
|
def __run_fetch(self) -> tuple[int, str]:
|
||||||
|
"""Run the fetcher 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 = fetch_artists.main(
|
||||||
|
[str(self.__snapshot)])
|
||||||
|
return status, stderr.getvalue()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __read_rows(path: Path) -> list[list[str]]:
|
||||||
|
"""Read the rows of a CSV file.
|
||||||
|
|
||||||
|
:param path: The CSV file.
|
||||||
|
:return: The rows, the header included.
|
||||||
|
"""
|
||||||
|
with open(path, encoding="utf-8", newline="") as file:
|
||||||
|
return list(csv.reader(file))
|
||||||
|
|
||||||
|
def __assert_summary(self, stderr: str, resolved: int,
|
||||||
|
attempted: int) -> None:
|
||||||
|
"""Assert the closing summary line has the expected shape.
|
||||||
|
|
||||||
|
:param stderr: The captured standard error.
|
||||||
|
:param resolved: The expected count of resolved artists.
|
||||||
|
:param attempted: The expected count of attempted
|
||||||
|
artists.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.assertRegex(
|
||||||
|
stderr,
|
||||||
|
rf"Done\. Resolved {resolved}/{attempted} artists\."
|
||||||
|
r" \d{2}:\d{2} elapsed\.")
|
||||||
|
|
||||||
|
def test_unique_candidate_selected(self) -> None:
|
||||||
|
"""Test a single candidate resolving the full metadata."""
|
||||||
|
self.__seed(["Adele"])
|
||||||
|
candidates: dict[str, Any] = self.__sparql(
|
||||||
|
[{"item": self.__uri("Q1")}])
|
||||||
|
claims: dict[str, Any] = self.__claims(
|
||||||
|
"Q1", {
|
||||||
|
"P21": [self.__claim("Q2")],
|
||||||
|
"P31": [self.__claim("Q5")],
|
||||||
|
"P136": [self.__claim("Q3"), self.__claim("Q4")],
|
||||||
|
"P27": [self.__claim("Q6")]},
|
||||||
|
"English singer")
|
||||||
|
labels: dict[str, Any] = self.__labels({
|
||||||
|
"Q2": "female", "Q5": "human", "Q3": "pop",
|
||||||
|
"Q4": "soul music", "Q6": "United Kingdom"})
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(candidates),
|
||||||
|
self.__response(claims),
|
||||||
|
self.__response(labels)]) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 3)
|
||||||
|
first: Any = urlopen.call_args_list[0][0][0]
|
||||||
|
self.assertEqual(first.get_header("User-agent"),
|
||||||
|
fetch_artists.USER_AGENT)
|
||||||
|
self.assertTrue(
|
||||||
|
first.full_url.startswith(fetch_artists.SPARQL_URL))
|
||||||
|
self.assertEqual(
|
||||||
|
first.get_header("Accept"),
|
||||||
|
"application/sparql-results+json")
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0], self.HEADER)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Adele", "Q1", "female", "solo", "pop; soul music",
|
||||||
|
"United Kingdom", "English singer"])
|
||||||
|
self.__assert_summary(stderr, 1, 1)
|
||||||
|
|
||||||
|
def test_full_run_sorted_by_name(self) -> None:
|
||||||
|
"""Test that a full run writes rows sorted by artist
|
||||||
|
name, not by the artist ID order."""
|
||||||
|
self.__seed(["Zed", "Amy", "Mia"])
|
||||||
|
empty: dict[str, Any] = self.__sparql([])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(empty),
|
||||||
|
self.__response(empty),
|
||||||
|
self.__response(empty)]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 3)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(
|
||||||
|
[x[0] for x in rows[1:]], ["Amy", "Mia", "Zed"])
|
||||||
|
|
||||||
|
def test_pinned_qid_skips_search(self) -> None:
|
||||||
|
"""Test that a pinned name short-circuits the search."""
|
||||||
|
self.__seed(["Pinkfong"])
|
||||||
|
qid: str = fetch_artists.PINNED_QIDS["Pinkfong"]
|
||||||
|
claims: dict[str, Any] = self.__claims(
|
||||||
|
qid, {}, "South Korean children's brand")
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(claims)]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 1)
|
||||||
|
first: Any = urlopen.call_args_list[0][0][0]
|
||||||
|
self.assertTrue(first.full_url.startswith(
|
||||||
|
fetch_artists.API_URL))
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Pinkfong", qid, "", "", "",
|
||||||
|
"", "South Korean children's brand"])
|
||||||
|
|
||||||
|
def test_stage1_performer_intersection(self) -> None:
|
||||||
|
"""Test the multi-candidate resolution via a performer."""
|
||||||
|
ids: dict[str, int] = self.__seed(["Boyz"])
|
||||||
|
self.__seed_song(ids["Boyz"], "Song A")
|
||||||
|
self.__seed_song(ids["Boyz"], "Song B")
|
||||||
|
candidates: dict[str, Any] = self.__sparql([
|
||||||
|
{"item": self.__uri("Q1")},
|
||||||
|
{"item": self.__uri("Q2")}])
|
||||||
|
stage1: dict[str, Any] = self.__sparql(
|
||||||
|
[{"performer": self.__uri("Q2")}])
|
||||||
|
claims: dict[str, Any] = self.__claims(
|
||||||
|
"Q2", {}, "A boy band")
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(candidates),
|
||||||
|
self.__response(stage1),
|
||||||
|
self.__response(claims)]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 3)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Boyz", "Q2", "", "", "", "", "A boy band"])
|
||||||
|
|
||||||
|
def test_stage1_member_hop(self) -> None:
|
||||||
|
"""Test the multi-candidate resolution via a member."""
|
||||||
|
ids: dict[str, int] = self.__seed(["Trio"])
|
||||||
|
self.__seed_song(ids["Trio"], "Track")
|
||||||
|
candidates: dict[str, Any] = self.__sparql([
|
||||||
|
{"item": self.__uri("Q1")},
|
||||||
|
{"item": self.__uri("Q2")}])
|
||||||
|
stage1: dict[str, Any] = self.__sparql([{
|
||||||
|
"performer": self.__uri("Q9"),
|
||||||
|
"member": self.__uri("Q2")}])
|
||||||
|
claims: dict[str, Any] = self.__claims(
|
||||||
|
"Q2", {}, "A member of a group")
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(candidates),
|
||||||
|
self.__response(stage1),
|
||||||
|
self.__response(claims)]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 3)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Trio", "Q2", "", "", "", "", "A member of a group"])
|
||||||
|
|
||||||
|
def test_stage2_casefold_rescue(self) -> None:
|
||||||
|
"""Test the case-insensitive stage-2 rescue."""
|
||||||
|
ids: dict[str, int] = self.__seed(["Case"])
|
||||||
|
self.__seed_song(ids["Case"], "Song One")
|
||||||
|
candidates: dict[str, Any] = self.__sparql([
|
||||||
|
{"item": self.__uri("Q1")},
|
||||||
|
{"item": self.__uri("Q2")}])
|
||||||
|
stage1: dict[str, Any] = self.__sparql([])
|
||||||
|
stage2: dict[str, Any] = self.__sparql([{
|
||||||
|
"cand": self.__uri("Q2"),
|
||||||
|
"label": self.__literal("song one")}])
|
||||||
|
claims: dict[str, Any] = self.__claims(
|
||||||
|
"Q2", {}, "Rescued by casefold")
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(candidates),
|
||||||
|
self.__response(stage1),
|
||||||
|
self.__response(stage2),
|
||||||
|
self.__response(claims)]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 4)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Case", "Q2", "", "", "", "", "Rescued by casefold"])
|
||||||
|
|
||||||
|
def test_unresolved_continues_to_next_artist(self) -> None:
|
||||||
|
"""Test that an unresolved artist does not stop the run."""
|
||||||
|
self.__seed(["Ambiguous", "Nobody"])
|
||||||
|
candidates_ambiguous: dict[str, Any] = self.__sparql([
|
||||||
|
{"item": self.__uri("Q1")},
|
||||||
|
{"item": self.__uri("Q2")}])
|
||||||
|
stage2_empty: dict[str, Any] = self.__sparql([])
|
||||||
|
candidates_nobody: dict[str, Any] = self.__sparql([])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(candidates_ambiguous),
|
||||||
|
self.__response(stage2_empty),
|
||||||
|
self.__response(candidates_nobody)]
|
||||||
|
) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 3)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(len(rows), 3)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Ambiguous", "", "", "", "", "", "not found"])
|
||||||
|
self.assertEqual(rows[2], [
|
||||||
|
"Nobody", "", "", "", "", "", "not found"])
|
||||||
|
self.__assert_summary(stderr, 0, 2)
|
||||||
|
|
||||||
|
def test_retry_429_then_success(self) -> None:
|
||||||
|
"""Test a 429 retry followed by a successful request."""
|
||||||
|
self.__seed(["Retry"])
|
||||||
|
candidates: dict[str, Any] = self.__sparql([])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__http_error(429),
|
||||||
|
self.__response(candidates)]
|
||||||
|
) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Retry", "", "", "", "", "", "not found"])
|
||||||
|
self.__assert_summary(stderr, 0, 1)
|
||||||
|
|
||||||
|
def test_timeout_then_success(self) -> None:
|
||||||
|
"""Test a read timeout retried into a successful request."""
|
||||||
|
self.__seed(["SlowQuery"])
|
||||||
|
candidates: dict[str, Any] = self.__sparql([])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[TimeoutError("timed out"),
|
||||||
|
self.__response(candidates)]
|
||||||
|
) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"SlowQuery", "", "", "", "", "", "not found"])
|
||||||
|
self.__assert_summary(stderr, 0, 1)
|
||||||
|
|
||||||
|
def test_retry_exhausted_is_error(self) -> None:
|
||||||
|
"""Test that exhausted retries yield an error row."""
|
||||||
|
self.__seed(["Exhausted"])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__http_error(503)] * 5) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 5)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1][:2], ["Exhausted", ""])
|
||||||
|
self.assertTrue(rows[1][6].startswith(
|
||||||
|
"error: retries exhausted"))
|
||||||
|
self.__assert_summary(stderr, 0, 1)
|
||||||
|
|
||||||
|
def test_non_retryable_error_continues(self) -> None:
|
||||||
|
"""Test that a non-retryable HTTP error is noted as an
|
||||||
|
error and does not stop the run."""
|
||||||
|
self.__seed(["Broken", "Nobody"])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__http_error(404),
|
||||||
|
self.__response(self.__sparql([]))]
|
||||||
|
) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(rows[1][:2], ["Broken", ""])
|
||||||
|
self.assertTrue(rows[1][6].startswith("error: "))
|
||||||
|
self.assertEqual(rows[2], [
|
||||||
|
"Nobody", "", "", "", "", "", "not found"])
|
||||||
|
self.__assert_summary(stderr, 0, 2)
|
||||||
|
|
||||||
|
def test_rerun_skips_existing(self) -> None:
|
||||||
|
"""Test that the snapshot rows are skipped and preserved."""
|
||||||
|
self.__seed(["Adele", "Nobody"])
|
||||||
|
snapshot: Path = self.__snapshot
|
||||||
|
old_row: list[str] = [
|
||||||
|
"Adele", "Q1", "female", "solo", "pop",
|
||||||
|
"United Kingdom", "English singer"]
|
||||||
|
with open(snapshot, "w", encoding="utf-8",
|
||||||
|
newline="") as file:
|
||||||
|
writer: Any = csv.writer(file)
|
||||||
|
writer.writerow(self.HEADER)
|
||||||
|
writer.writerow(old_row)
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(self.__sparql([]))]
|
||||||
|
) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 1)
|
||||||
|
rows: list[list[str]] = self.__read_rows(snapshot)
|
||||||
|
self.assertEqual(len(rows), 3)
|
||||||
|
self.assertEqual(rows[0], self.HEADER)
|
||||||
|
self.assertNotIn(self.HEADER, rows[1:])
|
||||||
|
self.assertEqual(rows[1], old_row)
|
||||||
|
self.assertEqual(rows[2], [
|
||||||
|
"Nobody", "", "", "", "", "", "not found"])
|
||||||
|
self.__assert_summary(stderr, 0, 1)
|
||||||
|
|
||||||
|
def test_topup_inserts_sorted_position(self) -> None:
|
||||||
|
"""Test that a top-up inserts the new row in its sorted
|
||||||
|
position, not appended at the end of the file."""
|
||||||
|
self.__seed(["Amy", "Mia", "Zed"])
|
||||||
|
snapshot: Path = self.__snapshot
|
||||||
|
amy_row: list[str] = [
|
||||||
|
"Amy", "Q1", "", "", "", "", "not found"]
|
||||||
|
zed_row: list[str] = [
|
||||||
|
"Zed", "Q2", "", "", "", "", "not found"]
|
||||||
|
with open(snapshot, "w", encoding="utf-8",
|
||||||
|
newline="") as file:
|
||||||
|
writer: Any = csv.writer(file)
|
||||||
|
writer.writerow(self.HEADER)
|
||||||
|
writer.writerow(amy_row)
|
||||||
|
writer.writerow(zed_row)
|
||||||
|
empty: dict[str, Any] = self.__sparql([])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(empty)]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 1)
|
||||||
|
rows: list[list[str]] = self.__read_rows(snapshot)
|
||||||
|
self.assertEqual(
|
||||||
|
[x[0] for x in rows[1:]], ["Amy", "Mia", "Zed"])
|
||||||
|
self.assertEqual(rows[1], amy_row)
|
||||||
|
self.assertEqual(rows[3], zed_row)
|
||||||
|
|
||||||
|
def test_crash_mid_run_leaves_partial_rows(self) -> None:
|
||||||
|
"""Test that a crash mid-run leaves the already-fetched
|
||||||
|
rows on disk for a later top-up run to resume from."""
|
||||||
|
self.__seed(["Alpha", "Zulu"])
|
||||||
|
empty: dict[str, Any] = self.__sparql([])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with (mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(empty)]) as urlopen,
|
||||||
|
mock.patch.object(
|
||||||
|
fetch_artists, "read_artist_titles",
|
||||||
|
side_effect=[[], OSError("boom")])):
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 1)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__snapshot)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0], self.HEADER)
|
||||||
|
self.assertEqual(rows[1], [
|
||||||
|
"Alpha", "", "", "", "", "", "not found"])
|
||||||
|
|
||||||
|
def test_no_store_fails(self) -> None:
|
||||||
|
"""Test that a missing working store fails the run."""
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch("urllib.request.urlopen") as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
urlopen.assert_not_called()
|
||||||
|
self.assertIn("error:", stderr)
|
||||||
|
|
||||||
|
def test_summary_line_exact_shape(self) -> None:
|
||||||
|
"""Test the exact wording and timing of the summary
|
||||||
|
line."""
|
||||||
|
self.__seed(["Adele"])
|
||||||
|
candidates: dict[str, Any] = self.__sparql([])
|
||||||
|
with (mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(candidates)]),
|
||||||
|
mock.patch(
|
||||||
|
"time.monotonic",
|
||||||
|
side_effect=[1000.0, 1125.0])):
|
||||||
|
stderr: str = self.__run_fetch()[1]
|
||||||
|
self.assertIn(
|
||||||
|
"Done. Resolved 0/1 artists. 02:05 elapsed.",
|
||||||
|
stderr)
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
"""Unit tests for the lyrics fetcher module."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
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 fetch_lyrics
|
||||||
|
from pop_fem_audit_tools.database import Base, DataSource
|
||||||
|
from pop_fem_audit_tools.models import (
|
||||||
|
Artist,
|
||||||
|
Role,
|
||||||
|
Song,
|
||||||
|
SongArtist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchLyrics(unittest.TestCase):
|
||||||
|
"""Test cases for the lyrics fetcher."""
|
||||||
|
|
||||||
|
PROVENANCE_HEADER: list[str] = [
|
||||||
|
"song_id", "source", "method", "acquired_at", "note"]
|
||||||
|
"""The expected header row of the provenance CSV file."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Create a temporary capture directory with the store."""
|
||||||
|
tmp: tempfile.TemporaryDirectory[str] \
|
||||||
|
= tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
self.__dir: Path = Path(tmp.name)
|
||||||
|
self.__lyrics: Path = self.__dir / "lyrics"
|
||||||
|
self.__provenance: Path = \
|
||||||
|
self.__dir / "lyrics-provenance.csv"
|
||||||
|
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()
|
||||||
|
patchers: list[Any] = [
|
||||||
|
mock.patch.object(fetch_lyrics, "ds", self.__ds),
|
||||||
|
mock.patch.object(fetch_lyrics, "SLEEP_SECONDS", 0.0)]
|
||||||
|
for patcher in patchers:
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def __seed(self, songs: list[tuple[str, str]]) -> None:
|
||||||
|
"""Create the schema and the fixture songs.
|
||||||
|
|
||||||
|
Each song gets a single primary artist at position 0;
|
||||||
|
the song IDs are assigned in list order starting from 1.
|
||||||
|
|
||||||
|
:param songs: The (title, artist) pairs.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__seed_with_credit(
|
||||||
|
[(title, artist, artist) for title, artist in songs])
|
||||||
|
|
||||||
|
def __seed_with_credit(
|
||||||
|
self, songs: list[tuple[str, str, str]]) -> None:
|
||||||
|
"""Create the schema and the fixture songs.
|
||||||
|
|
||||||
|
Each song gets a single primary artist at position 0 and
|
||||||
|
an independently given artist credit; the song IDs are
|
||||||
|
assigned in list order starting from 1.
|
||||||
|
|
||||||
|
:param songs: The (title, artist, artist_credit) triples.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
Base.metadata.create_all(self.__ds.engine)
|
||||||
|
session: Session = self.__ds.get_db()
|
||||||
|
try:
|
||||||
|
artists: dict[str, Artist] = {}
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
credit: str
|
||||||
|
for title, artist, credit in songs:
|
||||||
|
if artist not in artists:
|
||||||
|
artists[artist] = Artist(name=artist)
|
||||||
|
song: Song = Song(title=title,
|
||||||
|
artist_credit=credit)
|
||||||
|
session.add(song)
|
||||||
|
session.add(SongArtist(song=song,
|
||||||
|
artist=artists[artist],
|
||||||
|
role=Role.PRIMARY,
|
||||||
|
position=0))
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __response(payload: dict[str, Any]) -> mock.MagicMock:
|
||||||
|
"""Build a fake HTTP response with a JSON body.
|
||||||
|
|
||||||
|
:param payload: The JSON payload of the response body.
|
||||||
|
:return: The fake response, usable as a context manager.
|
||||||
|
"""
|
||||||
|
response: mock.MagicMock = mock.MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.read.return_value \
|
||||||
|
= json.dumps(payload).encode("utf-8")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __not_found() -> urllib.error.HTTPError:
|
||||||
|
"""Build an HTTP 404 error.
|
||||||
|
|
||||||
|
:return: The HTTP 404 error.
|
||||||
|
"""
|
||||||
|
return urllib.error.HTTPError(
|
||||||
|
"https://example.com/", 404, "Not Found", None, None)
|
||||||
|
|
||||||
|
def __run_fetch(self) -> tuple[int, str]:
|
||||||
|
"""Run the fetcher 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 = fetch_lyrics.main(
|
||||||
|
[str(self.__lyrics), str(self.__provenance)])
|
||||||
|
return status, stderr.getvalue()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __read_rows(path: Path) -> list[list[str]]:
|
||||||
|
"""Read the rows of a CSV file.
|
||||||
|
|
||||||
|
:param path: The CSV file.
|
||||||
|
:return: The rows, the header included.
|
||||||
|
"""
|
||||||
|
with open(path, encoding="utf-8", newline="") as file:
|
||||||
|
return list(csv.reader(file))
|
||||||
|
|
||||||
|
def test_ovh_hit(self) -> None:
|
||||||
|
"""Test that a Lyrics.ovh hit writes the cache files."""
|
||||||
|
self.__seed([("Hello", "Adele")])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__response(
|
||||||
|
{"lyrics": "Hello, it's me\n"})]) as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 1)
|
||||||
|
request: Any = urlopen.call_args[0][0]
|
||||||
|
self.assertEqual(request.get_header("User-agent"),
|
||||||
|
fetch_lyrics.USER_AGENT)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.__lyrics / "1.txt")
|
||||||
|
.read_text(encoding="utf-8"),
|
||||||
|
"Hello, it's me\n")
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__provenance)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0], self.PROVENANCE_HEADER)
|
||||||
|
self.assertEqual(rows[1][:3],
|
||||||
|
["1", "lyrics.ovh", "api-fetch"])
|
||||||
|
self.assertNotEqual(rows[1][3], "")
|
||||||
|
self.assertEqual(rows[1][4], "")
|
||||||
|
self.assertIn(
|
||||||
|
"Done. Fetched lyrics for 1/1 songs.", stderr)
|
||||||
|
|
||||||
|
def test_lrclib_fallback(self) -> None:
|
||||||
|
"""Test that an ovh miss falls back to an LRCLIB hit."""
|
||||||
|
self.__seed([("Hello", "Adele")])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[
|
||||||
|
self.__not_found(),
|
||||||
|
self.__response({"plainLyrics": "Hello\n"})]
|
||||||
|
) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.__lyrics / "1.txt")
|
||||||
|
.read_text(encoding="utf-8"),
|
||||||
|
"Hello\n")
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__provenance)
|
||||||
|
self.assertEqual(rows[1][:2], ["1", "lrclib"])
|
||||||
|
|
||||||
|
def test_credit_fallback_hit(self) -> None:
|
||||||
|
"""Test that a joint credit is queried after both miss."""
|
||||||
|
self.__seed_with_credit(
|
||||||
|
[("Tequila", "Dan", "Dan + Shay")])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[
|
||||||
|
self.__not_found(),
|
||||||
|
self.__not_found(),
|
||||||
|
self.__not_found(),
|
||||||
|
self.__response(
|
||||||
|
{"plainLyrics": "Tequila\n"})]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 4)
|
||||||
|
urls: list[str] = [x[0][0].full_url
|
||||||
|
for x in urlopen.call_args_list]
|
||||||
|
self.assertIn("Dan%20%2B%20Shay", urls[2])
|
||||||
|
self.assertIn("artist_name=Dan+%2B+Shay", urls[3])
|
||||||
|
self.assertEqual(
|
||||||
|
(self.__lyrics / "1.txt")
|
||||||
|
.read_text(encoding="utf-8"),
|
||||||
|
"Tequila\n")
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__provenance)
|
||||||
|
self.assertEqual(rows[1][:2], ["1", "lrclib"])
|
||||||
|
|
||||||
|
def test_credit_fallback_skipped_when_same(self) -> None:
|
||||||
|
"""Test that a matching credit skips the second round."""
|
||||||
|
self.__seed([("Hello", "Adele")])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__not_found(),
|
||||||
|
self.__not_found()]) as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
|
||||||
|
def test_both_miss(self) -> None:
|
||||||
|
"""Test that a double miss reports the song as missing."""
|
||||||
|
self.__seed([("Hello", "Adele")])
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__not_found(),
|
||||||
|
self.__not_found()]):
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertFalse((self.__lyrics / "1.txt").exists())
|
||||||
|
self.assertFalse(self.__provenance.exists())
|
||||||
|
self.assertIn("song 1 \"Hello\": miss", stderr)
|
||||||
|
self.assertIn(
|
||||||
|
"Done. Fetched lyrics for 0/1 songs.", stderr)
|
||||||
|
|
||||||
|
def test_cached_song_skipped(self) -> None:
|
||||||
|
"""Test that a cached song triggers no HTTP request."""
|
||||||
|
self.__seed([("Hello", "Adele")])
|
||||||
|
self.__lyrics.mkdir()
|
||||||
|
(self.__lyrics / "1.txt").write_text(
|
||||||
|
"cached\n", encoding="utf-8")
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch("urllib.request.urlopen") as urlopen:
|
||||||
|
status: int = self.__run_fetch()[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
urlopen.assert_not_called()
|
||||||
|
self.assertEqual(
|
||||||
|
(self.__lyrics / "1.txt")
|
||||||
|
.read_text(encoding="utf-8"),
|
||||||
|
"cached\n")
|
||||||
|
|
||||||
|
def test_url_encoding(self) -> None:
|
||||||
|
"""Test the percent-encoding of the artist and title."""
|
||||||
|
self.__seed([("What's Up? / Down", "AC/DC & Friends")])
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[self.__not_found(),
|
||||||
|
self.__not_found()]) as urlopen:
|
||||||
|
self.__run_fetch()
|
||||||
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
urls: list[str] = [x[0][0].full_url
|
||||||
|
for x in urlopen.call_args_list]
|
||||||
|
self.assertEqual(
|
||||||
|
urls[0],
|
||||||
|
"https://api.lyrics.ovh/v1/AC%2FDC%20%26%20Friends/"
|
||||||
|
"What%27s%20Up%3F%20%2F%20Down")
|
||||||
|
self.assertEqual(
|
||||||
|
urls[1],
|
||||||
|
"https://lrclib.net/api/get"
|
||||||
|
"?artist_name=AC%2FDC+%26+Friends"
|
||||||
|
"&track_name=What%27s+Up%3F+%2F+Down")
|
||||||
|
|
||||||
|
def test_provenance_single_header(self) -> None:
|
||||||
|
"""Test that the provenance keeps one header across runs."""
|
||||||
|
self.__seed([("Hello", "Adele"),
|
||||||
|
("Umbrella", "Rihanna")])
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[
|
||||||
|
self.__response({"lyrics": "one\n"}),
|
||||||
|
self.__response({"lyrics": "two\n"})]):
|
||||||
|
self.assertEqual(self.__run_fetch()[0], 0)
|
||||||
|
rows: list[list[str]] = self.__read_rows(
|
||||||
|
self.__provenance)
|
||||||
|
self.assertEqual(len(rows), 3)
|
||||||
|
self.assertEqual(rows[0], self.PROVENANCE_HEADER)
|
||||||
|
(self.__lyrics / "2.txt").unlink()
|
||||||
|
with mock.patch(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
side_effect=[
|
||||||
|
self.__response({"lyrics": "two again\n"})]):
|
||||||
|
self.assertEqual(self.__run_fetch()[0], 0)
|
||||||
|
rows = self.__read_rows(self.__provenance)
|
||||||
|
self.assertEqual(len(rows), 4)
|
||||||
|
self.assertEqual(rows[0], self.PROVENANCE_HEADER)
|
||||||
|
self.assertNotIn(self.PROVENANCE_HEADER, rows[1:])
|
||||||
|
self.assertEqual([x[0] for x in rows[1:]],
|
||||||
|
["1", "2", "2"])
|
||||||
|
|
||||||
|
def test_no_store_fails(self) -> None:
|
||||||
|
"""Test that a missing working store fails the run."""
|
||||||
|
urlopen: mock.Mock
|
||||||
|
with mock.patch("urllib.request.urlopen") as urlopen:
|
||||||
|
status: int
|
||||||
|
stderr: str
|
||||||
|
status, stderr = self.__run_fetch()
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
urlopen.assert_not_called()
|
||||||
|
self.assertIn("error:", stderr)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
# AI assistance: Claude Code (Anthropic)
|
||||||
|
"""Unit tests for the package-level CLI dispatcher."""
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pop_fem_audit_tools import __main__
|
||||||
|
from pop_fem_audit_tools.commands import run_llm
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatcher(unittest.TestCase):
|
||||||
|
"""Test cases for the package-level CLI dispatcher."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __run_main(argv: list[str]) -> tuple[int, str, str]:
|
||||||
|
"""Run the dispatcher with captured output.
|
||||||
|
|
||||||
|
:param argv: The command-line arguments.
|
||||||
|
:return: A tuple of the exit status, the standard output,
|
||||||
|
and the standard error.
|
||||||
|
"""
|
||||||
|
stdout: io.StringIO = io.StringIO()
|
||||||
|
stderr: io.StringIO = io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
status: int = __main__.main(argv)
|
||||||
|
return status, stdout.getvalue(), stderr.getvalue()
|
||||||
|
|
||||||
|
def test_run_llm_registered(self) -> None:
|
||||||
|
"""Test that run-llm is bound to the run_llm tool main."""
|
||||||
|
self.assertIs(__main__.SUBCOMMANDS["run-llm"], run_llm.main)
|
||||||
|
|
||||||
|
def test_run_llm_dispatch(self) -> None:
|
||||||
|
"""Test that run-llm forwards the arguments and the status."""
|
||||||
|
tool: mock.Mock = mock.Mock(return_value=7)
|
||||||
|
with mock.patch.dict(__main__.SUBCOMMANDS,
|
||||||
|
{"run-llm": tool}):
|
||||||
|
status: int = self.__run_main(
|
||||||
|
["run-llm", "--input", "items.jsonl"])[0]
|
||||||
|
self.assertEqual(status, 7)
|
||||||
|
tool.assert_called_once_with(["--input", "items.jsonl"])
|
||||||
|
|
||||||
|
def test_help_exits_zero_and_lists_subcommands(self) -> None:
|
||||||
|
"""Test that --help lists the subcommands and exits 0."""
|
||||||
|
status: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
status, stdout, stderr = self.__run_main(["--help"])
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertIn("run-llm", stdout)
|
||||||
|
|
||||||
|
def test_no_arguments_exits_non_zero(self) -> None:
|
||||||
|
"""Test that no arguments yields the usage and non-zero."""
|
||||||
|
status: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
status, stdout, stderr = self.__run_main([])
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("run-llm", stderr)
|
||||||
|
|
||||||
|
def test_usage_prog_module_run(self) -> None:
|
||||||
|
"""Test the usage program name when run with python -m."""
|
||||||
|
argv: list[str] = ["/x/pop_fem_audit_tools/__main__.py"]
|
||||||
|
stdout: str
|
||||||
|
with mock.patch.object(sys, "argv", argv):
|
||||||
|
stdout = self.__run_main(["--help"])[1]
|
||||||
|
self.assertIn(
|
||||||
|
"usage: python -m pop_fem_audit_tools ", stdout)
|
||||||
|
|
||||||
|
def test_usage_prog_console_script(self) -> None:
|
||||||
|
"""Test the usage program name when run as a script."""
|
||||||
|
argv: list[str] = ["/x/bin/pop-fem-audit-tools"]
|
||||||
|
stdout: str
|
||||||
|
with mock.patch.object(sys, "argv", argv):
|
||||||
|
stdout = self.__run_main(["--help"])[1]
|
||||||
|
self.assertIn("usage: pop-fem-audit-tools ", stdout)
|
||||||
|
|
||||||
|
def test_subcommand_prog_module_run(self) -> None:
|
||||||
|
"""Test the subcommand program name with python -m."""
|
||||||
|
argv: list[str] = ["/x/pop_fem_audit_tools/__main__.py"]
|
||||||
|
self.assertEqual(
|
||||||
|
self.__dispatched_prog(argv),
|
||||||
|
"python -m pop_fem_audit_tools run-llm")
|
||||||
|
|
||||||
|
def test_subcommand_prog_console_script(self) -> None:
|
||||||
|
"""Test the subcommand program name as a script."""
|
||||||
|
argv: list[str] = ["/x/bin/pop-fem-audit-tools"]
|
||||||
|
self.assertEqual(
|
||||||
|
self.__dispatched_prog(argv),
|
||||||
|
"pop-fem-audit-tools run-llm")
|
||||||
|
|
||||||
|
def __dispatched_prog(self, argv: list[str]) -> str:
|
||||||
|
"""Run a stub run-llm tool and return the prog it sees.
|
||||||
|
|
||||||
|
:param argv: The value to patch ``sys.argv`` with.
|
||||||
|
:return: The default argparse program name seen by the
|
||||||
|
dispatched tool.
|
||||||
|
"""
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def tool(tool_argv: list[str] | None) -> int:
|
||||||
|
"""Record the argparse program name and succeed.
|
||||||
|
|
||||||
|
:param tool_argv: The tool command-line arguments.
|
||||||
|
:return: Always 0.
|
||||||
|
"""
|
||||||
|
parser: argparse.ArgumentParser = \
|
||||||
|
argparse.ArgumentParser()
|
||||||
|
seen.append(parser.prog)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with mock.patch.object(sys, "argv", argv), \
|
||||||
|
mock.patch.dict(__main__.SUBCOMMANDS,
|
||||||
|
{"run-llm": tool}):
|
||||||
|
self.__run_main(["run-llm"])
|
||||||
|
return seen[0]
|
||||||
|
|
||||||
|
def test_unknown_subcommand_exits_non_zero(self) -> None:
|
||||||
|
"""Test that an unknown subcommand errors to standard error."""
|
||||||
|
status: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
status, stdout, stderr = self.__run_main(["nonsense"])
|
||||||
|
self.assertNotEqual(status, 0)
|
||||||
|
self.assertIn("nonsense", stderr)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||||
|
# AI assistance: Claude Code (Anthropic)
|
||||||
|
"""Unit tests for the data models."""
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from pop_fem_audit_tools import config
|
||||||
|
from pop_fem_audit_tools.database import Base, DataSource
|
||||||
|
from pop_fem_audit_tools.models import (
|
||||||
|
Artist,
|
||||||
|
ChartEntry,
|
||||||
|
Role,
|
||||||
|
Song,
|
||||||
|
SongArtist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestModels(unittest.TestCase):
|
||||||
|
"""Test cases for the data models."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Create the schema on an in-memory SQLite database."""
|
||||||
|
config.set_settings(config.Settings(
|
||||||
|
SQLALCHEMY_DATABASE_URL="sqlite://",
|
||||||
|
ANTHROPIC_API_KEY="test-key"))
|
||||||
|
self.__ds: DataSource = DataSource()
|
||||||
|
Base.metadata.create_all(self.__ds.engine)
|
||||||
|
self.__session: Session = self.__ds.get_db()
|
||||||
|
self.addCleanup(self.__session.close)
|
||||||
|
|
||||||
|
def __add_song(self) -> None:
|
||||||
|
"""Add a song with chart entries, artists, and lyrics.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
song: Song = Song(title="One Dance",
|
||||||
|
artist_credit="Drake featuring Wizkid")
|
||||||
|
song.chart_entries = [ChartEntry(year=2016, rank=4)]
|
||||||
|
song.song_artists = [
|
||||||
|
SongArtist(artist=Artist(name="Drake"),
|
||||||
|
role=Role.PRIMARY, position=0),
|
||||||
|
SongArtist(artist=Artist(name="Wizkid"),
|
||||||
|
role=Role.FEATURED, position=1)]
|
||||||
|
song.lyrics = "Baby, I like your style"
|
||||||
|
self.__session.add(song)
|
||||||
|
self.__session.commit()
|
||||||
|
|
||||||
|
def test_song_graph(self) -> None:
|
||||||
|
"""Test reading a song graph back through relationships."""
|
||||||
|
self.__add_song()
|
||||||
|
self.__session.expunge_all()
|
||||||
|
song: Song | None = self.__session.scalar(
|
||||||
|
sa.select(Song).where(Song.title == "One Dance"))
|
||||||
|
assert song is not None
|
||||||
|
self.assertEqual(song.artist_credit,
|
||||||
|
"Drake featuring Wizkid")
|
||||||
|
self.assertEqual([(x.year, x.rank)
|
||||||
|
for x in song.chart_entries],
|
||||||
|
[(2016, 4)])
|
||||||
|
self.assertEqual([(x.artist.name, x.role, x.position)
|
||||||
|
for x in song.song_artists],
|
||||||
|
[("Drake", Role.PRIMARY, 0),
|
||||||
|
("Wizkid", Role.FEATURED, 1)])
|
||||||
|
self.assertEqual(song.lyrics,
|
||||||
|
"Baby, I like your style")
|
||||||
|
artist: Artist | None = self.__session.scalar(
|
||||||
|
sa.select(Artist).where(Artist.name == "Wizkid"))
|
||||||
|
assert artist is not None
|
||||||
|
self.assertEqual([x.song.title for x in artist.song_artists],
|
||||||
|
["One Dance"])
|
||||||
|
|
||||||
|
def test_duplicated_song_rejected(self) -> None:
|
||||||
|
"""Test that a duplicated title and artist credit fails."""
|
||||||
|
self.__add_song()
|
||||||
|
self.__session.add(
|
||||||
|
Song(title="One Dance",
|
||||||
|
artist_credit="Drake featuring Wizkid"))
|
||||||
|
with self.assertRaises(sa.exc.IntegrityError):
|
||||||
|
self.__session.commit()
|
||||||
|
|
||||||
|
def test_invalid_role_rejected(self) -> None:
|
||||||
|
"""Test that an invalid song-artist role fails."""
|
||||||
|
self.__add_song()
|
||||||
|
song: Song | None = self.__session.scalar(
|
||||||
|
sa.select(Song).where(Song.title == "One Dance"))
|
||||||
|
assert song is not None
|
||||||
|
self.__session.add(
|
||||||
|
SongArtist(song=song, artist=Artist(name="Kyla"),
|
||||||
|
role="cover", position=2))
|
||||||
|
with self.assertRaises(sa.exc.IntegrityError):
|
||||||
|
self.__session.commit()
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
# Tools for A Feminist Audit of Pop Music.
|
||||||
|
# Copyright 2026 imacat. All rights reserved.
|
||||||
|
# Authors:
|
||||||
|
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
||||||
|
# AI assistance: Claude Code (Anthropic)
|
||||||
|
"""Unit tests for the run_llm batch executor module."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pop_fem_audit_tools import config
|
||||||
|
from pop_fem_audit_tools.commands import run_llm
|
||||||
|
|
||||||
|
|
||||||
|
class RunLLMTestCase(unittest.TestCase):
|
||||||
|
"""The common base test case with the shared helpers."""
|
||||||
|
|
||||||
|
def _make_temp_dir(self) -> Path:
|
||||||
|
"""Create a temporary directory removed on test cleanup.
|
||||||
|
|
||||||
|
:return: The path of the temporary directory.
|
||||||
|
"""
|
||||||
|
tmp: tempfile.TemporaryDirectory[str] \
|
||||||
|
= tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
return Path(tmp.name)
|
||||||
|
|
||||||
|
def _make_success_entry(self, custom_id: str,
|
||||||
|
text: str) -> mock.Mock:
|
||||||
|
"""Create a mock succeeded batch result entry.
|
||||||
|
|
||||||
|
:param custom_id: The custom ID of the entry.
|
||||||
|
:param text: The output text.
|
||||||
|
:return: The mock result entry.
|
||||||
|
"""
|
||||||
|
entry: mock.Mock = mock.Mock()
|
||||||
|
entry.custom_id = custom_id
|
||||||
|
entry.result = mock.Mock()
|
||||||
|
entry.result.type = "succeeded"
|
||||||
|
entry.result.message = self.__make_message(text)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_error_entry(custom_id: str,
|
||||||
|
error_type: str) -> mock.Mock:
|
||||||
|
"""Create a mock errored batch result entry.
|
||||||
|
|
||||||
|
The error object is shaped as the SDK envelope: the outer
|
||||||
|
error carries the constant type ``error``, and the specific
|
||||||
|
error code lives at ``error.error.type``.
|
||||||
|
|
||||||
|
:param custom_id: The custom ID of the entry.
|
||||||
|
:param error_type: The specific error code.
|
||||||
|
:return: The mock result entry.
|
||||||
|
"""
|
||||||
|
entry: mock.Mock = mock.Mock()
|
||||||
|
entry.custom_id = custom_id
|
||||||
|
entry.result = mock.Mock()
|
||||||
|
entry.result.type = "errored"
|
||||||
|
entry.result.error = mock.Mock()
|
||||||
|
entry.result.error.type = "error"
|
||||||
|
entry.result.error.error = mock.Mock()
|
||||||
|
entry.result.error.error.type = error_type
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __make_message(text: str) -> mock.Mock:
|
||||||
|
"""Create a mock message with a single text block.
|
||||||
|
|
||||||
|
:param text: The text of the text block.
|
||||||
|
:return: The mock message.
|
||||||
|
"""
|
||||||
|
block: mock.Mock = mock.Mock()
|
||||||
|
block.type = "text"
|
||||||
|
block.text = text
|
||||||
|
usage: mock.Mock = mock.Mock()
|
||||||
|
usage.model_dump.return_value = {
|
||||||
|
"input_tokens": 10, "output_tokens": 5, "extra": None}
|
||||||
|
message: mock.Mock = mock.Mock()
|
||||||
|
message.content = [block]
|
||||||
|
message.stop_reason = "end_turn"
|
||||||
|
message.usage = usage
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadItems(RunLLMTestCase):
|
||||||
|
"""Test cases for the input JSONL validation."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Create a temporary directory for the input files."""
|
||||||
|
self.__dir: Path = self._make_temp_dir()
|
||||||
|
|
||||||
|
def __write_input(self, content: str) -> Path:
|
||||||
|
"""Write an input file with the given content.
|
||||||
|
|
||||||
|
:param content: The file content.
|
||||||
|
:return: The path of the input file.
|
||||||
|
"""
|
||||||
|
path: Path = self.__dir / "items.jsonl"
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_valid_items(self) -> None:
|
||||||
|
"""Test that valid items are loaded in file order."""
|
||||||
|
path: Path = self.__write_input(
|
||||||
|
'{"id": "a", "content": "one"}\n'
|
||||||
|
'{"id": "b", "content": "two"}\n')
|
||||||
|
items: list[run_llm.InputItem] = run_llm.load_items(path)
|
||||||
|
self.assertEqual(items, [
|
||||||
|
run_llm.InputItem(id="a", content="one"),
|
||||||
|
run_llm.InputItem(id="b", content="two")])
|
||||||
|
|
||||||
|
def test_malformed_json_names_line(self) -> None:
|
||||||
|
"""Test that malformed JSON reports the line number."""
|
||||||
|
path: Path = self.__write_input(
|
||||||
|
'{"id": "a", "content": "one"}\n'
|
||||||
|
'not json\n')
|
||||||
|
with self.assertRaises(run_llm.InputFormatError) as context:
|
||||||
|
run_llm.load_items(path)
|
||||||
|
self.assertIn("line 2", str(context.exception))
|
||||||
|
|
||||||
|
def test_missing_key_names_line(self) -> None:
|
||||||
|
"""Test that a missing key reports the line number."""
|
||||||
|
path: Path = self.__write_input('{"id": "a"}\n')
|
||||||
|
with self.assertRaises(run_llm.InputFormatError) as context:
|
||||||
|
run_llm.load_items(path)
|
||||||
|
self.assertIn("line 1", str(context.exception))
|
||||||
|
|
||||||
|
def test_extra_key_rejected(self) -> None:
|
||||||
|
"""Test that an extra key is rejected."""
|
||||||
|
path: Path = self.__write_input(
|
||||||
|
'{"id": "a", "content": "one", "extra": 1}\n')
|
||||||
|
with self.assertRaises(run_llm.InputFormatError):
|
||||||
|
run_llm.load_items(path)
|
||||||
|
|
||||||
|
def test_non_string_content_rejected(self) -> None:
|
||||||
|
"""Test that a non-string content is rejected."""
|
||||||
|
path: Path = self.__write_input('{"id": "a", "content": 3}\n')
|
||||||
|
with self.assertRaises(run_llm.InputFormatError):
|
||||||
|
run_llm.load_items(path)
|
||||||
|
|
||||||
|
def test_duplicated_id_names_line(self) -> None:
|
||||||
|
"""Test that a duplicated ID reports the line number."""
|
||||||
|
path: Path = self.__write_input(
|
||||||
|
'{"id": "a", "content": "one"}\n'
|
||||||
|
'{"id": "a", "content": "two"}\n')
|
||||||
|
with self.assertRaises(run_llm.InputFormatError) as context:
|
||||||
|
run_llm.load_items(path)
|
||||||
|
self.assertIn("line 2", str(context.exception))
|
||||||
|
self.assertIn("a", str(context.exception))
|
||||||
|
|
||||||
|
def test_empty_file_rejected(self) -> None:
|
||||||
|
"""Test that an empty input file is rejected."""
|
||||||
|
path: Path = self.__write_input("")
|
||||||
|
with self.assertRaises(run_llm.InputFormatError):
|
||||||
|
run_llm.load_items(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestBuilding(RunLLMTestCase):
|
||||||
|
"""Test cases for the request construction."""
|
||||||
|
|
||||||
|
def test_build_request(self) -> None:
|
||||||
|
"""Test the shape of a batch request."""
|
||||||
|
request: dict[str, Any] = run_llm.build_request(
|
||||||
|
run_llm.InputItem(id="song-1", content="the lyrics"),
|
||||||
|
"the system prompt", 2048)
|
||||||
|
self.assertEqual(request["custom_id"], "song-1")
|
||||||
|
params: dict[str, Any] = request["params"]
|
||||||
|
self.assertEqual(params["model"], "claude-sonnet-4-6")
|
||||||
|
self.assertEqual(params["temperature"], 0.0)
|
||||||
|
self.assertEqual(params["thinking"], {"type": "disabled"})
|
||||||
|
self.assertEqual(params["max_tokens"], 2048)
|
||||||
|
self.assertEqual(params["system"], "the system prompt")
|
||||||
|
self.assertEqual(params["messages"],
|
||||||
|
[{"role": "user", "content": "the lyrics"}])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectResults(RunLLMTestCase):
|
||||||
|
"""Test cases for the batch result collection."""
|
||||||
|
|
||||||
|
def test_collect_success_and_error(self) -> None:
|
||||||
|
"""Test collecting succeeded and errored results."""
|
||||||
|
client: mock.Mock = mock.Mock()
|
||||||
|
client.messages.batches.results.return_value = iter([
|
||||||
|
self._make_success_entry("a", "output a"),
|
||||||
|
self._make_error_entry("b", "invalid_request_error")])
|
||||||
|
results: run_llm.Results = run_llm.collect_results(
|
||||||
|
client, "batch_x")
|
||||||
|
self.assertEqual(results["a"].text, "output a")
|
||||||
|
self.assertEqual(results["a"].stop_reason, "end_turn")
|
||||||
|
self.assertEqual(results["a"].usage,
|
||||||
|
{"input_tokens": 10, "output_tokens": 5})
|
||||||
|
self.assertEqual(results["b"], run_llm.BatchResult(
|
||||||
|
id="b", error="invalid_request_error"))
|
||||||
|
client.messages.batches.results.assert_called_once_with(
|
||||||
|
"batch_x")
|
||||||
|
|
||||||
|
def test_find_failures(self) -> None:
|
||||||
|
"""Test finding failed and missing items."""
|
||||||
|
results: run_llm.Results = {
|
||||||
|
"a": run_llm.BatchResult(id="a", text="fine"),
|
||||||
|
"b": run_llm.BatchResult(id="b", error="errored")}
|
||||||
|
self.assertEqual(
|
||||||
|
run_llm.find_failures(["a", "b", "c"], results),
|
||||||
|
["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):
|
||||||
|
"""Test cases for the archive directory handling."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Create a temporary directory as the runs root."""
|
||||||
|
self.__dir: Path = self._make_temp_dir()
|
||||||
|
|
||||||
|
def test_create_archive_dir(self) -> None:
|
||||||
|
"""Test the archive directory creation."""
|
||||||
|
target: Path = self.__dir / "01-01-tag" / "run1"
|
||||||
|
directory: Path = run_llm.create_archive_dir(target, False)
|
||||||
|
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(
|
||||||
|
(run1 / "output.jsonl").read_text(encoding="utf-8"),
|
||||||
|
"run1 data")
|
||||||
|
|
||||||
|
def test_write_jsonl(self) -> None:
|
||||||
|
"""Test writing records as JSON Lines."""
|
||||||
|
path: Path = self.__dir / "out.jsonl"
|
||||||
|
run_llm.write_jsonl(path, [{"id": "a", "text": "中文"},
|
||||||
|
{"id": "b", "text": "two"}])
|
||||||
|
lines: list[str] = path.read_text(
|
||||||
|
encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(len(lines), 2)
|
||||||
|
self.assertEqual(json.loads(lines[0]),
|
||||||
|
{"id": "a", "text": "中文"})
|
||||||
|
self.assertIn("中文", lines[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainFlow(RunLLMTestCase):
|
||||||
|
"""Test cases for the end-to-end main flow."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Create a temporary directory with the input files."""
|
||||||
|
directory: Path = self._make_temp_dir()
|
||||||
|
self.__runs: Path = directory / "runs"
|
||||||
|
self.__archive_dir: Path = self.__runs / "task_v1" / "run1"
|
||||||
|
self.__prompt: Path = directory / "task_v1.md"
|
||||||
|
self.__prompt.write_text(
|
||||||
|
"The task prompt.\n", encoding="utf-8")
|
||||||
|
self.__input: Path = directory / "items.jsonl"
|
||||||
|
self.__input.write_text(
|
||||||
|
'{"id": "a", "content": "first item"}\n'
|
||||||
|
'{"id": "b", "content": "second item"}\n',
|
||||||
|
encoding="utf-8")
|
||||||
|
self.__argv: list[str] = [
|
||||||
|
str(self.__prompt), str(self.__input),
|
||||||
|
str(self.__archive_dir)]
|
||||||
|
self.__settings: config.Settings = config.Settings(
|
||||||
|
SQLALCHEMY_DATABASE_URL="sqlite://",
|
||||||
|
ANTHROPIC_API_KEY="test-key")
|
||||||
|
config.set_settings(self.__settings)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __make_client(entries: list[Any]) -> mock.Mock:
|
||||||
|
"""Create a mock Anthropic client serving canned results.
|
||||||
|
|
||||||
|
:param entries: The result entries of the single run batch.
|
||||||
|
:return: The mock client.
|
||||||
|
"""
|
||||||
|
client: mock.Mock = mock.Mock()
|
||||||
|
client.messages.batches.create.return_value = mock.Mock(
|
||||||
|
id="batch_run1")
|
||||||
|
ended: mock.Mock = mock.Mock()
|
||||||
|
ended.processing_status = "ended"
|
||||||
|
ended.ended_at = datetime(2026, 7, 30, 20, 0)
|
||||||
|
client.messages.batches.retrieve.return_value = ended
|
||||||
|
client.messages.batches.results.return_value = iter(entries)
|
||||||
|
return client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __run_main(argv: list[str],
|
||||||
|
client: mock.Mock | None = None) -> tuple[int, str]:
|
||||||
|
"""Run main() with a mocked client and captured output.
|
||||||
|
|
||||||
|
:param argv: The command-line arguments.
|
||||||
|
:param client: The mock client, or None for dry runs.
|
||||||
|
:return: A tuple of the exit status and the standard output.
|
||||||
|
"""
|
||||||
|
stdout: io.StringIO = io.StringIO()
|
||||||
|
stderr: io.StringIO = io.StringIO()
|
||||||
|
with mock.patch.object(run_llm.anthropic, "Anthropic",
|
||||||
|
return_value=client), \
|
||||||
|
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
status: int = run_llm.main(argv)
|
||||||
|
return status, stdout.getvalue()
|
||||||
|
|
||||||
|
def test_dry_run(self) -> None:
|
||||||
|
"""Test the dry-run behavior."""
|
||||||
|
status: int
|
||||||
|
stdout: str
|
||||||
|
status, stdout = self.__run_main(
|
||||||
|
self.__argv + ["--dry-run"])
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
run_dir: Path = self.__archive_dir
|
||||||
|
self.assertEqual((run_dir / "prompt.md").read_text(
|
||||||
|
encoding="utf-8"), "The task prompt.\n")
|
||||||
|
meta: dict[str, Any] = json.loads(
|
||||||
|
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertTrue(meta["dry_run"])
|
||||||
|
self.assertEqual(meta["item_count"], 2)
|
||||||
|
self.assertEqual(meta["model"], "claude-sonnet-4-6")
|
||||||
|
self.assertNotIn("run", meta)
|
||||||
|
self.assertFalse((run_dir / "output.jsonl").exists())
|
||||||
|
request: dict[str, Any] = json.loads(stdout)
|
||||||
|
self.assertEqual(request["custom_id"], "a")
|
||||||
|
self.assertEqual(request["params"]["system"],
|
||||||
|
"The task prompt.\n")
|
||||||
|
|
||||||
|
def test_run_produces_output_file(self) -> None:
|
||||||
|
"""Test that a run submits one batch and writes output."""
|
||||||
|
client: mock.Mock = self.__make_client(
|
||||||
|
[self._make_success_entry("a", "answer a"),
|
||||||
|
self._make_success_entry("b", "answer b")])
|
||||||
|
status: int = self.__run_main(self.__argv, client)[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
client.messages.batches.create.call_count, 1)
|
||||||
|
run_dir: Path = self.__archive_dir
|
||||||
|
self.assertTrue((run_dir / "output.jsonl").exists())
|
||||||
|
output: list[dict[str, Any]] = [
|
||||||
|
json.loads(x) for x in (run_dir / "output.jsonl")
|
||||||
|
.read_text(encoding="utf-8").splitlines()]
|
||||||
|
self.assertEqual(output[0]["text"], "answer a")
|
||||||
|
meta: dict[str, Any] = json.loads(
|
||||||
|
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertNotIn("run", meta)
|
||||||
|
self.assertEqual(meta["batch"]["batch_id"], "batch_run1")
|
||||||
|
self.assertEqual(meta["usage"],
|
||||||
|
{"input_tokens": 20, "output_tokens": 10})
|
||||||
|
|
||||||
|
def test_existing_archive_rejected_without_replace(self) -> None:
|
||||||
|
"""Test that an existing archive without --replace fails."""
|
||||||
|
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(
|
||||||
|
[self._make_success_entry("a", "answer a"),
|
||||||
|
self._make_success_entry("b", "answer b")])
|
||||||
|
status: int = self.__run_main(
|
||||||
|
self.__argv + ["--replace"], client)[0]
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertFalse((run_dir / "stale.jsonl").exists())
|
||||||
|
self.assertTrue((run_dir / "output.jsonl").exists())
|
||||||
|
|
||||||
|
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(
|
||||||
|
(run1_dir / "output.jsonl").read_text(encoding="utf-8"),
|
||||||
|
"run1 data")
|
||||||
|
self.assertNotEqual(
|
||||||
|
(run2_dir / "output.jsonl").read_text(encoding="utf-8"),
|
||||||
|
"stale run2 data")
|
||||||
|
|
||||||
|
def test_run_failure_exits_non_zero(self) -> None:
|
||||||
|
"""Test that a failed item aborts with a non-zero status."""
|
||||||
|
client: mock.Mock = self.__make_client(
|
||||||
|
[self._make_success_entry("a", "answer a"),
|
||||||
|
self._make_error_entry("b", "invalid_request_error")])
|
||||||
|
status: int = self.__run_main(self.__argv, client)[0]
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
run_dir: Path = self.__archive_dir
|
||||||
|
self.assertTrue((run_dir / "output.jsonl").exists())
|
||||||
|
self.assertTrue((run_dir / "meta.json").exists())
|
||||||
|
output_lines: list[str] = (run_dir / "output.jsonl") \
|
||||||
|
.read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(json.loads(output_lines[1]),
|
||||||
|
{"id": "b",
|
||||||
|
"error": "invalid_request_error"})
|
||||||
|
|
||||||
|
def test_invalid_input_exits_non_zero(self) -> None:
|
||||||
|
"""Test that an invalid input file aborts before archiving."""
|
||||||
|
self.__input.write_text('{"id": "a"}\n', encoding="utf-8")
|
||||||
|
status: int = self.__run_main(
|
||||||
|
self.__argv + ["--dry-run"])[0]
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertFalse(self.__runs.exists())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 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