diff --git a/.github/workflows/music-release-tracker.yaml b/.github/workflows/music-release-tracker.yaml new file mode 100644 index 0000000..ae68b1e --- /dev/null +++ b/.github/workflows/music-release-tracker.yaml @@ -0,0 +1,104 @@ +name: Check music releases + +on: + workflow_dispatch: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + +jobs: + check-music-releases: + runs-on: self-hosted + env: + ARTIST: "Torin Asakura" + TRACKS: | + One More Night + Robocop Origin + Малинки + + 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 + + - name: Spotify + env: + SPOTIFY_TOKEN: ${{ secrets.SPOTIFY_TOKEN }} + run: | + echo "TODO: get spotify token" + # python scripts/music-release-tracker/spotify/main.py + + - name: Apple Music + run: | + python scripts/music-release-tracker/apple-music/main.py + + - name: YouTube Music + env: + YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} + run: | + echo "TODO: get release_date for songs" + # python scripts/music-release-tracker/youtube-music/main.py + + - 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) + + if not data: + print("## Music availability\n") + print("_dict.yaml is empty_") + raise SystemExit(0) + + platforms = set() + for v in data.values(): + if isinstance(v, dict): + platforms.update(v.keys()) + platforms = sorted(platforms) + + 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) + " |" + sep = "| --- | " + " | ".join(["---"] * len(platforms)) + " |" + print(header) + print(sep) + + for track in sorted(data.keys()): + track_entry = data.get(track) or {} + row = [track] + for p in platforms: + cell = fmt_cell(track_entry.get(p)) + row.append(cell) + print("| " + " | ".join(row) + " |") + EOF 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..a41eb18 --- /dev/null +++ b/scripts/music-release-tracker/apple-music/main.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request +import traceback +from typing import Any, Tuple, Optional + + +DICT_PATH = "dict.yaml" +PLATFORM = "apple_music" + +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") + + +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 = { + "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 find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: + results = payload.get("results", []) + + n_artist = norm_artist(artist) + n_title = norm_title(title) + + for item in results: + if norm_title(item.get("trackName", "")) != n_title: + continue + if norm_artist(item.get("artistName", "")) != n_artist: + continue + + 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]: + 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: + 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: dict[str, Any] = load_dict(DICT_PATH) + + for title in tracks: + try: + payload = fetch_search(country, artist, title) + 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") + traceback.print_exc() + status = "unknown" + release_date = None + + if status not in (0, 1, "unknown"): + status = "unknown" + + 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) + + +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..20c0aed --- /dev/null +++ b/scripts/music-release-tracker/spotify/main.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request +import traceback +from typing import Any, Tuple, Optional + + +DICT_PATH = "dict.yaml" +PLATFORM = "spotify" + +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") + + +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) + 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 find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: + tracks = payload.get("tracks", {}).get("items", []) + + n_artist = norm_artist(artist) + n_title = norm_title(title) + + for track in tracks: + if norm_title(track.get("name", "")) != n_title: + continue + + artists = [norm_artist(a.get("name", "")) for a in track.get("artists", [])] + if n_artist not in artists: + continue + + 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]: + 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: + 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: dict[str, Any] = load_dict(DICT_PATH) + + for title in tracks: + try: + payload = fetch_search(token, artist, title) + 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") + traceback.print_exc() + status = "unknown" + release_date = None + + if status not in (0, 1, "unknown"): + status = "unknown" + + 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) + + +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..91f180e --- /dev/null +++ b/scripts/music-release-tracker/yandex-music/main.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request +import traceback +from typing import Any, Tuple, Optional + + +DICT_PATH = "dict.yaml" +PLATFORM = "yandex_music" + +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") + + +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" + 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 find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: + tracks = ( + payload.get("result", {}) + .get("tracks", {}) + .get("results", []) + ) + + n_artist = norm_artist(artist) + n_title = norm_title(title) + + for track in tracks: + if norm_title(track.get("title", "")) != n_title: + continue + + artists = [norm_artist(a.get("name", "")) for a in track.get("artists", [])] + if n_artist not in artists: + continue + + 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]: + 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: + 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: dict[str, Any] = load_dict(DICT_PATH) + + for title in tracks: + try: + payload = fetch_search(artist, title) + 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") + traceback.print_exc() + status = "unknown" + release_date = None + + if status not in (0, 1, "unknown"): + status = "unknown" + + 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) + + +if __name__ == "__main__": + 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..eec2b47 --- /dev/null +++ b/scripts/music-release-tracker/youtube-music/main.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +import json +import os +import re +import sys +import urllib.parse +import urllib.request +import traceback +from typing import Any, Tuple, Optional + + +DICT_PATH = "dict.yaml" +PLATFORM = "youtube_music" + +_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]") + + +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": "track", + "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 find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]: + items = payload.get("items", []) + + n_artist = norm_artist(artist) + n_title = norm_title(title) + + for item in items: + snippet = item.get("snippet", {}) or {} + raw_title = snippet.get("title", "") or "" + channel = snippet.get("channelTitle", "") or "" + + 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 + + +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, 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") + traceback.print_exc() + status = "unknown" + release_date = None + + if status not in (0, 1, "unknown"): + status = "unknown" + + 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) + + +if __name__ == "__main__": + main()