diff --git a/app.py b/app.py index 011eef1..2bd5082 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,11 @@ from flask import Flask, render_template -import os + +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 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..6d72881 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -673,6 +673,220 @@ 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-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 { display: flex; diff --git a/static/js/ym.js b/static/js/ym.js new file mode 100644 index 0000000..461e8cd --- /dev/null +++ b/static/js/ym.js @@ -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 + ? `` + : ``; + const artists = (t.artists || []).map(escapeHtml).join(", "); + const context = t.context ? ` · ${escapeHtml(t.context)}` : ""; + const link = t.url + ? `` + : `"; + return `
  • + ${link} + ${cover} + + ${escapeHtml(t.title)} + ${artists || "—"}${context} + + ${dayLabel(t)} + ${close} +
  • `; + }).join(""); + + list.innerHTML = html || '
  • Пока нет прослушиваний
  • '; + st.firstLoad = false; + }; + + const setStatus = (msg, isError) => { + list.innerHTML = `
  • ${escapeHtml(msg)}
  • `; + }; + + 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(); +})(); diff --git a/templates/author.html b/templates/author.html index 699455d..cc04fe9 100644 --- a/templates/author.html +++ b/templates/author.html @@ -194,6 +194,29 @@ +
    +
    +
    +
    + live +

    Что я слушаю

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

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

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