# aimusiclist 규약집 v1.3 — 내가 만든 음악 사이트를 aimusiclist.com에 연결하기

- 버전: **Rulebook v1.3** (연동규약 v1.3 = catalog 1.3, 순위 산정 방식 v2.0 기준) · 날짜: **2026-10-29**
- 이 파일의 고정 주소: `https://aimusiclist.com/developers/aimusiclist-rulebook.ko.md`
  (영어 원본: `https://aimusiclist.com/developers/aimusiclist-rulebook.md` — 내용이 다르면 영어 원본이 기준)
- 검사 도구: `https://aimusiclist.com/developers/check`

## 0. 이 파일 쓰는 법

**이 파일 전체를 AI(ChatGPT, Claude, Gemini… 무료 버전도 됨)에게 붙여넣고, 아래 요청문 중 하나를 보내세요.**
AI는 이 파일의 "반드시(MUST)"를 그대로 지켜야 합니다. `코드 글꼴`로 쓴 이름은 정확한 이름이므로 번역하거나 바꾸면 안 됩니다.

### 요청문 A — PHP 웹호스팅 (cPanel / Apache)
```text
위 내용은 aimusiclist 규약집입니다. PHP 8로 내 음악 사이트를 만들어 주세요. 웹호스팅(Apache +
.htaccess)용이고, 프레임워크와 데이터베이스는 쓰지 말고 데이터는 웹에서 열 수 없는 폴더의 JSON
파일에 저장해 주세요. 기능: 관리자 비밀번호 1개, MP3/M4A 곡 올리기(최대 15MB), 제목·아티스트·장르
(genres.json의 키) 수정, "aimusiclist.com에도 공개" 체크(기본 체크됨), 앨범. 규약집 2장의 주소를
모두 정확히 만들어 주세요(ETag가 있는 catalog.json, verify, Range와 CORS가 있는 /a/{id}, /i, /l, /p).
자동 등록(5장)과 변경할 때마다 ping(7장)도 넣어 주세요. 모든 파일의 전체 코드와 초보자용 설치
순서를 주고, 마지막에 aimusiclist 검사 도구로 확인하는 방법을 알려 주세요.
```

### 요청문 B — Cloudflare Workers + R2 (무료 요금제)
```text
위 내용은 aimusiclist 규약집입니다. 내 음악 사이트를 R2 버킷이 연결된 Cloudflare Worker 1개로
만들어 주세요(무료 요금제, 요청 수와 CPU를 적게). 기능: 관리자 로그인, MP3/M4A 곡을 R2에 올리기
(최대 15MB), 제목·아티스트·장르(genres.json의 키) 수정, "aimusiclist.com에도 공개" 체크(기본 체크됨),
앨범. site_secret은 처음 실행할 때 만들어 R2에 보관하고 HTML/JS에는 절대 넣지 마세요. 규약집 2장의
주소를 모두 정확히 만들고, 자동 등록(5장), 변경할 때마다 ping(7장), cron 재시도를 넣어 주세요.
전체 코드, wrangler.toml, 초보자용 배포 순서, 검사 도구로 확인하는 방법을 알려 주세요.
```

### 요청문 C — 정적 사이트 + 작은 서버
```text
위 내용은 aimusiclist 규약집입니다. 내 화면은 정적 사이트(예: GitHub Pages, Netlify)입니다. 화면은
정적인 그대로 두고, 가장 작은 서버 부분(같은 도메인의 무료 Cloudflare Worker 1개, 또는 리다이렉트가
아닌 내부 rewrite를 쓰는 서버리스 함수)을 더해서 규약집 2장의 주소를 모두 제공하고, site_secret을
비밀로 지키고, 자동 등록(5장)과 ping(7장)을 하게 해 주세요. 곡은 [R2 / 내 저장소]에 있습니다.
어떤 주소가 서버 부분으로 가야 하는지 정확히 설명하고, 전체 코드와 초보자용 순서, 검사 도구로
확인하는 방법을 알려 주세요.
```

팁: AI가 다 만들면 `https://aimusiclist.com/developers/check`를 열어 내 사이트 주소를 넣고, 빨간색·노란색 줄을 그대로 AI에게 붙여넣으세요.

---

## 1. 전체 흐름

aimusiclist.com은 **파일을 받지 않습니다.** 내 사이트에서 곡 목록을 **가져갑니다.** 내 사이트는 "다시 가져가 주세요"라는 신호(ping)만 보냅니다.

```text
 내 사이트 (https://your.site)                         aimusiclist.com
 ─────────────────────────────                         ─────────────────────────────
 1. 처음 실행: site_secret(16진수 64자리)을 만들어 비밀로 보관
 2. POST /api/v1/sites/auto {url, name, …, site_secret} ──►
                                    ◄── GET /.well-known/aimusiclist/verify?c=CHALLENGE
 3. 응답 {"site_id":…, "proof": HMAC(site_secret, "verify:"+CHALLENGE)} ──►
                                    ◄── 201 {"ok":true,"site_id":"s_xxxxxxxx"}
 4. site_id 저장, catalog.json에 넣기
                                    ◄── GET /.well-known/aimusiclist/catalog.json (약 3초 뒤)
 5. 바뀔 때마다: POST /api/v1/ping (Bearer site_secret) ──►
                                    ◄── GET catalog.json (대기열에서, 사이트별 6시간 분산)
 6. aimusiclist.com 청취자가 재생      ◄── 브라우저 GET /a/{id} (Range, CORS)
                                           브라우저 GET /i/{id} 이미지, /l/{id} 가사
```

- 곡은 **내 catalog.json에 있는 동안에만** aimusiclist에 공개됩니다. 목록에서 빼면 = 비공개.
- 내 사이트에는 모든 곡을 보여 줘도 됩니다. "aimusiclist.com에도 공개"가 켜진 곡만 catalog.json에 넣습니다.
- 주인 확인 = "그 주소에서 코드를 실제로 돌릴 수 있는가". 회원가입·수동 승인 없음.

