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 `
Также рекомендую ознакомиться
@@ -229,5 +248,6 @@ +