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}`
+ : `${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 @@
+
+
играет сейчас
+
проверяю…
+
+
- Загрузка истории…
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)