---

## 2. 내 사이트가 반드시 제공해야 하는 주소

모두 내 **사이트 origin**(예: `https://music.example.com`, 경로 없음)에 있어야 합니다. HTTPS만. **리다이렉트 금지**(aimusiclist는 301/302를 따라가지 않음). 이 주소들에는 로그인·봇 확인 화면을 걸지 마세요.

| 주소 | 방식 | 필수 | 돌려줄 것 |
|---|---|---|---|
| `/.well-known/aimusiclist/catalog.json` | GET | 반드시 | catalog JSON(3장). `Content-Type: application/json`. ETag + `If-None-Match`면 `304` 권장 |
| `/.well-known/aimusiclist/verify?c={challenge}` | GET | 반드시 | `{"site_id": "s_…" 또는 null, "proof": "소문자 16진수 64자리"}` (6장) |
| `/a/{track_id}` | GET, HEAD, OPTIONS | 반드시 | 음원 파일. Range 지원(`206`), `Accept-Ranges: bytes`, `Cache-Control: no-cache`, CORS(아래) |
| `/i/{track_id}` | GET | 선택 | 곡 이미지 100×100 WebP (직접 올린 이미지를 쓸 때만) |
| `/l/{track_id}` | GET | 선택 | 가사 `.srt` 글자, `text/plain; charset=utf-8`, CORS(아래) |
| `/c/{album_id}` | GET | 선택 | 앨범 커버 200×200 WebP |
| `/p` | GET | 선택 | 프로필 이미지 200×200 WebP, 100KB 이하 (aimusiclist가 `/p?v={해시}`로 내려받음) |

**시간**: catalog는 **3초**(ping) / **10초**(정기) 안에, verify는 **5초** 안에 응답. 응답 최대 **1MB**(catalog 자체는 512KB 이하).

### CORS (`/a/`·`/l/`은 반드시)
aimusiclist.com 청취자의 브라우저가 `/a/`를 `crossOrigin`으로(비주얼라이저용), `/l/`을 `fetch()`로 불러옵니다. 요청 머리글 `Origin`이 `https://aimusiclist.com` 또는 `https://www.aimusiclist.com`이고 **그 곡이 catalog에 있으면** 다음을 보냅니다.

```text
Access-Control-Allow-Origin: https://aimusiclist.com        (요청의 Origin 값을 그대로)
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
Access-Control-Allow-Headers: Range, If-None-Match
Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges
Vary: Origin
```
`OPTIONS`에는 `204` + 같은 머리글. catalog에 **없는** 곡에는 CORS 머리글을 붙이지 않습니다(내 사이트에서는 그대로 재생됨).
`/a/`에 CORS가 없으면 aimusiclist에서 재생은 되지만 진짜 비주얼라이저가 움직이지 않고, `/l/`에 없으면 가사 창이 열리지 않습니다.

---

## 3. catalog.json (규격 1.3)

최상위 — 모두 반드시:

| 필드 | 타입 | 규칙 |
|---|---|---|
| `schema` | string | `"1.3"` (aimusiclist는 `1.0`·`1.1`·`1.2`·`1.3`과 앞으로의 `1.x`를 모두 받고 `2.0`은 거부) |
| `generated_at` | string | ISO 8601 UTC, 예 `"2026-10-28T04:23:21Z"` |
| `site` | object | 아래 |
| `albums` | array | 없으면 `[]` |
| `tracks` | array | 없으면 `[]` (형식이 맞는 빈 목록 = "모두 비공개") |

**site**

| 필드 | 타입 | 필수 | 규칙 |
|---|---|---|---|
| `site_id` | string | 반드시 | 등록 때 받은 값(`s_` + 8자). 틀리면 목록 전체 거부 |
| `url` | string | 반드시 | 등록한 origin과 정확히 같게, 예 `https://music.example.com`. 틀리면 목록 전체 거부 |
| `name` | string | 반드시 | 사이트 이름 1~40자 |
| `artist_name` | string | 반드시 | 대표 아티스트 1~40자 |
| `default_lang` | string | 반드시 | `en`, `ko`, `ja`, `pt-BR` … |
| `description` | string | 선택 | 200자 이내 |
| `profile_url` | string | 선택 | `{url}/p?v={소문자 16진수 8자리}`만 (v = 이미지 해시, 이미지를 바꾸면 v도 바꿈) |
| `socials` | string[] | 선택 (1.2) | 아티스트 SNS **종류 키**만 0~10개, 예 `["youtube","instagram","soundcloud"]` — **주소는 넣지 않음** (3b) |
| `ext` | object | 선택 (1.3) | fields.json에서 `scope: "site"`인 추가 정보 (3c), 2KB 이하 |

**tracks[]**

| 필드 | 타입 | 필수 | 규칙 |
|---|---|---|---|
| `id` | string | 반드시 | `[a-z0-9_-]`, 1~64자, 사이트 안에서 고유, **절대 바꾸지 않음** |
| `title` | string | 반드시 | 1~100자 |
| `artist` | string | 반드시 | 1~60자 (보통 사이트 이름, 또는 가상 아티스트 이름) |
| `genres` | string[] | 반드시 | 장르 **키** 1~3개, 첫 번째 = 메인 장르(4장) |
| `duration` | integer | 반드시 | 초, **정수** 30~900 (`221.4`나 `"221"` 안 됨) |
| `audio_url` | string | 반드시 | 정확히 `{site.url}/a/{id}` |
| `published_at` | string | 반드시 | ISO 8601, 예 `"2026-10-28T02:13:57Z"` (표시용) |
| `thumb_url` | string | 선택 | `{site.url}/i/{id}` 또는 `https://aimusiclist.com/img/default/{5자리}.webp`만 (10장) |
| `lyrics_url` | string | 선택 | `{site.url}/l/{id}`만, 가사가 있을 때만 |
| `langs` | string[] | 선택 (1.2) | 곡의 가사 언어 1~2개, 첫 번째 = 주 언어 (3a). 연주곡은 `["zxx"]` 하나만 |
| `ext` | object | 선택 (1.3) | `scope: "track"`인 추가 정보, 예 `{"mood":["calm","nostalgic"]}` (3c), 1KB 이하 |

