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 + ? `` + : ``; + const close = 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 + Что я слушаю + + ListenBrainz + Яндекс Музыка + + + + подключение… + ↻ + + + + + Загрузка истории… + + + + Также рекомендую ознакомиться @@ -229,5 +252,6 @@ +
Также рекомендую ознакомиться