feat(romhacks): serve library over internal Caddy file_server; notes embed art + guide links
Deploy to valhalla / deploy (push) Has been cancelled

- proxy: read-only bind /storage1/labdata/romhacks/library -> /srv/romhacks
- Caddyfile: romhacks-files.ginnoir.com (internal_only) file_server + browse
- render_catalog_notes.py + handoff.py: embed box art and link guides/
  spreadsheets via https://romhacks-files.ginnoir.com/<slug>/<file>
  (handoff art/guides; guide filter drops buried game-data txt, caps at 30)
- regenerate bookmarks
This commit is contained in:
ginnoir
2026-06-08 04:44:38 -05:00
parent 8224a8fddb
commit 9210cd23f8
6 changed files with 181 additions and 15 deletions
+10
View File
@@ -128,6 +128,16 @@ roms.ginnoir.com {
reverse_proxy romm:8080
}
# Pokémon ROM-hack library files (box art, guides, spreadsheets) for the
# Obsidian catalog notes. Internal-only — these downloads are private.
# Root is the read-only /storage1/labdata/romhacks/library bind from the proxy
# stack; notes embed art via https://romhacks-files.ginnoir.com/<slug>/<file>.
romhacks-files.ginnoir.com {
import internal_only
root * /srv/romhacks
file_server browse
}
# =============================================================
# NOTIFICATIONS & RSS — public
# =============================================================
+6 -4
View File
@@ -49,11 +49,8 @@
<DT><A HREF="https://nzbget.ginnoir.com">nzbget</A>
<DT><A HREF="https://whisparr.ginnoir.com">whisparr</A>
<DT><A HREF="https://stash.ginnoir.com">stash</A>
</DL><p>
<DT><H3>GAMES / ROMS (internal)</H3>
<DL><p>
<DT><A HREF="https://roms.ginnoir.com">roms</A>
<DT><A HREF="https://romhacks-files.ginnoir.com">romhacks-files</A>
</DL><p>
<DT><H3>NOTIFICATIONS &amp; RSS (public)</H3>
@@ -101,5 +98,10 @@
<DT><A HREF="https://plane.ginnoir.com">plane</A>
</DL><p>
<DT><H3>NOTES / PKM — public (obsidian-livesync syncs from any device) (public)</H3>
<DL><p>
<DT><A HREF="https://obsidian.ginnoir.com">obsidian</A>
</DL><p>
</DL><p>
</DL><p>
+10
View File
@@ -57,6 +57,11 @@
<DT><A HREF="http://valhalla:7575">homarr</A>
</DL><p>
<DT><H3>Notes</H3>
<DL><p>
<DT><A HREF="http://valhalla:5984">couchdb</A>
</DL><p>
<DT><H3>Notify</H3>
<DL><p>
<DT><A HREF="http://valhalla:8000">ntfy :8000</A>
@@ -88,6 +93,11 @@
<DT><A HREF="http://valhalla:3000">resume</A>
</DL><p>
<DT><H3>Romhacks</H3>
<DL><p>
<DT><A HREF="http://valhalla:8998">jdownloader :8998</A>
</DL><p>
<DT><H3>Roms</H3>
<DL><p>
<DT><A HREF="http://valhalla:8997">romm :8997</A>
+4
View File
@@ -32,6 +32,10 @@ services:
- /config/caddy/data:/data
- /config/caddy/config:/config
- /storage1/Books:/srv/Books
# Pokémon ROM-hack library (box art + guides + spreadsheets), served
# read-only and LAN-only by the romhacks-files.ginnoir.com site so the
# Obsidian catalog notes can embed art and link guides.
- /storage1/labdata/romhacks/library:/srv/romhacks:ro
networks:
edge:
+63 -8
View File
@@ -24,6 +24,7 @@ import re
import shutil
import subprocess
import sys
from urllib.parse import quote
INCOMING = pathlib.Path(os.environ.get("INCOMING_DIR", "/incoming"))
LIBRARY = pathlib.Path(os.environ.get("LIBRARY_DIR", "/library"))
@@ -41,6 +42,51 @@ PATCH_EXTS = {".ips", ".bps", ".ups", ".xdelta"}
INPROGRESS = (".part", ".jdownload", ".jdownload.cfg", ".tmp", ".crdownload")
FANGAME_HINTS = ("game.exe", "game.ini", ".rxdata", ".rvdata", ".rvdata2", ".rgssad", ".rgss2a", ".rgss3a")
ART_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif")
# Internal-only Caddy file_server over the library (romhacks-files.ginnoir.com →
# /srv/romhacks). Lets the Obsidian notes embed art / link guides off /storage1.
FILE_BASE = os.environ.get("FILE_BASE", "https://romhacks-files.ginnoir.com")
# Document/spreadsheet extensions that make a genuine guide (a subset of
# GUIDE_EXTS — bare game-data .txt under PBS/Graphics/Plugins is not a guide).
DOC_EXTS = {".html", ".htm", ".pdf", ".xlsx", ".xls", ".ods", ".csv",
".doc", ".docx", ".rtf"}
GAME_DATA_DIRS = {"pbs", "graphics", "plugins", "audio", "data", "fonts",
"scripts", "optional", "se", "bgm", "tilesets", "sound",
"movies", "backup"}
MAX_GUIDES = 30
def file_url(slug_name, rel):
"""URL into the served library, encoding each path segment."""
parts = [quote(slug_name)] + [quote(seg) for seg in str(rel).split("/")]
return FILE_BASE + "/" + "/".join(parts)
def pick_guide_rels(guide_rels, artifact_name):
"""Filter raw guide rel-paths to linkable docs/spreadsheets/extras."""
art_l = (artifact_name or "").lower()
scored = []
for rel in guide_rels:
low = rel.lower()
parts = low.split("/")
bn = parts[-1]
if bn in ("note.md", "handoff.json"):
continue
ext = os.path.splitext(bn)[1]
in_gamedata = any(seg in GAME_DATA_DIRS for seg in parts[:-1])
if ext in DOC_EXTS:
if in_gamedata:
continue
scored.append((0, rel))
elif ext in (".txt", ".md"):
if in_gamedata or len(parts) > 2:
continue
scored.append((1, rel))
elif ext in ARCHIVE_EXTS:
if bn == art_l:
continue
scored.append((2, rel))
scored.sort(key=lambda t: (t[0], t[1].lower()))
return [rel for _, rel in scored]
# Document / extra files worth keeping (guides, spreadsheets, changelogs, and
# supplementary or alternate-version archives). Deliberately excludes raw game
# assets (png/audio/scripts) so an extracted tree isn't counted as "guides".
@@ -127,7 +173,7 @@ def collect_guides(folder, primary):
return guides
def render_note(meta, kind, platform, primary, guides, art, folder):
def render_note(meta, kind, platform, primary, guides, art, folder, slug_name):
name = meta.get("name", folder.name)
lines = [
"---",
@@ -140,8 +186,10 @@ def render_note(meta, kind, platform, primary, guides, art, folder):
f"# {name}",
"",
]
# Box art — art is copied into the library folder root, so <slug>/<name>
# resolves on the file-server.
for a in art:
lines.append(f"![[{a.name}]]")
lines.append(f"![|320]({file_url(slug_name, a.name)})")
if art:
lines.append("")
desc = (meta.get("description") or "").strip()
@@ -152,12 +200,19 @@ def render_note(meta, kind, platform, primary, guides, art, folder):
lines += ["## Download links", ""]
lines += [f"- {l}" for l in links]
lines.append("")
guide_rels = [str(g.relative_to(folder)) for g in guides]
picked = pick_guide_rels(guide_rels, primary.name if primary else None)
if picked:
lines += ["## Guides & extras", ""]
for rel in picked[:MAX_GUIDES]:
lines.append(f"- [{rel}]({file_url(slug_name, rel)})")
if len(picked) > MAX_GUIDES:
lines.append(f"- …and {len(picked) - MAX_GUIDES} more "
f"(browse the [library folder]({file_url(slug_name, '')}))")
lines.append("")
lines += ["## Files", "",
f"- **Artifact:** `{primary.name if primary else 'n/a'}` ({kind}/{platform})"]
if guides:
lines.append(f"- **Guides / extras ({len(guides)}):**")
for g in sorted(guides):
lines.append(f" - `{g.relative_to(folder)}`")
f"- **Artifact:** `{primary.name if primary else 'n/a'}` ({kind}/{platform})",
f"- **Library:** [browse files]({file_url(slug_name, '')})"]
lines.append("")
return "\n".join(lines)
@@ -197,7 +252,7 @@ def process(folder, handled):
kind, platform, primary = classify(folder)
guides = collect_guides(folder, primary)
note = render_note(meta, kind, platform, primary, guides, art, folder)
note = render_note(meta, kind, platform, primary, guides, art, folder, slug_name)
print(f"[handoff] {slug_name}: kind={kind} platform={platform} "
f"artifact={primary.name if primary else 'NONE'} guides={len(guides)} art={len(art)}")
@@ -1,10 +1,74 @@
import json, re, pathlib, datetime
import json, os, re, pathlib, datetime
from urllib.parse import quote
rows = json.load(open("/tmp/catalog_new.json", encoding="utf-8"))
META = pathlib.Path("/storage1/labdata/romhacks/metadata")
LIBRARY = pathlib.Path("/storage1/labdata/romhacks/library")
OUT = pathlib.Path("/tmp/catalog_notes"); OUT.mkdir(exist_ok=True)
TODAY = "2026-06-08"
# Internal-only Caddy file_server over /storage1/labdata/romhacks/library
# (romhacks-files.ginnoir.com → /srv/romhacks, import internal_only). Lets the
# notes embed box art and link guides that Obsidian can't load off /storage1.
FILE_BASE = "https://romhacks-files.ginnoir.com"
# Genuine guide/spreadsheet documents worth linking. Bare game-data text files
# (PBS/, Graphics/, Plugins/ trees of an RPG-Maker fangame) are NOT guides.
DOC_EXTS = {".html", ".htm", ".pdf", ".xlsx", ".xls", ".ods", ".csv",
".doc", ".docx", ".rtf"}
ARCHIVE_EXTS = {".zip", ".rar", ".7z"}
GAME_DATA_DIRS = {"pbs", "graphics", "plugins", "audio", "data", "fonts",
"scripts", "optional", "se", "bgm", "tilesets", "sound",
"movies", "backup"}
MAX_GUIDES = 30
def file_url(slug, rel):
"""URL into the served library, encoding each path segment (spaces, etc.)."""
parts = [quote(slug)] + [quote(seg) for seg in rel.split("/")]
return FILE_BASE + "/" + "/".join(parts)
def load_handoff(slug):
hj = LIBRARY / slug / "handoff.json"
if hj.exists():
try:
return json.loads(hj.read_text(encoding="utf-8"))
except Exception:
pass
return {}
def pick_guides(guides, artifact):
"""Filter the raw handoff guide list to linkable docs/spreadsheets/extras.
Returns rel paths, docs first, capped at MAX_GUIDES (caller notes overflow).
"""
art_l = (artifact or "").lower()
scored = []
for rel in guides:
low = rel.lower()
parts = low.split("/")
bn = parts[-1]
if bn in ("note.md", "handoff.json"):
continue
ext = os.path.splitext(bn)[1]
in_gamedata = any(seg in GAME_DATA_DIRS for seg in parts[:-1])
if ext in DOC_EXTS:
if in_gamedata:
continue # buried html/csv inside a game tree = noise
scored.append((0, rel))
elif ext in (".txt", ".md"):
if in_gamedata or len(parts) > 2:
continue # only top-level readme/changelog/credits
scored.append((1, rel))
elif ext in ARCHIVE_EXTS:
if rel == artifact or bn == art_l:
continue # that's the rom/game itself, linked elsewhere
scored.append((2, rel))
scored.sort(key=lambda t: (t[0], t[1].lower()))
return [rel for _, rel in scored]
BASES = ["HeartGold","SoulSilver","FireRed","LeafGreen","Omega Ruby","Alpha Sapphire",
"Black 2","White 2","Emerald","Crystal","Platinum","Diamond","Pearl",
"Ruby","Sapphire","Gold","Silver","Black","White","Red","Blue","Yellow",
@@ -85,6 +149,17 @@ def render(r):
"patch":"Patch file — apply to a base ROM.","unknown":"Imported."}.get(r["kind"],"Imported.")
fm.append(f"> [!info] Auto-imported from the Discord catalog ({r['generation']}) on {TODAY}. {kindline}")
fm.append("")
# Box art — embed from the internal Caddy file-server. Fall back to art
# filenames in the metadata folder if the handoff record is missing them.
ho = load_handoff(r["slug"])
art = ho.get("art") or []
if not art and (META / r["slug"]).exists():
art = sorted(p.name for p in (META / r["slug"]).glob("*")
if p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp", ".gif"))
for a in art:
fm.append(f"![|320]({file_url(r['slug'], a)})")
if art:
fm.append("")
cd = clean_desc(desc)
if cd:
fm += ["## Summary", "", cd, ""]
@@ -95,10 +170,20 @@ def render(r):
except Exception: pass
if links:
fm += ["## Download", ""] + [f"- {l}" for l in links] + [""]
# Guides / spreadsheets / extras — link each to the served file.
guides = pick_guides(ho.get("guides", []), r["artifact"])
if guides:
fm += ["## Guides & extras", ""]
for rel in guides[:MAX_GUIDES]:
fm.append(f"- [{rel}]({file_url(r['slug'], rel)})")
if len(guides) > MAX_GUIDES:
fm.append(f"- …and {len(guides) - MAX_GUIDES} more "
f"(browse the [library folder]({file_url(r['slug'], '')}))")
fm.append("")
fm += ["## Files", "",
f"- **Artifact:** `{r['artifact'] or 'n/a'}`",
f"- **Library:** `/storage1/labdata/romhacks/library/{r['slug']}/`"
+ (f"{r['n_guides']} guide/extra file(s)" if r['n_guides'] else "")]
f"- **Library:** [browse files]({file_url(r['slug'], '')}) — "
f"`/storage1/labdata/romhacks/library/{r['slug']}/`"]
fm.append("")
return safe_filename(name), "\n".join(fm)