**albums[]** (9장)

| 필드 | 타입 | 필수 | 규칙 |
|---|---|---|---|
| `id` | string | 반드시 | 곡 id와 같은 규칙 |
| `title` | string | 반드시 | 1~100자 |
| `artist` | string | 반드시 | 1~60자 |
| `cover_url` | string/null | 반드시 | `{site.url}/c/{id}` 또는 `null` |
| `track_ids` | string[] | 반드시 | 서로 다른 곡 id 10~30개, 모두 `tracks`에 있어야 함 |
| `published_at` | string | 반드시 | ISO 8601 |

**한도**: 파일 512KB, 곡 1,000개, 앨범 100개 이하. 모르는 필드는 무시합니다(내 필드를 더해도 안전 — 예를 들어 내 사이트에서만 쓰는 칸을 담은 `local`은 무시됩니다. 그런 칸은 `ext`에 넣지 마세요).

**틀렸을 때 aimusiclist의 처리**

| 틀린 것 | 결과 |
|---|---|
| JSON 오류, `schema`가 1.x 아님, `site.site_id`/`site.url` 다름, 한도 초과 | 목록 전체 거부, 이전 상태 유지, 가져가기 실패 1회 |
| 곡 하나가 틀림(id, title, artist, 메인 장르, duration, audio_url, published_at, 중복 id) | 그 곡만 거부 |
| `thumb_url` / `lyrics_url` / `profile_url` 형식 틀림 | 그 필드만 무시 |
| (1.2) `langs`의 모르는 코드, `socials`의 모르는 키, SNS 10개 초과 | 그 값만 무시. 맞는 `langs`가 없으면 곡 언어 = `site.default_lang`의 `-` 앞부분(예 `pt-BR` → `pt`) |
| (1.3) fields.json에 없는 `ext` 키, 형식·옵션 틀림, 개수 초과, 1KB 초과 | 그 값만(1KB 초과는 `ext` 전체) 무시 — 곡은 유지 |
| 모르는 보조 장르·중복 장르 | 그 장르만 무시 |
| 앨범이 틀림(곡 수, 없는 곡, 필드) | 그 앨범만 거부 |
| 앨범 `cover_url` 형식 틀림 | 커버만 무시 |
| `site.name` / `artist_name` / `default_lang` 틀림 | 이전 값 유지 |

### 최소 예시 (곡 1개)
```json
{
  "schema": "1.3",
  "generated_at": "2026-10-28T04:23:21Z",
  "site": { "site_id": "s_ez57qni8", "url": "https://music.example.com",
            "name": "Hip2pm", "artist_name": "Hip2pm", "default_lang": "en" },
  "albums": [],
  "tracks": [
    { "id": "t_ynyd78co", "title": "Where Are You Going", "artist": "Hip2pm",
      "genres": ["rnb"], "duration": 221,
      "audio_url": "https://music.example.com/a/t_ynyd78co",
      "published_at": "2026-10-28T02:13:57Z" }
  ]
}
```

### 전체 예시 (선택 필드 모두, 앨범은 줄여서 표시)
```json
{
  "schema": "1.3",
  "generated_at": "2026-10-28T04:23:21Z",
  "site": {
    "site_id": "s_ez57qni8",
    "url": "https://music.example.com",
    "name": "Hip2pm",
    "artist_name": "Hip2pm",
    "default_lang": "en",
    "description": "Late-night R&B made with AI.",
    "profile_url": "https://music.example.com/p?v=9f2c01ab",
    "socials": ["youtube", "instagram", "soundcloud"]
  },
  "albums": [
    { "id": "a_night", "title": "Night Drive", "artist": "Hip2pm",
      "cover_url": "https://music.example.com/c/a_night",
      "track_ids": ["t_01","t_02","t_03","t_04","t_05","t_06","t_07","t_08","t_09","t_10"],
      "published_at": "2026-10-20T00:00:00Z" }
  ],
  "tracks": [
    { "id": "t_01", "title": "Where Are You Going", "artist": "Hip2pm",
      "genres": ["rnb", "hiphop", "dance"], "duration": 221,
      "audio_url": "https://music.example.com/a/t_01",
      "published_at": "2026-10-28T02:13:57Z",
      "thumb_url": "https://music.example.com/i/t_01",
      "lyrics_url": "https://music.example.com/l/t_01",
      "langs": ["ko", "en"],
      "ext": { "mood": ["calm", "nostalgic"] } },
    { "id": "t_02", "title": "Blue Hour", "artist": "Neon Ghost",
      "genres": ["rnb", "neo_soul"], "duration": 198,
      "audio_url": "https://music.example.com/a/t_02",
      "published_at": "2026-10-27T11:00:00Z",
      "thumb_url": "https://aimusiclist.com/img/default/04213.webp",
      "langs": ["zxx"] }
  ]
}
```
(위 앨범이 공개되려면 `t_01`…`t_10` 10곡이 모두 `tracks`에 있어야 합니다. 여기서는 2곡만 보였습니다.)
aimusiclist 표시: 곡 제목 아래에 `Hip2pm`(아티스트 = 사이트 이름, 대소문자 무시) 또는 `Hip2pm > Neon Ghost`(아티스트가 다름). 사이트 이름을 누르면 내 사이트로 갑니다.
곡 줄 오른쪽에는 [SNS 아이콘] → [SRT(가사가 있을 때만)] → [♥ 좋아요 수] → [+ 담기]가 보입니다. 위 예시의 SNS는 YouTube, Instagram, 🔗(SoundCloud) 세 개로 보입니다.

