feat(app): Add yandex music status
This commit is contained in:
1 parent
76032d9869
commit
f2e433d1e4
8 files changed
+632
-1
No files matched your search
@@ -1,7 +1,11 @@
|
|||||||
from flask import Flask, render_template
|
from flask import Flask, render_template
|
||||||
import os
|
|
||||||
|
from utils.listenbrainz import listenbrainz_bp
|
||||||
|
from utils.ym import ym_bp
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(listenbrainz_bp)
|
||||||
|
app.register_blueprint(ym_bp)
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
flask==3.1.2
|
flask==3.1.2
|
||||||
gunicorn
|
gunicorn
|
||||||
|
yandex-music==3.2.1
|
||||||
@@ -673,6 +673,220 @@ img.card-icon { border-radius: 12px; }
|
|||||||
color: #b6beff;
|
color: #b6beff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- трекер Яндекс Музыки ---------- */
|
||||||
|
.ym-tracker {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-title { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.ym-title h2 {
|
||||||
|
font-size: clamp(1.1rem, 3vw, 1.4rem);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .68rem;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(94, 234, 212, .08);
|
||||||
|
border: 1px solid rgba(94, 234, 212, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, .04);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-tab {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .72rem;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .2s, background .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-tab:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.ym-tab.active {
|
||||||
|
background: rgba(94, 234, 212, .12);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .74rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-refresh {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255, 255, 255, .04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .2s, border-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-refresh:hover { color: var(--accent); border-color: rgba(94, 234, 212, .4); }
|
||||||
|
|
||||||
|
.ym-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
counter-reset: ym;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-status {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .84rem;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px dashed var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-status.ym-error { color: #fca5a5; border-color: rgba(248, 113, 113, .35); }
|
||||||
|
|
||||||
|
.ym-track { counter-increment: ym; }
|
||||||
|
|
||||||
|
.ym-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: transform .2s ease, border-color .2s ease, background .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-link::before {
|
||||||
|
content: counter(ym, decimal-leading-zero);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .74rem;
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-link:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
border-color: rgba(94, 234, 212, .4);
|
||||||
|
background: rgba(94, 234, 212, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-cover {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-cover-empty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-info { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||||
|
|
||||||
|
.ym-track-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-artists {
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-context { font-family: var(--mono); font-size: .74rem; }
|
||||||
|
|
||||||
|
.ym-day {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .72rem;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-day.ym-now {
|
||||||
|
color: var(--accent);
|
||||||
|
animation: ym-blink 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-track.ym-playing .ym-link {
|
||||||
|
border-color: rgba(94, 234, 212, .45);
|
||||||
|
background: rgba(94, 234, 212, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ym-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: .45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ym-track.ym-new .ym-link {
|
||||||
|
animation: ym-pop .6s cubic-bezier(.22, 1, .36, 1);
|
||||||
|
border-color: rgba(94, 234, 212, .55);
|
||||||
|
box-shadow: 0 0 0 3px rgba(94, 234, 212, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ym-pop {
|
||||||
|
from { opacity: 0; transform: translateY(-8px) scale(.98); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.ym-day { display: none; }
|
||||||
|
.ym-link { padding: 9px 11px; gap: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- кнопка друга ---------- */
|
/* ---------- кнопка друга ---------- */
|
||||||
.friend-cta {
|
.friend-cta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const list = document.getElementById("ymHistory");
|
||||||
|
const updated = document.getElementById("ymUpdated");
|
||||||
|
const refresh = document.getElementById("ymRefresh");
|
||||||
|
const tabs = Array.from(document.querySelectorAll(".ym-tab"));
|
||||||
|
if (!list || !updated) return;
|
||||||
|
|
||||||
|
const SOURCES = {
|
||||||
|
lb: "/api/listenbrainz/recent",
|
||||||
|
ym: "/api/ym/history",
|
||||||
|
};
|
||||||
|
const POLL_MS = 30000;
|
||||||
|
|
||||||
|
let current = "lb";
|
||||||
|
let timer = null;
|
||||||
|
let busy = false;
|
||||||
|
const state = {
|
||||||
|
lb: { seen: new Set(), firstLoad: true },
|
||||||
|
ym: { seen: new Set(), firstLoad: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (s) =>
|
||||||
|
String(s).replace(/[&<>"']/g, (c) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
})[c]);
|
||||||
|
|
||||||
|
const dayLabel = (t) => {
|
||||||
|
if (t.nowplaying) return "сейчас";
|
||||||
|
if (!t.day) return "";
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const yest = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||||
|
if (t.day === today) return "сегодня";
|
||||||
|
if (t.day === yest) return "вчера";
|
||||||
|
const [y, m, d] = t.day.split("-");
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (tracks) => {
|
||||||
|
const st = state[current];
|
||||||
|
const fresh = [];
|
||||||
|
const html = tracks.map((t) => {
|
||||||
|
const key = `${t.id}:${t.album_id || ""}:${t.day || ""}`;
|
||||||
|
if (!st.seen.has(key)) {
|
||||||
|
st.seen.add(key);
|
||||||
|
if (!st.firstLoad) fresh.push(key);
|
||||||
|
}
|
||||||
|
const isNew = fresh.includes(key);
|
||||||
|
const cover = t.cover
|
||||||
|
? `<img class="ym-cover" src="${escapeHtml(t.cover)}" alt="" loading="lazy" width="48" height="48">`
|
||||||
|
: `<span class="ym-cover ym-cover-empty" aria-hidden="true">♪</span>`;
|
||||||
|
const artists = (t.artists || []).map(escapeHtml).join(", ");
|
||||||
|
const context = t.context ? ` <span class="ym-context">· ${escapeHtml(t.context)}</span>` : "";
|
||||||
|
const link = t.url
|
||||||
|
? `<a class="ym-link" href="${escapeHtml(t.url)}" target="_blank" rel="noopener">`
|
||||||
|
: `<div class="ym-link">`;
|
||||||
|
const close = t.url ? "</a>" : "</div>";
|
||||||
|
return `<li class="ym-track${isNew ? " ym-new" : ""}${t.nowplaying ? " ym-playing" : ""}">
|
||||||
|
${link}
|
||||||
|
${cover}
|
||||||
|
<span class="ym-info">
|
||||||
|
<span class="ym-track-title">${escapeHtml(t.title)}</span>
|
||||||
|
<span class="ym-artists">${artists || "—"}${context}</span>
|
||||||
|
</span>
|
||||||
|
<span class="ym-day${t.nowplaying ? " ym-now" : ""}">${dayLabel(t)}</span>
|
||||||
|
${close}
|
||||||
|
</li>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
list.innerHTML = html || '<li class="ym-status">Пока нет прослушиваний</li>';
|
||||||
|
st.firstLoad = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (msg, isError) => {
|
||||||
|
list.innerHTML = `<li class="ym-status${isError ? " ym-error" : ""}">${escapeHtml(msg)}</li>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(SOURCES[current], { headers: { Accept: "application/json" } });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setStatus(data.error || `Ошибка ${res.status}`, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render(data.tracks || []);
|
||||||
|
const t = new Date(data.updated_at * 1000);
|
||||||
|
updated.textContent = `обновлено ${t.toLocaleTimeString("ru-RU")}${data.stale ? " (кэш)" : ""}`;
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Нет связи с сервером", true);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(tick, POLL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const poke = () => { if (timer) clearTimeout(timer); tick(); };
|
||||||
|
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
const source = tab.dataset.source;
|
||||||
|
if (!source || source === current) return;
|
||||||
|
current = source;
|
||||||
|
tabs.forEach((t) => t.classList.toggle("active", t === tab));
|
||||||
|
updated.textContent = "подключение…";
|
||||||
|
poke();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refresh) refresh.addEventListener("click", poke);
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) poke();
|
||||||
|
});
|
||||||
|
|
||||||
|
tick();
|
||||||
|
})();
|
||||||
@@ -194,6 +194,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="listening">
|
||||||
|
<div class="ym-tracker" data-reveal>
|
||||||
|
<div class="ym-head">
|
||||||
|
<div class="ym-title">
|
||||||
|
<span class="ym-live"><span class="pulse"></span>live</span>
|
||||||
|
<h2>Что я слушаю</h2>
|
||||||
|
<div class="ym-tabs" role="tablist">
|
||||||
|
<button class="ym-tab active" type="button" data-source="lb">ListenBrainz</button>
|
||||||
|
<button class="ym-tab" type="button" data-source="ym">Яндекс Музыка</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ym-meta">
|
||||||
|
<span class="ym-updated" id="ymUpdated">подключение…</span>
|
||||||
|
<button class="ym-refresh" id="ymRefresh" type="button" aria-label="Обновить">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="ym-list" id="ymHistory">
|
||||||
|
<li class="ym-status" id="ymStatus">Загрузка истории…</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="friend-cta" data-reveal>
|
<div class="friend-cta" data-reveal>
|
||||||
<p class="friend-note">Также рекомендую ознакомиться</p>
|
<p class="friend-note">Также рекомендую ознакомиться</p>
|
||||||
<a class="friend-btn" href="https://risdeveau.lair.moe" target="_blank" rel="noopener">
|
<a class="friend-btn" href="https://risdeveau.lair.moe" target="_blank" rel="noopener">
|
||||||
@@ -229,5 +252,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/ym.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Whitespace-only changes.
@@ -0,0 +1,123 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
listenbrainz_bp = Blueprint("listenbrainz", __name__)
|
||||||
|
|
||||||
|
LB_USERNAME = os.environ.get("LISTENBRAINZ_USERNAME", "").strip()
|
||||||
|
LB_TOKEN = os.environ.get("LISTENBRAINZ_TOKEN", "").strip()
|
||||||
|
HISTORY_TTL = 30
|
||||||
|
HISTORY_LIMIT = 30
|
||||||
|
API_BASE = "https://api.listenbrainz.org/1"
|
||||||
|
|
||||||
|
_history_lock = threading.Lock()
|
||||||
|
_history_cache: dict = {"at": 0.0, "payload": None, "error": None}
|
||||||
|
|
||||||
|
_YT_ID_RE = re.compile(r"(?:youtu\.be/|v=|shorts/)([\w-]{6,})")
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url):
|
||||||
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
|
if LB_TOKEN:
|
||||||
|
req.add_header("Authorization", f"Token {LB_TOKEN}")
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.load(resp)
|
||||||
|
if isinstance(data, dict) and data.get("error"):
|
||||||
|
raise RuntimeError(data["error"])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _cover(meta, origin_url):
|
||||||
|
mbid = (meta.get("mbid_mapping") or {}).get("release_mbid")
|
||||||
|
if mbid:
|
||||||
|
return f"https://coverartarchive.org/release/{mbid}/front-250"
|
||||||
|
|
||||||
|
if origin_url:
|
||||||
|
match = _YT_ID_RE.search(origin_url)
|
||||||
|
if match:
|
||||||
|
return f"https://i.ytimg.com/vi/{match.group(1)}/hqdefault.jpg"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_listen(item):
|
||||||
|
meta = item.get("track_metadata") or {}
|
||||||
|
additional = meta.get("additional_info") or {}
|
||||||
|
origin_url = additional.get("origin_url")
|
||||||
|
|
||||||
|
listened_at = item.get("listened_at")
|
||||||
|
day = time.strftime("%Y-%m-%d", time.localtime(int(listened_at))) if listened_at else None
|
||||||
|
|
||||||
|
duration = additional.get("duration")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": item.get("recording_msid") or f"{meta.get('artist_name')}:{meta.get('track_name')}:{listened_at}",
|
||||||
|
"album_id": None,
|
||||||
|
"title": meta.get("track_name") or "Без названия",
|
||||||
|
"artists": [meta["artist_name"]] if meta.get("artist_name") else [],
|
||||||
|
"cover": _cover(meta, origin_url),
|
||||||
|
"duration_ms": int(duration) * 1000 if duration else None,
|
||||||
|
"day": day,
|
||||||
|
"nowplaying": bool(item.get("playing_now")),
|
||||||
|
"context": meta.get("release_name") or None,
|
||||||
|
"url": origin_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_recent():
|
||||||
|
user = urllib.parse.quote(LB_USERNAME)
|
||||||
|
listens = _get(f"{API_BASE}/user/{user}/listens?count={HISTORY_LIMIT}")
|
||||||
|
now = _get(f"{API_BASE}/user/{user}/playing-now")
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
now_payload = now.get("payload") or {}
|
||||||
|
playing = now_payload.get("playing_now")
|
||||||
|
if isinstance(playing, dict):
|
||||||
|
entries.append(_serialize_listen(playing))
|
||||||
|
elif playing is True:
|
||||||
|
for item in now_payload.get("listens") or []:
|
||||||
|
if isinstance(item, dict) and item.get("playing_now"):
|
||||||
|
entries.append(_serialize_listen(item))
|
||||||
|
break
|
||||||
|
|
||||||
|
for item in ((listens.get("payload") or {}).get("listens") or []):
|
||||||
|
if isinstance(item, dict) and not item.get("playing_now"):
|
||||||
|
entries.append(_serialize_listen(item))
|
||||||
|
|
||||||
|
entries = entries[:HISTORY_LIMIT + 1]
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
"count": len(entries),
|
||||||
|
"tracks": entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@listenbrainz_bp.route("/api/listenbrainz/recent")
|
||||||
|
def listenbrainz_recent():
|
||||||
|
if not LB_USERNAME:
|
||||||
|
return jsonify(ok=False, error="LISTENBRAINZ_USERNAME is not set"), 503
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with _history_lock:
|
||||||
|
if _history_cache["payload"] is not None and now - _history_cache["at"] < HISTORY_TTL:
|
||||||
|
return jsonify(_history_cache["payload"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = _fetch_recent()
|
||||||
|
except (RuntimeError, OSError, ValueError, KeyError, AttributeError) as exc:
|
||||||
|
if _history_cache["payload"] is not None:
|
||||||
|
stale = dict(_history_cache["payload"])
|
||||||
|
stale["stale"] = True
|
||||||
|
return jsonify(stale)
|
||||||
|
_history_cache["error"] = str(exc)
|
||||||
|
return jsonify(ok=False, error=str(exc)), 502
|
||||||
|
|
||||||
|
_history_cache["at"] = now
|
||||||
|
_history_cache["payload"] = payload
|
||||||
|
return jsonify(payload)
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from yandex_music import Client
|
||||||
|
from yandex_music.exceptions import YandexMusicError
|
||||||
|
|
||||||
|
ym_bp = Blueprint("ym", __name__)
|
||||||
|
|
||||||
|
YM_TOKEN = os.environ.get("YM_TOKEN", "").strip()
|
||||||
|
HISTORY_TTL = 30
|
||||||
|
HISTORY_LIMIT = 30
|
||||||
|
|
||||||
|
_history_lock = threading.Lock()
|
||||||
|
_history_cache: dict = {"at": 0.0, "payload": None, "error": None}
|
||||||
|
_ym_client = None
|
||||||
|
_ym_client_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ym_client():
|
||||||
|
global _ym_client
|
||||||
|
if _ym_client is not None:
|
||||||
|
return _ym_client
|
||||||
|
with _ym_client_lock:
|
||||||
|
if _ym_client is None:
|
||||||
|
_ym_client = Client(YM_TOKEN).init()
|
||||||
|
return _ym_client
|
||||||
|
|
||||||
|
|
||||||
|
def _track_url(track_id, album_id):
|
||||||
|
if album_id:
|
||||||
|
return f"https://music.yandex.ru/album/{album_id}/track/{track_id}"
|
||||||
|
return f"https://music.yandex.ru/track/{track_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_track(track, day, context_title, track_id=None, album_id=None):
|
||||||
|
cover = None
|
||||||
|
try:
|
||||||
|
cover = track.get_cover_url("400x400")
|
||||||
|
except (YandexMusicError, AttributeError, TypeError):
|
||||||
|
cover = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(track_id or track.id),
|
||||||
|
"album_id": str(album_id) if album_id else None,
|
||||||
|
"title": track.title or "Без названия",
|
||||||
|
"artists": track.artists_name() if hasattr(track, "artists_name") else [],
|
||||||
|
"cover": cover,
|
||||||
|
"duration_ms": track.duration_ms,
|
||||||
|
"day": day,
|
||||||
|
"context": context_title,
|
||||||
|
"url": _track_url(track_id or track.id, album_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _context_title(ctx):
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
for attr in ("album", "playlist", "artist", "wave"):
|
||||||
|
obj = getattr(ctx, attr, None)
|
||||||
|
title = getattr(obj, "title", None) or getattr(obj, "name", None)
|
||||||
|
if title:
|
||||||
|
return title
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_history():
|
||||||
|
client = _get_ym_client()
|
||||||
|
history = client.music_history(full_models_count=HISTORY_LIMIT)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
pending_ids = []
|
||||||
|
|
||||||
|
for tab in ((history.history_tabs if history else None) or []):
|
||||||
|
day = tab.date
|
||||||
|
for group in tab.items or []:
|
||||||
|
ctx = group.context.data.full_model if group.context and group.context.data else None
|
||||||
|
context_title = _context_title(ctx)
|
||||||
|
|
||||||
|
for item in group.tracks or []:
|
||||||
|
if item.type != "track" or item.data is None:
|
||||||
|
continue
|
||||||
|
item_id = item.data.item_id
|
||||||
|
track_id = item_id.track_id if item_id else None
|
||||||
|
album_id = item_id.album_id if item_id else None
|
||||||
|
track = item.data.full_model
|
||||||
|
|
||||||
|
if track is not None:
|
||||||
|
entries.append(_serialize_track(track, day, context_title, track_id, album_id))
|
||||||
|
elif track_id:
|
||||||
|
pending_ids.append((str(track_id), str(album_id) if album_id else None, day, context_title))
|
||||||
|
|
||||||
|
if pending_ids:
|
||||||
|
ids = [f"{t}:{a}" if a else t for t, a, _, _ in pending_ids[:HISTORY_LIMIT]]
|
||||||
|
try:
|
||||||
|
fetched = client.tracks(ids) or []
|
||||||
|
except YandexMusicError:
|
||||||
|
fetched = []
|
||||||
|
by_id = {}
|
||||||
|
for tr in fetched:
|
||||||
|
if tr is None:
|
||||||
|
continue
|
||||||
|
if isinstance(tr, list):
|
||||||
|
tr = next((x for x in tr if x is not None), None)
|
||||||
|
if tr is None:
|
||||||
|
continue
|
||||||
|
by_id[str(tr.id)] = tr
|
||||||
|
for track_id, album_id, day, context_title in pending_ids:
|
||||||
|
tr = by_id.get(track_id)
|
||||||
|
if tr is not None:
|
||||||
|
entries.append(_serialize_track(tr, day, context_title, track_id, album_id))
|
||||||
|
|
||||||
|
entries = entries[:HISTORY_LIMIT]
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
"count": len(entries),
|
||||||
|
"tracks": entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ym_bp.route("/api/ym/history")
|
||||||
|
def ym_history():
|
||||||
|
if not YM_TOKEN:
|
||||||
|
return jsonify(ok=False, error="YM_TOKEN is not set"), 503
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with _history_lock:
|
||||||
|
if _history_cache["payload"] is not None and now - _history_cache["at"] < HISTORY_TTL:
|
||||||
|
return jsonify(_history_cache["payload"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = _fetch_history()
|
||||||
|
except (YandexMusicError, OSError, ValueError, KeyError, AttributeError) as exc:
|
||||||
|
if _history_cache["payload"] is not None:
|
||||||
|
stale = dict(_history_cache["payload"])
|
||||||
|
stale["stale"] = True
|
||||||
|
return jsonify(stale)
|
||||||
|
_history_cache["error"] = str(exc)
|
||||||
|
return jsonify(ok=False, error=str(exc)), 502
|
||||||
|
|
||||||
|
_history_cache["at"] = now
|
||||||
|
_history_cache["payload"] = payload
|
||||||
|
return jsonify(payload)
|
||||||
Reference in new issue
Block a user