fix(app): Fix music block and added playing now model
Build, Test and Push Flask App / docker-build-and-push (push) Successful in 1m20s
Build, Test and Push Flask App / deploy (push) Successful in 1m28s

This commit is contained in:
Black_Raven committed 2026-10-08 18:22:33 +03:00
1 parent f2e433d1e4
commit d0985375a8
7 files changed
+171 -234

No files matched your search

-2
View File
@@ -1,11 +1,9 @@
from flask import Flask, render_template from flask import Flask, render_template
from utils.listenbrainz import listenbrainz_bp 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(listenbrainz_bp)
app.register_blueprint(ym_bp)
@app.route("/") @app.route("/")
def index(): def index():
-1
View File
@@ -1,3 +1,2 @@
flask==3.1.2 flask==3.1.2
gunicorn gunicorn
yandex-music==3.2.1
+49 -22
View File
@@ -711,33 +711,60 @@ img.card-icon { border-radius: 12px; }
border: 1px solid rgba(94, 234, 212, .3); 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; display: inline-flex;
gap: 4px; align-items: center;
padding: 3px; gap: 7px;
border-radius: 999px;
background: rgba(255, 255, 255, .04);
border: 1px solid var(--card-border);
}
.ym-tab {
font-family: var(--mono); font-family: var(--mono);
font-size: .72rem; font-size: .68rem;
letter-spacing: .06em; letter-spacing: .14em;
padding: 5px 14px; text-transform: uppercase;
border-radius: 999px; color: var(--accent);
border: none; flex-shrink: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: color .2s, background .2s;
} }
.ym-tab:hover { color: var(--text); } .ym-now-body { min-width: 0; }
.ym-tab.active { .ym-now-link {
background: rgba(94, 234, 212, .12); display: flex;
color: var(--accent); 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 { .ym-meta {
+61 -27
View File
@@ -4,22 +4,20 @@
const list = document.getElementById("ymHistory"); const list = document.getElementById("ymHistory");
const updated = document.getElementById("ymUpdated"); const updated = document.getElementById("ymUpdated");
const refresh = document.getElementById("ymRefresh"); const refresh = document.getElementById("ymRefresh");
const tabs = Array.from(document.querySelectorAll(".ym-tab")); const nowBody = document.getElementById("ymNowBody");
if (!list || !updated) return; if (!list || !updated) return;
const SOURCES = { const SOURCE = "/api/listenbrainz/recent";
lb: "/api/listenbrainz/recent", const NOW_SOURCE = "/api/listenbrainz/now";
ym: "/api/ym/history",
};
const POLL_MS = 30000; const POLL_MS = 30000;
const NOW_POLL_MS = 10000;
let current = "lb";
let timer = null; let timer = null;
let nowTimer = null;
let busy = false; let busy = false;
const state = { let nowBusy = false;
lb: { seen: new Set(), firstLoad: true }, const seen = new Set();
ym: { seen: new Set(), firstLoad: true }, let firstLoad = true;
};
const escapeHtml = (s) => const escapeHtml = (s) =>
String(s).replace(/[&<>"']/g, (c) => ({ String(s).replace(/[&<>"']/g, (c) => ({
@@ -38,13 +36,12 @@
}; };
const render = (tracks) => { const render = (tracks) => {
const st = state[current];
const fresh = []; const fresh = [];
const html = tracks.map((t) => { const html = tracks.map((t) => {
const key = `${t.id}:${t.album_id || ""}:${t.day || ""}`; const key = `${t.id}:${t.album_id || ""}:${t.day || ""}`;
if (!st.seen.has(key)) { if (!seen.has(key)) {
st.seen.add(key); seen.add(key);
if (!st.firstLoad) fresh.push(key); if (!firstLoad) fresh.push(key);
} }
const isNew = fresh.includes(key); const isNew = fresh.includes(key);
const cover = t.cover const cover = t.cover
@@ -69,18 +66,40 @@
}).join(""); }).join("");
list.innerHTML = html || '<li class="ym-status">Пока нет прослушиваний</li>'; list.innerHTML = html || '<li class="ym-status">Пока нет прослушиваний</li>';
st.firstLoad = false; firstLoad = false;
}; };
const setStatus = (msg, isError) => { const setStatus = (msg, isError) => {
list.innerHTML = `<li class="ym-status${isError ? " ym-error" : ""}">${escapeHtml(msg)}</li>`; list.innerHTML = `<li class="ym-status${isError ? " ym-error" : ""}">${escapeHtml(msg)}</li>`;
}; };
const renderNow = (track) => {
if (!nowBody) return;
if (!track) {
nowBody.innerHTML = '<span class="ym-now-idle">ничего не играет</span>';
return;
}
const cover = track.cover
? `<img class="ym-now-cover" src="${escapeHtml(track.cover)}" alt="" loading="lazy" width="72" height="72">`
: `<span class="ym-now-cover ym-cover-empty" aria-hidden="true">♪</span>`;
const artists = (track.artists || []).map(escapeHtml).join(", ");
const context = track.context ? ` <span class="ym-context">· ${escapeHtml(track.context)}</span>` : "";
const inner = `
${cover}
<span class="ym-now-info">
<span class="ym-track-title">${escapeHtml(track.title)}</span>
<span class="ym-artists">${artists || "—"}${context}</span>
</span>`;
nowBody.innerHTML = track.url
? `<a class="ym-now-link" href="${escapeHtml(track.url)}" target="_blank" rel="noopener">${inner}</a>`
: `<div class="ym-now-link">${inner}</div>`;
};
const tick = async () => { const tick = async () => {
if (busy) return; if (busy) return;
busy = true; busy = true;
try { 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(); const data = await res.json();
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
setStatus(data.error || `Ошибка ${res.status}`, true); 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 = `<span class="ym-now-idle">${escapeHtml(data.error || `Ошибка ${res.status}`)}</span>`;
return;
}
renderNow(data.track);
} catch (e) {
if (nowBody) nowBody.innerHTML = '<span class="ym-now-idle">нет связи</span>';
} finally {
nowBusy = false;
if (nowTimer) clearTimeout(nowTimer);
nowTimer = setTimeout(tickNow, NOW_POLL_MS);
}
};
tabs.forEach((tab) => { const poke = () => {
tab.addEventListener("click", () => { if (timer) clearTimeout(timer);
const source = tab.dataset.source; if (nowTimer) clearTimeout(nowTimer);
if (!source || source === current) return; tick();
current = source; tickNow();
tabs.forEach((t) => t.classList.toggle("active", t === tab)); };
updated.textContent = "подключение…";
poke();
});
});
if (refresh) refresh.addEventListener("click", poke); if (refresh) refresh.addEventListener("click", poke);
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
@@ -117,4 +150,5 @@
}); });
tick(); tick();
tickNow();
})(); })();
+5 -4
View File
@@ -200,10 +200,6 @@
<div class="ym-title"> <div class="ym-title">
<span class="ym-live"><span class="pulse"></span>live</span> <span class="ym-live"><span class="pulse"></span>live</span>
<h2>Что я слушаю</h2> <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>
<div class="ym-meta"> <div class="ym-meta">
<span class="ym-updated" id="ymUpdated">подключение…</span> <span class="ym-updated" id="ymUpdated">подключение…</span>
@@ -211,6 +207,11 @@
</div> </div>
</div> </div>
<div class="ym-now" id="ymNow">
<span class="ym-now-label"><span class="pulse"></span>играет сейчас</span>
<div class="ym-now-body" id="ymNowBody">проверяю…</div>
</div>
<ol class="ym-list" id="ymHistory"> <ol class="ym-list" id="ymHistory">
<li class="ym-status" id="ymStatus">Загрузка истории…</li> <li class="ym-status" id="ymStatus">Загрузка истории…</li>
</ol> </ol>
+56 -33
View File
@@ -13,11 +13,14 @@ listenbrainz_bp = Blueprint("listenbrainz", __name__)
LB_USERNAME = os.environ.get("LISTENBRAINZ_USERNAME", "").strip() LB_USERNAME = os.environ.get("LISTENBRAINZ_USERNAME", "").strip()
LB_TOKEN = os.environ.get("LISTENBRAINZ_TOKEN", "").strip() LB_TOKEN = os.environ.get("LISTENBRAINZ_TOKEN", "").strip()
HISTORY_TTL = 30 HISTORY_TTL = 30
HISTORY_LIMIT = 30 NOW_TTL = 10
HISTORY_LIMIT = 10
API_BASE = "https://api.listenbrainz.org/1" API_BASE = "https://api.listenbrainz.org/1"
_history_lock = threading.Lock() _HISTORY_LOCK = threading.Lock()
_history_cache: dict = {"at": 0.0, "payload": None, "error": None} _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,})") _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(): def _fetch_recent():
user = urllib.parse.quote(LB_USERNAME) user = urllib.parse.quote(LB_USERNAME)
listens = _get(f"{API_BASE}/user/{user}/listens?count={HISTORY_LIMIT}") listens = _get(f"{API_BASE}/user/{user}/listens?count={HISTORY_LIMIT}")
now = _get(f"{API_BASE}/user/{user}/playing-now")
entries = [] 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 []): for item in ((listens.get("payload") or {}).get("listens") or []):
if isinstance(item, dict) and not item.get("playing_now"): if isinstance(item, dict) and not item.get("playing_now"):
entries.append(_serialize_listen(item)) entries.append(_serialize_listen(item))
entries = entries[:HISTORY_LIMIT + 1]
return { return {
"ok": True, "ok": True,
"updated_at": int(time.time()), "updated_at": int(time.time()),
"count": len(entries), "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") @listenbrainz_bp.route("/api/listenbrainz/recent")
def listenbrainz_recent(): def listenbrainz_recent():
if not LB_USERNAME: if not LB_USERNAME:
return jsonify(ok=False, error="LISTENBRAINZ_USERNAME is not set"), 503 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: @listenbrainz_bp.route("/api/listenbrainz/now")
payload = _fetch_recent() def listenbrainz_now():
except (RuntimeError, OSError, ValueError, KeyError, AttributeError) as exc: if not LB_USERNAME:
if _history_cache["payload"] is not None: return jsonify(ok=False, error="LISTENBRAINZ_USERNAME is not set"), 503
stale = dict(_history_cache["payload"]) return _serve(_NOW_CACHE, _NOW_LOCK, NOW_TTL, _fetch_now)
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
View File
@@ -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)