7 Commits

Author SHA1 Message Date
Sweetbread 85d1e2be85 tmp 2026-02-10 10:27:45 +03:00
Sweetbread e6bc69aa3e Add rtime for steam 2026-02-07 20:05:29 +03:00
Sweetbread a0cfa765bd Cover replacing for LB
Docker Build and Push / build-and-push (push) Successful in 20s
2026-02-07 19:25:12 +03:00
Sweetbread 62b8f056fa Fix covers
Docker Build and Push / build-and-push (push) Successful in 20s
2026-02-07 18:39:58 +03:00
Sweetbread 0edf6e35d7 Reactive updating of related time 2026-02-07 18:39:58 +03:00
Sweetbread db11fabe1a Refactoring 2026-02-07 18:39:58 +03:00
Sweetbread 0f5d1b5221 Don't send data if not modified 2026-02-06 18:56:38 +03:00
11 changed files with 428 additions and 91 deletions
+34 -22
View File
@@ -1,21 +1,22 @@
import os
import magic
from pathlib import Path
from htmlmin import minify
from time import time
from datetime import datetime, timedelta
from musicbrainzngs import get_image_front
from pathlib import Path
from time import time
from flask import (
Blueprint,
render_template,
send_file,
send_from_directory,
make_response,
abort,
make_response,
render_template,
request,
send_file,
)
import magic
from htmlmin import minify
from musicbrainzngs import get_image_front
from .modules.api.lb import listens, listening
from .modules.api.steam import recent, owned
from .modules.api.lb import data as lb_data
from .modules.api.steam import data as steam_data
def tmsmp(sec: int) -> str:
if sec == 0:
@@ -32,11 +33,6 @@ def tmsmp(sec: int) -> str:
days = round(sec / 86400, 1)
return f"{days:.0f} d" if days.is_integer() else f"{days:.1f} d"
def utmsmp(unix: int) -> str:
return datetime \
.utcfromtimestamp(unix) \
.strftime('%Y-%m-%d %H:%M:%S')
def rtmsmp(unix: int) -> str:
return tmsmp(int(time() - unix))
@@ -52,7 +48,8 @@ def render_tmpl(filename: str, **kwargs) -> str:
template_path = os.path.join("risdeveau/templates", filename)
return minify(
render_template(template_path, **kwargs),
remove_empty_space=True
remove_empty_space=True,
remove_all_empty_space=True
)
@bp.route("/static/<path:filename>")
@@ -73,12 +70,9 @@ def mb_cover(mbid):
return r
args = {
"lb": listens,
"lb_now": listening,
"recent": recent,
"owned": owned,
"lb": lb_data,
"steam": steam_data,
"tmsmp": tmsmp,
"utmsmp": utmsmp,
"rtmsmp": rtmsmp
}
@@ -88,4 +82,22 @@ def index():
@bp.route("/m/<module>")
def module(module):
if modified_since := request.headers.get('if-modified-since'):
modified_since = int(modified_since)
none_match = request.headers.get('if-none-match')
if any((modified_since, none_match)):
match module:
case "listenbrainz":
if modified_since >= int(lb_data['last_updated']):
return '', 304
if none_match == lb_data['etag']:
return '', 304
case "steam":
if modified_since >= int(steam_data['last_updated']):
return '', 304
if none_match == steam_data['etag']:
return '', 304
return render_tmpl(f'{module}.htm', **args)
+60 -26
View File
@@ -1,14 +1,31 @@
from flask import Flask, jsonify
import atexit
import re
from dataclasses import dataclass
from hashlib import md5
from json import dumps
from time import time
from urllib.parse import parse_qs, urlparse
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
import requests
from datetime import datetime
import atexit
import re
from urllib.parse import urlparse, parse_qs
from flask import Flask, jsonify
listens = {}
listening = {}
@dataclass
class Cache:
data = {}
last_updated = time()
status = None
data = {
"caches": {
"now": Cache(),
"listens": Cache()
},
"last_updated": time(),
"etag": ""
}
def yt_cover(youtube_url):
parsed_url = urlparse(youtube_url)
@@ -23,24 +40,36 @@ def yt_cover(youtube_url):
if not video_id:
return
return f"http://img.youtube.com/vi/{video_id}/sddefault.jpg"
return f"https://img.youtube.com/vi/{video_id}/2.jpg"
def parse_listens(data: dict) -> dict:
new_data = {
"count": data["count"],
def parse_listens(json: dict) -> dict:
cover_replacing = {
"1e699948-c7c8-4bb2-9f8b-62e14b882a5d": "ca464c1d-5848-45bb-b92d-b1e4b00f9d65",
"0d516a93-061e-4a27-9cf7-f36e3a96f888": "5cc0c0c7-22f9-4a4b-a24c-f1a6732f813b",
"92ea5cc8-80e0-4da0-a10b-1bc2f8e8781e": "e8f3e14a-4794-4bab-b403-d562cdad4c2f",
}
new_json = {
"count": json["count"],
"listens": []
}
for track in data["listens"]:
for track in json["listens"]:
listened_at = track.get("listened_at", 0)
track = track["track_metadata"]
new_track = {
"artist_name": track["artist_name"],
"track_name": track["track_name"]
"track_name": track["track_name"],
"listened_at": listened_at
}
if mb := track.get("mbid_mapping"):
new_track["id"] = mb.get("caa_release_mbid", mb["release_mbid"])
new_track["id"] = \
cover_replacing.get(mb["release_mbid"],
mb.get("caa_release_mbid",
mb["release_mbid"]
))
new_track["artist_name"] = mb["artists"][0]["artist_credit_name"]
new_track["track_name"] = mb["recording_name"]
elif info := track.get("additional_info"):
@@ -53,33 +82,38 @@ def parse_listens(data: dict) -> dict:
if "cover_url" not in new_track.keys() and "id" in new_track.keys():
new_track["cover_url"] = "/asset/mb/" + new_track["id"]
new_data["listens"].append(new_track)
new_json["listens"].append(new_track)
return new_data
return new_json
def api_request(url: str, cache):
def api_request(url: str, cache: Cache):
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
cache.update({
'data': parse_listens(response.json().get("payload")),
'last_updated': datetime.now().isoformat(),
'status': 'success'
})
json = parse_listens(response.json().get("payload"))
cache.status = 'success'
if cache.data != json:
cache.data = json
cache.last_updated = time()
data['last_updated'] = time()
data['etag'] = md5(''.join(
( dumps(data['caches'][x].data) for x in data['caches'] )
).encode()).hexdigest()
else:
cache['status'] = f'error: {response.status_code}'
cache.status = f'error: {response.status_code}'
except Exception as e:
cache['status'] = f'error: {str(e)}'
cache.status = f'error: {str(e)}'
scheduler = BackgroundScheduler()
scheduler.add_job(
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/listens?count=5", listens),
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/listens?count=5", data['caches']['listens']),
trigger=IntervalTrigger(minutes=1),
id='risdeveau.listenbrainz.listens',
replace_existing=True
)
scheduler.add_job(
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/playing-now", listening),
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/playing-now", data['caches']['now']),
trigger=IntervalTrigger(seconds=15),
id='risdeveau.listenbrainz.playing-now',
replace_existing=True
+44 -20
View File
@@ -1,28 +1,46 @@
import atexit
import re
from dataclasses import dataclass
from hashlib import md5
from json import dumps
from os import environ
from flask import Flask, jsonify
from time import time
from urllib.parse import parse_qs, urlparse
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
import requests
from time import time
import atexit
import re
from urllib.parse import urlparse, parse_qs
from flask import Flask
TOKEN = environ.get("STEAM_TOKEN")
MY_ID = 76561198826355942
recent = {}
owned = {}
@dataclass
class Cache:
data = {}
last_updated = time()
status = None
data = {
"caches": {
"recent": Cache(),
"owned": Cache()
},
"last_updated": time(),
"etag": ""
}
def modify_game_list(json: dict) -> dict:
if 'games' in json.keys():
apps = (3301060, 404790, 1281930, 1920960, 1325960, 431960)
new_games = []
new_games = {}
for i, g in enumerate(json['games']):
if g['appid'] not in apps:
json['games'][i]['h_cover'] = f"https://shared.fastly.steamstatic.com/store_item_assets//steam/apps/{g['appid']}/header.jpg"
json['games'][i]['v_cover'] = f"https://shared.fastly.steamstatic.com/store_item_assets//steam/apps/{g['appid']}/library_600x900.jpg"
new_games.append(json['games'][i])
new_games[g['appid']] = json['games'][i]
json['games'] = new_games
return json
@@ -37,34 +55,40 @@ def api_request(cache, *args, **kwargs):
try:
response = steam_request(*args, **kwargs)
if response.status_code == 200:
cache.update({
'data': modify_game_list(response.json().get("response")),
'last_updated': time(),
'status': 'success'
})
json = modify_game_list(response.json().get("response"))
cache.status = 'success'
if cache.data != json:
cache.data = json
cache.last_updated = time()
data['last_updated'] = time()
data['etag'] = md5(''.join(
( dumps(data['caches'][x].data) for x in data['caches'] )
).encode()).hexdigest()
else:
cache['status'] = f'error: {response.status_code}'
cache.status = f'error: {response.status_code}'
print("x")
except Exception as e:
cache['status'] = f'error: {str(e)}'
cache.status = f'error: {str(e)}'
if TOKEN:
scheduler = BackgroundScheduler()
scheduler.add_job(
func=lambda: api_request(recent, "IPlayerService", "GetRecentlyPlayedGames", steamid=76561198826355942),
func=lambda: api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=76561198826355942),
trigger=IntervalTrigger(minutes=15),
id='risdeveau.steam.recent',
replace_existing=True
)
scheduler.add_job(
func=lambda: api_request(owned, "IPlayerService", "GetOwnedGames", steamid=76561198826355942, include_appinfo=1, include_played_free_games=1),
func=lambda: api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=76561198826355942, include_appinfo=1, include_played_free_games=1),
trigger=IntervalTrigger(minutes=60),
id='risdeveau.steam.owned',
replace_existing=True
)
scheduler.start()
api_request(recent, "IPlayerService", "GetRecentlyPlayedGames", steamid=76561198826355942)
api_request(owned, "IPlayerService", "GetOwnedGames", steamid=76561198826355942, include_appinfo=1, include_played_free_games=1)
api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=76561198826355942)
api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=76561198826355942, include_appinfo=1, include_played_free_games=1)
atexit.register(lambda: scheduler.shutdown())
else:
+136
View File
@@ -0,0 +1,136 @@
document.addEventListener('alpine:init', () => {
Alpine.data('rtime', (unixTimestamp) => ({
targetDate: new Date(unixTimestamp * 1000),
timeString: '',
timer: null,
interval: 1000,
colorClasses: {
green: 't-green',
yellow: 't-yellow',
orange: 't-orange',
red: 't-red'
},
currentColor: 'green',
get textColorClass() {
return this.colorClasses[this.currentColor];
},
init() {
this.updateTime();
this.timer = setInterval(() => this.updateTime(), this.interval);
this.$el.addEventListener('alpine:removing', () => {
if (this.interval) clearInterval(this.interval);
});
},
updateTime() {
const now = new Date();
const diffInSeconds = Math.floor((now - this.targetDate) / 1000);
const diffInMinutes = Math.floor(diffInSeconds / 60);
const diffInHours = Math.floor(diffInSeconds / 3600);
const diffInDays = Math.floor(diffInSeconds / 86400);
let newInterval = this.interval;
if (diffInSeconds < 60) {
this.timeString = 'a moment ago';
} else if (diffInMinutes < 60) {
this.timeString = `${diffInMinutes} m ago`;
newInterval = 10000;
} else if (diffInHours < 24) {
this.timeString = `${diffInHours} h ago`;
newInterval = 60000;
} else {
this.timeString = `${diffInDays} d ago`;
clearInterval(this.timer);
}
if (diffInMinutes <= 15) {
this.currentColor = 'green';
} else if (diffInHours < 1) {
this.currentColor = 'yellow';
} else if (diffInDays < 1) {
this.currentColor = 'orange';
} else {
this.currentColor = 'red';
}
if (this.interval != newInterval) {
clearInterval(this.timer)
this.timer = setInterval(() => this.updateTime(), newInterval);
}
},
}));
Alpine.data('steam_rtime', (unixTimestamp) => ({
targetDate: new Date(unixTimestamp * 1000),
timeString: '',
timer: null,
interval: 1000,
colorClasses: {
green: 't-green',
yellow: 't-yellow',
orange: 't-orange',
red: 't-red'
},
currentColor: 'green',
get textColorClass() {
return this.colorClasses[this.currentColor];
},
init() {
this.updateTime();
this.timer = setInterval(() => this.updateTime(), this.interval);
this.$el.addEventListener('alpine:removing', () => {
if (this.interval) clearInterval(this.interval);
});
},
updateTime() {
const now = new Date();
const diffInMinutes = Math.floor((now - this.targetDate) / 60000);
const diffInHours = Math.floor(diffInMinutes / 60);
const diffInDays = Math.floor(diffInHours / 24);
const diffInMonths = Math.floor(diffInDays / 30);
let newInterval = this.interval;
if (diffInMinutes < 60) {
this.timeString = `${diffInMinutes} m ago`;
newInterval = 10000;
} else if (diffInHours < 24) {
this.timeString = `${diffInHours} h ago`;
newInterval = 60000;
} else if (diffInDays < 30) {
this.timeString = `${diffInDays} d ago`;
clearInterval(this.timer);
} else {
this.timeString = `${diffInMonths} mth ago`;
clearInterval(this.timer);
}
if (diffInHours < 12) {
this.currentColor = 'green';
} else if (diffInDays < 1) {
this.currentColor = 'yellow';
} else if (diffInMonths < 6) {
this.currentColor = 'orange'
} else {
this.currentColor = 'red';
}
if (this.interval != newInterval) {
clearInterval(this.timer)
this.timer = setInterval(() => this.updateTime(), newInterval);
}
},
}));
});
@@ -44,13 +44,34 @@ h3 {
img {
width: 5rem;
height: 5rem;
object-fit: cover;
border-radius: .5rem;
}
}
.steam {
.block {
display: flex;
&:not(.popup) {
display: flex;
position: relative;
z-index: 1;
}
&.popup {
margin-top: -.5rem;
padding-top: 1rem;
background-color: theme.$mantle;
transition: all ease-out 300ms;
&.enter {}
&.off {
margin-bottom: -.5rem;
padding: 0 .5rem;
opacity: 0;
transform: translateY(-100%);
}
}
img {
height: 7rem;
+18
View File
@@ -3,9 +3,12 @@
<head>
<title>Sweet Bread</title>
<link rel="stylesheet" href="/static/style/tw.css">
<link rel="stylesheet" href="/static/style/main.css">
<link rel="stylesheet" href="/static/style/risdeveau.css">
<link rel="icon" type="image/webp" href="/static/icon/us/risdeveau.webp" />
<script src="/static/script/rtime.js"></script>
<script
src="https://track.lair.moe/api/script.js"
data-site-id="1"
@@ -16,7 +19,22 @@
integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"
crossorigin="anonymous"
></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta
name="htmx-config"
content='{
"responseHandling":[
{"code":"204", "swap": false},
{"code":"304", "swap": false},
{"code":"[23]..", "swap": true},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": false, "error":true},
{"code":"...", "swap": true}
]
}'
/>
</head>
<body>
<header>
@@ -6,6 +6,13 @@
<div>
<p><b>{{ track.artist_name }}</b></p>
<p>{{ track.track_name }}</p>
{% if not is_active %}
<p
x-data="rtime({{ track.listened_at }})"
x-text="`Listened ${timeString}`"
:class="textColorClass"
></p>
{% endif %}
</div>
</div>
{% endmacro %}
@@ -15,13 +22,17 @@
hx-get="/m/listenbrainz"
hx-trigger="every 15s"
hx-swap="outerHTML"
hx-headers='{
"If-Modified-Since": {{ lb.last_updated | int }},
"If-None-Match": "{{ lb.etag }}"
}'
>
<h2><a href="https://listenbrainz.org/user/risdeveau/">Listenbrainz</a></h2>
{% if lb_now.data and lb_now.data.listens.0 %}
{{ track_block(lb_now.data.listens.0, is_active=true) }}
{% if lb.caches.now.data and lb.caches.now.data.listens.0 %}
{{ track_block(lb.caches.now.data.listens.0, is_active=true) }}
{% endif %}
{% if lb.data and lb.data.listens %}
{% for track in lb.data.listens %}
{% if lb.caches.listens.data and lb.caches.listens.data.listens %}
{% for track in lb.caches.listens.data.listens %}
{{ track_block(track) }}
{% endfor %}
{% endif %}
+83 -17
View File
@@ -1,15 +1,25 @@
{% if request.headers.get('hx-request') != "true" %}
<div x-data='{ "current": null, "total_mode": "T" }' class="mt-1">
{% endif %}
<div
class="block steam"
hx-get="/m/steam"
hx-trigger="every 1m"
hx-swap="outerHTML"
hx-headers='{
"If-Modified-Since": {{ steam.last_updated | int }},
"If-None-Match": "{{ steam.etag }}"
}'
>
<h2><a href="https://steamcommunity.com/id/risdeveau">Steam</a></h2>
{% if recent.data.games %}
{{ steam.caches.recent.status }}
{{ steam.caches.owned.status }}
{% if steam.caches.recent.data.games %}
<h3>Recently played:</h3>
{% for g in recent.data.games %}
<a href="https://store.steampowered.com/app/{{ g.appid }}" class="block">
{% for g in steam.caches.recent.data.games.values() %}
<div href="https://store.steampowered.com/app/{{ g.appid }}" class="block">
<picture>
<source media="(max-width: 45rem)" srcset="{{ g.v_cover }}">
<img src="{{ g.h_cover }}">
@@ -18,23 +28,51 @@
<div>
<strong>{{ g.name }}</strong>
<p>Played last 2 weeks: {{ tmsmp(g.playtime_2weeks*60) }}
<p>
Total played:
{{ tmsmp(g.playtime_linux_forever*60) }} (<abbr title="On Linux">L</abbr>) +
{{ tmsmp(g.playtime_windows_forever*60) }} (<abbr title="On Windows">W</abbr>) =
{{ tmsmp(g.playtime_forever*60) }} (<abbr title="Total">T</abbr>)
</p>
<div>
<p
x-data='{ playtime: { L: "{{ tmsmp(g.playtime_linux_forever*60) }}", W: "{{ tmsmp(g.playtime_windows_forever*60) }}", T: "{{ tmsmp(g.playtime_forever*60) }}" }}'
x-text="`Total played: ${playtime[total_mode]}`"
>
</p>
<div>
<button @click="total_mode = 'L'" :class="total_mode == 'L' && 't-green'">L</button>
<button @click="total_mode = 'W'" :class="total_mode == 'W' && 't-green'">W</button>
<button @click="total_mode = 'T'" :class="total_mode == 'T' && 't-green'">T</button>
</div>
</div>
{% if steam.caches.owned.data.games %}
{% if steam.caches.owned.data.games[g.appid] %}
<p
x-data="steam_rtime({{ steam.caches.owned.data.games[g.appid].rtime_last_played }})"
x-text="`Last played: ${timeString}`"
:class="textColorClass"
></p>
{% else %}
<p class="t-red">Last played: Unknown</p>
{% endif %}
{% endif %}
</div>
</a>
</div>
{% endfor %}
<p>Last updated: {{ rtmsmp(recent.last_updated) }} ago</p>
<p
x-data="rtime({{steam.caches.recent.last_updated}})"
x-text="`Last updated: ${timeString}`"
></p>
{% endif %}
{% if owned.data.games %}
{% if steam.caches.owned.data.games %}
<h3>Top played games:</h3>
{% set owned_games = owned.data.games | sort(attribute="playtime_forever", reverse=true) %}
{% set owned_games = steam.caches.owned.data.games.values() | sort(attribute="playtime_forever", reverse=true) %}
{% for g in owned_games[:5] %}
<a href="https://store.steampowered.com/app/{{ g.appid }}" class="block">
<div
@click='current == {{ g.appid }} ? current = null : current = {{ g.appid }}'
href="https://store.steampowered.com/app/{{ g.appid }}"
class="block"
>
<picture>
<source media="(max-width: 45rem)" srcset="{{ g.v_cover }}">
<img src="{{ g.h_cover }}">
@@ -49,11 +87,39 @@
{{ tmsmp(g.playtime_forever*60) }} (<abbr title="Total">T</abbr>)
</p>
{% if g.rtime_last_played != 0 %}
<p>Last played: {{ utmsmp(g.rtime_last_played) }}</p>
<p
x-data="steam_rtime({{ g.rtime_last_played }})"
x-text="`Last played: ${timeString}`"
:class="textColorClass"
></p>
{% endif %}
</div>
</a>
</div>
<div
class="block popup"
x-show="current == {{ g.appid }}"
x-transition:enter-start="off"
x-transition:leave-end="off"
>
<p>Some info</p>
<p>Some info</p>
<p>Some info</p>
<p>Some info</p>
<p>Some info</p>
<p>Some info</p>
<p>Some info</p>
<p>Some info</p>
<p>Some info</p>
</div>
{% endfor %}
<p>Last updated: {{ rtmsmp(owned.last_updated) }} ago</p>
<p
x-data="rtime({{steam.caches.owned.last_updated}})"
x-text="`Last updated: ${timeString}`"
></p>
{% endif %}
</div>
{% if request.headers.get('hx-request') != "true" %}
</div>
{% endif %}
+1 -1
View File
@@ -3,7 +3,7 @@ from os.path import join
def compile_styles():
dir = "blueprints/root/static/style"
files = ("main",)
files = ("main", "tw")
for file in files:
console(f"sass {join(dir, file+'.scss')} {join(dir, file+'.css')}")
@@ -18,6 +18,7 @@ $subtext0: #a6adc8;
$subtext1: #bac2de;
$red: #f38ba8;
$yellow: #f9e2af;
$green: #a6e3a1;
$peach: #fab387;
$blue: #89b4fa;
+14
View File
@@ -0,0 +1,14 @@
@use "catppuccin" as theme;
.m {
&t {
&-1 { margin-top: .5rem; }
}
}
.t {
&-red { color: theme.$red; }
&-orange { color: theme.$peach; }
&-yellow { color: theme.$yellow; }
&-green { color: theme.$green; }
}