From 717c5c1fca06838857f3b8175d5a06266c4aefb0 Mon Sep 17 00:00:00 2001 From: Patrick Hickey Date: Fri, 14 Aug 2026 21:59:20 -0700 Subject: [PATCH 01/22] Add RomM install script RomM scans, enriches, and plays retro game collections. The script keeps the ROM library, fetched artwork, and saves on one location so RomM can link artwork correctly, auto-generates all database and auth secrets, and pins the PostgreSQL version to protect against accidental one-way database upgrades. Optional install questions let advanced users provide metadata-provider API keys and toggle scanning options while defaults stay one-click friendly; blank optional credentials resolve to empty strings to match fields left blank in the TrueNAS UI. --- README.md | 1 + romm.json | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 romm.json diff --git a/README.md b/README.md index 2d00e41..33b5ed3 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Install scripts are JSON configurations that automate app deployment on HexOS | [qui](qui.json) | A qBittorrent web UI | | [Rackula](rackula.json) | A drag and drop rack visualizer | | [Radarr](radarr.json) | Movie collection manager | +| [RomM](romm.json) | Game ROM collection manager and player | | [Scrutiny](scrutiny.json) | Hard drive S.M.A.R.T. monitoring | | [Seerr](seerr.json) | Request management and media discovery tool | | [SnapOtter](snapotter.json) | File editing toolkit | diff --git a/romm.json b/romm.json new file mode 100644 index 0000000..8c439a4 --- /dev/null +++ b/romm.json @@ -0,0 +1,218 @@ +{ + "version": 4, + "script": { + "version": "1.0.0", + "changeLog": "Initial" + }, + "installation_questions": [ + { + "question": "IGDB Client ID (optional)", + "description": "Generate an ID and secret for IGDB (https://api-docs.igdb.com/#account-creation). IGDB provides titles, descriptions, cover art and related games.", + "type": "text", + "key": "igdb_client_id", + "required": false + }, + { + "question": "IGDB Client Secret (optional)", + "description": "The client secret from your IGDB application.", + "type": "text", + "key": "igdb_client_secret", + "required": false + }, + { + "question": "ScreenScraper username (optional)", + "description": "Username for ScreenScraper.fr (https://www.screenscraper.fr). A free provider of titles, descriptions, cover art, screenshots and manuals.", + "type": "text", + "key": "screenscraper_user", + "required": false + }, + { + "question": "ScreenScraper password (optional)", + "description": "Password for your ScreenScraper.fr account.", + "type": "text", + "key": "screenscraper_password", + "required": false + }, + { + "question": "RetroAchievements API key (optional)", + "description": "Generate an API key from your RetroAchievements account settings (https://retroachievements.org/settings). Adds achievement progress tracking.", + "type": "text", + "key": "retroachievements_api_key", + "required": false + }, + { + "question": "SteamGridDB API key (optional)", + "description": "Generate an API key from your SteamGridDB profile preferences (https://www.steamgriddb.com/profile/preferences/api). Provides additional high-quality cover art.", + "type": "text", + "key": "steamgriddb_api_key", + "required": false + }, + { + "question": "Enable Hasheous metadata provider", + "description": "Free, keyless hash-based matching that proxies IGDB titles, descriptions and cover art. Enabled by default so your first scan has metadata without any API keys.", + "type": "select", + "key": "hasheous_api_enabled", + "required": false, + "default": "true", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + }, + { + "question": "Enable PlayMatch metadata provider", + "description": "Free, community-driven hash-based matching service. No API key required.", + "type": "select", + "key": "playmatch_api_enabled", + "required": false, + "default": "false", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + }, + { + "question": "Enable Flashpoint metadata", + "description": "Metadata for 180,000+ flash and browser-based games. No API key required.", + "type": "select", + "key": "flashpoint_api_enabled", + "required": false, + "default": "false", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + }, + { + "question": "Enable HowLongToBeat metadata", + "description": "Adds game completion times for 84,000+ games. No API key required.", + "type": "select", + "key": "hltb_api_enabled", + "required": false, + "default": "false", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + }, + { + "question": "Enable LaunchBox metadata", + "description": "Community-driven metadata database. Requires running a LaunchBox metadata update (manual or scheduled) inside RomM before it can match games.", + "type": "select", + "key": "launchbox_api_enabled", + "required": false, + "default": "false", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + }, + { + "question": "Rescan library on file changes", + "description": "Automatically re-scan the library when filesystem changes are detected.", + "type": "select", + "key": "enable_rescan_on_filesystem_change", + "required": false, + "default": "false", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + }, + { + "question": "Enable scheduled library rescan", + "description": "Periodically re-scan the library on a schedule.", + "type": "select", + "key": "enable_scheduled_rescan", + "required": false, + "default": "false", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + }, + { + "question": "Scheduled rescan cron expression", + "description": "Cron schedule for library rescans, e.g. \"0 3 * * *\" for 3:00 AM daily. Only used when scheduled rescan is enabled.", + "type": "text", + "key": "scheduled_rescan_cron", + "required": false, + "default": "0 3 * * *" + }, + { + "question": "Disable download endpoint authentication", + "description": "Only required for Tinfoil/WebRcade integration. Lowers security, leave off unless you need it.", + "type": "select", + "key": "disable_download_endpoint_auth", + "required": false, + "default": "false", + "options": [ + { "text": "Yes", "value": "true" }, + { "text": "No", "value": "false" } + ] + } + ], + "requirements": { + "locations": ["ApplicationsPerformance", "Media"], + "specifications": ["2CORE", "1024MB"], + "permissions": ["READ_WRITE_LOCATIONS"], + "ports": [30446] + }, + "ensure_directories_exists": [ + { + "path": "$LOCATION(ApplicationsPerformance)" + }, + { + "path": "$LOCATION(Media)", + "network_share": true + }, + { "path": "$LOCATION(Media)/romm/library", "owner": { "user": "apps" }, "snapshot": { "id": "library" } }, + { "path": "$LOCATION(Media)/romm/resources", "owner": { "user": "apps" }, "snapshot": { "id": "resources" } }, + { "path": "$LOCATION(Media)/romm/assets", "owner": { "user": "apps" }, "snapshot": { "id": "assets" } }, + { "path": "$LOCATION(ApplicationsPerformance)/romm/config", "owner": { "user": "apps" }, "snapshot": { "id": "config" } }, + { "path": "$LOCATION(ApplicationsPerformance)/romm/postgres_data", "owner": { "user": "netdata", "group": "docker" }, "snapshot": { "id": "db" } } + ], + "app_values": { + "romm": { + "db_password": "$RANDOM_STRING(16)", + "redis_password": "$RANDOM_STRING(16)", + "auth_secret_key": "$RANDOM_STRING(32)", + "postgres_image_selector": "postgres_18_image", + "hasheous_api_enabled": "$QUESTION(hasheous_api_enabled)", + "playmatch_api_enabled": "$QUESTION(playmatch_api_enabled)", + "flashpoint_api_enabled": "$QUESTION(flashpoint_api_enabled)", + "hltb_api_enabled": "$QUESTION(hltb_api_enabled)", + "launchbox_api_enabled": "$QUESTION(launchbox_api_enabled)", + "enable_rescan_on_filesystem_change": "$QUESTION(enable_rescan_on_filesystem_change)", + "enable_scheduled_rescan": "$QUESTION(enable_scheduled_rescan)", + "scheduled_rescan_cron": "$QUESTION(scheduled_rescan_cron)", + "disable_download_endpoint_auth": "$QUESTION(disable_download_endpoint_auth)", + "igdb_client_id": "$IF($QUESTION(igdb_client_id), $QUESTION(igdb_client_id), \"\")", + "igdb_client_secret": "$IF($QUESTION(igdb_client_secret), $QUESTION(igdb_client_secret), \"\")", + "screenscraper_user": "$IF($QUESTION(screenscraper_user), $QUESTION(screenscraper_user), \"\")", + "screenscraper_password": "$IF($QUESTION(screenscraper_password), $QUESTION(screenscraper_password), \"\")", + "retroachievements_api_key": "$IF($QUESTION(retroachievements_api_key), $QUESTION(retroachievements_api_key), \"\")", + "steamgriddb_api_key": "$IF($QUESTION(steamgriddb_api_key), $QUESTION(steamgriddb_api_key), \"\")", + "additional_envs": [] + }, + "storage": { + "library": "$HOST_PATH($LOCATION(Media)/romm/library)", + "resources": "$HOST_PATH($LOCATION(Media)/romm/resources)", + "assets": "$HOST_PATH($LOCATION(Media)/romm/assets)", + "config": "$HOST_PATH($LOCATION(ApplicationsPerformance)/romm/config)", + "postgres_data": "$HOST_PATH($LOCATION(ApplicationsPerformance)/romm/postgres_data)" + }, + "network": { + "web_port": { + "bind_mode": "published", + "port_number": 30446 + } + }, + "resources": { + "limits": { + "cpus": 2, + "memory": "$MEMORY(10%, 4096)" + } + } + } +} From 809052e2f1f8d1bbc3f0ed4ac5ed53d2b216ec4e Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Tue, 18 Aug 2026 01:18:39 -0500 Subject: [PATCH 02/22] converted credential questions to password type with random-string defaults --- nextcloud.json | 7 ++++--- palworld.json | 6 +++--- paperless-ngx.json | 4 ++-- snapotter.json | 8 ++++---- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/nextcloud.json b/nextcloud.json index c643f79..e8a499e 100644 --- a/nextcloud.json +++ b/nextcloud.json @@ -28,10 +28,11 @@ }, { "question": "Nextcloud Password", - "description": "Create a password for the admin account. You'll need this to log in via web browser and mobile apps. Choose something secure but memorable!", - "type": "text", + "description": "Create a password for the admin account, or press the button to generate one. You'll need this to log in via web browser and mobile apps, so copy it somewhere safe.", + "type": "password", "key": "admin_password", - "required": true + "required": true, + "default": "$RANDOM_STRING(16)" }, { "question": "Default Phone Region", diff --git a/palworld.json b/palworld.json index ef36913..059106e 100644 --- a/palworld.json +++ b/palworld.json @@ -217,7 +217,7 @@ }, { "question": "Input a server password", - "type": "text", + "type": "password", "key": "server_password", "required": false, "default": "", @@ -226,10 +226,10 @@ { "question": "Input the admin password", "description": "This is used to login to the rest api server management tools at http://serverIP:30443. Login username is admin. Read more about the rest api here https://docs.palworldgame.com/category/rest-api", - "type": "text", + "type": "password", "key": "admin_password", "required": true, - "default": "" + "default": "$RANDOM_STRING(16)" }, { "question": "Set Pal spawn randomizer type", diff --git a/paperless-ngx.json b/paperless-ngx.json index 8dc68b4..fc562fa 100644 --- a/paperless-ngx.json +++ b/paperless-ngx.json @@ -49,10 +49,10 @@ { "question": "Choose the Admin Password", "description": "Only used during initial installation", - "type": "text", + "type": "password", "key": "admin_password", "required": true, - "default": "" + "default": "$RANDOM_STRING(16)" } ], "app_values": { diff --git a/snapotter.json b/snapotter.json index 460cd34..11f06c8 100644 --- a/snapotter.json +++ b/snapotter.json @@ -18,10 +18,10 @@ "question": "Choose the Admin Password", "description": "Only used on installation", "placeholder": "", - "type": "text", + "type": "password", "key": "DEFAULT_PASSWORD", "required": true, - "default": "" + "default": "$RANDOM_STRING(16)" } ], "requirements": { @@ -60,8 +60,8 @@ } }, "snapotter": { - "db_password": "$RANDOM_STRING(7)", - "redis_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "redis_password": "$RANDOM_STRING(16)", "additional_envs": [ { "name": "DEFAULT_USERNAME", From 1de96a101e81a5d20952a356cdfd4d47b90138a1 Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Tue, 18 Aug 2026 01:18:39 -0500 Subject: [PATCH 03/22] bumped 7-char random secrets to 16 (passwords) and 32 (signing keys) --- affine.json | 4 ++-- blinko.json | 4 ++-- home-assistant.json | 2 +- immich.json | 4 ++-- jellystat.json | 4 ++-- linkwarden.json | 6 +++--- lubelogger.json | 2 +- mealie.json | 2 +- memos.json | 2 +- warracker.json | 2 +- wiki-js.json | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/affine.json b/affine.json index 66bb672..e2d1770 100644 --- a/affine.json +++ b/affine.json @@ -23,8 +23,8 @@ ], "app_values": { "affine": { - "db_password": "$RANDOM_STRING(7)", - "redis_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "redis_password": "$RANDOM_STRING(16)", "additional_envs": [] }, "network": { diff --git a/blinko.json b/blinko.json index 9e78bbe..56fd313 100644 --- a/blinko.json +++ b/blinko.json @@ -22,8 +22,8 @@ ], "app_values": { "blinko": { - "db_password": "$RANDOM_STRING(7)", - "nextauth_secret": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "nextauth_secret": "$RANDOM_STRING(32)", "additional_envs": [] }, "network": { diff --git a/home-assistant.json b/home-assistant.json index e63b0e1..8c0cb28 100644 --- a/home-assistant.json +++ b/home-assistant.json @@ -24,7 +24,7 @@ ], "app_values": { "home_assistant": { - "db_password": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)" }, "storage": { "config": "$HOST_PATH($LOCATION(ApplicationsPerformance)/home-assistant/config)", diff --git a/immich.json b/immich.json index 9906289..7acdcc6 100644 --- a/immich.json +++ b/immich.json @@ -26,8 +26,8 @@ "immich": { "enable_ml": true, "ml_image_selector": "ml_image", - "db_password": "$RANDOM_STRING(7)", - "redis_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "redis_password": "$RANDOM_STRING(16)", "log_level": "log", "hugging_face_endpoint": "", "db_storage_type": "SSD", diff --git a/jellystat.json b/jellystat.json index f760fc8..f86488d 100644 --- a/jellystat.json +++ b/jellystat.json @@ -39,8 +39,8 @@ "app_values": { "jellystat": { - "db_password": "$RANDOM_STRING(7)", - "jwt_secret": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "jwt_secret": "$RANDOM_STRING(32)", "is_emby_api": "$QUESTION(is_emby_api)", "additional_envs": [] }, diff --git a/linkwarden.json b/linkwarden.json index f4ec31e..a5dd734 100644 --- a/linkwarden.json +++ b/linkwarden.json @@ -23,9 +23,9 @@ ], "app_values": { "linkwarden": { - "db_password": "$RANDOM_STRING(7)", - "nextauth_secret": "$RANDOM_STRING(7)", - "meilisearch_master_key": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)", + "nextauth_secret": "$RANDOM_STRING(32)", + "meilisearch_master_key": "$RANDOM_STRING(32)" }, "storage": { "data": "$HOST_PATH($LOCATION(ApplicationsCapacity)/linkwarden/data)", diff --git a/lubelogger.json b/lubelogger.json index 52c796c..5c7a6d2 100644 --- a/lubelogger.json +++ b/lubelogger.json @@ -23,7 +23,7 @@ ], "app_values": { "lubelogger": { - "db_password": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)" }, "network": { "web_port": { diff --git a/mealie.json b/mealie.json index dec2a76..7bd8550 100644 --- a/mealie.json +++ b/mealie.json @@ -31,7 +31,7 @@ "mealie": { "allow_signup": true, "base_url": "http://$SERVER_LAN_IP:30111/", - "db_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", "default_group": "Home" }, "network": { diff --git a/memos.json b/memos.json index 9222f3c..fdc9283 100644 --- a/memos.json +++ b/memos.json @@ -22,7 +22,7 @@ ], "app_values": { "memos": { - "db_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", "additional_envs": [] }, "network": { diff --git a/warracker.json b/warracker.json index 3a4ed03..d5a273b 100644 --- a/warracker.json +++ b/warracker.json @@ -22,7 +22,7 @@ ], "app_values": { "warracker": { - "db_password": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)" }, "network": { "web_port": { diff --git a/wiki-js.json b/wiki-js.json index 5df5e18..6b05086 100644 --- a/wiki-js.json +++ b/wiki-js.json @@ -22,7 +22,7 @@ ], "app_values": { "wiki_js": { - "db_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", "additional_envs": [] }, "network": { From 797cb40dab9aa1b594c691b1068c5bb36ecfda6c Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Tue, 18 Aug 2026 01:59:38 -0500 Subject: [PATCH 04/22] converted gb-multiple storage specs to {n}gb and documented spec grammar --- README.md | 19 +++++++++++++++++++ actual-budget.json | 2 +- bentopdf.json | 2 +- dozzle.json | 2 +- drawio.json | 2 +- excalidraw.json | 2 +- firefly-iii.json | 2 +- handbrake.json | 2 +- mealie.json | 2 +- mkvtoolnix.json | 2 +- navidrome.json | 2 +- nextcloud.json | 2 +- pairdrop.json | 2 +- peanut.json | 2 +- portracker.json | 2 +- rackula.json | 2 +- scrutiny.json | 2 +- seerr.json | 2 +- snapotter.json | 2 +- sure.json | 2 +- 20 files changed, 38 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 2d00e41..9ee85ef 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,25 @@ Install scripts are JSON configurations that automate app deployment on HexOS For schema documentation, macros reference, and contribution guidelines, see [docs.hexos.com](https://docs.hexos.com). +## Requirement specifications + +`requirements.specifications` values are parametric — any sensible number works without a platform change. The accepted patterns: + +| Pattern | Meaning | Examples | +|---------|---------|----------| +| `{n}CORE` | Minimum CPU cores | `2CORE`, `4CORE`, `6CORE` | +| `{n}MB` / `{n}GB` | Approximate storage needed for installation | `256MB`, `1GB`, `30GB` | +| `{n}MBRAM` / `{n}GBRAM` | Minimum memory | `512MBRAM`, `8GBRAM` | +| `GPU` | GPU recommended | `GPU` | + +Every value must match `^(\d+)(MBRAM|GBRAM|MB|GB|CORE)$` or be the literal `GPU` — anything else fails the catalog sync for that app. Use the largest natural unit (`1GB`, not `1024MB`; `8GBRAM`, not `8192MBRAM`) so the rendered text reads cleanly. + +```json +"requirements": { + "specifications": ["4CORE", "30GB", "16GBRAM"] +} +``` + ## Contributing 1. Fork this repository diff --git a/actual-budget.json b/actual-budget.json index 8001442..1a8b2a0 100644 --- a/actual-budget.json +++ b/actual-budget.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/bentopdf.json b/bentopdf.json index bb05134..479be80 100644 --- a/bentopdf.json +++ b/bentopdf.json @@ -10,7 +10,7 @@ "Downloads" ], "specifications": [ - "2CORE", "1024MB"], + "2CORE", "1GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/dozzle.json b/dozzle.json index b1dc57b..cc29d96 100644 --- a/dozzle.json +++ b/dozzle.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30064] }, diff --git a/drawio.json b/drawio.json index 4cfd27e..042ff17 100644 --- a/drawio.json +++ b/drawio.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30090, 30091] }, diff --git a/excalidraw.json b/excalidraw.json index 85abbb1..fb50851 100644 --- a/excalidraw.json +++ b/excalidraw.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30255] }, diff --git a/firefly-iii.json b/firefly-iii.json index 0f8799b..9300998 100644 --- a/firefly-iii.json +++ b/firefly-iii.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/handbrake.json b/handbrake.json index f0baaf4..2a20892 100644 --- a/handbrake.json +++ b/handbrake.json @@ -13,7 +13,7 @@ "Videos" ], "specifications": [ - "2CORE", "2048MB"], + "2CORE", "2GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/mealie.json b/mealie.json index dec2a76..b576fcf 100644 --- a/mealie.json +++ b/mealie.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/mkvtoolnix.json b/mkvtoolnix.json index c95ed96..2f3409f 100644 --- a/mkvtoolnix.json +++ b/mkvtoolnix.json @@ -32,7 +32,7 @@ "Movies" ], "specifications": [ - "2CORE", "1024MB"], + "2CORE", "1GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/navidrome.json b/navidrome.json index 4f40990..8be758b 100644 --- a/navidrome.json +++ b/navidrome.json @@ -175,7 +175,7 @@ "Music" ], "specifications": [ - "2CORE", "1024MB"], + "2CORE", "1GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/nextcloud.json b/nextcloud.json index c643f79..4c05a87 100644 --- a/nextcloud.json +++ b/nextcloud.json @@ -11,7 +11,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/pairdrop.json b/pairdrop.json index 2a09b28..06cb297 100644 --- a/pairdrop.json +++ b/pairdrop.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30207] }, diff --git a/peanut.json b/peanut.json index 68f2c4d..0f12dc4 100644 --- a/peanut.json +++ b/peanut.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/portracker.json b/portracker.json index e016690..29e3911 100644 --- a/portracker.json +++ b/portracker.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/rackula.json b/rackula.json index 0850186..7bfaa6d 100644 --- a/rackula.json +++ b/rackula.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/scrutiny.json b/scrutiny.json index e222c8b..c2ca88e 100644 --- a/scrutiny.json +++ b/scrutiny.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/seerr.json b/seerr.json index ad23841..ba5e16a 100644 --- a/seerr.json +++ b/seerr.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/snapotter.json b/snapotter.json index 460cd34..be63978 100644 --- a/snapotter.json +++ b/snapotter.json @@ -30,7 +30,7 @@ ], "specifications": [ "4CORE", - "4096MB" + "4GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/sure.json b/sure.json index d3e07c6..b3bbb9c 100644 --- a/sure.json +++ b/sure.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" From 2a27a0583a075b881a4eced2182c447d1cf4aeba Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Tue, 18 Aug 2026 01:59:38 -0500 Subject: [PATCH 05/22] raised palworld requirements to 4CORE/30GB/16GBRAM --- palworld.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/palworld.json b/palworld.json index ef36913..1d9e1c8 100644 --- a/palworld.json +++ b/palworld.json @@ -1349,7 +1349,7 @@ "ApplicationsPerformance" ], "permissions": ["READ_WRITE_LOCATIONS"], - "specifications": ["4CORE", "8GB"], + "specifications": ["4CORE", "30GB", "16GBRAM"], "ports": [30443, 38212, 38211] }, "ensure_directories_exists": [ From acd06a5e53509906df01617c59a790f6510f0ffe Mon Sep 17 00:00:00 2001 From: Nicholas Kathrein Date: Sun, 16 Aug 2026 22:19:44 -0500 Subject: [PATCH 06/22] =?UTF-8?q?=E2=9C=A8=20Flagged=20first-boot-only=20a?= =?UTF-8?q?dmin=20credential=20questions=20installonly=20(snapotter,=20pap?= =?UTF-8?q?erless-ngx)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- paperless-ngx.json | 6 ++++-- snapotter.json | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/paperless-ngx.json b/paperless-ngx.json index 8dc68b4..7630f30 100644 --- a/paperless-ngx.json +++ b/paperless-ngx.json @@ -44,7 +44,8 @@ "type": "text", "key": "admin_user", "required": true, - "default": "" + "default": "", + "installonly": true }, { "question": "Choose the Admin Password", @@ -52,7 +53,8 @@ "type": "text", "key": "admin_password", "required": true, - "default": "" + "default": "", + "installonly": true } ], "app_values": { diff --git a/snapotter.json b/snapotter.json index 460cd34..98a6b85 100644 --- a/snapotter.json +++ b/snapotter.json @@ -12,7 +12,8 @@ "type": "text", "key": "DEFAULT_USERNAME", "required": true, - "default": "" + "default": "", + "installonly": true }, { "question": "Choose the Admin Password", @@ -21,7 +22,8 @@ "type": "text", "key": "DEFAULT_PASSWORD", "required": true, - "default": "" + "default": "", + "installonly": true } ], "requirements": { From ad6ef3ed2f55e03c6e6d11973f27ca81d3bb10d3 Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Wed, 26 Aug 2026 00:06:15 -0500 Subject: [PATCH 07/22] Correct the declared requirements on handbrake, paperless-ngx, and warracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three entries declared requirements that did not match what they actually use. None broke an install — the runtime is lenient about all three — but each one made the requirements screen lie to the user about what the app needs. handbrake mounts $LOCATION(Downloads) into the container but never listed Downloads, so the location was neither shown nor covered by the permission grant. paperless-ngx had no specifications block at all, so it advertised no CPU, disk, or memory needs. It bundles postgres, redis, and an OCR pipeline, so it now declares 2 cores, roughly 2 GB to install, and 2 GB of RAM — the last one matters most, since OCR is what actually runs the box out of memory. warracker listed "documents", which is not a LocationPreferenceId and so could never resolve. The script never touches Documents; it uses ApplicationsCapacity, which was undeclared. Replacing the one with the other fixes both halves. Patch versions bumped so the change shows up in the update plan's changelog. --- handbrake.json | 5 +++-- paperless-ngx.json | 9 +++++++-- warracker.json | 6 +++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/handbrake.json b/handbrake.json index f0baaf4..743e8b9 100644 --- a/handbrake.json +++ b/handbrake.json @@ -1,12 +1,13 @@ { "version": 4, "script": { - "version": "1.1.0", - "changeLog": "Upgraded to V4 install script format" + "version": "1.1.1", + "changeLog": "Declared the Downloads location the app already mounts" }, "requirements": { "locations": [ "ApplicationsPerformance", + "Downloads", "Media", "Movies", "Shows", diff --git a/paperless-ngx.json b/paperless-ngx.json index 8dc68b4..ba162e4 100644 --- a/paperless-ngx.json +++ b/paperless-ngx.json @@ -1,14 +1,19 @@ { "version": 5, "script": { - "version": "1.0.1", - "changeLog": "hotfix" + "version": "1.0.2", + "changeLog": "Declared CPU, install size, and RAM requirements" }, "requirements": { "locations": [ "ApplicationsPerformance", "Documents" ], + "specifications": [ + "2CORE", + "2GB", + "2GBRAM" + ], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/warracker.json b/warracker.json index 3a4ed03..0ad37bd 100644 --- a/warracker.json +++ b/warracker.json @@ -1,11 +1,11 @@ { "version": 5, "script": { - "version": "1.0.0", - "changeLog": "initial install script" + "version": "1.0.1", + "changeLog": "Corrected the declared locations to match the ones used" }, "requirements": { - "locations": ["ApplicationsPerformance", "documents"], + "locations": ["ApplicationsPerformance", "ApplicationsCapacity"], "specifications": ["2CORE", "256MB"], "permissions": ["READ_WRITE_LOCATIONS"], "ports": [30148] From 497238c967b37b9d7f21f1a530f14b72c70e1d70 Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Wed, 26 Aug 2026 00:19:55 -0500 Subject: [PATCH 08/22] Validate install scripts on every pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install scripts run on users' machines, and until now nothing checked them. A script that is merely valid JSON can still misbehave once installed: an unknown $LOCATION throws during processing, a $QUESTION with no question behind it resolves to nothing, an icon path that is not in the repo 404s in the catalog UI. All of that was landing on staging unexamined. The validator checks JSON parse, the install-script version, the required script/requirements/app_values blocks, the spec regex, the permission and location enums, port ranges, metadata assets that must exist inside the repo, duplicate or malformed question keys, hook shape and duplicate ids, the ensure_directories_exists owner/snapshot/network_share shapes, unknown or unbalanced macros, every $LOCATION against the enum, and every $QUESTION against a declared key. Locations used by a macro but never declared are warnings rather than errors: they install correctly, they just under-report what the app needs. This repository is public and the platform is private, so CI cannot import the real types. Everything enforced is mirrored in _lib/contract.ts, in one place, so a drift fix is a single edit rather than a hunt through checks. The workflow typechecks the tooling before running it. Bun executes TypeScript by stripping types, so a type error in _lib would never stop the validator from running — it would just make its verdict untrustworthy. --- .github/workflows/validate.yml | 32 +++ .gitignore | 3 +- _lib/bun.lock | 24 +++ _lib/contract.ts | 62 ++++++ _lib/package.json | 13 ++ _lib/tsconfig.json | 13 ++ _lib/validate.ts | 367 +++++++++++++++++++++++++++++++++ 7 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/validate.yml create mode 100644 _lib/bun.lock create mode 100644 _lib/contract.ts create mode 100644 _lib/package.json create mode 100644 _lib/tsconfig.json create mode 100644 _lib/validate.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..a6db35e --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,32 @@ +# Install scripts are executed on users' machines, and a script that is merely +# valid JSON can still abort an install — an unknown $LOCATION throws, a +# $QUESTION with no question behind it resolves to nothing, an icon path that +# is not in the repo 404s in the catalog UI. This runs on GitHub-hosted runners +# with no secrets, so it is safe on pull_request from forks. +name: Validate install scripts + +on: + pull_request: + push: + branches: [staging, prod] + +jobs: + validate: + runs-on: ubuntu-latest + # Seconds of work; anything approaching this is a hang, not a slow run. + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - name: Install tooling dependencies + run: bun install + working-directory: _lib + # Typecheck first, deliberately: bun executes TypeScript by stripping + # types, so a type error in _lib never stops the validator from running — + # it just makes its verdict untrustworthy. Checking the checker before + # believing it keeps a broken validator from reporting a green catalog. + - name: Typecheck the tooling + run: bun run typecheck + working-directory: _lib + - name: Validate install scripts + run: bun _lib/validate.ts diff --git a/.gitignore b/.gitignore index a3d83c0..b4057de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc -*.DS_Store \ No newline at end of file +*.DS_Store +node_modules/ diff --git a/_lib/bun.lock b/_lib/bun.lock new file mode 100644 index 0000000..b745539 --- /dev/null +++ b/_lib/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "hexos-app-catalog-lib", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.6.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/_lib/contract.ts b/_lib/contract.ts new file mode 100644 index 0000000..11ffd22 --- /dev/null +++ b/_lib/contract.ts @@ -0,0 +1,62 @@ +// Hand-synced mirror of the install-script contract in hexos-platform +// (packages/shared/eshtek/apps.ts, packages/shared/eshtek/preferences.ts, +// packages/shared/eshtek/hooks.ts, packages/backend/src/lib/macros.ts). +// +// The catalog is public and the platform is private, so CI cannot import the +// real types. Everything the validator enforces is duplicated here instead, in +// one place, so a drift fix is a single edit rather than a hunt through checks. + +/** LocationPreferenceId — the only names $LOCATION() can resolve. */ +export const LOCATIONS = [ + "ApplicationsPerformance", + "ApplicationsCapacity", + "Downloads", + "Documents", + "Media", + "Photos", + "Music", + "Movies", + "Shows", + "Videos", + "VirtualizationPerformance", + "VirtualizationCapacity", + "InstallMedia", + "VirtualDisks", +] as const; + +/** APP_SPEC_REGEX. */ +export const SPEC_REGEX = /^(\d+)(MBRAM|GBRAM|MB|GB|CORE)$|^GPU$/; + +/** AppPermission. */ +export const PERMISSIONS = ["READ_WRITE_LOCATIONS"] as const; + +/** InstallationQuestionType. */ +export const QUESTION_TYPES = ["text", "number", "select", "boolean", "password"] as const; + +/** HookEvent. */ +export const HOOK_EVENTS = [ + "onBeforeInstall", + "onAfterInstall", + "onBeforeUpgrade", + "onAfterUpgrade", + "action", +] as const; + +/** Install-script versions the backend still parses (appsInstallScriptSchema). */ +export const SUPPORTED_VERSIONS = [4, 5] as const; + +/** Macros taking parenthesised arguments. */ +export const CALL_MACROS = [ + "IF", + "QUESTION", + "MEMORY", + "RANDOM_STRING", + "HOST_PATH", + "MOUNTED_HOST_PATH", + "LOCATION", + "APP_INSTALLED", + "GPU_CONFIG", +] as const; + +/** Macros substituted as bare tokens, with no argument list. */ +export const BARE_MACROS = ["SERVER_LAN_IP", "SERVER_HOST_ID"] as const; diff --git a/_lib/package.json b/_lib/package.json new file mode 100644 index 0000000..8e4766e --- /dev/null +++ b/_lib/package.json @@ -0,0 +1,13 @@ +{ + "name": "hexos-app-catalog-lib", + "private": true, + "type": "module", + "scripts": { + "validate": "cd .. && bun _lib/validate.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.6.0" + } +} diff --git a/_lib/tsconfig.json b/_lib/tsconfig.json new file mode 100644 index 0000000..3163f67 --- /dev/null +++ b/_lib/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "noUncheckedIndexedAccess": false, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/_lib/validate.ts b/_lib/validate.ts new file mode 100644 index 0000000..b7184e8 --- /dev/null +++ b/_lib/validate.ts @@ -0,0 +1,367 @@ +// Catalog validator. Every check here corresponds to a way an install script +// can be accepted by GitHub and still fail on a user's box: unparseable JSON, +// a version the backend no longer understands, a $LOCATION the resolver will +// throw on, a $QUESTION with no question behind it, an icon path that 404s. +// +// Run: bun run validate + +import { readdirSync, existsSync, statSync } from "node:fs"; +import { + BARE_MACROS, + CALL_MACROS, + HOOK_EVENTS, + LOCATIONS, + PERMISSIONS, + QUESTION_TYPES, + SPEC_REGEX, + SUPPORTED_VERSIONS, +} from "./contract"; + +type Problem = { file: string; message: string }; + +const errors: Problem[] = []; +const warnings: Problem[] = []; + +const err = (file: string, message: string) => errors.push({ file, message }); +const warn = (file: string, message: string) => warnings.push({ file, message }); + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** Every string leaf in the document, with a dotted path for error messages. */ +function* strings(value: unknown, path = ""): Generator<{ path: string; value: string }> { + if (typeof value === "string") { + yield { path, value }; + } else if (Array.isArray(value)) { + for (const [i, v] of value.entries()) yield* strings(v, `${path}[${i}]`); + } else if (isObject(value)) { + for (const [k, v] of Object.entries(value)) yield* strings(v, path ? `${path}.${k}` : k); + } +} + +/** + * A referenced file must exist and must sit inside the repo. Catalog assets are + * fetched by relative path from the raw GitHub URL, so a leading slash or a + * `../` escape resolves somewhere that is not the catalog. + */ +function checkAssetPath(file: string, field: string, value: string) { + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return; // absolute URL, not our asset + if (value.startsWith("/")) { + err(file, `${field}: "${value}" is an absolute path; asset paths are relative to the repo root`); + return; + } + if (value.split("/").includes("..")) { + err(file, `${field}: "${value}" escapes the repository`); + return; + } + if (!existsSync(value) || !statSync(value).isFile()) { + err(file, `${field}: "${value}" does not exist in the repository`); + } +} + +function checkMacros(file: string, script: unknown, questionKeys: Set) { + const raw = JSON.stringify(script); + + // A macro name we do not know is almost always a typo. It survives install + // untouched and lands in the container as a literal "$LOCATIN(...)". + for (const match of raw.matchAll(/\$([A-Z_]{3,})/g)) { + const name = match[1]; + const followedByParen = raw[match.index! + match[0].length] === "("; + if (followedByParen) { + if (!(CALL_MACROS as readonly string[]).includes(name)) { + err(file, `unknown macro $${name}()`); + } + } else if (!(BARE_MACROS as readonly string[]).includes(name)) { + // $LOCATION with no argument list is the same typo class. + if ((CALL_MACROS as readonly string[]).includes(name)) { + err(file, `$${name} is used without an argument list`); + } else { + err(file, `unknown macro $${name}`); + } + } + } + + // replaceLocation() throws PROCESS_INSTALL_SCRIPT_UNKNOWN_LOCATION on any + // name outside LocationPreferenceId, which aborts the whole install. + for (const match of raw.matchAll(/\$LOCATION\(([^)]+)\)/g)) { + const name = match[1].trim(); + if (!(LOCATIONS as readonly string[]).includes(name)) { + err(file, `$LOCATION(${name}) is not a known location`); + } + } + + // A $QUESTION with no declaration behind it resolves to nothing. + for (const match of raw.matchAll(/\$QUESTION\(([^)]+)\)/g)) { + const key = match[1].trim().replace(/^["']|["']$/g, "").split(",")[0].trim(); + if (!questionKeys.has(key)) { + err(file, `$QUESTION(${key}) has no matching question key`); + } + } + + // Unbalanced parentheses make the tokenizer consume to end-of-input. + for (const name of CALL_MACROS) { + for (const match of raw.matchAll(new RegExp(`\\$${name}\\(`, "g"))) { + let depth = 0; + let closed = false; + for (let i = match.index! + match[0].length - 1; i < raw.length; i++) { + if (raw[i] === "(") depth++; + else if (raw[i] === ")" && --depth === 0) { closed = true; break; } + } + if (!closed) err(file, `$${name}( is never closed`); + } + } +} + +function validate(file: string, script: Record) { + const version = script.version; + if (typeof version !== "number") { + err(file, "version is missing or not a number"); + return; + } + if (!(SUPPORTED_VERSIONS as readonly number[]).includes(version)) { + err( + file, + `version ${version} is not parsed by the backend (supported: ${SUPPORTED_VERSIONS.join(", ")})`, + ); + return; + } + + // --- script block (required from v4 on) --- + const scriptMeta = script.script; + if (!isObject(scriptMeta)) { + err(file, "script block is missing"); + } else if (typeof scriptMeta.version !== "string") { + err(file, "script.version is missing"); + } + + // --- requirements (required from v4 on) --- + const req = script.requirements; + const declaredLocations = new Set(); + if (!isObject(req)) { + err(file, "requirements block is missing"); + } else { + for (const key of ["permissions", "specifications", "locations", "ports"]) { + if (!Array.isArray(req[key])) err(file, `requirements.${key} must be an array`); + } + for (const spec of (req.specifications as unknown[]) ?? []) { + if (typeof spec !== "string" || !SPEC_REGEX.test(spec)) { + err(file, `requirements.specifications: "${String(spec)}" is not a valid spec`); + } + } + for (const perm of (req.permissions as unknown[]) ?? []) { + if (typeof perm !== "string" || !(PERMISSIONS as readonly string[]).includes(perm)) { + err(file, `requirements.permissions: "${String(perm)}" is not a known permission`); + } + } + for (const loc of (req.locations as unknown[]) ?? []) { + if (typeof loc !== "string" || !(LOCATIONS as readonly string[]).includes(loc)) { + err(file, `requirements.locations: "${String(loc)}" is not a known location`); + } else { + declaredLocations.add(loc); + } + } + for (const port of (req.ports as unknown[]) ?? []) { + if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) { + err(file, `requirements.ports: "${String(port)}" is not a valid port`); + } + } + } + + // --- metadata and its assets --- + const metadata = script.metadata; + if (isObject(metadata)) { + for (const key of ["name", "description", "icon", "version"]) { + if (typeof metadata[key] !== "string" || !(metadata[key] as string).trim()) { + err(file, `metadata.${key} is missing`); + } + } + if (typeof metadata.icon === "string") checkAssetPath(file, "metadata.icon", metadata.icon); + for (const [i, shot] of ((metadata.screenshots as unknown[]) ?? []).entries()) { + if (typeof shot === "string") checkAssetPath(file, `metadata.screenshots[${i}]`, shot); + } + } + + // --- questions --- + const questionKeys = new Set(); + const collectQuestion = (q: unknown, where: string) => { + if (!isObject(q)) return; + const key = q.key; + if (typeof key !== "string" || !key.trim()) { + err(file, `${where}: question is missing a key`); + return; + } + if (questionKeys.has(key)) err(file, `${where}: duplicate question key "${key}"`); + questionKeys.add(key); + if (typeof q.type !== "string" || !(QUESTION_TYPES as readonly string[]).includes(q.type)) { + err(file, `${where}: question "${key}" has invalid type "${String(q.type)}"`); + } + if (q.type === "select") { + const options = q.options; + if (!Array.isArray(options) || options.length === 0) { + err(file, `${where}: select question "${key}" has no options`); + } + } + }; + + for (const [i, q] of ((script.installation_questions as unknown[]) ?? []).entries()) { + collectQuestion(q, `installation_questions[${i}]`); + } + + // --- ensure_directories_exists --- + for (const [i, entry] of ((script.ensure_directories_exists as unknown[]) ?? []).entries()) { + const where = `ensure_directories_exists[${i}]`; + if (!isObject(entry)) { + err(file, `${where}: v${version} entries must be objects with a path`); + continue; + } + if (typeof entry.path !== "string" || !entry.path.trim()) { + err(file, `${where}: path is missing`); + } + // owner accepts a bare username string as shorthand for { user }. + if (entry.owner !== undefined) { + if (typeof entry.owner === "string") { + if (!entry.owner.trim()) err(file, `${where}.owner: username is empty`); + } else if (!isObject(entry.owner)) { + err(file, `${where}.owner: must be a username string or { "user": "apps" }`); + } else { + if (typeof entry.owner.user !== "string" || !entry.owner.user.trim()) { + err(file, `${where}.owner.user is missing`); + } + if (entry.owner.group !== undefined && typeof entry.owner.group !== "string") { + err(file, `${where}.owner.group must be a string`); + } + } + } + if (entry.snapshot !== undefined) { + if (!isObject(entry.snapshot) || typeof entry.snapshot.id !== "string") { + err(file, `${where}.snapshot: must be an object with an id`); + } + } + if (entry.network_share !== undefined && typeof entry.network_share !== "boolean") { + err(file, `${where}.network_share must be a boolean`); + } + } + + // --- app_values --- + if (!isObject(script.app_values)) err(file, "app_values is missing"); + + // --- hooks (v5) --- + const hookIds = new Set(); + for (const [i, hook] of ((script.hooks as unknown[]) ?? []).entries()) { + const where = `hooks[${i}]`; + if (!isObject(hook)) { + err(file, `${where}: must be an object`); + continue; + } + if (version !== 5) err(file, `${where}: hooks require version 5`); + const id = hook.id; + if (typeof id !== "string" || !id.trim()) { + err(file, `${where}: id is missing`); + } else { + if (hookIds.has(id)) err(file, `${where}: duplicate hook id "${id}"`); + hookIds.add(id); + } + if (typeof hook.event !== "string" || !(HOOK_EVENTS as readonly string[]).includes(hook.event)) { + err(file, `${where}: event "${String(hook.event)}" is not a known hook event`); + } + // A hook body is either a file reference or inlined scriptContent. + const hasFile = typeof hook.script === "string" && hook.script.trim().length > 0; + const hasInline = typeof hook.scriptContent === "string" && hook.scriptContent.trim().length > 0; + if (!hasFile && !hasInline) { + err(file, `${where}: needs either script or scriptContent`); + } else if (hasFile && hasInline) { + err(file, `${where}: declares both script and scriptContent`); + } else if (hasFile) { + checkAssetPath(file, `${where}.script`, hook.script as string); + } + if (typeof hook.entrypoint !== "string" || !hook.entrypoint.trim()) { + err(file, `${where}: entrypoint is missing`); + } + if (hook.timeout !== undefined && (typeof hook.timeout !== "number" || hook.timeout <= 0)) { + err(file, `${where}: timeout must be a positive number`); + } + for (const [j, input] of ((hook.inputs as unknown[]) ?? []).entries()) { + if (!isObject(input)) continue; + if (input.type === "question") collectQuestion(input.question, `${where}.inputs[${j}]`); + } + } + + checkMacros(file, script, questionKeys); + + // A location used by a macro but never declared still installs — the path + // resolves — but HexOS never prompts for it, so the requirements screen and + // the permission grant are both wrong. + const used = new Set( + [...JSON.stringify(script).matchAll(/\$LOCATION\(([^)]+)\)/g)].map((m) => m[1].trim()), + ); + for (const loc of used) { + if ((LOCATIONS as readonly string[]).includes(loc) && !declaredLocations.has(loc)) { + warn(file, `$LOCATION(${loc}) is used but not declared in requirements.locations`); + } + } +} + +// --- run --- + +const files = readdirSync(".") + .filter((f) => f.endsWith(".json")) + .sort(); + +if (files.length === 0) { + console.error("No install scripts found. Is the working directory the repo root?"); + process.exit(1); +} + +for (const file of files) { + let parsed: unknown; + try { + parsed = JSON.parse(await Bun.file(file).text()); + } catch (cause) { + err(file, `is not valid JSON: ${(cause as Error).message}`); + continue; + } + if (!isObject(parsed)) { + err(file, "top level must be an object"); + continue; + } + validate(file, parsed); +} + +// Assets are cheap to orphan and invisible when they rot, so name the ones +// nothing points at rather than letting them accumulate. +const referenced = new Set(); +for (const file of files) { + try { + for (const { value } of strings(JSON.parse(await Bun.file(file).text()))) { + if (value.includes("/") && !/^[a-z][a-z0-9+.-]*:/i.test(value) && existsSync(value)) { + referenced.add(value); + } + } + } catch { /* already reported as a parse error */ } +} + +const group = (problems: Problem[]) => { + const byFile = new Map(); + for (const p of problems) { + if (!byFile.has(p.file)) byFile.set(p.file, []); + byFile.get(p.file)!.push(p.message); + } + return byFile; +}; + +for (const [file, messages] of group(warnings)) { + console.log(`\n⚠ ${file}`); + for (const m of messages) console.log(` ${m}`); +} + +for (const [file, messages] of group(errors)) { + console.log(`\n✗ ${file}`); + for (const m of messages) console.log(` ${m}`); +} + +console.log( + `\n${files.length} install scripts checked — ` + + `${errors.length} error(s), ${warnings.length} warning(s)`, +); + +process.exit(errors.length > 0 ? 1 : 0); From 469e6275574a71de3a1086069d1b19e4386c11bf Mon Sep 17 00:00:00 2001 From: Jerod Fritz Date: Tue, 1 Sep 2026 11:10:33 -0400 Subject: [PATCH 09/22] Add install script v6 support to the validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teaches the validator the v6 contract so v6 scripts (hooks with kind/events/conditions/target/surfaces/rerun, and widgets with sizes/slots/conditions/buttons) pass CI when they land. No app scripts included — this is scaffolding only; fileflows and other v6 apps follow as separate PRs. contract.ts: version 6 added to SUPPORTED_VERSIONS, plus HOOK_KINDS, HOOK_EVENT_SPECS, HOOK_RERUNS, HOOK_SURFACES, HOOK_CONDITION_TYPES/ROLES, HOOK_TARGET_TYPES, SUPPORTED_WIDGETS_SCHEMA, WIDGET_SLOT_TYPES. validate.ts: v6 hook validation branch (kind, events array, conditions, target, surfaces, rerun, retries), full widget validation (id format, title, refresh floor, script/scriptContent, entrypoint, sizes with slot type/field/count limits, conditions, buttons referencing hook ids), shared checkCondition helper, per-hook question-key scoping for v6 (hooks carry their own inputs independently). hook_context.ts: updated mirror with ContextMount, target (file-targeted actions), mounts, getInstalledAppUrl, setProgress. --- _lib/contract.ts | 45 +++++++- _lib/hook_context.ts | 72 +++++++++++++ _lib/validate.ts | 237 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 345 insertions(+), 9 deletions(-) diff --git a/_lib/contract.ts b/_lib/contract.ts index 11ffd22..1ee0908 100644 --- a/_lib/contract.ts +++ b/_lib/contract.ts @@ -43,7 +43,7 @@ export const HOOK_EVENTS = [ ] as const; /** Install-script versions the backend still parses (appsInstallScriptSchema). */ -export const SUPPORTED_VERSIONS = [4, 5] as const; +export const SUPPORTED_VERSIONS = [4, 5, 6] as const; /** Macros taking parenthesised arguments. */ export const CALL_MACROS = [ @@ -60,3 +60,46 @@ export const CALL_MACROS = [ /** Macros substituted as bare tokens, with no argument list. */ export const BARE_MACROS = ["SERVER_LAN_IP", "SERVER_HOST_ID"] as const; + +// --- v6 hook contract --- + +/** HookKind — descriptive taxonomy (surfaces, icons, grouping). */ +export const HOOK_KINDS = ["connect", "maintain", "custom"] as const; + +/** HookEventSpec — what goes in v6 hooks' `events` array. */ +export const HOOK_EVENT_SPECS = [ + "userAction", + "onBeforeInstall", + "onAfterInstall", + "onBeforeUpgrade", + "onAfterUpgrade", +] as const; + +/** HookRerun — re-run semantics for user-triggerable hooks. */ +export const HOOK_RERUNS = ["idempotent", "converge", "refuse"] as const; + +/** HookSurface — where a hook can appear. */ +export const HOOK_SURFACES = ["installPicker", "card", "fileBrowser", "widget"] as const; + +/** Known hook condition types. Unknown types are accepted (fail closed at runtime). */ +export const HOOK_CONDITION_TYPES = [ + "appInstalled", + "appRunning", + "appVersion", + "capabilityPresent", + "script", +] as const; + +/** HookConditionRole. */ +export const HOOK_CONDITION_ROLES = ["visibility", "availability"] as const; + +/** Hook target types. */ +export const HOOK_TARGET_TYPES = ["files"] as const; + +// --- v6 widget contract --- + +/** Supported widgetsSchema version. */ +export const SUPPORTED_WIDGETS_SCHEMA = 2; + +/** Widget slot types. */ +export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "image"] as const; diff --git a/_lib/hook_context.ts b/_lib/hook_context.ts index 059abd9..7708fdd 100644 --- a/_lib/hook_context.ts +++ b/_lib/hook_context.ts @@ -1,6 +1,10 @@ /** * HookContext — the runtime context passed to every hook entrypoint. * + * MIRROR: this interface is a hand-maintained copy of the public surface of + * hexos-platform/packages/backend/src/interface/hooks.ts (class HookContext). + * Update BOTH files when the contract grows — nothing checks this for you. + * * The actual class is provided by hexos-platform at execution time. * Hook scripts import this interface for autocompletion and type safety. * @@ -15,6 +19,27 @@ * } * ``` */ +/** + * One mount entry from the app's live config — the same shape every script + * surface receives (hooks, actions, widgets). + */ +export interface ContextMount { + /** Host path (/mnt/...). */ + hostPath: string; + /** + * The same directory as the app sees it inside its container — null for + * chart-style primary storage (config/data/logs) whose container path the + * chart fixes internally. Match those by hostPath instead. + */ + containerPath: string | null; + /** + * The same directory as THIS script can read it (the local node's host + * bind-mount). Use for harvesting app config files, e.g. Plex's + * Preferences.xml — no more hand-walking /host/mnt. + */ + localPath: string; +} + export interface HookContext { /** "app" or "vm" */ readonly resourceType: string; @@ -41,6 +66,31 @@ export interface HookContext { */ readonly inputs: Record; + /** + * File-targeted actions only: the files the user selected. `containerPath` + * is the same file as THIS app sees it (pre-resolved from its mounts), or + * null when the file isn't under any of the app's mounts — scripts for + * requiresTargetMount:false actions must handle null (e.g. deliver the + * file themselves via the platform mounts). + */ + readonly target?: { + type: "files"; + files: Array<{ + path: string; + containerPath: string | null; + name: string; + extension: string; + size?: number; + }>; + }; + + /** + * The app's live mounts, pre-resolved (empty when the app doesn't exist + * yet, e.g. onBeforeInstall, or mount discovery failed — scripts must + * tolerate an empty list). + */ + readonly mounts: ContextMount[]; + /** * Type-safe input accessor. Throws if the input is missing. * @@ -67,6 +117,13 @@ export interface HookContext { * ]); * ``` */ + /** + * Base URL of another installed app (e.g. "http://192.168.1.50:5055"), + * or null when it isn't installed or has no known port. Available to app + * actions; lifecycle hooks may receive null depending on platform version. + */ + getInstalledAppUrl(appId: string): Promise; + registerCheckpoints(checkpoints: Array<{ id: string; message: string }>): Promise; /** @@ -94,6 +151,21 @@ export interface HookContext { */ updateCheckpointMessage(id: string, message: string): Promise; + /** + * Drive the task's progress bar directly (clamped 0-99) without touching + * checkpoints. For long-running work whose real progress the script can + * measure — e.g. frames encoded out of a known total. A later + * emitCheckpoint WITHOUT an explicit progress reverts the bar to + * checkpoint-fraction math, so pass explicit progress on the final + * checkpoint when using this. + * + * @example + * ```ts + * await ctx.setProgress(42); + * ``` + */ + setProgress(percent: number): Promise; + /** * Fail the hook with a structured error. The message is shown as the primary error, * and context items are rendered as labeled key-value pairs in the UI. diff --git a/_lib/validate.ts b/_lib/validate.ts index b7184e8..348c768 100644 --- a/_lib/validate.ts +++ b/_lib/validate.ts @@ -9,12 +9,21 @@ import { readdirSync, existsSync, statSync } from "node:fs"; import { BARE_MACROS, CALL_MACROS, + HOOK_CONDITION_ROLES, + HOOK_CONDITION_TYPES, + HOOK_EVENT_SPECS, HOOK_EVENTS, + HOOK_KINDS, + HOOK_RERUNS, + HOOK_SURFACES, + HOOK_TARGET_TYPES, LOCATIONS, PERMISSIONS, QUESTION_TYPES, SPEC_REGEX, SUPPORTED_VERSIONS, + SUPPORTED_WIDGETS_SCHEMA, + WIDGET_SLOT_TYPES, } from "./contract"; type Problem = { file: string; message: string }; @@ -59,6 +68,40 @@ function checkAssetPath(file: string, field: string, value: string) { } } +function checkCondition(file: string, where: string, cond: unknown) { + if (!isObject(cond)) { + err(file, `${where}: must be an object`); + return; + } + if (typeof cond.role !== "string" || !(HOOK_CONDITION_ROLES as readonly string[]).includes(cond.role)) { + err(file, `${where}: role must be "visibility" or "availability"`); + } + if (typeof cond.type !== "string" || !cond.type.trim()) { + err(file, `${where}: type is missing`); + return; + } + if ((HOOK_CONDITION_TYPES as readonly string[]).includes(cond.type)) { + if (cond.type === "appInstalled" || cond.type === "appRunning") { + if (typeof cond.app !== "string" || !cond.app.trim()) { + err(file, `${where}: ${cond.type} condition requires an app name`); + } + } + if (cond.type === "appVersion") { + if (typeof cond.app !== "string" || !cond.app.trim()) { + err(file, `${where}: appVersion condition requires an app name`); + } + if (typeof cond.range !== "string" || !cond.range.trim()) { + err(file, `${where}: appVersion condition requires a range`); + } + } + if (cond.type === "script") { + if (typeof cond.script !== "string" || !cond.script.trim()) { + err(file, `${where}: script condition requires a script path`); + } + } + } +} + function checkMacros(file: string, script: unknown, questionKeys: Set) { const raw = JSON.stringify(script); @@ -183,14 +226,16 @@ function validate(file: string, script: Record) { // --- questions --- const questionKeys = new Set(); - const collectQuestion = (q: unknown, where: string) => { + const collectQuestion = (q: unknown, where: string, scopeKeys?: Set) => { if (!isObject(q)) return; const key = q.key; if (typeof key !== "string" || !key.trim()) { err(file, `${where}: question is missing a key`); return; } - if (questionKeys.has(key)) err(file, `${where}: duplicate question key "${key}"`); + const dupeSet = scopeKeys ?? questionKeys; + if (dupeSet.has(key)) err(file, `${where}: duplicate question key "${key}"`); + dupeSet.add(key); questionKeys.add(key); if (typeof q.type !== "string" || !(QUESTION_TYPES as readonly string[]).includes(q.type)) { err(file, `${where}: question "${key}" has invalid type "${String(q.type)}"`); @@ -245,7 +290,7 @@ function validate(file: string, script: Record) { // --- app_values --- if (!isObject(script.app_values)) err(file, "app_values is missing"); - // --- hooks (v5) --- + // --- hooks (v5 and v6) --- const hookIds = new Set(); for (const [i, hook] of ((script.hooks as unknown[]) ?? []).entries()) { const where = `hooks[${i}]`; @@ -253,7 +298,7 @@ function validate(file: string, script: Record) { err(file, `${where}: must be an object`); continue; } - if (version !== 5) err(file, `${where}: hooks require version 5`); + if (version !== 5 && version !== 6) err(file, `${where}: hooks require version 5 or 6`); const id = hook.id; if (typeof id !== "string" || !id.trim()) { err(file, `${where}: id is missing`); @@ -261,10 +306,79 @@ function validate(file: string, script: Record) { if (hookIds.has(id)) err(file, `${where}: duplicate hook id "${id}"`); hookIds.add(id); } - if (typeof hook.event !== "string" || !(HOOK_EVENTS as readonly string[]).includes(hook.event)) { - err(file, `${where}: event "${String(hook.event)}" is not a known hook event`); + + if (version === 6) { + if (typeof hook.title !== "string" || !hook.title.trim()) { + err(file, `${where}: title is missing`); + } + if (hook.kind !== undefined) { + if (typeof hook.kind !== "string" || !(HOOK_KINDS as readonly string[]).includes(hook.kind)) { + err(file, `${where}: kind "${String(hook.kind)}" is not a known hook kind`); + } + } + if (hook.events !== undefined) { + if (!Array.isArray(hook.events)) { + err(file, `${where}: events must be an array`); + } else { + for (const [j, ev] of hook.events.entries()) { + if (typeof ev === "string") { + if (!(HOOK_EVENT_SPECS as readonly string[]).includes(ev)) { + err(file, `${where}.events[${j}]: "${ev}" is not a known event spec`); + } + } else if (!isObject(ev)) { + err(file, `${where}.events[${j}]: must be a string or event object`); + } + } + } + } + if (hook.rerun !== undefined) { + if (typeof hook.rerun !== "string" || !(HOOK_RERUNS as readonly string[]).includes(hook.rerun)) { + err(file, `${where}: rerun "${String(hook.rerun)}" is not a known rerun mode`); + } + } + if (hook.surfaces !== undefined) { + if (!Array.isArray(hook.surfaces)) { + err(file, `${where}: surfaces must be an array`); + } else { + for (const s of hook.surfaces) { + if (typeof s !== "string" || !(HOOK_SURFACES as readonly string[]).includes(s)) { + err(file, `${where}: surface "${String(s)}" is not known`); + } + } + } + } + if (hook.conditions !== undefined) { + if (!Array.isArray(hook.conditions)) { + err(file, `${where}: conditions must be an array`); + } else { + for (const [j, cond] of hook.conditions.entries()) { + checkCondition(file, `${where}.conditions[${j}]`, cond); + } + } + } + if (hook.target !== undefined) { + if (!isObject(hook.target)) { + err(file, `${where}: target must be an object`); + } else { + if (typeof hook.target.type !== "string" || !(HOOK_TARGET_TYPES as readonly string[]).includes(hook.target.type)) { + err(file, `${where}: target.type "${String(hook.target.type)}" is not known`); + } + if (hook.target.type === "files") { + if (!Array.isArray(hook.target.accepts) || hook.target.accepts.length === 0) { + err(file, `${where}: target.accepts must be a non-empty array of extensions`); + } + } + } + } + if (hook.retries !== undefined && (typeof hook.retries !== "number" || hook.retries < 0)) { + err(file, `${where}: retries must be a non-negative number`); + } + } else { + if (typeof hook.event !== "string" || !(HOOK_EVENTS as readonly string[]).includes(hook.event)) { + err(file, `${where}: event "${String(hook.event)}" is not a known hook event`); + } } - // A hook body is either a file reference or inlined scriptContent. + const hasFile = typeof hook.script === "string" && hook.script.trim().length > 0; const hasInline = typeof hook.scriptContent === "string" && hook.scriptContent.trim().length > 0; if (!hasFile && !hasInline) { @@ -280,9 +394,116 @@ function validate(file: string, script: Record) { if (hook.timeout !== undefined && (typeof hook.timeout !== "number" || hook.timeout <= 0)) { err(file, `${where}: timeout must be a positive number`); } + const hookQuestionScope = version === 6 ? new Set() : undefined; for (const [j, input] of ((hook.inputs as unknown[]) ?? []).entries()) { if (!isObject(input)) continue; - if (input.type === "question") collectQuestion(input.question, `${where}.inputs[${j}]`); + if (input.type === "question") collectQuestion(input.question, `${where}.inputs[${j}]`, hookQuestionScope); + } + } + + // --- widgets (v6) --- + if (script.widgets !== undefined) { + if (version !== 6) err(file, "widgets require version 6"); + if (script.widgetsSchema !== undefined && script.widgetsSchema !== SUPPORTED_WIDGETS_SCHEMA) { + err(file, `widgetsSchema ${script.widgetsSchema} is not supported (expected ${SUPPORTED_WIDGETS_SCHEMA})`); + } + if (!Array.isArray(script.widgets)) { + err(file, "widgets must be an array"); + } else { + const widgetIds = new Set(); + for (const [i, widget] of script.widgets.entries()) { + const where = `widgets[${i}]`; + if (!isObject(widget)) { + err(file, `${where}: must be an object`); + continue; + } + const wid = widget.id; + if (typeof wid !== "string" || !wid.trim()) { + err(file, `${where}: id is missing`); + } else { + if (!/^[a-z0-9_-]+$/.test(wid)) { + err(file, `${where}: id "${wid}" must be lowercase alphanumeric with hyphens/underscores`); + } + if (widgetIds.has(wid)) err(file, `${where}: duplicate widget id "${wid}"`); + widgetIds.add(wid); + } + if (typeof widget.title !== "string" || !widget.title.trim()) { + err(file, `${where}: title is missing`); + } + if (widget.refresh !== undefined && (typeof widget.refresh !== "number" || widget.refresh < 10)) { + err(file, `${where}: refresh must be a number >= 10`); + } + const hasFile = typeof widget.script === "string" && widget.script.trim().length > 0; + const hasInline = typeof widget.scriptContent === "string" && widget.scriptContent.trim().length > 0; + if (!hasFile && !hasInline) { + err(file, `${where}: needs either script or scriptContent`); + } else if (hasFile && hasInline) { + err(file, `${where}: declares both script and scriptContent`); + } else if (hasFile) { + checkAssetPath(file, `${where}.script`, widget.script as string); + } + if (typeof widget.entrypoint !== "string" || !widget.entrypoint.trim()) { + err(file, `${where}: entrypoint is missing`); + } + if (widget.timeout !== undefined && (typeof widget.timeout !== "number" || widget.timeout <= 0)) { + err(file, `${where}: timeout must be a positive number`); + } + if (widget.conditions !== undefined) { + if (!Array.isArray(widget.conditions)) { + err(file, `${where}: conditions must be an array`); + } else { + for (const [j, cond] of widget.conditions.entries()) { + checkCondition(file, `${where}.conditions[${j}]`, cond); + } + } + } + if (widget.sizes !== undefined) { + if (!isObject(widget.sizes)) { + err(file, `${where}: sizes must be an object`); + } else { + for (const sizeKey of ["small", "large"] as const) { + const size = widget.sizes[sizeKey]; + if (size === undefined) continue; + if (!isObject(size)) { + err(file, `${where}.sizes.${sizeKey}: must be an object`); + continue; + } + const slots = size.slots; + if (!Array.isArray(slots) || slots.length === 0) { + err(file, `${where}.sizes.${sizeKey}: slots must be a non-empty array`); + } else { + const maxSlots = sizeKey === "small" ? 3 : 4; + if (slots.length > maxSlots) { + err(file, `${where}.sizes.${sizeKey}: max ${maxSlots} slots`); + } + for (const [k, slot] of slots.entries()) { + if (!isObject(slot)) continue; + if (typeof slot.type !== "string" || !(WIDGET_SLOT_TYPES as readonly string[]).includes(slot.type)) { + err(file, `${where}.sizes.${sizeKey}.slots[${k}]: type "${String(slot.type)}" is not known`); + } + if (typeof slot.field !== "string" || !slot.field.trim()) { + err(file, `${where}.sizes.${sizeKey}.slots[${k}]: field is missing`); + } + } + } + } + } + } + if (widget.buttons !== undefined) { + if (!Array.isArray(widget.buttons)) { + err(file, `${where}: buttons must be an array`); + } else { + if (widget.buttons.length > 4) err(file, `${where}: max 4 buttons`); + const seen = new Set(); + for (const btn of widget.buttons) { + if (typeof btn !== "string") continue; + if (seen.has(btn)) err(file, `${where}: duplicate button "${btn}"`); + seen.add(btn); + if (!hookIds.has(btn)) err(file, `${where}: button "${btn}" references unknown hook id`); + } + } + } + } } } From abadbd75692a405d7f3dda2efc2e452524c74944 Mon Sep 17 00:00:00 2001 From: Jerod Fritz Date: Tue, 1 Sep 2026 11:17:40 -0400 Subject: [PATCH 10/22] Add widget_context type mirror missed from the v6 scaffolding PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the v6 scaffolding: _lib/widget_context.ts is the hand-maintained mirror of the platform's WidgetContext (packages/backend/src/interface/widgets.ts) that catalog widget scripts import for typechecking — WidgetContext with host/port/mounts, WidgetFieldValue slot shapes (text/stat/list/image), and WidgetQueryResult. Every v6 app PR with a widget script depends on it. --- _lib/widget_context.ts | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 _lib/widget_context.ts diff --git a/_lib/widget_context.ts b/_lib/widget_context.ts new file mode 100644 index 0000000..cf6ffb8 --- /dev/null +++ b/_lib/widget_context.ts @@ -0,0 +1,70 @@ +/** + * MIRROR of the platform's WidgetContext (packages/backend/src/interface/ + * widgets.ts). Keep in sync — this file exists so catalog widget scripts + * typecheck; the platform constructs the real object at query time. + * + * Widgets are read-only glances: the script runs in-process on the local + * node, must return within its declared timeout, and its result is cached + * by the platform for the widget's refresh interval. No tasks, no + * checkpoints, no inputs — return data or { needsSetup }. + */ + +import type { ContextMount } from "./hook_context"; + +/** One mount shape across every script surface — defined once in hook_context. */ +export type WidgetMount = ContextMount; + +export interface WidgetContext { + readonly appId: string; + /** Box LAN IP — app APIs live at http://host:port. */ + readonly host: string; + /** The app's first declared catalog port, when it declares one. */ + readonly port?: number; + /** The app's live mounts. */ + readonly mounts: WidgetMount[]; + log(message: string): void; +} + +export interface WidgetListEntry { + title: string; + subtitle?: string; + meta?: string; + /** + * A duration in steady motion — capable renderers advance it in real + * time between polls; the text floor (meta) must still carry a static + * representation. state "paused" = not accruing. + */ + elapsed?: { ms: number; ofMs?: number; state?: "running" | "paused" }; + /** + * Optional thumbnail as a size-capped (60KB) data URI. Data URIs ONLY — + * fetch and inline server-side; never emit app URLs (key leak, + * mixed-content, off-LAN breakage). + */ + image?: string; +} + +/** + * One named field of the result document (widgetsSchema 2). The + * declaration's `sizes` slots reference these fields by name; a standalone + * image field is the media slot's source. Max 16 fields; images are + * size-capped (60KB) data URIs, text is capped at 500 chars. + */ +export type WidgetFieldValue = + | { type: "text"; text: string } + | { type: "stat"; label: string; value: string } + | { type: "list"; entries: WidgetListEntry[] } + | { type: "image"; image: string; alt?: string }; + +/** + * What a widget script returns: named fields the platform's size templates + * project from — one widget, ONE query; sizes never add polls. Return + * `needsSetup` (with a human reason) when the data source isn't usable + * yet. Harvest credentials first — needs-setup is the fallback, not the + * default. Field names must be STABLE (sizes reference them); dynamic + * collections (users, library sections) ride as `list` entries. + */ +export interface WidgetQueryResult { + needsSetup?: boolean; + reason?: string; + fields?: Record; +} From 606832894e2383b7714b219e25d430463f31d10c Mon Sep 17 00:00:00 2001 From: Jerod Fritz Date: Tue, 1 Sep 2026 11:22:33 -0400 Subject: [PATCH 11/22] Mirror the backend's v6 superRefine rules in the validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A validator-green v6 script could still be rejected by the backend's zod: the merged v6 support checked field shapes but not the cross-field rules. Now mirrored from hookDeclarationV6Schema's superRefine: v5-style singular event rejected, rerun required iff user-triggerable, target/optional/userOptional gated to their firing class, version-gated triggers can't share a declaration with userAction, duplicate triggers and both-halves-of-a-ceremony declarations rejected, event objects validated (lifecycle-only, from/to on upgrade events, strict keys), target.accepts extension format, requiresHooks capped at 8 and resolved against declared hook ids, capabilityPresent conditions require a capability, widget sizes.media validated. Also fixes a false positive: widget refresh below 10 is legal (the platform clamps to 10) — now a warning, not an error. Verified non-vacuous: a synthetic script planting all 16 defects produces exactly 16 errors + 1 warning; the 49 real scripts stay clean. --- _lib/validate.ts | 153 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 12 deletions(-) diff --git a/_lib/validate.ts b/_lib/validate.ts index 348c768..83c7682 100644 --- a/_lib/validate.ts +++ b/_lib/validate.ts @@ -94,9 +94,16 @@ function checkCondition(file: string, where: string, cond: unknown) { err(file, `${where}: appVersion condition requires a range`); } } + if (cond.type === "capabilityPresent") { + if (typeof cond.capability !== "string" || !cond.capability.trim()) { + err(file, `${where}: capabilityPresent condition requires a capability name`); + } + } if (cond.type === "script") { if (typeof cond.script !== "string" || !cond.script.trim()) { err(file, `${where}: script condition requires a script path`); + } else { + checkAssetPath(file, `${where}.script`, cond.script); } } } @@ -316,29 +323,112 @@ function validate(file: string, script: Record) { err(file, `${where}: kind "${String(hook.kind)}" is not a known hook kind`); } } + // V5's singular field in a V6 entry is rejected by the backend — it + // would otherwise silently turn a lifecycle hook into a user verb. + if (hook.event !== undefined) { + err(file, `${where}: v6 uses events (array) — found v5-style singular event`); + } + + // Trigger analysis mirrors the backend's superRefine: absent events + // defaults to ["userAction"], and several fields are legal only for + // one firing class. + const upgradeEvents = new Set(["onBeforeUpgrade", "onAfterUpgrade"]); + const installEvents = new Set(["onBeforeInstall", "onAfterInstall"]); + const eventKeys: string[] = []; + let hasVersionGate = false; + let eventsValid = true; if (hook.events !== undefined) { - if (!Array.isArray(hook.events)) { - err(file, `${where}: events must be an array`); + if (!Array.isArray(hook.events) || hook.events.length === 0) { + err(file, `${where}: events must be a non-empty array`); + eventsValid = false; } else { for (const [j, ev] of hook.events.entries()) { if (typeof ev === "string") { if (!(HOOK_EVENT_SPECS as readonly string[]).includes(ev)) { err(file, `${where}.events[${j}]: "${ev}" is not a known event spec`); + eventsValid = false; + } else { + eventKeys.push(ev); + } + } else if (isObject(ev)) { + const evName = ev.event; + if (typeof evName !== "string" || !(HOOK_EVENT_SPECS as readonly string[]).includes(evName) || evName === "userAction") { + err(file, `${where}.events[${j}]: event object must name a lifecycle event`); + eventsValid = false; + continue; + } + const extraKeys = Object.keys(ev).filter((k) => !["event", "from", "to"].includes(k)); + if (extraKeys.length > 0) { + err(file, `${where}.events[${j}]: unknown keys ${extraKeys.join(", ")}`); + } + if ((ev.from !== undefined || ev.to !== undefined) && !upgradeEvents.has(evName)) { + err(file, `${where}.events[${j}]: from/to gate upgrade transitions — legal on upgrade events only`); } - } else if (!isObject(ev)) { + if (ev.from !== undefined || ev.to !== undefined) hasVersionGate = true; + eventKeys.push(evName); + } else { err(file, `${where}.events[${j}]: must be a string or event object`); + eventsValid = false; } } } + } else { + eventKeys.push("userAction"); } - if (hook.rerun !== undefined) { - if (typeof hook.rerun !== "string" || !(HOOK_RERUNS as readonly string[]).includes(hook.rerun)) { - err(file, `${where}: rerun "${String(hook.rerun)}" is not a known rerun mode`); + const userTriggerable = eventKeys.includes("userAction"); + const lifecycleCount = eventKeys.filter((k) => k !== "userAction").length; + const hasInstall = eventKeys.some((k) => installEvents.has(k)); + + if (eventsValid) { + const seen = new Map(); + for (const k of eventKeys) seen.set(k, (seen.get(k) ?? 0) + 1); + for (const [k, count] of seen) { + if (count > 1) err(file, `${where}: duplicate trigger entries for "${k}"`); + } + const keys = [...seen.keys()]; + if (keys.filter((k) => installEvents.has(k)).length > 1) { + err(file, `${where}: onBeforeInstall and onAfterInstall cannot share a declaration`); + } + if (keys.filter((k) => upgradeEvents.has(k)).length > 1) { + err(file, `${where}: onBeforeUpgrade and onAfterUpgrade cannot share a declaration`); + } + if (hasVersionGate && userTriggerable) { + err(file, `${where}: version-gated triggers cannot share a declaration with "userAction"`); + } + + // rerun: required iff user-triggerable. + if (hook.rerun === undefined) { + if (userTriggerable) err(file, `${where}: rerun is required on user-triggerable hooks`); + } else { + if (typeof hook.rerun !== "string" || !(HOOK_RERUNS as readonly string[]).includes(hook.rerun)) { + err(file, `${where}: rerun "${String(hook.rerun)}" is not a known rerun mode`); + } + if (!userTriggerable) err(file, `${where}: rerun applies to user firings only`); + } + if (hook.optional !== undefined && lifecycleCount === 0) { + err(file, `${where}: optional requires a lifecycle trigger`); + } + if (hook.userOptional !== undefined) { + if (!hasInstall) { + err(file, `${where}: userOptional requires an install trigger (onBeforeInstall/onAfterInstall)`); + } + if (!isObject(hook.userOptional)) { + err(file, `${where}: userOptional must be an object`); + } else if (hook.userOptional.link !== undefined) { + const link = hook.userOptional.link; + if (!isObject(link) || typeof link.url !== "string" || !/^https?:\/\//.test(link.url) || typeof link.label !== "string" || !link.label.trim()) { + err(file, `${where}: userOptional.link needs a valid url and a label`); + } + } + } + if (hook.target !== undefined && lifecycleCount > 0) { + err(file, `${where}: file-targeted hooks are user-fired only`); } } + if (hook.surfaces !== undefined) { - if (!Array.isArray(hook.surfaces)) { - err(file, `${where}: surfaces must be an array`); + if (!Array.isArray(hook.surfaces) || hook.surfaces.length === 0) { + err(file, `${where}: surfaces must be a non-empty array`); } else { for (const s of hook.surfaces) { if (typeof s !== "string" || !(HOOK_SURFACES as readonly string[]).includes(s)) { @@ -366,12 +456,29 @@ function validate(file: string, script: Record) { if (hook.target.type === "files") { if (!Array.isArray(hook.target.accepts) || hook.target.accepts.length === 0) { err(file, `${where}: target.accepts must be a non-empty array of extensions`); + } else { + for (const ext of hook.target.accepts) { + if (typeof ext !== "string" || !/^\.[a-z0-9]+$/i.test(ext)) { + err(file, `${where}: target.accepts "${String(ext)}" — extensions are dot-prefixed (e.g. ".mkv")`); + } + } + } + if (hook.target.maxFiles !== undefined && (typeof hook.target.maxFiles !== "number" || !Number.isInteger(hook.target.maxFiles) || hook.target.maxFiles <= 0)) { + err(file, `${where}: target.maxFiles must be a positive integer`); + } + if (hook.target.requiresTargetMount !== undefined && typeof hook.target.requiresTargetMount !== "boolean") { + err(file, `${where}: target.requiresTargetMount must be a boolean`); } } } } - if (hook.retries !== undefined && (typeof hook.retries !== "number" || hook.retries < 0)) { - err(file, `${where}: retries must be a non-negative number`); + if (hook.requiresHooks !== undefined) { + if (!Array.isArray(hook.requiresHooks) || hook.requiresHooks.length > 8) { + err(file, `${where}: requiresHooks must be an array of at most 8 hook ids`); + } + } + if (hook.retries !== undefined && (typeof hook.retries !== "number" || !Number.isInteger(hook.retries) || hook.retries < 0)) { + err(file, `${where}: retries must be a non-negative integer`); } } else { if (typeof hook.event !== "string" || !(HOOK_EVENTS as readonly string[]).includes(hook.event)) { @@ -401,6 +508,16 @@ function validate(file: string, script: Record) { } } + // requiresHooks may forward-reference, so resolve after all ids are known. + for (const [i, hook] of ((script.hooks as unknown[]) ?? []).entries()) { + if (!isObject(hook) || !Array.isArray(hook.requiresHooks)) continue; + for (const ref of hook.requiresHooks) { + if (typeof ref === "string" && !hookIds.has(ref)) { + err(file, `hooks[${i}]: requiresHooks references unknown hook id "${ref}"`); + } + } + } + // --- widgets (v6) --- if (script.widgets !== undefined) { if (version !== 6) err(file, "widgets require version 6"); @@ -430,8 +547,12 @@ function validate(file: string, script: Record) { if (typeof widget.title !== "string" || !widget.title.trim()) { err(file, `${where}: title is missing`); } - if (widget.refresh !== undefined && (typeof widget.refresh !== "number" || widget.refresh < 10)) { - err(file, `${where}: refresh must be a number >= 10`); + if (widget.refresh !== undefined) { + if (typeof widget.refresh !== "number" || !Number.isInteger(widget.refresh) || widget.refresh <= 0) { + err(file, `${where}: refresh must be a positive integer`); + } else if (widget.refresh < 10) { + warn(file, `${where}: refresh ${widget.refresh} is below the 10s floor — the platform clamps it to 10`); + } } const hasFile = typeof widget.script === "string" && widget.script.trim().length > 0; const hasInline = typeof widget.scriptContent === "string" && widget.scriptContent.trim().length > 0; @@ -468,6 +589,14 @@ function validate(file: string, script: Record) { err(file, `${where}.sizes.${sizeKey}: must be an object`); continue; } + if (size.media !== undefined) { + const placements = sizeKey === "small" ? ["top", "bottom"] : ["left", "right", "both"]; + if (!isObject(size.media) || typeof size.media.placement !== "string" || !placements.includes(size.media.placement)) { + err(file, `${where}.sizes.${sizeKey}.media: placement must be one of ${placements.join("/")}`); + } else if (typeof size.media.field !== "string" || !size.media.field.trim()) { + err(file, `${where}.sizes.${sizeKey}.media: field is missing`); + } + } const slots = size.slots; if (!Array.isArray(slots) || slots.length === 0) { err(file, `${where}.sizes.${sizeKey}: slots must be a non-empty array`); From feabab6f1af92ddcb9972d86436d66bf1ab741fd Mon Sep 17 00:00:00 2001 From: Jerod Fritz Date: Tue, 1 Sep 2026 11:27:52 -0400 Subject: [PATCH 12/22] Add FileFlows v6 install script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First v6 install script in the catalog. Includes a "Convert to MP4" file-targeted hook action (H.264 encode via FFmpeg Builder, hardware-first with CPU fail-over, non-destructive — the converted copy lands next to the original) and a "Conversions" widget showing active/queued files with real encoder details harvested from runner logs. --- fileflows.json | 182 +++++++++++++++ fileflows/convert_to_mp4.ts | 402 ++++++++++++++++++++++++++++++++ fileflows/widget_conversions.ts | 134 +++++++++++ 3 files changed, 718 insertions(+) create mode 100644 fileflows.json create mode 100644 fileflows/convert_to_mp4.ts create mode 100644 fileflows/widget_conversions.ts diff --git a/fileflows.json b/fileflows.json new file mode 100644 index 0000000..ebddf47 --- /dev/null +++ b/fileflows.json @@ -0,0 +1,182 @@ +{ + "version": 6, + "script": { + "version": "1.0.0", + "changeLog": "Initial script" + }, + "requirements": { + "locations": [ + "ApplicationsPerformance", + "ApplicationsCapacity", + "Media", + "Movies", + "Music", + "Photos", + "Shows", + "Documents" + ], + "specifications": [ + "2CORE", + "512MB" + ], + "permissions": [ + "READ_WRITE_LOCATIONS" + ], + "ports": [ + 30200 + ] + }, + "ensure_directories_exists": [ + { + "path": "$LOCATION(ApplicationsPerformance)" + }, + { + "path": "$LOCATION(ApplicationsCapacity)" + }, + { + "path": "$LOCATION(Media)", + "network_share": true + }, + { + "path": "$LOCATION(Movies)", + "network_share": true + }, + { + "path": "$LOCATION(Music)", + "network_share": true + }, + { + "path": "$LOCATION(Photos)", + "network_share": true + }, + { + "path": "$LOCATION(Shows)", + "network_share": true + }, + { + "path": "$LOCATION(Documents)", + "network_share": true + }, + { + "path": "$LOCATION(ApplicationsPerformance)/fileflows/data", + "snapshot": { + "id": "data" + } + }, + { + "path": "$LOCATION(ApplicationsCapacity)/fileflows/logs" + }, + { + "path": "$LOCATION(ApplicationsCapacity)/fileflows/temp" + } + ], + "app_values": { + "fileflows": { + "additional_envs": [] + }, + "network": { + "server_port": { + "bind_mode": "published", + "port_number": 30200 + } + }, + "storage": { + "data": "$HOST_PATH($LOCATION(ApplicationsPerformance)/fileflows/data)", + "logs": "$HOST_PATH($LOCATION(ApplicationsCapacity)/fileflows/logs)", + "temp": "$HOST_PATH($LOCATION(ApplicationsCapacity)/fileflows/temp)", + "additional_storage": [ + "$MOUNTED_HOST_PATH($LOCATION(Media), /media)", + "$MOUNTED_HOST_PATH($LOCATION(Movies), /movies)", + "$MOUNTED_HOST_PATH($LOCATION(Music), /music)", + "$MOUNTED_HOST_PATH($LOCATION(Photos), /photos)", + "$MOUNTED_HOST_PATH($LOCATION(Shows), /shows)", + "$MOUNTED_HOST_PATH($LOCATION(Documents), /documents)" + ] + }, + "resources": { + "limits": { + "cpus": 4, + "memory": "$MEMORY(10%, 4096)" + }, + "gpus": "$GPU_CONFIG()" + } + }, + "hooks": [ + { + "id": "convert-to-mp4", + "title": "Convert to MP4", + "description": "Convert this video to an MP4 file using FileFlows. The converted copy appears next to the original; the original file is not changed.", + "kind": "custom", + "target": { + "type": "files", + "accepts": [ + ".mkv", + ".avi", + ".mov", + ".wmv", + ".flv", + ".webm", + ".mpg", + ".mpeg", + ".m4v", + ".ts" + ], + "maxFiles": 5, + "requiresTargetMount": true + }, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "fileflows" + } + ], + "rerun": "converge", + "script": "fileflows/convert_to_mp4.ts", + "entrypoint": "run", + "timeout": 3600, + "retries": 0 + } + ], + "widgetsSchema": 2, + "widgets": [ + { + "id": "conversions", + "title": "Conversions", + "description": "What FileFlows is converting right now, and how.", + "refresh": 10, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "fileflows" + } + ], + "sizes": { + "small": { + "slots": [ + { + "type": "stat", + "field": "active" + } + ] + }, + "large": { + "slots": [ + { + "type": "stat", + "field": "active" + }, + { + "type": "list", + "field": "files" + } + ] + } + }, + "script": "fileflows/widget_conversions.ts", + "entrypoint": "run", + "timeout": 10 + } + ] +} diff --git a/fileflows/convert_to_mp4.ts b/fileflows/convert_to_mp4.ts new file mode 100644 index 0000000..5049c67 --- /dev/null +++ b/fileflows/convert_to_mp4.ts @@ -0,0 +1,402 @@ +// The flow is non-destructive: the converted file is copied next to the +// original as .mp4 and the original is left untouched. +import type { HookContext } from "../_lib/hook_context"; + +const FLOW_NAME = "HexOS Convert to MP4"; + +// Library-file statuses (from /api/library-file/status) +const STATUS_UNPROCESSED = 0; +const STATUS_PROCESSED = 1; +const STATUS_PROCESSING = 2; +const STATUS_FAILED = 4; + +const POLL_INTERVAL_MS = 15000; + +interface LibraryFileRecord { + /** uid */ + u: string; + /** display name — the full path we enqueued */ + dn: string; + /** status */ + s: number; + /** original size in bytes */ + os?: number; + /** flow uid */ + fu?: string; +} + +// The blessed flow, as the FileFlows API accepts it: Video File -> FFmpeg +// Builder (H.264 encode, remux to MP4) -> copy the result next to the +// original. A zero Uid means "create"; part uids are only node ids within +// the flow graph. +function flowDefinition(): Record { + const nInput = "b6d7d4a1-0001-4e00-9000-000000000001"; + const nStart = "b6d7d4a1-0002-4e00-9000-000000000002"; + const nEncode = "b6d7d4a1-0003-4e00-9000-000000000003"; + const nRemux = "b6d7d4a1-0004-4e00-9000-000000000004"; + const nExec = "b6d7d4a1-0005-4e00-9000-000000000005"; + const nCopy = "b6d7d4a1-0006-4e00-9000-000000000006"; + const nFailEncode = "b6d7d4a1-0007-4e00-9000-000000000007"; + const nFailExec = "b6d7d4a1-0008-4e00-9000-000000000008"; + const nCustom = "b6d7d4a1-0009-4e00-9000-000000000009"; + const nNoAttach = "b6d7d4a1-000a-4e00-9000-00000000000a"; + return { + Uid: "00000000-0000-0000-0000-000000000000", + Name: FLOW_NAME, + Type: 0, + Enabled: true, + Description: + "Created by HexOS. Converts a video to H.264 MP4 and places the result next to the original file. The original is not modified.", + Icon: "fas fa-video:#7F35B2", + Properties: { Fields: [], Variables: {} }, + Parts: [ + { + Uid: nInput, + Name: "", + FlowElementUid: "FileFlows.VideoNodes.VideoFile", + xPos: 230, + yPos: 70, + Icon: "fas fa-video", + Label: "Video File", + Inputs: 0, + Outputs: 1, + Type: 0, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nStart }], + }, + { + Uid: nStart, + Name: "", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderStart", + xPos: 230, + yPos: 200, + Icon: "far fa-file-video", + Label: "FFMPEG Builder: Start", + Inputs: 1, + Outputs: 1, + Type: 4, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nEncode }], + }, + { + Uid: nEncode, + Name: "H.264", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderVideoEncodeSimple", + xPos: 230, + yPos: 330, + Icon: "far fa-file-video", + Label: "FFMPEG Builder: Video Encode", + Inputs: 1, + Outputs: 1, + Type: 6, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nCustom }], + // Hardware-first (auto): CPU-only runs ~0.15x realtime. The forced + // 8-bit output below is what lets hardware encoders take 10-bit + // sources (no encoder anywhere does 10-bit H.264); if hardware still + // fails, the executor's error path falls over to CPU. + Model: { Codec: "h264", Encoder: "", Quality: 6, Speed: 3 }, + }, + { + Uid: nCustom, + Name: "8-bit output", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderCustomParameters", + xPos: 230, + yPos: 395, + Icon: "far fa-file-video", + Label: "FFMPEG Builder: Custom Parameters", + Inputs: 1, + Outputs: 1, + Type: 6, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nNoAttach }], + // 10-bit sources otherwise negotiate a High-10 encode, which both + // breaks (x264 pipeline error) and yields a less-compatible file. + Model: { Parameters: "-pix_fmt:v:0 yuv420p", ForceEncode: false }, + }, + { + Uid: nNoAttach, + Name: "", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderRemoveAttachments", + xPos: 230, + yPos: 425, + Icon: "far fa-file-video", + Label: "Remove Attachments", + Inputs: 1, + Outputs: 1, + Type: 6, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nRemux }], + // MKV font attachments (OTF/TTF for styled subs) cannot exist in MP4 — + // the muxer fails writing the header if they're mapped through. + Model: {}, + }, + { + Uid: nRemux, + Name: "", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderRemuxToMP4", + xPos: 230, + yPos: 460, + Icon: "far fa-file-video", + Label: "Remux to MP4", + Inputs: 1, + Outputs: 1, + Type: 6, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nExec }], + Model: { UseHvc1: false }, + }, + { + Uid: nExec, + Name: "", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderExecutor", + xPos: 230, + yPos: 590, + Icon: "far fa-file-video", + Label: "FFMPEG Builder: Executor", + Inputs: 1, + Outputs: 2, + Type: 5, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nCopy }], + // Hardware encode can fail on sources the GPU can't handle (e.g. + // NVENC has no 10-bit H.264); the error path re-encodes on CPU — + // same pattern as FileFlows' own convert templates. + ErrorConnection: { Input: 1, Output: -1, InputNode: nFailEncode }, + Model: { HardwareDecoding: "auto", Strictness: "experimental" }, + }, + { + Uid: nFailEncode, + Name: "CPU Fail-over Encode", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderVideoEncodeSimple", + xPos: 450, + yPos: 590, + Icon: "far fa-file-video", + Label: "FFMPEG Builder: Video Encode", + Inputs: 1, + Outputs: 1, + Type: 6, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nFailExec }], + Model: { Codec: "h264", Encoder: "CPU", Quality: 6, Speed: 3 }, + }, + // (fail-over keeps CPU too — it exists as belt-and-braces for future + // hardware-primary variants of this flow) + { + Uid: nFailExec, + Name: "CPU Fail-over Executor", + FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderExecutor", + xPos: 450, + yPos: 720, + Icon: "far fa-file-video", + Label: "FFMPEG Builder: Executor", + Inputs: 1, + Outputs: 2, + Type: 5, + OutputConnections: [{ Input: 1, Output: 1, InputNode: nCopy }], + Model: { HardwareDecoding: false, Strictness: "experimental" }, + }, + { + Uid: nCopy, + Name: "", + FlowElementUid: "FileFlows.BasicNodes.File.CopyFile", + xPos: 230, + yPos: 720, + Icon: "fas fa-copy", + Label: "Copy next to original", + Inputs: 1, + Outputs: 1, + Type: 2, + Model: { + InputFile: "", + DestinationPath: "{folder.Orig.FullName}", + DestinationFile: "{file.Orig.FileNameNoExtension}.mp4", + CopyFolder: false, + AdditionalFiles: [], + AdditionalFilesFromOriginal: false, + PreserverOriginalDates: false, + }, + }, + ], + }; +} + +async function listByStatus(base: string, status: number): Promise { + const response = await fetch(`${base}/api/library-file/list-all?status=${status}`); + if (!response.ok) return []; + return (await response.json()) as LibraryFileRecord[]; +} + +async function recordsFor(base: string, paths: Set): Promise { + const all: LibraryFileRecord[] = []; + for (const status of [STATUS_UNPROCESSED, STATUS_PROCESSING, STATUS_PROCESSED, STATUS_FAILED]) { + for (const record of await listByStatus(base, status)) { + if (paths.has(record.dn)) all.push(record); + } + } + return all; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ---- real conversion progress ----------------------------------------------- +// FileFlows serves a file's processing log DURING the run; ffmpeg writes a +// stats line into it every few seconds. Frames done / frames total is the +// truthful travel signal (the time= field freezes when subtitle streams are +// being converted — frame= keeps moving). + +/** Total frames in the source, from the log header (Duration + stream fps). */ +function parseTotalFrames(logText: string): number | null { + const duration = logText.match(/Duration: (\d+):(\d+):(\d+)\.(\d+)/); + const fps = logText.match(/(\d+(?:\.\d+)?) fps/); + if (!duration || !fps) return null; + const seconds = + Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number(duration[4]) / 100; + const frames = seconds * Number(fps[1]); + return frames > 0 ? frames : null; +} + +/** Latest encoded frame count from ffmpeg's periodic stats lines. */ +function parseCurrentFrame(logText: string): number | null { + const matches = logText.match(/frame=\s*(\d+)/g); + if (!matches || matches.length === 0) return null; + const last = matches[matches.length - 1].match(/(\d+)/); + return last ? Number(last[1]) : null; +} + +async function fetchLogText(base: string, uid: string): Promise { + try { + const response = await fetch(`${base}/api/library-file/${uid}/log`); + if (!response.ok) return null; + // the endpoint serves HTML-wrapped lines; strip tags, keep text + return (await response.text()).replace(/<[^>]+>/g, ""); + } catch { + return null; + } +} + +export async function run(ctx: HookContext) { + await ctx.registerCheckpoints([ + { id: "flow", message: "Checking the FileFlows convert flow" }, + { id: "enqueue", message: "Sending files to FileFlows" }, + { id: "convert", message: "Converting" }, + ]); + + if (!ctx.host || !ctx.port) throw new Error("Missing FileFlows host/port in action context"); + const base = `http://${ctx.host}:${ctx.port}`; + + const files = ctx.target?.files ?? []; + if (files.length === 0) throw new Error("No files were selected"); + // The engine only offers this action for mounted files; this guards direct calls. + const unmapped = files.filter((f) => !f.containerPath); + if (unmapped.length > 0) { + ctx.fail("Some files are not in a folder FileFlows can access", [ + { label: "Files", value: unmapped.map((f) => f.name).join(", ") }, + { label: "Fix", value: "Move them into a media folder (Movies, Shows, Media…) and try again" }, + ]); + } + + // 1. Ensure the blessed flow exists (find by name, create via API if missing) + const flowsResponse = await fetch(`${base}/api/flow`); + if (!flowsResponse.ok) throw new Error(`Could not reach FileFlows (${flowsResponse.status})`); + const flows = (await flowsResponse.json()) as Array<{ Uid: string; Name: string }>; + let flowUid = flows.find((f) => f.Name === FLOW_NAME)?.Uid; + if (!flowUid) { + const createResponse = await fetch(`${base}/api/flow`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(flowDefinition()), + }); + if (!createResponse.ok) throw new Error(`Could not create the convert flow (${createResponse.status})`); + flowUid = ((await createResponse.json()) as { Uid: string }).Uid; + } + await ctx.emitCheckpoint("flow"); + + // 2. Clear stale queue records for these paths so a re-run converts again + const containerPaths = files.map((f) => f.containerPath as string); + const pathSet = new Set(containerPaths); + const stale = await recordsFor(base, pathSet); + if (stale.length > 0) { + await fetch(`${base}/api/library-file`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ Uids: stale.map((r) => r.u) }), + }); + } + + // 3. Enqueue + const addResponse = await fetch(`${base}/api/library-file/manually-add`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ FlowUid: flowUid, Files: containerPaths }), + }); + if (!addResponse.ok) throw new Error(`FileFlows rejected the files (${addResponse.status})`); + await ctx.emitCheckpoint("enqueue"); + + // 4. Poll until every file reaches a terminal status. The progress bar is + // real conversion travel: frames encoded / total frames per file (from the + // live processing log), size-weighted across files, mapped into a 5-95 + // band — the edges cover queue pickup and the final remux/copy-back. + // Monotonic: a CPU fail-over restarts ffmpeg's frame count mid-file, so + // the bar plateaus during the retry instead of jumping backwards. + await ctx.setProgress(2); + const totalFiles = files.length; + const sourceFrames = new Map(); // record uid -> total frames + const fractions = new Map(); // container path -> best-known travel + let reported = 2; + + for (;;) { + await sleep(POLL_INTERVAL_MS); + const records = await recordsFor(base, pathSet); + const done = records.filter((r) => r.s === STATUS_PROCESSED); + const failed = records.filter((r) => r.s === STATUS_FAILED); + + for (const record of records) { + if (record.s === STATUS_PROCESSED || record.s === STATUS_FAILED) { + fractions.set(record.dn, 1); + continue; + } + if (record.s !== STATUS_PROCESSING) continue; + const logText = await fetchLogText(base, record.u); + if (!logText) continue; + if (!sourceFrames.has(record.u)) { + const parsed = parseTotalFrames(logText); + if (parsed) sourceFrames.set(record.u, parsed); + } + const frameTotal = sourceFrames.get(record.u); + const frame = parseCurrentFrame(logText); + if (frameTotal && frame !== null) { + const fraction = Math.min(frame / frameTotal, 0.99); + if (fraction > (fractions.get(record.dn) ?? 0)) fractions.set(record.dn, fraction); + } + } + + let weightTotal = 0; + let weightDone = 0; + for (const file of files) { + const containerPath = file.containerPath as string; + const record = records.find((r) => r.dn === containerPath); + const weight = record?.os || file.size || 1; + weightTotal += weight; + weightDone += weight * (fractions.get(containerPath) ?? 0); + } + const overall = weightTotal > 0 ? Math.round(5 + (weightDone / weightTotal) * 90) : 5; + if (overall > reported) { + reported = overall; + await ctx.setProgress(overall); + } + + if (done.length + failed.length >= totalFiles) { + if (failed.length > 0) { + ctx.fail(`FileFlows could not convert ${failed.length} of ${totalFiles} file(s)`, [ + { label: "Failed", value: failed.map((r) => r.dn).join(", ") }, + { label: "Details", value: "Open the FileFlows app and check the file's processing log" }, + ]); + } + break; + } + + await ctx.updateCheckpointMessage( + "convert", + totalFiles > 1 + ? `Converting — ${overall}% (${done.length + failed.length} of ${totalFiles} files done)` + : `Converting — ${overall}%`, + ); + } + + await ctx.emitCheckpoint("convert", `Converted ${totalFiles} file(s) to MP4`, 99); +} diff --git a/fileflows/widget_conversions.ts b/fileflows/widget_conversions.ts new file mode 100644 index 0000000..28eaf7f --- /dev/null +++ b/fileflows/widget_conversions.ts @@ -0,0 +1,134 @@ +// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. +import { readFileSync, readdirSync, statSync } from "node:fs"; + +import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; + +// FileFlows' /api/status is unauthenticated and purpose-built for glances: +// counts plus one row per converting file (name, step, stepPercent). What it +// does NOT say is HOW the file is being converted — the chosen encoder only +// exists in the per-file runner log, which FileFlows writes live to its logs +// mount. So the widget joins the two: status API for the rows, freshest +// runner logs for "h264 → hevc · NVENC". +interface StatusFile { + name?: string; + library?: string; + step?: string; + stepPercent?: number; +} + +interface StatusResponse { + queue?: number; + processing?: number; + processed?: number; + processingFiles?: StatusFile[]; +} + +const ENCODER_KINDS: Array<[RegExp, string]> = [ + [/nvenc/i, "NVENC"], + [/qsv/i, "QuickSync"], + [/vaapi/i, "VAAPI"], + [/amf/i, "AMF"], + [/videotoolbox/i, "VideoToolbox"], + [/^lib|^copy$/i, "software"], +]; + +function encoderKind(encoder: string): string { + for (const [pattern, kind] of ENCODER_KINDS) if (pattern.test(encoder)) return kind; + return "software"; +} + +/** Codec family from an encoder name: h264_nvenc → h264, libx265 → hevc. */ +function encoderCodec(encoder: string): string { + const lowered = encoder.toLowerCase(); + if (lowered.includes("265") || lowered.includes("hevc")) return "hevc"; + if (lowered.includes("264")) return "h264"; + if (lowered.includes("av1")) return "av1"; + if (lowered.includes("vp9")) return "vp9"; + return encoder; +} + +interface LogFacts { + sourceCodec?: string; + encoder?: string; +} + +/** + * Read encoder facts for active conversions from the freshest runner logs. + * Keyed by the source file path the log names in its ffmpeg probe line. + */ +function harvestLogFacts(ctx: WidgetContext): Map { + const facts = new Map(); + const logsMount = ctx.mounts.find((m) => m.hostPath.endsWith("/fileflows/logs")); + if (!logsMount) return facts; + const dir = `${logsMount.localPath}/LibraryFiles`; + try { + const now = Date.now(); + for (const file of readdirSync(dir)) { + if (!file.endsWith(".log")) continue; + const path = `${dir}/${file}`; + try { + if (now - statSync(path).mtimeMs > 10 * 60_000) continue; + // Runner logs stay small during encode (a few hundred KB at most). + const text = readFileSync(path, "utf-8"); + const source = text.match(/Executing: \S*ffmpeg[^\n]* -i (\/[^\n]+)/)?.[1]?.trim(); + if (!source) continue; + const entry: LogFacts = { + sourceCodec: text.match(/Codec: (\w+)/)?.[1], + encoder: text.match(/Encoding Parameters: (\S+)/)?.[1], + }; + facts.set(source, entry); + } catch { + // One unreadable log never blanks the widget. + } + } + } catch { + return facts; + } + return facts; +} + +const basename = (path: string): string => path.split("/").pop() || path; + +export async function run(ctx: WidgetContext): Promise { + const base = `http://${ctx.host}:${ctx.port ?? 30200}`; + + const response = await fetch(`${base}/api/status`, { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`FileFlows status query failed (${response.status})`); + const status = (await response.json()) as StatusResponse; + + const files = status.processingFiles ?? []; + const facts = harvestLogFacts(ctx); + + return { + fields: { + active: { + type: "stat", + label: "Converting", + value: String(status.processing ?? files.length), + }, + queued: { type: "stat", label: "Queued", value: String(status.queue ?? 0) }, + files: { + type: "list", + entries: files.slice(0, 5).map((file) => { + const fact = file.name ? facts.get(file.name) : undefined; + const conversion = + fact?.encoder !== undefined + ? `${fact.sourceCodec ?? "?"} → ${encoderCodec(fact.encoder)} · ${encoderKind(fact.encoder)}` + : file.step; + return { + title: basename(file.name ?? "Unknown file"), + subtitle: conversion || undefined, + meta: file.stepPercent !== undefined ? `${Math.round(file.stepPercent)}%` : undefined, + }; + }), + }, + summary: { + type: "text", + text: + files.length === 0 + ? `Nothing converting · ${status.queue ?? 0} queued` + : `${files.length} converting · ${status.queue ?? 0} queued`, + }, + }, + }; +} From 491c44d4264d9bea71cc5c8fe8b672f80c27140d Mon Sep 17 00:00:00 2001 From: Mkayyyyyy Date: Tue, 1 Sep 2026 12:05:53 -0400 Subject: [PATCH 13/22] Rework the conversions widget: frame-based progress aligned with the convert hook, ETA, quoted-path log join, source codec from the probed stream --- fileflows/widget_conversions.ts | 134 ++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 25 deletions(-) diff --git a/fileflows/widget_conversions.ts b/fileflows/widget_conversions.ts index 28eaf7f..5060334 100644 --- a/fileflows/widget_conversions.ts +++ b/fileflows/widget_conversions.ts @@ -3,12 +3,11 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; -// FileFlows' /api/status is unauthenticated and purpose-built for glances: -// counts plus one row per converting file (name, step, stepPercent). What it -// does NOT say is HOW the file is being converted — the chosen encoder only -// exists in the per-file runner log, which FileFlows writes live to its logs -// mount. So the widget joins the two: status API for the rows, freshest -// runner logs for "h264 → hevc · NVENC". +// FileFlows' /api/status gives the counts and one row per converting file, but +// its stepPercent is per-STEP (resets each flow step) and the encoder only +// exists in the per-file runner log. So the widget joins the status rows with +// the freshest runner logs and derives the convert hook's own frame-based +// 5–95 progress, so the percent matches the file browser and notifications. interface StatusFile { name?: string; library?: string; @@ -50,11 +49,45 @@ function encoderCodec(encoder: string): string { interface LogFacts { sourceCodec?: string; encoder?: string; + /** Overall progress 0..1 from frames encoded / total frames. */ + fraction?: number; + /** Seconds remaining, from the live encode fps and frames left. */ + etaSeconds?: number; + /** Log freshness — a re-convert writes a new log for the same source. */ + mtimeMs: number; +} + +/** Total frames in the source, from the log header (Duration + stream fps). */ +function parseTotalFrames(logText: string): number | null { + const duration = logText.match(/Duration: (\d+):(\d+):(\d+)\.(\d+)/); + const fps = logText.match(/(\d+(?:\.\d+)?) fps/); + if (!duration || !fps) return null; + const seconds = + Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number(duration[4]) / 100; + const frames = seconds * Number(fps[1]); + return frames > 0 ? frames : null; +} + +/** Latest encoded frame count from ffmpeg's periodic stats lines. */ +function parseCurrentFrame(logText: string): number | null { + const matches = logText.match(/frame=\s*(\d+)/g); + if (!matches || matches.length === 0) return null; + const last = matches[matches.length - 1].match(/(\d+)/); + return last ? Number(last[1]) : null; +} + +/** Live encode rate (fps= in the stats line) — the moving one, not the source fps. */ +function parseEncodeFps(logText: string): number | null { + const matches = logText.match(/fps=\s*(\d+(?:\.\d+)?)/g); + if (!matches || matches.length === 0) return null; + const last = matches[matches.length - 1].match(/(\d+(?:\.\d+)?)/); + const value = last ? Number(last[1]) : 0; + return value > 0 ? value : null; } /** - * Read encoder facts for active conversions from the freshest runner logs. - * Keyed by the source file path the log names in its ffmpeg probe line. + * Read encoder + progress facts for active conversions from the freshest runner + * logs. Keyed by the source file path the log names in its ffmpeg probe line. */ function harvestLogFacts(ctx: WidgetContext): Map { const facts = new Map(); @@ -67,16 +100,33 @@ function harvestLogFacts(ctx: WidgetContext): Map { if (!file.endsWith(".log")) continue; const path = `${dir}/${file}`; try { - if (now - statSync(path).mtimeMs > 10 * 60_000) continue; + const mtimeMs = statSync(path).mtimeMs; + if (now - mtimeMs > 10 * 60_000) continue; // Runner logs stay small during encode (a few hundred KB at most). const text = readFileSync(path, "utf-8"); - const source = text.match(/Executing: \S*ffmpeg[^\n]* -i (\/[^\n]+)/)?.[1]?.trim(); + // FileFlows quotes the -i path when it contains spaces — tolerate the + // quote and stop the capture at it, or real media paths never match. + const source = text.match(/Executing: \S*ffmpeg[^\n]* -i "?(\/[^"\n]+)"?/)?.[1]?.trim(); if (!source) continue; - const entry: LogFacts = { - sourceCodec: text.match(/Codec: (\w+)/)?.[1], + // Freshest log wins per source — a finished log inside the 10-minute + // window must not shadow a live re-convert with its stale ~100%. + const prev = facts.get(source); + if (prev && prev.mtimeMs >= mtimeMs) continue; + const total = parseTotalFrames(text); + const current = parseCurrentFrame(text); + const fraction = total && current !== null ? Math.min(Math.max(current / total, 0), 0.999) : undefined; + const fps = parseEncodeFps(text); + const etaSeconds = + total && current !== null && fps ? Math.max(0, Math.round((total - current) / fps)) : undefined; + facts.set(source, { + // The probed input stream is the source; the "Codec:" line is the + // encode node's TARGET config and must not be read here. + sourceCodec: text.match(/Video: (\w+)/)?.[1], encoder: text.match(/Encoding Parameters: (\S+)/)?.[1], - }; - facts.set(source, entry); + fraction, + etaSeconds, + mtimeMs, + }); } catch { // One unreadable log never blanks the widget. } @@ -89,6 +139,28 @@ function harvestLogFacts(ctx: WidgetContext): Map { const basename = (path: string): string => path.split("/").pop() || path; +/** Facts by the log's -i path, falling back to basename only when UNAMBIGUOUS — + * two shows both converting an "S01E01.mkv" must not swap progress rows. */ +function factsFor(facts: Map, name: string): LogFacts | undefined { + const direct = facts.get(name); + if (direct) return direct; + const base = basename(name); + const matches = [...facts.entries()].filter(([key]) => basename(key) === base); + return matches.length === 1 ? matches[0][1] : undefined; +} + +/** The convert hook's 5–95 band, so the same file shows the same percent. */ +function bandedPercent(fraction: number): number { + return Math.round(5 + fraction * 90); +} + +/** "~4m" / "~45s" / ">1h" — a compact glance ETA, never false precision. */ +function formatEta(seconds: number): string { + if (seconds >= 3600) return ">1h"; + if (seconds >= 60) return `~${Math.round(seconds / 60)}m`; + return `~${Math.max(5, Math.round(seconds / 5) * 5)}s`; +} + export async function run(ctx: WidgetContext): Promise { const base = `http://${ctx.host}:${ctx.port ?? 30200}`; @@ -98,36 +170,48 @@ export async function run(ctx: WidgetContext): Promise { const files = status.processingFiles ?? []; const facts = harvestLogFacts(ctx); + // Authoritative count for the stat AND the summary, so they never disagree. + const converting = status.processing ?? files.length; + const queued = status.queue ?? 0; return { fields: { - active: { - type: "stat", - label: "Converting", - value: String(status.processing ?? files.length), - }, - queued: { type: "stat", label: "Queued", value: String(status.queue ?? 0) }, + active: { type: "stat", label: "Converting", value: String(converting) }, + queued: { type: "stat", label: "Queued", value: String(queued) }, files: { type: "list", entries: files.slice(0, 5).map((file) => { - const fact = file.name ? facts.get(file.name) : undefined; + const fact = file.name ? factsFor(facts, file.name) : undefined; const conversion = fact?.encoder !== undefined ? `${fact.sourceCodec ?? "?"} → ${encoderCodec(fact.encoder)} · ${encoderKind(fact.encoder)}` : file.step; + // Frame-based overall percent; per-step stepPercent only when the + // log carries no frame lines. + const percent = + fact?.fraction !== undefined + ? bandedPercent(fact.fraction) + : file.stepPercent !== undefined + ? Math.round(file.stepPercent) + : undefined; + // ETA only while it's still meaningful — drop it in the final seconds. + const eta = fact?.etaSeconds !== undefined && fact.etaSeconds >= 10 ? formatEta(fact.etaSeconds) : undefined; + const meta = [percent !== undefined ? `${percent}%` : undefined, eta].filter(Boolean).join(" · "); return { title: basename(file.name ?? "Unknown file"), subtitle: conversion || undefined, - meta: file.stepPercent !== undefined ? `${Math.round(file.stepPercent)}%` : undefined, + meta: meta || undefined, }; }), }, summary: { type: "text", text: - files.length === 0 - ? `Nothing converting · ${status.queue ?? 0} queued` - : `${files.length} converting · ${status.queue ?? 0} queued`, + converting === 0 + ? queued > 0 + ? `Nothing converting · ${queued} queued` + : "Nothing converting" + : `${converting} converting · ${queued} queued`, }, }, }; From cf1d805dacbbdd869847a3f0a4431a4035415219 Mon Sep 17 00:00:00 2001 From: Mkayyyyyy Date: Tue, 1 Sep 2026 12:30:07 -0400 Subject: [PATCH 14/22] Add Scrutiny disk-health widget on a v6 install script --- scrutiny.json | 49 ++++++++++++++++++++-- scrutiny/widget_disk_health.ts | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 scrutiny/widget_disk_health.ts diff --git a/scrutiny.json b/scrutiny.json index e222c8b..d997dc3 100644 --- a/scrutiny.json +++ b/scrutiny.json @@ -1,8 +1,8 @@ { - "version": 4, + "version": 6, "script": { - "version": "1.1.0", - "changeLog": "Upgraded to V4 install script format" + "version": "1.2.0", + "changeLog": "Added the disk-health widget and moved to the v6 install script format" }, "requirements": { "locations": [ @@ -58,5 +58,46 @@ "memory": 2048 } } - } + }, + "widgetsSchema": 2, + "widgets": [ + { + "id": "disk-health", + "title": "Disk health", + "description": "S.M.A.R.T. status and temperatures from Scrutiny.", + "refresh": 900, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "scrutiny" + } + ], + "sizes": { + "small": { + "slots": [ + { + "type": "stat", + "field": "passing" + } + ] + }, + "large": { + "slots": [ + { + "type": "stat", + "field": "passing" + }, + { + "type": "list", + "field": "disks" + } + ] + } + }, + "script": "scrutiny/widget_disk_health.ts", + "entrypoint": "run", + "timeout": 10 + } + ] } diff --git a/scrutiny/widget_disk_health.ts b/scrutiny/widget_disk_health.ts new file mode 100644 index 0000000..68cc7fe --- /dev/null +++ b/scrutiny/widget_disk_health.ts @@ -0,0 +1,77 @@ +// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. +import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; + +// Scrutiny's summary API is unauthenticated on the LAN port: one GET carries +// every device's identity, S.M.A.R.T. verdict, and live temperature. +interface ScrutinySummaryEntry { + device?: { + device_name?: string; + model_name?: string; + capacity?: number; + archived?: boolean; + /** 0 = passed; bit 1 = failed S.M.A.R.T., bit 2 = failed Scrutiny thresholds. */ + device_status?: number; + }; + smart?: { + temp?: number; + power_on_hours?: number; + }; +} + +function capacityText(bytes: number | undefined): string | undefined { + if (!bytes) return undefined; + const tb = bytes / 1_000_000_000_000; + if (tb >= 1) return `${tb >= 10 ? Math.round(tb) : Math.round(tb * 10) / 10} TB`; + return `${Math.round(bytes / 1_000_000_000)} GB`; +} + +export async function run(ctx: WidgetContext): Promise { + const base = `http://${ctx.host}:${ctx.port ?? 31054}`; + + const response = await fetch(`${base}/api/summary`, { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`Scrutiny summary query failed (${response.status})`); + const body = (await response.json()) as { data?: { summary?: Record } }; + + const devices = Object.values(body.data?.summary ?? {}).filter((entry) => !entry.device?.archived); + const failing = devices.filter((entry) => (entry.device?.device_status ?? 0) !== 0); + + // Failing disks lead the list — the whole point of the glance. + const ordered = [...failing, ...devices.filter((entry) => !failing.includes(entry))]; + + return { + fields: { + passing: { + type: "stat", + label: "Disks passing", + value: `${devices.length - failing.length}/${devices.length}`, + }, + disks: { + type: "list", + // Cap at the platform's per-list ceiling (20) so a large array can't + // fail result validation; failing disks sort first, so the cap never + // hides a problem, and only the top few actually render. + entries: ordered.slice(0, 20).map((entry) => ({ + title: entry.device?.model_name || entry.device?.device_name || "Unknown disk", + subtitle: [entry.device?.device_name, capacityText(entry.device?.capacity)] + .filter(Boolean) + .join(" · ") || undefined, + meta: [ + (entry.device?.device_status ?? 0) !== 0 ? "FAILING" : undefined, + entry.smart?.temp !== undefined ? `${entry.smart.temp}°C` : undefined, + ] + .filter(Boolean) + .join(" · ") || undefined, + })), + }, + status: { + type: "text", + text: + devices.length === 0 + ? "Waiting for the first disk report" + : failing.length === 0 + ? `All ${devices.length} disks passing` + : `${failing.length} of ${devices.length} disks FAILING`, + }, + }, + }; +} From 32d3fac428a2a10a7f3642fab246cc98689ee5a2 Mon Sep 17 00:00:00 2001 From: Mkayyyyyy Date: Tue, 1 Sep 2026 12:57:03 -0400 Subject: [PATCH 15/22] Add Plex now-playing widget on a v6 install script --- plex.json | 67 +++++++++++-- plex/widget_now_playing.ts | 197 +++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 plex/widget_now_playing.ts diff --git a/plex.json b/plex.json index 448efa7..2e1a6b9 100644 --- a/plex.json +++ b/plex.json @@ -1,8 +1,8 @@ { - "version": 5, + "version": 6, "script": { - "version": "2.0.0", - "changeLog": "V5 with post-install hooks for automated Plex configuration" + "version": "2.1.0", + "changeLog": "Added the now-playing widget and moved to the v6 install script format" }, "installation_questions": [ { @@ -105,15 +105,13 @@ "hooks": [ { "id": "configure-plex", - "event": "onAfterInstall", - "script": "plex/plex_hook.ts", - "entrypoint": "afterInstall", - "timeout": 300, + "title": "Pre-configure Plex", "description": "Setting up Plex server", + "events": [ + "onAfterInstall" + ], "optional": true, - "retries": 0, "userOptional": { - "label": "Pre-configure Plex", "description": "Sign in to your Plex account to automatically claim your server, set preferences, and create media libraries. By enabling this, you agree that HexOS will accept the Plex Terms of Service on your behalf.", "default": true, "link": { @@ -154,7 +152,56 @@ "placeholder": "HexOS Plex" } } - ] + ], + "script": "plex/plex_hook.ts", + "entrypoint": "afterInstall", + "timeout": 300, + "retries": 0 + } + ], + "widgetsSchema": 2, + "widgets": [ + { + "id": "now-playing", + "title": "Now playing", + "description": "Active Plex streams right now.", + "refresh": 10, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "plex" + } + ], + "sizes": { + "small": { + "slots": [ + { + "type": "stat", + "field": "streams" + } + ] + }, + "large": { + "media": { + "placement": "left", + "field": "art" + }, + "slots": [ + { + "type": "stat", + "field": "streams" + }, + { + "type": "list", + "field": "sessions" + } + ] + } + }, + "script": "plex/widget_now_playing.ts", + "entrypoint": "run", + "timeout": 10 } ] } diff --git a/plex/widget_now_playing.ts b/plex/widget_now_playing.ts new file mode 100644 index 0000000..3e45c53 --- /dev/null +++ b/plex/widget_now_playing.ts @@ -0,0 +1,197 @@ +// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. +import { Buffer } from "node:buffer"; +import { readFileSync } from "node:fs"; + +import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; + +const PREFS_RELATIVE = "/Library/Application Support/Plex Media Server/Preferences.xml"; + +function harvestPlexToken(ctx: WidgetContext): string | null { + const config = + ctx.mounts.find((m) => m.containerPath === "/config") ?? + ctx.mounts.find((m) => m.hostPath.endsWith("/plex/config")); + if (!config) return null; + try { + const xml = readFileSync(config.localPath + PREFS_RELATIVE, "utf-8"); + return xml.match(/PlexOnlineToken="([^"]+)"/)?.[1] ?? null; + } catch { + return null; + } +} + +/** Poster as a size-capped data URI via Plex's photo transcoder. */ +async function fetchThumb( + base: string, + token: string, + thumb: string | undefined, +): Promise { + if (!thumb) return undefined; + try { + const response = await fetch( + `${base}/photo/:/transcode?width=120&height=180&minSize=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${token}`, + ); + if (!response.ok) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.startsWith("image/")) return undefined; + const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; + return uri.length <= 60_000 ? uri : undefined; + } catch { + return undefined; + } +} + +interface PlexSession { + title: string; + type?: string; + grandparentTitle?: string; + parentIndex?: number; + index?: number; + viewOffset?: number; + duration?: number; + thumb?: string; + grandparentThumb?: string; + User?: { title?: string }; + Player?: { title?: string; product?: string; state?: string }; + // The TranscodeSession is the authoritative decision signal; absent means + // direct play. Session.bandwidth is the reserved stream bandwidth in kbps. + Session?: { bandwidth?: number }; + TranscodeSession?: { videoDecision?: string; audioDecision?: string }; +} + +/** SxxExx when both indices are known. */ +function episodeCode(season: number | undefined, episode: number | undefined): string | undefined { + if (season === undefined || episode === undefined) return undefined; + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; +} + +function sessionTitle(session: PlexSession): string { + if (session.type === "episode" && session.grandparentTitle) { + const code = episodeCode(session.parentIndex, session.index); + return code + ? `${session.grandparentTitle} — ${code} · ${session.title}` + : `${session.grandparentTitle} — ${session.title}`; + } + return session.title; +} + +/** + * Direct Play / Direct Stream (remux) / Transcode. No TranscodeSession = + * direct play; one whose streams all "copy" = remux (Direct Stream); any + * stream re-encoding = Transcode. (Part.decision only ever says + * directplay/transcode — it cannot express remux — and reading Media[0] + * misreports multi-version items, so neither is used.) + */ +function streamLabel(session: PlexSession): string { + const transcode = session.TranscodeSession; + if (!transcode) return "Direct Play"; + if (transcode.videoDecision === "transcode" || transcode.audioDecision === "transcode") return "Transcode"; + return "Direct Stream"; +} + +/** kbps → human bitrate; drops non-positive/unknown. */ +function bandwidthLabel(kbps: number | undefined): string | undefined { + const n = Number(kbps); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n >= 1000 ? `${(n / 1000).toFixed(1)} Mbps` : `${Math.round(n)} kbps`; +} + +function sessionSubtitle(session: PlexSession): string | undefined { + const who = session.User?.title; + const where = session.Player?.product || session.Player?.title; + const method = streamLabel(session); + // Identity for a plain direct play; the load story (method · bitrate) when + // it isn't — the tight subtitle only fits two short facts. + if (method === "Direct Play") return [who, where].filter(Boolean).join(" · ") || undefined; + return [method, bandwidthLabel(session.Session?.bandwidth)].filter(Boolean).join(" · ") || undefined; +} + +function timecode(ms: number): string { + const total = Math.floor(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` + : `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +function sessionMeta(session: PlexSession): string | undefined { + const parts: string[] = []; + if (session.Player?.state === "paused") parts.push("paused"); + if (session.viewOffset !== undefined && session.duration) { + parts.push(`${timecode(session.viewOffset)} / ${timecode(session.duration)}`); + } + return parts.join(" · ") || undefined; +} + +export async function run(ctx: WidgetContext): Promise { + const token = harvestPlexToken(ctx); + if (!token) return { needsSetup: true, reason: "Sign in to Plex to see your library here" }; + + const base = `http://${ctx.host}:${ctx.port ?? 32400}`; + const headers = { Accept: "application/json", "X-Plex-Token": token }; + + const fetchActive = async (signal?: AbortSignal): Promise => { + const response = await fetch(`${base}/status/sessions`, { headers, signal }); + if (!response.ok) throw new Error(`Plex sessions query failed (${response.status})`); + const body = (await response.json()) as { MediaContainer?: { Metadata?: PlexSession[] } }; + return body.MediaContainer?.Metadata ?? []; + }; + // /status/sessions has brief empty windows mid-playback (a missed client + // timeline ping) — retry before believing a 0, or a blip gets cached for a + // whole refresh cycle. Retries are bounded (a slow app can't eat the 10s + // budget) and a retry failure keeps the confirmed-good empty answer. + let active = await fetchActive(); + for (let i = 0; active.length === 0 && i < 2; i++) { + await new Promise((resolve) => setTimeout(resolve, 700)); + try { + active = await fetchActive(AbortSignal.timeout(2000)); + } catch { + break; + } + } + // The count reflects every active stream; the list shows the first few. + const total = active.length; + const sessions = active.slice(0, 5); + + const images = await Promise.all( + sessions.map((session) => fetchThumb(base, token, session.grandparentThumb || session.thumb)), + ); + + const fields: NonNullable = { + streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, + sessions: { + type: "list", + entries: sessions.map((session, i) => ({ + title: sessionTitle(session), + subtitle: sessionSubtitle(session), + // Text floor: a static timecode any renderer can show as-is. + meta: sessionMeta(session), + // Enrichment: capable renderers tick this between polls. + elapsed: + session.viewOffset !== undefined && session.duration + ? { + ms: session.viewOffset, + ofMs: session.duration, + state: session.Player?.state === "paused" ? ("paused" as const) : ("running" as const), + } + : undefined, + image: images[i], + })), + }, + summary: { + type: "text", + text: + total === 0 + ? "Nothing playing" + : `${total} stream${total === 1 ? "" : "s"} · ${sessionTitle(sessions[0])}`, + }, + }; + + // The media slot's source: the first session with a poster — its own title + // labels the art. + const art = images.find((image) => image !== undefined); + if (art) fields.art = { type: "image", image: art, alt: sessionTitle(sessions[images.indexOf(art)]) }; + + return { fields }; +} From 81e1836e040e310c43b58fbc6ea21e48d0ab893a Mon Sep 17 00:00:00 2001 From: acknologia <64101826+acknologia@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:19:59 -0400 Subject: [PATCH 16/22] Update romm.json Removed "(optional)" from questions. We now have a red asterisk showing if a question is optional or not added moby games question Removed the questions for rescanning library, scheduling library rescans, enabling hasheous, playmatch and howlongtobeat. this was done to streamline the questions list and make it seem less daunting. I see no reason not to enable those 3 metadata providers by default. If there is any good reason I am happy to add the questions back. Same for auto scanning. Scheduled scanning using cron time seemed complicated for the average user so it was removed. as this is a postgres app, I have decided not to expose any romm folders as postgres apps tend to not work as intended when users interact with their file directories outside of using the app. --- romm.json | 128 ++++++++++++++---------------------------------------- 1 file changed, 33 insertions(+), 95 deletions(-) diff --git a/romm.json b/romm.json index 8c439a4..6ffd48e 100644 --- a/romm.json +++ b/romm.json @@ -6,74 +6,57 @@ }, "installation_questions": [ { - "question": "IGDB Client ID (optional)", + "question": "Input your IGDB Client ID", "description": "Generate an ID and secret for IGDB (https://api-docs.igdb.com/#account-creation). IGDB provides titles, descriptions, cover art and related games.", "type": "text", "key": "igdb_client_id", "required": false }, { - "question": "IGDB Client Secret (optional)", + "question": "Input your IGDB Client Secret", "description": "The client secret from your IGDB application.", - "type": "text", + "type": "password", "key": "igdb_client_secret", "required": false }, { - "question": "ScreenScraper username (optional)", + "question": "Input your MobyGames API Key", + "description": "Instructions for how to generate an API key for MobyGames can be found here: https://www.mobygames.com/info/api", + "type": "text", + "key": "mobygames_api_key", + "required": false + }, + { + "question": "Input your ScreenScraper username", "description": "Username for ScreenScraper.fr (https://www.screenscraper.fr). A free provider of titles, descriptions, cover art, screenshots and manuals.", "type": "text", "key": "screenscraper_user", "required": false }, { - "question": "ScreenScraper password (optional)", + "question": "Input your ScreenScraper password", "description": "Password for your ScreenScraper.fr account.", - "type": "text", + "type": "password", "key": "screenscraper_password", "required": false }, { - "question": "RetroAchievements API key (optional)", + "question": "Input your RetroAchievements API key", "description": "Generate an API key from your RetroAchievements account settings (https://retroachievements.org/settings). Adds achievement progress tracking.", "type": "text", "key": "retroachievements_api_key", "required": false }, { - "question": "SteamGridDB API key (optional)", + "question": "Input your SteamGridDB API key", "description": "Generate an API key from your SteamGridDB profile preferences (https://www.steamgriddb.com/profile/preferences/api). Provides additional high-quality cover art.", "type": "text", "key": "steamgriddb_api_key", "required": false }, - { - "question": "Enable Hasheous metadata provider", - "description": "Free, keyless hash-based matching that proxies IGDB titles, descriptions and cover art. Enabled by default so your first scan has metadata without any API keys.", - "type": "select", - "key": "hasheous_api_enabled", - "required": false, - "default": "true", - "options": [ - { "text": "Yes", "value": "true" }, - { "text": "No", "value": "false" } - ] - }, - { - "question": "Enable PlayMatch metadata provider", - "description": "Free, community-driven hash-based matching service. No API key required.", - "type": "select", - "key": "playmatch_api_enabled", - "required": false, - "default": "false", - "options": [ - { "text": "Yes", "value": "true" }, - { "text": "No", "value": "false" } - ] - }, { "question": "Enable Flashpoint metadata", - "description": "Metadata for 180,000+ flash and browser-based games. No API key required.", + "description": "Metadata for 180,000+ flash and browser-based games.", "type": "select", "key": "flashpoint_api_enabled", "required": false, @@ -83,18 +66,6 @@ { "text": "No", "value": "false" } ] }, - { - "question": "Enable HowLongToBeat metadata", - "description": "Adds game completion times for 84,000+ games. No API key required.", - "type": "select", - "key": "hltb_api_enabled", - "required": false, - "default": "false", - "options": [ - { "text": "Yes", "value": "true" }, - { "text": "No", "value": "false" } - ] - }, { "question": "Enable LaunchBox metadata", "description": "Community-driven metadata database. Requires running a LaunchBox metadata update (manual or scheduled) inside RomM before it can match games.", @@ -107,45 +78,13 @@ { "text": "No", "value": "false" } ] }, - { - "question": "Rescan library on file changes", - "description": "Automatically re-scan the library when filesystem changes are detected.", - "type": "select", - "key": "enable_rescan_on_filesystem_change", - "required": false, - "default": "false", - "options": [ - { "text": "Yes", "value": "true" }, - { "text": "No", "value": "false" } - ] - }, - { - "question": "Enable scheduled library rescan", - "description": "Periodically re-scan the library on a schedule.", - "type": "select", - "key": "enable_scheduled_rescan", - "required": false, - "default": "false", - "options": [ - { "text": "Yes", "value": "true" }, - { "text": "No", "value": "false" } - ] - }, - { - "question": "Scheduled rescan cron expression", - "description": "Cron schedule for library rescans, e.g. \"0 3 * * *\" for 3:00 AM daily. Only used when scheduled rescan is enabled.", - "type": "text", - "key": "scheduled_rescan_cron", - "required": false, - "default": "0 3 * * *" - }, { "question": "Disable download endpoint authentication", "description": "Only required for Tinfoil/WebRcade integration. Lowers security, leave off unless you need it.", "type": "select", "key": "disable_download_endpoint_auth", "required": false, - "default": "false", + "default": "true", "options": [ { "text": "Yes", "value": "true" }, { "text": "No", "value": "false" } @@ -153,8 +92,8 @@ } ], "requirements": { - "locations": ["ApplicationsPerformance", "Media"], - "specifications": ["2CORE", "1024MB"], + "locations": ["ApplicationsPerformance", "ApplicationsCapacity"], + "specifications": ["2CORE", "2GBRAM"], "permissions": ["READ_WRITE_LOCATIONS"], "ports": [30446] }, @@ -163,12 +102,11 @@ "path": "$LOCATION(ApplicationsPerformance)" }, { - "path": "$LOCATION(Media)", - "network_share": true + "path": "$LOCATION(ApplicationsCapacity)" }, - { "path": "$LOCATION(Media)/romm/library", "owner": { "user": "apps" }, "snapshot": { "id": "library" } }, - { "path": "$LOCATION(Media)/romm/resources", "owner": { "user": "apps" }, "snapshot": { "id": "resources" } }, - { "path": "$LOCATION(Media)/romm/assets", "owner": { "user": "apps" }, "snapshot": { "id": "assets" } }, + { "path": "$LOCATION(ApplicationsCapacity)/romm/library", "owner": { "user": "apps" }, "snapshot": { "id": "library" } }, + { "path": "$LOCATION(ApplicationsPerformance)/romm/resources", "owner": { "user": "apps" }, "snapshot": { "id": "resources" } }, + { "path": "$LOCATION(ApplicationsPerformance)/romm/assets", "owner": { "user": "apps" }, "snapshot": { "id": "assets" } }, { "path": "$LOCATION(ApplicationsPerformance)/romm/config", "owner": { "user": "apps" }, "snapshot": { "id": "config" } }, { "path": "$LOCATION(ApplicationsPerformance)/romm/postgres_data", "owner": { "user": "netdata", "group": "docker" }, "snapshot": { "id": "db" } } ], @@ -178,17 +116,17 @@ "redis_password": "$RANDOM_STRING(16)", "auth_secret_key": "$RANDOM_STRING(32)", "postgres_image_selector": "postgres_18_image", - "hasheous_api_enabled": "$QUESTION(hasheous_api_enabled)", - "playmatch_api_enabled": "$QUESTION(playmatch_api_enabled)", + "hasheous_api_enabled": true, + "playmatch_api_enabled": true, "flashpoint_api_enabled": "$QUESTION(flashpoint_api_enabled)", - "hltb_api_enabled": "$QUESTION(hltb_api_enabled)", + "hltb_api_enabled": true, "launchbox_api_enabled": "$QUESTION(launchbox_api_enabled)", - "enable_rescan_on_filesystem_change": "$QUESTION(enable_rescan_on_filesystem_change)", - "enable_scheduled_rescan": "$QUESTION(enable_scheduled_rescan)", - "scheduled_rescan_cron": "$QUESTION(scheduled_rescan_cron)", + "enable_rescan_on_filesystem_change": true, + "enable_scheduled_rescan": true, "disable_download_endpoint_auth": "$QUESTION(disable_download_endpoint_auth)", "igdb_client_id": "$IF($QUESTION(igdb_client_id), $QUESTION(igdb_client_id), \"\")", "igdb_client_secret": "$IF($QUESTION(igdb_client_secret), $QUESTION(igdb_client_secret), \"\")", + "mobygames_api_key": "$IF($QUESTION(mobygames_api_key), $QUESTION(mobygames_api_key), \"\")", "screenscraper_user": "$IF($QUESTION(screenscraper_user), $QUESTION(screenscraper_user), \"\")", "screenscraper_password": "$IF($QUESTION(screenscraper_password), $QUESTION(screenscraper_password), \"\")", "retroachievements_api_key": "$IF($QUESTION(retroachievements_api_key), $QUESTION(retroachievements_api_key), \"\")", @@ -196,9 +134,9 @@ "additional_envs": [] }, "storage": { - "library": "$HOST_PATH($LOCATION(Media)/romm/library)", - "resources": "$HOST_PATH($LOCATION(Media)/romm/resources)", - "assets": "$HOST_PATH($LOCATION(Media)/romm/assets)", + "library": "$HOST_PATH($LOCATION(ApplicationsCapacity)/romm/library)", + "resources": "$HOST_PATH($LOCATION(ApplicationsPerformance)/romm/resources)", + "assets": "$HOST_PATH($LOCATION(ApplicationsPerformance)/romm/assets)", "config": "$HOST_PATH($LOCATION(ApplicationsPerformance)/romm/config)", "postgres_data": "$HOST_PATH($LOCATION(ApplicationsPerformance)/romm/postgres_data)" }, @@ -211,7 +149,7 @@ "resources": { "limits": { "cpus": 2, - "memory": "$MEMORY(10%, 4096)" + "memory": "$MEMORY(10%, 2048)" } } } From 6dbff6dce632efa6dd0cf27701dc55ed506cf7d6 Mon Sep 17 00:00:00 2001 From: acknologia <64101826+acknologia@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:43:58 -0400 Subject: [PATCH 17/22] added donetick in prep for release --- README.md | 1 + donetick.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 donetick.json diff --git a/README.md b/README.md index 3e4dd95..686503b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Install scripts are JSON configurations that automate app deployment on HexOS | [Bazarr](bazarr.json) | Subtitle management for Sonarr & Radarr | | [BentoPDF](bentopdf.json) | PDF editing toolkit | | [Blinko](blinko.json) | Self-hosted bookmarking and note-taking | +| [Donetick](dozzle.json) | Smart task manager | | [Dozzle](dozzle.json) | Real-time Docker log viewer | | [Draw.io](drawio.json) | Diagram and whiteboard editor | | [Emby](emby.json) | Media server | diff --git a/donetick.json b/donetick.json new file mode 100644 index 0000000..f89bb3b --- /dev/null +++ b/donetick.json @@ -0,0 +1,42 @@ +{ + "version": 5, + "script": { + "version": "1.0.0", + "changeLog": "Initial" + }, + "requirements": { + "locations": ["ApplicationsPerformance"], + "specifications": ["2CORE", "2048MB"], + "permissions": ["READ_WRITE_LOCATIONS"], + "ports": [30353] + }, + "ensure_directories_exists": [ + { + "path": "$LOCATION(ApplicationsPerformance)", + "network_share": true + }, + { "path": "$LOCATION(ApplicationsPerformance)/donetick/data", "owner": { "user": "apps" }, "snapshot": { "id": "data" } }, + { "path": "$LOCATION(ApplicationsPerformance)/donetick/config", "owner": { "user": "apps" }, "snapshot": { "id": "config" } } + ], + "app_values": { + "donetick": { + "jwt_secret": "$RANDOM_STRING(32)" + }, + "network": { + "web_port": { + "bind_mode": "published", + "port_number": 30353 + } + }, + "storage": { + "data": "$HOST_PATH($LOCATION(ApplicationsPerformance)/donetick/data)", + "config": "$HOST_PATH($LOCATION(ApplicationsPerformance)/donetick/config)" + }, + "resources": { + "limits": { + "cpus": 2, + "memory": "$MEMORY(10%, 2048)" + } + } + } +} \ No newline at end of file From 0914fac65aac2a3c36bbf7b40ca2b7ce67ec2a40 Mon Sep 17 00:00:00 2001 From: acknologia <64101826+acknologia@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:45:10 -0400 Subject: [PATCH 18/22] Update donetick.json --- donetick.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/donetick.json b/donetick.json index f89bb3b..de8d6f0 100644 --- a/donetick.json +++ b/donetick.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": ["ApplicationsPerformance"], - "specifications": ["2CORE", "2048MB"], + "specifications": ["2CORE", "1GBRAM"], "permissions": ["READ_WRITE_LOCATIONS"], "ports": [30353] }, @@ -35,7 +35,7 @@ "resources": { "limits": { "cpus": 2, - "memory": "$MEMORY(10%, 2048)" + "memory": "$MEMORY(5%, 1024)" } } } From 718a7b4da7680e76e22761b8f613c7b53f360edd Mon Sep 17 00:00:00 2001 From: acknologia <64101826+acknologia@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:51:19 -0400 Subject: [PATCH 19/22] update question default --- romm.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/romm.json b/romm.json index 6ffd48e..44f077f 100644 --- a/romm.json +++ b/romm.json @@ -1,8 +1,8 @@ { - "version": 4, + "version": 5, "script": { - "version": "1.0.0", - "changeLog": "Initial" + "version": "1.0.1", + "changeLog": "Changed default for a question" }, "installation_questions": [ { @@ -84,7 +84,7 @@ "type": "select", "key": "disable_download_endpoint_auth", "required": false, - "default": "true", + "default": "false", "options": [ { "text": "Yes", "value": "true" }, { "text": "No", "value": "false" } @@ -153,4 +153,4 @@ } } } -} +} \ No newline at end of file From 17b9ef4329821e5e57355faffbfad5605265569b Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Sat, 3 Oct 2026 18:56:05 -0700 Subject: [PATCH 20/22] Move the hexos-test harness apps to v6 with their actions and widgets hexos-test, hexos-test-fail and hexos-test-inline move to the v6 install script format, copied unchanged from feature/app-actions-widgets. hexos-test gains the harness actions and widgets the app surface E2E specs run. Lifecycle hooks parse to the same runnable hooks as before. --- hexos-test-fail.json | 11 ++- hexos-test-inline.json | 33 ++++--- hexos-test.json | 216 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 222 insertions(+), 38 deletions(-) diff --git a/hexos-test-fail.json b/hexos-test-fail.json index 9cdd306..0b67210 100644 --- a/hexos-test-fail.json +++ b/hexos-test-fail.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "custom": true, "internal": true, "metadata": { @@ -79,12 +79,15 @@ "hooks": [ { "id": "required-fail", - "event": "onAfterInstall", + "title": "Required step that always fails", + "description": "Required step that always fails", + "events": [ + "onAfterInstall" + ], + "optional": false, "script": "hexos-test-fail/hooks.ts", "entrypoint": "requiredFail", "timeout": 30, - "description": "Required step that always fails", - "optional": false, "retries": 0 } ] diff --git a/hexos-test-inline.json b/hexos-test-inline.json index 0c0b86c..b58ed65 100644 --- a/hexos-test-inline.json +++ b/hexos-test-inline.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "custom": true, "internal": true, "metadata": { @@ -39,29 +39,38 @@ "hooks": [ { "id": "inline-before-install", - "event": "onBeforeInstall", + "title": "Pre-install validation (inline)", + "description": "Pre-install validation (inline)", + "events": [ + "onBeforeInstall" + ], "scriptContent": "export async function beforeInstall(ctx) {\n await ctx.registerCheckpoints([\n { \"id\": \"validateContext\", \"message\": \"Validating install context\" },\n { \"id\": \"validateInline\", \"message\": \"Confirming inline execution\" }\n ]);\n\n if (ctx.resourceType !== \"app\") throw new Error(`Wrong resourceType: ${ctx.resourceType}`);\n if (ctx.resourceId !== \"hexos-test-inline\") throw new Error(`Wrong resourceId: ${ctx.resourceId}`);\n if (ctx.event !== \"onBeforeInstall\") throw new Error(`Wrong event: ${ctx.event}`);\n await ctx.emitCheckpoint(\"validateContext\");\n\n ctx.log(\"Inline hook running — no external .ts file needed\");\n await ctx.emitCheckpoint(\"validateInline\");\n}", "entrypoint": "beforeInstall", - "timeout": 30, - "description": "Pre-install validation (inline)" + "timeout": 30 }, { "id": "inline-after-install", - "event": "onAfterInstall", + "title": "Post-install health check (inline)", + "description": "Post-install health check (inline)", + "events": [ + "onAfterInstall" + ], + "optional": true, "scriptContent": "export async function afterInstall(ctx) {\n await ctx.registerCheckpoints([\n { \"id\": \"appReachable\", \"message\": \"Waiting for app to start\" },\n { \"id\": \"healthCheck\", \"message\": \"Running health check\" },\n { \"id\": \"complete\", \"message\": \"Inline hook complete\" }\n ]);\n\n await ctx.waitForApp(\"/\");\n await ctx.emitCheckpoint(\"appReachable\");\n\n const resp = await fetch(`${ctx.baseUrl}/health`, {\n signal: AbortSignal.timeout(5000)\n });\n ctx.log(`Health check: ${resp.status}`);\n await ctx.emitCheckpoint(\"healthCheck\", `Health responded ${resp.status}`);\n\n await ctx.emitCheckpoint(\"complete\", \"All inline checks passed\");\n}", "entrypoint": "afterInstall", - "timeout": 120, - "description": "Post-install health check (inline)", - "optional": true + "timeout": 120 }, { "id": "inline-optional-fail", - "event": "onAfterInstall", + "title": "Optional inline step that intentionally fails", + "description": "Optional inline step that intentionally fails", + "events": [ + "onAfterInstall" + ], + "optional": true, "scriptContent": "export async function optionalFail(ctx) {\n await ctx.registerCheckpoints([\n { \"id\": \"willFail\", \"message\": \"About to fail intentionally\" }\n ]);\n\n await ctx.updateCheckpointMessage(\"willFail\", \"Failing now...\");\n ctx.log(\"Inline hook intentionally fails\");\n throw new Error(\"Intentional inline failure for testing optional hook behavior\");\n}", "entrypoint": "optionalFail", - "timeout": 10, - "description": "Optional inline step that intentionally fails", - "optional": true + "timeout": 10 } ] } diff --git a/hexos-test.json b/hexos-test.json index 12170d2..a4a7255 100644 --- a/hexos-test.json +++ b/hexos-test.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "custom": true, "internal": true, "metadata": { @@ -51,22 +51,24 @@ "hooks": [ { "id": "before-install", - "event": "onBeforeInstall", + "title": "Pre-install validation", + "description": "Pre-install validation", + "events": [ + "onBeforeInstall" + ], "script": "hexos-test/hooks.ts", "entrypoint": "beforeInstall", - "timeout": 30, - "description": "Pre-install validation" + "timeout": 30 }, { "id": "after-install", - "event": "onAfterInstall", - "script": "hexos-test/hooks.ts", - "entrypoint": "afterInstall", - "timeout": 120, + "title": "Run post-install validation", "description": "Post-install configuration", + "events": [ + "onAfterInstall" + ], "optional": true, "userOptional": { - "label": "Run post-install validation", "description": "Exercises all hook capabilities: OAuth, questions, checkpoints, health checks, and stress testing.", "default": true }, @@ -109,8 +111,14 @@ "type": "select", "key": "test_select", "options": [ - { "text": "Option A", "value": "a" }, - { "text": "Option B", "value": "b" } + { + "text": "Option A", + "value": "a" + }, + { + "text": "Option B", + "value": "b" + } ], "default": "a" } @@ -128,33 +136,197 @@ "placeholder": "HexOS Test App" } } - ] + ], + "script": "hexos-test/hooks.ts", + "entrypoint": "afterInstall", + "timeout": 120 }, { "id": "before-upgrade", - "event": "onBeforeUpgrade", + "title": "Pre-upgrade patch", + "description": "Pre-upgrade patch", + "events": [ + { + "event": "onBeforeUpgrade", + "from": "< 2.0.0" + } + ], "script": "hexos-test/hooks.ts", "entrypoint": "beforeUpgrade", - "timeout": 30, - "description": "Pre-upgrade patch", - "condition": { "fromVersionRange": "< 2.0.0" } + "timeout": 30 }, { "id": "after-upgrade", - "event": "onAfterUpgrade", + "title": "Post-upgrade cleanup", + "description": "Post-upgrade cleanup", + "events": [ + "onAfterUpgrade" + ], "script": "hexos-test/hooks.ts", "entrypoint": "afterUpgrade", - "timeout": 30, - "description": "Post-upgrade cleanup" + "timeout": 30 }, { "id": "optional-fail", - "event": "onAfterInstall", + "title": "Optional step that intentionally fails", + "description": "Optional step that intentionally fails", + "events": [ + "onAfterInstall" + ], + "optional": true, "script": "hexos-test/hooks.ts", "entrypoint": "optionalFail", + "timeout": 10 + }, + { + "id": "harness-ok", + "title": "Harness: succeed with checkpoints", + "description": "Runs three checkpoints over ~4s and completes.", + "kind": "custom", + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-fail", + "title": "Harness: always fails", + "description": "Fails at its second checkpoint to exercise structured failure.", + "kind": "custom", + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"explode\", message: \"This step always fails\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1000));\n throw new Error(\"Intentional harness failure\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-refuse", + "title": "Harness: runs once", + "description": "Refuses to run a second time once it has completed.", + "kind": "custom", + "rerun": "refuse", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1500));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-needs-self-running", + "title": "Harness: requires this app running", + "description": "Visible when hexos-test is installed; runnable only while it is running.", + "kind": "custom", + "conditions": [ + { + "role": "visibility", + "type": "appInstalled", + "app": "hexos-test" + }, + { + "role": "availability", + "type": "appRunning", + "app": "hexos-test" + } + ], + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-invisible", + "title": "Harness: should never be shown", + "description": "Gated on an app that does not exist.", + "kind": "custom", + "conditions": [ + { + "role": "visibility", + "type": "appInstalled", + "app": "app-that-does-not-exist" + } + ], + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-future-condition", + "title": "Harness: unknown condition type", + "description": "Uses a predicate this platform does not know; must fail closed (hidden).", + "kind": "custom", + "conditions": [ + { + "role": "visibility", + "type": "quantumEntanglement", + "qubits": 7 + } + ], + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-file-verb", + "title": "Harness: convert selected file", + "description": "Runs against selected .txt files in the file browser.", + "kind": "custom", + "rerun": "idempotent", + "surfaces": ["fileBrowser"], + "target": { "type": "files", "accepts": [".txt"], "maxFiles": 5, "requiresTargetMount": false }, + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1500));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-widget-button", + "title": "Harness: widget button", + "description": "Fired from the dashboard widget.", + "kind": "custom", + "rerun": "idempotent", + "surfaces": ["card", "widget"], + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1500));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + } + ], + "widgetsSchema": 2, + "widgets": [ + { + "id": "harness-stats", + "title": "Harness stats", + "description": "Renders one field of each widget kind.", + "refresh": 30, + "entrypoint": "run", + "scriptContent": "export async function run(ctx) {\n return {\n fields: {\n headline: { type: \"text\", text: \"Harness widget\" },\n streams: { type: \"stat\", label: \"Streams\", value: \"3\" },\n items: { type: \"list\", entries: [{ title: \"First\" }, { title: \"Second\" }] },\n },\n };\n}\n", "timeout": 10, - "description": "Optional step that intentionally fails", - "optional": true + "buttons": ["harness-widget-button"], + "sizes": { + "small": { "slots": [{ "type": "stat", "field": "streams" }] }, + "large": { + "slots": [ + { "type": "text", "field": "headline" }, + { "type": "stat", "field": "streams" }, + { "type": "list", "field": "items" } + ] + } + } + }, + { + "id": "harness-needs-setup", + "title": "Harness needs setup", + "description": "Always reports that it needs setup.", + "refresh": 30, + "entrypoint": "run", + "scriptContent": "export async function run(ctx) {\n return { needsSetup: true, reason: \"Harness widget needs setup\" };\n}\n", + "timeout": 10, + "sizes": { "small": { "slots": [{ "type": "text", "field": "headline" }] } } } ] } From 46beb5a888bf59824717d0263f11295d63c0e33f Mon Sep 17 00:00:00 2001 From: Aaron Roach <37710170+AverageCakeSlice@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:37:54 -0400 Subject: [PATCH 21/22] Widgets on widgetsSchema 3, the app's card, and a now-playing widget for Jellyfin (#71) * Add card sizes for the Plex and FileFlows widgets, with validator and type support for the card size and progress * Validated widgets against widgetsSchema 3: a widget is the app's card, with its own background and slots The sizes of widgetsSchema 2 are gone, an image is never a slot (the artwork is the background), and a list slot may say how many rows it shows. Co-Authored-By: Claude Opus 5.5 (1M context) * Moved the FileFlows and Scrutiny widgets to widgetsSchema 3 Co-Authored-By: Claude Opus 5.5 (1M context) * Moved the Plex widget to widgetsSchema 3, with each stream's artwork behind the card The script fetches each stream's wide artwork once, caps it to the image field's size and keeps it while the stream plays. Co-Authored-By: Claude Opus 5.5 (1M context) * Added a now-playing widget to Jellyfin, the same card Plex draws The setup hook creates the API key the widget reads sessions with; without it the widget asks the user to sign in. Co-Authored-By: Claude Opus 5.5 (1M context) * Moved the hexos-test harness widgets to widgetsSchema 3 Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- _lib/contract.ts | 18 ++- _lib/validate.ts | 83 +++++----- _lib/widget_context.ts | 45 ++++-- fileflows.json | 31 ++-- fileflows/widget_conversions.ts | 3 + hexos-test.json | 19 +-- jellyfin.json | 41 ++++- jellyfin/jellyfin_hook.ts | 37 ++++- jellyfin/widget_now_playing.ts | 265 ++++++++++++++++++++++++++++++++ plex.json | 38 ++--- plex/widget_now_playing.ts | 190 +++++++++++++---------- scrutiny.json | 31 ++-- 12 files changed, 583 insertions(+), 218 deletions(-) create mode 100644 jellyfin/widget_now_playing.ts diff --git a/_lib/contract.ts b/_lib/contract.ts index 1ee0908..7b0dbb8 100644 --- a/_lib/contract.ts +++ b/_lib/contract.ts @@ -98,8 +98,18 @@ export const HOOK_TARGET_TYPES = ["files"] as const; // --- v6 widget contract --- -/** Supported widgetsSchema version. */ -export const SUPPORTED_WIDGETS_SCHEMA = 2; +/** + * Supported widgetsSchema version. In 3 a widget is the app's dashboard card, + * so its `slots` and `background` sit on the declaration itself; the `sizes` + * of 2 are gone. + */ +export const SUPPORTED_WIDGETS_SCHEMA = 3; -/** Widget slot types. */ -export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "image"] as const; +/** Widget slot types. An image is never a slot: the card's artwork is its background. */ +export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "progress"] as const; + +/** The shortest widget `refresh` the platform honours, in seconds: one deck poll (MIN_REFRESH_S). */ +export const WIDGET_MIN_REFRESH_S = 5; + +/** Most slots a widget declares. */ +export const WIDGET_MAX_SLOTS = 3; diff --git a/_lib/validate.ts b/_lib/validate.ts index 83c7682..0980f48 100644 --- a/_lib/validate.ts +++ b/_lib/validate.ts @@ -23,6 +23,8 @@ import { SPEC_REGEX, SUPPORTED_VERSIONS, SUPPORTED_WIDGETS_SCHEMA, + WIDGET_MAX_SLOTS, + WIDGET_MIN_REFRESH_S, WIDGET_SLOT_TYPES, } from "./contract"; @@ -550,8 +552,11 @@ function validate(file: string, script: Record) { if (widget.refresh !== undefined) { if (typeof widget.refresh !== "number" || !Number.isInteger(widget.refresh) || widget.refresh <= 0) { err(file, `${where}: refresh must be a positive integer`); - } else if (widget.refresh < 10) { - warn(file, `${where}: refresh ${widget.refresh} is below the 10s floor — the platform clamps it to 10`); + } else if (widget.refresh < WIDGET_MIN_REFRESH_S) { + warn( + file, + `${where}: refresh ${widget.refresh} is below the ${WIDGET_MIN_REFRESH_S}s floor; the platform clamps it to ${WIDGET_MIN_REFRESH_S}`, + ); } } const hasFile = typeof widget.script === "string" && widget.script.trim().length > 0; @@ -578,42 +583,46 @@ function validate(file: string, script: Record) { } } } + // widgetsSchema 3 made the widget the app's card: no sizes, one layout. if (widget.sizes !== undefined) { - if (!isObject(widget.sizes)) { - err(file, `${where}: sizes must be an object`); - } else { - for (const sizeKey of ["small", "large"] as const) { - const size = widget.sizes[sizeKey]; - if (size === undefined) continue; - if (!isObject(size)) { - err(file, `${where}.sizes.${sizeKey}: must be an object`); - continue; - } - if (size.media !== undefined) { - const placements = sizeKey === "small" ? ["top", "bottom"] : ["left", "right", "both"]; - if (!isObject(size.media) || typeof size.media.placement !== "string" || !placements.includes(size.media.placement)) { - err(file, `${where}.sizes.${sizeKey}.media: placement must be one of ${placements.join("/")}`); - } else if (typeof size.media.field !== "string" || !size.media.field.trim()) { - err(file, `${where}.sizes.${sizeKey}.media: field is missing`); - } - } - const slots = size.slots; - if (!Array.isArray(slots) || slots.length === 0) { - err(file, `${where}.sizes.${sizeKey}: slots must be a non-empty array`); - } else { - const maxSlots = sizeKey === "small" ? 3 : 4; - if (slots.length > maxSlots) { - err(file, `${where}.sizes.${sizeKey}: max ${maxSlots} slots`); - } - for (const [k, slot] of slots.entries()) { - if (!isObject(slot)) continue; - if (typeof slot.type !== "string" || !(WIDGET_SLOT_TYPES as readonly string[]).includes(slot.type)) { - err(file, `${where}.sizes.${sizeKey}.slots[${k}]: type "${String(slot.type)}" is not known`); - } - if (typeof slot.field !== "string" || !slot.field.trim()) { - err(file, `${where}.sizes.${sizeKey}.slots[${k}]: field is missing`); - } - } + err(file, `${where}: sizes is gone in widgetsSchema 3; put slots and background on the widget itself`); + } + if (widget.media !== undefined) { + err(file, `${where}: has no media slot; name an image field in background instead`); + } + if ( + widget.background !== undefined && + (!isObject(widget.background) || + typeof widget.background.field !== "string" || + !widget.background.field.trim()) + ) { + err(file, `${where}.background: field is missing`); + } + const slots = widget.slots; + if (!Array.isArray(slots) || slots.length === 0) { + err(file, `${where}: slots must be a non-empty array`); + } else { + if (slots.length > WIDGET_MAX_SLOTS) { + err(file, `${where}: max ${WIDGET_MAX_SLOTS} slots`); + } + for (const [k, slot] of slots.entries()) { + if (!isObject(slot)) { + err(file, `${where}.slots[${k}]: must be an object`); + continue; + } + if (slot.type === "image") { + err(file, `${where}.slots[${k}]: an image is not a slot; name the image field in background instead`); + } else if (typeof slot.type !== "string" || !(WIDGET_SLOT_TYPES as readonly string[]).includes(slot.type)) { + err(file, `${where}.slots[${k}]: type "${String(slot.type)}" is not known`); + } + if (typeof slot.field !== "string" || !slot.field.trim()) { + err(file, `${where}.slots[${k}]: field is missing`); + } + if (slot.rows !== undefined) { + if (slot.type !== "list") { + err(file, `${where}.slots[${k}]: rows applies to a list slot only`); + } else if (typeof slot.rows !== "number" || ![1, 2, 3].includes(slot.rows)) { + err(file, `${where}.slots[${k}]: rows must be 1, 2 or 3`); } } } diff --git a/_lib/widget_context.ts b/_lib/widget_context.ts index cf6ffb8..d99d15b 100644 --- a/_lib/widget_context.ts +++ b/_lib/widget_context.ts @@ -28,6 +28,13 @@ export interface WidgetContext { export interface WidgetListEntry { title: string; subtitle?: string; + /** + * Lines that take turns under the entry (up to 4, each at most 80 chars). + * The featured form steps through them at twice the pace of its entries, + * starting over with each entry. `subtitle` stays the floor for renderers + * that ignore this, so repeat the first line there. + */ + subtitles?: string[]; meta?: string; /** * A duration in steady motion — capable renderers advance it in real @@ -36,32 +43,44 @@ export interface WidgetListEntry { */ elapsed?: { ms: number; ofMs?: number; state?: "running" | "paused" }; /** - * Optional thumbnail as a size-capped (60KB) data URI. Data URIs ONLY — - * fetch and inline server-side; never emit app URLs (key leak, - * mixed-content, off-LAN breakage). + * How far along this entry is, 0 to 100, for a quantity that is not time + * in motion (a download, a conversion). Capable renderers draw a bar; the + * text floor (meta) must still carry the figure. + */ + progress?: number; + /** + * The entry's artwork as a size-capped (60KB) data URI. When the widget's + * background names this list, the card draws the image of the entry it has + * on screen, so fetch a wide crop near the card's shape (about 660x260). + * Data URIs ONLY: fetch and inline server-side; never emit app URLs (key + * leak, mixed-content, off-LAN breakage). */ image?: string; } /** - * One named field of the result document (widgetsSchema 2). The - * declaration's `sizes` slots reference these fields by name; a standalone - * image field is the media slot's source. Max 16 fields; images are + * One named field of the result document (widgetsSchema 3). The + * declaration's `slots` reference these fields by name; the card's background + * names a standalone image field, or a list whose entries carry images (the + * card then draws the image of the entry on screen). An image is never a slot. + * Max 16 fields; images are * size-capped (60KB) data URIs, text is capped at 500 chars. */ export type WidgetFieldValue = | { type: "text"; text: string } | { type: "stat"; label: string; value: string } | { type: "list"; entries: WidgetListEntry[] } - | { type: "image"; image: string; alt?: string }; + | { type: "image"; image: string; alt?: string } + /** A bounded quantity: `value` is 0 to 100, `text` the caption beside the label ("3.2 GB of 5 GB", max 60 chars). */ + | { type: "progress"; label: string; value: number; text?: string }; /** - * What a widget script returns: named fields the platform's size templates - * project from — one widget, ONE query; sizes never add polls. Return - * `needsSetup` (with a human reason) when the data source isn't usable - * yet. Harvest credentials first — needs-setup is the fallback, not the - * default. Field names must be STABLE (sizes reference them); dynamic - * collections (users, library sections) ride as `list` entries. + * What a widget script returns: named fields the card's slots project + * from. One widget is ONE query. Return `needsSetup` (with a human reason) + * when the data source isn't usable yet. Harvest credentials first: + * needs-setup is the fallback, not the default. Field names must be STABLE + * (slots reference them); dynamic collections (users, library sections) + * ride as `list` entries. */ export interface WidgetQueryResult { needsSetup?: boolean; diff --git a/fileflows.json b/fileflows.json index ebddf47..24e092d 100644 --- a/fileflows.json +++ b/fileflows.json @@ -138,7 +138,7 @@ "retries": 0 } ], - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "conversions", @@ -152,28 +152,17 @@ "app": "fileflows" } ], - "sizes": { - "small": { - "slots": [ - { - "type": "stat", - "field": "active" - } - ] + "slots": [ + { + "type": "stat", + "field": "active" }, - "large": { - "slots": [ - { - "type": "stat", - "field": "active" - }, - { - "type": "list", - "field": "files" - } - ] + { + "type": "list", + "field": "files", + "rows": 1 } - }, + ], "script": "fileflows/widget_conversions.ts", "entrypoint": "run", "timeout": 10 diff --git a/fileflows/widget_conversions.ts b/fileflows/widget_conversions.ts index 5060334..23168b0 100644 --- a/fileflows/widget_conversions.ts +++ b/fileflows/widget_conversions.ts @@ -200,7 +200,10 @@ export async function run(ctx: WidgetContext): Promise { return { title: basename(file.name ?? "Unknown file"), subtitle: conversion || undefined, + // Text floor: the figure any renderer can show as-is. meta: meta || undefined, + // Enrichment: capable renderers draw this as a bar. + progress: percent !== undefined ? Math.min(Math.max(percent, 0), 100) : undefined, }; }), }, diff --git a/hexos-test.json b/hexos-test.json index a4a7255..c5916d7 100644 --- a/hexos-test.json +++ b/hexos-test.json @@ -296,7 +296,7 @@ "retries": 0 } ], - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "harness-stats", @@ -307,16 +307,11 @@ "scriptContent": "export async function run(ctx) {\n return {\n fields: {\n headline: { type: \"text\", text: \"Harness widget\" },\n streams: { type: \"stat\", label: \"Streams\", value: \"3\" },\n items: { type: \"list\", entries: [{ title: \"First\" }, { title: \"Second\" }] },\n },\n };\n}\n", "timeout": 10, "buttons": ["harness-widget-button"], - "sizes": { - "small": { "slots": [{ "type": "stat", "field": "streams" }] }, - "large": { - "slots": [ - { "type": "text", "field": "headline" }, - { "type": "stat", "field": "streams" }, - { "type": "list", "field": "items" } - ] - } - } + "slots": [ + { "type": "text", "field": "headline" }, + { "type": "stat", "field": "streams" }, + { "type": "list", "field": "items" } + ] }, { "id": "harness-needs-setup", @@ -326,7 +321,7 @@ "entrypoint": "run", "scriptContent": "export async function run(ctx) {\n return { needsSetup: true, reason: \"Harness widget needs setup\" };\n}\n", "timeout": 10, - "sizes": { "small": { "slots": [{ "type": "text", "field": "headline" }] } } + "slots": [{ "type": "text", "field": "headline" }] } ] } diff --git a/jellyfin.json b/jellyfin.json index 117126c..3867ad3 100644 --- a/jellyfin.json +++ b/jellyfin.json @@ -1,8 +1,8 @@ { - "version": 5, + "version": 6, "script": { - "version": "2.0.0", - "changeLog": "V5 with post-install hook for automated Jellyfin configuration" + "version": "2.1.0", + "changeLog": "Added the now-playing widget and moved to the v6 install script format" }, "requirements": { "locations": ["ApplicationsPerformance", "ApplicationsCapacity", "Media", "Photos", "Music", "Movies", "Shows", "Videos"], @@ -49,7 +49,10 @@ "hooks": [ { "id": "configure-jellyfin", - "event": "onAfterInstall", + "title": "Pre-configure Jellyfin", + "events": [ + "onAfterInstall" + ], "script": "jellyfin/jellyfin_hook.ts", "entrypoint": "afterInstall", "timeout": 300, @@ -57,7 +60,6 @@ "optional": false, "retries": 0, "userOptional": { - "label": "Pre-configure Jellyfin", "description": "Automatically set up your server, apply preferences, and create media libraries. By enabling this, you acknowledge Jellyfin's media server policy.", "default": true, "link": { @@ -103,5 +105,34 @@ } ] } + ], + "widgetsSchema": 3, + "widgets": [ + { + "id": "now-playing", + "title": "Now playing", + "description": "Active Jellyfin streams right now.", + "refresh": 5, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "jellyfin" + } + ], + "background": { + "field": "sessions" + }, + "slots": [ + { + "type": "list", + "field": "sessions", + "rows": 1 + } + ], + "script": "jellyfin/widget_now_playing.ts", + "entrypoint": "run", + "timeout": 10 + } ] } diff --git a/jellyfin/jellyfin_hook.ts b/jellyfin/jellyfin_hook.ts index e704156..3f566ef 100644 --- a/jellyfin/jellyfin_hook.ts +++ b/jellyfin/jellyfin_hook.ts @@ -3,6 +3,9 @@ import type { HookContext } from "../_lib/hook_context"; const DEVICE_ID = "hexos-jellyfin-setup"; const CLIENT_HEADER = `MediaBrowser Client="HexOS", Device="Setup", DeviceId="${DEVICE_ID}", Version="1.0.0"`; +/** The name of the API key the now-playing widget reads sessions with. */ +const WIDGET_KEY_NAME = "HexOS"; + const AUTO_RETRIES = 5; const AUTO_RETRY_DELAY_MS = 5000; const LIBRARY_PACE_MS = 5000; @@ -51,6 +54,36 @@ export async function afterInstall(ctx: HookContext) { } await setServerName(ctx, token, serverName); await createLibraries(ctx, token); + await ensureWidgetKey(ctx, token); +} + +/** + * The API key the now-playing widget reads sessions with (jellyfin/widget_now_playing.ts finds it + * by name in Jellyfin's database). Made once: a rerun finds it and leaves it. A failure here costs + * only the widget, which then asks for a key, so it never fails the setup. + */ +async function ensureWidgetKey(ctx: HookContext, token: string): Promise { + const headers = { "Authorization": `MediaBrowser Token="${token}"` }; + try { + const listResp = await fetch(`${ctx.baseUrl}/Auth/Keys`, { headers, signal: AbortSignal.timeout(10000) }); + if (!listResp.ok) { + ctx.log(`Auth/Keys returned ${listResp.status}; the widget will ask for an API key.`); + return; + } + const keys = await listResp.json() as { Items?: Array<{ AppName?: string }> }; + if (keys.Items?.some((key) => key.AppName === WIDGET_KEY_NAME)) { + ctx.log("Widget API key already exists."); + return; + } + const createResp = await fetch(`${ctx.baseUrl}/Auth/Keys?app=${WIDGET_KEY_NAME}`, { + method: "POST", + headers, + signal: AbortSignal.timeout(10000), + }); + ctx.log(createResp.ok ? "Created the widget API key." : `Creating the widget API key returned ${createResp.status}.`); + } catch (e) { + ctx.log(`Could not create the widget API key: ${e}`); + } } function getServerName(ctx: HookContext): string { @@ -144,7 +177,9 @@ async function authenticate(ctx: HookContext, username: string, password: string method: "POST", headers: { "Content-Type": "application/json", - "X-Emby-Authorization": CLIENT_HEADER, + // Jellyfin dropped the legacy X-Emby-Authorization header (EnableLegacyAuthorization is off + // since its 2026-05-31 migration); without client details in Authorization the sign-in is a 400. + "Authorization": CLIENT_HEADER, }, body: JSON.stringify({ Username: username, Pw: password }), signal: AbortSignal.timeout(10000), diff --git a/jellyfin/widget_now_playing.ts b/jellyfin/widget_now_playing.ts new file mode 100644 index 0000000..082c320 --- /dev/null +++ b/jellyfin/widget_now_playing.ts @@ -0,0 +1,265 @@ +// Sibling files are type imports only: each script is compiled standalone, so runtime imports of them wouldn't resolve. +import { Database } from "bun:sqlite"; +import { Buffer } from "node:buffer"; + +import type { WidgetContext, WidgetListEntry, WidgetQueryResult } from "../_lib/widget_context"; + +// What Jellyfin is playing, one stream at a time on the app card, each with its own artwork. +// The same card Plex draws, from Jellyfin's sessions. + +const DB_RELATIVE = "/data/jellyfin.db"; +/** The name the setup hook gives the API key it creates. */ +const KEY_NAME = "HexOS"; +/** The device the setup hook signs in as; its session is the first that stands in for the key. */ +const SETUP_DEVICE_ID = "hexos-jellyfin-setup"; +/** Jellyfin's PermissionKind.IsAdministrator. Only an administrator sees every session. */ +const IS_ADMINISTRATOR = 0; +const SETUP_REASON = "Sign in to Jellyfin to see what's playing here"; +/** Jellyfin counts time in ticks of 100 ns. */ +const TICKS_PER_MS = 10_000; + +/** + * A token the widget can read sessions with, from Jellyfin's own database, read-only, so the owner + * never has to hand HexOS one: the API key HexOS's setup made, else any API key, else the session + * of an administrator signed in to any Jellyfin app (HexOS's setup sign-in first, then the most + * recently used). Only an administrator sees every stream. Null when no administrator has ever + * signed in, which signing in fixes. + */ +function harvestToken(ctx: WidgetContext): string | null { + const config = + ctx.mounts.find((m) => m.containerPath === "/config") ?? + ctx.mounts.find((m) => m.hostPath.endsWith("/jellyfin/config")); + if (!config) return null; + let db: Database | undefined; + try { + db = new Database(config.localPath + DB_RELATIVE, { readonly: true }); + const key = db + .query<{ AccessToken: string }, [string]>( + "SELECT AccessToken FROM ApiKeys ORDER BY (Name = ?) DESC, DateCreated DESC LIMIT 1", + ) + .get(KEY_NAME); + if (key?.AccessToken) return key.AccessToken; + const session = db + .query<{ AccessToken: string }, [number, string]>( + `SELECT d.AccessToken FROM Devices d + JOIN Permissions p ON p.UserId = d.UserId AND p.Kind = ? AND p.Value = 1 + WHERE d.IsActive = 1 + ORDER BY (d.DeviceId = ?) DESC, d.DateLastActivity DESC + LIMIT 1`, + ) + .get(IS_ADMINISTRATOR, SETUP_DEVICE_ID); + return session?.AccessToken ?? null; + } catch { + return null; + } finally { + db?.close(); + } +} + +interface JellyfinItem { + Id: string; + Name?: string; + Type?: string; + SeriesName?: string; + ParentIndexNumber?: number; + IndexNumber?: number; + RunTimeTicks?: number; + BackdropImageTags?: string[]; + ParentBackdropItemId?: string; + ParentBackdropImageTags?: string[]; + ImageTags?: { Primary?: string; Thumb?: string }; + ParentThumbItemId?: string; +} + +interface JellyfinSession { + UserName?: string; + Client?: string; + DeviceName?: string; + NowPlayingItem?: JellyfinItem; + PlayState?: { PositionTicks?: number; IsPaused?: boolean; PlayMethod?: string }; + TranscodingInfo?: { Bitrate?: number; IsVideoDirect?: boolean; IsAudioDirect?: boolean }; +} + +/** SxxExx when both indices are known. */ +function episodeCode(season: number | undefined, episode: number | undefined): string | undefined { + if (season === undefined || episode === undefined) return undefined; + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; +} + +/** A movie's title; an episode's show and code ("The Pitt · S01E12"), which is what fits a line. */ +function sessionTitle(item: JellyfinItem): string { + const name = item.Name || "Untitled"; + if (item.Type === "Episode" && item.SeriesName) { + return `${item.SeriesName} · ${episodeCode(item.ParentIndexNumber, item.IndexNumber) ?? name}`; + } + return name; +} + +/** bps → human bitrate; drops non-positive/unknown. */ +function bitrateLabel(bps: number | undefined): string | undefined { + const n = Number(bps); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} Mbps` : `${Math.round(n / 1000)} kbps`; +} + +/** + * What the server does to the picture, the one thing about serving a stream an owner weighs: + * Transcode when the video is re-encoded, DirectPlay otherwise (a remux, or only the audio + * converted, costs little). Jellyfin's PlayMethod says Transcode whenever any stream is + * re-encoded, so the transcoding flags decide. + */ +function videoMethod(session: JellyfinSession): string { + const method = session.PlayState?.PlayMethod; + if (method !== "Transcode") return "DirectPlay"; + return session.TranscodingInfo?.IsVideoDirect ? "DirectPlay" : "Transcode"; +} + +/** + * The lines that take turns under the stream: who is watching where, then how the picture is + * served and, while the server is producing the stream, at what bitrate. + */ +function sessionLines(session: JellyfinSession): string[] { + const who = [session.UserName, session.DeviceName || session.Client].filter(Boolean).join(" · "); + const how = [videoMethod(session), bitrateLabel(session.TranscodingInfo?.Bitrate)].filter(Boolean).join(" · "); + return [who, how].filter(Boolean); +} + +function timecode(ms: number): string { + const total = Math.floor(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` + : `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +/** + * Which picture of the item is its wide artwork: its own backdrop, its series' backdrop for an + * episode, else its thumb, else its primary image (an episode's primary is a 16:9 still). + */ +function artworkPath(item: JellyfinItem): string | undefined { + if (item.BackdropImageTags?.length) return `/Items/${item.Id}/Images/Backdrop/0`; + if (item.ParentBackdropItemId && item.ParentBackdropImageTags?.length) { + return `/Items/${item.ParentBackdropItemId}/Images/Backdrop/0`; + } + if (item.ImageTags?.Thumb) return `/Items/${item.Id}/Images/Thumb`; + if (item.ParentThumbItemId) return `/Items/${item.ParentThumbItemId}/Images/Thumb`; + if (item.ImageTags?.Primary) return `/Items/${item.Id}/Images/Primary`; + return undefined; +} + +/** A crop of the image filling `width` x `height`, as a data URI, or undefined when over the cap. */ +async function fetchImage( + base: string, + headers: Record, + path: string, + width: number, + height: number, +): Promise { + const response = await fetch(`${base}${path}?fillWidth=${width}&fillHeight=${height}&quality=80&format=Jpg`, { + headers, + }); + if (!response.ok) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.startsWith("image/")) return undefined; + const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; + return uri.length <= 60_000 ? uri : undefined; +} + +/** + * The item's wide artwork as a size-capped data URI, for the app card's background while the + * card shows this stream. Near twice the card's size for a sharp picture, then near its own size + * when the sharp one is over the cap. + */ +// A stream's artwork does not change while it plays, and the card asks every few seconds, so each +// picture is fetched once and kept: the box reuses this module between runs until the script +// changes. Only pictures that arrived are kept, so a failed one is tried again on the next run. +const ARTWORK_CACHE_SIZE = 20; +const artworkCache = new Map(); + +function rememberArtwork(key: string, image: string): void { + artworkCache.delete(key); + artworkCache.set(key, image); + const oldest = artworkCache.keys().next().value; + if (artworkCache.size > ARTWORK_CACHE_SIZE && oldest !== undefined) artworkCache.delete(oldest); +} + +async function fetchArtwork( + base: string, + headers: Record, + item: JellyfinItem, +): Promise { + const path = artworkPath(item); + if (!path) return undefined; + const key = `${base}${path}`; + const kept = artworkCache.get(key); + if (kept) return kept; + try { + const image = + (await fetchImage(base, headers, path, 660, 260)) ?? (await fetchImage(base, headers, path, 480, 190)); + if (image) rememberArtwork(key, image); + return image; + } catch { + return undefined; + } +} + +export async function run(ctx: WidgetContext): Promise { + const token = harvestToken(ctx); + if (!token) return { needsSetup: true, reason: SETUP_REASON }; + + const base = `http://${ctx.host}:${ctx.port ?? 30013}`; + const headers = { Accept: "application/json", Authorization: `MediaBrowser Token="${token}"` }; + + const response = await fetch(`${base}/Sessions?activeWithinSeconds=960`, { headers }); + // A session token dies when its device signs out; the next run picks another. + if (response.status === 401) return { needsSetup: true, reason: SETUP_REASON }; + if (!response.ok) throw new Error(`Jellyfin sessions query failed (${response.status})`); + const sessions = ((await response.json()) as JellyfinSession[]).filter( + (session): session is JellyfinSession & { NowPlayingItem: JellyfinItem } => !!session.NowPlayingItem, + ); + + // The count reflects every active stream; the card steps through the first few, one dot each. + const total = sessions.length; + const shown = sessions.slice(0, 5); + const artwork = await Promise.all(shown.map((session) => fetchArtwork(base, headers, session.NowPlayingItem))); + + const entries: WidgetListEntry[] = shown.map((session, i) => { + const item = session.NowPlayingItem; + const paused = session.PlayState?.IsPaused === true; + const positionMs = session.PlayState?.PositionTicks !== undefined + ? Math.floor(session.PlayState.PositionTicks / TICKS_PER_MS) + : undefined; + const durationMs = item.RunTimeTicks ? Math.floor(item.RunTimeTicks / TICKS_PER_MS) : undefined; + const timed = positionMs !== undefined && durationMs !== undefined && durationMs > 0; + return { + title: sessionTitle(item), + // The floor shows the first line; the card takes turns with all of them. + subtitle: sessionLines(session)[0], + subtitles: sessionLines(session), + // Text floor: a static timecode any renderer can show as-is. + meta: [paused ? "paused" : undefined, timed ? `${timecode(positionMs)} / ${timecode(durationMs)}` : undefined] + .filter(Boolean) + .join(" · ") || undefined, + // Enrichment: capable renderers tick this between polls. + elapsed: timed + ? { ms: Math.min(positionMs, durationMs), ofMs: durationMs, state: paused ? "paused" : "running" } + : undefined, + image: artwork[i], + }; + }); + + return { + fields: { + streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, + // Each entry carries its own artwork: the card's background names this list, so the + // picture behind the card is the one for the stream on screen. + sessions: { type: "list", entries }, + summary: { + type: "text", + text: total === 0 ? "Nothing playing" : `${total} stream${total === 1 ? "" : "s"} · ${entries[0].title}`, + }, + }, + }; +} diff --git a/plex.json b/plex.json index 2e1a6b9..6ed1d2a 100644 --- a/plex.json +++ b/plex.json @@ -159,13 +159,13 @@ "retries": 0 } ], - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "now-playing", "title": "Now playing", "description": "Active Plex streams right now.", - "refresh": 10, + "refresh": 5, "conditions": [ { "role": "availability", @@ -173,32 +173,16 @@ "app": "plex" } ], - "sizes": { - "small": { - "slots": [ - { - "type": "stat", - "field": "streams" - } - ] - }, - "large": { - "media": { - "placement": "left", - "field": "art" - }, - "slots": [ - { - "type": "stat", - "field": "streams" - }, - { - "type": "list", - "field": "sessions" - } - ] - } + "background": { + "field": "sessions" }, + "slots": [ + { + "type": "list", + "field": "sessions", + "rows": 1 + } + ], "script": "plex/widget_now_playing.ts", "entrypoint": "run", "timeout": 10 diff --git a/plex/widget_now_playing.ts b/plex/widget_now_playing.ts index 3e45c53..6698d16 100644 --- a/plex/widget_now_playing.ts +++ b/plex/widget_now_playing.ts @@ -1,4 +1,4 @@ -// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. +// Type imports only: each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. import { Buffer } from "node:buffer"; import { readFileSync } from "node:fs"; @@ -19,22 +19,57 @@ function harvestPlexToken(ctx: WidgetContext): string | null { } } -/** Poster as a size-capped data URI via Plex's photo transcoder. */ -async function fetchThumb( +/** A Plex photo-transcoder crop of `path` as a data URI, or undefined when it is over the size cap. */ +async function transcodeImage( base: string, token: string, - thumb: string | undefined, + path: string, + width: number, + height: number, ): Promise { - if (!thumb) return undefined; + const response = await fetch( + `${base}/photo/:/transcode?width=${width}&height=${height}&minSize=1&format=jpeg&url=${encodeURIComponent(path)}&X-Plex-Token=${token}`, + ); + if (!response.ok) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.startsWith("image/")) return undefined; + const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; + return uri.length <= 60_000 ? uri : undefined; +} + +/** + * The session's wide artwork as a size-capped data URI, for the app card's background while the + * card shows this session. A card is about 330x130, so the transcoder is asked for a landscape + * crop near twice that for a sharp picture, then near the card's own size when the sharp one is + * over the cap. An item with no wide artwork falls back to its poster, cropped the same way. + */ +// A stream's artwork does not change while it plays, and the card asks every few seconds, so each +// picture is fetched once and kept: the box reuses this module between runs until the script +// changes. Only pictures that arrived are kept, so a failed one is tried again on the next run. +const ARTWORK_CACHE_SIZE = 20; + +/** Streams the last run saw, kept like the artwork: an empty answer is retried only after a busy one. */ +let lastActiveCount = 0; +const artworkCache = new Map(); + +function rememberArtwork(key: string, image: string): void { + artworkCache.delete(key); + artworkCache.set(key, image); + const oldest = artworkCache.keys().next().value; + if (artworkCache.size > ARTWORK_CACHE_SIZE && oldest !== undefined) artworkCache.delete(oldest); +} + +async function fetchArtwork(base: string, token: string, session: PlexSession): Promise { + const path = session.art || session.grandparentArt || session.grandparentThumb || session.thumb; + if (!path) return undefined; + const key = `${base}${path}`; + const kept = artworkCache.get(key); + if (kept) return kept; try { - const response = await fetch( - `${base}/photo/:/transcode?width=120&height=180&minSize=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${token}`, - ); - if (!response.ok) return undefined; - const type = response.headers.get("content-type") ?? ""; - if (!type.startsWith("image/")) return undefined; - const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; - return uri.length <= 60_000 ? uri : undefined; + const image = + (await transcodeImage(base, token, path, 660, 260)) ?? (await transcodeImage(base, token, path, 480, 190)); + if (image) rememberArtwork(key, image); + return image; } catch { return undefined; } @@ -50,6 +85,8 @@ interface PlexSession { duration?: number; thumb?: string; grandparentThumb?: string; + art?: string; + grandparentArt?: string; User?: { title?: string }; Player?: { title?: string; product?: string; state?: string }; // The TranscodeSession is the authoritative decision signal; absent means @@ -64,28 +101,25 @@ function episodeCode(season: number | undefined, episode: number | undefined): s return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; } +/** A movie's title; an episode's show and code ("The Pitt · S01E12"), which is what fits a line. */ function sessionTitle(session: PlexSession): string { if (session.type === "episode" && session.grandparentTitle) { const code = episodeCode(session.parentIndex, session.index); - return code - ? `${session.grandparentTitle} — ${code} · ${session.title}` - : `${session.grandparentTitle} — ${session.title}`; + return `${session.grandparentTitle} · ${code ?? session.title}`; } return session.title; } /** - * Direct Play / Direct Stream (remux) / Transcode. No TranscodeSession = - * direct play; one whose streams all "copy" = remux (Direct Stream); any - * stream re-encoding = Transcode. (Part.decision only ever says - * directplay/transcode — it cannot express remux — and reading Media[0] - * misreports multi-version items, so neither is used.) + * What the server does to the picture, the one thing about serving a stream an + * owner weighs: Transcode when the video is re-encoded, DirectPlay otherwise + * (no TranscodeSession, or one that copies the video, remuxing or converting + * only the audio, which costs little). Decided by videoDecision, the per-stream + * decision; Part.decision cannot tell a remux from a transcode, and reading + * Media[0] misreports multi-version items. */ -function streamLabel(session: PlexSession): string { - const transcode = session.TranscodeSession; - if (!transcode) return "Direct Play"; - if (transcode.videoDecision === "transcode" || transcode.audioDecision === "transcode") return "Transcode"; - return "Direct Stream"; +function videoMethod(session: PlexSession): string { + return session.TranscodeSession?.videoDecision === "transcode" ? "Transcode" : "DirectPlay"; } /** kbps → human bitrate; drops non-positive/unknown. */ @@ -95,14 +129,15 @@ function bandwidthLabel(kbps: number | undefined): string | undefined { return n >= 1000 ? `${(n / 1000).toFixed(1)} Mbps` : `${Math.round(n)} kbps`; } -function sessionSubtitle(session: PlexSession): string | undefined { - const who = session.User?.title; - const where = session.Player?.product || session.Player?.title; - const method = streamLabel(session); - // Identity for a plain direct play; the load story (method · bitrate) when - // it isn't — the tight subtitle only fits two short facts. - if (method === "Direct Play") return [who, where].filter(Boolean).join(" · ") || undefined; - return [method, bandwidthLabel(session.Session?.bandwidth)].filter(Boolean).join(" · ") || undefined; +/** + * The lines that take turns under the stream: who is watching where, then how + * the picture is served and at what bitrate. Either is left out when Plex says + * nothing for it. + */ +function sessionLines(session: PlexSession): string[] { + const who = [session.User?.title, session.Player?.product || session.Player?.title].filter(Boolean).join(" · "); + const how = [videoMethod(session), bandwidthLabel(session.Session?.bandwidth)].filter(Boolean).join(" · "); + return [who, how].filter(Boolean); } function timecode(ms: number): string { @@ -138,11 +173,14 @@ export async function run(ctx: WidgetContext): Promise { return body.MediaContainer?.Metadata ?? []; }; // /status/sessions has brief empty windows mid-playback (a missed client - // timeline ping) — retry before believing a 0, or a blip gets cached for a - // whole refresh cycle. Retries are bounded (a slow app can't eat the 10s - // budget) and a retry failure keeps the confirmed-good empty answer. + // timeline ping): retry before believing a 0, or a blip gets cached for a + // whole refresh cycle. Only when the last run saw streams, though: with + // nothing playing before, an empty answer is simply true, and retrying it + // would hold every poll of an idle server for 1.4 s. Retries are bounded (a + // slow app can't eat the timeout) and a retry failure keeps the + // confirmed-good empty answer. let active = await fetchActive(); - for (let i = 0; active.length === 0 && i < 2; i++) { + for (let i = 0; active.length === 0 && lastActiveCount > 0 && i < 2; i++) { await new Promise((resolve) => setTimeout(resolve, 700)); try { active = await fetchActive(AbortSignal.timeout(2000)); @@ -150,48 +188,46 @@ export async function run(ctx: WidgetContext): Promise { break; } } - // The count reflects every active stream; the list shows the first few. + lastActiveCount = active.length; + // The count reflects every active stream; the card steps through the first few, one dot each. const total = active.length; const sessions = active.slice(0, 5); - const images = await Promise.all( - sessions.map((session) => fetchThumb(base, token, session.grandparentThumb || session.thumb)), - ); - - const fields: NonNullable = { - streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, - sessions: { - type: "list", - entries: sessions.map((session, i) => ({ - title: sessionTitle(session), - subtitle: sessionSubtitle(session), - // Text floor: a static timecode any renderer can show as-is. - meta: sessionMeta(session), - // Enrichment: capable renderers tick this between polls. - elapsed: - session.viewOffset !== undefined && session.duration - ? { - ms: session.viewOffset, - ofMs: session.duration, - state: session.Player?.state === "paused" ? ("paused" as const) : ("running" as const), - } - : undefined, - image: images[i], - })), - }, - summary: { - type: "text", - text: - total === 0 - ? "Nothing playing" - : `${total} stream${total === 1 ? "" : "s"} · ${sessionTitle(sessions[0])}`, + const artwork = await Promise.all(sessions.map((session) => fetchArtwork(base, token, session))); + + return { + fields: { + streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, + // Each entry carries its own artwork: the card's background names this list, so the + // picture behind the card is the one for the stream on screen. + sessions: { + type: "list", + entries: sessions.map((session, i) => ({ + title: sessionTitle(session), + // The floor shows the first line; the card takes turns with all of them. + subtitle: sessionLines(session)[0], + subtitles: sessionLines(session), + // Text floor: a static timecode any renderer can show as-is. + meta: sessionMeta(session), + // Enrichment: capable renderers tick this between polls. + elapsed: + session.viewOffset !== undefined && session.duration + ? { + ms: session.viewOffset, + ofMs: session.duration, + state: session.Player?.state === "paused" ? ("paused" as const) : ("running" as const), + } + : undefined, + image: artwork[i], + })), + }, + summary: { + type: "text", + text: + total === 0 + ? "Nothing playing" + : `${total} stream${total === 1 ? "" : "s"} · ${sessionTitle(sessions[0])}`, + }, }, }; - - // The media slot's source: the first session with a poster — its own title - // labels the art. - const art = images.find((image) => image !== undefined); - if (art) fields.art = { type: "image", image: art, alt: sessionTitle(sessions[images.indexOf(art)]) }; - - return { fields }; } diff --git a/scrutiny.json b/scrutiny.json index 2f8f830..30fe21a 100644 --- a/scrutiny.json +++ b/scrutiny.json @@ -59,7 +59,7 @@ } } }, - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "disk-health", @@ -73,28 +73,17 @@ "app": "scrutiny" } ], - "sizes": { - "small": { - "slots": [ - { - "type": "stat", - "field": "passing" - } - ] + "slots": [ + { + "type": "stat", + "field": "passing" }, - "large": { - "slots": [ - { - "type": "stat", - "field": "passing" - }, - { - "type": "list", - "field": "disks" - } - ] + { + "type": "list", + "field": "disks", + "rows": 2 } - }, + ], "script": "scrutiny/widget_disk_health.ts", "entrypoint": "run", "timeout": 10 From 19dc45f67f905b81ddff8e75722f9359a1c19812 Mon Sep 17 00:00:00 2001 From: Jerod Fritz Date: Fri, 9 Oct 2026 11:40:50 -0400 Subject: [PATCH 22/22] Remove FileFlows app integration (#72) --- fileflows.json | 171 -------------- fileflows/convert_to_mp4.ts | 402 -------------------------------- fileflows/widget_conversions.ts | 221 ------------------ 3 files changed, 794 deletions(-) delete mode 100644 fileflows.json delete mode 100644 fileflows/convert_to_mp4.ts delete mode 100644 fileflows/widget_conversions.ts diff --git a/fileflows.json b/fileflows.json deleted file mode 100644 index 24e092d..0000000 --- a/fileflows.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "version": 6, - "script": { - "version": "1.0.0", - "changeLog": "Initial script" - }, - "requirements": { - "locations": [ - "ApplicationsPerformance", - "ApplicationsCapacity", - "Media", - "Movies", - "Music", - "Photos", - "Shows", - "Documents" - ], - "specifications": [ - "2CORE", - "512MB" - ], - "permissions": [ - "READ_WRITE_LOCATIONS" - ], - "ports": [ - 30200 - ] - }, - "ensure_directories_exists": [ - { - "path": "$LOCATION(ApplicationsPerformance)" - }, - { - "path": "$LOCATION(ApplicationsCapacity)" - }, - { - "path": "$LOCATION(Media)", - "network_share": true - }, - { - "path": "$LOCATION(Movies)", - "network_share": true - }, - { - "path": "$LOCATION(Music)", - "network_share": true - }, - { - "path": "$LOCATION(Photos)", - "network_share": true - }, - { - "path": "$LOCATION(Shows)", - "network_share": true - }, - { - "path": "$LOCATION(Documents)", - "network_share": true - }, - { - "path": "$LOCATION(ApplicationsPerformance)/fileflows/data", - "snapshot": { - "id": "data" - } - }, - { - "path": "$LOCATION(ApplicationsCapacity)/fileflows/logs" - }, - { - "path": "$LOCATION(ApplicationsCapacity)/fileflows/temp" - } - ], - "app_values": { - "fileflows": { - "additional_envs": [] - }, - "network": { - "server_port": { - "bind_mode": "published", - "port_number": 30200 - } - }, - "storage": { - "data": "$HOST_PATH($LOCATION(ApplicationsPerformance)/fileflows/data)", - "logs": "$HOST_PATH($LOCATION(ApplicationsCapacity)/fileflows/logs)", - "temp": "$HOST_PATH($LOCATION(ApplicationsCapacity)/fileflows/temp)", - "additional_storage": [ - "$MOUNTED_HOST_PATH($LOCATION(Media), /media)", - "$MOUNTED_HOST_PATH($LOCATION(Movies), /movies)", - "$MOUNTED_HOST_PATH($LOCATION(Music), /music)", - "$MOUNTED_HOST_PATH($LOCATION(Photos), /photos)", - "$MOUNTED_HOST_PATH($LOCATION(Shows), /shows)", - "$MOUNTED_HOST_PATH($LOCATION(Documents), /documents)" - ] - }, - "resources": { - "limits": { - "cpus": 4, - "memory": "$MEMORY(10%, 4096)" - }, - "gpus": "$GPU_CONFIG()" - } - }, - "hooks": [ - { - "id": "convert-to-mp4", - "title": "Convert to MP4", - "description": "Convert this video to an MP4 file using FileFlows. The converted copy appears next to the original; the original file is not changed.", - "kind": "custom", - "target": { - "type": "files", - "accepts": [ - ".mkv", - ".avi", - ".mov", - ".wmv", - ".flv", - ".webm", - ".mpg", - ".mpeg", - ".m4v", - ".ts" - ], - "maxFiles": 5, - "requiresTargetMount": true - }, - "conditions": [ - { - "role": "availability", - "type": "appRunning", - "app": "fileflows" - } - ], - "rerun": "converge", - "script": "fileflows/convert_to_mp4.ts", - "entrypoint": "run", - "timeout": 3600, - "retries": 0 - } - ], - "widgetsSchema": 3, - "widgets": [ - { - "id": "conversions", - "title": "Conversions", - "description": "What FileFlows is converting right now, and how.", - "refresh": 10, - "conditions": [ - { - "role": "availability", - "type": "appRunning", - "app": "fileflows" - } - ], - "slots": [ - { - "type": "stat", - "field": "active" - }, - { - "type": "list", - "field": "files", - "rows": 1 - } - ], - "script": "fileflows/widget_conversions.ts", - "entrypoint": "run", - "timeout": 10 - } - ] -} diff --git a/fileflows/convert_to_mp4.ts b/fileflows/convert_to_mp4.ts deleted file mode 100644 index 5049c67..0000000 --- a/fileflows/convert_to_mp4.ts +++ /dev/null @@ -1,402 +0,0 @@ -// The flow is non-destructive: the converted file is copied next to the -// original as .mp4 and the original is left untouched. -import type { HookContext } from "../_lib/hook_context"; - -const FLOW_NAME = "HexOS Convert to MP4"; - -// Library-file statuses (from /api/library-file/status) -const STATUS_UNPROCESSED = 0; -const STATUS_PROCESSED = 1; -const STATUS_PROCESSING = 2; -const STATUS_FAILED = 4; - -const POLL_INTERVAL_MS = 15000; - -interface LibraryFileRecord { - /** uid */ - u: string; - /** display name — the full path we enqueued */ - dn: string; - /** status */ - s: number; - /** original size in bytes */ - os?: number; - /** flow uid */ - fu?: string; -} - -// The blessed flow, as the FileFlows API accepts it: Video File -> FFmpeg -// Builder (H.264 encode, remux to MP4) -> copy the result next to the -// original. A zero Uid means "create"; part uids are only node ids within -// the flow graph. -function flowDefinition(): Record { - const nInput = "b6d7d4a1-0001-4e00-9000-000000000001"; - const nStart = "b6d7d4a1-0002-4e00-9000-000000000002"; - const nEncode = "b6d7d4a1-0003-4e00-9000-000000000003"; - const nRemux = "b6d7d4a1-0004-4e00-9000-000000000004"; - const nExec = "b6d7d4a1-0005-4e00-9000-000000000005"; - const nCopy = "b6d7d4a1-0006-4e00-9000-000000000006"; - const nFailEncode = "b6d7d4a1-0007-4e00-9000-000000000007"; - const nFailExec = "b6d7d4a1-0008-4e00-9000-000000000008"; - const nCustom = "b6d7d4a1-0009-4e00-9000-000000000009"; - const nNoAttach = "b6d7d4a1-000a-4e00-9000-00000000000a"; - return { - Uid: "00000000-0000-0000-0000-000000000000", - Name: FLOW_NAME, - Type: 0, - Enabled: true, - Description: - "Created by HexOS. Converts a video to H.264 MP4 and places the result next to the original file. The original is not modified.", - Icon: "fas fa-video:#7F35B2", - Properties: { Fields: [], Variables: {} }, - Parts: [ - { - Uid: nInput, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.VideoFile", - xPos: 230, - yPos: 70, - Icon: "fas fa-video", - Label: "Video File", - Inputs: 0, - Outputs: 1, - Type: 0, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nStart }], - }, - { - Uid: nStart, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderStart", - xPos: 230, - yPos: 200, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Start", - Inputs: 1, - Outputs: 1, - Type: 4, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nEncode }], - }, - { - Uid: nEncode, - Name: "H.264", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderVideoEncodeSimple", - xPos: 230, - yPos: 330, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Video Encode", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nCustom }], - // Hardware-first (auto): CPU-only runs ~0.15x realtime. The forced - // 8-bit output below is what lets hardware encoders take 10-bit - // sources (no encoder anywhere does 10-bit H.264); if hardware still - // fails, the executor's error path falls over to CPU. - Model: { Codec: "h264", Encoder: "", Quality: 6, Speed: 3 }, - }, - { - Uid: nCustom, - Name: "8-bit output", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderCustomParameters", - xPos: 230, - yPos: 395, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Custom Parameters", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nNoAttach }], - // 10-bit sources otherwise negotiate a High-10 encode, which both - // breaks (x264 pipeline error) and yields a less-compatible file. - Model: { Parameters: "-pix_fmt:v:0 yuv420p", ForceEncode: false }, - }, - { - Uid: nNoAttach, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderRemoveAttachments", - xPos: 230, - yPos: 425, - Icon: "far fa-file-video", - Label: "Remove Attachments", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nRemux }], - // MKV font attachments (OTF/TTF for styled subs) cannot exist in MP4 — - // the muxer fails writing the header if they're mapped through. - Model: {}, - }, - { - Uid: nRemux, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderRemuxToMP4", - xPos: 230, - yPos: 460, - Icon: "far fa-file-video", - Label: "Remux to MP4", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nExec }], - Model: { UseHvc1: false }, - }, - { - Uid: nExec, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderExecutor", - xPos: 230, - yPos: 590, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Executor", - Inputs: 1, - Outputs: 2, - Type: 5, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nCopy }], - // Hardware encode can fail on sources the GPU can't handle (e.g. - // NVENC has no 10-bit H.264); the error path re-encodes on CPU — - // same pattern as FileFlows' own convert templates. - ErrorConnection: { Input: 1, Output: -1, InputNode: nFailEncode }, - Model: { HardwareDecoding: "auto", Strictness: "experimental" }, - }, - { - Uid: nFailEncode, - Name: "CPU Fail-over Encode", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderVideoEncodeSimple", - xPos: 450, - yPos: 590, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Video Encode", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nFailExec }], - Model: { Codec: "h264", Encoder: "CPU", Quality: 6, Speed: 3 }, - }, - // (fail-over keeps CPU too — it exists as belt-and-braces for future - // hardware-primary variants of this flow) - { - Uid: nFailExec, - Name: "CPU Fail-over Executor", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderExecutor", - xPos: 450, - yPos: 720, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Executor", - Inputs: 1, - Outputs: 2, - Type: 5, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nCopy }], - Model: { HardwareDecoding: false, Strictness: "experimental" }, - }, - { - Uid: nCopy, - Name: "", - FlowElementUid: "FileFlows.BasicNodes.File.CopyFile", - xPos: 230, - yPos: 720, - Icon: "fas fa-copy", - Label: "Copy next to original", - Inputs: 1, - Outputs: 1, - Type: 2, - Model: { - InputFile: "", - DestinationPath: "{folder.Orig.FullName}", - DestinationFile: "{file.Orig.FileNameNoExtension}.mp4", - CopyFolder: false, - AdditionalFiles: [], - AdditionalFilesFromOriginal: false, - PreserverOriginalDates: false, - }, - }, - ], - }; -} - -async function listByStatus(base: string, status: number): Promise { - const response = await fetch(`${base}/api/library-file/list-all?status=${status}`); - if (!response.ok) return []; - return (await response.json()) as LibraryFileRecord[]; -} - -async function recordsFor(base: string, paths: Set): Promise { - const all: LibraryFileRecord[] = []; - for (const status of [STATUS_UNPROCESSED, STATUS_PROCESSING, STATUS_PROCESSED, STATUS_FAILED]) { - for (const record of await listByStatus(base, status)) { - if (paths.has(record.dn)) all.push(record); - } - } - return all; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// ---- real conversion progress ----------------------------------------------- -// FileFlows serves a file's processing log DURING the run; ffmpeg writes a -// stats line into it every few seconds. Frames done / frames total is the -// truthful travel signal (the time= field freezes when subtitle streams are -// being converted — frame= keeps moving). - -/** Total frames in the source, from the log header (Duration + stream fps). */ -function parseTotalFrames(logText: string): number | null { - const duration = logText.match(/Duration: (\d+):(\d+):(\d+)\.(\d+)/); - const fps = logText.match(/(\d+(?:\.\d+)?) fps/); - if (!duration || !fps) return null; - const seconds = - Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number(duration[4]) / 100; - const frames = seconds * Number(fps[1]); - return frames > 0 ? frames : null; -} - -/** Latest encoded frame count from ffmpeg's periodic stats lines. */ -function parseCurrentFrame(logText: string): number | null { - const matches = logText.match(/frame=\s*(\d+)/g); - if (!matches || matches.length === 0) return null; - const last = matches[matches.length - 1].match(/(\d+)/); - return last ? Number(last[1]) : null; -} - -async function fetchLogText(base: string, uid: string): Promise { - try { - const response = await fetch(`${base}/api/library-file/${uid}/log`); - if (!response.ok) return null; - // the endpoint serves HTML-wrapped lines; strip tags, keep text - return (await response.text()).replace(/<[^>]+>/g, ""); - } catch { - return null; - } -} - -export async function run(ctx: HookContext) { - await ctx.registerCheckpoints([ - { id: "flow", message: "Checking the FileFlows convert flow" }, - { id: "enqueue", message: "Sending files to FileFlows" }, - { id: "convert", message: "Converting" }, - ]); - - if (!ctx.host || !ctx.port) throw new Error("Missing FileFlows host/port in action context"); - const base = `http://${ctx.host}:${ctx.port}`; - - const files = ctx.target?.files ?? []; - if (files.length === 0) throw new Error("No files were selected"); - // The engine only offers this action for mounted files; this guards direct calls. - const unmapped = files.filter((f) => !f.containerPath); - if (unmapped.length > 0) { - ctx.fail("Some files are not in a folder FileFlows can access", [ - { label: "Files", value: unmapped.map((f) => f.name).join(", ") }, - { label: "Fix", value: "Move them into a media folder (Movies, Shows, Media…) and try again" }, - ]); - } - - // 1. Ensure the blessed flow exists (find by name, create via API if missing) - const flowsResponse = await fetch(`${base}/api/flow`); - if (!flowsResponse.ok) throw new Error(`Could not reach FileFlows (${flowsResponse.status})`); - const flows = (await flowsResponse.json()) as Array<{ Uid: string; Name: string }>; - let flowUid = flows.find((f) => f.Name === FLOW_NAME)?.Uid; - if (!flowUid) { - const createResponse = await fetch(`${base}/api/flow`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(flowDefinition()), - }); - if (!createResponse.ok) throw new Error(`Could not create the convert flow (${createResponse.status})`); - flowUid = ((await createResponse.json()) as { Uid: string }).Uid; - } - await ctx.emitCheckpoint("flow"); - - // 2. Clear stale queue records for these paths so a re-run converts again - const containerPaths = files.map((f) => f.containerPath as string); - const pathSet = new Set(containerPaths); - const stale = await recordsFor(base, pathSet); - if (stale.length > 0) { - await fetch(`${base}/api/library-file`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ Uids: stale.map((r) => r.u) }), - }); - } - - // 3. Enqueue - const addResponse = await fetch(`${base}/api/library-file/manually-add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ FlowUid: flowUid, Files: containerPaths }), - }); - if (!addResponse.ok) throw new Error(`FileFlows rejected the files (${addResponse.status})`); - await ctx.emitCheckpoint("enqueue"); - - // 4. Poll until every file reaches a terminal status. The progress bar is - // real conversion travel: frames encoded / total frames per file (from the - // live processing log), size-weighted across files, mapped into a 5-95 - // band — the edges cover queue pickup and the final remux/copy-back. - // Monotonic: a CPU fail-over restarts ffmpeg's frame count mid-file, so - // the bar plateaus during the retry instead of jumping backwards. - await ctx.setProgress(2); - const totalFiles = files.length; - const sourceFrames = new Map(); // record uid -> total frames - const fractions = new Map(); // container path -> best-known travel - let reported = 2; - - for (;;) { - await sleep(POLL_INTERVAL_MS); - const records = await recordsFor(base, pathSet); - const done = records.filter((r) => r.s === STATUS_PROCESSED); - const failed = records.filter((r) => r.s === STATUS_FAILED); - - for (const record of records) { - if (record.s === STATUS_PROCESSED || record.s === STATUS_FAILED) { - fractions.set(record.dn, 1); - continue; - } - if (record.s !== STATUS_PROCESSING) continue; - const logText = await fetchLogText(base, record.u); - if (!logText) continue; - if (!sourceFrames.has(record.u)) { - const parsed = parseTotalFrames(logText); - if (parsed) sourceFrames.set(record.u, parsed); - } - const frameTotal = sourceFrames.get(record.u); - const frame = parseCurrentFrame(logText); - if (frameTotal && frame !== null) { - const fraction = Math.min(frame / frameTotal, 0.99); - if (fraction > (fractions.get(record.dn) ?? 0)) fractions.set(record.dn, fraction); - } - } - - let weightTotal = 0; - let weightDone = 0; - for (const file of files) { - const containerPath = file.containerPath as string; - const record = records.find((r) => r.dn === containerPath); - const weight = record?.os || file.size || 1; - weightTotal += weight; - weightDone += weight * (fractions.get(containerPath) ?? 0); - } - const overall = weightTotal > 0 ? Math.round(5 + (weightDone / weightTotal) * 90) : 5; - if (overall > reported) { - reported = overall; - await ctx.setProgress(overall); - } - - if (done.length + failed.length >= totalFiles) { - if (failed.length > 0) { - ctx.fail(`FileFlows could not convert ${failed.length} of ${totalFiles} file(s)`, [ - { label: "Failed", value: failed.map((r) => r.dn).join(", ") }, - { label: "Details", value: "Open the FileFlows app and check the file's processing log" }, - ]); - } - break; - } - - await ctx.updateCheckpointMessage( - "convert", - totalFiles > 1 - ? `Converting — ${overall}% (${done.length + failed.length} of ${totalFiles} files done)` - : `Converting — ${overall}%`, - ); - } - - await ctx.emitCheckpoint("convert", `Converted ${totalFiles} file(s) to MP4`, 99); -} diff --git a/fileflows/widget_conversions.ts b/fileflows/widget_conversions.ts deleted file mode 100644 index 23168b0..0000000 --- a/fileflows/widget_conversions.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. -import { readFileSync, readdirSync, statSync } from "node:fs"; - -import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; - -// FileFlows' /api/status gives the counts and one row per converting file, but -// its stepPercent is per-STEP (resets each flow step) and the encoder only -// exists in the per-file runner log. So the widget joins the status rows with -// the freshest runner logs and derives the convert hook's own frame-based -// 5–95 progress, so the percent matches the file browser and notifications. -interface StatusFile { - name?: string; - library?: string; - step?: string; - stepPercent?: number; -} - -interface StatusResponse { - queue?: number; - processing?: number; - processed?: number; - processingFiles?: StatusFile[]; -} - -const ENCODER_KINDS: Array<[RegExp, string]> = [ - [/nvenc/i, "NVENC"], - [/qsv/i, "QuickSync"], - [/vaapi/i, "VAAPI"], - [/amf/i, "AMF"], - [/videotoolbox/i, "VideoToolbox"], - [/^lib|^copy$/i, "software"], -]; - -function encoderKind(encoder: string): string { - for (const [pattern, kind] of ENCODER_KINDS) if (pattern.test(encoder)) return kind; - return "software"; -} - -/** Codec family from an encoder name: h264_nvenc → h264, libx265 → hevc. */ -function encoderCodec(encoder: string): string { - const lowered = encoder.toLowerCase(); - if (lowered.includes("265") || lowered.includes("hevc")) return "hevc"; - if (lowered.includes("264")) return "h264"; - if (lowered.includes("av1")) return "av1"; - if (lowered.includes("vp9")) return "vp9"; - return encoder; -} - -interface LogFacts { - sourceCodec?: string; - encoder?: string; - /** Overall progress 0..1 from frames encoded / total frames. */ - fraction?: number; - /** Seconds remaining, from the live encode fps and frames left. */ - etaSeconds?: number; - /** Log freshness — a re-convert writes a new log for the same source. */ - mtimeMs: number; -} - -/** Total frames in the source, from the log header (Duration + stream fps). */ -function parseTotalFrames(logText: string): number | null { - const duration = logText.match(/Duration: (\d+):(\d+):(\d+)\.(\d+)/); - const fps = logText.match(/(\d+(?:\.\d+)?) fps/); - if (!duration || !fps) return null; - const seconds = - Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number(duration[4]) / 100; - const frames = seconds * Number(fps[1]); - return frames > 0 ? frames : null; -} - -/** Latest encoded frame count from ffmpeg's periodic stats lines. */ -function parseCurrentFrame(logText: string): number | null { - const matches = logText.match(/frame=\s*(\d+)/g); - if (!matches || matches.length === 0) return null; - const last = matches[matches.length - 1].match(/(\d+)/); - return last ? Number(last[1]) : null; -} - -/** Live encode rate (fps= in the stats line) — the moving one, not the source fps. */ -function parseEncodeFps(logText: string): number | null { - const matches = logText.match(/fps=\s*(\d+(?:\.\d+)?)/g); - if (!matches || matches.length === 0) return null; - const last = matches[matches.length - 1].match(/(\d+(?:\.\d+)?)/); - const value = last ? Number(last[1]) : 0; - return value > 0 ? value : null; -} - -/** - * Read encoder + progress facts for active conversions from the freshest runner - * logs. Keyed by the source file path the log names in its ffmpeg probe line. - */ -function harvestLogFacts(ctx: WidgetContext): Map { - const facts = new Map(); - const logsMount = ctx.mounts.find((m) => m.hostPath.endsWith("/fileflows/logs")); - if (!logsMount) return facts; - const dir = `${logsMount.localPath}/LibraryFiles`; - try { - const now = Date.now(); - for (const file of readdirSync(dir)) { - if (!file.endsWith(".log")) continue; - const path = `${dir}/${file}`; - try { - const mtimeMs = statSync(path).mtimeMs; - if (now - mtimeMs > 10 * 60_000) continue; - // Runner logs stay small during encode (a few hundred KB at most). - const text = readFileSync(path, "utf-8"); - // FileFlows quotes the -i path when it contains spaces — tolerate the - // quote and stop the capture at it, or real media paths never match. - const source = text.match(/Executing: \S*ffmpeg[^\n]* -i "?(\/[^"\n]+)"?/)?.[1]?.trim(); - if (!source) continue; - // Freshest log wins per source — a finished log inside the 10-minute - // window must not shadow a live re-convert with its stale ~100%. - const prev = facts.get(source); - if (prev && prev.mtimeMs >= mtimeMs) continue; - const total = parseTotalFrames(text); - const current = parseCurrentFrame(text); - const fraction = total && current !== null ? Math.min(Math.max(current / total, 0), 0.999) : undefined; - const fps = parseEncodeFps(text); - const etaSeconds = - total && current !== null && fps ? Math.max(0, Math.round((total - current) / fps)) : undefined; - facts.set(source, { - // The probed input stream is the source; the "Codec:" line is the - // encode node's TARGET config and must not be read here. - sourceCodec: text.match(/Video: (\w+)/)?.[1], - encoder: text.match(/Encoding Parameters: (\S+)/)?.[1], - fraction, - etaSeconds, - mtimeMs, - }); - } catch { - // One unreadable log never blanks the widget. - } - } - } catch { - return facts; - } - return facts; -} - -const basename = (path: string): string => path.split("/").pop() || path; - -/** Facts by the log's -i path, falling back to basename only when UNAMBIGUOUS — - * two shows both converting an "S01E01.mkv" must not swap progress rows. */ -function factsFor(facts: Map, name: string): LogFacts | undefined { - const direct = facts.get(name); - if (direct) return direct; - const base = basename(name); - const matches = [...facts.entries()].filter(([key]) => basename(key) === base); - return matches.length === 1 ? matches[0][1] : undefined; -} - -/** The convert hook's 5–95 band, so the same file shows the same percent. */ -function bandedPercent(fraction: number): number { - return Math.round(5 + fraction * 90); -} - -/** "~4m" / "~45s" / ">1h" — a compact glance ETA, never false precision. */ -function formatEta(seconds: number): string { - if (seconds >= 3600) return ">1h"; - if (seconds >= 60) return `~${Math.round(seconds / 60)}m`; - return `~${Math.max(5, Math.round(seconds / 5) * 5)}s`; -} - -export async function run(ctx: WidgetContext): Promise { - const base = `http://${ctx.host}:${ctx.port ?? 30200}`; - - const response = await fetch(`${base}/api/status`, { headers: { Accept: "application/json" } }); - if (!response.ok) throw new Error(`FileFlows status query failed (${response.status})`); - const status = (await response.json()) as StatusResponse; - - const files = status.processingFiles ?? []; - const facts = harvestLogFacts(ctx); - // Authoritative count for the stat AND the summary, so they never disagree. - const converting = status.processing ?? files.length; - const queued = status.queue ?? 0; - - return { - fields: { - active: { type: "stat", label: "Converting", value: String(converting) }, - queued: { type: "stat", label: "Queued", value: String(queued) }, - files: { - type: "list", - entries: files.slice(0, 5).map((file) => { - const fact = file.name ? factsFor(facts, file.name) : undefined; - const conversion = - fact?.encoder !== undefined - ? `${fact.sourceCodec ?? "?"} → ${encoderCodec(fact.encoder)} · ${encoderKind(fact.encoder)}` - : file.step; - // Frame-based overall percent; per-step stepPercent only when the - // log carries no frame lines. - const percent = - fact?.fraction !== undefined - ? bandedPercent(fact.fraction) - : file.stepPercent !== undefined - ? Math.round(file.stepPercent) - : undefined; - // ETA only while it's still meaningful — drop it in the final seconds. - const eta = fact?.etaSeconds !== undefined && fact.etaSeconds >= 10 ? formatEta(fact.etaSeconds) : undefined; - const meta = [percent !== undefined ? `${percent}%` : undefined, eta].filter(Boolean).join(" · "); - return { - title: basename(file.name ?? "Unknown file"), - subtitle: conversion || undefined, - // Text floor: the figure any renderer can show as-is. - meta: meta || undefined, - // Enrichment: capable renderers draw this as a bar. - progress: percent !== undefined ? Math.min(Math.max(percent, 0), 100) : undefined, - }; - }), - }, - summary: { - type: "text", - text: - converting === 0 - ? queued > 0 - ? `Nothing converting · ${queued} queued` - : "Nothing converting" - : `${converting} converting · ${queued} queued`, - }, - }, - }; -}