Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a2166c93 | ||
|
|
d0f65a7ddf | ||
|
|
66be33a393 | ||
|
|
2cbd6acf7a | ||
|
|
dc682ff5b9 | ||
|
|
9dc10db549 | ||
|
|
979c2f8761 | ||
|
|
8816b74b2d | ||
|
|
09702b4015 | ||
|
|
877304161a | ||
|
|
70dfbf9f7c | ||
|
|
46b24e10a7 | ||
|
|
bf3c042254 | ||
|
|
2e7c11f978 | ||
|
|
635648683b | ||
|
|
0aa93c0778 | ||
|
|
b9ce1eb08a | ||
|
|
9eed6917ab | ||
|
|
9a089f6c6b |
@@ -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
|
||||
data/lyrics/
|
||||
data/captures/lyrics/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
# Claude Code local settings (machine/user-specific, not research content)
|
||||
.claude/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -17,3 +14,4 @@ venv/
|
||||
# OS / Dropbox artifacts
|
||||
*.conflicted copy*
|
||||
.DS_Store
|
||||
excludes/
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
# Project Workflow Rules
|
||||
# Project Conventions
|
||||
|
||||
Keep this file minimal. It is injected into every Claude Code
|
||||
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)
|
||||
## Analysis pipeline
|
||||
|
||||
- LLM analysis runs via Python scripts calling the Anthropic
|
||||
Messages API: model `claude-sonnet-4-6`, `temperature=0`,
|
||||
@@ -31,14 +17,16 @@ criteria, or expected results here. Domain material lives in
|
||||
raw outputs of both runs, arbitration output, and `meta.json`
|
||||
(model ID, parameters, timestamps, batch IDs).
|
||||
- Scripts read the API key from the `ANTHROPIC_API_KEY`
|
||||
environment variable (`.env`, gitignored). Never hardcode
|
||||
keys.
|
||||
environment variable (`.env`, gitignored).
|
||||
|
||||
## Data rules
|
||||
|
||||
- `data/yearend_hot100_2016_2025.csv` is the only hand-placed
|
||||
raw file; everything else in `data/` is script-derived.
|
||||
- Full lyrics are copyrighted: they stay in `data/lyrics/`
|
||||
- `data/source/` holds the immutable hand-placed raw files;
|
||||
`data/captures/` is written only by the fetch commands and the
|
||||
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
|
||||
full anywhere in the repo.
|
||||
|
||||
|
||||
@@ -14,17 +14,25 @@ Hot 100(2016–2025)為例的內容分析。
|
||||
|
||||
## 重現方式
|
||||
|
||||
1. 準備 Python 3.10+ 環境,安裝 `requirements.txt`。
|
||||
2. 將 Anthropic API 金鑰寫入 `.env`(格式見 `.env.example`)。
|
||||
3. 依 `docs/research_plan.md` 的階段順序執行 `scripts/` 下的
|
||||
程式。LLM 步驟使用 `claude-sonnet-4-6`、temperature=0、
|
||||
1. 準備 Python 3.14+ 環境,安裝分析管線套件:
|
||||
`pip install -e tools/`。
|
||||
2. 自 `tools/.env.example` 建立 `tools/.env`,寫入
|
||||
Anthropic API 金鑰。
|
||||
3. 依 `docs/research_plan.md` 的階段順序,於 `tools/` 目錄下
|
||||
執行子命令,必要輸入以位置引數、選擇性輸入以選項給定(如
|
||||
`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. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於
|
||||
`runs/`,可逐筆稽核。論文引用的最終資料表在 `results/`。
|
||||
|
||||
注意:歌詞受版權保護,`data/lyrics/` 不隨 repo 發布,須自行
|
||||
以 `scripts/` 中的抓取程式重建。
|
||||
注意:歌詞受版權保護,`data/captures/lyrics/` 不隨 repo 發布,須自行
|
||||
以 `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,470 @@
|
||||
Name,Wikidata QID,Gender,Type,Genre,Country,Songs
|
||||
070 Shake,,,,,,Escapism (2023#48)
|
||||
2 Chainz,,,,,,Big Bank (2018#63)
|
||||
21 Savage,,,,,,A Lot (2019#42)|Bank Account (2017#48)|Bartier Cardi (2018#61)|Creepin' (2023#5)|Good Good (2024#79)|Jimmy Cooks (2022#33)|Knife Talk (2021#86/2022#60)|Peaches & Eggplants (2023#94)|Redrum (2024#42)|Rich Flex (2023#14)|Rockstar (2017#56/2018#5)|Spin Bout U (2023#43)
|
||||
24kGoldn,,,,,,Mood (2020#47/2021#4)
|
||||
42 Dugg,,,,,,We Paid (2020#57)
|
||||
4batz,,,,,,Act II: Date @ 8 (2024#80)
|
||||
5 Seconds of Summer,,,,,,Youngblood (2018#36/2019#33)
|
||||
50 Cent,,,,,,The Woo (2020#78)
|
||||
6ix9ine,,,,,,FEFE (2018#31)|Gummo (2018#56)|Swervin (2019#82)
|
||||
6LACK,,,,,,Calling My Phone (2021#33)
|
||||
A Boogie wit da Hoodie,,,,,,Drowning (2017#81)|Look Back At It (2019#41)|Swervin (2019#82)
|
||||
A$AP Ferg,,,,,,Plain Jane (2018#74)
|
||||
A$AP Rocky,,,,,,No Limit (2018#30)
|
||||
Adassa,,,,,,We Don't Talk About Bruno (2022#24)
|
||||
Adele,,,,,,Easy On Me (2022#4)|Hello (2016#7)|Oh My God (2022#72)|Send My Love (To Your New Lover) (2016#26)|Water Under The Bridge (2017#88)|When We Were Young (2016#83)
|
||||
AJR,,,,,,Bang! (2021#56)
|
||||
Alessia Cara,,,,,,1-800-273-8255 (2017#31/2018#84)|Here (2016#39)|Scars To Your Beautiful (2017#30)|Stay (2017#17)
|
||||
Alex Warren,,,,,,Ordinary (2025#7)
|
||||
Aminé,,,,,,Caroline (2017#60)
|
||||
Anderson .Paak,,,,,,Leave The Door Open (2021#7)|Smokin Out The Window (2022#41)
|
||||
Andrew Choi,,,,,,Free (2025#92)|Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Anne-Marie,,,,,,Friends (2018#26)|Rockabye (2017#44)
|
||||
Aqua,,,,,,Barbie World (2023#46)
|
||||
Ariana Grande,,,,,,"34+35 (2021#21)|7 Rings (2019#7)|Boyfriend (2019#98)|Break Up With Your Girlfriend, I'm Bored (2019#36)|Breathin (2019#72)|Dangerous Woman (2016#36)|Die For You (2023#7)|God Is A Woman (2018#62)|Into You (2016#51)|No Tears Left To Cry (2018#20)|Positions (2021#14)|pov (2021#87)|Rain On Me (2020#48)|Save Your Tears (2021#2/2022#40)|Side To Side (2016#77/2017#43)|Stuck With U (2020#80)|Thank U, Next (2019#12)|We Can't Be Friends (Wait For Your Love) (2024#26)|Yes, And? (2024#92)"
|
||||
Arizona Zervas,,,,,,Roxanne (2020#16)
|
||||
Artemas,,,,,,I Like The Way You Kiss Me (2024#63)
|
||||
ATR Son Son,,,,,,Rags2Riches (2020#90)
|
||||
Audrey Nuna,,,,,,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||
Ava Max,,,,,,Kings & Queens (2021#73)|Sweet But Psycho (2019#23)
|
||||
Avery Anna,,,,,,Indigo (2025#49)
|
||||
Ayo,,,,,,Rolex (2017#61)
|
||||
AzChike,,,,,,Peekaboo (2025#53)
|
||||
Bad Bunny,,,,,,Baile Inolvidable (2025#70)|Dakiti (2021#28)|Despues de La Playa (2022#78)|DTMF (2025#51)|Efecto (2022#69)|EOO (2025#77)|I Like It (2018#7/2019#69)|Me Porto Bonito (2022#20)|MIA (2019#44)|Monaco (2024#82)|Moscow Mule (2022#44)|Nuevayol (2025#89)|Ojitos Lindos (2022#87)|Party (2022#77)|Te Bote (2018#81)|Titi Me Pregunto (2022#22)|Un x100to (2023#49)|Where She Goes (2023#72)|Yonaguni (2021#83)
|
||||
Bailey Zimmerman,,,,,,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,,,,,,Happier (2018#80/2019#6)
|
||||
Bazzi,,,,,,Beautiful (2019#95)|Mine (2018#21)
|
||||
beabadoobee,,,,,,Death Bed (2020#43)
|
||||
Bebe Rexha,,,,,,"I'm Good (Blue) (2023#10)|Me, Myself & I (2016#19)|Meant To Be (2018#3)"
|
||||
Becky G,,,,,,MAMIII (2022#59)
|
||||
BENEE,,,,,,Supalonely (2020#84)
|
||||
benny blanco,,,,,,Eastside (2018#77/2019#17)|Lonely (2021#65)
|
||||
Benson Boone,,,,,,Beautiful Things (2024#3/2025#6)|Mystical Magical (2025#47)|Slow It Down (2024#41)|Sorry I'm Here For Someone Else (2025#37)
|
||||
Beyoncé,,,,,,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,,,,,,Big Bank (2018#63)|Bounce Back (2017#35)|Feels (2017#74)
|
||||
BigXthaPlug,,,,,,All The Way (2025#33)
|
||||
Billie Eilish,,,,,,Bad Guy (2019#4/2020#46)|Birds Of A Feather (2024#15/2025#5)|Bury A Friend (2019#73)|Everything I Wanted (2020#18)|Happier Than Ever (2021#94)|Lunch (2024#48)|Therefore I Am (2021#25)|What Was I Made For? (2023#81/2024#46)|When The Party's Over (2019#67)|Wildflower (2024#89/2025#26)
|
||||
Billy Ray Cyrus,,,,,,Old Town Road (2019#1)
|
||||
Bipolar Sunshine,,,,,,Middle (2016#80)
|
||||
Bizarrap,,,,,,"Bzrp Music Sessions, Vol. 53 (2023#96)"
|
||||
Black Eyed Peas,,,,,,RITMO (Bad Boys For Life) (2020#50)
|
||||
blackbear,,,,,,Do Re Mi (2017#98)|Hot Girl Bummer (2020#26)|My Ex's Best Friend (2021#23)
|
||||
Blake Shelton,,,,,,God's Country (2019#53)|Nobody But You (2020#52)|Pour Me A Drink (2024#76)
|
||||
Blanco Brown,,,,,,Just The Way (2021#88)|The Git Up (2019#56)
|
||||
BlocBoy JB,,,,,,Look Alive (2018#23)
|
||||
Blueface,,,,,,Thotiana (2019#47)
|
||||
Bobby Helms,,,,,,Jingle Bell Rock (2022#86/2023#68/2024#68/2025#81)
|
||||
Bomba Estéreo,,,,,,Ojitos Lindos (2022#87)
|
||||
Bradley Cooper,,,,,,Shallow (2019#19)
|
||||
Brandon Lake,,,,,,Hard Fought Hallelujah (2025#84)
|
||||
Brenda Lee,,,,,,Rockin' Around The Christmas Tree (2021#92/2022#80/2023#60/2024#59/2025#67)
|
||||
Brendon Urie,,,,,,ME! (2019#43)
|
||||
Brett Young,,,,,,In Case You Didn't Know (2017#57)|Mercy (2018#95)
|
||||
BRS Kash,,,,,,Throat Baby (Go Baby) (2021#97)
|
||||
Bruno Mars,,,,,,24K Magic (2017#16)|APT. (2025#9)|Die With A Smile (2024#62/2025#1)|Finesse (2018#14)|Leave The Door Open (2021#7)|Please Me (2019#37)|Smokin Out The Window (2022#41)|That's What I Like (2017#3)|Wake Up In The Sky (2019#51)
|
||||
Bryson Tiller,,,,,,Don't (2016#35)|Exchange (2016#53)|Whatever She Wants (2024#58)|Wild Thoughts (2017#18)
|
||||
BTS,,,,,,Butter (2021#11)|Dynamite (2020#38/2021#41)
|
||||
Burl Ives,,,,,,A Holly Jolly Christmas (2022#89/2023#71/2024#94)
|
||||
Calboy,,,,,,Envy Me (2019#59)
|
||||
Calvin Harris,,,,,,Feels (2017#74)|One Kiss (2018#68)|Slide (2017#71)|This Is What You Came For (2016#17)
|
||||
Camila Cabello,,,,,,Bad Things (2017#41)|Bam Bam (2022#66)|Beautiful (2019#95)|Havana (2017#96/2018#4)|I Know What You Did Last Summer (2016#86)|My Oh My (2020#37)|Never Be The Same (2018#18)|Senorita (2019#15/2020#44)
|
||||
Cardi B,,,,,,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,,,,,,I Hope You're Happy Now (2020#73)
|
||||
Carolina Gaitán,,,,,,We Don't Talk About Bruno (2022#24)
|
||||
Carrie Underwood,,,,,,I'm Gonna Love You (2025#78)|If I Didn't Love You (2021#85)|The Fighter (2017#100)
|
||||
Cash Cobain,,,,,,Somebody Loves Me (2025#85)
|
||||
Casper Mágico,,,,,,Te Bote (2018#81)
|
||||
Chance the Rapper,,,,,,Holy (2021#35)|I'm The One (2017#12)|No Brainer (2018#73)
|
||||
Chappell Roan,,,,,,"Good Luck, Babe! (2024#18/2025#31)|Hot To Go! (2024#53)|Pink Pony Club (2025#10)"
|
||||
Charlie Puth,,,,,,Attention (2017#22)|How Long (2018#65)|I Hope (2020#12/2021#40)|One Call Away (2016#43)|See You Again (2016#99)|We Don't Talk Anymore (2016#50)
|
||||
Chase Rice,,,,,,Drinkin' Beer. Talkin' God. Amen. (2021#99)|Eyes On You (2019#96)
|
||||
Cheat Codes,,,,,,No Promises (2017#91)
|
||||
Chencho Corleone,,,,,,Me Porto Bonito (2022#20)
|
||||
Childish Gambino,,,,,,Redbone (2017#25)|This Is America (2018#51)
|
||||
Chris Brown,,,,,,Back To Sleep (2016#89)|Freaky Friday (2018#55)|Go Crazy (2020#39/2021#19)|No Guidance (2019#21/2020#36)|Residuals (2025#63)|Superhero (Heroes & Villains) (2023#38)|Under The Influence (2023#17)
|
||||
Chris Stapleton,,,,,,Say Something (2018#85)|Starting Over (2021#53)|White Horse (2024#77)|You Should Probably Leave (2022#79)
|
||||
Chris Young,,,,,,Famous Friends (2021#69)
|
||||
City Girls,,,,,,Act Up (2019#68)
|
||||
CJ,,,,,,Whoopty (2021#50)
|
||||
CKay,,,,,,Love Nwantiti (Ah Ah Ah) (2022#55)
|
||||
Clean Bandit,,,,,,Rockabye (2017#44)
|
||||
Cody Johnson,,,,,,'Til You Can't (2022#34)|I'm Gonna Love You (2025#78)|The Painter (2024#98)
|
||||
Coi Leray,,,,,,No More Parties (2021#79)|Players (2023#33)
|
||||
Coldplay,,,,,,Adventure Of A Lifetime (2016#95)|Hymn For The Weekend (2016#73)|Something Just Like This (2017#5)
|
||||
Cole Swindell,,,,,,Never Say Never (2022#92)|She Had Me At Heads Carolina (2022#46)|Single Saturday Night (2021#95)
|
||||
Corey Kent,,,,,,Wild As Her (2023#93)
|
||||
Cris MJ,,,,,,Gata Only (2024#52)
|
||||
D.R.A.M.,,,,,,Broccoli (2016#34/2017#70)
|
||||
DaBaby,,,,,,Baby (2019#84)|Beat Box (2021#44)|BOP (2020#29)|Cash Shit (2019#90)|Cry Baby (2021#77)|For The Night (2020#49/2021#32)|My Oh My (2020#37)|Rockstar (2020#5)|Suge (2019#24)|Whats Poppin (2020#13)
|
||||
Daddy Yankee,,,,,,Con Calma (2019#65)|Despacito (2017#2)|Dura (2018#93)
|
||||
Daft Punk,,,,,,I Feel It Coming (2017#34)|Starboy (2016#58/2017#20)
|
||||
Dan Smyers,,,,,,"10,000 Hours (2020#23)|All To Myself (2019#97)|Glad You Exist (2021#64)|Speechless (2019#35)|Tequila (2018#32/2019#91)"
|
||||
Daniel Caesar,,,,,,Peaches (2021#10)
|
||||
Danny Chung,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Darell,,,,,,Te Bote (2018#81)
|
||||
Dasha,,,,,,Austin (2024#27)
|
||||
David Guetta,,,,,,I'm Good (Blue) (2023#10)
|
||||
David Kushner,,,,,,Daylight (2023#62)
|
||||
Daya,,,,,,"Don't Let Me Down (2016#8)|Hide Away (2016#84)|Sit Still, Look Pretty (2016#78)"
|
||||
dazy,,,,,,Sunroof (2022#29)
|
||||
Dean Lewis,,,,,,Be Alright (2019#54)
|
||||
Demi Lovato,,,,,,No Promises (2017#91)|Sorry Not Sorry (2017#47/2018#64)
|
||||
Desiigner,,,,,,Panda (2016#6)|Tiimmy Turner (2016#98)
|
||||
Diane Guerrero,,,,,,We Don't Talk About Bruno (2022#24)
|
||||
Diplo,,,,,,Close To Me (2019#58)
|
||||
DJ Khaled,,,,,,Every Chance I Get (2021#59)|For Free (2016#59)|I'm The One (2017#12)|No Brainer (2018#73)|Popstar (2020#66)|Wild Thoughts (2017#18)
|
||||
DJ Snake,,,,,,Lean On (2016#85)|Let Me Love You (2016#47/2017#46)|Middle (2016#80)|Taki Taki (2019#57)
|
||||
Djo,,,,,,End Of Beginning (2024#47)
|
||||
DNCE,,,,,,Cake By The Ocean (2016#18)
|
||||
Doechii,,,,,,Anxiety (2025#39)|Denial Is A River (2025#75)|What It Is (Block Boy) (2023#76)
|
||||
Doja Cat,,,,,,34+35 (2021#21)|Agora Hills (2024#17)|Best Friend (2021#29)|Get Into It (Yuh) (2022#68)|I Like You (A Happier Song) (2022#26/2023#75)|Juicy (2020#87)|Kiss Me More (2021#6/2022#90)|Need To Know (2021#46/2022#18)|Paint The Town Red (2023#52/2024#21)|Say So (2020#11)|Streets (2021#67)|Vegas (2022#47)|Woman (2022#21)|You Right (2021#43/2022#45)
|
||||
Don Toliver,,,,,,Lemonade (2021#51)|No Pole (2025#97)
|
||||
Dove Cameron,,,,,,Boyfriend (2022#51)
|
||||
Drake,,,,,,Act II: Date @ 8 (2024#80)|Both (2017#83)|Controlla (2016#41)|Fake Love (2017#37)|First Person Shooter (2024#61)|For Free (2016#59)|God's Plan (2018#1)|Going Bad (2019#18)|Hotline Bling (2016#24)|I'm Upset (2018#86)|IDGAF (2024#83)|In My Feelings (2018#9)|Jimmy Cooks (2022#33)|Jumpman (2016#37)|Knife Talk (2021#86/2022#60)|Laugh Now Cry Later (2020#41/2021#45)|Life Is Good (2020#7)|Look Alive (2018#23)|Meltdown (2023#97)|MIA (2019#44)|Money In The Grave (2019#34)|Nice For What (2018#11)|No Guidance (2019#21/2020#36)|Nokia (2025#24)|Nonstop (2018#52)|One Dance (2016#3)|Passionfruit (2017#65)|Pop Style (2016#82)|Popstar (2020#66)|Rich Baby Daddy (2024#45)|Rich Flex (2023#14)|Search & Rescue (2023#45)|Somebody Loves Me (2025#85)|Spin Bout U (2023#43)|Too Good (2016#29)|Toosie Slide (2020#32)|Wait For U (2022#11/2023#83)|Walk It Talk It (2018#43)|Wants And Needs (2021#47)|Way 2 Sexy (2021#48)|What's Next (2021#68)|Work (2016#4)|Yes Indeed (2018#25)|You're Mines Still (2021#58)
|
||||
Dua Lipa,,,,,,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,,,,,,Arcade (2021#82)
|
||||
Dustin Lynch,,,,,,Small Town Boy (2017#94)
|
||||
Ed Sheeran,,,,,,Bad Habits (2021#15/2022#13)|Bam Bam (2022#66)|Beautiful People (2019#50)|Castle On The Hill (2017#40)|Eyes Closed (2023#64)|I Don't Care (2019#16)|Perfect (2018#2)|Shape Of You (2017#1/2018#71)|Shivers (2022#5)
|
||||
EJAE,,,,,,Free (2025#92)|Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||
Ella Langley,,,,,,Weren't For The Wind (2025#71)
|
||||
Ella Mai,,,,,,Boo'd Up (2018#15)|Trip (2018#92/2019#80)
|
||||
Elle King,,,,,,Ex's & Oh's (2016#63)
|
||||
Ellie Goulding,,,,,,Close To Me (2019#58)|On My Mind (2016#81)
|
||||
Elton John,,,,,,Cold Heart (PNAU Remix) (2022#10)
|
||||
Em Beihold,,,,,,Numb Little Bug (2022#32)
|
||||
Emilee,,,,,,ily (2020#95)
|
||||
Eminem,,,,,,Godzilla (2020#62)|Houdini (2024#39)
|
||||
Encanto Cast,,,,,,We Don't Talk About Bruno (2022#24)
|
||||
Eric Church,,,,,,Does To Me (2020#93)|Hell Of A View (2021#91)
|
||||
ERNEST,,,,,,Cowgirls (2024#29)|Flower Shops (2022#96)
|
||||
Eslabón Armado,,,,,,Ella Baila Sola (2023#26)
|
||||
Fat Joe,,,,,,All The Way Up (2016#87)
|
||||
Fetty Wap,,,,,,679 (2016#54)|All In My Head (Flex) (2016#93)
|
||||
Fifth Harmony,,,,,,All In My Head (Flex) (2016#93)|Work From Home (2016#16)
|
||||
Fifty Fifty,,,,,,Cupid (2023#44)
|
||||
Flipp Dinero,,,,,,Leave Me Alone (2019#71)
|
||||
Flo Milli,,,,,,Never Lose Me (2024#49)
|
||||
Flo Rida,,,,,,My House (2016#14)
|
||||
Florida Georgia Line,,,,,,Drinkin' Beer. Talkin' God. Amen. (2021#99)|H.O.L.Y. (2016#49)|Lil Bit (2021#70)|Meant To Be (2018#3)|Simple (2018#82)|Talk You Out Of It (2019#94)
|
||||
FloyyMenor,,,,,,Gata Only (2024#52)
|
||||
Flume,,,,,,Never Be Like You (2016#60)
|
||||
Frank Ocean,,,,,,Slide (2017#71)
|
||||
French Montana,,,,,,All The Way Up (2016#87)|Unforgettable (2017#15)
|
||||
Fuerza Regida,,,,,,Bebe Dame (2023#73)|Tu Boda (2025#87)
|
||||
Future,,,,,,Cold (2017#69)|Jumpman (2016#37)|King's Dead (2018#79)|Life Is Good (2020#7)|Like That (2024#14)|Low Life (2016#30)|Mask Off (2017#14)|Puffin On Zootiez (2022#84)|Pushin P (2022#48)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)|Wait For U (2022#11/2023#83)|Way 2 Sexy (2021#48)|Wicked (2016#97)
|
||||
G-Eazy,,,,,,"Him & I (2018#45)|Me, Myself & I (2016#19)|No Limit (2018#30)"
|
||||
Gabby Barrett,,,,,,I Hope (2020#12/2021#40)|The Good Ones (2021#62)
|
||||
GAYLE,,,,,,abcdefu (2022#17)
|
||||
Gigi Perez,,,,,,Sailor Song (2025#36)
|
||||
Giveon,,,,,,Heartbreak Anniversary (2021#31)|Peaches (2021#10)
|
||||
Glass Animals,,,,,,Heat Waves (2021#16/2022#1)
|
||||
GloRilla,,,,,,Sticky (2025#41)|Tomorrow 2 (2023#70)|Wanna Be (2024#40)|Whatchu Kno About Me (2025#48)|Yeah Glo! (2024#44)
|
||||
gnash,,,,,,I Hate U I Love U (2016#38)|Lights Down Low (2018#66)
|
||||
Gracie Abrams,,,,,,That's So True (2025#14)
|
||||
Grey,,,,,,Starving (2016#94/2017#82)|The Middle (2018#8)
|
||||
Grupo Frontera,,,,,,Bebe Dame (2023#73)|Un x100to (2023#49)
|
||||
Gucci Mane,,,,,,Black Beatles (2017#19)|Both (2017#83)|I Get The Bag (2017#93/2018#72)|Slippery (2017#86)|Wake Up In The Sky (2019#51)
|
||||
Gunna,,,,,,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,,,,,,Supalonely (2020#84)
|
||||
Gwen Stefani,,,,,,Nobody But You (2020#52)
|
||||
H.E.R.,,,,,,Slide (2020#99)
|
||||
Hailee Steinfeld,,,,,,Starving (2016#94/2017#82)
|
||||
Halsey,,,,,,Bad At Love (2018#27)|Closer (2016#10/2017#7)|Eastside (2018#77/2019#17)|Him & I (2018#45)|Now Or Never (2017#59)|Without Me (2019#3)|You Should Be Sad (2020#82)
|
||||
HARDY,,,,,,Truck Bed (2024#67)|Wait In The Truck (2023#54)
|
||||
Harry Styles,,,,,,Adore You (2020#6)|As It Was (2022#2/2023#15)|Late Night Talking (2022#25)|Sign Of The Times (2017#87)|Watermelon Sugar (2020#20)
|
||||
Hozier,,,,,,Too Sweet (2024#10/2025#34)
|
||||
Hudson Westbrook,,,,,,House Again (2025#95)
|
||||
iann dior,,,,,,Mood (2020#47/2021#4)
|
||||
Ice Spice,,,,,,"Barbie World (2023#46)|Boy's A Liar, Pt. 2 (2023#20)|Karma (2023#27)|Princess Diana (2023#69)"
|
||||
Imagine Dragons,,,,,,Believer (2017#9/2018#100)|Enemy (2022#15)|Natural (2018#69)|Sucker For Pain (2016#68)|Thunder (2017#51/2018#22)|Whatever It Takes (2018#37)
|
||||
Infared,,,,,,All The Way Up (2016#87)
|
||||
Internet Money,,,,,,Lemonade (2021#51)
|
||||
J Balvin,,,,,,I Like It (2018#7/2019#69)|Mi Gente (2017#50/2018#99)|RITMO (Bad Boys For Life) (2020#50)|X (2018#90)
|
||||
J. Cole,,,,,,All My Life (2023#25)|First Person Shooter (2024#61)|Middle Child (2019#25)|The London (2019#64)
|
||||
Jack Harlow,,,,,,First Class (2022#6)|Industry Baby (2021#24/2022#16)|Lovin On Me (2024#5)|Whats Poppin (2020#13)
|
||||
Jake Scott,,,,,,She Likes It (2022#91)
|
||||
James Arthur,,,,,,Say You Won't Let Go (2017#11)
|
||||
James Bay,,,,,,Let It Go (2016#44)
|
||||
James Blake,,,,,,King's Dead (2018#79)
|
||||
Jason Aldean,,,,,,Got What I Got (2020#60)|If I Didn't Love You (2021#85)|Trouble With A Heartbreak (2022#88)|Try That In A Small Town (2023#66)|You Make It Easy (2018#70)
|
||||
Jason Derulo,,,,,,Savage Love (Laxed - Siren Beat) (2020#35)|Swalla (2017#85)
|
||||
Jawsh 685,,,,,,Savage Love (Laxed - Siren Beat) (2020#35)
|
||||
Jay Rock,,,,,,King's Dead (2018#79)
|
||||
Jay Z,,,,,,All The Way Up (2016#87)|Pop Style (2016#82)
|
||||
Jelly Roll,,,,,,Hard Fought Hallelujah (2025#84)|I Am Not Okay (2024#75/2025#79)|Liar (2025#86)|Need A Favor (2023#34/2024#64)|Save Me (2024#65)|Wild Ones (2024#36)
|
||||
Jeremih,,,,,,Oui (2016#55)
|
||||
Jessica Darrow,,,,,,Surface Pressure (2022#53)
|
||||
Jessie Murph,,,,,,Blue Strips (2025#52)|High Road (2024#81/2025#55)|Wild Ones (2024#36)
|
||||
Jhay Cortez,,,,,,Dakiti (2021#28)
|
||||
Jhené Aiko,,,,,,P*$$y Fairy (OTW) (2020#94)
|
||||
JID,,,,,,Enemy (2022#15)
|
||||
Jimin,,,,,,Who (2025#57)
|
||||
JNR CHOI,,,,,,To The Moon! (2022#97)
|
||||
John Legend,,,,,,Like I'm Gonna Lose You (2016#42)
|
||||
Joji,,,,,,Glimpse Of Us (2022#52)
|
||||
Jon Bellion,,,,,,All Time Low (2017#75)
|
||||
Jon Pardi,,,,,,Last Night Lonely (2022#95)
|
||||
Jonas Brothers,,,,,,Leave Before You Love Me (2021#63)|Only Human (2019#78/2020#77)|Sucker (2019#10)
|
||||
Jordan Davis,,,,,,Buy Dirt (2022#49)|Next Thing You Know (2023#47)|What My World Spins Around (2023#91)
|
||||
JP Saxe,,,,,,If The World Was Ending (2020#56)
|
||||
Juice WRLD,,,,,,Bandit (2020#63)|Come & Go (2020#54)|Godzilla (2020#62)|Lucid Dreams (2018#12/2019#48)|Robbery (2019#100)|Suicidal (2020#75)|Wishing Well (2020#92)
|
||||
Juicy J,,,,,,Powerglide (2018#97)
|
||||
Julia Michaels,,,,,,If The World Was Ending (2020#56)|Issues (2017#29)
|
||||
Jung Kook,,,,,,Seven (2023#82)
|
||||
Justin Bieber,,,,,,"10,000 Hours (2020#23)|Anyone (2021#74)|Cold Water (2016#25)|Daisies (2025#60)|Despacito (2017#2)|Essence (2021#60/2022#64)|Ghost (2022#8)|Holy (2021#35)|I Don't Care (2019#16)|I'm The One (2017#12)|Intentions (2020#17)|Let Me Love You (2016#47/2017#46)|Lonely (2021#65)|Love Yourself (2016#1)|No Brainer (2018#73)|Peaches (2021#10)|Sorry (2016#2)|Stay (2021#12/2022#3)|Stuck With U (2020#80)|What Do You Mean? (2016#31)|Yummy (2020#58)"
|
||||
Justin Timberlake,,,,,,Can't Stop The Feeling! (2016#9/2017#49)|Say Something (2018#85)
|
||||
JVKE,,,,,,Golden Hour (2023#41)
|
||||
Kacey Musgraves,,,,,,I Remember Everything (2023#74/2024#9)
|
||||
Kai,,,,,,Never Be Like You (2016#60)
|
||||
Kali Uchis,,,,,,Telepatia (2021#49)
|
||||
Kaliii,,,,,,Area Codes (2023#88)
|
||||
Kane Brown,,,,,,Be Like That (2020#88)|Bury Me In Georgia (2023#89)|Famous Friends (2021#69)|Good As You (2019#86)|Heaven (2018#53)|Homesick (2020#89)|Like I Love Country Music (2022#85)|Miles On It (2024#34)|One Mississippi (2022#99)|One Thing Right (2019#89)|Thank God (2023#23)|What Ifs (2017#72)
|
||||
Kanye West,,,,,,Carnival (2024#38)|Pop Style (2016#82)
|
||||
Karol G,,,,,,MAMIII (2022#59)|Provenza (2022#63)|TQG (2023#65)
|
||||
Kate Bush,,,,,,Running Up That Hill (A Deal With God) (2022#23)
|
||||
Katelyn Brown,,,,,,Thank God (2023#23)
|
||||
Katy Perry,,,,,,Chained To The Rhythm (2017#73)|Con Calma (2019#65)|Feels (2017#74)
|
||||
Kehlani,,,,,,Folded (2025#98)
|
||||
Keith Urban,,,,,,One Too Many (2021#81)|The Fighter (2017#100)
|
||||
Kendrick Lamar,,,,,,30 For 30 (2025#21)|All The Stars (2018#47)|DNA. (2017#62)|Don't Wanna Know (2017#38)|Euphoria (2024#66)|Humble. (2017#4)|King's Dead (2018#79)|Like That (2024#14)|Love. (2018#50)|Loyalty. (2017#66)|Luther (2025#2)|Not Like Us (2024#6/2025#17)|Peekaboo (2025#53)|Pray For Me (2018#40)|Squabble Up (2025#20)|TV Off (2025#15)
|
||||
Kenny Chesney,,,,,,Get Along (2018#87)
|
||||
Kent Jones,,,,,,Don't Mind (2016#48)
|
||||
Kesha,,,,,,Praying (2017#67)
|
||||
Kevin Gates,,,,,,2 Phones (2016#57)|Really Really (2016#92)
|
||||
Kevin Woo,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Khalid,,,,,,1-800-273-8255 (2017#31/2018#84)|Be Like That (2020#88)|Beautiful People (2019#50)|Better (2019#20)|Eastside (2018#77/2019#17)|Location (2017#21)|Love Lies (2018#19/2019#88)|Talk (2019#8)|Young Dumb & Broke (2017#78/2018#67)
|
||||
Kid Cudi,,,,,,The Scotts (2020#86)
|
||||
Kiiara,,,,,,Gold (2016#52)
|
||||
Kim Petras,,,,,,Unholy (2022#98/2023#11)
|
||||
Kodak Black,,,,,,Drowning (2017#81)|Super Gremlin (2022#9)|Tunnel Vision (2017#55)|Wake Up In The Sky (2019#51)|What It Is (Block Boy) (2023#76)|ZEZE (2019#31)
|
||||
Koe Wetzel,,,,,,High Road (2024#81/2025#55)
|
||||
Kygo,,,,,,It Ain't Me (2017#27)
|
||||
Kyla,,,,,,One Dance (2016#3)
|
||||
KYLE,,,,,,iSpy (2017#28)
|
||||
Lady Gaga,,,,,,Abracadabra (2025#56)|Bloody Mary (2023#99)|Die With A Smile (2024#62/2025#1)|Rain On Me (2020#48)|Shallow (2019#19)
|
||||
Lainey Wilson,,,,,,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,,,,,,Big Energy (2022#7)|Put It On Da Floor Again (2023#98)|Seven (2023#82)
|
||||
Lauren Alaina,,,,,,What Ifs (2017#72)
|
||||
Lauren Daigle,,,,,,You Say (2019#60)
|
||||
Lauren Spencer-Smith,,,,,,Fingers Crossed (2022#75)
|
||||
Lauv,,,,,,I Like Me Better (2018#35)
|
||||
Lee Brice,,,,,,I Hope You're Happy Now (2020#73)|One Of Them Girls (2020#68)|Rumor (2019#81)
|
||||
Lefty Gunplay,,,,,,TV Off (2025#15)
|
||||
Leon Thomas,,,,,,Mutt (2025#22)
|
||||
Lewis Capaldi,,,,,,Before You Go (2020#21)|Someone You Loved (2019#27/2020#10)
|
||||
Liam Payne,,,,,,Strip That Down (2017#36)
|
||||
Lil Baby,,,,,,Baby (2019#84)|Close Friends (2019#74)|Drip Too Hard (2019#26)|Emotionally Scarred (2020#74)|Every Chance I Get (2021#59)|For The Night (2020#49/2021#32)|In A Minute (2022#43)|On Me (2021#36)|Sum 2 Prove (2020#79)|The Bigger Picture (2020#76)|Wants And Needs (2021#47)|We Paid (2020#57)|Woah (2020#42)|Yes Indeed (2018#25)
|
||||
Lil Dicky,,,,,,Freaky Friday (2018#55)
|
||||
Lil Durk,,,,,,All My Life (2023#25)|Back In Blood (2021#39)|Broadway Girls (2022#81)|Every Chance I Get (2021#59)|Laugh Now Cry Later (2020#41/2021#45)|No More Parties (2021#79)|What Happened To Virgil (2022#83)
|
||||
Lil Mosey,,,,,,Blueberry Faygo (2020#27)
|
||||
Lil Nas X,,,,,,Industry Baby (2021#24/2022#16)|Montero (Call Me By Your Name) (2021#9)|Old Town Road (2019#1)|Panini (2019#40/2020#71)|Thats What I Want (2022#14)
|
||||
Lil Pump,,,,,,Gucci Gang (2018#44)
|
||||
Lil Tecca,,,,,,Dark Thoughts (2025#96)|Ran$om (2019#28)
|
||||
Lil Tjay,,,,,,Calling My Phone (2021#33)|Mood Swings (2020#81)|Pop Out (2019#45)
|
||||
Lil Uzi Vert,,,,,,Bad And Boujee (2017#6)|Everybody (2024#71)|Just Wanna Rock (2023#28)|XO TOUR Llif3 (2017#13)
|
||||
Lil Wayne,,,,,,I'm The One (2017#12)|Sticky (2025#41)|Sucker For Pain (2016#68)|Whats Poppin (2020#13)
|
||||
Lil Yachty,,,,,,Broccoli (2016#34/2017#70)|iSpy (2017#28)
|
||||
Lizzo,,,,,,About Damn Time (2022#12)|Good As Hell (2020#31)|Truth Hurts (2019#13/2020#55)
|
||||
Logic,,,,,,1-800-273-8255 (2017#31/2018#84)|Sucker For Pain (2016#68)
|
||||
Lola Young,,,,,,Like Him (2025#62)|Messy (2025#28)
|
||||
Luis Fonsi,,,,,,Despacito (2017#2)
|
||||
Lukas Graham,,,,,,7 Years (2016#12)
|
||||
Luke Bryan,,,,,,Buy Dirt (2022#49)|Knockin' Boots (2019#79)|One Margarita (2020#70)
|
||||
Luke Combs,,,,,,"Ain't No Love In Oklahoma (2024#37)|Backup Plan (2025#80)|Beautiful Crazy (2019#46)|Beer Never Broke My Heart (2019#63)|Better Together (2021#57)|Does To Me (2020#93)|Doin' This (2022#71)|Even Though I'm Leaving (2020#85)|Fast Car (2023#8/2024#23)|Forever After All (2021#18)|Going, Going, Gone (2023#59)|Hurricane (2017#76)|Love You Anyway (2023#36)|Lovin' On You (2020#97)|One Number Away (2018#96)|The Kind Of Love We Make (2022#36)|Where The Wild Things Are (2024#70)"
|
||||
Machine Gun Kelly,,,,,,Bad Things (2017#41)|My Ex's Best Friend (2021#23)
|
||||
Maddie,,,,,,Die From A Broken Heart (2020#65)
|
||||
Mae Muller,,,,,,Better Days (2022#73)
|
||||
Major Lazer,,,,,,Cold Water (2016#25)|Lean On (2016#85)
|
||||
Maren Morris,,,,,,Chasing After You (2021#61)|Circles Around This Town (2022#100)|The Bones (2020#9)|The Middle (2018#8)
|
||||
Mariah Carey,,,,,,All I Want For Christmas Is You (2020#67/2021#78/2022#65/2023#55/2024#54/2025#59)
|
||||
Mariah the Scientist,,,,,,Burning Blue (2025#58)
|
||||
Marian Hill,,,,,,Down (2017#90)
|
||||
Maroon 5,,,,,,Beautiful Mistakes (2021#34)|Cold (2017#69)|Don't Wanna Know (2017#38)|Girls Like You (2018#10/2019#22)|Memories (2020#8)|Wait (2018#58)|What Lovers Do (2017#97/2018#89)
|
||||
Marshmello,,,,,,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,,,,,,Astronaut In The Ocean (2021#20)
|
||||
Mauro Castillo,,,,,,We Don't Talk About Bruno (2022#24)
|
||||
MAX,,,,,,Lights Down Low (2018#66)
|
||||
Meek Mill,,,,,,Going Bad (2019#18)
|
||||
Megan Moroney,,,,,,Am I Okay? (2025#66)|Tennessee Orange (2023#67)
|
||||
Megan Thee Stallion,,,,,,34+35 (2021#21)|Beautiful Mistakes (2021#34)|Body (2021#54)|Cash Shit (2019#90)|Cry Baby (2021#77)|Hot Girl Summer (2019#93)|Savage (2020#15)|Sweetest Pie (2022#62)|Thot Shit (2021#71)|Wanna Be (2024#40)|WAP (2020#24)
|
||||
Meghan Trainor,,,,,,Like I'm Gonna Lose You (2016#42)|Made You Look (2023#53)|Me Too (2016#62)|No (2016#45)
|
||||
Metro Boomin,,,,,,Creepin' (2023#5)|Like That (2024#14)|Ric Flair Drip (2018#38)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)
|
||||
Migos,,,,,,Bad And Boujee (2017#6)|I Get The Bag (2017#93/2018#72)|MotorSport (2018#34)|Pure Water (2019#55)|Slide (2017#71)|Slippery (2017#86)|Stir Fry (2018#48)|T-Shirt (2017#52)|Walk It Talk It (2018#43)
|
||||
Miguel,,,,,,Sky Walker (2018#75)|Sure Thing (2023#24)
|
||||
Mike Posner,,,,,,I Took A Pill In Ibiza (2016#15)
|
||||
Miley Cyrus,,,,,,Flowers (2023#2/2024#69)|Malibu (2017#89)
|
||||
Miranda Lambert,,,,,,Bluebird (2020#91)
|
||||
MNEK,,,,,,Never Forget You (2016#46)
|
||||
Moneybagg Yo,,,,,,Said Sum (2020#98)|Time Today (2021#76)|Wockesha (2021#42)
|
||||
Mooski,,,,,,Track Star (2021#75)
|
||||
Morgan Wallen,,,,,,20 Cigarettes (2025#90)|Ain't That Some (2023#92)|Broadway Girls (2022#81)|Chasin' You (2020#34)|Cowgirls (2024#29)|Everything I Love (2023#56)|Flower Shops (2022#96)|I Ain't Coming Back (2025#72)|I Got Better (2025#40)|I Had Some Help (2024#4/2025#8)|I Wrote The Book (2023#95)|I'm The Problem (2025#13)|Just In Case (2025#18)|Last Night (2023#1/2024#28)|Lies Lies Lies (2024#50)|Love Somebody (2025#11)|More Than My Hometown (2020#96)|One Thing At A Time (2023#37)|Sand In My Boots (2022#58)|Smile (2025#64)|Superman (2025#99)|Thinkin' Bout Me (2023#19/2024#31)|Thought You Should Know (2023#22)|Wasted On You (2022#19)|What I Want (2025#27)|Whiskey Glasses (2019#52)|You Proof (2022#27/2023#12)
|
||||
Muni Long,,,,,,Hrs And Hrs (2022#57)|Made For Me (2024#56)
|
||||
Murda Beatz,,,,,,FEFE (2018#31)
|
||||
Mustard,,,,,,Ballin' (2020#25)|High Fashion (2020#40)|Pure Water (2019#55)
|
||||
Myles Smith,,,,,,Stargazing (2024#73/2025#29)
|
||||
Måneskin,,,,,,Beggin' (2021#66)
|
||||
MØ,,,,,,Cold Water (2016#25)|Lean On (2016#85)
|
||||
N*E*R*D,,,,,,Lemon (2018#83)
|
||||
Natanael Cano,,,,,,PRC (2023#90)
|
||||
Nate Smith,,,,,,Bulletproof (2024#96)|World On Fire (2024#90)
|
||||
NAV,,,,,,Lemonade (2021#51)
|
||||
Neckwav,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
NEIKED,,,,,,Better Days (2022#73)
|
||||
Nelly,,,,,,Lil Bit (2021#70)
|
||||
Netón Vega,,,,,,Loco (2025#100)
|
||||
NF,,,,,,Let You Down (2018#29)
|
||||
Niall Horan,,,,,,Slow Hands (2017#32)
|
||||
Nick Jonas,,,,,,Close (2016#66)
|
||||
Nicki Minaj,,,,,,Barbie World (2023#46)|Big Bank (2018#63)|Down In The DM (2016#69)|Everybody (2024#71)|FEFE (2018#31)|FTCU (2024#88)|Hot Girl Summer (2019#93)|MotorSport (2018#34)|Plain Jane (2018#74)|Princess Diana (2023#69)|Rake It Up (2017#53)|Side To Side (2016#77/2017#43)|Super Freaky Girl (2022#56/2023#79)|Swalla (2017#85)
|
||||
Nicky Jam,,,,,,Te Bote (2018#81)|X (2018#90)
|
||||
Nicky Youre,,,,,,Sunroof (2022#29)
|
||||
Niko Moon,,,,,,Good Time (2021#84)
|
||||
Nio García,,,,,,Te Bote (2018#81)
|
||||
NLE Choppa,,,,,,Shotta Flow (2019#92)|Walk Em Down (2020#100)
|
||||
Noah Kahan,,,,,,Dial Drunk (2023#80)|Stick Season (2024#11)
|
||||
Normani,,,,,,Dancing With A Stranger (2019#14)|Love Lies (2018#19/2019#88)
|
||||
O.T. Genasis,,,,,,Cut It (2016#91)
|
||||
Offset,,,,,,Clout (2019#87)|Ric Flair Drip (2018#38)|Taste (2018#28)|ZEZE (2019#31)
|
||||
Old Dominion,,,,,,Memory Lane (2023#87)|One Man Band (2020#59)
|
||||
Oliver Anthony Music,,,,,,Rich Men North Of Richmond (2023#78)
|
||||
Olivia O'Brien,,,,,,I Hate U I Love U (2016#38)
|
||||
Olivia Rodrigo,,,,,,Deja Vu (2021#13)|Drivers License (2021#8)|Good 4 U (2021#5/2022#31)|Traitor (2021#38)|Vampire (2023#30/2024#57)
|
||||
One Direction,,,,,,Perfect (2016#100)
|
||||
OneRepublic,,,,,,I Ain't Worried (2022#37)
|
||||
Ozuna,,,,,,Taki Taki (2019#57)|Te Bote (2018#81)
|
||||
P!nk,,,,,,Just Like Fire (2016#33)|One Too Many (2021#81)|Walk Me Home (2019#99)|What About Us (2017#84)
|
||||
Panic! At The Disco,,,,,,"Hey Look Ma, I Made It (2019#61)|High Hopes (2019#11)"
|
||||
Parker McCollum,,,,,,Burn It Down (2024#84)|Handle On You (2023#85)
|
||||
Parmalee,,,,,,Just The Way (2021#88)|Take My Name (2022#82)
|
||||
PARTYNEXTDOOR,,,,,,Somebody Loves Me (2025#85)
|
||||
Paul Russell,,,,,,Lil Boo Thang (2024#78)
|
||||
Peso Pluma,,,,,,Ella Baila Sola (2023#26)|La Bebe (2023#40)|Por Las Noches (2023#86)|PRC (2023#90)
|
||||
Pharrell Williams,,,,,,Feels (2017#74)
|
||||
Pinkfong,,,,,,Baby Shark (2019#75)
|
||||
PinkPantheress,,,,,,"Boy's A Liar, Pt. 2 (2023#20)"
|
||||
Playboi Carti,,,,,,Carnival (2024#38)|FE!N (2024#97)|Magnolia (2017#79)|Rather Lie (2025#91)|Timeless (2025#16)|Type Shit (2024#51)
|
||||
PnB Rock,,,,,,Everyday We Lit (2017#95)
|
||||
Polo G,,,,,,Better Days (2022#73)|Pop Out (2019#45)|Rapstar (2021#30)
|
||||
Pooh Shiesty,,,,,,Back In Blood (2021#39)|Beat Box (2021#44)
|
||||
Pop Smoke,,,,,,Dior (2020#83)|For The Night (2020#49/2021#32)|Mood Swings (2020#81)|The Woo (2020#78)|What You Know Bout Love (2021#22)
|
||||
Portugal. The Man,,,,,,Feel It Still (2017#45/2018#33)
|
||||
Post Malone,,,,,,Better Now (2018#13/2019#32)|Chemical (2023#57)|Circles (2019#62/2020#2)|Congratulations (2017#10)|Dial Drunk (2023#80)|Fortnight (2024#22)|Goodbyes (2019#30)|I Ain't Coming Back (2025#72)|I Fall Apart (2018#39)|I Had Some Help (2024#4/2025#8)|I Like You (A Happier Song) (2022#26/2023#75)|One Right Now (2022#30)|Pour Me A Drink (2024#76)|Psycho (2018#6)|Rockstar (2017#56/2018#5)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|White Iverson (2016#65)|Wow. (2019#5)
|
||||
Powfu,,,,,,Death Bed (2020#43)
|
||||
Project Pat,,,,,,Knife Talk (2021#86/2022#60)
|
||||
Quavo,,,,,,Congratulations (2017#10)|I'm The One (2017#12)|Intentions (2020#17)|No Brainer (2018#73)|Strip That Down (2017#36)
|
||||
Rae Sremmurd,,,,,,Black Beatles (2017#19)|Powerglide (2018#97)|Swang (2017#64)
|
||||
Rauw Alejandro,,,,,,Party (2022#77)|Todo de Ti (2021#100)
|
||||
Ravyn Lenae,,,,,,Love Me Not (2025#30)
|
||||
RAYE,,,,,,Escapism (2023#48)
|
||||
REI AMI,,,,,,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||
Rema,,,,,,Calm Down (2023#6)
|
||||
Remy Boyz,,,,,,679 (2016#54)
|
||||
Remy Ma,,,,,,All The Way Up (2016#87)
|
||||
Rhenzy Feliz,,,,,,We Don't Talk About Bruno (2022#24)
|
||||
Rich The Kid,,,,,,Carnival (2024#38)|Plug Walk (2018#57)
|
||||
Rick Ross,,,,,,Money In The Grave (2019#34)
|
||||
Rihanna,,,,,,Lemon (2018#83)|Lift Me Up (2023#63)|Love On The Brain (2017#33)|Loyalty. (2017#66)|Needed Me (2016#13)|This Is What You Came For (2016#17)|Too Good (2016#29)|Wild Thoughts (2017#18)|Work (2016#4)
|
||||
Riley Green,,,,,,Worst Way (2025#35)
|
||||
Ritt Momney,,,,,,Put Your Records On (2021#93)
|
||||
Rod Wave,,,,,,Heart On Ice (2020#51)|Rags2Riches (2020#90)|Tombstone (2021#98)
|
||||
Roddy Ricch,,,,,,Ballin' (2020#25)|High Fashion (2020#40)|Late At Night (2021#72)|Rockstar (2020#5)|The Box (2020#3)|The Woo (2020#78)|Walk Em Down (2020#100)
|
||||
ROSÉ,,,,,,APT. (2025#9)
|
||||
Rozes,,,,,,Roses (2016#27)
|
||||
Russell Dickerson,,,,,,Happen To Me (2025#69)|She Likes It (2022#91)
|
||||
Ruth B,,,,,,Lost Boy (2016#76)
|
||||
Ryan Hurd,,,,,,Chasing After You (2021#61)
|
||||
Sabrina Carpenter,,,,,,Bed Chem (2025#45)|Espresso (2024#7/2025#12)|Feather (2024#25)|Manchild (2025#38)|Please Please Please (2024#16/2025#50)|Taste (2024#86/2025#19)
|
||||
SAINt JHN,,,,,,Roses (2020#19)
|
||||
Sam Barber,,,,,,Indigo (2025#49)
|
||||
Sam Hunt,,,,,,Body Like A Back Road (2017#8)|Hard To Forget (2020#69)
|
||||
Sam Smith,,,,,,Dancing With A Stranger (2019#14)|How Do You Sleep? (2019#83)|Too Good At Goodbyes (2017#77/2018#49)|Unholy (2022#98/2023#11)
|
||||
Sam Tompkins,,,,,,To The Moon! (2022#97)
|
||||
samUIL Lee,,,,,,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Saweetie,,,,,,Best Friend (2021#29)|My Type (2019#76)
|
||||
Scotty McCreery,,,,,,Damn Strait (2022#93)
|
||||
Sean Paul,,,,,,Cheap Thrills (2016#11)|Rockabye (2017#44)
|
||||
Selena Gomez,,,,,,Back To You (2018#41)|Calm Down (2023#6)|Hands To Myself (2016#56)|It Ain't Me (2017#27)|Lose You To Love Me (2020#30)|Same Old Love (2016#40)|Taki Taki (2019#57)|We Don't Talk Anymore (2016#50)|Wolves (2018#60)
|
||||
Sexyy Red,,,,,,Get It Sexyy (2024#55)|Rich Baby Daddy (2024#45)|Sticky (2025#41)|Whatchu Kno About Me (2025#48)
|
||||
Shaboozey,,,,,,A Bar Song (Tipsy) (2024#2/2025#3)|Good News (2025#23)
|
||||
SHAED,,,,,,Trampoline (2019#70/2020#53)
|
||||
Shakira,,,,,,"Bzrp Music Sessions, Vol. 53 (2023#96)|TQG (2023#65)"
|
||||
Shawn Mendes,,,,,,I Know What You Did Last Summer (2016#86)|If I Can't Have You (2019#29)|In My Blood (2018#46)|Mercy (2017#54)|Senorita (2019#15/2020#44)|Stitches (2016#23)|There's Nothing Holdin' Me Back (2017#23)|Treat You Better (2016#28/2017#92)
|
||||
Shay Mooney,,,,,,"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,,,,,,Mo Bamba (2019#49)
|
||||
Sia,,,,,,Cheap Thrills (2016#11)
|
||||
Silentó,,,,,,Watch Me (2016#88)
|
||||
Skip Marley,,,,,,Chained To The Rhythm (2017#73)
|
||||
Snow,,,,,,Con Calma (2019#65)
|
||||
Social House,,,,,,Boyfriend (2019#98)
|
||||
sombr,,,,,,Back To Friends (2025#44)|Undressed (2025#42)
|
||||
SpotemGottem,,,,,,Beat Box (2021#44)
|
||||
StaySolidRocky,,,,,,Party Girl (2020#64)
|
||||
Stephanie Beatriz,,,,,,We Don't Talk About Bruno (2022#24)
|
||||
Stephen Sanchez,,,,,,Until I Found You (2023#50)
|
||||
Steve Lacy,,,,,,Bad Habit (2022#28/2023#39)
|
||||
Summer Walker,,,,,,Good Good (2024#79)|Heart Of A Woman (2025#94)
|
||||
Surf Mesa,,,,,,ily (2020#95)
|
||||
Surfaces,,,,,,Sunday Best (2020#61)
|
||||
Swae Lee,,,,,,Be Like That (2020#88)|Close To Me (2019#58)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|Unforgettable (2017#15)
|
||||
SZA,,,,,,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,,,,,,Die From A Broken Heart (2020#65)
|
||||
Tate McRae,,,,,,Exes (2024#93)|Greedy (2024#13)|Revolving Door (2025#82)|She's All I Wanna Be (2022#94)|Sports Car (2025#46)|What I Want (2025#27)|You Broke Me First. (2021#37)
|
||||
Taylor Swift,,,,,,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,,,,,,Bad Dreams (2025#74)|Lose Control (2024#1/2025#4)
|
||||
Tems,,,,,,Essence (2021#60/2022#64)|Wait For U (2022#11/2023#83)
|
||||
Teo,,,,,,Rolex (2017#61)
|
||||
The Chainsmokers,,,,,,Closer (2016#10/2017#7)|Don't Let Me Down (2016#8)|Paris (2017#42)|Roses (2016#27)|Something Just Like This (2017#5)
|
||||
The Kid LAROI,,,,,,Stay (2021#12/2022#3)|Without You (2021#17)
|
||||
The Marías,,,,,,No One Noticed (2025#32)
|
||||
The Weeknd,,,,,,Blinding Lights (2020#1/2021#3)|Call Out My Name (2018#78)|Can't Feel My Face (2016#72)|Creepin' (2023#5)|Cry For Me (2025#73)|Die For You (2023#7)|Heartless (2020#28)|I Feel It Coming (2017#34)|In The Night (2016#61)|Low Life (2016#30)|One Right Now (2022#30)|Pray For Me (2018#40)|Rather Lie (2025#91)|Save Your Tears (2021#2/2022#40)|Starboy (2016#58/2017#20)|Take My Breath (2021#89)|The Hills (2016#32)|Timeless (2025#16)|You Right (2021#43/2022#45)
|
||||
Thomas Rhett,,,,,,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,,,,,,Humble And Kind (2016#96)
|
||||
Tommy Richman,,,,,,Million Dollar Baby (2024#8)
|
||||
Tones And I,,,,,,Dance Monkey (2020#14)
|
||||
Toosii,,,,,,Favorite Song (2023#21)
|
||||
Tory Lanez,,,,,,Luv (2016#70)|Say It (2016#74)|Whats Poppin (2020#13)
|
||||
Tove Lo,,,,,,Close (2016#66)
|
||||
Travis Scott,,,,,,Antidote (2016#75)|FE!N (2024#97)|Goosebumps (2017#68)|HIGHEST IN THE ROOM (2020#45)|Love Galore (2017#80)|Meltdown (2023#97)|Sicko Mode (2018#42/2019#9)|Sky Walker (2018#75)|The London (2019#64)|The Scotts (2020#86)|Type Shit (2024#51)|ZEZE (2019#31)
|
||||
Trevor Daniel,,,,,,Falling (2020#22)
|
||||
Tucker Wetmore,,,,,,Wind Up Missin' You (2024#95)
|
||||
twenty one pilots,,,,,,Heathens (2016#21/2017#58)|Ride (2016#20)|Stressed Out (2016#5)
|
||||
Ty Dolla $ign,,,,,,Carnival (2024#38)|Hot Girl Summer (2019#93)|Psycho (2018#6)|Sucker For Pain (2016#68)|Swalla (2017#85)|Work From Home (2016#16)
|
||||
Tyga,,,,,,Juicy (2020#87)|Taste (2018#28)
|
||||
Tyla,,,,,,Water (2024#24)
|
||||
Tyler Cole,,,,,,Meet Me At Our Spot (2022#74)
|
||||
Tyler Hubbard,,,,,,5 Foot 9 (2022#67)|Dancin' In The Country (2023#61)
|
||||
"Tyler, The Creator",,,,,,Like Him (2025#62)|Sticky (2025#41)
|
||||
Usher,,,,,,Good Good (2024#79)|No Limit (2016#90)
|
||||
Victoria Monét,,,,,,On My Mama (2024#91)
|
||||
Walker Hayes,,,,,,AA (2022#61)|Fancy Like (2021#27/2022#35)
|
||||
Warren Zeiders,,,,,,Pretty Little Poison (2024#60)
|
||||
Wham!,,,,,,Last Christmas (2023#84/2024#74/2025#65)
|
||||
WILLOW,,,,,,Meet Me At Our Spot (2022#74)
|
||||
Willy William,,,,,,Mi Gente (2017#50/2018#99)
|
||||
Wiz Khalifa,,,,,,See You Again (2016#99)|Sucker For Pain (2016#68)
|
||||
Wizkid,,,,,,Essence (2021#60/2022#64)|One Dance (2016#3)
|
||||
X Ambassadors,,,,,,Sucker For Pain (2016#68)|Unsteady (2016#67)
|
||||
Xavi,,,,,,La Diabla (2024#72)
|
||||
XXXTENTACION,,,,,,Changes (2018#94)|Look At Me! (2017#99)|Moonlight (2018#88)|Sad! (2018#17)
|
||||
Yeat,,,,,,IDGAF (2024#83)
|
||||
YFN Lucci,,,,,,Everyday We Lit (2017#95)
|
||||
YG,,,,,,Big Bank (2018#63)|Slide (2020#99)
|
||||
YK Osiris,,,,,,Worth It (2019#77)
|
||||
Yng Lvcas,,,,,,La Bebe (2023#40)
|
||||
YNW Melly,,,,,,Murder On My Mind (2019#66)|Suicidal (2020#75)
|
||||
Yo Gotti,,,,,,Down In The DM (2016#69)|Rake It Up (2017#53)
|
||||
Young Dolph,,,,,,Cut It (2016#91)
|
||||
Young Nudy,,,,,,Peaches & Eggplants (2023#94)
|
||||
Young Thug,,,,,,Go Crazy (2020#39/2021#19)|Goodbyes (2019#30)|Havana (2017#96/2018#4)|Hot (2020#72)|No Limit (2016#90)|Pushin P (2022#48)|The London (2019#64)|Way 2 Sexy (2021#48)
|
||||
YoungBoy Never Broke Again,,,,,,Bandit (2020#63)|Outside Today (2018#91)
|
||||
Yung Bleu,,,,,,You're Mines Still (2021#58)
|
||||
Zacari,,,,,,Love. (2018#50)
|
||||
Zach Bryan,,,,,,I Remember Everything (2023#74/2024#9)|Pink Skies (2024#30)|Something In The Orange (2022#39/2023#13)
|
||||
Zach Top,,,,,,I Never Lie (2025#43)
|
||||
Zara Larsson,,,,,,Never Forget You (2016#46)
|
||||
Zay Hilfigerrr,,,,,,Juju On That Beat (TZ Anthem) (2017#63)
|
||||
Zayion McCall,,,,,,Juju On That Beat (TZ Anthem) (2017#63)
|
||||
Zayn,,,,,,I Don't Wanna Live Forever (Fifty Shades Darker) (2017#26)|Pillowtalk (2016#22)
|
||||
Zedd,,,,,,Starving (2016#94/2017#82)|Stay (2017#17)|The Middle (2018#8)
|
||||
Óscar Maydon,,,,,,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
|
||||
|
@@ -1,303 +1,303 @@
|
||||
year,rank,title,artist
|
||||
2016,1,Love Yourself,Justin Bieber
|
||||
2016,2,Sorry,Justin Bieber
|
||||
2016,3,One Dance,Drake featuring Wizkid and Kyla
|
||||
2016,4,Work,Rihanna featuring Drake
|
||||
2016,5,Stressed Out,Twenty One Pilots
|
||||
2016,3,One Dance,Drake Featuring WizKid & Kyla
|
||||
2016,4,Work,Rihanna Featuring Drake
|
||||
2016,5,Stressed Out,twenty one pilots
|
||||
2016,6,Panda,Desiigner
|
||||
2016,7,Hello,Adele
|
||||
2016,8,Don't Let Me Down,The Chainsmokers featuring Daya
|
||||
2016,9,Can't Stop the Feeling!,Justin Timberlake
|
||||
2016,10,Closer,The Chainsmokers featuring Halsey
|
||||
2016,11,Cheap Thrills,Sia featuring Sean Paul
|
||||
2016,8,Don't Let Me Down,The Chainsmokers Featuring Daya
|
||||
2016,9,Can't Stop The Feeling!,Justin Timberlake
|
||||
2016,10,Closer,The Chainsmokers Featuring Halsey
|
||||
2016,11,Cheap Thrills,Sia Featuring Sean Paul
|
||||
2016,12,7 Years,Lukas Graham
|
||||
2016,13,Needed Me,Rihanna
|
||||
2016,14,My House,Flo Rida
|
||||
2016,15,I Took a Pill in Ibiza,Mike Posner
|
||||
2016,16,Work from Home,Fifth Harmony featuring Ty Dolla Sign
|
||||
2016,17,This Is What You Came For,Calvin Harris featuring Rihanna
|
||||
2016,18,Cake by the Ocean,DNCE
|
||||
2016,19,"Me, Myself & I",G-Eazy and Bebe Rexha
|
||||
2016,20,Ride,Twenty One Pilots
|
||||
2016,21,Heathens,Twenty One Pilots
|
||||
2016,15,I Took A Pill In Ibiza,Mike Posner
|
||||
2016,16,Work From Home,Fifth Harmony Featuring Ty Dolla $ign
|
||||
2016,17,This Is What You Came For,Calvin Harris Featuring Rihanna
|
||||
2016,18,Cake By The Ocean,DNCE
|
||||
2016,19,"Me, Myself & I",G-Eazy x Bebe Rexha
|
||||
2016,20,Ride,twenty one pilots
|
||||
2016,21,Heathens,twenty one pilots
|
||||
2016,22,Pillowtalk,Zayn
|
||||
2016,23,Stitches,Shawn Mendes
|
||||
2016,24,Hotline Bling,Drake
|
||||
2016,25,Cold Water,Major Lazer featuring Justin Bieber and MØ
|
||||
2016,25,Cold Water,Major Lazer Featuring Justin Bieber & MO
|
||||
2016,26,Send My Love (To Your New Lover),Adele
|
||||
2016,27,Roses,The Chainsmokers featuring Rozes
|
||||
2016,27,Roses,The Chainsmokers Featuring Rozes
|
||||
2016,28,Treat You Better,Shawn Mendes
|
||||
2016,29,Too Good,Drake featuring Rihanna
|
||||
2016,30,Low Life,Future featuring The Weeknd
|
||||
2016,29,Too Good,Drake Featuring Rihanna
|
||||
2016,30,Low Life,Future Featuring The Weeknd
|
||||
2016,31,What Do You Mean?,Justin Bieber
|
||||
2016,32,The Hills,The Weeknd
|
||||
2016,33,Just Like Fire,P!nk
|
||||
2016,34,Broccoli,DRAM featuring Lil Yachty
|
||||
2016,34,Broccoli,D.R.A.M. Featuring Lil Yachty
|
||||
2016,35,Don't,Bryson Tiller
|
||||
2016,36,Dangerous Woman,Ariana Grande
|
||||
2016,37,Jumpman,Drake and Future
|
||||
2016,38,"I Hate U, I Love U",Gnash featuring Olivia O'Brien
|
||||
2016,37,Jumpman,Drake & Future
|
||||
2016,38,I Hate U I Love U,gnash Featuring Olivia O'Brien
|
||||
2016,39,Here,Alessia Cara
|
||||
2016,40,Same Old Love,Selena Gomez
|
||||
2016,41,Controlla,Drake
|
||||
2016,42,Like I'm Gonna Lose You,Meghan Trainor featuring John Legend
|
||||
2016,42,Like I'm Gonna Lose You,Meghan Trainor Featuring John Legend
|
||||
2016,43,One Call Away,Charlie Puth
|
||||
2016,44,Let It Go,James Bay
|
||||
2016,45,No,Meghan Trainor
|
||||
2016,46,Never Forget You,Zara Larsson and MNEK
|
||||
2016,47,Let Me Love You,DJ Snake featuring Justin Bieber
|
||||
2016,46,Never Forget You,Zara Larsson & MNEK
|
||||
2016,47,Let Me Love You,DJ Snake Featuring Justin Bieber
|
||||
2016,48,Don't Mind,Kent Jones
|
||||
2016,49,H.O.L.Y.,Florida Georgia Line
|
||||
2016,50,We Don't Talk Anymore,Charlie Puth featuring Selena Gomez
|
||||
2016,50,We Don't Talk Anymore,Charlie Puth Featuring Selena Gomez
|
||||
2016,51,Into You,Ariana Grande
|
||||
2016,52,Gold,Kiiara
|
||||
2016,53,Exchange,Bryson Tiller
|
||||
2016,54,679,Fetty Wap featuring Remy Boyz
|
||||
2016,54,679,Fetty Wap Featuring Remy Boyz
|
||||
2016,55,Oui,Jeremih
|
||||
2016,56,Hands to Myself,Selena Gomez
|
||||
2016,56,Hands To Myself,Selena Gomez
|
||||
2016,57,2 Phones,Kevin Gates
|
||||
2016,58,Starboy,The Weeknd featuring Daft Punk
|
||||
2016,59,For Free,DJ Khaled featuring Drake
|
||||
2016,60,Never Be Like You,Flume featuring Kai
|
||||
2016,61,In the Night,The Weeknd
|
||||
2016,58,Starboy,The Weeknd Featuring Daft Punk
|
||||
2016,59,For Free,DJ Khaled Featuring Drake
|
||||
2016,60,Never Be Like You,Flume Featuring Kai
|
||||
2016,61,In The Night,The Weeknd
|
||||
2016,62,Me Too,Meghan Trainor
|
||||
2016,63,Ex's & Oh's,Elle King
|
||||
2016,64,Die a Happy Man,Thomas Rhett
|
||||
2016,64,Die A Happy Man,Thomas Rhett
|
||||
2016,65,White Iverson,Post Malone
|
||||
2016,66,Close,Nick Jonas featuring Tove Lo
|
||||
2016,66,Close,Nick Jonas Featuring Tove Lo
|
||||
2016,67,Unsteady,X Ambassadors
|
||||
2016,68,Sucker for Pain,"Lil Wayne, Wiz Khalifa and Imagine Dragons with Logic and Ty Dolla Sign featuring X Ambassadors"
|
||||
2016,69,Down in the DM,Yo Gotti featuring Nicki Minaj
|
||||
2016,68,Sucker For Pain,"Lil Wayne, Wiz Khalifa & Imagine Dragons With Logic & Ty Dolla $ign Feat. X Ambassadors"
|
||||
2016,69,Down In The DM,Yo Gotti Featuring Nicki Minaj
|
||||
2016,70,Luv,Tory Lanez
|
||||
2016,71,Sorry,Beyoncé
|
||||
2016,71,Sorry,Beyonce
|
||||
2016,72,Can't Feel My Face,The Weeknd
|
||||
2016,73,Hymn for the Weekend,Coldplay
|
||||
2016,73,Hymn For The Weekend,Coldplay
|
||||
2016,74,Say It,Tory Lanez
|
||||
2016,75,Antidote,Travis Scott
|
||||
2016,76,Lost Boy,Ruth B
|
||||
2016,77,Side to Side,Ariana Grande featuring Nicki Minaj
|
||||
2016,77,Side To Side,Ariana Grande Featuring Nicki Minaj
|
||||
2016,78,"Sit Still, Look Pretty",Daya
|
||||
2016,79,Wildest Dreams,Taylor Swift
|
||||
2016,80,Middle,DJ Snake featuring Bipolar Sunshine
|
||||
2016,80,Middle,DJ Snake Featuring Bipolar Sunshine
|
||||
2016,81,On My Mind,Ellie Goulding
|
||||
2016,82,Pop Style,Drake featuring Jay-Z and Kanye West
|
||||
2016,82,Pop Style,Drake Featuring The Throne
|
||||
2016,83,When We Were Young,Adele
|
||||
2016,84,Hide Away,Daya
|
||||
2016,85,Lean On,Major Lazer and DJ Snake featuring MØ
|
||||
2016,86,I Know What You Did Last Summer,Shawn Mendes and Camila Cabello
|
||||
2016,87,All the Way Up,Fat Joe and Remy Ma featuring French Montana and Infared
|
||||
2016,88,Watch Me,Silentó
|
||||
2016,89,Back to Sleep,Chris Brown
|
||||
2016,90,No Limit,Usher featuring Young Thug
|
||||
2016,91,Cut It,O.T. Genasis featuring Young Dolph
|
||||
2016,85,Lean On,Major Lazer & DJ Snake Featuring MO
|
||||
2016,86,I Know What You Did Last Summer,Shawn Mendes & Camila Cabello
|
||||
2016,87,All The Way Up,"Fat Joe, Remy Ma & Jay Z Featuring French Montana & Infared"
|
||||
2016,88,Watch Me,Silento
|
||||
2016,89,Back To Sleep,Chris Brown
|
||||
2016,90,No Limit,Usher Featuring Young Thug
|
||||
2016,91,Cut It,O.T. Genasis Featuring Young Dolph
|
||||
2016,92,Really Really,Kevin Gates
|
||||
2016,93,All In My Head (Flex),Fifth Harmony featuring Fetty Wap
|
||||
2016,94,Starving,Hailee Steinfeld and Grey featuring Zedd
|
||||
2016,95,Adventure of a Lifetime,Coldplay
|
||||
2016,96,Humble and Kind,Tim McGraw
|
||||
2016,93,All In My Head (Flex),Fifth Harmony Featuring Fetty Wap
|
||||
2016,94,Starving,Hailee Steinfeld & Grey Featuring Zedd
|
||||
2016,95,Adventure Of A Lifetime,Coldplay
|
||||
2016,96,Humble And Kind,Tim McGraw
|
||||
2016,97,Wicked,Future
|
||||
2016,98,Tiimmy Turner,Desiigner
|
||||
2016,99,See You Again,Wiz Khalifa featuring Charlie Puth
|
||||
2016,99,See You Again,Wiz Khalifa Featuring Charlie Puth
|
||||
2016,100,Perfect,One Direction
|
||||
2017,1,Shape of You,Ed Sheeran
|
||||
2017,2,Despacito (Remix),Luis Fonsi and Daddy Yankee featuring Justin Bieber
|
||||
2017,1,Shape Of You,Ed Sheeran
|
||||
2017,2,Despacito,Luis Fonsi & Daddy Yankee Featuring Justin Bieber
|
||||
2017,3,That's What I Like,Bruno Mars
|
||||
2017,4,Humble,Kendrick Lamar
|
||||
2017,5,Something Just Like This,The Chainsmokers and Coldplay
|
||||
2017,6,Bad and Boujee,Migos featuring Lil Uzi Vert
|
||||
2017,7,Closer,The Chainsmokers featuring Halsey
|
||||
2017,8,Body Like a Back Road,Sam Hunt
|
||||
2017,4,Humble.,Kendrick Lamar
|
||||
2017,5,Something Just Like This,The Chainsmokers & Coldplay
|
||||
2017,6,Bad And Boujee,Migos Featuring Lil Uzi Vert
|
||||
2017,7,Closer,The Chainsmokers Featuring Halsey
|
||||
2017,8,Body Like A Back Road,Sam Hunt
|
||||
2017,9,Believer,Imagine Dragons
|
||||
2017,10,Congratulations,Post Malone featuring Quavo
|
||||
2017,10,Congratulations,Post Malone Featuring Quavo
|
||||
2017,11,Say You Won't Let Go,James Arthur
|
||||
2017,12,I'm the One,"DJ Khaled featuring Justin Bieber, Quavo, Chance the Rapper and Lil Wayne"
|
||||
2017,13,XO Tour Llif3,Lil Uzi Vert
|
||||
2017,12,I'm The One,"DJ Khaled Featuring Justin Bieber, Quavo, Chance The Rapper & Lil Wayne"
|
||||
2017,13,XO TOUR Llif3,Lil Uzi Vert
|
||||
2017,14,Mask Off,Future
|
||||
2017,15,Unforgettable,French Montana featuring Swae Lee
|
||||
2017,15,Unforgettable,French Montana Featuring Swae Lee
|
||||
2017,16,24K Magic,Bruno Mars
|
||||
2017,17,Stay,Zedd and Alessia Cara
|
||||
2017,18,Wild Thoughts,DJ Khaled featuring Rihanna and Bryson Tiller
|
||||
2017,19,Black Beatles,Rae Sremmurd featuring Gucci Mane
|
||||
2017,20,Starboy,The Weeknd featuring Daft Punk
|
||||
2017,17,Stay,Zedd & Alessia Cara
|
||||
2017,18,Wild Thoughts,DJ Khaled Featuring Rihanna & Bryson Tiller
|
||||
2017,19,Black Beatles,Rae Sremmurd Featuring Gucci Mane
|
||||
2017,20,Starboy,The Weeknd Featuring Daft Punk
|
||||
2017,21,Location,Khalid
|
||||
2017,22,Attention,Charlie Puth
|
||||
2017,23,There's Nothing Holdin' Me Back,Shawn Mendes
|
||||
2017,24,Bodak Yellow,Cardi B
|
||||
2017,24,Bodak Yellow (Money Moves),Cardi B
|
||||
2017,25,Redbone,Childish Gambino
|
||||
2017,26,I Don't Wanna Live Forever,Zayn and Taylor Swift
|
||||
2017,27,It Ain't Me,Kygo and Selena Gomez
|
||||
2017,28,iSpy,Kyle featuring Lil Yachty
|
||||
2017,26,I Don't Wanna Live Forever (Fifty Shades Darker),Zayn / Taylor Swift
|
||||
2017,27,It Ain't Me,Kygo x Selena Gomez
|
||||
2017,28,iSpy,KYLE Featuring Lil Yachty
|
||||
2017,29,Issues,Julia Michaels
|
||||
2017,30,Scars to Your Beautiful,Alessia Cara
|
||||
2017,31,1-800-273-8255,Logic featuring Alessia Cara and Khalid
|
||||
2017,30,Scars To Your Beautiful,Alessia Cara
|
||||
2017,31,1-800-273-8255,Logic Featuring Alessia Cara & Khalid
|
||||
2017,32,Slow Hands,Niall Horan
|
||||
2017,33,Love on the Brain,Rihanna
|
||||
2017,34,I Feel It Coming,The Weeknd featuring Daft Punk
|
||||
2017,33,Love On The Brain,Rihanna
|
||||
2017,34,I Feel It Coming,The Weeknd Featuring Daft Punk
|
||||
2017,35,Bounce Back,Big Sean
|
||||
2017,36,Strip That Down,Liam Payne featuring Quavo
|
||||
2017,36,Strip That Down,Liam Payne Featuring Quavo
|
||||
2017,37,Fake Love,Drake
|
||||
2017,38,Don't Wanna Know,Maroon 5 featuring Kendrick Lamar
|
||||
2017,38,Don't Wanna Know,Maroon 5 Featuring Kendrick Lamar
|
||||
2017,39,Look What You Made Me Do,Taylor Swift
|
||||
2017,40,Castle on the Hill,Ed Sheeran
|
||||
2017,41,Bad Things,Machine Gun Kelly and Camila Cabello
|
||||
2017,40,Castle On The Hill,Ed Sheeran
|
||||
2017,41,Bad Things,Machine Gun Kelly x Camila Cabello
|
||||
2017,42,Paris,The Chainsmokers
|
||||
2017,43,Side to Side,Ariana Grande featuring Nicki Minaj
|
||||
2017,44,Rockabye,Clean Bandit featuring Sean Paul and Anne-Marie
|
||||
2017,43,Side To Side,Ariana Grande Featuring Nicki Minaj
|
||||
2017,44,Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie
|
||||
2017,45,Feel It Still,Portugal. The Man
|
||||
2017,46,Let Me Love You,DJ Snake featuring Justin Bieber
|
||||
2017,46,Let Me Love You,DJ Snake Featuring Justin Bieber
|
||||
2017,47,Sorry Not Sorry,Demi Lovato
|
||||
2017,48,Bank Account,21 Savage
|
||||
2017,49,Can't Stop the Feeling!,Justin Timberlake
|
||||
2017,50,Mi Gente,J Balvin and Willy William featuring Beyoncé
|
||||
2017,49,Can't Stop The Feeling!,Justin Timberlake
|
||||
2017,50,Mi Gente,J Balvin & Willy William Featuring Beyonce
|
||||
2017,51,Thunder,Imagine Dragons
|
||||
2017,52,T-shirt,Migos
|
||||
2017,53,Rake It Up,Yo Gotti featuring Nicki Minaj
|
||||
2017,52,T-Shirt,Migos
|
||||
2017,53,Rake It Up,Yo Gotti Featuring Nicki Minaj
|
||||
2017,54,Mercy,Shawn Mendes
|
||||
2017,55,Tunnel Vision,Kodak Black
|
||||
2017,56,Rockstar,Post Malone featuring 21 Savage
|
||||
2017,56,Rockstar,Post Malone Featuring 21 Savage
|
||||
2017,57,In Case You Didn't Know,Brett Young
|
||||
2017,58,Heathens,Twenty One Pilots
|
||||
2017,59,Now or Never,Halsey
|
||||
2017,60,Caroline,Aminé
|
||||
2017,58,Heathens,twenty one pilots
|
||||
2017,59,Now Or Never,Halsey
|
||||
2017,60,Caroline,Amine
|
||||
2017,61,Rolex,Ayo & Teo
|
||||
2017,62,DNA,Kendrick Lamar
|
||||
2017,63,Juju on That Beat (TZ Anthem),Zay Hilfigerrr & Zayion McCall
|
||||
2017,62,DNA.,Kendrick Lamar
|
||||
2017,63,Juju On That Beat (TZ Anthem),Zay Hilfigerrr & Zayion McCall
|
||||
2017,64,Swang,Rae Sremmurd
|
||||
2017,65,Passionfruit,Drake
|
||||
2017,66,Loyalty,Kendrick Lamar featuring Rihanna
|
||||
2017,66,Loyalty.,Kendrick Lamar Featuring Rihanna
|
||||
2017,67,Praying,Kesha
|
||||
2017,68,Goosebumps,Travis Scott
|
||||
2017,69,Cold,Maroon 5 featuring Future
|
||||
2017,70,Broccoli,DRAM featuring Lil Yachty
|
||||
2017,71,Slide,Calvin Harris featuring Frank Ocean and Migos
|
||||
2017,72,What Ifs,Kane Brown featuring Lauren Alaina
|
||||
2017,73,Chained to the Rhythm,Katy Perry featuring Skip Marley
|
||||
2017,74,Feels,"Calvin Harris featuring Pharrell Williams, Katy Perry and Big Sean"
|
||||
2017,69,Cold,Maroon 5 Featuring Future
|
||||
2017,70,Broccoli,D.R.A.M. Featuring Lil Yachty
|
||||
2017,71,Slide,Calvin Harris Featuring Frank Ocean & Migos
|
||||
2017,72,What Ifs,Kane Brown Featuring Lauren Alaina
|
||||
2017,73,Chained To The Rhythm,Katy Perry Featuring Skip Marley
|
||||
2017,74,Feels,"Calvin Harris Featuring Pharrell Williams, Katy Perry & Big Sean"
|
||||
2017,75,All Time Low,Jon Bellion
|
||||
2017,76,Hurricane,Luke Combs
|
||||
2017,77,Too Good at Goodbyes,Sam Smith
|
||||
2017,77,Too Good At Goodbyes,Sam Smith
|
||||
2017,78,Young Dumb & Broke,Khalid
|
||||
2017,79,Magnolia,Playboi Carti
|
||||
2017,80,Love Galore,SZA featuring Travis Scott
|
||||
2017,81,Drowning,A Boogie wit da Hoodie featuring Kodak Black
|
||||
2017,82,Starving,Hailee Steinfeld and Grey featuring Zedd
|
||||
2017,83,Both,Gucci Mane featuring Drake
|
||||
2017,80,Love Galore,SZA Featuring Travis Scott
|
||||
2017,81,Drowning,A Boogie Wit da Hoodie Featuring Kodak Black
|
||||
2017,82,Starving,Hailee Steinfeld & Grey Featuring Zedd
|
||||
2017,83,Both,Gucci Mane Featuring Drake
|
||||
2017,84,What About Us,P!nk
|
||||
2017,85,Swalla,Jason Derulo featuring Nicki Minaj and Ty Dolla Sign
|
||||
2017,86,Slippery,Migos featuring Gucci Mane
|
||||
2017,87,Sign of the Times,Harry Styles
|
||||
2017,88,Water Under the Bridge,Adele
|
||||
2017,85,Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign
|
||||
2017,86,Slippery,Migos Featuring Gucci Mane
|
||||
2017,87,Sign Of The Times,Harry Styles
|
||||
2017,88,Water Under The Bridge,Adele
|
||||
2017,89,Malibu,Miley Cyrus
|
||||
2017,90,Down,Marian Hill
|
||||
2017,91,No Promises,Cheat Codes featuring Demi Lovato
|
||||
2017,91,No Promises,Cheat Codes Featuring Demi Lovato
|
||||
2017,92,Treat You Better,Shawn Mendes
|
||||
2017,93,I Get the Bag,Gucci Mane featuring Migos
|
||||
2017,93,I Get The Bag,Gucci Mane Featuring Migos
|
||||
2017,94,Small Town Boy,Dustin Lynch
|
||||
2017,95,Everyday We Lit,YFN Lucci featuring PnB Rock
|
||||
2017,96,Havana,Camila Cabello featuring Young Thug
|
||||
2017,97,What Lovers Do,Maroon 5 featuring SZA
|
||||
2017,95,Everyday We Lit,YFN Lucci Featuring PnB Rock
|
||||
2017,96,Havana,Camila Cabello Featuring Young Thug
|
||||
2017,97,What Lovers Do,Maroon 5 Featuring SZA
|
||||
2017,98,Do Re Mi,Blackbear
|
||||
2017,99,Look at Me!,XXXTentacion
|
||||
2017,100,The Fighter,Keith Urban featuring Carrie Underwood
|
||||
2017,99,Look At Me!,XXXTentacion
|
||||
2017,100,The Fighter,Keith Urban Featuring Carrie Underwood
|
||||
2018,1,God's Plan,Drake
|
||||
2018,2,Perfect,Ed Sheeran
|
||||
2018,3,Meant to Be,Bebe Rexha featuring Florida Georgia Line
|
||||
2018,4,Havana,Camila Cabello featuring Young Thug
|
||||
2018,5,Rockstar,Post Malone featuring 21 Savage
|
||||
2018,6,Psycho,Post Malone featuring Ty Dolla Sign
|
||||
2018,7,I Like It,"Cardi B, Bad Bunny and J Balvin"
|
||||
2018,8,The Middle,"Zedd, Maren Morris and Grey"
|
||||
2018,3,Meant To Be,Bebe Rexha & Florida Georgia Line
|
||||
2018,4,Havana,Camila Cabello Featuring Young Thug
|
||||
2018,5,Rockstar,Post Malone Featuring 21 Savage
|
||||
2018,6,Psycho,Post Malone Featuring Ty Dolla $ign
|
||||
2018,7,I Like It,"Cardi B, Bad Bunny & J Balvin"
|
||||
2018,8,The Middle,"Zedd, Maren Morris & Grey"
|
||||
2018,9,In My Feelings,Drake
|
||||
2018,10,Girls Like You,Maroon 5 featuring Cardi B
|
||||
2018,11,Nice for What,Drake
|
||||
2018,12,Lucid Dreams,Juice Wrld
|
||||
2018,10,Girls Like You,Maroon 5 Featuring Cardi B
|
||||
2018,11,Nice For What,Drake
|
||||
2018,12,Lucid Dreams,Juice WRLD
|
||||
2018,13,Better Now,Post Malone
|
||||
2018,14,Finesse,Bruno Mars featuring Cardi B
|
||||
2018,14,Finesse,Bruno Mars & Cardi B
|
||||
2018,15,Boo'd Up,Ella Mai
|
||||
2018,16,New Rules,Dua Lipa
|
||||
2018,17,Sad!,XXXTentacion
|
||||
2018,18,Never Be the Same,Camila Cabello
|
||||
2018,19,Love Lies,Khalid and Normani
|
||||
2018,20,No Tears Left to Cry,Ariana Grande
|
||||
2018,17,Sad!,XXXTENTACION
|
||||
2018,18,Never Be The Same,Camila Cabello
|
||||
2018,19,Love Lies,Khalid & Normani
|
||||
2018,20,No Tears Left To Cry,Ariana Grande
|
||||
2018,21,Mine,Bazzi
|
||||
2018,22,Thunder,Imagine Dragons
|
||||
2018,23,Look Alive,BlocBoy JB featuring Drake
|
||||
2018,23,Look Alive,BlocBoy JB Featuring Drake
|
||||
2018,24,Delicate,Taylor Swift
|
||||
2018,25,Yes Indeed,Lil Baby and Drake
|
||||
2018,26,Friends,Marshmello and Anne-Marie
|
||||
2018,27,Bad at Love,Halsey
|
||||
2018,28,Taste,Tyga featuring Offset
|
||||
2018,25,Yes Indeed,Lil Baby & Drake
|
||||
2018,26,Friends,Marshmello & Anne-Marie
|
||||
2018,27,Bad At Love,Halsey
|
||||
2018,28,Taste,Tyga Featuring Offset
|
||||
2018,29,Let You Down,NF
|
||||
2018,30,No Limit,G-Eazy featuring ASAP Rocky and Cardi B
|
||||
2018,31,Fefe,6ix9ine featuring Nicki Minaj and Murda Beatz
|
||||
2018,30,No Limit,G-Eazy Featuring A$AP Rocky & Cardi B
|
||||
2018,31,FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz
|
||||
2018,32,Tequila,Dan + Shay
|
||||
2018,33,Feel It Still,Portugal. The Man
|
||||
2018,34,MotorSport,"Migos, Nicki Minaj and Cardi B"
|
||||
2018,34,MotorSport,"Migos, Nicki Minaj & Cardi B"
|
||||
2018,35,I Like Me Better,Lauv
|
||||
2018,36,Youngblood,5 Seconds of Summer
|
||||
2018,36,Youngblood,5 Seconds Of Summer
|
||||
2018,37,Whatever It Takes,Imagine Dragons
|
||||
2018,38,Ric Flair Drip,Offset and Metro Boomin
|
||||
2018,38,Ric Flair Drip,Offset & Metro Boomin
|
||||
2018,39,I Fall Apart,Post Malone
|
||||
2018,40,Pray for Me,The Weeknd and Kendrick Lamar
|
||||
2018,41,Back to You,Selena Gomez
|
||||
2018,40,Pray For Me,The Weeknd & Kendrick Lamar
|
||||
2018,41,Back To You,Selena Gomez
|
||||
2018,42,Sicko Mode,Travis Scott
|
||||
2018,43,Walk It Talk It,Migos featuring Drake
|
||||
2018,43,Walk It Talk It,Migos Featuring Drake
|
||||
2018,44,Gucci Gang,Lil Pump
|
||||
2018,45,Him & I,G-Eazy and Halsey
|
||||
2018,45,Him & I,G-Eazy & Halsey
|
||||
2018,46,In My Blood,Shawn Mendes
|
||||
2018,47,All the Stars,Kendrick Lamar and SZA
|
||||
2018,47,All The Stars,Kendrick Lamar & SZA
|
||||
2018,48,Stir Fry,Migos
|
||||
2018,49,Too Good at Goodbyes,Sam Smith
|
||||
2018,50,Love,Kendrick Lamar featuring Zacari
|
||||
2018,49,Too Good At Goodbyes,Sam Smith
|
||||
2018,50,Love.,Kendrick Lamar Featuring Zacari
|
||||
2018,51,This Is America,Childish Gambino
|
||||
2018,52,Nonstop,Drake
|
||||
2018,53,Heaven,Kane Brown
|
||||
2018,54,Bodak Yellow,Cardi B
|
||||
2018,55,Freaky Friday,Lil Dicky featuring Chris Brown
|
||||
2018,54,Bodak Yellow (Money Moves),Cardi B
|
||||
2018,55,Freaky Friday,Lil Dicky Featuring Chris Brown
|
||||
2018,56,Gummo,6ix9ine
|
||||
2018,57,Plug Walk,Rich the Kid
|
||||
2018,57,Plug Walk,Rich The Kid
|
||||
2018,58,Wait,Maroon 5
|
||||
2018,59,Be Careful,Cardi B
|
||||
2018,60,Wolves,Selena Gomez and Marshmello
|
||||
2018,61,Bartier Cardi,Cardi B featuring 21 Savage
|
||||
2018,62,God Is a Woman,Ariana Grande
|
||||
2018,63,Big Bank,"YG featuring 2 Chainz, Nicki Minaj and Big Sean"
|
||||
2018,60,Wolves,Selena Gomez X Marshmello
|
||||
2018,61,Bartier Cardi,Cardi B Featuring 21 Savage
|
||||
2018,62,God Is A Woman,Ariana Grande
|
||||
2018,63,Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj"
|
||||
2018,64,Sorry Not Sorry,Demi Lovato
|
||||
2018,65,How Long,Charlie Puth
|
||||
2018,66,Lights Down Low,Max featuring Gnash
|
||||
2018,66,Lights Down Low,MAX Featuring gnash
|
||||
2018,67,Young Dumb & Broke,Khalid
|
||||
2018,68,One Kiss,Calvin Harris and Dua Lipa
|
||||
2018,68,One Kiss,Calvin Harris & Dua Lipa
|
||||
2018,69,Natural,Imagine Dragons
|
||||
2018,70,You Make It Easy,Jason Aldean
|
||||
2018,71,Shape of You,Ed Sheeran
|
||||
2018,72,I Get the Bag,Gucci Mane featuring Migos
|
||||
2018,73,No Brainer,"DJ Khaled featuring Justin Bieber, Chance the Rapper and Quavo"
|
||||
2018,74,Plain Jane,ASAP Ferg featuring Nicki Minaj
|
||||
2018,75,Sky Walker,Miguel featuring Travis Scott
|
||||
2018,71,Shape Of You,Ed Sheeran
|
||||
2018,72,I Get The Bag,Gucci Mane Featuring Migos
|
||||
2018,73,No Brainer,"DJ Khaled Featuring Justin Bieber, Chance The Rapper & Quavo"
|
||||
2018,74,Plain Jane,A$AP Ferg Featuring Nicki Minaj
|
||||
2018,75,Sky Walker,Miguel Featuring Travis Scott
|
||||
2018,76,Marry Me,Thomas Rhett
|
||||
2018,77,Eastside,"Benny Blanco, Halsey and Khalid"
|
||||
2018,77,Eastside,"Benny Blanco, Halsey & Khalid"
|
||||
2018,78,Call Out My Name,The Weeknd
|
||||
2018,79,King's Dead,"Jay Rock, Kendrick Lamar, Future and James Blake"
|
||||
2018,80,Happier,Marshmello and Bastille
|
||||
2018,81,Te Boté,"Nio García, Darell and Casper Mágico featuring Bad Bunny, Nicky Jam and Ozuna"
|
||||
2018,79,King's Dead,"Jay Rock, Kendrick Lamar, Future & James Blake"
|
||||
2018,80,Happier,Marshmello & Bastille
|
||||
2018,81,Te Bote,"Casper Magico, Nio Garcia, Darell, Nicky Jam, Ozuna & Bad Bunny"
|
||||
2018,82,Simple,Florida Georgia Line
|
||||
2018,83,Lemon,N.E.R.D and Rihanna
|
||||
2018,84,1-800-273-8255,Logic featuring Alessia Cara and Khalid
|
||||
2018,85,Say Something,Justin Timberlake featuring Chris Stapleton
|
||||
2018,83,Lemon,N*E*R*D & Rihanna
|
||||
2018,84,1-800-273-8255,Logic Featuring Alessia Cara & Khalid
|
||||
2018,85,Say Something,Justin Timberlake Featuring Chris Stapleton
|
||||
2018,86,I'm Upset,Drake
|
||||
2018,87,Get Along,Kenny Chesney
|
||||
2018,88,Moonlight,XXXTentacion
|
||||
2018,89,What Lovers Do,Maroon 5 featuring SZA
|
||||
2018,90,X,Nicky Jam and J Balvin
|
||||
2018,88,Moonlight,XXXTENTACION
|
||||
2018,89,What Lovers Do,Maroon 5 Featuring SZA
|
||||
2018,90,X,Nicky Jam x J Balvin
|
||||
2018,91,Outside Today,YoungBoy Never Broke Again
|
||||
2018,92,Trip,Ella Mai
|
||||
2018,93,Dura,Daddy Yankee
|
||||
2018,94,Changes,XXXTentacion
|
||||
2018,94,Changes,XXXTENTACION
|
||||
2018,95,Mercy,Brett Young
|
||||
2018,96,One Number Away,Luke Combs
|
||||
2018,97,Powerglide,Rae Sremmurd featuring Juicy J
|
||||
2018,97,Powerglide,Rae Sremmurd & Juicy J
|
||||
2018,98,IDGAF,Dua Lipa
|
||||
2018,99,Mi Gente,J Balvin and Willy William featuring Beyoncé
|
||||
2018,99,Mi Gente,J Balvin & Willy William Featuring Beyonce
|
||||
2018,100,Believer,Imagine Dragons
|
||||
2019,1,Old Town Road,Lil Nas X Featuring Billy Ray Cyrus
|
||||
2019,2,Sunflower (Spider-Man: Into The Spider-Verse),Post Malone & Swae Lee
|
||||
@@ -493,7 +493,7 @@ year,rank,title,artist
|
||||
2020,92,Wishing Well,Juice WRLD
|
||||
2020,93,Does To Me,Luke Combs Featuring Eric Church
|
||||
2020,94,P*$$y Fairy (OTW),Jhene Aiko
|
||||
2020,95,ily,Surf Mesa Featuring Emilee
|
||||
2020,95,ily,surf mesa Featuring Emilee
|
||||
2020,96,More Than My Hometown,Morgan Wallen
|
||||
2020,97,Lovin' On You,Luke Combs
|
||||
2020,98,Said Sum,Moneybagg Yo
|
||||
@@ -564,7 +564,7 @@ year,rank,title,artist
|
||||
2021,63,Leave Before You Love Me,Marshmello X Jonas Brothers
|
||||
2021,64,Glad You Exist,Dan + Shay
|
||||
2021,65,Lonely,Justin Bieber & benny blanco
|
||||
2021,66,Beggin',Måneskin
|
||||
2021,66,Beggin',Maneskin
|
||||
2021,67,Streets,Doja Cat
|
||||
2021,68,What's Next,Drake
|
||||
2021,69,Famous Friends,Chris Young + Kane Brown
|
||||
@@ -632,7 +632,7 @@ year,rank,title,artist
|
||||
2022,31,Good 4 U,Olivia Rodrigo
|
||||
2022,32,Numb Little Bug,Em Beihold
|
||||
2022,33,Jimmy Cooks,Drake Featuring 21 Savage
|
||||
2022,34,Til You Can't,Cody Johnson
|
||||
2022,34,'Til You Can't,Cody Johnson
|
||||
2022,35,Fancy Like,Walker Hayes
|
||||
2022,36,The Kind Of Love We Make,Luke Combs
|
||||
2022,37,I Ain't Worried,OneRepublic
|
||||
@@ -806,14 +806,14 @@ year,rank,title,artist
|
||||
2024,5,Lovin On Me,Jack Harlow
|
||||
2024,6,Not Like Us,Kendrick Lamar
|
||||
2024,7,Espresso,Sabrina Carpenter
|
||||
2024,8,MILLION DOLLAR BABY,Tommy Richman
|
||||
2024,8,Million Dollar Baby,Tommy Richman
|
||||
2024,9,I Remember Everything,Zach Bryan Featuring Kacey Musgraves
|
||||
2024,10,Too Sweet,Hozier
|
||||
2024,11,Stick Season,Noah Kahan
|
||||
2024,12,Cruel Summer,Taylor Swift
|
||||
2024,13,greedy,Tate McRae
|
||||
2024,13,Greedy,Tate McRae
|
||||
2024,14,Like That,"Future, Metro Boomin & Kendrick Lamar"
|
||||
2024,15,BIRDS OF A FEATHER,Billie Eilish
|
||||
2024,15,Birds Of A Feather,Billie Eilish
|
||||
2024,16,Please Please Please,Sabrina Carpenter
|
||||
2024,17,Agora Hills,Doja Cat
|
||||
2024,18,"Good Luck, Babe!",Chappell Roan
|
||||
@@ -824,51 +824,51 @@ year,rank,title,artist
|
||||
2024,23,Fast Car,Luke Combs
|
||||
2024,24,Water,Tyla
|
||||
2024,25,Feather,Sabrina Carpenter
|
||||
2024,26,we can't be friends (wait for your love),Ariana Grande
|
||||
2024,26,We Can't Be Friends (Wait For Your Love),Ariana Grande
|
||||
2024,27,Austin,Dasha
|
||||
2024,28,Last Night,Morgan Wallen
|
||||
2024,29,Cowgirls,Morgan Wallen Featuring ERNEST
|
||||
2024,30,Pink Skies,Zach Bryan
|
||||
2024,31,Thinkin' Bout Me,Morgan Wallen
|
||||
2024,32,TEXAS HOLD 'EM,Beyonce
|
||||
2024,32,Texas Hold 'Em,Beyonce
|
||||
2024,33,Is It Over Now? (Taylor's Version) [From The Vault],Taylor Swift
|
||||
2024,34,Miles On It,Marshmello & Kane Brown
|
||||
2024,35,I Can Do It With a Broken Heart,Taylor Swift
|
||||
2024,35,I Can Do It With A Broken Heart,Taylor Swift
|
||||
2024,36,Wild Ones,Jessie Murph & Jelly Roll
|
||||
2024,37,Ain't No Love In Oklahoma,Luke Combs
|
||||
2024,38,CARNIVAL,¥$: Ye & Ty Dolla $ign Featuring Rich The Kid & Playboi Carti
|
||||
2024,38,Carnival,¥$: Ye & Ty Dolla $ign Featuring Rich The Kid & Playboi Carti
|
||||
2024,39,Houdini,Eminem
|
||||
2024,40,Wanna Be,GloRilla & Megan Thee Stallion
|
||||
2024,41,Slow It Down,Benson Boone
|
||||
2024,42,redrum,21 Savage
|
||||
2024,42,Redrum,21 Savage
|
||||
2024,43,Houdini,Dua Lipa
|
||||
2024,44,Yeah Glo!,GloRilla
|
||||
2024,45,Rich Baby Daddy,Drake Featuring Sexyy Red & SZA
|
||||
2024,46,What Was I Made For?,Billie Eilish
|
||||
2024,47,End of Beginning,Djo
|
||||
2024,48,LUNCH,Billie Eilish
|
||||
2024,47,End Of Beginning,Djo
|
||||
2024,48,Lunch,Billie Eilish
|
||||
2024,49,Never Lose Me,Flo Milli
|
||||
2024,50,Lies Lies Lies,Morgan Wallen
|
||||
2024,51,Type Shit,"Future, Metro Boomin, Travis Scott & Playboi Carti"
|
||||
2024,52,Gata Only,FloyyMenor X Cris Mj
|
||||
2024,53,HOT TO GO!,Chappell Roan
|
||||
2024,53,Hot To Go!,Chappell Roan
|
||||
2024,54,All I Want For Christmas Is You,Mariah Carey
|
||||
2024,55,Get It Sexyy,Sexyy Red
|
||||
2024,56,Made For Me,Muni Long
|
||||
2024,57,vampire,Olivia Rodrigo
|
||||
2024,57,Vampire,Olivia Rodrigo
|
||||
2024,58,Whatever She Wants,Bryson Tiller
|
||||
2024,59,Rockin' Around the Christmas Tree,Brenda Lee
|
||||
2024,59,Rockin' Around The Christmas Tree,Brenda Lee
|
||||
2024,60,Pretty Little Poison,Warren Zeiders
|
||||
2024,61,First Person Shooter,Drake Featuring J. Cole
|
||||
2024,62,Die With A Smile,Lady Gaga & Bruno Mars
|
||||
2024,63,i like the way you kiss me,Artemas
|
||||
2024,64,Need a Favor,Jelly Roll
|
||||
2024,63,I Like The Way You Kiss Me,Artemas
|
||||
2024,64,Need A Favor,Jelly Roll
|
||||
2024,65,Save Me,Jelly Roll With Lainey Wilson
|
||||
2024,66,euphoria,Kendrick Lamar
|
||||
2024,67,TRUCK BED,HARDY
|
||||
2024,66,Euphoria,Kendrick Lamar
|
||||
2024,67,Truck Bed,HARDY
|
||||
2024,68,Jingle Bell Rock,Bobby Helms
|
||||
2024,69,Flowers,Miley Cyrus
|
||||
2024,70,Where the Wild Things Are,Luke Combs
|
||||
2024,70,Where The Wild Things Are,Luke Combs
|
||||
2024,71,Everybody,Nicki Minaj Featuring Lil Uzi Vert
|
||||
2024,72,La Diabla,Xavi
|
||||
2024,73,Stargazing,Myles Smith
|
||||
@@ -877,21 +877,21 @@ year,rank,title,artist
|
||||
2024,76,Pour Me A Drink,Post Malone Featuring Blake Shelton
|
||||
2024,77,White Horse,Chris Stapleton
|
||||
2024,78,Lil Boo Thang,Paul Russell
|
||||
2024,79,Good Good,"USHER, Summer Walker & 21 Savage"
|
||||
2024,80,act ii: date @ 8,4batz Featuring Drake
|
||||
2024,79,Good Good,"Usher, Summer Walker & 21 Savage"
|
||||
2024,80,Act II: Date @ 8,4batz Featuring Drake
|
||||
2024,81,High Road,Koe Wetzel & Jessie Murph
|
||||
2024,82,MONACO,Bad Bunny
|
||||
2024,82,Monaco,Bad Bunny
|
||||
2024,83,IDGAF,Drake Featuring Yeat
|
||||
2024,84,Burn It Down,Parker McCollum
|
||||
2024,85,fukumean,Gunna
|
||||
2024,85,Fukumean,Gunna
|
||||
2024,86,Taste,Sabrina Carpenter
|
||||
2024,87,Where It Ends,Bailey Zimmerman
|
||||
2024,88,FTCU,Nicki Minaj
|
||||
2024,89,WILDFLOWER,Billie Eilish
|
||||
2024,90,World on Fire,Nate Smith
|
||||
2024,89,Wildflower,Billie Eilish
|
||||
2024,90,World On Fire,Nate Smith
|
||||
2024,91,On My Mama,Victoria Monet
|
||||
2024,92,"yes, and?",Ariana Grande
|
||||
2024,93,exes,Tate McRae
|
||||
2024,92,"Yes, And?",Ariana Grande
|
||||
2024,93,Exes,Tate McRae
|
||||
2024,94,A Holly Jolly Christmas,Burl Ives
|
||||
2024,95,Wind Up Missin' You,Tucker Wetmore
|
||||
2024,96,Bulletproof,Nate Smith
|
||||
@@ -907,7 +907,7 @@ year,rank,title,artist
|
||||
2025,6,Beautiful Things,Benson Boone
|
||||
2025,7,Ordinary,Alex Warren
|
||||
2025,8,I Had Some Help,Post Malone Featuring Morgan Wallen
|
||||
2025,9,APT.,ROSÉ & Bruno Mars
|
||||
2025,9,APT.,ROSE & Bruno Mars
|
||||
2025,10,Pink Pony Club,Chappell Roan
|
||||
2025,11,Love Somebody,Morgan Wallen
|
||||
2025,12,Espresso,Sabrina Carpenter
|
||||
|
@@ -27,3 +27,171 @@
|
||||
- **完整歌詞不進 git**(版權);API 金鑰走 `.env`。
|
||||
- **專案目錄維持原名 `pop-fem-audit`**:API 管線下目錄名不會
|
||||
進入模型輸入,無污染疑慮。
|
||||
|
||||
## 2026-07-31
|
||||
|
||||
- **專案英文名定為「A Feminist Audit of Pop Music」**:明示
|
||||
女性主義立場,「audit」兼指對歌曲與對 LLM 標籤系統的稽核,
|
||||
並與縮寫 `pop-fem-audit` 對應。
|
||||
- **程式碼收整為 `tools/` src-layout 子專案**:發行名
|
||||
`pop-fem-audit-tools`、import 套件名 `pop_fem_audit_tools`、
|
||||
description「Tools for A Feminist Audit of Pop Music.」;
|
||||
以 `pip install -e tools/` 安裝、`python -m
|
||||
pop_fem_audit_tools.run_llm` 執行;相依套件記於
|
||||
`pyproject.toml`(`requirements.txt` 移除);Sphinx 文件
|
||||
暫緩。理由:後續多支程式將共用程式碼,套件化後測試可用
|
||||
正常 import;目錄名 `tools/` 經無脈絡的獨立 subagent 命名
|
||||
評估選出,最誠實反映「服務研究的輔助工具」定位——研究
|
||||
本體在根目錄的 prompts/、runs/、results/,程式只是配套。
|
||||
- **CLI 入口改為套件層級 dispatcher**:dispatcher 為單一
|
||||
入口,`run_llm.py` 的 entry point 移除;有兩種等價呼叫
|
||||
形式——`python -m pop_fem_audit_tools run-llm ...` 與
|
||||
console script `pop-fem-audit-tools run-llm ...`
|
||||
(`[project.scripts]`)。理由:後續多支工具共用單一入口,
|
||||
`--help` 可列出全部子命令,重現文件穩定。
|
||||
- **資料儲存架構定案**:commit 判準——凡能由「committed 輸入+
|
||||
committed 程式」決定性再生者不 commit;源頭、外部捕捉、人工
|
||||
著作以文字格式 commit。工作儲存採 SQLite 單檔
|
||||
(`tools/instance/`,generated、不進 git;歌詞全文入 DB 故無
|
||||
版權疑慮),schema 以 SQLAlchemy 2.0 typed ORM 定義,設定經
|
||||
pydantic-settings(`.env`)。`results/` 報表 CSV 為「可再生仍
|
||||
commit」的唯一例外,理由:論文引用穩定性、審稿人零門檻、
|
||||
撰稿期數字變動可 diff。(本條經多輪辯證後定案,推翻 Claude
|
||||
最初的全 CSV 方案。)
|
||||
- **資料模型**:songs、chart_entries(1—N)、artists、
|
||||
song_artists(M—N:角色、署名順序)、lyrics(1—1);領域
|
||||
不變量檢查內建 `build-db`,違規即失敗。
|
||||
- **歌手背景擴充與防火牆**:歌手資料擴為實體表(QID、性別、
|
||||
型態、曲風、國籍),women-power 候選的歌手另做深度背景
|
||||
(族裔以公開自我認同為準、音樂場景),供人工解讀與論文
|
||||
討論;**歌手背景絕不進 LLM 輸入**(歌詞-only),避免光環
|
||||
偏誤污染條件 A/B 實驗。
|
||||
- **沿用先導研究歌詞捕捉**(lyrics.json,684 首,2018–2025):
|
||||
只匯入識別欄位與歌詞本文,pilot 分析欄位不匯入;以
|
||||
(year, rank) 精確匹配。出處記於 `lyrics_provenance.csv`:
|
||||
source(原始 API)與 method(pilot-import / api-fetch)
|
||||
兩層;取得日期不可考者不假造,僅記可證上界。
|
||||
- **`run_llm` 改走 pydantic-settings 統一設定**(刪手寫 .env
|
||||
parser),與 `config.py` / `database.py` 一致。
|
||||
- **`lyrics` 表併入 `songs.lyrics` nullable 欄位**(資料模型由
|
||||
五表改四表)。理由:1—1 關係在此量級下獨立成表只有正規化
|
||||
慣性,nullable 欄位更簡單;「未取得」以 NULL 表達,語意
|
||||
等價。
|
||||
|
||||
## 2026-08-01
|
||||
|
||||
- **引擎獨立性以 PostgreSQL/SQLite 為範圍**(MySQL 的 VARCHAR
|
||||
長度限制不處理)。
|
||||
- **`build-db` 的重置改用逐表 DELETE,不再 drop/create**:
|
||||
schema 生命週期歸 migration 管,build-db 只管資料。連帶
|
||||
效果:重置成為純 DML,全程單一交易、驗證通過才 commit——
|
||||
建置失敗時前一版資料完好。首次執行仍以 create_all
|
||||
(checkfirst)補缺表。
|
||||
- **song/artist ID 由 build-db 顯式指派**(首次出現順序 1、2、
|
||||
3…),不依賴 autoincrement——PostgreSQL 的 sequence 在
|
||||
DELETE 後不重置,顯式指派讓重建決定性跨引擎成立。
|
||||
- **禁止文字 SQL statement,一律經 SQLAlchemy ORM/Core API**。
|
||||
唯一記錄在案的例外:SQLite 的 `PRAGMA foreign_keys=ON`
|
||||
(官方建議作法,無非文字 API 可用;SQLite 的 FK 旗標為
|
||||
逐連線設定)。裁定其歸屬為連線組態,實作於 `database.py`
|
||||
的 `__create_engine`——建 engine 時對 SQLite 註冊 connect
|
||||
listener,所有消費者全程生效,不再由 build-db 各自註冊。
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
- **`import-lyrics` 不設為子命令,pilot 歌詞改以 `excludes/`
|
||||
私人腳本匯入**。理由:pilot 捕捉檔不隨論文發布,子命令
|
||||
形式會在發布的 CLI 裡留下讀者無法執行的死命令——要交待的
|
||||
是「沿用 pilot 捕捉」的事實(記於 lyrics_provenance.csv 與
|
||||
論文方法節),不是工具本身;工具移出專案,發布管線即
|
||||
「讀者可完整執行的程序」。讀者重現歌詞的路徑為
|
||||
`fetch-lyrics`,API 漂移造成的差異屬捕捉層的已承認限制。
|
||||
provenance 的 method 值域定為
|
||||
pilot-import / api-fetch / manual。
|
||||
- **資料路徑全面改為顯式 CLI 引數**——必要運算元為位置
|
||||
引數、選擇性輸入為選項(build-db 吃榜單 CSV 一個位置引數,
|
||||
歌詞目錄、Wikidata 快照、overrides 為 `--lyrics-dir`/
|
||||
`--wikidata-csv`/`--overrides-csv` 三個選項;fetch-lyrics
|
||||
吃歌詞目錄、provenance、缺漏報表三個位置引數;fetch-artists
|
||||
吃快照檔;run-llm 吃 runs 目錄),命令與 CWD 無關,且每次
|
||||
執行觸碰的檔案完整見於指令本身。省略選項即不載入該捕捉層;
|
||||
給了選項而路徑不存在即建置失敗,取代原本的默默跳過。理由:
|
||||
settings 的 `.env` 依 pydantic 慣例讀自 CWD(約定
|
||||
`tools/`),資料路徑原以 repo 根為 CWD,兩者衝突;顯式引數
|
||||
消滅隱性 CWD 契約,亦拒絕以父目錄推導兄弟檔案的隱性慣例。
|
||||
- **`data/` 依生命週期分三層**:`source/`(源頭,手放後不動)、
|
||||
`captures/`(外部捕捉,只由 fetch 命令與私人匯入腳本寫入)、
|
||||
`manual/`(人工著作,只由研究者手寫;之後的黃金標準編碼
|
||||
亦居此)。理由:三種生命週期混住一層,目錄無法傳達「誰可以
|
||||
寫哪裡」;顯式引數化後搬遷零程式改動。
|
||||
- **源頭榜單 CSV 全面對齊 Billboard 年終榜發布版**(修正
|
||||
203 列)。語料源頭定義為「年終榜發布當時的印法」,以
|
||||
Wayback Machine 發布時快照逐字轉錄(2016–2018 原沿
|
||||
Wikipedia 改寫慣例);年終原文殘缺或有疑問者,以當年週榜
|
||||
掛名為準(唯一適用例:2016#87〈All the Way Up〉)。
|
||||
- **歌名正規化採 canonical artist credit 對照表**:同一首歌
|
||||
因署名字串寫法不同而分裂者,以明示對照表合併;歌曲身分=
|
||||
(原始歌名,對照後署名字串),不解析署名——拆解機器不進
|
||||
歌曲身分判定,對照表即完整可稽核清單。
|
||||
- **歌手名單拆解規則定案**:署名列出成員的團體只留成員
|
||||
(冒號與括號兩型);名字內含連接詞的單一藝人入保護名單;
|
||||
規則無法表達的個案入例外表;人名連寫的二人組拆成個人;
|
||||
僅以團名掛名的團體維持單一實體,屬性留待 fetch-artists
|
||||
與人工 overrides。完整清單見 `build_db.py`。
|
||||
- **歌手名正規化**:歌手名大小寫不敏感歸併、存首見拼法;
|
||||
正名表(casefold 鍵→官方拼法)存官方拼法,含跨拼法合併
|
||||
ye→Kanye West。
|
||||
- **`data/` 增設 `derived/` 衍生層**:`build-db` 建置工作儲存
|
||||
的同一動作產出兩張人讀報表(songs.csv、artists.csv)並
|
||||
commit——與 SQLite 同交易語意、驗證通過才寫檔,稽核鏈
|
||||
「committed 輸入+程式→CSV」無中間空缺;為「可再生仍
|
||||
commit」的第二例外。報表不含資料庫 ID(內部參照,資料
|
||||
改版會重排,不供引用);關係以內嵌字串呈現——`/` 連同
|
||||
一首歌的多次上榜、`|` 連不同歌曲——欄位一律字母序。
|
||||
- **歌手正名表全面改存官方拼法**:變音符號一律恢復
|
||||
(Billboard 印法慣性去符號,共 12 筆,如 Aminé、Jhené
|
||||
Aiko、Silentó);二人組拆名改存成員全名(Dan Smyers、
|
||||
Shay Mooney);官方大小寫 3 筆(Cris MJ、Mariah the
|
||||
Scientist、Surf Mesa)。官方拼法逐筆以 DSP 官方頁查證
|
||||
(維基百科/Wikidata 的大小寫是其自家規範,不作準);
|
||||
查證亦確認 Tones And I 的 chart 印法即官方寫法,不改。
|
||||
- **`fetch-artists` 解析演算法定案**:改用 WDQS 的 SPARQL
|
||||
索引查詢(不依賴搜尋排名、不用 LCASE——實測會逾時)。
|
||||
步驟:(1) 歌手名與 label/alias 精確比對(@en+@mul 兩層)
|
||||
取候選,型態限人類∪音樂團體子樹∪original cast;(2) 唯一
|
||||
即定案;(3) 歧義以上榜歌曲佐證——歌名精確比對取歌曲條目
|
||||
的 P175 演出者(含其 P527 成員)與候選交集;(4) 再落空則
|
||||
反向錨定:候選(含所屬團體)演出過的歌曲以 casefold 比對
|
||||
歌名;(5) 均落空記查無並續行。每步須恰一命中才選定,
|
||||
實測零錯選。Last-resort 釘定表僅一筆(Pinkfong——唯一
|
||||
P31 為品牌的上榜演出者,型態閘門依設計排除)。暫時性
|
||||
故障(429/5xx/讀取逾時)退避重試,重試盡記 error 列;
|
||||
not found 專指程序查證後無所得。全量實跑 469 位全數
|
||||
解析且與人工查證的 QID 逐筆相符;首次快照隨本次入
|
||||
捕捉層。
|
||||
- **先補完 Wikidata、再捕捉快照**:歌手資料查證做在上游——
|
||||
以 subagent(`artist-wikidata-updater` 定義檔)逐位查證
|
||||
快照欄位(P21、P31、P136、P27/P495,新條目加 P106)並
|
||||
草擬附來源的 QuickStatements,由研究者本人審摘要、以本人
|
||||
帳號執行;編輯沉澱後重跑 `fetch-artists`,Wikidata 容不下
|
||||
的判斷才入 overrides。理由:修在上游全網受益,編輯史與
|
||||
來源公開可稽核,快照乾淨、overrides 縮小;論文方法節揭露
|
||||
「捕捉前經研究者查證補完」。性別以公開自我認同為準,
|
||||
推測不確定且無佐證者列疑慮清單;查證屬資料策展而非
|
||||
分析,以 Claude Code 輔助、不走分析 API;QS 批次存
|
||||
`excludes/`(私人工作檔,不隨論文發布)。
|
||||
|
||||
## 2026-08-04
|
||||
|
||||
- **移除人工 overrides 層**:`build-db` 刪去
|
||||
`--overrides-csv` 選項與套用邏輯,文件同步移除
|
||||
`data/manual/artists_overrides.csv`。理由:「先補完
|
||||
Wikidata、再捕捉快照」工作流實跑後,469 位歌手全數
|
||||
在上游查證補齊,快照即完整,本地覆蓋層已無存在事實;
|
||||
`data/manual/` 層保留(供日後黃金標準編碼)。
|
||||
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
||||
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
||||
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
||||
拆成個人後,男女混合是歌曲層(song_artists+各歌手
|
||||
gender)可推導的事實,不屬歌手實體。非人非團體者
|
||||
(Pinkfong)type 留空由人工判定,維持現狀。
|
||||
|
||||
+50
-15
@@ -1,26 +1,53 @@
|
||||
# 專案目錄結構
|
||||
|
||||
(2026-07-30 討論定案版;分析管線已改為 API script 路線,
|
||||
定義檔不再放 `.claude/agents/`)
|
||||
(2026-07-30 討論定案;2026-07-31 更新為 tools/ 子專案與
|
||||
SQLite 工作儲存架構)
|
||||
|
||||
```
|
||||
pop-fem-audit/
|
||||
├── README.md # 專案說明、重現步驟
|
||||
├── CLAUDE.md # 極簡工作規範(subagent 會讀到,
|
||||
│ # 絕不放理論、codebook、預期結果)
|
||||
├── .gitignore # data/lyrics/、.env、scratch
|
||||
├── .gitignore # captures/lyrics/、.env、scratch
|
||||
├── conference_abstract.md # pilot 摘要(投稿版)
|
||||
├── data/
|
||||
│ ├── yearend_hot100_2016_2025.csv # 原始榜單(唯一手放原始檔)
|
||||
│ ├── songs_unique.csv # 衍生:去重歌曲表(song id)
|
||||
│ ├── artists_gender.csv # 衍生:演唱者性別後設資料
|
||||
│ └── lyrics/ # 歌詞快取(gitignored,版權)
|
||||
├── data/ # 依生命週期分層(文字格式)
|
||||
│ ├── source/ # 源頭:手放後不動
|
||||
│ │ └── yearend_hot100_2016_2025.csv # 原始榜單
|
||||
│ ├── captures/ # 外部捕捉:只由 fetch 命令與
|
||||
│ │ │ # 私人匯入腳本寫入
|
||||
│ │ ├── artists-wikidata.csv # Wikidata 快照
|
||||
│ │ ├── lyrics_provenance.csv # 歌詞出處
|
||||
│ │ ├── lyrics_missing.csv # 歌詞缺漏報表
|
||||
│ │ └── lyrics/ # 歌詞 .txt 快取
|
||||
│ │ # (gitignored,版權)
|
||||
│ ├── manual/ # 人工著作:只由研究者手寫
|
||||
│ │ # (黃金標準編碼等)
|
||||
│ └── derived/ # 衍生:只由 build-db 寫入
|
||||
│ ├── songs.csv # 歌曲報表(人讀;進 git)
|
||||
│ └── artists.csv # 歌手報表(人讀;進 git)
|
||||
├── 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/
|
||||
├── 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 進入點(分派子命令)
|
||||
│ │ ├── build_db.py # build the SQLite working store
|
||||
│ │ │ # from the inputs
|
||||
│ │ ├── config.py # pydantic-settings 設定(.env)
|
||||
│ │ ├── database.py # SQLAlchemy engine / session / Base
|
||||
│ │ ├── 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 runner:2+1 協定、Batch API、
|
||||
│ │ # 寫入引數指定的 runs 目錄;執行
|
||||
│ │ # 方式 pop-fem-audit-tools run-llm
|
||||
│ └── tests/ # 單元測試(unittest)
|
||||
├── runs/ # 每次執行的完整稽核紀錄(進 git)
|
||||
│ └── <階段>/<日期>-<定義檔版本>/
|
||||
│ ├── prompt.md # 當次定義檔快照(自我完備)
|
||||
@@ -29,7 +56,8 @@ pop-fem-audit/
|
||||
│ ├── arbitration.jsonl # 仲裁輸出
|
||||
│ └── meta.json # model ID、temperature、時間戳、
|
||||
│ # batch ID、一致率
|
||||
├── results/ # 仲裁後最終衍生表(論文引用來源)
|
||||
├── results/ # 論文引用的報表 CSV(export 產出;
|
||||
│ # 「可再生仍 commit」的唯一例外)
|
||||
├── docs/
|
||||
│ ├── research_plan.md # 研究步驟規劃(本檔之姊妹篇)
|
||||
│ ├── project_structure.md # 本檔
|
||||
@@ -49,8 +77,15 @@ pop-fem-audit/
|
||||
論文只引 `results/`,其來源可回溯至 `runs/`。
|
||||
- **`prompts/` 用檔名版本化**(不只靠 git 歷史):定義檔版本是
|
||||
論文附錄的引用單位,須可直接指名(如 judge_v2.md)。
|
||||
- **API 金鑰**放 `.env`(gitignored),script 由環境變數讀取,
|
||||
絕不寫入 repo。
|
||||
- **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 不會自動讀到)。
|
||||
|
||||
+53
-3
@@ -1,6 +1,7 @@
|
||||
# 研究步驟規劃
|
||||
|
||||
(2026-07-30 討論定案版;後續變更請記入 `decision_log.md`)
|
||||
(2026-07-30 討論定案、2026-07-31 增補資料架構;
|
||||
後續變更請記入 `decision_log.md`)
|
||||
|
||||
## 總體框架
|
||||
|
||||
@@ -24,12 +25,61 @@
|
||||
人工黃金標準終審。可重現性定義為「程序透明+可稽核」:
|
||||
公開定義檔、記錄 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 實驗。women-power
|
||||
候選歌曲的歌手另做深度背景(族裔以公開自我認同為準、音樂
|
||||
場景),script 輔助、人工核定。
|
||||
- **Pilot 歌詞沿用(私人匯入,不進發布管線)**:先導研究
|
||||
捕捉檔(lyrics.json,684 首,2018–2025)以 `excludes/` 的
|
||||
私人腳本匯入歌詞快取——只取識別欄位與歌詞本文,以
|
||||
(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-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 |
|
||||
| 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 | 候選篩選:全部唯一歌曲高召回 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 |
|
||||
@@ -58,7 +108,7 @@
|
||||
|
||||
## 其他已定案事項
|
||||
|
||||
- 歌詞受版權保護:完整歌詞不進 git(`data/lyrics/` gitignored),
|
||||
- 歌詞受版權保護:完整歌詞不進 git(`data/captures/lyrics/` gitignored),
|
||||
論文與 repo 只留分類所引摘錄。
|
||||
- 工程支援(寫 script、style check、審稿)用公司訂閱的
|
||||
Claude Code;進論文的分析 token 由個人 API 帳號支付。
|
||||
|
||||
@@ -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,81 @@
|
||||
=======================================
|
||||
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.
|
||||
|
||||
|
||||
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,69 @@
|
||||
pop\_fem\_audit\_tools package
|
||||
==============================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pop\_fem\_audit\_tools.build\_db module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.build_db
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.config module
|
||||
------------------------------------
|
||||
|
||||
.. 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.fetch\_artists module
|
||||
--------------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.fetch_artists
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.fetch\_lyrics module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.fetch_lyrics
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.models module
|
||||
------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.models
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.run\_llm module
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.run_llm
|
||||
: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,95 @@
|
||||
# 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 pop_fem_audit_tools import (
|
||||
build_db,
|
||||
fetch_artists,
|
||||
fetch_lyrics,
|
||||
run_llm,
|
||||
)
|
||||
|
||||
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.main,
|
||||
"fetch-artists": fetch_artists.main,
|
||||
"fetch-lyrics": fetch_lyrics.main,
|
||||
"run-llm": run_llm.main,
|
||||
}
|
||||
"""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,748 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""The builder of the SQLite working store.
|
||||
|
||||
Rebuilds the working store from scratch out of the committed
|
||||
inputs: the year-end chart CSV and the output directory for the
|
||||
review CSV files, given as the two positional command-line
|
||||
arguments, and the optional capture inputs, each given as an
|
||||
option: the lyrics cache directory and the Wikidata artist
|
||||
snapshot CSV. An omitted option
|
||||
leaves its capture layer unloaded; a given option whose path does
|
||||
not exist fails the build. Missing tables
|
||||
are created on a fresh store; existing tables are never altered,
|
||||
as the schema lifecycle belongs to the migrations. Every rebuild
|
||||
deletes all the rows, loads the data, and validates it in one
|
||||
transaction, committed only after the data passes the validation
|
||||
invariants; a failed build leaves the previous store contents
|
||||
intact.
|
||||
|
||||
The rebuild is deterministic: the builder assigns the song and
|
||||
artist IDs itself, as 1, 2, 3, ... in the first-occurrence file
|
||||
order, so the IDs are reproducible across rebuilds on every
|
||||
database engine, given the frozen input file.
|
||||
|
||||
A song is identified by its raw title together with its artist
|
||||
credit, the credit canonicalized through
|
||||
``CANONICAL_ARTIST_CREDITS``; a credit listed there collapses onto
|
||||
the same song as its canonical form, and the stored artist credit
|
||||
is always the canonical form. Artist deduplication is by the
|
||||
identity key resolved from the parsed artist name (see
|
||||
`resolve_artist_identity`): the case-folded name, or, when that
|
||||
case-folded name is listed in ``CANONICAL_ARTIST_NAMES``, the
|
||||
case-folded canonical spelling, so letter-case variants and
|
||||
alternate spellings mapped to the same canonical name all
|
||||
collapse onto a single artist row. The stored artist name is the
|
||||
first-seen spelling, except for the names listed in
|
||||
``CANONICAL_ARTIST_NAMES``, which always store the canonical
|
||||
spelling regardless of which variant is seen first.
|
||||
|
||||
On a successful build, two review CSV files, ``songs.csv`` and
|
||||
``artists.csv``, are (re)written under the given output directory,
|
||||
mirroring the stored songs and artists without their IDs; see
|
||||
`CSVExporter`. A failed build leaves any existing review CSV
|
||||
files untouched, matching the store rollback.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import Base, ds
|
||||
from .models import (
|
||||
Artist,
|
||||
ChartEntry,
|
||||
Role,
|
||||
Song,
|
||||
SongArtist,
|
||||
)
|
||||
|
||||
YEARS: Sequence[int] = range(2016, 2026)
|
||||
"""The expected chart years."""
|
||||
RANKS_PER_YEAR: int = 100
|
||||
"""The expected number of ranks on the chart of each year."""
|
||||
ARTIST_FIELDS: dict[str, str] = {
|
||||
"qid": "wikidata_qid",
|
||||
"gender": "gender",
|
||||
"type": "type",
|
||||
"genre": "genre",
|
||||
"country": "country",
|
||||
}
|
||||
"""The artist CSV columns mapped to the Artist attributes."""
|
||||
FEATURING_PATTERN: re.Pattern[str] = re.compile(
|
||||
r" featuring | feat\. ", re.IGNORECASE)
|
||||
"""The pattern splitting the primary and featured sides."""
|
||||
DELIMITER_PATTERN: re.Pattern[str] = re.compile(
|
||||
r", | & | \+ | / |(?i: and | x | with )")
|
||||
"""The pattern splitting the artist names within a side."""
|
||||
COLON_PATTERN: re.Pattern[str] = re.compile(r": ")
|
||||
"""The pattern separating a group prefix from its members in a
|
||||
"<group>: <members>" credit."""
|
||||
PAREN_MEMBERS_PATTERN: re.Pattern[str] = re.compile(
|
||||
r"^.+ \((?P<members>.+)\)$")
|
||||
"""The pattern separating a group name from its members in a
|
||||
"<group> (<members>)" credit spanning the whole credit."""
|
||||
DUET_WITH_PATTERN: re.Pattern[str] = re.compile(
|
||||
r" Duet With ", re.IGNORECASE)
|
||||
"""The pattern normalizing the "Duet With" co-billing connector
|
||||
to the plain "with" delimiter."""
|
||||
PROTECTED_ARTIST_NAMES: tuple[str, ...] = (
|
||||
"Tyler, The Creator",
|
||||
"Lil Nas X",
|
||||
"Tones And I",
|
||||
)
|
||||
"""The exact artist names guarded from the delimiter splitting,
|
||||
because each contains a delimiter word or punctuation as part of
|
||||
the name itself."""
|
||||
EXCEPTION_CREDITS: dict[str, list[tuple[str, Role]]] = {
|
||||
"SpotemGottem Featuring Pooh Shiesty Or DaBaby": [
|
||||
("SpotemGottem", Role.PRIMARY),
|
||||
("Pooh Shiesty", Role.FEATURED),
|
||||
("DaBaby", Role.FEATURED),
|
||||
],
|
||||
"THE SCOTTS, Travis Scott & Kid Cudi": [
|
||||
("Travis Scott", Role.PRIMARY),
|
||||
("Kid Cudi", Role.PRIMARY),
|
||||
],
|
||||
"Drake Featuring The Throne": [
|
||||
("Drake", Role.PRIMARY),
|
||||
("Jay Z", Role.FEATURED),
|
||||
("Kanye West", Role.FEATURED),
|
||||
],
|
||||
}
|
||||
"""The single-credit exceptions parsed by an explicit lookup
|
||||
rather than by the general rules, because the credit text alone
|
||||
does not spell out the correct member split."""
|
||||
CANONICAL_ARTIST_CREDITS: dict[str, str] = {
|
||||
"benny blanco, Halsey & Khalid": "Benny Blanco, Halsey & Khalid",
|
||||
}
|
||||
"""The canonical artist credit spellings, keyed by a variant
|
||||
credit string."""
|
||||
CANONICAL_ARTIST_NAMES: dict[str, str] = {
|
||||
"beyonce": "Beyoncé",
|
||||
"5 seconds of summer": "5 Seconds of Summer",
|
||||
"a boogie wit da hoodie": "A Boogie wit da Hoodie",
|
||||
"benny blanco": "benny blanco",
|
||||
"blackbear": "blackbear",
|
||||
"chance the rapper": "Chance the Rapper",
|
||||
"xxxtentacion": "XXXTENTACION",
|
||||
"maneskin": "Måneskin",
|
||||
"rose": "ROSÉ",
|
||||
"mo": "MØ",
|
||||
"wizkid": "Wizkid",
|
||||
"ye": "Kanye West",
|
||||
"amine": "Aminé",
|
||||
"bomba estereo": "Bomba Estéreo",
|
||||
"carolina gaitan": "Carolina Gaitán",
|
||||
"casper magico": "Casper Mágico",
|
||||
"eslabon armado": "Eslabón Armado",
|
||||
"jhene aiko": "Jhené Aiko",
|
||||
"neton vega": "Netón Vega",
|
||||
"nio garcia": "Nio García",
|
||||
"oscar maydon": "Óscar Maydon",
|
||||
"silento": "Silentó",
|
||||
"the marias": "The Marías",
|
||||
"victoria monet": "Victoria Monét",
|
||||
"dan": "Dan Smyers",
|
||||
"shay": "Shay Mooney",
|
||||
"cris mj": "Cris MJ",
|
||||
"mariah the scientist": "Mariah the Scientist",
|
||||
"surf mesa": "Surf Mesa",
|
||||
}
|
||||
"""The canonical artist spellings, keyed by the case-folded
|
||||
identity."""
|
||||
|
||||
|
||||
class BuildError(Exception):
|
||||
"""An error that fails the build."""
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"""Parse the command-line arguments.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The parsed arguments.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
description="Rebuild the SQLite working store from the"
|
||||
" committed inputs.")
|
||||
parser.add_argument(
|
||||
"chart_csv", type=Path,
|
||||
help="the year-end chart CSV file")
|
||||
parser.add_argument(
|
||||
"derived_dir", type=Path,
|
||||
help="the output directory for the review CSV files")
|
||||
parser.add_argument(
|
||||
"--lyrics-dir", type=Path, default=None,
|
||||
help="the lyrics cache directory to load")
|
||||
parser.add_argument(
|
||||
"--wikidata-csv", type=Path, default=None,
|
||||
help="the Wikidata artist snapshot CSV file to apply")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
|
||||
"""Parse a combined artist credit into artists and roles.
|
||||
|
||||
A credit listed in ``EXCEPTION_CREDITS`` is looked up verbatim,
|
||||
because its correct split is not derivable from the credit
|
||||
text alone. Otherwise the credit first reduces to an
|
||||
effective credit: a "<group>: <members>" prefix (split at the
|
||||
first ": ") drops the group and keeps the members; failing
|
||||
that, a "<group> (<members>)" suffix spanning the whole credit
|
||||
drops the group and keeps the members. The "Duet With"
|
||||
connector, case-insensitively, then normalizes to "with". The
|
||||
effective credit splits into a primary side and a featured
|
||||
side on the word "featuring" or "feat.", case-insensitively;
|
||||
without them, every artist is primary. Each side splits into
|
||||
artist names on the delimiters ", ", " & ", " + ", " / "
|
||||
(literally) and " and ", " x ", " with " (case-insensitively),
|
||||
except for the names listed in ``PROTECTED_ARTIST_NAMES``,
|
||||
which are never split even though each contains a delimiter
|
||||
word or punctuation.
|
||||
|
||||
Known limitation: a compound act name that contains one of the
|
||||
delimiters, other than the protected names, is over-split.
|
||||
|
||||
:param credit: The combined artist credit string.
|
||||
:return: The (name, role) pairs in credit order, primary side
|
||||
first, with the role ``Role.PRIMARY`` or
|
||||
``Role.FEATURED``.
|
||||
"""
|
||||
if credit in EXCEPTION_CREDITS:
|
||||
return list(EXCEPTION_CREDITS[credit])
|
||||
effective: str = credit
|
||||
colon_match: re.Match[str] | None = COLON_PATTERN.search(
|
||||
effective)
|
||||
if colon_match is not None:
|
||||
effective = effective[colon_match.end():]
|
||||
else:
|
||||
paren_match: re.Match[str] | None = \
|
||||
PAREN_MEMBERS_PATTERN.match(effective)
|
||||
if paren_match is not None:
|
||||
effective = paren_match.group("members")
|
||||
effective = DUET_WITH_PATTERN.sub(" with ", effective)
|
||||
placeholders: dict[str, str] = {}
|
||||
index: int
|
||||
protected: str
|
||||
for index, protected in enumerate(PROTECTED_ARTIST_NAMES):
|
||||
if protected in effective:
|
||||
placeholder: str = f"{index}"
|
||||
placeholders[placeholder] = protected
|
||||
effective = effective.replace(protected, placeholder)
|
||||
sides: list[str] = FEATURING_PATTERN.split(
|
||||
effective, maxsplit=1)
|
||||
pairs: list[tuple[str, Role]] = []
|
||||
role: Role
|
||||
side: str
|
||||
for side, role in zip(sides, (Role.PRIMARY, Role.FEATURED)):
|
||||
token: str
|
||||
for token in DELIMITER_PATTERN.split(side):
|
||||
name: str = token.strip()
|
||||
placeholder = ""
|
||||
original: str
|
||||
for placeholder, original in placeholders.items():
|
||||
name = name.replace(placeholder, original)
|
||||
if name != "":
|
||||
pairs.append((name, role))
|
||||
return pairs
|
||||
|
||||
|
||||
def song_identity(title: str, credit: str) -> tuple[str, str]:
|
||||
"""Compute the identity key of a chart row.
|
||||
|
||||
The key pairs the raw title with the artist credit,
|
||||
canonicalized through ``CANONICAL_ARTIST_CREDITS``; a credit
|
||||
absent from the table maps to itself. Two chart rows denote
|
||||
the same song iff their identity keys are equal.
|
||||
|
||||
:param title: The song title as printed on the chart.
|
||||
:param credit: The combined artist credit string.
|
||||
:return: The identity key: the raw title paired with the
|
||||
canonical artist credit.
|
||||
"""
|
||||
return title, CANONICAL_ARTIST_CREDITS.get(credit, credit)
|
||||
|
||||
|
||||
def resolve_artist_identity(name: str) -> tuple[str, str]:
|
||||
"""Resolve the dedup key and the stored spelling of a name.
|
||||
|
||||
The name's case-folded form is looked up in
|
||||
``CANONICAL_ARTIST_NAMES`` first; when it is listed there, the
|
||||
dedup key is the canonical spelling case-folded and the stored
|
||||
spelling is the canonical spelling, so every variant of the
|
||||
name, canonical or not, resolves to the same identity.
|
||||
Otherwise the dedup key is the name case-folded and the stored
|
||||
spelling is the given name.
|
||||
|
||||
:param name: An artist name, as parsed from a credit.
|
||||
:return: A tuple of the dedup key and the stored spelling.
|
||||
"""
|
||||
folded: str = name.casefold()
|
||||
canonical: str | None = CANONICAL_ARTIST_NAMES.get(folded)
|
||||
if canonical is not None:
|
||||
return canonical.casefold(), canonical
|
||||
return folded, name
|
||||
|
||||
|
||||
def create_song(session: Session, song_id: int, title: str,
|
||||
credit: str, artists: dict[str, Artist]) -> Song:
|
||||
"""Create a song with its parsed artist credits.
|
||||
|
||||
The song takes the given ID. An artist parsed out of the
|
||||
credit is matched against the known artists by its identity
|
||||
key (see `resolve_artist_identity`); a newly seen one takes
|
||||
the ID following the known artists, keyed by its identity key,
|
||||
and its stored name is the resolved stored spelling. An
|
||||
artist duplicated within the credit, by its identity key, is
|
||||
kept only at its first occurrence, with a warning to the
|
||||
standard error.
|
||||
|
||||
:param session: The database session.
|
||||
:param song_id: The song ID to assign.
|
||||
:param title: The song title.
|
||||
:param credit: The combined artist credit string.
|
||||
:param artists: The known artists by identity key, updated
|
||||
with the newly created ones as an observable side effect.
|
||||
:return: The created song, added to the session.
|
||||
"""
|
||||
song: Song = Song(id=song_id, title=title,
|
||||
artist_credit=credit)
|
||||
session.add(song)
|
||||
seen: set[str] = set()
|
||||
position: int = 0
|
||||
name: str
|
||||
role: Role
|
||||
for name, role in parse_artist_credit(credit):
|
||||
key: str
|
||||
stored_name: str
|
||||
key, stored_name = resolve_artist_identity(name)
|
||||
if key in seen:
|
||||
print(f"warning: {credit}: duplicated artist"
|
||||
f" \"{name}\"", file=sys.stderr)
|
||||
continue
|
||||
seen.add(key)
|
||||
if key not in artists:
|
||||
artists[key] = Artist(id=len(artists) + 1,
|
||||
name=stored_name)
|
||||
session.add(SongArtist(song=song, artist=artists[key],
|
||||
role=role, position=position))
|
||||
position += 1
|
||||
return song
|
||||
|
||||
|
||||
def load_chart(session: Session, path: Path) -> None:
|
||||
"""Load the chart CSV into songs, chart entries, and credits.
|
||||
|
||||
A song repeated across the rows is stored once, matched by its
|
||||
identity key (see `song_identity`); every row yields one chart
|
||||
entry. The stored title is the raw title; the stored artist
|
||||
credit is the canonical credit from the identity key. The
|
||||
songs and the artists take the IDs 1, 2, 3, ... in the
|
||||
first-occurrence row order.
|
||||
|
||||
:param session: The database session.
|
||||
:param path: The chart CSV file with the columns year, rank,
|
||||
title, and artist.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be read.
|
||||
"""
|
||||
songs: dict[tuple[str, str], Song] = {}
|
||||
artists: dict[str, Artist] = {}
|
||||
with open(path, encoding="utf-8", newline="") as file:
|
||||
row: dict[str, str]
|
||||
for row in csv.DictReader(file):
|
||||
key: tuple[str, str] = song_identity(
|
||||
row["title"], row["artist"])
|
||||
if key not in songs:
|
||||
title: str
|
||||
credit: str
|
||||
title, credit = key
|
||||
songs[key] = create_song(
|
||||
session, len(songs) + 1, title, credit,
|
||||
artists)
|
||||
session.add(ChartEntry(year=int(row["year"]),
|
||||
rank=int(row["rank"]),
|
||||
song=songs[key]))
|
||||
|
||||
|
||||
def load_lyrics(session: Session, directory: Path) -> None:
|
||||
"""Load the cached lyrics files into the matching songs.
|
||||
|
||||
A file whose stem is not an existing song ID is skipped with
|
||||
a warning to the standard error.
|
||||
|
||||
:param session: The database session, with the songs flushed.
|
||||
:param directory: The existing lyrics cache directory with
|
||||
one ``<song_id>.txt`` file per song.
|
||||
:return: None.
|
||||
:raises OSError: When a lyrics file cannot be read.
|
||||
"""
|
||||
for path in sorted(directory.glob("*.txt")):
|
||||
song: Song | None = None
|
||||
if path.stem.isdigit():
|
||||
song = session.get(Song, int(path.stem))
|
||||
if song is None:
|
||||
print(f"warning: {path}: no song with ID"
|
||||
f" \"{path.stem}\"", file=sys.stderr)
|
||||
continue
|
||||
song.lyrics = path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def apply_artist_csv(session: Session, path: Path) -> None:
|
||||
"""Apply an artist attribute CSV onto the artist rows.
|
||||
|
||||
Artists match by exact name. Only the non-empty cells are
|
||||
applied, field by field. The note column is ignored.
|
||||
|
||||
:param session: The database session, with the artists
|
||||
flushed.
|
||||
:param path: The CSV file with the columns name, qid, gender,
|
||||
type, genre, country, and note.
|
||||
:return: None.
|
||||
:raises BuildError: When a name matches no artist.
|
||||
:raises OSError: When the file cannot be read.
|
||||
"""
|
||||
with open(path, encoding="utf-8", newline="") as file:
|
||||
row: dict[str, str]
|
||||
for row in csv.DictReader(file):
|
||||
artist: Artist | None = session.scalar(
|
||||
sa.select(Artist)
|
||||
.where(Artist.name == row["name"]))
|
||||
if artist is None:
|
||||
raise BuildError(
|
||||
f"{path}: no artist named \"{row['name']}\"")
|
||||
column: str
|
||||
attribute: str
|
||||
for column, attribute in ARTIST_FIELDS.items():
|
||||
if row.get(column):
|
||||
setattr(artist, attribute, row[column])
|
||||
|
||||
|
||||
def find_violations(session: Session, years: Iterable[int],
|
||||
ranks_per_year: int) -> list[str]:
|
||||
"""Find the invariant violations in the loaded data.
|
||||
|
||||
The invariants: the chart entries cover each expected year
|
||||
and rank exactly once and nothing else, every song has at
|
||||
least one primary artist, and every artist name is non-empty.
|
||||
|
||||
:param session: The database session with the loaded data
|
||||
flushed.
|
||||
:param years: The expected chart years.
|
||||
:param ranks_per_year: The expected number of ranks per year.
|
||||
:return: The violation messages, empty when the data is
|
||||
valid.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
expected: set[tuple[int, int]] = {
|
||||
(year, rank) for year in years
|
||||
for rank in range(1, ranks_per_year + 1)}
|
||||
actual: set[tuple[int, int]] = {
|
||||
(x.year, x.rank)
|
||||
for x in session.scalars(sa.select(ChartEntry))}
|
||||
year: int
|
||||
rank: int
|
||||
for year, rank in sorted(expected - actual):
|
||||
violations.append(
|
||||
f"missing chart entry: year {year} rank {rank}")
|
||||
for year, rank in sorted(actual - expected):
|
||||
violations.append(
|
||||
f"unexpected chart entry: year {year} rank {rank}")
|
||||
primary_ids: set[int] = set(session.scalars(
|
||||
sa.select(SongArtist.song_id)
|
||||
.where(SongArtist.role == Role.PRIMARY)))
|
||||
song: Song
|
||||
for song in session.scalars(sa.select(Song).order_by(Song.id)):
|
||||
if song.id not in primary_ids:
|
||||
violations.append(
|
||||
f"song {song.id} \"{song.title}\" has no primary"
|
||||
" artist")
|
||||
artist: Artist
|
||||
for artist in session.scalars(sa.select(Artist)):
|
||||
if artist.name.strip() == "":
|
||||
violations.append(
|
||||
f"artist {artist.id} has an empty name")
|
||||
return violations
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreCounts:
|
||||
"""The row counts of the working store, for the build summary."""
|
||||
|
||||
songs: int
|
||||
"""The number of the songs."""
|
||||
chart_entries: int
|
||||
"""The number of the chart entries."""
|
||||
artists: int
|
||||
"""The number of the artists."""
|
||||
credits: int
|
||||
"""The number of the song-artist credits."""
|
||||
songs_with_lyrics: int
|
||||
"""The number of the songs with lyrics."""
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, session: Session) -> Self:
|
||||
"""Counts the loaded rows and returns the counts.
|
||||
|
||||
:param session: The database session with the loaded data
|
||||
flushed.
|
||||
:return: The row counts of the working store.
|
||||
"""
|
||||
def count(selectable: sa.Select[tuple[int]]) -> int:
|
||||
value: int | None = session.scalar(selectable)
|
||||
assert value is not None
|
||||
return value
|
||||
|
||||
return cls(
|
||||
songs=count(
|
||||
sa.select(sa.func.count()).select_from(Song)),
|
||||
chart_entries=count(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(ChartEntry)),
|
||||
artists=count(
|
||||
sa.select(sa.func.count()).select_from(Artist)),
|
||||
credits=count(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(SongArtist)),
|
||||
songs_with_lyrics=count(
|
||||
sa.select(sa.func.count()).select_from(Song)
|
||||
.where(Song.lyrics.is_not(None))))
|
||||
|
||||
|
||||
def prepare_engine(engine: sa.Engine) -> None:
|
||||
"""Prepare a SQLite engine for a build.
|
||||
|
||||
For a file-based SQLite engine, the parent directory of the
|
||||
database file is created when missing. A non-SQLite engine is
|
||||
left untouched.
|
||||
|
||||
:param engine: The database engine.
|
||||
:return: None.
|
||||
"""
|
||||
if engine.url.get_backend_name() != "sqlite":
|
||||
return
|
||||
database: str | None = engine.url.database
|
||||
if database is not None and database != ":memory:":
|
||||
Path(database).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def reset_store(session: Session) -> None:
|
||||
"""Delete all the rows from every table of the store.
|
||||
|
||||
:param session: The database session.
|
||||
:return: None.
|
||||
"""
|
||||
model: type[Base]
|
||||
for model in (SongArtist, ChartEntry, Song, Artist):
|
||||
session.execute(sa.delete(model))
|
||||
|
||||
|
||||
class CSVExporter:
|
||||
"""Writes the review CSV files mirroring the working store."""
|
||||
|
||||
__SONGS_HEADER: tuple[str, ...] = ("Title", "Artists", "Positions")
|
||||
"""The header row of ``songs.csv``, for human readers."""
|
||||
__ARTISTS_HEADER: tuple[str, ...] = (
|
||||
"Name", "Wikidata QID", "Gender", "Type", "Genre", "Country",
|
||||
"Songs")
|
||||
"""The header row of ``artists.csv``, for human readers."""
|
||||
|
||||
def __init__(self, session: Session, derived_dir: Path) -> None:
|
||||
"""Initialize the exporter.
|
||||
|
||||
:param session: The database session with the loaded data
|
||||
flushed.
|
||||
:param derived_dir: The output directory for the review CSV
|
||||
files.
|
||||
"""
|
||||
self.__session: Session = session
|
||||
self.__derived_dir: Path = derived_dir
|
||||
|
||||
def write(self) -> None:
|
||||
"""Write the review CSV files mirroring the loaded data.
|
||||
|
||||
Fully overwrites ``songs.csv`` and ``artists.csv`` under the
|
||||
output directory, creating it when missing, with normal
|
||||
minimal CSV quoting. Neither file carries a song or an
|
||||
artist ID; a multi-valued field is a plain joined string,
|
||||
itself CSV-quoted as a whole only when its content requires
|
||||
it: "/" joins the chart appearances of one song, and "|"
|
||||
joins the distinct songs credited to one artist.
|
||||
|
||||
:return: None.
|
||||
:raises OSError: When a CSV file cannot be written.
|
||||
"""
|
||||
self.__derived_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.__write_csv(
|
||||
self.__derived_dir / "songs.csv", self.__SONGS_HEADER,
|
||||
self.__songs_rows())
|
||||
self.__write_csv(
|
||||
self.__derived_dir / "artists.csv", self.__ARTISTS_HEADER,
|
||||
self.__artists_rows())
|
||||
|
||||
@staticmethod
|
||||
def __write_csv(path: Path, header: Sequence[str],
|
||||
rows: Iterable[Sequence[str]]) -> None:
|
||||
"""Write a CSV file with LF line endings, fully overwritten.
|
||||
|
||||
:param path: The output CSV file.
|
||||
:param header: The header row.
|
||||
:param rows: The data rows, in the given order.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||
writer: Any = csv.writer(file, lineterminator="\n")
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
|
||||
@staticmethod
|
||||
def __sorted_chart_entries(song: Song) -> list[ChartEntry]:
|
||||
"""Sort the chart entries of a song by year then rank.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The chart entries, ordered by year then rank.
|
||||
"""
|
||||
return sorted(
|
||||
song.chart_entries, key=lambda x: (x.year, x.rank))
|
||||
|
||||
@classmethod
|
||||
def __song_positions(cls, song: Song) -> str:
|
||||
"""Format the chart positions of a song for the songs.csv
|
||||
value.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||
joined by "/".
|
||||
"""
|
||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||
|
||||
@classmethod
|
||||
def __formatted_song_positions(cls, song: Song) -> str:
|
||||
"""Format the chart positions of a song for the artists.csv
|
||||
value.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||
joined by "/".
|
||||
"""
|
||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||
|
||||
def __songs_rows(self) -> list[list[str]]:
|
||||
"""Build the sorted data rows of ``songs.csv``.
|
||||
|
||||
:return: The rows, sorted by the case-folded title, then
|
||||
the case-folded artist credit.
|
||||
"""
|
||||
songs: list[Song] = sorted(
|
||||
self.__session.scalars(sa.select(Song)),
|
||||
key=lambda x: (x.title.casefold(),
|
||||
x.artist_credit.casefold()))
|
||||
rows: list[list[str]] = []
|
||||
song: Song
|
||||
for song in songs:
|
||||
row: list[str] = [
|
||||
song.title, song.artist_credit,
|
||||
self.__song_positions(song)]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
def __artist_songs(cls, artist: Artist) -> str:
|
||||
"""Format the credited songs for the artists.csv value.
|
||||
|
||||
:param artist: The artist with its song credits loaded.
|
||||
:return: The credited songs, each formatted as
|
||||
"TITLE (YEAR#RANK[/YEAR#RANK...])" with its chart
|
||||
appearances, sorted alphabetically case-folded by
|
||||
title, joined by "|".
|
||||
"""
|
||||
songs: list[Song] = sorted(
|
||||
(x.song for x in artist.song_artists),
|
||||
key=lambda x: x.title.casefold())
|
||||
entries: list[str] = [
|
||||
f"{song.title} ({cls.__formatted_song_positions(song)})"
|
||||
for song in songs]
|
||||
return "|".join(entries)
|
||||
|
||||
def __artists_rows(self) -> list[list[str]]:
|
||||
"""Build the sorted data rows of ``artists.csv``.
|
||||
|
||||
:return: The rows, sorted by the case-folded name.
|
||||
"""
|
||||
artists: list[Artist] = sorted(
|
||||
self.__session.scalars(sa.select(Artist)),
|
||||
key=lambda x: x.name.casefold())
|
||||
rows: list[list[str]] = []
|
||||
artist: Artist
|
||||
for artist in artists:
|
||||
row: list[str] = [
|
||||
artist.name, artist.wikidata_qid or "",
|
||||
artist.gender or "", artist.type or "",
|
||||
artist.genre or "", artist.country or "",
|
||||
self.__artist_songs(artist)]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Rebuild the SQLite working store from the inputs.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The exit status: 0 on success, non-zero on failure.
|
||||
"""
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
engine: sa.Engine = ds.engine
|
||||
prepare_engine(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
session: Session = ds.get_db()
|
||||
counts: StoreCounts
|
||||
try:
|
||||
reset_store(session)
|
||||
load_chart(session, args.chart_csv)
|
||||
session.flush()
|
||||
if args.lyrics_dir is not None:
|
||||
if not args.lyrics_dir.is_dir():
|
||||
raise BuildError(
|
||||
f"{args.lyrics_dir}: no such directory")
|
||||
load_lyrics(session, args.lyrics_dir)
|
||||
if args.wikidata_csv is not None:
|
||||
apply_artist_csv(session, args.wikidata_csv)
|
||||
session.flush()
|
||||
violations: list[str] = find_violations(
|
||||
session, YEARS, RANKS_PER_YEAR)
|
||||
if len(violations) > 0:
|
||||
session.rollback()
|
||||
for violation in violations:
|
||||
print(f"error: {violation}", file=sys.stderr)
|
||||
return 1
|
||||
counts = StoreCounts.get_instance(session)
|
||||
CSVExporter(session, args.derived_dir).write()
|
||||
session.commit()
|
||||
except (OSError, BuildError) as error:
|
||||
session.rollback()
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
session.close()
|
||||
print(f"done: {counts.songs} songs,"
|
||||
f" {counts.chart_entries} chart entries,"
|
||||
f" {counts.artists} artists,"
|
||||
f" {counts.credits} credits,"
|
||||
f" {counts.songs_with_lyrics} songs with lyrics",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
@@ -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,858 @@
|
||||
# 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
|
||||
|
||||
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 format_duration(seconds: float) -> str:
|
||||
"""Format an elapsed duration for the closing summary line.
|
||||
|
||||
:param seconds: The elapsed duration, in seconds.
|
||||
:return: The duration formatted ``mm:ss``, or ``h:mm:ss``
|
||||
once it reaches one hour.
|
||||
"""
|
||||
total: int = round(seconds)
|
||||
hours: int
|
||||
remainder: int
|
||||
hours, remainder = divmod(total, 3600)
|
||||
minutes: int
|
||||
secs: int
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""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,315 @@
|
||||
# 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.
|
||||
|
||||
A song that every API misses is reported in the missing lyrics
|
||||
CSV, also given as a positional command-line argument, which is
|
||||
rewritten on every run to reflect the current status. Misses
|
||||
are expected and do not fail the run.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import ds
|
||||
from .models import (
|
||||
Artist,
|
||||
Role,
|
||||
Song,
|
||||
SongArtist,
|
||||
)
|
||||
|
||||
PROVENANCE_FIELDS: Sequence[str] = (
|
||||
"song_id", "source", "method", "acquired_at", "note")
|
||||
"""The header columns of the lyrics provenance CSV file."""
|
||||
MISSING_FIELDS: Sequence[str] = (
|
||||
"song_id", "title", "artist_credit", "reason")
|
||||
"""The header columns of the missing lyrics report CSV file."""
|
||||
USER_AGENT: str = ("pop-fem-audit-tools"
|
||||
" (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."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MissingLyrics:
|
||||
"""One row of the missing lyrics report CSV file."""
|
||||
|
||||
song_id: int
|
||||
"""The song ID."""
|
||||
title: str
|
||||
"""The song title."""
|
||||
artist_credit: str
|
||||
"""The artist credit of the song."""
|
||||
reason: str
|
||||
"""The reason the lyrics are missing."""
|
||||
|
||||
def to_row(self) -> list[str]:
|
||||
"""Return this report entry as a CSV row.
|
||||
|
||||
:return: The row values, in the column order.
|
||||
"""
|
||||
return [str(self.song_id), self.title,
|
||||
self.artist_credit, self.reason]
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"""Parse the command-line arguments.
|
||||
|
||||
: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")
|
||||
parser.add_argument(
|
||||
"missing_csv", type=Path,
|
||||
help="the missing lyrics report 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 write_missing(path: Path,
|
||||
misses: Sequence[MissingLyrics]) -> None:
|
||||
"""Rewrite the missing lyrics report CSV file.
|
||||
|
||||
The previous content is replaced, so the file reflects the
|
||||
current misses only.
|
||||
|
||||
:param path: The missing lyrics report CSV file.
|
||||
:param misses: The report entries of the songs still without
|
||||
lyrics.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8",
|
||||
newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
writer.writerow(MISSING_FIELDS)
|
||||
writer.writerows(x.to_row() for x in misses)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""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.
|
||||
"""
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
fetcher: LyricsFetcher = LyricsFetcher()
|
||||
fetched: int = 0
|
||||
misses: list[MissingLyrics] = []
|
||||
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:
|
||||
misses.append(MissingLyrics(
|
||||
song_id=song.id, title=song.title,
|
||||
artist_credit=song.artist_credit,
|
||||
reason="not found"))
|
||||
print(f"song {song.id} \"{song.title}\": miss",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
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)
|
||||
write_missing(args.missing_csv, misses)
|
||||
except (OSError, sa.exc.SQLAlchemyError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
session.close()
|
||||
print(f"done: {fetched} fetched, {len(misses)} missed",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
@@ -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,653 @@
|
||||
#!/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 runner for one LLM analysis step.
|
||||
|
||||
Sends every input item to the Anthropic Messages Batch API twice with
|
||||
the same system prompt, reconciles the disagreeing items with a third
|
||||
arbitration batch, and archives every artifact self-contained under
|
||||
``<runs_dir>/<phase>/<YYYYMMDD-HHMM>-<prompt-stem>/``, where the
|
||||
base directory of the run archives is given as the positional
|
||||
command-line argument.
|
||||
"""
|
||||
import argparse
|
||||
import enum
|
||||
import hashlib
|
||||
import json
|
||||
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 1.0.0"
|
||||
POLL_INTERVAL_SECONDS: float = 60.0
|
||||
ARBITRATION_TEMPLATE: str = (
|
||||
"<item>\n{content}\n</item>\n"
|
||||
"<run1>\n{run1}\n</run1>\n"
|
||||
"<run2>\n{run2}\n</run2>")
|
||||
|
||||
|
||||
class Source(enum.StrEnum):
|
||||
"""The source of a final record."""
|
||||
|
||||
AGREED = "agreed"
|
||||
"""The record takes the run text the two runs agreed on."""
|
||||
ARBITRATION = "arbitration"
|
||||
"""The record takes the arbitration output."""
|
||||
|
||||
|
||||
class InputFormatError(Exception):
|
||||
"""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 step: 2 runs + 1 arbitration.")
|
||||
parser.add_argument(
|
||||
"runs_dir", type=Path,
|
||||
help="the base directory of the run archives")
|
||||
parser.add_argument(
|
||||
"--prompt", required=True, type=Path,
|
||||
help="the prompt definition file, used as the system prompt")
|
||||
parser.add_argument(
|
||||
"--arbitration-prompt", required=True, type=Path,
|
||||
help="the arbitration prompt definition file")
|
||||
parser.add_argument(
|
||||
"--input", required=True, type=Path,
|
||||
help="the JSONL input file with \"id\" and \"content\"")
|
||||
parser.add_argument(
|
||||
"--phase", required=True,
|
||||
help="the phase name for the 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")
|
||||
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 build_arbitration_content(content: str, run1_text: str,
|
||||
run2_text: str) -> str:
|
||||
"""Build the arbitration user message for one item.
|
||||
|
||||
:param content: The original item content.
|
||||
:param run1_text: The run-1 output text.
|
||||
:param run2_text: The run-2 output text.
|
||||
:return: The user message text.
|
||||
"""
|
||||
return ARBITRATION_TEMPLATE.format(
|
||||
content=content, run1=run1_text, run2=run2_text)
|
||||
|
||||
|
||||
def submit_batch(client: anthropic.Anthropic,
|
||||
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 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 split_by_agreement(
|
||||
items: list[InputItem], run1: Results, run2: Results,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Split the item IDs into agreed and disagreeing ones.
|
||||
|
||||
Two outputs agree when their texts are identical after strip().
|
||||
|
||||
:param items: The input items.
|
||||
:param run1: The run-1 results, keyed by item ID.
|
||||
:param run2: The run-2 results, keyed by item ID.
|
||||
:return: A tuple of the agreed item IDs and the disagreeing item
|
||||
IDs, both in input order.
|
||||
"""
|
||||
agreed: list[str] = []
|
||||
disagreed: list[str] = []
|
||||
for item in items:
|
||||
text1: str | None = run1[item.id].text
|
||||
text2: str | None = run2[item.id].text
|
||||
assert text1 is not None and text2 is not None
|
||||
if text1.strip() == text2.strip():
|
||||
agreed.append(item.id)
|
||||
else:
|
||||
disagreed.append(item.id)
|
||||
return agreed, disagreed
|
||||
|
||||
|
||||
def build_final_records(
|
||||
items: list[InputItem], run1: Results, arbitration: Results,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Assemble the final records, one per item, in input order.
|
||||
|
||||
An arbitrated item takes the arbitration output; an agreed item
|
||||
takes the agreed (stripped) run text.
|
||||
|
||||
:param items: The input items.
|
||||
:param run1: The run-1 results, keyed by item ID.
|
||||
:param arbitration: The arbitration results, keyed by item ID.
|
||||
:return: The final records with "id", "text", and "source".
|
||||
"""
|
||||
records: list[dict[str, str]] = []
|
||||
for item in items:
|
||||
text: str | None
|
||||
if item.id in arbitration:
|
||||
text = arbitration[item.id].text
|
||||
assert text is not None
|
||||
records.append({"id": item.id, "text": text,
|
||||
"source": Source.ARBITRATION})
|
||||
else:
|
||||
text = run1[item.id].text
|
||||
assert text is not None
|
||||
records.append({"id": item.id, "text": text.strip(),
|
||||
"source": Source.AGREED})
|
||||
return records
|
||||
|
||||
|
||||
def create_archive_dir(runs_root: Path, phase: str, prompt_path: Path,
|
||||
now: datetime) -> Path:
|
||||
"""Create the archive directory for this execution.
|
||||
|
||||
:param runs_root: The root directory of the run archives.
|
||||
:param phase: The phase name.
|
||||
:param prompt_path: The path of the prompt definition file.
|
||||
:param now: The local timestamp of this execution.
|
||||
:return: The created archive directory.
|
||||
:raises FileExistsError: When the directory already exists.
|
||||
"""
|
||||
stem: str = prompt_path.stem
|
||||
name: str = f"{now.strftime('%Y%m%d-%H%M')}-{stem}"
|
||||
directory: Path = runs_root / phase / name
|
||||
if directory.exists():
|
||||
raise FileExistsError(
|
||||
f"archive directory {directory} already exists")
|
||||
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`` values under ``batches`` are written as
|
||||
plain JSON objects.
|
||||
|
||||
:param path: The path of the ``meta.json`` file.
|
||||
:param meta: The metadata to write.
|
||||
:return: None.
|
||||
"""
|
||||
write_json(path, {
|
||||
**meta,
|
||||
"batches": {k: asdict(v)
|
||||
for k, v in meta["batches"].items()}})
|
||||
|
||||
|
||||
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_runs(
|
||||
client: anthropic.Anthropic, items: list[InputItem],
|
||||
system_prompt: str, max_tokens: int, meta: dict[str, Any],
|
||||
) -> tuple[Results, Results]:
|
||||
"""Submit the two identical runs and await their results.
|
||||
|
||||
The batch IDs 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 run 1 and run 2, keyed by item ID.
|
||||
"""
|
||||
requests: list[dict[str, Any]] = [
|
||||
build_request(x, system_prompt, max_tokens) for x in items]
|
||||
infos: dict[str, BatchInfo] = {}
|
||||
for run_name in ("run1", "run2"):
|
||||
info: BatchInfo = BatchInfo(
|
||||
batch_id=submit_batch(client, requests),
|
||||
submitted_at=now_iso())
|
||||
infos[run_name] = info
|
||||
meta["batches"][run_name] = info
|
||||
print(f"{run_name}: submitted batch {info.batch_id}",
|
||||
file=sys.stderr)
|
||||
batches: dict[str, Any] = poll_batches(
|
||||
client, [x.batch_id for x in infos.values()])
|
||||
for info in infos.values():
|
||||
info.ended_at = batches[info.batch_id].ended_at.isoformat()
|
||||
return (collect_results(client, infos["run1"].batch_id),
|
||||
collect_results(client, infos["run2"].batch_id))
|
||||
|
||||
|
||||
def execute_arbitration(
|
||||
client: anthropic.Anthropic, items: list[InputItem],
|
||||
disagreed: list[str], run1: Results, run2: Results,
|
||||
system_prompt: str, max_tokens: int, meta: dict[str, Any],
|
||||
) -> Results:
|
||||
"""Submit the arbitration batch and await its results.
|
||||
|
||||
The batch ID and timestamps are recorded into the metadata as an
|
||||
observable side effect.
|
||||
|
||||
:param client: The Anthropic client.
|
||||
:param items: The input items.
|
||||
:param disagreed: The disagreeing item IDs.
|
||||
:param run1: The run-1 results, keyed by item ID.
|
||||
:param run2: The run-2 results, keyed by item ID.
|
||||
:param system_prompt: The arbitration system prompt text.
|
||||
:param max_tokens: The maximum output tokens per request.
|
||||
:param meta: The metadata to record the batch bookkeeping into.
|
||||
:return: The arbitration results, keyed by item ID.
|
||||
"""
|
||||
content_by_id: dict[str, str] = {
|
||||
x.id: x.content for x in items}
|
||||
requests: list[dict[str, Any]] = []
|
||||
for item_id in disagreed:
|
||||
text1: str | None = run1[item_id].text
|
||||
text2: str | None = run2[item_id].text
|
||||
assert text1 is not None and text2 is not None
|
||||
requests.append(build_request(
|
||||
InputItem(id=item_id,
|
||||
content=build_arbitration_content(
|
||||
content_by_id[item_id], text1, text2)),
|
||||
system_prompt, max_tokens))
|
||||
info: BatchInfo = BatchInfo(
|
||||
batch_id=submit_batch(client, requests),
|
||||
submitted_at=now_iso())
|
||||
meta["batches"]["arbitration"] = info
|
||||
print(f"arbitration: 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 step end-to-end.
|
||||
|
||||
: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")
|
||||
arbitration_text: str = args.arbitration_prompt.read_text(
|
||||
encoding="utf-8")
|
||||
except (OSError, InputFormatError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
run_dir: Path = create_archive_dir(
|
||||
args.runs_dir, args.phase, args.prompt,
|
||||
datetime.now())
|
||||
except FileExistsError as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
meta_path: Path = run_dir / "meta.json"
|
||||
(run_dir / "prompt.md").write_bytes(args.prompt.read_bytes())
|
||||
(run_dir / "arbitration_prompt.md").write_bytes(
|
||||
args.arbitration_prompt.read_bytes())
|
||||
meta: dict[str, Any] = {
|
||||
"model": MODEL,
|
||||
"temperature": TEMPERATURE,
|
||||
"max_tokens": args.max_tokens,
|
||||
"thinking": THINKING,
|
||||
"prompt_path": str(args.prompt),
|
||||
"prompt_sha256": sha256_of(args.prompt),
|
||||
"arbitration_prompt_path": str(args.arbitration_prompt),
|
||||
"arbitration_prompt_sha256": sha256_of(
|
||||
args.arbitration_prompt),
|
||||
"batches": {},
|
||||
"item_count": len(items),
|
||||
"agreed_count": None,
|
||||
"agreement_rate": None,
|
||||
"dry_run": args.dry_run,
|
||||
"script_version": SCRIPT_VERSION,
|
||||
}
|
||||
if args.dry_run:
|
||||
write_meta(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 {run_dir}",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
client: anthropic.Anthropic = anthropic.Anthropic(
|
||||
api_key=get_settings().ANTHROPIC_API_KEY)
|
||||
run1: Results
|
||||
run2: Results
|
||||
run1, run2 = execute_runs(
|
||||
client, items, prompt_text, args.max_tokens, meta)
|
||||
item_ids: list[str] = [x.id for x in items]
|
||||
write_jsonl(run_dir / "run1.jsonl",
|
||||
[run1[x].to_record() for x in item_ids if x in run1])
|
||||
write_jsonl(run_dir / "run2.jsonl",
|
||||
[run2[x].to_record() for x in item_ids if x in run2])
|
||||
failed: set[str] = (set(find_failures(item_ids, run1))
|
||||
| set(find_failures(item_ids, run2)))
|
||||
if len(failed) > 0:
|
||||
write_meta(meta_path, meta)
|
||||
names: str = ", ".join(x for x in item_ids if x in failed)
|
||||
print(f"error: failed items: {names}", file=sys.stderr)
|
||||
return 1
|
||||
agreed: list[str]
|
||||
disagreed: list[str]
|
||||
agreed, disagreed = split_by_agreement(items, run1, run2)
|
||||
meta["agreed_count"] = len(agreed)
|
||||
meta["agreement_rate"] = len(agreed) / len(items)
|
||||
arbitration: Results = {}
|
||||
if len(disagreed) > 0:
|
||||
arbitration = execute_arbitration(
|
||||
client, items, disagreed, run1, run2, arbitration_text,
|
||||
args.max_tokens, meta)
|
||||
write_jsonl(run_dir / "arbitration.jsonl",
|
||||
[arbitration[x].to_record() for x in disagreed
|
||||
if x in arbitration])
|
||||
arb_failed: list[str] = find_failures(disagreed, arbitration)
|
||||
if len(arb_failed) > 0:
|
||||
write_meta(meta_path, meta)
|
||||
names = ", ".join(arb_failed)
|
||||
print(f"error: failed arbitration items: {names}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
write_jsonl(run_dir / "final.jsonl",
|
||||
build_final_records(items, run1, arbitration))
|
||||
write_meta(meta_path, meta)
|
||||
print(f"done: {len(items)} items, {len(agreed)} agreed,"
|
||||
f" {len(disagreed)} arbitrated; archived to {run_dir}",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
@@ -0,0 +1,763 @@
|
||||
# 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 build_db, config
|
||||
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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.parse_artist_credit(
|
||||
"Blake Shelton Duet With Gwen Stefani"),
|
||||
[("Blake Shelton", Role.PRIMARY),
|
||||
("Gwen Stefani", Role.PRIMARY)])
|
||||
self.assertEqual(
|
||||
build_db.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.parse_artist_credit(
|
||||
"Cole Swindell / Lainey Wilson"),
|
||||
[("Cole Swindell", Role.PRIMARY),
|
||||
("Lainey Wilson", Role.PRIMARY)])
|
||||
self.assertEqual(
|
||||
build_db.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.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.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.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.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.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.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.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, "YEARS", [2016, 2017]),
|
||||
mock.patch.object(build_db, "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_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_lf_line_endings(self) -> None:
|
||||
"""Test that the derived CSV files use LF line endings."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
raw: bytes = (self.__derived / "songs.csv").read_bytes()
|
||||
self.assertNotIn(b"\r", raw)
|
||||
|
||||
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,615 @@
|
||||
# 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, 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_format_duration_under_hour(self) -> None:
|
||||
"""Test the mm:ss format for a duration under one hour."""
|
||||
self.assertEqual(
|
||||
fetch_artists.format_duration(205), "03:25")
|
||||
|
||||
def test_format_duration_over_hour(self) -> None:
|
||||
"""Test the h:mm:ss format once the duration reaches an
|
||||
hour."""
|
||||
self.assertEqual(
|
||||
fetch_artists.format_duration(6439), "1:47:19")
|
||||
|
||||
def test_summary_line_exact_shape(self) -> None:
|
||||
"""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,281 @@
|
||||
# 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, 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."""
|
||||
MISSING_HEADER: list[str] = [
|
||||
"song_id", "title", "artist_credit", "reason"]
|
||||
"""The expected header row of the missing report 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"
|
||||
self.__missing: Path = self.__dir / "lyrics_missing.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.
|
||||
"""
|
||||
Base.metadata.create_all(self.__ds.engine)
|
||||
session: Session = self.__ds.get_db()
|
||||
try:
|
||||
artists: dict[str, Artist] = {}
|
||||
title: str
|
||||
artist: str
|
||||
for title, artist in songs:
|
||||
if artist not in artists:
|
||||
artists[artist] = Artist(name=artist)
|
||||
song: Song = Song(title=title,
|
||||
artist_credit=artist)
|
||||
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),
|
||||
str(self.__missing)])
|
||||
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("1 fetched, 0 missed", 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_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())
|
||||
rows: list[list[str]] = self.__read_rows(self.__missing)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0], self.MISSING_HEADER)
|
||||
self.assertEqual(rows[1][:3], ["1", "Hello", "Adele"])
|
||||
self.assertIn("0 fetched, 1 missed", stderr)
|
||||
|
||||
def test_cached_song_skipped(self) -> None:
|
||||
"""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")
|
||||
rows: list[list[str]] = self.__read_rows(self.__missing)
|
||||
self.assertEqual(rows, [self.MISSING_HEADER])
|
||||
|
||||
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,130 @@
|
||||
# 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__, 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,516 @@
|
||||
# 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 runner 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, 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"}])
|
||||
|
||||
def test_arbitration_content(self) -> None:
|
||||
"""Test the arbitration user message format."""
|
||||
content: str = run_llm.build_arbitration_content(
|
||||
"the item", "output one", "output two")
|
||||
self.assertEqual(content,
|
||||
"<item>\nthe item\n</item>\n"
|
||||
"<run1>\noutput one\n</run1>\n"
|
||||
"<run2>\noutput two\n</run2>")
|
||||
|
||||
|
||||
class TestAgreement(RunLLMTestCase):
|
||||
"""Test cases for the agreement computation."""
|
||||
|
||||
def test_split_by_agreement(self) -> None:
|
||||
"""Test splitting items into agreed and disagreeing ones."""
|
||||
items: list[run_llm.InputItem] = [
|
||||
run_llm.InputItem(id="a", content="one"),
|
||||
run_llm.InputItem(id="b", content="two"),
|
||||
run_llm.InputItem(id="c", content="three")]
|
||||
run1: run_llm.Results = {
|
||||
"a": run_llm.BatchResult(id="a", text="same\n"),
|
||||
"b": run_llm.BatchResult(id="b", text="left"),
|
||||
"c": run_llm.BatchResult(id="c", text=" padded ")}
|
||||
run2: run_llm.Results = {
|
||||
"a": run_llm.BatchResult(id="a", text="same"),
|
||||
"b": run_llm.BatchResult(id="b", text="right"),
|
||||
"c": run_llm.BatchResult(id="c", text="padded")}
|
||||
agreed: list[str]
|
||||
disagreed: list[str]
|
||||
agreed, disagreed = run_llm.split_by_agreement(
|
||||
items, run1, run2)
|
||||
self.assertEqual(agreed, ["a", "c"])
|
||||
self.assertEqual(disagreed, ["b"])
|
||||
|
||||
|
||||
class TestFinalRecords(RunLLMTestCase):
|
||||
"""Test cases for the final record assembly."""
|
||||
|
||||
def test_build_final_records(self) -> None:
|
||||
"""Test assembling final records from runs and arbitration."""
|
||||
items: list[run_llm.InputItem] = [
|
||||
run_llm.InputItem(id="a", content="one"),
|
||||
run_llm.InputItem(id="b", content="two")]
|
||||
run1: run_llm.Results = {
|
||||
"a": run_llm.BatchResult(id="a", text="agreed text\n"),
|
||||
"b": run_llm.BatchResult(id="b", text="left")}
|
||||
arbitration: run_llm.Results = {
|
||||
"b": run_llm.BatchResult(id="b",
|
||||
text="arbitrated text")}
|
||||
records: list[dict[str, str]] = run_llm.build_final_records(
|
||||
items, run1, arbitration)
|
||||
self.assertEqual(records, [
|
||||
{"id": "a", "text": "agreed text", "source": "agreed"},
|
||||
{"id": "b", "text": "arbitrated text",
|
||||
"source": "arbitration"}])
|
||||
|
||||
|
||||
class TestCollectResults(RunLLMTestCase):
|
||||
"""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"])
|
||||
|
||||
|
||||
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 naming and creation."""
|
||||
now: datetime = datetime(2026, 7, 30, 20, 5)
|
||||
directory: Path = run_llm.create_archive_dir(
|
||||
self.__dir, "coding", Path("prompts/gender_v3.md"), now)
|
||||
self.assertTrue(directory.is_dir())
|
||||
self.assertEqual(
|
||||
directory,
|
||||
self.__dir / "coding" / "20260730-2005-gender_v3")
|
||||
|
||||
def test_existing_archive_dir_rejected(self) -> None:
|
||||
"""Test that an existing archive directory is rejected."""
|
||||
now: datetime = datetime(2026, 7, 30, 20, 5)
|
||||
run_llm.create_archive_dir(
|
||||
self.__dir, "coding", Path("gender_v3.md"), now)
|
||||
with self.assertRaises(FileExistsError):
|
||||
run_llm.create_archive_dir(
|
||||
self.__dir, "coding", Path("gender_v3.md"), now)
|
||||
|
||||
def test_write_jsonl(self) -> None:
|
||||
"""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"
|
||||
prompt: Path = directory / "task_v1.md"
|
||||
prompt.write_text("The task prompt.\n", encoding="utf-8")
|
||||
arbitration: Path = directory / "task_arbitration_v1.md"
|
||||
arbitration.write_text(
|
||||
"The arbitration 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.__runs),
|
||||
"--prompt", str(prompt),
|
||||
"--arbitration-prompt", str(arbitration),
|
||||
"--input", str(self.__input),
|
||||
"--phase", "coding"]
|
||||
self.__settings: config.Settings = config.Settings(
|
||||
SQLALCHEMY_DATABASE_URL="sqlite://",
|
||||
ANTHROPIC_API_KEY="test-key")
|
||||
config.set_settings(self.__settings)
|
||||
|
||||
@staticmethod
|
||||
def __make_client(run1: list[Any], run2: list[Any],
|
||||
arbitration: list[Any] | None = None) \
|
||||
-> mock.Mock:
|
||||
"""Create a mock Anthropic client serving canned batch results.
|
||||
|
||||
:param run1: The result entries of the run-1 batch.
|
||||
:param run2: The result entries of the run-2 batch.
|
||||
:param arbitration: The result entries of the arbitration
|
||||
batch.
|
||||
:return: The mock client.
|
||||
"""
|
||||
client: mock.Mock = mock.Mock()
|
||||
batch_ids: list[str] = ["batch_run1", "batch_run2",
|
||||
"batch_arb"]
|
||||
client.messages.batches.create.side_effect = [
|
||||
mock.Mock(id=x) for x in batch_ids]
|
||||
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
|
||||
results: dict[str, list[Any]] = {
|
||||
"batch_run1": run1, "batch_run2": run2,
|
||||
"batch_arb": arbitration if arbitration is not None
|
||||
else []}
|
||||
client.messages.batches.results.side_effect = (
|
||||
lambda batch_id: iter(results[batch_id]))
|
||||
return client
|
||||
|
||||
@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 __archive_dir(self) -> Path:
|
||||
"""Locate the single archive directory of the run.
|
||||
|
||||
:return: The archive directory.
|
||||
"""
|
||||
directories: list[Path] = list(
|
||||
(self.__runs / "coding").iterdir())
|
||||
self.assertEqual(len(directories), 1)
|
||||
return directories[0]
|
||||
|
||||
def test_dry_run(self) -> None:
|
||||
"""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")
|
||||
self.assertEqual(
|
||||
(run_dir / "arbitration_prompt.md").read_text(
|
||||
encoding="utf-8"), "The arbitration 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.assertFalse((run_dir / "run1.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_all_agreed_skips_arbitration(self) -> None:
|
||||
"""Test that full agreement skips the arbitration batch."""
|
||||
client: mock.Mock = self.__make_client(
|
||||
run1=[self._make_success_entry("a", "answer a"),
|
||||
self._make_success_entry("b", "answer b")],
|
||||
run2=[self._make_success_entry("a", "answer a\n"),
|
||||
self._make_success_entry("b", "answer b")])
|
||||
status: int = self.__run_main(self.__argv, client)[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(
|
||||
client.messages.batches.create.call_count, 2)
|
||||
run_dir: Path = self.__archive_dir()
|
||||
self.assertEqual(
|
||||
(run_dir / "arbitration.jsonl").read_text(
|
||||
encoding="utf-8"), "")
|
||||
final: list[dict[str, Any]] = [
|
||||
json.loads(x) for x in (run_dir / "final.jsonl")
|
||||
.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual(final, [
|
||||
{"id": "a", "text": "answer a", "source": "agreed"},
|
||||
{"id": "b", "text": "answer b", "source": "agreed"}])
|
||||
meta: dict[str, Any] = json.loads(
|
||||
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(meta["agreed_count"], 2)
|
||||
self.assertEqual(meta["agreement_rate"], 1.0)
|
||||
self.assertNotIn("arbitration", meta["batches"])
|
||||
|
||||
def test_disagreement_triggers_arbitration(self) -> None:
|
||||
"""Test that disagreeing items go through arbitration."""
|
||||
client: mock.Mock = self.__make_client(
|
||||
run1=[self._make_success_entry("a", "answer a"),
|
||||
self._make_success_entry("b", "answer b1")],
|
||||
run2=[self._make_success_entry("a", "answer a"),
|
||||
self._make_success_entry("b", "answer b2")],
|
||||
arbitration=[
|
||||
self._make_success_entry("b", "answer b final")])
|
||||
status: int = self.__run_main(self.__argv, client)[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(
|
||||
client.messages.batches.create.call_count, 3)
|
||||
arb_call: mock.call = \
|
||||
client.messages.batches.create.call_args_list[2]
|
||||
arb_requests: list[dict[str, Any]] = \
|
||||
arb_call.kwargs["requests"]
|
||||
self.assertEqual(len(arb_requests), 1)
|
||||
self.assertEqual(arb_requests[0]["custom_id"], "b")
|
||||
self.assertEqual(arb_requests[0]["params"]["system"],
|
||||
"The arbitration prompt.\n")
|
||||
self.assertEqual(
|
||||
arb_requests[0]["params"]["messages"][0]["content"],
|
||||
"<item>\nsecond item\n</item>\n"
|
||||
"<run1>\nanswer b1\n</run1>\n"
|
||||
"<run2>\nanswer b2\n</run2>")
|
||||
run_dir: Path = self.__archive_dir()
|
||||
arb_lines: list[str] = (run_dir / "arbitration.jsonl") \
|
||||
.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(len(arb_lines), 1)
|
||||
self.assertEqual(json.loads(arb_lines[0])["text"],
|
||||
"answer b final")
|
||||
final: list[dict[str, Any]] = [
|
||||
json.loads(x) for x in (run_dir / "final.jsonl")
|
||||
.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual(final, [
|
||||
{"id": "a", "text": "answer a", "source": "agreed"},
|
||||
{"id": "b", "text": "answer b final",
|
||||
"source": "arbitration"}])
|
||||
meta: dict[str, Any] = json.loads(
|
||||
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(meta["agreed_count"], 1)
|
||||
self.assertEqual(meta["agreement_rate"], 0.5)
|
||||
self.assertEqual(meta["batches"]["arbitration"]["batch_id"],
|
||||
"batch_arb")
|
||||
|
||||
def test_run_failure_exits_non_zero(self) -> None:
|
||||
"""Test that a failed item aborts with a non-zero status."""
|
||||
client: mock.Mock = self.__make_client(
|
||||
run1=[self._make_success_entry("a", "answer a"),
|
||||
self._make_error_entry(
|
||||
"b", "invalid_request_error")],
|
||||
run2=[self._make_success_entry("a", "answer a"),
|
||||
self._make_success_entry("b", "answer b")])
|
||||
status: int = self.__run_main(self.__argv, client)[0]
|
||||
self.assertEqual(status, 1)
|
||||
run_dir: Path = self.__archive_dir()
|
||||
self.assertTrue((run_dir / "run1.jsonl").exists())
|
||||
self.assertTrue((run_dir / "run2.jsonl").exists())
|
||||
self.assertTrue((run_dir / "meta.json").exists())
|
||||
self.assertFalse((run_dir / "final.jsonl").exists())
|
||||
run1_lines: list[str] = (run_dir / "run1.jsonl") \
|
||||
.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(json.loads(run1_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())
|
||||
Reference in New Issue
Block a user