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

+ +
+

Status

+
Loading…
+
+ +
+

Libraries

+
Loading…
+
+ +
+

Recent Listens

+
Loading…
+
+ +
+

Notes

+ +
+
+ + `; + 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 |