6 Commits

Author SHA1 Message Date
Sweetbread fd22c7552c Respect DNT header
Docker Build and Push / build-and-push (push) Successful in 24s
2026-04-09 04:38:52 +03:00
Sweetbread 75525b4d57 Add some webrings
Difficult euroring parsing was made by GPT too. Its realisation is
horror
2026-04-09 04:38:52 +03:00
Sweetbread b35aaced54 Add own 88x31
(and rename icon->img, btw...)
2026-04-09 04:38:52 +03:00
Sweetbread 3a47b84a1b Use Pug template generator
Docker Build and Push / build-and-push (push) Successful in 1m15s
2026-04-08 23:03:37 +03:00
Sweetbread 4844cdb7b6 Fix API calls by all workers
Quite a complex commit by GPT. I'll rewrite it somewhen
2026-04-08 22:55:27 +03:00
Sweetbread 6c22740fbd Update info 2026-03-07 18:45:24 +03:00
21 changed files with 587 additions and 56 deletions
+1
View File
@@ -14,6 +14,7 @@ FROM python:3.11-slim
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -y \ apt-get install --no-install-recommends -y \
libmagic1 \ libmagic1 \
git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+8 -2
View File
@@ -15,8 +15,8 @@ import magic
from htmlmin import minify from htmlmin import minify
from musicbrainzngs import get_image_front from musicbrainzngs import get_image_front
from .modules.api.lb import data as lb_data from .modules.api.lb import data as lb_data, refresh_cache as lb_refresh
from .modules.api.steam import data as steam_data from .modules.api.steam import data as steam_data, refresh_cache as steam_refresh
def tmsmp(sec: int) -> str: def tmsmp(sec: int) -> str:
if sec == 0: if sec == 0:
@@ -83,10 +83,16 @@ args = {
@bp.route("/") @bp.route("/")
def index(): def index():
lb_refresh()
steam_refresh()
return render_tmpl('index.pug', **args) return render_tmpl('index.pug', **args)
@bp.route("/m/<module>") @bp.route("/m/<module>")
def module(module): def module(module):
if module == "listenbrainz":
lb_refresh()
elif module == "steam":
steam_refresh()
if none_match := request.headers.get('if-none-match'): if none_match := request.headers.get('if-none-match'):
match module: match module:
case "listenbrainz": case "listenbrainz":
+77 -7
View File
@@ -11,6 +11,9 @@ from apscheduler.triggers.interval import IntervalTrigger
import requests import requests
from flask import Flask, jsonify from flask import Flask, jsonify
from .scheduler_guard import should_start_scheduler
from .shared_cache import atomic_write_json, cache_file, load_json_if_newer
@dataclass @dataclass
class Cache: class Cache:
@@ -18,6 +21,7 @@ class Cache:
last_updated = time() last_updated = time()
status = None status = None
data = { data = {
"caches": { "caches": {
"now": Cache(), "now": Cache(),
@@ -27,6 +31,55 @@ data = {
"etag": "" "etag": ""
} }
_IS_WRITER = should_start_scheduler()
_CACHE_PATH = cache_file("listenbrainz")
_CACHE_MTIME = 0.0
def refresh_cache() -> None:
"""Refresh in-memory cache from the shared JSON file (for non-writer workers)."""
global _CACHE_MTIME
if _IS_WRITER:
return
payload, mtime = load_json_if_newer(_CACHE_PATH, _CACHE_MTIME)
if payload is None:
return
try:
data["etag"] = payload.get("etag", data.get("etag", ""))
data["last_updated"] = payload.get("last_updated", data.get("last_updated", time()))
caches = payload.get("caches", {})
for key, cache in data.get("caches", {}).items():
if isinstance(caches, dict) and key in caches and isinstance(caches[key], dict):
c = caches[key]
cache.data = c.get("data", cache.data)
cache.last_updated = c.get("last_updated", cache.last_updated)
cache.status = c.get("status", cache.status)
finally:
_CACHE_MTIME = mtime
def _persist_cache() -> None:
"""Persist current cache state to the shared JSON file (writer worker only)."""
if not _IS_WRITER:
return
payload = {
"etag": data.get("etag", ""),
"last_updated": data.get("last_updated", time()),
"caches": {
k: {
"data": v.data,
"last_updated": v.last_updated,
"status": v.status,
}
for k, v in data.get("caches", {}).items()
},
}
atomic_write_json(_CACHE_PATH, payload)
def yt_cover(youtube_url): def yt_cover(youtube_url):
parsed_url = urlparse(youtube_url) parsed_url = urlparse(youtube_url)
@@ -42,6 +95,7 @@ def yt_cover(youtube_url):
return f"https://img.youtube.com/vi/{video_id}/2.jpg" return f"https://img.youtube.com/vi/{video_id}/2.jpg"
def parse_listens(json: dict) -> dict: def parse_listens(json: dict) -> dict:
cover_replacing = { cover_replacing = {
"1e699948-c7c8-4bb2-9f8b-62e14b882a5d": "ca464c1d-5848-45bb-b92d-b1e4b00f9d65", "1e699948-c7c8-4bb2-9f8b-62e14b882a5d": "ca464c1d-5848-45bb-b92d-b1e4b00f9d65",
@@ -86,7 +140,11 @@ def parse_listens(json: dict) -> dict:
return new_json return new_json
def api_request(url: str, cache: Cache): def api_request(url: str, cache: Cache):
changed = False
prev_status = cache.status
try: try:
response = requests.get(url, timeout=10) response = requests.get(url, timeout=10)
if response.status_code == 200: if response.status_code == 200:
@@ -100,24 +158,36 @@ def api_request(url: str, cache: Cache):
data['etag'] = md5(''.join( data['etag'] = md5(''.join(
( dumps(data['caches'][x].data) for x in data['caches'] ) ( dumps(data['caches'][x].data) for x in data['caches'] )
).encode()).hexdigest() ).encode()).hexdigest()
changed = True
else: else:
cache.status = f'error: {response.status_code}' cache.status = f'error: {response.status_code}'
except Exception as e: except Exception as e:
cache.status = f'error: {str(e)}' cache.status = f'error: {str(e)}'
if prev_status != cache.status:
changed = True
if changed:
_persist_cache()
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
scheduler.add_job(
if _IS_WRITER:
scheduler.add_job(
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/listens?count=5", data['caches']['listens']), func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/listens?count=5", data['caches']['listens']),
trigger=IntervalTrigger(minutes=1), trigger=IntervalTrigger(minutes=1),
id='risdeveau.listenbrainz.listens', id='risdeveau.listenbrainz.listens',
replace_existing=True replace_existing=True
) )
scheduler.add_job( scheduler.add_job(
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/playing-now", data['caches']['now']), func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/playing-now", data['caches']['now']),
trigger=IntervalTrigger(seconds=15), trigger=IntervalTrigger(seconds=15),
id='risdeveau.listenbrainz.playing-now', id='risdeveau.listenbrainz.playing-now',
replace_existing=True replace_existing=True
) )
scheduler.start() _persist_cache()
scheduler.start()
atexit.register(lambda: scheduler.shutdown()) atexit.register(lambda: scheduler.shutdown())
else:
refresh_cache()
@@ -0,0 +1,35 @@
from __future__ import annotations
import os
import fcntl
from typing import Optional
_LOCK_FD: Optional[int] = None # keep fd open for process lifetime
def should_start_scheduler() -> bool:
"""Return True in exactly one process (the scheduler owner).
Under gunicorn --workers N, code is imported in N processes. If APScheduler
starts at import time, you get N schedulers => N× API calls.
We prevent that by taking a non-blocking exclusive lock on a lockfile.
"""
global _LOCK_FD
if _LOCK_FD is not None:
return True
lock_path = os.environ.get("LAIR_SCHED_LOCK", "/tmp/lair-scheduler.lock")
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_LOCK_FD = fd
return True
except BlockingIOError:
os.close(fd)
return False
except Exception:
try:
os.close(fd)
finally:
return False
@@ -0,0 +1,53 @@
from __future__ import annotations
import json
import os
import tempfile
from typing import Any, Optional, Tuple
def cache_dir() -> str:
"""Directory where the scheduler owner writes shared cache JSON files."""
d = os.environ.get("LAIR_CACHE_DIR", "/tmp/lair-cache")
os.makedirs(d, exist_ok=True)
return d
def cache_file(name: str) -> str:
return os.path.join(cache_dir(), f"{name}.json")
def atomic_write_json(path: str, payload: dict) -> None:
"""Write JSON atomically so readers never observe partial content."""
parent = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
finally:
try:
if os.path.exists(tmp):
os.unlink(tmp)
except Exception:
pass
def load_json_if_newer(path: str, last_mtime: float) -> Tuple[Optional[dict], float]:
"""Load JSON only if the file exists and has mtime newer than last_mtime."""
try:
st = os.stat(path)
except FileNotFoundError:
return None, last_mtime
except Exception:
return None, last_mtime
mtime = float(st.st_mtime)
if mtime <= float(last_mtime):
return None, last_mtime
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f), mtime
except Exception:
return None, last_mtime
+85 -6
View File
@@ -12,16 +12,21 @@ from apscheduler.triggers.interval import IntervalTrigger
import requests import requests
from flask import Flask from flask import Flask
from .scheduler_guard import should_start_scheduler
from .shared_cache import atomic_write_json, cache_file, load_json_if_newer
TOKEN = environ.get("STEAM_TOKEN") TOKEN = environ.get("STEAM_TOKEN")
MY_ID = 76561198826355942 MY_ID = 76561198826355942
@dataclass @dataclass
class Cache: class Cache:
data = {} data = {}
last_updated = time() last_updated = time()
status = None status = None
data = { data = {
"caches": { "caches": {
"recent": Cache(), "recent": Cache(),
@@ -31,6 +36,55 @@ data = {
"etag": "" "etag": ""
} }
_IS_WRITER = should_start_scheduler()
_CACHE_PATH = cache_file("steam")
_CACHE_MTIME = 0.0
def refresh_cache() -> None:
"""Refresh in-memory cache from the shared JSON file (for non-writer workers)."""
global _CACHE_MTIME
if _IS_WRITER:
return
payload, mtime = load_json_if_newer(_CACHE_PATH, _CACHE_MTIME)
if payload is None:
return
try:
data["etag"] = payload.get("etag", data.get("etag", ""))
data["last_updated"] = payload.get("last_updated", data.get("last_updated", time()))
caches = payload.get("caches", {})
for key, cache in data.get("caches", {}).items():
if isinstance(caches, dict) and key in caches and isinstance(caches[key], dict):
c = caches[key]
cache.data = c.get("data", cache.data)
cache.last_updated = c.get("last_updated", cache.last_updated)
cache.status = c.get("status", cache.status)
finally:
_CACHE_MTIME = mtime
def _persist_cache() -> None:
"""Persist current cache state to the shared JSON file (writer worker only)."""
if not _IS_WRITER:
return
payload = {
"etag": data.get("etag", ""),
"last_updated": data.get("last_updated", time()),
"caches": {
k: {
"data": v.data,
"last_updated": v.last_updated,
"status": v.status,
}
for k, v in data.get("caches", {}).items()
},
}
atomic_write_json(_CACHE_PATH, payload)
def modify_game_list(json: dict) -> dict: def modify_game_list(json: dict) -> dict:
if 'games' in json.keys(): if 'games' in json.keys():
apps = (3301060, 404790, 1281930, 1920960, 1325960, 431960) apps = (3301060, 404790, 1281930, 1920960, 1325960, 431960)
@@ -43,6 +97,7 @@ def modify_game_list(json: dict) -> dict:
json['games'] = new_games json['games'] = new_games
return json return json
def steam_request(interface: str, method: str, v: int = 1, **kwargs) -> requests.Response: def steam_request(interface: str, method: str, v: int = 1, **kwargs) -> requests.Response:
return requests.get( return requests.get(
f"https://api.steampowered.com/{interface}/{method}/v{v:04}/", f"https://api.steampowered.com/{interface}/{method}/v{v:04}/",
@@ -50,7 +105,11 @@ def steam_request(interface: str, method: str, v: int = 1, **kwargs) -> requests
timeout=10 timeout=10
) )
def api_request(cache, *args, **kwargs): def api_request(cache, *args, **kwargs):
changed = False
prev_status = cache.status
try: try:
response = steam_request(*args, **kwargs) response = steam_request(*args, **kwargs)
if response.status_code == 200: if response.status_code == 200:
@@ -64,31 +123,51 @@ def api_request(cache, *args, **kwargs):
data['etag'] = md5(''.join( data['etag'] = md5(''.join(
( dumps(data['caches'][x].data) for x in data['caches'] ) ( dumps(data['caches'][x].data) for x in data['caches'] )
).encode()).hexdigest() ).encode()).hexdigest()
changed = True
else: else:
cache.status = f'error: {response.status_code}' cache.status = f'error: {response.status_code}'
print("x")
except Exception as e: except Exception as e:
cache.status = f'error: {str(e)}' cache.status = f'error: {str(e)}'
if prev_status != cache.status:
changed = True
if changed:
_persist_cache()
if TOKEN: if TOKEN:
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
if _IS_WRITER:
scheduler.add_job( scheduler.add_job(
func=lambda: api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=76561198826355942), func=lambda: api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=MY_ID),
trigger=IntervalTrigger(minutes=15), trigger=IntervalTrigger(minutes=15),
id='risdeveau.steam.recent', id='risdeveau.steam.recent',
replace_existing=True replace_existing=True
) )
scheduler.add_job( scheduler.add_job(
func=lambda: api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=76561198826355942, include_appinfo=1, include_played_free_games=1), func=lambda: api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=MY_ID, include_appinfo=1, include_played_free_games=1),
trigger=IntervalTrigger(minutes=60), trigger=IntervalTrigger(minutes=60),
id='risdeveau.steam.owned', id='risdeveau.steam.owned',
replace_existing=True replace_existing=True
) )
_persist_cache()
scheduler.start() scheduler.start()
api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=76561198826355942) api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=MY_ID)
api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=76561198826355942, include_appinfo=1, include_played_free_games=1) api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=MY_ID, include_appinfo=1, include_played_free_games=1)
atexit.register(lambda: scheduler.shutdown()) atexit.register(lambda: scheduler.shutdown())
else:
refresh_cache()
else: else:
print("STEAM_TOKEN is not defined") msg = "STEAM_TOKEN is not defined"
print(msg)
for c in data["caches"].values():
c.status = msg
if _IS_WRITER:
_persist_cache()
else:
refresh_cache()
+3
View File
@@ -3,6 +3,9 @@ div
a.disabled(href="https://chest.lair.moe") a.disabled(href="https://chest.lair.moe")
img(src="/static/img/88x31/gf.png") img(src="/static/img/88x31/gf.png")
a(href="//lair.moe")
img(src="/static/img/88x31/lair.gif")
a#pie(href="https://preview.about.akarpov.ru") a#pie(href="https://preview.about.akarpov.ru")
img(src="/static/img/88x31/withpie.gif") img(src="/static/img/88x31/withpie.gif")
+2 -2
View File
@@ -2,7 +2,7 @@
h3 Development h3 Development
.blocks.badges .blocks.badges
a.block(href="//g.lair.moe/Sweetbread") a.block(href="//g.lair.moe/Sweetbread")
img.icon(src="/static/icon/service/gitea.webp") img.icon(src="/static/img/service/gitea.webp")
| Gitea | Gitea
a.block(href="https://github.com/VerySweetBread") a.block(href="https://github.com/VerySweetBread")
@@ -20,7 +20,7 @@
| Matrix | Matrix
a.block(href="//b.lair.moe/@risdeveau") a.block(href="//b.lair.moe/@risdeveau")
img.icon(src="/static/icon/service/sharkey.webp") img.icon(src="/static/img/service/sharkey.webp")
| Fediverse | Fediverse
a.block(href="https://discord.com/users/459823895256498186") a.block(href="https://discord.com/users/459823895256498186")
+2 -1
View File
@@ -7,9 +7,10 @@ html(lang="en")
each f in ('tw', 'main', 'risdeveau') each f in ('tw', 'main', 'risdeveau')
link(rel="stylesheet", href="/static/style/#{f}.css") link(rel="stylesheet", href="/static/style/#{f}.css")
link(rel="icon" type="image/webp" href="/static/icon/us/risdeveau.webp") link(rel="icon" type="image/webp" href="/static/img/us/risdeveau.webp")
script(src="/static/script/rtime.js") script(src="/static/script/rtime.js")
if request.headers.get('DNT') != "1":
script( script(
src="https://track.lair.moe/api/script.js" src="https://track.lair.moe/api/script.js"
data-site-id="1" data-site-id="1"
+4 -1
View File
@@ -1,6 +1,8 @@
from htmlmin import minify from htmlmin import minify
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, render_template, request, jsonify
from .modules.euroring import data as euroring_data, refresh_cache as euroring_refresh
bp = Blueprint( bp = Blueprint(
"root", "root",
__name__, __name__,
@@ -9,8 +11,9 @@ bp = Blueprint(
) )
def render_tmpl(filename: str) -> str: def render_tmpl(filename: str) -> str:
euroring_refresh()
return minify( return minify(
render_template(filename), render_template(filename, euroring=euroring_data),
remove_empty_space=True remove_empty_space=True
) )
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import atexit
import json
import os
import re
import tempfile
from time import time
from urllib.parse import urlsplit
import fcntl
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
def _cache_dir() -> str:
path = os.environ.get("LAIR_CACHE_DIR", "/tmp/lair-cache")
os.makedirs(path, exist_ok=True)
return path
def _cache_file(name: str) -> str:
return os.path.join(_cache_dir(), f"{name}.json")
def _atomic_write_json(path: str, payload: dict) -> None:
parent = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
finally:
try:
if os.path.exists(tmp):
os.unlink(tmp)
except Exception:
pass
_CACHE_MTIME = 0.0
_LOCK_FD: int | None = None
def _load_json_if_newer(path: str, last_mtime: float) -> tuple[dict | None, float]:
try:
stat = os.stat(path)
except FileNotFoundError:
return None, last_mtime
except Exception:
return None, last_mtime
mtime = float(stat.st_mtime)
if mtime <= float(last_mtime):
return None, last_mtime
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f), mtime
except Exception:
return None, last_mtime
def _should_start_scheduler() -> bool:
global _LOCK_FD
if _LOCK_FD is not None:
return True
lock_path = os.environ.get("LAIR_SCHED_LOCK", "/tmp/lair-scheduler.lock")
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_LOCK_FD = fd
return True
except BlockingIOError:
os.close(fd)
return False
except Exception:
try:
os.close(fd)
finally:
return False
EURORING_SOURCE_URL = "https://euroring.neocities.org/scripts/onionring-variables.js"
EURORING_SITE_URL = "https://lair.moe"
EURORING_TIMEOUT = float(os.environ.get("EURORING_TIMEOUT", "10"))
data = {
"enabled": bool(EURORING_SOURCE_URL and EURORING_SITE_URL),
"status": "disabled",
"source_url": EURORING_SOURCE_URL,
"site_url": EURORING_SITE_URL,
"ring_name": "",
"index_url": None,
"prev_url": None,
"next_url": None,
"count": 0,
"last_updated": 0,
}
_IS_WRITER = _should_start_scheduler()
_CACHE_PATH = _cache_file("webring")
def _site_key(url: str) -> tuple[str, int | None, str, str]:
parts = urlsplit(url.strip())
hostname = (parts.hostname or "").lower()
port = parts.port
path = re.sub(r"/+$", "", parts.path or "")
query = parts.query or ""
return hostname, port, path, query
def _parse_js_string_value(text: str, variable: str) -> str | None:
match = re.search(
rf"var\s+{re.escape(variable)}\s*=\s*(['\"])(.*?)\1\s*;",
text,
re.S,
)
if not match:
return None
return match.group(2).strip()
def _parse_bool_value(text: str, variable: str) -> bool | None:
match = re.search(rf"var\s+{re.escape(variable)}\s*=\s*(true|false)\s*;", text)
if not match:
return None
return match.group(1) == "true"
def _parse_sites(text: str) -> list[str]:
match = re.search(r"var\s+sites\s*=\s*\[(.*?)\]\s*;", text, re.S)
if not match:
raise ValueError("sites array not found")
sites_block = match.group(1)
sites: list[str] = []
for raw_line in sites_block.splitlines():
line = raw_line.strip()
if not line or line.startswith("//"):
continue
site_match = re.match(
r"(?P<quote>['\"])(?P<url>.*?)(?P=quote)\s*,?\s*(?://.*)?$",
line,
)
if site_match:
sites.append(site_match.group("url").strip())
if not sites:
raise ValueError("sites array is empty")
return sites
def _compute_ring_payload(text: str) -> dict:
sites = _parse_sites(text)
target_key = _site_key(EURORING_SITE_URL)
current_index = None
for i, site in enumerate(sites):
if _site_key(site) == target_key:
current_index = i
break
if current_index is None:
raise ValueError(f"site not found in ring: {EURORING_SITE_URL}")
ring_name = _parse_js_string_value(text, "ringName") or "Webring"
use_index = _parse_bool_value(text, "useIndex")
index_url = _parse_js_string_value(text, "indexPage") if use_index else None
return {
"enabled": True,
"status": "success",
"source_url": EURORING_SOURCE_URL,
"site_url": EURORING_SITE_URL,
"ring_name": ring_name,
"index_url": index_url,
"prev_url": sites[(current_index - 1) % len(sites)],
"next_url": sites[(current_index + 1) % len(sites)],
"count": len(sites),
"last_updated": time(),
}
def refresh_cache() -> None:
global _CACHE_MTIME
if _IS_WRITER:
return
payload, mtime = _load_json_if_newer(_CACHE_PATH, _CACHE_MTIME)
if payload is None:
return
data.update(payload)
_CACHE_MTIME = mtime
def _persist_cache() -> None:
if not _IS_WRITER:
return
_atomic_write_json(_CACHE_PATH, data)
def fetch_webring() -> None:
if not data["enabled"]:
return
previous_state = dict(data)
try:
response = requests.get(EURORING_SOURCE_URL, timeout=EURORING_TIMEOUT)
response.raise_for_status()
payload = _compute_ring_payload(response.text)
data.update(payload)
except Exception as exc:
data.update({
"enabled": True,
"status": f"error: {exc}",
"source_url": EURORING_SOURCE_URL,
"site_url": EURORING_SITE_URL,
"ring_name": data.get("ring_name") or "Webring",
"index_url": data.get("index_url"),
"prev_url": data.get("prev_url"),
"next_url": data.get("next_url"),
"count": data.get("count", 0),
"last_updated": data.get("last_updated", 0),
})
if data != previous_state:
_persist_cache()
if data["enabled"]:
scheduler = BackgroundScheduler()
if _IS_WRITER:
scheduler.add_job(
func=fetch_webring,
trigger=IntervalTrigger(days=1),
id="root.webring.refresh",
replace_existing=True,
)
_persist_cache()
scheduler.start()
fetch_webring()
atexit.register(lambda: scheduler.shutdown())
else:
refresh_cache()
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

+2
View File
@@ -185,6 +185,8 @@ footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
& + & { margin-top: .25rem; }
a { a {
padding: .5rem 1rem; padding: .5rem 1rem;
margin: .1rem !important; margin: .1rem !important;
+5 -2
View File
@@ -4,21 +4,24 @@ html(lang=g.locale)
title Lair title Lair
link(rel="stylesheet" href="/static/style/main.css") link(rel="stylesheet" href="/static/style/main.css")
link(rel="icon" type="image/webp" href="/static/icon/lair.webp") link(rel="icon" type="image/webp" href="/static/img/lair.webp")
script(src="/static/script/copy-mono.js") script(src="/static/script/copy-mono.js")
if request.headers.get('DNT') != "1":
script( script(
src="https://track.lair.moe/api/script.js" src="https://track.lair.moe/api/script.js"
data-site-id="1" data-site-id="1"
defer defer
) )
meta(name="viewport" content="width=device-width, initial-scale=1.0") meta(name="viewport" content="width=device-width, initial-scale=1.0")
meta(name="mock-email" content="admin@example.com") meta(name="mock-email" content="admin@example.com")
meta(property="og:type" value="website") meta(property="og:type" value="website")
meta(property="og:url" value="https://lair.moe") meta(property="og:url" value="https://lair.moe")
meta(property="og:title" value="Lair.moe") meta(property="og:title" value="Lair.moe")
meta(property="og:image" value="https://lair.moe/static/icon/lair.webp") meta(property="og:image" value="https://lair.moe/static/img/lair.webp")
meta(property="og:description" value=_("description")) meta(property="og:description" value=_("description"))
body body
+22 -3
View File
@@ -1,19 +1,19 @@
extends root/templates/base.pug extends root/templates/base.pug
block title block title
img.icon(src="/static/icon/lair.webp") img.icon(src="/static/img/lair.webp")
| Lair | Lair
block content block content
a.block(href="https://b.lair.moe" target="_blank") a.block(href="https://b.lair.moe" target="_blank")
.header .header
img.icon(src="/static/icon/service/sharkey.webp") img.icon(src="/static/img/service/sharkey.webp")
strong Sharkey strong Sharkey
p= _('index.descr:sharkey') p= _('index.descr:sharkey')
a.block(href="https://g.lair.moe" target="_blank") a.block(href="https://g.lair.moe" target="_blank")
.header .header
img.icon(src="/static/icon/service/gitea.webp") img.icon(src="/static/img/service/gitea.webp")
strong Gitea strong Gitea
p= _('index.descr:gitea') p= _('index.descr:gitea')
@@ -68,3 +68,22 @@ block content
strong Yggdrasil strong Yggdrasil
| : | :
span.mono 200:ee1:bad2:1732:4b91:c3e3:2f08:29b3 span.mono 200:ee1:bad2:1732:4b91:c3e3:2f08:29b3
.webring
a.block(href="https://nixwebr.ing/prev/lair" rel="external prev") &larr;
a.block(href="https://nixwebr.ing/" rel="external") Nix webring
a.block(href="https://nixwebr.ing/next/lair" rel="external next") &rarr;
.webring
a.block(href="https://ctp-webr.ing/lair/previous" rel="external prev") &larr;
a.block(href="https://ctp-webr.ing/" rel="external") Catppuccin webring
a.block(href="https://ctp-webr.ing/lair/next" rel="external next") &rarr;
if euroring.prev_url and euroring.next_url
.webring
a.block(href=euroring.prev_url rel="external prev") &larr;
if euroring.index_url
a.block(href=euroring.index_url rel="external") Euroring
else
span.block= euroring.ring_name
a.block(href=euroring.next_url rel="external next") &rarr;
+2 -2
View File
@@ -6,12 +6,12 @@ block title
block content block content
a.block.green(href=url_for('risdeveau.index')) a.block.green(href=url_for('risdeveau.index'))
.header .header
img.icon(src="/static/icon/us/risdeveau.webp") img.icon(src="/static/img/us/risdeveau.webp")
| Sweetbread | Sweetbread
| Главный админ, занимается почти всеми сервисами. Создал этот сайт | Главный админ, занимается почти всеми сервисами. Создал этот сайт
.block.orange.disabled .block.orange.disabled
.header .header
img.icon(src="/static/icon/us/chest.webp") img.icon(src="/static/img/us/chest.webp")
| Chest | Chest
| Должна была помогать делать этот сайт | Должна была помогать делать этот сайт