#!/usr/bin/env python3 """Generate Homepage YAML from Homarr default.json with smart categorization.""" import json import re from pathlib import Path from urllib.parse import urlparse _ROOT = Path(__file__).resolve().parent HOMARR = _ROOT.parent / "homarr" / "config" / "default.json" OUT = _ROOT / "config" WEBSITES_ADD = _ROOT / "websites-add.txt" ICON_MAP = { "adguard": "adguard-home.png", "gitea": "gitea.png", "grafana": "grafana.png", "prometheus": "prometheus.png", "portainer": "portainer.png", "postgres": "postgres.png", "neo4j": "neo4j.png", "home assistant": "home-assistant.png", "ha ": "home-assistant.png", "unifi": "unifi.png", "nginx": "nginx-proxy-manager.png", "vaultwarden": "vaultwarden.png", "immich": "immich.png", "n8n": "n8n.png", "sonarr": "sonarr.png", "radarr": "radarr.png", "prowlarr": "prowlarr.png", "qbittorrent": "qbittorrent.png", "sabnzbd": "sabnzbd.png", "nextcloud": "nextcloud.png", "excalidraw": "excalidraw.png", "wazuh": "wazuh.png", "truenas": "truenas.png", "frigate": "frigate.png", "linkwarden": "linkwarden.png", "uptime": "uptime-kuma.png", "changedetection": "changedetection.png", "traccar": "traccar.png", "tunarr": "tunarr.png", "metube": "metube.png", "remotely": "remotely.png", "homarr": "homarr.png", "chatgpt": "si-openai", "github": "si-github", "anthropic": "si-anthropic", "deepseek": "si-deepseek", "grok": "si-x", "youtube": "si-youtube", "proxmox": "proxmox.png", } # Hex colors per group for mdi/si icons GROUP_ICON_COLORS = { "Infrastructure": "#3b82f6", "Media & TV": "#a855f7", "Smart Home": "#22c55e", "Productivity": "#06b6d4", "Tools & Utils": "#f59e0b", "AI Assistants": "#ec4899", "Dev & Docs": "#14b8a6", "Web Design": "#f97316", } # Service groups → Homepage tab SERVICE_GROUPS = [ "Infrastructure", "Media & TV", "Smart Home", "Productivity", "Tools & Utils", "AI Assistants", "Dev & Docs", "Web Design", ] HOMELAB_RULES = [ ("Infrastructure", [ "proxmox", "portainer", "truenas", " adguard", "unifi", "wazuh", "idrac", "webmin", "pve script", "modem", "rackula", "mo-nas", "nginx", "nas", "lxc", "browserless", "remotely", "minarca", "change detection", "changedetection", "dsm on", "dsm aux", "turnkey", "synology stuff", ]), ("Media & TV", [ "tunarr", "immich", "frigate", "metube", "nodecast", "nzb geek", "usenetdeal", ]), ("Smart Home", [ " ha", "home assistant", "ring", "mawaqit", "home control", "ha voice", ]), ("Productivity", [ "nextcloud", "vaultwarden", "n8n", "gitea", "excalidraw", "linkwarden", "invoice", "onlyoffice", "neo4j", "crm", "resume", "openresume", "bentopdf", "vert", "moocup", "thunderbird", "noted apps", ]), ] RSS_BOOKMARKS = { "Tech News": [ ("Hacker News", "https://hnrss.org/frontpage"), ("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"), ("The Verge", "https://www.theverge.com/rss/index.xml"), ("Lobsters", "https://lobste.rs/rss"), ], "Security": [ ("BleepingComputer", "https://www.bleepingcomputer.com/feed/"), ("Krebs on Security", "https://krebsonsecurity.com/feed/"), ("The Hacker News", "https://feeds.feedburner.com/TheHackersNews"), ], "Homelab": [ ("selfh.st", "https://selfh.st/rss/"), ("Proxmox Forum", "https://forum.proxmox.com/forums/-/index.rss"), ("r/selfhosted", "https://www.reddit.com/r/selfhosted/.rss"), ("Docker Blog", "https://www.docker.com/blog/feed/"), ("LinuxServer.io", "https://www.linuxserver.io/blog/rss/"), ("Marius Hosting", "https://mariushosting.com/feed/"), ], "AI & Dev": [ ("OpenAI Blog", "https://openai.com/blog/rss.xml"), ("Hugging Face Blog", "https://huggingface.co/blog/feed.xml"), ("GitHub Blog", "https://github.blog/feed/"), ], "Blogs": [ ("Jack van lightly", "https://jack-vanlightly.com/feed.xml"), ("Juhache", "https://juhache.substack.com/feed"), ], } def slug_name(name: str) -> str: s = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower() return s or "service" def guess_icon(name: str, url: str) -> str: n = name.lower() for key, icon in ICON_MAP.items(): if key in n: return icon host = urlparse(url).hostname or "" if host: part = host.split(".")[0] if part in ("192", "www"): return "mdi-server-network" return f"mdi-{part[:12]}" return "mdi-circle-outline" def colorize_icon(icon: str, group: str) -> str: """Add hex color to vector icons for vivid Homepage gradients.""" if "#" in icon or icon.endswith((".png", ".webp", ".svg")) or icon.startswith("http"): return icon color = GROUP_ICON_COLORS.get(group, "#22d3ee") if icon.startswith(("mdi-", "si-")): return f"{icon}-{color}" return icon def classify_homelab(name: str, url: str) -> str: blob = f" {name.lower()} {url.lower()} " for group, keys in HOMELAB_RULES: if any(k in blob for k in keys): return group return "Tools & Utils" def classify_app(name: str, url: str, homarr_cat: str) -> str: if homarr_cat == "AI": return "AI Assistants" if homarr_cat == "HTML5": return "Web Design" if homarr_cat in ("Other", "Data Stuff", "Projects"): return "Dev & Docs" if homarr_cat == "HomeLab Stuff": return classify_homelab(name, url) return "Tools & Utils" def yaml_escape(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"') def load_websites_extra() -> dict: """Lees websites-add.txt: groep|naam|url per regel.""" extra = {g: [] for g in SERVICE_GROUPS} if not WEBSITES_ADD.exists(): return extra for raw in WEBSITES_ADD.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue parts = [p.strip() for p in line.split("|", 2)] if len(parts) != 3: continue group, name, url = parts if group not in extra: print(f" Waarschuwing: onbekende groep '{group}' — regel overgeslagen") continue if name and url: extra[group].append((name, url)) return extra def merge_groups(base: dict, extra: dict) -> dict: """Homarr + handmatige sites; extra overschrijft zelfde naam in zelfde groep.""" out = {g: list(base.get(g, [])) for g in SERVICE_GROUPS} for group, items in extra.items(): if group not in out: out[group] = [] names = {n.lower() for n, _ in out[group]} for name, url in items: key = name.lower() if key in names: out[group] = [(n, u) for n, u in out[group] if n.lower() != key] names.discard(key) out[group].append((name, url)) names.add(key) return out def write_services(by_group: dict) -> str: lines = [] for group in SERVICE_GROUPS: items = by_group.get(group, []) if not items: continue lines.append(f"- {group}:") seen = set() for name, url in items: key = slug_name(name) if key in seen: key = f"{key}-{len(seen)}" seen.add(key) icon = colorize_icon(guess_icon(name, url), group) lines.append(f" - {name}:") lines.append(f" icon: {icon}") lines.append(f" href: {url}") lines.append(f" description: {yaml_escape(name)}") try: p = urlparse(url) if p.scheme and p.hostname and re.match(r"^\d+\.\d+\.\d+\.\d+$", p.hostname): base = f"{p.scheme}://{p.netloc}" lines.append(f" siteMonitor: {base}") lines.append(" statusStyle: dot") except Exception: pass lines.append("") return "\n".join(lines) def write_settings() -> str: layout = { "Infrastructure": {"tab": "Ops", "icon": "mdi-server-network", "columns": 4, "style": "row"}, "Media & TV": {"tab": "Media", "icon": "mdi-television-play", "columns": 4, "style": "row"}, "Smart Home": {"tab": "Home", "icon": "mdi-home-automation", "columns": 3, "style": "row"}, "Productivity": {"tab": "Work", "icon": "mdi-briefcase-outline", "columns": 4, "style": "row"}, "Tools & Utils": {"tab": "Ops", "icon": "mdi-toolbox-outline", "columns": 3, "style": "row"}, "AI Assistants": {"tab": "AI", "icon": "mdi-brain", "columns": 4, "style": "row"}, "Dev & Docs": {"tab": "AI", "icon": "mdi-book-open-page-variant", "columns": 3, "style": "row"}, "Web Design": {"tab": "AI", "icon": "mdi-palette-outline", "columns": 3, "style": "row"}, "Tech News": {"tab": "Feeds", "icon": "mdi-newspaper-variant-outline", "columns": 2}, "Security": {"tab": "Feeds", "icon": "mdi-shield-alert-outline", "columns": 2}, "Homelab": {"tab": "Feeds", "icon": "mdi-server", "columns": 3}, "AI & Dev": {"tab": "Feeds", "icon": "mdi-robot-outline", "columns": 2}, "Blogs": {"tab": "Feeds", "icon": "mdi-rss", "columns": 2}, } lines = [ "---", "title: EL-KADI OPS", "description: Homelab command surface", "theme: dark", "color: slate", "iconStyle: gradient", "headerStyle: underlined", "useEqualHeights: true", "statusStyle: dot", "fullWidth: true", "cardBlur: md", "hideVersion: false", "quicklaunch:", " searchDescriptions: true", " hideInternetSearch: false", " showSearchSuggestions: true", "layout:", ] for name, opts in layout.items(): lines.append(f" {name}:") for k, v in opts.items(): lines.append(f" {k}: {v}") return "\n".join(lines) + "\n" def write_bookmarks() -> str: lines = ["---"] for group, items in RSS_BOOKMARKS.items(): lines.append(f"- {group}:") for name, url in items: abbr = "".join(w[0] for w in re.findall(r"[A-Za-z0-9]+", name)[:2]).upper()[:2] or "RS" lines.append(f" - {name}:") lines.append(f" - abbr: {abbr}") lines.append(f" href: {url}") return "\n".join(lines) + "\n" def write_widgets() -> str: return """--- - logo: icon: /images/logo.jpg - greeting: text_size: xl text: el-kadi ops - datetime: text_size: xl format: timeStyle: short dateStyle: medium hour12: false - search: provider: google target: _blank showSearchSuggestions: true - resources: cpu: true memory: true cputemp: true uptime: true units: metric refresh: 3000 """ def main(): with open(HOMARR) as f: d = json.load(f) cats = {c["id"]: c["name"] for c in d.get("categories", [])} by_group: dict[str, list] = {g: [] for g in SERVICE_GROUPS} for a in d.get("apps", []): name = (a.get("name") or "").strip() url = (a.get("url") or "").strip() if not name or not url: continue area = a.get("area", {}).get("properties", {}).get("id", "") homarr_cat = cats.get(area, "Other") group = classify_app(name, url, homarr_cat) by_group.setdefault(group, []).append((name, url)) extra = load_websites_extra() extra_count = sum(len(v) for v in extra.values()) by_group = merge_groups(by_group, extra) OUT.mkdir(parents=True, exist_ok=True) (OUT / "services.yaml").write_text(write_services(by_group)) (OUT / "settings.yaml").write_text(write_settings()) (OUT / "widgets.yaml").write_text(write_widgets()) (OUT / "bookmarks.yaml").write_text(write_bookmarks()) total = sum(len(v) for v in by_group.values()) print( f"Generated {total} apps in {sum(1 for v in by_group.values() if v)} groups" + (f" (+{extra_count} uit websites-add.txt)" if extra_count else "") ) if __name__ == "__main__": main()