Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa530b0df1 |
@@ -0,0 +1,2 @@
|
||||
# Copy to .env and fill in. .env is gitignored.
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
@@ -1 +0,0 @@
|
||||
data/source/*.csv -text
|
||||
+6
-4
@@ -1,11 +1,14 @@
|
||||
# Copyrighted lyrics cache — never commit full lyrics
|
||||
data/captures/lyrics/
|
||||
data/lyrics/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Claude Code local settings (machine/user-specific, not research content)
|
||||
.claude/
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -14,4 +17,3 @@ data/captures/lyrics/
|
||||
# OS / Dropbox artifacts
|
||||
*.conflicted copy*
|
||||
.DS_Store
|
||||
excludes/
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
# Project Conventions
|
||||
# Project Workflow Rules
|
||||
|
||||
## Analysis pipeline
|
||||
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)
|
||||
|
||||
- LLM analysis runs via Python scripts calling the Anthropic
|
||||
Messages API: model `claude-sonnet-4-6`, `temperature=0`,
|
||||
thinking disabled, Batch API where possible.
|
||||
- Prompt definition files live in
|
||||
`prompts/<track>-<step>-<substep>-<task>.md` (e.g.
|
||||
01-01-01-tag.md; the substep number fixes the execution
|
||||
order; no version suffix -- versions live in git history)
|
||||
and are passed verbatim as the system prompt.
|
||||
- LLM steps whose outputs are item-by-item comparable
|
||||
(convergence, coding) run the same definition file twice,
|
||||
then a separate arbitration step settles only the
|
||||
script-computed disagreements ("2 runs + 1 arbitration").
|
||||
Free-generation steps run twice and both outputs are pooled,
|
||||
unarbitrated. If arbitration output is unexpected, revise
|
||||
the definition file and repeat that cycle; never patch
|
||||
results by hand.
|
||||
- Each run of a step is archived self-contained under the
|
||||
destination directory given explicitly on the `run-llm`
|
||||
command line (by convention `runs/<definition-file>/run<N>/`):
|
||||
prompt snapshot, raw output, and `meta.json` (model ID,
|
||||
parameters, timestamps, batch ID). The two runs of a step
|
||||
are two separate invocations of `run-llm`. An arbitration
|
||||
pass is a step of its own with its own archive. Replacing
|
||||
an existing run archive requires an explicit flag;
|
||||
superseded runs live in git history.
|
||||
- Prompt definition files live in `prompts/<task>_v<N>.md` and
|
||||
are passed verbatim as the system prompt.
|
||||
- Every LLM step runs the same definition file twice, then a
|
||||
separate arbitration step reconciles the two outputs
|
||||
("2 runs + 1 arbitration"). If arbitration output is
|
||||
unexpected, revise the definition file and repeat the whole
|
||||
cycle; never patch results by hand.
|
||||
- Every execution is archived self-contained under
|
||||
`runs/<phase>/<date>-<prompt-version>/`: prompt snapshot,
|
||||
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).
|
||||
environment variable (`.env`, gitignored). Never hardcode
|
||||
keys.
|
||||
|
||||
## Data rules
|
||||
|
||||
- `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/`
|
||||
- `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/`
|
||||
(gitignored) and must never be committed or reproduced in
|
||||
full anywhere in the repo.
|
||||
|
||||
@@ -47,4 +48,4 @@
|
||||
cites); `runs/` holds raw audit records. The paper cites
|
||||
`results/` only.
|
||||
- Any change to a definition file, the codebook, or the plan is
|
||||
recorded in `docs/decision-log.md` with date and reason.
|
||||
recorded in `docs/decision_log.md` with date and reason.
|
||||
|
||||
@@ -7,33 +7,24 @@ Hot 100(2016–2025)為例的內容分析。
|
||||
|
||||
## 目錄結構
|
||||
|
||||
見 `docs/project-structure.md`。研究步驟規劃見
|
||||
`docs/research-plan.md`;方法細節見 `docs/methodology.md`;
|
||||
見 `docs/project_structure.md`。研究步驟規劃見
|
||||
`docs/research_plan.md`;方法細節見 `docs/methodology.md`;
|
||||
人工編碼手冊見 `docs/codebook.md`;決策日誌見
|
||||
`docs/decision-log.md`。
|
||||
`docs/decision_log.md`。
|
||||
|
||||
## 重現方式
|
||||
|
||||
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」協定),自由
|
||||
生成步驟兩次執行進池不仲裁。
|
||||
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、
|
||||
thinking 關閉;每步驟獨立執行兩次後由仲裁步驟合併
|
||||
(「2+1」協定)。
|
||||
4. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於
|
||||
`runs/`,可逐筆稽核。論文引用的最終資料表在 `results/`。
|
||||
|
||||
注意:歌詞受版權保護,`data/captures/lyrics/` 不隨 repo 發布,須自行
|
||||
以 `tools/` 中的抓取程式重建。
|
||||
注意:歌詞受版權保護,`data/lyrics/` 不隨 repo 發布,須自行
|
||||
以 `scripts/` 中的抓取程式重建。
|
||||
|
||||
## 授權
|
||||
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
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
|
||||
|
@@ -1,884 +0,0 @@
|
||||
song_id,source,method,acquired_at,note
|
||||
192,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
193,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
194,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
187,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
151,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
195,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
196,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
197,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
198,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
199,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
200,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
201,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
202,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
203,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
204,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
205,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
206,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
207,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
208,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
209,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
146,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
212,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
214,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
215,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
224,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
228,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
245,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
247,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
143,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
251,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
252,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
263,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
267,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
272,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
275,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
277,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
145,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
278,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
279,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
280,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
281,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
261,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
283,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
285,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
286,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
287,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
288,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
289,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
290,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
291,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
293,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
296,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
300,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
302,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
303,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
223,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
307,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
310,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
314,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
315,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
317,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
320,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
323,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
328,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
330,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
331,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
333,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
335,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
337,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
341,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
342,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
344,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
347,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
348,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
353,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
354,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
356,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
220,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
360,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
361,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
362,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
364,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
367,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
332,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
368,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
369,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
370,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
371,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
372,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
373,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
374,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
375,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
376,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
377,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
378,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
379,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
380,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
381,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
382,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
383,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
384,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
385,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
386,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
387,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
388,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
389,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
390,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
391,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
392,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
393,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
394,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
395,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
396,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
397,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
398,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
399,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
295,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
400,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
401,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
402,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
403,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
404,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
405,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
406,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
407,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
408,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
409,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
410,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
411,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
412,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
413,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
339,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
414,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
415,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
416,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
417,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
418,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
419,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
420,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
421,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
422,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
423,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
424,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
425,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
426,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
427,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
428,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
429,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
311,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
430,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
431,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
432,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
433,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
434,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
435,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
436,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
437,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
438,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
439,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
440,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
441,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
442,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
443,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
444,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
445,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
446,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
447,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
448,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
449,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
450,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
451,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
452,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
453,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
454,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
455,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
456,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
457,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
458,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
459,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
460,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
461,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
462,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
463,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
464,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
465,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
466,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
467,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
468,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
469,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
470,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
471,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
472,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
473,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
474,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
475,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
476,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
477,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
478,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
479,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
480,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
481,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
482,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
483,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
484,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
485,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
486,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
487,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
488,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
489,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
490,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
491,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
492,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
493,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
494,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
495,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
496,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
497,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
498,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
499,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
500,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
501,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
502,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
503,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
504,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
505,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
506,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
507,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
508,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
509,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
510,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
511,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
512,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
513,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
514,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
515,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
516,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
517,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
518,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
519,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
520,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
521,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
522,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
523,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
524,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
525,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
526,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
527,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
528,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
529,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
530,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
531,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
532,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
533,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
534,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
535,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
536,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
537,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
538,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
539,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
540,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
541,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
542,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
543,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
544,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
545,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
546,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
547,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
548,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
549,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
550,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
551,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
552,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
553,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
554,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
555,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
556,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
557,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
558,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
559,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
560,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
561,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
562,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
563,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
564,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
565,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
566,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
567,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
568,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
569,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
570,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
571,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
572,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
573,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
574,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
575,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
576,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
577,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
578,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
579,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
580,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
581,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
582,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
583,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
584,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
585,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
586,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
587,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
588,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
589,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
590,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
591,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
592,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
593,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
594,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
595,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
596,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
597,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
598,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
599,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
600,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
601,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
602,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
603,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
604,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
605,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
606,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
607,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
608,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
609,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
610,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
611,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
612,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
613,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
614,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
615,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
616,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
617,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
618,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
619,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
620,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
621,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
622,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
623,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
624,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
625,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
626,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
627,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
628,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
629,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
630,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
631,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
632,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
633,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
634,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
635,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
636,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
637,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
638,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
639,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
640,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
641,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
642,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
643,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
644,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
645,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
646,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
647,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
648,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
649,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
650,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
651,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
652,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
653,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
654,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
655,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
656,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
657,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
658,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
659,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
660,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
661,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
662,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
663,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
664,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
665,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
666,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
667,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
668,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
669,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
670,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
671,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
672,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
673,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
674,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
675,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
676,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
677,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
678,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
679,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
680,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
681,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
682,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
683,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
684,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
685,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
686,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
687,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
688,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
689,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
690,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
691,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
692,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
693,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
694,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
695,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
696,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
697,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
698,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
699,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
700,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
701,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
702,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
703,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
704,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
705,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
706,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
707,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
708,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
709,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
710,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
711,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
712,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
713,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
714,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
715,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
716,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
717,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
718,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
719,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
720,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
721,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
722,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
723,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
724,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
725,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
726,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
727,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
728,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
729,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
730,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
731,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
732,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
733,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
734,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
735,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
736,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
737,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
738,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
739,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
740,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
741,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
742,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
743,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
744,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
745,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
746,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
747,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
748,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
749,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
750,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
751,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
752,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
753,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
754,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
755,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
756,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
757,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
758,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
759,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
760,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
761,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
762,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
763,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
764,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
765,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
766,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
767,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
768,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
769,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
770,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
771,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
772,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
773,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
774,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
775,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
776,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
777,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
778,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
779,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
780,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
781,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
782,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
783,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
784,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
785,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
786,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
787,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
788,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
789,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
790,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
791,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
792,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
793,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
794,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
795,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
796,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
797,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
798,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
799,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
800,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
801,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
802,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
803,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
804,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
805,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
806,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
807,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
808,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
809,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
810,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
811,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
812,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
813,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
814,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
815,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
816,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
817,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
818,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
819,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
820,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
821,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
822,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
823,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
824,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
825,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
826,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
827,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
828,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
829,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
830,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
831,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
832,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
833,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
834,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
835,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
836,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
837,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
838,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
839,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
840,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
841,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
842,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
843,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
844,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
845,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
846,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
847,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
848,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
849,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
850,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
851,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
852,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
853,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
854,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
855,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
856,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
857,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
858,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
859,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
860,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
861,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
862,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
863,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
864,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
865,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
866,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
867,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
868,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
869,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
870,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
871,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
872,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
873,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
874,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
875,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
876,lrclib,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
877,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
878,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
879,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
880,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
881,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
882,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
883,lyrics.ovh,pilot-import,2026-08-04,fetched by the pilot study before 2026-04
|
||||
1,lyrics.ovh,api-fetch,2026-08-04,
|
||||
2,lyrics.ovh,api-fetch,2026-08-04,
|
||||
3,lyrics.ovh,api-fetch,2026-08-04,
|
||||
4,lyrics.ovh,api-fetch,2026-08-04,
|
||||
5,lyrics.ovh,api-fetch,2026-08-04,
|
||||
6,lyrics.ovh,api-fetch,2026-08-04,
|
||||
7,lyrics.ovh,api-fetch,2026-08-04,
|
||||
8,lrclib,api-fetch,2026-08-04,
|
||||
9,lrclib,api-fetch,2026-08-04,
|
||||
10,lyrics.ovh,api-fetch,2026-08-04,
|
||||
11,lyrics.ovh,api-fetch,2026-08-04,
|
||||
12,lyrics.ovh,api-fetch,2026-08-04,
|
||||
13,lyrics.ovh,api-fetch,2026-08-04,
|
||||
14,lyrics.ovh,api-fetch,2026-08-04,
|
||||
15,lyrics.ovh,api-fetch,2026-08-04,
|
||||
16,lyrics.ovh,api-fetch,2026-08-04,
|
||||
17,lyrics.ovh,api-fetch,2026-08-04,
|
||||
18,lyrics.ovh,api-fetch,2026-08-04,
|
||||
19,lrclib,api-fetch,2026-08-04,
|
||||
20,lyrics.ovh,api-fetch,2026-08-04,
|
||||
21,lyrics.ovh,api-fetch,2026-08-04,
|
||||
22,lrclib,api-fetch,2026-08-04,
|
||||
23,lyrics.ovh,api-fetch,2026-08-04,
|
||||
24,lyrics.ovh,api-fetch,2026-08-04,
|
||||
25,lyrics.ovh,api-fetch,2026-08-04,
|
||||
26,lyrics.ovh,api-fetch,2026-08-04,
|
||||
27,lyrics.ovh,api-fetch,2026-08-04,
|
||||
28,lyrics.ovh,api-fetch,2026-08-04,
|
||||
29,lyrics.ovh,api-fetch,2026-08-04,
|
||||
30,lyrics.ovh,api-fetch,2026-08-04,
|
||||
31,lyrics.ovh,api-fetch,2026-08-04,
|
||||
32,lyrics.ovh,api-fetch,2026-08-04,
|
||||
33,lrclib,api-fetch,2026-08-04,
|
||||
34,lrclib,api-fetch,2026-08-04,
|
||||
35,lrclib,api-fetch,2026-08-04,
|
||||
36,lyrics.ovh,api-fetch,2026-08-04,
|
||||
37,lyrics.ovh,api-fetch,2026-08-04,
|
||||
38,lyrics.ovh,api-fetch,2026-08-04,
|
||||
39,lyrics.ovh,api-fetch,2026-08-04,
|
||||
40,lyrics.ovh,api-fetch,2026-08-04,
|
||||
41,lyrics.ovh,api-fetch,2026-08-04,
|
||||
42,lrclib,api-fetch,2026-08-04,
|
||||
43,lyrics.ovh,api-fetch,2026-08-04,
|
||||
44,lyrics.ovh,api-fetch,2026-08-04,
|
||||
45,lyrics.ovh,api-fetch,2026-08-04,
|
||||
46,lyrics.ovh,api-fetch,2026-08-04,
|
||||
47,lyrics.ovh,api-fetch,2026-08-04,
|
||||
48,lrclib,api-fetch,2026-08-04,
|
||||
49,lrclib,api-fetch,2026-08-04,
|
||||
50,lrclib,api-fetch,2026-08-04,
|
||||
51,lyrics.ovh,api-fetch,2026-08-04,
|
||||
52,lyrics.ovh,api-fetch,2026-08-04,
|
||||
53,lyrics.ovh,api-fetch,2026-08-04,
|
||||
54,lyrics.ovh,api-fetch,2026-08-04,
|
||||
55,lyrics.ovh,api-fetch,2026-08-04,
|
||||
56,lyrics.ovh,api-fetch,2026-08-04,
|
||||
57,lyrics.ovh,api-fetch,2026-08-04,
|
||||
58,lyrics.ovh,api-fetch,2026-08-04,
|
||||
59,lyrics.ovh,api-fetch,2026-08-04,
|
||||
60,lyrics.ovh,api-fetch,2026-08-04,
|
||||
61,lyrics.ovh,api-fetch,2026-08-04,
|
||||
62,lyrics.ovh,api-fetch,2026-08-04,
|
||||
63,lrclib,api-fetch,2026-08-04,
|
||||
64,lrclib,api-fetch,2026-08-04,
|
||||
65,lyrics.ovh,api-fetch,2026-08-04,
|
||||
66,lyrics.ovh,api-fetch,2026-08-04,
|
||||
67,lyrics.ovh,api-fetch,2026-08-04,
|
||||
68,lyrics.ovh,api-fetch,2026-08-04,
|
||||
69,lyrics.ovh,api-fetch,2026-08-04,
|
||||
70,lyrics.ovh,api-fetch,2026-08-04,
|
||||
71,lyrics.ovh,api-fetch,2026-08-04,
|
||||
72,lrclib,api-fetch,2026-08-04,
|
||||
73,lyrics.ovh,api-fetch,2026-08-04,
|
||||
74,lyrics.ovh,api-fetch,2026-08-04,
|
||||
75,lyrics.ovh,api-fetch,2026-08-04,
|
||||
76,lyrics.ovh,api-fetch,2026-08-04,
|
||||
77,lyrics.ovh,api-fetch,2026-08-04,
|
||||
78,lyrics.ovh,api-fetch,2026-08-04,
|
||||
79,lyrics.ovh,api-fetch,2026-08-04,
|
||||
80,lyrics.ovh,api-fetch,2026-08-04,
|
||||
81,lyrics.ovh,api-fetch,2026-08-04,
|
||||
82,lyrics.ovh,api-fetch,2026-08-04,
|
||||
83,lyrics.ovh,api-fetch,2026-08-04,
|
||||
84,lyrics.ovh,api-fetch,2026-08-04,
|
||||
85,lyrics.ovh,api-fetch,2026-08-04,
|
||||
86,lyrics.ovh,api-fetch,2026-08-04,
|
||||
87,lyrics.ovh,api-fetch,2026-08-04,
|
||||
88,lrclib,api-fetch,2026-08-04,
|
||||
89,lyrics.ovh,api-fetch,2026-08-04,
|
||||
90,lyrics.ovh,api-fetch,2026-08-04,
|
||||
91,lrclib,api-fetch,2026-08-04,
|
||||
92,lyrics.ovh,api-fetch,2026-08-04,
|
||||
93,lyrics.ovh,api-fetch,2026-08-04,
|
||||
94,lyrics.ovh,api-fetch,2026-08-04,
|
||||
95,lyrics.ovh,api-fetch,2026-08-04,
|
||||
96,lyrics.ovh,api-fetch,2026-08-04,
|
||||
97,lyrics.ovh,api-fetch,2026-08-04,
|
||||
98,lyrics.ovh,api-fetch,2026-08-04,
|
||||
99,lyrics.ovh,api-fetch,2026-08-04,
|
||||
100,lyrics.ovh,api-fetch,2026-08-04,
|
||||
101,lyrics.ovh,api-fetch,2026-08-04,
|
||||
102,lyrics.ovh,api-fetch,2026-08-04,
|
||||
103,lrclib,api-fetch,2026-08-04,
|
||||
104,lyrics.ovh,api-fetch,2026-08-04,
|
||||
105,lyrics.ovh,api-fetch,2026-08-04,
|
||||
106,lyrics.ovh,api-fetch,2026-08-04,
|
||||
107,lyrics.ovh,api-fetch,2026-08-04,
|
||||
108,lyrics.ovh,api-fetch,2026-08-04,
|
||||
109,lyrics.ovh,api-fetch,2026-08-04,
|
||||
110,lrclib,api-fetch,2026-08-04,
|
||||
111,lrclib,api-fetch,2026-08-04,
|
||||
112,lyrics.ovh,api-fetch,2026-08-04,
|
||||
113,lyrics.ovh,api-fetch,2026-08-04,
|
||||
114,lyrics.ovh,api-fetch,2026-08-04,
|
||||
115,lyrics.ovh,api-fetch,2026-08-04,
|
||||
116,lyrics.ovh,api-fetch,2026-08-04,
|
||||
117,lyrics.ovh,api-fetch,2026-08-04,
|
||||
118,lyrics.ovh,api-fetch,2026-08-04,
|
||||
119,lyrics.ovh,api-fetch,2026-08-04,
|
||||
120,lyrics.ovh,api-fetch,2026-08-04,
|
||||
121,lrclib,api-fetch,2026-08-04,
|
||||
122,lyrics.ovh,api-fetch,2026-08-04,
|
||||
123,lyrics.ovh,api-fetch,2026-08-04,
|
||||
124,lrclib,api-fetch,2026-08-04,
|
||||
125,lrclib,api-fetch,2026-08-04,
|
||||
126,lyrics.ovh,api-fetch,2026-08-04,
|
||||
127,lyrics.ovh,api-fetch,2026-08-04,
|
||||
128,lyrics.ovh,api-fetch,2026-08-04,
|
||||
129,lrclib,api-fetch,2026-08-04,
|
||||
130,lyrics.ovh,api-fetch,2026-08-04,
|
||||
131,lyrics.ovh,api-fetch,2026-08-04,
|
||||
132,lyrics.ovh,api-fetch,2026-08-04,
|
||||
133,lyrics.ovh,api-fetch,2026-08-04,
|
||||
134,lyrics.ovh,api-fetch,2026-08-04,
|
||||
135,lyrics.ovh,api-fetch,2026-08-04,
|
||||
136,lrclib,api-fetch,2026-08-04,
|
||||
137,lyrics.ovh,api-fetch,2026-08-04,
|
||||
138,lyrics.ovh,api-fetch,2026-08-04,
|
||||
139,lyrics.ovh,api-fetch,2026-08-04,
|
||||
140,lyrics.ovh,api-fetch,2026-08-04,
|
||||
141,lyrics.ovh,api-fetch,2026-08-04,
|
||||
142,lyrics.ovh,api-fetch,2026-08-04,
|
||||
144,lyrics.ovh,api-fetch,2026-08-04,
|
||||
147,lyrics.ovh,api-fetch,2026-08-04,
|
||||
148,lyrics.ovh,api-fetch,2026-08-04,
|
||||
149,lyrics.ovh,api-fetch,2026-08-04,
|
||||
150,lyrics.ovh,api-fetch,2026-08-04,
|
||||
152,lrclib,api-fetch,2026-08-04,
|
||||
153,lyrics.ovh,api-fetch,2026-08-04,
|
||||
154,lyrics.ovh,api-fetch,2026-08-04,
|
||||
155,lrclib,api-fetch,2026-08-04,
|
||||
156,lyrics.ovh,api-fetch,2026-08-04,
|
||||
157,lyrics.ovh,api-fetch,2026-08-04,
|
||||
158,lyrics.ovh,api-fetch,2026-08-04,
|
||||
159,lyrics.ovh,api-fetch,2026-08-04,
|
||||
160,lyrics.ovh,api-fetch,2026-08-04,
|
||||
161,lyrics.ovh,api-fetch,2026-08-04,
|
||||
162,lyrics.ovh,api-fetch,2026-08-04,
|
||||
163,lyrics.ovh,api-fetch,2026-08-04,
|
||||
164,lyrics.ovh,api-fetch,2026-08-04,
|
||||
165,lyrics.ovh,api-fetch,2026-08-04,
|
||||
166,lyrics.ovh,api-fetch,2026-08-04,
|
||||
167,lyrics.ovh,api-fetch,2026-08-04,
|
||||
168,lyrics.ovh,api-fetch,2026-08-04,
|
||||
169,lyrics.ovh,api-fetch,2026-08-04,
|
||||
170,lyrics.ovh,api-fetch,2026-08-04,
|
||||
171,lrclib,api-fetch,2026-08-04,
|
||||
172,lyrics.ovh,api-fetch,2026-08-04,
|
||||
173,lyrics.ovh,api-fetch,2026-08-04,
|
||||
174,lyrics.ovh,api-fetch,2026-08-04,
|
||||
175,lyrics.ovh,api-fetch,2026-08-04,
|
||||
176,lrclib,api-fetch,2026-08-04,
|
||||
177,lyrics.ovh,api-fetch,2026-08-04,
|
||||
178,lyrics.ovh,api-fetch,2026-08-04,
|
||||
179,lyrics.ovh,api-fetch,2026-08-04,
|
||||
180,lyrics.ovh,api-fetch,2026-08-04,
|
||||
181,lyrics.ovh,api-fetch,2026-08-04,
|
||||
182,lyrics.ovh,api-fetch,2026-08-04,
|
||||
183,lyrics.ovh,api-fetch,2026-08-04,
|
||||
184,lyrics.ovh,api-fetch,2026-08-04,
|
||||
185,lyrics.ovh,api-fetch,2026-08-04,
|
||||
186,lyrics.ovh,api-fetch,2026-08-04,
|
||||
188,lyrics.ovh,api-fetch,2026-08-04,
|
||||
189,lyrics.ovh,api-fetch,2026-08-04,
|
||||
190,lyrics.ovh,api-fetch,2026-08-04,
|
||||
191,lyrics.ovh,api-fetch,2026-08-04,
|
||||
210,lyrics.ovh,api-fetch,2026-08-04,
|
||||
211,lyrics.ovh,api-fetch,2026-08-04,
|
||||
213,lyrics.ovh,api-fetch,2026-08-04,
|
||||
216,lyrics.ovh,api-fetch,2026-08-04,
|
||||
217,lyrics.ovh,api-fetch,2026-08-04,
|
||||
218,lyrics.ovh,api-fetch,2026-08-04,
|
||||
219,lyrics.ovh,api-fetch,2026-08-04,
|
||||
221,lrclib,api-fetch,2026-08-04,
|
||||
222,lyrics.ovh,api-fetch,2026-08-04,
|
||||
225,lyrics.ovh,api-fetch,2026-08-04,
|
||||
226,lyrics.ovh,api-fetch,2026-08-04,
|
||||
227,lrclib,api-fetch,2026-08-04,
|
||||
229,lyrics.ovh,api-fetch,2026-08-04,
|
||||
230,lrclib,api-fetch,2026-08-04,
|
||||
231,lyrics.ovh,api-fetch,2026-08-04,
|
||||
232,lrclib,api-fetch,2026-08-04,
|
||||
233,lyrics.ovh,api-fetch,2026-08-04,
|
||||
234,lyrics.ovh,api-fetch,2026-08-04,
|
||||
235,lyrics.ovh,api-fetch,2026-08-04,
|
||||
236,lyrics.ovh,api-fetch,2026-08-04,
|
||||
237,lyrics.ovh,api-fetch,2026-08-04,
|
||||
238,lyrics.ovh,api-fetch,2026-08-04,
|
||||
239,lyrics.ovh,api-fetch,2026-08-04,
|
||||
240,lyrics.ovh,api-fetch,2026-08-04,
|
||||
241,lyrics.ovh,api-fetch,2026-08-04,
|
||||
242,lyrics.ovh,api-fetch,2026-08-04,
|
||||
243,lyrics.ovh,api-fetch,2026-08-04,
|
||||
244,lyrics.ovh,api-fetch,2026-08-04,
|
||||
246,lyrics.ovh,api-fetch,2026-08-04,
|
||||
248,lyrics.ovh,api-fetch,2026-08-04,
|
||||
249,lyrics.ovh,api-fetch,2026-08-04,
|
||||
250,lyrics.ovh,api-fetch,2026-08-04,
|
||||
253,lyrics.ovh,api-fetch,2026-08-04,
|
||||
254,lyrics.ovh,api-fetch,2026-08-04,
|
||||
255,lrclib,api-fetch,2026-08-04,
|
||||
256,lyrics.ovh,api-fetch,2026-08-04,
|
||||
257,lrclib,api-fetch,2026-08-04,
|
||||
258,lyrics.ovh,api-fetch,2026-08-04,
|
||||
259,lyrics.ovh,api-fetch,2026-08-04,
|
||||
260,lrclib,api-fetch,2026-08-04,
|
||||
262,lyrics.ovh,api-fetch,2026-08-04,
|
||||
264,lrclib,api-fetch,2026-08-04,
|
||||
265,lyrics.ovh,api-fetch,2026-08-04,
|
||||
266,lrclib,api-fetch,2026-08-04,
|
||||
268,lyrics.ovh,api-fetch,2026-08-04,
|
||||
269,lrclib,api-fetch,2026-08-04,
|
||||
270,lyrics.ovh,api-fetch,2026-08-04,
|
||||
271,lyrics.ovh,api-fetch,2026-08-04,
|
||||
273,lyrics.ovh,api-fetch,2026-08-04,
|
||||
274,lyrics.ovh,api-fetch,2026-08-04,
|
||||
276,lyrics.ovh,api-fetch,2026-08-04,
|
||||
282,lyrics.ovh,api-fetch,2026-08-04,
|
||||
284,lyrics.ovh,api-fetch,2026-08-04,
|
||||
292,lrclib,api-fetch,2026-08-04,
|
||||
294,lyrics.ovh,api-fetch,2026-08-04,
|
||||
297,lyrics.ovh,api-fetch,2026-08-04,
|
||||
298,lyrics.ovh,api-fetch,2026-08-04,
|
||||
299,lyrics.ovh,api-fetch,2026-08-04,
|
||||
301,lrclib,api-fetch,2026-08-04,
|
||||
304,lyrics.ovh,api-fetch,2026-08-04,
|
||||
305,lyrics.ovh,api-fetch,2026-08-04,
|
||||
308,lyrics.ovh,api-fetch,2026-08-04,
|
||||
309,lyrics.ovh,api-fetch,2026-08-04,
|
||||
312,lyrics.ovh,api-fetch,2026-08-04,
|
||||
313,lyrics.ovh,api-fetch,2026-08-04,
|
||||
316,lyrics.ovh,api-fetch,2026-08-04,
|
||||
318,lyrics.ovh,api-fetch,2026-08-04,
|
||||
319,lyrics.ovh,api-fetch,2026-08-04,
|
||||
321,lyrics.ovh,api-fetch,2026-08-04,
|
||||
322,lyrics.ovh,api-fetch,2026-08-04,
|
||||
324,lyrics.ovh,api-fetch,2026-08-04,
|
||||
325,lyrics.ovh,api-fetch,2026-08-04,
|
||||
326,lyrics.ovh,api-fetch,2026-08-04,
|
||||
327,lyrics.ovh,api-fetch,2026-08-04,
|
||||
329,lyrics.ovh,api-fetch,2026-08-04,
|
||||
334,lyrics.ovh,api-fetch,2026-08-04,
|
||||
336,lyrics.ovh,api-fetch,2026-08-04,
|
||||
338,lyrics.ovh,api-fetch,2026-08-04,
|
||||
340,lyrics.ovh,api-fetch,2026-08-04,
|
||||
343,lyrics.ovh,api-fetch,2026-08-04,
|
||||
345,lyrics.ovh,api-fetch,2026-08-04,
|
||||
346,lyrics.ovh,api-fetch,2026-08-04,
|
||||
349,lyrics.ovh,api-fetch,2026-08-04,
|
||||
350,lyrics.ovh,api-fetch,2026-08-04,
|
||||
351,lyrics.ovh,api-fetch,2026-08-04,
|
||||
355,lyrics.ovh,api-fetch,2026-08-04,
|
||||
357,lyrics.ovh,api-fetch,2026-08-04,
|
||||
358,lyrics.ovh,api-fetch,2026-08-04,
|
||||
359,lyrics.ovh,api-fetch,2026-08-04,
|
||||
365,lrclib,api-fetch,2026-08-04,
|
||||
366,lyrics.ovh,api-fetch,2026-08-04,
|
||||
306,lyrics.ovh,api-fetch,2026-08-04,
|
||||
352,lrclib,api-fetch,2026-08-04,
|
||||
363,lyrics.ovh,api-fetch,2026-08-04,
|
||||
|
@@ -1,470 +0,0 @@
|
||||
Name,Wikidata QID,Gender,Type,Genre,Country,Songs
|
||||
070 Shake,Q54861040,female,solo,hip-hop; cloud rap; alternative hip-hop,United States,Escapism (2023#48)
|
||||
2 Chainz,Q129668,male,solo,Southern hip-hop; trap music; gangsta rap; dirty south; Atlanta hip-hop; hardcore hip-hop,United States,Big Bank (2018#63)
|
||||
21 Savage,Q25095399,male,solo,hip-hop; trap music,United Kingdom,A Lot (2019#42)|Bank Account (2017#48)|Bartier Cardi (2018#61)|Creepin' (2023#5)|Good Good (2024#79)|Jimmy Cooks (2022#33)|Knife Talk (2021#86/2022#60)|Peaches & Eggplants (2023#94)|Redrum (2024#42)|Rich Flex (2023#14)|Rockstar (2017#56/2018#5)|Spin Bout U (2023#43)
|
||||
24kGoldn,Q83751918,male,solo,pop rap,United States,Mood (2020#47/2021#4)
|
||||
42 Dugg,Q95694810,male,solo,Michigan rap; Midwest hip-hop; trap music; gangsta rap,United States,We Paid (2020#57)
|
||||
4batz,Q124313062,male,solo,contemporary R&B,United States,Act II: Date @ 8 (2024#80)
|
||||
5 Seconds of Summer,Q4641147,,group,pop rock; pop-punk,Australia,Youngblood (2018#36/2019#33)
|
||||
50 Cent,Q6060,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop,United States,The Woo (2020#78)
|
||||
6ix9ine,Q44479972,male,solo,hip-hop; East Coast hip-hop; gangsta rap; trap metal; hardcore hip-hop; SoundCloud rap; punk rap; drill; trap music; urbano music; reggaeton; Brooklyn drill,United States,FEFE (2018#31)|Gummo (2018#56)|Swervin (2019#82)
|
||||
6LACK,Q27886640,male,solo,alternative R&B; contemporary R&B; trap music; cloud rap; industrial music; soul; hip-hop soul,United States,Calling My Phone (2021#33)
|
||||
A Boogie wit da Hoodie,Q27927826,male,solo,hip-hop; trap music,United States,Drowning (2017#81)|Look Back At It (2019#41)|Swervin (2019#82)
|
||||
A$AP Ferg,Q15648254,male,solo,East Coast hip-hop; trap music,United States,Plain Jane (2018#74)
|
||||
A$AP Rocky,Q129910,male,solo,East Coast hip-hop; psychedelic rap; experimental hip-hop; alternative hip-hop; cloud rap; trap music,United States,No Limit (2018#30)
|
||||
Adassa,Q576181,female,solo,reggaeton; urban contemporary; pop music,United States,We Don't Talk About Bruno (2022#24)
|
||||
Adele,Q23215,female,solo,pop music; blue-eyed soul; rhythm and blues; soul; jazz; rock music,United Kingdom,Easy On Me (2022#4)|Hello (2016#7)|Oh My God (2022#72)|Send My Love (To Your New Lover) (2016#26)|Water Under The Bridge (2017#88)|When We Were Young (2016#83)
|
||||
AJR,Q16154058,,group,indie pop; alternative pop; electropop,United States,Bang! (2021#56)
|
||||
Alessia Cara,Q20090091,female,solo,contemporary R&B; indie pop; pop music; soul; alternative R&B,Italy,1-800-273-8255 (2017#31/2018#84)|Here (2016#39)|Scars To Your Beautiful (2017#30)|Stay (2017#17)
|
||||
Alex Warren,Q121842162,male,solo,pop music,United States,Ordinary (2025#7)
|
||||
Aminé,Q27830860,male,solo,West Coast hip-hop; alternative hip-hop; trap music; contemporary R&B,United States,Caroline (2017#60)
|
||||
Anderson .Paak,Q20810369,male,solo,contemporary R&B; hip-hop; soul; funk; pop music,United States,Leave The Door Open (2021#7)|Smokin Out The Window (2022#41)
|
||||
Andrew Choi,Q15229870,male,solo,K-pop; pop music; contemporary R&B,South Korea,Free (2025#92)|Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Anne-Marie,Q25396976,female,solo,pop music; contemporary R&B,United Kingdom,Friends (2018#26)|Rockabye (2017#44)
|
||||
Aqua,Q213388,,group,pop music; bubblegum music; Eurodance,Denmark,Barbie World (2023#46)
|
||||
Ariana Grande,Q151892,female,solo,pop music; contemporary R&B; electronic dance music,United States,"34+35 (2021#21)|7 Rings (2019#7)|Boyfriend (2019#98)|Break Up With Your Girlfriend, I'm Bored (2019#36)|Breathin (2019#72)|Dangerous Woman (2016#36)|Die For You (2023#7)|God Is A Woman (2018#62)|Into You (2016#51)|No Tears Left To Cry (2018#20)|Positions (2021#14)|pov (2021#87)|Rain On Me (2020#48)|Save Your Tears (2021#2/2022#40)|Side To Side (2016#77/2017#43)|Stuck With U (2020#80)|Thank U, Next (2019#12)|We Can't Be Friends (Wait For Your Love) (2024#26)|Yes, And? (2024#92)"
|
||||
Arizona Zervas,Q75124792,male,solo,hip-hop; contemporary R&B; pop music,United States,Roxanne (2020#16)
|
||||
Artemas,Q125176874,male,solo,alternative pop,United Kingdom,I Like The Way You Kiss Me (2024#63)
|
||||
ATR Son Son,Q140814072,male,solo,hip-hop,United States,Rags2Riches (2020#90)
|
||||
Audrey Nuna,Q105538243,female,solo,K-pop; pop music; hip-hop; contemporary R&B; trap music,United States,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||
Ava Max,Q56755505,female,solo,dance-pop,United States,Kings & Queens (2021#73)|Sweet But Psycho (2019#23)
|
||||
Avery Anna,Q131401753,female,solo,pop rock; country music,United States,Indigo (2025#49)
|
||||
Ayo,Q55860029,male,solo,hip-hop,United States,Rolex (2017#61)
|
||||
AzChike,Q131417489,male,solo,hip-hop,United States,Peekaboo (2025#53)
|
||||
Bad Bunny,Q44333953,male,solo,Latin trap; reggaeton,Puerto Rico,Baile Inolvidable (2025#70)|Dakiti (2021#28)|Despues de La Playa (2022#78)|DTMF (2025#51)|Efecto (2022#69)|EOO (2025#77)|I Like It (2018#7/2019#69)|Me Porto Bonito (2022#20)|MIA (2019#44)|Monaco (2024#82)|Moscow Mule (2022#44)|Nuevayol (2025#89)|Ojitos Lindos (2022#87)|Party (2022#77)|Te Bote (2018#81)|Titi Me Pregunto (2022#22)|Un x100to (2023#49)|Where She Goes (2023#72)|Yonaguni (2021#83)
|
||||
Bailey Zimmerman,Q112581616,male,solo,country music,United States,All The Way (2025#33)|Backup Plan (2025#80)|Fall In Love (2022#54)|Religiously (2023#42)|Rock And A Hard Place (2022#70/2023#16)|Where It Ends (2024#87)
|
||||
Bastille,Q689,,group,synth-pop,United Kingdom,Happier (2018#80/2019#6)
|
||||
Bazzi,Q48720231,male,solo,pop music,United States,Beautiful (2019#95)|Mine (2018#21)
|
||||
beabadoobee,Q66817366,female,solo,indie rock; space rock; alternative rock; slacker rock; indie pop; folk-pop; bedroom pop,Philippines,Death Bed (2020#43)
|
||||
Bebe Rexha,Q16185856,female,solo,pop music; contemporary R&B,United States,"I'm Good (Blue) (2023#10)|Me, Myself & I (2016#19)|Meant To Be (2018#3)"
|
||||
Becky G,Q1393997,female,solo,pop music; hip-hop; Latin music; Latin pop; reggaeton,United States,MAMIII (2022#59)
|
||||
BENEE,Q61749159,female,solo,alternative pop; soul,New Zealand,Supalonely (2020#84)
|
||||
benny blanco,Q818048,male,solo,pop music,United States,Eastside (2018#77/2019#17)|Lonely (2021#65)
|
||||
Benson Boone,Q110008128,male,solo,pop music; pop rock,United States,Beautiful Things (2024#3/2025#6)|Mystical Magical (2025#47)|Slow It Down (2024#41)|Sorry I'm Here For Someone Else (2025#37)
|
||||
Beyoncé,Q36153,female,solo,pop music; contemporary R&B; country music,United States,Break My Soul (2022#38)|Cuff It (2023#29)|Mi Gente (2017#50/2018#99)|Sorry (2016#71)|Texas Hold 'Em (2024#32)
|
||||
Big Sean,Q367634,male,solo,hip-hop; Midwest hip-hop; Michigan rap; pop rap,United States,Big Bank (2018#63)|Bounce Back (2017#35)|Feels (2017#74)
|
||||
BigXthaPlug,Q124750337,male,solo,rapping; hip-hop,United States,All The Way (2025#33)
|
||||
Billie Eilish,Q29564107,female,solo,electropop; indie pop; alternative pop,United States,Bad Guy (2019#4/2020#46)|Birds Of A Feather (2024#15/2025#5)|Bury A Friend (2019#73)|Everything I Wanted (2020#18)|Happier Than Ever (2021#94)|Lunch (2024#48)|Therefore I Am (2021#25)|What Was I Made For? (2023#81/2024#46)|When The Party's Over (2019#67)|Wildflower (2024#89/2025#26)
|
||||
Billy Ray Cyrus,Q231259,male,solo,country music; country pop; country rock; country blues,United States,Old Town Road (2019#1)
|
||||
Bipolar Sunshine,Q16727521,male,solo,pop music,United Kingdom,Middle (2016#80)
|
||||
Bizarrap,Q99937928,male,solo,Latin trap; reggaeton,Argentina,"Bzrp Music Sessions, Vol. 53 (2023#96)"
|
||||
Black Eyed Peas,Q134541,,group,alternative hip-hop; pop rap; hip-hop; pop music; Latin hip-hop,United States,RITMO (Bad Boys For Life) (2020#50)
|
||||
blackbear,Q22974007,male,solo,pop music; emo; contemporary R&B; hip-hop,United States,Do Re Mi (2017#98)|Hot Girl Bummer (2020#26)|My Ex's Best Friend (2021#23)
|
||||
Blake Shelton,Q94831,male,solo,country music,United States,God's Country (2019#53)|Nobody But You (2020#52)|Pour Me A Drink (2024#76)
|
||||
Blanco Brown,Q64736729,male,solo,country rap; trap music; hip-hop,United States,Just The Way (2021#88)|The Git Up (2019#56)
|
||||
BlocBoy JB,Q49481016,male,solo,hip-hop; trap music,United States,Look Alive (2018#23)
|
||||
Blueface,Q60463134,male,solo,hip-hop; trap music; West Coast hip-hop; gangsta rap,United States,Thotiana (2019#47)
|
||||
Bobby Helms,Q888554,male,solo,country music,United States,Jingle Bell Rock (2022#86/2023#68/2024#68/2025#81)
|
||||
Bomba Estéreo,Q887911,,group,cumbia,Colombia,Ojitos Lindos (2022#87)
|
||||
Bradley Cooper,Q205707,male,solo,,United States,Shallow (2019#19)
|
||||
Brandon Lake,Q100314823,male,solo,contemporary Christian music; Christian rock,United States,Hard Fought Hallelujah (2025#84)
|
||||
Brenda Lee,Q230925,female,solo,rock and roll; country music; pop music; rockabilly,United States,Rockin' Around The Christmas Tree (2021#92/2022#80/2023#60/2024#59/2025#67)
|
||||
Brendon Urie,Q1047474,male,solo,pop rock; alternative rock,United States,ME! (2019#43)
|
||||
Brett Young,Q25189679,male,solo,country music,United States,In Case You Didn't Know (2017#57)|Mercy (2018#95)
|
||||
BRS Kash,Q105409004,male,solo,hip-hop,United States,Throat Baby (Go Baby) (2021#97)
|
||||
Bruno Mars,Q1450,male,solo,pop music; contemporary R&B; hip-hop; new wave; reggae; funk,United States,24K Magic (2017#16)|APT. (2025#9)|Die With A Smile (2024#62/2025#1)|Finesse (2018#14)|Leave The Door Open (2021#7)|Please Me (2019#37)|Smokin Out The Window (2022#41)|That's What I Like (2017#3)|Wake Up In The Sky (2019#51)
|
||||
Bryson Tiller,Q21066641,male,solo,contemporary R&B; hip-hop; trap music; soul,United States,Don't (2016#35)|Exchange (2016#53)|Whatever She Wants (2024#58)|Wild Thoughts (2017#18)
|
||||
BTS,Q13580495,,group,K-pop; dance-pop; Korean hip-hop,South Korea,Butter (2021#11)|Dynamite (2020#38/2021#41)
|
||||
Burl Ives,Q315723,male,solo,folk music,United States,A Holly Jolly Christmas (2022#89/2023#71/2024#94)
|
||||
Calboy,Q66085272,male,solo,hip-hop,United States,Envy Me (2019#59)
|
||||
Calvin Harris,Q81637,male,solo,electronic dance music; electronic pop; acid house,United Kingdom,Feels (2017#74)|One Kiss (2018#68)|Slide (2017#71)|This Is What You Came For (2016#17)
|
||||
Camila Cabello,Q18810940,female,solo,pop music; contemporary R&B; Latin pop; Gothic pop,Cuba,Bad Things (2017#41)|Bam Bam (2022#66)|Beautiful (2019#95)|Havana (2017#96/2018#4)|I Know What You Did Last Summer (2016#86)|My Oh My (2020#37)|Never Be The Same (2018#18)|Senorita (2019#15/2020#44)
|
||||
Cardi B,Q29033668,female,solo,hip-hop; pop music; pop rap,United States,Bartier Cardi (2018#61)|Be Careful (2018#59)|Bodak Yellow (Money Moves) (2017#24/2018#54)|Clout (2019#87)|Finesse (2018#14)|Girls Like You (2018#10/2019#22)|I Like It (2018#7/2019#69)|Money (2019#38)|MotorSport (2018#34)|No Limit (2018#30)|Please Me (2019#37)|Put It On Da Floor Again (2023#98)|Taki Taki (2019#57)|Tomorrow 2 (2023#70)|Up (2021#26)|WAP (2020#24)
|
||||
Carly Pearce,Q29913337,female,solo,country music,United States,I Hope You're Happy Now (2020#73)
|
||||
Carolina Gaitán,Q5753201,female,solo,,Colombia,We Don't Talk About Bruno (2022#24)
|
||||
Carrie Underwood,Q215546,female,solo,pop music; country music; country pop; country rock,United States,I'm Gonna Love You (2025#78)|If I Didn't Love You (2021#85)|The Fighter (2017#100)
|
||||
Cash Cobain,Q125989634,male,solo,hip-hop; drill; Jersey drill; pop rap; East Coast hip-hop; trap music,United States,Somebody Loves Me (2025#85)
|
||||
Casper Mágico,Q60333702,male,solo,Latin trap; reggaeton,United States,Te Bote (2018#81)
|
||||
Chance the Rapper,Q12470060,male,solo,hip-hop; alternative hip-hop; contemporary R&B; conscious hip-hop; alternative R&B; gospel music,United States,Holy (2021#35)|I'm The One (2017#12)|No Brainer (2018#73)
|
||||
Chappell Roan,Q110281146,female,solo,synth-pop; pop music; electronic music,United States,"Good Luck, Babe! (2024#18/2025#31)|Hot To Go! (2024#53)|Pink Pony Club (2025#10)"
|
||||
Charlie Puth,Q19810390,male,solo,pop music; contemporary R&B,United States,Attention (2017#22)|How Long (2018#65)|I Hope (2020#12/2021#40)|One Call Away (2016#43)|See You Again (2016#99)|We Don't Talk Anymore (2016#50)
|
||||
Chase Rice,Q5087189,male,solo,country music,United States,Drinkin' Beer. Talkin' God. Amen. (2021#99)|Eyes On You (2019#96)
|
||||
Cheat Codes,Q23978754,,group,electronic music; electronic dance music; tropical house; dancehall,United States,No Promises (2017#91)
|
||||
Chencho Corleone,Q113625582,male,solo,reggaeton,United States,Me Porto Bonito (2022#20)
|
||||
Childish Gambino,Q1239933,male,solo,hip-hop; funk; contemporary R&B; soul; psychedelic music; indie pop; comedy hip-hop,United States,Redbone (2017#25)|This Is America (2018#51)
|
||||
Chris Brown,Q155700,male,solo,contemporary R&B; pop music; hip-hop,United States,Back To Sleep (2016#89)|Freaky Friday (2018#55)|Go Crazy (2020#39/2021#19)|No Guidance (2019#21/2020#36)|Residuals (2025#63)|Superhero (Heroes & Villains) (2023#38)|Under The Influence (2023#17)
|
||||
Chris Stapleton,Q5108148,male,solo,country music,United States,Say Something (2018#85)|Starting Over (2021#53)|White Horse (2024#77)|You Should Probably Leave (2022#79)
|
||||
Chris Young,Q952176,male,solo,country music,United States,Famous Friends (2021#69)
|
||||
City Girls,Q60658858,,group,hip-hop,United States,Act Up (2019#68)
|
||||
CJ,Q103868817,male,solo,drill; trap music; East Coast hip-hop; hip-hop,United States,Whoopty (2021#50)
|
||||
CKay,Q98312451,male,solo,Afrobeats,Nigeria,Love Nwantiti (Ah Ah Ah) (2022#55)
|
||||
Clean Bandit,Q11838808,,group,electropop,United Kingdom,Rockabye (2017#44)
|
||||
Cody Johnson,Q17916913,male,solo,country music; country rock,United States,'Til You Can't (2022#34)|I'm Gonna Love You (2025#78)|The Painter (2024#98)
|
||||
Coi Leray,Q106376206,female,solo,hip-hop; pop music,United States,No More Parties (2021#79)|Players (2023#33)
|
||||
Coldplay,Q45188,,group,alternative rock; pop music,United Kingdom,Adventure Of A Lifetime (2016#95)|Hymn For The Weekend (2016#73)|Something Just Like This (2017#5)
|
||||
Cole Swindell,Q15101510,male,solo,country music,United States,Never Say Never (2022#92)|She Had Me At Heads Carolina (2022#46)|Single Saturday Night (2021#95)
|
||||
Corey Kent,Q113452679,male,solo,country music,United States,Wild As Her (2023#93)
|
||||
Cris MJ,Q111425015,male,solo,reggaeton,Chile,Gata Only (2024#52)
|
||||
D.R.A.M.,Q25138504,male,solo,Southern hip-hop; contemporary R&B; trap music,United States,Broccoli (2016#34/2017#70)
|
||||
DaBaby,Q62107457,male,solo,hip-hop; trap music; pop rap; Southern hip-hop,United States,Baby (2019#84)|Beat Box (2021#44)|BOP (2020#29)|Cash Shit (2019#90)|Cry Baby (2021#77)|For The Night (2020#49/2021#32)|My Oh My (2020#37)|Rockstar (2020#5)|Suge (2019#24)|Whats Poppin (2020#13)
|
||||
Daddy Yankee,Q272591,male,solo,reggaeton; Latin hip-hop,United States,Con Calma (2019#65)|Despacito (2017#2)|Dura (2018#93)
|
||||
Daft Punk,Q185828,,group,house music; electronic music; French house; synth-pop; electronic rock; electronica; nu-disco; dance music; leftfield house,France,I Feel It Coming (2017#34)|Starboy (2016#58/2017#20)
|
||||
Dan Smyers,Q57630595,male,solo,country music,United States,"10,000 Hours (2020#23)|All To Myself (2019#97)|Glad You Exist (2021#64)|Speechless (2019#35)|Tequila (2018#32/2019#91)"
|
||||
Daniel Caesar,Q29589083,male,solo,contemporary R&B; soul; neo soul,Canada,Peaches (2021#10)
|
||||
Danny Chung,Q140814119,male,solo,hip-hop; K-pop,United States,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Darell,Q56467651,male,solo,reggaeton; Latin trap,United States,Te Bote (2018#81)
|
||||
Dasha,Q125157260,female,solo,country pop,United States,Austin (2024#27)
|
||||
David Guetta,Q8298,male,solo,electronic dance music; house music; electroclash; electro house,France,I'm Good (Blue) (2023#10)
|
||||
David Kushner,Q117745879,male,solo,,United States,Daylight (2023#62)
|
||||
Daya,Q21005558,female,solo,pop music,United States,"Don't Let Me Down (2016#8)|Hide Away (2016#84)|Sit Still, Look Pretty (2016#78)"
|
||||
dazy,Q140814120,male,solo,pop music,,Sunroof (2022#29)
|
||||
Dean Lewis,Q29905254,male,solo,pop music,Australia,Be Alright (2019#54)
|
||||
Demi Lovato,Q41173,genderfluid,solo,pop rock; pop music; contemporary R&B; power pop; dance-pop; teen pop; electropop; soul; pop-punk; nu metal,United States,No Promises (2017#91)|Sorry Not Sorry (2017#47/2018#64)
|
||||
Desiigner,Q23091498,male,solo,hip-hop; East Coast hip-hop; trap music; mumble rap; hardcore hip-hop,United States,Panda (2016#6)|Tiimmy Turner (2016#98)
|
||||
Diane Guerrero,Q17166318,female,solo,,United States,We Don't Talk About Bruno (2022#24)
|
||||
Diplo,Q533781,male,solo,electronic dance music; trap music; hip-hop; pop music; moombahton; electropop; house music; EDM trap music,United States,Close To Me (2019#58)
|
||||
DJ Khaled,Q80805,male,solo,hip-hop; pop rap,United States,Every Chance I Get (2021#59)|For Free (2016#59)|I'm The One (2017#12)|No Brainer (2018#73)|Popstar (2020#66)|Wild Thoughts (2017#18)
|
||||
DJ Snake,Q16728588,male,solo,electronic dance music; EDM trap music,France,Lean On (2016#85)|Let Me Love You (2016#47/2017#46)|Middle (2016#80)|Taki Taki (2019#57)
|
||||
Djo,Q27064031,male,solo,synth-pop,United States,End Of Beginning (2024#47)
|
||||
DNCE,Q21648421,,group,pop rock,United States,Cake By The Ocean (2016#18)
|
||||
Doechii,Q106629186,female,solo,hip-hop; contemporary R&B; pop music; alternative hip-hop; pop rap,United States,Anxiety (2025#39)|Denial Is A River (2025#75)|What It Is (Block Boy) (2023#76)
|
||||
Doja Cat,Q51120673,female,solo,contemporary R&B; pop music; electronic music; indie pop; hip-hop,United States,34+35 (2021#21)|Agora Hills (2024#17)|Best Friend (2021#29)|Get Into It (Yuh) (2022#68)|I Like You (A Happier Song) (2022#26/2023#75)|Juicy (2020#87)|Kiss Me More (2021#6/2022#90)|Need To Know (2021#46/2022#18)|Paint The Town Red (2023#52/2024#21)|Say So (2020#11)|Streets (2021#67)|Vegas (2022#47)|Woman (2022#21)|You Right (2021#43/2022#45)
|
||||
Don Toliver,Q56513383,male,solo,hip-hop; trap music; contemporary R&B,United States,Lemonade (2021#51)|No Pole (2025#97)
|
||||
Dove Cameron,Q13560423,female,solo,pop music; alternative pop; soul,United States,Boyfriend (2022#51)
|
||||
Drake,Q33240,male,solo,hip-hop; contemporary R&B; pop music; trap music,Canada,Act II: Date @ 8 (2024#80)|Both (2017#83)|Controlla (2016#41)|Fake Love (2017#37)|First Person Shooter (2024#61)|For Free (2016#59)|God's Plan (2018#1)|Going Bad (2019#18)|Hotline Bling (2016#24)|I'm Upset (2018#86)|IDGAF (2024#83)|In My Feelings (2018#9)|Jimmy Cooks (2022#33)|Jumpman (2016#37)|Knife Talk (2021#86/2022#60)|Laugh Now Cry Later (2020#41/2021#45)|Life Is Good (2020#7)|Look Alive (2018#23)|Meltdown (2023#97)|MIA (2019#44)|Money In The Grave (2019#34)|Nice For What (2018#11)|No Guidance (2019#21/2020#36)|Nokia (2025#24)|Nonstop (2018#52)|One Dance (2016#3)|Passionfruit (2017#65)|Pop Style (2016#82)|Popstar (2020#66)|Rich Baby Daddy (2024#45)|Rich Flex (2023#14)|Search & Rescue (2023#45)|Somebody Loves Me (2025#85)|Spin Bout U (2023#43)|Too Good (2016#29)|Toosie Slide (2020#32)|Wait For U (2022#11/2023#83)|Walk It Talk It (2018#43)|Wants And Needs (2021#47)|Way 2 Sexy (2021#48)|What's Next (2021#68)|Work (2016#4)|Yes Indeed (2018#25)|You're Mines Still (2021#58)
|
||||
Dua Lipa,Q21914464,female,solo,pop music; post-disco; house music; funktronica; indie pop; dream pop; synth-pop,United Kingdom,Break My Heart (2020#33)|Cold Heart (PNAU Remix) (2022#10)|Dance The Night (2023#35/2024#100)|Don't Start Now (2020#4)|Houdini (2024#43)|IDGAF (2018#98)|Levitating (2021#1/2022#42)|New Rules (2018#16)|One Kiss (2018#68)|Sweetest Pie (2022#62)|We're Good (2021#90)
|
||||
Duncan Laurence,Q60758679,male,solo,pop music,Kingdom of the Netherlands,Arcade (2021#82)
|
||||
Dustin Lynch,Q5317118,male,solo,country music,United States,Small Town Boy (2017#94)
|
||||
Ed Sheeran,Q47447,male,solo,pop music; folk music; hip-hop; dance music; soul; rock music,United Kingdom,Bad Habits (2021#15/2022#13)|Bam Bam (2022#66)|Beautiful People (2019#50)|Castle On The Hill (2017#40)|Eyes Closed (2023#64)|I Don't Care (2019#16)|Perfect (2018#2)|Shape Of You (2017#1/2018#71)|Shivers (2022#5)
|
||||
EJAE,Q135108208,female,solo,K-pop; pop music; contemporary R&B,South Korea,Free (2025#92)|Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||
Ella Langley,Q127559972,female,solo,country music,United States,Weren't For The Wind (2025#71)
|
||||
Ella Mai,Q29033458,female,solo,contemporary R&B,United Kingdom,Boo'd Up (2018#15)|Trip (2018#92/2019#80)
|
||||
Elle King,Q5364665,female,solo,country music; Americana; blues rock; alternative country,United States,Ex's & Oh's (2016#63)
|
||||
Ellie Goulding,Q31164,female,solo,synth-pop; electropop; indie pop; folktronica; alternative rock; alternative music; dream pop; indietronica,United Kingdom,Close To Me (2019#58)|On My Mind (2016#81)
|
||||
Elton John,Q2808,male,solo,rock music; pop rock; glam rock; soft rock; rhythm and blues,United Kingdom,Cold Heart (PNAU Remix) (2022#10)
|
||||
Em Beihold,Q111333139,female,solo,pop music,United States,Numb Little Bug (2022#32)
|
||||
Emilee,Q98635221,female,solo,pop music,United States,ily (2020#95)
|
||||
Eminem,Q5608,male,solo,hip-hop; horrorcore; hardcore hip-hop; comedy hip-hop; Midwest hip-hop; rap rock,United States,Godzilla (2020#62)|Houdini (2024#39)
|
||||
Encanto Cast,Q140814124,,,,United States,We Don't Talk About Bruno (2022#24)
|
||||
Eric Church,Q975911,male,solo,country music,United States,Does To Me (2020#93)|Hell Of A View (2021#91)
|
||||
ERNEST,Q110442717,male,solo,country music,United States,Cowgirls (2024#29)|Flower Shops (2022#96)
|
||||
Eslabón Armado,Q112702265,,group,regional Mexican,United States,Ella Baila Sola (2023#26)
|
||||
Fat Joe,Q309888,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop; boom bap; mafioso rap,United States,All The Way Up (2016#87)
|
||||
Fetty Wap,Q19361511,male,solo,hip-hop; trap music; pop rap,United States,679 (2016#54)|All In My Head (Flex) (2016#93)
|
||||
Fifth Harmony,Q3087893,,group,pop music,United States,All In My Head (Flex) (2016#93)|Work From Home (2016#16)
|
||||
Fifty Fifty,Q116731010,,group,K-pop; J-pop,South Korea,Cupid (2023#44)
|
||||
Flipp Dinero,Q57584556,male,solo,hip-hop,United States,Leave Me Alone (2019#71)
|
||||
Flo Milli,Q89352069,female,solo,hip-hop,United States,Never Lose Me (2024#49)
|
||||
Flo Rida,Q213538,male,solo,hip-hop; pop music; dance music; electronic dance music; Southern hip-hop; contemporary R&B; house music; electro hop; electro house; electropop; electro; EDM trap music; trap music; bass music; crunk; pop rap; dirty south; dance-pop; hip house; Miami bass; electronic music; techno; trance,United States,My House (2016#14)
|
||||
Florida Georgia Line,Q3644642,,group,country music,United States,Drinkin' Beer. Talkin' God. Amen. (2021#99)|H.O.L.Y. (2016#49)|Lil Bit (2021#70)|Meant To Be (2018#3)|Simple (2018#82)|Talk You Out Of It (2019#94)
|
||||
FloyyMenor,Q125819969,male,solo,reggaeton,Chile,Gata Only (2024#52)
|
||||
Flume,Q4038837,male,solo,electronica; trip hop; future bass,Australia,Never Be Like You (2016#60)
|
||||
Frank Ocean,Q357645,male,solo,alternative R&B; psychedelic soul; progressive soul; contemporary R&B; pop music; soul; hip-hop; avant-pop,United States,Slide (2017#71)
|
||||
French Montana,Q1455261,male,solo,East Coast hip-hop; gangsta rap; hardcore hip-hop,Morocco,All The Way Up (2016#87)|Unforgettable (2017#15)
|
||||
Fuerza Regida,Q118466501,,group,regional Mexican; banda music; corrido; corridos tumbados; sierreña; norteño,United States,Bebe Dame (2023#73)|Tu Boda (2025#87)
|
||||
Future,Q3445057,male,solo,Southern hip-hop; trap music; mumble rap; contemporary R&B,United States,Cold (2017#69)|Jumpman (2016#37)|King's Dead (2018#79)|Life Is Good (2020#7)|Like That (2024#14)|Low Life (2016#30)|Mask Off (2017#14)|Puffin On Zootiez (2022#84)|Pushin P (2022#48)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)|Wait For U (2022#11/2023#83)|Way 2 Sexy (2021#48)|Wicked (2016#97)
|
||||
G-Eazy,Q5511893,male,solo,hip-hop,United States,"Him & I (2018#45)|Me, Myself & I (2016#19)|No Limit (2018#30)"
|
||||
Gabby Barrett,Q53912835,female,solo,country pop,United States,I Hope (2020#12/2021#40)|The Good Ones (2021#62)
|
||||
GAYLE,Q109768702,female,solo,pop-punk; pop rock; power pop; emo pop; alternative pop; alternative rock,United States,abcdefu (2022#17)
|
||||
Gigi Perez,Q130235359,female,solo,indie folk,United States,Sailor Song (2025#36)
|
||||
Giveon,Q96418479,male,solo,contemporary R&B,United States,Heartbreak Anniversary (2021#31)|Peaches (2021#10)
|
||||
Glass Animals,Q16839267,,group,indie rock; psychedelic pop; indietronica; alternative pop; electronic rock; psychedelic rock,United Kingdom,Heat Waves (2021#16/2022#1)
|
||||
GloRilla,Q114132368,female,solo,hip-hop; trap music; crunk; Memphis rap; Southern hip-hop,United States,Sticky (2025#41)|Tomorrow 2 (2023#70)|Wanna Be (2024#40)|Whatchu Kno About Me (2025#48)|Yeah Glo! (2024#44)
|
||||
gnash,Q23978226,male,solo,pop music; hip-hop; alternative R&B; indie folk,United States,I Hate U I Love U (2016#38)|Lights Down Low (2018#66)
|
||||
Gracie Abrams,Q106301562,female,solo,bedroom pop; pop music,United States,That's So True (2025#14)
|
||||
Grey,Q28451953,,group,electronic music; pop music; future bass,United States,Starving (2016#94/2017#82)|The Middle (2018#8)
|
||||
Grupo Frontera,Q118141487,,group,regional Mexican; norteño; cumbia norteña mexicana,United States,Bebe Dame (2023#73)|Un x100to (2023#49)
|
||||
Gucci Mane,Q206032,male,solo,Southern hip-hop; trap music; gangsta rap; hardcore hip-hop; dirty south; Atlanta hip-hop; dirty rap,United States,Black Beatles (2017#19)|Both (2017#83)|I Get The Bag (2017#93/2018#72)|Slippery (2017#86)|Wake Up In The Sky (2019#51)
|
||||
Gunna,Q55613105,male,solo,hip-hop; trap music; Southern hip-hop; contemporary R&B,United States,Drip Too Hard (2019#26)|Fukumean (2023#31/2024#85)|Hot (2020#72)|Lemonade (2021#51)|Pushin P (2022#48)|What Happened To Virgil (2022#83)
|
||||
Gus Dapperton,Q51858412,male,solo,pop music,United States,Supalonely (2020#84)
|
||||
Gwen Stefani,Q483379,female,solo,pop music; contemporary R&B; ska; electronica; new wave; rock music,United States,Nobody But You (2020#52)
|
||||
H.E.R.,Q30639849,female,solo,contemporary R&B,United States,Slide (2020#99)
|
||||
Hailee Steinfeld,Q231726,female,solo,pop music; dance-pop,United States,Starving (2016#94/2017#82)
|
||||
Halsey,Q18534249,female,solo,pop music; electropop; synth-pop; art pop; alternative pop; alternative rock; alternative R&B; indie pop; contemporary R&B,United States,Bad At Love (2018#27)|Closer (2016#10/2017#7)|Eastside (2018#77/2019#17)|Him & I (2018#45)|Now Or Never (2017#59)|Without Me (2019#3)|You Should Be Sad (2020#82)
|
||||
HARDY,Q63341424,male,solo,country music,United States,Truck Bed (2024#67)|Wait In The Truck (2023#54)
|
||||
Harry Styles,Q3626966,male,solo,soft rock; pop music; rock music; Britpop; traditional folk music,United Kingdom,Adore You (2020#6)|As It Was (2022#2/2023#15)|Late Night Talking (2022#25)|Sign Of The Times (2017#87)|Watermelon Sugar (2020#20)
|
||||
Hozier,Q16233232,male,solo,blues; blues rock; rhythm and blues; traditional folk music; soul; indie rock,Ireland,Too Sweet (2024#10/2025#34)
|
||||
Hudson Westbrook,Q134125831,male,solo,country music,United States,House Again (2025#95)
|
||||
iann dior,Q65631841,male,solo,emo rap; pop rap; rap rock; pop rock,United States,Mood (2020#47/2021#4)
|
||||
Ice Spice,Q114114977,female,solo,Bronx drill; hip-hop; East Coast hip-hop; Jersey club; pop rap,United States,"Barbie World (2023#46)|Boy's A Liar, Pt. 2 (2023#20)|Karma (2023#27)|Princess Diana (2023#69)"
|
||||
Imagine Dragons,Q391348,,group,pop rock; electropop; alternative rock; new wave,United States,Believer (2017#9/2018#100)|Enemy (2022#15)|Natural (2018#69)|Sucker For Pain (2016#68)|Thunder (2017#51/2018#22)|Whatever It Takes (2018#37)
|
||||
Infared,Q140814135,male,solo,hip-hop,,All The Way Up (2016#87)
|
||||
Internet Money,Q99691610,,,hip-hop,United States,Lemonade (2021#51)
|
||||
J Balvin,Q966845,male,solo,reggaeton; Latin pop; Latin trap,Colombia,I Like It (2018#7/2019#69)|Mi Gente (2017#50/2018#99)|RITMO (Bad Boys For Life) (2020#50)|X (2018#90)
|
||||
J. Cole,Q204018,male,solo,hip-hop; Southern hip-hop; pop rap; East Coast hip-hop; conscious hip-hop,United States,All My Life (2023#25)|First Person Shooter (2024#61)|Middle Child (2019#25)|The London (2019#64)
|
||||
Jack Harlow,Q59727812,male,solo,hip-hop; pop rap,United States,First Class (2022#6)|Industry Baby (2021#24/2022#16)|Lovin On Me (2024#5)|Whats Poppin (2020#13)
|
||||
Jake Scott,Q140814152,male,solo,pop music,United States,She Likes It (2022#91)
|
||||
James Arthur,Q717626,male,solo,pop music,United Kingdom,Say You Won't Let Go (2017#11)
|
||||
James Bay,Q17811637,male,solo,rock music; pop music; soul; Americana,United Kingdom,Let It Go (2016#44)
|
||||
James Blake,Q350362,male,solo,pop music; electronica; post-dubstep; electronic dance music,United Kingdom,King's Dead (2018#79)
|
||||
Jason Aldean,Q195439,male,solo,country music,United States,Got What I Got (2020#60)|If I Didn't Love You (2021#85)|Trouble With A Heartbreak (2022#88)|Try That In A Small Town (2023#66)|You Make It Easy (2018#70)
|
||||
Jason Derulo,Q243639,male,solo,pop music; rhythm and blues; contemporary R&B; hip-hop,United States,Savage Love (Laxed - Siren Beat) (2020#35)|Swalla (2017#85)
|
||||
Jawsh 685,Q96472235,male,solo,pop music,New Zealand,Savage Love (Laxed - Siren Beat) (2020#35)
|
||||
Jay Rock,Q152632,male,solo,West Coast hip-hop,United States,King's Dead (2018#79)
|
||||
Jay Z,Q62766,male,solo,East Coast hip-hop; mafioso rap; gangsta rap; hardcore hip-hop,United States,All The Way Up (2016#87)|Pop Style (2016#82)
|
||||
Jelly Roll,Q6176881,male,solo,Southern hip-hop; country rap; country music; hardcore hip-hop,United States,Hard Fought Hallelujah (2025#84)|I Am Not Okay (2024#75/2025#79)|Liar (2025#86)|Need A Favor (2023#34/2024#64)|Save Me (2024#65)|Wild Ones (2024#36)
|
||||
Jeremih,Q472891,male,solo,contemporary R&B,United States,Oui (2016#55)
|
||||
Jessica Darrow,Q109718598,non-binary,solo,,United States,Surface Pressure (2022#53)
|
||||
Jessie Murph,Q122157373,female,solo,country rock; country pop; pop music,United States,Blue Strips (2025#52)|High Road (2024#81/2025#55)|Wild Ones (2024#36)
|
||||
Jhay Cortez,Q75997191,male,solo,Latin trap; reggaeton; Latin R&B,United States,Dakiti (2021#28)
|
||||
Jhené Aiko,Q15929781,female,solo,hip-hop; pop music; rhythm and blues; soul; alternative R&B; neo soul; contemporary R&B,United States,P*$$y Fairy (OTW) (2020#94)
|
||||
JID,Q29588830,male,solo,hip-hop,United States,Enemy (2022#15)
|
||||
Jimin,Q20947093,male,solo,K-pop,South Korea,Who (2025#57)
|
||||
JNR CHOI,Q111507858,male,solo,hip-hop; trap music; world music,United Kingdom,To The Moon! (2022#97)
|
||||
John Legend,Q44857,male,solo,soul; pop music; contemporary R&B,United States,Like I'm Gonna Lose You (2016#42)
|
||||
Joji,Q23826617,male,solo,contemporary R&B; alternative R&B; trip hop; lo-fi music; surreal humour; satirical music; satire; comedy hip-hop; black comedy; alternative rock; comedy music,Japan,Glimpse Of Us (2022#52)
|
||||
Jon Bellion,Q18044284,male,solo,pop music; contemporary R&B; hip-hop; indie rock; pop rap; alternative hip-hop; indie pop; alternative rock; dance-pop; soul,United States,All Time Low (2017#75)
|
||||
Jon Pardi,Q6271354,male,solo,country music,United States,Last Night Lonely (2022#95)
|
||||
Jonas Brothers,Q134233,,group,pop music; pop rock,United States,Leave Before You Love Me (2021#63)|Only Human (2019#78/2020#77)|Sucker (2019#10)
|
||||
Jordan Davis,Q44274417,male,solo,country pop; country music; pop music,United States,Buy Dirt (2022#49)|Next Thing You Know (2023#47)|What My World Spins Around (2023#91)
|
||||
JP Saxe,Q86009464,male,solo,pop music,Canada,If The World Was Ending (2020#56)
|
||||
Juice WRLD,Q52151598,male,solo,hip-hop; emo rap; alternative rock; trap music; pop rap; SoundCloud rap; contemporary R&B; alternative hip-hop; mumble rap,United States,Bandit (2020#63)|Come & Go (2020#54)|Godzilla (2020#62)|Lucid Dreams (2018#12/2019#48)|Robbery (2019#100)|Suicidal (2020#75)|Wishing Well (2020#92)
|
||||
Juicy J,Q602779,male,solo,Southern hip-hop; gangsta rap; trap music; dirty rap; Memphis rap; dirty south; horrorcore; hardcore hip-hop; crunk,United States,Powerglide (2018#97)
|
||||
Julia Michaels,Q20051697,female,solo,pop music; electronic dance music,United States,If The World Was Ending (2020#56)|Issues (2017#29)
|
||||
Jung Kook,Q22338877,male,solo,K-pop,South Korea,Seven (2023#82)
|
||||
Justin Bieber,Q34086,male,solo,pop music; contemporary R&B; tropical house; reggaeton; contemporary country,Canada,"10,000 Hours (2020#23)|Anyone (2021#74)|Cold Water (2016#25)|Daisies (2025#60)|Despacito (2017#2)|Essence (2021#60/2022#64)|Ghost (2022#8)|Holy (2021#35)|I Don't Care (2019#16)|I'm The One (2017#12)|Intentions (2020#17)|Let Me Love You (2016#47/2017#46)|Lonely (2021#65)|Love Yourself (2016#1)|No Brainer (2018#73)|Peaches (2021#10)|Sorry (2016#2)|Stay (2021#12/2022#3)|Stuck With U (2020#80)|What Do You Mean? (2016#31)|Yummy (2020#58)"
|
||||
Justin Timberlake,Q43432,male,solo,pop music; contemporary R&B,United States,Can't Stop The Feeling! (2016#9/2017#49)|Say Something (2018#85)
|
||||
JVKE,Q114865231,male,solo,pop music,United States,Golden Hour (2023#41)
|
||||
Kacey Musgraves,Q2783855,female,solo,country music; folk music,United States,I Remember Everything (2023#74/2024#9)
|
||||
Kai,Q6347342,female,solo,pop music,Canada,Never Be Like You (2016#60)
|
||||
Kali Uchis,Q19004484,female,solo,contemporary R&B,United States,Telepatia (2021#49)
|
||||
Kaliii,Q115633164,female,solo,hip-hop; trap music; Southern hip-hop,United States,Area Codes (2023#88)
|
||||
Kane Brown,Q21620939,male,solo,country music,United States,Be Like That (2020#88)|Bury Me In Georgia (2023#89)|Famous Friends (2021#69)|Good As You (2019#86)|Heaven (2018#53)|Homesick (2020#89)|Like I Love Country Music (2022#85)|Miles On It (2024#34)|One Mississippi (2022#99)|One Thing Right (2019#89)|Thank God (2023#23)|What Ifs (2017#72)
|
||||
Kanye West,Q15935,male,solo,hip-hop; pop music; electro; soul; gospel music; progressive rap; art pop; pop rap; hip-hop soul; chipmunk soul; contemporary R&B,United States,Carnival (2024#38)|Pop Style (2016#82)
|
||||
Karol G,Q22001324,female,solo,reggaeton; Latin pop; Latin R&B,Colombia,MAMIII (2022#59)|Provenza (2022#63)|TQG (2023#65)
|
||||
Kate Bush,Q636,female,solo,art pop; progressive pop; singer-songwriter music; baroque pop; experimental pop; art rock; pop rock,United Kingdom,Running Up That Hill (A Deal With God) (2022#23)
|
||||
Katelyn Brown,Q140814175,female,solo,pop music,United States,Thank God (2023#23)
|
||||
Katy Perry,Q42493,female,solo,pop music; rock music; pop rock; dance music; synth-pop; electropop; gospel music,United States,Chained To The Rhythm (2017#73)|Con Calma (2019#65)|Feels (2017#74)
|
||||
Kehlani,Q20657541,non-binary,solo,contemporary R&B; pop music; neo soul; hip-hop,United States,Folded (2025#98)
|
||||
Keith Urban,Q315547,male,solo,country music,Australia,One Too Many (2021#81)|The Fighter (2017#100)
|
||||
Kendrick Lamar,Q130798,male,solo,West Coast hip-hop; progressive rap; alternative hip-hop; jazz rap; conscious hip-hop; gangsta rap; hardcore hip-hop; jazz; pop music,United States,30 For 30 (2025#21)|All The Stars (2018#47)|DNA. (2017#62)|Don't Wanna Know (2017#38)|Euphoria (2024#66)|Humble. (2017#4)|King's Dead (2018#79)|Like That (2024#14)|Love. (2018#50)|Loyalty. (2017#66)|Luther (2025#2)|Not Like Us (2024#6/2025#17)|Peekaboo (2025#53)|Pray For Me (2018#40)|Squabble Up (2025#20)|TV Off (2025#15)
|
||||
Kenny Chesney,Q467526,male,solo,country music,United States,Get Along (2018#87)
|
||||
Kent Jones,Q25190082,male,solo,pop rap; contemporary R&B; pop music,United States,Don't Mind (2016#48)
|
||||
Kesha,Q33605,female,solo,electropop; dance-pop; synth-pop; pop rock; pop rap; rock music; dance music; country music,United States,Praying (2017#67)
|
||||
Kevin Gates,Q6396353,male,solo,hip-hop; gangsta rap; hardcore hip-hop; dirty south; Southern hip-hop,United States,2 Phones (2016#57)|Really Really (2016#92)
|
||||
Kevin Woo,Q6397789,male,solo,K-pop,United States,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Khalid,Q28816483,male,solo,contemporary R&B,United States,1-800-273-8255 (2017#31/2018#84)|Be Like That (2020#88)|Beautiful People (2019#50)|Better (2019#20)|Eastside (2018#77/2019#17)|Location (2017#21)|Love Lies (2018#19/2019#88)|Talk (2019#8)|Young Dumb & Broke (2017#78/2018#67)
|
||||
Kid Cudi,Q299138,male,solo,Midwest hip-hop; contemporary R&B; alternative hip-hop; pop rap; alternative rock; trip hop; neo-psychedelia; punk rock; grunge; emo rap; psychedelic rap; psychedelic hip-hop,United States,The Scotts (2020#86)
|
||||
Kiiara,Q23042767,female,solo,pop music; electropop; alternative R&B; experimental pop,United States,Gold (2016#52)
|
||||
Kim Petras,Q60964,trans woman,solo,pop music,Germany,Unholy (2022#98/2023#11)
|
||||
Kodak Black,Q22005867,male,solo,hip-hop; trap music; SoundCloud rap; gangsta rap; Southern hip-hop; mumble rap,United States,Drowning (2017#81)|Super Gremlin (2022#9)|Tunnel Vision (2017#55)|Wake Up In The Sky (2019#51)|What It Is (Block Boy) (2023#76)|ZEZE (2019#31)
|
||||
Koe Wetzel,Q107434048,male,solo,country music; rock music,United States,High Road (2024#81/2025#55)
|
||||
Kygo,Q16845512,male,solo,tropical house; deep house; downtempo; progressive house; house music; electronic dance music,Norway,It Ain't Me (2017#27)
|
||||
Kyla,Q25190133,female,solo,UK funky,United Kingdom,One Dance (2016#3)
|
||||
KYLE,Q28606665,male,solo,West Coast hip-hop; pop rap,United States,iSpy (2017#28)
|
||||
Lady Gaga,Q19848,female,solo,electropop; dance-pop; electronic dance music; art pop; pop music; synth-pop,United States,Abracadabra (2025#56)|Bloody Mary (2023#99)|Die With A Smile (2024#62/2025#1)|Rain On Me (2020#48)|Shallow (2019#19)
|
||||
Lainey Wilson,Q106378786,female,solo,country music; folk music,United States,Heart Like A Truck (2023#58)|Never Say Never (2022#92)|Save Me (2024#65)|Things A Man Oughta Know (2021#96)|Wait In The Truck (2023#54)|Watermelon Moonshine (2023#100)
|
||||
Latto,Q65029930,female,solo,hip-hop; trap music; pop music; pop rap,United States,Big Energy (2022#7)|Put It On Da Floor Again (2023#98)|Seven (2023#82)
|
||||
Lauren Alaina,Q271796,female,solo,country music; folk music,United States,What Ifs (2017#72)
|
||||
Lauren Daigle,Q18685595,female,solo,contemporary Christian music,United States,You Say (2019#60)
|
||||
Lauren Spencer-Smith,Q110630787,female,solo,pop music,Canada,Fingers Crossed (2022#75)
|
||||
Lauv,Q30122224,male,solo,electronic music; pop music,United States,I Like Me Better (2018#35)
|
||||
Lee Brice,Q1811729,male,solo,country music,United States,I Hope You're Happy Now (2020#73)|One Of Them Girls (2020#68)|Rumor (2019#81)
|
||||
Lefty Gunplay,Q131374668,male,solo,hip-hop; West Coast hip-hop; gangsta rap,United States,TV Off (2025#15)
|
||||
Leon Thomas,Q714375,male,solo,contemporary R&B; pop music; soul; hip-hop,United States,Mutt (2025#22)
|
||||
Lewis Capaldi,Q42315001,male,solo,blue-eyed soul; pop music; pop rock; alternative rock; soft rock,United Kingdom,Before You Go (2020#21)|Someone You Loved (2019#27/2020#10)
|
||||
Liam Payne,Q2756349,male,solo,pop music; contemporary R&B,United Kingdom,Strip That Down (2017#36)
|
||||
Lil Baby,Q50527563,male,solo,hip-hop; trap music; Southern hip-hop,United States,Baby (2019#84)|Close Friends (2019#74)|Drip Too Hard (2019#26)|Emotionally Scarred (2020#74)|Every Chance I Get (2021#59)|For The Night (2020#49/2021#32)|In A Minute (2022#43)|On Me (2021#36)|Sum 2 Prove (2020#79)|The Bigger Picture (2020#76)|Wants And Needs (2021#47)|We Paid (2020#57)|Woah (2020#42)|Yes Indeed (2018#25)
|
||||
Lil Dicky,Q16876006,male,solo,hip-hop; pop rap; comedy hip-hop,United States,Freaky Friday (2018#55)
|
||||
Lil Durk,Q2899818,male,solo,Midwest hip-hop; trap music; drill; gangsta rap; hardcore hip-hop; Chicago hip-hop; Chicago drill,United States,All My Life (2023#25)|Back In Blood (2021#39)|Broadway Girls (2022#81)|Every Chance I Get (2021#59)|Laugh Now Cry Later (2020#41/2021#45)|No More Parties (2021#79)|What Happened To Virgil (2022#83)
|
||||
Lil Mosey,Q55819454,male,solo,hip-hop; pop rap; trap music,United States,Blueberry Faygo (2020#27)
|
||||
Lil Nas X,Q62591281,male,solo,hip-hop; pop rap; country rap; trap music; pop music; rock music; pop rock; country music; Southern hip-hop,United States,Industry Baby (2021#24/2022#16)|Montero (Call Me By Your Name) (2021#9)|Old Town Road (2019#1)|Panini (2019#40/2020#71)|Thats What I Want (2022#14)
|
||||
Lil Pump,Q38002101,male,solo,hip-hop; trap music; SoundCloud rap; punk rap; Southern hip-hop,United States,Gucci Gang (2018#44)
|
||||
Lil Tecca,Q65054660,male,solo,hip-hop; trap music,United States,Dark Thoughts (2025#96)|Ran$om (2019#28)
|
||||
Lil Tjay,Q61670761,male,solo,hip-hop,United States,Calling My Phone (2021#33)|Mood Swings (2020#81)|Pop Out (2019#45)
|
||||
Lil Uzi Vert,Q23771950,non-binary,solo,hip-hop; emo rap; trap music; SoundCloud rap; punk rap; psychedelic rap; rage; cloud rap; psychedelic hip-hop; East Coast hip-hop,United States,Bad And Boujee (2017#6)|Everybody (2024#71)|Just Wanna Rock (2023#28)|XO TOUR Llif3 (2017#13)
|
||||
Lil Wayne,Q15615,male,solo,Southern hip-hop; bounce music; gangsta rap; trap music; pop rap; dirty south; hardcore hip-hop,United States,I'm The One (2017#12)|Sticky (2025#41)|Sucker For Pain (2016#68)|Whats Poppin (2020#13)
|
||||
Lil Yachty,Q23772141,male,solo,hip-hop; trap music; pop rap; mumble rap; cloud rap; Southern hip-hop,United States,Broccoli (2016#34/2017#70)|iSpy (2017#28)
|
||||
Lizzo,Q16232225,female,solo,hip-hop; soul; contemporary R&B; gospel music,United States,About Damn Time (2022#12)|Good As Hell (2020#31)|Truth Hurts (2019#13/2020#55)
|
||||
Logic,Q6667429,male,solo,hip-hop; pop rap; conscious hip-hop; East Coast hip-hop,United States,1-800-273-8255 (2017#31/2018#84)|Sucker For Pain (2016#68)
|
||||
Lola Young,Q115869427,female,solo,pop music; soul; contemporary R&B,United Kingdom,Like Him (2025#62)|Messy (2025#28)
|
||||
Luis Fonsi,Q1387726,male,solo,pop music; Latin pop; ballad; reggaeton,United States,Despacito (2017#2)
|
||||
Lukas Graham,Q300935,,group,soul; pop music; pop rock; funk,Denmark,7 Years (2016#12)
|
||||
Luke Bryan,Q166197,male,solo,country music,United States,Buy Dirt (2022#49)|Knockin' Boots (2019#79)|One Margarita (2020#70)
|
||||
Luke Combs,Q23884740,male,solo,country music,United States,"Ain't No Love In Oklahoma (2024#37)|Backup Plan (2025#80)|Beautiful Crazy (2019#46)|Beer Never Broke My Heart (2019#63)|Better Together (2021#57)|Does To Me (2020#93)|Doin' This (2022#71)|Even Though I'm Leaving (2020#85)|Fast Car (2023#8/2024#23)|Forever After All (2021#18)|Going, Going, Gone (2023#59)|Hurricane (2017#76)|Love You Anyway (2023#36)|Lovin' On You (2020#97)|One Number Away (2018#96)|The Kind Of Love We Make (2022#36)|Where The Wild Things Are (2024#70)"
|
||||
Machine Gun Kelly,Q1354843,male,solo,hip-hop; pop rap; rap rock; pop-punk; alternative rock; alternative hip-hop,United States,Bad Things (2017#41)|My Ex's Best Friend (2021#23)
|
||||
Maddie,Q54607228,female,solo,country music,United States,Die From A Broken Heart (2020#65)
|
||||
Mae Muller,Q83696883,female,solo,pop music; rhythm and blues,United Kingdom,Better Days (2022#73)
|
||||
Major Lazer,Q2604214,,group,dancehall; moombahton; reggae fusion; trap music; electronic dance music; hip-hop; electro house; rocksteady,United States,Cold Water (2016#25)|Lean On (2016#85)
|
||||
Maren Morris,Q21622122,female,solo,country music; country pop; folk rock; folk music,United States,Chasing After You (2021#61)|Circles Around This Town (2022#100)|The Bones (2020#9)|The Middle (2018#8)
|
||||
Mariah Carey,Q41076,female,solo,contemporary R&B; pop music; adult contemporary music; dance-pop; hip-hop,United States,All I Want For Christmas Is You (2020#67/2021#78/2022#65/2023#55/2024#54/2025#59)
|
||||
Mariah the Scientist,Q84356665,female,solo,alternative R&B,United States,Burning Blue (2025#58)
|
||||
Marian Hill,Q20858093,,group,electronic music,United States,Down (2017#90)
|
||||
Maroon 5,Q182223,,group,alternative rock; funk rock; pop rock; pop music,United States,Beautiful Mistakes (2021#34)|Cold (2017#69)|Don't Wanna Know (2017#38)|Girls Like You (2018#10/2019#22)|Memories (2020#8)|Wait (2018#58)|What Lovers Do (2017#97/2018#89)
|
||||
Marshmello,Q26334949,male,solo,electronic dance music,United States,Come & Go (2020#54)|Friends (2018#26)|Happier (2018#80/2019#6)|Leave Before You Love Me (2021#63)|Miles On It (2024#34)|One Thing Right (2019#89)|Wolves (2018#60)
|
||||
Masked Wolf,Q105490126,male,solo,hip-hop,Australia,Astronaut In The Ocean (2021#20)
|
||||
Mauro Castillo,Q16605580,male,solo,salsa,Colombia,We Don't Talk About Bruno (2022#24)
|
||||
MAX,Q1190160,male,solo,pop music,United States,Lights Down Low (2018#66)
|
||||
Meek Mill,Q1897911,male,solo,East Coast hip-hop; hardcore hip-hop; gangsta rap; trap music; hip-hop,United States,Going Bad (2019#18)
|
||||
Megan Moroney,Q116786312,female,solo,country music; folk music,United States,Am I Okay? (2025#66)|Tennessee Orange (2023#67)
|
||||
Megan Thee Stallion,Q59268408,female,solo,hip-hop; trap music; dirty rap; pop rap; pop music,United States,34+35 (2021#21)|Beautiful Mistakes (2021#34)|Body (2021#54)|Cash Shit (2019#90)|Cry Baby (2021#77)|Hot Girl Summer (2019#93)|Savage (2020#15)|Sweetest Pie (2022#62)|Thot Shit (2021#71)|Wanna Be (2024#40)|WAP (2020#24)
|
||||
Meghan Trainor,Q17403494,female,solo,pop music; contemporary R&B; dance-pop; soul; doo-wop,United States,Like I'm Gonna Lose You (2016#42)|Made You Look (2023#53)|Me Too (2016#62)|No (2016#45)
|
||||
Metro Boomin,Q16235273,male,solo,hip-hop; trap music,United States,Creepin' (2023#5)|Like That (2024#14)|Ric Flair Drip (2018#38)|Superhero (Heroes & Villains) (2023#38)|Type Shit (2024#51)
|
||||
Migos,Q15777045,,group,Southern hip-hop; trap music; mumble rap,United States,Bad And Boujee (2017#6)|I Get The Bag (2017#93/2018#72)|MotorSport (2018#34)|Pure Water (2019#55)|Slide (2017#71)|Slippery (2017#86)|Stir Fry (2018#48)|T-Shirt (2017#52)|Walk It Talk It (2018#43)
|
||||
Miguel,Q73463,male,solo,contemporary R&B; alternative R&B; hip-hop; pop music; soul,United States,Sky Walker (2018#75)|Sure Thing (2023#24)
|
||||
Mike Posner,Q268284,male,solo,pop music,United States,I Took A Pill In Ibiza (2016#15)
|
||||
Miley Cyrus,Q4235,genderfluid,solo,pop music; country music; pop rock; dance-pop; electropop; rock music; hip-hop; teen pop; country pop,United States,Flowers (2023#2/2024#69)|Malibu (2017#89)
|
||||
Miranda Lambert,Q231233,female,solo,country music; folk music,United States,Bluebird (2020#91)
|
||||
MNEK,Q6716905,male,solo,pop music,United Kingdom,Never Forget You (2016#46)
|
||||
Moneybagg Yo,Q38459032,male,solo,hip-hop; trap music; gangsta rap; Memphis rap; Southern hip-hop,United States,Said Sum (2020#98)|Time Today (2021#76)|Wockesha (2021#42)
|
||||
Mooski,Q113554625,male,solo,hip-hop,United States,Track Star (2021#75)
|
||||
Morgan Wallen,Q29561472,male,solo,country music; country pop; bro-country; country rap,United States,20 Cigarettes (2025#90)|Ain't That Some (2023#92)|Broadway Girls (2022#81)|Chasin' You (2020#34)|Cowgirls (2024#29)|Everything I Love (2023#56)|Flower Shops (2022#96)|I Ain't Coming Back (2025#72)|I Got Better (2025#40)|I Had Some Help (2024#4/2025#8)|I Wrote The Book (2023#95)|I'm The Problem (2025#13)|Just In Case (2025#18)|Last Night (2023#1/2024#28)|Lies Lies Lies (2024#50)|Love Somebody (2025#11)|More Than My Hometown (2020#96)|One Thing At A Time (2023#37)|Sand In My Boots (2022#58)|Smile (2025#64)|Superman (2025#99)|Thinkin' Bout Me (2023#19/2024#31)|Thought You Should Know (2023#22)|Wasted On You (2022#19)|What I Want (2025#27)|Whiskey Glasses (2019#52)|You Proof (2022#27/2023#12)
|
||||
Muni Long,Q7245622,female,solo,contemporary R&B; pop music,United States,Hrs And Hrs (2022#57)|Made For Me (2024#56)
|
||||
Murda Beatz,Q27970613,male,solo,hip-hop,Canada,FEFE (2018#31)
|
||||
Mustard,Q15941115,male,solo,hip-hop; ratchet music; West Coast hip-hop; contemporary R&B,United States,Ballin' (2020#25)|High Fashion (2020#40)|Pure Water (2019#55)
|
||||
Myles Smith,Q126076580,male,solo,folk-pop; folk music; pop music; folk rock,United Kingdom,Stargazing (2024#73/2025#29)
|
||||
Måneskin,Q56884561,,group,pop rock; glam rock,Italy,Beggin' (2021#66)
|
||||
MØ,Q10670047,female,solo,indie pop; electronic music,Kingdom of Denmark,Cold Water (2016#25)|Lean On (2016#85)
|
||||
N*E*R*D,Q941293,,group,funk rock; bossa nova; orchestral pop; hip-hop; skate punk,United States,Lemon (2018#83)
|
||||
Natanael Cano,Q104904549,male,solo,corridos tumbados; regional Mexican,Mexico,PRC (2023#90)
|
||||
Nate Smith,Q110707312,male,solo,country music; country rock,United States,Bulletproof (2024#96)|World On Fire (2024#90)
|
||||
NAV,Q28831246,male,solo,hip-hop; cloud rap; trap music; contemporary R&B,Canada,Lemonade (2021#51)
|
||||
Neckwav,Q140814188,male,solo,contemporary R&B,South Korea,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
NEIKED,Q26703294,,group,,Sweden,Better Days (2022#73)
|
||||
Nelly,Q218992,male,solo,Midwest hip-hop; pop rap; contemporary R&B; pop music; country rap; country music,United States,Lil Bit (2021#70)
|
||||
Netón Vega,Q129208676,male,solo,regional Mexican; corridos tumbados,Mexico,Loco (2025#100)
|
||||
NF,Q19571648,male,solo,hip-hop,United States,Let You Down (2018#29)
|
||||
Niall Horan,Q775231,male,solo,pop music; pop rock; folk-pop,Ireland,Slow Hands (2017#32)
|
||||
Nick Jonas,Q201656,male,solo,pop music; rock music; contemporary R&B,United States,Close (2016#66)
|
||||
Nicki Minaj,Q162202,female,solo,hip-hop; pop music; contemporary R&B; trap music; dance music,Trinidad and Tobago,Barbie World (2023#46)|Big Bank (2018#63)|Down In The DM (2016#69)|Everybody (2024#71)|FEFE (2018#31)|FTCU (2024#88)|Hot Girl Summer (2019#93)|MotorSport (2018#34)|Plain Jane (2018#74)|Princess Diana (2023#69)|Rake It Up (2017#53)|Side To Side (2016#77/2017#43)|Super Freaky Girl (2022#56/2023#79)|Swalla (2017#85)
|
||||
Nicky Jam,Q1374560,male,solo,reggaeton; Latin trap,United States,Te Bote (2018#81)|X (2018#90)
|
||||
Nicky Youre,Q112666811,male,solo,indie pop,United States,Sunroof (2022#29)
|
||||
Niko Moon,Q67399825,male,solo,country music,United States,Good Time (2021#84)
|
||||
Nio García,Q107358694,male,solo,Latin trap; reggaeton,United States,Te Bote (2018#81)
|
||||
NLE Choppa,Q64009351,male,solo,trap music; hip-hop; drill; Memphis rap; gangsta rap; Southern hip-hop,United States,Shotta Flow (2019#92)|Walk Em Down (2020#100)
|
||||
Noah Kahan,Q53832755,male,solo,folk-pop,United States,Dial Drunk (2023#80)|Stick Season (2024#11)
|
||||
Normani,Q28148022,female,solo,pop music; contemporary R&B,United States,Dancing With A Stranger (2019#14)|Love Lies (2018#19/2019#88)
|
||||
O.T. Genasis,Q18623749,male,solo,hip-hop; trap music,United States,Cut It (2016#91)
|
||||
Offset,Q30612480,male,solo,hip-hop; trap music; Southern hip-hop,United States,Clout (2019#87)|Ric Flair Drip (2018#38)|Taste (2018#28)|ZEZE (2019#31)
|
||||
Old Dominion,Q20685018,,group,country pop,United States,Memory Lane (2023#87)|One Man Band (2020#59)
|
||||
Oliver Anthony Music,Q121391237,male,solo,country music; folk music,United States,Rich Men North Of Richmond (2023#78)
|
||||
Olivia O'Brien,Q23978275,female,solo,dance-pop; pop music,United States,I Hate U I Love U (2016#38)
|
||||
Olivia Rodrigo,Q63243883,female,solo,indie pop; pop music; rock music,United States,Deja Vu (2021#13)|Drivers License (2021#8)|Good 4 U (2021#5/2022#31)|Traitor (2021#38)|Vampire (2023#30/2024#57)
|
||||
One Direction,Q146027,,group,dance-pop; teen pop; pop rock,United Kingdom,Perfect (2016#100)
|
||||
OneRepublic,Q1438730,,group,pop rock; alternative rock; cello rock,United States,I Ain't Worried (2022#37)
|
||||
Ozuna,Q28739901,male,solo,reggaeton; Latin trap; Latin pop; dancehall,United States,Taki Taki (2019#57)|Te Bote (2018#81)
|
||||
P!nk,Q160009,female,solo,pop music; pop rock; contemporary R&B,United States,Just Like Fire (2016#33)|One Too Many (2021#81)|Walk Me Home (2019#99)|What About Us (2017#84)
|
||||
Panic! At The Disco,Q277551,,group,pop rock; synth-pop; pop-punk; baroque pop; emo pop; alternative rock,United States,"Hey Look Ma, I Made It (2019#61)|High Hopes (2019#11)"
|
||||
Parker McCollum,Q20684755,male,solo,Americana; country music,United States,Burn It Down (2024#84)|Handle On You (2023#85)
|
||||
Parmalee,Q7139162,,group,country music,United States,Just The Way (2021#88)|Take My Name (2022#82)
|
||||
PARTYNEXTDOOR,Q15929398,male,solo,Canadian hip-hop; alternative R&B,Canada,Somebody Loves Me (2025#85)
|
||||
Paul Russell,Q122932071,male,solo,hip-hop; contemporary R&B; pop rap,United States,Lil Boo Thang (2024#78)
|
||||
Peso Pluma,Q117242724,male,solo,regional Mexican; corridos tumbados; reggaeton; Latin trap; cumbia norteña mexicana,Mexico,Ella Baila Sola (2023#26)|La Bebe (2023#40)|Por Las Noches (2023#86)|PRC (2023#90)
|
||||
Pharrell Williams,Q14313,male,solo,contemporary R&B; hip-hop; pop music; funk; rock music; pop rap,United States,Feels (2017#74)
|
||||
Pinkfong,Q55735607,,,children's music,South Korea,Baby Shark (2019#75)
|
||||
PinkPantheress,Q107570416,female,solo,pop music; bedroom pop; dance music; alternative pop; drum and bass; 2-step garage; jungle; hyperpop,United Kingdom,"Boy's A Liar, Pt. 2 (2023#20)"
|
||||
Playboi Carti,Q27671080,male,solo,Southern hip-hop; trap music; mumble rap; cloud rap; rage; punk rap; alternative hip-hop; plugg music; lo-fi music; SoundCloud rap,United States,Carnival (2024#38)|FE!N (2024#97)|Magnolia (2017#79)|Rather Lie (2025#91)|Timeless (2025#16)|Type Shit (2024#51)
|
||||
PnB Rock,Q28155029,male,solo,hip-hop; contemporary R&B; trap music; pop music; pop rap,United States,Everyday We Lit (2017#95)
|
||||
Polo G,Q62953486,male,solo,hip-hop; drill; trap music; conscious hip-hop; Midwest hip-hop; Chicago hip-hop,United States,Better Days (2022#73)|Pop Out (2019#45)|Rapstar (2021#30)
|
||||
Pooh Shiesty,Q105105274,male,solo,trap music; Southern hip-hop; drill; hardcore hip-hop; gangsta rap; Memphis rap,United States,Back In Blood (2021#39)|Beat Box (2021#44)
|
||||
Pop Smoke,Q81698554,male,solo,East Coast hip-hop; Brooklyn drill; trap music; gangsta rap; drill; hip-hop,United States,Dior (2020#83)|For The Night (2020#49/2021#32)|Mood Swings (2020#81)|The Woo (2020#78)|What You Know Bout Love (2021#22)
|
||||
Portugal. The Man,Q606879,,group,pop music; indie rock,United States,Feel It Still (2017#45/2018#33)
|
||||
Post Malone,Q21621919,male,solo,hip-hop; pop music; country music,United States,Better Now (2018#13/2019#32)|Chemical (2023#57)|Circles (2019#62/2020#2)|Congratulations (2017#10)|Dial Drunk (2023#80)|Fortnight (2024#22)|Goodbyes (2019#30)|I Ain't Coming Back (2025#72)|I Fall Apart (2018#39)|I Had Some Help (2024#4/2025#8)|I Like You (A Happier Song) (2022#26/2023#75)|One Right Now (2022#30)|Pour Me A Drink (2024#76)|Psycho (2018#6)|Rockstar (2017#56/2018#5)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|White Iverson (2016#65)|Wow. (2019#5)
|
||||
Powfu,Q89069821,male,solo,hip-hop; lo-fi music; power pop; rock music,Canada,Death Bed (2020#43)
|
||||
Project Pat,Q593741,male,solo,Memphis rap; gangsta rap; hardcore hip-hop; Southern hip-hop; crunk; dirty south; trap music; horrorcore,United States,Knife Talk (2021#86/2022#60)
|
||||
Quavo,Q30072039,male,solo,hip-hop; trap music,United States,Congratulations (2017#10)|I'm The One (2017#12)|Intentions (2020#17)|No Brainer (2018#73)|Strip That Down (2017#36)
|
||||
Rae Sremmurd,Q18123226,,group,hip-hop; trap music; pop music; pop rap,United States,Black Beatles (2017#19)|Powerglide (2018#97)|Swang (2017#64)
|
||||
Rauw Alejandro,Q61586861,male,solo,contemporary R&B; urban contemporary; Latin R&B; reggaeton; Latin trap; Latin pop; salsa,United States,Party (2022#77)|Todo de Ti (2021#100)
|
||||
Ravyn Lenae,Q28962463,female,solo,contemporary R&B; alternative R&B; neo soul,United States,Love Me Not (2025#30)
|
||||
RAYE,Q28071144,female,solo,contemporary R&B,United Kingdom,Escapism (2023#48)
|
||||
REI AMI,Q104842557,female,solo,K-pop; pop music; hip-hop; contemporary R&B,South Korea,Golden (2025#25)|How It's Done (2025#68)|Takedown (2025#93)|What It Sounds Like (2025#83)
|
||||
Rema,Q65071082,male,solo,Afrobeats,Nigeria,Calm Down (2023#6)
|
||||
Remy Boyz,Q63405888,,group,hip-hop,United States,679 (2016#54)
|
||||
Remy Ma,Q275437,female,solo,East Coast hip-hop,United States,All The Way Up (2016#87)
|
||||
Rhenzy Feliz,Q34344805,male,solo,,United States,We Don't Talk About Bruno (2022#24)
|
||||
Rich The Kid,Q18637908,male,solo,hip-hop; trap music; mumble rap; plugg music; Southern hip-hop,United States,Carnival (2024#38)|Plug Walk (2018#57)
|
||||
Rick Ross,Q297831,male,solo,Southern hip-hop; gangsta rap; mafioso rap; trap music; hardcore hip-hop; dirty south,United States,Money In The Grave (2019#34)
|
||||
Rihanna,Q36844,female,solo,pop music; dancehall; contemporary R&B; electronic dance music; adult contemporary music,Barbados,Lemon (2018#83)|Lift Me Up (2023#63)|Love On The Brain (2017#33)|Loyalty. (2017#66)|Needed Me (2016#13)|This Is What You Came For (2016#17)|Too Good (2016#29)|Wild Thoughts (2017#18)|Work (2016#4)
|
||||
Riley Green,Q57418818,male,solo,country music,United States,Worst Way (2025#35)
|
||||
Ritt Momney,Q105105308,male,solo,folk music; indie pop; synth-pop,United States,Put Your Records On (2021#93)
|
||||
Rod Wave,Q78754352,male,solo,hip-hop; contemporary R&B; soul; trap music,United States,Heart On Ice (2020#51)|Rags2Riches (2020#90)|Tombstone (2021#98)
|
||||
Roddy Ricch,Q59209423,male,solo,West Coast hip-hop; trap music; pop rap; gangsta rap; hardcore hip-hop; hip-hop,United States,Ballin' (2020#25)|High Fashion (2020#40)|Late At Night (2021#72)|Rockstar (2020#5)|The Box (2020#3)|The Woo (2020#78)|Walk Em Down (2020#100)
|
||||
ROSÉ,Q27655344,female,solo,K-pop; dance-pop; hip-hop; electronic dance music,New Zealand,APT. (2025#9)
|
||||
Rozes,Q22955580,female,solo,pop music; dance music; electronic dance music,United States,Roses (2016#27)
|
||||
Russell Dickerson,Q24572267,male,solo,country music,United States,Happen To Me (2025#69)|She Likes It (2022#91)
|
||||
Ruth B,Q22056665,female,solo,pop music,Canada,Lost Boy (2016#76)
|
||||
Ryan Hurd,Q42224127,male,solo,country music,United States,Chasing After You (2021#61)
|
||||
Sabrina Carpenter,Q7396400,female,solo,pop music; teen pop; dance-pop; contemporary R&B; electropop; country pop,United States,Bed Chem (2025#45)|Espresso (2024#7/2025#12)|Feather (2024#25)|Manchild (2025#38)|Please Please Please (2024#16/2025#50)|Taste (2024#86/2025#19)
|
||||
SAINt JHN,Q52274581,male,solo,hip-hop; trap music; contemporary R&B,United States,Roses (2020#19)
|
||||
Sam Barber,Q124428137,male,solo,country music,United States,Indigo (2025#49)
|
||||
Sam Hunt,Q16223171,male,solo,country music,United States,Body Like A Back Road (2017#8)|Hard To Forget (2020#69)
|
||||
Sam Smith,Q15123969,non-binary,solo,soul; contemporary R&B; pop music; rhythm and blues,United Kingdom,Dancing With A Stranger (2019#14)|How Do You Sleep? (2019#83)|Too Good At Goodbyes (2017#77/2018#49)|Unholy (2022#98/2023#11)
|
||||
Sam Tompkins,Q112155955,male,solo,pop music,United Kingdom,To The Moon! (2022#97)
|
||||
samUIL Lee,Q140814222,male,solo,J-pop,South Korea,Soda Pop (2025#61)|Your Idol (2025#54)
|
||||
Saweetie,Q50486592,female,solo,hip-hop,United States,Best Friend (2021#29)|My Type (2019#76)
|
||||
Scotty McCreery,Q255969,male,solo,country music,United States,Damn Strait (2022#93)
|
||||
Sean Paul,Q200577,male,solo,dancehall; pop music,Jamaica,Cheap Thrills (2016#11)|Rockabye (2017#44)
|
||||
Selena Gomez,Q83287,female,solo,pop music; dance-pop; electropop; pop rock; contemporary R&B; alternative R&B,United States,Back To You (2018#41)|Calm Down (2023#6)|Hands To Myself (2016#56)|It Ain't Me (2017#27)|Lose You To Love Me (2020#30)|Same Old Love (2016#40)|Taki Taki (2019#57)|We Don't Talk Anymore (2016#50)|Wolves (2018#60)
|
||||
Sexyy Red,Q119842023,female,solo,hip-hop; trap music; dirty rap; Midwest hip-hop,United States,Get It Sexyy (2024#55)|Rich Baby Daddy (2024#45)|Sticky (2025#41)|Whatchu Kno About Me (2025#48)
|
||||
Shaboozey,Q125295750,male,solo,alternative country; country music; hip-hop; Americana,United States,A Bar Song (Tipsy) (2024#2/2025#3)|Good News (2025#23)
|
||||
SHAED,Q60643444,,group,electropop; indie pop,United States,Trampoline (2019#70/2020#53)
|
||||
Shakira,Q34424,female,solo,pop music; Latin pop; pop rock; Latin rock; dance music; folk music; world music; contemporary R&B; rock music,Spain,"Bzrp Music Sessions, Vol. 53 (2023#96)|TQG (2023#65)"
|
||||
Shawn Mendes,Q17198340,male,solo,folk-pop; pop music; pop rock,Canada,I Know What You Did Last Summer (2016#86)|If I Can't Have You (2019#29)|In My Blood (2018#46)|Mercy (2017#54)|Senorita (2019#15/2020#44)|Stitches (2016#23)|There's Nothing Holdin' Me Back (2017#23)|Treat You Better (2016#28/2017#92)
|
||||
Shay Mooney,Q116536996,male,solo,country music,United States,"10,000 Hours (2020#23)|All To Myself (2019#97)|Glad You Exist (2021#64)|Speechless (2019#35)|Tequila (2018#32/2019#91)"
|
||||
Sheck Wes,Q56513324,male,solo,East Coast hip-hop; trap music,United States,Mo Bamba (2019#49)
|
||||
Sia,Q181484,female,solo,electropop; indie pop,Australia,Cheap Thrills (2016#11)
|
||||
Silentó,Q20354248,male,solo,hip-hop; pop rap; trap music; comedy hip-hop; snap music; pop music,United States,Watch Me (2016#88)
|
||||
Skip Marley,Q21621948,male,solo,reggae; pop music; hip-hop; rock music,Jamaica,Chained To The Rhythm (2017#73)
|
||||
Snow,Q94708,male,solo,dancehall; reggae; hip-hop; reggae fusion,Canada,Con Calma (2019#65)
|
||||
Social House,Q65117469,,group,pop music,United States,Boyfriend (2019#98)
|
||||
sombr,Q133444448,male,solo,alternative pop; bedroom pop,United States,Back To Friends (2025#44)|Undressed (2025#42)
|
||||
SpotemGottem,Q105105325,male,solo,mumble rap; trap music; drill; Southern hip-hop,United States,Beat Box (2021#44)
|
||||
StaySolidRocky,Q95918251,male,solo,hip-hop,United States,Party Girl (2020#64)
|
||||
Stephanie Beatriz,Q16832110,female,solo,,United States,We Don't Talk About Bruno (2022#24)
|
||||
Stephen Sanchez,Q113023532,male,solo,pop music,United States,Until I Found You (2023#50)
|
||||
Steve Lacy,Q56733980,male,solo,alternative R&B,United States,Bad Habit (2022#28/2023#39)
|
||||
Summer Walker,Q61959478,female,solo,contemporary R&B,United States,Good Good (2024#79)|Heart Of A Woman (2025#94)
|
||||
Surf Mesa,Q93771412,male,solo,tropical house,United States,ily (2020#95)
|
||||
Surfaces,Q76330429,,group,pop music,United States,Sunday Best (2020#61)
|
||||
Swae Lee,Q30103025,male,solo,hip-hop; trap music,United States,Be Like That (2020#88)|Close To Me (2019#58)|Sunflower (Spider-Man: Into The Spider-Verse) (2019#2)|Unforgettable (2017#15)
|
||||
SZA,Q16210722,female,solo,contemporary R&B; alternative R&B; neo soul; pop music; hip-hop,United States,30 For 30 (2025#21)|All The Stars (2018#47)|BMF (2025#76)|Good Days (2021#52)|I Hate U (2022#50)|Kill Bill (2023#3)|Kiss Me More (2021#6/2022#90)|Love Galore (2017#80)|Luther (2025#2)|Nobody Gets Me (2023#77)|Rich Baby Daddy (2024#45)|Saturn (2024#19)|Shirt (2023#51)|Snooze (2023#9/2024#20)|What Lovers Do (2017#97/2018#89)
|
||||
Tae,Q54607285,female,solo,country music,United States,Die From A Broken Heart (2020#65)
|
||||
Tate McRae,Q95129121,female,solo,pop music; alternative pop; dance-pop,Canada,Exes (2024#93)|Greedy (2024#13)|Revolving Door (2025#82)|She's All I Wanna Be (2022#94)|Sports Car (2025#46)|What I Want (2025#27)|You Broke Me First. (2021#37)
|
||||
Taylor Swift,Q26876,female,solo,pop music; country music,United States,All Too Well (Taylor's Version) (2022#76)|Anti-Hero (2023#4)|Cruel Summer (2023#18/2024#12)|Delicate (2018#24)|Down Bad (2024#99)|Fortnight (2024#22)|I Can Do It With A Broken Heart (2024#35)|I Don't Wanna Live Forever (Fifty Shades Darker) (2017#26)|Is It Over Now? (Taylor's Version) [From The Vault] (2024#33)|Karma (2023#27)|Lavender Haze (2023#32)|Look What You Made Me Do (2017#39)|ME! (2019#43)|Wildest Dreams (2016#79)|Willow (2021#55)|You Need To Calm Down (2019#39)
|
||||
Teddy Swims,Q105542333,male,solo,country music; pop music; rhythm and blues; soul,United States,Bad Dreams (2025#74)|Lose Control (2024#1/2025#4)
|
||||
Tems,Q72759173,female,solo,alternative R&B; Afrobeats,Nigeria,Essence (2021#60/2022#64)|Wait For U (2022#11/2023#83)
|
||||
Teo,Q55860035,male,solo,hip-hop,United States,Rolex (2017#61)
|
||||
The Chainsmokers,Q7721993,,group,electronic dance music,United States,Closer (2016#10/2017#7)|Don't Let Me Down (2016#8)|Paris (2017#42)|Roses (2016#27)|Something Just Like This (2017#5)
|
||||
The Kid LAROI,Q82890761,male,solo,Australian hip-hop; contemporary R&B; pop music; emo rap,Australia,Stay (2021#12/2022#3)|Without You (2021#17)
|
||||
The Marías,Q85807852,,group,indie pop; psychedelic soul; dream pop; synth-pop,United States,No One Noticed (2025#32)
|
||||
The Weeknd,Q2121062,male,solo,alternative R&B; pop music; electropop,Canada,Blinding Lights (2020#1/2021#3)|Call Out My Name (2018#78)|Can't Feel My Face (2016#72)|Creepin' (2023#5)|Cry For Me (2025#73)|Die For You (2023#7)|Heartless (2020#28)|I Feel It Coming (2017#34)|In The Night (2016#61)|Low Life (2016#30)|One Right Now (2022#30)|Pray For Me (2018#40)|Rather Lie (2025#91)|Save Your Tears (2021#2/2022#40)|Starboy (2016#58/2017#20)|Take My Breath (2021#89)|The Hills (2016#32)|Timeless (2025#16)|You Right (2021#43/2022#45)
|
||||
Thomas Rhett,Q7793491,male,solo,country music,United States,After All The Bars Are Closed (2025#88)|Die A Happy Man (2016#64)|Look What God Gave Her (2019#85)|Marry Me (2018#76)|What's Your Country Song (2021#80)
|
||||
Tim McGraw,Q356487,male,solo,country music,United States,Humble And Kind (2016#96)
|
||||
Tommy Richman,Q125778611,male,solo,pop rap; contemporary R&B,United States,Million Dollar Baby (2024#8)
|
||||
Tones And I,Q62887629,female,solo,indie pop; electronic dance music; pop music,Australia,Dance Monkey (2020#14)
|
||||
Toosii,Q106725126,male,solo,hip-hop,United States,Favorite Song (2023#21)
|
||||
Tory Lanez,Q18353558,male,solo,hip-hop; contemporary R&B,Canada,Luv (2016#70)|Say It (2016#74)|Whats Poppin (2020#13)
|
||||
Tove Lo,Q15948790,female,solo,pop music; indie pop; synth-pop; electropop,Sweden,Close (2016#66)
|
||||
Travis Scott,Q13605596,male,solo,hip-hop; trap music; pop rap; psychedelic rap; rock music; Southern hip-hop; contemporary R&B; psychedelic music; pop rock; alternative rock; alternative hip-hop; alternative R&B; psychedelic rock; psychedelic hip-hop,United States,Antidote (2016#75)|FE!N (2024#97)|Goosebumps (2017#68)|HIGHEST IN THE ROOM (2020#45)|Love Galore (2017#80)|Meltdown (2023#97)|Sicko Mode (2018#42/2019#9)|Sky Walker (2018#75)|The London (2019#64)|The Scotts (2020#86)|Type Shit (2024#51)|ZEZE (2019#31)
|
||||
Trevor Daniel,Q81738417,male,solo,contemporary R&B; pop music,United States,Falling (2020#22)
|
||||
Tucker Wetmore,Q124754909,male,solo,country music,United States,Wind Up Missin' You (2024#95)
|
||||
twenty one pilots,Q7857806,,group,reggae; alternative rock; electropop; alternative hip-hop; rap rock; indie pop; pop rock,United States,Heathens (2016#21/2017#58)|Ride (2016#20)|Stressed Out (2016#5)
|
||||
Ty Dolla $ign,Q7859785,male,solo,contemporary R&B; West Coast hip-hop; trap music; hip-hop,United States,Carnival (2024#38)|Hot Girl Summer (2019#93)|Psycho (2018#6)|Sucker For Pain (2016#68)|Swalla (2017#85)|Work From Home (2016#16)
|
||||
Tyga,Q358087,male,solo,West Coast hip-hop; pop rap,United States,Juicy (2020#87)|Taste (2018#28)
|
||||
Tyla,Q118105408,female,solo,contemporary R&B; pop music; Amapiano; new jack swing,South Africa,Water (2024#24)
|
||||
Tyler Cole,Q140814245,male,solo,,United States,Meet Me At Our Spot (2022#74)
|
||||
Tyler Hubbard,Q20740966,male,solo,country music,United States,5 Foot 9 (2022#67)|Dancin' In The Country (2023#61)
|
||||
"Tyler, The Creator",Q167635,male,solo,alternative hip-hop; neo soul; West Coast hip-hop; jazz rap; hardcore hip-hop; horrorcore,United States,Like Him (2025#62)|Sticky (2025#41)
|
||||
Usher,Q165911,male,solo,contemporary R&B; pop music; soul; hip-hop; dance music; dance-pop; crunk,United States,Good Good (2024#79)|No Limit (2016#90)
|
||||
Victoria Monét,Q19864989,female,solo,pop music; contemporary R&B; hip-hop,United States,On My Mama (2024#91)
|
||||
Walker Hayes,Q7962267,male,solo,country music,United States,AA (2022#61)|Fancy Like (2021#27/2022#35)
|
||||
Warren Zeiders,Q120986980,male,solo,country music,United States,Pretty Little Poison (2024#60)
|
||||
Wham!,Q744087,,group,pop music; new wave; blue-eyed soul,United Kingdom,Last Christmas (2023#84/2024#74/2025#65)
|
||||
WILLOW,Q298209,female,solo,indie pop; alternative R&B; experimental pop; neo soul; hip-hop; electronic dance music; pop music; dream pop; contemporary R&B; rock music; indie rock; progressive rock; pop-punk; alternative rock; alternative pop; jazz,United States,Meet Me At Our Spot (2022#74)
|
||||
Willy William,Q17016749,male,solo,house music; reggaeton; zouk,France,Mi Gente (2017#50/2018#99)
|
||||
Wiz Khalifa,Q117139,male,solo,East Coast hip-hop; pop rap,United States,See You Again (2016#99)|Sucker For Pain (2016#68)
|
||||
Wizkid,Q16233663,male,solo,Afrobeats; dancehall; rhythm and blues,Nigeria,Essence (2021#60/2022#64)|One Dance (2016#3)
|
||||
X Ambassadors,Q15963313,,group,alternative rock,United States,Sucker For Pain (2016#68)|Unsteady (2016#67)
|
||||
Xavi,Q124219723,male,solo,regional Mexican; corridos tumbados,United States,La Diabla (2024#72)
|
||||
XXXTENTACION,Q28561969,male,solo,hip-hop; emo rap; SoundCloud rap; rap rock; lo-fi music; hardcore hip-hop; alternative rock; lofi hip-hop; indie rock; nu metal; cloud rap; trap music; hardcore punk; trap metal; punk rock; rock music; rap metal; alternative R&B; contemporary R&B; punk rap; heavy metal music,United States,Changes (2018#94)|Look At Me! (2017#99)|Moonlight (2018#88)|Sad! (2018#17)
|
||||
Yeat,Q108761978,male,solo,experimental hip-hop; rage; trap music,United States,IDGAF (2024#83)
|
||||
YFN Lucci,Q28000403,male,solo,hip-hop,United States,Everyday We Lit (2017#95)
|
||||
YG,Q3076050,male,solo,West Coast hip-hop; gangsta rap; dirty rap; hardcore hip-hop,United States,Big Bank (2018#63)|Slide (2020#99)
|
||||
YK Osiris,Q66095815,male,solo,contemporary R&B; hip-hop,United States,Worth It (2019#77)
|
||||
Yng Lvcas,Q117834547,male,solo,reggaeton,Mexico,La Bebe (2023#40)
|
||||
YNW Melly,Q56276347,male,solo,Southern hip-hop; trap music; contemporary R&B; gangsta rap; drill; hardcore hip-hop,United States,Murder On My Mind (2019#66)|Suicidal (2020#75)
|
||||
Yo Gotti,Q586919,male,solo,Southern hip-hop; trap music; dirty south; hardcore hip-hop; gangsta rap; Memphis rap,United States,Down In The DM (2016#69)|Rake It Up (2017#53)
|
||||
Young Dolph,Q23541285,male,solo,Southern hip-hop; gangsta rap; trap music; Memphis rap; dirty south; hardcore hip-hop,United States,Cut It (2016#91)
|
||||
Young Nudy,Q62036530,male,solo,hip-hop,United States,Peaches & Eggplants (2023#94)
|
||||
Young Thug,Q15637814,male,solo,Southern hip-hop; trap music; mumble rap; progressive rap; SoundCloud rap,United States,Go Crazy (2020#39/2021#19)|Goodbyes (2019#30)|Havana (2017#96/2018#4)|Hot (2020#72)|No Limit (2016#90)|Pushin P (2022#48)|The London (2019#64)|Way 2 Sexy (2021#48)
|
||||
YoungBoy Never Broke Again,Q36490532,male,solo,hip-hop; Southern hip-hop; gangsta rap; trap music,United States,Bandit (2020#63)|Outside Today (2018#91)
|
||||
Yung Bleu,Q105105248,male,solo,contemporary R&B; hip-hop,United States,You're Mines Still (2021#58)
|
||||
Zacari,Q68593407,male,solo,contemporary R&B; hip-hop; alternative hip-hop; alternative R&B,United States,Love. (2018#50)
|
||||
Zach Bryan,Q106770023,male,solo,alternative country; red dirt; country music,United States,I Remember Everything (2023#74/2024#9)|Pink Skies (2024#30)|Something In The Orange (2022#39/2023#13)
|
||||
Zach Top,Q126414316,male,solo,country music,United States,I Never Lie (2025#43)
|
||||
Zara Larsson,Q4177928,female,solo,dance-pop; pop music; electropop,Sweden,Never Forget You (2016#46)
|
||||
Zay Hilfigerrr,Q55859289,male,solo,hip-hop,United States,Juju On That Beat (TZ Anthem) (2017#63)
|
||||
Zayion McCall,Q55859293,male,solo,hip-hop,United States,Juju On That Beat (TZ Anthem) (2017#63)
|
||||
Zayn,Q3626950,male,solo,pop music; contemporary R&B; dance-pop; alternative R&B,United Kingdom,I Don't Wanna Live Forever (Fifty Shades Darker) (2017#26)|Pillowtalk (2016#22)
|
||||
Zedd,Q21088,male,solo,electronic dance music; house music; electro house,Russia,Starving (2016#94/2017#82)|Stay (2017#17)|The Middle (2018#8)
|
||||
Óscar Maydon,Q130241052,male,solo,corridos tumbados,Mexico,Tu Boda (2025#87)
|
||||
|
@@ -1,884 +0,0 @@
|
||||
Title,Artists,Positions
|
||||
'Til You Can't,Cody Johnson,2022#34
|
||||
1-800-273-8255,Logic Featuring Alessia Cara & Khalid,2017#31/2018#84
|
||||
"10,000 Hours",Dan + Shay & Justin Bieber,2020#23
|
||||
2 Phones,Kevin Gates,2016#57
|
||||
20 Cigarettes,Morgan Wallen,2025#90
|
||||
24K Magic,Bruno Mars,2017#16
|
||||
30 For 30,SZA With Kendrick Lamar,2025#21
|
||||
34+35,Ariana Grande Feat. Doja Cat & Megan Thee Stallion,2021#21
|
||||
5 Foot 9,Tyler Hubbard,2022#67
|
||||
679,Fetty Wap Featuring Remy Boyz,2016#54
|
||||
7 Rings,Ariana Grande,2019#7
|
||||
7 Years,Lukas Graham,2016#12
|
||||
A Bar Song (Tipsy),Shaboozey,2024#2/2025#3
|
||||
A Holly Jolly Christmas,Burl Ives,2022#89/2023#71/2024#94
|
||||
A Lot,21 Savage,2019#42
|
||||
AA,Walker Hayes,2022#61
|
||||
abcdefu,GAYLE,2022#17
|
||||
About Damn Time,Lizzo,2022#12
|
||||
Abracadabra,Lady Gaga,2025#56
|
||||
Act II: Date @ 8,4batz Featuring Drake,2024#80
|
||||
Act Up,City Girls,2019#68
|
||||
Adore You,Harry Styles,2020#6
|
||||
Adventure Of A Lifetime,Coldplay,2016#95
|
||||
After All The Bars Are Closed,Thomas Rhett,2025#88
|
||||
Agora Hills,Doja Cat,2024#17
|
||||
Ain't No Love In Oklahoma,Luke Combs,2024#37
|
||||
Ain't That Some,Morgan Wallen,2023#92
|
||||
All I Want For Christmas Is You,Mariah Carey,2020#67/2021#78/2022#65/2023#55/2024#54/2025#59
|
||||
All In My Head (Flex),Fifth Harmony Featuring Fetty Wap,2016#93
|
||||
All My Life,Lil Durk Featuring J. Cole,2023#25
|
||||
All The Stars,Kendrick Lamar & SZA,2018#47
|
||||
All The Way,BigXthaPlug Featuring Bailey Zimmerman,2025#33
|
||||
All The Way Up,"Fat Joe, Remy Ma & Jay Z Featuring French Montana & Infared",2016#87
|
||||
All Time Low,Jon Bellion,2017#75
|
||||
All To Myself,Dan + Shay,2019#97
|
||||
All Too Well (Taylor's Version),Taylor Swift,2022#76
|
||||
Am I Okay?,Megan Moroney,2025#66
|
||||
Anti-Hero,Taylor Swift,2023#4
|
||||
Antidote,Travis Scott,2016#75
|
||||
Anxiety,Doechii,2025#39
|
||||
Anyone,Justin Bieber,2021#74
|
||||
APT.,ROSE & Bruno Mars,2025#9
|
||||
Arcade,Duncan Laurence,2021#82
|
||||
Area Codes,Kaliii,2023#88
|
||||
As It Was,Harry Styles,2022#2/2023#15
|
||||
Astronaut In The Ocean,Masked Wolf,2021#20
|
||||
Attention,Charlie Puth,2017#22
|
||||
Austin,Dasha,2024#27
|
||||
Baby,Lil Baby & DaBaby,2019#84
|
||||
Baby Shark,Pinkfong,2019#75
|
||||
Back In Blood,Pooh Shiesty Featuring Lil Durk,2021#39
|
||||
Back To Friends,sombr,2025#44
|
||||
Back To Sleep,Chris Brown,2016#89
|
||||
Back To You,Selena Gomez,2018#41
|
||||
Backup Plan,Bailey Zimmerman & Luke Combs,2025#80
|
||||
Bad And Boujee,Migos Featuring Lil Uzi Vert,2017#6
|
||||
Bad At Love,Halsey,2018#27
|
||||
Bad Dreams,Teddy Swims,2025#74
|
||||
Bad Guy,Billie Eilish,2019#4/2020#46
|
||||
Bad Habit,Steve Lacy,2022#28/2023#39
|
||||
Bad Habits,Ed Sheeran,2021#15/2022#13
|
||||
Bad Things,Machine Gun Kelly x Camila Cabello,2017#41
|
||||
Baile Inolvidable,Bad Bunny,2025#70
|
||||
Ballin',Mustard & Roddy Ricch,2020#25
|
||||
Bam Bam,Camila Cabello Featuring Ed Sheeran,2022#66
|
||||
Bandit,Juice WRLD & YoungBoy Never Broke Again,2020#63
|
||||
Bang!,AJR,2021#56
|
||||
Bank Account,21 Savage,2017#48
|
||||
Barbie World,Nicki Minaj & Ice Spice With Aqua,2023#46
|
||||
Bartier Cardi,Cardi B Featuring 21 Savage,2018#61
|
||||
Be Alright,Dean Lewis,2019#54
|
||||
Be Careful,Cardi B,2018#59
|
||||
Be Like That,Kane Brown With Swae Lee & Khalid,2020#88
|
||||
Beat Box,SpotemGottem Featuring Pooh Shiesty Or DaBaby,2021#44
|
||||
Beautiful,Bazzi Featuring Camila Cabello,2019#95
|
||||
Beautiful Crazy,Luke Combs,2019#46
|
||||
Beautiful Mistakes,Maroon 5 Featuring Megan Thee Stallion,2021#34
|
||||
Beautiful People,Ed Sheeran Featuring Khalid,2019#50
|
||||
Beautiful Things,Benson Boone,2024#3/2025#6
|
||||
Bebe Dame,Fuerza Regida X Grupo Frontera,2023#73
|
||||
Bed Chem,Sabrina Carpenter,2025#45
|
||||
Beer Never Broke My Heart,Luke Combs,2019#63
|
||||
Before You Go,Lewis Capaldi,2020#21
|
||||
Beggin',Maneskin,2021#66
|
||||
Believer,Imagine Dragons,2017#9/2018#100
|
||||
Best Friend,Saweetie Featuring Doja Cat,2021#29
|
||||
Better,Khalid,2019#20
|
||||
Better Days,NEIKED X Mae Muller X Polo G,2022#73
|
||||
Better Now,Post Malone,2018#13/2019#32
|
||||
Better Together,Luke Combs,2021#57
|
||||
Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",2018#63
|
||||
Big Energy,Latto,2022#7
|
||||
Birds Of A Feather,Billie Eilish,2024#15/2025#5
|
||||
Black Beatles,Rae Sremmurd Featuring Gucci Mane,2017#19
|
||||
Blinding Lights,The Weeknd,2020#1/2021#3
|
||||
Bloody Mary,Lady Gaga,2023#99
|
||||
Blue Strips,Jessie Murph,2025#52
|
||||
Blueberry Faygo,Lil Mosey,2020#27
|
||||
Bluebird,Miranda Lambert,2020#91
|
||||
BMF,SZA,2025#76
|
||||
Bodak Yellow (Money Moves),Cardi B,2017#24/2018#54
|
||||
Body,Megan Thee Stallion,2021#54
|
||||
Body Like A Back Road,Sam Hunt,2017#8
|
||||
Boo'd Up,Ella Mai,2018#15
|
||||
BOP,DaBaby,2020#29
|
||||
Both,Gucci Mane Featuring Drake,2017#83
|
||||
Bounce Back,Big Sean,2017#35
|
||||
"Boy's A Liar, Pt. 2",PinkPantheress & Ice Spice,2023#20
|
||||
Boyfriend,Ariana Grande & Social House,2019#98
|
||||
Boyfriend,Dove Cameron,2022#51
|
||||
Break My Heart,Dua Lipa,2020#33
|
||||
Break My Soul,Beyonce,2022#38
|
||||
"Break Up With Your Girlfriend, I'm Bored",Ariana Grande,2019#36
|
||||
Breathin,Ariana Grande,2019#72
|
||||
Broadway Girls,Lil Durk Featuring Morgan Wallen,2022#81
|
||||
Broccoli,D.R.A.M. Featuring Lil Yachty,2016#34/2017#70
|
||||
Bulletproof,Nate Smith,2024#96
|
||||
Burn It Down,Parker McCollum,2024#84
|
||||
Burning Blue,Mariah The Scientist,2025#58
|
||||
Bury A Friend,Billie Eilish,2019#73
|
||||
Bury Me In Georgia,Kane Brown,2023#89
|
||||
Butter,BTS,2021#11
|
||||
Buy Dirt,Jordan Davis Featuring Luke Bryan,2022#49
|
||||
"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,2023#96
|
||||
Cake By The Ocean,DNCE,2016#18
|
||||
Call Out My Name,The Weeknd,2018#78
|
||||
Calling My Phone,Lil Tjay Featuring 6LACK,2021#33
|
||||
Calm Down,Rema & Selena Gomez,2023#6
|
||||
Can't Feel My Face,The Weeknd,2016#72
|
||||
Can't Stop The Feeling!,Justin Timberlake,2016#9/2017#49
|
||||
Carnival,¥$: Ye & Ty Dolla $ign Featuring Rich The Kid & Playboi Carti,2024#38
|
||||
Caroline,Amine,2017#60
|
||||
Cash Shit,Megan Thee Stallion Featuring DaBaby,2019#90
|
||||
Castle On The Hill,Ed Sheeran,2017#40
|
||||
Chained To The Rhythm,Katy Perry Featuring Skip Marley,2017#73
|
||||
Changes,XXXTENTACION,2018#94
|
||||
Chasin' You,Morgan Wallen,2020#34
|
||||
Chasing After You,Ryan Hurd With Maren Morris,2021#61
|
||||
Cheap Thrills,Sia Featuring Sean Paul,2016#11
|
||||
Chemical,Post Malone,2023#57
|
||||
Circles,Post Malone,2019#62/2020#2
|
||||
Circles Around This Town,Maren Morris,2022#100
|
||||
Close,Nick Jonas Featuring Tove Lo,2016#66
|
||||
Close Friends,Lil Baby,2019#74
|
||||
Close To Me,Ellie Goulding X Diplo Featuring Swae Lee,2019#58
|
||||
Closer,The Chainsmokers Featuring Halsey,2016#10/2017#7
|
||||
Clout,Offset Featuring Cardi B,2019#87
|
||||
Cold,Maroon 5 Featuring Future,2017#69
|
||||
Cold Heart (PNAU Remix),Elton John & Dua Lipa,2022#10
|
||||
Cold Water,Major Lazer Featuring Justin Bieber & MO,2016#25
|
||||
Come & Go,Juice WRLD x Marshmello,2020#54
|
||||
Con Calma,Daddy Yankee & Katy Perry Featuring Snow,2019#65
|
||||
Congratulations,Post Malone Featuring Quavo,2017#10
|
||||
Controlla,Drake,2016#41
|
||||
Cowgirls,Morgan Wallen Featuring ERNEST,2024#29
|
||||
Creepin',"Metro Boomin, The Weeknd & 21 Savage",2023#5
|
||||
Cruel Summer,Taylor Swift,2023#18/2024#12
|
||||
Cry Baby,Megan Thee Stallion Featuring DaBaby,2021#77
|
||||
Cry For Me,The Weeknd,2025#73
|
||||
Cuff It,Beyonce,2023#29
|
||||
Cupid,Fifty Fifty,2023#44
|
||||
Cut It,O.T. Genasis Featuring Young Dolph,2016#91
|
||||
Daisies,Justin Bieber,2025#60
|
||||
Dakiti,Bad Bunny & Jhay Cortez,2021#28
|
||||
Damn Strait,Scotty McCreery,2022#93
|
||||
Dance Monkey,Tones And I,2020#14
|
||||
Dance The Night,Dua Lipa,2023#35/2024#100
|
||||
Dancin' In The Country,Tyler Hubbard,2023#61
|
||||
Dancing With A Stranger,Sam Smith & Normani,2019#14
|
||||
Dangerous Woman,Ariana Grande,2016#36
|
||||
Dark Thoughts,Lil Tecca,2025#96
|
||||
Daylight,David Kushner,2023#62
|
||||
Death Bed,Powfu Featuring beabadoobee,2020#43
|
||||
Deja Vu,Olivia Rodrigo,2021#13
|
||||
Delicate,Taylor Swift,2018#24
|
||||
Denial Is A River,Doechii,2025#75
|
||||
Despacito,Luis Fonsi & Daddy Yankee Featuring Justin Bieber,2017#2
|
||||
Despues de La Playa,Bad Bunny,2022#78
|
||||
Dial Drunk,Noah Kahan With Post Malone,2023#80
|
||||
Die A Happy Man,Thomas Rhett,2016#64
|
||||
Die For You,The Weeknd & Ariana Grande,2023#7
|
||||
Die From A Broken Heart,Maddie & Tae,2020#65
|
||||
Die With A Smile,Lady Gaga & Bruno Mars,2024#62/2025#1
|
||||
Dior,Pop Smoke,2020#83
|
||||
DNA.,Kendrick Lamar,2017#62
|
||||
Do Re Mi,Blackbear,2017#98
|
||||
Does To Me,Luke Combs Featuring Eric Church,2020#93
|
||||
Doin' This,Luke Combs,2022#71
|
||||
Don't,Bryson Tiller,2016#35
|
||||
Don't Let Me Down,The Chainsmokers Featuring Daya,2016#8
|
||||
Don't Mind,Kent Jones,2016#48
|
||||
Don't Start Now,Dua Lipa,2020#4
|
||||
Don't Wanna Know,Maroon 5 Featuring Kendrick Lamar,2017#38
|
||||
Down,Marian Hill,2017#90
|
||||
Down Bad,Taylor Swift,2024#99
|
||||
Down In The DM,Yo Gotti Featuring Nicki Minaj,2016#69
|
||||
Drinkin' Beer. Talkin' God. Amen.,Chase Rice Featuring Florida Georgia Line,2021#99
|
||||
Drip Too Hard,Lil Baby & Gunna,2019#26
|
||||
Drivers License,Olivia Rodrigo,2021#8
|
||||
Drowning,A Boogie Wit da Hoodie Featuring Kodak Black,2017#81
|
||||
DTMF,Bad Bunny,2025#51
|
||||
Dura,Daddy Yankee,2018#93
|
||||
Dynamite,BTS,2020#38/2021#41
|
||||
Eastside,"Benny Blanco, Halsey & Khalid",2018#77/2019#17
|
||||
Easy On Me,Adele,2022#4
|
||||
Efecto,Bad Bunny,2022#69
|
||||
Ella Baila Sola,Eslabon Armado X Peso Pluma,2023#26
|
||||
Emotionally Scarred,Lil Baby,2020#74
|
||||
End Of Beginning,Djo,2024#47
|
||||
Enemy,Imagine Dragons X JID,2022#15
|
||||
Envy Me,Calboy,2019#59
|
||||
EOO,Bad Bunny,2025#77
|
||||
Escapism,RAYE Featuring 070 Shake,2023#48
|
||||
Espresso,Sabrina Carpenter,2024#7/2025#12
|
||||
Essence,Wizkid Featuring Justin Bieber & Tems,2021#60/2022#64
|
||||
Euphoria,Kendrick Lamar,2024#66
|
||||
Even Though I'm Leaving,Luke Combs,2020#85
|
||||
Every Chance I Get,DJ Khaled Featuring Lil Baby & Lil Durk,2021#59
|
||||
Everybody,Nicki Minaj Featuring Lil Uzi Vert,2024#71
|
||||
Everyday We Lit,YFN Lucci Featuring PnB Rock,2017#95
|
||||
Everything I Love,Morgan Wallen,2023#56
|
||||
Everything I Wanted,Billie Eilish,2020#18
|
||||
Ex's & Oh's,Elle King,2016#63
|
||||
Exchange,Bryson Tiller,2016#53
|
||||
Exes,Tate McRae,2024#93
|
||||
Eyes Closed,Ed Sheeran,2023#64
|
||||
Eyes On You,Chase Rice,2019#96
|
||||
Fake Love,Drake,2017#37
|
||||
Fall In Love,Bailey Zimmerman,2022#54
|
||||
Falling,Trevor Daniel,2020#22
|
||||
Famous Friends,Chris Young + Kane Brown,2021#69
|
||||
Fancy Like,Walker Hayes,2021#27/2022#35
|
||||
Fast Car,Luke Combs,2023#8/2024#23
|
||||
Favorite Song,Toosii,2023#21
|
||||
FE!N,Travis Scott Featuring Playboi Carti,2024#97
|
||||
Feather,Sabrina Carpenter,2024#25
|
||||
Feel It Still,Portugal. The Man,2017#45/2018#33
|
||||
Feels,"Calvin Harris Featuring Pharrell Williams, Katy Perry & Big Sean",2017#74
|
||||
FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,2018#31
|
||||
Finesse,Bruno Mars & Cardi B,2018#14
|
||||
Fingers Crossed,Lauren Spencer-Smith,2022#75
|
||||
First Class,Jack Harlow,2022#6
|
||||
First Person Shooter,Drake Featuring J. Cole,2024#61
|
||||
Flower Shops,ERNEST Featuring Morgan Wallen,2022#96
|
||||
Flowers,Miley Cyrus,2023#2/2024#69
|
||||
Folded,Kehlani,2025#98
|
||||
For Free,DJ Khaled Featuring Drake,2016#59
|
||||
For The Night,Pop Smoke Featuring Lil Baby & DaBaby,2020#49/2021#32
|
||||
Forever After All,Luke Combs,2021#18
|
||||
Fortnight,Taylor Swift Featuring Post Malone,2024#22
|
||||
Freaky Friday,Lil Dicky Featuring Chris Brown,2018#55
|
||||
Free,Rumi & JINU: EJAE & Andrew Choi,2025#92
|
||||
Friends,Marshmello & Anne-Marie,2018#26
|
||||
FTCU,Nicki Minaj,2024#88
|
||||
Fukumean,Gunna,2023#31/2024#85
|
||||
Gata Only,FloyyMenor X Cris Mj,2024#52
|
||||
Get Along,Kenny Chesney,2018#87
|
||||
Get Into It (Yuh),Doja Cat,2022#68
|
||||
Get It Sexyy,Sexyy Red,2024#55
|
||||
Ghost,Justin Bieber,2022#8
|
||||
Girls Like You,Maroon 5 Featuring Cardi B,2018#10/2019#22
|
||||
Glad You Exist,Dan + Shay,2021#64
|
||||
Glimpse Of Us,Joji,2022#52
|
||||
Go Crazy,Chris Brown & Young Thug,2020#39/2021#19
|
||||
God Is A Woman,Ariana Grande,2018#62
|
||||
God's Country,Blake Shelton,2019#53
|
||||
God's Plan,Drake,2018#1
|
||||
Godzilla,Eminem Featuring Juice WRLD,2020#62
|
||||
Going Bad,Meek Mill Featuring Drake,2019#18
|
||||
"Going, Going, Gone",Luke Combs,2023#59
|
||||
Gold,Kiiara,2016#52
|
||||
Golden,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#25
|
||||
Golden Hour,JVKE,2023#41
|
||||
Good 4 U,Olivia Rodrigo,2021#5/2022#31
|
||||
Good As Hell,Lizzo,2020#31
|
||||
Good As You,Kane Brown,2019#86
|
||||
Good Days,SZA,2021#52
|
||||
Good Good,"Usher, Summer Walker & 21 Savage",2024#79
|
||||
"Good Luck, Babe!",Chappell Roan,2024#18/2025#31
|
||||
Good News,Shaboozey,2025#23
|
||||
Good Time,Niko Moon,2021#84
|
||||
Goodbyes,Post Malone Featuring Young Thug,2019#30
|
||||
Goosebumps,Travis Scott,2017#68
|
||||
Got What I Got,Jason Aldean,2020#60
|
||||
Greedy,Tate McRae,2024#13
|
||||
Gucci Gang,Lil Pump,2018#44
|
||||
Gummo,6ix9ine,2018#56
|
||||
H.O.L.Y.,Florida Georgia Line,2016#49
|
||||
Handle On You,Parker McCollum,2023#85
|
||||
Hands To Myself,Selena Gomez,2016#56
|
||||
Happen To Me,Russell Dickerson,2025#69
|
||||
Happier,Marshmello & Bastille,2018#80/2019#6
|
||||
Happier Than Ever,Billie Eilish,2021#94
|
||||
Hard Fought Hallelujah,Brandon Lake X Jelly Roll,2025#84
|
||||
Hard To Forget,Sam Hunt,2020#69
|
||||
Havana,Camila Cabello Featuring Young Thug,2017#96/2018#4
|
||||
Heart Like A Truck,Lainey Wilson,2023#58
|
||||
Heart Of A Woman,Summer Walker,2025#94
|
||||
Heart On Ice,Rod Wave,2020#51
|
||||
Heartbreak Anniversary,Giveon,2021#31
|
||||
Heartless,The Weeknd,2020#28
|
||||
Heat Waves,Glass Animals,2021#16/2022#1
|
||||
Heathens,twenty one pilots,2016#21/2017#58
|
||||
Heaven,Kane Brown,2018#53
|
||||
Hell Of A View,Eric Church,2021#91
|
||||
Hello,Adele,2016#7
|
||||
Here,Alessia Cara,2016#39
|
||||
"Hey Look Ma, I Made It",Panic! At The Disco,2019#61
|
||||
Hide Away,Daya,2016#84
|
||||
High Fashion,Roddy Ricch Featuring Mustard,2020#40
|
||||
High Hopes,Panic! At The Disco,2019#11
|
||||
High Road,Koe Wetzel & Jessie Murph,2024#81/2025#55
|
||||
HIGHEST IN THE ROOM,Travis Scott,2020#45
|
||||
Him & I,G-Eazy & Halsey,2018#45
|
||||
Holy,Justin Bieber Featuring Chance The Rapper,2021#35
|
||||
Homesick,Kane Brown,2020#89
|
||||
Hot,Young Thug Featuring Gunna,2020#72
|
||||
Hot Girl Bummer,blackbear,2020#26
|
||||
Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",2019#93
|
||||
Hot To Go!,Chappell Roan,2024#53
|
||||
Hotline Bling,Drake,2016#24
|
||||
Houdini,Dua Lipa,2024#43
|
||||
Houdini,Eminem,2024#39
|
||||
House Again,Hudson Westbrook,2025#95
|
||||
How Do You Sleep?,Sam Smith,2019#83
|
||||
How It's Done,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#68
|
||||
How Long,Charlie Puth,2018#65
|
||||
Hrs And Hrs,Muni Long,2022#57
|
||||
Humble And Kind,Tim McGraw,2016#96
|
||||
Humble.,Kendrick Lamar,2017#4
|
||||
Hurricane,Luke Combs,2017#76
|
||||
Hymn For The Weekend,Coldplay,2016#73
|
||||
I Ain't Coming Back,Morgan Wallen Featuring Post Malone,2025#72
|
||||
I Ain't Worried,OneRepublic,2022#37
|
||||
I Am Not Okay,Jelly Roll,2024#75/2025#79
|
||||
I Can Do It With A Broken Heart,Taylor Swift,2024#35
|
||||
I Don't Care,Ed Sheeran & Justin Bieber,2019#16
|
||||
I Don't Wanna Live Forever (Fifty Shades Darker),Zayn / Taylor Swift,2017#26
|
||||
I Fall Apart,Post Malone,2018#39
|
||||
I Feel It Coming,The Weeknd Featuring Daft Punk,2017#34
|
||||
I Get The Bag,Gucci Mane Featuring Migos,2017#93/2018#72
|
||||
I Got Better,Morgan Wallen,2025#40
|
||||
I Had Some Help,Post Malone Featuring Morgan Wallen,2024#4/2025#8
|
||||
I Hate U,SZA,2022#50
|
||||
I Hate U I Love U,gnash Featuring Olivia O'Brien,2016#38
|
||||
I Hope,Gabby Barrett Featuring Charlie Puth,2020#12/2021#40
|
||||
I Hope You're Happy Now,Carly Pearce & Lee Brice,2020#73
|
||||
I Know What You Did Last Summer,Shawn Mendes & Camila Cabello,2016#86
|
||||
I Like It,"Cardi B, Bad Bunny & J Balvin",2018#7/2019#69
|
||||
I Like Me Better,Lauv,2018#35
|
||||
I Like The Way You Kiss Me,Artemas,2024#63
|
||||
I Like You (A Happier Song),Post Malone Featuring Doja Cat,2022#26/2023#75
|
||||
I Never Lie,Zach Top,2025#43
|
||||
I Remember Everything,Zach Bryan Featuring Kacey Musgraves,2023#74/2024#9
|
||||
I Took A Pill In Ibiza,Mike Posner,2016#15
|
||||
I Wrote The Book,Morgan Wallen,2023#95
|
||||
I'm Gonna Love You,Cody Johnson & Carrie Underwood,2025#78
|
||||
I'm Good (Blue),David Guetta & Bebe Rexha,2023#10
|
||||
I'm The One,"DJ Khaled Featuring Justin Bieber, Quavo, Chance The Rapper & Lil Wayne",2017#12
|
||||
I'm The Problem,Morgan Wallen,2025#13
|
||||
I'm Upset,Drake,2018#86
|
||||
IDGAF,Drake Featuring Yeat,2024#83
|
||||
IDGAF,Dua Lipa,2018#98
|
||||
If I Can't Have You,Shawn Mendes,2019#29
|
||||
If I Didn't Love You,Jason Aldean & Carrie Underwood,2021#85
|
||||
If The World Was Ending,JP Saxe Featuring Julia Michaels,2020#56
|
||||
ily,surf mesa Featuring Emilee,2020#95
|
||||
In A Minute,Lil Baby,2022#43
|
||||
In Case You Didn't Know,Brett Young,2017#57
|
||||
In My Blood,Shawn Mendes,2018#46
|
||||
In My Feelings,Drake,2018#9
|
||||
In The Night,The Weeknd,2016#61
|
||||
Indigo,Sam Barber Featuring Avery Anna,2025#49
|
||||
Industry Baby,Lil Nas X & Jack Harlow,2021#24/2022#16
|
||||
Intentions,Justin Bieber Featuring Quavo,2020#17
|
||||
Into You,Ariana Grande,2016#51
|
||||
Is It Over Now? (Taylor's Version) [From The Vault],Taylor Swift,2024#33
|
||||
iSpy,KYLE Featuring Lil Yachty,2017#28
|
||||
Issues,Julia Michaels,2017#29
|
||||
It Ain't Me,Kygo x Selena Gomez,2017#27
|
||||
Jimmy Cooks,Drake Featuring 21 Savage,2022#33
|
||||
Jingle Bell Rock,Bobby Helms,2022#86/2023#68/2024#68/2025#81
|
||||
Juicy,Doja Cat & Tyga,2020#87
|
||||
Juju On That Beat (TZ Anthem),Zay Hilfigerrr & Zayion McCall,2017#63
|
||||
Jumpman,Drake & Future,2016#37
|
||||
Just In Case,Morgan Wallen,2025#18
|
||||
Just Like Fire,P!nk,2016#33
|
||||
Just The Way,Parmalee x Blanco Brown,2021#88
|
||||
Just Wanna Rock,Lil Uzi Vert,2023#28
|
||||
Karma,Taylor Swift Featuring Ice Spice,2023#27
|
||||
Kill Bill,SZA,2023#3
|
||||
King's Dead,"Jay Rock, Kendrick Lamar, Future & James Blake",2018#79
|
||||
Kings & Queens,Ava Max,2021#73
|
||||
Kiss Me More,Doja Cat Featuring SZA,2021#6/2022#90
|
||||
Knife Talk,Drake Featuring 21 Savage & Project Pat,2021#86/2022#60
|
||||
Knockin' Boots,Luke Bryan,2019#79
|
||||
La Bebe,Yng Lvcas x Peso Pluma,2023#40
|
||||
La Diabla,Xavi,2024#72
|
||||
Last Christmas,Wham!,2023#84/2024#74/2025#65
|
||||
Last Night,Morgan Wallen,2023#1/2024#28
|
||||
Last Night Lonely,Jon Pardi,2022#95
|
||||
Late At Night,Roddy Ricch,2021#72
|
||||
Late Night Talking,Harry Styles,2022#25
|
||||
Laugh Now Cry Later,Drake Featuring Lil Durk,2020#41/2021#45
|
||||
Lavender Haze,Taylor Swift,2023#32
|
||||
Lean On,Major Lazer & DJ Snake Featuring MO,2016#85
|
||||
Leave Before You Love Me,Marshmello X Jonas Brothers,2021#63
|
||||
Leave Me Alone,Flipp Dinero,2019#71
|
||||
Leave The Door Open,Silk Sonic (Bruno Mars & Anderson .Paak),2021#7
|
||||
Lemon,N*E*R*D & Rihanna,2018#83
|
||||
Lemonade,Internet Money & Gunna Featuring Don Toliver & NAV,2021#51
|
||||
Let It Go,James Bay,2016#44
|
||||
Let Me Love You,DJ Snake Featuring Justin Bieber,2016#47/2017#46
|
||||
Let You Down,NF,2018#29
|
||||
Levitating,Dua Lipa,2021#1/2022#42
|
||||
Liar,Jelly Roll,2025#86
|
||||
Lies Lies Lies,Morgan Wallen,2024#50
|
||||
Life Is Good,Future Featuring Drake,2020#7
|
||||
Lift Me Up,Rihanna,2023#63
|
||||
Lights Down Low,MAX Featuring gnash,2018#66
|
||||
Like Him,"Tyler, The Creator Featuring Lola Young",2025#62
|
||||
Like I Love Country Music,Kane Brown,2022#85
|
||||
Like I'm Gonna Lose You,Meghan Trainor Featuring John Legend,2016#42
|
||||
Like That,"Future, Metro Boomin & Kendrick Lamar",2024#14
|
||||
Lil Bit,Nelly & Florida Georgia Line,2021#70
|
||||
Lil Boo Thang,Paul Russell,2024#78
|
||||
Location,Khalid,2017#21
|
||||
Loco,Neton Vega,2025#100
|
||||
Lonely,Justin Bieber & benny blanco,2021#65
|
||||
Look Alive,BlocBoy JB Featuring Drake,2018#23
|
||||
Look At Me!,XXXTentacion,2017#99
|
||||
Look Back At It,A Boogie Wit da Hoodie,2019#41
|
||||
Look What God Gave Her,Thomas Rhett,2019#85
|
||||
Look What You Made Me Do,Taylor Swift,2017#39
|
||||
Lose Control,Teddy Swims,2024#1/2025#4
|
||||
Lose You To Love Me,Selena Gomez,2020#30
|
||||
Lost Boy,Ruth B,2016#76
|
||||
Love Galore,SZA Featuring Travis Scott,2017#80
|
||||
Love Lies,Khalid & Normani,2018#19/2019#88
|
||||
Love Me Not,Ravyn Lenae,2025#30
|
||||
Love Nwantiti (Ah Ah Ah),CKay,2022#55
|
||||
Love On The Brain,Rihanna,2017#33
|
||||
Love Somebody,Morgan Wallen,2025#11
|
||||
Love You Anyway,Luke Combs,2023#36
|
||||
Love Yourself,Justin Bieber,2016#1
|
||||
Love.,Kendrick Lamar Featuring Zacari,2018#50
|
||||
Lovin On Me,Jack Harlow,2024#5
|
||||
Lovin' On You,Luke Combs,2020#97
|
||||
Low Life,Future Featuring The Weeknd,2016#30
|
||||
Loyalty.,Kendrick Lamar Featuring Rihanna,2017#66
|
||||
Lucid Dreams,Juice WRLD,2018#12/2019#48
|
||||
Lunch,Billie Eilish,2024#48
|
||||
Luther,Kendrick Lamar & SZA,2025#2
|
||||
Luv,Tory Lanez,2016#70
|
||||
Made For Me,Muni Long,2024#56
|
||||
Made You Look,Meghan Trainor,2023#53
|
||||
Magnolia,Playboi Carti,2017#79
|
||||
Malibu,Miley Cyrus,2017#89
|
||||
MAMIII,Becky G X Karol G,2022#59
|
||||
Manchild,Sabrina Carpenter,2025#38
|
||||
Marry Me,Thomas Rhett,2018#76
|
||||
Mask Off,Future,2017#14
|
||||
Me Porto Bonito,Bad Bunny & Chencho Corleone,2022#20
|
||||
Me Too,Meghan Trainor,2016#62
|
||||
ME!,Taylor Swift Featuring Brendon Urie,2019#43
|
||||
"Me, Myself & I",G-Eazy x Bebe Rexha,2016#19
|
||||
Meant To Be,Bebe Rexha & Florida Georgia Line,2018#3
|
||||
Meet Me At Our Spot,THE ANXIETY: WILLOW & Tyler Cole,2022#74
|
||||
Meltdown,Travis Scott Featuring Drake,2023#97
|
||||
Memories,Maroon 5,2020#8
|
||||
Memory Lane,Old Dominion,2023#87
|
||||
Mercy,Brett Young,2018#95
|
||||
Mercy,Shawn Mendes,2017#54
|
||||
Messy,Lola Young,2025#28
|
||||
Mi Gente,J Balvin & Willy William Featuring Beyonce,2017#50/2018#99
|
||||
MIA,Bad Bunny Featuring Drake,2019#44
|
||||
Middle,DJ Snake Featuring Bipolar Sunshine,2016#80
|
||||
Middle Child,J. Cole,2019#25
|
||||
Miles On It,Marshmello & Kane Brown,2024#34
|
||||
Million Dollar Baby,Tommy Richman,2024#8
|
||||
Mine,Bazzi,2018#21
|
||||
Mo Bamba,Sheck Wes,2019#49
|
||||
Monaco,Bad Bunny,2024#82
|
||||
Money,Cardi B,2019#38
|
||||
Money In The Grave,Drake Featuring Rick Ross,2019#34
|
||||
Montero (Call Me By Your Name),Lil Nas X,2021#9
|
||||
Mood,24kGoldn Featuring iann dior,2020#47/2021#4
|
||||
Mood Swings,Pop Smoke Featuring Lil Tjay,2020#81
|
||||
Moonlight,XXXTENTACION,2018#88
|
||||
More Than My Hometown,Morgan Wallen,2020#96
|
||||
Moscow Mule,Bad Bunny,2022#44
|
||||
MotorSport,"Migos, Nicki Minaj & Cardi B",2018#34
|
||||
Murder On My Mind,YNW Melly,2019#66
|
||||
Mutt,Leon Thomas,2025#22
|
||||
My Ex's Best Friend,Machine Gun Kelly X blackbear,2021#23
|
||||
My House,Flo Rida,2016#14
|
||||
My Oh My,Camila Cabello Featuring DaBaby,2020#37
|
||||
My Type,Saweetie,2019#76
|
||||
Mystical Magical,Benson Boone,2025#47
|
||||
Natural,Imagine Dragons,2018#69
|
||||
Need A Favor,Jelly Roll,2023#34/2024#64
|
||||
Need To Know,Doja Cat,2021#46/2022#18
|
||||
Needed Me,Rihanna,2016#13
|
||||
Never Be Like You,Flume Featuring Kai,2016#60
|
||||
Never Be The Same,Camila Cabello,2018#18
|
||||
Never Forget You,Zara Larsson & MNEK,2016#46
|
||||
Never Lose Me,Flo Milli,2024#49
|
||||
Never Say Never,Cole Swindell / Lainey Wilson,2022#92
|
||||
New Rules,Dua Lipa,2018#16
|
||||
Next Thing You Know,Jordan Davis,2023#47
|
||||
Nice For What,Drake,2018#11
|
||||
No,Meghan Trainor,2016#45
|
||||
No Brainer,"DJ Khaled Featuring Justin Bieber, Chance The Rapper & Quavo",2018#73
|
||||
No Guidance,Chris Brown Featuring Drake,2019#21/2020#36
|
||||
No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,2018#30
|
||||
No Limit,Usher Featuring Young Thug,2016#90
|
||||
No More Parties,Coi Leray Featuring Lil Durk,2021#79
|
||||
No One Noticed,The Marias,2025#32
|
||||
No Pole,Don Toliver,2025#97
|
||||
No Promises,Cheat Codes Featuring Demi Lovato,2017#91
|
||||
No Tears Left To Cry,Ariana Grande,2018#20
|
||||
Nobody But You,Blake Shelton Duet With Gwen Stefani,2020#52
|
||||
Nobody Gets Me,SZA,2023#77
|
||||
Nokia,Drake,2025#24
|
||||
Nonstop,Drake,2018#52
|
||||
Not Like Us,Kendrick Lamar,2024#6/2025#17
|
||||
Now Or Never,Halsey,2017#59
|
||||
Nuevayol,Bad Bunny,2025#89
|
||||
Numb Little Bug,Em Beihold,2022#32
|
||||
Oh My God,Adele,2022#72
|
||||
Ojitos Lindos,Bad Bunny & Bomba Estereo,2022#87
|
||||
Old Town Road,Lil Nas X Featuring Billy Ray Cyrus,2019#1
|
||||
On Me,Lil Baby,2021#36
|
||||
On My Mama,Victoria Monet,2024#91
|
||||
On My Mind,Ellie Goulding,2016#81
|
||||
One Call Away,Charlie Puth,2016#43
|
||||
One Dance,Drake Featuring WizKid & Kyla,2016#3
|
||||
One Kiss,Calvin Harris & Dua Lipa,2018#68
|
||||
One Man Band,Old Dominion,2020#59
|
||||
One Margarita,Luke Bryan,2020#70
|
||||
One Mississippi,Kane Brown,2022#99
|
||||
One Number Away,Luke Combs,2018#96
|
||||
One Of Them Girls,Lee Brice,2020#68
|
||||
One Right Now,Post Malone & The Weeknd,2022#30
|
||||
One Thing At A Time,Morgan Wallen,2023#37
|
||||
One Thing Right,Marshmello & Kane Brown,2019#89
|
||||
One Too Many,Keith Urban Duet With P!nk,2021#81
|
||||
Only Human,Jonas Brothers,2019#78/2020#77
|
||||
Ordinary,Alex Warren,2025#7
|
||||
Oui,Jeremih,2016#55
|
||||
Outside Today,YoungBoy Never Broke Again,2018#91
|
||||
P*$$y Fairy (OTW),Jhene Aiko,2020#94
|
||||
Paint The Town Red,Doja Cat,2023#52/2024#21
|
||||
Panda,Desiigner,2016#6
|
||||
Panini,Lil Nas X,2019#40/2020#71
|
||||
Paris,The Chainsmokers,2017#42
|
||||
Party,Bad Bunny & Rauw Alejandro,2022#77
|
||||
Party Girl,StaySolidRocky,2020#64
|
||||
Passionfruit,Drake,2017#65
|
||||
Peaches,Justin Bieber Featuring Daniel Caesar & Giveon,2021#10
|
||||
Peaches & Eggplants,Young Nudy Featuring 21 Savage,2023#94
|
||||
Peekaboo,Kendrick Lamar Featuring AzChike,2025#53
|
||||
Perfect,Ed Sheeran,2018#2
|
||||
Perfect,One Direction,2016#100
|
||||
Pillowtalk,Zayn,2016#22
|
||||
Pink Pony Club,Chappell Roan,2025#10
|
||||
Pink Skies,Zach Bryan,2024#30
|
||||
Plain Jane,A$AP Ferg Featuring Nicki Minaj,2018#74
|
||||
Players,Coi Leray,2023#33
|
||||
Please Me,Cardi B & Bruno Mars,2019#37
|
||||
Please Please Please,Sabrina Carpenter,2024#16/2025#50
|
||||
Plug Walk,Rich The Kid,2018#57
|
||||
Pop Out,Polo G Featuring Lil Tjay,2019#45
|
||||
Pop Style,Drake Featuring The Throne,2016#82
|
||||
Popstar,DJ Khaled Featuring Drake,2020#66
|
||||
Por Las Noches,Peso Pluma,2023#86
|
||||
Positions,Ariana Grande,2021#14
|
||||
Pour Me A Drink,Post Malone Featuring Blake Shelton,2024#76
|
||||
pov,Ariana Grande,2021#87
|
||||
Powerglide,Rae Sremmurd & Juicy J,2018#97
|
||||
Pray For Me,The Weeknd & Kendrick Lamar,2018#40
|
||||
Praying,Kesha,2017#67
|
||||
PRC,Peso Pluma X Natanael Cano,2023#90
|
||||
Pretty Little Poison,Warren Zeiders,2024#60
|
||||
Princess Diana,Ice Spice & Nicki Minaj,2023#69
|
||||
Provenza,Karol G,2022#63
|
||||
Psycho,Post Malone Featuring Ty Dolla $ign,2018#6
|
||||
Puffin On Zootiez,Future,2022#84
|
||||
Pure Water,Mustard & Migos,2019#55
|
||||
Pushin P,Gunna & Future Featuring Young Thug,2022#48
|
||||
Put It On Da Floor Again,Latto Featuring Cardi B,2023#98
|
||||
Put Your Records On,Ritt Momney,2021#93
|
||||
Rags2Riches,Rod Wave Featuring ATR Son Son,2020#90
|
||||
Rain On Me,Lady Gaga & Ariana Grande,2020#48
|
||||
Rake It Up,Yo Gotti Featuring Nicki Minaj,2017#53
|
||||
Ran$om,Lil Tecca,2019#28
|
||||
Rapstar,Polo G,2021#30
|
||||
Rather Lie,Playboi Carti & The Weeknd,2025#91
|
||||
Really Really,Kevin Gates,2016#92
|
||||
Redbone,Childish Gambino,2017#25
|
||||
Redrum,21 Savage,2024#42
|
||||
Religiously,Bailey Zimmerman,2023#42
|
||||
Residuals,Chris Brown,2025#63
|
||||
Revolving Door,Tate McRae,2025#82
|
||||
Ric Flair Drip,Offset & Metro Boomin,2018#38
|
||||
Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,2024#45
|
||||
Rich Flex,Drake & 21 Savage,2023#14
|
||||
Rich Men North Of Richmond,Oliver Anthony Music,2023#78
|
||||
Ride,twenty one pilots,2016#20
|
||||
RITMO (Bad Boys For Life),Black Eyed Peas X J Balvin,2020#50
|
||||
Robbery,Juice WRLD,2019#100
|
||||
Rock And A Hard Place,Bailey Zimmerman,2022#70/2023#16
|
||||
Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie,2017#44
|
||||
Rockin' Around The Christmas Tree,Brenda Lee,2021#92/2022#80/2023#60/2024#59/2025#67
|
||||
Rockstar,DaBaby Featuring Roddy Ricch,2020#5
|
||||
Rockstar,Post Malone Featuring 21 Savage,2017#56/2018#5
|
||||
Rolex,Ayo & Teo,2017#61
|
||||
Roses,SAINt JHN,2020#19
|
||||
Roses,The Chainsmokers Featuring Rozes,2016#27
|
||||
Roxanne,Arizona Zervas,2020#16
|
||||
Rumor,Lee Brice,2019#81
|
||||
Running Up That Hill (A Deal With God),Kate Bush,2022#23
|
||||
Sad!,XXXTENTACION,2018#17
|
||||
Said Sum,Moneybagg Yo,2020#98
|
||||
Sailor Song,Gigi Perez,2025#36
|
||||
Same Old Love,Selena Gomez,2016#40
|
||||
Sand In My Boots,Morgan Wallen,2022#58
|
||||
Saturn,SZA,2024#19
|
||||
Savage,Megan Thee Stallion,2020#15
|
||||
Savage Love (Laxed - Siren Beat),Jawsh 685 x Jason Derulo,2020#35
|
||||
Save Me,Jelly Roll With Lainey Wilson,2024#65
|
||||
Save Your Tears,The Weeknd & Ariana Grande,2021#2/2022#40
|
||||
Say It,Tory Lanez,2016#74
|
||||
Say So,Doja Cat,2020#11
|
||||
Say Something,Justin Timberlake Featuring Chris Stapleton,2018#85
|
||||
Say You Won't Let Go,James Arthur,2017#11
|
||||
Scars To Your Beautiful,Alessia Cara,2017#30
|
||||
Search & Rescue,Drake,2023#45
|
||||
See You Again,Wiz Khalifa Featuring Charlie Puth,2016#99
|
||||
Send My Love (To Your New Lover),Adele,2016#26
|
||||
Senorita,Shawn Mendes & Camila Cabello,2019#15/2020#44
|
||||
Seven,Jung Kook Featuring Latto,2023#82
|
||||
Shallow,Lady Gaga & Bradley Cooper,2019#19
|
||||
Shape Of You,Ed Sheeran,2017#1/2018#71
|
||||
She Had Me At Heads Carolina,Cole Swindell,2022#46
|
||||
She Likes It,Russell Dickerson & Jake Scott,2022#91
|
||||
She's All I Wanna Be,Tate McRae,2022#94
|
||||
Shirt,SZA,2023#51
|
||||
Shivers,Ed Sheeran,2022#5
|
||||
Shotta Flow,NLE Choppa,2019#92
|
||||
Sicko Mode,Travis Scott,2018#42/2019#9
|
||||
Side To Side,Ariana Grande Featuring Nicki Minaj,2016#77/2017#43
|
||||
Sign Of The Times,Harry Styles,2017#87
|
||||
Simple,Florida Georgia Line,2018#82
|
||||
Single Saturday Night,Cole Swindell,2021#95
|
||||
"Sit Still, Look Pretty",Daya,2016#78
|
||||
Sky Walker,Miguel Featuring Travis Scott,2018#75
|
||||
Slide,Calvin Harris Featuring Frank Ocean & Migos,2017#71
|
||||
Slide,H.E.R. Featuring YG,2020#99
|
||||
Slippery,Migos Featuring Gucci Mane,2017#86
|
||||
Slow Hands,Niall Horan,2017#32
|
||||
Slow It Down,Benson Boone,2024#41
|
||||
Small Town Boy,Dustin Lynch,2017#94
|
||||
Smile,Morgan Wallen,2025#64
|
||||
Smokin Out The Window,Silk Sonic (Bruno Mars & Anderson .Paak),2022#41
|
||||
Snooze,SZA,2023#9/2024#20
|
||||
Soda Pop,"Saja Boys: Andrew Choi, Neckwav, Danny Chung, Kevin Woo & samUIL Lee",2025#61
|
||||
Somebody Loves Me,"PARTYNEXTDOOR, Drake & Cash Cobain",2025#85
|
||||
Someone You Loved,Lewis Capaldi,2019#27/2020#10
|
||||
Something In The Orange,Zach Bryan,2022#39/2023#13
|
||||
Something Just Like This,The Chainsmokers & Coldplay,2017#5
|
||||
Sorry,Beyonce,2016#71
|
||||
Sorry,Justin Bieber,2016#2
|
||||
Sorry I'm Here For Someone Else,Benson Boone,2025#37
|
||||
Sorry Not Sorry,Demi Lovato,2017#47/2018#64
|
||||
Speechless,Dan + Shay,2019#35
|
||||
Spin Bout U,Drake & 21 Savage,2023#43
|
||||
Sports Car,Tate McRae,2025#46
|
||||
Squabble Up,Kendrick Lamar,2025#20
|
||||
Starboy,The Weeknd Featuring Daft Punk,2016#58/2017#20
|
||||
Stargazing,Myles Smith,2024#73/2025#29
|
||||
Starting Over,Chris Stapleton,2021#53
|
||||
Starving,Hailee Steinfeld & Grey Featuring Zedd,2016#94/2017#82
|
||||
Stay,The Kid LAROI & Justin Bieber,2021#12/2022#3
|
||||
Stay,Zedd & Alessia Cara,2017#17
|
||||
Stick Season,Noah Kahan,2024#11
|
||||
Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",2025#41
|
||||
Stir Fry,Migos,2018#48
|
||||
Stitches,Shawn Mendes,2016#23
|
||||
Streets,Doja Cat,2021#67
|
||||
Stressed Out,twenty one pilots,2016#5
|
||||
Strip That Down,Liam Payne Featuring Quavo,2017#36
|
||||
Stuck With U,Ariana Grande & Justin Bieber,2020#80
|
||||
Sucker,Jonas Brothers,2019#10
|
||||
Sucker For Pain,"Lil Wayne, Wiz Khalifa & Imagine Dragons With Logic & Ty Dolla $ign Feat. X Ambassadors",2016#68
|
||||
Suge,DaBaby,2019#24
|
||||
Suicidal,YNW Melly & Juice WRLD,2020#75
|
||||
Sum 2 Prove,Lil Baby,2020#79
|
||||
Sunday Best,Surfaces,2020#61
|
||||
Sunflower (Spider-Man: Into The Spider-Verse),Post Malone & Swae Lee,2019#2
|
||||
Sunroof,Nicky Youre & dazy,2022#29
|
||||
Supalonely,BENEE Featuring Gus Dapperton,2020#84
|
||||
Super Freaky Girl,Nicki Minaj,2022#56/2023#79
|
||||
Super Gremlin,Kodak Black,2022#9
|
||||
Superhero (Heroes & Villains),"Metro Boomin, Future & Chris Brown",2023#38
|
||||
Superman,Morgan Wallen,2025#99
|
||||
Sure Thing,Miguel,2023#24
|
||||
Surface Pressure,Jessica Darrow,2022#53
|
||||
Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,2017#85
|
||||
Swang,Rae Sremmurd,2017#64
|
||||
Sweet But Psycho,Ava Max,2019#23
|
||||
Sweetest Pie,Megan Thee Stallion & Dua Lipa,2022#62
|
||||
Swervin,A Boogie Wit da Hoodie Featuring 6ix9ine,2019#82
|
||||
T-Shirt,Migos,2017#52
|
||||
Take My Breath,The Weeknd,2021#89
|
||||
Take My Name,Parmalee,2022#82
|
||||
Takedown,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#93
|
||||
Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",2019#57
|
||||
Talk,Khalid,2019#8
|
||||
Talk You Out Of It,Florida Georgia Line,2019#94
|
||||
Taste,Sabrina Carpenter,2024#86/2025#19
|
||||
Taste,Tyga Featuring Offset,2018#28
|
||||
Te Bote,"Casper Magico, Nio Garcia, Darell, Nicky Jam, Ozuna & Bad Bunny",2018#81
|
||||
Telepatia,Kali Uchis,2021#49
|
||||
Tennessee Orange,Megan Moroney,2023#67
|
||||
Tequila,Dan + Shay,2018#32/2019#91
|
||||
Texas Hold 'Em,Beyonce,2024#32
|
||||
Thank God,Kane Brown With Katelyn Brown,2023#23
|
||||
"Thank U, Next",Ariana Grande,2019#12
|
||||
That's So True,Gracie Abrams,2025#14
|
||||
That's What I Like,Bruno Mars,2017#3
|
||||
Thats What I Want,Lil Nas X,2022#14
|
||||
The Bigger Picture,Lil Baby,2020#76
|
||||
The Bones,Maren Morris,2020#9
|
||||
The Box,Roddy Ricch,2020#3
|
||||
The Fighter,Keith Urban Featuring Carrie Underwood,2017#100
|
||||
The Git Up,Blanco Brown,2019#56
|
||||
The Good Ones,Gabby Barrett,2021#62
|
||||
The Hills,The Weeknd,2016#32
|
||||
The Kind Of Love We Make,Luke Combs,2022#36
|
||||
The London,"Young Thug, J. Cole & Travis Scott",2019#64
|
||||
The Middle,"Zedd, Maren Morris & Grey",2018#8
|
||||
The Painter,Cody Johnson,2024#98
|
||||
The Scotts,"THE SCOTTS, Travis Scott & Kid Cudi",2020#86
|
||||
The Woo,Pop Smoke Featuring 50 Cent & Roddy Ricch,2020#78
|
||||
There's Nothing Holdin' Me Back,Shawn Mendes,2017#23
|
||||
Therefore I Am,Billie Eilish,2021#25
|
||||
Things A Man Oughta Know,Lainey Wilson,2021#96
|
||||
Thinkin' Bout Me,Morgan Wallen,2023#19/2024#31
|
||||
This Is America,Childish Gambino,2018#51
|
||||
This Is What You Came For,Calvin Harris Featuring Rihanna,2016#17
|
||||
Thot Shit,Megan Thee Stallion,2021#71
|
||||
Thotiana,Blueface,2019#47
|
||||
Thought You Should Know,Morgan Wallen,2023#22
|
||||
Throat Baby (Go Baby),BRS Kash,2021#97
|
||||
Thunder,Imagine Dragons,2017#51/2018#22
|
||||
Tiimmy Turner,Desiigner,2016#98
|
||||
Time Today,Moneybagg Yo,2021#76
|
||||
Timeless,The Weeknd & Playboi Carti,2025#16
|
||||
Titi Me Pregunto,Bad Bunny,2022#22
|
||||
To The Moon!,JNR CHOI & Sam Tompkins,2022#97
|
||||
Todo de Ti,Rauw Alejandro,2021#100
|
||||
Tombstone,Rod Wave,2021#98
|
||||
Tomorrow 2,GloRilla & Cardi B,2023#70
|
||||
Too Good,Drake Featuring Rihanna,2016#29
|
||||
Too Good At Goodbyes,Sam Smith,2017#77/2018#49
|
||||
Too Sweet,Hozier,2024#10/2025#34
|
||||
Toosie Slide,Drake,2020#32
|
||||
TQG,Karol G x Shakira,2023#65
|
||||
Track Star,Mooski,2021#75
|
||||
Traitor,Olivia Rodrigo,2021#38
|
||||
Trampoline,SHAED,2019#70/2020#53
|
||||
Treat You Better,Shawn Mendes,2016#28/2017#92
|
||||
Trip,Ella Mai,2018#92/2019#80
|
||||
Trouble With A Heartbreak,Jason Aldean,2022#88
|
||||
Truck Bed,HARDY,2024#67
|
||||
Truth Hurts,Lizzo,2019#13/2020#55
|
||||
Try That In A Small Town,Jason Aldean,2023#66
|
||||
Tu Boda,Oscar Maydon & Fuerza Regida,2025#87
|
||||
Tunnel Vision,Kodak Black,2017#55
|
||||
TV Off,Kendrick Lamar Featuring Lefty Gunplay,2025#15
|
||||
Type Shit,"Future, Metro Boomin, Travis Scott & Playboi Carti",2024#51
|
||||
Un x100to,Grupo Frontera X Bad Bunny,2023#49
|
||||
Under The Influence,Chris Brown,2023#17
|
||||
Undressed,sombr,2025#42
|
||||
Unforgettable,French Montana Featuring Swae Lee,2017#15
|
||||
Unholy,Sam Smith & Kim Petras,2022#98/2023#11
|
||||
Unsteady,X Ambassadors,2016#67
|
||||
Until I Found You,Stephen Sanchez,2023#50
|
||||
Up,Cardi B,2021#26
|
||||
Vampire,Olivia Rodrigo,2023#30/2024#57
|
||||
Vegas,Doja Cat,2022#47
|
||||
Wait,Maroon 5,2018#58
|
||||
Wait For U,Future Featuring Drake & Tems,2022#11/2023#83
|
||||
Wait In The Truck,HARDY Featuring Lainey Wilson,2023#54
|
||||
Wake Up In The Sky,Gucci Mane X Bruno Mars X Kodak Black,2019#51
|
||||
Walk Em Down,NLE Choppa Featuring Roddy Ricch,2020#100
|
||||
Walk It Talk It,Migos Featuring Drake,2018#43
|
||||
Walk Me Home,P!nk,2019#99
|
||||
Wanna Be,GloRilla & Megan Thee Stallion,2024#40
|
||||
Wants And Needs,Drake Featuring Lil Baby,2021#47
|
||||
WAP,Cardi B Featuring Megan Thee Stallion,2020#24
|
||||
Wasted On You,Morgan Wallen,2022#19
|
||||
Watch Me,Silento,2016#88
|
||||
Water,Tyla,2024#24
|
||||
Water Under The Bridge,Adele,2017#88
|
||||
Watermelon Moonshine,Lainey Wilson,2023#100
|
||||
Watermelon Sugar,Harry Styles,2020#20
|
||||
Way 2 Sexy,Drake Featuring Future & Young Thug,2021#48
|
||||
We Can't Be Friends (Wait For Your Love),Ariana Grande,2024#26
|
||||
We Don't Talk About Bruno,"Carolina Gaitan, Mauro Castillo, Adassa, Rhenzy Feliz, Diane Guerrero, Stephanie Beatriz & Encanto Cast",2022#24
|
||||
We Don't Talk Anymore,Charlie Puth Featuring Selena Gomez,2016#50
|
||||
We Paid,Lil Baby & 42 Dugg,2020#57
|
||||
We're Good,Dua Lipa,2021#90
|
||||
Weren't For The Wind,Ella Langley,2025#71
|
||||
What About Us,P!nk,2017#84
|
||||
What Do You Mean?,Justin Bieber,2016#31
|
||||
What Happened To Virgil,Lil Durk Featuring Gunna,2022#83
|
||||
What I Want,Morgan Wallen Featuring Tate McRae,2025#27
|
||||
What Ifs,Kane Brown Featuring Lauren Alaina,2017#72
|
||||
What It Is (Block Boy),Doechii Featuring Kodak Black,2023#76
|
||||
What It Sounds Like,"HUNTR/X: EJAE, Audrey Nuna & REI AMI",2025#83
|
||||
What Lovers Do,Maroon 5 Featuring SZA,2017#97/2018#89
|
||||
What My World Spins Around,Jordan Davis,2023#91
|
||||
What Was I Made For?,Billie Eilish,2023#81/2024#46
|
||||
What You Know Bout Love,Pop Smoke,2021#22
|
||||
What's Next,Drake,2021#68
|
||||
What's Your Country Song,Thomas Rhett,2021#80
|
||||
Whatchu Kno About Me,GloRilla & Sexyy Red,2025#48
|
||||
Whatever It Takes,Imagine Dragons,2018#37
|
||||
Whatever She Wants,Bryson Tiller,2024#58
|
||||
Whats Poppin,"Jack Harlow Featuring DaBaby, Tory Lanez & Lil Wayne",2020#13
|
||||
When The Party's Over,Billie Eilish,2019#67
|
||||
When We Were Young,Adele,2016#83
|
||||
Where It Ends,Bailey Zimmerman,2024#87
|
||||
Where She Goes,Bad Bunny,2023#72
|
||||
Where The Wild Things Are,Luke Combs,2024#70
|
||||
Whiskey Glasses,Morgan Wallen,2019#52
|
||||
White Horse,Chris Stapleton,2024#77
|
||||
White Iverson,Post Malone,2016#65
|
||||
Who,Jimin,2025#57
|
||||
Whoopty,CJ,2021#50
|
||||
Wicked,Future,2016#97
|
||||
Wild As Her,Corey Kent,2023#93
|
||||
Wild Ones,Jessie Murph & Jelly Roll,2024#36
|
||||
Wild Thoughts,DJ Khaled Featuring Rihanna & Bryson Tiller,2017#18
|
||||
Wildest Dreams,Taylor Swift,2016#79
|
||||
Wildflower,Billie Eilish,2024#89/2025#26
|
||||
Willow,Taylor Swift,2021#55
|
||||
Wind Up Missin' You,Tucker Wetmore,2024#95
|
||||
Wishing Well,Juice WRLD,2020#92
|
||||
Without Me,Halsey,2019#3
|
||||
Without You,The Kid LAROI,2021#17
|
||||
Woah,Lil Baby,2020#42
|
||||
Wockesha,Moneybagg Yo,2021#42
|
||||
Wolves,Selena Gomez X Marshmello,2018#60
|
||||
Woman,Doja Cat,2022#21
|
||||
Work,Rihanna Featuring Drake,2016#4
|
||||
Work From Home,Fifth Harmony Featuring Ty Dolla $ign,2016#16
|
||||
World On Fire,Nate Smith,2024#90
|
||||
Worst Way,Riley Green,2025#35
|
||||
Worth It,YK Osiris,2019#77
|
||||
Wow.,Post Malone,2019#5
|
||||
X,Nicky Jam x J Balvin,2018#90
|
||||
XO TOUR Llif3,Lil Uzi Vert,2017#13
|
||||
Yeah Glo!,GloRilla,2024#44
|
||||
Yes Indeed,Lil Baby & Drake,2018#25
|
||||
"Yes, And?",Ariana Grande,2024#92
|
||||
Yonaguni,Bad Bunny,2021#83
|
||||
You Broke Me First.,Tate McRae,2021#37
|
||||
You Make It Easy,Jason Aldean,2018#70
|
||||
You Need To Calm Down,Taylor Swift,2019#39
|
||||
You Proof,Morgan Wallen,2022#27/2023#12
|
||||
You Right,Doja Cat & The Weeknd,2021#43/2022#45
|
||||
You Say,Lauren Daigle,2019#60
|
||||
You Should Be Sad,Halsey,2020#82
|
||||
You Should Probably Leave,Chris Stapleton,2022#79
|
||||
You're Mines Still,Yung Bleu Featuring Drake,2021#58
|
||||
Young Dumb & Broke,Khalid,2017#78/2018#67
|
||||
Youngblood,5 Seconds Of Summer,2018#36/2019#33
|
||||
Your Idol,"Saja Boys: Andrew Choi, Neckwav, Danny Chung, Kevin Woo & samUIL Lee",2025#54
|
||||
Yummy,Justin Bieber,2020#58
|
||||
ZEZE,Kodak Black Featuring Travis Scott & Offset,2019#31
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,383 +0,0 @@
|
||||
# 決策日誌
|
||||
|
||||
每筆記錄:日期、決策、理由。定義檔(`prompts/`)、codebook、
|
||||
研究計畫的任何修訂都必須在此留下記錄。
|
||||
|
||||
## 2026-07-30
|
||||
|
||||
- **語料擴大為 2016–2025 十年**(先導研究為 2018–2025)。
|
||||
理由:框架完整、可看年度趨勢。
|
||||
- **全文不與先導研究比較**:論文僅呈現正式研究結果,數字
|
||||
一律以正式研究為準;先導研究只作為 codebook v0 與假說的
|
||||
內部來源,記於本日誌,不進入論文敘事。
|
||||
- **分析管線採 API script,不用 Claude Code subagent**。理由:
|
||||
實測證實 subagent 會繼承 CLAUDE.md 與環境資訊,context 無法
|
||||
僅憑定義檔重現;API 呼叫的輸入完全受控且可稽核。
|
||||
- **模型釘定 `claude-sonnet-4-6`、temperature=0、thinking 關閉**。
|
||||
理由:該組合非決定性最低;條件 A/B 盲點實驗以模型為研究
|
||||
對象,須與 pilot 的 Sonnet 家族銜接。Fable 5 不採用(成本、
|
||||
thinking 不可關閉且不可稽核、無 temperature、混淆核心主張)。
|
||||
- **工具性環節的模型於階段 3 以校準樣本實測後決定**:同批
|
||||
校準樣本以 Sonnet 4.6 與 Opus 5 各跑一次,對照人工黃金標準
|
||||
比較一致率,據以決定是否於特定環節升級。
|
||||
- **codebook 採 directed content analysis**:理論骨架
|
||||
(top-down)+開放碼歸納通道(bottom-up),並加做 LLM 純歸納
|
||||
輪與黃金標準的映射分析。映射分析方法須在看到結果前寫入
|
||||
`methodology.md`。
|
||||
- **完整歌詞不進 git**(版權);API 金鑰走 `.env`。
|
||||
- **專案目錄維持原名 `pop-fem-audit`**:API 管線下目錄名不會
|
||||
進入模型輸入,無污染疑慮。
|
||||
|
||||
## 2026-07-31
|
||||
|
||||
- **專案英文名定為「A Feminist Audit of Pop Music」**:明示
|
||||
女性主義立場,「audit」兼指對歌曲與對 LLM 標籤系統的稽核,
|
||||
並與縮寫 `pop-fem-audit` 對應。
|
||||
- **程式碼收整為 `tools/` src-layout 子專案**:發行名
|
||||
`pop-fem-audit-tools`、import 套件名 `pop_fem_audit_tools`、
|
||||
description「Tools for A Feminist Audit of Pop Music.」;
|
||||
以 `pip install -e tools/` 安裝、`python -m
|
||||
pop_fem_audit_tools.run_llm` 執行;相依套件記於
|
||||
`pyproject.toml`(`requirements.txt` 移除);Sphinx 文件
|
||||
暫緩。理由:後續多支程式將共用程式碼,套件化後測試可用
|
||||
正常 import;目錄名 `tools/` 經無脈絡的獨立 subagent 命名
|
||||
評估選出,最誠實反映「服務研究的輔助工具」定位——研究
|
||||
本體在根目錄的 prompts/、runs/、results/,程式只是配套。
|
||||
- **CLI 入口改為套件層級 dispatcher**:dispatcher 為單一
|
||||
入口,`run_llm.py` 的 entry point 移除;有兩種等價呼叫
|
||||
形式——`python -m pop_fem_audit_tools run-llm ...` 與
|
||||
console script `pop-fem-audit-tools run-llm ...`
|
||||
(`[project.scripts]`)。理由:後續多支工具共用單一入口,
|
||||
`--help` 可列出全部子命令,重現文件穩定。
|
||||
- **資料儲存架構定案**:commit 判準——凡能由「committed 輸入+
|
||||
committed 程式」決定性再生者不 commit;源頭、外部捕捉、人工
|
||||
著作以文字格式 commit。工作儲存採 SQLite 單檔
|
||||
(`tools/instance/`,generated、不進 git;歌詞全文入 DB 故無
|
||||
版權疑慮),schema 以 SQLAlchemy 2.0 typed ORM 定義,設定經
|
||||
pydantic-settings(`.env`)。`results/` 報表 CSV 為「可再生仍
|
||||
commit」的唯一例外,理由:論文引用穩定性、審稿人零門檻、
|
||||
撰稿期數字變動可 diff。(本條經多輪辯證後定案,推翻 Claude
|
||||
最初的全 CSV 方案。)
|
||||
- **資料模型**:songs、chart_entries(1—N)、artists、
|
||||
song_artists(M—N:角色、署名順序)、lyrics(1—1);領域
|
||||
不變量檢查內建 `build-db`,違規即失敗。
|
||||
- **歌手背景擴充與防火牆**:歌手資料擴為實體表(QID、性別、
|
||||
型態、曲風、國籍),women-power 候選的歌手另做深度背景
|
||||
(族裔以公開自我認同為準、音樂場景),供人工解讀與論文
|
||||
討論;**歌手背景絕不進 LLM 輸入**(歌詞-only),避免光環
|
||||
偏誤污染條件 A/B 實驗。
|
||||
- **沿用先導研究歌詞捕捉**(lyrics.json,684 首,2018–2025):
|
||||
只匯入識別欄位與歌詞本文,pilot 分析欄位不匯入;以
|
||||
(year, rank) 精確匹配。出處記於 `lyrics-provenance.csv`:
|
||||
source(原始 API)與 method(pilot-import / api-fetch)
|
||||
兩層;取得日期不可考者不假造,僅記可證上界。
|
||||
- **`run_llm` 改走 pydantic-settings 統一設定**(刪手寫 .env
|
||||
parser),與 `config.py` / `database.py` 一致。
|
||||
- **`lyrics` 表併入 `songs.lyrics` nullable 欄位**(資料模型由
|
||||
五表改四表)。理由:1—1 關係在此量級下獨立成表只有正規化
|
||||
慣性,nullable 欄位更簡單;「未取得」以 NULL 表達,語意
|
||||
等價。
|
||||
|
||||
## 2026-08-01
|
||||
|
||||
- **引擎獨立性以 PostgreSQL/SQLite 為範圍**(MySQL 的 VARCHAR
|
||||
長度限制不處理)。
|
||||
- **`build-db` 的重置改用逐表 DELETE,不再 drop/create**:
|
||||
schema 生命週期歸 migration 管,build-db 只管資料。連帶
|
||||
效果:重置成為純 DML,全程單一交易、驗證通過才 commit——
|
||||
建置失敗時前一版資料完好。首次執行仍以 create_all
|
||||
(checkfirst)補缺表。
|
||||
- **song/artist ID 由 build-db 顯式指派**(首次出現順序 1、2、
|
||||
3…),不依賴 autoincrement——PostgreSQL 的 sequence 在
|
||||
DELETE 後不重置,顯式指派讓重建決定性跨引擎成立。
|
||||
- **禁止文字 SQL statement,一律經 SQLAlchemy ORM/Core API**。
|
||||
唯一記錄在案的例外:SQLite 的 `PRAGMA foreign_keys=ON`
|
||||
(官方建議作法,無非文字 API 可用;SQLite 的 FK 旗標為
|
||||
逐連線設定)。裁定其歸屬為連線組態,實作於 `database.py`
|
||||
的 `__create_engine`——建 engine 時對 SQLite 註冊 connect
|
||||
listener,所有消費者全程生效,不再由 build-db 各自註冊。
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
- **`import-lyrics` 不設為子命令,pilot 歌詞改以私人腳本
|
||||
匯入**。理由:pilot 捕捉檔不隨論文發布,子命令
|
||||
形式會在發布的 CLI 裡留下讀者無法執行的死命令——要交待的
|
||||
是「沿用 pilot 捕捉」的事實(記於 lyrics-provenance.csv 與
|
||||
論文方法節),不是工具本身;工具移出專案,發布管線即
|
||||
「讀者可完整執行的程序」。讀者重現歌詞的路徑為
|
||||
`fetch-lyrics`,API 漂移造成的差異屬捕捉層的已承認限制。
|
||||
provenance 的 method 值域定為
|
||||
pilot-import / api-fetch / manual。
|
||||
- **資料路徑全面改為顯式 CLI 引數**——必要運算元為位置
|
||||
引數、選擇性輸入為選項(build-db 吃榜單 CSV 一個位置引數,
|
||||
歌詞目錄、Wikidata 快照、overrides 為 `--lyrics-dir`/
|
||||
`--wikidata-csv`/`--overrides-csv` 三個選項;fetch-lyrics
|
||||
吃歌詞目錄、provenance、缺漏報表三個位置引數;fetch-artists
|
||||
吃快照檔;run-llm 吃 runs 目錄),命令與 CWD 無關,且每次
|
||||
執行觸碰的檔案完整見於指令本身。省略選項即不載入該捕捉層;
|
||||
給了選項而路徑不存在即建置失敗,取代原本的默默跳過。理由:
|
||||
settings 的 `.env` 依 pydantic 慣例讀自 CWD(約定
|
||||
`tools/`),資料路徑原以 repo 根為 CWD,兩者衝突;顯式引數
|
||||
消滅隱性 CWD 契約,亦拒絕以父目錄推導兄弟檔案的隱性慣例。
|
||||
- **`data/` 依生命週期分三層**:`source/`(源頭,手放後不動)、
|
||||
`captures/`(外部捕捉,只由 fetch 命令與私人匯入腳本寫入)、
|
||||
`manual/`(人工著作,只由研究者手寫;之後的黃金標準編碼
|
||||
亦居此)。理由:三種生命週期混住一層,目錄無法傳達「誰可以
|
||||
寫哪裡」;顯式引數化後搬遷零程式改動。
|
||||
- **源頭榜單 CSV 全面對齊 Billboard 年終榜發布版**(修正
|
||||
203 列)。語料源頭定義為「年終榜發布當時的印法」,以
|
||||
Wayback Machine 發布時快照逐字轉錄(2016–2018 原沿
|
||||
Wikipedia 改寫慣例);年終原文殘缺或有疑問者,以當年週榜
|
||||
掛名為準(唯一適用例:2016#87〈All the Way Up〉)。
|
||||
- **歌名正規化採 canonical artist credit 對照表**:同一首歌
|
||||
因署名字串寫法不同而分裂者,以明示對照表合併;歌曲身分=
|
||||
(原始歌名,對照後署名字串),不解析署名——拆解機器不進
|
||||
歌曲身分判定,對照表即完整可稽核清單。
|
||||
- **歌手名單拆解規則定案**:署名列出成員的團體只留成員
|
||||
(冒號與括號兩型);名字內含連接詞的單一藝人入保護名單;
|
||||
規則無法表達的個案入例外表;人名連寫的二人組拆成個人;
|
||||
僅以團名掛名的團體維持單一實體,屬性留待 fetch-artists
|
||||
與人工 overrides。完整清單見 `build_db.py`。
|
||||
- **歌手名正規化**:歌手名大小寫不敏感歸併、存首見拼法;
|
||||
正名表(casefold 鍵→官方拼法)存官方拼法,含跨拼法合併
|
||||
ye→Kanye West。
|
||||
- **`data/` 增設 `derived/` 衍生層**:`build-db` 建置工作儲存
|
||||
的同一動作產出兩張人讀報表(songs.csv、artists.csv)並
|
||||
commit——與 SQLite 同交易語意、驗證通過才寫檔,稽核鏈
|
||||
「committed 輸入+程式→CSV」無中間空缺;為「可再生仍
|
||||
commit」的第二例外。報表不含資料庫 ID(內部參照,資料
|
||||
改版會重排,不供引用);關係以內嵌字串呈現——`/` 連同
|
||||
一首歌的多次上榜、`|` 連不同歌曲——欄位一律字母序。
|
||||
- **歌手正名表全面改存官方拼法**:變音符號一律恢復
|
||||
(Billboard 印法慣性去符號,共 12 筆,如 Aminé、Jhené
|
||||
Aiko、Silentó);二人組拆名改存成員全名(Dan Smyers、
|
||||
Shay Mooney);官方大小寫 3 筆(Cris MJ、Mariah the
|
||||
Scientist、Surf Mesa)。官方拼法逐筆以 DSP 官方頁查證
|
||||
(維基百科/Wikidata 的大小寫是其自家規範,不作準);
|
||||
查證亦確認 Tones And I 的 chart 印法即官方寫法,不改。
|
||||
- **`fetch-artists` 解析演算法定案**:改用 WDQS 的 SPARQL
|
||||
索引查詢(不依賴搜尋排名、不用 LCASE——實測會逾時)。
|
||||
步驟:(1) 歌手名與 label/alias 精確比對(@en+@mul 兩層)
|
||||
取候選,型態限人類∪音樂團體子樹∪original cast;(2) 唯一
|
||||
即定案;(3) 歧義以上榜歌曲佐證——歌名精確比對取歌曲條目
|
||||
的 P175 演出者(含其 P527 成員)與候選交集;(4) 再落空則
|
||||
反向錨定:候選(含所屬團體)演出過的歌曲以 casefold 比對
|
||||
歌名;(5) 均落空記查無並續行。每步須恰一命中才選定,
|
||||
實測零錯選。Last-resort 釘定表僅一筆(Pinkfong——唯一
|
||||
P31 為品牌的上榜演出者,型態閘門依設計排除)。暫時性
|
||||
故障(429/5xx/讀取逾時)退避重試,重試盡記 error 列;
|
||||
not found 專指程序查證後無所得。全量實跑 469 位全數
|
||||
解析且與人工查證的 QID 逐筆相符;首次快照隨本次入
|
||||
捕捉層。
|
||||
- **先補完 Wikidata、再捕捉快照**:歌手資料查證做在上游——
|
||||
以 subagent(`artist-wikidata-updater` 定義檔)逐位查證
|
||||
快照欄位(P21、P31、P136、P27/P495,新條目加 P106)並
|
||||
草擬附來源的 QuickStatements,由研究者本人審摘要、以本人
|
||||
帳號執行;編輯沉澱後重跑 `fetch-artists`,Wikidata 容不下
|
||||
的判斷才入 overrides。理由:修在上游全網受益,編輯史與
|
||||
來源公開可稽核,快照乾淨、overrides 縮小;論文方法節揭露
|
||||
「捕捉前經研究者查證補完」。性別以公開自我認同為準,
|
||||
推測不確定且無佐證者列疑慮清單;查證屬資料策展而非
|
||||
分析,以 Claude Code 輔助、不走分析 API;QS 批次以
|
||||
私人工作檔留存,不隨論文發布。
|
||||
|
||||
## 2026-08-04
|
||||
|
||||
- **移除人工 overrides 層**:`build-db` 刪去
|
||||
`--overrides-csv` 選項與套用邏輯,文件同步移除
|
||||
`data/manual/artists_overrides.csv`。理由:「先補完
|
||||
Wikidata、再捕捉快照」工作流實跑後,469 位歌手全數
|
||||
在上游查證補齊,快照即完整,本地覆蓋層已無存在事實;
|
||||
`data/manual/` 層保留(供日後黃金標準編碼)。
|
||||
- **`fetch-lyrics` 加原始署名 fallback**:以第一位 primary
|
||||
歌手查詢兩 API 皆落空時,改以拆解前的原始署名字串(缺漏
|
||||
報表既有的 `artist_credit`)重查一次;與主查詢同為精確
|
||||
查詢,API 順序不變。理由:二重唱/雙掛名歌曲在歌詞 API
|
||||
目錄以合體名義建檔(Dan + Shay、Lil Baby & DaBaby),
|
||||
單人名查詢必落空;手動模擬證實 fallback 三首全中。
|
||||
- **`build-db` 匯入重構為兩個 job class**:`SongImporter`
|
||||
(榜單 CSV→歌曲身分去重→songs+chart_entries)與
|
||||
`ArtistImporter`(自資料庫依 song ID 讀回署名→拆解→正名
|
||||
→去重登記→artists+song_artists),與 `CSVExporter` 同形
|
||||
(建構子收 session、單一公開入口);命名取 import/export
|
||||
對稱,不用 loader(有「載入記憶體」聯想,實為寫入持久
|
||||
儲存)。歌手登記改於歌曲全數入庫後第二階段進行,兩塊
|
||||
之間不再共享記憶體狀態;各 job 專用的純函數與規則表
|
||||
(身分判定、拆解、正名)隨行入 class 作公開 staticmethod
|
||||
/class 常數,「哪個函式屬哪個工作」由 class 歸屬直接
|
||||
表達。flush 定為匯入工作的完工契約——entry method 返回
|
||||
時自身寫入已可查詢,不再由呼叫者補 flush。捕捉層套用
|
||||
(歌詞、Wikidata 快照)隨後同型打包為 `CaptureImporter`
|
||||
(單一入口收兩個可省略路徑)。實測重構前後工作儲存
|
||||
dump 與衍生報表逐位元組相同。
|
||||
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
||||
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
||||
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
||||
拆成個人後,男女混合是歌曲層(song_artists+各歌手
|
||||
gender)可推導的事實,不屬歌手實體。非人非團體者
|
||||
(Pinkfong)type 留空由人工判定,維持現狀。
|
||||
- **獨立驗證步驟解散,不變量檢查歸屬各匯入工作**:榜單
|
||||
覆蓋((year, rank) 網格恰好齊全、無缺漏、無多出、無
|
||||
重複)檢在 `SongImporter` 匯入結尾;署名解析結果(至少
|
||||
一人、含 primary、名字非空白)檢在 `ArtistImporter` 逐筆
|
||||
解析後立即失敗。違規拋 `BuildError` 走既有的失敗路徑,
|
||||
`find_violations` 刪除。理由:檢查跟著產生資料的工作走,
|
||||
main 不再有驗證分支;逐筆即時失敗使錯誤指向出錯的那筆
|
||||
署名。
|
||||
- **Markdown 檔名一律以 dash 連接**(decision-log.md、
|
||||
research-plan.md、project-structure.md 等;與 data/ 層
|
||||
CSV 檔名慣例一致),定義檔命名慣例同步改為
|
||||
`prompts/<task>-v<N>.md`(如 screen-v1.md),版本庫外的
|
||||
私人工作檔一併改名;`conference_abstract.md` 改名並搬入
|
||||
`paper/`——它是本次年會實際送出的摘要,與全文同屬投稿
|
||||
血脈,不是 `docs/` 的內部工作文件。全 repo 指涉同步更新。
|
||||
- **fetch-lyrics 移除缺漏報表**:刪去 `missing_csv` 位置引數
|
||||
與缺漏報表 CSV 輸出。理由:該報表只寫不讀(指令從未讀取
|
||||
它),缺漏數字每次執行皆由工作儲存比對歌詞目錄重新算出;
|
||||
worklist 角色由重跑冪等指令本身承擔;完整性已由 provenance
|
||||
CSV 與 build-db 統計數字佐證;該檔案不承載任何無法重新
|
||||
產生的資訊。
|
||||
- **新增 `export-llm-input` 子命令**:由工作儲存產出
|
||||
`run-llm` 的 JSONL 輸入檔(每筆 `id`+`content`);此為
|
||||
「歌手背景絕不進 LLM 輸入、歌詞-only」防火牆的執行點
|
||||
——內容只有歌詞,歌曲身分以 `song-<ID>` 不透明鍵放
|
||||
`custom_id`,不入訊息本體;ID 由 build-db 決定性指派
|
||||
故檔案可再生,依 commit 判準不進 git(且含歌詞全文,
|
||||
版權亦不許);固定匯出全部歌曲,缺歌詞即失敗;單一
|
||||
位置引數收輸出檔路徑,文件範例輸出至 `tools/instance/`。
|
||||
- **指令模組集中為 `commands` sub-package**:五個子命令模組
|
||||
移入 `pop_fem_audit_tools.commands`,`__init__` 以
|
||||
`from .run_llm import main as run_llm_command` 逐條登記為
|
||||
指令清單,`__main__` 只消費此 façade;`_command` 後綴避免
|
||||
與子模組同名遮蔽,測試仍以模組屬性風格使用;基礎設施模組
|
||||
(config/database/models/utils)留頂層。理由:「指令
|
||||
vs 共用底層」由目錄結構直接表達,與「哪個函式屬哪個工作」
|
||||
的歸屬原則同型。
|
||||
- **自然編碼管線四步驟定案**:自由標註(tag,×2 全進池不
|
||||
仲裁——自由詞彙兩次輸出無共享比對單位,仲裁即再生成)→
|
||||
自然收斂(merge,2+1)→ 強制收斂至 50 內(cap,2+1)→
|
||||
以定稿詞彙表全量編碼(code,2+1,附引述,逐首仲裁看
|
||||
歌詞)。收斂步驟不看歌詞(目標是產生 codebook 而非編
|
||||
碼);收斂仲裁的紀律:程式先算共識核(凍結)與分歧清
|
||||
單,仲裁只裁分歧、不得引入新概念。編碼回歸採 LLM 直接
|
||||
編碼為主儀器,理由:唯此能逐標籤附歌詞引述(偏差檢視的
|
||||
依據)、標籤是模型對歌曲的直接斷言(批判對象乾淨、不混
|
||||
收斂軌跡假影)、詞彙表出自模型自身故無語意干涉;軌跡
|
||||
機械對映降為診斷副產品。此設計為對前導研究「映回後未
|
||||
對照歌詞」限制的明文改良。
|
||||
- **2+1 協定適用原則重述**:由「每個 LLM 步驟」修正為
|
||||
「輸出可逐項機械比對的步驟」;自由生成步驟改為兩次執行
|
||||
進池。CLAUDE.md 同步修訂。理由:自由詞彙步驟強行仲裁
|
||||
等於第三次生成,無稽核意義;可比步驟的一致率(逐詞塊、
|
||||
逐首)才是有定義的穩定性證據。
|
||||
- **提示詞只定格式、不定語意**:LLM 定義檔不含任何主題
|
||||
定義、判準、範例;研究者判準只住 codebook。理由:研究
|
||||
對象是模型以網路語料知識背景所做的自然編碼,先給定義
|
||||
即消毒了批判對象。此原則之落實:粒度鎖定 thematic
|
||||
keywords(前導研究三粒度比較之繼承,執行前鎖定防事後
|
||||
擇優);收斂輸入不附頻次(頻率的分析角色由編碼步驟承
|
||||
擔;頻次誘使頻率剪枝與高頻主題細分)。引述歌詞證據為
|
||||
必要輸出——檢視編碼偏差的依據,不屬語意干涉。
|
||||
- **「女性力量」單目標篩選降級為取樣補漏網**:候選集由
|
||||
自然編碼(code 步驟)浮現;單目標篩選僅用於把模型「被
|
||||
明示提醒後認得出」的歌撈進人工審視範圍,量測漏標方向
|
||||
的偏差,不進偏差統計的定義。「全編碼+標籤詞提示」混合設計
|
||||
取消——既非自然編碼亦非深度判準,量到的東西
|
||||
無法解釋。screen 逐首 2+1、不給定義,標籤詞用
|
||||
`women-power-and-empowerment`。認清:此編碼是任意的——
|
||||
基於研究目的由研究者決定,不是由資料產生(先導研究中的
|
||||
來歷已不可考,不宣稱由下而上湧現)。選用理由:複合詞
|
||||
同時涵蓋力量宣示與賦權論述兩半概念空間,補漏網寧廣勿
|
||||
窄;「empowerment」正是研究對象的市場語彙;不押注單一
|
||||
時代慣用語。同義詞清單不採——每多一詞即研究者多塑形
|
||||
一分。已知限制:雙管詞語意模糊(無從分辨觸發自哪半),
|
||||
僅作召回之用,結果永不進統計的分子分母。標籤詞是
|
||||
screen 提示中唯一的語意種子,屬研究者的儀器選擇,據實
|
||||
揭露。
|
||||
- **定義檔命名定案:軌-步前綴、不帶版本號**:
|
||||
`prompts/<軌>-<步>-<task>.md`——軌 01=由下而上自然編碼、
|
||||
02=預先決定的 women-power-and-empowerment 篩選:
|
||||
01-01-tag、01-02-merge、01-03-cap、01-04-code、
|
||||
02-01-screen;仲裁檔同 prefix 加 `-arb`。檔名不帶版本
|
||||
號——版本即 git 歷史,失敗的版本不保留;每次執行的定義
|
||||
檔快照隨 runs/ 自我完備,`runs/` 目錄改為
|
||||
`<定義檔名>/`——同理不編日期:只存一份,重跑即取代,
|
||||
被取代的執行在 git 歷史,時間戳在 meta.json。四次收斂執行(merge ×2、cap ×2)
|
||||
各存合併記錄 JSON 隨該次執行入 runs/。
|
||||
- **`run-llm` 降階為純執行器**:原內建的 2+1 編排(文字級
|
||||
一致性判定、固定模板仲裁)拆除;run-llm 只負責「一份
|
||||
定義檔+一份輸入 JSONL,跑 N 次(預設 2、仲裁場合 1),
|
||||
歸檔 `runs/<定義檔名>/`(重跑即取代)」。理由:一致性
|
||||
判定須逐步驟、結構性(分組比對、標籤集合比對),屬
|
||||
確定性腳本的工作;仲裁輸入由比對腳本建構,本身成為
|
||||
runs/ 可稽核工件;執行器變小易測。比對/進池/裁決
|
||||
套用子命令另行實作。
|
||||
- **`run-llm` 一次呼叫即一次執行**:`--runs` 與 `--run` 皆不存
|
||||
在,完整命令形狀為 `run-llm <定義檔> <輸入檔> <歸檔目錄>`——三個
|
||||
必要運算元皆為位置引數(沿 2026-08-02「必要運算元為位置引數」慣
|
||||
例),歸檔目錄如 `runs/01-01-tag/run1`,工具內部零 run 概念,
|
||||
meta 不記 run 編號;「獨立執行兩次」=重現命令清單上的兩行命令,
|
||||
run 身分只活在命令清單與目錄佈局約定,不在工具內。理由:兩次執行
|
||||
互相獨立是方法學宣稱,其證據應由執行結構與重現命令清單自明,不應
|
||||
要求讀者讀工具原始碼;歸檔目的地為顯式引數,亦是「資料路徑全面顯
|
||||
式化、消滅隱性推導」原則的貫徹。比對子命令驗證兩 run 的定義檔與
|
||||
輸入 SHA 一致、缺 run 即失敗。目標目錄已存在即拒絕執行,重跑須明
|
||||
示 `--replace`。
|
||||
- **收斂仲裁演算法定案(逐對裁決)**:不做組對組匹配
|
||||
(兩組是否「同一組」無原則性答案),比較化約為詞對的
|
||||
共組關係——共識塊(交集細分)→ 分歧塊對枚舉 → 仲裁
|
||||
逐對二元裁決(輸入匿名,不含執行別、共識與歌詞)→
|
||||
union-find 遞移重組(固定邊集的連通元件唯一,與順序
|
||||
無關,決定性成立)。仲裁自身亦 2+1:獨立執行兩次,
|
||||
逐對比對,兩次相同即定案,相異者送 `-arb-arb` 終局票
|
||||
(單次,依協定終局)——每個分歧對等同三票多數決,
|
||||
仲裁鏈至此終止。邊界判斷之裁決為單次記錄性決定,不
|
||||
宣稱可再生(可重現性依計畫定義=程序透明+可稽核)。
|
||||
遞移導出的合併(共識「分開」的塊經第三塊橋接併組)為
|
||||
等價關係語意的邏輯結論,照單接受並逐筆記錄供稽核。
|
||||
一致率低於門檻(暫訂 50%,首輪實跑後校準)即不仲裁
|
||||
——視為定義檔約束不足,修訂後重跑該循環。cap 仲裁後
|
||||
組數略超 50 可接受,如實記錄。演算法明文於
|
||||
`methodology.md`,供讀者依循重現。
|
||||
- **收斂執行不命名,組名全權歸命名鏈(命名 2+1)**:
|
||||
merge、cap 的執行輸出改為無名分組(陣列之陣列)——
|
||||
既然命名鏈存在,收斂執行自取的名字毫無用處,徒增
|
||||
兩次執行間「同組不同名」的假分歧。最終分組定案後,
|
||||
`-name` 命名步驟獨立執行兩次對全部組自由命名——逐組
|
||||
比對,同名即定案(單次執行內名字唯一,故定名間必無
|
||||
撞名);異名送 `-name-arb` 擇一仲裁(匿名呈現兩候選,
|
||||
輸出限於候選並避讓已定名,機械可驗證)。理由:組名
|
||||
承重——merge 名為 cap 的輸入詞、cap 名為 code 的編碼
|
||||
類目——凡承重的語意判斷皆受 2+1。cap 的輸入即 merge
|
||||
定案後的組名清單,原始關鍵字不下傳——如同證據不進
|
||||
收斂,上游細節不污染下游步驟。
|
||||
- **定義檔輸出形狀統一與去署名**:LLM 輸出統一為單層
|
||||
dict/array——tag、code:關鍵字→引述;merge、cap:
|
||||
無名分組(陣列之陣列);code-arb:保留關鍵字→仲裁者自己的引述
|
||||
(剔除即不列,剔除集合由程式以鍵差推得);screen 系:
|
||||
引述陣列,非空即「有」(present 布林刪除,判斷與依據
|
||||
合一)。仲裁輸入一律不含執行別(assigned_by、
|
||||
run1/run2 刪除)——標示何方主張會誘使仲裁者揣測
|
||||
「哪次較可信」,而非就文本裁決。收斂組名採自由命名
|
||||
(不限取自成員詞),取命名貼合度;可追溯性由軌跡歸檔
|
||||
承擔。
|
||||
- **定義檔改三層編號**:
|
||||
`prompts/<軌>-<步>-<次步>-<task>.md`——次步為步內執行
|
||||
順序,明定讀者依循的先後(如 01-02-01-merge →
|
||||
01-02-02-merge-arb → 01-02-03-merge-arb-arb →
|
||||
01-02-04-merge-name → 01-02-05-merge-name-arb)。後綴
|
||||
`-arb` 仲裁、`-arb-arb` 終局票、`-name` 命名、
|
||||
`-name-arb` 命名擇一仲裁。
|
||||
- **screen 標籤詞改為 `women-power`(來歷考據定案)**:
|
||||
考據先導研究的 local agent 存檔:其第一步指令含數十個
|
||||
範例 thematic keywords,其中即有 women-power——為當時
|
||||
協作的 Claude Code 依研究者長期表達的關注主動加入
|
||||
(研究者端播種,非明示指定);先導標籤
|
||||
women-power-and-empowerment 則是第三步強制合併
|
||||
women-power+empowerment 兩個關鍵字的管線人工產物
|
||||
(研究者數月來誤以為是 women-power+women-empowerment
|
||||
之併,至此方明)。修正:探針指向被播種的概念本詞
|
||||
women-power,不用合併假影;research-plan 的「來歷不可
|
||||
考」敘述同步改為考據結果。附帶認清:先導第一步並非零
|
||||
語意提示——此即正式研究「只定格式、不定語意」設計所
|
||||
矯正者。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 決策日誌
|
||||
|
||||
每筆記錄:日期、決策、理由。定義檔(`prompts/`)、codebook、
|
||||
研究計畫的任何修訂都必須在此留下記錄。
|
||||
|
||||
## 2026-07-30
|
||||
|
||||
- **語料擴大為 2016–2025 十年**(先導研究為 2018–2025)。
|
||||
理由:框架完整、可看年度趨勢。
|
||||
- **全文不與先導研究比較**:論文僅呈現正式研究結果,數字
|
||||
一律以正式研究為準;先導研究只作為 codebook v0 與假說的
|
||||
內部來源,記於本日誌,不進入論文敘事。
|
||||
- **分析管線採 API script,不用 Claude Code subagent**。理由:
|
||||
實測證實 subagent 會繼承 CLAUDE.md 與環境資訊,context 無法
|
||||
僅憑定義檔重現;API 呼叫的輸入完全受控且可稽核。
|
||||
- **模型釘定 `claude-sonnet-4-6`、temperature=0、thinking 關閉**。
|
||||
理由:該組合非決定性最低;條件 A/B 盲點實驗以模型為研究
|
||||
對象,須與 pilot 的 Sonnet 家族銜接。Fable 5 不採用(成本、
|
||||
thinking 不可關閉且不可稽核、無 temperature、混淆核心主張)。
|
||||
- **工具性環節的模型於階段 3 以校準樣本實測後決定**:同批
|
||||
校準樣本以 Sonnet 4.6 與 Opus 5 各跑一次,對照人工黃金標準
|
||||
比較一致率,據以決定是否於特定環節升級。
|
||||
- **codebook 採 directed content analysis**:理論骨架
|
||||
(top-down)+開放碼歸納通道(bottom-up),並加做 LLM 純歸納
|
||||
輪與黃金標準的映射分析。映射分析方法須在看到結果前寫入
|
||||
`methodology.md`。
|
||||
- **完整歌詞不進 git**(版權);API 金鑰走 `.env`。
|
||||
- **專案目錄維持原名 `pop-fem-audit`**:API 管線下目錄名不會
|
||||
進入模型輸入,無污染疑慮。
|
||||
@@ -1,197 +0,0 @@
|
||||
# 方法細節
|
||||
|
||||
(全文方法節底稿。演算法在執行前寫定;任何修訂記入
|
||||
`decision-log.md`。定義檔全文見 `prompts/`,執行紀錄見
|
||||
`runs/`。)
|
||||
|
||||
## 自然編碼管線總覽
|
||||
|
||||
四步驟:自由標註(tag)→ 自然收斂(merge)→ 強制收斂
|
||||
(cap)→ 全量編碼(code),另設「女性力量」單目標篩選
|
||||
(screen)作黃金標準取樣的補漏網。歌詞只出現在 tag、code、
|
||||
screen 與各仲裁步驟;merge、cap 及其仲裁、命名皆不接觸
|
||||
歌詞。設計原則見 `research-plan.md`;本檔記載可重現的
|
||||
演算法細節。
|
||||
|
||||
## 收斂步驟(merge、cap)的 2+1 比對與仲裁
|
||||
|
||||
兩次獨立執行對同一批輸入詞各產生一個分組。比對不做
|
||||
「組對組」的匹配——兩個組是否為「同一組的變體」無原則性
|
||||
答案——而是把比較化約為「詞對的共組關係」:
|
||||
|
||||
1. **共識塊(交集細分)**:兩次執行都放在同組的詞歸為
|
||||
同一塊。即以「(第一次的組, 第二次的組)」二元組為鍵
|
||||
分桶,一桶一塊。此步為純集合運算。
|
||||
2. **分歧塊對枚舉**:每個塊完整落在各次執行的恰一組內,
|
||||
故「兩塊在某次執行中是否同組」定義良好。逐塊對檢查:
|
||||
兩次執行答案相同者為共識(同組或分開,直接定案);
|
||||
不同者列入分歧清單。
|
||||
3. **逐對仲裁(自身 2+1)**:分歧塊對送 LLM 仲裁
|
||||
(`01-02-02-merge-arb.md`、`01-03-02-cap-arb.md`)。
|
||||
仲裁者只看兩塊的內容詞,逐對二元裁決「是否同一
|
||||
主題」;輸入不含執行別(避免「猜哪一次較可信」的
|
||||
偏誤),不含共識部份,不含歌詞。仲裁自身獨立執行
|
||||
兩次,逐對比對:兩次裁決相同即定案;相異的塊對送
|
||||
終局票(`01-02-03-merge-arb-arb.md`、
|
||||
`01-03-03-cap-arb-arb.md`,單次執行,依協定為終局)
|
||||
——每個分歧塊對等同三票多數決,仲裁鏈至此終止。
|
||||
送入仲裁的塊對即模型自身不穩定的邊界判斷,其裁決為
|
||||
單次記錄性決定,不宣稱可再生;可重現性依計畫定義為
|
||||
「程序透明+可稽核」,裁決與其輸入全程歸檔。
|
||||
4. **確定性重組**:以每個「同組」裁決為一條邊,最終
|
||||
分組=圖的連通元件(union-find)。固定邊集的連通
|
||||
元件唯一,與處理順序無關,故重組決定性成立。
|
||||
遞移性後果照單全收:兩個共識「分開」的塊可能經第三
|
||||
塊橋接而併入同組——此為等價關係語意的邏輯結論,
|
||||
比對程式將此類「遞移導出的合併」逐筆記錄於歸檔,
|
||||
供稽核。仲裁的結果空間因此大於「兩次執行擇一」:
|
||||
可能比兩次都粗(多對皆裁同組),也可能比兩次都細
|
||||
(多對皆裁分開)。
|
||||
5. **一致率與保險絲**:比對程式計算塊對層級的一致率並
|
||||
記入歸檔。低於門檻(暫訂 50%,首輪實跑後校準)即
|
||||
不進行仲裁——兩次分組面目全非說明定義檔約束不足,
|
||||
依協定修訂定義檔並重跑該循環。
|
||||
6. **cap 的上限容忍**:仲裁後組數可能略超 50(多對裁
|
||||
「分開」時)。略微超過可接受,如實記錄,不強行
|
||||
壓縮。
|
||||
7. **組名定案(收斂不命名+命名 2+1)**:merge、cap 的
|
||||
執行輸出不含組名(無名分組,陣列之陣列)——命名鏈
|
||||
既然存在,收斂執行自取的名字只會製造兩次執行間
|
||||
「同組不同名」的假分歧。組名承重——merge 的組名是
|
||||
cap 的輸入詞,cap 的組名是 code 對歌詞編碼的類目——
|
||||
故與其他語意判斷同受 2+1。程序:(1) 最終分組定案後,
|
||||
命名步驟(`01-02-04-merge-name.md`、
|
||||
`01-03-04-cap-name.md`)獨立執行兩次,輸入為不透明
|
||||
組 ID 對成員詞,對全部組自由命名,僅受格式約束
|
||||
(小寫連字號、單次執行內不重複);(2) 逐組比對:兩次
|
||||
同名即定案(單次執行內名字唯一,故定名間必無撞名);
|
||||
(3) 異名的組送擇一仲裁(`01-02-05-merge-name-arb.md`、
|
||||
`01-03-05-cap-name-arb.md`)——匿名呈現兩候選,逐組
|
||||
擇一,輸出限於候選並避讓已定名,機械可驗證。cap 的
|
||||
輸入即 merge 定案後的組名清單。
|
||||
|
||||
### 收斂步驟的資料流(比對子命令的輸入輸出契約)
|
||||
|
||||
以 merge 為例(cap 完全同構,檔名換為 01-03 系):
|
||||
|
||||
1. 兩次執行的原始輸出:`runs/01-02-01-merge/run1/output.jsonl`
|
||||
與 `run2/output.jsonl`,各含一個無名分組(陣列之陣列)。
|
||||
2. 比對子命令讀入兩份分組,先驗證兩者為同一輸入詞集的
|
||||
完整分割(缺詞、多詞、重複即失敗),再計算共識塊與
|
||||
分歧塊對,產出仲裁輸入檔——即
|
||||
`01-02-02-merge-arb.md` 所收的 JSON:
|
||||
- `blocks`:塊 ID → 成員詞。**塊 ID 的指派決定性**:
|
||||
全部塊先按「各塊字典序最小的成員詞」排序,依序編為
|
||||
b1、b2、…;塊內成員詞亦按字典序排列。
|
||||
- `pairs`:分歧塊對清單,每對內部按塊 ID 序、清單
|
||||
整體按 (第一元素, 第二元素) 字典序排列。
|
||||
- 僅分歧塊對入列;共識(同組或分開)不送仲裁,由
|
||||
比對子命令直接寫入共識紀錄檔。
|
||||
同時產出:共識紀錄(共識塊、共識同組對、共識分開對)
|
||||
與塊對一致率(含保險絲判定)。
|
||||
3. 仲裁輸入檔以 run-llm 跑兩次
|
||||
(`runs/01-02-02-merge-arb/run1`、`run2`);比對
|
||||
子命令逐對比對兩份裁決,兩票相同即定案,相異的塊對
|
||||
依同一契約組成終局票輸入檔(`blocks` 僅含涉事塊、
|
||||
ID 沿用原編號),跑
|
||||
`runs/01-02-03-merge-arb-arb/run1`。
|
||||
4. 裁決套用子命令彙整三票結果,以 union-find 重組出
|
||||
最終分組,並寫出:最終分組檔(無名,陣列之陣列,
|
||||
組序與組內成員皆字典序)、遞移導出合併的紀錄、
|
||||
收斂軌跡(原始詞 → 最終組)。
|
||||
5. 最終分組轉為不透明組 ID(依組序編 g1、g2、…)進入
|
||||
命名鏈(見第 7 條);命名定案後,組名清單(字典序)
|
||||
即下一步的輸入。
|
||||
6. 以上中間檔全部隨 `runs/` 歸檔;一切排序規則固定,
|
||||
故給定相同的兩份執行輸出與相同的裁決,全流程輸出
|
||||
逐位元組可再生。
|
||||
|
||||
## 編碼步驟(code、screen)的 2+1 比對與仲裁
|
||||
|
||||
- **code**:逐首比對兩次執行的標籤集合(引述不參與
|
||||
比對)。兩次皆有的標籤為共識保留、兩次皆無為共識
|
||||
不標;單邊標籤送仲裁(`01-04-02-code-arb.md`)——仲裁者
|
||||
看歌詞全文與該標籤的引述(不含執行別),裁決保留者
|
||||
附仲裁者自己的引述,剔除者不列。剔除集合=送裁鍵減
|
||||
輸出鍵,由程式推得。
|
||||
- **screen**:輸出即引述陣列,非空=有、空=無。僅
|
||||
「一有一無」的歌送仲裁(`02-01-02-screen-arb.md`),
|
||||
輸入為歌詞加主張「有」方的引述(不記名),輸出同為
|
||||
引述陣列。
|
||||
- 仲裁者的引述可能與原引述不同:仲裁是對歌詞的重新
|
||||
判讀,其引述是該裁決自身的依據,非轉抄。
|
||||
|
||||
## 軌跡對映(診斷用)
|
||||
|
||||
沿收斂軌跡的機械對映:原始關鍵字 → merge 組 → cap 組,
|
||||
純程式查表,決定性。以其結果與 code 直接編碼的差異率
|
||||
作為「收斂軌跡扭曲」的診斷量,不作主結果。
|
||||
|
||||
## 全管線的交接契約
|
||||
|
||||
每一步的輸出如何變成下一步的輸入,皆為確定性程序,規則
|
||||
明定如下(收斂步驟內部的交接見上節):
|
||||
|
||||
- **歌詞輸入檔(tag、code、screen 共用)**:
|
||||
`export-llm-input` 自工作儲存產出,每筆
|
||||
`{"id": "song-<ID>", "content": <歌詞>}`,依歌曲 ID
|
||||
升序。三個讀歌詞的步驟共用同一檔,SHA-256 記入各步
|
||||
meta。
|
||||
- **tag → merge**:兩次執行的全部關鍵字取聯集、逐字串
|
||||
精確去重、字典序排列成 JSON 陣列,即 merge 的輸入。
|
||||
進池同時寫出處記錄(關鍵字 →(執行別,歌曲 ID)
|
||||
清單),供軌跡對映回到歌曲;出處記錄不進任何 LLM
|
||||
輸入。
|
||||
- **merge 定案 → cap**:merge 定案組名以字典序排成
|
||||
JSON 陣列,即 cap 的輸入(見上節第 5 點)。
|
||||
- **cap 定案 → code 定義檔**:定案組名以字典序逐行填入
|
||||
`01-04-01-code.md` 的詞彙表節(逐字),檔案隨 git
|
||||
commit 後方可執行——code 的定義檔因此自我完備,
|
||||
論文附錄可直接引用。
|
||||
- **code 兩次執行 → code-arb**:逐首比對標籤集合
|
||||
(鍵集合,引述不參與比對);僅有分歧的歌入仲裁輸入
|
||||
JSONL,依歌曲 ID 升序,每筆 `id` 沿用 `song-<ID>`、
|
||||
`content` 為固定鍵序序列化的
|
||||
`{"lyrics": …, "disagreements": …}`,disagreements
|
||||
鍵按字典序。
|
||||
- **code 定案**:每首歌的最終標籤=共識標籤 ∪ 仲裁保留
|
||||
標籤,寫入逐首紀錄檔(歌依 ID 升序、標籤按字典序,
|
||||
各標籤附其定案時的引述與來源層——共識或仲裁)。
|
||||
- **screen 兩次執行 → screen-arb**:僅「一有一無」的歌
|
||||
入仲裁輸入 JSONL(依 ID 升序),`content` 為
|
||||
`{"lyrics": …, "evidence": <肯定方引述>}`。
|
||||
- **screen 定案**:命中集合=兩次皆有 ∪ 仲裁裁定有。
|
||||
- **命名鏈的輸入構成**:`-name` 輸入的組 ID 依最終分組
|
||||
之組序(上節第 5 點)編 g1、g2、…,組內成員字典序;
|
||||
`-name-arb` 輸入中每組的兩個候選名**按字典序排列**
|
||||
——不按執行別,避免順序洩漏何方所取;`taken` 為已
|
||||
定案名的字典序清單。
|
||||
- **女性力量候選集**:於 cap 詞彙表定案後、黃金標準
|
||||
編碼開始前,由研究者指認詞彙表中屬「女性力量」概念
|
||||
域的組(指認及理由記入決策日誌),候選集=code 定案
|
||||
標籤含該等組者 ∪ screen 命中者。
|
||||
- **序列化通則**:所有中間檔為 UTF-8 JSON,鍵序與元素
|
||||
序皆依上列規則明定,無時間戳、無隨機成分;解析一律
|
||||
偵測重複鍵,違規即失敗。JSONL 一律以換行字元(\n)
|
||||
切行——歌詞含 U+2028 等 Unicode 行分隔符,
|
||||
`str.splitlines()` 類的通用切行會截斷 JSON 字串
|
||||
(實測踩中)。給定相同的 LLM 執行輸出,
|
||||
全部交接產物逐位元組可再生。
|
||||
|
||||
## 執行與稽核
|
||||
|
||||
- 每一步驟以 `run-llm <定義檔> <輸入檔> <歸檔目錄>`
|
||||
執行;獨立執行兩次=重現命令清單上的兩行命令,各自
|
||||
歸檔(`runs/<定義檔名>/run1`、`run2`),仲裁與命名
|
||||
各為獨立步驟、獨立歸檔。
|
||||
- 比對、裁決套用、重組、對映皆為確定性程式(子命令),
|
||||
其輸入輸出檔隨 runs/ 歸檔,JSON 解析一律偵測重複鍵,
|
||||
違規即失敗。
|
||||
- Batch API 的每筆請求自含全部脈絡且互不可見(平台
|
||||
契約),歌與歌之間的獨立性由此成立;兩次執行的獨立
|
||||
性由「兩次呼叫、兩個批次、兩份歸檔」的執行結構自明。
|
||||
|
||||
## 映射分析方法
|
||||
|
||||
(依 2026-07-30 決策,於看到結果前寫定;待黃金標準
|
||||
編碼展開前補入。)
|
||||
@@ -1,100 +0,0 @@
|
||||
# 專案目錄結構
|
||||
|
||||
(2026-07-30 討論定案;2026-07-31 更新為 tools/ 子專案與
|
||||
SQLite 工作儲存架構)
|
||||
|
||||
```
|
||||
pop-fem-audit/
|
||||
├── README.md # 專案說明、重現步驟
|
||||
├── CLAUDE.md # 極簡工作規範(subagent 會讀到,
|
||||
│ # 絕不放理論、codebook、預期結果)
|
||||
├── .gitignore # captures/lyrics/、.env、scratch
|
||||
├── data/ # 依生命週期分層(文字格式)
|
||||
│ ├── source/ # 源頭:手放後不動
|
||||
│ │ └── yearend_hot100_2016_2025.csv # 原始榜單
|
||||
│ ├── captures/ # 外部捕捉:只由 fetch 命令與
|
||||
│ │ │ # 私人匯入腳本寫入
|
||||
│ │ ├── artists-wikidata.csv # Wikidata 快照
|
||||
│ │ ├── lyrics-provenance.csv # 歌詞出處
|
||||
│ │ └── lyrics/ # 歌詞 .txt 快取
|
||||
│ │ # (gitignored,版權)
|
||||
│ ├── manual/ # 人工著作:只由研究者手寫
|
||||
│ │ # (黃金標準編碼等)
|
||||
│ └── derived/ # 衍生:只由 build-db 寫入
|
||||
│ ├── songs.csv # 歌曲報表(人讀;進 git)
|
||||
│ └── artists.csv # 歌手報表(人讀;進 git)
|
||||
├── prompts/ # LLM 定義檔(逐字作為 system prompt)
|
||||
│ └── <軌>-<步>-<次步>-<task>.md # 01-01-01-tag.md、
|
||||
│ # 01-02-02-merge-arb.md…
|
||||
│ # 不帶版本號,版本即 git 歷史
|
||||
├── tools/ # 輔助工具子專案(src-layout)
|
||||
│ ├── pyproject.toml # 發行名 pop-fem-audit-tools;
|
||||
│ │ # pip install -e tools/ 安裝
|
||||
│ ├── README.rst LICENSE MANIFEST.in .env.example .gitignore
|
||||
│ ├── docs/ # Sphinx API 文件
|
||||
│ ├── instance/ # SQLite 工作儲存(generated、
|
||||
│ │ # gitignored;含歌詞全文)
|
||||
│ ├── src/pop_fem_audit_tools/
|
||||
│ │ ├── __main__.py # 套件 CLI 進入點(分派子命令)
|
||||
│ │ ├── commands/ # CLI 子命令模組(登記於 __init__)
|
||||
│ │ │ ├── build_db.py # build the SQLite working store
|
||||
│ │ │ │ # from the inputs
|
||||
│ │ │ ├── export_llm_input.py # export the LLM input JSONL
|
||||
│ │ │ │ # (lyrics only) from the
|
||||
│ │ │ │ # working store
|
||||
│ │ │ ├── fetch_artists.py # fetch artist metadata from
|
||||
│ │ │ │ # Wikidata into the snapshot CSV
|
||||
│ │ │ ├── fetch_lyrics.py # fetch missing lyrics from the
|
||||
│ │ │ │ # public APIs into the lyrics dir
|
||||
│ │ │ └── run_llm.py # API 執行器:一份定義檔+一份輸入
|
||||
│ │ │ # →歸檔至指定目錄(Batch API);
|
||||
│ │ │ # 比對與仲裁編排由獨立子命令承擔
|
||||
│ │ ├── config.py # pydantic-settings 設定(.env)
|
||||
│ │ ├── database.py # SQLAlchemy engine / session / Base
|
||||
│ │ ├── models.py # SQLAlchemy ORM 資料模型
|
||||
│ │ └── utils.py # 共用工具(format_duration)
|
||||
│ └── tests/ # 單元測試(unittest)
|
||||
├── runs/ # 現行執行的完整稽核紀錄(進 git;
|
||||
│ │ # 重跑同一 run 須明示 --replace)
|
||||
│ └── <定義檔名>/ # 仲裁步驟居自己的 <task>-arb/
|
||||
│ └── run<N>/ # 每個 run 一份自我完備歸檔
|
||||
│ ├── prompt.md # 當次定義檔快照(自我完備)
|
||||
│ ├── output.jsonl # 該次執行原始輸出
|
||||
│ └── meta.json # model ID、temperature、時間戳、
|
||||
│ # batch ID、token 用量
|
||||
│ # (一致率由比對子命令記錄)
|
||||
├── results/ # 論文引用的報表 CSV(export 產出;
|
||||
│ # 「可再生仍 commit」的唯一例外)
|
||||
├── docs/
|
||||
│ ├── research-plan.md # 研究步驟規劃(本檔之姊妹篇)
|
||||
│ ├── project-structure.md # 本檔
|
||||
│ ├── codebook.md # 人工編碼手冊(版本由 git 管理)
|
||||
│ ├── decision-log.md # 決策日誌:每次改定義檔的原因
|
||||
│ └── methodology.md # 方法細節(全文方法節底稿;
|
||||
│ # 映射分析方法須在看結果前寫定)
|
||||
└── paper/
|
||||
├── abstract.md # 摘要
|
||||
└── full-paper.md # 全文
|
||||
```
|
||||
|
||||
## 設計理由
|
||||
|
||||
- **`runs/` 自我完備**:每個執行目錄含定義檔快照 + 原始輸出 +
|
||||
meta,讀者不需 git 考古即可稽核任一筆結果。
|
||||
- **`runs/`(原始稽核資料)與 `results/`(最終表)分離**:
|
||||
論文只引 `results/`,其來源可回溯至 `runs/`。
|
||||
- **`prompts/` 檔名不帶版本號**:版本即 git 歷史,失敗的
|
||||
版本不保留;論文引用的單位是 `runs/` 內隨執行保存的定義檔
|
||||
快照(每個執行目錄自我完備),不需檔名可指的版本名。
|
||||
- **Commit 判準**:能由「committed 輸入+程式」決定性再生者不
|
||||
commit(SQLite 工作儲存、LLM 輸入檔);源頭、捕捉、人工著作
|
||||
一律以文字 commit。「可再生仍 commit」的例外有二:
|
||||
`results/` 報表(引用穩定性、審稿人零門檻、撰稿期可 diff)
|
||||
與 `data/derived/` 人讀報表(與工作儲存同一動作產出,稽核
|
||||
鏈無中間空缺)。詳見 `research-plan.md`「資料儲存與模型」。
|
||||
- **設定**經 pydantic-settings 統一:`.env`(gitignored,範本
|
||||
`tools/.env.example`)供應 `SQLALCHEMY_DATABASE_URL` 與
|
||||
`ANTHROPIC_API_KEY`,絕不寫入 repo。
|
||||
- **CLAUDE.md 極簡**:實測證實 Claude Code subagent 會繼承專案
|
||||
CLAUDE.md 全文,故其中只放工作流程規則,領域知識一律放
|
||||
`docs/`(subagent 不會自動讀到)。
|
||||
@@ -0,0 +1,56 @@
|
||||
# 專案目錄結構
|
||||
|
||||
(2026-07-30 討論定案版;分析管線已改為 API script 路線,
|
||||
定義檔不再放 `.claude/agents/`)
|
||||
|
||||
```
|
||||
pop-fem-audit/
|
||||
├── README.md # 專案說明、重現步驟
|
||||
├── CLAUDE.md # 極簡工作規範(subagent 會讀到,
|
||||
│ # 絕不放理論、codebook、預期結果)
|
||||
├── .gitignore # data/lyrics/、.env、scratch
|
||||
├── conference_abstract.md # pilot 摘要(投稿版)
|
||||
├── data/
|
||||
│ ├── yearend_hot100_2016_2025.csv # 原始榜單(唯一手放原始檔)
|
||||
│ ├── songs_unique.csv # 衍生:去重歌曲表(song id)
|
||||
│ ├── artists_gender.csv # 衍生:演唱者性別後設資料
|
||||
│ └── lyrics/ # 歌詞快取(gitignored,版權)
|
||||
├── prompts/ # LLM 定義檔(逐字作為 system prompt)
|
||||
│ └── <task>_v<N>.md # 版本化:screen_v1.md、judge_v2.md…
|
||||
├── scripts/ # deterministic Python scripts
|
||||
│ │ # (runner、抓歌詞、統計、圖表)
|
||||
│ └── run_llm.py # API runner:2+1 協定、Batch API、
|
||||
│ # 自動寫入 runs/
|
||||
├── runs/ # 每次執行的完整稽核紀錄(進 git)
|
||||
│ └── <階段>/<日期>-<定義檔版本>/
|
||||
│ ├── prompt.md # 當次定義檔快照(自我完備)
|
||||
│ ├── run1.jsonl # 第一次執行原始輸出
|
||||
│ ├── run2.jsonl # 第二次執行原始輸出
|
||||
│ ├── arbitration.jsonl # 仲裁輸出
|
||||
│ └── meta.json # model ID、temperature、時間戳、
|
||||
│ # batch ID、一致率
|
||||
├── results/ # 仲裁後最終衍生表(論文引用來源)
|
||||
├── docs/
|
||||
│ ├── research_plan.md # 研究步驟規劃(本檔之姊妹篇)
|
||||
│ ├── project_structure.md # 本檔
|
||||
│ ├── codebook.md # 人工編碼手冊(版本由 git 管理)
|
||||
│ ├── decision_log.md # 決策日誌:每次改定義檔的原因
|
||||
│ └── methodology.md # 方法細節(全文方法節底稿;
|
||||
│ # 映射分析方法須在看結果前寫定)
|
||||
└── paper/
|
||||
└── full_paper.md # 全文
|
||||
```
|
||||
|
||||
## 設計理由
|
||||
|
||||
- **`runs/` 自我完備**:每個執行目錄含定義檔快照 + 原始輸出 +
|
||||
meta,讀者不需 git 考古即可稽核任一筆結果。
|
||||
- **`runs/`(原始稽核資料)與 `results/`(最終表)分離**:
|
||||
論文只引 `results/`,其來源可回溯至 `runs/`。
|
||||
- **`prompts/` 用檔名版本化**(不只靠 git 歷史):定義檔版本是
|
||||
論文附錄的引用單位,須可直接指名(如 judge_v2.md)。
|
||||
- **API 金鑰**放 `.env`(gitignored),script 由環境變數讀取,
|
||||
絕不寫入 repo。
|
||||
- **CLAUDE.md 極簡**:實測證實 Claude Code subagent 會繼承專案
|
||||
CLAUDE.md 全文,故其中只放工作流程規則,領域知識一律放
|
||||
`docs/`(subagent 不會自動讀到)。
|
||||
@@ -1,174 +0,0 @@
|
||||
# 研究步驟規劃
|
||||
|
||||
(2026-07-30 討論定案、2026-07-31 增補資料架構;
|
||||
後續變更請記入 `decision-log.md`)
|
||||
|
||||
## 總體框架
|
||||
|
||||
- **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料
|
||||
共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究
|
||||
做比較**;全文數字一律以正式研究
|
||||
結果為準。先導研究僅作為 codebook v0 與假說的內部來源,
|
||||
記於決策日誌,不進入論文敘事。
|
||||
- **執行原則**:主會話只做討論;所有分析由 deterministic script
|
||||
執行。LLM 步驟以 Python script 呼叫 Anthropic Messages API
|
||||
(個人 Console 帳號、Batch API 五折),定義檔逐字作為 system
|
||||
prompt。2+1 協定依輸出可比性適用:輸出可逐項機械比對的步驟
|
||||
(收斂、編碼)「同一定義檔獨立執行兩次+一次仲裁」,仲裁
|
||||
只裁程式算出的分歧清單;自由生成步驟(首步自由標註)兩次
|
||||
執行全數進池、不仲裁——自由詞彙兩次輸出不共享比對單位,
|
||||
無物可裁。仲裁結果不符預期則修訂定義檔重跑該循環。
|
||||
- **提示詞只定格式、不定語意**:研究對象是通用 LLM 以其
|
||||
網路語料知識背景所做的自然編碼,編碼結果本身是批判對象。
|
||||
LLM 定義檔只規定任務形狀(輸入、數量範圍、輸出格式),
|
||||
不給任何主題的定義、判準或範例;研究者的深度判準只寫在
|
||||
codebook(人工黃金標準用),兩者不互相滲透。LLM 編碼
|
||||
一律要求逐標籤引述歌詞原句,作為檢視編碼偏差的依據。
|
||||
- **模型**:`claude-sonnet-4-6`、temperature=0、thinking 關閉
|
||||
(此組合非決定性最低、成本合理)。條件 A/B 盲點實驗為
|
||||
自足設計:同語料、同模型、僅提示不同,對照黃金標準。
|
||||
其他工具性環節於階段 3 以校準樣本實測 Sonnet 4.6 vs Opus 5
|
||||
的一致率後決定是否升級。
|
||||
- **信度與效度分工**:2+1 協定量測穩定性(intra-rater
|
||||
reliability,報告一致率/kappa);效度靠理論 codebook 與
|
||||
人工黃金標準終審。可重現性定義為「程序透明+可稽核」:
|
||||
公開定義檔、記錄 model ID 與執行時間、保存全部原始輸出。
|
||||
|
||||
## 資料儲存與模型(2026-07-31 定案)
|
||||
|
||||
- **Commit 判準**:凡能由「committed 的輸入+committed 的程式」
|
||||
決定性再生者,不 commit;凡不能者——源頭資料、外部世界的
|
||||
捕捉(Wikidata 快照、LLM 原始輸出)、人工著作(核定、編碼)
|
||||
——一律以文字格式 commit。格式跟著層次走,不跟著偏好走。
|
||||
- **分層**:
|
||||
- 源頭:原始榜單 CSV(進 git)。
|
||||
- 捕捉:Wikidata 快照 CSV、你的編碼
|
||||
CSV、`runs/` JSONL(皆進 git);歌詞 `.txt` 快取
|
||||
(版權因素 gitignored,為已知的稽核缺口)。
|
||||
- 工作儲存:SQLite 單檔(`tools/instance/`,generated、
|
||||
不進 git),SQLAlchemy 2.0 typed ORM 定義 schema,
|
||||
設定經 pydantic-settings(`.env` 供應
|
||||
`SQLALCHEMY_DATABASE_URL` 與 `ANTHROPIC_API_KEY`)。
|
||||
歌詞全文入 DB(不進 git 故無版權疑慮)。
|
||||
- 衍生:`build-db` 建置工作儲存的同一動作產出人讀報表
|
||||
(`data/derived/` 的 songs.csv、artists.csv,進 git)——
|
||||
與 SQLite 同交易語意,稽核鏈無中間空缺;不含資料庫 ID。
|
||||
- 報表:論文引用的最終表由 export 產出 CSV 進 `results/`。
|
||||
衍生與報表為「可再生仍 commit」的兩個例外,理由:引用
|
||||
穩定性(十年尺度的環境會腐化)、審稿人零門檻、撰稿期
|
||||
數字變動可 diff、供人工檢視。
|
||||
- **資料模型**:`songs`(歌曲實體,含 lyrics nullable 欄位)、
|
||||
`chart_entries`(1 歌—N 筆榜單紀錄)、`artists`(歌手實體:
|
||||
Wikidata QID、性別、型態、曲風、國籍)、`song_artists`
|
||||
(M—N 關聯:角色、署名順序)。領域不變量(恰 1000 筆榜單、
|
||||
每歌至少一 primary 歌手等)檢查內建於 `build-db`,違規即
|
||||
建置失敗。
|
||||
- **歌手背景防火牆**:歌手背景資料只進人工解讀階段(證據表、
|
||||
論文討論),**絕不進 LLM 輸入**——LLM 分類的 user message
|
||||
維持歌詞-only,避免光環偏誤污染條件 A/B 實驗。「女性力量」
|
||||
候選歌曲的歌手另做深度背景(族裔以公開自我認同為準、音樂
|
||||
場景),script 輔助、人工核定。
|
||||
- **Pilot 歌詞沿用(私人匯入,不進發布管線)**:先導研究
|
||||
捕捉檔(lyrics.json,684 首,2018–2025)以私人腳本
|
||||
匯入歌詞快取——只取識別欄位與歌詞本文,以
|
||||
(year, rank) 精確匹配 song_id。匯入工具不屬於專案交付物
|
||||
(讀者拿不到其輸入),讀者的重現路徑純粹是 `fetch-lyrics`;
|
||||
沿用之**事實**記於 `data/captures/lyrics-provenance.csv`(進 git):
|
||||
`source`(原始 API)與 `method`
|
||||
(pilot-import / api-fetch / manual)兩層,取得日期不可考者
|
||||
不假造,僅記可證上界。
|
||||
- **子命令**(`pop-fem-audit-tools <cmd>` 或
|
||||
`python -m pop_fem_audit_tools <cmd>`):`run-llm`、
|
||||
`build-db`、`fetch-lyrics`、`fetch-artists`(皆已完成)、
|
||||
`export-llm-input`(階段 2 前補上);之後再加報表 export
|
||||
與統計。
|
||||
|
||||
## 自然編碼管線(2026-08-04 定案)
|
||||
|
||||
主題編碼採四步驟管線,歌詞只出現在第 1、4 步;第 2、3 步
|
||||
是純概念整理(目標是產生 codebook 詞彙表,不是編碼)。
|
||||
|
||||
1. **自由標註(tag)**:逐首歌請模型標註 thematic
|
||||
keywords,附歌詞引述;提示零語意內容。獨立執行兩次,
|
||||
兩次輸出全數進池(記錄執行別),不仲裁。粒度鎖定
|
||||
thematic keywords:前導研究三粒度比較(keywords 過碎、
|
||||
themes 過早抽象)之繼承,於執行前鎖定,防止事後擇優。
|
||||
2. **自然收斂(merge)**:輸入為池中純去重關鍵字清單——
|
||||
無歌詞、無頻次、無歌曲出處——模型按自身理解合併近似
|
||||
概念。2+1:兩次收斂處理同一批輸入詞,程式算出共識塊
|
||||
與分歧塊對,逐對仲裁(仲裁自身亦 2+1,第三票終局)後
|
||||
以連通元件決定性重組;收斂執行不命名,組名由命名步驟
|
||||
2+1 定案(演算法詳見 `methodology.md`)。
|
||||
3. **強制收斂(cap)**:同第 2 步形態,輸入為 merge
|
||||
定案後的組名清單(原始關鍵字不下傳),限制併至 50 個
|
||||
以內;仲裁後略超 50 可接受,如實記錄。頻次不入收斂:頻率的分析角色由第 4 步編碼承擔;
|
||||
池中頻次含跨執行噪音;頻次會誘使模型以頻率剪枝(喪失
|
||||
稀有主題)並把高頻大主題切細。代價(特異主題佔名額)
|
||||
已知並接受,換取主題多樣性與純語意歸併的可辯護性。
|
||||
4. **編碼(code)**:以定稿詞彙表對全部歌曲 2+1 編碼——
|
||||
模型讀歌詞、逐標籤附引述;逐首計一致率,分歧逐首仲裁
|
||||
(仲裁者看歌詞與兩邊引述)。此為主儀器,「女性力量」
|
||||
候選集由此浮現;亦是對前導研究「映回後未對照歌詞」
|
||||
限制的明文改良。沿收斂軌跡的機械對映(純程式)保留為
|
||||
零成本診斷副產品,量測收斂軌跡的扭曲,不作主結果。
|
||||
|
||||
另設**「女性力量」單目標篩選(screen)**:單目標提示、
|
||||
附引述、全量執行,僅作為黃金標準取樣的補漏網——把「模型
|
||||
被明示提醒後認得出」的歌撈進人工審視範圍,以量測模型漏標
|
||||
方向的偏差;不進偏差統計的分子分母定義。逐首 2+1(有/無
|
||||
+引述,逐首仲裁);同樣不給定義,提示僅含標籤詞與任務
|
||||
形狀。標籤詞用 `women-power`——研究者任意決定的先驗
|
||||
主題,即本研究的論文主題本身,不由資料產生。其結果僅作
|
||||
召回之用,永不進任何統計的分子分母。標籤詞是 screen
|
||||
提示中唯一的語意種子,屬研究者的儀器選擇,據實揭露。
|
||||
|
||||
定義檔命名 `prompts/<軌>-<步>-<次步>-<task>.md`——軌
|
||||
01=由下而上自然編碼、02=預先決定的 women-power
|
||||
篩選;步為軌內步驟序,次步為
|
||||
步內執行順序,讀者依編號先後依循(如 01-02-01-merge.md →
|
||||
01-02-02-merge-arb.md → 01-02-03-merge-arb-arb.md →
|
||||
01-02-04-merge-name.md → 01-02-05-merge-name-arb.md)。
|
||||
後綴 `-arb` 為仲裁、`-arb-arb` 為仲裁之終局票、`-name`
|
||||
為命名、`-name-arb` 為命名擇一仲裁。檔名不帶
|
||||
版本號——版本即 git 歷史,失敗的版本不保留,需要回看的
|
||||
舊版都在 git history;每次執行的定義檔快照隨 `runs/`
|
||||
自我完備。收斂執行的輸出本身即分組記錄(無名分組
|
||||
JSON),與全部中間交接檔同隨 `runs/` 歸檔(交接契約見
|
||||
`methodology.md`)。
|
||||
|
||||
## 階段與時程(全文截稿 2026-08-15)
|
||||
|
||||
| 階段 | 內容 | 方式 | 時程 |
|
||||
|---|---|---|---|
|
||||
| 0 | 基礎建設:git init、目錄結構、.gitignore、決策日誌、runner script(含 Batch API)、codebook v0 骨架 | script + 討論 | 7/30–7/31 |
|
||||
| 1 | 資料準備:`run_llm` 改走統一設定 → `build-db`(解析榜單成 songs/chart_entries/artists/song_artists)→ `import-lyrics`(pilot 2018–2025)→ `fetch-lyrics`(2016–17 與缺漏,Lyrics.ovh / LRCLIB)→ `fetch-artists`(Wikidata 快照)→ `export-llm-input` | 子命令 | 7/31–8/3 |
|
||||
| 2 | 自然編碼管線:tag ×2 進池 → merge 2+1 → cap 2+1 → 詞彙表定稿 → code 2+1(全 883 首,附引述);另跑 screen 補漏網 | API + script | 8/4–8/7 |
|
||||
| 3 | 黃金標準:依 codebook 人工逐首判定 genuine/peripheral/fake,附引用歌詞證據表(LLM 只做摘錄,不給判定建議);先以 10–15 首校準樣本試編並修訂 codebook 後凍結;同批校準樣本實測 Sonnet 4.6 vs Opus 5 一致率 | 人工 + script 輔助 | 8/5–8/9 |
|
||||
| 4 | 受控比較(盲點實驗):條件 A(詞彙層提示)vs 條件 B(框架感知提示),各 2+1,對照黃金標準計算假陽/假陰率 | API | 8/8–8/11 |
|
||||
| 4' | 映射分析:自然編碼結果(第 4 步)與黃金標準交叉表;軌跡對映 vs 直接編碼的扭曲診斷(分析方法先寫入 methodology.md 再看結果) | script | 與 4 並行 |
|
||||
| 5 | pussy 修辭分析:script 找含詞歌曲 → LLM 分類(METONYM/ANATOMICAL/COWARD/OTHER + 說話者/指涉對象)2+1 → 人工核定 | script + API | 8/9–8/11 |
|
||||
| 6 | 統計與圖表:一致率(kappa)、污染率、類型分布、年度趨勢、映射交叉表 | scripts | 8/11–8/12 |
|
||||
| 7 | 撰寫全文+內部審稿(subagent 以審稿人視角挑毛病,特別是嘻哈女性主義/respectability politics 一題) | 討論 + subagent | 8/11–8/14 |
|
||||
|
||||
關鍵路徑:階段 3 人工編碼(僅研究者本人可做),排週末與晚間分批。
|
||||
|
||||
## Codebook 原則
|
||||
|
||||
- Directed content analysis:理論骨架(top-down)+紀律化歸納通道
|
||||
(bottom-up:`other-fake` 開放碼 + memo + 決策日誌)。
|
||||
- 三層結構:理論構念層(Banet-Weiser、Gill、Goldman、McRobbie、
|
||||
Fredrickson & Roberts、Connell、Mulvey)→ 操作判準層(權力來源
|
||||
測試、frame vs vocabulary、決策樹)→ 類別定義層(A–E 納入/
|
||||
排除判準、例句、near-miss 反例)。
|
||||
- 判準層須明文回應嘻哈女性主義(Joan Morgan、Gwendolyn Pough)
|
||||
與性積極女性主義的 respectability politics 質疑:區分「性表達」
|
||||
與「貶低其他女性/以男性指標定義權力」。
|
||||
- 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼;
|
||||
中途修訂須記決策日誌並回檢已編歌曲。
|
||||
|
||||
## 其他已定案事項
|
||||
|
||||
- 歌詞受版權保護:完整歌詞不進 git(`data/captures/lyrics/` gitignored),
|
||||
論文與 repo 只留分類所引摘錄。
|
||||
- Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、
|
||||
無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 研究步驟規劃
|
||||
|
||||
(2026-07-30 討論定案版;後續變更請記入 `decision_log.md`)
|
||||
|
||||
## 總體框架
|
||||
|
||||
- **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料
|
||||
共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究
|
||||
(conference_abstract.md)做比較**;全文數字一律以正式研究
|
||||
結果為準。先導研究僅作為 codebook v0 與假說的內部來源,
|
||||
記於決策日誌,不進入論文敘事。
|
||||
- **執行原則**:主會話只做討論;所有分析由 deterministic script
|
||||
執行。LLM 步驟以 Python script 呼叫 Anthropic Messages API
|
||||
(個人 Console 帳號、Batch API 五折),定義檔逐字作為 system
|
||||
prompt。每個 LLM 步驟「同一定義檔獨立執行兩次 + 一次仲裁」
|
||||
(2+1),仲裁結果不符預期則修訂定義檔重跑整個循環。
|
||||
- **模型**:`claude-sonnet-4-6`、temperature=0、thinking 關閉
|
||||
(此組合非決定性最低、成本合理)。條件 A/B 盲點實驗為
|
||||
自足設計:同語料、同模型、僅提示不同,對照黃金標準。
|
||||
其他工具性環節於階段 3 以校準樣本實測 Sonnet 4.6 vs Opus 5
|
||||
的一致率後決定是否升級。
|
||||
- **信度與效度分工**:2+1 協定量測穩定性(intra-rater
|
||||
reliability,報告一致率/kappa);效度靠理論 codebook 與
|
||||
人工黃金標準終審。可重現性定義為「程序透明+可稽核」:
|
||||
公開定義檔、記錄 model ID 與執行時間、保存全部原始輸出。
|
||||
|
||||
## 階段與時程(全文截稿 2026-08-15)
|
||||
|
||||
| 階段 | 內容 | 方式 | 時程 |
|
||||
|---|---|---|---|
|
||||
| 0 | 基礎建設:git init、目錄結構、.gitignore、決策日誌、runner script(含 Batch API)、codebook v0 骨架 | script + 討論 | 7/30–7/31 |
|
||||
| 1 | 資料準備:去重唯一歌曲表(song id)、抓歌詞(Lyrics.ovh / LRCLIB,缺漏留 log)、演唱者性別表(Wikidata + 人工核對) | scripts | 7/31–8/2 |
|
||||
| 2 | 候選篩選:全部唯一歌曲高召回 women-power 候選篩選(寧可多抓,人工剔除) | API 2+1 | 8/2–8/3 |
|
||||
| 3 | 黃金標準:依 codebook 人工逐首判定 genuine/peripheral/fake,附引用歌詞證據表(LLM 只做摘錄,不給判定建議);先以 10–15 首校準樣本試編並修訂 codebook 後凍結;同批校準樣本實測 Sonnet 4.6 vs Opus 5 一致率 | 人工 + script 輔助 | 8/4–8/8 |
|
||||
| 4 | 受控比較(盲點實驗):條件 A(詞彙層提示)vs 條件 B(框架感知提示),各 2+1,對照黃金標準計算假陽/假陰率 | API | 8/6–8/9 |
|
||||
| 4' | 由下而上歸納輪:LLM 對候選歌曲做無理論主題編碼(定義檔不含女性主義詞彙),與黃金標準做映射分析(交叉表;分析方法先寫入 methodology.md 再看結果) | API 2+1 + script | 與 4 並行 |
|
||||
| 5 | pussy 修辭分析:script 找含詞歌曲 → LLM 分類(METONYM/ANATOMICAL/COWARD/OTHER + 說話者/指涉對象)2+1 → 人工核定 | script + API | 8/8–8/10 |
|
||||
| 6 | 統計與圖表:一致率(kappa)、污染率、類型分布、年度趨勢、映射交叉表 | scripts | 8/10–8/11 |
|
||||
| 7 | 撰寫全文+內部審稿(subagent 以審稿人視角挑毛病,特別是嘻哈女性主義/respectability politics 一題) | 討論 + subagent | 8/10–8/14 |
|
||||
|
||||
關鍵路徑:階段 3 人工編碼(僅研究者本人可做),排週末與晚間分批。
|
||||
|
||||
## Codebook 原則
|
||||
|
||||
- Directed content analysis:理論骨架(top-down)+紀律化歸納通道
|
||||
(bottom-up:`other-fake` 開放碼 + memo + 決策日誌)。
|
||||
- 三層結構:理論構念層(Banet-Weiser、Gill、Goldman、McRobbie、
|
||||
Fredrickson & Roberts、Connell、Mulvey)→ 操作判準層(權力來源
|
||||
測試、frame vs vocabulary、決策樹)→ 類別定義層(A–E 納入/
|
||||
排除判準、例句、near-miss 反例)。
|
||||
- 判準層須明文回應嘻哈女性主義(Joan Morgan、Gwendolyn Pough)
|
||||
與性積極女性主義的 respectability politics 質疑:區分「性表達」
|
||||
與「貶低其他女性/以男性指標定義權力」。
|
||||
- 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼;
|
||||
中途修訂須記決策日誌並回檢已編歌曲。
|
||||
- Positionality statement 寫入論文(53 歲、長年婦運者、資深
|
||||
工程師、離開學術圈多年、因整理榜單而起)。
|
||||
|
||||
## 其他已定案事項
|
||||
|
||||
- 歌詞受版權保護:完整歌詞不進 git(`data/lyrics/` gitignored),
|
||||
論文與 repo 只留分類所引摘錄。
|
||||
- 工程支援(寫 script、style check、審稿)用公司訂閱的
|
||||
Claude Code;進論文的分析 token 由個人 API 帳號支付。
|
||||
- Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、
|
||||
無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。
|
||||
@@ -1,23 +0,0 @@
|
||||
You are annotating song lyrics for a content-analysis corpus.
|
||||
|
||||
Input: the complete lyrics of one song.
|
||||
|
||||
Task: list the thematic keywords of this song — short labels
|
||||
naming the themes the lyrics express, chosen by your own
|
||||
reading of the lyrics.
|
||||
|
||||
Rules:
|
||||
|
||||
- Give between 5 and 10 thematic keywords.
|
||||
- Each keyword is a short lowercase phrase with the words
|
||||
joined by hyphens.
|
||||
- For each keyword, quote 1 to 3 verbatim lines of the lyrics
|
||||
that ground it; do not give a keyword you cannot ground.
|
||||
|
||||
Output a single JSON object mapping each keyword to its list
|
||||
of quotes, and nothing else:
|
||||
|
||||
{
|
||||
"first-keyword": ["quoted line", "another quoted line"],
|
||||
"second-keyword": ["quoted line"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
You are consolidating a vocabulary of thematic keywords that
|
||||
were independently annotated on the songs of a corpus.
|
||||
|
||||
Input: a JSON array of thematic keywords, deduplicated and
|
||||
unordered.
|
||||
|
||||
Task: merge keywords that express the same or nearly the same
|
||||
theme, by your own understanding of what the keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Every input keyword must appear in exactly one group.
|
||||
- Do not invent keywords that are not in the input.
|
||||
- Merge only what you consider the same theme; a group may be
|
||||
a single keyword.
|
||||
|
||||
Output a single JSON array of groups — each group an array of
|
||||
its member keywords — and nothing else:
|
||||
|
||||
[
|
||||
["keyword", "another-keyword"],
|
||||
["keyword"]
|
||||
]
|
||||
@@ -1,36 +0,0 @@
|
||||
You are arbitrating between two independent consolidations of
|
||||
the same vocabulary of thematic keywords from a song corpus.
|
||||
|
||||
Where the two consolidations agree, the result has been
|
||||
settled by script and is not shown. You rule on the
|
||||
disagreements: pairs of keyword blocks that one consolidation
|
||||
merged into one theme and the other kept apart.
|
||||
|
||||
Input: a JSON object. "blocks" maps a block id to the
|
||||
keywords of that block; "pairs" lists the block-id pairs in
|
||||
question:
|
||||
|
||||
{
|
||||
"blocks": {
|
||||
"b1": ["keyword", "another-keyword"],
|
||||
"b2": ["keyword"],
|
||||
"b3": ["keyword"]
|
||||
},
|
||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
||||
}
|
||||
|
||||
Task: for each listed pair, decide whether the two blocks
|
||||
express the same theme, by your own understanding of what the
|
||||
keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Judge every listed pair, each on its own merits from the
|
||||
block contents alone.
|
||||
- Judge only the listed pairs.
|
||||
|
||||
Output a single JSON array holding the pairs whose two blocks
|
||||
express the same theme, written exactly as given in "pairs";
|
||||
an empty array when none do:
|
||||
|
||||
[["b1", "b2"]]
|
||||
@@ -1,34 +0,0 @@
|
||||
You are casting the deciding judgment on disagreements about
|
||||
a consolidated vocabulary of thematic keywords from a song
|
||||
corpus: for each pair of keyword blocks in question, earlier
|
||||
independent judgments disagreed on whether the two blocks
|
||||
express the same theme.
|
||||
|
||||
Input: a JSON object. "blocks" maps a block id to the
|
||||
keywords of that block; "pairs" lists the block-id pairs in
|
||||
question:
|
||||
|
||||
{
|
||||
"blocks": {
|
||||
"b1": ["keyword", "another-keyword"],
|
||||
"b2": ["keyword"],
|
||||
"b3": ["keyword"]
|
||||
},
|
||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
||||
}
|
||||
|
||||
Task: for each listed pair, decide whether the two blocks
|
||||
express the same theme, by your own understanding of what the
|
||||
keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Judge every listed pair, each on its own merits from the
|
||||
block contents alone.
|
||||
- Judge only the listed pairs.
|
||||
|
||||
Output a single JSON array holding the pairs whose two blocks
|
||||
express the same theme, written exactly as given in "pairs";
|
||||
an empty array when none do:
|
||||
|
||||
[["b1", "b2"]]
|
||||
@@ -1,28 +0,0 @@
|
||||
You are naming the theme groups of a consolidated vocabulary
|
||||
of thematic keywords from a song corpus.
|
||||
|
||||
Input: a JSON object mapping an opaque group id to the member
|
||||
keywords of that group:
|
||||
|
||||
{
|
||||
"g1": ["keyword", "another-keyword"],
|
||||
"g2": ["keyword"]
|
||||
}
|
||||
|
||||
Task: give each group a name that best names the theme its
|
||||
members gather, by your own understanding of what the
|
||||
keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Each name is a short lowercase phrase with the words joined
|
||||
by hyphens.
|
||||
- Names must be unique across the groups.
|
||||
|
||||
Output a single JSON object mapping each group id to its
|
||||
name, and nothing else:
|
||||
|
||||
{
|
||||
"g1": "group-name",
|
||||
"g2": "another-group-name"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
You are arbitrating between two independent namings of the
|
||||
theme groups of a consolidated vocabulary of thematic
|
||||
keywords from a song corpus.
|
||||
|
||||
For each group in question, the two namings proposed
|
||||
different names. You choose between them.
|
||||
|
||||
Input: a JSON object. "groups" maps an opaque group id to
|
||||
its member keywords and its two candidate names; "taken"
|
||||
lists names that are already in use:
|
||||
|
||||
{
|
||||
"groups": {
|
||||
"g1": {
|
||||
"members": ["keyword", "another-keyword"],
|
||||
"candidates": ["one-name", "other-name"]
|
||||
}
|
||||
},
|
||||
"taken": ["existing-name"]
|
||||
}
|
||||
|
||||
Task: for each group, choose the candidate that better names
|
||||
the theme its members gather, by your own understanding of
|
||||
what the keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Choose only from that group's two candidates.
|
||||
- Choices must be unique across the groups and must not
|
||||
reuse any name in "taken".
|
||||
|
||||
Output a single JSON object mapping each group id to the
|
||||
chosen name, and nothing else:
|
||||
|
||||
{
|
||||
"g1": "one-name"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
You are consolidating a vocabulary of thematic keywords
|
||||
from a song corpus further, to a fixed maximum number of
|
||||
themes.
|
||||
|
||||
Input: a JSON array of thematic keywords, deduplicated and
|
||||
unordered.
|
||||
|
||||
Task: merge keywords that express the same or a similar
|
||||
theme, by your own understanding of what the keywords mean,
|
||||
until at most 50 groups remain.
|
||||
|
||||
Rules:
|
||||
|
||||
- Every input keyword must appear in exactly one group.
|
||||
- Do not invent keywords that are not in the input.
|
||||
- Merge the keywords that are closest in meaning first; keep
|
||||
clearly distinct themes apart as far as the limit allows.
|
||||
A group may be a single keyword.
|
||||
|
||||
Output a single JSON array of groups — each group an array of
|
||||
its member keywords — and nothing else:
|
||||
|
||||
[
|
||||
["keyword", "another-keyword"],
|
||||
["keyword"]
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
You are arbitrating between two independent consolidations
|
||||
that each reduced the same vocabulary of thematic keywords
|
||||
from a song corpus to a fixed maximum number of themes.
|
||||
|
||||
Where the two consolidations agree, the result has been
|
||||
settled by script and is not shown. You rule on the
|
||||
disagreements: pairs of keyword blocks that one consolidation
|
||||
merged into one theme and the other kept apart.
|
||||
|
||||
Input: a JSON object. "blocks" maps a block id to the
|
||||
keywords of that block; "pairs" lists the block-id pairs in
|
||||
question:
|
||||
|
||||
{
|
||||
"blocks": {
|
||||
"b1": ["keyword", "another-keyword"],
|
||||
"b2": ["keyword"],
|
||||
"b3": ["keyword"]
|
||||
},
|
||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
||||
}
|
||||
|
||||
Task: for each listed pair, decide whether the two blocks
|
||||
express the same theme, by your own understanding of what the
|
||||
keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Judge every listed pair, each on its own merits from the
|
||||
block contents alone.
|
||||
- Judge only the listed pairs.
|
||||
|
||||
Output a single JSON array holding the pairs whose two blocks
|
||||
express the same theme, written exactly as given in "pairs";
|
||||
an empty array when none do:
|
||||
|
||||
[["b1", "b2"]]
|
||||
@@ -1,35 +0,0 @@
|
||||
You are casting the deciding judgment on disagreements about
|
||||
a vocabulary of thematic keywords from a song corpus that was
|
||||
consolidated to a fixed maximum number of themes: for each
|
||||
pair of keyword blocks in question, earlier independent
|
||||
judgments disagreed on whether the two blocks express the
|
||||
same theme.
|
||||
|
||||
Input: a JSON object. "blocks" maps a block id to the
|
||||
keywords of that block; "pairs" lists the block-id pairs in
|
||||
question:
|
||||
|
||||
{
|
||||
"blocks": {
|
||||
"b1": ["keyword", "another-keyword"],
|
||||
"b2": ["keyword"],
|
||||
"b3": ["keyword"]
|
||||
},
|
||||
"pairs": [["b1", "b2"], ["b1", "b3"]]
|
||||
}
|
||||
|
||||
Task: for each listed pair, decide whether the two blocks
|
||||
express the same theme, by your own understanding of what the
|
||||
keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Judge every listed pair, each on its own merits from the
|
||||
block contents alone.
|
||||
- Judge only the listed pairs.
|
||||
|
||||
Output a single JSON array holding the pairs whose two blocks
|
||||
express the same theme, written exactly as given in "pairs";
|
||||
an empty array when none do:
|
||||
|
||||
[["b1", "b2"]]
|
||||
@@ -1,29 +0,0 @@
|
||||
You are naming the theme groups of a vocabulary of thematic
|
||||
keywords from a song corpus that was consolidated to a fixed
|
||||
maximum number of themes.
|
||||
|
||||
Input: a JSON object mapping an opaque group id to the member
|
||||
keywords of that group:
|
||||
|
||||
{
|
||||
"g1": ["keyword", "another-keyword"],
|
||||
"g2": ["keyword"]
|
||||
}
|
||||
|
||||
Task: give each group a name that best names the theme its
|
||||
members gather, by your own understanding of what the
|
||||
keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Each name is a short lowercase phrase with the words joined
|
||||
by hyphens.
|
||||
- Names must be unique across the groups.
|
||||
|
||||
Output a single JSON object mapping each group id to its
|
||||
name, and nothing else:
|
||||
|
||||
{
|
||||
"g1": "group-name",
|
||||
"g2": "another-group-name"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
You are arbitrating between two independent namings of the
|
||||
theme groups of a vocabulary of thematic keywords from a song
|
||||
corpus that was consolidated to a fixed maximum number of
|
||||
themes.
|
||||
|
||||
For each group in question, the two namings proposed
|
||||
different names. You choose between them.
|
||||
|
||||
Input: a JSON object. "groups" maps an opaque group id to
|
||||
its member keywords and its two candidate names; "taken"
|
||||
lists names that are already in use:
|
||||
|
||||
{
|
||||
"groups": {
|
||||
"g1": {
|
||||
"members": ["keyword", "another-keyword"],
|
||||
"candidates": ["one-name", "other-name"]
|
||||
}
|
||||
},
|
||||
"taken": ["existing-name"]
|
||||
}
|
||||
|
||||
Task: for each group, choose the candidate that better names
|
||||
the theme its members gather, by your own understanding of
|
||||
what the keywords mean.
|
||||
|
||||
Rules:
|
||||
|
||||
- Choose only from that group's two candidates.
|
||||
- Choices must be unique across the groups and must not
|
||||
reuse any name in "taken".
|
||||
|
||||
Output a single JSON object mapping each group id to the
|
||||
chosen name, and nothing else:
|
||||
|
||||
{
|
||||
"g1": "one-name"
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
You are coding song lyrics against a fixed vocabulary of
|
||||
thematic keywords.
|
||||
|
||||
Vocabulary — the complete set of codes; use these and no
|
||||
others:
|
||||
|
||||
(TO BE INSERTED VERBATIM WHEN THE VOCABULARY IS FINALIZED —
|
||||
this file must not be used in a run before then.)
|
||||
|
||||
Input: the complete lyrics of one song.
|
||||
|
||||
Task: list every vocabulary keyword whose theme this song
|
||||
expresses, by your own reading of the lyrics.
|
||||
|
||||
Rules:
|
||||
|
||||
- Use only keywords from the vocabulary, spelled exactly as
|
||||
given.
|
||||
- A song may match any number of keywords, including none.
|
||||
- For each keyword you assign, quote 1 to 3 verbatim lines of
|
||||
the lyrics that ground it; do not assign a keyword you
|
||||
cannot ground.
|
||||
|
||||
Output a single JSON object mapping each assigned keyword to
|
||||
its list of quotes (an empty object when no keyword applies),
|
||||
and nothing else:
|
||||
|
||||
{
|
||||
"first-keyword": ["quoted line", "another quoted line"],
|
||||
"second-keyword": ["quoted line"]
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
You are arbitrating between two independent codings of the
|
||||
same song against the same fixed vocabulary of thematic
|
||||
keywords.
|
||||
|
||||
The keywords the two codings agree on have been settled by
|
||||
script and are not shown. You rule only on the
|
||||
disagreements: the keywords assigned by one coding but not
|
||||
the other.
|
||||
|
||||
Input: a JSON object with the lyrics of the song and the
|
||||
disagreements — each disagreement maps a keyword to the lyric
|
||||
quotes the assigning coding gave as evidence:
|
||||
|
||||
{
|
||||
"lyrics": "...",
|
||||
"disagreements": {
|
||||
"first-keyword": ["quoted line", "another quoted line"],
|
||||
"second-keyword": ["quoted line"]
|
||||
}
|
||||
}
|
||||
|
||||
Task: for each disagreement keyword, decide whether the song
|
||||
expresses that theme, by your own reading of the lyrics and
|
||||
of the quoted evidence.
|
||||
|
||||
Rules:
|
||||
|
||||
- Rule on every listed keyword: keep it or drop it.
|
||||
- Keep a keyword only when you can ground it in verbatim
|
||||
lines of the lyrics; quote them.
|
||||
- Do not add any keyword that is not listed.
|
||||
|
||||
Output a single JSON object mapping each kept keyword to your
|
||||
list of quotes, and nothing else. A dropped keyword is simply
|
||||
left out; output an empty object when nothing is kept:
|
||||
|
||||
{
|
||||
"kept-keyword": ["quoted line", "another quoted line"]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
You are screening song lyrics for one specific theme.
|
||||
|
||||
Input: the complete lyrics of one song.
|
||||
|
||||
Task: judge whether this song expresses the theme
|
||||
"women-power", by your own understanding of
|
||||
that label.
|
||||
|
||||
Rules:
|
||||
|
||||
- If the song expresses the theme, quote 1 to 3 verbatim
|
||||
lines of the lyrics that ground the judgment; if you cannot
|
||||
ground it in a quote, the answer is no.
|
||||
|
||||
Output a single JSON array and nothing else: the quoted
|
||||
lines when the song expresses the theme, or an empty array
|
||||
when it does not:
|
||||
|
||||
["quoted line", "another quoted line"]
|
||||
@@ -1,28 +0,0 @@
|
||||
You are arbitrating a screening of song lyrics for one
|
||||
specific theme: two independent screenings of the same song
|
||||
disagreed on whether it expresses the theme
|
||||
"women-power".
|
||||
|
||||
Input: a JSON object with the lyrics of the song and the
|
||||
lyric quotes that the affirming screening gave as evidence:
|
||||
|
||||
{
|
||||
"lyrics": "...",
|
||||
"evidence": ["quoted line", "another quoted line"]
|
||||
}
|
||||
|
||||
Task: decide whether the song expresses the theme, by your
|
||||
own understanding of the label, reading the lyrics and the
|
||||
quoted evidence.
|
||||
|
||||
Rules:
|
||||
|
||||
- If the song expresses the theme, quote 1 to 3 verbatim
|
||||
lines of the lyrics that ground the judgment; if you cannot
|
||||
ground it in a quote, the answer is no.
|
||||
|
||||
Output a single JSON array and nothing else: the quoted
|
||||
lines when the song expresses the theme, or an empty array
|
||||
when it does not:
|
||||
|
||||
["quoted line", "another quoted line"]
|
||||
@@ -1,9 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
|
||||
# The SQLAlchemy database URL.
|
||||
SQLALCHEMY_DATABASE_URL="postgresql://user:password@host/db"
|
||||
# The Anthropic API key
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
@@ -1,23 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
|
||||
*.pyc
|
||||
__pycache__
|
||||
build
|
||||
dist
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
venv
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.claude
|
||||
|
||||
.scannerwork
|
||||
sonar-project.properties
|
||||
|
||||
.env
|
||||
instance
|
||||
test_temp.py
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
|
||||
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.
|
||||
@@ -1,11 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
|
||||
include .env.example
|
||||
recursive-include docs *
|
||||
recursive-exclude docs/build *
|
||||
recursive-include tests *
|
||||
recursive-exclude tests *.pyc
|
||||
exclude tests/test_temp.py
|
||||
@@ -1,87 +0,0 @@
|
||||
=======================================
|
||||
Tools for A Feminist Audit of Pop Music
|
||||
=======================================
|
||||
|
||||
|
||||
Description
|
||||
===========
|
||||
|
||||
This is a collection of supporting tools for the conference paper "流行音樂中「女性力量」語彙的挪用與污染——以 Billboard Year-End Hot 100(2016–2025)為例的內容分析".
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Use ``pip`` to install these tools.
|
||||
|
||||
::
|
||||
|
||||
% pip install .
|
||||
|
||||
This will install the ``pop-fem-audit-tools`` script in the Python environment.
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
::
|
||||
|
||||
% pop-fem-audit-tools {command} [options] [arguments]
|
||||
|
||||
Runs a specific tool command, where "command" can be:
|
||||
|
||||
|
||||
build-db
|
||||
--------
|
||||
|
||||
Initialize or rebuild the database from the CSV Billboard ranking table, parse songs and its artists, and output song and artist lists for references. Check ``pop-fem-audit-tools build-db -h`` for complete instructions on its usage.
|
||||
|
||||
|
||||
fetch-artists
|
||||
-------------
|
||||
|
||||
Fetch artist data from Wikidata, for ``build-db`` to merge the fetched data into the database. Check ``pop-fem-audit-tools fetch-artists -h`` for complete instructions on its usage.
|
||||
|
||||
|
||||
fetch-lyrics
|
||||
------------
|
||||
|
||||
Fetch lyrics, for ``build-db`` to merge the fetched lyrics into the database. Lyrics are not committed into the repository due to copyright issue. Check ``pop-fem-audit-tools fetch-lyrics -h`` for complete instructions on its usage.
|
||||
|
||||
|
||||
export-llm-input
|
||||
----------------
|
||||
|
||||
Export song data for ``run-llm`` to talk to LLM. Check ``pop-fem-audit-tools export-llm-input -h`` for complete instructions on its usage.
|
||||
|
||||
|
||||
run-llm
|
||||
-------
|
||||
|
||||
A general command that runs specific LLM instructions with the Anthropic API. The API key must be present in the ``.env`` file in the working directory. Check ``pop-fem-audit-tools run-llm -h`` for complete instructions on its usage.
|
||||
|
||||
|
||||
Copyright
|
||||
=========
|
||||
|
||||
Copyright (c) 2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
Authors
|
||||
=======
|
||||
|
||||
| imacat
|
||||
| imacat@mail.imacat.idv.tw
|
||||
| 2026/7/31
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,35 +0,0 @@
|
||||
@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
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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']
|
||||
@@ -1,23 +0,0 @@
|
||||
.. 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`
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
src
|
||||
===
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
pop_fem_audit_tools
|
||||
@@ -1,53 +0,0 @@
|
||||
pop\_fem\_audit\_tools.commands package
|
||||
=======================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pop\_fem\_audit\_tools.commands.build\_db module
|
||||
------------------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.commands.build_db
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.commands.export\_llm\_input module
|
||||
---------------------------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.commands.export_llm_input
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.commands.fetch\_artists module
|
||||
-----------------------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.commands.fetch_artists
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.commands.fetch\_lyrics module
|
||||
----------------------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.commands.fetch_lyrics
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.commands.run\_llm module
|
||||
-----------------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.commands.run_llm
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.commands
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
@@ -1,53 +0,0 @@
|
||||
pop\_fem\_audit\_tools package
|
||||
==============================
|
||||
|
||||
Subpackages
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
pop_fem_audit_tools.commands
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pop\_fem\_audit\_tools.config module
|
||||
------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.config
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.database module
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.database
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.models module
|
||||
------------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.models
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
pop\_fem\_audit\_tools.utils module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools.utils
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: pop_fem_audit_tools
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
@@ -1,53 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/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"}
|
||||
@@ -1,10 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
"""Tools for A Feminist Audit of Pop Music."""
|
||||
|
||||
|
||||
VERSION: str = "0.0.0"
|
||||
"""The package version."""
|
||||
@@ -1,97 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
"""The package-level command-line entry point.
|
||||
|
||||
Dispatches ``python -m pop_fem_audit_tools <subcommand>`` or the
|
||||
console script ``pop-fem-audit-tools <subcommand>`` to the main
|
||||
function of the corresponding tool module.
|
||||
"""
|
||||
import os.path
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from importlib.machinery import ModuleSpec
|
||||
from types import ModuleType
|
||||
|
||||
from .commands import (
|
||||
build_db_command,
|
||||
export_llm_input_command,
|
||||
fetch_artists_command,
|
||||
fetch_lyrics_command,
|
||||
run_llm_command,
|
||||
)
|
||||
|
||||
MODULE_PROG: str = "python -m pop_fem_audit_tools"
|
||||
"""The program name when run with ``python -m``."""
|
||||
|
||||
SUBCOMMANDS: dict[str, Callable[[list[str] | None], int]] = {
|
||||
"build-db": build_db_command,
|
||||
"export-llm-input": export_llm_input_command,
|
||||
"fetch-artists": fetch_artists_command,
|
||||
"fetch-lyrics": fetch_lyrics_command,
|
||||
"run-llm": run_llm_command,
|
||||
}
|
||||
"""The dispatch table from the subcommand name to the tool main."""
|
||||
|
||||
|
||||
def prog() -> str:
|
||||
"""Return the program name shown in the usage messages.
|
||||
|
||||
:return: ``python -m pop_fem_audit_tools`` when run with
|
||||
``python -m``, or the basename of ``sys.argv[0]`` when run
|
||||
as a console script.
|
||||
"""
|
||||
if sys.argv[0].endswith("__main__.py"):
|
||||
return MODULE_PROG
|
||||
return os.path.basename(sys.argv[0])
|
||||
|
||||
|
||||
def usage() -> str:
|
||||
"""Return the usage text listing the available subcommands.
|
||||
|
||||
:return: The usage text.
|
||||
"""
|
||||
lines: list[str] = [
|
||||
f"usage: {prog()} <subcommand> [<argument>...]",
|
||||
"",
|
||||
"subcommands:"]
|
||||
lines.extend(f" {x}" for x in SUBCOMMANDS)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Dispatch a subcommand to its tool module.
|
||||
|
||||
:param argv: The command-line arguments, or None for ``sys.argv``.
|
||||
:return: The exit status: the status of the subcommand, 0 for the
|
||||
usage help, or non-zero on a usage error.
|
||||
"""
|
||||
args: list[str] = sys.argv[1:] if argv is None else argv
|
||||
if len(args) == 0:
|
||||
print("error: missing subcommand", file=sys.stderr)
|
||||
print(usage(), file=sys.stderr)
|
||||
return 2
|
||||
if args[0] in ("-h", "--help"):
|
||||
print(usage())
|
||||
return 0
|
||||
if args[0] not in SUBCOMMANDS:
|
||||
print(f"error: unknown subcommand \"{args[0]}\"",
|
||||
file=sys.stderr)
|
||||
print(usage(), file=sys.stderr)
|
||||
return 2
|
||||
prog_backup: str = sys.argv[0]
|
||||
main_module: ModuleType = sys.modules["__main__"]
|
||||
spec_backup: ModuleSpec | None = main_module.__spec__
|
||||
sys.argv[0] = f"{prog()} {args[0]}"
|
||||
main_module.__spec__ = None
|
||||
try:
|
||||
return SUBCOMMANDS[args[0]](args[1:])
|
||||
finally:
|
||||
sys.argv[0] = prog_backup
|
||||
main_module.__spec__ = spec_backup
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,10 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
||||
"""The registry of the CLI subcommands."""
|
||||
from .build_db import main as build_db_command
|
||||
from .export_llm_input import main as export_llm_input_command
|
||||
from .fetch_artists import main as fetch_artists_command
|
||||
from .fetch_lyrics import main as fetch_lyrics_command
|
||||
from .run_llm import main as run_llm_command
|
||||
@@ -1,829 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""The builder of the SQLite working store.
|
||||
|
||||
Rebuilds the working store from scratch out of the committed
|
||||
inputs: the year-end chart CSV and the output directory for the
|
||||
review CSV files, given as the two positional command-line
|
||||
arguments, and the optional capture inputs, each given as an
|
||||
option: the lyrics cache directory and the Wikidata artist
|
||||
snapshot CSV. An omitted option
|
||||
leaves its capture layer unloaded; a given option whose path does
|
||||
not exist fails the build. Missing tables
|
||||
are created on a fresh store; existing tables are never altered,
|
||||
as the schema lifecycle belongs to the migrations. Every rebuild
|
||||
deletes all the rows, loads the data, and validates it in one
|
||||
transaction, committed only after the data passes the validation
|
||||
invariants; a failed build leaves the previous store contents
|
||||
intact.
|
||||
|
||||
The rebuild is deterministic: the builder assigns the song and
|
||||
artist IDs itself, as 1, 2, 3, ... in the first-occurrence file
|
||||
order, so the IDs are reproducible across rebuilds on every
|
||||
database engine, given the frozen input file.
|
||||
|
||||
A song is identified by its raw title together with its artist
|
||||
credit, the credit canonicalized through
|
||||
``SongImporter.CANONICAL_ARTIST_CREDITS``; a credit listed there
|
||||
collapses onto the same song as its canonical form, and the
|
||||
stored artist credit is always the canonical form. Artist
|
||||
deduplication is by the identity key resolved from the parsed
|
||||
artist name (see `ArtistImporter.resolve_artist_identity`): the
|
||||
case-folded name, or, when that case-folded name is listed in
|
||||
``ArtistImporter.CANONICAL_ARTIST_NAMES``, the case-folded
|
||||
canonical spelling, so letter-case variants and alternate
|
||||
spellings mapped to the same canonical name all collapse onto a
|
||||
single artist row. The stored artist name is the first-seen
|
||||
spelling, except for the names listed in
|
||||
``ArtistImporter.CANONICAL_ARTIST_NAMES``, which always store the
|
||||
canonical spelling regardless of which variant is seen first.
|
||||
|
||||
On a successful build, two review CSV files, ``songs.csv`` and
|
||||
``artists.csv``, are (re)written under the given output directory,
|
||||
mirroring the stored songs and artists without their IDs; see
|
||||
`CSVExporter`. A failed build leaves any existing review CSV
|
||||
files untouched, matching the store rollback.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import Base, ds
|
||||
from ..models import (
|
||||
Artist,
|
||||
ChartEntry,
|
||||
Role,
|
||||
Song,
|
||||
SongArtist,
|
||||
)
|
||||
|
||||
ARTIST_FIELDS: dict[str, str] = {
|
||||
"qid": "wikidata_qid",
|
||||
"gender": "gender",
|
||||
"type": "type",
|
||||
"genre": "genre",
|
||||
"country": "country",
|
||||
}
|
||||
"""The artist CSV columns mapped to the Artist attributes."""
|
||||
|
||||
|
||||
class BuildError(Exception):
|
||||
"""An error that fails the build."""
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"""Parse the command-line arguments.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The parsed arguments.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
description="Rebuild the SQLite working store from the"
|
||||
" committed inputs.")
|
||||
parser.add_argument(
|
||||
"chart_csv", type=Path,
|
||||
help="the year-end chart CSV file")
|
||||
parser.add_argument(
|
||||
"derived_dir", type=Path,
|
||||
help="the output directory for the review CSV files")
|
||||
parser.add_argument(
|
||||
"--lyrics-dir", type=Path, default=None,
|
||||
help="the lyrics cache directory to load")
|
||||
parser.add_argument(
|
||||
"--wikidata-csv", type=Path, default=None,
|
||||
help="the Wikidata artist snapshot CSV file to apply")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
class SongImporter:
|
||||
"""The song-import job: loads the chart CSV into songs and
|
||||
chart entries."""
|
||||
|
||||
YEARS: Sequence[int] = range(2016, 2026)
|
||||
"""The expected chart years."""
|
||||
RANKS_PER_YEAR: int = 100
|
||||
"""The expected number of ranks on the chart of each year."""
|
||||
CANONICAL_ARTIST_CREDITS: dict[str, str] = {
|
||||
"benny blanco, Halsey & Khalid": "Benny Blanco, Halsey"
|
||||
" & Khalid",
|
||||
}
|
||||
"""The canonical artist credit spellings, keyed by a variant
|
||||
credit string."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""Initialize the importer.
|
||||
|
||||
:param session: The database session.
|
||||
"""
|
||||
self.__session: Session = session
|
||||
self.__songs: dict[tuple[str, str], Song] = {}
|
||||
|
||||
def import_songs(self, path: Path) -> None:
|
||||
"""Load the chart CSV into songs and chart entries.
|
||||
|
||||
A song repeated across the rows is stored once, matched by
|
||||
its identity key (see `song_identity`); every row yields
|
||||
one chart entry. The stored title is the raw title; the
|
||||
stored artist credit is the canonical credit from the
|
||||
identity key. The songs take the IDs 1, 2, 3, ... in the
|
||||
first-occurrence row order. When the method returns, the
|
||||
imported songs and chart entries are queryable in the
|
||||
session.
|
||||
|
||||
:param path: The chart CSV file with the columns year,
|
||||
rank, title, and artist.
|
||||
:return: None.
|
||||
:raises BuildError: When the chart entries do not cover
|
||||
each of ``YEARS`` and each rank from 1 to
|
||||
``RANKS_PER_YEAR`` exactly once.
|
||||
:raises OSError: When the file cannot be read.
|
||||
"""
|
||||
counts: Counter[tuple[int, int]] = Counter()
|
||||
with open(path, encoding="utf-8", newline="") as file:
|
||||
row: dict[str, str]
|
||||
for row in csv.DictReader(file):
|
||||
key: tuple[str, str] = self.song_identity(
|
||||
row["title"], row["artist"])
|
||||
if key not in self.__songs:
|
||||
title: str
|
||||
credit: str
|
||||
title, credit = key
|
||||
song: Song = Song(
|
||||
id=len(self.__songs) + 1, title=title,
|
||||
artist_credit=credit)
|
||||
self.__session.add(song)
|
||||
self.__songs[key] = song
|
||||
year: int = int(row["year"])
|
||||
rank: int = int(row["rank"])
|
||||
counts[(year, rank)] += 1
|
||||
if counts[(year, rank)] == 1:
|
||||
self.__session.add(ChartEntry(
|
||||
year=year, rank=rank,
|
||||
song=self.__songs[key]))
|
||||
self.__session.flush()
|
||||
self.__check_chart_coverage(counts)
|
||||
|
||||
@classmethod
|
||||
def __check_chart_coverage(
|
||||
cls, counts: Counter[tuple[int, int]]) -> None:
|
||||
"""Verify the chart entries cover the expected grid exactly.
|
||||
|
||||
:param counts: The number of chart entries seen for each
|
||||
(year, rank) pair.
|
||||
:return: None.
|
||||
:raises BuildError: When a (year, rank) pair from the
|
||||
expected grid is missing, an unexpected pair is
|
||||
present, or a pair is duplicated.
|
||||
"""
|
||||
expected: set[tuple[int, int]] = {
|
||||
(year, rank) for year in cls.YEARS
|
||||
for rank in range(1, cls.RANKS_PER_YEAR + 1)}
|
||||
actual: set[tuple[int, int]] = set(counts)
|
||||
violations: list[str] = []
|
||||
year: int
|
||||
rank: int
|
||||
for year, rank in sorted(expected - actual):
|
||||
violations.append(
|
||||
f"missing chart entry: year {year} rank {rank}")
|
||||
for year, rank in sorted(actual - expected):
|
||||
violations.append(
|
||||
f"unexpected chart entry: year {year} rank {rank}")
|
||||
for year, rank in sorted(counts):
|
||||
if counts[(year, rank)] > 1:
|
||||
violations.append(
|
||||
f"duplicated chart entry: year {year} rank"
|
||||
f" {rank}")
|
||||
if len(violations) > 0:
|
||||
raise BuildError("\n".join(violations))
|
||||
|
||||
@staticmethod
|
||||
def song_identity(title: str, credit: str) -> tuple[str, str]:
|
||||
"""Compute the identity key of a chart row.
|
||||
|
||||
The key pairs the raw title with the artist credit,
|
||||
canonicalized through ``CANONICAL_ARTIST_CREDITS``; a
|
||||
credit absent from the table maps to itself. Two chart
|
||||
rows denote the same song iff their identity keys are
|
||||
equal.
|
||||
|
||||
:param title: The song title as printed on the chart.
|
||||
:param credit: The combined artist credit string.
|
||||
:return: The identity key: the raw title paired with the
|
||||
canonical artist credit.
|
||||
"""
|
||||
return title, SongImporter.CANONICAL_ARTIST_CREDITS.get(
|
||||
credit, credit)
|
||||
|
||||
|
||||
class ArtistImporter:
|
||||
"""The artist-import job: parses the stored songs' artist
|
||||
credits into artists and song-artist credits."""
|
||||
|
||||
FEATURING_PATTERN: re.Pattern[str] = re.compile(
|
||||
r" featuring | feat\. ", re.IGNORECASE)
|
||||
"""The pattern splitting the primary and featured sides."""
|
||||
DELIMITER_PATTERN: re.Pattern[str] = re.compile(
|
||||
r", | & | \+ | / |(?i: and | x | with )")
|
||||
"""The pattern splitting the artist names within a side."""
|
||||
COLON_PATTERN: re.Pattern[str] = re.compile(r": ")
|
||||
"""The pattern separating a group prefix from its members in a
|
||||
"<group>: <members>" credit."""
|
||||
PAREN_MEMBERS_PATTERN: re.Pattern[str] = re.compile(
|
||||
r"^.+ \((?P<members>.+)\)$")
|
||||
"""The pattern separating a group name from its members in a
|
||||
"<group> (<members>)" credit spanning the whole credit."""
|
||||
DUET_WITH_PATTERN: re.Pattern[str] = re.compile(
|
||||
r" Duet With ", re.IGNORECASE)
|
||||
"""The pattern normalizing the "Duet With" co-billing connector
|
||||
to the plain "with" delimiter."""
|
||||
PROTECTED_ARTIST_NAMES: tuple[str, ...] = (
|
||||
"Tyler, The Creator",
|
||||
"Lil Nas X",
|
||||
"Tones And I",
|
||||
)
|
||||
"""The exact artist names guarded from the delimiter splitting,
|
||||
because each contains a delimiter word or punctuation as part
|
||||
of the name itself."""
|
||||
EXCEPTION_CREDITS: dict[str, list[tuple[str, Role]]] = {
|
||||
"SpotemGottem Featuring Pooh Shiesty Or DaBaby": [
|
||||
("SpotemGottem", Role.PRIMARY),
|
||||
("Pooh Shiesty", Role.FEATURED),
|
||||
("DaBaby", Role.FEATURED),
|
||||
],
|
||||
"THE SCOTTS, Travis Scott & Kid Cudi": [
|
||||
("Travis Scott", Role.PRIMARY),
|
||||
("Kid Cudi", Role.PRIMARY),
|
||||
],
|
||||
"Drake Featuring The Throne": [
|
||||
("Drake", Role.PRIMARY),
|
||||
("Jay Z", Role.FEATURED),
|
||||
("Kanye West", Role.FEATURED),
|
||||
],
|
||||
}
|
||||
"""The single-credit exceptions parsed by an explicit lookup
|
||||
rather than by the general rules, because the credit text alone
|
||||
does not spell out the correct member split."""
|
||||
CANONICAL_ARTIST_NAMES: dict[str, str] = {
|
||||
"beyonce": "Beyoncé",
|
||||
"5 seconds of summer": "5 Seconds of Summer",
|
||||
"a boogie wit da hoodie": "A Boogie wit da Hoodie",
|
||||
"benny blanco": "benny blanco",
|
||||
"blackbear": "blackbear",
|
||||
"chance the rapper": "Chance the Rapper",
|
||||
"xxxtentacion": "XXXTENTACION",
|
||||
"maneskin": "Måneskin",
|
||||
"rose": "ROSÉ",
|
||||
"mo": "MØ",
|
||||
"wizkid": "Wizkid",
|
||||
"ye": "Kanye West",
|
||||
"amine": "Aminé",
|
||||
"bomba estereo": "Bomba Estéreo",
|
||||
"carolina gaitan": "Carolina Gaitán",
|
||||
"casper magico": "Casper Mágico",
|
||||
"eslabon armado": "Eslabón Armado",
|
||||
"jhene aiko": "Jhené Aiko",
|
||||
"neton vega": "Netón Vega",
|
||||
"nio garcia": "Nio García",
|
||||
"oscar maydon": "Óscar Maydon",
|
||||
"silento": "Silentó",
|
||||
"the marias": "The Marías",
|
||||
"victoria monet": "Victoria Monét",
|
||||
"dan": "Dan Smyers",
|
||||
"shay": "Shay Mooney",
|
||||
"cris mj": "Cris MJ",
|
||||
"mariah the scientist": "Mariah the Scientist",
|
||||
"surf mesa": "Surf Mesa",
|
||||
}
|
||||
"""The canonical artist spellings, keyed by the case-folded
|
||||
identity."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""Initialize the importer.
|
||||
|
||||
:param session: The database session.
|
||||
"""
|
||||
self.__session: Session = session
|
||||
self.__artists: dict[str, Artist] = {}
|
||||
|
||||
def import_artists(self) -> None:
|
||||
"""Parse the stored songs' credits into artists and
|
||||
song-artist credits.
|
||||
|
||||
Reads the songs back from the database in ``Song.id`` order,
|
||||
including any songs pending in the same session, and for
|
||||
each song parses ``Song.artist_credit`` (see
|
||||
`parse_artist_credit`). An artist parsed out of a credit is
|
||||
matched against the known artists by its identity key (see
|
||||
`resolve_artist_identity`); a newly seen one takes the ID
|
||||
following the known artists, keyed by its identity key,
|
||||
assigned in first-seen order across the songs, and its
|
||||
stored name is the resolved stored spelling. An artist
|
||||
duplicated within one song's credit, by its identity key,
|
||||
is kept only at its first occurrence within that credit,
|
||||
with a warning to the standard error. When the method
|
||||
returns, the imported artists and credits are queryable in
|
||||
the session.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
song: Song
|
||||
for song in self.__session.scalars(
|
||||
sa.select(Song).order_by(Song.id)):
|
||||
self.__import_song_artists(song)
|
||||
self.__session.flush()
|
||||
|
||||
def __import_song_artists(self, song: Song) -> None:
|
||||
"""Parse and store the artist credits of one song.
|
||||
|
||||
:param song: The song with its stored artist credit.
|
||||
:return: None.
|
||||
:raises BuildError: When the parsed credit has no primary
|
||||
artist or contains a blank artist name (see
|
||||
`__check_parsed_credit`).
|
||||
"""
|
||||
parsed: list[tuple[str, Role]] = self.parse_artist_credit(
|
||||
song.artist_credit)
|
||||
self.__check_parsed_credit(song, parsed)
|
||||
seen: set[str] = set()
|
||||
position: int = 0
|
||||
name: str
|
||||
role: Role
|
||||
for name, role in parsed:
|
||||
key: str
|
||||
stored_name: str
|
||||
key, stored_name = self.resolve_artist_identity(name)
|
||||
if key in seen:
|
||||
print(f"warning: {song.artist_credit}: duplicated"
|
||||
f" artist \"{name}\"", file=sys.stderr)
|
||||
continue
|
||||
seen.add(key)
|
||||
if key not in self.__artists:
|
||||
self.__artists[key] = Artist(
|
||||
id=len(self.__artists) + 1, name=stored_name)
|
||||
self.__session.add(SongArtist(
|
||||
song=song, artist=self.__artists[key], role=role,
|
||||
position=position))
|
||||
position += 1
|
||||
|
||||
@staticmethod
|
||||
def __check_parsed_credit(
|
||||
song: Song, parsed: list[tuple[str, Role]]) -> None:
|
||||
"""Verify a song's parsed artist credit is well-formed.
|
||||
|
||||
:param song: The song whose credit was parsed.
|
||||
:param parsed: The (name, role) pairs parsed from
|
||||
``song.artist_credit``.
|
||||
:return: None.
|
||||
:raises BuildError: When ``parsed`` is empty, has no
|
||||
``Role.PRIMARY`` entry, or contains a name blank after
|
||||
stripping.
|
||||
"""
|
||||
role: Role
|
||||
if len(parsed) == 0 or not any(
|
||||
role == Role.PRIMARY for _, role in parsed):
|
||||
raise BuildError(
|
||||
f"song {song.id} \"{song.artist_credit}\": no"
|
||||
" primary artist parsed")
|
||||
name: str
|
||||
for name, role in parsed:
|
||||
if name.strip() == "":
|
||||
raise BuildError(
|
||||
f"song {song.id} \"{song.artist_credit}\":"
|
||||
" blank artist name parsed")
|
||||
|
||||
@staticmethod
|
||||
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
|
||||
"""Parse a combined artist credit into artists and roles.
|
||||
|
||||
A credit listed in ``EXCEPTION_CREDITS`` is looked up
|
||||
verbatim, because its correct split is not derivable from
|
||||
the credit text alone. Otherwise the credit first reduces
|
||||
to an effective credit: a "<group>: <members>" prefix
|
||||
(split at the first ": ") drops the group and keeps the
|
||||
members; failing that, a "<group> (<members>)" suffix
|
||||
spanning the whole credit drops the group and keeps the
|
||||
members. The "Duet With" connector, case-insensitively,
|
||||
then normalizes to "with". The effective credit splits
|
||||
into a primary side and a featured side on the word
|
||||
"featuring" or "feat.", case-insensitively; without them,
|
||||
every artist is primary. Each side splits into artist
|
||||
names on the delimiters ", ", " & ", " + ", " / "
|
||||
(literally) and " and ", " x ", " with "
|
||||
(case-insensitively), except for the names listed in
|
||||
``PROTECTED_ARTIST_NAMES``, which are never split even
|
||||
though each contains a delimiter word or punctuation.
|
||||
|
||||
Known limitation: a compound act name that contains one of
|
||||
the delimiters, other than the protected names, is
|
||||
over-split.
|
||||
|
||||
:param credit: The combined artist credit string.
|
||||
:return: The (name, role) pairs in credit order, primary
|
||||
side first, with the role ``Role.PRIMARY`` or
|
||||
``Role.FEATURED``.
|
||||
"""
|
||||
if credit in ArtistImporter.EXCEPTION_CREDITS:
|
||||
return list(ArtistImporter.EXCEPTION_CREDITS[credit])
|
||||
effective: str = credit
|
||||
colon_match: re.Match[str] | None = \
|
||||
ArtistImporter.COLON_PATTERN.search(effective)
|
||||
if colon_match is not None:
|
||||
effective = effective[colon_match.end():]
|
||||
else:
|
||||
paren_match: re.Match[str] | None = \
|
||||
ArtistImporter.PAREN_MEMBERS_PATTERN.match(
|
||||
effective)
|
||||
if paren_match is not None:
|
||||
effective = paren_match.group("members")
|
||||
effective = ArtistImporter.DUET_WITH_PATTERN.sub(
|
||||
" with ", effective)
|
||||
placeholders: dict[str, str] = {}
|
||||
index: int
|
||||
protected: str
|
||||
for index, protected in enumerate(
|
||||
ArtistImporter.PROTECTED_ARTIST_NAMES):
|
||||
if protected in effective:
|
||||
placeholder: str = f"{index}"
|
||||
placeholders[placeholder] = protected
|
||||
effective = effective.replace(protected, placeholder)
|
||||
sides: list[str] = ArtistImporter.FEATURING_PATTERN.split(
|
||||
effective, maxsplit=1)
|
||||
pairs: list[tuple[str, Role]] = []
|
||||
role: Role
|
||||
side: str
|
||||
for side, role in zip(sides, (Role.PRIMARY, Role.FEATURED)):
|
||||
token: str
|
||||
for token in ArtistImporter.DELIMITER_PATTERN.split(
|
||||
side):
|
||||
name: str = token.strip()
|
||||
placeholder = ""
|
||||
original: str
|
||||
for placeholder, original in placeholders.items():
|
||||
name = name.replace(placeholder, original)
|
||||
if name != "":
|
||||
pairs.append((name, role))
|
||||
return pairs
|
||||
|
||||
@staticmethod
|
||||
def resolve_artist_identity(name: str) -> tuple[str, str]:
|
||||
"""Resolve the dedup key and the stored spelling of a name.
|
||||
|
||||
The name's case-folded form is looked up in
|
||||
``CANONICAL_ARTIST_NAMES`` first; when it is listed there,
|
||||
the dedup key is the canonical spelling case-folded and the
|
||||
stored spelling is the canonical spelling, so every variant
|
||||
of the name, canonical or not, resolves to the same
|
||||
identity. Otherwise the dedup key is the name case-folded
|
||||
and the stored spelling is the given name.
|
||||
|
||||
:param name: An artist name, as parsed from a credit.
|
||||
:return: A tuple of the dedup key and the stored spelling.
|
||||
"""
|
||||
folded: str = name.casefold()
|
||||
canonical: str | None = \
|
||||
ArtistImporter.CANONICAL_ARTIST_NAMES.get(folded)
|
||||
if canonical is not None:
|
||||
return canonical.casefold(), canonical
|
||||
return folded, name
|
||||
|
||||
|
||||
class CaptureImporter:
|
||||
"""The capture-import job: applies the optional capture-layer
|
||||
inputs onto the stored songs and artists."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""Initialize the importer.
|
||||
|
||||
:param session: The database session.
|
||||
"""
|
||||
self.__session: Session = session
|
||||
|
||||
def import_captures(self, lyrics_dir: Path | None,
|
||||
wikidata_csv: Path | None) -> None:
|
||||
"""Apply the optional capture-layer inputs onto the store.
|
||||
|
||||
A None input leaves its capture layer unloaded. When
|
||||
``lyrics_dir`` is given, its cached lyrics files load into
|
||||
the matching songs (see `__load_lyrics`). When
|
||||
``wikidata_csv`` is given, it applies onto the artist rows
|
||||
(see `__apply_artist_csv`). When the method returns, the
|
||||
applied changes are queryable in the session.
|
||||
|
||||
:param lyrics_dir: The lyrics cache directory to load, or
|
||||
None to skip the lyrics capture layer.
|
||||
:param wikidata_csv: The Wikidata artist snapshot CSV file
|
||||
to apply, or None to skip the artist capture layer.
|
||||
:return: None.
|
||||
:raises BuildError: When ``lyrics_dir`` does not exist, or
|
||||
a name in ``wikidata_csv`` matches no artist.
|
||||
:raises OSError: When a capture file cannot be read.
|
||||
"""
|
||||
if lyrics_dir is not None:
|
||||
if not lyrics_dir.is_dir():
|
||||
raise BuildError(
|
||||
f"{lyrics_dir}: no such directory")
|
||||
self.__load_lyrics(lyrics_dir)
|
||||
if wikidata_csv is not None:
|
||||
self.__apply_artist_csv(wikidata_csv)
|
||||
self.__session.flush()
|
||||
|
||||
def __load_lyrics(self, directory: Path) -> None:
|
||||
"""Load the cached lyrics files into the matching songs.
|
||||
|
||||
A file whose stem is not an existing song ID is skipped
|
||||
with a warning to the standard error.
|
||||
|
||||
:param directory: The existing lyrics cache directory with
|
||||
one ``<song_id>.txt`` file per song.
|
||||
:return: None.
|
||||
:raises OSError: When a lyrics file cannot be read.
|
||||
"""
|
||||
for path in sorted(directory.glob("*.txt")):
|
||||
song: Song | None = None
|
||||
if path.stem.isdigit():
|
||||
song = self.__session.get(Song, int(path.stem))
|
||||
if song is None:
|
||||
print(f"warning: {path}: no song with ID"
|
||||
f" \"{path.stem}\"", file=sys.stderr)
|
||||
continue
|
||||
song.lyrics = path.read_text(encoding="utf-8")
|
||||
|
||||
def __apply_artist_csv(self, path: Path) -> None:
|
||||
"""Apply an artist attribute CSV onto the artist rows.
|
||||
|
||||
Artists match by exact name. Only the non-empty cells are
|
||||
applied, field by field. The note column is ignored.
|
||||
|
||||
:param path: The CSV file with the columns name, qid,
|
||||
gender, type, genre, country, and note.
|
||||
:return: None.
|
||||
:raises BuildError: When a name matches no artist.
|
||||
:raises OSError: When the file cannot be read.
|
||||
"""
|
||||
with open(path, encoding="utf-8", newline="") as file:
|
||||
row: dict[str, str]
|
||||
for row in csv.DictReader(file):
|
||||
artist: Artist | None = self.__session.scalar(
|
||||
sa.select(Artist)
|
||||
.where(Artist.name == row["name"]))
|
||||
if artist is None:
|
||||
raise BuildError(
|
||||
f"{path}: no artist named"
|
||||
f" \"{row['name']}\"")
|
||||
column: str
|
||||
attribute: str
|
||||
for column, attribute in ARTIST_FIELDS.items():
|
||||
if row.get(column):
|
||||
setattr(artist, attribute, row[column])
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreCounts:
|
||||
"""The row counts of the working store, for the build summary."""
|
||||
|
||||
songs: int
|
||||
"""The number of the songs."""
|
||||
chart_entries: int
|
||||
"""The number of the chart entries."""
|
||||
artists: int
|
||||
"""The number of the artists."""
|
||||
credits: int
|
||||
"""The number of the song-artist credits."""
|
||||
songs_with_lyrics: int
|
||||
"""The number of the songs with lyrics."""
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, session: Session) -> Self:
|
||||
"""Counts the loaded rows and returns the counts.
|
||||
|
||||
:param session: The database session with the loaded data
|
||||
flushed.
|
||||
:return: The row counts of the working store.
|
||||
"""
|
||||
def count(selectable: sa.Select[tuple[int]]) -> int:
|
||||
value: int | None = session.scalar(selectable)
|
||||
assert value is not None
|
||||
return value
|
||||
|
||||
return cls(
|
||||
songs=count(
|
||||
sa.select(sa.func.count()).select_from(Song)),
|
||||
chart_entries=count(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(ChartEntry)),
|
||||
artists=count(
|
||||
sa.select(sa.func.count()).select_from(Artist)),
|
||||
credits=count(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(SongArtist)),
|
||||
songs_with_lyrics=count(
|
||||
sa.select(sa.func.count()).select_from(Song)
|
||||
.where(Song.lyrics.is_not(None))))
|
||||
|
||||
|
||||
def reset_store(session: Session) -> None:
|
||||
"""Delete all the rows from every table of the store.
|
||||
|
||||
:param session: The database session.
|
||||
:return: None.
|
||||
"""
|
||||
model: type[Base]
|
||||
for model in (SongArtist, ChartEntry, Song, Artist):
|
||||
session.execute(sa.delete(model))
|
||||
|
||||
|
||||
class CSVExporter:
|
||||
"""Writes the review CSV files mirroring the working store."""
|
||||
|
||||
__SONGS_HEADER: tuple[str, ...] = ("Title", "Artists", "Positions")
|
||||
"""The header row of ``songs.csv``, for human readers."""
|
||||
__ARTISTS_HEADER: tuple[str, ...] = (
|
||||
"Name", "Wikidata QID", "Gender", "Type", "Genre", "Country",
|
||||
"Songs")
|
||||
"""The header row of ``artists.csv``, for human readers."""
|
||||
|
||||
def __init__(self, session: Session, derived_dir: Path) -> None:
|
||||
"""Initialize the exporter.
|
||||
|
||||
:param session: The database session with the loaded data
|
||||
flushed.
|
||||
:param derived_dir: The output directory for the review CSV
|
||||
files.
|
||||
"""
|
||||
self.__session: Session = session
|
||||
self.__derived_dir: Path = derived_dir
|
||||
|
||||
def write(self) -> None:
|
||||
"""Write the review CSV files mirroring the loaded data.
|
||||
|
||||
Fully overwrites ``songs.csv`` and ``artists.csv`` under the
|
||||
output directory, creating it when missing, with normal
|
||||
minimal CSV quoting. Neither file carries a song or an
|
||||
artist ID; a multi-valued field is a plain joined string,
|
||||
itself CSV-quoted as a whole only when its content requires
|
||||
it: "/" joins the chart appearances of one song, and "|"
|
||||
joins the distinct songs credited to one artist.
|
||||
|
||||
:return: None.
|
||||
:raises OSError: When a CSV file cannot be written.
|
||||
"""
|
||||
self.__derived_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.__write_csv(
|
||||
self.__derived_dir / "songs.csv", self.__SONGS_HEADER,
|
||||
self.__songs_rows())
|
||||
self.__write_csv(
|
||||
self.__derived_dir / "artists.csv", self.__ARTISTS_HEADER,
|
||||
self.__artists_rows())
|
||||
|
||||
@staticmethod
|
||||
def __write_csv(path: Path, header: Sequence[str],
|
||||
rows: Iterable[Sequence[str]]) -> None:
|
||||
"""Write a CSV file with LF line endings, fully overwritten.
|
||||
|
||||
:param path: The output CSV file.
|
||||
:param header: The header row.
|
||||
:param rows: The data rows, in the given order.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
|
||||
@staticmethod
|
||||
def __sorted_chart_entries(song: Song) -> list[ChartEntry]:
|
||||
"""Sort the chart entries of a song by year then rank.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The chart entries, ordered by year then rank.
|
||||
"""
|
||||
return sorted(
|
||||
song.chart_entries, key=lambda x: (x.year, x.rank))
|
||||
|
||||
@classmethod
|
||||
def __song_positions(cls, song: Song) -> str:
|
||||
"""Format the chart positions of a song for the songs.csv
|
||||
value.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||
joined by "/".
|
||||
"""
|
||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||
|
||||
@classmethod
|
||||
def __formatted_song_positions(cls, song: Song) -> str:
|
||||
"""Format the chart positions of a song for the artists.csv
|
||||
value.
|
||||
|
||||
:param song: The song with its chart entries loaded.
|
||||
:return: The "YEAR#RANK" tokens, ordered by year then rank,
|
||||
joined by "/".
|
||||
"""
|
||||
entries: list[ChartEntry] = cls.__sorted_chart_entries(song)
|
||||
return "/".join(f"{x.year}#{x.rank}" for x in entries)
|
||||
|
||||
def __songs_rows(self) -> list[list[str]]:
|
||||
"""Build the sorted data rows of ``songs.csv``.
|
||||
|
||||
:return: The rows, sorted by the case-folded title, then
|
||||
the case-folded artist credit.
|
||||
"""
|
||||
songs: list[Song] = sorted(
|
||||
self.__session.scalars(sa.select(Song)),
|
||||
key=lambda x: (x.title.casefold(),
|
||||
x.artist_credit.casefold()))
|
||||
rows: list[list[str]] = []
|
||||
song: Song
|
||||
for song in songs:
|
||||
row: list[str] = [
|
||||
song.title, song.artist_credit,
|
||||
self.__song_positions(song)]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
def __artist_songs(cls, artist: Artist) -> str:
|
||||
"""Format the credited songs for the artists.csv value.
|
||||
|
||||
:param artist: The artist with its song credits loaded.
|
||||
:return: The credited songs, each formatted as
|
||||
"TITLE (YEAR#RANK[/YEAR#RANK...])" with its chart
|
||||
appearances, sorted alphabetically case-folded by
|
||||
title, joined by "|".
|
||||
"""
|
||||
songs: list[Song] = sorted(
|
||||
(x.song for x in artist.song_artists),
|
||||
key=lambda x: x.title.casefold())
|
||||
entries: list[str] = [
|
||||
f"{song.title} ({cls.__formatted_song_positions(song)})"
|
||||
for song in songs]
|
||||
return "|".join(entries)
|
||||
|
||||
def __artists_rows(self) -> list[list[str]]:
|
||||
"""Build the sorted data rows of ``artists.csv``.
|
||||
|
||||
:return: The rows, sorted by the case-folded name.
|
||||
"""
|
||||
artists: list[Artist] = sorted(
|
||||
self.__session.scalars(sa.select(Artist)),
|
||||
key=lambda x: x.name.casefold())
|
||||
rows: list[list[str]] = []
|
||||
artist: Artist
|
||||
for artist in artists:
|
||||
row: list[str] = [
|
||||
artist.name, artist.wikidata_qid or "",
|
||||
artist.gender or "", artist.type or "",
|
||||
artist.genre or "", artist.country or "",
|
||||
self.__artist_songs(artist)]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Rebuild the SQLite working store from the inputs.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The exit status: 0 on success, non-zero on failure.
|
||||
"""
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
Base.metadata.create_all(ds.engine)
|
||||
session: Session = ds.get_db()
|
||||
counts: StoreCounts
|
||||
try:
|
||||
reset_store(session)
|
||||
SongImporter(session).import_songs(args.chart_csv)
|
||||
ArtistImporter(session).import_artists()
|
||||
CaptureImporter(session).import_captures(
|
||||
args.lyrics_dir, args.wikidata_csv)
|
||||
counts = StoreCounts.get_instance(session)
|
||||
CSVExporter(session, args.derived_dir).write()
|
||||
session.commit()
|
||||
except (OSError, BuildError) as error:
|
||||
session.rollback()
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
session.close()
|
||||
print(f"done: {counts.songs} songs,"
|
||||
f" {counts.chart_entries} chart entries,"
|
||||
f" {counts.artists} artists,"
|
||||
f" {counts.credits} credits,"
|
||||
f" {counts.songs_with_lyrics} songs with lyrics",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
@@ -1,84 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
||||
"""The exporter of the LLM input JSONL file.
|
||||
|
||||
Exports the songs from the SQLite working store into the JSONL
|
||||
input file for the ``run-llm`` subcommand, given as the positional
|
||||
command-line argument. This is the enforcement point of the
|
||||
project's lyrics-only firewall: the output carries only the
|
||||
lyrics text of each song, identified by an opaque song key; no
|
||||
title, artist, or chart data crosses into the LLM input.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import ds
|
||||
from ..models import Song
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"""Parse the command-line arguments.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The parsed arguments.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
description="Export the LLM input JSONL file (lyrics"
|
||||
" only) from the SQLite working store.")
|
||||
parser.add_argument(
|
||||
"output_jsonl", type=Path,
|
||||
help="the JSONL output file")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def build_lines(session: Session) -> list[str]:
|
||||
"""Build the JSONL lines of every song's lyrics.
|
||||
|
||||
:param session: The database session.
|
||||
:return: The JSON lines, one per song, ordered by song ID.
|
||||
:raises ValueError: When a song has no lyrics.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
song: Song
|
||||
for song in session.scalars(sa.select(Song).order_by(Song.id)):
|
||||
if song.lyrics is None:
|
||||
raise ValueError(
|
||||
f"song {song.id} \"{song.title}\": no lyrics")
|
||||
record: dict[str, str] = {
|
||||
"id": f"song-{song.id}", "content": song.lyrics}
|
||||
lines.append(json.dumps(record, ensure_ascii=False))
|
||||
return lines
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Export the LLM input JSONL file from the working store.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The exit status: 0 on success, non-zero on failure.
|
||||
"""
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
session: Session = ds.get_db()
|
||||
lines: list[str]
|
||||
try:
|
||||
lines = build_lines(session)
|
||||
except (OSError, sa.exc.SQLAlchemyError, ValueError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
session.close()
|
||||
args.output_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(args.output_jsonl, "w", encoding="utf-8") as file:
|
||||
line: str
|
||||
for line in lines:
|
||||
file.write(line + "\n")
|
||||
print(f"done: {len(lines)} songs exported", file=sys.stderr)
|
||||
return 0
|
||||
@@ -1,840 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""The fetcher of the artist metadata.
|
||||
|
||||
Fetches the metadata of the artists without a snapshot row from
|
||||
Wikidata into the capture layer: the Wikidata artist snapshot
|
||||
CSV, given as the positional command-line argument. The working
|
||||
store is only read, never written; the ``build-db`` subcommand
|
||||
assembles the captured files into the store on the next rebuild.
|
||||
|
||||
Every fetched row is meant for later human verification: the
|
||||
description of the resolved item is recorded in the note column
|
||||
so that a bad match can be spotted. An unresolved artist or an
|
||||
error on one artist is noted on its row and does not fail the
|
||||
run.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import enum
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TextIO
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import VERSION
|
||||
from ..database import ds
|
||||
from ..models import Artist, Song, SongArtist
|
||||
from ..utils import format_duration
|
||||
|
||||
API_URL: str = "https://www.wikidata.org/w/api.php"
|
||||
"""The URL of the Wikidata API endpoint."""
|
||||
SPARQL_URL: str = "https://query.wikidata.org/sparql"
|
||||
"""The URL of the Wikidata Query Service SPARQL endpoint."""
|
||||
USER_AGENT: str = (
|
||||
f"pop-fem-audit-tools/{VERSION}"
|
||||
" (https://github.com/imacat/pop-fem-audit;"
|
||||
" mailto:imacat@mail.imacat.idv.tw)")
|
||||
"""The User-Agent header sent on every HTTP request."""
|
||||
TIMEOUT: float = 30.0
|
||||
"""The timeout of an API HTTP request, in seconds."""
|
||||
SPARQL_TIMEOUT: float = 90.0
|
||||
"""The timeout of a SPARQL HTTP request, in seconds.
|
||||
|
||||
Higher than the API timeout: the WDQS server aborts a slow
|
||||
query at 60 seconds, and a lower client timeout would race
|
||||
that server-side abort and misclassify a slow-but-answerable
|
||||
query as a client-side timeout instead of letting the server's
|
||||
own HTTP error response arrive and enter the retry path."""
|
||||
SLEEP_SECONDS: float = 1.0
|
||||
"""The delay between consecutive HTTP requests, in seconds."""
|
||||
MAX_ATTEMPTS: int = 5
|
||||
"""The maximum number of attempts on a transient error."""
|
||||
RETRY_SECONDS: float = 15.0
|
||||
"""The back-off unit on a transient error, in seconds;
|
||||
multiplied by the attempt number already made."""
|
||||
RETRY_STATUSES: frozenset[int] = frozenset({429, 500, 502, 503})
|
||||
"""The HTTP statuses that are retried with a back-off."""
|
||||
MAX_STAGE1_TITLES: int = 3
|
||||
"""The maximum number of charted titles used for the stage-1 song
|
||||
corroboration."""
|
||||
HUMAN_QID: str = "Q5"
|
||||
"""The Wikidata item ID of "human"."""
|
||||
ENSEMBLE_QID: str = "Q2088357"
|
||||
"""The Wikidata item ID of "musical ensemble"."""
|
||||
ORIGINAL_CAST_QID: str = "Q106497009"
|
||||
"""The Wikidata item ID of "original cast"."""
|
||||
GROUP_KEYWORDS: Sequence[str] = ("band", "group", "duo", "trio")
|
||||
"""The label keywords that suggest a musical ensemble, covering
|
||||
labels like "boy band" and "girl group"."""
|
||||
NOTE_NOT_FOUND: str = "not found"
|
||||
"""The note sentinel of an artist without a resolved Wikidata
|
||||
item, written to the snapshot and read back for the
|
||||
classification."""
|
||||
PINNED_QIDS: dict[str, str] = {
|
||||
"Pinkfong": "Q55735607",
|
||||
}
|
||||
"""The last-resort pinned item IDs, keyed by the artist name.
|
||||
|
||||
Each entry is for an artist the algorithm documented on
|
||||
``ArtistFetcher`` is structurally unable to resolve, with its
|
||||
justification recorded here:
|
||||
|
||||
- "Pinkfong": the only charting act whose item is typed as a
|
||||
brand (P31 = Q431289), which the type gate (human / musical
|
||||
ensemble / original cast) excludes by design.
|
||||
|
||||
A pinned name skips the candidate retrieval and corroboration
|
||||
steps; its item ID is used directly."""
|
||||
|
||||
|
||||
class ArtistType(enum.StrEnum):
|
||||
"""The decided artist type of a snapshot row."""
|
||||
|
||||
SOLO = "solo"
|
||||
"""A solo artist: a human."""
|
||||
GROUP = "group"
|
||||
"""A musical ensemble."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtistSnapshot:
|
||||
"""One row of the Wikidata artist snapshot CSV file."""
|
||||
|
||||
name: str
|
||||
"""The artist name."""
|
||||
qid: str = ""
|
||||
"""The Wikidata item ID, or empty when unresolved."""
|
||||
gender: str = ""
|
||||
"""The gender label, or empty when unresolved."""
|
||||
type: str = ""
|
||||
"""The artist type, an ``ArtistType`` value, or empty for the
|
||||
human to decide."""
|
||||
genre: str = ""
|
||||
"""The genre labels, joined with ``; ``."""
|
||||
country: str = ""
|
||||
"""The country label, or empty when unresolved."""
|
||||
note: str = ""
|
||||
"""The note for human verification: the description of the
|
||||
resolved item, ``not found``, or ``error: <reason>``."""
|
||||
|
||||
def to_row(self) -> dict[str, str]:
|
||||
"""Return this snapshot as a CSV row.
|
||||
|
||||
:return: The row values, keyed by the column name.
|
||||
"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
SNAPSHOT_FIELDS: Sequence[str] = tuple(
|
||||
x.name for x in fields(ArtistSnapshot))
|
||||
"""The header columns of the Wikidata artist snapshot CSV file."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtistClaims:
|
||||
"""The item-ID claim targets and description of a Wikidata
|
||||
artist item."""
|
||||
|
||||
gender_ids: list[str] = field(default_factory=list)
|
||||
"""The item IDs of the gender targets."""
|
||||
instance_of_ids: list[str] = field(default_factory=list)
|
||||
"""The item IDs of the instance-of targets."""
|
||||
genre_ids: list[str] = field(default_factory=list)
|
||||
"""The item IDs of the genre targets."""
|
||||
country_ids: list[str] = field(default_factory=list)
|
||||
"""The item IDs of the country-of-citizenship targets."""
|
||||
origin_country_ids: list[str] = field(default_factory=list)
|
||||
"""The item IDs of the country-of-origin targets."""
|
||||
description: str = ""
|
||||
"""The English description of the item, or empty when
|
||||
absent."""
|
||||
|
||||
|
||||
class RetryExhausted(Exception):
|
||||
"""The retries on a transient error are exhausted.
|
||||
|
||||
A transient error is a retryable HTTP status (429, 500,
|
||||
502, or 503) or a read timeout.
|
||||
"""
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"""Parse the command-line arguments.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The parsed arguments.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
description="Fetch the artist metadata from Wikidata"
|
||||
" into the capture layer.")
|
||||
parser.add_argument(
|
||||
"wikidata_csv", type=Path,
|
||||
help="the Wikidata artist snapshot CSV file")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
class ArtistFetcher:
|
||||
"""A fetcher of artist metadata from Wikidata.
|
||||
|
||||
An artist name is resolved to a Wikidata item ID through the
|
||||
Wikidata Query Service (SPARQL), not the search API, so that
|
||||
every step is a deterministic indexed lookup with no ranking
|
||||
and no case folding of the label text itself:
|
||||
|
||||
1. Candidate retrieval: the items whose ``rdfs:label`` or
|
||||
``skos:altLabel`` exactly equals the artist name at
|
||||
``@en`` or ``@mul`` (the multilingual default layer),
|
||||
restricted to a human (P31 = Q5), a musical ensemble
|
||||
(P31/P279* = Q2088357), or an original cast
|
||||
(P31 = Q106497009).
|
||||
2. A single candidate is selected outright.
|
||||
3. With multiple candidates, stage 1 corroborates with up to
|
||||
the first 3 charted titles: the song items whose label or
|
||||
alias exactly equals a title at ``@en`` or ``@mul`` are
|
||||
looked up with their P175 performers and, optionally,
|
||||
those performers' P527 members. The candidates are
|
||||
intersected with the performers, and, only when that
|
||||
intersection is empty, with the members; a stage succeeds
|
||||
only when the intersection has exactly one item.
|
||||
4. Stage 2 is an anchored, case-insensitive fallback: every
|
||||
song performed by a candidate, directly or via a parent
|
||||
group, is compared against the charted titles with a
|
||||
casefold match on the song label, without a language
|
||||
restriction; a single matching candidate is selected.
|
||||
5. Zero candidates, or no stage narrowing to exactly one
|
||||
item, leaves the artist unresolved.
|
||||
|
||||
As a last resort, a name listed in ``PINNED_QIDS`` uses its
|
||||
pinned item ID directly, skipping every step above.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Construct the fetcher."""
|
||||
self.__sent: int = 0
|
||||
"""The number of the HTTP requests already sent."""
|
||||
|
||||
def fetch(self, name: str,
|
||||
titles: Sequence[str]) -> ArtistSnapshot:
|
||||
"""Fetch the metadata of an artist.
|
||||
|
||||
The note carries the description of the resolved item
|
||||
for human verification. ``not found`` is reserved for
|
||||
the algorithm genuinely finding nothing for the artist.
|
||||
Any HTTP, network, or decoding error -- including the
|
||||
retries on a transient error being exhausted -- yields a
|
||||
snapshot with what was resolved so far and the note
|
||||
``error: <reason>``.
|
||||
|
||||
:param name: The artist name to resolve.
|
||||
:param titles: The charted song titles credited to the
|
||||
artist, used for the multi-candidate resolution.
|
||||
:return: The snapshot of the artist.
|
||||
"""
|
||||
snapshot: ArtistSnapshot = ArtistSnapshot(name=name)
|
||||
try:
|
||||
qid: str | None = self.__resolve_qid(name, titles)
|
||||
if qid is None:
|
||||
snapshot.note = NOTE_NOT_FOUND
|
||||
return snapshot
|
||||
snapshot.qid = qid
|
||||
self.__resolve(snapshot)
|
||||
except (RetryExhausted, OSError, ValueError) as error:
|
||||
snapshot.note = f"error: {error}"
|
||||
return snapshot
|
||||
|
||||
def __resolve_qid(self, name: str,
|
||||
titles: Sequence[str]) -> str | None:
|
||||
"""Resolve an artist name to a Wikidata item ID.
|
||||
|
||||
:param name: The artist name.
|
||||
:param titles: The charted song titles credited to the
|
||||
artist.
|
||||
:return: The pinned item ID from ``PINNED_QIDS`` when the
|
||||
name is listed there; otherwise the resolved item
|
||||
ID, or None when the algorithm documented on this
|
||||
class does not narrow to exactly one item.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
if name in PINNED_QIDS:
|
||||
return PINNED_QIDS[name]
|
||||
candidates: list[str] = self.__candidates(name)
|
||||
if len(candidates) == 0:
|
||||
return None
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
selected: str | None = self.__stage1(candidates, titles)
|
||||
if selected is not None:
|
||||
return selected
|
||||
return self.__stage2(candidates, titles)
|
||||
|
||||
def __candidates(self, name: str) -> list[str]:
|
||||
"""Retrieve the Wikidata items matching an artist name.
|
||||
|
||||
:param name: The artist name.
|
||||
:return: The item IDs of the human, musical-ensemble, or
|
||||
original-cast items whose label or alias exactly
|
||||
equals the name.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
query: str = f"""
|
||||
SELECT DISTINCT ?item WHERE {{
|
||||
VALUES ?name {{ {self.__literals([name])} }}
|
||||
{{ ?item rdfs:label ?name }}
|
||||
UNION {{ ?item skos:altLabel ?name }}
|
||||
{{
|
||||
?item wdt:P31 wd:{HUMAN_QID}
|
||||
}} UNION {{
|
||||
?item wdt:P31/wdt:P279* wd:{ENSEMBLE_QID}
|
||||
}} UNION {{
|
||||
?item wdt:P31 wd:{ORIGINAL_CAST_QID}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
rows: list[dict[str, str]] = self.__sparql(query)
|
||||
return [self.__qid(x["item"]) for x in rows
|
||||
if "item" in x]
|
||||
|
||||
def __stage1(self, candidates: Sequence[str],
|
||||
titles: Sequence[str]) -> str | None:
|
||||
"""Corroborate the candidates with the charted titles.
|
||||
|
||||
:param candidates: The candidate item IDs.
|
||||
:param titles: The charted song titles credited to the
|
||||
artist.
|
||||
:return: The single candidate among the performers of a
|
||||
matching song, or, only when no such single
|
||||
candidate exists, among those performers' group
|
||||
members; None when neither intersection has exactly
|
||||
one item, or there are no titles to try.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
subset: Sequence[str] = titles[:MAX_STAGE1_TITLES]
|
||||
if len(subset) == 0:
|
||||
return None
|
||||
query: str = f"""
|
||||
SELECT DISTINCT ?performer ?member WHERE {{
|
||||
VALUES ?title {{ {self.__literals(subset)} }}
|
||||
{{ ?song rdfs:label ?title }}
|
||||
UNION {{ ?song skos:altLabel ?title }}
|
||||
?song wdt:P175 ?performer .
|
||||
OPTIONAL {{ ?performer wdt:P527 ?member . }}
|
||||
}}
|
||||
"""
|
||||
rows: list[dict[str, str]] = self.__sparql(query)
|
||||
performers: set[str] = {
|
||||
self.__qid(x["performer"]) for x in rows
|
||||
if "performer" in x}
|
||||
hit: set[str] = set(candidates) & performers
|
||||
if len(hit) == 1:
|
||||
return next(iter(hit))
|
||||
members: set[str] = {
|
||||
self.__qid(x["member"]) for x in rows
|
||||
if "member" in x}
|
||||
hit = set(candidates) & members
|
||||
if len(hit) == 1:
|
||||
return next(iter(hit))
|
||||
return None
|
||||
|
||||
def __stage2(self, candidates: Sequence[str],
|
||||
titles: Sequence[str]) -> str | None:
|
||||
"""Rescue a single candidate by a case-insensitive match.
|
||||
|
||||
:param candidates: The candidate item IDs.
|
||||
:param titles: The charted song titles credited to the
|
||||
artist.
|
||||
:return: The single candidate with a charted song among
|
||||
the songs it, or a parent group, performs, matched
|
||||
case-insensitively against the song label; None when
|
||||
no such single candidate exists.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
values: str = " ".join(f"wd:{x}" for x in candidates)
|
||||
query: str = f"""
|
||||
SELECT DISTINCT ?cand ?label WHERE {{
|
||||
VALUES ?cand {{ {values} }}
|
||||
{{ ?song wdt:P175 ?cand }}
|
||||
UNION {{ ?g wdt:P527 ?cand . ?song wdt:P175 ?g }}
|
||||
?song rdfs:label ?label .
|
||||
}}
|
||||
"""
|
||||
rows: list[dict[str, str]] = self.__sparql(query)
|
||||
folded: set[str] = {x.casefold() for x in titles}
|
||||
hits: set[str] = {
|
||||
self.__qid(x["cand"]) for x in rows
|
||||
if "cand" in x and "label" in x
|
||||
and x["label"].casefold() in folded}
|
||||
if len(hits) == 1:
|
||||
return next(iter(hits))
|
||||
return None
|
||||
|
||||
def __resolve(self, snapshot: ArtistSnapshot) -> None:
|
||||
"""Resolve the claims of an artist into the snapshot.
|
||||
|
||||
:param snapshot: The snapshot, with the QID set.
|
||||
:return: None.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
claims: ArtistClaims = self.__get_claims(snapshot.qid)
|
||||
snapshot.note = claims.description
|
||||
country_ids: list[str] = claims.country_ids
|
||||
if len(country_ids) == 0:
|
||||
country_ids = claims.origin_country_ids
|
||||
labels: dict[str, str] = self.__get_labels(
|
||||
claims.gender_ids + claims.instance_of_ids
|
||||
+ claims.genre_ids + country_ids)
|
||||
if len(claims.gender_ids) > 0:
|
||||
snapshot.gender = labels.get(claims.gender_ids[0], "")
|
||||
snapshot.type = self.__artist_type(
|
||||
claims.instance_of_ids, labels)
|
||||
snapshot.genre = "; ".join(
|
||||
labels[x] for x in claims.genre_ids if x in labels)
|
||||
if len(country_ids) > 0:
|
||||
snapshot.country = labels.get(country_ids[0], "")
|
||||
|
||||
def __get_claims(self, qid: str) -> ArtistClaims:
|
||||
"""Fetch the claims and the description of a Wikidata
|
||||
item.
|
||||
|
||||
:param qid: The item ID.
|
||||
:return: The item-ID targets of the gender, instance-of,
|
||||
genre, and country properties, and the English
|
||||
description.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
data: Any = self.__get_json({
|
||||
"action": "wbgetentities", "ids": qid,
|
||||
"props": "claims|descriptions", "languages": "en",
|
||||
"format": "json"})
|
||||
entity: Any = None
|
||||
if isinstance(data, dict) \
|
||||
and isinstance(data.get("entities"), dict):
|
||||
entity = data["entities"].get(qid)
|
||||
claims: Any = entity.get("claims") \
|
||||
if isinstance(entity, dict) else None
|
||||
if not isinstance(claims, dict):
|
||||
claims = {}
|
||||
return ArtistClaims(
|
||||
gender_ids=self.__targets(claims.get("P21")),
|
||||
instance_of_ids=self.__targets(claims.get("P31")),
|
||||
genre_ids=self.__targets(claims.get("P136")),
|
||||
country_ids=self.__targets(claims.get("P27")),
|
||||
origin_country_ids=self.__targets(claims.get("P495")),
|
||||
description=self.__description(entity))
|
||||
|
||||
@staticmethod
|
||||
def __description(entity: Any) -> str:
|
||||
"""Extract the English description of a Wikidata entity.
|
||||
|
||||
:param entity: The entity data, or None.
|
||||
:return: The description, or the empty string when
|
||||
absent.
|
||||
"""
|
||||
descriptions: Any = entity.get("descriptions") \
|
||||
if isinstance(entity, dict) else None
|
||||
if not isinstance(descriptions, dict):
|
||||
return ""
|
||||
description: Any = descriptions.get("en")
|
||||
if isinstance(description, dict) \
|
||||
and isinstance(description.get("value"), str):
|
||||
return description["value"]
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __targets(statements: Any) -> list[str]:
|
||||
"""Extract the item-ID targets of the property statements.
|
||||
|
||||
:param statements: The statements of a property, or None.
|
||||
:return: The item IDs of the statement targets.
|
||||
"""
|
||||
if not isinstance(statements, list):
|
||||
return []
|
||||
ids: list[str] = []
|
||||
statement: Any
|
||||
for statement in statements:
|
||||
if not isinstance(statement, dict):
|
||||
continue
|
||||
snak: Any = statement.get("mainsnak")
|
||||
if not isinstance(snak, dict):
|
||||
continue
|
||||
datavalue: Any = snak.get("datavalue")
|
||||
if not isinstance(datavalue, dict):
|
||||
continue
|
||||
value: Any = datavalue.get("value")
|
||||
if isinstance(value, dict) \
|
||||
and isinstance(value.get("id"), str):
|
||||
ids.append(value["id"])
|
||||
return ids
|
||||
|
||||
def __get_labels(self, qids: Sequence[str]) \
|
||||
-> dict[str, str]:
|
||||
"""Resolve item IDs to their English labels in one batch.
|
||||
|
||||
:param qids: The item IDs, duplicates allowed.
|
||||
:return: The English labels, keyed by the item ID; the
|
||||
items without an English label are left out.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
unique: list[str] = list(dict.fromkeys(qids))
|
||||
if len(unique) == 0:
|
||||
return {}
|
||||
data: Any = self.__get_json({
|
||||
"action": "wbgetentities", "ids": "|".join(unique),
|
||||
"props": "labels", "languages": "en",
|
||||
"format": "json"})
|
||||
entities: Any = data.get("entities") \
|
||||
if isinstance(data, dict) else None
|
||||
if not isinstance(entities, dict):
|
||||
return {}
|
||||
labels: dict[str, str] = {}
|
||||
qid: str
|
||||
for qid in unique:
|
||||
entity: Any = entities.get(qid)
|
||||
if not isinstance(entity, dict) \
|
||||
or not isinstance(entity.get("labels"), dict):
|
||||
continue
|
||||
label: Any = entity["labels"].get("en")
|
||||
if isinstance(label, dict) \
|
||||
and isinstance(label.get("value"), str):
|
||||
labels[qid] = label["value"]
|
||||
return labels
|
||||
|
||||
@staticmethod
|
||||
def __artist_type(type_ids: Sequence[str],
|
||||
labels: dict[str, str]) \
|
||||
-> ArtistType | Literal[""]:
|
||||
"""Derive the artist type from the instance-of targets.
|
||||
|
||||
:param type_ids: The item IDs of the instance-of targets.
|
||||
:param labels: The English labels, keyed by the item ID.
|
||||
:return: ``ArtistType.SOLO`` for a human,
|
||||
``ArtistType.GROUP`` for a musical ensemble, or the
|
||||
empty string for the human to decide.
|
||||
"""
|
||||
if HUMAN_QID in type_ids:
|
||||
return ArtistType.SOLO
|
||||
qid: str
|
||||
for qid in type_ids:
|
||||
label: str = labels.get(qid, "").lower()
|
||||
if any(x in label for x in GROUP_KEYWORDS):
|
||||
return ArtistType.GROUP
|
||||
return ""
|
||||
|
||||
def __sparql(self, query: str) -> list[dict[str, str]]:
|
||||
"""Run a SPARQL query against the Wikidata Query Service.
|
||||
|
||||
:param query: The SPARQL query text.
|
||||
:return: The result bindings, each variable name mapped
|
||||
to its bound value.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
url: str = (f"{SPARQL_URL}?"
|
||||
f"{urllib.parse.urlencode({'query': query})}")
|
||||
request: urllib.request.Request = urllib.request.Request(
|
||||
url, headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/sparql-results+json"})
|
||||
body: bytes = self.__send(
|
||||
request, timeout=SPARQL_TIMEOUT)
|
||||
data: Any = json.loads(body)
|
||||
bindings: Any = None
|
||||
if isinstance(data, dict) \
|
||||
and isinstance(data.get("results"), dict):
|
||||
bindings = data["results"].get("bindings")
|
||||
if not isinstance(bindings, list):
|
||||
return []
|
||||
rows: list[dict[str, str]] = []
|
||||
binding: Any
|
||||
for binding in bindings:
|
||||
if not isinstance(binding, dict):
|
||||
continue
|
||||
row: dict[str, str] = {}
|
||||
key: str
|
||||
cell: Any
|
||||
for key, cell in binding.items():
|
||||
if isinstance(cell, dict) \
|
||||
and isinstance(cell.get("value"), str):
|
||||
row[key] = cell["value"]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def __get_json(self, params: dict[str, str]) -> Any:
|
||||
"""Send a GET request to the API and return the JSON body.
|
||||
|
||||
:param params: The query parameters.
|
||||
:return: The parsed JSON body.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
:raises ValueError: On a JSON decoding error.
|
||||
"""
|
||||
url: str = f"{API_URL}?{urllib.parse.urlencode(params)}"
|
||||
request: urllib.request.Request = urllib.request.Request(
|
||||
url, headers={"User-Agent": USER_AGENT})
|
||||
return json.loads(self.__send(request))
|
||||
|
||||
def __send(self, request: urllib.request.Request,
|
||||
timeout: float = TIMEOUT) -> bytes:
|
||||
"""Send an HTTP request, retrying on a transient error.
|
||||
|
||||
Consecutive requests are separated by a fixed delay. A
|
||||
transient error -- a response with a retryable HTTP
|
||||
status, or a read timeout -- is retried with an
|
||||
increasing back-off, up to ``MAX_ATTEMPTS`` attempts in
|
||||
total.
|
||||
|
||||
:param request: The prepared HTTP request.
|
||||
:param timeout: The read timeout, in seconds.
|
||||
:return: The raw response body.
|
||||
:raises OSError: On a non-retryable HTTP or network
|
||||
error.
|
||||
:raises RetryExhausted: When the retries on a
|
||||
transient error are exhausted.
|
||||
"""
|
||||
if self.__sent > 0:
|
||||
time.sleep(SLEEP_SECONDS)
|
||||
self.__sent += 1
|
||||
attempt: int = 1
|
||||
reason: str
|
||||
cause: BaseException
|
||||
while True:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code not in RETRY_STATUSES:
|
||||
raise
|
||||
reason = str(error)
|
||||
cause = error
|
||||
except TimeoutError as error:
|
||||
reason = str(error) or "timed out"
|
||||
cause = error
|
||||
except urllib.error.URLError as error:
|
||||
if not isinstance(error.reason, TimeoutError):
|
||||
raise
|
||||
reason = str(error.reason) or "timed out"
|
||||
cause = error
|
||||
if attempt >= MAX_ATTEMPTS:
|
||||
raise RetryExhausted(
|
||||
f"retries exhausted ({reason})") from cause
|
||||
time.sleep(RETRY_SECONDS * attempt)
|
||||
attempt += 1
|
||||
|
||||
@staticmethod
|
||||
def __literals(texts: Sequence[str]) -> str:
|
||||
"""Build the SPARQL literals of texts at both languages.
|
||||
|
||||
:param texts: The texts to embed as string literals.
|
||||
:return: The literals, each text once tagged ``@en`` and
|
||||
once tagged ``@mul``, space-separated.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
text: str
|
||||
for text in texts:
|
||||
escaped: str = ArtistFetcher.__escape(text)
|
||||
parts.append(f'"{escaped}"@en')
|
||||
parts.append(f'"{escaped}"@mul')
|
||||
return " ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def __escape(text: str) -> str:
|
||||
"""Escape a text for embedding as a SPARQL string literal.
|
||||
|
||||
:param text: The text to embed.
|
||||
:return: The text with the backslashes and double quotes
|
||||
escaped.
|
||||
"""
|
||||
return text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
@staticmethod
|
||||
def __qid(uri: str) -> str:
|
||||
"""Extract the item ID from a Wikidata entity URI.
|
||||
|
||||
:param uri: The entity URI.
|
||||
:return: The item ID, the last path segment of the URI.
|
||||
"""
|
||||
return uri.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def read_snapshot_rows(file: TextIO) -> list[dict[str, str]]:
|
||||
"""Read the current rows of a snapshot CSV file handle.
|
||||
|
||||
:param file: The open, seekable snapshot CSV file.
|
||||
:return: The rows, keyed by the column name.
|
||||
:raises OSError: When the file cannot be read.
|
||||
"""
|
||||
file.seek(0)
|
||||
reader: csv.DictReader[str] = csv.DictReader(file)
|
||||
return list(reader)
|
||||
|
||||
|
||||
def read_artist_titles(session: Session,
|
||||
artist_id: int) -> list[str]:
|
||||
"""Read the charted song titles credited to an artist.
|
||||
|
||||
:param session: The database session.
|
||||
:param artist_id: The artist ID.
|
||||
:return: The song titles credited to the artist, ordered by
|
||||
the song ID, with the duplicate titles removed.
|
||||
"""
|
||||
titles: Sequence[str] = session.scalars(
|
||||
sa.select(Song.title)
|
||||
.join(SongArtist, SongArtist.song_id == Song.id)
|
||||
.where(SongArtist.artist_id == artist_id)
|
||||
.order_by(Song.id)).all()
|
||||
return list(dict.fromkeys(titles))
|
||||
|
||||
|
||||
def ensure_snapshot_header(file: TextIO) -> None:
|
||||
"""Write the snapshot CSV header row if the file is empty.
|
||||
|
||||
:param file: The open, seekable snapshot CSV file.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
file.seek(0, os.SEEK_END)
|
||||
if file.tell() == 0:
|
||||
csv.writer(file).writerow(SNAPSHOT_FIELDS)
|
||||
file.flush()
|
||||
|
||||
|
||||
def append_row(file: TextIO, snapshot: ArtistSnapshot) -> None:
|
||||
"""Append a snapshot row to a snapshot CSV file handle.
|
||||
|
||||
:param file: The open snapshot CSV file, opened for append.
|
||||
:param snapshot: The snapshot of an artist.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
csv.DictWriter(file, SNAPSHOT_FIELDS).writerow(
|
||||
snapshot.to_row())
|
||||
file.flush()
|
||||
|
||||
|
||||
def write_snapshot(file: TextIO) -> None:
|
||||
"""Rewrite a snapshot CSV file handle sorted by artist name.
|
||||
|
||||
Reads back the current rows and rewrites the header and the
|
||||
rows ordered by the case-folded artist name, matching the
|
||||
convention of the derived ``artists.csv``.
|
||||
|
||||
:param file: The open, seekable snapshot CSV file.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be read or written.
|
||||
"""
|
||||
ordered: list[dict[str, str]] = sorted(
|
||||
read_snapshot_rows(file),
|
||||
key=lambda row: row["name"].casefold())
|
||||
file.seek(0)
|
||||
file.truncate()
|
||||
writer: csv.DictWriter[str] = csv.DictWriter(
|
||||
file, SNAPSHOT_FIELDS)
|
||||
writer.writeheader()
|
||||
writer.writerows(ordered)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Fetch the artist metadata from Wikidata.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The exit status: 0 on success, misses and errors
|
||||
included, non-zero on a setup error.
|
||||
"""
|
||||
started: float = time.monotonic()
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
fetcher: ArtistFetcher = ArtistFetcher()
|
||||
fetched: int = 0
|
||||
not_found: int = 0
|
||||
errors: int = 0
|
||||
skipped: int = 0
|
||||
session: Session = ds.get_db()
|
||||
try:
|
||||
args.wikidata_csv.parent.mkdir(
|
||||
parents=True, exist_ok=True)
|
||||
with open(args.wikidata_csv, "a+", encoding="utf-8",
|
||||
newline="") as csv_file:
|
||||
done: set[str] = {x["name"] for x in
|
||||
read_snapshot_rows(csv_file)}
|
||||
ensure_snapshot_header(csv_file)
|
||||
artist: Artist
|
||||
for artist in session.scalars(
|
||||
sa.select(Artist).order_by(Artist.id)):
|
||||
if artist.name in done:
|
||||
skipped += 1
|
||||
continue
|
||||
titles: list[str] = read_artist_titles(
|
||||
session, artist.id)
|
||||
snapshot: ArtistSnapshot = fetcher.fetch(
|
||||
artist.name, titles)
|
||||
append_row(csv_file, snapshot)
|
||||
status: str = snapshot.qid
|
||||
if snapshot.note == NOTE_NOT_FOUND:
|
||||
not_found += 1
|
||||
status = "not found"
|
||||
elif snapshot.note.startswith("error: "):
|
||||
errors += 1
|
||||
status = snapshot.note
|
||||
else:
|
||||
fetched += 1
|
||||
print(f"artist \"{artist.name}\": {status}",
|
||||
file=sys.stderr)
|
||||
write_snapshot(csv_file)
|
||||
except (OSError, sa.exc.SQLAlchemyError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
session.close()
|
||||
attempted: int = fetched + not_found + errors
|
||||
elapsed: str = format_duration(time.monotonic() - started)
|
||||
print(f"Done. Resolved {fetched}/{attempted} artists."
|
||||
f" {elapsed} elapsed.",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
@@ -1,274 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""The fetcher of the missing song lyrics.
|
||||
|
||||
Fetches the lyrics of the songs without a cache file from the
|
||||
public lyrics APIs, Lyrics.ovh and LRCLIB, into the capture
|
||||
layer: the lyrics cache directory and the provenance CSV, each
|
||||
given as a positional command-line argument. The working store
|
||||
is only read, never written; the ``build-db`` subcommand
|
||||
assembles the captured files into the store on the next rebuild.
|
||||
|
||||
Each song is first queried with the name of its primary-role
|
||||
artist with the lowest position. When every API misses that
|
||||
query and the song's full artist credit differs from that
|
||||
artist name, the same APIs are queried again with the artist
|
||||
credit, to catch songs cataloged only under a joint credit such
|
||||
as "Dan + Shay".
|
||||
|
||||
A song that every API misses on both queries is reported on the
|
||||
standard error, but does not fail the run; misses are expected.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import ds
|
||||
from ..models import (
|
||||
Artist,
|
||||
Role,
|
||||
Song,
|
||||
SongArtist,
|
||||
)
|
||||
from ..utils import format_duration
|
||||
|
||||
PROVENANCE_FIELDS: Sequence[str] = (
|
||||
"song_id", "source", "method", "acquired_at", "note")
|
||||
"""The header columns of the lyrics provenance CSV file."""
|
||||
USER_AGENT: str = ("pop-fem-audit-tools"
|
||||
" (https://github.com/imacat/pop-fem-audit)")
|
||||
"""The User-Agent header sent on every HTTP request."""
|
||||
TIMEOUT: float = 30.0
|
||||
"""The timeout of an HTTP request, in seconds."""
|
||||
SLEEP_SECONDS: float = 1.0
|
||||
"""The delay between consecutive HTTP requests, in seconds."""
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"""Parse the command-line arguments.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The parsed arguments.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
description="Fetch the missing song lyrics from the"
|
||||
" public lyrics APIs into the capture layer.")
|
||||
parser.add_argument(
|
||||
"lyrics_dir", type=Path,
|
||||
help="the lyrics cache directory")
|
||||
parser.add_argument(
|
||||
"provenance_csv", type=Path,
|
||||
help="the lyrics provenance CSV file")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
class LyricsFetcher:
|
||||
"""A fetcher of song lyrics from the public lyrics APIs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Construct the fetcher."""
|
||||
self.__sent: int = 0
|
||||
"""The number of the HTTP requests already sent."""
|
||||
|
||||
def fetch(self, artist: str, title: str) \
|
||||
-> tuple[str, str] | None:
|
||||
"""Fetch the lyrics of a song.
|
||||
|
||||
The APIs are tried in order, Lyrics.ovh and then LRCLIB,
|
||||
stopping at the first hit. An HTTP or network error on
|
||||
an API counts as a miss on that API.
|
||||
|
||||
:param artist: The artist name to query with.
|
||||
:param title: The song title to query with.
|
||||
:return: The lyrics text and the source name, or None
|
||||
when every API misses.
|
||||
"""
|
||||
lyrics: str | None = self.__fetch_ovh(artist, title)
|
||||
if lyrics is not None:
|
||||
return lyrics, "lyrics.ovh"
|
||||
lyrics = self.__fetch_lrclib(artist, title)
|
||||
if lyrics is not None:
|
||||
return lyrics, "lrclib"
|
||||
return None
|
||||
|
||||
def __fetch_ovh(self, artist: str, title: str) -> str | None:
|
||||
"""Fetch the lyrics of a song from Lyrics.ovh.
|
||||
|
||||
:param artist: The artist name to query with.
|
||||
:param title: The song title to query with.
|
||||
:return: The lyrics text, or None on a miss.
|
||||
"""
|
||||
url: str = ("https://api.lyrics.ovh/v1/"
|
||||
f"{urllib.parse.quote(artist, safe='')}/"
|
||||
f"{urllib.parse.quote(title, safe='')}")
|
||||
data: Any = self.__get_json(url)
|
||||
if isinstance(data, dict) \
|
||||
and isinstance(data.get("lyrics"), str) \
|
||||
and data["lyrics"] != "":
|
||||
return data["lyrics"]
|
||||
return None
|
||||
|
||||
def __fetch_lrclib(self, artist: str, title: str) \
|
||||
-> str | None:
|
||||
"""Fetch the lyrics of a song from LRCLIB.
|
||||
|
||||
:param artist: The artist name to query with.
|
||||
:param title: The song title to query with.
|
||||
:return: The plain lyrics text, or None on a miss.
|
||||
"""
|
||||
query: str = urllib.parse.urlencode(
|
||||
{"artist_name": artist, "track_name": title})
|
||||
url: str = f"https://lrclib.net/api/get?{query}"
|
||||
data: Any = self.__get_json(url)
|
||||
if isinstance(data, dict) \
|
||||
and isinstance(data.get("plainLyrics"), str) \
|
||||
and data["plainLyrics"] != "":
|
||||
return data["plainLyrics"]
|
||||
return None
|
||||
|
||||
def __get_json(self, url: str) -> Any:
|
||||
"""Send a GET request and return the parsed JSON body.
|
||||
|
||||
Consecutive requests are separated by a fixed delay.
|
||||
|
||||
:param url: The URL to request.
|
||||
:return: The parsed JSON body, or None on any HTTP,
|
||||
network, or decoding error.
|
||||
"""
|
||||
if self.__sent > 0:
|
||||
time.sleep(SLEEP_SECONDS)
|
||||
self.__sent += 1
|
||||
request: urllib.request.Request = urllib.request.Request(
|
||||
url, headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=TIMEOUT) as response:
|
||||
return json.load(response)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def query_artist(session: Session, song_id: int) -> str:
|
||||
"""Find the artist name to query the APIs with.
|
||||
|
||||
:param session: The database session.
|
||||
:param song_id: The song ID.
|
||||
:return: The name of the primary-role artist with the lowest
|
||||
position.
|
||||
"""
|
||||
name: str | None = session.scalar(
|
||||
sa.select(Artist.name)
|
||||
.join(SongArtist, SongArtist.artist_id == Artist.id)
|
||||
.where(SongArtist.song_id == song_id,
|
||||
SongArtist.role == Role.PRIMARY)
|
||||
.order_by(SongArtist.position)
|
||||
.limit(1))
|
||||
assert name is not None
|
||||
return name
|
||||
|
||||
|
||||
def save_lyrics(lyrics_dir: Path, song_id: int,
|
||||
lyrics: str) -> None:
|
||||
"""Write the lyrics of a song into the cache directory.
|
||||
|
||||
The cache directory is created when missing.
|
||||
|
||||
:param lyrics_dir: The lyrics cache directory.
|
||||
:param song_id: The song ID.
|
||||
:param lyrics: The lyrics text.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
lyrics_dir.mkdir(parents=True, exist_ok=True)
|
||||
(lyrics_dir / f"{song_id}.txt").write_text(
|
||||
lyrics, encoding="utf-8")
|
||||
|
||||
|
||||
def append_provenance(path: Path, song_id: int,
|
||||
source: str) -> None:
|
||||
"""Append a provenance row for a fetched lyrics file.
|
||||
|
||||
The CSV file is created with the header row when missing.
|
||||
|
||||
:param path: The lyrics provenance CSV file.
|
||||
:param song_id: The song ID.
|
||||
:param source: The source name of the fetched lyrics.
|
||||
:return: None.
|
||||
:raises OSError: When the file cannot be written.
|
||||
"""
|
||||
is_new: bool = not path.exists()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8",
|
||||
newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
if is_new:
|
||||
writer.writerow(PROVENANCE_FIELDS)
|
||||
writer.writerow([song_id, source, "api-fetch",
|
||||
datetime.date.today().isoformat(), ""])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Fetch the missing song lyrics from the public APIs.
|
||||
|
||||
:param argv: The command-line arguments, or None for
|
||||
``sys.argv``.
|
||||
:return: The exit status: 0 on success, misses included,
|
||||
non-zero on a setup error.
|
||||
"""
|
||||
started: float = time.monotonic()
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
fetcher: LyricsFetcher = LyricsFetcher()
|
||||
fetched: int = 0
|
||||
missed: int = 0
|
||||
session: Session = ds.get_db()
|
||||
try:
|
||||
song: Song
|
||||
for song in session.scalars(
|
||||
sa.select(Song).order_by(Song.id)):
|
||||
if (args.lyrics_dir / f"{song.id}.txt").exists():
|
||||
continue
|
||||
artist: str = query_artist(session, song.id)
|
||||
result: tuple[str, str] | None = fetcher.fetch(
|
||||
artist, song.title)
|
||||
if result is None and song.artist_credit != artist:
|
||||
result = fetcher.fetch(
|
||||
song.artist_credit, song.title)
|
||||
if result is None:
|
||||
missed += 1
|
||||
print(f"song {song.id} \"{song.title}\": miss",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
lyrics: str
|
||||
source: str
|
||||
lyrics, source = result
|
||||
save_lyrics(args.lyrics_dir, song.id, lyrics)
|
||||
append_provenance(args.provenance_csv, song.id,
|
||||
source)
|
||||
fetched += 1
|
||||
print(f"song {song.id} \"{song.title}\": {source}",
|
||||
file=sys.stderr)
|
||||
except (OSError, sa.exc.SQLAlchemyError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
session.close()
|
||||
attempted: int = fetched + missed
|
||||
elapsed: str = format_duration(time.monotonic() - started)
|
||||
print(f"Done. Fetched lyrics for {fetched}/{attempted}"
|
||||
f" songs. {elapsed} elapsed.",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
@@ -1,509 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
"""The generic batch executor for one LLM analysis step.
|
||||
|
||||
One invocation is one definition file plus one input, sent to the
|
||||
Anthropic Messages Batch API exactly once, archived self-contained
|
||||
under the destination directory given by the three positional
|
||||
command-line arguments: prompt, input, archive_dir. The tool
|
||||
knows nothing about run counts or protocols: run identity --
|
||||
run1/run2, arbitration -- lives entirely in the caller's command
|
||||
list, per the research plan. A rerun of an already existing
|
||||
destination requires ``--replace``; any other directory is never
|
||||
touched.
|
||||
|
||||
Comparing runs and reconciling disagreements are the responsibility
|
||||
of separate subcommands, not this one.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
import anthropic
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
MODEL: str = "claude-sonnet-4-6"
|
||||
TEMPERATURE: float = 0.0
|
||||
THINKING: dict[str, str] = {"type": "disabled"}
|
||||
SCRIPT_VERSION: str = "run_llm.py 3.0.0"
|
||||
POLL_INTERVAL_SECONDS: float = 60.0
|
||||
|
||||
|
||||
class InputFormatError(Exception):
|
||||
"""An error in the JSONL input file."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputItem:
|
||||
"""An input item of the LLM step."""
|
||||
|
||||
id: str
|
||||
"""The item ID."""
|
||||
content: str
|
||||
"""The item content."""
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, data: Any, path: Path,
|
||||
number: int) -> Self:
|
||||
"""Validate one parsed JSONL record as an input item.
|
||||
|
||||
:param data: The parsed JSON value of the line.
|
||||
:param path: The path of the JSONL input file, for the
|
||||
messages.
|
||||
:param number: The line number, for the messages.
|
||||
:return: The validated input item.
|
||||
:raises InputFormatError: When the record is malformed.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise InputFormatError(
|
||||
f"{path}: line {number}: not a JSON object")
|
||||
if set(data.keys()) != {"id", "content"}:
|
||||
raise InputFormatError(
|
||||
f"{path}: line {number}: keys must be exactly"
|
||||
" \"id\" and \"content\"")
|
||||
if not isinstance(data["id"], str) or data["id"] == "":
|
||||
raise InputFormatError(
|
||||
f"{path}: line {number}: \"id\" must be a"
|
||||
" non-empty string")
|
||||
if not isinstance(data["content"], str):
|
||||
raise InputFormatError(
|
||||
f"{path}: line {number}: \"content\" must be a"
|
||||
" string")
|
||||
return cls(id=data["id"], content=data["content"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchResult:
|
||||
"""One batch result record."""
|
||||
|
||||
id: str
|
||||
"""The item ID."""
|
||||
text: str | None = None
|
||||
"""The output text of a succeeded result."""
|
||||
stop_reason: str | None = None
|
||||
"""The stop reason of a succeeded result."""
|
||||
usage: dict[str, Any] | None = None
|
||||
"""The token usage of a succeeded result."""
|
||||
error: str | None = None
|
||||
"""The error code of a failed result."""
|
||||
|
||||
@property
|
||||
def is_failure(self) -> bool:
|
||||
"""Whether this result is a failure.
|
||||
|
||||
:return: True when the result carries an error, or False
|
||||
when it succeeded.
|
||||
"""
|
||||
return self.error is not None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, entry: Any) -> Self:
|
||||
"""Create the result record of a batch result entry.
|
||||
|
||||
A succeeded entry yields the text, the stop reason, and
|
||||
the usage; any other entry yields the error code.
|
||||
|
||||
:param entry: The batch result entry.
|
||||
:return: The result record.
|
||||
"""
|
||||
result: Any = entry.result
|
||||
match result.type:
|
||||
case "succeeded":
|
||||
message: Any = result.message
|
||||
text: str = "".join(
|
||||
x.text for x in message.content
|
||||
if x.type == "text")
|
||||
return cls(id=entry.custom_id, text=text,
|
||||
stop_reason=message.stop_reason,
|
||||
usage=usage_to_dict(message.usage))
|
||||
case "errored":
|
||||
return cls(id=entry.custom_id,
|
||||
error=result.error.error.type)
|
||||
case other:
|
||||
return cls(id=entry.custom_id, error=str(other))
|
||||
|
||||
def to_record(self) -> dict[str, Any]:
|
||||
"""Return this result as an archive JSONL record.
|
||||
|
||||
:return: The record, with the None fields omitted.
|
||||
"""
|
||||
return {k: v for k, v in asdict(self).items()
|
||||
if v is not None}
|
||||
|
||||
|
||||
type Results = dict[str, BatchResult]
|
||||
"""The batch result records, keyed by item ID."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchInfo:
|
||||
"""The bookkeeping of one submitted message batch."""
|
||||
|
||||
batch_id: str
|
||||
"""The batch ID."""
|
||||
submitted_at: str
|
||||
"""The submission time, in ISO 8601 format."""
|
||||
ended_at: str | None = None
|
||||
"""The end time, in ISO 8601 format, or None while the batch
|
||||
is still processing."""
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
"""Parse the command-line arguments.
|
||||
|
||||
:param argv: The command-line arguments, or None for ``sys.argv``.
|
||||
:return: The parsed arguments.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
description="Run one LLM definition file against one input"
|
||||
" and archive the result.")
|
||||
parser.add_argument(
|
||||
"prompt", type=Path,
|
||||
help="the prompt definition file, used as the system prompt")
|
||||
parser.add_argument(
|
||||
"input", type=Path,
|
||||
help="the JSONL input file with \"id\" and \"content\"")
|
||||
parser.add_argument(
|
||||
"archive_dir", type=Path,
|
||||
help="the destination archive directory")
|
||||
parser.add_argument(
|
||||
"--max-tokens", type=int, default=2048,
|
||||
help="the maximum output tokens per request (default 2048)")
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="validate and archive without calling the API")
|
||||
parser.add_argument(
|
||||
"--replace", action="store_true",
|
||||
help="replace an already existing archive directory")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def load_items(path: Path) -> list[InputItem]:
|
||||
"""Load and validate the JSONL input items.
|
||||
|
||||
:param path: The path of the JSONL input file.
|
||||
:return: The input items, in file order.
|
||||
:raises InputFormatError: When a line is malformed, an ID is
|
||||
duplicated, or the file contains no item.
|
||||
:raises OSError: When the file cannot be read.
|
||||
"""
|
||||
items: list[InputItem] = []
|
||||
seen: set[str] = set()
|
||||
with open(path, encoding="utf-8") as file:
|
||||
for number, line in enumerate(file, start=1):
|
||||
if line.strip() == "":
|
||||
continue
|
||||
try:
|
||||
data: Any = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
raise InputFormatError(
|
||||
f"{path}: line {number}: malformed JSON: {error}")
|
||||
item: InputItem = InputItem.get_instance(
|
||||
data, path, number)
|
||||
if item.id in seen:
|
||||
raise InputFormatError(
|
||||
f"{path}: line {number}: duplicated ID"
|
||||
f" \"{item.id}\"")
|
||||
seen.add(item.id)
|
||||
items.append(item)
|
||||
if len(items) == 0:
|
||||
raise InputFormatError(f"{path}: no input items")
|
||||
return items
|
||||
|
||||
|
||||
def build_request(item: InputItem, system_prompt: str,
|
||||
max_tokens: int) -> dict[str, Any]:
|
||||
"""Build one Message Batches request for an input item.
|
||||
|
||||
:param item: The input item.
|
||||
:param system_prompt: The system prompt text.
|
||||
:param max_tokens: The maximum output tokens.
|
||||
:return: The batch request with "custom_id" and "params".
|
||||
"""
|
||||
return {
|
||||
"custom_id": item.id,
|
||||
"params": {
|
||||
"model": MODEL,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": TEMPERATURE,
|
||||
"thinking": THINKING,
|
||||
"system": system_prompt,
|
||||
"messages": [
|
||||
{"role": "user", "content": item.content},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def submit_batch(client: anthropic.Anthropic,
|
||||
requests: list[dict[str, Any]]) -> str:
|
||||
"""Submit one message batch.
|
||||
|
||||
:param client: The Anthropic client.
|
||||
:param requests: The batch requests.
|
||||
:return: The batch ID.
|
||||
"""
|
||||
return client.messages.batches.create(requests=requests).id
|
||||
|
||||
|
||||
def poll_batches(client: anthropic.Anthropic,
|
||||
batch_ids: list[str]) -> dict[str, Any]:
|
||||
"""Poll the batches until every one of them has ended.
|
||||
|
||||
Progress is printed to the standard error every poll.
|
||||
|
||||
:param client: The Anthropic client.
|
||||
:param batch_ids: The batch IDs to poll.
|
||||
:return: The final batch object of each batch, keyed by batch ID.
|
||||
"""
|
||||
while True:
|
||||
batches: dict[str, Any] = {
|
||||
x: client.messages.batches.retrieve(x) for x in batch_ids}
|
||||
pending: list[str] = [
|
||||
x for x in batch_ids
|
||||
if batches[x].processing_status != "ended"]
|
||||
for batch_id in batch_ids:
|
||||
status: str = batches[batch_id].processing_status
|
||||
print(f"batch {batch_id}: {status}", file=sys.stderr)
|
||||
if len(pending) == 0:
|
||||
return batches
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def usage_to_dict(usage: Any) -> dict[str, Any]:
|
||||
"""Convert a usage object to a plain dictionary.
|
||||
|
||||
:param usage: The usage object of a message.
|
||||
:return: The usage as a dictionary, without null entries.
|
||||
"""
|
||||
return {k: v for k, v in usage.model_dump().items()
|
||||
if v is not None}
|
||||
|
||||
|
||||
def sum_usage(results: Results) -> dict[str, int]:
|
||||
"""Sum the token usage of every succeeded result.
|
||||
|
||||
:param results: The result records, keyed by item ID.
|
||||
:return: The summed integer usage fields.
|
||||
"""
|
||||
totals: dict[str, int] = {}
|
||||
for result in results.values():
|
||||
if result.usage is None:
|
||||
continue
|
||||
for key, value in result.usage.items():
|
||||
if isinstance(value, int):
|
||||
totals[key] = totals.get(key, 0) + value
|
||||
return totals
|
||||
|
||||
|
||||
def collect_results(client: anthropic.Anthropic,
|
||||
batch_id: str) -> Results:
|
||||
"""Collect the results of an ended batch.
|
||||
|
||||
:param client: The Anthropic client.
|
||||
:param batch_id: The batch ID.
|
||||
:return: The result records, keyed by custom ID.
|
||||
"""
|
||||
results: Results = {}
|
||||
for entry in client.messages.batches.results(batch_id):
|
||||
results[entry.custom_id] = BatchResult.get_instance(entry)
|
||||
return results
|
||||
|
||||
|
||||
def find_failures(item_ids: list[str],
|
||||
results: Results) -> list[str]:
|
||||
"""Find the item IDs that failed in a result set.
|
||||
|
||||
An item failed when it is missing from the results or when its
|
||||
record is a failure.
|
||||
|
||||
:param item_ids: The item IDs to check, in order.
|
||||
:param results: The result records, keyed by item ID.
|
||||
:return: The failed item IDs, in the given order.
|
||||
"""
|
||||
return [x for x in item_ids
|
||||
if x not in results or results[x].is_failure]
|
||||
|
||||
|
||||
def create_archive_dir(directory: Path, replace: bool) -> Path:
|
||||
"""Create the archive directory.
|
||||
|
||||
Only this directory is ever created or removed; no other
|
||||
directory is ever touched.
|
||||
|
||||
:param directory: The destination archive directory.
|
||||
:param replace: Whether to remove an already existing archive
|
||||
directory before creating it.
|
||||
:return: The created archive directory.
|
||||
:raises FileExistsError: When the archive directory already
|
||||
exists and ``replace`` is False.
|
||||
"""
|
||||
if directory.exists():
|
||||
if not replace:
|
||||
raise FileExistsError(
|
||||
f"{directory} already exists; pass --replace to"
|
||||
" replace it")
|
||||
shutil.rmtree(directory)
|
||||
directory.mkdir(parents=True)
|
||||
return directory
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
|
||||
"""Write records to a file as JSON Lines.
|
||||
|
||||
:param path: The path of the file to write.
|
||||
:param records: The records, one per line.
|
||||
:return: None.
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8") as file:
|
||||
for record in records:
|
||||
file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
"""Write data to a file as pretty-printed JSON.
|
||||
|
||||
:param path: The path of the file to write.
|
||||
:param data: The data to write.
|
||||
:return: None.
|
||||
"""
|
||||
path.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
def write_meta(path: Path, meta: dict[str, Any]) -> None:
|
||||
"""Write the metadata to the ``meta.json`` file.
|
||||
|
||||
The ``BatchInfo`` value under ``batch`` is written as a plain
|
||||
JSON object.
|
||||
|
||||
:param path: The path of the ``meta.json`` file.
|
||||
:param meta: The metadata to write.
|
||||
:return: None.
|
||||
"""
|
||||
write_json(path, {**meta, "batch": asdict(meta["batch"])})
|
||||
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
"""Calculate the SHA-256 digest of a file.
|
||||
|
||||
:param path: The path of the file.
|
||||
:return: The hexadecimal SHA-256 digest.
|
||||
"""
|
||||
with open(path, "rb") as file:
|
||||
return hashlib.file_digest(file, "sha256").hexdigest()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""Return the current local time in ISO 8601 format.
|
||||
|
||||
:return: The current local time with the timezone offset.
|
||||
"""
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def execute_run(
|
||||
client: anthropic.Anthropic, items: list[InputItem],
|
||||
system_prompt: str, max_tokens: int,
|
||||
meta: dict[str, Any],
|
||||
) -> Results:
|
||||
"""Submit the batch of this run and await its results.
|
||||
|
||||
The batch ID and timestamps are recorded into the metadata as an
|
||||
observable side effect.
|
||||
|
||||
:param client: The Anthropic client.
|
||||
:param items: The input items.
|
||||
:param system_prompt: The system prompt text.
|
||||
:param max_tokens: The maximum output tokens per request.
|
||||
:param meta: The metadata to record the batch bookkeeping into.
|
||||
:return: The results of this run, keyed by item ID.
|
||||
"""
|
||||
requests: list[dict[str, Any]] = [
|
||||
build_request(x, system_prompt, max_tokens) for x in items]
|
||||
info: BatchInfo = BatchInfo(
|
||||
batch_id=submit_batch(client, requests),
|
||||
submitted_at=now_iso())
|
||||
meta["batch"] = info
|
||||
print(f"submitted batch {info.batch_id}", file=sys.stderr)
|
||||
batches: dict[str, Any] = poll_batches(client, [info.batch_id])
|
||||
info.ended_at = batches[info.batch_id].ended_at.isoformat()
|
||||
return collect_results(client, info.batch_id)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run one LLM definition file against one input and archive it.
|
||||
|
||||
:param argv: The command-line arguments, or None for ``sys.argv``.
|
||||
:return: The exit status: 0 on success, non-zero on failure.
|
||||
"""
|
||||
args: argparse.Namespace = parse_args(argv)
|
||||
try:
|
||||
items: list[InputItem] = load_items(args.input)
|
||||
prompt_text: str = args.prompt.read_text(encoding="utf-8")
|
||||
except (OSError, InputFormatError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
archive_dir: Path = create_archive_dir(
|
||||
args.archive_dir, args.replace)
|
||||
except FileExistsError as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
meta_path: Path = archive_dir / "meta.json"
|
||||
(archive_dir / "prompt.md").write_bytes(args.prompt.read_bytes())
|
||||
meta: dict[str, Any] = {
|
||||
"script_version": SCRIPT_VERSION,
|
||||
"model": MODEL,
|
||||
"temperature": TEMPERATURE,
|
||||
"thinking": THINKING,
|
||||
"max_tokens": args.max_tokens,
|
||||
"prompt_path": str(args.prompt),
|
||||
"prompt_sha256": sha256_of(args.prompt),
|
||||
"input_path": str(args.input),
|
||||
"input_sha256": sha256_of(args.input),
|
||||
"item_count": len(items),
|
||||
"dry_run": args.dry_run,
|
||||
"started_at": now_iso(),
|
||||
"batch": None,
|
||||
"usage": {},
|
||||
}
|
||||
if args.dry_run:
|
||||
write_json(meta_path, meta)
|
||||
print(json.dumps(
|
||||
build_request(items[0], prompt_text, args.max_tokens),
|
||||
ensure_ascii=False, indent=2))
|
||||
print(f"dry run: archive created at {archive_dir}",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
client: anthropic.Anthropic = anthropic.Anthropic(
|
||||
api_key=get_settings().ANTHROPIC_API_KEY)
|
||||
results: Results = execute_run(
|
||||
client, items, prompt_text, args.max_tokens, meta)
|
||||
item_ids: list[str] = [x.id for x in items]
|
||||
write_jsonl(
|
||||
archive_dir / "output.jsonl",
|
||||
[results[x].to_record() for x in item_ids if x in results])
|
||||
meta["usage"] = sum_usage(results)
|
||||
write_meta(meta_path, meta)
|
||||
failed: list[str] = find_failures(item_ids, results)
|
||||
if len(failed) > 0:
|
||||
print(f"error: failed items: {', '.join(failed)}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print(f"done: {len(items)} items;"
|
||||
f" archived to {archive_dir}", file=sys.stderr)
|
||||
return 0
|
||||
@@ -1,48 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""The 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
|
||||
@@ -1,106 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""The 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."""
|
||||
@@ -1,114 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""The 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."""
|
||||
@@ -1,24 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
||||
"""The shared utilities of the package."""
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""Format an elapsed duration for the closing summary line.
|
||||
|
||||
:param seconds: The elapsed duration, in seconds.
|
||||
:return: The duration formatted ``mm:ss``, or ``h:mm:ss``
|
||||
once it reaches one hour.
|
||||
"""
|
||||
total: int = round(seconds)
|
||||
hours: int
|
||||
remainder: int
|
||||
hours, remainder = divmod(total, 3600)
|
||||
minutes: int
|
||||
secs: int
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
@@ -1,823 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""Unit tests for the working store builder module."""
|
||||
import csv
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from pop_fem_audit_tools import config
|
||||
from pop_fem_audit_tools.commands import build_db
|
||||
from pop_fem_audit_tools.database import DataSource
|
||||
from pop_fem_audit_tools.models import (
|
||||
Artist,
|
||||
ChartEntry,
|
||||
Role,
|
||||
Song,
|
||||
)
|
||||
|
||||
|
||||
class TestParseArtistCredit(unittest.TestCase):
|
||||
"""Test cases for the artist credit parser."""
|
||||
|
||||
def test_plain_solo(self) -> None:
|
||||
"""Test a plain solo artist credit."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit("Adele"),
|
||||
[("Adele", Role.PRIMARY)])
|
||||
|
||||
def test_featuring_with_and(self) -> None:
|
||||
"""Test a featuring credit with an "and" delimiter."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Drake featuring Wizkid and Kyla"),
|
||||
[("Drake", Role.PRIMARY),
|
||||
("Wizkid", Role.FEATURED),
|
||||
("Kyla", Role.FEATURED)])
|
||||
|
||||
def test_comma_and_ampersand(self) -> None:
|
||||
"""Test a credit with comma and ampersand delimiters."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Lady Gaga, Bradley Cooper & BloodPop"),
|
||||
[("Lady Gaga", Role.PRIMARY),
|
||||
("Bradley Cooper", Role.PRIMARY),
|
||||
("BloodPop", Role.PRIMARY)])
|
||||
|
||||
def test_x_delimiter(self) -> None:
|
||||
"""Test the "x" delimiter."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"KAROL G x Nicki Minaj"),
|
||||
[("KAROL G", Role.PRIMARY),
|
||||
("Nicki Minaj", Role.PRIMARY)])
|
||||
|
||||
def test_plus_delimiter(self) -> None:
|
||||
"""Test the "+" delimiter."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Marshmello + Halsey"),
|
||||
[("Marshmello", Role.PRIMARY),
|
||||
("Halsey", Role.PRIMARY)])
|
||||
|
||||
def test_with_delimiter(self) -> None:
|
||||
"""Test the "with" delimiter."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Kane Brown with Lauren Alaina"),
|
||||
[("Kane Brown", Role.PRIMARY),
|
||||
("Lauren Alaina", Role.PRIMARY)])
|
||||
|
||||
def test_feat_abbreviation(self) -> None:
|
||||
"""Test that "Feat." splits the featured side."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Ariana Grande Feat. Doja Cat"
|
||||
" & Megan Thee Stallion"),
|
||||
[("Ariana Grande", Role.PRIMARY),
|
||||
("Doja Cat", Role.FEATURED),
|
||||
("Megan Thee Stallion", Role.FEATURED)])
|
||||
|
||||
def test_case_insensitive_featuring(self) -> None:
|
||||
"""Test that "Featuring" splits case-insensitively."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"24kGoldn Featuring iann dior"),
|
||||
[("24kGoldn", Role.PRIMARY),
|
||||
("iann dior", Role.FEATURED)])
|
||||
|
||||
def test_colon_prefix_group(self) -> None:
|
||||
"""Test that a colon-prefixed group name is dropped."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"¥$: Ye & Ty Dolla $ign Featuring Rich The Kid"
|
||||
" & Playboi Carti"),
|
||||
[("Ye", Role.PRIMARY),
|
||||
("Ty Dolla $ign", Role.PRIMARY),
|
||||
("Rich The Kid", Role.FEATURED),
|
||||
("Playboi Carti", Role.FEATURED)])
|
||||
|
||||
def test_colon_prefix_with_ampersand_in_prefix(self) -> None:
|
||||
"""Test a colon prefix that itself contains "&"."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Rumi & JINU: EJAE & Andrew Choi"),
|
||||
[("EJAE", Role.PRIMARY),
|
||||
("Andrew Choi", Role.PRIMARY)])
|
||||
|
||||
def test_colon_prefix_comma_list(self) -> None:
|
||||
"""Test a colon-prefixed comma-separated member list."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"HUNTR/X: EJAE, Audrey Nuna & REI AMI"),
|
||||
[("EJAE", Role.PRIMARY),
|
||||
("Audrey Nuna", Role.PRIMARY),
|
||||
("REI AMI", Role.PRIMARY)])
|
||||
|
||||
def test_colon_prefix_five_members(self) -> None:
|
||||
"""Test a colon-prefixed five-member list."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Saja Boys: Andrew Choi, Neckwav, Danny Chung,"
|
||||
" Kevin Woo & samUIL Lee"),
|
||||
[("Andrew Choi", Role.PRIMARY),
|
||||
("Neckwav", Role.PRIMARY),
|
||||
("Danny Chung", Role.PRIMARY),
|
||||
("Kevin Woo", Role.PRIMARY),
|
||||
("samUIL Lee", Role.PRIMARY)])
|
||||
|
||||
def test_colon_prefix_duo(self) -> None:
|
||||
"""Test a colon-prefixed two-member list."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"THE ANXIETY: WILLOW & Tyler Cole"),
|
||||
[("WILLOW", Role.PRIMARY),
|
||||
("Tyler Cole", Role.PRIMARY)])
|
||||
|
||||
def test_parenthesized_members(self) -> None:
|
||||
"""Test that a parenthesized member list replaces the
|
||||
group name spanning the whole credit."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Silk Sonic (Bruno Mars & Anderson .Paak)"),
|
||||
[("Bruno Mars", Role.PRIMARY),
|
||||
("Anderson .Paak", Role.PRIMARY)])
|
||||
|
||||
def test_duet_with_connector(self) -> None:
|
||||
"""Test that "Duet With" is a co-billing connector like
|
||||
"with", dropping the word "Duet" entirely."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Blake Shelton Duet With Gwen Stefani"),
|
||||
[("Blake Shelton", Role.PRIMARY),
|
||||
("Gwen Stefani", Role.PRIMARY)])
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Keith Urban Duet With P!nk"),
|
||||
[("Keith Urban", Role.PRIMARY),
|
||||
("P!nk", Role.PRIMARY)])
|
||||
|
||||
def test_slash_delimiter(self) -> None:
|
||||
"""Test the " / " co-billing delimiter."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Cole Swindell / Lainey Wilson"),
|
||||
[("Cole Swindell", Role.PRIMARY),
|
||||
("Lainey Wilson", Role.PRIMARY)])
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Zayn / Taylor Swift"),
|
||||
[("Zayn", Role.PRIMARY),
|
||||
("Taylor Swift", Role.PRIMARY)])
|
||||
|
||||
def test_protected_name_lil_nas_x(self) -> None:
|
||||
"""Test that "Lil Nas X" is guarded from the " x "
|
||||
delimiter split."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Lil Nas X & Jack Harlow"),
|
||||
[("Lil Nas X", Role.PRIMARY),
|
||||
("Jack Harlow", Role.PRIMARY)])
|
||||
|
||||
def test_protected_name_tyler_the_creator(self) -> None:
|
||||
"""Test that "Tyler, The Creator" is guarded from the
|
||||
comma delimiter split."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Tyler, The Creator Featuring GloRilla, Sexyy Red"
|
||||
" & Lil Wayne"),
|
||||
[("Tyler, The Creator", Role.PRIMARY),
|
||||
("GloRilla", Role.FEATURED),
|
||||
("Sexyy Red", Role.FEATURED),
|
||||
("Lil Wayne", Role.FEATURED)])
|
||||
|
||||
def test_protected_name_tones_and_i(self) -> None:
|
||||
"""Test that "Tones And I" is guarded from the " and "
|
||||
delimiter split."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Tones And I"),
|
||||
[("Tones And I", Role.PRIMARY)])
|
||||
|
||||
def test_exception_spotemgottem(self) -> None:
|
||||
"""Test the SpotemGottem exception-table credit."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"SpotemGottem Featuring Pooh Shiesty Or DaBaby"),
|
||||
[("SpotemGottem", Role.PRIMARY),
|
||||
("Pooh Shiesty", Role.FEATURED),
|
||||
("DaBaby", Role.FEATURED)])
|
||||
|
||||
def test_exception_the_scotts(self) -> None:
|
||||
"""Test the THE SCOTTS exception-table credit."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"THE SCOTTS, Travis Scott & Kid Cudi"),
|
||||
[("Travis Scott", Role.PRIMARY),
|
||||
("Kid Cudi", Role.PRIMARY)])
|
||||
|
||||
def test_exception_drake_featuring_the_throne(self) -> None:
|
||||
"""Test the Drake Featuring The Throne exception-table
|
||||
credit."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Drake Featuring The Throne"),
|
||||
[("Drake", Role.PRIMARY),
|
||||
("Jay Z", Role.FEATURED),
|
||||
("Kanye West", Role.FEATURED)])
|
||||
|
||||
def test_plus_delimiter_unaffected(self) -> None:
|
||||
"""Test that the existing "+" delimiter split is
|
||||
unaffected by the new rules."""
|
||||
self.assertEqual(
|
||||
build_db.ArtistImporter.parse_artist_credit(
|
||||
"Dan + Shay"),
|
||||
[("Dan", Role.PRIMARY), ("Shay", Role.PRIMARY)])
|
||||
|
||||
|
||||
class TestBuildDB(unittest.TestCase):
|
||||
"""Test cases for the working store build."""
|
||||
|
||||
CHART_CSV: str = (
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Hello,Adele\n"
|
||||
"2016,2,One Dance,Drake featuring Wizkid\n"
|
||||
"2017,1,One Dance,Drake featuring Wizkid\n"
|
||||
"2017,2,Shape of You,Ed Sheeran\n")
|
||||
"""The default chart CSV fixture: 2 years with 2 ranks each."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a temporary data directory with the fixtures."""
|
||||
tmp: tempfile.TemporaryDirectory[str] \
|
||||
= tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.__dir: Path = Path(tmp.name)
|
||||
self.__chart: Path = self.__dir / "chart.csv"
|
||||
self.__derived: Path = self.__dir / "derived"
|
||||
self.__lyrics: Path = self.__dir / "lyrics"
|
||||
self.__wikidata: Path = \
|
||||
self.__dir / "artists_wikidata.csv"
|
||||
self.__write_chart(self.CHART_CSV)
|
||||
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
||||
config.set_settings(config.Settings(
|
||||
SQLALCHEMY_DATABASE_URL=url,
|
||||
ANTHROPIC_API_KEY="test-key"))
|
||||
self.__ds: DataSource = DataSource()
|
||||
patchers: list[Any] = [
|
||||
mock.patch.object(build_db, "ds", self.__ds),
|
||||
mock.patch.object(
|
||||
build_db.SongImporter, "YEARS", [2016, 2017]),
|
||||
mock.patch.object(
|
||||
build_db.SongImporter, "RANKS_PER_YEAR", 2)]
|
||||
for patcher in patchers:
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def __write_chart(self, content: str) -> None:
|
||||
"""Write the chart CSV fixture.
|
||||
|
||||
:param content: The CSV content.
|
||||
:return: None.
|
||||
"""
|
||||
self.__chart.write_text(content, encoding="utf-8")
|
||||
|
||||
def __run_build(self, *options: str) -> tuple[int, str]:
|
||||
"""Run the build with the standard error captured.
|
||||
|
||||
:param options: The additional command-line options.
|
||||
:return: A tuple of the exit status and the standard
|
||||
error.
|
||||
"""
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
status: int = build_db.main(
|
||||
[str(self.__chart), str(self.__derived), *options])
|
||||
return status, stderr.getvalue()
|
||||
|
||||
def __read_csv_rows(self, name: str) -> list[list[str]]:
|
||||
"""Read the data rows of a derived CSV file.
|
||||
|
||||
:param name: The CSV file name under the derived directory.
|
||||
:return: The data rows, the header row excluded.
|
||||
"""
|
||||
with open(self.__derived / name, encoding="utf-8",
|
||||
newline="") as file:
|
||||
rows: list[list[str]] = list(csv.reader(file))
|
||||
return rows[1:]
|
||||
|
||||
def __read_csv_header(self, name: str) -> list[str]:
|
||||
"""Read the header row of a derived CSV file.
|
||||
|
||||
:param name: The CSV file name under the derived directory.
|
||||
:return: The header row.
|
||||
"""
|
||||
with open(self.__derived / name, encoding="utf-8",
|
||||
newline="") as file:
|
||||
return next(csv.reader(file))
|
||||
|
||||
@staticmethod
|
||||
def __split_joined_field(value: str,
|
||||
separator: str = "|") -> list[str]:
|
||||
"""Split a joined field into its entries.
|
||||
|
||||
:param value: The joined field value.
|
||||
:param separator: The join separator.
|
||||
:return: The entries, in the given order.
|
||||
"""
|
||||
return value.split(separator)
|
||||
|
||||
def __session(self) -> Session:
|
||||
"""Open a database session closed on test cleanup.
|
||||
|
||||
:return: The database session.
|
||||
"""
|
||||
session: Session = self.__ds.get_db()
|
||||
self.addCleanup(session.close)
|
||||
return session
|
||||
|
||||
def __song_titles(self) -> dict[int, str]:
|
||||
"""Read the song titles keyed by their IDs.
|
||||
|
||||
:return: The song titles, keyed by the song IDs.
|
||||
"""
|
||||
session: Session = self.__session()
|
||||
return {x.id: x.title
|
||||
for x in session.scalars(sa.select(Song))}
|
||||
|
||||
def test_dedup_repeated_song(self) -> None:
|
||||
"""Test that a song repeated across years is stored once."""
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(
|
||||
len(list(session.scalars(sa.select(Song)))), 3)
|
||||
song: Song | None = session.scalar(
|
||||
sa.select(Song).where(Song.title == "One Dance"))
|
||||
assert song is not None
|
||||
self.assertEqual(sorted((x.year, x.rank)
|
||||
for x in song.chart_entries),
|
||||
[(2016, 2), (2017, 1)])
|
||||
self.assertEqual([(x.artist.name, x.role, x.position)
|
||||
for x in song.song_artists],
|
||||
[("Drake", Role.PRIMARY, 0),
|
||||
("Wizkid", Role.FEATURED, 1)])
|
||||
self.assertIn("3 songs", stderr)
|
||||
self.assertIn("4 chart entries", stderr)
|
||||
self.assertIn("4 artists", stderr)
|
||||
self.assertIn("4 credits", stderr)
|
||||
|
||||
def test_dedup_credit_variant(self) -> None:
|
||||
"""Test that a credit variant listed in
|
||||
``CANONICAL_ARTIST_CREDITS`` merges into one song, storing
|
||||
the canonical credit, with the canonical artist spelling
|
||||
applied to the artist listed in ``CANONICAL_ARTIST_NAMES``."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Eastside,\"benny blanco, Halsey & Khalid\"\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,Eastside,\"Benny Blanco, Halsey & Khalid\"\n"
|
||||
"2017,2,filler,Filler Artist\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
songs: list[Song] = list(session.scalars(
|
||||
sa.select(Song).where(Song.title == "Eastside")))
|
||||
self.assertEqual(len(songs), 1)
|
||||
self.assertEqual(songs[0].artist_credit,
|
||||
"Benny Blanco, Halsey & Khalid")
|
||||
self.assertEqual(
|
||||
sorted((x.year, x.rank)
|
||||
for x in songs[0].chart_entries),
|
||||
[(2016, 1), (2017, 1)])
|
||||
self.assertEqual(
|
||||
[x.artist.name for x in songs[0].song_artists],
|
||||
["benny blanco", "Halsey", "Khalid"])
|
||||
|
||||
def test_dedup_artist_name_case_variant(self) -> None:
|
||||
"""Test that an artist name case variant merges into one
|
||||
artist, keeping the first-seen spelling."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Song One,Marshmello\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,Song Two,marshmello\n"
|
||||
"2017,2,filler,Filler Artist\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
artists: list[Artist] = list(session.scalars(
|
||||
sa.select(Artist).where(Artist.name.ilike("marsh%"))))
|
||||
self.assertEqual(len(artists), 1)
|
||||
self.assertEqual(artists[0].name, "Marshmello")
|
||||
self.assertEqual(
|
||||
{x.title for x in
|
||||
{y.song for y in artists[0].song_artists}},
|
||||
{"Song One", "Song Two"})
|
||||
|
||||
def test_canonical_artist_name_restored(self) -> None:
|
||||
"""Test that a canonical spelling is stored even for a
|
||||
single, non-canonically spelled occurrence."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Beggin',Maneskin\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,filler2,Filler Artist Two\n"
|
||||
"2017,2,filler3,Filler Artist Three\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
artist: Artist | None = session.scalar(
|
||||
sa.select(Artist).where(Artist.name.ilike("m%nesk%")))
|
||||
assert artist is not None
|
||||
self.assertEqual(artist.name, "Måneskin")
|
||||
|
||||
def test_canonical_ye_resolves_to_kanye_west(self) -> None:
|
||||
"""Test that a "Ye" credit and a "Kanye West" credit merge
|
||||
into a single artist row named "Kanye West", credited on
|
||||
both songs."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Song One,Ye\n"
|
||||
"2016,2,Song Two,Kanye West\n"
|
||||
"2017,1,filler2,Filler Artist Two\n"
|
||||
"2017,2,filler3,Filler Artist Three\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
artists: list[Artist] = list(session.scalars(
|
||||
sa.select(Artist).where(Artist.name == "Kanye West")))
|
||||
self.assertEqual(len(artists), 1)
|
||||
self.assertEqual(
|
||||
{x.title for x in
|
||||
{y.song for y in artists[0].song_artists}},
|
||||
{"Song One", "Song Two"})
|
||||
|
||||
def test_canonical_silento_restores_diacritic(self) -> None:
|
||||
"""Test that a "Silento" credit stores the diacritic-
|
||||
restored canonical spelling "Silentó"."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Watch Me,Silento\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,filler2,Filler Artist Two\n"
|
||||
"2017,2,filler3,Filler Artist Three\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
artist: Artist | None = session.scalar(
|
||||
sa.select(Artist).where(Artist.name.ilike("silent%")))
|
||||
assert artist is not None
|
||||
self.assertEqual(artist.name, "Silentó")
|
||||
|
||||
def test_canonical_dan_and_shay_full_names(self) -> None:
|
||||
"""Test that a "Dan + Shay" credit stores the canonical
|
||||
full names "Dan Smyers" and "Shay Mooney"."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Tequila,Dan + Shay\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,filler2,Filler Artist Two\n"
|
||||
"2017,2,filler3,Filler Artist Three\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
song: Song | None = session.scalar(
|
||||
sa.select(Song).where(Song.title == "Tequila"))
|
||||
assert song is not None
|
||||
self.assertEqual(
|
||||
[x.artist.name for x in song.song_artists],
|
||||
["Dan Smyers", "Shay Mooney"])
|
||||
|
||||
def test_canonical_surf_mesa_case_restored(self) -> None:
|
||||
"""Test that a "surf mesa" credit stores the canonical
|
||||
case styling "Surf Mesa"."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Ily,surf mesa\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,filler2,Filler Artist Two\n"
|
||||
"2017,2,filler3,Filler Artist Three\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
session: Session = self.__session()
|
||||
artist: Artist | None = session.scalar(
|
||||
sa.select(Artist).where(Artist.name.ilike("surf%")))
|
||||
assert artist is not None
|
||||
self.assertEqual(artist.name, "Surf Mesa")
|
||||
|
||||
def test_first_run_on_fresh_store(self) -> None:
|
||||
"""Test that a build on a fresh store creates the tables."""
|
||||
self.assertFalse((self.__dir / "store.sqlite3").exists())
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(
|
||||
len(list(session.scalars(sa.select(Song)))), 3)
|
||||
|
||||
def test_deterministic_ids(self) -> None:
|
||||
"""Test that two rebuilds assign the same song IDs."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
titles: dict[int, str] = self.__song_titles()
|
||||
self.assertEqual(titles, {1: "Hello", 2: "One Dance",
|
||||
3: "Shape of You"})
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertEqual(self.__song_titles(), titles)
|
||||
|
||||
def test_failed_build_keeps_previous(self) -> None:
|
||||
"""Test that a failed build keeps the previous contents."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
titles: dict[int, str] = self.__song_titles()
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Hello,Adele\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn("year 2016 rank 2", stderr)
|
||||
self.assertEqual(self.__song_titles(), titles)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(
|
||||
len(list(session.scalars(sa.select(ChartEntry)))), 4)
|
||||
|
||||
def test_missing_rank_fails(self) -> None:
|
||||
"""Test that a missing rank fails without partial data."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Hello,Adele\n"
|
||||
"2016,2,One Dance,Drake featuring Wizkid\n"
|
||||
"2017,1,One Dance,Drake featuring Wizkid\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn("year 2017 rank 2", stderr)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(
|
||||
list(session.scalars(sa.select(ChartEntry))), [])
|
||||
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||
|
||||
def test_duplicate_chart_entry_fails(self) -> None:
|
||||
"""Test that a duplicated (year, rank) row fails without
|
||||
partial data, even though the set of distinct (year, rank)
|
||||
pairs still covers the expected grid exactly."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Hello,Adele\n"
|
||||
"2016,1,Hello,Adele\n"
|
||||
"2016,2,One Dance,Drake featuring Wizkid\n"
|
||||
"2017,1,One Dance,Drake featuring Wizkid\n"
|
||||
"2017,2,Shape of You,Ed Sheeran\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn(
|
||||
"duplicated chart entry: year 2016 rank 1", stderr)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(
|
||||
list(session.scalars(sa.select(ChartEntry))), [])
|
||||
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||
|
||||
def test_blank_artist_name_fails(self) -> None:
|
||||
"""Test that a parsed credit with a name blank after
|
||||
stripping fails the build."""
|
||||
with mock.patch.object(
|
||||
build_db.ArtistImporter, "parse_artist_credit",
|
||||
return_value=[("Adele", Role.PRIMARY),
|
||||
(" ", Role.FEATURED)]):
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn("blank artist name parsed", stderr)
|
||||
|
||||
def test_no_primary_artist_fails(self) -> None:
|
||||
"""Test that a parsed credit without a primary artist
|
||||
fails the build."""
|
||||
with mock.patch.object(
|
||||
build_db.ArtistImporter, "parse_artist_credit",
|
||||
return_value=[("Wizkid", Role.FEATURED)]):
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn("no primary artist parsed", stderr)
|
||||
|
||||
def test_lyrics_loaded(self) -> None:
|
||||
"""Test loading the lyrics cache into the songs."""
|
||||
self.__lyrics.mkdir()
|
||||
(self.__lyrics / "1.txt").write_text(
|
||||
"Hello, it's me\n", encoding="utf-8")
|
||||
(self.__lyrics / "999.txt").write_text(
|
||||
"orphan\n", encoding="utf-8")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build(
|
||||
"--lyrics-dir", str(self.__lyrics))
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("999", stderr)
|
||||
self.assertIn("1 songs with lyrics", stderr)
|
||||
session: Session = self.__session()
|
||||
song: Song | None = session.get(Song, 1)
|
||||
assert song is not None
|
||||
self.assertEqual(song.title, "Hello")
|
||||
self.assertEqual(song.lyrics, "Hello, it's me\n")
|
||||
|
||||
def test_omitted_options_skip_capture_layers(self) -> None:
|
||||
"""Test that omitted options leave the layers unloaded."""
|
||||
self.__lyrics.mkdir()
|
||||
(self.__lyrics / "1.txt").write_text(
|
||||
"Hello, it's me\n", encoding="utf-8")
|
||||
self.__wikidata.write_text(
|
||||
"name,qid,gender,type,genre,country,note\n"
|
||||
"Adele,Q2831,female,solo,pop,GB,\n",
|
||||
encoding="utf-8")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("0 songs with lyrics", stderr)
|
||||
session: Session = self.__session()
|
||||
song: Song | None = session.get(Song, 1)
|
||||
assert song is not None
|
||||
self.assertIsNone(song.lyrics)
|
||||
artist: Artist | None = session.scalar(
|
||||
sa.select(Artist).where(Artist.name == "Adele"))
|
||||
assert artist is not None
|
||||
self.assertIsNone(artist.wikidata_qid)
|
||||
self.assertIsNone(artist.gender)
|
||||
|
||||
def test_missing_lyrics_dir_fails(self) -> None:
|
||||
"""Test that a given but missing lyrics directory fails."""
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build(
|
||||
"--lyrics-dir", str(self.__lyrics))
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn(f"error: {self.__lyrics}", stderr)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||
|
||||
def test_missing_wikidata_csv_fails(self) -> None:
|
||||
"""Test that a given but missing snapshot CSV fails."""
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build(
|
||||
"--wikidata-csv", str(self.__wikidata))
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn("error:", stderr)
|
||||
self.assertIn(str(self.__wikidata), stderr)
|
||||
session: Session = self.__session()
|
||||
self.assertEqual(list(session.scalars(sa.select(Song))), [])
|
||||
|
||||
REVIEW_CHART_CSV: str = (
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,banana,Artist B\n"
|
||||
"2016,2,\"Me, Myself & I\",Billie\n"
|
||||
"2017,1,\"Me, Myself & I\",Billie\n"
|
||||
"2017,2,Apple,Artist A\n")
|
||||
"""The chart CSV fixture exercising the review CSV ordering,
|
||||
positions, and the outer-field comma quoting."""
|
||||
|
||||
def test_derived_dir_created_when_missing(self) -> None:
|
||||
"""Test that the derived directory is created when
|
||||
missing."""
|
||||
self.assertFalse(self.__derived.exists())
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertTrue(self.__derived.is_dir())
|
||||
self.assertTrue((self.__derived / "songs.csv").is_file())
|
||||
self.assertTrue((self.__derived / "artists.csv").is_file())
|
||||
|
||||
def test_songs_csv_written(self) -> None:
|
||||
"""Test the songs.csv header, the rows, the title
|
||||
ordering, and the "/"-joined "positions" value."""
|
||||
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertEqual(
|
||||
self.__read_csv_header("songs.csv"),
|
||||
["Title", "Artists", "Positions"])
|
||||
rows: list[list[str]] = self.__read_csv_rows("songs.csv")
|
||||
self.assertEqual(
|
||||
[row[:2] for row in rows],
|
||||
[["Apple", "Artist A"],
|
||||
["banana", "Artist B"],
|
||||
["Me, Myself & I", "Billie"]])
|
||||
self.assertEqual(
|
||||
[self.__split_joined_field(row[2], "/") for row in rows],
|
||||
[["2017#2"], ["2016#1"], ["2016#2", "2017#1"]])
|
||||
|
||||
def test_songs_csv_uses_minimal_quoting(self) -> None:
|
||||
"""Test that songs.csv uses normal minimal CSV quoting: a
|
||||
numeric-looking title is written unquoted, like every other
|
||||
field not otherwise requiring quoting."""
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,679,Artist A\n"
|
||||
"2016,2,filler,Filler Artist\n"
|
||||
"2017,1,filler2,Filler Artist Two\n"
|
||||
"2017,2,filler3,Filler Artist Three\n")
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
content: str = (self.__derived / "songs.csv").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertIn("679,Artist A,2016#1", content)
|
||||
|
||||
def test_artists_csv_written(self) -> None:
|
||||
"""Test the artists.csv header, the rows, the name
|
||||
ordering, and the "|"-joined "TITLE (YEAR#RANK...)" song
|
||||
encoding."""
|
||||
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertEqual(
|
||||
self.__read_csv_header("artists.csv"),
|
||||
["Name", "Wikidata QID", "Gender", "Type", "Genre",
|
||||
"Country", "Songs"])
|
||||
rows: list[list[str]] = self.__read_csv_rows("artists.csv")
|
||||
self.assertEqual(
|
||||
[row[0] for row in rows],
|
||||
["Artist A", "Artist B", "Billie"])
|
||||
self.assertEqual(
|
||||
[self.__split_joined_field(row[6]) for row in rows],
|
||||
[["Apple (2017#2)"],
|
||||
["banana (2016#1)"],
|
||||
["Me, Myself & I (2016#2/2017#1)"]])
|
||||
|
||||
def test_artists_csv_songs_field_quoted_for_comma_title(
|
||||
self) -> None:
|
||||
"""Test that the outer "Songs" field is CSV-quoted as a
|
||||
whole when the joined value contains a comma, from a
|
||||
comma-bearing credited title."""
|
||||
self.__write_chart(self.REVIEW_CHART_CSV)
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
content: str = (self.__derived / "artists.csv").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertIn(
|
||||
'"Me, Myself & I (2016#2/2017#1)"', content)
|
||||
self.assertIn("Apple (2017#2)\n", content)
|
||||
|
||||
def test_derived_csvs_have_no_ids(self) -> None:
|
||||
"""Test that neither derived CSV file exposes a song or an
|
||||
artist ID column."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
self.assertNotIn(
|
||||
"id", self.__read_csv_header("songs.csv"))
|
||||
self.assertNotIn(
|
||||
"id", self.__read_csv_header("artists.csv"))
|
||||
|
||||
def test_derived_csvs_have_crlf_line_endings(self) -> None:
|
||||
"""Test that the derived CSV files use CRLF line
|
||||
endings."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
raw: bytes = (self.__derived / "songs.csv").read_bytes()
|
||||
self.assertGreater(raw.count(b"\r\n"), 0)
|
||||
self.assertEqual(raw.count(b"\r"), raw.count(b"\r\n"))
|
||||
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n"))
|
||||
|
||||
def test_failed_build_leaves_derived_csvs_untouched(self) -> None:
|
||||
"""Test that a failed build does not touch the existing
|
||||
derived CSV files."""
|
||||
self.assertEqual(self.__run_build()[0], 0)
|
||||
songs_before: str = (self.__derived / "songs.csv").read_text(
|
||||
encoding="utf-8")
|
||||
artists_before: str = \
|
||||
(self.__derived / "artists.csv").read_text(
|
||||
encoding="utf-8")
|
||||
self.__write_chart(
|
||||
"year,rank,title,artist\n"
|
||||
"2016,1,Hello,Adele\n")
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_build()
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertEqual(
|
||||
(self.__derived / "songs.csv").read_text(
|
||||
encoding="utf-8"),
|
||||
songs_before)
|
||||
self.assertEqual(
|
||||
(self.__derived / "artists.csv").read_text(
|
||||
encoding="utf-8"),
|
||||
artists_before)
|
||||
@@ -1,169 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
||||
"""Unit tests for the LLM input exporter module."""
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from pop_fem_audit_tools import config
|
||||
from pop_fem_audit_tools.commands import export_llm_input
|
||||
from pop_fem_audit_tools.database import Base, DataSource
|
||||
from pop_fem_audit_tools.models import (
|
||||
Artist,
|
||||
Role,
|
||||
Song,
|
||||
SongArtist,
|
||||
)
|
||||
|
||||
|
||||
class TestExportLlmInput(unittest.TestCase):
|
||||
"""Test cases for the LLM input exporter."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a temporary working store for the tests."""
|
||||
tmp: tempfile.TemporaryDirectory[str] \
|
||||
= tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.__dir: Path = Path(tmp.name)
|
||||
self.__output: Path = self.__dir / "llm-input.jsonl"
|
||||
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
||||
config.set_settings(config.Settings(
|
||||
SQLALCHEMY_DATABASE_URL=url,
|
||||
ANTHROPIC_API_KEY="test-key"))
|
||||
self.__ds: DataSource = DataSource()
|
||||
patcher: Any = mock.patch.object(
|
||||
export_llm_input, "ds", self.__ds)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def __seed(
|
||||
self, songs: list[tuple[str, str, str | None]]) -> None:
|
||||
"""Create the schema and the fixture songs.
|
||||
|
||||
Each song gets a single primary artist at position 0,
|
||||
whose name equals the song's artist credit; the song IDs
|
||||
are assigned in list order starting from 1.
|
||||
|
||||
:param songs: The (title, artist, lyrics) triples.
|
||||
:return: None.
|
||||
"""
|
||||
Base.metadata.create_all(self.__ds.engine)
|
||||
session: Session = self.__ds.get_db()
|
||||
try:
|
||||
title: str
|
||||
artist: str
|
||||
lyrics: str | None
|
||||
for title, artist, lyrics in songs:
|
||||
artist_row: Artist = Artist(name=artist)
|
||||
song: Song = Song(
|
||||
title=title, artist_credit=artist,
|
||||
lyrics=lyrics)
|
||||
session.add(song)
|
||||
session.add(SongArtist(
|
||||
song=song, artist=artist_row,
|
||||
role=Role.PRIMARY, position=0))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def __run_export(self) -> tuple[int, str]:
|
||||
"""Run the exporter with the standard error captured.
|
||||
|
||||
:return: A tuple of the exit status and the standard
|
||||
error.
|
||||
"""
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
status: int = export_llm_input.main(
|
||||
[str(self.__output)])
|
||||
return status, stderr.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def __read_records(path: Path) -> list[dict[str, str]]:
|
||||
"""Read the JSONL records of a file.
|
||||
|
||||
:param path: The JSONL file.
|
||||
:return: The parsed records, in file order.
|
||||
"""
|
||||
with open(path, encoding="utf-8") as file:
|
||||
return [json.loads(line) for line in file
|
||||
if line.strip() != ""]
|
||||
|
||||
def test_exports_songs_ordered_by_id(self) -> None:
|
||||
"""Test that the songs export in ID order, one per line."""
|
||||
self.__seed([
|
||||
("Hello", "Adele", "hello lyrics\n"),
|
||||
("Umbrella", "Rihanna", "umbrella lyrics\n")])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_export()
|
||||
self.assertEqual(status, 0)
|
||||
records: list[dict[str, str]] = self.__read_records(
|
||||
self.__output)
|
||||
self.assertEqual(records, [
|
||||
{"id": "song-1", "content": "hello lyrics\n"},
|
||||
{"id": "song-2", "content": "umbrella lyrics\n"}])
|
||||
self.assertIn("done: 2 songs exported", stderr)
|
||||
|
||||
def test_preserves_non_ascii_lyrics(self) -> None:
|
||||
"""Test that non-ASCII lyrics are written verbatim."""
|
||||
self.__seed([("Song", "Artist", "非英文歌詞\n")])
|
||||
self.assertEqual(self.__run_export()[0], 0)
|
||||
self.assertEqual(
|
||||
self.__output.read_text(encoding="utf-8"),
|
||||
json.dumps(
|
||||
{"id": "song-1", "content": "非英文歌詞\n"},
|
||||
ensure_ascii=False) + "\n")
|
||||
self.assertIn("非英文歌詞", self.__output.read_text(
|
||||
encoding="utf-8"))
|
||||
|
||||
def test_no_artist_or_title_data_in_output(self) -> None:
|
||||
"""Test the lyrics-only firewall: no title or artist
|
||||
leaks into the output."""
|
||||
self.__seed([(
|
||||
"Confidential Title", "Confidential Artist",
|
||||
"plain lyrics\n")])
|
||||
self.assertEqual(self.__run_export()[0], 0)
|
||||
content: str = self.__output.read_text(encoding="utf-8")
|
||||
self.assertNotIn("Confidential Title", content)
|
||||
self.assertNotIn("Confidential Artist", content)
|
||||
records: list[dict[str, str]] = self.__read_records(
|
||||
self.__output)
|
||||
self.assertEqual(records, [
|
||||
{"id": "song-1", "content": "plain lyrics\n"}])
|
||||
|
||||
def test_missing_lyrics_fails(self) -> None:
|
||||
"""Test that a song without lyrics fails without writing
|
||||
a partial file."""
|
||||
self.__seed([
|
||||
("Hello", "Adele", "hello lyrics\n"),
|
||||
("Silent", "Nobody", None)])
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_export()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn(
|
||||
"error: song 2 \"Silent\": no lyrics", stderr)
|
||||
self.assertFalse(self.__output.exists())
|
||||
|
||||
def test_creates_parent_directory(self) -> None:
|
||||
"""Test that the output file's parent directory is
|
||||
created when missing."""
|
||||
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
||||
nested: Path = self.__dir / "nested" / "dir" / "out.jsonl"
|
||||
status: int = export_llm_input.main([str(nested)])
|
||||
self.assertEqual(status, 0)
|
||||
self.assertTrue(nested.exists())
|
||||
records: list[dict[str, str]] = self.__read_records(
|
||||
nested)
|
||||
self.assertEqual(records, [
|
||||
{"id": "song-1", "content": "hello lyrics\n"}])
|
||||
@@ -1,605 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""Unit tests for the artist metadata fetcher module."""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from pop_fem_audit_tools import config
|
||||
from pop_fem_audit_tools.commands import fetch_artists
|
||||
from pop_fem_audit_tools.database import Base, DataSource
|
||||
from pop_fem_audit_tools.models import Artist, Role, Song, SongArtist
|
||||
|
||||
|
||||
class TestFetchArtists(unittest.TestCase):
|
||||
"""Test cases for the artist metadata fetcher."""
|
||||
|
||||
HEADER: list[str] = [
|
||||
"name", "qid", "gender", "type", "genre",
|
||||
"country", "note"]
|
||||
"""The expected header row of the snapshot CSV file."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a temporary capture directory with the store."""
|
||||
tmp: tempfile.TemporaryDirectory[str] \
|
||||
= tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.__dir: Path = Path(tmp.name)
|
||||
self.__snapshot: Path = \
|
||||
self.__dir / "artists_wikidata.csv"
|
||||
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
||||
config.set_settings(config.Settings(
|
||||
SQLALCHEMY_DATABASE_URL=url,
|
||||
ANTHROPIC_API_KEY="test-key"))
|
||||
self.__ds: DataSource = DataSource()
|
||||
patchers: list[Any] = [
|
||||
mock.patch.object(fetch_artists, "ds", self.__ds),
|
||||
mock.patch.object(fetch_artists, "SLEEP_SECONDS",
|
||||
0.0),
|
||||
mock.patch.object(fetch_artists, "RETRY_SECONDS",
|
||||
0.0)]
|
||||
for patcher in patchers:
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def __seed(self, names: list[str]) -> dict[str, int]:
|
||||
"""Create the schema and the fixture artists.
|
||||
|
||||
:param names: The artist names.
|
||||
:return: The created artist IDs, keyed by the name.
|
||||
"""
|
||||
Base.metadata.create_all(self.__ds.engine)
|
||||
session: Session = self.__ds.get_db()
|
||||
ids: dict[str, int] = {}
|
||||
try:
|
||||
name: str
|
||||
for name in names:
|
||||
artist: Artist = Artist(name=name)
|
||||
session.add(artist)
|
||||
session.flush()
|
||||
ids[name] = artist.id
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
return ids
|
||||
|
||||
def __seed_song(self, artist_id: int, title: str) -> None:
|
||||
"""Add a charted song credited to an artist.
|
||||
|
||||
:param artist_id: The artist ID.
|
||||
:param title: The song title.
|
||||
:return: None.
|
||||
"""
|
||||
session: Session = self.__ds.get_db()
|
||||
try:
|
||||
song: Song = Song(title=title, artist_credit=title)
|
||||
session.add(song)
|
||||
session.flush()
|
||||
session.add(SongArtist(
|
||||
song_id=song.id, artist_id=artist_id,
|
||||
role=Role.PRIMARY.value, position=0))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@staticmethod
|
||||
def __response(payload: dict[str, Any]) -> mock.MagicMock:
|
||||
"""Build a fake HTTP response with a JSON body.
|
||||
|
||||
:param payload: The JSON payload of the response body.
|
||||
:return: The fake response, usable as a context manager.
|
||||
"""
|
||||
response: mock.MagicMock = mock.MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value \
|
||||
= json.dumps(payload).encode("utf-8")
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def __http_error(status: int) -> urllib.error.HTTPError:
|
||||
"""Build an HTTP error.
|
||||
|
||||
:param status: The HTTP status code.
|
||||
:return: The HTTP error.
|
||||
"""
|
||||
return urllib.error.HTTPError(
|
||||
"https://example.com/", status, "Error", None, None)
|
||||
|
||||
@staticmethod
|
||||
def __claim(qid: str) -> dict[str, Any]:
|
||||
"""Build a claim statement with an item-ID target.
|
||||
|
||||
:param qid: The item ID of the statement target.
|
||||
:return: The claim statement.
|
||||
"""
|
||||
return {"mainsnak": {"snaktype": "value",
|
||||
"datavalue": {"value": {"id": qid}}}}
|
||||
|
||||
@staticmethod
|
||||
def __labels(labels: dict[str, str]) -> dict[str, Any]:
|
||||
"""Build a label query response payload.
|
||||
|
||||
:param labels: The English labels, keyed by the item ID.
|
||||
:return: The response payload.
|
||||
"""
|
||||
return {"entities": {
|
||||
x: {"labels": {"en": {"value": y}}}
|
||||
for x, y in labels.items()}}
|
||||
|
||||
@staticmethod
|
||||
def __claims(qid: str, claims: dict[str, Any],
|
||||
description: str) -> dict[str, Any]:
|
||||
"""Build a claims-and-description response payload.
|
||||
|
||||
:param qid: The item ID.
|
||||
:param claims: The claim statements, keyed by the
|
||||
property.
|
||||
:param description: The English description.
|
||||
:return: The response payload.
|
||||
"""
|
||||
return {"entities": {qid: {
|
||||
"claims": claims,
|
||||
"descriptions": {"en": {"value": description}}}}}
|
||||
|
||||
@staticmethod
|
||||
def __sparql(rows: list[dict[str, dict[str, str]]]) \
|
||||
-> dict[str, Any]:
|
||||
"""Build a SPARQL query response payload.
|
||||
|
||||
:param rows: The result bindings.
|
||||
:return: The response payload.
|
||||
"""
|
||||
return {"results": {"bindings": rows}}
|
||||
|
||||
@staticmethod
|
||||
def __uri(qid: str) -> dict[str, str]:
|
||||
"""Build a SPARQL URI binding cell for an item ID.
|
||||
|
||||
:param qid: The item ID.
|
||||
:return: The binding cell.
|
||||
"""
|
||||
return {"type": "uri",
|
||||
"value": f"http://www.wikidata.org/entity/{qid}"}
|
||||
|
||||
@staticmethod
|
||||
def __literal(value: str) -> dict[str, str]:
|
||||
"""Build a SPARQL literal binding cell.
|
||||
|
||||
:param value: The literal value.
|
||||
:return: The binding cell.
|
||||
"""
|
||||
return {"type": "literal", "value": value}
|
||||
|
||||
def __run_fetch(self) -> tuple[int, str]:
|
||||
"""Run the fetcher with the standard error captured.
|
||||
|
||||
:return: A tuple of the exit status and the standard
|
||||
error.
|
||||
"""
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
status: int = fetch_artists.main(
|
||||
[str(self.__snapshot)])
|
||||
return status, stderr.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def __read_rows(path: Path) -> list[list[str]]:
|
||||
"""Read the rows of a CSV file.
|
||||
|
||||
:param path: The CSV file.
|
||||
:return: The rows, the header included.
|
||||
"""
|
||||
with open(path, encoding="utf-8", newline="") as file:
|
||||
return list(csv.reader(file))
|
||||
|
||||
def __assert_summary(self, stderr: str, resolved: int,
|
||||
attempted: int) -> None:
|
||||
"""Assert the closing summary line has the expected shape.
|
||||
|
||||
:param stderr: The captured standard error.
|
||||
:param resolved: The expected count of resolved artists.
|
||||
:param attempted: The expected count of attempted
|
||||
artists.
|
||||
:return: None.
|
||||
"""
|
||||
self.assertRegex(
|
||||
stderr,
|
||||
rf"Done\. Resolved {resolved}/{attempted} artists\."
|
||||
r" \d{2}:\d{2} elapsed\.")
|
||||
|
||||
def test_unique_candidate_selected(self) -> None:
|
||||
"""Test a single candidate resolving the full metadata."""
|
||||
self.__seed(["Adele"])
|
||||
candidates: dict[str, Any] = self.__sparql(
|
||||
[{"item": self.__uri("Q1")}])
|
||||
claims: dict[str, Any] = self.__claims(
|
||||
"Q1", {
|
||||
"P21": [self.__claim("Q2")],
|
||||
"P31": [self.__claim("Q5")],
|
||||
"P136": [self.__claim("Q3"), self.__claim("Q4")],
|
||||
"P27": [self.__claim("Q6")]},
|
||||
"English singer")
|
||||
labels: dict[str, Any] = self.__labels({
|
||||
"Q2": "female", "Q5": "human", "Q3": "pop",
|
||||
"Q4": "soul music", "Q6": "United Kingdom"})
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(candidates),
|
||||
self.__response(claims),
|
||||
self.__response(labels)]) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 3)
|
||||
first: Any = urlopen.call_args_list[0][0][0]
|
||||
self.assertEqual(first.get_header("User-agent"),
|
||||
fetch_artists.USER_AGENT)
|
||||
self.assertTrue(
|
||||
first.full_url.startswith(fetch_artists.SPARQL_URL))
|
||||
self.assertEqual(
|
||||
first.get_header("Accept"),
|
||||
"application/sparql-results+json")
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0], self.HEADER)
|
||||
self.assertEqual(rows[1], [
|
||||
"Adele", "Q1", "female", "solo", "pop; soul music",
|
||||
"United Kingdom", "English singer"])
|
||||
self.__assert_summary(stderr, 1, 1)
|
||||
|
||||
def test_full_run_sorted_by_name(self) -> None:
|
||||
"""Test that a full run writes rows sorted by artist
|
||||
name, not by the artist ID order."""
|
||||
self.__seed(["Zed", "Amy", "Mia"])
|
||||
empty: dict[str, Any] = self.__sparql([])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(empty),
|
||||
self.__response(empty),
|
||||
self.__response(empty)]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 3)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(
|
||||
[x[0] for x in rows[1:]], ["Amy", "Mia", "Zed"])
|
||||
|
||||
def test_pinned_qid_skips_search(self) -> None:
|
||||
"""Test that a pinned name short-circuits the search."""
|
||||
self.__seed(["Pinkfong"])
|
||||
qid: str = fetch_artists.PINNED_QIDS["Pinkfong"]
|
||||
claims: dict[str, Any] = self.__claims(
|
||||
qid, {}, "South Korean children's brand")
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(claims)]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
first: Any = urlopen.call_args_list[0][0][0]
|
||||
self.assertTrue(first.full_url.startswith(
|
||||
fetch_artists.API_URL))
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1], [
|
||||
"Pinkfong", qid, "", "", "",
|
||||
"", "South Korean children's brand"])
|
||||
|
||||
def test_stage1_performer_intersection(self) -> None:
|
||||
"""Test the multi-candidate resolution via a performer."""
|
||||
ids: dict[str, int] = self.__seed(["Boyz"])
|
||||
self.__seed_song(ids["Boyz"], "Song A")
|
||||
self.__seed_song(ids["Boyz"], "Song B")
|
||||
candidates: dict[str, Any] = self.__sparql([
|
||||
{"item": self.__uri("Q1")},
|
||||
{"item": self.__uri("Q2")}])
|
||||
stage1: dict[str, Any] = self.__sparql(
|
||||
[{"performer": self.__uri("Q2")}])
|
||||
claims: dict[str, Any] = self.__claims(
|
||||
"Q2", {}, "A boy band")
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(candidates),
|
||||
self.__response(stage1),
|
||||
self.__response(claims)]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 3)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1], [
|
||||
"Boyz", "Q2", "", "", "", "", "A boy band"])
|
||||
|
||||
def test_stage1_member_hop(self) -> None:
|
||||
"""Test the multi-candidate resolution via a member."""
|
||||
ids: dict[str, int] = self.__seed(["Trio"])
|
||||
self.__seed_song(ids["Trio"], "Track")
|
||||
candidates: dict[str, Any] = self.__sparql([
|
||||
{"item": self.__uri("Q1")},
|
||||
{"item": self.__uri("Q2")}])
|
||||
stage1: dict[str, Any] = self.__sparql([{
|
||||
"performer": self.__uri("Q9"),
|
||||
"member": self.__uri("Q2")}])
|
||||
claims: dict[str, Any] = self.__claims(
|
||||
"Q2", {}, "A member of a group")
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(candidates),
|
||||
self.__response(stage1),
|
||||
self.__response(claims)]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 3)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1], [
|
||||
"Trio", "Q2", "", "", "", "", "A member of a group"])
|
||||
|
||||
def test_stage2_casefold_rescue(self) -> None:
|
||||
"""Test the case-insensitive stage-2 rescue."""
|
||||
ids: dict[str, int] = self.__seed(["Case"])
|
||||
self.__seed_song(ids["Case"], "Song One")
|
||||
candidates: dict[str, Any] = self.__sparql([
|
||||
{"item": self.__uri("Q1")},
|
||||
{"item": self.__uri("Q2")}])
|
||||
stage1: dict[str, Any] = self.__sparql([])
|
||||
stage2: dict[str, Any] = self.__sparql([{
|
||||
"cand": self.__uri("Q2"),
|
||||
"label": self.__literal("song one")}])
|
||||
claims: dict[str, Any] = self.__claims(
|
||||
"Q2", {}, "Rescued by casefold")
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(candidates),
|
||||
self.__response(stage1),
|
||||
self.__response(stage2),
|
||||
self.__response(claims)]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 4)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1], [
|
||||
"Case", "Q2", "", "", "", "", "Rescued by casefold"])
|
||||
|
||||
def test_unresolved_continues_to_next_artist(self) -> None:
|
||||
"""Test that an unresolved artist does not stop the run."""
|
||||
self.__seed(["Ambiguous", "Nobody"])
|
||||
candidates_ambiguous: dict[str, Any] = self.__sparql([
|
||||
{"item": self.__uri("Q1")},
|
||||
{"item": self.__uri("Q2")}])
|
||||
stage2_empty: dict[str, Any] = self.__sparql([])
|
||||
candidates_nobody: dict[str, Any] = self.__sparql([])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(candidates_ambiguous),
|
||||
self.__response(stage2_empty),
|
||||
self.__response(candidates_nobody)]
|
||||
) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 3)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[1], [
|
||||
"Ambiguous", "", "", "", "", "", "not found"])
|
||||
self.assertEqual(rows[2], [
|
||||
"Nobody", "", "", "", "", "", "not found"])
|
||||
self.__assert_summary(stderr, 0, 2)
|
||||
|
||||
def test_retry_429_then_success(self) -> None:
|
||||
"""Test a 429 retry followed by a successful request."""
|
||||
self.__seed(["Retry"])
|
||||
candidates: dict[str, Any] = self.__sparql([])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__http_error(429),
|
||||
self.__response(candidates)]
|
||||
) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1], [
|
||||
"Retry", "", "", "", "", "", "not found"])
|
||||
self.__assert_summary(stderr, 0, 1)
|
||||
|
||||
def test_timeout_then_success(self) -> None:
|
||||
"""Test a read timeout retried into a successful request."""
|
||||
self.__seed(["SlowQuery"])
|
||||
candidates: dict[str, Any] = self.__sparql([])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[TimeoutError("timed out"),
|
||||
self.__response(candidates)]
|
||||
) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1], [
|
||||
"SlowQuery", "", "", "", "", "", "not found"])
|
||||
self.__assert_summary(stderr, 0, 1)
|
||||
|
||||
def test_retry_exhausted_is_error(self) -> None:
|
||||
"""Test that exhausted retries yield an error row."""
|
||||
self.__seed(["Exhausted"])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__http_error(503)] * 5) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 5)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1][:2], ["Exhausted", ""])
|
||||
self.assertTrue(rows[1][6].startswith(
|
||||
"error: retries exhausted"))
|
||||
self.__assert_summary(stderr, 0, 1)
|
||||
|
||||
def test_non_retryable_error_continues(self) -> None:
|
||||
"""Test that a non-retryable HTTP error is noted as an
|
||||
error and does not stop the run."""
|
||||
self.__seed(["Broken", "Nobody"])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__http_error(404),
|
||||
self.__response(self.__sparql([]))]
|
||||
) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(rows[1][:2], ["Broken", ""])
|
||||
self.assertTrue(rows[1][6].startswith("error: "))
|
||||
self.assertEqual(rows[2], [
|
||||
"Nobody", "", "", "", "", "", "not found"])
|
||||
self.__assert_summary(stderr, 0, 2)
|
||||
|
||||
def test_rerun_skips_existing(self) -> None:
|
||||
"""Test that the snapshot rows are skipped and preserved."""
|
||||
self.__seed(["Adele", "Nobody"])
|
||||
snapshot: Path = self.__snapshot
|
||||
old_row: list[str] = [
|
||||
"Adele", "Q1", "female", "solo", "pop",
|
||||
"United Kingdom", "English singer"]
|
||||
with open(snapshot, "w", encoding="utf-8",
|
||||
newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
writer.writerow(self.HEADER)
|
||||
writer.writerow(old_row)
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(self.__sparql([]))]
|
||||
) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
rows: list[list[str]] = self.__read_rows(snapshot)
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0], self.HEADER)
|
||||
self.assertNotIn(self.HEADER, rows[1:])
|
||||
self.assertEqual(rows[1], old_row)
|
||||
self.assertEqual(rows[2], [
|
||||
"Nobody", "", "", "", "", "", "not found"])
|
||||
self.__assert_summary(stderr, 0, 1)
|
||||
|
||||
def test_topup_inserts_sorted_position(self) -> None:
|
||||
"""Test that a top-up inserts the new row in its sorted
|
||||
position, not appended at the end of the file."""
|
||||
self.__seed(["Amy", "Mia", "Zed"])
|
||||
snapshot: Path = self.__snapshot
|
||||
amy_row: list[str] = [
|
||||
"Amy", "Q1", "", "", "", "", "not found"]
|
||||
zed_row: list[str] = [
|
||||
"Zed", "Q2", "", "", "", "", "not found"]
|
||||
with open(snapshot, "w", encoding="utf-8",
|
||||
newline="") as file:
|
||||
writer: Any = csv.writer(file)
|
||||
writer.writerow(self.HEADER)
|
||||
writer.writerow(amy_row)
|
||||
writer.writerow(zed_row)
|
||||
empty: dict[str, Any] = self.__sparql([])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(empty)]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
rows: list[list[str]] = self.__read_rows(snapshot)
|
||||
self.assertEqual(
|
||||
[x[0] for x in rows[1:]], ["Amy", "Mia", "Zed"])
|
||||
self.assertEqual(rows[1], amy_row)
|
||||
self.assertEqual(rows[3], zed_row)
|
||||
|
||||
def test_crash_mid_run_leaves_partial_rows(self) -> None:
|
||||
"""Test that a crash mid-run leaves the already-fetched
|
||||
rows on disk for a later top-up run to resume from."""
|
||||
self.__seed(["Alpha", "Zulu"])
|
||||
empty: dict[str, Any] = self.__sparql([])
|
||||
urlopen: mock.Mock
|
||||
with (mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(empty)]) as urlopen,
|
||||
mock.patch.object(
|
||||
fetch_artists, "read_artist_titles",
|
||||
side_effect=[[], OSError("boom")])):
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__snapshot)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0], self.HEADER)
|
||||
self.assertEqual(rows[1], [
|
||||
"Alpha", "", "", "", "", "", "not found"])
|
||||
|
||||
def test_no_store_fails(self) -> None:
|
||||
"""Test that a missing working store fails the run."""
|
||||
urlopen: mock.Mock
|
||||
with mock.patch("urllib.request.urlopen") as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertNotEqual(status, 0)
|
||||
urlopen.assert_not_called()
|
||||
self.assertIn("error:", stderr)
|
||||
|
||||
def test_summary_line_exact_shape(self) -> None:
|
||||
"""Test the exact wording and timing of the summary
|
||||
line."""
|
||||
self.__seed(["Adele"])
|
||||
candidates: dict[str, Any] = self.__sparql([])
|
||||
with (mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(candidates)]),
|
||||
mock.patch(
|
||||
"time.monotonic",
|
||||
side_effect=[1000.0, 1125.0])):
|
||||
stderr: str = self.__run_fetch()[1]
|
||||
self.assertIn(
|
||||
"Done. Resolved 0/1 artists. 02:05 elapsed.",
|
||||
stderr)
|
||||
@@ -1,329 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
"""Unit tests for the lyrics fetcher module."""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from pop_fem_audit_tools import config
|
||||
from pop_fem_audit_tools.commands import fetch_lyrics
|
||||
from pop_fem_audit_tools.database import Base, DataSource
|
||||
from pop_fem_audit_tools.models import (
|
||||
Artist,
|
||||
Role,
|
||||
Song,
|
||||
SongArtist,
|
||||
)
|
||||
|
||||
|
||||
class TestFetchLyrics(unittest.TestCase):
|
||||
"""Test cases for the lyrics fetcher."""
|
||||
|
||||
PROVENANCE_HEADER: list[str] = [
|
||||
"song_id", "source", "method", "acquired_at", "note"]
|
||||
"""The expected header row of the provenance CSV file."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a temporary capture directory with the store."""
|
||||
tmp: tempfile.TemporaryDirectory[str] \
|
||||
= tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.__dir: Path = Path(tmp.name)
|
||||
self.__lyrics: Path = self.__dir / "lyrics"
|
||||
self.__provenance: Path = \
|
||||
self.__dir / "lyrics-provenance.csv"
|
||||
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
||||
config.set_settings(config.Settings(
|
||||
SQLALCHEMY_DATABASE_URL=url,
|
||||
ANTHROPIC_API_KEY="test-key"))
|
||||
self.__ds: DataSource = DataSource()
|
||||
patchers: list[Any] = [
|
||||
mock.patch.object(fetch_lyrics, "ds", self.__ds),
|
||||
mock.patch.object(fetch_lyrics, "SLEEP_SECONDS", 0.0)]
|
||||
for patcher in patchers:
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def __seed(self, songs: list[tuple[str, str]]) -> None:
|
||||
"""Create the schema and the fixture songs.
|
||||
|
||||
Each song gets a single primary artist at position 0;
|
||||
the song IDs are assigned in list order starting from 1.
|
||||
|
||||
:param songs: The (title, artist) pairs.
|
||||
:return: None.
|
||||
"""
|
||||
self.__seed_with_credit(
|
||||
[(title, artist, artist) for title, artist in songs])
|
||||
|
||||
def __seed_with_credit(
|
||||
self, songs: list[tuple[str, str, str]]) -> None:
|
||||
"""Create the schema and the fixture songs.
|
||||
|
||||
Each song gets a single primary artist at position 0 and
|
||||
an independently given artist credit; the song IDs are
|
||||
assigned in list order starting from 1.
|
||||
|
||||
:param songs: The (title, artist, artist_credit) triples.
|
||||
:return: None.
|
||||
"""
|
||||
Base.metadata.create_all(self.__ds.engine)
|
||||
session: Session = self.__ds.get_db()
|
||||
try:
|
||||
artists: dict[str, Artist] = {}
|
||||
title: str
|
||||
artist: str
|
||||
credit: str
|
||||
for title, artist, credit in songs:
|
||||
if artist not in artists:
|
||||
artists[artist] = Artist(name=artist)
|
||||
song: Song = Song(title=title,
|
||||
artist_credit=credit)
|
||||
session.add(song)
|
||||
session.add(SongArtist(song=song,
|
||||
artist=artists[artist],
|
||||
role=Role.PRIMARY,
|
||||
position=0))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@staticmethod
|
||||
def __response(payload: dict[str, Any]) -> mock.MagicMock:
|
||||
"""Build a fake HTTP response with a JSON body.
|
||||
|
||||
:param payload: The JSON payload of the response body.
|
||||
:return: The fake response, usable as a context manager.
|
||||
"""
|
||||
response: mock.MagicMock = mock.MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value \
|
||||
= json.dumps(payload).encode("utf-8")
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def __not_found() -> urllib.error.HTTPError:
|
||||
"""Build an HTTP 404 error.
|
||||
|
||||
:return: The HTTP 404 error.
|
||||
"""
|
||||
return urllib.error.HTTPError(
|
||||
"https://example.com/", 404, "Not Found", None, None)
|
||||
|
||||
def __run_fetch(self) -> tuple[int, str]:
|
||||
"""Run the fetcher with the standard error captured.
|
||||
|
||||
:return: A tuple of the exit status and the standard
|
||||
error.
|
||||
"""
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
status: int = fetch_lyrics.main(
|
||||
[str(self.__lyrics), str(self.__provenance)])
|
||||
return status, stderr.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def __read_rows(path: Path) -> list[list[str]]:
|
||||
"""Read the rows of a CSV file.
|
||||
|
||||
:param path: The CSV file.
|
||||
:return: The rows, the header included.
|
||||
"""
|
||||
with open(path, encoding="utf-8", newline="") as file:
|
||||
return list(csv.reader(file))
|
||||
|
||||
def test_ovh_hit(self) -> None:
|
||||
"""Test that a Lyrics.ovh hit writes the cache files."""
|
||||
self.__seed([("Hello", "Adele")])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__response(
|
||||
{"lyrics": "Hello, it's me\n"})]) as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
request: Any = urlopen.call_args[0][0]
|
||||
self.assertEqual(request.get_header("User-agent"),
|
||||
fetch_lyrics.USER_AGENT)
|
||||
self.assertEqual(
|
||||
(self.__lyrics / "1.txt")
|
||||
.read_text(encoding="utf-8"),
|
||||
"Hello, it's me\n")
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__provenance)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0], self.PROVENANCE_HEADER)
|
||||
self.assertEqual(rows[1][:3],
|
||||
["1", "lyrics.ovh", "api-fetch"])
|
||||
self.assertNotEqual(rows[1][3], "")
|
||||
self.assertEqual(rows[1][4], "")
|
||||
self.assertIn(
|
||||
"Done. Fetched lyrics for 1/1 songs.", stderr)
|
||||
|
||||
def test_lrclib_fallback(self) -> None:
|
||||
"""Test that an ovh miss falls back to an LRCLIB hit."""
|
||||
self.__seed([("Hello", "Adele")])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[
|
||||
self.__not_found(),
|
||||
self.__response({"plainLyrics": "Hello\n"})]
|
||||
) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
self.assertEqual(
|
||||
(self.__lyrics / "1.txt")
|
||||
.read_text(encoding="utf-8"),
|
||||
"Hello\n")
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__provenance)
|
||||
self.assertEqual(rows[1][:2], ["1", "lrclib"])
|
||||
|
||||
def test_credit_fallback_hit(self) -> None:
|
||||
"""Test that a joint credit is queried after both miss."""
|
||||
self.__seed_with_credit(
|
||||
[("Tequila", "Dan", "Dan + Shay")])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[
|
||||
self.__not_found(),
|
||||
self.__not_found(),
|
||||
self.__not_found(),
|
||||
self.__response(
|
||||
{"plainLyrics": "Tequila\n"})]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 4)
|
||||
urls: list[str] = [x[0][0].full_url
|
||||
for x in urlopen.call_args_list]
|
||||
self.assertIn("Dan%20%2B%20Shay", urls[2])
|
||||
self.assertIn("artist_name=Dan+%2B+Shay", urls[3])
|
||||
self.assertEqual(
|
||||
(self.__lyrics / "1.txt")
|
||||
.read_text(encoding="utf-8"),
|
||||
"Tequila\n")
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__provenance)
|
||||
self.assertEqual(rows[1][:2], ["1", "lrclib"])
|
||||
|
||||
def test_credit_fallback_skipped_when_same(self) -> None:
|
||||
"""Test that a matching credit skips the second round."""
|
||||
self.__seed([("Hello", "Adele")])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__not_found(),
|
||||
self.__not_found()]) as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
|
||||
def test_both_miss(self) -> None:
|
||||
"""Test that a double miss reports the song as missing."""
|
||||
self.__seed([("Hello", "Adele")])
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__not_found(),
|
||||
self.__not_found()]):
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertFalse((self.__lyrics / "1.txt").exists())
|
||||
self.assertFalse(self.__provenance.exists())
|
||||
self.assertIn("song 1 \"Hello\": miss", stderr)
|
||||
self.assertIn(
|
||||
"Done. Fetched lyrics for 0/1 songs.", stderr)
|
||||
|
||||
def test_cached_song_skipped(self) -> None:
|
||||
"""Test that a cached song triggers no HTTP request."""
|
||||
self.__seed([("Hello", "Adele")])
|
||||
self.__lyrics.mkdir()
|
||||
(self.__lyrics / "1.txt").write_text(
|
||||
"cached\n", encoding="utf-8")
|
||||
urlopen: mock.Mock
|
||||
with mock.patch("urllib.request.urlopen") as urlopen:
|
||||
status: int = self.__run_fetch()[0]
|
||||
self.assertEqual(status, 0)
|
||||
urlopen.assert_not_called()
|
||||
self.assertEqual(
|
||||
(self.__lyrics / "1.txt")
|
||||
.read_text(encoding="utf-8"),
|
||||
"cached\n")
|
||||
|
||||
def test_url_encoding(self) -> None:
|
||||
"""Test the percent-encoding of the artist and title."""
|
||||
self.__seed([("What's Up? / Down", "AC/DC & Friends")])
|
||||
urlopen: mock.Mock
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[self.__not_found(),
|
||||
self.__not_found()]) as urlopen:
|
||||
self.__run_fetch()
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
urls: list[str] = [x[0][0].full_url
|
||||
for x in urlopen.call_args_list]
|
||||
self.assertEqual(
|
||||
urls[0],
|
||||
"https://api.lyrics.ovh/v1/AC%2FDC%20%26%20Friends/"
|
||||
"What%27s%20Up%3F%20%2F%20Down")
|
||||
self.assertEqual(
|
||||
urls[1],
|
||||
"https://lrclib.net/api/get"
|
||||
"?artist_name=AC%2FDC+%26+Friends"
|
||||
"&track_name=What%27s+Up%3F+%2F+Down")
|
||||
|
||||
def test_provenance_single_header(self) -> None:
|
||||
"""Test that the provenance keeps one header across runs."""
|
||||
self.__seed([("Hello", "Adele"),
|
||||
("Umbrella", "Rihanna")])
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[
|
||||
self.__response({"lyrics": "one\n"}),
|
||||
self.__response({"lyrics": "two\n"})]):
|
||||
self.assertEqual(self.__run_fetch()[0], 0)
|
||||
rows: list[list[str]] = self.__read_rows(
|
||||
self.__provenance)
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0], self.PROVENANCE_HEADER)
|
||||
(self.__lyrics / "2.txt").unlink()
|
||||
with mock.patch(
|
||||
"urllib.request.urlopen",
|
||||
side_effect=[
|
||||
self.__response({"lyrics": "two again\n"})]):
|
||||
self.assertEqual(self.__run_fetch()[0], 0)
|
||||
rows = self.__read_rows(self.__provenance)
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertEqual(rows[0], self.PROVENANCE_HEADER)
|
||||
self.assertNotIn(self.PROVENANCE_HEADER, rows[1:])
|
||||
self.assertEqual([x[0] for x in rows[1:]],
|
||||
["1", "2", "2"])
|
||||
|
||||
def test_no_store_fails(self) -> None:
|
||||
"""Test that a missing working store fails the run."""
|
||||
urlopen: mock.Mock
|
||||
with mock.patch("urllib.request.urlopen") as urlopen:
|
||||
status: int
|
||||
stderr: str
|
||||
status, stderr = self.__run_fetch()
|
||||
self.assertNotEqual(status, 0)
|
||||
urlopen.assert_not_called()
|
||||
self.assertIn("error:", stderr)
|
||||
@@ -1,131 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
"""Unit tests for the package-level CLI dispatcher."""
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from unittest import mock
|
||||
|
||||
from pop_fem_audit_tools import __main__
|
||||
from pop_fem_audit_tools.commands import run_llm
|
||||
|
||||
|
||||
class TestDispatcher(unittest.TestCase):
|
||||
"""Test cases for the package-level CLI dispatcher."""
|
||||
|
||||
@staticmethod
|
||||
def __run_main(argv: list[str]) -> tuple[int, str, str]:
|
||||
"""Run the dispatcher with captured output.
|
||||
|
||||
:param argv: The command-line arguments.
|
||||
:return: A tuple of the exit status, the standard output,
|
||||
and the standard error.
|
||||
"""
|
||||
stdout: io.StringIO = io.StringIO()
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
status: int = __main__.main(argv)
|
||||
return status, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def test_run_llm_registered(self) -> None:
|
||||
"""Test that run-llm is bound to the run_llm tool main."""
|
||||
self.assertIs(__main__.SUBCOMMANDS["run-llm"], run_llm.main)
|
||||
|
||||
def test_run_llm_dispatch(self) -> None:
|
||||
"""Test that run-llm forwards the arguments and the status."""
|
||||
tool: mock.Mock = mock.Mock(return_value=7)
|
||||
with mock.patch.dict(__main__.SUBCOMMANDS,
|
||||
{"run-llm": tool}):
|
||||
status: int = self.__run_main(
|
||||
["run-llm", "--input", "items.jsonl"])[0]
|
||||
self.assertEqual(status, 7)
|
||||
tool.assert_called_once_with(["--input", "items.jsonl"])
|
||||
|
||||
def test_help_exits_zero_and_lists_subcommands(self) -> None:
|
||||
"""Test that --help lists the subcommands and exits 0."""
|
||||
status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
status, stdout, stderr = self.__run_main(["--help"])
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("run-llm", stdout)
|
||||
|
||||
def test_no_arguments_exits_non_zero(self) -> None:
|
||||
"""Test that no arguments yields the usage and non-zero."""
|
||||
status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
status, stdout, stderr = self.__run_main([])
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn("run-llm", stderr)
|
||||
|
||||
def test_usage_prog_module_run(self) -> None:
|
||||
"""Test the usage program name when run with python -m."""
|
||||
argv: list[str] = ["/x/pop_fem_audit_tools/__main__.py"]
|
||||
stdout: str
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
stdout = self.__run_main(["--help"])[1]
|
||||
self.assertIn(
|
||||
"usage: python -m pop_fem_audit_tools ", stdout)
|
||||
|
||||
def test_usage_prog_console_script(self) -> None:
|
||||
"""Test the usage program name when run as a script."""
|
||||
argv: list[str] = ["/x/bin/pop-fem-audit-tools"]
|
||||
stdout: str
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
stdout = self.__run_main(["--help"])[1]
|
||||
self.assertIn("usage: pop-fem-audit-tools ", stdout)
|
||||
|
||||
def test_subcommand_prog_module_run(self) -> None:
|
||||
"""Test the subcommand program name with python -m."""
|
||||
argv: list[str] = ["/x/pop_fem_audit_tools/__main__.py"]
|
||||
self.assertEqual(
|
||||
self.__dispatched_prog(argv),
|
||||
"python -m pop_fem_audit_tools run-llm")
|
||||
|
||||
def test_subcommand_prog_console_script(self) -> None:
|
||||
"""Test the subcommand program name as a script."""
|
||||
argv: list[str] = ["/x/bin/pop-fem-audit-tools"]
|
||||
self.assertEqual(
|
||||
self.__dispatched_prog(argv),
|
||||
"pop-fem-audit-tools run-llm")
|
||||
|
||||
def __dispatched_prog(self, argv: list[str]) -> str:
|
||||
"""Run a stub run-llm tool and return the prog it sees.
|
||||
|
||||
:param argv: The value to patch ``sys.argv`` with.
|
||||
:return: The default argparse program name seen by the
|
||||
dispatched tool.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
|
||||
def tool(tool_argv: list[str] | None) -> int:
|
||||
"""Record the argparse program name and succeed.
|
||||
|
||||
:param tool_argv: The tool command-line arguments.
|
||||
:return: Always 0.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = \
|
||||
argparse.ArgumentParser()
|
||||
seen.append(parser.prog)
|
||||
return 0
|
||||
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.dict(__main__.SUBCOMMANDS,
|
||||
{"run-llm": tool}):
|
||||
self.__run_main(["run-llm"])
|
||||
return seen[0]
|
||||
|
||||
def test_unknown_subcommand_exits_non_zero(self) -> None:
|
||||
"""Test that an unknown subcommand errors to standard error."""
|
||||
status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
status, stdout, stderr = self.__run_main(["nonsense"])
|
||||
self.assertNotEqual(status, 0)
|
||||
self.assertIn("nonsense", stderr)
|
||||
@@ -1,96 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/31
|
||||
# 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()
|
||||
@@ -1,462 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
"""Unit tests for the run_llm batch executor module."""
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from pop_fem_audit_tools import config
|
||||
from pop_fem_audit_tools.commands import run_llm
|
||||
|
||||
|
||||
class RunLLMTestCase(unittest.TestCase):
|
||||
"""The common base test case with the shared helpers."""
|
||||
|
||||
def _make_temp_dir(self) -> Path:
|
||||
"""Create a temporary directory removed on test cleanup.
|
||||
|
||||
:return: The path of the temporary directory.
|
||||
"""
|
||||
tmp: tempfile.TemporaryDirectory[str] \
|
||||
= tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
return Path(tmp.name)
|
||||
|
||||
def _make_success_entry(self, custom_id: str,
|
||||
text: str) -> mock.Mock:
|
||||
"""Create a mock succeeded batch result entry.
|
||||
|
||||
:param custom_id: The custom ID of the entry.
|
||||
:param text: The output text.
|
||||
:return: The mock result entry.
|
||||
"""
|
||||
entry: mock.Mock = mock.Mock()
|
||||
entry.custom_id = custom_id
|
||||
entry.result = mock.Mock()
|
||||
entry.result.type = "succeeded"
|
||||
entry.result.message = self.__make_message(text)
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def _make_error_entry(custom_id: str,
|
||||
error_type: str) -> mock.Mock:
|
||||
"""Create a mock errored batch result entry.
|
||||
|
||||
The error object is shaped as the SDK envelope: the outer
|
||||
error carries the constant type ``error``, and the specific
|
||||
error code lives at ``error.error.type``.
|
||||
|
||||
:param custom_id: The custom ID of the entry.
|
||||
:param error_type: The specific error code.
|
||||
:return: The mock result entry.
|
||||
"""
|
||||
entry: mock.Mock = mock.Mock()
|
||||
entry.custom_id = custom_id
|
||||
entry.result = mock.Mock()
|
||||
entry.result.type = "errored"
|
||||
entry.result.error = mock.Mock()
|
||||
entry.result.error.type = "error"
|
||||
entry.result.error.error = mock.Mock()
|
||||
entry.result.error.error.type = error_type
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def __make_message(text: str) -> mock.Mock:
|
||||
"""Create a mock message with a single text block.
|
||||
|
||||
:param text: The text of the text block.
|
||||
:return: The mock message.
|
||||
"""
|
||||
block: mock.Mock = mock.Mock()
|
||||
block.type = "text"
|
||||
block.text = text
|
||||
usage: mock.Mock = mock.Mock()
|
||||
usage.model_dump.return_value = {
|
||||
"input_tokens": 10, "output_tokens": 5, "extra": None}
|
||||
message: mock.Mock = mock.Mock()
|
||||
message.content = [block]
|
||||
message.stop_reason = "end_turn"
|
||||
message.usage = usage
|
||||
return message
|
||||
|
||||
|
||||
class TestLoadItems(RunLLMTestCase):
|
||||
"""Test cases for the input JSONL validation."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a temporary directory for the input files."""
|
||||
self.__dir: Path = self._make_temp_dir()
|
||||
|
||||
def __write_input(self, content: str) -> Path:
|
||||
"""Write an input file with the given content.
|
||||
|
||||
:param content: The file content.
|
||||
:return: The path of the input file.
|
||||
"""
|
||||
path: Path = self.__dir / "items.jsonl"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_valid_items(self) -> None:
|
||||
"""Test that valid items are loaded in file order."""
|
||||
path: Path = self.__write_input(
|
||||
'{"id": "a", "content": "one"}\n'
|
||||
'{"id": "b", "content": "two"}\n')
|
||||
items: list[run_llm.InputItem] = run_llm.load_items(path)
|
||||
self.assertEqual(items, [
|
||||
run_llm.InputItem(id="a", content="one"),
|
||||
run_llm.InputItem(id="b", content="two")])
|
||||
|
||||
def test_malformed_json_names_line(self) -> None:
|
||||
"""Test that malformed JSON reports the line number."""
|
||||
path: Path = self.__write_input(
|
||||
'{"id": "a", "content": "one"}\n'
|
||||
'not json\n')
|
||||
with self.assertRaises(run_llm.InputFormatError) as context:
|
||||
run_llm.load_items(path)
|
||||
self.assertIn("line 2", str(context.exception))
|
||||
|
||||
def test_missing_key_names_line(self) -> None:
|
||||
"""Test that a missing key reports the line number."""
|
||||
path: Path = self.__write_input('{"id": "a"}\n')
|
||||
with self.assertRaises(run_llm.InputFormatError) as context:
|
||||
run_llm.load_items(path)
|
||||
self.assertIn("line 1", str(context.exception))
|
||||
|
||||
def test_extra_key_rejected(self) -> None:
|
||||
"""Test that an extra key is rejected."""
|
||||
path: Path = self.__write_input(
|
||||
'{"id": "a", "content": "one", "extra": 1}\n')
|
||||
with self.assertRaises(run_llm.InputFormatError):
|
||||
run_llm.load_items(path)
|
||||
|
||||
def test_non_string_content_rejected(self) -> None:
|
||||
"""Test that a non-string content is rejected."""
|
||||
path: Path = self.__write_input('{"id": "a", "content": 3}\n')
|
||||
with self.assertRaises(run_llm.InputFormatError):
|
||||
run_llm.load_items(path)
|
||||
|
||||
def test_duplicated_id_names_line(self) -> None:
|
||||
"""Test that a duplicated ID reports the line number."""
|
||||
path: Path = self.__write_input(
|
||||
'{"id": "a", "content": "one"}\n'
|
||||
'{"id": "a", "content": "two"}\n')
|
||||
with self.assertRaises(run_llm.InputFormatError) as context:
|
||||
run_llm.load_items(path)
|
||||
self.assertIn("line 2", str(context.exception))
|
||||
self.assertIn("a", str(context.exception))
|
||||
|
||||
def test_empty_file_rejected(self) -> None:
|
||||
"""Test that an empty input file is rejected."""
|
||||
path: Path = self.__write_input("")
|
||||
with self.assertRaises(run_llm.InputFormatError):
|
||||
run_llm.load_items(path)
|
||||
|
||||
|
||||
class TestRequestBuilding(RunLLMTestCase):
|
||||
"""Test cases for the request construction."""
|
||||
|
||||
def test_build_request(self) -> None:
|
||||
"""Test the shape of a batch request."""
|
||||
request: dict[str, Any] = run_llm.build_request(
|
||||
run_llm.InputItem(id="song-1", content="the lyrics"),
|
||||
"the system prompt", 2048)
|
||||
self.assertEqual(request["custom_id"], "song-1")
|
||||
params: dict[str, Any] = request["params"]
|
||||
self.assertEqual(params["model"], "claude-sonnet-4-6")
|
||||
self.assertEqual(params["temperature"], 0.0)
|
||||
self.assertEqual(params["thinking"], {"type": "disabled"})
|
||||
self.assertEqual(params["max_tokens"], 2048)
|
||||
self.assertEqual(params["system"], "the system prompt")
|
||||
self.assertEqual(params["messages"],
|
||||
[{"role": "user", "content": "the lyrics"}])
|
||||
|
||||
|
||||
class TestCollectResults(RunLLMTestCase):
|
||||
"""Test cases for the batch result collection."""
|
||||
|
||||
def test_collect_success_and_error(self) -> None:
|
||||
"""Test collecting succeeded and errored results."""
|
||||
client: mock.Mock = mock.Mock()
|
||||
client.messages.batches.results.return_value = iter([
|
||||
self._make_success_entry("a", "output a"),
|
||||
self._make_error_entry("b", "invalid_request_error")])
|
||||
results: run_llm.Results = run_llm.collect_results(
|
||||
client, "batch_x")
|
||||
self.assertEqual(results["a"].text, "output a")
|
||||
self.assertEqual(results["a"].stop_reason, "end_turn")
|
||||
self.assertEqual(results["a"].usage,
|
||||
{"input_tokens": 10, "output_tokens": 5})
|
||||
self.assertEqual(results["b"], run_llm.BatchResult(
|
||||
id="b", error="invalid_request_error"))
|
||||
client.messages.batches.results.assert_called_once_with(
|
||||
"batch_x")
|
||||
|
||||
def test_find_failures(self) -> None:
|
||||
"""Test finding failed and missing items."""
|
||||
results: run_llm.Results = {
|
||||
"a": run_llm.BatchResult(id="a", text="fine"),
|
||||
"b": run_llm.BatchResult(id="b", error="errored")}
|
||||
self.assertEqual(
|
||||
run_llm.find_failures(["a", "b", "c"], results),
|
||||
["b", "c"])
|
||||
|
||||
def test_sum_usage(self) -> None:
|
||||
"""Test summing the token usage across results."""
|
||||
results: run_llm.Results = {
|
||||
"a": run_llm.BatchResult(
|
||||
id="a", text="fine",
|
||||
usage={"input_tokens": 10, "output_tokens": 5}),
|
||||
"b": run_llm.BatchResult(
|
||||
id="b", text="fine",
|
||||
usage={"input_tokens": 3, "output_tokens": 2}),
|
||||
"c": run_llm.BatchResult(id="c", error="errored")}
|
||||
self.assertEqual(
|
||||
run_llm.sum_usage(results),
|
||||
{"input_tokens": 13, "output_tokens": 7})
|
||||
|
||||
|
||||
class TestArchive(RunLLMTestCase):
|
||||
"""Test cases for the archive directory handling."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a temporary directory as the runs root."""
|
||||
self.__dir: Path = self._make_temp_dir()
|
||||
|
||||
def test_create_archive_dir(self) -> None:
|
||||
"""Test the archive directory creation."""
|
||||
target: Path = self.__dir / "01-01-tag" / "run1"
|
||||
directory: Path = run_llm.create_archive_dir(target, False)
|
||||
self.assertTrue(directory.is_dir())
|
||||
self.assertEqual(directory, target)
|
||||
|
||||
def test_existing_archive_dir_rejected_without_replace(
|
||||
self) -> None:
|
||||
"""Test that an existing archive is rejected by default."""
|
||||
target: Path = self.__dir / "01-01-tag" / "run1"
|
||||
run_llm.create_archive_dir(target, False)
|
||||
with self.assertRaises(FileExistsError):
|
||||
run_llm.create_archive_dir(target, False)
|
||||
|
||||
def test_existing_archive_dir_replaced(self) -> None:
|
||||
"""Test that --replace replaces an existing archive."""
|
||||
target: Path = self.__dir / "01-01-tag" / "run1"
|
||||
first: Path = run_llm.create_archive_dir(target, False)
|
||||
(first / "stale.txt").write_text("stale", encoding="utf-8")
|
||||
second: Path = run_llm.create_archive_dir(target, True)
|
||||
self.assertEqual(first, second)
|
||||
self.assertFalse((second / "stale.txt").exists())
|
||||
|
||||
def test_replace_leaves_sibling_dir_untouched(self) -> None:
|
||||
"""Test that replacing run2 does not touch run1."""
|
||||
run1: Path = run_llm.create_archive_dir(
|
||||
self.__dir / "01-01-tag" / "run1", False)
|
||||
(run1 / "output.jsonl").write_text(
|
||||
"run1 data", encoding="utf-8")
|
||||
run2: Path = run_llm.create_archive_dir(
|
||||
self.__dir / "01-01-tag" / "run2", False)
|
||||
(run2 / "stale.jsonl").write_text("stale", encoding="utf-8")
|
||||
run_llm.create_archive_dir(
|
||||
self.__dir / "01-01-tag" / "run2", True)
|
||||
self.assertEqual(
|
||||
(run1 / "output.jsonl").read_text(encoding="utf-8"),
|
||||
"run1 data")
|
||||
|
||||
def test_write_jsonl(self) -> None:
|
||||
"""Test writing records as JSON Lines."""
|
||||
path: Path = self.__dir / "out.jsonl"
|
||||
run_llm.write_jsonl(path, [{"id": "a", "text": "中文"},
|
||||
{"id": "b", "text": "two"}])
|
||||
lines: list[str] = path.read_text(
|
||||
encoding="utf-8").splitlines()
|
||||
self.assertEqual(len(lines), 2)
|
||||
self.assertEqual(json.loads(lines[0]),
|
||||
{"id": "a", "text": "中文"})
|
||||
self.assertIn("中文", lines[0])
|
||||
|
||||
|
||||
class TestMainFlow(RunLLMTestCase):
|
||||
"""Test cases for the end-to-end main flow."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a temporary directory with the input files."""
|
||||
directory: Path = self._make_temp_dir()
|
||||
self.__runs: Path = directory / "runs"
|
||||
self.__archive_dir: Path = self.__runs / "task_v1" / "run1"
|
||||
self.__prompt: Path = directory / "task_v1.md"
|
||||
self.__prompt.write_text(
|
||||
"The task prompt.\n", encoding="utf-8")
|
||||
self.__input: Path = directory / "items.jsonl"
|
||||
self.__input.write_text(
|
||||
'{"id": "a", "content": "first item"}\n'
|
||||
'{"id": "b", "content": "second item"}\n',
|
||||
encoding="utf-8")
|
||||
self.__argv: list[str] = [
|
||||
str(self.__prompt), str(self.__input),
|
||||
str(self.__archive_dir)]
|
||||
self.__settings: config.Settings = config.Settings(
|
||||
SQLALCHEMY_DATABASE_URL="sqlite://",
|
||||
ANTHROPIC_API_KEY="test-key")
|
||||
config.set_settings(self.__settings)
|
||||
|
||||
@staticmethod
|
||||
def __make_client(entries: list[Any]) -> mock.Mock:
|
||||
"""Create a mock Anthropic client serving canned results.
|
||||
|
||||
:param entries: The result entries of the single run batch.
|
||||
:return: The mock client.
|
||||
"""
|
||||
client: mock.Mock = mock.Mock()
|
||||
client.messages.batches.create.return_value = mock.Mock(
|
||||
id="batch_run1")
|
||||
ended: mock.Mock = mock.Mock()
|
||||
ended.processing_status = "ended"
|
||||
ended.ended_at = datetime(2026, 7, 30, 20, 0)
|
||||
client.messages.batches.retrieve.return_value = ended
|
||||
client.messages.batches.results.return_value = iter(entries)
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def __run_main(argv: list[str],
|
||||
client: mock.Mock | None = None) -> tuple[int, str]:
|
||||
"""Run main() with a mocked client and captured output.
|
||||
|
||||
:param argv: The command-line arguments.
|
||||
:param client: The mock client, or None for dry runs.
|
||||
:return: A tuple of the exit status and the standard output.
|
||||
"""
|
||||
stdout: io.StringIO = io.StringIO()
|
||||
stderr: io.StringIO = io.StringIO()
|
||||
with mock.patch.object(run_llm.anthropic, "Anthropic",
|
||||
return_value=client), \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
status: int = run_llm.main(argv)
|
||||
return status, stdout.getvalue()
|
||||
|
||||
def test_dry_run(self) -> None:
|
||||
"""Test the dry-run behavior."""
|
||||
status: int
|
||||
stdout: str
|
||||
status, stdout = self.__run_main(
|
||||
self.__argv + ["--dry-run"])
|
||||
self.assertEqual(status, 0)
|
||||
run_dir: Path = self.__archive_dir
|
||||
self.assertEqual((run_dir / "prompt.md").read_text(
|
||||
encoding="utf-8"), "The task prompt.\n")
|
||||
meta: dict[str, Any] = json.loads(
|
||||
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||
self.assertTrue(meta["dry_run"])
|
||||
self.assertEqual(meta["item_count"], 2)
|
||||
self.assertEqual(meta["model"], "claude-sonnet-4-6")
|
||||
self.assertNotIn("run", meta)
|
||||
self.assertFalse((run_dir / "output.jsonl").exists())
|
||||
request: dict[str, Any] = json.loads(stdout)
|
||||
self.assertEqual(request["custom_id"], "a")
|
||||
self.assertEqual(request["params"]["system"],
|
||||
"The task prompt.\n")
|
||||
|
||||
def test_run_produces_output_file(self) -> None:
|
||||
"""Test that a run submits one batch and writes output."""
|
||||
client: mock.Mock = self.__make_client(
|
||||
[self._make_success_entry("a", "answer a"),
|
||||
self._make_success_entry("b", "answer b")])
|
||||
status: int = self.__run_main(self.__argv, client)[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(
|
||||
client.messages.batches.create.call_count, 1)
|
||||
run_dir: Path = self.__archive_dir
|
||||
self.assertTrue((run_dir / "output.jsonl").exists())
|
||||
output: list[dict[str, Any]] = [
|
||||
json.loads(x) for x in (run_dir / "output.jsonl")
|
||||
.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual(output[0]["text"], "answer a")
|
||||
meta: dict[str, Any] = json.loads(
|
||||
(run_dir / "meta.json").read_text(encoding="utf-8"))
|
||||
self.assertNotIn("run", meta)
|
||||
self.assertEqual(meta["batch"]["batch_id"], "batch_run1")
|
||||
self.assertEqual(meta["usage"],
|
||||
{"input_tokens": 20, "output_tokens": 10})
|
||||
|
||||
def test_existing_archive_rejected_without_replace(self) -> None:
|
||||
"""Test that an existing archive without --replace fails."""
|
||||
run_dir: Path = self.__archive_dir
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "stale.jsonl").write_text(
|
||||
"stale", encoding="utf-8")
|
||||
status: int = self.__run_main(
|
||||
self.__argv + ["--dry-run"])[0]
|
||||
self.assertEqual(status, 1)
|
||||
self.assertTrue((run_dir / "stale.jsonl").exists())
|
||||
|
||||
def test_rerun_replaces_archive_with_flag(self) -> None:
|
||||
"""Test that --replace replaces a pre-existing archive."""
|
||||
run_dir: Path = self.__archive_dir
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "stale.jsonl").write_text(
|
||||
"stale", encoding="utf-8")
|
||||
client: mock.Mock = self.__make_client(
|
||||
[self._make_success_entry("a", "answer a"),
|
||||
self._make_success_entry("b", "answer b")])
|
||||
status: int = self.__run_main(
|
||||
self.__argv + ["--replace"], client)[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertFalse((run_dir / "stale.jsonl").exists())
|
||||
self.assertTrue((run_dir / "output.jsonl").exists())
|
||||
|
||||
def test_replace_leaves_sibling_dir_untouched(self) -> None:
|
||||
"""Test that replacing run2 does not affect run1's archive."""
|
||||
run1_dir: Path = self.__archive_dir
|
||||
run1_dir.mkdir(parents=True)
|
||||
(run1_dir / "output.jsonl").write_text(
|
||||
"run1 data", encoding="utf-8")
|
||||
run2_dir: Path = self.__runs / "task_v1" / "run2"
|
||||
run2_dir.mkdir(parents=True)
|
||||
(run2_dir / "output.jsonl").write_text(
|
||||
"stale run2 data", encoding="utf-8")
|
||||
client: mock.Mock = self.__make_client(
|
||||
[self._make_success_entry("a", "answer a"),
|
||||
self._make_success_entry("b", "answer b")])
|
||||
argv: list[str] = [
|
||||
str(self.__prompt), str(self.__input),
|
||||
str(run2_dir), "--replace"]
|
||||
status: int = self.__run_main(argv, client)[0]
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(
|
||||
(run1_dir / "output.jsonl").read_text(encoding="utf-8"),
|
||||
"run1 data")
|
||||
self.assertNotEqual(
|
||||
(run2_dir / "output.jsonl").read_text(encoding="utf-8"),
|
||||
"stale run2 data")
|
||||
|
||||
def test_run_failure_exits_non_zero(self) -> None:
|
||||
"""Test that a failed item aborts with a non-zero status."""
|
||||
client: mock.Mock = self.__make_client(
|
||||
[self._make_success_entry("a", "answer a"),
|
||||
self._make_error_entry("b", "invalid_request_error")])
|
||||
status: int = self.__run_main(self.__argv, client)[0]
|
||||
self.assertEqual(status, 1)
|
||||
run_dir: Path = self.__archive_dir
|
||||
self.assertTrue((run_dir / "output.jsonl").exists())
|
||||
self.assertTrue((run_dir / "meta.json").exists())
|
||||
output_lines: list[str] = (run_dir / "output.jsonl") \
|
||||
.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(json.loads(output_lines[1]),
|
||||
{"id": "b",
|
||||
"error": "invalid_request_error"})
|
||||
|
||||
def test_invalid_input_exits_non_zero(self) -> None:
|
||||
"""Test that an invalid input file aborts before archiving."""
|
||||
self.__input.write_text('{"id": "a"}\n', encoding="utf-8")
|
||||
status: int = self.__run_main(
|
||||
self.__argv + ["--dry-run"])[0]
|
||||
self.assertEqual(status, 1)
|
||||
self.assertFalse(self.__runs.exists())
|
||||
@@ -1,21 +0,0 @@
|
||||
# Tools for A Feminist Audit of Pop Music.
|
||||
# Copyright 2026 imacat. All rights reserved.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
||||
"""Unit tests for the shared utilities module."""
|
||||
import unittest
|
||||
|
||||
from pop_fem_audit_tools import utils
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
"""Test cases for the shared utilities."""
|
||||
|
||||
def test_format_duration_under_hour(self) -> None:
|
||||
"""Test the mm:ss format for a duration under one hour."""
|
||||
self.assertEqual(utils.format_duration(205), "03:25")
|
||||
|
||||
def test_format_duration_over_hour(self) -> None:
|
||||
"""Test the h:mm:ss format once the duration reaches an
|
||||
hour."""
|
||||
self.assertEqual(utils.format_duration(6439), "1:47:19")
|
||||
Reference in New Issue
Block a user