feat(steam): Added steam activity
This commit is contained in:
1 parent
1ea9e22d8f
commit
e528b99743
5 files changed
+403
-2
No files matched your search
@@ -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():
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const recentList = document.getElementById("steamRecent");
|
||||
const topList = document.getElementById("steamTop");
|
||||
const updated = document.getElementById("steamUpdated");
|
||||
const refresh = document.getElementById("steamRefresh");
|
||||
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(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'>" +
|
||||
"<rect width='48' height='48' rx='8' fill='#0c0e16'/>" +
|
||||
"<text x='24' y='31' font-size='20' text-anchor='middle'>🎮</text></svg>"
|
||||
);
|
||||
|
||||
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) => `<li class="ym-track">
|
||||
<a class="ym-link" href="${escapeHtml(g.url)}" target="_blank" rel="noopener">
|
||||
<img class="ym-cover st-cover" src="${escapeHtml(g.header)}" alt="" loading="lazy" width="48" height="48">
|
||||
<span class="ym-info">
|
||||
<span class="ym-track-title">${escapeHtml(g.name)}</span>
|
||||
<span class="ym-artists">${sub}</span>
|
||||
</span>
|
||||
<span class="ym-day">${right}</span>
|
||||
</a>
|
||||
</li>`;
|
||||
|
||||
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 || `<li class="${recentEmpty.cls}">${recentEmpty.html}</li>`;
|
||||
topList.innerHTML = topHtml || `<li class="${topEmpty.cls}">${topEmpty.html}</li>`;
|
||||
|
||||
bindCovers(recentList);
|
||||
bindCovers(topList);
|
||||
|
||||
if (countEl) countEl.textContent = data.game_count ? `всего игр: ${data.game_count}` : "";
|
||||
};
|
||||
|
||||
const showError = (msg) => {
|
||||
const html = `<li class="ym-status ym-error">${escapeHtml(msg)}</li>`;
|
||||
recentList.innerHTML = html;
|
||||
topList.innerHTML = html;
|
||||
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);
|
||||
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();
|
||||
})();
|
||||
+37
-2
@@ -251,14 +251,16 @@
|
||||
</section>
|
||||
|
||||
<section class="section" id="listening">
|
||||
<div class="ym-tracker" data-reveal>
|
||||
<div class="activity-grid">
|
||||
<div class="activity-col" data-reveal>
|
||||
<div class="ym-tracker">
|
||||
<div class="ym-head">
|
||||
<div class="ym-title">
|
||||
<span class="ym-live"><span class="pulse"></span>live</span>
|
||||
<h2>Что я слушаю</h2>
|
||||
</div>
|
||||
<div class="ym-meta">
|
||||
<span class="ym-updated" id="ymUpdated">подключение…</span>
|
||||
<span id="ymUpdated">подключение…</span>
|
||||
<button class="ym-refresh" id="ymRefresh" type="button" aria-label="Обновить">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,6 +274,38 @@
|
||||
<li class="ym-status" id="ymStatus">Загрузка истории…</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activity-col" data-reveal>
|
||||
<div class="steam-panel">
|
||||
<div class="ym-head">
|
||||
<div class="ym-title">
|
||||
<span class="ym-live"><span class="pulse"></span>live</span>
|
||||
<h2>Что я играю</h2>
|
||||
</div>
|
||||
<div class="ym-meta">
|
||||
<span id="steamUpdated">подключение…</span>
|
||||
<button class="ym-refresh" id="steamRefresh" type="button" aria-label="Обновить">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="st-sub"><h3>Недавно играю</h3></div>
|
||||
<ol class="ym-list" id="steamRecent">
|
||||
<li class="ym-status">Загрузка…</li>
|
||||
</ol>
|
||||
|
||||
<div class="st-sub"><h3>Больше всего времени</h3></div>
|
||||
<ol class="ym-list" id="steamTop">
|
||||
<li class="ym-status">Загрузка…</li>
|
||||
</ol>
|
||||
|
||||
<p class="st-foot">
|
||||
<a class="inline-link" href="https://steamcommunity.com/profiles/76561199137306791" target="_blank" rel="noopener">профиль Steam</a>
|
||||
<span id="steamCount"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="friend-cta" data-reveal>
|
||||
@@ -310,5 +344,6 @@
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ym.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/steam.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
+170
@@ -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()
|
||||
Reference in new issue
Block a user