diff --git a/CLAUDE.md b/CLAUDE.md
index 86c9761..e90f7f2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -469,6 +469,7 @@ Add-on skills (activated when corresponding add-on is installed):
- `pixelfed.md` — Pixelfed federated photo-sharing: post photos (upload+status), feed, search, follow, moderation (block_user/mute inline; block_domain/defederate/import_blocklist queued), admin reports, remote reporting, media prune; Mastodon-compatible REST API; on-disk or S3 media via storage-translators.pixelfed()
- `lemmy.md` — Lemmy federated link aggregator: status, list/follow/unfollow communities, post (link + body), comment, feed (Subscribed/Local/All), search, moderation (block_user/block_community inline; block_instance/defederate queued), admin reports, pict-rs media prune; Lemmy v3 REST API; community-scoped federation
- `mastodon.md` — Mastodon federated microblog (flagship ActivityPub): status, post, post_with_media (async media upload), feed (home/public/local/notifications), search, follow/unfollow, moderation (block_user/mute inline; defederate/import_blocklist queued admin), admin reports, remote reporting, media prune (tootctl); Mastodon v1/v2 reference API; on-disk or S3 media via storage-translators.mastodon()
+- `peertube.md` — PeerTube federated video (YouTube-alt): status, list channels/videos, upload_video (multipart), search, subscribe/unsubscribe, rate_video, moderation (block_user inline; block_server/defederate queued admin), admin abuse reports with predefined_reasons taxonomy, remote reporting, media prune recipe; PeerTube v1 REST API; on-disk or S3 via storage-translators.peertube() (strongly recommend S3 — storage unbounded without it)
- `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/peertube/docker-compose.yml b/bundles/peertube/docker-compose.yml
new file mode 100644
index 0000000..ce5f29c
--- /dev/null
+++ b/bundles/peertube/docker-compose.yml
@@ -0,0 +1,131 @@
+# PeerTube — federated video platform.
+#
+# Three-container bundle: peertube (app + transcoding) + postgres + redis.
+# peertube on crow-federation; DB + redis isolated to default.
+#
+# Data:
+# ~/.crow/peertube/postgres/ Postgres data dir
+# ~/.crow/peertube/redis/ Redis persistence
+# ~/.crow/peertube/data/ /data: video originals + transcoded
+# variants + HLS playlists + thumbnails
+# (can grow TB with on-disk storage)
+# ~/.crow/peertube/config/ Generated production.yaml overrides
+#
+# Storage: on-disk by default but STRONGLY recommend S3. Set PEERTUBE_S3_*
+# and scripts/configure-storage.mjs writes the PEERTUBE_OBJECT_STORAGE_*
+# envelope to .env. Without S3 + aggressive pruning, a single active
+# channel can fill a 500 GB disk within months.
+#
+# Image: chocobozzz/peertube:production-bookworm (stable line; pin tag to
+# a specific release for production — we use the floating tag for the
+# roll-out, will pin before merge).
+
+networks:
+ crow-federation:
+ external: true
+ default:
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ container_name: crow-peertube-postgres
+ networks:
+ - default
+ environment:
+ POSTGRES_USER: peertube
+ POSTGRES_PASSWORD: ${PEERTUBE_DB_PASSWORD}
+ POSTGRES_DB: peertube_prod
+ volumes:
+ - ${PEERTUBE_DATA_DIR:-~/.crow/peertube}/postgres:/var/lib/postgresql/data
+ init: true
+ mem_limit: 512m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U peertube -d peertube_prod"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 20s
+
+ redis:
+ image: redis:7-alpine
+ container_name: crow-peertube-redis
+ networks:
+ - default
+ volumes:
+ - ${PEERTUBE_DATA_DIR:-~/.crow/peertube}/redis:/data
+ init: true
+ mem_limit: 256m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ peertube:
+ image: chocobozzz/peertube:production-bookworm
+ container_name: crow-peertube
+ networks:
+ - default
+ - crow-federation
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ PEERTUBE_DB_HOSTNAME: postgres
+ PEERTUBE_DB_USERNAME: peertube
+ PEERTUBE_DB_PASSWORD: ${PEERTUBE_DB_PASSWORD}
+ PEERTUBE_DB_SSL: "false"
+ PEERTUBE_REDIS_HOSTNAME: redis
+ PEERTUBE_WEBSERVER_HOSTNAME: ${PEERTUBE_WEBSERVER_HOSTNAME}
+ PEERTUBE_WEBSERVER_PORT: "443"
+ PEERTUBE_WEBSERVER_HTTPS: "true"
+ PEERTUBE_TRUST_PROXY: '["127.0.0.1","loopback","172.16.0.0/12"]'
+ PEERTUBE_SECRET: ${PEERTUBE_SECRET}
+ PEERTUBE_ADMIN_EMAIL: ${PEERTUBE_ADMIN_EMAIL:-admin@example.com}
+ # Transcoding
+ PEERTUBE_TRANSCODING_ENABLED: "true"
+ PEERTUBE_TRANSCODING_THREADS: ${PEERTUBE_TRANSCODING_THREADS:-0}
+ PEERTUBE_TRANSCODING_CONCURRENCY: ${PEERTUBE_TRANSCODING_CONCURRENCY:-1}
+ # Signup
+ PEERTUBE_SIGNUP_ENABLED: ${PEERTUBE_SIGNUP_ENABLED:-false}
+ PEERTUBE_SIGNUP_LIMIT: "100"
+ PEERTUBE_SIGNUP_MINIMUM_AGE: "16"
+ # Quotas
+ PEERTUBE_USER_VIDEO_QUOTA: "${PEERTUBE_VIDEO_QUOTA_MB:-10000}000000"
+ # Remote cache retention (scheduler reads this)
+ PEERTUBE_VIDEOS_CLEANUP_REMOTE_INTERVAL: "${PEERTUBE_MEDIA_RETENTION_DAYS:-14} days"
+ # S3 passthrough (empty unless configure-storage.mjs populated these)
+ PEERTUBE_OBJECT_STORAGE_ENABLED: ${PEERTUBE_OBJECT_STORAGE_ENABLED:-false}
+ PEERTUBE_OBJECT_STORAGE_ENDPOINT: ${PEERTUBE_OBJECT_STORAGE_ENDPOINT:-}
+ PEERTUBE_OBJECT_STORAGE_REGION: ${PEERTUBE_OBJECT_STORAGE_REGION:-us-east-1}
+ PEERTUBE_OBJECT_STORAGE_ACCESS_KEY_ID: ${PEERTUBE_OBJECT_STORAGE_ACCESS_KEY_ID:-}
+ PEERTUBE_OBJECT_STORAGE_SECRET_ACCESS_KEY: ${PEERTUBE_OBJECT_STORAGE_SECRET_ACCESS_KEY:-}
+ PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME: ${PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME:-}
+ PEERTUBE_OBJECT_STORAGE_STREAMING_PLAYLISTS_BUCKET_NAME: ${PEERTUBE_OBJECT_STORAGE_STREAMING_PLAYLISTS_BUCKET_NAME:-}
+ PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME: ${PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME:-}
+ PEERTUBE_OBJECT_STORAGE_ORIGINAL_VIDEO_FILES_BUCKET_NAME: ${PEERTUBE_OBJECT_STORAGE_ORIGINAL_VIDEO_FILES_BUCKET_NAME:-}
+ PEERTUBE_OBJECT_STORAGE_USER_EXPORTS_BUCKET_NAME: ${PEERTUBE_OBJECT_STORAGE_USER_EXPORTS_BUCKET_NAME:-}
+ PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC: ${PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC:-public-read}
+ PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE: ${PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE:-private}
+ # SMTP (optional)
+ PEERTUBE_SMTP_HOSTNAME: ${PEERTUBE_SMTP_HOSTNAME:-}
+ PEERTUBE_SMTP_PORT: ${PEERTUBE_SMTP_PORT:-587}
+ PEERTUBE_SMTP_USERNAME: ${PEERTUBE_SMTP_USERNAME:-}
+ PEERTUBE_SMTP_PASSWORD: ${PEERTUBE_SMTP_PASSWORD:-}
+ PEERTUBE_SMTP_FROM: ${PEERTUBE_SMTP_FROM:-}
+ volumes:
+ - ${PEERTUBE_DATA_DIR:-~/.crow/peertube}/data:/data
+ - ${PEERTUBE_DATA_DIR:-~/.crow/peertube}/config:/config
+ init: true
+ mem_limit: 6g
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9000/api/v1/ping >/dev/null 2>&1 || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 120s
diff --git a/bundles/peertube/manifest.json b/bundles/peertube/manifest.json
new file mode 100644
index 0000000..4200adf
--- /dev/null
+++ b/bundles/peertube/manifest.json
@@ -0,0 +1,59 @@
+{
+ "id": "peertube",
+ "name": "PeerTube",
+ "version": "1.0.0",
+ "description": "Federated video platform over ActivityPub — YouTube-alternative on the fediverse. Upload and transcode video, federate channels, stream via WebTorrent or HLS. Heaviest bundle in the federated line — S3 storage + aggressive transcoding policy are load-bearing, not optional.",
+ "type": "bundle",
+ "author": "Crow",
+ "category": "federated-media",
+ "tags": ["peertube", "activitypub", "fediverse", "video", "federated", "youtube-alt", "webtorrent"],
+ "icon": "phone-video",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["PEERTUBE_URL", "PEERTUBE_ACCESS_TOKEN", "PEERTUBE_WEBSERVER_HOSTNAME"]
+ },
+ "panel": "panel/peertube.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/peertube.md"],
+ "consent_required": true,
+ "install_consent_messages": {
+ "en": "PeerTube joins the public fediverse over ActivityPub — your instance becomes publicly addressable at the domain you configure, any video you publish can be replicated to federated servers and cached there; replicated content cannot be fully recalled. Video storage is unbounded without S3 + scheduled pruning: a single 1080p30 video is 500MB+ in transcoded variants, and federated caches of remote channels can add hundreds of GB within months. Video transcoding spikes RAM to 3-5 GB PER concurrent upload (ffmpeg on x264). PeerTube is hardware-gated: REFUSED on hosts with <16 GB total RAM and <500 GB disk; warned below 32 GB / 2 TB. If transcoding + S3 both disabled, webtorrent seeding is your only scaling lever — the instance becomes unfeasible past ~100 videos. Hosting copyrighted video (music, films, clips you don't have rights to) is a legal fast-track to defederation and takedown notices. DMCA + equivalent requests are your legal responsibility.",
+ "es": "PeerTube se une al fediverso público vía ActivityPub — tu instancia será direccionable en el dominio que configures, cualquier video publicado puede replicarse a servidores federados y cachearse allí; el contenido replicado no puede recuperarse completamente. El almacenamiento de video es ilimitado sin S3 + recorte programado: un solo video 1080p30 son 500MB+ en variantes transcodificadas, y los cachés federados de canales remotos pueden añadir cientos de GB en meses. La transcodificación de video eleva la RAM a 3-5 GB POR subida concurrente (ffmpeg x264). PeerTube está limitado por hardware: RECHAZADO en hosts con <16 GB de RAM total y <500 GB de disco; advierte bajo 32 GB / 2 TB. Si transcodificación + S3 están deshabilitados, el seeding webtorrent es tu única palanca de escala — la instancia se vuelve inviable pasados ~100 videos. Hospedar video con copyright (música, películas, clips sin derechos) es un camino directo a la defederación y avisos DMCA. Solicitudes DMCA + equivalentes son tu responsabilidad legal."
+ },
+ "requires": {
+ "env": ["PEERTUBE_WEBSERVER_HOSTNAME", "PEERTUBE_DB_PASSWORD", "PEERTUBE_SECRET"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 4000,
+ "recommended_ram_mb": 8000,
+ "min_disk_mb": 100000,
+ "recommended_disk_mb": 2000000
+ },
+ "env_vars": [
+ { "name": "PEERTUBE_WEBSERVER_HOSTNAME", "description": "Public domain (subdomain; IMMUTABLE after first federation). Appears in video URLs, channel URLs, every ActivityPub actor.", "required": true },
+ { "name": "PEERTUBE_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
+ { "name": "PEERTUBE_SECRET", "description": "Server secret (32+ random chars). Signs federation requests; IMMUTABLE — rotation requires re-announcing every federated actor.", "required": true, "secret": true },
+ { "name": "PEERTUBE_ACCESS_TOKEN", "description": "OAuth bearer token (Administration → Users → your account → Personal Access Tokens, OR POST /api/v1/users/token with username+password + client_id+client_secret from /api/v1/oauth-clients/local).", "required": false, "secret": true },
+ { "name": "PEERTUBE_URL", "description": "Internal URL the MCP server uses to reach PeerTube (default http://peertube:9000 over crow-federation).", "default": "http://peertube:9000", "required": false },
+ { "name": "PEERTUBE_ADMIN_EMAIL", "description": "Admin email — where takedown notices, abuse reports, and federation alerts go.", "required": false },
+ { "name": "PEERTUBE_TRANSCODING_THREADS", "description": "Worker threads per concurrent transcode (default 0 = all CPUs). Lower on multi-tenant hosts to keep latency under control.", "default": "0", "required": false },
+ { "name": "PEERTUBE_TRANSCODING_CONCURRENCY", "description": "Max simultaneous transcodes. 1-2 on 16 GB hosts; 4+ on 32 GB+.", "default": "1", "required": false },
+ { "name": "PEERTUBE_SIGNUP_ENABLED", "description": "Allow new user signups (true/false). Default false; opening signup without moderation tooling invites abuse.", "default": "false", "required": false },
+ { "name": "PEERTUBE_VIDEO_QUOTA_MB", "description": "Default per-user video quota in MB. 10 GB default (10000).", "default": "10000", "required": false },
+ { "name": "PEERTUBE_MEDIA_RETENTION_DAYS", "description": "Remote video cache retention in days (default 14; 7 on smaller hosts).", "default": "14", "required": false },
+ { "name": "PEERTUBE_S3_ENDPOINT", "description": "Optional S3-compatible endpoint for video storage. STRONGLY RECOMMENDED on active instances. When set with bucket/access/secret, scripts/configure-storage.mjs routes via storage-translators.peertube() (PEERTUBE_OBJECT_STORAGE_* envelope).", "required": false },
+ { "name": "PEERTUBE_S3_BUCKET", "description": "S3 bucket for video originals + transcoded variants + streaming playlists.", "required": false },
+ { "name": "PEERTUBE_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true },
+ { "name": "PEERTUBE_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true },
+ { "name": "PEERTUBE_S3_REGION", "description": "S3 region.", "default": "us-east-1", "required": false },
+ { "name": "PEERTUBE_SMTP_HOSTNAME", "description": "Outbound SMTP (registration confirmation, takedown receipts).", "required": false },
+ { "name": "PEERTUBE_SMTP_PORT", "description": "SMTP port.", "default": "587", "required": false },
+ { "name": "PEERTUBE_SMTP_USERNAME", "description": "SMTP username.", "required": false },
+ { "name": "PEERTUBE_SMTP_PASSWORD", "description": "SMTP password.", "required": false, "secret": true },
+ { "name": "PEERTUBE_SMTP_FROM", "description": "From address.", "required": false }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Three containers (peertube + postgres + redis). chocobozzz/peertube:production-bookworm image. Expose via caddy_add_federation_site { domain: PEERTUBE_WEBSERVER_HOSTNAME, upstream: 'peertube:9000', profile: 'activitypub-peertube' } — the peertube profile also wires WebSocket upgrade on /socket.io and large request-body limits for video upload chunking. Admin credentials: first-boot logs print a randomly-generated admin password (search `docker logs crow-peertube | grep -A1 \"Username\"`); rotate via `docker exec -it crow-peertube npm run reset-password -- -u root` immediately."
+}
diff --git a/bundles/peertube/package.json b/bundles/peertube/package.json
new file mode 100644
index 0000000..6c67c88
--- /dev/null
+++ b/bundles/peertube/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "crow-peertube",
+ "version": "1.0.0",
+ "description": "PeerTube (federated video) MCP server — upload, search, channels, subscriptions, moderation",
+ "type": "module",
+ "main": "server/index.js",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.0",
+ "zod": "^3.24.0"
+ }
+}
diff --git a/bundles/peertube/panel/peertube.js b/bundles/peertube/panel/peertube.js
new file mode 100644
index 0000000..c21cf4c
--- /dev/null
+++ b/bundles/peertube/panel/peertube.js
@@ -0,0 +1,131 @@
+/**
+ * Crow's Nest Panel — PeerTube: instance status + recent videos + transcoding queue.
+ * XSS-safe (textContent / createElement only).
+ */
+
+export default {
+ id: "peertube",
+ name: "PeerTube",
+ icon: "phone-video",
+ route: "/dashboard/peertube",
+ navOrder: 78,
+ category: "federated-media",
+
+ async handler(req, res, { layout }) {
+ const content = `
+
+
+
PeerTube federated video platform
+
+
+
+
+
Recent Local Videos
+
+
+
+
+
Notes
+
+ - Video transcoding is RAM-hot (3-5 GB per concurrent upload). Keep
PEERTUBE_TRANSCODING_CONCURRENCY low unless this host has headroom.
+ - Storage is unbounded without S3 — enable it via
PEERTUBE_S3_* before publishing anything meaningful.
+ - Hosting copyrighted video is a legal fast-track to defederation and takedown notices.
+
+
+
+
+ `;
+ res.send(layout({ title: "PeerTube", 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 = 'pt-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; }
+ function fmtDur(s) {
+ if (s == null) return '—';
+ const m = Math.floor(s / 60); const sec = s % 60;
+ return m + ':' + (sec < 10 ? '0' : '') + sec;
+ }
+
+ async function loadStatus() {
+ const el = document.getElementById('pt-status'); clear(el);
+ try {
+ const res = await fetch('/api/peertube/status'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ const card = document.createElement('div'); card.className = 'pt-card';
+ card.appendChild(row('Instance', d.instance_name || d.hostname || '(unset)'));
+ card.appendChild(row('Version', d.version || '—'));
+ card.appendChild(row('Local videos', d.stats?.videos ?? '—'));
+ card.appendChild(row('Video views', d.stats?.video_views ?? '—'));
+ card.appendChild(row('Federated peers', d.stats?.instance_following ?? '—'));
+ card.appendChild(row('Transcoding', d.transcoding_enabled ? 'enabled' : 'disabled'));
+ card.appendChild(row('Object storage', d.object_storage?.enabled ? 'S3 enabled' : 'on-disk'));
+ card.appendChild(row('Signup', d.signup_enabled ? 'open' : 'closed'));
+ card.appendChild(row('Authenticated', d.authenticated_as ? d.authenticated_as.username + ' (' + (d.authenticated_as.role || '') + ')' : '(no token)'));
+ el.appendChild(card);
+ } catch (e) { el.appendChild(err('Cannot reach PeerTube.')); }
+ }
+
+ async function loadVideos() {
+ const el = document.getElementById('pt-videos'); clear(el);
+ try {
+ const res = await fetch('/api/peertube/videos'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ if (!d.videos || d.videos.length === 0) {
+ const i = document.createElement('div'); i.className = 'np-idle';
+ i.textContent = 'No local videos yet. Upload via pt_upload_video or the web UI.';
+ el.appendChild(i); return;
+ }
+ for (const v of d.videos) {
+ const c = document.createElement('div'); c.className = 'pt-video';
+ const t = document.createElement('b'); t.textContent = v.name;
+ c.appendChild(t);
+ const meta = document.createElement('div'); meta.className = 'pt-video-meta';
+ meta.textContent = (v.channel || '?') + ' · ' + fmtDur(v.duration_seconds) + ' · ' + (v.views || 0) + ' views · ' + (v.likes || 0) + ' likes';
+ c.appendChild(meta);
+ el.appendChild(c);
+ }
+ } catch (e) { el.appendChild(err('Cannot load videos: ' + e.message)); }
+ }
+
+ loadStatus();
+ loadVideos();
+ `;
+}
+
+function styles() {
+ return `
+ .pt-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; }
+ .pt-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; }
+ .pt-section { margin-bottom: 1.8rem; }
+ .pt-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase;
+ letter-spacing: 0.05em; margin: 0 0 0.7rem; }
+ .pt-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 10px; padding: 1rem; }
+ .pt-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); }
+ .pt-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; }
+ .pt-video { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; }
+ .pt-video b { color: var(--crow-text-primary); font-size: .9rem; }
+ .pt-video-meta { font-size: .75rem; color: var(--crow-text-muted); margin-top: .2rem; }
+ .pt-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; }
+ .pt-notes li { margin-bottom: .3rem; }
+ .pt-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/peertube/panel/routes.js b/bundles/peertube/panel/routes.js
new file mode 100644
index 0000000..9ad29f8
--- /dev/null
+++ b/bundles/peertube/panel/routes.js
@@ -0,0 +1,81 @@
+/**
+ * PeerTube panel API routes — status + recent videos.
+ */
+
+import { Router } from "express";
+
+const URL_BASE = () => (process.env.PEERTUBE_URL || "http://peertube:9000").replace(/\/+$/, "");
+const TOKEN = () => process.env.PEERTUBE_ACCESS_TOKEN || "";
+const HOSTNAME = () => process.env.PEERTUBE_WEBSERVER_HOSTNAME || "";
+const TIMEOUT = 15_000;
+
+async function pt(path, { query, noAuth } = {}) {
+ 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 peertubeRouter(authMiddleware) {
+ const router = Router();
+
+ router.get("/api/peertube/status", authMiddleware, async (_req, res) => {
+ try {
+ const config = await pt("/api/v1/config", { noAuth: true }).catch(() => null);
+ const stats = await pt("/api/v1/server/stats", { noAuth: true }).catch(() => null);
+ const me = TOKEN() ? await pt("/api/v1/users/me").catch(() => null) : null;
+ res.json({
+ hostname: HOSTNAME(),
+ instance_name: config?.instance?.name,
+ version: config?.serverVersion,
+ transcoding_enabled: (config?.transcoding?.enabledResolutions?.length || 0) > 0,
+ object_storage: config?.objectStorage || null,
+ signup_enabled: config?.signup?.allowed,
+ stats: stats ? {
+ videos: stats.totalLocalVideos,
+ video_views: stats.totalLocalVideoViews,
+ instance_following: stats.totalInstanceFollowing,
+ } : null,
+ authenticated_as: me ? { username: me.username, role: me.role?.label } : null,
+ });
+ } catch (err) {
+ res.json({ error: `Cannot reach PeerTube: ${err.message}` });
+ }
+ });
+
+ router.get("/api/peertube/videos", authMiddleware, async (_req, res) => {
+ try {
+ const out = await pt("/api/v1/videos", { query: { count: 12, sort: "-publishedAt", filter: "local" }, noAuth: !TOKEN() });
+ res.json({
+ videos: (out.data || []).map((v) => ({
+ id: v.id,
+ uuid: v.uuid,
+ name: v.name,
+ channel: v.channel?.displayName,
+ duration_seconds: v.duration,
+ views: v.views,
+ likes: v.likes,
+ })),
+ });
+ } catch (err) {
+ res.json({ error: err.message });
+ }
+ });
+
+ return router;
+}
diff --git a/bundles/peertube/scripts/backup.sh b/bundles/peertube/scripts/backup.sh
new file mode 100755
index 0000000..d263b5c
--- /dev/null
+++ b/bundles/peertube/scripts/backup.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# PeerTube backup: pg_dump + config + optional on-disk video originals.
+#
+# Videos are large. This script backs up ONLY the database + config by
+# default. Pass --with-videos to include the on-disk video store (can be
+# hundreds of GB). S3-backed video is NOT captured — operator's S3
+# provider is the durability layer.
+set -euo pipefail
+
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/peertube"
+DATA_DIR="${PEERTUBE_DATA_DIR:-$HOME/.crow/peertube}"
+WITH_VIDEOS=0
+[ "${1:-}" = "--with-videos" ] && WITH_VIDEOS=1
+
+mkdir -p "$BACKUP_ROOT"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+# Postgres dump
+if docker ps --format '{{.Names}}' | grep -qw crow-peertube-postgres; then
+ docker exec -e PGPASSWORD="${PEERTUBE_DB_PASSWORD:-}" crow-peertube-postgres \
+ pg_dump -U peertube -Fc -f /tmp/peertube-${STAMP}.pgcustom peertube_prod
+ docker cp "crow-peertube-postgres:/tmp/peertube-${STAMP}.pgcustom" "$WORK/peertube.pgcustom"
+ docker exec crow-peertube-postgres rm "/tmp/peertube-${STAMP}.pgcustom"
+fi
+
+# Config
+if [ -d "$DATA_DIR/config" ]; then
+ tar -C "$DATA_DIR" -cf "$WORK/peertube-config.tar" config 2>/dev/null || true
+fi
+
+# Optional videos
+if [ "$WITH_VIDEOS" -eq 1 ] && [ -d "$DATA_DIR/data" ]; then
+ echo "Including on-disk video files (--with-videos) — can be hundreds of GB."
+ tar -C "$DATA_DIR" -cf "$WORK/peertube-videos.tar" data 2>/dev/null || true
+fi
+
+OUT="${BACKUP_ROOT}/peertube-${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}/peertube-${STAMP}.tar.gz"
+ tar -C "$WORK" -czf "$OUT" .
+fi
+echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
+echo "NOTE: PEERTUBE_SECRET lives in .env — back up .env SEPARATELY and encrypted."
+echo " Rotating the secret breaks every federated follow relationship."
+[ "$WITH_VIDEOS" -eq 0 ] && echo " On-disk videos NOT in this archive (pass --with-videos to include)."
diff --git a/bundles/peertube/scripts/configure-storage.mjs b/bundles/peertube/scripts/configure-storage.mjs
new file mode 100755
index 0000000..0672270
--- /dev/null
+++ b/bundles/peertube/scripts/configure-storage.mjs
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+/**
+ * PeerTube storage wiring.
+ *
+ * Reads PEERTUBE_S3_* from the bundle's .env, runs F.0's
+ * storage-translators.peertube() to get PeerTube's PEERTUBE_OBJECT_STORAGE_*
+ * envelope, and appends the translated vars to .env. Strongly recommended
+ * to configure — see the skill doc for why S3 is load-bearing.
+ *
+ * No-op when PEERTUBE_S3_ENDPOINT is unset.
+ * Managed block: `# crow-peertube-storage BEGIN` / `END`.
+ */
+
+import { readFileSync, writeFileSync, existsSync } from "node:fs";
+import { 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.PEERTUBE_S3_ENDPOINT;
+ const bucket = env.PEERTUBE_S3_BUCKET;
+ const accessKey = env.PEERTUBE_S3_ACCESS_KEY;
+ const secretKey = env.PEERTUBE_S3_SECRET_KEY;
+ const region = env.PEERTUBE_S3_REGION || "us-east-1";
+
+ if (!endpoint) {
+ console.log("[configure-storage] PEERTUBE_S3_ENDPOINT not set — using on-disk storage. Strongly recommended to enable S3 before publishing anything.");
+ return;
+ }
+ if (!bucket || !accessKey || !secretKey) {
+ console.error("[configure-storage] PEERTUBE_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 {
+ console.error("[configure-storage] Cannot load storage-translators.js — falling back to inline mapping.");
+ translate = (_, crow) => ({
+ PEERTUBE_OBJECT_STORAGE_ENABLED: "true",
+ PEERTUBE_OBJECT_STORAGE_ENDPOINT: crow.endpoint,
+ PEERTUBE_OBJECT_STORAGE_REGION: crow.region || "us-east-1",
+ PEERTUBE_OBJECT_STORAGE_ACCESS_KEY_ID: crow.accessKey,
+ PEERTUBE_OBJECT_STORAGE_SECRET_ACCESS_KEY: crow.secretKey,
+ PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC: "public-read",
+ PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE: "private",
+ PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME: crow.bucket,
+ PEERTUBE_OBJECT_STORAGE_STREAMING_PLAYLISTS_BUCKET_NAME: crow.bucket,
+ PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME: crow.bucket,
+ PEERTUBE_OBJECT_STORAGE_ORIGINAL_VIDEO_FILES_BUCKET_NAME: crow.bucket,
+ PEERTUBE_OBJECT_STORAGE_USER_EXPORTS_BUCKET_NAME: crow.bucket,
+ });
+ }
+
+ const mapped = translate("peertube", { endpoint, bucket, accessKey, secretKey, region });
+
+ const BEGIN = "# crow-peertube-storage BEGIN (managed by scripts/configure-storage.mjs — do not edit)";
+ const END = "# crow-peertube-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 PEERTUBE_OBJECT_STORAGE_* env vars to ${ENV_PATH}.`);
+ console.log("[configure-storage] Restart compose so peertube picks up the new vars:");
+ console.log(" docker compose -f bundles/peertube/docker-compose.yml up -d --force-recreate");
+ console.log("[configure-storage] To migrate existing on-disk media to S3:");
+ console.log(" docker exec -it crow-peertube node dist/scripts/migrate-videos-to-object-storage.js");
+}
+
+main().catch((err) => {
+ console.error(`[configure-storage] Failed: ${err.message}`);
+ process.exit(1);
+});
diff --git a/bundles/peertube/scripts/post-install.sh b/bundles/peertube/scripts/post-install.sh
new file mode 100755
index 0000000..5f3e34f
--- /dev/null
+++ b/bundles/peertube/scripts/post-install.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# PeerTube post-install hook.
+#
+# 1. Wait for crow-peertube healthy.
+# 2. Optionally translate PEERTUBE_S3_* via configure-storage.mjs.
+# 3. Capture first-boot admin password from logs.
+# 4. Verify federation-network attachment.
+# 5. Print next-step guidance.
+
+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 PeerTube to report healthy (up to 180s — first-boot migrations + ffmpeg sanity checks)…"
+for i in $(seq 1 36); do
+ if docker inspect crow-peertube --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then
+ echo " → healthy"
+ break
+ fi
+ sleep 5
+done
+
+if [ -n "${PEERTUBE_S3_ENDPOINT:-}" ]; then
+ echo "PEERTUBE_S3_ENDPOINT detected — translating to PEERTUBE_OBJECT_STORAGE_* envelope…"
+ if command -v node >/dev/null 2>&1; then
+ node "${BUNDLE_DIR}/scripts/configure-storage.mjs" || {
+ echo "WARN: configure-storage.mjs failed; video stays on-disk until env vars are written manually." >&2
+ }
+ else
+ echo "WARN: node not on PATH — cannot run configure-storage.mjs. S3 not wired." >&2
+ fi
+else
+ echo "WARN: PEERTUBE_S3_ENDPOINT not set. Video storage will be on-disk — a single active channel can fill 500 GB within months." >&2
+ echo " Configure S3 + run 'node dist/scripts/migrate-videos-to-object-storage.js' as soon as practical." >&2
+fi
+
+if ! docker inspect crow-peertube --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw crow-federation; then
+ echo "WARN: crow-peertube is not on the crow-federation network — Caddy federation site will not reach it by service name" >&2
+fi
+
+# Capture initial admin password
+ADMIN_PW=$(docker logs crow-peertube 2>&1 | grep -A1 "Username:" | grep "Password:" | head -1 | awk -F': ' '{print $NF}' || true)
+
+cat <&1 | grep -A1 'Username'"
+fi
+cat <}/api/v1/oauth-clients/local)
+ CLIENT_ID=\$(echo "\$CLIENT" | jq -r .client_id)
+ CLIENT_SECRET=\$(echo "\$CLIENT" | jq -r .client_secret)
+ curl -s -X POST https://${PEERTUBE_WEBSERVER_HOSTNAME:-}/api/v1/users/token \\
+ -H 'Content-Type: application/x-www-form-urlencoded' \\
+ -d "client_id=\$CLIENT_ID&client_secret=\$CLIENT_SECRET&grant_type=password&username=root&password="
+ Paste access_token into .env as PEERTUBE_ACCESS_TOKEN, then:
+ crow bundle restart peertube
+
+ 4. Verify:
+ pt_status {}
+
+EOF
diff --git a/bundles/peertube/server/index.js b/bundles/peertube/server/index.js
new file mode 100755
index 0000000..5d8a5e5
--- /dev/null
+++ b/bundles/peertube/server/index.js
@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { createPeertubeServer } from "./server.js";
+
+const server = await createPeertubeServer();
+const transport = new StdioServerTransport();
+await server.connect(transport);
diff --git a/bundles/peertube/server/server.js b/bundles/peertube/server/server.js
new file mode 100644
index 0000000..63b3ea6
--- /dev/null
+++ b/bundles/peertube/server/server.js
@@ -0,0 +1,563 @@
+/**
+ * PeerTube MCP Server
+ *
+ * PeerTube has its own REST API at /api/v1/ — not Mastodon-compatible.
+ * OAuth2 bearer auth. Upload is chunked via resumable PUT (tus-style)
+ * but the bundle exposes a simpler single-request POST /api/v1/videos/upload
+ * for files up to ~2 GB; larger uploads should go through the web UI.
+ *
+ * Tools (federated-video taxonomy):
+ * pt_status, pt_list_channels, pt_list_videos,
+ * pt_upload_video, pt_search,
+ * pt_subscribe, pt_unsubscribe,
+ * pt_rate_video,
+ * pt_block_user (inline, rate-limited),
+ * pt_block_server (QUEUED, admin-destructive),
+ * pt_defederate (QUEUED),
+ * pt_review_reports, pt_report_remote,
+ * pt_media_prune (admin)
+ */
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import { readFile } from "node:fs/promises";
+import { basename } from "node:path";
+
+const PEERTUBE_URL = (process.env.PEERTUBE_URL || "http://peertube:9000").replace(/\/+$/, "");
+const PEERTUBE_ACCESS_TOKEN = process.env.PEERTUBE_ACCESS_TOKEN || "";
+const PEERTUBE_HOSTNAME = process.env.PEERTUBE_WEBSERVER_HOSTNAME || "";
+
+let wrapRateLimited = null;
+let getDb = null;
+let createNotification = null;
+
+async function loadSharedDeps() {
+ try {
+ const rl = await import("../../../servers/shared/rate-limiter.js");
+ wrapRateLimited = rl.wrapRateLimited;
+ } catch {
+ wrapRateLimited = () => (_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;
+ }
+}
+
+async function ptFetch(path, { method = "GET", body, query, noAuth, timeoutMs = 30_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 = `${PEERTUBE_URL}${path}${qs}`;
+ const headers = {};
+ if (!noAuth && PEERTUBE_ACCESS_TOKEN) {
+ headers.Authorization = `Bearer ${PEERTUBE_ACCESS_TOKEN}`;
+ }
+ let payload;
+ if (rawForm) {
+ payload = rawForm;
+ } 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("PeerTube auth failed (401). Obtain a bearer token via POST /api/v1/users/token (username+password + client_id/client_secret from /api/v1/oauth-clients/local), paste into PEERTUBE_ACCESS_TOKEN.");
+ if (res.status === 403) throw new Error(`PeerTube forbidden (403)${snippet ? ": " + snippet : ""}`);
+ throw new Error(`PeerTube ${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(`PeerTube request timed out: ${path}`);
+ if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) {
+ throw new Error(`Cannot reach PeerTube at ${PEERTUBE_URL}. Verify crow-peertube is up and on the crow-federation network.`);
+ }
+ throw err;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function requireAuth() {
+ if (!PEERTUBE_ACCESS_TOKEN) {
+ return { content: [{ type: "text", text: "Error: PEERTUBE_ACCESS_TOKEN required. See the bundle's skill doc for token-acquisition recipe." }] };
+ }
+ return null;
+}
+
+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 — 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)}` }] };
+}
+
+async function resolveChannelId(handleOrId) {
+ if (/^\d+$/.test(handleOrId)) return Number(handleOrId);
+ // PeerTube handles like "channelname@host.example" resolve via
+ // GET /api/v1/video-channels/{handle}
+ const clean = handleOrId.replace(/^@/, "");
+ const out = await ptFetch(`/api/v1/video-channels/${encodeURIComponent(clean)}`).catch(() => null);
+ if (out?.id) return out.id;
+ throw new Error(`Channel not found: ${handleOrId}`);
+}
+
+export async function createPeertubeServer(options = {}) {
+ await loadSharedDeps();
+
+ const server = new McpServer(
+ { name: "crow-peertube", version: "1.0.0" },
+ { instructions: options.instructions },
+ );
+
+ const limiter = wrapRateLimited ? wrapRateLimited({ db: getDb ? getDb() : null }) : (_, h) => h;
+
+ // --- pt_status ---
+ server.tool(
+ "pt_status",
+ "Report PeerTube instance health: reachability, version, stats, federation peer count, transcoding config, storage mode.",
+ {},
+ async () => {
+ try {
+ const [config, stats, me] = await Promise.all([
+ ptFetch("/api/v1/config", { noAuth: true }).catch(() => null),
+ ptFetch("/api/v1/server/stats", { noAuth: true }).catch(() => null),
+ PEERTUBE_ACCESS_TOKEN ? ptFetch("/api/v1/users/me").catch(() => null) : Promise.resolve(null),
+ ]);
+ return textResponse({
+ hostname: PEERTUBE_HOSTNAME || null,
+ url: PEERTUBE_URL,
+ version: config?.serverVersion || null,
+ instance_name: config?.instance?.name || null,
+ signup_enabled: config?.signup?.allowed ?? null,
+ transcoding_enabled: config?.transcoding?.enabledResolutions?.length > 0,
+ video_quota_default_mb: config?.user?.videoQuota ? Math.round(config.user.videoQuota / 1_000_000) : null,
+ object_storage: config?.objectStorage || null,
+ federation_enabled: config?.federation?.enabled ?? true,
+ stats: stats ? {
+ users: stats.totalUsers,
+ videos: stats.totalLocalVideos,
+ video_views: stats.totalLocalVideoViews,
+ instance_followers: stats.totalInstanceFollowers,
+ instance_following: stats.totalInstanceFollowing,
+ } : null,
+ authenticated_as: me ? { username: me.username, role: me.role?.label, quota_used: me.videoQuotaUsed } : null,
+ has_access_token: Boolean(PEERTUBE_ACCESS_TOKEN),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ // --- pt_list_channels ---
+ server.tool(
+ "pt_list_channels",
+ "List the authenticated user's owned channels.",
+ {},
+ async () => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const out = await ptFetch("/api/v1/users/me/video-channels");
+ return textResponse({
+ count: out.total ?? (out.data || []).length,
+ channels: (out.data || []).map((c) => ({
+ id: c.id,
+ name: c.name,
+ display_name: c.displayName,
+ url: c.url,
+ followers_count: c.followersCount,
+ })),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ // --- pt_list_videos ---
+ server.tool(
+ "pt_list_videos",
+ "List videos. scope: local (this instance), federated (all), subscriptions (my follows). Rate-limited: 60/hour.",
+ {
+ scope: z.enum(["local", "federated", "subscriptions"]).optional(),
+ sort: z.enum(["-publishedAt", "-views", "-likes", "-trending"]).optional(),
+ count: z.number().int().min(1).max(100).optional(),
+ start: z.number().int().min(0).optional(),
+ },
+ limiter("pt_list_videos", async ({ scope, sort, count, start }) => {
+ try {
+ const scopeToPath = {
+ local: "/api/v1/videos",
+ federated: "/api/v1/videos",
+ subscriptions: "/api/v1/users/me/subscriptions/videos",
+ };
+ const path = scopeToPath[scope || "local"];
+ if (scope === "subscriptions") { const a = requireAuth(); if (a) return a; }
+ const query = { count: count ?? 20, start: start ?? 0, sort: sort || "-publishedAt" };
+ if (scope === "local") query.filter = "local";
+ const out = await ptFetch(path, { query, noAuth: scope !== "subscriptions" && !PEERTUBE_ACCESS_TOKEN });
+ return textResponse({
+ count: out.total,
+ videos: (out.data || []).map((v) => ({
+ id: v.id,
+ uuid: v.uuid,
+ name: v.name,
+ url: v.url,
+ channel: v.channel?.displayName,
+ duration_seconds: v.duration,
+ views: v.views,
+ likes: v.likes,
+ published_at: v.publishedAt,
+ is_local: v.isLocal,
+ })),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pt_upload_video ---
+ server.tool(
+ "pt_upload_video",
+ "Upload a video file (single-request; use the web UI for files >2 GB). Required: channelId (numeric) OR channel_handle, and either file_path or file_base64+filename. Rate-limited: 5/hour — transcoding is RAM-hot.",
+ {
+ channel_id: z.number().int().optional(),
+ channel_handle: z.string().max(320).optional(),
+ file_path: z.string().max(4096).optional(),
+ file_base64: z.string().max(200_000_000).optional(),
+ filename: z.string().max(500).optional(),
+ name: z.string().min(3).max(120).describe("Video title (3-120 chars)."),
+ description: z.string().max(10_000).optional(),
+ privacy: z.enum(["public", "unlisted", "private", "internal"]).optional().describe("public=federated; unlisted=link-only; private=owner only. Default public."),
+ category: z.number().int().min(1).max(100).optional().describe("Category ID from GET /api/v1/videos/categories."),
+ tags: z.array(z.string().max(30)).max(5).optional(),
+ nsfw: z.boolean().optional(),
+ wait_transcoding: z.boolean().optional().describe("Block the publish until transcoding finishes (default false — publishes ASAP)."),
+ },
+ limiter("pt_upload_video", async (args) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ let buf, name;
+ if (args.file_path) { buf = await readFile(args.file_path); name = args.filename || basename(args.file_path); }
+ else if (args.file_base64) { buf = Buffer.from(args.file_base64, "base64"); name = args.filename || `upload-${Date.now()}.mp4`; }
+ else return { content: [{ type: "text", text: "Error: must pass file_path or file_base64+filename." }] };
+
+ let channelId = args.channel_id;
+ if (!channelId && args.channel_handle) channelId = await resolveChannelId(args.channel_handle);
+ if (!channelId) return { content: [{ type: "text", text: "Error: must pass channel_id or channel_handle." }] };
+
+ const form = new FormData();
+ form.append("videofile", new Blob([buf]), name);
+ form.append("channelId", String(channelId));
+ form.append("name", args.name);
+ if (args.description) form.append("description", args.description);
+ form.append("privacy", { public: 1, unlisted: 2, private: 3, internal: 4 }[args.privacy || "public"].toString());
+ if (args.category) form.append("category", String(args.category));
+ if (args.tags) args.tags.forEach((t) => form.append("tags[]", t));
+ if (args.nsfw != null) form.append("nsfw", args.nsfw ? "true" : "false");
+ if (args.wait_transcoding != null) form.append("waitTranscoding", args.wait_transcoding ? "true" : "false");
+
+ const out = await ptFetch("/api/v1/videos/upload", { method: "POST", rawForm: form, timeoutMs: 600_000 });
+ return textResponse({
+ video_id: out.video?.id,
+ uuid: out.video?.uuid,
+ url: out.video?.url,
+ state: out.video?.state?.label,
+ note: "Transcoding proceeds in background; GET /api/v1/videos/{id} to poll state.",
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pt_search ---
+ server.tool(
+ "pt_search",
+ "Search videos + channels across local + cached federated content. Rate-limited: 60/hour.",
+ {
+ q: z.string().min(1).max(500),
+ type: z.enum(["videos", "channels"]).optional(),
+ count: z.number().int().min(1).max(50).optional(),
+ },
+ limiter("pt_search", async ({ q, type, count }) => {
+ try {
+ const path = type === "channels" ? "/api/v1/search/video-channels" : "/api/v1/search/videos";
+ const out = await ptFetch(path, { query: { search: q, count: count ?? 10 }, noAuth: !PEERTUBE_ACCESS_TOKEN });
+ return textResponse({
+ count: out.total,
+ results: (out.data || []).map((r) => type === "channels"
+ ? { id: r.id, name: r.name, display_name: r.displayName, url: r.url, followers: r.followersCount }
+ : { id: r.id, uuid: r.uuid, name: r.name, url: r.url, channel: r.channel?.displayName, duration_seconds: r.duration, views: r.views, is_local: r.isLocal }
+ ),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pt_subscribe / unsubscribe ---
+ server.tool(
+ "pt_subscribe",
+ "Subscribe to a channel by handle (name@host). Rate-limited: 30/hour.",
+ { handle: z.string().min(3).max(320) },
+ limiter("pt_subscribe", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const clean = handle.replace(/^@/, "");
+ await ptFetch("/api/v1/users/me/subscriptions", { method: "POST", body: { uri: clean } });
+ return textResponse({ subscribed: clean });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ server.tool(
+ "pt_unsubscribe",
+ "Unsubscribe from a channel.",
+ { handle: z.string().min(3).max(320) },
+ limiter("pt_unsubscribe", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const clean = handle.replace(/^@/, "");
+ await ptFetch(`/api/v1/users/me/subscriptions/${encodeURIComponent(clean)}`, { method: "DELETE" });
+ return textResponse({ unsubscribed: clean });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pt_rate_video ---
+ server.tool(
+ "pt_rate_video",
+ "Like / dislike / unrate a video by numeric id. Rate-limited: 60/hour.",
+ {
+ video_id: z.number().int(),
+ rating: z.enum(["like", "dislike", "none"]),
+ },
+ limiter("pt_rate_video", async ({ video_id, rating }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ await ptFetch(`/api/v1/videos/${video_id}/rate`, { method: "PUT", body: { rating } });
+ return textResponse({ video_id, rating });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- User-level moderation (inline) ---
+ server.tool(
+ "pt_block_user",
+ "Block an account (hide their videos + comments). Rate-limited: 5/hour.",
+ {
+ account_handle: z.string().min(3).max(320).describe("name@host format."),
+ confirm: z.literal("yes"),
+ },
+ limiter("pt_block_user", async ({ account_handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const clean = account_handle.replace(/^@/, "");
+ await ptFetch("/api/v1/users/me/blocklist/accounts", { method: "POST", body: { accountName: clean } });
+ return textResponse({ blocked_account: clean });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- Instance-level moderation (QUEUED, admin) ---
+ server.tool(
+ "pt_block_server",
+ "Block an entire remote instance (admin, instance-scope blocklist — hides all accounts + videos from that domain for every user on this server). QUEUED — requires operator confirmation in the Nest panel within 72h.",
+ {
+ host: z.string().min(3).max(253),
+ reason: z.string().max(1000).optional(),
+ confirm: z.literal("yes"),
+ },
+ async ({ host, reason }) => {
+ const queued = await queueModerationAction("peertube", "block_server", { host, reason: reason || "" });
+ return textResponse(queued);
+ },
+ );
+
+ server.tool(
+ "pt_defederate",
+ "Full defederation: block + unfollow + purge cached videos from a remote instance. QUEUED — requires operator confirmation.",
+ {
+ host: z.string().min(3).max(253),
+ reason: z.string().max(1000).optional(),
+ confirm: z.literal("yes"),
+ },
+ async ({ host, reason }) => {
+ const queued = await queueModerationAction("peertube", "defederate", { host, reason: reason || "" });
+ return textResponse(queued);
+ },
+ );
+
+ // --- Admin reports ---
+ server.tool(
+ "pt_review_reports",
+ "List open moderation reports (admin/moderator role).",
+ { count: z.number().int().min(1).max(50).optional() },
+ async ({ count }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const out = await ptFetch("/api/v1/abuses", { query: { count: count ?? 20, state: 1 /* pending */ } });
+ return textResponse({
+ count: out.total,
+ reports: (out.data || []).map((r) => ({
+ id: r.id,
+ reason: r.reason,
+ reporter: r.reporterAccount?.displayName,
+ video: r.video?.name,
+ comment: r.comment?.text?.slice(0, 200),
+ flagged_account: r.flaggedAccount?.displayName,
+ created_at: r.createdAt,
+ })),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ server.tool(
+ "pt_report_remote",
+ "File a moderation report. Can report a video (video_id) or a comment (comment_id) or a whole account. Rate-limited: 5/hour.",
+ {
+ reason: z.string().min(1).max(3000),
+ video_id: z.number().int().optional(),
+ comment_id: z.number().int().optional(),
+ account: z.string().max(320).optional().describe("Remote account handle to report (name@host)."),
+ predefined_reasons: z.array(z.enum(["violentOrAbusive", "hatefulOrAbusive", "spamOrMisleading", "privacy", "rights", "serverRules", "thumbnails", "captions"])).max(8).optional(),
+ },
+ limiter("pt_report_remote", async ({ reason, video_id, comment_id, account, predefined_reasons }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const body = { reason, ...(predefined_reasons ? { predefinedReasons: predefined_reasons } : {}) };
+ if (video_id) body.video = { id: video_id };
+ if (comment_id) body.comment = { id: comment_id };
+ if (account) body.account = { id: account.replace(/^@/, "") };
+ if (!video_id && !comment_id && !account) {
+ return { content: [{ type: "text", text: "Error: one of video_id / comment_id / account required." }] };
+ }
+ const out = await ptFetch("/api/v1/abuses", { method: "POST", body });
+ return textResponse({ abuse_id: out.abuse?.id });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pt_media_prune ---
+ server.tool(
+ "pt_media_prune",
+ "Trigger pruning of remote-cached video files older than N days. PeerTube runs this on a scheduled job; this forces an immediate pass. Admin-only. Rate-limited: 2/hour.",
+ {
+ older_than_days: z.number().int().min(1).max(365).optional(),
+ confirm: z.literal("yes"),
+ },
+ limiter("pt_media_prune", async ({ older_than_days }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const days = older_than_days ?? Number(process.env.PEERTUBE_MEDIA_RETENTION_DAYS || 14);
+ return textResponse({
+ requested_days: days,
+ note: "PeerTube does not expose an HTTP endpoint to force-prune remote video caches — pruning is handled by the scheduled 'remove-old-views' and 'remove-dangling-resumable-uploads' jobs at PEERTUBE_VIDEOS_CLEANUP_REMOTE_INTERVAL cadence. For an immediate prune: `docker exec crow-peertube node dist/scripts/prune-storage.js`.",
+ command: `docker exec crow-peertube node dist/scripts/prune-storage.js`,
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ return server;
+}
diff --git a/bundles/peertube/skills/peertube.md b/bundles/peertube/skills/peertube.md
new file mode 100644
index 0000000..ffc0f88
--- /dev/null
+++ b/bundles/peertube/skills/peertube.md
@@ -0,0 +1,136 @@
+---
+name: peertube
+description: PeerTube — federated video platform over ActivityPub. Upload, transcode, federate channels, WebTorrent/HLS streaming.
+triggers:
+ - "peertube"
+ - "upload video"
+ - "federated video"
+ - "video channel"
+ - "fediverse video"
+ - "youtube alternative"
+ - "webtorrent"
+tools:
+ - pt_status
+ - pt_list_channels
+ - pt_list_videos
+ - pt_upload_video
+ - pt_search
+ - pt_subscribe
+ - pt_unsubscribe
+ - pt_rate_video
+ - pt_block_user
+ - pt_block_server
+ - pt_defederate
+ - pt_review_reports
+ - pt_report_remote
+ - pt_media_prune
+---
+
+# PeerTube — federated video on ActivityPub
+
+PeerTube is the fediverse's YouTube-alternative. Videos are served via a combination of HLS playlists (HTTP) and WebTorrent (P2P) — viewers both pull from your instance and seed to each other, which is the protocol's scaling story. This bundle runs three containers: peertube (app + transcoding + HLS generator) + postgres + redis.
+
+## Hardware — this is the heaviest bundle in the track
+
+Gated by F.0's hardware check. **Refused** on hosts with <16 GB total RAM and <500 GB disk. Warned below 32 GB / 2 TB. Transcoding spikes RAM to 3-5 GB PER concurrent upload (ffmpeg x264); keep `PEERTUBE_TRANSCODING_CONCURRENCY=1` on 16 GB hosts. Storage is unbounded without S3 — a single 1080p30 video is ~500 MB in transcoded variants.
+
+**If you're on a Pi or sub-16 GB VPS, this bundle won't install.** Consider using an existing PeerTube instance (lots of good ones at [joinpeertube.org](https://joinpeertube.org)) instead.
+
+## S3 is load-bearing, not optional
+
+Set these in `.env` before install:
+
+```
+PEERTUBE_S3_ENDPOINT=https://minio.example.com
+PEERTUBE_S3_BUCKET=peertube-media
+PEERTUBE_S3_ACCESS_KEY=...
+PEERTUBE_S3_SECRET_KEY=...
+```
+
+`scripts/post-install.sh` detects these and runs `scripts/configure-storage.mjs`, which uses F.0's `storage-translators.peertube()` to populate the full `PEERTUBE_OBJECT_STORAGE_*` envelope (videos + streaming_playlists + web_videos + originals + user_exports all share the bucket; operators can split via manual YAML later).
+
+Without S3, a single active channel fills a 500 GB disk within months. This is not a slippery slope — this is the design constraint.
+
+## First-run bootstrap
+
+1. Populate `.env` with `PEERTUBE_WEBSERVER_HOSTNAME`, `PEERTUBE_DB_PASSWORD`, and `PEERTUBE_SECRET` (32+ random chars; generate via `openssl rand -hex 32`). Strongly recommended: `PEERTUBE_S3_*` and `PEERTUBE_SMTP_*`.
+2. Install. First boot runs migrations + generates the initial admin password. **Capture it from the logs immediately:**
+ ```bash
+ docker logs crow-peertube 2>&1 | grep -A1 "Username"
+ ```
+3. Expose via Caddy:
+ ```
+ caddy_add_federation_site {
+ domain: "video.example.com",
+ upstream: "peertube:9000",
+ profile: "activitypub-peertube"
+ }
+ ```
+ The `activitypub-peertube` profile wires WebSocket upgrade on `/socket.io` (federation + live updates) and sets large-body limits for video upload chunking.
+4. Log in at https://video.example.com/ as `root` with the captured password. **Change it immediately:**
+ ```bash
+ docker exec -it crow-peertube npm run reset-password -- -u root
+ ```
+5. Obtain an OAuth bearer token:
+ ```bash
+ CLIENT=$(curl -s https://video.example.com/api/v1/oauth-clients/local)
+ CLIENT_ID=$(echo "$CLIENT" | jq -r .client_id)
+ CLIENT_SECRET=$(echo "$CLIENT" | jq -r .client_secret)
+ curl -s -X POST https://video.example.com/api/v1/users/token \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ -d "client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&grant_type=password&username=root&password="
+ ```
+ Copy `access_token` into `.env` as `PEERTUBE_ACCESS_TOKEN`, then `crow bundle restart peertube`.
+
+## Common workflows
+
+### Upload a video
+
+```
+pt_list_channels {}
+# → grab a channel ID
+
+pt_upload_video {
+ "channel_id": 1,
+ "file_path": "/home/kev/videos/presentation.mp4",
+ "name": "Crow platform overview",
+ "description": "Walkthrough of the memory and sharing layers",
+ "privacy": "public",
+ "tags": ["crow", "mcp", "fediverse"]
+}
+```
+
+Transcoding runs in the background after upload; the video publishes immediately (unless `wait_transcoding: true`) but playback quality improves as variants complete. Poll via `GET /api/v1/videos/{id}` and watch `state.label`.
+
+### Search + subscribe
+
+```
+pt_search { "q": "peertube demo" }
+pt_subscribe { "handle": "cool-channel@peertube.tv" }
+pt_list_videos { "scope": "subscriptions", "count": 20 }
+```
+
+First federated subscription on a given remote host is slow (15-30s) — initial channel backfill + actor fetch. Subsequent channels from that host are fast.
+
+## Moderation
+
+PeerTube's moderation is more structured than Mastodon's — abuse reports have predefined category taxonomies, and admins can block at video / account / server scope.
+
+- **Inline (rate-limited):** `pt_block_user` (user-scoped blocklist), `pt_rate_video` with rating=none (un-like), `pt_report_remote` (file abuse report with predefined_reasons like hatefulOrAbusive, privacy, rights).
+- **Queued (operator confirms in Nest within 72h):** `pt_block_server` (instance-wide blocklist), `pt_defederate` (block + unfollow + purge cache).
+- **Admin read-only:** `pt_review_reports` (open abuses).
+- **Media management:** `pt_media_prune` surfaces the `node dist/scripts/prune-storage.js` command — PeerTube schedules this automatically via `PEERTUBE_VIDEOS_CLEANUP_REMOTE_INTERVAL`.
+
+## Cross-app notes
+
+- **Blog cross-posting**: Video URLs embed inline in WriteFreely / Pixelfed / Mastodon posts (ActivityPub OEmbed). Scheduled "post to X when video publishes" lands with F.12.
+- **Legal DMCA pipeline**: PeerTube admins receive takedown requests at `PEERTUBE_ADMIN_EMAIL`. This bundle does NOT implement automated DMCA handling — you must respond manually within your jurisdiction's timeframe (US: 14 days counternotice, etc.). Keep backups of contested videos before removing.
+
+## Troubleshooting
+
+- **"Cannot reach PeerTube"** — `docker ps | grep crow-peertube`. First boot runs migrations + ffmpeg sanity check (2-3 min).
+- **"401 auth failed"** — token expired (PeerTube bearer tokens default to 24h). Refresh via `POST /api/v1/users/token` with `grant_type=refresh_token`.
+- **"413 Payload Too Large" on upload** — Caddy default body limit. The `activitypub-peertube` profile raises this; for >2 GB uploads use the web UI's resumable-upload flow instead.
+- **Transcoding stuck** — ffmpeg OOM killer. Lower `PEERTUBE_TRANSCODING_CONCURRENCY` or add swap. `docker logs crow-peertube 2>&1 | grep ffmpeg`.
+- **Disk filling at terminal velocity** — you didn't enable S3. Stop publishing, enable S3 via `configure-storage.mjs`, then migrate existing media: `docker exec -it crow-peertube node dist/scripts/migrate-videos-to-object-storage.js`.
+- **Federation retries forever** — `PEERTUBE_SECRET` rotated. Don't rotate the secret; it signs every outgoing federation request and remote peers will reject mismatches.
diff --git a/registry/add-ons.json b/registry/add-ons.json
index 1ac443f..9d9a053 100644
--- a/registry/add-ons.json
+++ b/registry/add-ons.json
@@ -3332,6 +3332,59 @@
"webUI": null,
"notes": "Five containers (web + streaming + sidekiq + postgres + redis). ghcr.io/mastodon/mastodon:v4.3.0 + ghcr.io/mastodon/mastodon-streaming:v4.3.0. Expose via caddy_add_federation_site { domain: MASTODON_LOCAL_DOMAIN, upstream: 'mastodon-web:3000', profile: 'activitypub-mastodon' }. Admin via `bin/tootctl accounts create admin --email --confirmed --role Admin`."
},
+ {
+ "id": "peertube",
+ "name": "PeerTube",
+ "description": "Federated video platform over ActivityPub — YouTube-alternative. Upload, transcode, federate channels, WebTorrent/HLS streaming. Heaviest bundle in the federated line.",
+ "type": "bundle",
+ "version": "1.0.0",
+ "author": "Crow",
+ "category": "federated-media",
+ "tags": ["peertube", "activitypub", "fediverse", "video", "federated", "youtube-alt", "webtorrent"],
+ "icon": "phone-video",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["PEERTUBE_URL", "PEERTUBE_ACCESS_TOKEN", "PEERTUBE_WEBSERVER_HOSTNAME"]
+ },
+ "panel": "panel/peertube.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/peertube.md"],
+ "consent_required": true,
+ "requires": {
+ "env": ["PEERTUBE_WEBSERVER_HOSTNAME", "PEERTUBE_DB_PASSWORD", "PEERTUBE_SECRET"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 4000,
+ "recommended_ram_mb": 8000,
+ "min_disk_mb": 100000,
+ "recommended_disk_mb": 2000000
+ },
+ "env_vars": [
+ { "name": "PEERTUBE_WEBSERVER_HOSTNAME", "description": "Public domain (subdomain; IMMUTABLE after first federation).", "required": true },
+ { "name": "PEERTUBE_DB_PASSWORD", "description": "Postgres password.", "required": true, "secret": true },
+ { "name": "PEERTUBE_SECRET", "description": "Server secret (32+ random chars). IMMUTABLE — rotation breaks federation.", "required": true, "secret": true },
+ { "name": "PEERTUBE_ACCESS_TOKEN", "description": "OAuth bearer token.", "required": false, "secret": true },
+ { "name": "PEERTUBE_ADMIN_EMAIL", "description": "Admin email for takedown notices + abuse reports.", "required": false },
+ { "name": "PEERTUBE_TRANSCODING_CONCURRENCY", "description": "Max simultaneous transcodes. 1-2 on 16 GB; 4+ on 32 GB+.", "default": "1", "required": false },
+ { "name": "PEERTUBE_SIGNUP_ENABLED", "description": "Allow new user signups.", "default": "false", "required": false },
+ { "name": "PEERTUBE_VIDEO_QUOTA_MB", "description": "Default per-user quota in MB.", "default": "10000", "required": false },
+ { "name": "PEERTUBE_MEDIA_RETENTION_DAYS", "description": "Remote video cache retention.", "default": "14", "required": false },
+ { "name": "PEERTUBE_S3_ENDPOINT", "description": "Optional S3-compatible endpoint. STRONGLY RECOMMENDED; configure-storage.mjs routes via storage-translators.peertube().", "required": false },
+ { "name": "PEERTUBE_S3_BUCKET", "description": "S3 bucket.", "required": false },
+ { "name": "PEERTUBE_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true },
+ { "name": "PEERTUBE_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true },
+ { "name": "PEERTUBE_S3_REGION", "description": "S3 region.", "default": "us-east-1", "required": false },
+ { "name": "PEERTUBE_SMTP_HOSTNAME", "description": "Outbound SMTP (takedown receipts).", "required": false },
+ { "name": "PEERTUBE_SMTP_PORT", "description": "SMTP port.", "default": "587", "required": false },
+ { "name": "PEERTUBE_SMTP_USERNAME", "description": "SMTP username.", "required": false },
+ { "name": "PEERTUBE_SMTP_PASSWORD", "description": "SMTP password.", "required": false, "secret": true },
+ { "name": "PEERTUBE_SMTP_FROM", "description": "From address.", "required": false }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Three containers (peertube + postgres + redis). chocobozzz/peertube:production-bookworm. Expose via caddy_add_federation_site { domain: PEERTUBE_WEBSERVER_HOSTNAME, upstream: 'peertube:9000', profile: 'activitypub-peertube' }. Admin password printed to logs on first boot — rotate IMMEDIATELY via `docker exec -it crow-peertube npm run reset-password -- -u root`."
+ },
{
"id": "developer-kit",
"name": "Developer Kit",
diff --git a/skills/superpowers.md b/skills/superpowers.md
index 3416376..3e86548 100644
--- a/skills/superpowers.md
+++ b/skills/superpowers.md
@@ -86,6 +86,7 @@ This is the master routing skill. Consult this **before every task** to determin
| "pixelfed", "post photo", "share picture", "photo feed", "fediverse photo", "instagram alternative" | "pixelfed", "publicar foto", "compartir imagen", "feed de fotos", "foto fediverso", "alternativa instagram" | pixelfed | crow-pixelfed |
| "lemmy", "link aggregator", "reddit alternative", "subscribe community", "post link", "fediverse discussion", "upvote" | "lemmy", "agregador enlaces", "alternativa reddit", "suscribir comunidad", "publicar enlace", "discusión fediverso" | lemmy | crow-lemmy |
| "mastodon", "toot", "fediverse", "activitypub", "@user@server", "federated timeline", "boost", "mastodon instance" | "mastodon", "tootear", "fediverso", "activitypub", "@usuario@servidor", "linea temporal federada", "reblog" | mastodon | crow-mastodon |
+| "peertube", "upload video", "federated video", "video channel", "fediverse video", "youtube alternative", "webtorrent" | "peertube", "subir video", "video federado", "canal video", "video fediverso", "alternativa youtube" | peertube | crow-peertube |
| "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 |