### 3a. 곡 언어 `langs` (catalog 1.2)

- 내 사이트 화면 언어가 아니라 **곡의 가사 언어**입니다. 1~2개, 첫 번째가 주 언어. 언어별 차트와 언어 필터는 주 언어 기준입니다.
- 코드 목록: `GET https://aimusiclist.com/api/v1/song-langs.json` (공개, CORS `*`, ETag, 하루 캐시). 지금:
  `en ko ja zh yue es pt id hi th de ru fr vi tr it ar fil ms nl pl sv uk he fa bn ur ta te pa mr sw el cs ro hu fi da no km my mn kk zxx mis`
  (`zxx` = 연주곡·가사 없음 — 혼자만, `mis` = 기타 언어). 코드에는 **지역이 없습니다**: `pt-BR`이 아니라 `pt`, `zh-TW`가 아니라 `zh`.
- 언어 이름은 그 언어로 쓴 자기 이름을 그대로 보여 주세요(목록에 있음: `한국어`, `日本語`, `ไทย` …). `zxx`·`mis`만 이름이 없고(`"key":"songlang.zxx"`) 내 언어팩에서 번역합니다.
- 아티스트가 아무것도 고르지 않으면 기본값 = 사이트 기본 언어(`-` 앞부분). `langs`를 보내지 않으면 aimusiclist도 같은 값으로 봅니다.
- 듣는 사람은 "언어가 틀려요"를 신고할 수 있습니다. aimusiclist 관리자가 곡 언어를 고치면 그 값이 우선합니다(관리자가 고정을 풀 때까지). 계속 틀리게 표시하는 사이트는 언어별 차트에서 제외될 수 있습니다.

### 3b. SNS `site.socials` (catalog 1.2)

- aimusiclist는 SNS의 **종류(키)만** 받고 주소는 받지 않습니다. aimusiclist의 아이콘은 **내 사이트의** SNS 모음 화면 `{site.url}/#/links/{key}`(🔗는 `{site.url}/#/links/more`)로 보내므로, 내 사이트는 그 화면에 SNS 주소를 보여 줘야 합니다.
- 키 — 큰 5개(aimusiclist에 아이콘으로, 늘 이 순서): `youtube instagram tiktok facebook x`.
  그 밖의 SNS(aimusiclist에는 🔗 하나로): `threads soundcloud spotify applemusic bandcamp suno udio discord twitch line whatsapp telegram kakao bilibili patreon bluesky vk weibo`.
- `socials` 순서: 쓰는 큰 5개 먼저, 그다음 다른 SNS. 최대 10개. 모르는 키는 무시합니다.
- 내 관리 화면은 각 SNS의 공식 도메인 `https://` 주소만 받게 하세요(예: YouTube는 `youtube.com`, `youtu.be`).

### 3c. 추가 정보 `ext`와 `fields.json` (catalog 1.3)

새 선택 항목(예: **분위기 mood**)은 파일 하나로 알려 주므로, 항목이 생길 때마다 사이트 코드를 고칠 필요가 없습니다.

- `GET https://aimusiclist.com/api/v1/fields.json` (공개, CORS `*`, ETag, `Cache-Control: public, max-age=3600`, 64KB 이하, 데이터만):
  ```json
  {"schema":"fields-1","version":1,"updated_at":"2026-10-28T00:00:00Z","fields":[
    {"key":"mood","scope":"track","type":"multiselect","max_select":2,"required":false,
     "label":{"en":"Mood","ko":"분위기"},"help":{"en":"Up to 2","ko":"최대 2개"},
     "options":[{"key":"happy","label":{"en":"Happy","ko":"신나는"}},{"key":"calm","label":{"en":"Calm","ko":"잔잔한"}}],
     "display":{"track_row":true,"detail":true,"filter":true,"order":10},"since":1,"status":"active"}]}
  ```
- 형식: `text`(글자, `max_length` ≤ 200) · `number`(`min`/`max`) · `bool` · `select`(옵션 키 1개) · `multiselect`(옵션 키 배열, `max_select` ≤ 5). `scope`가 `track`이면 `tracks[].ext`, `site`면 `site.ext`.
- 글자: 내 화면 언어로, 없는 언어는 `en`. 항상 이스케이프해서 출력하세요(HTML이 아니라 데이터).
- **하루 1번**, 사이트마다 흩어진 시각(예: 분 = hash(site_id) mod 1440)에 `If-None-Match`로 받습니다. 실패·잘못된 JSON·64KB 초과·모르는 `schema`(예 `fields-2`)면 마지막 정상본을 계속 씁니다. 모르는 `type`은 그 항목만 건너뛰고 "이 항목은 프로그램 업데이트 후 쓸 수 있습니다"를 보여 주세요.
- 새 항목은 항상 선택 사항입니다. `status: "deprecated"` = 입력란은 숨기되 있는 값은 계속 보냅니다. 키·옵션 키는 바꾸거나 다시 쓰지 않습니다.
- 내 사이트에서만 쓰는 칸은 별도 `local` 객체에 두고 catalog.json으로 **보내지 않습니다**.
- 값: 아티스트가 채운 것만 보냅니다. aimusiclist는 모든 값을 fields.json으로 검사해 틀린 값만 버립니다.
- aimusiclist는 `display.track_row` 항목을 장르 옆 회색 글자로, `display.filter` 항목을 목록 필터 칩으로 보여 줍니다. **추가 정보는 순위 점수에 쓰지 않습니다.**
- **경계**: 공통 입력 엔진(aimusiclist 개인사이트 코드: Workers판·PHP판·메쉬멜로 호스팅)을 쓰는 사이트는 새 항목이 자동으로 생깁니다. **직접 만든 업로드 화면**(자체 PHP, WordPress 등)은 aimusiclist 연동은 계속 되지만, 새 칸은 개발자가 fields.json을 읽는 코드를 붙여야 생깁니다. 공통 입력 엔진 사용을 권장합니다.

