Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/music-release-tracker.yaml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +13 to +14

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict self-hosted runner for pull_request jobs

This job runs on a self-hosted runner while the workflow is triggered by pull_request events. If the repo accepts PRs from forks or untrusted contributors, their code will execute on your self-hosted runner, which can expose internal network access or local credentials that GitHub-hosted runners don’t protect. Consider limiting this workflow to trusted branches/actors or moving PR runs to a GitHub-hosted runner so untrusted code can’t run on the self-hosted machine.

Useful? React with 👍 / 👎.

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
145 changes: 145 additions & 0 deletions scripts/music-release-tracker/apple-music/main.py
Original file line number Diff line number Diff line change
@@ -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()
146 changes: 146 additions & 0 deletions scripts/music-release-tracker/spotify/main.py
Original file line number Diff line number Diff line change
@@ -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()
Loading