feat(romhacks): serve library over internal Caddy file_server; notes embed art + guide links
Deploy to valhalla / deploy (push) Has been cancelled
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:
@@ -128,6 +128,16 @@ roms.ginnoir.com {
|
|||||||
reverse_proxy romm:8080
|
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
|
# NOTIFICATIONS & RSS — public
|
||||||
# =============================================================
|
# =============================================================
|
||||||
|
|||||||
@@ -49,11 +49,8 @@
|
|||||||
<DT><A HREF="https://nzbget.ginnoir.com">nzbget</A>
|
<DT><A HREF="https://nzbget.ginnoir.com">nzbget</A>
|
||||||
<DT><A HREF="https://whisparr.ginnoir.com">whisparr</A>
|
<DT><A HREF="https://whisparr.ginnoir.com">whisparr</A>
|
||||||
<DT><A HREF="https://stash.ginnoir.com">stash</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://roms.ginnoir.com">roms</A>
|
||||||
|
<DT><A HREF="https://romhacks-files.ginnoir.com">romhacks-files</A>
|
||||||
</DL><p>
|
</DL><p>
|
||||||
|
|
||||||
<DT><H3>NOTIFICATIONS & RSS (public)</H3>
|
<DT><H3>NOTIFICATIONS & RSS (public)</H3>
|
||||||
@@ -101,5 +98,10 @@
|
|||||||
<DT><A HREF="https://plane.ginnoir.com">plane</A>
|
<DT><A HREF="https://plane.ginnoir.com">plane</A>
|
||||||
</DL><p>
|
</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>
|
||||||
</DL><p>
|
</DL><p>
|
||||||
|
|||||||
@@ -57,6 +57,11 @@
|
|||||||
<DT><A HREF="http://valhalla:7575">homarr</A>
|
<DT><A HREF="http://valhalla:7575">homarr</A>
|
||||||
</DL><p>
|
</DL><p>
|
||||||
|
|
||||||
|
<DT><H3>Notes</H3>
|
||||||
|
<DL><p>
|
||||||
|
<DT><A HREF="http://valhalla:5984">couchdb</A>
|
||||||
|
</DL><p>
|
||||||
|
|
||||||
<DT><H3>Notify</H3>
|
<DT><H3>Notify</H3>
|
||||||
<DL><p>
|
<DL><p>
|
||||||
<DT><A HREF="http://valhalla:8000">ntfy :8000</A>
|
<DT><A HREF="http://valhalla:8000">ntfy :8000</A>
|
||||||
@@ -88,6 +93,11 @@
|
|||||||
<DT><A HREF="http://valhalla:3000">resume</A>
|
<DT><A HREF="http://valhalla:3000">resume</A>
|
||||||
</DL><p>
|
</DL><p>
|
||||||
|
|
||||||
|
<DT><H3>Romhacks</H3>
|
||||||
|
<DL><p>
|
||||||
|
<DT><A HREF="http://valhalla:8998">jdownloader :8998</A>
|
||||||
|
</DL><p>
|
||||||
|
|
||||||
<DT><H3>Roms</H3>
|
<DT><H3>Roms</H3>
|
||||||
<DL><p>
|
<DL><p>
|
||||||
<DT><A HREF="http://valhalla:8997">romm :8997</A>
|
<DT><A HREF="http://valhalla:8997">romm :8997</A>
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ services:
|
|||||||
- /config/caddy/data:/data
|
- /config/caddy/data:/data
|
||||||
- /config/caddy/config:/config
|
- /config/caddy/config:/config
|
||||||
- /storage1/Books:/srv/Books
|
- /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:
|
networks:
|
||||||
edge:
|
edge:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
INCOMING = pathlib.Path(os.environ.get("INCOMING_DIR", "/incoming"))
|
INCOMING = pathlib.Path(os.environ.get("INCOMING_DIR", "/incoming"))
|
||||||
LIBRARY = pathlib.Path(os.environ.get("LIBRARY_DIR", "/library"))
|
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")
|
INPROGRESS = (".part", ".jdownload", ".jdownload.cfg", ".tmp", ".crdownload")
|
||||||
FANGAME_HINTS = ("game.exe", "game.ini", ".rxdata", ".rvdata", ".rvdata2", ".rgssad", ".rgss2a", ".rgss3a")
|
FANGAME_HINTS = ("game.exe", "game.ini", ".rxdata", ".rvdata", ".rvdata2", ".rgssad", ".rgss2a", ".rgss3a")
|
||||||
ART_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif")
|
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
|
# Document / extra files worth keeping (guides, spreadsheets, changelogs, and
|
||||||
# supplementary or alternate-version archives). Deliberately excludes raw game
|
# supplementary or alternate-version archives). Deliberately excludes raw game
|
||||||
# assets (png/audio/scripts) so an extracted tree isn't counted as "guides".
|
# assets (png/audio/scripts) so an extracted tree isn't counted as "guides".
|
||||||
@@ -127,7 +173,7 @@ def collect_guides(folder, primary):
|
|||||||
return guides
|
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)
|
name = meta.get("name", folder.name)
|
||||||
lines = [
|
lines = [
|
||||||
"---",
|
"---",
|
||||||
@@ -140,8 +186,10 @@ def render_note(meta, kind, platform, primary, guides, art, folder):
|
|||||||
f"# {name}",
|
f"# {name}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
# Box art — art is copied into the library folder root, so <slug>/<name>
|
||||||
|
# resolves on the file-server.
|
||||||
for a in art:
|
for a in art:
|
||||||
lines.append(f"![[{a.name}]]")
|
lines.append(f"})")
|
||||||
if art:
|
if art:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
desc = (meta.get("description") or "").strip()
|
desc = (meta.get("description") or "").strip()
|
||||||
@@ -152,12 +200,19 @@ def render_note(meta, kind, platform, primary, guides, art, folder):
|
|||||||
lines += ["## Download links", ""]
|
lines += ["## Download links", ""]
|
||||||
lines += [f"- {l}" for l in links]
|
lines += [f"- {l}" for l in links]
|
||||||
lines.append("")
|
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", "",
|
lines += ["## Files", "",
|
||||||
f"- **Artifact:** `{primary.name if primary else 'n/a'}` ({kind}/{platform})"]
|
f"- **Artifact:** `{primary.name if primary else 'n/a'}` ({kind}/{platform})",
|
||||||
if guides:
|
f"- **Library:** [browse files]({file_url(slug_name, '')})"]
|
||||||
lines.append(f"- **Guides / extras ({len(guides)}):**")
|
|
||||||
for g in sorted(guides):
|
|
||||||
lines.append(f" - `{g.relative_to(folder)}`")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -197,7 +252,7 @@ def process(folder, handled):
|
|||||||
|
|
||||||
kind, platform, primary = classify(folder)
|
kind, platform, primary = classify(folder)
|
||||||
guides = collect_guides(folder, primary)
|
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} "
|
print(f"[handoff] {slug_name}: kind={kind} platform={platform} "
|
||||||
f"artifact={primary.name if primary else 'NONE'} guides={len(guides)} art={len(art)}")
|
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"))
|
rows = json.load(open("/tmp/catalog_new.json", encoding="utf-8"))
|
||||||
META = pathlib.Path("/storage1/labdata/romhacks/metadata")
|
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)
|
OUT = pathlib.Path("/tmp/catalog_notes"); OUT.mkdir(exist_ok=True)
|
||||||
TODAY = "2026-06-08"
|
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",
|
BASES = ["HeartGold","SoulSilver","FireRed","LeafGreen","Omega Ruby","Alpha Sapphire",
|
||||||
"Black 2","White 2","Emerald","Crystal","Platinum","Diamond","Pearl",
|
"Black 2","White 2","Emerald","Crystal","Platinum","Diamond","Pearl",
|
||||||
"Ruby","Sapphire","Gold","Silver","Black","White","Red","Blue","Yellow",
|
"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.")
|
"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(f"> [!info] Auto-imported from the Discord catalog ({r['generation']}) on {TODAY}. {kindline}")
|
||||||
fm.append("")
|
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"})")
|
||||||
|
if art:
|
||||||
|
fm.append("")
|
||||||
cd = clean_desc(desc)
|
cd = clean_desc(desc)
|
||||||
if cd:
|
if cd:
|
||||||
fm += ["## Summary", "", cd, ""]
|
fm += ["## Summary", "", cd, ""]
|
||||||
@@ -95,10 +170,20 @@ def render(r):
|
|||||||
except Exception: pass
|
except Exception: pass
|
||||||
if links:
|
if links:
|
||||||
fm += ["## Download", ""] + [f"- {l}" for l in 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", "",
|
fm += ["## Files", "",
|
||||||
f"- **Artifact:** `{r['artifact'] or 'n/a'}`",
|
f"- **Artifact:** `{r['artifact'] or 'n/a'}`",
|
||||||
f"- **Library:** `/storage1/labdata/romhacks/library/{r['slug']}/`"
|
f"- **Library:** [browse files]({file_url(r['slug'], '')}) — "
|
||||||
+ (f" — {r['n_guides']} guide/extra file(s)" if r['n_guides'] else "")]
|
f"`/storage1/labdata/romhacks/library/{r['slug']}/`"]
|
||||||
fm.append("")
|
fm.append("")
|
||||||
return safe_filename(name), "\n".join(fm)
|
return safe_filename(name), "\n".join(fm)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user