### 3d. 개인사이트 프로그램 새 버전 `versions.json`

- `GET https://aimusiclist.com/api/v1/versions.json` (공개, CORS `*`, 하루 캐시):
  `{"schema":"versions-1","personal_site":{"latest":"3.8.0","min_supported":"3.5.0","update_url":"https://…","notes_url":null}}`
- 설치된 버전이 `latest`보다 낮으면 관리 화면 머리에 "새 버전이 있습니다" 한 줄을 보여 주세요(강제 아님).

---

## 4. 장르

- 목록: `GET https://aimusiclist.com/api/v1/genres.json` (공개, CORS `*`, 하루 캐시)
  ```json
  {"schema":"1.0","rules":{"max_genres":3,"first_must_be_main":true},
   "genres":[{"key":"rnb","names":{"en":"R&B","ko":"알앤비"},
              "subs":[{"key":"neo_soul","names":{"en":"Neo soul","ko":"네오 소울"}}]}]}
  ```
- `genres`에는 **키**(`"rnb"`)를 넣고, 표시 이름(`"R&B"`, `"알앤비"`)은 넣지 않습니다.
- `genres[0]`은 반드시 **메인** 키(맨 위 단계 `key`). 세부 키(예 `"kpop"`)를 넣으면 → 그 곡 거부.
- `genres[1]`, `genres[2]`(선택) = 메인·세부 키 아무거나. 중복 금지. 모두 합쳐 최대 3개.
- 지금 메인 키: `pop rnb hiphop rock electronic dance ballad jazz classical folk latin soundtrack ambient world`. 최신 목록은 항상 genres.json에서 읽으세요(키는 바꾸거나 지우지 않습니다).
- aimusiclist에서 받아 쓰는 설정 파일(`genres.json`, `song-langs.json`, `fields.json`, `default-images.json`, `versions.json`)은 하루 1번만, `If-None-Match`로 받고, aimusiclist에 닿지 않으면 마지막 것을 계속 쓰세요.

---

## 5. 자동 등록 — `POST https://aimusiclist.com/api/v1/sites/auto`

회원가입·로그인 없음. 처음 곡을 공개할 때(또는 설치 직후) 보냅니다. 응답은 최대 20초 기다립니다. 요청을 처리하는 동안 aimusiclist가 내 verify 주소를 부르므로 **내 서버는 동시에 다른 요청에 응답할 수 있어야 합니다.**

요청 (`Content-Type: application/json`):
```json
{ "url": "https://music.example.com", "name": "Hip2pm", "artist_name": "Hip2pm",
  "contact_email": "owner@example.com", "default_lang": "en",
  "site_secret": "소문자 16진수 64자리" }
```

| 필드 | 규칙 |
|---|---|
| `url` | `https://` + 호스트만. 경로·쿼리·포트·IP 주소 불가, 200자 이내. 주소 하나에 등록 하나 |
| `name`, `artist_name` | 1~40자 |
| `contact_email` | 선택(권장). 공개하지 않고 차단·응답 없음 알림에만 씀 |
| `default_lang` | 예 `en` (없으면 `en`) |
| `site_secret` | 16진수 64자리. 안전한 무작위로 한 번 만듦(PHP `bin2hex(random_bytes(32))`, JS `crypto.getRandomValues`). 서버에만 보관 |

성공: `201 {"ok":true,"site_id":"s_ez57qni8","renewed":false,"status":"active"}` → **`site_id`를 바로 저장**하고 catalog.json에 넣습니다. aimusiclist는 약 3초 뒤(늦어도 1~2분 안) catalog를 가져갑니다.

**다시 등록**(재설치, 비밀키 분실): 새 비밀키를 만들어 같은 `url`로 다시 보내면 됩니다. verify를 통과하면 `site_id`는 그대로, 비밀키만 바뀌고 `201` + `"renewed": true`.

오류 (본문 `{"ok":false,"error":{"code":"…","params":{…}}}`):

| HTTP | code | 뜻 / 할 일 |
|---|---|---|
| 400 | `err.site.url_invalid` | 주소 형식 오류(http, 경로, 포트, IP…) |
| 400 | `err.validation` | 필드 오류, `params.field`가 어느 필드인지 알려 줌 |
| 429 | `err.rate_limited` | 같은 url을 10분 안에 다시 보냄(실패한 시도도 셈), 또는 이번 시간 새 사이트가 너무 많음. `params.retry_after`초(`Retry-After` 머리글) 기다리기 |
| 403 | `err.site.suspended` | 관리자 차단. 다시 등록해도 풀리지 않음 |
| 422 | `err.site.verify_failed` | proof가 맞지 않음(6장) |
| 422 | `err.catalog.too_large` | verify 응답이 1MB 초과 |
| 503 | `err.site.offline` | verify에 연결 못 함(시간 초과, DNS, 인증서, 리다이렉트, HTTP 오류) |
| 503 | 내 사이트의 코드 | verify가 `{"ok":false,"error":{"code":"err.site.not_configured"}}`를 돌려주면 그 코드를 그대로 전달 |

내 사이트의 재시도: 실패하면 10분마다, 하루 최대 20번.

---

## 6. verify (주인 확인) — `GET /.well-known/aimusiclist/verify?c={challenge}`

- `c` = aimusiclist가 만든 무작위 값(지금은 16진수 32자리, `[A-Za-z0-9_-]{8,128}`을 받으면 됨).
- 응답 `200`, `Content-Type: application/json`, `Cache-Control: no-store`:
  `{"site_id": "<내 site_id, 첫 등록 전이면 null>", "proof": "<16진수>"}`
