diff --git a/CLAUDE.md b/CLAUDE.md
index c0b7162..eff75f1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -465,6 +465,7 @@ Add-on skills (activated when corresponding add-on is installed):
- `gotosocial.md` — GoToSocial ActivityPub microblog: post, follow, search, moderate (block_user/mute inline; defederate/block_domain/import_blocklist queued for operator confirmation), media prune, federation health
- `writefreely.md` — WriteFreely federated blog: create/update/publish/unpublish posts, list collections, fetch public posts, export; minimalist publisher (no comments, no moderation queue — WF is publish-oriented only)
- `matrix-dendrite.md` — Matrix homeserver on Dendrite: create/join/leave rooms, send messages, sync, invite users, federation health; appservice registration prep for F.12 bridges; :8448-vs-well-known either/or federation story
+- `funkwhale.md` — Funkwhale federated music pod: library listing, search, upload, follow remote channels/libraries, playlists, listening history, moderation (block_user/mute inline; block_domain/defederate queued), media prune; on-disk or S3 audio storage via storage-translators.funkwhale()
- `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS
- `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download
- `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read
diff --git a/bundles/funkwhale/docker-compose.yml b/bundles/funkwhale/docker-compose.yml
new file mode 100644
index 0000000..cc65660
--- /dev/null
+++ b/bundles/funkwhale/docker-compose.yml
@@ -0,0 +1,197 @@
+# Funkwhale — federated music server.
+#
+# Six-container bundle: api + celeryworker + celerybeat + nginx + postgres + redis.
+# All on crow-federation + an internal default network. Caddy reverse-proxies
+# :443 → funkwhale-nginx:80, which in turn serves static files and proxies
+# /api + /federation + websockets to funkwhale-api:5000.
+#
+# Data:
+# ~/.crow/funkwhale/postgres/ Postgres data dir
+# ~/.crow/funkwhale/redis/ Redis persistence (optional AOF/RDB)
+# ~/.crow/funkwhale/data/ Funkwhale /data (audio uploads, transcodes)
+# ~/.crow/funkwhale/music/ Optional read-only in-place library (FUNKWHALE_MUSIC_DIR)
+#
+# Audio storage: on-disk by default. Set FUNKWHALE_S3_* env vars to route
+# uploads to MinIO / external S3 — storage-translators.funkwhale() handles
+# the env-var mapping to AWS_* names Funkwhale expects.
+#
+# Image: funkwhale/funkwhale:1.4 (pinned at impl time; verify upstream
+# release notes + CVE feed before bumping).
+
+networks:
+ crow-federation:
+ external: true
+ default:
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ container_name: crow-funkwhale-postgres
+ networks:
+ - default
+ environment:
+ POSTGRES_USER: funkwhale
+ POSTGRES_PASSWORD: ${FUNKWHALE_POSTGRES_PASSWORD}
+ POSTGRES_DB: funkwhale
+ volumes:
+ - ${FUNKWHALE_DATA_DIR:-~/.crow/funkwhale}/postgres:/var/lib/postgresql/data
+ init: true
+ mem_limit: 512m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U funkwhale"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 20s
+
+ redis:
+ image: redis:7-alpine
+ container_name: crow-funkwhale-redis
+ networks:
+ - default
+ volumes:
+ - ${FUNKWHALE_DATA_DIR:-~/.crow/funkwhale}/redis:/data
+ init: true
+ mem_limit: 256m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ api:
+ image: funkwhale/funkwhale:1.4
+ container_name: crow-funkwhale-api
+ networks:
+ - default
+ - crow-federation
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ FUNKWHALE_HOSTNAME: ${FUNKWHALE_HOSTNAME}
+ FUNKWHALE_PROTOCOL: https
+ DJANGO_SECRET_KEY: ${FUNKWHALE_DJANGO_SECRET_KEY}
+ DATABASE_URL: postgresql://funkwhale:${FUNKWHALE_POSTGRES_PASSWORD}@postgres:5432/funkwhale
+ CACHE_URL: redis://redis:6379/0
+ CELERY_BROKER_URL: redis://redis:6379/0
+ DJANGO_ALLOWED_HOSTS: ${FUNKWHALE_HOSTNAME},funkwhale-api,api,localhost
+ FUNKWHALE_WEB_WORKERS: "2"
+ MEDIA_ROOT: /srv/funkwhale/data/media
+ STATIC_ROOT: /srv/funkwhale/data/static
+ MUSIC_DIRECTORY_PATH: /music
+ MUSIC_DIRECTORY_SERVE_PATH: /music
+ # S3 storage (activated when AWS_ACCESS_KEY_ID is non-empty — see
+ # scripts/configure-storage.mjs which writes the translated env vars
+ # into .env when FUNKWHALE_S3_* is configured at install time)
+ AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
+ AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
+ AWS_STORAGE_BUCKET_NAME: ${AWS_STORAGE_BUCKET_NAME:-}
+ AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-}
+ AWS_S3_REGION_NAME: ${AWS_S3_REGION_NAME:-us-east-1}
+ AWS_LOCATION: ${AWS_LOCATION:-}
+ AWS_QUERYSTRING_AUTH: ${AWS_QUERYSTRING_AUTH:-true}
+ AWS_QUERYSTRING_EXPIRE: ${AWS_QUERYSTRING_EXPIRE:-3600}
+ volumes:
+ - ${FUNKWHALE_DATA_DIR:-~/.crow/funkwhale}/data:/srv/funkwhale/data
+ - ${FUNKWHALE_MUSIC_DIR:-~/.crow/funkwhale/music}:/music:ro
+ command: >
+ sh -c "funkwhale-manage migrate --noinput &&
+ funkwhale-manage collectstatic --noinput &&
+ gunicorn config.asgi:application -w $${FUNKWHALE_WEB_WORKERS:-2}
+ -k uvicorn.workers.UvicornWorker -b 0.0.0.0:5000
+ --access-logfile - --error-logfile -"
+ init: true
+ mem_limit: 1500m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000/api/v1/instance/nodeinfo/2.0/ >/dev/null 2>&1 || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 120s
+
+ celeryworker:
+ image: funkwhale/funkwhale:1.4
+ container_name: crow-funkwhale-celeryworker
+ networks:
+ - default
+ depends_on:
+ api:
+ condition: service_healthy
+ environment:
+ FUNKWHALE_HOSTNAME: ${FUNKWHALE_HOSTNAME}
+ DJANGO_SECRET_KEY: ${FUNKWHALE_DJANGO_SECRET_KEY}
+ DATABASE_URL: postgresql://funkwhale:${FUNKWHALE_POSTGRES_PASSWORD}@postgres:5432/funkwhale
+ CACHE_URL: redis://redis:6379/0
+ CELERY_BROKER_URL: redis://redis:6379/0
+ C_FORCE_ROOT: "true"
+ AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
+ AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
+ AWS_STORAGE_BUCKET_NAME: ${AWS_STORAGE_BUCKET_NAME:-}
+ AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-}
+ AWS_S3_REGION_NAME: ${AWS_S3_REGION_NAME:-us-east-1}
+ volumes:
+ - ${FUNKWHALE_DATA_DIR:-~/.crow/funkwhale}/data:/srv/funkwhale/data
+ - ${FUNKWHALE_MUSIC_DIR:-~/.crow/funkwhale/music}:/music:ro
+ command: >
+ celery -A funkwhale_api.taskapp worker -l INFO --concurrency=${FUNKWHALE_CELERYD_CONCURRENCY:-2}
+ init: true
+ mem_limit: 768m
+ restart: unless-stopped
+
+ celerybeat:
+ image: funkwhale/funkwhale:1.4
+ container_name: crow-funkwhale-celerybeat
+ networks:
+ - default
+ depends_on:
+ api:
+ condition: service_healthy
+ environment:
+ FUNKWHALE_HOSTNAME: ${FUNKWHALE_HOSTNAME}
+ DJANGO_SECRET_KEY: ${FUNKWHALE_DJANGO_SECRET_KEY}
+ DATABASE_URL: postgresql://funkwhale:${FUNKWHALE_POSTGRES_PASSWORD}@postgres:5432/funkwhale
+ CACHE_URL: redis://redis:6379/0
+ CELERY_BROKER_URL: redis://redis:6379/0
+ C_FORCE_ROOT: "true"
+ volumes:
+ - ${FUNKWHALE_DATA_DIR:-~/.crow/funkwhale}/data:/srv/funkwhale/data
+ command: >
+ celery -A funkwhale_api.taskapp beat -l INFO
+ --schedule=/srv/funkwhale/data/celerybeat-schedule
+ init: true
+ mem_limit: 256m
+ restart: unless-stopped
+
+ nginx:
+ image: funkwhale/nginx:1.4
+ container_name: crow-funkwhale-nginx
+ networks:
+ - default
+ - crow-federation
+ depends_on:
+ api:
+ condition: service_healthy
+ environment:
+ FUNKWHALE_API_IP: api
+ FUNKWHALE_API_PORT: "5000"
+ FUNKWHALE_HOSTNAME: ${FUNKWHALE_HOSTNAME}
+ FUNKWHALE_PROTOCOL: https
+ NGINX_MAX_BODY_SIZE: ${FUNKWHALE_NGINX_MAX_BODY_SIZE:-100M}
+ volumes:
+ - ${FUNKWHALE_DATA_DIR:-~/.crow/funkwhale}/data:/srv/funkwhale/data:ro
+ - ${FUNKWHALE_MUSIC_DIR:-~/.crow/funkwhale/music}:/music:ro
+ init: true
+ mem_limit: 128m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/v1/instance/nodeinfo/2.0/ >/dev/null 2>&1 || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 60s
diff --git a/bundles/funkwhale/manifest.json b/bundles/funkwhale/manifest.json
new file mode 100644
index 0000000..b6a9b47
--- /dev/null
+++ b/bundles/funkwhale/manifest.json
@@ -0,0 +1,48 @@
+{
+ "id": "funkwhale",
+ "name": "Funkwhale",
+ "version": "1.0.0",
+ "description": "Federated music server — self-hosted audio library + podcast streaming + fediverse-federated listening over ActivityPub. Upload your own library; follow remote channels and artists across the fediverse.",
+ "type": "bundle",
+ "author": "Crow",
+ "category": "federated-media",
+ "tags": ["music", "funkwhale", "activitypub", "fediverse", "federated", "audio", "podcasts"],
+ "icon": "music",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["FUNKWHALE_URL", "FUNKWHALE_ACCESS_TOKEN", "FUNKWHALE_HOSTNAME"]
+ },
+ "panel": "panel/funkwhale.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/funkwhale.md"],
+ "consent_required": true,
+ "install_consent_messages": {
+ "en": "Funkwhale joins the public fediverse over ActivityPub — your Funkwhale instance becomes addressable at the domain you configure, any library/channel you make public is discoverable and followable by remote Mastodon/GoToSocial/Pixelfed users, and published audio can be replicated to federated servers and cannot be fully recalled. Funkwhale stores your music library in Postgres metadata + on-disk (or S3) audio files; a modest library (1000 tracks) consumes 5-20 GB depending on format and bitrate. If you enable federation with remote pods, their library metadata is cached locally — this can grow to hundreds of MB. Funkwhale is hardware-gated: refused on hosts with <1.5 GB effective RAM after committed bundles; warns below 8 GB total host RAM. Uploading copyrighted material you don't have rights to is your legal responsibility — major fediverse hubs may defederate servers that become known for piracy.",
+ "es": "Funkwhale se une al fediverso público vía ActivityPub — tu instancia de Funkwhale será direccionable en el dominio que configures, cualquier biblioteca/canal que hagas público será descubrible y seguible por usuarios remotos de Mastodon/GoToSocial/Pixelfed, y el audio publicado puede replicarse a servidores federados y no puede recuperarse completamente. Funkwhale almacena tu biblioteca musical como metadatos en Postgres + archivos de audio en disco (o S3); una biblioteca modesta (1000 pistas) consume 5-20 GB según formato y bitrate. Si habilitas la federación con pods remotos, sus metadatos se cachean localmente — esto puede crecer a cientos de MB. Funkwhale está limitado por hardware: rechazado en hosts con <1.5 GB de RAM efectiva tras los paquetes comprometidos; advierte por debajo de 8 GB de RAM total. Subir material con copyright del que no tienes derechos es tu responsabilidad legal — los principales hubs del fediverso pueden dejar de federarse con servidores conocidos por piratería."
+ },
+ "requires": {
+ "env": ["FUNKWHALE_HOSTNAME", "FUNKWHALE_POSTGRES_PASSWORD", "FUNKWHALE_DJANGO_SECRET_KEY"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 1500,
+ "recommended_ram_mb": 3000,
+ "min_disk_mb": 10000,
+ "recommended_disk_mb": 100000
+ },
+ "env_vars": [
+ { "name": "FUNKWHALE_HOSTNAME", "description": "Public domain (e.g. music.example.com). Must be a subdomain; path-mounts break ActivityPub actor URLs.", "required": true },
+ { "name": "FUNKWHALE_POSTGRES_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
+ { "name": "FUNKWHALE_DJANGO_SECRET_KEY", "description": "Django secret key (use 64+ random chars). Changing this invalidates existing sessions.", "required": true, "secret": true },
+ { "name": "FUNKWHALE_URL", "description": "Internal URL the Crow MCP server uses to reach Funkwhale's API (over the crow-federation docker network).", "default": "http://funkwhale-api:5000", "required": false },
+ { "name": "FUNKWHALE_ACCESS_TOKEN", "description": "OAuth2 / PAT access token for the admin account (create via Settings → Applications in the web UI, or POST /api/v1/oauth/apps + token exchange).", "required": false, "secret": true },
+ { "name": "FUNKWHALE_S3_ENDPOINT", "description": "Optional S3-compatible endpoint for audio storage (defaults to on-disk). If set with FUNKWHALE_S3_BUCKET/ACCESS/SECRET, audio files go to S3 via the storage-translators mapping.", "required": false },
+ { "name": "FUNKWHALE_S3_BUCKET", "description": "S3 bucket name for audio storage.", "required": false },
+ { "name": "FUNKWHALE_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true },
+ { "name": "FUNKWHALE_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true },
+ { "name": "FUNKWHALE_S3_REGION", "description": "S3 region (default us-east-1).", "default": "us-east-1", "required": false }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Five containers (api + celeryworker + celerybeat + nginx + postgres + redis). No host port publish — expose via caddy_add_federation_site { domain: FUNKWHALE_HOSTNAME, upstream: 'funkwhale-nginx:80', profile: 'activitypub' }. Initial superuser created via `docker exec crow-funkwhale-api funkwhale-manage createsuperuser`. Audio storage defaults to on-disk; set FUNKWHALE_S3_* to wire MinIO/external S3 via the storage-translators funkwhale() mapping."
+}
diff --git a/bundles/funkwhale/package.json b/bundles/funkwhale/package.json
new file mode 100644
index 0000000..d91ce50
--- /dev/null
+++ b/bundles/funkwhale/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "crow-funkwhale",
+ "version": "1.0.0",
+ "description": "Funkwhale (federated music server) MCP server — library, upload, search, follow, playlists, moderation",
+ "type": "module",
+ "main": "server/index.js",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.0",
+ "zod": "^3.24.0"
+ }
+}
diff --git a/bundles/funkwhale/panel/funkwhale.js b/bundles/funkwhale/panel/funkwhale.js
new file mode 100644
index 0000000..c6311b7
--- /dev/null
+++ b/bundles/funkwhale/panel/funkwhale.js
@@ -0,0 +1,150 @@
+/**
+ * Crow's Nest Panel — Funkwhale: pod status + libraries + recent listens.
+ * XSS-safe (textContent / createElement only).
+ */
+
+export default {
+ id: "funkwhale",
+ name: "Funkwhale",
+ icon: "music",
+ route: "/dashboard/funkwhale",
+ navOrder: 74,
+ category: "federated-media",
+
+ async handler(req, res, { layout }) {
+ const content = `
+
+
+
Funkwhale federated music pod
+
+
+
+
+
+
+
+
+
Notes
+
+ - Federation is over ActivityPub — your channels appear to remote Mastodon/Pixelfed followers as regular fediverse actors.
+ - Uploading copyrighted material you don't own is your legal responsibility. Major hubs may defederate pods known for piracy.
+ - Cache growth: federated audio caches prune on a celerybeat schedule (default 14 days). Manual:
fw_media_prune.
+
+
+
+
+ `;
+ res.send(layout({ title: "Funkwhale", content }));
+ },
+};
+
+function script() {
+ return `
+ function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
+ function row(label, value) {
+ const r = document.createElement('div'); r.className = 'fw-row';
+ const b = document.createElement('b'); b.textContent = label;
+ const s = document.createElement('span'); s.textContent = value == null ? '—' : String(value);
+ r.appendChild(b); r.appendChild(s); return r;
+ }
+ function err(msg) { const d = document.createElement('div'); d.className = 'np-error'; d.textContent = msg; return d; }
+
+ async function loadStatus() {
+ const el = document.getElementById('fw-status'); clear(el);
+ try {
+ const res = await fetch('/api/funkwhale/status'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ const card = document.createElement('div'); card.className = 'fw-card';
+ card.appendChild(row('Hostname', d.hostname || '(unset)'));
+ card.appendChild(row('Software', (d.software || 'funkwhale') + ' ' + (d.version || '?')));
+ card.appendChild(row('Federation', d.federation_enabled ? 'enabled' : 'disabled'));
+ card.appendChild(row('Users', d.usage_users?.total ?? '—'));
+ card.appendChild(row('Authenticated', d.whoami ? d.whoami.username + (d.whoami.is_superuser ? ' (admin)' : '') : '(no token)'));
+ el.appendChild(card);
+ } catch (e) { el.appendChild(err('Cannot reach Funkwhale.')); }
+ }
+
+ async function loadLibraries() {
+ const el = document.getElementById('fw-libs'); clear(el);
+ try {
+ const res = await fetch('/api/funkwhale/libraries'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ if (!d.libraries || d.libraries.length === 0) {
+ const i = document.createElement('div'); i.className = 'np-idle';
+ i.textContent = 'No owned libraries yet. Create one in Settings → Content → Libraries.';
+ el.appendChild(i); return;
+ }
+ for (const l of d.libraries) {
+ const c = document.createElement('div'); c.className = 'fw-lib';
+ const t = document.createElement('b'); t.textContent = l.name || '(unnamed)';
+ c.appendChild(t);
+ const meta = document.createElement('div'); meta.className = 'fw-lib-meta';
+ meta.textContent = (l.uploads_count || 0) + ' tracks · ' + (l.privacy_level || 'private');
+ c.appendChild(meta);
+ el.appendChild(c);
+ }
+ } catch (e) { el.appendChild(err('Cannot load libraries: ' + e.message)); }
+ }
+
+ async function loadListens() {
+ const el = document.getElementById('fw-listens'); clear(el);
+ try {
+ const res = await fetch('/api/funkwhale/listens'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ if (!d.listens || d.listens.length === 0) {
+ const i = document.createElement('div'); i.className = 'np-idle';
+ i.textContent = 'No recent listens.';
+ el.appendChild(i); return;
+ }
+ for (const l of d.listens) {
+ const c = document.createElement('div'); c.className = 'fw-listen';
+ const t = document.createElement('b'); t.textContent = l.track_title || '(unknown)';
+ c.appendChild(t);
+ const meta = document.createElement('div'); meta.className = 'fw-listen-meta';
+ meta.textContent = (l.artist || 'unknown artist') + (l.album ? ' — ' + l.album : '');
+ c.appendChild(meta);
+ el.appendChild(c);
+ }
+ } catch (e) { el.appendChild(err('Cannot load listens: ' + e.message)); }
+ }
+
+ loadStatus();
+ loadLibraries();
+ loadListens();
+ `;
+}
+
+function styles() {
+ return `
+ .fw-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; }
+ .fw-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; }
+ .fw-section { margin-bottom: 1.8rem; }
+ .fw-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase;
+ letter-spacing: 0.05em; margin: 0 0 0.7rem; }
+ .fw-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 10px; padding: 1rem; }
+ .fw-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); }
+ .fw-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; }
+ .fw-lib, .fw-listen { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; }
+ .fw-lib b, .fw-listen b { color: var(--crow-text-primary); font-size: .9rem; }
+ .fw-lib-meta, .fw-listen-meta { font-size: .8rem; color: var(--crow-text-muted); margin-top: .2rem; }
+ .fw-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; }
+ .fw-notes li { margin-bottom: .3rem; }
+ .fw-notes code { font-family: ui-monospace, monospace; background: var(--crow-bg);
+ padding: 1px 4px; border-radius: 3px; font-size: .8em; }
+ .np-idle, .np-loading { color: var(--crow-text-muted); font-size: 0.9rem; padding: 1rem;
+ background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
+ .np-error { color: #ef4444; font-size: 0.9rem; padding: 1rem;
+ background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
+ `;
+}
diff --git a/bundles/funkwhale/panel/routes.js b/bundles/funkwhale/panel/routes.js
new file mode 100644
index 0000000..646ce12
--- /dev/null
+++ b/bundles/funkwhale/panel/routes.js
@@ -0,0 +1,90 @@
+/**
+ * Funkwhale panel API routes — status, libraries, recent listens.
+ */
+
+import { Router } from "express";
+
+const URL_BASE = () => (process.env.FUNKWHALE_URL || "http://funkwhale-api:5000").replace(/\/+$/, "");
+const TOKEN = () => process.env.FUNKWHALE_ACCESS_TOKEN || "";
+const HOSTNAME = () => process.env.FUNKWHALE_HOSTNAME || "";
+const TIMEOUT = 15_000;
+
+async function fw(path, { noAuth, query } = {}) {
+ const ctl = new AbortController();
+ const t = setTimeout(() => ctl.abort(), TIMEOUT);
+ try {
+ const qs = query
+ ? "?" +
+ Object.entries(query)
+ .filter(([, v]) => v != null && v !== "")
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+ .join("&")
+ : "";
+ const headers = {};
+ if (!noAuth && TOKEN()) headers.Authorization = `Bearer ${TOKEN()}`;
+ const r = await fetch(`${URL_BASE()}${path}${qs}`, { signal: ctl.signal, headers });
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
+ const text = await r.text();
+ return text ? JSON.parse(text) : {};
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+export default function funkwhaleRouter(authMiddleware) {
+ const router = Router();
+
+ router.get("/api/funkwhale/status", authMiddleware, async (_req, res) => {
+ try {
+ const nodeinfo = await fw("/api/v1/instance/nodeinfo/2.0/", { noAuth: true }).catch(() => null);
+ const whoami = TOKEN() ? await fw("/api/v1/users/me/").catch(() => null) : null;
+ res.json({
+ hostname: HOSTNAME(),
+ software: nodeinfo?.software?.name || null,
+ version: nodeinfo?.software?.version || null,
+ federation_enabled: nodeinfo?.metadata?.federation?.enabled ?? null,
+ usage_users: nodeinfo?.usage?.users || null,
+ whoami: whoami ? { username: whoami.username, is_superuser: whoami.is_superuser } : null,
+ });
+ } catch (err) {
+ res.json({ error: `Cannot reach Funkwhale: ${err.message}` });
+ }
+ });
+
+ router.get("/api/funkwhale/libraries", authMiddleware, async (_req, res) => {
+ try {
+ if (!TOKEN()) return res.json({ error: "FUNKWHALE_ACCESS_TOKEN not set" });
+ const out = await fw("/api/v1/libraries/", { query: { scope: "me", page_size: 20 } });
+ res.json({
+ count: out.count,
+ libraries: (out.results || []).map((l) => ({
+ uuid: l.uuid,
+ name: l.name,
+ uploads_count: l.uploads_count,
+ privacy_level: l.privacy_level,
+ })),
+ });
+ } catch (err) {
+ res.json({ error: err.message });
+ }
+ });
+
+ router.get("/api/funkwhale/listens", authMiddleware, async (_req, res) => {
+ try {
+ if (!TOKEN()) return res.json({ error: "FUNKWHALE_ACCESS_TOKEN not set" });
+ const out = await fw("/api/v1/history/listenings/", { query: { page_size: 10, ordering: "-creation_date" } });
+ res.json({
+ listens: (out.results || []).map((l) => ({
+ ts: l.creation_date,
+ track_title: l.track?.title,
+ artist: l.track?.artist?.name,
+ album: l.track?.album?.title,
+ })),
+ });
+ } catch (err) {
+ res.json({ error: err.message });
+ }
+ });
+
+ return router;
+}
diff --git a/bundles/funkwhale/scripts/backup.sh b/bundles/funkwhale/scripts/backup.sh
new file mode 100755
index 0000000..9f24a98
--- /dev/null
+++ b/bundles/funkwhale/scripts/backup.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# Funkwhale backup: pg_dump + media (on-disk only) + data dir (secret key,
+# cached federation state, celerybeat schedule).
+#
+# S3-backed audio is NOT captured here — the operator's S3 provider
+# handles durability for those files. Only the metadata / on-disk audio
+# is in scope of this bundle.
+set -euo pipefail
+
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/funkwhale"
+DATA_DIR="${FUNKWHALE_DATA_DIR:-$HOME/.crow/funkwhale}"
+
+mkdir -p "$BACKUP_ROOT"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+# Postgres dump
+if docker ps --format '{{.Names}}' | grep -qw crow-funkwhale-postgres; then
+ docker exec -e PGPASSWORD="${FUNKWHALE_POSTGRES_PASSWORD:-}" crow-funkwhale-postgres \
+ pg_dump -U funkwhale -Fc -f /tmp/funkwhale-${STAMP}.pgcustom funkwhale
+ docker cp "crow-funkwhale-postgres:/tmp/funkwhale-${STAMP}.pgcustom" "$WORK/funkwhale.pgcustom"
+ docker exec crow-funkwhale-postgres rm "/tmp/funkwhale-${STAMP}.pgcustom"
+fi
+
+# Data dir (media/, static/, celerybeat schedule) — skip transcodes (regenerable)
+tar -C "$DATA_DIR/data" \
+ --exclude='./media/__cache__' \
+ --exclude='./static' \
+ -cf "$WORK/funkwhale-data.tar" . 2>/dev/null || true
+
+OUT="${BACKUP_ROOT}/funkwhale-${STAMP}.tar.zst"
+if command -v zstd >/dev/null 2>&1; then
+ tar -C "$WORK" -cf - . | zstd -T0 -19 -o "$OUT"
+else
+ OUT="${BACKUP_ROOT}/funkwhale-${STAMP}.tar.gz"
+ tar -C "$WORK" -czf "$OUT" .
+fi
+echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
+echo "NOTE: S3-backed audio (if configured) is NOT in this archive."
+echo " The Django secret key is — keep this backup encrypted."
diff --git a/bundles/funkwhale/scripts/configure-storage.mjs b/bundles/funkwhale/scripts/configure-storage.mjs
new file mode 100755
index 0000000..fc15c3c
--- /dev/null
+++ b/bundles/funkwhale/scripts/configure-storage.mjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+/**
+ * Funkwhale storage wiring.
+ *
+ * Reads FUNKWHALE_S3_* from the bundle's .env, runs F.0's
+ * storage-translators.funkwhale() to get Funkwhale's AWS_* schema, and
+ * appends the translated vars to the .env file so the compose stack picks
+ * them up on the next `up`.
+ *
+ * If FUNKWHALE_S3_ENDPOINT is not set, exits 0 (on-disk storage — no-op).
+ *
+ * Invoked by scripts/post-install.sh. Safe to re-run (writes a managed
+ * block delimited by `# crow-funkwhale-storage BEGIN` / `END`).
+ */
+
+import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
+import { join, dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ENV_PATH = resolve(__dirname, "..", ".env");
+
+function parseEnv(text) {
+ const out = {};
+ for (const line of text.split("\n")) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+ if (m) out[m[1]] = m[2].replace(/^"|"$/g, "");
+ }
+ return out;
+}
+
+function loadEnv() {
+ if (!existsSync(ENV_PATH)) return {};
+ return parseEnv(readFileSync(ENV_PATH, "utf8"));
+}
+
+async function main() {
+ const env = loadEnv();
+ const endpoint = env.FUNKWHALE_S3_ENDPOINT;
+ const bucket = env.FUNKWHALE_S3_BUCKET;
+ const accessKey = env.FUNKWHALE_S3_ACCESS_KEY;
+ const secretKey = env.FUNKWHALE_S3_SECRET_KEY;
+ const region = env.FUNKWHALE_S3_REGION || "us-east-1";
+
+ if (!endpoint) {
+ console.log("[configure-storage] FUNKWHALE_S3_ENDPOINT not set — using on-disk storage.");
+ return;
+ }
+ if (!bucket || !accessKey || !secretKey) {
+ console.error("[configure-storage] FUNKWHALE_S3_ENDPOINT is set but bucket/access/secret are missing — refusing partial config.");
+ process.exit(1);
+ }
+
+ let translate;
+ try {
+ const mod = await import(resolve(__dirname, "..", "..", "..", "servers", "gateway", "storage-translators.js"));
+ translate = mod.translate;
+ } catch (err) {
+ console.error(`[configure-storage] Cannot load storage-translators.js (monorepo helper). In installed-mode this is expected; falling back to direct mapping.`);
+ translate = (_, crow) => ({
+ AWS_ACCESS_KEY_ID: crow.accessKey,
+ AWS_SECRET_ACCESS_KEY: crow.secretKey,
+ AWS_STORAGE_BUCKET_NAME: crow.bucket,
+ AWS_S3_ENDPOINT_URL: crow.endpoint,
+ AWS_S3_REGION_NAME: crow.region || "us-east-1",
+ AWS_LOCATION: "",
+ AWS_QUERYSTRING_AUTH: "true",
+ AWS_QUERYSTRING_EXPIRE: "3600",
+ });
+ }
+
+ const mapped = translate("funkwhale", { endpoint, bucket, accessKey, secretKey, region });
+
+ const BEGIN = "# crow-funkwhale-storage BEGIN (managed by scripts/configure-storage.mjs — do not edit)";
+ const END = "# crow-funkwhale-storage END";
+ const block = [BEGIN, ...Object.entries(mapped).map(([k, v]) => `${k}=${v}`), END, ""].join("\n");
+
+ let cur = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, "utf8") : "";
+ if (cur.includes(BEGIN)) {
+ cur = cur.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}\\n?`), "");
+ }
+ if (cur.length && !cur.endsWith("\n")) cur += "\n";
+ writeFileSync(ENV_PATH, cur + block);
+ console.log(`[configure-storage] Wrote ${Object.keys(mapped).length} translated S3 env vars to ${ENV_PATH}.`);
+ console.log("[configure-storage] Restart the compose stack so api + celeryworker pick up the new vars:");
+ console.log(" docker compose -f bundles/funkwhale/docker-compose.yml up -d --force-recreate");
+}
+
+main().catch((err) => {
+ console.error(`[configure-storage] Failed: ${err.message}`);
+ process.exit(1);
+});
diff --git a/bundles/funkwhale/scripts/post-install.sh b/bundles/funkwhale/scripts/post-install.sh
new file mode 100755
index 0000000..415b3a8
--- /dev/null
+++ b/bundles/funkwhale/scripts/post-install.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+# Funkwhale post-install hook.
+#
+# 1. Wait for crow-funkwhale-api to report healthy (first boot runs Django
+# migrations + collectstatic — can take 2+ minutes on cold disks).
+# 2. Optionally translate FUNKWHALE_S3_* into AWS_* if S3 storage was
+# configured at install time.
+# 3. Verify the crow-federation network is attached to the nginx container
+# (Caddy reverse-proxies to funkwhale-nginx:80).
+# 4. Print next-step guidance (superuser creation, Caddy site, token).
+
+set -euo pipefail
+
+BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+ENV_FILE="${BUNDLE_DIR}/.env"
+if [ -f "$ENV_FILE" ]; then
+ set -a; . "$ENV_FILE"; set +a
+fi
+
+echo "Waiting for Funkwhale API to report healthy (up to 180s)…"
+for i in $(seq 1 36); do
+ if docker inspect crow-funkwhale-api --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then
+ echo " → healthy"
+ break
+ fi
+ sleep 5
+done
+
+# Translate S3 vars if configured
+if [ -n "${FUNKWHALE_S3_ENDPOINT:-}" ]; then
+ echo "FUNKWHALE_S3_ENDPOINT detected — translating to AWS_* schema via storage-translators…"
+ if command -v node >/dev/null 2>&1; then
+ node "${BUNDLE_DIR}/scripts/configure-storage.mjs" || {
+ echo "WARN: configure-storage.mjs failed; audio uploads will fall back to on-disk until S3 env vars are written manually." >&2
+ }
+ else
+ echo "WARN: node not available on PATH — cannot run configure-storage.mjs. S3 not wired." >&2
+ fi
+fi
+
+# Verify federation network
+if ! docker inspect crow-funkwhale-nginx --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw crow-federation; then
+ echo "WARN: crow-funkwhale-nginx is not on the crow-federation network — Caddy federation sites will not reach it by service name" >&2
+fi
+
+cat < (_toolId, handler) => handler;
+ }
+ try {
+ const db = await import("../../../servers/db.js");
+ getDb = db.createDbClient;
+ } catch {
+ getDb = null;
+ }
+ try {
+ const notif = await import("../../../servers/shared/notifications.js");
+ createNotification = notif.createNotification;
+ } catch {
+ createNotification = null;
+ }
+}
+
+// --- HTTP helper ---
+
+async function fwFetch(path, { method = "GET", body, query, noAuth, timeoutMs = 20_000, rawForm } = {}) {
+ const qs = query
+ ? "?" +
+ Object.entries(query)
+ .filter(([, v]) => v != null && v !== "")
+ .map(([k, v]) =>
+ Array.isArray(v)
+ ? v.map((x) => `${encodeURIComponent(k)}=${encodeURIComponent(x)}`).join("&")
+ : `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
+ )
+ .join("&")
+ : "";
+ const url = `${FUNKWHALE_URL}${path}${qs}`;
+ const headers = {};
+ if (!noAuth && FUNKWHALE_ACCESS_TOKEN) {
+ headers.Authorization = `Bearer ${FUNKWHALE_ACCESS_TOKEN}`;
+ }
+ let payload;
+ if (rawForm) {
+ payload = rawForm; // FormData
+ } else if (body) {
+ headers["Content-Type"] = "application/json";
+ payload = JSON.stringify(body);
+ }
+ const ctl = new AbortController();
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
+ try {
+ const res = await fetch(url, { method, headers, body: payload, signal: ctl.signal });
+ const text = await res.text();
+ if (!res.ok) {
+ const snippet = text.slice(0, 600);
+ if (res.status === 401) throw new Error("Funkwhale auth failed (401). Create a PAT in Settings → Applications, paste into FUNKWHALE_ACCESS_TOKEN.");
+ if (res.status === 403) throw new Error(`Funkwhale forbidden (403)${snippet ? ": " + snippet : ""}`);
+ throw new Error(`Funkwhale ${res.status} ${res.statusText}${snippet ? " — " + snippet : ""}`);
+ }
+ if (!text) return {};
+ try { return JSON.parse(text); } catch { return { raw: text }; }
+ } catch (err) {
+ if (err.name === "AbortError") throw new Error(`Funkwhale request timed out: ${path}`);
+ if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) {
+ throw new Error(`Cannot reach Funkwhale at ${FUNKWHALE_URL}. Verify crow-funkwhale-api is up and on the crow-federation network.`);
+ }
+ throw err;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function requireAuth() {
+ if (!FUNKWHALE_ACCESS_TOKEN) {
+ return { content: [{ type: "text", text: "Error: FUNKWHALE_ACCESS_TOKEN required. Generate a Personal Access Token from Settings → Applications in the Funkwhale web UI." }] };
+ }
+ return null;
+}
+
+/**
+ * Queue a destructive moderation action. See bundles/gotosocial for full
+ * rationale. Returns `{ status, action_id?, expires_at? }`.
+ */
+async function queueModerationAction(bundle, actionType, payload) {
+ if (!getDb) {
+ return {
+ status: "queued_offline",
+ reason: "Crow database not reachable from bundle — moderation queue unavailable. Action NOT applied.",
+ requested: { action_type: actionType, payload },
+ };
+ }
+ const db = getDb();
+ try {
+ const now = Math.floor(Date.now() / 1000);
+ const expiresAt = now + 72 * 3600;
+ const payloadJson = JSON.stringify(payload);
+ const { createHash } = await import("node:crypto");
+ const idempotencyKey = createHash("sha256").update(`${bundle}:${actionType}:${payloadJson}`).digest("hex");
+
+ const existing = await db.execute({
+ sql: "SELECT id, expires_at, status FROM moderation_actions WHERE idempotency_key = ?",
+ args: [idempotencyKey],
+ });
+ if (existing.rows.length > 0) {
+ return {
+ status: "queued_duplicate",
+ action_id: Number(existing.rows[0].id),
+ previous_status: existing.rows[0].status,
+ };
+ }
+
+ const inserted = await db.execute({
+ sql: `INSERT INTO moderation_actions
+ (bundle_id, action_type, payload_json, requested_by,
+ requested_at, expires_at, status, idempotency_key)
+ VALUES (?, ?, ?, 'ai', ?, ?, 'pending', ?)
+ RETURNING id`,
+ args: [bundle, actionType, payloadJson, now, expiresAt, idempotencyKey],
+ });
+ const actionId = Number(inserted.rows[0].id);
+
+ if (createNotification) {
+ try {
+ await createNotification(db, {
+ title: `${bundle} moderation action awaiting confirmation`,
+ body: `${actionType} — review and confirm in the Nest panel before ${new Date(expiresAt * 1000).toLocaleString()}`,
+ type: "system",
+ source: bundle,
+ priority: "high",
+ action_url: `/dashboard/${bundle}?action=${actionId}`,
+ });
+ } catch {}
+ }
+
+ return { status: "queued", action_id: actionId, expires_at: expiresAt };
+ } catch (err) {
+ if (/no such table.*moderation_actions/i.test(err.message)) {
+ return {
+ status: "queued_unavailable",
+ reason: "moderation_actions table not present — queued action could not be persisted. Lands with F.11.",
+ };
+ }
+ throw err;
+ } finally {
+ try { db.close(); } catch {}
+ }
+}
+
+function textResponse(obj) {
+ return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
+}
+
+function errResponse(err) {
+ return { content: [{ type: "text", text: `Error: ${err.message || String(err)}` }] };
+}
+
+export async function createFunkwhaleServer(options = {}) {
+ await loadSharedDeps();
+
+ const server = new McpServer(
+ { name: "crow-funkwhale", version: "1.0.0" },
+ { instructions: options.instructions },
+ );
+
+ const limiter = wrapRateLimited ? wrapRateLimited({ db: getDb ? getDb() : null }) : (_, h) => h;
+
+ // --- fw_status ---
+ server.tool(
+ "fw_status",
+ "Report Funkwhale pod status: reachability, version, federation mode, instance policy counts, auth whoami.",
+ {},
+ async () => {
+ try {
+ const [nodeinfo, whoami, policies] = await Promise.all([
+ fwFetch("/api/v1/instance/nodeinfo/2.0/", { noAuth: true }).catch(() => null),
+ FUNKWHALE_ACCESS_TOKEN ? fwFetch("/api/v1/users/me/").catch(() => null) : Promise.resolve(null),
+ FUNKWHALE_ACCESS_TOKEN ? fwFetch("/api/v1/manage/moderation/instance-policies/", { query: { page_size: 1 } }).catch(() => null) : Promise.resolve(null),
+ ]);
+ return textResponse({
+ hostname: FUNKWHALE_HOSTNAME || null,
+ url: FUNKWHALE_URL,
+ version: nodeinfo?.software?.version || null,
+ software: nodeinfo?.software?.name || null,
+ open_registrations: nodeinfo?.openRegistrations ?? null,
+ federation_enabled: nodeinfo?.metadata?.federation?.enabled ?? null,
+ usage_users: nodeinfo?.usage?.users || null,
+ whoami: whoami ? { username: whoami.username, is_superuser: whoami.is_superuser, id: whoami.id } : null,
+ instance_policies_total: policies?.count ?? null,
+ has_access_token: Boolean(FUNKWHALE_ACCESS_TOKEN),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ // --- fw_list_library ---
+ server.tool(
+ "fw_list_library",
+ "List the authenticated user's owned libraries (track + upload counts per library).",
+ {
+ page: z.number().int().min(1).max(1000).optional(),
+ page_size: z.number().int().min(1).max(100).optional(),
+ },
+ async ({ page, page_size }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const out = await fwFetch("/api/v1/libraries/", { query: { scope: "me", page, page_size } });
+ return textResponse({
+ count: out.count,
+ libraries: (out.results || []).map((l) => ({
+ uuid: l.uuid,
+ name: l.name,
+ privacy_level: l.privacy_level,
+ uploads_count: l.uploads_count,
+ size: l.size,
+ actor: l.actor?.full_username || null,
+ })),
+ next: out.next,
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ // --- fw_search ---
+ server.tool(
+ "fw_search",
+ "Search the local + cached federated catalog. Default scope 'tracks' searches track titles; pass type to search artists/albums/channels. Rate-limited: 60/hour.",
+ {
+ q: z.string().min(1).max(500),
+ type: z.enum(["tracks", "artists", "albums", "channels"]).optional(),
+ page_size: z.number().int().min(1).max(100).optional(),
+ },
+ limiter("fw_search", async ({ q, type, page_size }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const t = type || "tracks";
+ const out = await fwFetch(`/api/v1/${t}/`, { query: { q, page_size: page_size || 20 } });
+ const simplified = (out.results || []).map((item) => ({
+ id: item.id || item.uuid,
+ fid: item.fid || null,
+ name: item.title || item.name || item.artist?.name,
+ artist: item.artist?.name,
+ album: item.album?.title,
+ is_local: item.is_local,
+ }));
+ return textResponse({ count: out.count, type: t, results: simplified });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_upload_track ---
+ server.tool(
+ "fw_upload_track",
+ "Upload an audio file to a library. Pass `file_path` (absolute path readable from this process) OR `file_base64` + `filename`. Rate-limited: 10/hour. Legal note: you must hold the rights — copyright violations can trigger defederation.",
+ {
+ library_uuid: z.string().uuid(),
+ file_path: z.string().max(4096).optional(),
+ file_base64: z.string().max(200_000_000).optional(),
+ filename: z.string().max(500).optional(),
+ import_reference: z.string().max(200).optional(),
+ },
+ limiter("fw_upload_track", async ({ library_uuid, file_path, file_base64, filename, import_reference }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ let buf;
+ let name;
+ if (file_path) {
+ buf = await readFile(file_path);
+ name = filename || basename(file_path);
+ } else if (file_base64) {
+ buf = Buffer.from(file_base64, "base64");
+ name = filename || `upload-${Date.now()}.bin`;
+ } else {
+ return { content: [{ type: "text", text: "Error: must pass file_path or file_base64+filename." }] };
+ }
+ const form = new FormData();
+ form.append("library", library_uuid);
+ if (import_reference) form.append("import_reference", import_reference);
+ form.append("audio_file", new Blob([buf]), name);
+ const out = await fwFetch("/api/v1/uploads/", { method: "POST", rawForm: form, timeoutMs: 120_000 });
+ return textResponse({ uuid: out.uuid, filename: out.filename, import_status: out.import_status, size: out.size });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_follow ---
+ server.tool(
+ "fw_follow",
+ "Follow a library (by UUID) or a remote channel (by actor URL/handle @user@server). Rate-limited: 30/hour.",
+ {
+ target_type: z.enum(["library", "channel"]),
+ target: z.string().min(1).max(500),
+ },
+ limiter("fw_follow", async ({ target_type, target }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ if (target_type === "library") {
+ const out = await fwFetch("/api/v1/federation/follows/library/", { method: "POST", body: { target } });
+ return textResponse({ follow_uuid: out.uuid, approved: out.approved });
+ }
+ const subscribe = await fwFetch("/api/v1/subscriptions/", { method: "POST", body: { object: target } });
+ return textResponse({ subscription_uuid: subscribe.uuid, channel_id: subscribe.channel?.id });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_unfollow ---
+ server.tool(
+ "fw_unfollow",
+ "Remove a library follow or channel subscription. Rate-limited: 30/hour.",
+ {
+ target_type: z.enum(["library", "channel"]),
+ uuid: z.string().uuid(),
+ },
+ limiter("fw_unfollow", async ({ target_type, uuid }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const path = target_type === "library"
+ ? `/api/v1/federation/follows/library/${uuid}/`
+ : `/api/v1/subscriptions/${uuid}/`;
+ await fwFetch(path, { method: "DELETE" });
+ return textResponse({ unfollowed: uuid, target_type });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_playlists ---
+ server.tool(
+ "fw_playlists",
+ "List the authenticated user's playlists with track counts.",
+ {
+ page: z.number().int().min(1).max(1000).optional(),
+ page_size: z.number().int().min(1).max(100).optional(),
+ },
+ async ({ page, page_size }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const out = await fwFetch("/api/v1/playlists/", { query: { scope: "me", page, page_size } });
+ return textResponse({
+ count: out.count,
+ playlists: (out.results || []).map((p) => ({
+ id: p.id, name: p.name, tracks_count: p.tracks_count, privacy_level: p.privacy_level,
+ })),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ // --- fw_now_playing ---
+ server.tool(
+ "fw_now_playing",
+ "Most recent listening activity for the authenticated user (last N listens).",
+ {
+ limit: z.number().int().min(1).max(50).optional(),
+ },
+ async ({ limit }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const out = await fwFetch("/api/v1/history/listenings/", { query: { page_size: limit || 10, ordering: "-creation_date" } });
+ const listens = (out.results || []).map((l) => ({
+ ts: l.creation_date,
+ track_title: l.track?.title,
+ artist: l.track?.artist?.name,
+ album: l.track?.album?.title,
+ }));
+ return textResponse({ count: out.count, listens });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ // --- fw_block_user (inline, rate-limited) ---
+ server.tool(
+ "fw_block_user",
+ "Block a single user (by full actor handle @user@server). Inline; rate-limited: 5/hour.",
+ {
+ handle: z.string().min(3).max(500).describe("Full actor handle, e.g. @alice@remote.example"),
+ confirm: z.literal("yes"),
+ },
+ limiter("fw_block_user", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const out = await fwFetch("/api/v1/manage/moderation/instance-policies/", {
+ method: "POST",
+ body: { target: { type: "actor", full_username: handle.replace(/^@/, "") }, block_all: true, is_active: true },
+ });
+ return textResponse({ policy_id: out.id, target: handle, blocked: true });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_mute_user (inline, rate-limited) ---
+ server.tool(
+ "fw_mute_user",
+ "Mute a user (silence notifications + hide from feeds but keep federation). Inline; rate-limited: 5/hour.",
+ {
+ handle: z.string().min(3).max(500),
+ confirm: z.literal("yes"),
+ },
+ limiter("fw_mute_user", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const out = await fwFetch("/api/v1/manage/moderation/instance-policies/", {
+ method: "POST",
+ body: { target: { type: "actor", full_username: handle.replace(/^@/, "") }, silence_notifications: true, silence_activity: true, is_active: true },
+ });
+ return textResponse({ policy_id: out.id, target: handle, muted: true });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_block_domain (QUEUED) ---
+ server.tool(
+ "fw_block_domain",
+ "Instance-wide block of a remote domain (all actors/libraries from that domain become unreachable). QUEUED — does not apply until an operator confirms in the Nest panel within 72 hours.",
+ {
+ domain: z.string().min(3).max(253),
+ reason: z.string().max(1000).optional(),
+ confirm: z.literal("yes"),
+ },
+ limiter("fw_block_domain", async ({ domain, reason }) => {
+ try {
+ const queued = await queueModerationAction("funkwhale", "block_domain", { domain, reason: reason || "" });
+ return textResponse(queued);
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_defederate (QUEUED) ---
+ server.tool(
+ "fw_defederate",
+ "Full defederation: block domain + purge cached content. QUEUED — requires operator confirmation in the Nest panel.",
+ {
+ domain: z.string().min(3).max(253),
+ reason: z.string().max(1000).optional(),
+ confirm: z.literal("yes"),
+ },
+ limiter("fw_defederate", async ({ domain, reason }) => {
+ try {
+ const queued = await queueModerationAction("funkwhale", "defederate", { domain, reason: reason || "" });
+ return textResponse(queued);
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- fw_media_prune ---
+ server.tool(
+ "fw_media_prune",
+ "Manually trigger a prune of cached remote audio files older than N days. Default retention is 14 days (7 days on Pi-class hosts). Rate-limited: 2/hour.",
+ {
+ older_than_days: z.number().int().min(1).max(365).optional(),
+ confirm: z.literal("yes"),
+ },
+ limiter("fw_media_prune", async ({ older_than_days }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const days = older_than_days ?? 14;
+ const out = await fwFetch("/api/v1/manage/library/uploads/action/", {
+ method: "POST",
+ body: { action: "prune", objects: "all", filters: { privacy_level__in: ["public"], is_local: false, older_than_days: days } },
+ });
+ return textResponse({ requested_days: days, deleted: out.updated ?? out.deleted ?? null, raw: out });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ return server;
+}
diff --git a/bundles/funkwhale/skills/funkwhale.md b/bundles/funkwhale/skills/funkwhale.md
new file mode 100644
index 0000000..cc28f0c
--- /dev/null
+++ b/bundles/funkwhale/skills/funkwhale.md
@@ -0,0 +1,123 @@
+---
+name: funkwhale
+description: Funkwhale — federated music server. Library, upload, search, channels, playlists, moderation over ActivityPub.
+triggers:
+ - "funkwhale"
+ - "federated music"
+ - "music server"
+ - "upload track"
+ - "follow channel"
+ - "playlist"
+ - "fediverse audio"
+tools:
+ - fw_status
+ - fw_list_library
+ - fw_search
+ - fw_upload_track
+ - fw_follow
+ - fw_unfollow
+ - fw_playlists
+ - fw_now_playing
+ - fw_block_user
+ - fw_mute_user
+ - fw_block_domain
+ - fw_defederate
+ - fw_media_prune
+---
+
+# Funkwhale — federated music on ActivityPub
+
+Funkwhale is a self-hosted music + podcast server that federates over ActivityPub. Remote Mastodon/GoToSocial/Pixelfed users can follow your channels; your pod can subscribe to remote channels and libraries and keep local caches of the audio. The bundle runs six containers: api, celeryworker, celerybeat, an internal nginx (Funkwhale's file-server), postgres, redis.
+
+## Hardware
+
+Gated by F.0's hardware check. Refused below **1.5 GB effective RAM after committed bundles**, warned below 8 GB total. Disk grows with your library — expect 5-20 GB for 1000 tracks; federated caches add hundreds of MB. Celery workers and the Django API are the memory-hot paths.
+
+## Storage: on-disk or S3
+
+Default: audio files live in `~/.crow/funkwhale/data/media`. To route to MinIO or external S3, set these in `.env` before install:
+
+```
+FUNKWHALE_S3_ENDPOINT=https://minio.example.com
+FUNKWHALE_S3_BUCKET=funkwhale-audio
+FUNKWHALE_S3_ACCESS_KEY=...
+FUNKWHALE_S3_SECRET_KEY=...
+```
+
+`scripts/post-install.sh` detects these and runs `scripts/configure-storage.mjs`, which uses F.0's `storage-translators.funkwhale()` to write the `AWS_*` env vars Funkwhale actually reads. MinIO presence alone is not enough — you must also set the bucket + credentials because Funkwhale needs per-bundle isolation.
+
+## First-run bootstrap
+
+1. After install, Caddy exposes Funkwhale:
+ ```
+ caddy_add_federation_site {
+ domain: "music.example.com",
+ upstream: "funkwhale-nginx:80",
+ profile: "activitypub"
+ }
+ ```
+2. Create the superuser:
+ ```bash
+ docker exec -it crow-funkwhale-api funkwhale-manage createsuperuser
+ ```
+3. Open https://music.example.com/ and log in.
+4. Go to **Settings → Applications → New application** (grant all scopes), then create a **Personal Access Token**.
+5. Paste that token into `.env` as `FUNKWHALE_ACCESS_TOKEN`, then restart the MCP server (`crow bundle restart funkwhale`).
+
+## Common workflows
+
+### Upload a local file
+
+```
+fw_list_library {}
+# → grab a library UUID
+
+fw_upload_track {
+ "library_uuid": "3b2a…",
+ "file_path": "/home/kev/music/my-song.flac",
+ "import_reference": "my-own"
+}
+```
+
+Uploads go through Celery for tagging/transcoding — check status via the web UI's **Library → Uploads** list.
+
+### Search
+
+```
+fw_search { "q": "radiohead", "type": "artists" }
+fw_search { "q": "no surprises", "type": "tracks" }
+```
+
+Searches hit the local catalog + any federated content your pod has cached. Channel/library searches surface remote actors.
+
+### Follow a remote channel
+
+```
+fw_follow {
+ "target_type": "channel",
+ "target": "@label@music.remote-pod.example"
+}
+```
+
+For libraries, use the library UUID shown on the remote pod's library page. First federation fetch can take 30+ seconds while Celery pulls the library contents.
+
+### Moderation
+
+- **Inline (rate-limited, fires immediately):** `fw_block_user`, `fw_mute_user`
+- **Queued (operator must confirm in the Nest panel within 72h):** `fw_block_domain`, `fw_defederate`
+- **Manual prune:** `fw_media_prune { "older_than_days": 7, "confirm": "yes" }`
+
+Queued moderation is the plan's human-in-the-loop enforcement — the rate limiter + `confirm: "yes"` are advisory; the real gate is the operator clicking "Apply" in the Nest. For single-user/channel bans, inline is fine; for instance-wide blocks, the 72h review window is load-bearing.
+
+## Cross-app notes
+
+- **Blog cross-posting**: Funkwhale doesn't write long-form posts, but audio tracks can be embedded in WriteFreely or GoToSocial posts by pasting the track page URL (their ActivityPub OEmbed preview works).
+- **Sharing integration**: remote Funkwhale channels you follow appear as `contacts` with `external_source = 'funkwhale'` once F.11 identity attestation lands.
+
+## Troubleshooting
+
+- **"Cannot reach Funkwhale"** — `docker ps | grep crow-funkwhale`. First boot can take 2+ minutes while Django migrations run.
+- **Federation tester green but remote pods can't see your content** — verify `FUNKWHALE_HOSTNAME` matches the public domain exactly (case-sensitive). ActivityPub actor URLs use this hostname.
+- **Uploads fail with "413 Payload Too Large"** — bump `FUNKWHALE_NGINX_MAX_BODY_SIZE` in `.env` (default 100M) and restart the nginx container.
+- **Celery queue piling up** — `docker logs crow-funkwhale-celeryworker`. Large library imports can saturate the worker; increase `FUNKWHALE_CELERYD_CONCURRENCY` if you have CPU headroom.
+- **Disk filling** — federated cache. Run `fw_media_prune { older_than_days: 7, confirm: "yes" }` or lower the celerybeat prune schedule in the web UI's **Administration → Settings → Music**.
diff --git a/registry/add-ons.json b/registry/add-ons.json
index 64b48e7..91a2b1a 100644
--- a/registry/add-ons.json
+++ b/registry/add-ons.json
@@ -3150,6 +3150,49 @@
"webUI": null,
"notes": "Two containers (dendrite + postgres). Federation: pick EITHER caddy_add_matrix_federation_port (router forwards :8448) OR caddy_set_wellknown matrix-server on the apex. Initial admin registered via `docker exec crow-dendrite create-account`."
},
+ {
+ "id": "funkwhale",
+ "name": "Funkwhale",
+ "description": "Federated music server — self-hosted audio library + podcast streaming + fediverse-federated listening over ActivityPub. Follow remote channels/libraries, share your own over AP.",
+ "type": "bundle",
+ "version": "1.0.0",
+ "author": "Crow",
+ "category": "federated-media",
+ "tags": ["music", "funkwhale", "activitypub", "fediverse", "federated", "audio", "podcasts"],
+ "icon": "music",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["FUNKWHALE_URL", "FUNKWHALE_ACCESS_TOKEN", "FUNKWHALE_HOSTNAME"]
+ },
+ "panel": "panel/funkwhale.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/funkwhale.md"],
+ "consent_required": true,
+ "requires": {
+ "env": ["FUNKWHALE_HOSTNAME", "FUNKWHALE_POSTGRES_PASSWORD", "FUNKWHALE_DJANGO_SECRET_KEY"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 1500,
+ "recommended_ram_mb": 3000,
+ "min_disk_mb": 10000,
+ "recommended_disk_mb": 100000
+ },
+ "env_vars": [
+ { "name": "FUNKWHALE_HOSTNAME", "description": "Public domain for this Funkwhale pod (subdomain; path-mounts break ActivityPub).", "required": true },
+ { "name": "FUNKWHALE_POSTGRES_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
+ { "name": "FUNKWHALE_DJANGO_SECRET_KEY", "description": "Django secret key (64+ random chars). Changing this invalidates sessions.", "required": true, "secret": true },
+ { "name": "FUNKWHALE_ACCESS_TOKEN", "description": "Personal Access Token from Settings → Applications (after superuser creation).", "required": false, "secret": true },
+ { "name": "FUNKWHALE_S3_ENDPOINT", "description": "Optional S3-compatible endpoint for audio storage. If set, scripts/configure-storage.mjs translates to AWS_* via storage-translators.funkwhale().", "required": false },
+ { "name": "FUNKWHALE_S3_BUCKET", "description": "S3 bucket for audio.", "required": false },
+ { "name": "FUNKWHALE_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true },
+ { "name": "FUNKWHALE_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true },
+ { "name": "FUNKWHALE_S3_REGION", "description": "S3 region.", "default": "us-east-1", "required": false }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Six containers (api + celeryworker + celerybeat + nginx + postgres + redis). Expose via caddy_add_federation_site { domain: FUNKWHALE_HOSTNAME, upstream: 'funkwhale-nginx:80', profile: 'activitypub' }. Audio storage on-disk by default; set FUNKWHALE_S3_* to route to MinIO/external S3 via storage-translators."
+ },
{
"id": "developer-kit",
"name": "Developer Kit",
diff --git a/skills/superpowers.md b/skills/superpowers.md
index 0850f29..b37edb5 100644
--- a/skills/superpowers.md
+++ b/skills/superpowers.md
@@ -82,6 +82,7 @@ This is the master routing skill. Consult this **before every task** to determin
| "toot", "post to fediverse", "follow @user@...", "mastodon", "gotosocial", "activitypub" | "publicar en fediverso", "tootear", "seguir @usuario@...", "mastodon", "gotosocial" | gotosocial | crow-gotosocial |
| "writefreely", "federated blog", "long-form post", "publish article", "blog to fediverse" | "writefreely", "blog federado", "artículo largo", "publicar al fediverso" | writefreely | crow-writefreely |
| "matrix", "dendrite", "join #room:server", "send @user:server", "e2ee chat", "matrix room" | "matrix", "dendrite", "unirse a #sala:servidor", "mensaje a @usuario:servidor", "chat e2ee" | matrix-dendrite | crow-matrix-dendrite |
+| "funkwhale", "federated music", "upload track", "follow channel", "music library", "fediverse audio", "podcast", "playlist" | "funkwhale", "música federada", "subir pista", "seguir canal", "biblioteca musical", "audio fediverso", "podcast", "lista de reproducción" | funkwhale | crow-funkwhale |
| "tutor me", "teach me", "quiz me", "help me understand" | "enséñame", "explícame", "evalúame" | tutoring | crow-memory |
| "wrap up", "summarize session", "what did we do" | "resumir sesión", "qué hicimos" | session-summary | crow-memory |
| "change language", "speak in..." | "cambiar idioma", "háblame en..." | i18n | crow-memory |