From a87da8df5b7afa55d72b315b7da667d7c5b6cbb1 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 7 Oct 2026 21:45:23 +0300 Subject: [PATCH] feat(app): Add yandex music status --- app.py | 4 +- dependencies.txt | 1 + static/css/style.css | 170 ++++++++++++++++++++++++++++++++++++++++++ static/js/ym.js | 94 +++++++++++++++++++++++ templates/author.html | 20 +++++ utils/__init__.py | 0 utils/ym.py | 145 +++++++++++++++++++++++++++++++++++ 7 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 static/js/ym.js create mode 100644 utils/__init__.py create mode 100644 utils/ym.py diff --git a/app.py b/app.py index 011eef1..0f858f3 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,9 @@ from flask import Flask, render_template -import os + +from utils.ym import ym_bp app = Flask(__name__) +app.register_blueprint(ym_bp) @app.route("/") def index(): diff --git a/dependencies.txt b/dependencies.txt index 4eee046..1d54268 100644 --- a/dependencies.txt +++ b/dependencies.txt @@ -1,2 +1,3 @@ flask==3.1.2 gunicorn +yandex-music==3.2.1 diff --git a/static/css/style.css b/static/css/style.css index 7745706..56b148c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -673,6 +673,176 @@ img.card-icon { border-radius: 12px; } 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-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-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 { display: flex; diff --git a/static/js/ym.js b/static/js/ym.js new file mode 100644 index 0000000..0a0535e --- /dev/null +++ b/static/js/ym.js @@ -0,0 +1,94 @@ +(() => { + "use strict"; + + const list = document.getElementById("ymHistory"); + const updated = document.getElementById("ymUpdated"); + const refresh = document.getElementById("ymRefresh"); + if (!list || !updated) return; + + const POLL_MS = 30000; + const seen = new Set(); + let firstLoad = true; + let timer = null; + let busy = false; + + const escapeHtml = (s) => + String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[c]); + + const dayLabel = (day) => { + if (!day) return ""; + const today = new Date().toISOString().slice(0, 10); + const yest = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + if (day === today) return "сегодня"; + if (day === yest) return "вчера"; + const [y, m, d] = day.split("-"); + return `${d}.${m}.${y}`; + }; + + const render = (tracks) => { + const fresh = []; + const html = tracks.map((t) => { + const key = `${t.id}:${t.album_id || ""}:${t.day}`; + if (!seen.has(key)) { + seen.add(key); + if (!firstLoad) fresh.push(key); + } + const isNew = fresh.includes(key); + const cover = t.cover + ? `` + : ``; + const artists = (t.artists || []).map(escapeHtml).join(", "); + const context = t.context ? ` · ${escapeHtml(t.context)}` : ""; + return `
  • + + ${cover} + + ${escapeHtml(t.title)} + ${artists || "—"}${context} + + ${dayLabel(t.day)} + +
  • `; + }).join(""); + + list.innerHTML = html || '
  • Пока нет прослушиваний за последние дни
  • '; + firstLoad = false; + }; + + const setStatus = (msg, isError) => { + list.innerHTML = `
  • ${escapeHtml(msg)}
  • `; + }; + + const tick = async () => { + if (busy) return; + busy = true; + try { + const res = await fetch("/api/ym/history", { 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(); }; + + if (refresh) refresh.addEventListener("click", poke); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) poke(); + }); + + tick(); +})(); diff --git a/templates/author.html b/templates/author.html index 699455d..020b608 100644 --- a/templates/author.html +++ b/templates/author.html @@ -194,6 +194,25 @@ +
    +
    +
    +
    + live +

    Яндекс Музыка — история прослушиваний

    +
    +
    + подключение… + +
    +
    + +
      +
    1. Загрузка истории…
    2. +
    +
    +
    +

    Также рекомендую ознакомиться

    @@ -229,5 +248,6 @@ + diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/ym.py b/utils/ym.py new file mode 100644 index 0000000..be442fe --- /dev/null +++ b/utils/ym.py @@ -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)