From d0985375a876b9fffa5a70ab850ffbbecd878cd5 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 8 Oct 2026 18:22:33 +0300 Subject: [PATCH] fix(app): Fix music block and added playing now model --- app.py | 2 - dependencies.txt | 1 - static/css/style.css | 71 ++++++++++++++------- static/js/ym.js | 88 +++++++++++++++++-------- templates/author.html | 9 +-- utils/listenbrainz.py | 89 ++++++++++++++++---------- utils/ym.py | 145 ------------------------------------------ 7 files changed, 171 insertions(+), 234 deletions(-) delete mode 100644 utils/ym.py diff --git a/app.py b/app.py index 2bd5082..982f9a9 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,9 @@ from flask import Flask, render_template from utils.listenbrainz import listenbrainz_bp -from utils.ym import ym_bp app = Flask(__name__) app.register_blueprint(listenbrainz_bp) -app.register_blueprint(ym_bp) @app.route("/") def index(): diff --git a/dependencies.txt b/dependencies.txt index 1d54268..4eee046 100644 --- a/dependencies.txt +++ b/dependencies.txt @@ -1,3 +1,2 @@ flask==3.1.2 gunicorn -yandex-music==3.2.1 diff --git a/static/css/style.css b/static/css/style.css index 6d72881..64634fe 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -711,33 +711,60 @@ img.card-icon { border-radius: 12px; } border: 1px solid rgba(94, 234, 212, .3); } -.ym-tabs { +.ym-now { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 16px; + margin-bottom: 16px; + border: 1px solid rgba(94, 234, 212, .25); + border-radius: 12px; + background: linear-gradient(120deg, rgba(94, 234, 212, .07), rgba(255, 255, 255, .02)); + flex-wrap: wrap; +} + +.ym-now-label { display: inline-flex; - gap: 4px; - padding: 3px; - border-radius: 999px; - background: rgba(255, 255, 255, .04); - border: 1px solid var(--card-border); -} - -.ym-tab { + align-items: center; + gap: 7px; 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; + font-size: .68rem; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--accent); + flex-shrink: 0; } -.ym-tab:hover { color: var(--text); } +.ym-now-body { min-width: 0; } -.ym-tab.active { - background: rgba(94, 234, 212, .12); - color: var(--accent); +.ym-now-link { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + color: inherit; + text-decoration: none; +} + +a.ym-now-link:hover .ym-track-title { color: var(--accent); } + +.ym-now-cover { + width: 72px; + height: 72px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255, 255, 255, .06); +} + +.ym-now-cover.ym-cover-empty { font-size: 1.5rem; } + +.ym-now-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; } + +.ym-now-idle { + font-family: var(--mono); + font-size: .8rem; + color: var(--muted); } .ym-meta { diff --git a/static/js/ym.js b/static/js/ym.js index 461e8cd..2534851 100644 --- a/static/js/ym.js +++ b/static/js/ym.js @@ -4,22 +4,20 @@ const list = document.getElementById("ymHistory"); const updated = document.getElementById("ymUpdated"); const refresh = document.getElementById("ymRefresh"); - const tabs = Array.from(document.querySelectorAll(".ym-tab")); + const nowBody = document.getElementById("ymNowBody"); if (!list || !updated) return; - const SOURCES = { - lb: "/api/listenbrainz/recent", - ym: "/api/ym/history", - }; + const SOURCE = "/api/listenbrainz/recent"; + const NOW_SOURCE = "/api/listenbrainz/now"; const POLL_MS = 30000; + const NOW_POLL_MS = 10000; - let current = "lb"; let timer = null; + let nowTimer = null; let busy = false; - const state = { - lb: { seen: new Set(), firstLoad: true }, - ym: { seen: new Set(), firstLoad: true }, - }; + let nowBusy = false; + const seen = new Set(); + let firstLoad = true; const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ @@ -38,13 +36,12 @@ }; 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); + if (!seen.has(key)) { + seen.add(key); + if (!firstLoad) fresh.push(key); } const isNew = fresh.includes(key); const cover = t.cover @@ -69,18 +66,40 @@ }).join(""); list.innerHTML = html || '
  • Пока нет прослушиваний
  • '; - st.firstLoad = false; + firstLoad = false; }; const setStatus = (msg, isError) => { list.innerHTML = `
  • ${escapeHtml(msg)}
  • `; }; + const renderNow = (track) => { + if (!nowBody) return; + if (!track) { + nowBody.innerHTML = 'ничего не играет'; + return; + } + const cover = track.cover + ? `` + : ``; + const artists = (track.artists || []).map(escapeHtml).join(", "); + const context = track.context ? ` · ${escapeHtml(track.context)}` : ""; + const inner = ` + ${cover} + + ${escapeHtml(track.title)} + ${artists || "—"}${context} + `; + nowBody.innerHTML = track.url + ? `${inner}` + : ``; + }; + const tick = async () => { if (busy) return; busy = true; try { - const res = await fetch(SOURCES[current], { headers: { Accept: "application/json" } }); + const res = await fetch(SOURCE, { headers: { Accept: "application/json" } }); const data = await res.json(); if (!res.ok || !data.ok) { setStatus(data.error || `Ошибка ${res.status}`, true); @@ -98,18 +117,32 @@ } }; - const poke = () => { if (timer) clearTimeout(timer); tick(); }; + const tickNow = async () => { + if (nowBusy) return; + nowBusy = true; + try { + const res = await fetch(NOW_SOURCE, { headers: { Accept: "application/json" } }); + const data = await res.json(); + if (!res.ok || !data.ok) { + if (nowBody) nowBody.innerHTML = `${escapeHtml(data.error || `Ошибка ${res.status}`)}`; + return; + } + renderNow(data.track); + } catch (e) { + if (nowBody) nowBody.innerHTML = 'нет связи'; + } finally { + nowBusy = false; + if (nowTimer) clearTimeout(nowTimer); + nowTimer = setTimeout(tickNow, NOW_POLL_MS); + } + }; - 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(); - }); - }); + const poke = () => { + if (timer) clearTimeout(timer); + if (nowTimer) clearTimeout(nowTimer); + tick(); + tickNow(); + }; if (refresh) refresh.addEventListener("click", poke); document.addEventListener("visibilitychange", () => { @@ -117,4 +150,5 @@ }); tick(); + tickNow(); })(); diff --git a/templates/author.html b/templates/author.html index cc04fe9..6344832 100644 --- a/templates/author.html +++ b/templates/author.html @@ -200,10 +200,6 @@
    live

    Что я слушаю

    -
    - - -
    подключение… @@ -211,6 +207,11 @@
    +
    + играет сейчас +
    проверяю…
    +
    +
    1. Загрузка истории…
    diff --git a/utils/listenbrainz.py b/utils/listenbrainz.py index 2c6f0e7..23465e3 100644 --- a/utils/listenbrainz.py +++ b/utils/listenbrainz.py @@ -13,11 +13,14 @@ 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 +NOW_TTL = 10 +HISTORY_LIMIT = 10 API_BASE = "https://api.listenbrainz.org/1" -_history_lock = threading.Lock() -_history_cache: dict = {"at": 0.0, "payload": None, "error": None} +_HISTORY_LOCK = threading.Lock() +_HISTORY_CACHE: dict = {"at": 0.0, "payload": None} +_NOW_LOCK = threading.Lock() +_NOW_CACHE: dict = {"at": 0.0, "payload": None} _YT_ID_RE = re.compile(r"(?:youtu\.be/|v=|shorts/)([\w-]{6,})") @@ -69,55 +72,75 @@ def _serialize_listen(item): } +def _extract_playing(now_payload): + playing = now_payload.get("playing_now") + if isinstance(playing, dict): + return playing + if playing is True: + for item in now_payload.get("listens") or []: + if isinstance(item, dict) and item.get("playing_now"): + return item + return None + + 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, + "tracks": entries[:HISTORY_LIMIT], } +def _fetch_now(): + user = urllib.parse.quote(LB_USERNAME) + now = _get(f"{API_BASE}/user/{user}/playing-now") + + playing = _extract_playing(now.get("payload") or {}) + return { + "ok": True, + "updated_at": int(time.time()), + "track": _serialize_listen(playing) if playing else None, + } + + +def _serve(cache, lock, ttl, fetch): + now = time.monotonic() + with lock: + if cache["payload"] is not None and now - cache["at"] < ttl: + return jsonify(cache["payload"]), 200 + + try: + payload = fetch() + except (RuntimeError, OSError, ValueError, KeyError, AttributeError) as exc: + if cache["payload"] is not None: + stale = dict(cache["payload"]) + stale["stale"] = True + return jsonify(stale), 200 + return jsonify(ok=False, error=str(exc)), 502 + + cache["at"] = now + cache["payload"] = payload + return jsonify(payload), 200 + + @listenbrainz_bp.route("/api/listenbrainz/recent") def listenbrainz_recent(): if not LB_USERNAME: return jsonify(ok=False, error="LISTENBRAINZ_USERNAME is not set"), 503 + return _serve(_HISTORY_CACHE, _HISTORY_LOCK, HISTORY_TTL, _fetch_recent) - 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) +@listenbrainz_bp.route("/api/listenbrainz/now") +def listenbrainz_now(): + if not LB_USERNAME: + return jsonify(ok=False, error="LISTENBRAINZ_USERNAME is not set"), 503 + return _serve(_NOW_CACHE, _NOW_LOCK, NOW_TTL, _fetch_now) diff --git a/utils/ym.py b/utils/ym.py deleted file mode 100644 index be442fe..0000000 --- a/utils/ym.py +++ /dev/null @@ -1,145 +0,0 @@ -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)