- **proof = HMAC-SHA256(키 = site_secret, 메시지 = "verify:" + c)**, 소문자 16진수 64자리.
- 키는 64자리 비밀키 **글자 그대로**(문자열의 UTF-8 바이트)입니다. 16진수를 바이트로 **바꾸지 마세요.**
- 아직 비밀키가 없으면: `503 {"ok":false,"error":{"code":"err.site.not_configured"}}`.

PHP:
```php
<?php
$c = $_GET['c'] ?? '';
if (!preg_match('/^[A-Za-z0-9_-]{8,128}$/', $c)) { http_response_code(400); exit; }
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode(['site_id' => $siteId ?: null,                 // 저장해 둔 값
                  'proof'   => hash_hmac('sha256', 'verify:' . $c, $siteSecret)]);
```

JavaScript (Cloudflare Workers, Deno, Node 18+):
```js
async function hmacHex(secret, message) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey('raw', enc.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message));
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
// const proof = await hmacHex(siteSecret, 'verify:' + c);
// return Response.json({ site_id: siteId || null, proof }, { headers: { 'cache-control': 'no-store' } });
```
시험 값: secret = `"0123456789abcdef"`를 4번 이은 64자, c = `"abc123xyz0000000"` →
proof = `a7ebecd185ed00ae76912b177af4ba1dc0f3a67f57d546bbd473831840162f6c`

---

## 7. ping — `POST https://aimusiclist.com/api/v1/ping`

catalog.json이 바뀌는 **모든** 변경(올리기, 수정, 공개/비공개, 삭제) 뒤에 보냅니다. 여러 파일을 올리면 끝난 뒤 **한 번만**.

```text
POST /api/v1/ping
Authorization: Bearer {site_secret}
Content-Type: application/json

{"site_id":"s_ez57qni8"}
```

| HTTP | 본문 | 뜻 |
|---|---|---|
| 200 | `{"ok":true,"status":"updated","tracks":24}` / `"not_modified"` | 바로 가져감 |
| 202 | `{"ok":true,"status":"queued"}` (+ `error`) | 몇 분 안에 다시 시도 |
| 401 | `err.auth.invalid_secret` | 비밀키 틀림 → 다시 등록(5장) |
| 404 | `err.site.not_found` | 모르는 site_id → 다시 등록 |
| 403 | `err.site.suspended` | 차단됨 |
| 429 | `err.rate_limited` | 사이트당 분당 6회 또는 시간당 60회 초과 |

ping이 실패하면 10초 뒤, 그다음 1분쯤 뒤 한 번 더. ping은 즉시 HTTP 202를 반환하고 서버 호출은 대기열에서 수행합니다. 정기 가져가기는 6시간마다 사이트별로 다른 시각에 분산하며, 고정된 4개 시각에 일괄 요청하지 않습니다. 변경 확인에는 `If-None-Match`를 사용합니다.

---

## 8. 주소 바꾸기(개인 도메인) — `POST https://aimusiclist.com/api/v1/sites/{site_id}/url`

```text
Authorization: Bearer {site_secret}
{"url":"https://mymusic.com"}
```
1. 새 도메인이 **같은 비밀키**로 verify(6장)에 이미 응답해야 합니다.
2. aimusiclist가 새 주소에서 verify를 확인. 통과 → `200 {"ok":true,"site_id":"…","url":"https://mymusic.com","changed":true}` 그리고 새 주소에서 catalog를 가져감.
3. 그 뒤로 catalog의 `site.url`과 모든 `*_url`은 새 주소를 써야 합니다.
4. 옛 주소도 `/.well-known/`, `/a/`, `/c/`, `/i/`, `/l/`에는 계속 응답해야 합니다(저장된 플레이리스트가 그 주소를 씀). 방문자 화면만 301로 옮겨도 됩니다.

오류: `401`, `404`, `400 err.site.url_invalid`, `409 err.site.url_taken`(다른 사이트가 쓰는 주소), `422 err.site.verify_failed`, `503`, `403 err.site.suspended`, `429`(시간당 5회).

---

## 9. 앨범

- 내 사이트의 앨범은 몇 곡이어도 되지만, catalog.json에는 **10~30곡**인 앨범만 넣습니다.
- `track_ids`의 모든 id는 `tracks`에도 있어야 합니다(앨범을 공개하면 소속 곡도 공개).
- aimusiclist가 일부 곡을 거부해서 10곡 미만이 남으면 그 앨범은 숨겨집니다.
- aimusiclist 첫 화면은 10곡 이상인 새 앨범을 소개합니다. 플레이리스트도 10곡 이상이어야 소개됩니다.

## 10. 기본 곡 이미지

- aimusiclist가 무료 곡 이미지 9,963장(100×100 WebP)을 제공합니다: `https://aimusiclist.com/img/default/00001.webp` … `09963.webp`
- `GET https://aimusiclist.com/api/v1/default-images.json` → `{"count":9963,"base":"https://aimusiclist.com/img/default/","ext":".webp"}`
- 곡을 올릴 때 1…count 중 무작위 번호를 골라 5자리로 앞에 0을 채워 `thumb_url` = base + 번호 + ext로 저장. 요청이 실패하면 count 9963을 씁니다. 그 곡은 번호를 계속 유지합니다.
- `thumb_url`이 없으면 aimusiclist가 자기 곡 번호로 기본 이미지를 고릅니다.
- 내 이미지를 쓰려면 `/i/{id}`(100×100 WebP, 50KB 이하)로 제공하고 `thumb_url`에 그 주소를 넣습니다.

## 11. 음원과 파일

