Skip to content

Commit 8478e24

Browse files
authored
Feat/music release tracker (#78)
* feat(workflows): music-release-tracker init * feat(workflows): music-release-tracker trigger * fix(music-release-tracker): yandex music script * fix(music-release-tracker): script exit code * feat(music-release-tracker): youtube music init * feat(music-release-tracker): action summary * feat(music-release-tracker): enable yandex music * feat(music-release-tracker): runs on self-hosted runner * fix(music-release-tracker): yandex music track version * feat(music-release-tracker): release-date init * feat(music-release-tracker): rm youtube-music * feat(music-release-tracker): add traceback * feat(music-release-tracker): update action summary
1 parent e0910a7 commit 8478e24

File tree

5 files changed

+709
-0
lines changed

5 files changed

+709
-0
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: Check music releases
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
types:
7+
- opened
8+
- edited
9+
- reopened
10+
- synchronize
11+
12+
jobs:
13+
check-music-releases:
14+
runs-on: self-hosted
15+
env:
16+
ARTIST: "Torin Asakura"
17+
TRACKS: |
18+
One More Night
19+
Robocop Origin
20+
Малинки
21+
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v5
25+
26+
- name: Setup Python
27+
uses: actions/setup-python@v5
28+
with:
29+
python-version: "3.11"
30+
31+
- name: Yandex Music
32+
run: |
33+
python scripts/music-release-tracker/yandex-music/main.py
34+
35+
- name: Spotify
36+
env:
37+
SPOTIFY_TOKEN: ${{ secrets.SPOTIFY_TOKEN }}
38+
run: |
39+
echo "TODO: get spotify token"
40+
# python scripts/music-release-tracker/spotify/main.py
41+
42+
- name: Apple Music
43+
run: |
44+
python scripts/music-release-tracker/apple-music/main.py
45+
46+
- name: YouTube Music
47+
env:
48+
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
49+
run: |
50+
echo "TODO: get release_date for songs"
51+
# python scripts/music-release-tracker/youtube-music/main.py
52+
53+
- name: Music summary
54+
run: |
55+
python - << 'EOF' >> "$GITHUB_STEP_SUMMARY"
56+
import json
57+
58+
with open("dict.yaml", "r", encoding="utf-8") as f:
59+
data = json.load(f)
60+
61+
if not data:
62+
print("## Music availability\n")
63+
print("_dict.yaml is empty_")
64+
raise SystemExit(0)
65+
66+
platforms = set()
67+
for v in data.values():
68+
if isinstance(v, dict):
69+
platforms.update(v.keys())
70+
platforms = sorted(platforms)
71+
72+
def fmt_cell(entry):
73+
if not isinstance(entry, dict):
74+
return "⚠️ unknown"
75+
status = entry.get("status", "unknown")
76+
release_date = entry.get("release_date")
77+
78+
if status == 1:
79+
icon = "✅"
80+
elif status == 0:
81+
icon = "❌"
82+
else:
83+
icon = "⚠️"
84+
85+
if release_date:
86+
return f"{icon} {release_date}"
87+
if status == "unknown":
88+
return f"{icon} unknown"
89+
return icon
90+
91+
print("## Music availability\n")
92+
header = "| Track | " + " | ".join(platforms) + " |"
93+
sep = "| --- | " + " | ".join(["---"] * len(platforms)) + " |"
94+
print(header)
95+
print(sep)
96+
97+
for track in sorted(data.keys()):
98+
track_entry = data.get(track) or {}
99+
row = [track]
100+
for p in platforms:
101+
cell = fmt_cell(track_entry.get(p))
102+
row.append(cell)
103+
print("| " + " | ".join(row) + " |")
104+
EOF
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import os
4+
import re
5+
import sys
6+
import urllib.parse
7+
import urllib.request
8+
import traceback
9+
from typing import Any, Tuple, Optional
10+
11+
12+
DICT_PATH = "dict.yaml"
13+
PLATFORM = "apple_music"
14+
15+
_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]")
16+
17+
18+
def norm_artist(s: str) -> str:
19+
return " ".join(s.lower().split())
20+
21+
22+
def norm_title(s: str) -> str:
23+
if not s:
24+
return ""
25+
part = _SPECIAL_SPLIT_RE.split(s, 1)[0]
26+
return " ".join(part.lower().split())
27+
28+
29+
def fetch_search(country: str, artist: str, title: str) -> dict:
30+
term = f"{artist} {title}"
31+
params = {
32+
"term": term,
33+
"media": "music",
34+
"entity": "song",
35+
"country": country,
36+
"limit": "50",
37+
}
38+
qs = urllib.parse.urlencode(params)
39+
url = f"https://itunes.apple.com/search?{qs}"
40+
req = urllib.request.Request(
41+
url,
42+
headers={
43+
"User-Agent": "music-dict-ci",
44+
"Accept": "application/json",
45+
},
46+
)
47+
with urllib.request.urlopen(req) as resp:
48+
data = resp.read()
49+
return json.loads(data)
50+
51+
52+
def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]:
53+
results = payload.get("results", [])
54+
55+
n_artist = norm_artist(artist)
56+
n_title = norm_title(title)
57+
58+
for item in results:
59+
if norm_title(item.get("trackName", "")) != n_title:
60+
continue
61+
if norm_artist(item.get("artistName", "")) != n_artist:
62+
continue
63+
64+
release_date = None
65+
rd = item.get("releaseDate")
66+
if isinstance(rd, str) and rd:
67+
release_date = rd.split("T", 1)[0]
68+
69+
return True, release_date
70+
71+
return False, None
72+
73+
74+
def parse_tracks(arg: str) -> list[str]:
75+
return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()]
76+
77+
78+
def load_dict(path: str) -> dict[str, Any]:
79+
if not os.path.exists(path):
80+
return {}
81+
with open(path, "r", encoding="utf-8") as f:
82+
content = f.read().strip()
83+
if not content:
84+
return {}
85+
return json.loads(content)
86+
87+
88+
def save_dict(path: str, data: dict[str, Any]) -> None:
89+
with open(path, "w", encoding="utf-8") as f:
90+
json.dump(data, f, ensure_ascii=False, indent=2)
91+
92+
93+
def main() -> None:
94+
artist = os.getenv("ARTIST", "").strip()
95+
tracks_raw = os.getenv("TRACKS", "")
96+
country = os.getenv("APPLE_COUNTRY", "US").strip() or "US"
97+
98+
if not artist or not tracks_raw.strip():
99+
sys.stderr.write("ARTIST и TRACKS должны быть заданы в env\n")
100+
sys.exit(1)
101+
102+
tracks = parse_tracks(tracks_raw)
103+
if not tracks:
104+
sys.stderr.write("TRACKS пустой после парсинга\n")
105+
sys.exit(1)
106+
107+
data: dict[str, Any] = load_dict(DICT_PATH)
108+
109+
for title in tracks:
110+
try:
111+
payload = fetch_search(country, artist, title)
112+
found, rd = find_match(artist, title, payload)
113+
if found:
114+
status: Any = 1
115+
release_date: Optional[str] = rd
116+
else:
117+
status = 0
118+
release_date = None
119+
except Exception as e:
120+
sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n")
121+
traceback.print_exc()
122+
status = "unknown"
123+
release_date = None
124+
125+
if status not in (0, 1, "unknown"):
126+
status = "unknown"
127+
128+
track_entry = data.get(title)
129+
if not isinstance(track_entry, dict):
130+
track_entry = {}
131+
data[title] = track_entry
132+
133+
platform_entry = track_entry.get(PLATFORM)
134+
if not isinstance(platform_entry, dict):
135+
platform_entry = {}
136+
track_entry[PLATFORM] = platform_entry
137+
138+
platform_entry["status"] = status
139+
platform_entry["release_date"] = release_date
140+
141+
save_dict(DICT_PATH, data)
142+
143+
144+
if __name__ == "__main__":
145+
main()
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import os
4+
import re
5+
import sys
6+
import urllib.parse
7+
import urllib.request
8+
import traceback
9+
from typing import Any, Tuple, Optional
10+
11+
12+
DICT_PATH = "dict.yaml"
13+
PLATFORM = "spotify"
14+
15+
_SPECIAL_SPLIT_RE = re.compile(r"[-–—\(\[\{\:\/]")
16+
17+
18+
def norm_artist(s: str) -> str:
19+
return " ".join(s.lower().split())
20+
21+
22+
def norm_title(s: str) -> str:
23+
if not s:
24+
return ""
25+
part = _SPECIAL_SPLIT_RE.split(s, 1)[0]
26+
return " ".join(part.lower().split())
27+
28+
29+
def fetch_search(token: str, artist: str, title: str) -> dict:
30+
query = f'track:"{title}" artist:"{artist}"'
31+
q = urllib.parse.quote(query)
32+
url = f"https://api.spotify.com/v1/search?type=track&limit=50&q={q}"
33+
req = urllib.request.Request(
34+
url,
35+
headers={
36+
"User-Agent": "music-dict-ci",
37+
"Accept": "application/json",
38+
"Authorization": f"Bearer {token}",
39+
},
40+
)
41+
with urllib.request.urlopen(req) as resp:
42+
data = resp.read()
43+
return json.loads(data)
44+
45+
46+
def find_match(artist: str, title: str, payload: dict) -> Tuple[bool, Optional[str]]:
47+
tracks = payload.get("tracks", {}).get("items", [])
48+
49+
n_artist = norm_artist(artist)
50+
n_title = norm_title(title)
51+
52+
for track in tracks:
53+
if norm_title(track.get("name", "")) != n_title:
54+
continue
55+
56+
artists = [norm_artist(a.get("name", "")) for a in track.get("artists", [])]
57+
if n_artist not in artists:
58+
continue
59+
60+
release_date = None
61+
album = track.get("album") or {}
62+
rd = album.get("release_date")
63+
if isinstance(rd, str) and rd:
64+
release_date = rd
65+
66+
return True, release_date
67+
68+
return False, None
69+
70+
71+
def parse_tracks(arg: str) -> list[str]:
72+
return [t.strip() for t in re.split(r"[;\n]", arg) if t.strip()]
73+
74+
75+
def load_dict(path: str) -> dict[str, Any]:
76+
if not os.path.exists(path):
77+
return {}
78+
with open(path, "r", encoding="utf-8") as f:
79+
content = f.read().strip()
80+
if not content:
81+
return {}
82+
return json.loads(content)
83+
84+
85+
def save_dict(path: str, data: dict[str, Any]) -> None:
86+
with open(path, "w", encoding="utf-8") as f:
87+
json.dump(data, f, ensure_ascii=False, indent=2)
88+
89+
90+
def main() -> None:
91+
token = os.getenv("SPOTIFY_TOKEN", "").strip()
92+
artist = os.getenv("ARTIST", "").strip()
93+
tracks_raw = os.getenv("TRACKS", "")
94+
95+
if not token:
96+
sys.stderr.write("SPOTIFY_TOKEN должен быть задан в env\n")
97+
sys.exit(1)
98+
99+
if not artist or not tracks_raw.strip():
100+
sys.stderr.write("ARTIST и TRACKS должны быть заданы в env\n")
101+
sys.exit(1)
102+
103+
tracks = parse_tracks(tracks_raw)
104+
if not tracks:
105+
sys.stderr.write("TRACKS пустой после парсинга\n")
106+
sys.exit(1)
107+
108+
data: dict[str, Any] = load_dict(DICT_PATH)
109+
110+
for title in tracks:
111+
try:
112+
payload = fetch_search(token, artist, title)
113+
found, rd = find_match(artist, title, payload)
114+
if found:
115+
status: Any = 1
116+
release_date: Optional[str] = rd
117+
else:
118+
status = 0
119+
release_date = None
120+
except Exception as e:
121+
sys.stderr.write(f"[{PLATFORM}] error for '{title}': {e}\n")
122+
traceback.print_exc()
123+
status = "unknown"
124+
release_date = None
125+
126+
if status not in (0, 1, "unknown"):
127+
status = "unknown"
128+
129+
track_entry = data.get(title)
130+
if not isinstance(track_entry, dict):
131+
track_entry = {}
132+
data[title] = track_entry
133+
134+
platform_entry = track_entry.get(PLATFORM)
135+
if not isinstance(platform_entry, dict):
136+
platform_entry = {}
137+
track_entry[PLATFORM] = platform_entry
138+
139+
platform_entry["status"] = status
140+
platform_entry["release_date"] = release_date
141+
142+
save_dict(DICT_PATH, data)
143+
144+
145+
if __name__ == "__main__":
146+
main()

0 commit comments

Comments
 (0)