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

+ +
+

Status

+
Loading…
+
+ +
+

Recent Local Videos

+
Loading…
+
+ +
+

Notes

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