From ac42fe110713468b85b984b4ed3cc07bc4a69dda Mon Sep 17 00:00:00 2001 From: Saigz Date: Fri, 9 Jan 2026 22:38:56 +1000 Subject: [PATCH 01/13] feat(workflows): music-release-tracker init --- .github/workflows/music-release-tracker.yaml | 42 +++++++ .../music-release-tracker/apple-music/main.py | 105 ++++++++++++++++++ scripts/music-release-tracker/spotify/main.py | 100 +++++++++++++++++ .../yandex-music/main.py | 101 +++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 .github/workflows/music-release-tracker.yaml create mode 100644 scripts/music-release-tracker/apple-music/main.py create mode 100644 scripts/music-release-tracker/spotify/main.py create mode 100644 scripts/music-release-tracker/yandex-music/main.py diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml new file mode 100644 index 0000000..6406c97 --- /dev/null +++ b/.github/workflows/music-release-tracker.yaml @@ -0,0 +1,42 @@ +name: Build music dict + +on: + workflow_dispatch: + +jobs: + build-music-dict: + runs-on: ubuntu-latest + env: + ARTIST: "Imagine Dragons" + TRACKS: | + Radioactive + Demons + Warriors + Bones + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Yandex Music + run: | + python scripts/music-release-tracker/yandex-music/main.py + cat yandex_dict.yaml + + - name: Spotify + env: + SPOTIFY_TOKEN: ${{ secrets.SPOTIFY_TOKEN }} + run: | + echo "TODO: get spotify token" + # python scripts/music-release-tracker/spotify/main.py + # cat spotify_dict.yaml + + - name: Apple Music + run: | + python scripts/music-release-tracker/apple-music/main.py + cat apple_music_dict.yaml diff --git a/scripts/music-release-tracker/apple-music/main.py b/scripts/music-release-tracker/apple-music/main.py new file mode 100644 index 0000000..31f90ab --- /dev/null +++ b/scripts/music-release-tracker/apple-music/main.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request + + +def norm(s: str) -> str: + return " ".join(s.lower().split()) + + +def fetch_search(country: str, artist: str, title: str) -> dict: + term = f"{artist} {title}" + q = urllib.parse.quote(term) + params = { + "term": term, + "media": "music", + "entity": "song", + "country": country, + "limit": "50", + } + qs = urllib.parse.urlencode(params) + url = f"https://itunes.apple.com/search?{qs}" + + req = urllib.request.Request( + url, + headers={ + "User-Agent": "music-dict-ci", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req) as resp: + data = resp.read() + return json.loads(data) + + +def is_exact_match(artist: str, title: str, payload: dict) -> bool: + results = payload.get("results", []) + + n_artist = norm(artist) + n_title = norm(title) + + for item in results: + if norm(item.get("trackName", "")) != n_title: + continue + if norm(item.get("artistName", "")) != n_artist: + continue + return True + + return False + + +def build_dict(country: str, artist: str, tracks: list[str]) -> dict: + result = {"apple_music": {}} + + for title in tracks: + try: + payload = fetch_search(country, artist, title) + found = is_exact_match(artist, title, payload) + except Exception: + found = False + result["apple_music"][title] = [1 if found else 0] + + return result + + +def to_yaml(data: dict) -> str: + lines = [] + for platform, mapping in data.items(): + lines.append(f"{platform}:") + for track_name, value in mapping.items(): + safe = track_name.replace('"', '\\"') + lines.append(f' "{safe}": [{value[0]}]') + return "\n".join(lines) + + +def parse_tracks(arg: str) -> list[str]: + return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] + + +def main() -> None: + artist = os.getenv("ARTIST", "").strip() + tracks_raw = os.getenv("TRACKS", "") + country = os.getenv("APPLE_COUNTRY", "US").strip() or "US" + + if not artist or not tracks_raw.strip(): + sys.stderr.write("ARTIST и TRACKS должны быть заданы в env\n") + sys.exit(1) + + tracks = parse_tracks(tracks_raw) + if not tracks: + sys.stderr.write("TRACKS пустой после парсинга\n") + sys.exit(1) + + data = build_dict(country, artist, tracks) + yaml_str = to_yaml(data) + + with open("apple_music_dict.yaml", "w", encoding="utf-8") as f: + f.write(yaml_str) + + +if __name__ == "__main__": + main() diff --git a/scripts/music-release-tracker/spotify/main.py b/scripts/music-release-tracker/spotify/main.py new file mode 100644 index 0000000..d72b6fb --- /dev/null +++ b/scripts/music-release-tracker/spotify/main.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request + + +def norm(s: str) -> str: + return " ".join(s.lower().split()) + + +def fetch_search(token: str, artist: str, title: str) -> dict: + query = f'track:"{title}" artist:"{artist}"' + q = urllib.parse.quote(query) + url = f"https://api.spotify.com/v1/search?type=track&limit=50&q={q}" + req = urllib.request.Request( + url, + headers={ + "User-Agent": "music-dict-ci", + "Accept": "application/json", + "Authorization": f"Bearer {token}", + }, + ) + with urllib.request.urlopen(req) as resp: + data = resp.read() + return json.loads(data) + + +def is_exact_match(artist: str, title: str, payload: dict) -> bool: + tracks = payload.get("tracks", {}).get("items", []) + + n_artist = norm(artist) + n_title = norm(title) + + for track in tracks: + if norm(track.get("name", "")) != n_title: + continue + artists = [norm(a.get("name", "")) for a in track.get("artists", [])] + if n_artist not in artists: + continue + return True + + return False + + +def build_dict(token: str, artist: str, tracks: list[str]) -> dict: + result = {"spotify": {}} + for title in tracks: + try: + payload = fetch_search(token, artist, title) + found = is_exact_match(artist, title, payload) + except Exception: + found = False + result["spotify"][title] = [1 if found else 0] + return result + + +def to_yaml(data: dict) -> str: + lines = [] + for platform, mapping in data.items(): + lines.append(f"{platform}:") + for track_name, value in mapping.items(): + safe = track_name.replace('"', '\\"') + lines.append(f' "{safe}": [{value[0]}]') + return "\n".join(lines) + + +def parse_tracks(arg: str) -> list[str]: + return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] + + +def main() -> None: + token = os.getenv("SPOTIFY_TOKEN", "").strip() + artist = os.getenv("ARTIST", "").strip() + tracks_raw = os.getenv("TRACKS", "") + + if not token: + sys.stderr.write("SPOTIFY_TOKEN должен быть задан в env\n") + sys.exit(1) + + if not artist or not tracks_raw.strip(): + sys.stderr.write("ARTIST и TRACKS должны быть заданы в env\n") + sys.exit(1) + + tracks = parse_tracks(tracks_raw) + if not tracks: + sys.stderr.write("TRACKS пустой после парсинга\n") + sys.exit(1) + + data = build_dict(token, artist, tracks) + yaml_str = to_yaml(data) + + with open("spotify_dict.yaml", "w", encoding="utf-8") as f: + f.write(yaml_str) + + +if __name__ == "__main__": + main() diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py new file mode 100644 index 0000000..83b2fae --- /dev/null +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request + + +def norm(s: str) -> str: + return " ".join(s.lower().split()) + + +def fetch_search(artist: str, title: str) -> dict: + query = urllib.parse.quote_plus(f"{artist} {title}") + url = f"https://api.music.yandex.net/search?type=track&text={query}&page=0&nococrrect=false" + req = urllib.request.Request( + url, + headers={ + "User-Agent": "Mozilla/5.0", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req) as resp: + data = resp.read() + return json.loads(data) + + +def is_exact_match(artist: str, title: str, payload: dict) -> bool: + tracks = ( + payload.get("result", {}) + .get("tracks", {}) + .get("results", []) + ) + + n_artist = norm(artist) + n_title = norm(title) + + for track in tracks: + version = track.get("version") or "" + if version: + continue + if norm(track.get("title", "")) != n_title: + continue + artists = [norm(a.get("name", "")) for a in track.get("artists", [])] + if n_artist not in artists: + continue + return True + + return False + + +def build_dict(artist: str, tracks: list[str]) -> dict: + result = {"yandex_music": {}} + for title in tracks: + try: + payload = fetch_search(artist, title) + found = is_exact_match(artist, title, payload) + except Exception: + found = False + result["yandex_music"][title] = [1 if found else 0] + return result + + +def to_yaml(data: dict) -> str: + lines = [] + for platform, mapping in data.items(): + lines.append(f"{platform}:") + for track_name, value in mapping.items(): + safe = track_name.replace('"', '\\"') + lines.append(f' "{safe}": [{value[0]}]') + return "\n".join(lines) + + +def parse_tracks_arg(arg: str) -> list[str]: + return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] + + +def main() -> None: + artist = os.getenv("ARTIST", "").strip() + tracks_raw = os.getenv("TRACKS", "") + + if not artist or not tracks_raw.strip(): + sys.stderr.write("ARTIST и TRACKS должны быть заданы в env\n") + sys.exit(1) + + tracks = parse_tracks_arg(tracks_raw) + + if not tracks: + sys.stderr.write("TRACKS пустой после парсинга\n") + sys.exit(1) + + data = build_dict(artist, tracks) + yaml_str = to_yaml(data) + + with open("yandex_dict.yaml", "w", encoding="utf-8") as f: + f.write(yaml_str) + + +if __name__ == "__main__": + main() From 76772a2eb91426b5a400cc71f7e3ed2c3be7f2a1 Mon Sep 17 00:00:00 2001 From: Saigz Date: Fri, 9 Jan 2026 22:40:10 +1000 Subject: [PATCH 02/13] feat(workflows): music-release-tracker trigger --- .github/workflows/music-release-tracker.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index 6406c97..3f063e4 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -2,6 +2,12 @@ name: Build music dict on: workflow_dispatch: + pull_request: + types: + - opened + - edited + - reopened + - synchronize jobs: build-music-dict: From 12c17f1cf69a03d23ed12eaa90dba989ea5ab550 Mon Sep 17 00:00:00 2001 From: Saigz Date: Fri, 9 Jan 2026 22:55:52 +1000 Subject: [PATCH 03/13] fix(music-release-tracker): yandex music script --- .github/workflows/music-release-tracker.yaml | 2 +- .../yandex-music/main.py | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index 3f063e4..2d30409 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -1,4 +1,4 @@ -name: Build music dict +name: Check music releases on: workflow_dispatch: diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py index 83b2fae..27403b8 100644 --- a/scripts/music-release-tracker/yandex-music/main.py +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -37,9 +37,6 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: n_title = norm(title) for track in tracks: - version = track.get("version") or "" - if version: - continue if norm(track.get("title", "")) != n_title: continue artists = [norm(a.get("name", "")) for a in track.get("artists", [])] @@ -52,12 +49,19 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: def build_dict(artist: str, tracks: list[str]) -> dict: result = {"yandex_music": {}} - for title in tracks: - try: - payload = fetch_search(artist, title) - found = is_exact_match(artist, title, payload) - except Exception: - found = False + for idx, title in enumerate(tracks): + payload = fetch_search(artist, title) + tracks_list = ( + payload.get("result", {}) + .get("tracks", {}) + .get("results", []) + ) + + sys.stderr.write( + f"[yandex] '{title}': got {len(tracks_list)} candidates\n" + ) + + found = is_exact_match(artist, title, payload) result["yandex_music"][title] = [1 if found else 0] return result From 8abd001901321c274dacd4bed15f1dd26746776a Mon Sep 17 00:00:00 2001 From: Saigz Date: Fri, 9 Jan 2026 23:18:48 +1000 Subject: [PATCH 04/13] fix(music-release-tracker): script exit code --- .github/workflows/music-release-tracker.yaml | 5 +++-- scripts/music-release-tracker/apple-music/main.py | 11 ++--------- scripts/music-release-tracker/spotify/main.py | 7 ++----- .../music-release-tracker/yandex-music/main.py | 15 ++++----------- 4 files changed, 11 insertions(+), 27 deletions(-) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index 2d30409..d7caf84 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -31,8 +31,9 @@ jobs: - name: Yandex Music run: | - python scripts/music-release-tracker/yandex-music/main.py - cat yandex_dict.yaml + echo "TODO: fix 451 error" + # python scripts/music-release-tracker/yandex-music/main.py + # cat yandex_dict.yaml - name: Spotify env: diff --git a/scripts/music-release-tracker/apple-music/main.py b/scripts/music-release-tracker/apple-music/main.py index 31f90ab..d47b550 100644 --- a/scripts/music-release-tracker/apple-music/main.py +++ b/scripts/music-release-tracker/apple-music/main.py @@ -13,7 +13,6 @@ def norm(s: str) -> str: def fetch_search(country: str, artist: str, title: str) -> dict: term = f"{artist} {title}" - q = urllib.parse.quote(term) params = { "term": term, "media": "music", @@ -23,7 +22,6 @@ def fetch_search(country: str, artist: str, title: str) -> dict: } qs = urllib.parse.urlencode(params) url = f"https://itunes.apple.com/search?{qs}" - req = urllib.request.Request( url, headers={ @@ -54,15 +52,10 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: def build_dict(country: str, artist: str, tracks: list[str]) -> dict: result = {"apple_music": {}} - for title in tracks: - try: - payload = fetch_search(country, artist, title) - found = is_exact_match(artist, title, payload) - except Exception: - found = False + payload = fetch_search(country, artist, title) + found = is_exact_match(artist, title, payload) result["apple_music"][title] = [1 if found else 0] - return result diff --git a/scripts/music-release-tracker/spotify/main.py b/scripts/music-release-tracker/spotify/main.py index d72b6fb..60ac47b 100644 --- a/scripts/music-release-tracker/spotify/main.py +++ b/scripts/music-release-tracker/spotify/main.py @@ -48,11 +48,8 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: def build_dict(token: str, artist: str, tracks: list[str]) -> dict: result = {"spotify": {}} for title in tracks: - try: - payload = fetch_search(token, artist, title) - found = is_exact_match(artist, title, payload) - except Exception: - found = False + payload = fetch_search(token, artist, title) + found = is_exact_match(artist, title, payload) result["spotify"][title] = [1 if found else 0] return result diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py index 27403b8..b60802f 100644 --- a/scripts/music-release-tracker/yandex-music/main.py +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -37,6 +37,9 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: n_title = norm(title) for track in tracks: + version = track.get("version") or "" + if version: + continue if norm(track.get("title", "")) != n_title: continue artists = [norm(a.get("name", "")) for a in track.get("artists", [])] @@ -49,18 +52,8 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: def build_dict(artist: str, tracks: list[str]) -> dict: result = {"yandex_music": {}} - for idx, title in enumerate(tracks): + for title in tracks: payload = fetch_search(artist, title) - tracks_list = ( - payload.get("result", {}) - .get("tracks", {}) - .get("results", []) - ) - - sys.stderr.write( - f"[yandex] '{title}': got {len(tracks_list)} candidates\n" - ) - found = is_exact_match(artist, title, payload) result["yandex_music"][title] = [1 if found else 0] return result From 89b26e81759ae621f2a84ca3ae9521e4bf2377f6 Mon Sep 17 00:00:00 2001 From: Saigz Date: Tue, 13 Jan 2026 03:16:03 +1000 Subject: [PATCH 05/13] feat(music-release-tracker): youtube music init --- .github/workflows/music-release-tracker.yaml | 14 +- dict.yaml | 17 +++ .../music-release-tracker/apple-music/main.py | 61 ++++++--- scripts/music-release-tracker/spotify/main.py | 61 ++++++--- .../yandex-music/main.py | 62 +++++---- .../youtube-music/main.py | 121 ++++++++++++++++++ 6 files changed, 269 insertions(+), 67 deletions(-) create mode 100644 dict.yaml create mode 100644 scripts/music-release-tracker/youtube-music/main.py diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index d7caf84..c43f6e7 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -33,7 +33,7 @@ jobs: run: | echo "TODO: fix 451 error" # python scripts/music-release-tracker/yandex-music/main.py - # cat yandex_dict.yaml + # cat dict.yaml - name: Spotify env: @@ -41,9 +41,17 @@ jobs: run: | echo "TODO: get spotify token" # python scripts/music-release-tracker/spotify/main.py - # cat spotify_dict.yaml + # cat dict.yaml - name: Apple Music run: | python scripts/music-release-tracker/apple-music/main.py - cat apple_music_dict.yaml + cat dict.yaml + + - name: YouTube Music + env: + YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} + run: | + python scripts/music-release-tracker/youtube-music/main.py + cat dict.yaml + diff --git a/dict.yaml b/dict.yaml new file mode 100644 index 0000000..7540172 --- /dev/null +++ b/dict.yaml @@ -0,0 +1,17 @@ +{ + "Radioactive": { + "youtube_music": 1, + "apple_music": 1, + "yandex_music": 1 + }, + "Demons": { + "youtube_music": 1, + "apple_music": 1, + "yandex_music": 1 + }, + "Warriors": { + "youtube_music": 1, + "apple_music": 1, + "yandex_music": 0 + } +} \ No newline at end of file diff --git a/scripts/music-release-tracker/apple-music/main.py b/scripts/music-release-tracker/apple-music/main.py index d47b550..1eb77ba 100644 --- a/scripts/music-release-tracker/apple-music/main.py +++ b/scripts/music-release-tracker/apple-music/main.py @@ -5,6 +5,11 @@ import sys import urllib.parse import urllib.request +from typing import Any + + +DICT_PATH = "dict.yaml" +PLATFORM = "apple_music" def norm(s: str) -> str: @@ -50,27 +55,23 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: return False -def build_dict(country: str, artist: str, tracks: list[str]) -> dict: - result = {"apple_music": {}} - for title in tracks: - payload = fetch_search(country, artist, title) - found = is_exact_match(artist, title, payload) - result["apple_music"][title] = [1 if found else 0] - return result +def parse_tracks(arg: str) -> list[str]: + return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] -def to_yaml(data: dict) -> str: - lines = [] - for platform, mapping in data.items(): - lines.append(f"{platform}:") - for track_name, value in mapping.items(): - safe = track_name.replace('"', '\\"') - lines.append(f' "{safe}": [{value[0]}]') - return "\n".join(lines) +def load_dict(path: str) -> dict[str, Any]: + if not os.path.exists(path): + return {} + with open(path, "r", encoding="utf-8") as f: + content = f.read().strip() + if not content: + return {} + return json.loads(content) -def parse_tracks(arg: str) -> list[str]: - return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] +def save_dict(path: str, data: dict[str, Any]) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) def main() -> None: @@ -87,11 +88,29 @@ def main() -> None: sys.stderr.write("TRACKS пустой после парсинга\n") sys.exit(1) - data = build_dict(country, artist, tracks) - yaml_str = to_yaml(data) + data: dict[str, Any] = load_dict(DICT_PATH) - with open("apple_music_dict.yaml", "w", encoding="utf-8") as f: - f.write(yaml_str) + for title in tracks: + try: + payload = fetch_search(country, artist, title) + found = is_exact_match(artist, title, payload) + status: Any = 1 if found else 0 + except Exception as e: + sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + status = "unknown" + + if status not in (0, 1, "unknown"): + status = "unknown" + + if title not in data: + data[title] = {} + entry = data[title] + if not isinstance(entry, dict): + entry = {} + data[title] = entry + entry[PLATFORM] = status + + save_dict(DICT_PATH, data) if __name__ == "__main__": diff --git a/scripts/music-release-tracker/spotify/main.py b/scripts/music-release-tracker/spotify/main.py index 60ac47b..4cb02cd 100644 --- a/scripts/music-release-tracker/spotify/main.py +++ b/scripts/music-release-tracker/spotify/main.py @@ -5,6 +5,11 @@ import sys import urllib.parse import urllib.request +from typing import Any + + +DICT_PATH = "dict.yaml" +PLATFORM = "spotify" def norm(s: str) -> str: @@ -45,27 +50,23 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: return False -def build_dict(token: str, artist: str, tracks: list[str]) -> dict: - result = {"spotify": {}} - for title in tracks: - payload = fetch_search(token, artist, title) - found = is_exact_match(artist, title, payload) - result["spotify"][title] = [1 if found else 0] - return result +def parse_tracks(arg: str) -> list[str]: + return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] -def to_yaml(data: dict) -> str: - lines = [] - for platform, mapping in data.items(): - lines.append(f"{platform}:") - for track_name, value in mapping.items(): - safe = track_name.replace('"', '\\"') - lines.append(f' "{safe}": [{value[0]}]') - return "\n".join(lines) +def load_dict(path: str) -> dict[str, Any]: + if not os.path.exists(path): + return {} + with open(path, "r", encoding="utf-8") as f: + content = f.read().strip() + if not content: + return {} + return json.loads(content) -def parse_tracks(arg: str) -> list[str]: - return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] +def save_dict(path: str, data: dict[str, Any]) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) def main() -> None: @@ -86,11 +87,29 @@ def main() -> None: sys.stderr.write("TRACKS пустой после парсинга\n") sys.exit(1) - data = build_dict(token, artist, tracks) - yaml_str = to_yaml(data) + data: dict[str, Any] = load_dict(DICT_PATH) - with open("spotify_dict.yaml", "w", encoding="utf-8") as f: - f.write(yaml_str) + for title in tracks: + try: + payload = fetch_search(token, artist, title) + found = is_exact_match(artist, title, payload) + status: Any = 1 if found else 0 + except Exception as e: + sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + status = "unknown" + + if status not in (0, 1, "unknown"): + status = "unknown" + + if title not in data: + data[title] = {} + entry = data[title] + if not isinstance(entry, dict): + entry = {} + data[title] = entry + entry[PLATFORM] = status + + save_dict(DICT_PATH, data) if __name__ == "__main__": diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py index b60802f..81b1178 100644 --- a/scripts/music-release-tracker/yandex-music/main.py +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -5,6 +5,11 @@ import sys import urllib.parse import urllib.request +from typing import Any + + +DICT_PATH = "dict.yaml" +PLATFORM = "yandex_music" def norm(s: str) -> str: @@ -50,27 +55,23 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: return False -def build_dict(artist: str, tracks: list[str]) -> dict: - result = {"yandex_music": {}} - for title in tracks: - payload = fetch_search(artist, title) - found = is_exact_match(artist, title, payload) - result["yandex_music"][title] = [1 if found else 0] - return result +def parse_tracks_arg(arg: str) -> list[str]: + return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] -def to_yaml(data: dict) -> str: - lines = [] - for platform, mapping in data.items(): - lines.append(f"{platform}:") - for track_name, value in mapping.items(): - safe = track_name.replace('"', '\\"') - lines.append(f' "{safe}": [{value[0]}]') - return "\n".join(lines) +def load_dict(path: str) -> dict[str, Any]: + if not os.path.exists(path): + return {} + with open(path, "r", encoding="utf-8") as f: + content = f.read().strip() + if not content: + return {} + return json.loads(content) -def parse_tracks_arg(arg: str) -> list[str]: - return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] +def save_dict(path: str, data: dict[str, Any]) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) def main() -> None: @@ -82,16 +83,33 @@ def main() -> None: sys.exit(1) tracks = parse_tracks_arg(tracks_raw) - if not tracks: sys.stderr.write("TRACKS пустой после парсинга\n") sys.exit(1) - data = build_dict(artist, tracks) - yaml_str = to_yaml(data) + data: dict[str, Any] = load_dict(DICT_PATH) - with open("yandex_dict.yaml", "w", encoding="utf-8") as f: - f.write(yaml_str) + for title in tracks: + try: + payload = fetch_search(artist, title) + found = is_exact_match(artist, title, payload) + status: Any = 1 if found else 0 + except Exception as e: + sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + status = "unknown" + + if status not in (0, 1, "unknown"): + status = "unknown" + + if title not in data: + data[title] = {} + entry = data[title] + if not isinstance(entry, dict): + entry = {} + data[title] = entry + entry[PLATFORM] = status + + save_dict(DICT_PATH, data) if __name__ == "__main__": diff --git a/scripts/music-release-tracker/youtube-music/main.py b/scripts/music-release-tracker/youtube-music/main.py new file mode 100644 index 0000000..d9053ca --- /dev/null +++ b/scripts/music-release-tracker/youtube-music/main.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request +from typing import Any + + +DICT_PATH = "dict.yaml" +PLATFORM = "youtube_music" + + +def norm(s: str) -> str: + return " ".join(s.lower().split()) + + +def fetch_search(api_key: str, artist: str, title: str) -> dict: + query = f"{artist} {title}" + params = { + "part": "snippet", + "type": "video", + "maxResults": "25", + "q": query, + "key": api_key, + } + url = "https://www.googleapis.com/youtube/v3/search?" + urllib.parse.urlencode(params) + req = urllib.request.Request( + url, + headers={ + "User-Agent": "music-dict-ci", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req) as resp: + data = resp.read() + return json.loads(data) + + +def is_exact_match(artist: str, title: str, payload: dict) -> bool: + items = payload.get("items", []) + + n_artist = norm(artist) + n_title = norm(title) + + for item in items: + snippet = item.get("snippet", {}) or {} + t = norm(snippet.get("title", "")) + channel = norm(snippet.get("channelTitle", "")) + + if n_title in t and (n_artist in t or n_artist in channel): + return True + + return False + + +def parse_tracks(arg: str) -> list[str]: + return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()] + + +def load_dict(path: str) -> dict[str, Any]: + if not os.path.exists(path): + return {} + with open(path, "r", encoding="utf-8") as f: + content = f.read().strip() + if not content: + return {} + return json.loads(content) + + +def save_dict(path: str, data: dict[str, Any]) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def main() -> None: + api_key = os.getenv("YOUTUBE_API_KEY", "").strip() + artist = os.getenv("ARTIST", "").strip() + tracks_raw = os.getenv("TRACKS", "") + + if not api_key: + sys.stderr.write("YOUTUBE_API_KEY должен быть задан в env\n") + sys.exit(1) + + if not artist or not tracks_raw.strip(): + sys.stderr.write("ARTIST и TRACKS должны быть заданы в env\n") + sys.exit(1) + + tracks = parse_tracks(tracks_raw) + if not tracks: + sys.stderr.write("TRACKS пустой после парсинга\n") + sys.exit(1) + + data: dict[str, Any] = load_dict(DICT_PATH) + + for title in tracks: + try: + payload = fetch_search(api_key, artist, title) + found = is_exact_match(artist, title, payload) + status: Any = 1 if found else 0 + except Exception as e: + sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + status = "unknown" + + if status not in (0, 1, "unknown"): + status = "unknown" + + if title not in data: + data[title] = {} + entry = data[title] + if not isinstance(entry, dict): + entry = {} + data[title] = entry + entry[PLATFORM] = status + + save_dict(DICT_PATH, data) + + +if __name__ == "__main__": + main() From 5999050d20153e8dbe673050b45dfa2eddb7631d Mon Sep 17 00:00:00 2001 From: Saigz Date: Tue, 13 Jan 2026 03:16:29 +1000 Subject: [PATCH 06/13] feat(music-release-tracker): action summary --- .github/workflows/music-release-tracker.yaml | 33 ++++++++++++++++++++ dict.yaml | 17 ---------- 2 files changed, 33 insertions(+), 17 deletions(-) delete mode 100644 dict.yaml diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index c43f6e7..4d95347 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -55,3 +55,36 @@ jobs: python scripts/music-release-tracker/youtube-music/main.py cat dict.yaml + - name: Music summary + run: | + python - << 'EOF' >> "$GITHUB_STEP_SUMMARY" + import json + + with open("dict.yaml", "r", encoding="utf-8") as f: + data = json.load(f) + + platforms = set() + for v in data.values(): + platforms.update(v.keys()) + platforms = sorted(platforms) + + def fmt_status(val): + if val == 1: + return "✅" + if val == 0: + return "❌" + return "⚠️ unknown" + + print("## Music availability\n") + header = "| Track | " + " | ".join(platforms) + " |" + sep = "| --- | " + " | ".join(["---"] * len(platforms)) + " |" + print(header) + print(sep) + + for track in sorted(data.keys()): + row = [track] + for p in platforms: + val = data[track].get(p, "unknown") + row.append(fmt_status(val)) + print("| " + " | ".join(row) + " |") + EOF diff --git a/dict.yaml b/dict.yaml deleted file mode 100644 index 7540172..0000000 --- a/dict.yaml +++ /dev/null @@ -1,17 +0,0 @@ -{ - "Radioactive": { - "youtube_music": 1, - "apple_music": 1, - "yandex_music": 1 - }, - "Demons": { - "youtube_music": 1, - "apple_music": 1, - "yandex_music": 1 - }, - "Warriors": { - "youtube_music": 1, - "apple_music": 1, - "yandex_music": 0 - } -} \ No newline at end of file From 990d187ddf182b3a4d2b79912949406cd8250c26 Mon Sep 17 00:00:00 2001 From: Saigz Date: Tue, 13 Jan 2026 03:18:00 +1000 Subject: [PATCH 07/13] feat(music-release-tracker): enable yandex music --- .github/workflows/music-release-tracker.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index 4d95347..9c9859c 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -31,9 +31,8 @@ jobs: - name: Yandex Music run: | - echo "TODO: fix 451 error" - # python scripts/music-release-tracker/yandex-music/main.py - # cat dict.yaml + python scripts/music-release-tracker/yandex-music/main.py + cat dict.yaml - name: Spotify env: From a83a2c59685576d2244a53ea7994bccd439c64d7 Mon Sep 17 00:00:00 2001 From: Saigz Date: Tue, 13 Jan 2026 15:57:10 +1000 Subject: [PATCH 08/13] feat(music-release-tracker): runs on self-hosted runner --- .github/workflows/music-release-tracker.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index 9c9859c..c5d994b 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -10,8 +10,8 @@ on: - synchronize jobs: - build-music-dict: - runs-on: ubuntu-latest + check-music-releases: + runs-on: self-hosted env: ARTIST: "Imagine Dragons" TRACKS: | From a4ed399ad8f0f9b1e9dd696d0f9687316701cfd9 Mon Sep 17 00:00:00 2001 From: Saigz Date: Tue, 13 Jan 2026 16:01:49 +1000 Subject: [PATCH 09/13] fix(music-release-tracker): yandex music track version --- .github/workflows/music-release-tracker.yaml | 4 ---- scripts/music-release-tracker/yandex-music/main.py | 3 --- 2 files changed, 7 deletions(-) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index c5d994b..d6836e1 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -32,7 +32,6 @@ jobs: - name: Yandex Music run: | python scripts/music-release-tracker/yandex-music/main.py - cat dict.yaml - name: Spotify env: @@ -40,19 +39,16 @@ jobs: run: | echo "TODO: get spotify token" # python scripts/music-release-tracker/spotify/main.py - # cat dict.yaml - name: Apple Music run: | python scripts/music-release-tracker/apple-music/main.py - cat dict.yaml - name: YouTube Music env: YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} run: | python scripts/music-release-tracker/youtube-music/main.py - cat dict.yaml - name: Music summary run: | diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py index 81b1178..34e682e 100644 --- a/scripts/music-release-tracker/yandex-music/main.py +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -42,9 +42,6 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: n_title = norm(title) for track in tracks: - version = track.get("version") or "" - if version: - continue if norm(track.get("title", "")) != n_title: continue artists = [norm(a.get("name", "")) for a in track.get("artists", [])] From f44d8d8722293f62b5d71db8fdd0adda279f87c3 Mon Sep 17 00:00:00 2001 From: Saigz Date: Tue, 20 Jan 2026 19:14:49 +1000 Subject: [PATCH 10/13] feat(music-release-tracker): release-date init --- .../music-release-tracker/apple-music/main.py | 43 +++++++++++----- scripts/music-release-tracker/spotify/main.py | 45 +++++++++++----- .../yandex-music/main.py | 51 ++++++++++++++----- .../youtube-music/main.py | 41 ++++++++++----- 4 files changed, 128 insertions(+), 52 deletions(-) diff --git a/scripts/music-release-tracker/apple-music/main.py b/scripts/music-release-tracker/apple-music/main.py index 1eb77ba..d3b01cf 100644 --- a/scripts/music-release-tracker/apple-music/main.py +++ b/scripts/music-release-tracker/apple-music/main.py @@ -5,7 +5,7 @@ import sys import urllib.parse import urllib.request -from typing import Any +from typing import Any, Tuple, Optional DICT_PATH = "dict.yaml" @@ -39,7 +39,7 @@ def fetch_search(country: str, artist: str, title: str) -> dict: return json.loads(data) -def is_exact_match(artist: str, title: str, payload: dict) -> bool: +def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: results = payload.get("results", []) n_artist = norm(artist) @@ -50,9 +50,15 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: continue if norm(item.get("artistName", "")) != n_artist: continue - return True - return False + release_date = None + rd = item.get("releaseDate") + if isinstance(rd, str) and rd: + release_date = rd.split("T", 1)[0] + + return True, release_date + + return False, None def parse_tracks(arg: str) -> list[str]: @@ -93,22 +99,33 @@ def main() -> None: for title in tracks: try: payload = fetch_search(country, artist, title) - found = is_exact_match(artist, title, payload) - status: Any = 1 if found else 0 + found, rd = find_match(artist, title, payload) + if found: + status: Any = 1 + release_date: Optional[str] = rd + else: + status = 0 + release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") status = "unknown" + release_date = None if status not in (0, 1, "unknown"): status = "unknown" - if title not in data: - data[title] = {} - entry = data[title] - if not isinstance(entry, dict): - entry = {} - data[title] = entry - entry[PLATFORM] = status + track_entry = data.get(title) + if not isinstance(track_entry, dict): + track_entry = {} + data[title] = track_entry + + platform_entry = track_entry.get(PLATFORM) + if not isinstance(platform_entry, dict): + platform_entry = {} + track_entry[PLATFORM] = platform_entry + + platform_entry["status"] = status + platform_entry["release_date"] = release_date save_dict(DICT_PATH, data) diff --git a/scripts/music-release-tracker/spotify/main.py b/scripts/music-release-tracker/spotify/main.py index 4cb02cd..622fa60 100644 --- a/scripts/music-release-tracker/spotify/main.py +++ b/scripts/music-release-tracker/spotify/main.py @@ -5,7 +5,7 @@ import sys import urllib.parse import urllib.request -from typing import Any +from typing import Any, Tuple, Optional DICT_PATH = "dict.yaml" @@ -33,7 +33,7 @@ def fetch_search(token: str, artist: str, title: str) -> dict: return json.loads(data) -def is_exact_match(artist: str, title: str, payload: dict) -> bool: +def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: tracks = payload.get("tracks", {}).get("items", []) n_artist = norm(artist) @@ -42,12 +42,20 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: for track in tracks: if norm(track.get("name", "")) != n_title: continue + artists = [norm(a.get("name", "")) for a in track.get("artists", [])] if n_artist not in artists: continue - return True - return False + release_date = None + album = track.get("album") or {} + rd = album.get("release_date") + if isinstance(rd, str) and rd: + release_date = rd + + return True, release_date + + return False, None def parse_tracks(arg: str) -> list[str]: @@ -92,22 +100,33 @@ def main() -> None: for title in tracks: try: payload = fetch_search(token, artist, title) - found = is_exact_match(artist, title, payload) - status: Any = 1 if found else 0 + found, rd = find_match(artist, title, payload) + if found: + status: Any = 1 + release_date: Optional[str] = rd + else: + status = 0 + release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") status = "unknown" + release_date = None if status not in (0, 1, "unknown"): status = "unknown" - if title not in data: - data[title] = {} - entry = data[title] - if not isinstance(entry, dict): - entry = {} - data[title] = entry - entry[PLATFORM] = status + track_entry = data.get(title) + if not isinstance(track_entry, dict): + track_entry = {} + data[title] = track_entry + + platform_entry = track_entry.get(PLATFORM) + if not isinstance(platform_entry, dict): + platform_entry = {} + track_entry[PLATFORM] = platform_entry + + platform_entry["status"] = status + platform_entry["release_date"] = release_date save_dict(DICT_PATH, data) diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py index 34e682e..6025d92 100644 --- a/scripts/music-release-tracker/yandex-music/main.py +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -5,7 +5,7 @@ import sys import urllib.parse import urllib.request -from typing import Any +from typing import Any, Tuple, Optional DICT_PATH = "dict.yaml" @@ -31,7 +31,7 @@ def fetch_search(artist: str, title: str) -> dict: return json.loads(data) -def is_exact_match(artist: str, title: str, payload: dict) -> bool: +def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: tracks = ( payload.get("result", {}) .get("tracks", {}) @@ -44,12 +44,26 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: for track in tracks: if norm(track.get("title", "")) != n_title: continue + artists = [norm(a.get("name", "")) for a in track.get("artists", [])] if n_artist not in artists: continue - return True - return False + release_date: Optional[str] = None + albums = track.get("albums") or [] + if albums: + album = albums[0] + rd = album.get("releaseDate") + if isinstance(rd, str) and rd: + release_date = rd.split("T", 1)[0] + else: + year = album.get("year") + if year: + release_date = str(year) + + return True, release_date + + return False, None def parse_tracks_arg(arg: str) -> list[str]: @@ -89,22 +103,33 @@ def main() -> None: for title in tracks: try: payload = fetch_search(artist, title) - found = is_exact_match(artist, title, payload) - status: Any = 1 if found else 0 + found, rd = find_match(artist, title, payload) + if found: + status: Any = 1 + release_date: Optional[str] = rd + else: + status = 0 + release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") status = "unknown" + release_date = None if status not in (0, 1, "unknown"): status = "unknown" - if title not in data: - data[title] = {} - entry = data[title] - if not isinstance(entry, dict): - entry = {} - data[title] = entry - entry[PLATFORM] = status + track_entry = data.get(title) + if not isinstance(track_entry, dict): + track_entry = {} + data[title] = track_entry + + platform_entry = track_entry.get(PLATFORM) + if not isinstance(platform_entry, dict): + platform_entry = {} + track_entry[PLATFORM] = platform_entry + + platform_entry["status"] = status + platform_entry["release_date"] = release_date save_dict(DICT_PATH, data) diff --git a/scripts/music-release-tracker/youtube-music/main.py b/scripts/music-release-tracker/youtube-music/main.py index d9053ca..492553f 100644 --- a/scripts/music-release-tracker/youtube-music/main.py +++ b/scripts/music-release-tracker/youtube-music/main.py @@ -5,7 +5,7 @@ import sys import urllib.parse import urllib.request -from typing import Any +from typing import Any, Tuple, Optional DICT_PATH = "dict.yaml" @@ -38,7 +38,7 @@ def fetch_search(api_key: str, artist: str, title: str) -> dict: return json.loads(data) -def is_exact_match(artist: str, title: str, payload: dict) -> bool: +def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: items = payload.get("items", []) n_artist = norm(artist) @@ -50,9 +50,13 @@ def is_exact_match(artist: str, title: str, payload: dict) -> bool: channel = norm(snippet.get("channelTitle", "")) if n_title in t and (n_artist in t or n_artist in channel): - return True + rd = snippet.get("publishedAt") + release_date = None + if isinstance(rd, str) and rd: + release_date = rd.split("T", 1)[0] + return True, release_date - return False + return False, None def parse_tracks(arg: str) -> list[str]: @@ -97,22 +101,33 @@ def main() -> None: for title in tracks: try: payload = fetch_search(api_key, artist, title) - found = is_exact_match(artist, title, payload) - status: Any = 1 if found else 0 + found, rd = find_match(artist, title, payload) + if found: + status: Any = 1 + release_date: Optional[str] = rd + else: + status = 0 + release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") status = "unknown" + release_date = None if status not in (0, 1, "unknown"): status = "unknown" - if title not in data: - data[title] = {} - entry = data[title] - if not isinstance(entry, dict): - entry = {} - data[title] = entry - entry[PLATFORM] = status + track_entry = data.get(title) + if not isinstance(track_entry, dict): + track_entry = {} + data[title] = track_entry + + platform_entry = track_entry.get(PLATFORM) + if not isinstance(platform_entry, dict): + platform_entry = {} + track_entry[PLATFORM] = platform_entry + + platform_entry["status"] = status + platform_entry["release_date"] = release_date save_dict(DICT_PATH, data) From a396a89e1d85eada80bdbfd3bacd5c805d05f9f2 Mon Sep 17 00:00:00 2001 From: Saigz Date: Thu, 22 Jan 2026 21:33:23 +1000 Subject: [PATCH 11/13] feat(music-release-tracker): rm youtube-music --- .github/workflows/music-release-tracker.yaml | 12 ++--- .../music-release-tracker/apple-music/main.py | 19 +++++-- scripts/music-release-tracker/spotify/main.py | 19 +++++-- .../yandex-music/main.py | 19 +++++-- .../youtube-music/main.py | 51 ++++++++++++++----- 5 files changed, 87 insertions(+), 33 deletions(-) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index d6836e1..9eb32ac 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -13,12 +13,11 @@ jobs: check-music-releases: runs-on: self-hosted env: - ARTIST: "Imagine Dragons" + ARTIST: "Torin Asakura" TRACKS: | - Radioactive - Demons - Warriors - Bones + One More Night + Robocop Origin + Малинки steps: - name: Checkout @@ -48,7 +47,8 @@ jobs: env: YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} run: | - python scripts/music-release-tracker/youtube-music/main.py + echo "TODO: get release_date for songs" + # python scripts/music-release-tracker/youtube-music/main.py - name: Music summary run: | diff --git a/scripts/music-release-tracker/apple-music/main.py b/scripts/music-release-tracker/apple-music/main.py index d3b01cf..a244929 100644 --- a/scripts/music-release-tracker/apple-music/main.py +++ b/scripts/music-release-tracker/apple-music/main.py @@ -11,11 +11,20 @@ DICT_PATH = "dict.yaml" PLATFORM = "apple_music" +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") -def norm(s: str) -> str: + +def norm_artist(s: str) -> str: return " ".join(s.lower().split()) +def norm_title(s: str) -> str: + if not s: + return "" + part = _SPECIAL_SPLIT_RE.split(s, 1)[0] + return " ".join(part.lower().split()) + + def fetch_search(country: str, artist: str, title: str) -> dict: term = f"{artist} {title}" params = { @@ -42,13 +51,13 @@ def fetch_search(country: str, artist: str, title: str) -> dict: def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: results = payload.get("results", []) - n_artist = norm(artist) - n_title = norm(title) + n_artist = norm_artist(artist) + n_title = norm_title(title) for item in results: - if norm(item.get("trackName", "")) != n_title: + if norm_title(item.get("trackName", "")) != n_title: continue - if norm(item.get("artistName", "")) != n_artist: + if norm_artist(item.get("artistName", "")) != n_artist: continue release_date = None diff --git a/scripts/music-release-tracker/spotify/main.py b/scripts/music-release-tracker/spotify/main.py index 622fa60..fafe4f2 100644 --- a/scripts/music-release-tracker/spotify/main.py +++ b/scripts/music-release-tracker/spotify/main.py @@ -11,11 +11,20 @@ DICT_PATH = "dict.yaml" PLATFORM = "spotify" +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") -def norm(s: str) -> str: + +def norm_artist(s: str) -> str: return " ".join(s.lower().split()) +def norm_title(s: str) -> str: + if not s: + return "" + part = _SPECIAL_SPLIT_RE.split(s, 1)[0] + return " ".join(part.lower().split()) + + def fetch_search(token: str, artist: str, title: str) -> dict: query = f'track:"{title}" artist:"{artist}"' q = urllib.parse.quote(query) @@ -36,14 +45,14 @@ def fetch_search(token: str, artist: str, title: str) -> dict: def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: tracks = payload.get("tracks", {}).get("items", []) - n_artist = norm(artist) - n_title = norm(title) + n_artist = norm_artist(artist) + n_title = norm_title(title) for track in tracks: - if norm(track.get("name", "")) != n_title: + if norm_title(track.get("name", "")) != n_title: continue - artists = [norm(a.get("name", "")) for a in track.get("artists", [])] + artists = [norm_artist(a.get("name", "")) for a in track.get("artists", [])] if n_artist not in artists: continue diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py index 6025d92..7767a0f 100644 --- a/scripts/music-release-tracker/yandex-music/main.py +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -11,11 +11,20 @@ DICT_PATH = "dict.yaml" PLATFORM = "yandex_music" +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") -def norm(s: str) -> str: + +def norm_artist(s: str) -> str: return " ".join(s.lower().split()) +def norm_title(s: str) -> str: + if not s: + return "" + part = _SPECIAL_SPLIT_RE.split(s, 1)[0] + return " ".join(part.lower().split()) + + def fetch_search(artist: str, title: str) -> dict: query = urllib.parse.quote_plus(f"{artist} {title}") url = f"https://api.music.yandex.net/search?type=track&text={query}&page=0&nococrrect=false" @@ -38,14 +47,14 @@ def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[s .get("results", []) ) - n_artist = norm(artist) - n_title = norm(title) + n_artist = norm_artist(artist) + n_title = norm_title(title) for track in tracks: - if norm(track.get("title", "")) != n_title: + if norm_title(track.get("title", "")) != n_title: continue - artists = [norm(a.get("name", "")) for a in track.get("artists", [])] + artists = [norm_artist(a.get("name", "")) for a in track.get("artists", [])] if n_artist not in artists: continue diff --git a/scripts/music-release-tracker/youtube-music/main.py b/scripts/music-release-tracker/youtube-music/main.py index 492553f..3c5e63d 100644 --- a/scripts/music-release-tracker/youtube-music/main.py +++ b/scripts/music-release-tracker/youtube-music/main.py @@ -11,16 +11,25 @@ DICT_PATH = "dict.yaml" PLATFORM = "youtube_music" +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") -def norm(s: str) -> str: + +def norm_artist(s: str) -> str: return " ".join(s.lower().split()) +def norm_title(s: str) -> str: + if not s: + return "" + part = _SPECIAL_SPLIT_RE.split(s, 1)[0] + return " ".join(part.lower().split()) + + def fetch_search(api_key: str, artist: str, title: str) -> dict: query = f"{artist} {title}" params = { "part": "snippet", - "type": "video", + "type": "track", "maxResults": "25", "q": query, "key": api_key, @@ -41,20 +50,38 @@ def fetch_search(api_key: str, artist: str, title: str) -> dict: def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: items = payload.get("items", []) - n_artist = norm(artist) - n_title = norm(title) + n_artist = norm_artist(artist) + n_title = norm_title(title) for item in items: snippet = item.get("snippet", {}) or {} - t = norm(snippet.get("title", "")) - channel = norm(snippet.get("channelTitle", "")) + raw_title = snippet.get("title", "") or "" + channel = snippet.get("channelTitle", "") or "" - if n_title in t and (n_artist in t or n_artist in channel): - rd = snippet.get("publishedAt") - release_date = None - if isinstance(rd, str) and rd: - release_date = rd.split("T", 1)[0] - return True, release_date + parts = re.split(r"[-–—]", raw_title, maxsplit=1) + if len(parts) == 2: + artist_part, title_part = parts[0], parts[1] + else: + artist_part, title_part = "", raw_title + + candidate_artists = set() + if artist_part: + candidate_artists.add(norm_artist(artist_part)) + if channel: + candidate_artists.add(norm_artist(channel)) + + if n_artist not in candidate_artists: + continue + + if norm_title(title_part) != n_title: + continue + + rd = snippet.get("publishedAt") + release_date = None + if isinstance(rd, str) and rd: + release_date = rd.split("T", 1)[0] + + return True, release_date return False, None From 5fc3d7ce3a07d1c2876ad866e26d1db6f569b1fb Mon Sep 17 00:00:00 2001 From: Saigz Date: Thu, 22 Jan 2026 21:58:15 +1000 Subject: [PATCH 12/13] feat(music-release-tracker): add traceback --- scripts/music-release-tracker/apple-music/main.py | 2 ++ scripts/music-release-tracker/spotify/main.py | 2 ++ scripts/music-release-tracker/yandex-music/main.py | 2 ++ scripts/music-release-tracker/youtube-music/main.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/scripts/music-release-tracker/apple-music/main.py b/scripts/music-release-tracker/apple-music/main.py index a244929..a41eb18 100644 --- a/scripts/music-release-tracker/apple-music/main.py +++ b/scripts/music-release-tracker/apple-music/main.py @@ -5,6 +5,7 @@ import sys import urllib.parse import urllib.request +import traceback from typing import Any, Tuple, Optional @@ -117,6 +118,7 @@ def main() -> None: release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + traceback.print_exc() status = "unknown" release_date = None diff --git a/scripts/music-release-tracker/spotify/main.py b/scripts/music-release-tracker/spotify/main.py index fafe4f2..20c0aed 100644 --- a/scripts/music-release-tracker/spotify/main.py +++ b/scripts/music-release-tracker/spotify/main.py @@ -5,6 +5,7 @@ import sys import urllib.parse import urllib.request +import traceback from typing import Any, Tuple, Optional @@ -118,6 +119,7 @@ def main() -> None: release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + traceback.print_exc() status = "unknown" release_date = None diff --git a/scripts/music-release-tracker/yandex-music/main.py b/scripts/music-release-tracker/yandex-music/main.py index 7767a0f..91f180e 100644 --- a/scripts/music-release-tracker/yandex-music/main.py +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -5,6 +5,7 @@ import sys import urllib.parse import urllib.request +import traceback from typing import Any, Tuple, Optional @@ -121,6 +122,7 @@ def main() -> None: release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + traceback.print_exc() status = "unknown" release_date = None diff --git a/scripts/music-release-tracker/youtube-music/main.py b/scripts/music-release-tracker/youtube-music/main.py index 3c5e63d..eec2b47 100644 --- a/scripts/music-release-tracker/youtube-music/main.py +++ b/scripts/music-release-tracker/youtube-music/main.py @@ -5,6 +5,7 @@ import sys import urllib.parse import urllib.request +import traceback from typing import Any, Tuple, Optional @@ -137,6 +138,7 @@ def main() -> None: release_date = None except Exception as e: sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n") + traceback.print_exc() status = "unknown" release_date = None From 73eb26379cad6d662a65a152d0a38817b810f541 Mon Sep 17 00:00:00 2001 From: Saigz Date: Thu, 22 Jan 2026 22:02:07 +1000 Subject: [PATCH 13/13] feat(music-release-tracker): update action summary --- .github/workflows/music-release-tracker.yaml | 37 +++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml index 9eb32ac..ae68b1e 100644 --- a/.github/workflows/music-release-tracker.yaml +++ b/.github/workflows/music-release-tracker.yaml @@ -58,17 +58,35 @@ jobs: with open("dict.yaml", "r", encoding="utf-8") as f: data = json.load(f) + if not data: + print("## Music availability\n") + print("_dict.yaml is empty_") + raise SystemExit(0) + platforms = set() for v in data.values(): - platforms.update(v.keys()) + if isinstance(v, dict): + platforms.update(v.keys()) platforms = sorted(platforms) - def fmt_status(val): - if val == 1: - return "✅" - if val == 0: - return "❌" - return "⚠️ unknown" + def fmt_cell(entry): + if not isinstance(entry, dict): + return "⚠️ unknown" + status = entry.get("status", "unknown") + release_date = entry.get("release_date") + + if status == 1: + icon = "✅" + elif status == 0: + icon = "❌" + else: + icon = "⚠️" + + if release_date: + return f"{icon} {release_date}" + if status == "unknown": + return f"{icon} unknown" + return icon print("## Music availability\n") header = "| Track | " + " | ".join(platforms) + " |" @@ -77,9 +95,10 @@ jobs: print(sep) for track in sorted(data.keys()): + track_entry = data.get(track) or {} row = [track] for p in platforms: - val = data[track].get(p, "unknown") - row.append(fmt_status(val)) + cell = fmt_cell(track_entry.get(p)) + row.append(cell) print("| " + " | ".join(row) + " |") EOF