| 항목 | 규칙 |
|---|---|
| 음원 | MP3(`audio/mpeg`) 또는 M4A/AAC(`audio/mp4`), 15MB 이하, 30초~15분 |
| `duration` | 실제 길이와 같게(정수 초) |
| 곡 이미지 | 100×100 WebP, 50KB 이하 |
| 앨범 커버 | 200×200 WebP, 100KB 이하 |
| 프로필 | 200×200 WebP, 100KB 이하, 진짜 WebP가 아니면 무시 |
| 가사 | `.srt`, UTF-8, 100KB 이하 |
| 공개곡 | 사이트당 1,000곡 이하 |

## 12. 호출 제한과 안전

| 대상 | 제한 | 넘으면 |
|---|---|---|
| `POST /sites/auto` | 같은 url 10분에 1회, aimusiclist 전체 시간당 새 사이트 200개 | `429` |
| `POST /ping` | 사이트당 분당 6회, 시간당 60회 | `429` |
| `POST /sites/{id}/url` | 사이트당 시간당 5회 | `429` |
| aimusiclist → 내 사이트 | 사이트당 10초에 1번까지 | 대기열에서 기다림 |
| 공개 GET API | IP당 분당 60회 | `429` |
| 검사 도구 | IP당 10분에 10회 | `429` |

aimusiclist가 내 사이트를 가져가는 방식: HTTPS만, 올바른 인증서, 리다이렉트 안 따라감, 공개 IP만, 1MB 이하, 시간 제한 3/5/10초, `User-Agent: aimusiclist-fetch/1.0 (+https://aimusiclist.com)`. 이 이름을 막지 마세요(`/.well-known/aimusiclist/*`에는 봇 확인 화면 끄기).

**응답 없음**: 정기 가져가기가 실패하면 5분, 30분 뒤 다시 시도합니다. 3회차 연속 실패(약 12시간)하면 사이트가 `hidden`(곡이 사라짐), 다음에 성공하면 바로 다시 보입니다. 30일 동안 실패하면 `contact_email`로 알립니다.

## 13. 권리·신고·차단

- **배포할 권리가 있는 곡만** 공개하세요(내가 만들었고, 쓴 AI 도구의 약관이 공개 배포를 허락함). 남의 곡·커버곡을 올리면 안 됩니다.
- 로그인한 aimusiclist 회원은 곡을 신고할 수 있습니다. 서로 다른 신고자 3명(가입 24시간 지난 계정)이면 관리자가 판단할 때까지(3일 안) 숨깁니다.
- 차단된 곡은 앞으로 가져가기에서 계속 무시됩니다. 도용 판정 3회 → 사이트 전체 `suspended`.
- 쓰레기 사이트(깨진 파일, 무음, 잡음, 음악이 아닌 것, 스팸, 속이는 정보)는 바로 `suspended`될 수 있습니다.
- 차단된 사이트는 다시 등록해도 풀리지 않습니다. 이의 제기는 `contact_email`로 받은 메일에 답장합니다.

## 14. 순위가 매겨지는 방식 (순위 산정 방식 v2.0 요약)

- **aimusiclist.com에서 재생된 것**만 셉니다. 내 사이트에서 들은 것은 세지 않습니다. 좋아요(♥)와 추가 정보는 **순위에 쓰지 않습니다**.
- 유효 재생 1회 = **실제로 30초 이상 들음**(건너뛴 시간 제외), 서버가 서명한 재생 시작표로 확인.
- 반복 재생이 아니라 **서로 다른 청취자**를 셉니다: 같은 청취자(회원 또는 기기)는 **같은 곡을 24시간에 1회**, 하루 최대 50곡까지. 주간 차트에서 한 사람이 한 곡에 줄 수 있는 점수는 **최대 3일분**. 아티스트 본인도 청취자 1명입니다.
- 가중치: 이메일 인증 회원 1.0, 그 밖의 회원·비회원 0.5, 가입 24시간 미만 ×0.5, 위치를 알 수 없는 재생 ×0.5, 회원 플레이리스트 담기 2.0(회원·곡마다 평생 1회).
- **나라 균형**(펜로즈 제곱근 방식): 청취자는 한 기간에 한 본국에만 속하고, 글로벌 점수는 나라마다 점수를 그 나라 활동 청취자 수의 제곱근으로 나눠 더합니다. 청취자가 4배 많은 나라는 2배의 영향력. 청취자가 아주 적은 나라들은 한 지역으로 묶습니다.
- 차트: **Global Top 100**, **나라별 Top 100**(그 나라 청취자가 충분할 때), **언어별 Top 100**(곡 주 언어), **장르별 Top 100**(장르 화면, 기본 정렬 "인기순"), **Rising 50**(처음 확인 28일 이내 곡).
- 기간(UTC): **주간 = 금요일 00:00 ~ 목요일 24:00**(대표 차트, 매주 금요일 00:30 UTC = 한국시간 09:30 확정), 일간 = UTC 하루, 매일 00:30 UTC 계산. 실시간 차트는 없습니다.
- 차트에 들어가려면 서로 다른 청취자가 일정 수 이상이어야 합니다(숫자는 비공개). 오래 머문 곡은 내려옵니다: 20주 뒤 50위 밖, 26주 뒤 25위 밖, 52주 뒤 10위 밖, 78주 뒤 5위 밖(주간 Global·나라·장르).
- 순위 옆 변동: ▲n / ▼n / – / `NEW`(이 차트 처음) / `RE`(빠졌다가 다시). 차트의 모든 곡에 청취자 수·청취 나라 수를 보여 줍니다.
- 이상한 곡은 **보류**하고 검토합니다: 아주 적은 네트워크에 몰린 재생, 새 계정 과다, 다른 곡은 듣지 않는 청취자 무리, 관련 없는 한 나라에서 갑자기 몰림.
- 지금 catalog에 있고 사이트가 `active`인 곡만 보여 줍니다(catalog에서 빼면 순위에서도 바로 빠짐).
- **조작 금지**(봇, 재생 농장, 가짜 계정 여러 개, 돈 받고 재생, 자동 반복 재생, VPN으로 나라 바꾸기). 조작된 곡은 보류되고 사이트가 차단될 수 있습니다.
- 방법 공개: `https://aimusiclist.com/charts/methodology` · 규칙(한국어 원문): `https://aimusiclist.com/developers/aimusiclist-ranking.ko.md`

