diff --git a/app.py b/app.py index 982f9a9..ebf91b5 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,11 @@ from flask import Flask, render_template from utils.listenbrainz import listenbrainz_bp +from utils.steam import steam_bp app = Flask(__name__) app.register_blueprint(listenbrainz_bp) +app.register_blueprint(steam_bp) @app.route("/") def index(): diff --git a/static/css/style.css b/static/css/style.css index a1bd1f9..0f87e88 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1041,6 +1041,62 @@ a.ym-now-link:hover .ym-track-title { color: var(--accent); } .ym-link { padding: 9px 11px; gap: 10px; } } +/* ---------- активность: музыка слева, Steam справа ---------- */ +.activity-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 26px; + align-items: start; +} + +.activity-grid .ym-tracker { + max-width: none; + margin: 0; + padding: 0; +} + +.steam-panel { min-width: 0; } + +.st-sub { + display: flex; + align-items: center; + gap: 12px; + margin: 18px 0 10px; +} + +.st-sub h3 { + font-family: var(--mono); + font-size: .74rem; + font-weight: 600; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; +} + +.st-sub::after { + content: ""; + flex: 1; + height: 1px; + background: rgba(255, 255, 255, .07); +} + +.st-foot { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 14px; + margin-top: 16px; + font-family: var(--mono); + font-size: .76rem; + color: var(--muted); +} + +@media (max-width: 900px) { + .activity-grid { grid-template-columns: 1fr; } +} + /* ---------- кнопка друга ---------- */ .friend-cta { display: flex; diff --git a/static/js/steam.js b/static/js/steam.js new file mode 100644 index 0000000..7e708ae --- /dev/null +++ b/static/js/steam.js @@ -0,0 +1,164 @@ +(() => { + "use strict"; + + const recentList = document.getElementById("steamRecent"); + const topList = document.getElementById("steamTop"); + const updated = document.getElementById("steamUpdated"); + const refresh = document.getElementById("steamRefresh"); + const nowLabel = document.getElementById("steamNowLabel"); + const nowBody = document.getElementById("steamNowBody"); + const countEl = document.getElementById("steamCount"); + if (!recentList || !topList || !updated) return; + + const SOURCE = "/api/steam/activity"; + const POLL_MS = 60000; + + let timer = null; + let busy = false; + + const escapeHtml = (s) => + String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[c]); + + const IMG_FALLBACK = "data:image/svg+xml," + encodeURIComponent( + "" + + "" + + "🎮" + ); + + const fmtHours = (h) => { + if (h == null) return ""; + if (h >= 168) return `${(h / 24).toFixed(1)} д`; + if (h >= 1) return `${h.toFixed(1)} ч`; + return `${Math.round(h * 60)} мин`; + }; + + const fmtDate = (ts) => { + if (!ts) return ""; + const d = new Date(ts * 1000); + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (d >= today) return "сегодня"; + if (d >= new Date(today.getTime() - 86400000)) return "вчера"; + return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); + }; + + const bindCovers = (root) => { + root.querySelectorAll("img.st-cover").forEach((img) => { + img.addEventListener("error", () => { img.src = IMG_FALLBACK; }, { once: true }); + }); + }; + + const rowHtml = (g, right, sub) => `
  • + + + + ${escapeHtml(g.name)} + ${sub} + + ${right} + +
  • `; + + const emptyStatus = (closed) => ({ + html: closed + ? "Детали игр скрыты в настройках приватности Steam" + : "Пока нет данных", + cls: closed ? "ym-status ym-error" : "ym-status", + }); + + const renderGames = (data) => { + const closed = Boolean(data.profile && data.profile.public === false); + + const recentHtml = (data.recent || []).map((g) => { + const parts = []; + if (g.hours_total) parts.push(`всего ${fmtHours(g.hours_total)}`); + const last = fmtDate(g.last_played); + if (last) parts.push(`играл ${last}`); + const right = g.hours_2weeks != null ? `${fmtHours(g.hours_2weeks)} / 2 нед` : ""; + return rowHtml(g, right, parts.join(" · ") || "—"); + }).join(""); + + const topHtml = (data.top || []).map((g) => { + const last = fmtDate(g.last_played); + const sub = last ? `играл ${last}` : "—"; + return rowHtml(g, fmtHours(g.hours_total), sub); + }).join(""); + + const recentEmpty = emptyStatus(closed); + const topEmpty = emptyStatus(closed); + recentList.innerHTML = recentHtml || `
  • ${recentEmpty.html}
  • `; + topList.innerHTML = topHtml || `
  • ${topEmpty.html}
  • `; + + bindCovers(recentList); + bindCovers(topList); + + if (countEl) countEl.textContent = data.game_count ? `всего игр: ${data.game_count}` : ""; + }; + + const renderNow = (data) => { + if (!nowBody) return; + const now = data.now; + if (now) { + if (nowLabel) nowLabel.innerHTML = 'сейчас в игре'; + const name = escapeHtml(now.name || "Steam"); + nowBody.innerHTML = ` + + + ${name} + открыть в Steam + + `; + bindCovers(nowBody); + } else { + if (nowLabel) nowLabel.innerHTML = 'статус'; + const state = data.state || ""; + const text = !state || state === "не в сети" ? "не в сети" : `не в игре · ${state}`; + nowBody.innerHTML = `${escapeHtml(text)}`; + } + }; + + const showError = (msg) => { + const html = `
  • ${escapeHtml(msg)}
  • `; + recentList.innerHTML = html; + topList.innerHTML = html; + if (nowBody) nowBody.innerHTML = `${escapeHtml(msg)}`; + updated.textContent = "ошибка"; + }; + + const tick = async () => { + if (busy) return; + busy = true; + try { + const res = await fetch(SOURCE, { headers: { Accept: "application/json" } }); + const data = await res.json(); + if (!res.ok || !data.ok) { + showError(data.error || `Ошибка ${res.status}`); + return; + } + renderGames(data); + renderNow(data); + const t = new Date(data.updated_at * 1000); + updated.textContent = `обновлено ${t.toLocaleTimeString("ru-RU")}${data.stale ? " (кэш)" : ""}`; + } catch (e) { + showError("Нет связи с сервером"); + } 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 d063dbe..7026dee 100644 --- a/templates/author.html +++ b/templates/author.html @@ -251,26 +251,65 @@
    -
    -
    -
    - live -

    Что я слушаю

    -
    -
    - подключение… - +
    +
    +
    +
    +
    + live +

    Что я слушаю

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

    Что я играю

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

    Недавно играю

    +
      +
    1. Загрузка…
    2. +
    + +

    Больше всего времени

    +
      +
    1. Загрузка…
    2. +
    + +

    + профиль Steam + +

    +
    +
    @@ -310,5 +349,6 @@ + diff --git a/utils/steam.py b/utils/steam.py new file mode 100644 index 0000000..b7fbe5e --- /dev/null +++ b/utils/steam.py @@ -0,0 +1,170 @@ +import json +import os +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +from flask import Blueprint, jsonify + +steam_bp = Blueprint("steam", __name__) + +STEAM_API_KEY = os.environ.get("STEAM_API_KEY", "").strip() +STEAM_ID = os.environ.get("STEAM_ID", "76561199137306791").strip() +ACTIVITY_TTL = 60 +RECENT_COUNT = 5 +TOP_COUNT = 5 + +API_BASE = "https://api.steampowered.com" +STORE_APP = "https://store.steampowered.com/app" +PROFILE_URL = f"https://steamcommunity.com/profiles/{STEAM_ID}" + +_CACHE_LOCK = threading.Lock() +_CACHE: dict = {"at": 0.0, "payload": None} + +PERSONA_STATES = { + 0: "не в сети", + 1: "в сети", + 2: "занят", + 3: "отошёл", + 4: "спит", + 5: "ищет обмен", + 6: "ищет поиграть", + 7: "невидим", +} + + +def _get(path, **params): + params["key"] = STEAM_API_KEY + url = f"{API_BASE}{path}?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def _header(appid): + return f"https://cdn.akamai.steamstatic.com/steam/apps/{appid}/header.jpg" + + +def _store_url(appid): + return f"{STORE_APP}/{appid}/" + + +def _serialize(game): + appid = int(game["appid"]) + minutes = int(game.get("playtime_forever") or 0) + two_weeks = game.get("playtime_2weeks") + last_played = int(game.get("rtime_last_played") or 0) + return { + "appid": appid, + "name": game.get("name") or f"App #{appid}", + "hours_total": round(minutes / 60, 1), + "hours_2weeks": round(int(two_weeks) / 60, 1) if two_weeks else None, + "last_played": last_played or None, + "header": _header(appid), + "url": _store_url(appid), + } + + +def _fetch(): + recent_resp = _get( + "/IPlayerService/GetRecentlyPlayedGames/v1/", + steamid=STEAM_ID, + count=RECENT_COUNT, + ) + owned_resp = _get( + "/IPlayerService/GetOwnedGames/v1/", + steamid=STEAM_ID, + include_appinfo=1, + include_played_free_games=1, + ) + summary_resp = _get("/ISteamUser/GetPlayerSummaries/v2/", steamids=STEAM_ID) + + recent = [ + _serialize(game) + for game in (recent_resp.get("response") or {}).get("games") or [] + ] + + owned = owned_resp.get("response") or {} + played = [ + game + for game in owned.get("games") or [] + if int(game.get("playtime_forever") or 0) > 0 + ] + played.sort(key=lambda game: int(game.get("playtime_forever") or 0), reverse=True) + top = [_serialize(game) for game in played[:TOP_COUNT]] + + players = (summary_resp.get("response") or {}).get("players") or [] + player = players[0] if players else {} + + game_id = str(player.get("gameid") or "") + game_name = player.get("gameextrainfo") or "" + now = None + if game_id: + appid = int(game_id) + if not game_name: + known = next((g["name"] for g in recent + top if g["appid"] == appid), None) + game_name = known or "" + now = { + "appid": appid, + "name": game_name, + "header": _header(appid), + "url": _store_url(appid), + } + + state = int(player.get("personastate") or 0) + return { + "ok": True, + "updated_at": int(time.time()), + "now": now, + "state": PERSONA_STATES.get(state, ""), + "profile": { + "url": player.get("profileurl") or PROFILE_URL, + "name": player.get("personaname") or "", + "public": int(player.get("communityvisibilitystate") or 0) == 3, + }, + "recent": recent, + "top": top, + "game_count": int(owned.get("game_count") or 0) or None, + } + + +def _fetch_activity(): + try: + return _fetch() + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise RuntimeError("Steam API: неверный или недействительный ключ") from exc + if exc.code == 429: + raise RuntimeError("Steam API: лимит запросов, попробуйте позже") from exc + raise RuntimeError(f"Steam API: HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"нет связи с Steam API: {exc.reason}") from exc + + +def _serve(): + now = time.monotonic() + with _CACHE_LOCK: + if _CACHE["payload"] is not None and now - _CACHE["at"] < ACTIVITY_TTL: + return jsonify(_CACHE["payload"]), 200 + + try: + payload = _fetch_activity() + 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 + + +@steam_bp.route("/api/steam/activity") +def steam_activity(): + if not STEAM_API_KEY: + return jsonify(ok=False, error="STEAM_API_KEY is not set"), 503 + return _serve()