## 15. 흔한 실수

1. HMAC 키를 16진수 바이트로 바꿈, 또는 메시지에 `verify:`를 빠뜨림 → `err.site.verify_failed`. 비밀키 글자를 그대로 쓰세요.
2. 어디든 리다이렉트(http→https, `www`↔`www` 없음, 끝의 `/`, `/catalog.json` → `/catalog.json/`) → 응답 없음 처리. aimusiclist는 리다이렉트를 따라가지 않습니다.
3. `site.url`이 등록한 origin과 다름(`www`, 경로, `http`, 포트) → 목록 전체 거부.
4. 등록 뒤에도 `site_id`가 `null`이거나 옛 값 → 목록 전체 거부. `201` 응답의 id부터 저장하세요.
5. `audio_url`이 R2/CDN/S3 주소이거나 `?쿼리`가 붙음 → 곡 거부. 반드시 `{site.url}/a/{id}`.
6. `duration`이 소수나 글자 → 곡 거부. 정수로 반올림하세요.
7. 첫 장르에 표시 이름(`"R&B"`)이나 세부 장르(`"kpop"`) → 곡 거부.
8. 10곡 미만·30곡 초과 앨범, 또는 `tracks`에 없는 id → 앨범 거부.
9. `/a/`에 CORS 없음(비주얼라이저 안 움직임) 또는 `/l/`에 없음(가사 안 열림). `OPTIONS`도 잊지 마세요.
10. catalog.json 대신 HTML 화면이 나옴(SPA 대체 화면, 404 화면, 로그인 화면, 봇 확인) → 거부.
11. 비밀키가 HTML/JS, catalog.json, 공개 Git 저장소에 보임. 비밀키를 가진 사람은 ping과 주소 변경을 할 수 있습니다.
12. 여러 곡을 올리면서 파일마다 ping, 또는 반복해서 ping → `429`.
13. 곡 id를 바꿈(예: 파일 이름으로 만듦) → 새 곡으로 처리되어 순위 기록이 사라짐.
14. 비공개로 할 곡을 catalog.json에 그대로 둠 — 공개 여부는 catalog가 정합니다.
15. `/sites/auto`를 기다리는 동안 verify에 응답하지 못하는 서버(요청을 하나씩만 받는 개발용 서버) → 시간 초과.
16. (1.2) `site.socials`에 SNS **주소**(`"https://youtube.com/@me"`) → 무시. 키(`"youtube"`)를 보내고 주소는 내 사이트 `#/links` 화면에 보여 주세요.
17. (1.2) `langs`에 화면 언어 코드(`"pt-BR"`, `"zh-TW"`) → 무시. `pt`, `zh`를 쓰세요. `zxx`를 다른 코드와 함께 → `zxx`가 버려짐.
18. (1.3) 지어낸 `ext` 키·옵션 키, 또는 내 사이트 전용 칸을 `ext`에 넣음 → 무시. 전용 칸은 `local`(보내지 않음)에.
19. (1.3) 화면을 열 때마다·1분마다 fields.json을 받음 — 하루 1번 받아 저장해 두세요.

## 16. 직접 확인하는 법

- 웹: **`https://aimusiclist.com/developers/check`** — 내 사이트 주소를 넣으세요. 실제 가져가기와 같은 검사로 catalog를 확인하고, verify(형식만, 비밀키 필요 없음)와 음원 1개(HEAD + CORS)를 시험합니다. 아무것도 저장하지 않고 등록도 하지 않습니다.
- API (같은 결과를 JSON으로):
  ```bash
  curl -s -X POST https://aimusiclist.com/api/v1/tools/check-catalog \
    -H 'Content-Type: application/json' \
    -d '{"url":"https://music.example.com","verify":true}'
  ```
  응답: `{"ok":true,"result":"pass|warn|error","summary":{"pass":n,"warn":n,"error":n},"checks":[{"status":"error","code":"chk.track.duration","where":{"kind":"track","id":"t_01","field":"duration"},"params":{"value":"221.4"}}, …]}`. 각 `code`의 설명과 고치는 법은 웹 화면에 나옵니다.
- HMAC은 6장의 시험 값으로 확인하세요.
- 내 catalog: `curl -i https://your.site/.well-known/aimusiclist/catalog.json` 결과가 `HTTP/… 200`과 `content-type: application/json`으로 시작해야 합니다.

## 17. 변경 기록

| 버전 | 날짜 | 바뀐 점 |
|---|---|---|
| v1.1 | 2026-10-28 | 첫 공개 규약집. aimusiclist.com에 실제 구현된 연동규약 v1.1(자동 등록, verify `?c=`, `thumb_url`·`lyrics_url`·`profile_url`이 있는 catalog 1.1, 주소 변경, 기본 이미지)과 순위 v1.2 기준 |
| v1.2 | 2026-10-29 | catalog 1.2: `tracks[].langs`(곡 언어, `song-langs.json`), `site.socials`(SNS 종류 키만, SNS 모음 화면 `#/links/{key}`). 순위 v2.0 요약(나라 균형, 나라·언어·장르·Rising 차트, UTC 금~목 주간, NEW/RE) |
| v1.3 | 2026-10-29 | catalog 1.3: `tracks[].ext`·`site.ext`와 `fields.json`(첫 항목 `mood`), `local`은 보내지 않음, `versions.json`, 직접 만든 업로드 화면의 경계. 고정 주소는 그대로 |
