feat(peertube): Add video import with multi-track subtitle sync

- New peertube-import script for importing from YouTube, Vimeo, 1000+ sites
- CGI endpoints for portal integration (peertube-import, peertube-import-status)
- Portal UI: Video Import card with progress tracking
- Multi-language subtitle download and PeerTube caption upload
- Fixed stdout/stderr separation for reliable function returns
- UCI config: uses peertube.admin.username/password
- Package version bumped to 1.2.0
- Added README.md with full documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-21 20:18:51 +01:00
parent e78f4fdf3d
commit bbf2b19415
8 changed files with 1281 additions and 3 deletions

View File

@ -3045,3 +3045,46 @@ git checkout HEAD -- index.html
- YouTube videos blocked by PO token requirement for subtitle access
- PeerTube videos on tube.gk2 have no captions uploaded
- Metadata extraction works; transcript step fails without subtitles/Whisper
31. **PeerTube Video Import with Multi-Track Subtitles (2026-02-21)**
- New `peertube-import` CLI tool for importing videos from YouTube, Vimeo, and 1000+ sites.
- **Features:**
- Download video via yt-dlp (best quality MP4)
- Extract metadata (title, description, tags)
- Download subtitles in multiple languages (configurable)
- Upload video to PeerTube via API
- Upload each subtitle track via `/api/v1/videos/{id}/captions/{lang}`
- **CLI Interface:**
```bash
peertube-import --lang fr,en,de,es https://youtube.com/watch?v=xxx
peertube-import --privacy 2 --channel 1 https://vimeo.com/xxx
```
- **Portal Integration:**
- New "Video Import" card in Intelligence & Analyse section
- Modal dialog with URL input, language selection, privacy options
- Progress bar with live status updates
- Direct link to imported video on completion
- **CGI Endpoints:**
- `POST /cgi-bin/peertube-import` — Start import job
- `GET /cgi-bin/peertube-import-status?job_id=xxx` — Poll status
- **Authentication:**
- Supports PEERTUBE_TOKEN env var
- UCI config: `peertube.api.username` / `peertube.api.password`
- OAuth client credential flow for token acquisition
- Package version bumped to 1.2.0
- **Files:**
- `secubox-app-peertube/files/usr/sbin/peertube-import` (new)
- `secubox-app-peertube/files/www/cgi-bin/peertube-import` (new)
- `secubox-app-peertube/files/www/cgi-bin/peertube-import-status` (new)
- `luci-app-secubox-portal/root/www/gk2-hub/portal.html` (updated)
- `secubox-app-peertube/Makefile` (updated)
31. **PeerTube Import Fixes (2026-02-21)**
- Fixed stdout/stderr separation in `peertube-import` script
- Changed UCI config path from `peertube.api.*` to `peertube.admin.*`
- Fixed yt-dlp output redirection to prevent mixing with function return values
- Fixed curl response handling in upload functions (use temp file, not 2>&1)
- Upgraded yt-dlp to 2026.2.4 for YouTube compatibility
- Installed Node.js (20.20.0) for yt-dlp JavaScript runtime support
- Verified end-to-end import flow: YouTube → download → subtitles → PeerTube upload

View File

@ -389,7 +389,12 @@
"Bash(__NEW_LINE_02bd2dd51e90cbf8__ echo \"\")",
"Bash(__NEW_LINE_70eb6f3ae1c26753__ echo \"\")",
"WebFetch(domain:radio.gk2.secubox.in)",
"WebFetch(domain:nextcloud-talk.readthedocs.io)"
"WebFetch(domain:nextcloud-talk.readthedocs.io)",
"Bash(__NEW_LINE_0334b7e65952251f__ rm -f \"$COOKIES\")",
"Bash(__NEW_LINE_d0f84baac9f3813d__ rm -f \"$COOKIES\")",
"Bash(__NEW_LINE_722c25da6bf58fe1__ rm -f \"$COOKIES\" /tmp/login.html)",
"WebFetch(domain:portal.nextcloud.com)",
"WebFetch(domain:arnowelzel.de)"
]
}
}

View File

@ -108,6 +108,7 @@
text-decoration: none;
color: inherit;
transition: all 0.2s;
cursor: pointer;
}
.service-card:hover {
border-color: var(--accent);
@ -126,6 +127,101 @@
font-size: 0.85rem;
}
.footer a { color: var(--accent); text-decoration: none; }
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 60px rgba(0,95,158,0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 { margin: 0; color: #fff; }
.modal-close {
background: none;
border: none;
color: var(--muted);
font-size: 1.5rem;
cursor: pointer;
}
.modal-close:hover { color: #fff; }
.modal-body { padding: 20px; }
.modal-footer {
padding: 15px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; color: var(--text); font-size: 0.9rem; }
.modal-input {
width: 100%;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.95rem;
}
.modal-input:focus { outline: none; border-color: var(--accent); }
.checkbox-group { display: flex; flex-wrap: wrap; gap: 15px; }
.checkbox-label { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.checkbox-label input { accent-color: var(--accent); }
.btn-primary {
background: var(--accent);
border: none;
color: #fff;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
.btn-primary:hover { background: #007acc; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
}
.btn-secondary:hover { border-color: var(--accent); }
.progress-bar {
background: var(--bg);
border-radius: 4px;
height: 8px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
background: var(--accent);
height: 100%;
width: 0%;
transition: width 0.3s;
}
.progress-status { font-size: 0.85rem; color: var(--muted); }
.result-success { color: #2ecc71; padding: 15px; background: rgba(46,204,113,0.1); border-radius: 6px; }
.result-success a { color: var(--accent); }
.result-error { color: #e74c3c; padding: 15px; background: rgba(231,76,60,0.1); border-radius: 6px; }
</style>
</head>
<body>
@ -203,15 +299,25 @@
<h2 class="section-title">Intelligence & Analyse</h2>
<div class="services-grid">
<a href="https://analyse.gk2.secubox.in/" class="service-card" target="_blank">
<div class="service-icon">🎬</div>
<div class="service-icon">🔍</div>
<div class="service-name">PeerTube Analyse</div>
<div class="service-url">analyse.gk2.secubox.in</div>
</a>
<a href="#" class="service-card" id="importVideoCard" onclick="openImportModal(); return false;">
<div class="service-icon">📥</div>
<div class="service-name">Video Import</div>
<div class="service-url">YouTube, Vimeo, 1000+ sites</div>
</a>
<a href="https://stream.gk2.secubox.in/" class="service-card" target="_blank">
<div class="service-icon">📻</div>
<div class="service-name">Radio Stream</div>
<div class="service-url">stream.gk2.secubox.in</div>
</a>
<a href="https://radio.gk2.secubox.in/" class="service-card" target="_blank">
<div class="service-icon">🎵</div>
<div class="service-name">Lyrion Radio</div>
<div class="service-url">radio.gk2.secubox.in</div>
</a>
</div>
<h2 class="section-title">Administration</h2>
@ -243,6 +349,50 @@
</footer>
</div>
<!-- Video Import Modal -->
<div class="modal-overlay" id="importModal" style="display:none;">
<div class="modal-box">
<div class="modal-header">
<h3>📥 Video Import</h3>
<button class="modal-close" onclick="closeImportModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Video URL</label>
<input type="text" id="importUrl" placeholder="https://youtube.com/watch?v=..." class="modal-input">
</div>
<div class="form-group">
<label>Subtitle Languages</label>
<div class="checkbox-group">
<label class="checkbox-label"><input type="checkbox" value="fr" checked> Francais</label>
<label class="checkbox-label"><input type="checkbox" value="en" checked> English</label>
<label class="checkbox-label"><input type="checkbox" value="de"> Deutsch</label>
<label class="checkbox-label"><input type="checkbox" value="es"> Espanol</label>
</div>
</div>
<div class="form-group">
<label>Privacy</label>
<select id="importPrivacy" class="modal-input">
<option value="1">Public</option>
<option value="2">Unlisted</option>
<option value="3">Private</option>
</select>
</div>
<div id="importProgress" style="display:none;">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-status" id="progressStatus">Starting...</div>
</div>
<div id="importResult" style="display:none;"></div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeImportModal()">Cancel</button>
<button class="btn-primary" id="startImportBtn" onclick="startImport()">Import Video</button>
</div>
</div>
</div>
<script>
// Check authentication
var token = sessionStorage.getItem("secubox_token");
@ -259,6 +409,131 @@ document.getElementById("logoutBtn").onclick = function() {
sessionStorage.clear();
window.location.href = "/login.html";
};
// Video Import Modal
var importJobId = null;
var pollInterval = null;
function openImportModal() {
document.getElementById("importModal").style.display = "flex";
document.getElementById("importUrl").value = "";
document.getElementById("importProgress").style.display = "none";
document.getElementById("importResult").style.display = "none";
document.getElementById("startImportBtn").disabled = false;
}
function closeImportModal() {
document.getElementById("importModal").style.display = "none";
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
function getSelectedLanguages() {
var checkboxes = document.querySelectorAll('.checkbox-group input:checked');
var langs = [];
checkboxes.forEach(function(cb) { langs.push(cb.value); });
return langs.length > 0 ? langs : ["fr", "en"];
}
function startImport() {
var url = document.getElementById("importUrl").value.trim();
if (!url) {
alert("Please enter a video URL");
return;
}
var languages = getSelectedLanguages();
var privacy = document.getElementById("importPrivacy").value;
document.getElementById("startImportBtn").disabled = true;
document.getElementById("importProgress").style.display = "block";
document.getElementById("importResult").style.display = "none";
document.getElementById("progressFill").style.width = "5%";
document.getElementById("progressStatus").textContent = "Starting import...";
fetch("/cgi-bin/peertube-import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url,
languages: languages,
privacy: parseInt(privacy)
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
showImportError(data.error);
return;
}
importJobId = data.job_id;
pollImportStatus();
})
.catch(function(err) {
showImportError("Failed to start import: " + err.message);
});
}
function pollImportStatus() {
if (!importJobId) return;
pollInterval = setInterval(function() {
fetch("/cgi-bin/peertube-import-status?job_id=" + importJobId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
clearInterval(pollInterval);
showImportError(data.error);
return;
}
var progress = data.progress || 0;
var status = data.status || "unknown";
document.getElementById("progressFill").style.width = progress + "%";
if (status === "downloading") {
document.getElementById("progressStatus").textContent = "Downloading video and subtitles...";
} else if (status === "uploading") {
document.getElementById("progressStatus").textContent = "Uploading to PeerTube...";
} else if (status === "completed") {
clearInterval(pollInterval);
showImportSuccess(data);
} else if (status === "failed") {
clearInterval(pollInterval);
showImportError(data.error || "Import failed");
} else {
document.getElementById("progressStatus").textContent = status;
}
})
.catch(function(err) {
document.getElementById("progressStatus").textContent = "Checking status...";
});
}, 3000);
}
function showImportSuccess(data) {
document.getElementById("importProgress").style.display = "none";
document.getElementById("importResult").style.display = "block";
document.getElementById("importResult").className = "result-success";
var videoUrl = data.video_url || ("https://tube.gk2.secubox.in/w/" + data.video_uuid);
var title = data.title || "Imported Video";
document.getElementById("importResult").innerHTML =
'<strong>Import successful!</strong><br><br>' +
'Title: ' + title + '<br>' +
'<a href="' + videoUrl + '" target="_blank">Open video on PeerTube</a>';
}
function showImportError(message) {
document.getElementById("importProgress").style.display = "none";
document.getElementById("importResult").style.display = "block";
document.getElementById("importResult").className = "result-error";
document.getElementById("importResult").innerHTML = '<strong>Import failed</strong><br>' + message;
document.getElementById("startImportBtn").disabled = false;
}
</script>
</body>

View File

@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-peertube
PKG_RELEASE:=1
PKG_VERSION:=1.1.0
PKG_VERSION:=1.2.0
PKG_ARCH:=all
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
PKG_LICENSE:=AGPL-3.0
@ -23,6 +23,7 @@ PeerTube federated video streaming platform.
Runs in an LXC Debian container with PostgreSQL, Redis, and Node.js.
Supports video hosting, live streaming, and ActivityPub federation.
Includes peertube-analyse: transcript extraction and Claude AI analysis.
Includes peertube-import: auto-import from YouTube/Vimeo with multi-track subtitles.
endef
define Package/secubox-app-peertube/conffiles
@ -42,6 +43,7 @@ define Package/secubox-app-peertube/install
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/peertubectl $(1)/usr/sbin/peertubectl
$(INSTALL_BIN) ./files/usr/sbin/peertube-analyse $(1)/usr/sbin/peertube-analyse
$(INSTALL_BIN) ./files/usr/sbin/peertube-import $(1)/usr/sbin/peertube-import
$(INSTALL_DIR) $(1)/www/peertube-analyse
$(INSTALL_DATA) ./files/www/peertube-analyse/index.html $(1)/www/peertube-analyse/
@ -49,6 +51,8 @@ define Package/secubox-app-peertube/install
$(INSTALL_DIR) $(1)/www/cgi-bin
$(INSTALL_BIN) ./files/www/cgi-bin/peertube-analyse $(1)/www/cgi-bin/
$(INSTALL_BIN) ./files/www/cgi-bin/peertube-analyse-status $(1)/www/cgi-bin/
$(INSTALL_BIN) ./files/www/cgi-bin/peertube-import $(1)/www/cgi-bin/
$(INSTALL_BIN) ./files/www/cgi-bin/peertube-import-status $(1)/www/cgi-bin/
endef
$(eval $(call BuildPackage,secubox-app-peertube))

View File

@ -0,0 +1,132 @@
# SecuBox PeerTube
Federated video streaming platform running in an LXC Debian container.
## Features
- **PeerTube Instance**: Self-hosted video platform with ActivityPub federation
- **Video Import**: Import videos from YouTube, Vimeo, and 1000+ sites via yt-dlp
- **Multi-Track Subtitles**: Automatic subtitle download and sync in multiple languages
- **Video Analysis**: Transcript extraction and Claude AI analysis (peertube-analyse)
- **Live Streaming**: RTMP ingest with HLS output
## Components
| Component | Description |
|-----------|-------------|
| `peertubectl` | Main control script for container management |
| `peertube-import` | Video import with subtitle sync |
| `peertube-analyse` | Transcript extraction and AI analysis |
## Video Import
Import videos from external platforms with automatic subtitle synchronization.
### CLI Usage
```bash
# Basic import
peertube-import https://youtube.com/watch?v=xxx
# Import with multiple subtitle languages
peertube-import --lang fr,en,de,es https://youtube.com/watch?v=xxx
# Import as unlisted video
peertube-import --privacy 2 https://youtube.com/watch?v=xxx
# Import to specific channel
peertube-import --channel 2 https://vimeo.com/xxx
```
### Options
| Option | Description | Default |
|--------|-------------|---------|
| `--lang <codes>` | Subtitle languages (comma-separated) | `fr,en` |
| `--channel <id>` | PeerTube channel ID | `1` |
| `--privacy <level>` | 1=public, 2=unlisted, 3=private | `1` |
| `--output <dir>` | Temp directory for downloads | `/tmp/peertube-import` |
| `--peertube <url>` | PeerTube instance URL | from UCI config |
### Portal Integration
Access via SecuBox Portal → Intelligence & Analyse → Video Import
The portal provides:
- URL input for video source
- Language selection checkboxes
- Privacy level selector
- Real-time progress tracking
- Direct link to imported video
### CGI Endpoints
```bash
# Start import job
curl -X POST http://192.168.255.1/cgi-bin/peertube-import \
-H "Content-Type: application/json" \
-d '{"url":"https://youtube.com/watch?v=xxx","languages":"fr,en"}'
# Response: {"success": true, "job_id": "import_xxx"}
# Check status
curl "http://192.168.255.1/cgi-bin/peertube-import-status?job_id=import_xxx"
# Response (in progress):
# {"status": "downloading", "progress": 45, "job_id": "import_xxx"}
# Response (completed):
# {"success": true, "video_url": "https://tube.example.com/w/uuid"}
```
## Configuration
UCI config file: `/etc/config/peertube`
```
config peertube 'main'
option enabled '1'
option data_path '/srv/peertube'
config peertube 'server'
option hostname 'tube.example.com'
option port '9001'
option https '1'
config peertube 'admin'
option username 'root'
option password 'changeme'
config peertube 'transcoding'
option enabled '1'
option threads '2'
list resolutions '480p'
list resolutions '720p'
```
## Dependencies
- `lxc`, `lxc-common` - Container runtime
- `wget-ssl` - HTTPS downloads
- `tar`, `jsonfilter` - Archive and JSON handling
- `yt-dlp` - Video download (pip install)
- `node` - JavaScript runtime for yt-dlp (opkg install)
## Supported Import Sources
yt-dlp supports 1000+ sites including:
- YouTube, YouTube Music
- Vimeo
- Dailymotion
- Twitch (VODs)
- Twitter/X
- TikTok
- And many more...
See: https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md
## Version
- Package: 1.2.0
- yt-dlp: 2026.2.4 (recommended)
- Node.js: 20.20.0 (for YouTube JS runtime)

View File

@ -0,0 +1,586 @@
#!/bin/sh
# PeerTube Video Import with Multi-Track Subtitle Sync
# SecuBox Intelligence Module
# Compatible: OpenWrt
set -e
#=============================================================================
# CONFIGURATION
#=============================================================================
SCRIPT_VERSION="1.0.0"
PEERTUBE_URL="${PEERTUBE_URL:-https://tube.gk2.secubox.in}"
PEERTUBE_TOKEN="${PEERTUBE_TOKEN:-}"
OUTPUT_BASE="${OUTPUT_BASE:-/tmp/peertube-import}"
DEFAULT_CHANNEL_ID=1
DEFAULT_PRIVACY=1 # 1=public, 2=unlisted, 3=private
#=============================================================================
# LOGGING
#=============================================================================
log_info() { echo >&2 "[INFO] $*"; }
log_ok() { echo >&2 "[OK] $*"; }
log_warn() { echo >&2 "[WARN] $*"; }
log_error() { echo >&2 "[ERROR] $*"; }
log_step() { echo >&2 ""; echo >&2 "==> $*"; }
log_progress() { echo >&2 "[PROGRESS] $*"; }
#=============================================================================
# UTILITY FUNCTIONS
#=============================================================================
# Generate slug from title
generate_slug() {
echo "$1" | tr '[:upper:]' '[:lower:]' | \
sed -E 's/[àáâãäå]/a/g; s/[èéêë]/e/g; s/[ìíîï]/i/g; s/[òóôõö]/o/g; s/[ùúûü]/u/g; s/[ç]/c/g' | \
sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g' | \
cut -c1-50
}
# Get PeerTube authentication token
get_peertube_token() {
local username="$1"
local password="$2"
if [ -n "$PEERTUBE_TOKEN" ]; then
echo "$PEERTUBE_TOKEN"
return 0
fi
# Read credentials from UCI config (admin section)
if [ -z "$username" ]; then
username=$(uci -q get peertube.admin.username)
password=$(uci -q get peertube.admin.password)
fi
# Try to get OAuth client credentials
local client_id client_secret
local oauth_clients
oauth_clients=$(curl -s "${PEERTUBE_URL}/api/v1/oauth-clients/local")
if command -v jq >/dev/null 2>&1; then
client_id=$(echo "$oauth_clients" | jq -r '.client_id // empty')
client_secret=$(echo "$oauth_clients" | jq -r '.client_secret // empty')
else
client_id=$(echo "$oauth_clients" | jsonfilter -e '@.client_id' 2>/dev/null)
client_secret=$(echo "$oauth_clients" | jsonfilter -e '@.client_secret' 2>/dev/null)
fi
if [ -z "$client_id" ] || [ -z "$username" ]; then
log_error "Cannot get PeerTube token. Set PEERTUBE_TOKEN or configure api credentials."
return 1
fi
# Get access token
local token_response
token_response=$(curl -s -X POST "${PEERTUBE_URL}/api/v1/users/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=${client_id}" \
-d "client_secret=${client_secret}" \
-d "grant_type=password" \
-d "response_type=code" \
-d "username=${username}" \
-d "password=${password}")
local token
if command -v jq >/dev/null 2>&1; then
token=$(echo "$token_response" | jq -r '.access_token // empty')
else
token=$(echo "$token_response" | jsonfilter -e '@.access_token' 2>/dev/null)
fi
if [ -z "$token" ]; then
log_error "Failed to get access token"
return 1
fi
echo "$token"
}
#=============================================================================
# VIDEO DOWNLOAD
#=============================================================================
download_video() {
local url="$1"
local output_dir="$2"
local slug="$3"
log_step "Downloading video"
local video_file="$output_dir/${slug}.%(ext)s"
# Download best quality video - redirect output to stderr to keep stdout clean
if yt-dlp -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" \
--merge-output-format mp4 \
-o "$output_dir/${slug}.%(ext)s" \
--no-playlist \
"$url" >&2 2>&1; then
# Find downloaded file
local found_video
found_video=$(find "$output_dir" -name "${slug}.*" -type f | grep -E '\.(mp4|webm|mkv)$' | head -1)
if [ -n "$found_video" ] && [ -f "$found_video" ]; then
log_ok "Video downloaded: $found_video"
echo "$found_video"
return 0
fi
fi
log_error "Failed to download video"
return 1
}
#=============================================================================
# METADATA EXTRACTION
#=============================================================================
extract_metadata() {
local url="$1"
local output_dir="$2"
local slug="$3"
log_step "Extracting metadata"
local meta_file="$output_dir/${slug}.meta.json"
if yt-dlp --dump-json --no-warnings "$url" 2>/dev/null > "$meta_file.tmp"; then
if command -v jq >/dev/null 2>&1; then
jq '{
id: .id,
title: .title,
description: .description,
duration: .duration,
upload_date: .upload_date,
uploader: .uploader,
channel: .channel,
tags: .tags,
webpage_url: .webpage_url,
thumbnail: .thumbnail,
subtitles: ((.subtitles // {}) | keys),
automatic_captions: ((.automatic_captions // {}) | keys)
}' "$meta_file.tmp" > "$meta_file"
else
mv "$meta_file.tmp" "$meta_file"
fi
rm -f "$meta_file.tmp"
log_ok "Metadata saved: $meta_file"
echo "$meta_file"
return 0
fi
log_error "Failed to extract metadata"
return 1
}
#=============================================================================
# SUBTITLE DOWNLOAD
#=============================================================================
download_subtitles() {
local url="$1"
local output_dir="$2"
local slug="$3"
local languages="$4" # Comma-separated: fr,en,de
log_step "Downloading subtitles"
[ -z "$languages" ] && languages="fr,en"
log_info "Requested languages: $languages"
# Download subtitles with yt-dlp
yt-dlp --write-sub --write-auto-sub \
--sub-lang "$languages" \
--sub-format vtt \
--convert-subs vtt \
--skip-download \
-o "$output_dir/${slug}" \
"$url" 2>&1 || true
# List downloaded subtitle files
local count=0
for vtt in "$output_dir"/${slug}*.vtt; do
[ -f "$vtt" ] || continue
count=$((count + 1))
log_ok "Downloaded: $(basename "$vtt")"
done
if [ "$count" -eq 0 ]; then
log_warn "No subtitles found for requested languages"
return 1
fi
log_ok "Downloaded $count subtitle file(s)"
return 0
}
#=============================================================================
# PEERTUBE UPLOAD
#=============================================================================
upload_video_to_peertube() {
local video_file="$1"
local meta_file="$2"
local channel_id="$3"
local privacy="$4"
local token="$5"
log_step "Uploading video to PeerTube"
[ -z "$channel_id" ] && channel_id=$DEFAULT_CHANNEL_ID
[ -z "$privacy" ] && privacy=$DEFAULT_PRIVACY
# Get title and description from metadata
local title description
if command -v jq >/dev/null 2>&1 && [ -f "$meta_file" ]; then
title=$(jq -r '.title // "Imported Video"' "$meta_file")
description=$(jq -r '.description // ""' "$meta_file" | head -c 10000)
else
title="Imported Video"
description=""
fi
log_info "Title: $title"
log_info "Channel ID: $channel_id"
log_info "Privacy: $privacy"
# Sanitize description (escape newlines and quotes)
description=$(echo "$description" | tr '\n' ' ' | sed 's/"/\\"/g' | head -c 5000)
# Upload video - use temp file to avoid mixing stderr with response
local tmpfile="/tmp/peertube_upload_$$.json"
local http_code
http_code=$(curl -s -w "%{http_code}" -o "$tmpfile" -X POST "${PEERTUBE_URL}/api/v1/videos/upload" \
-H "Authorization: Bearer ${token}" \
-F "videofile=@${video_file}" \
-F "channelId=${channel_id}" \
-F "name=${title}" \
-F "privacy=${privacy}" \
-F "description=${description}" \
2>/dev/null)
local body=""
[ -f "$tmpfile" ] && body=$(cat "$tmpfile")
rm -f "$tmpfile"
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
local video_id video_uuid
if command -v jq >/dev/null 2>&1; then
video_id=$(echo "$body" | jq -r '.video.id // empty')
video_uuid=$(echo "$body" | jq -r '.video.uuid // empty')
else
video_id=$(echo "$body" | jsonfilter -e '@.video.id' 2>/dev/null)
video_uuid=$(echo "$body" | jsonfilter -e '@.video.uuid' 2>/dev/null)
fi
if [ -n "$video_id" ]; then
log_ok "Video uploaded: ID=$video_id, UUID=$video_uuid"
echo "${video_id}|${video_uuid}"
return 0
fi
fi
log_error "Upload failed (HTTP $http_code)"
log_error "$body"
return 1
}
upload_subtitles_to_peertube() {
local video_id="$1"
local output_dir="$2"
local slug="$3"
local token="$4"
log_step "Uploading subtitles to PeerTube"
local uploaded=0
for vtt in "$output_dir"/${slug}*.vtt; do
[ -f "$vtt" ] || continue
# Extract language code from filename
# Format: slug.fr.vtt or slug.en-US.vtt
local filename
filename=$(basename "$vtt")
local lang
lang=$(echo "$filename" | sed -E "s/${slug}\.([a-zA-Z-]+)\.vtt/\1/")
# Normalize language code (en-US -> en)
lang=$(echo "$lang" | cut -d'-' -f1)
log_info "Uploading subtitle: $lang ($filename)"
local http_code
http_code=$(curl -s -w "%{http_code}" -o /dev/null -X PUT \
"${PEERTUBE_URL}/api/v1/videos/${video_id}/captions/${lang}" \
-H "Authorization: Bearer ${token}" \
-F "captionfile=@${vtt}" \
2>/dev/null)
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ] || [ "$http_code" = "201" ]; then
log_ok "Uploaded: $lang"
uploaded=$((uploaded + 1))
else
log_warn "Failed to upload $lang (HTTP $http_code)"
fi
done
log_ok "Uploaded $uploaded subtitle(s)"
return 0
}
#=============================================================================
# MAIN IMPORT FUNCTION
#=============================================================================
import_video() {
local url="$1"
local languages="$2"
local channel_id="$3"
local privacy="$4"
local username="$5"
local password="$6"
log_step "Starting video import"
log_info "URL: $url"
# Create output directory
# If OUTPUT_BASE already contains import_ (called from CGI), use it as-is
local output_dir
if echo "$OUTPUT_BASE" | grep -q "/import_"; then
output_dir="$OUTPUT_BASE"
else
output_dir="$OUTPUT_BASE/import_$(date +%s)_$$"
fi
mkdir -p "$output_dir"
# Extract metadata first to get title
local meta_file
meta_file=$(extract_metadata "$url" "$output_dir" "video") || {
log_error "Metadata extraction failed"
return 1
}
# Generate slug from title
local title slug
if command -v jq >/dev/null 2>&1; then
title=$(jq -r '.title // "video"' "$meta_file")
else
title=$(jsonfilter -i "$meta_file" -e '@.title' 2>/dev/null || echo "video")
fi
slug=$(generate_slug "$title")
[ -z "$slug" ] && slug="video_$$"
# Rename metadata file
mv "$meta_file" "$output_dir/${slug}.meta.json"
meta_file="$output_dir/${slug}.meta.json"
log_progress "downloading"
# Download video
local video_file
video_file=$(download_video "$url" "$output_dir" "$slug") || {
log_error "Video download failed"
return 1
}
# Download subtitles
download_subtitles "$url" "$output_dir" "$slug" "$languages" || {
log_warn "Subtitle download failed (continuing without subtitles)"
}
log_progress "uploading"
# Get PeerTube token
local token
token=$(get_peertube_token "$username" "$password") || {
log_error "Authentication failed"
return 1
}
# Upload video
local upload_result
upload_result=$(upload_video_to_peertube "$video_file" "$meta_file" "$channel_id" "$privacy" "$token") || {
log_error "Video upload failed"
return 1
}
local video_id video_uuid
video_id=$(echo "$upload_result" | cut -d'|' -f1)
video_uuid=$(echo "$upload_result" | cut -d'|' -f2)
# Upload subtitles
upload_subtitles_to_peertube "$video_id" "$output_dir" "$slug" "$token" || {
log_warn "Subtitle upload failed"
}
log_progress "completed"
# Output result
log_step "Import complete"
log_ok "Video ID: $video_id"
log_ok "Video UUID: $video_uuid"
log_ok "URL: ${PEERTUBE_URL}/w/${video_uuid}"
# Output JSON result
cat << EOF
{
"success": true,
"video_id": $video_id,
"video_uuid": "$video_uuid",
"video_url": "${PEERTUBE_URL}/w/${video_uuid}",
"title": $(echo "$title" | jq -Rs . 2>/dev/null || echo "\"$title\""),
"output_dir": "$output_dir"
}
EOF
return 0
}
#=============================================================================
# CLI PARSING
#=============================================================================
show_help() {
cat << EOF
PeerTube Video Import with Subtitle Sync
SecuBox Intelligence Module v${SCRIPT_VERSION}
Usage:
$(basename "$0") [OPTIONS] <url>
Options:
--url <url> Video URL (YouTube, Vimeo, etc.)
--lang <codes> Subtitle languages (comma-separated: fr,en,de)
Default: fr,en
--channel <id> PeerTube channel ID (default: 1)
--privacy <level> 1=public, 2=unlisted, 3=private (default: 1)
--username <user> PeerTube username (or set via UCI)
--password <pass> PeerTube password (or set via UCI)
--output <dir> Output directory (default: /tmp/peertube-import)
--peertube <url> PeerTube instance URL
-h, --help Show this help message
Environment Variables:
PEERTUBE_URL PeerTube instance URL
PEERTUBE_TOKEN OAuth access token (skip authentication)
Examples:
# Basic import
$(basename "$0") https://youtube.com/watch?v=xxx
# Import with multiple subtitle languages
$(basename "$0") --lang fr,en,de,es https://youtube.com/watch?v=xxx
# Import as unlisted video
$(basename "$0") --privacy 2 https://youtube.com/watch?v=xxx
EOF
}
parse_args() {
VIDEO_URL=""
LANGUAGES="fr,en"
CHANNEL_ID=""
PRIVACY=""
USERNAME=""
PASSWORD=""
while [ $# -gt 0 ]; do
case "$1" in
--url)
VIDEO_URL="$2"
shift 2
;;
--lang|--languages)
LANGUAGES="$2"
shift 2
;;
--channel)
CHANNEL_ID="$2"
shift 2
;;
--privacy)
PRIVACY="$2"
shift 2
;;
--username)
USERNAME="$2"
shift 2
;;
--password)
PASSWORD="$2"
shift 2
;;
--output)
OUTPUT_BASE="$2"
shift 2
;;
--peertube)
PEERTUBE_URL="$2"
shift 2
;;
-h|--help)
show_help
exit 0
;;
-*)
log_error "Unknown option: $1"
show_help
exit 1
;;
*)
# Positional argument = URL
if [ -z "$VIDEO_URL" ]; then
VIDEO_URL="$1"
else
log_error "Multiple URLs not supported"
exit 1
fi
shift
;;
esac
done
if [ -z "$VIDEO_URL" ]; then
log_error "No video URL provided"
show_help
exit 1
fi
}
#=============================================================================
# ENTRY POINT
#=============================================================================
main() {
parse_args "$@"
echo >&2 ""
echo >&2 "╔══════════════════════════════════════════════════════╗"
echo >&2 "║ PeerTube Video Import v${SCRIPT_VERSION} ║"
echo >&2 "║ SecuBox Intelligence Module ║"
echo >&2 "╚══════════════════════════════════════════════════════╝"
echo >&2 ""
# Check dependencies
for dep in yt-dlp curl; do
if ! command -v "$dep" >/dev/null 2>&1; then
log_error "Required dependency not found: $dep"
exit 1
fi
done
import_video "$VIDEO_URL" "$LANGUAGES" "$CHANNEL_ID" "$PRIVACY" "$USERNAME" "$PASSWORD"
exit $?
}
# Run if not sourced
if [ "${0##*/}" = "peertube-import" ] || [ "${0##*/}" = "sh" ]; then
main "$@"
fi

View File

@ -0,0 +1,164 @@
#!/bin/sh
# CGI endpoint for PeerTube video import
# Returns JSON response
# Set headers
printf "Content-Type: application/json\r\n"
printf "Access-Control-Allow-Origin: *\r\n"
printf "Access-Control-Allow-Methods: POST, OPTIONS\r\n"
printf "Access-Control-Allow-Headers: Content-Type\r\n"
printf "\r\n"
# Handle OPTIONS (CORS preflight)
if [ "$REQUEST_METHOD" = "OPTIONS" ]; then
exit 0
fi
# Only allow POST
if [ "$REQUEST_METHOD" != "POST" ]; then
echo '{"error": "Method not allowed"}'
exit 0
fi
# Read input
INPUT=$(cat)
# Parse JSON (use jq if available, else jsonfilter)
if command -v jq >/dev/null 2>&1; then
URL=$(echo "$INPUT" | jq -r '.url // empty')
LANGUAGES=$(echo "$INPUT" | jq -r '.languages // "fr,en"')
CHANNEL_ID=$(echo "$INPUT" | jq -r '.channel_id // "1"')
PRIVACY=$(echo "$INPUT" | jq -r '.privacy // "1"')
else
URL=$(echo "$INPUT" | jsonfilter -e '@.url' 2>/dev/null)
LANGUAGES=$(echo "$INPUT" | jsonfilter -e '@.languages' 2>/dev/null)
CHANNEL_ID=$(echo "$INPUT" | jsonfilter -e '@.channel_id' 2>/dev/null)
PRIVACY=$(echo "$INPUT" | jsonfilter -e '@.privacy' 2>/dev/null)
fi
# Handle array of languages
if echo "$LANGUAGES" | grep -q '^\['; then
if command -v jq >/dev/null 2>&1; then
LANGUAGES=$(echo "$INPUT" | jq -r '.languages | if type == "array" then join(",") else . end')
else
LANGUAGES="fr,en"
fi
fi
# Validate URL
if [ -z "$URL" ]; then
echo '{"error": "URL is required"}'
exit 0
fi
# Sanitize URL (basic security check)
case "$URL" in
http://*|https://*)
# Valid URL prefix
;;
*)
echo '{"error": "Invalid URL format"}'
exit 0
;;
esac
# Set defaults
[ -z "$LANGUAGES" ] && LANGUAGES="fr,en"
[ -z "$CHANNEL_ID" ] && CHANNEL_ID="1"
[ -z "$PRIVACY" ] && PRIVACY="1"
# Generate job ID
JOB_ID="import_$(date +%s)_$$"
STATUS_FILE="/tmp/peertube-import-${JOB_ID}.status"
RESULT_FILE="/tmp/peertube-import-${JOB_ID}.json"
LOG_FILE="/tmp/peertube-import-${JOB_ID}.log"
PROGRESS_FILE="/tmp/peertube-import-${JOB_ID}.progress"
# Check for import script
if [ ! -x "/usr/sbin/peertube-import" ]; then
echo '{"error": "peertube-import not installed"}'
exit 0
fi
# Initialize status
echo "starting" > "$STATUS_FILE"
echo "0" > "$PROGRESS_FILE"
# Start import in background
(
echo "downloading" > "$STATUS_FILE"
echo "10" > "$PROGRESS_FILE"
# Run the import
OUTPUT=$(/usr/sbin/peertube-import \
--lang "$LANGUAGES" \
--channel "$CHANNEL_ID" \
--privacy "$PRIVACY" \
"$URL" 2>&1)
RC=$?
echo "$OUTPUT" >> "$LOG_FILE"
if [ $RC -eq 0 ]; then
echo "completed" > "$STATUS_FILE"
echo "100" > "$PROGRESS_FILE"
# Extract JSON result from output (last JSON block)
local result_json
result_json=$(echo "$OUTPUT" | grep -E '^\{' | tail -1)
if [ -n "$result_json" ]; then
# Parse and rebuild result
if command -v jq >/dev/null 2>&1; then
VIDEO_ID=$(echo "$result_json" | jq -r '.video_id // empty')
VIDEO_UUID=$(echo "$result_json" | jq -r '.video_uuid // empty')
VIDEO_URL=$(echo "$result_json" | jq -r '.video_url // empty')
TITLE=$(echo "$result_json" | jq -r '.title // "Imported Video"')
else
VIDEO_ID=$(echo "$result_json" | jsonfilter -e '@.video_id' 2>/dev/null)
VIDEO_UUID=$(echo "$result_json" | jsonfilter -e '@.video_uuid' 2>/dev/null)
VIDEO_URL=$(echo "$result_json" | jsonfilter -e '@.video_url' 2>/dev/null)
TITLE="Imported Video"
fi
cat > "$RESULT_FILE" << EOF
{
"success": true,
"job_id": "$JOB_ID",
"video_id": $VIDEO_ID,
"video_uuid": "$VIDEO_UUID",
"video_url": "$VIDEO_URL",
"title": "$TITLE",
"status": "completed"
}
EOF
else
cat > "$RESULT_FILE" << EOF
{
"success": true,
"job_id": "$JOB_ID",
"status": "completed",
"message": "Import completed but no result JSON found"
}
EOF
fi
else
echo "failed" > "$STATUS_FILE"
echo "0" > "$PROGRESS_FILE"
# Get last error from log
ERROR_MSG=$(tail -5 "$LOG_FILE" 2>/dev/null | tr '\n' ' ' | sed 's/"/\\"/g' | head -c 500)
cat > "$RESULT_FILE" << EOF
{
"success": false,
"job_id": "$JOB_ID",
"status": "failed",
"error": "$ERROR_MSG"
}
EOF
fi
) &
# Return job ID for polling
echo "{\"success\": true, \"message\": \"Import started\", \"job_id\": \"$JOB_ID\"}"

View File

@ -0,0 +1,69 @@
#!/bin/sh
# CGI endpoint for checking PeerTube import status
# Returns JSON response
# Set headers
printf "Content-Type: application/json\r\n"
printf "Access-Control-Allow-Origin: *\r\n"
printf "\r\n"
# Get job_id from query string or POST body
JOB_ID=""
if [ -n "$QUERY_STRING" ]; then
JOB_ID=$(echo "$QUERY_STRING" | sed -n 's/.*job_id=\([^&]*\).*/\1/p')
fi
if [ -z "$JOB_ID" ] && [ "$REQUEST_METHOD" = "POST" ]; then
INPUT=$(cat)
if command -v jq >/dev/null 2>&1; then
JOB_ID=$(echo "$INPUT" | jq -r '.job_id // empty')
else
JOB_ID=$(echo "$INPUT" | jsonfilter -e '@.job_id' 2>/dev/null)
fi
fi
if [ -z "$JOB_ID" ]; then
echo '{"error": "job_id is required"}'
exit 0
fi
# Sanitize job_id (only allow alphanumeric and underscore)
JOB_ID=$(echo "$JOB_ID" | tr -cd 'a-zA-Z0-9_')
STATUS_FILE="/tmp/peertube-import-${JOB_ID}.status"
RESULT_FILE="/tmp/peertube-import-${JOB_ID}.json"
LOG_FILE="/tmp/peertube-import-${JOB_ID}.log"
PROGRESS_FILE="/tmp/peertube-import-${JOB_ID}.progress"
# Check if job exists
if [ ! -f "$STATUS_FILE" ]; then
echo '{"error": "Job not found", "job_id": "'"$JOB_ID"'"}'
exit 0
fi
STATUS=$(cat "$STATUS_FILE" 2>/dev/null || echo "unknown")
PROGRESS=$(cat "$PROGRESS_FILE" 2>/dev/null || echo "0")
# If completed or failed, return full result
if [ -f "$RESULT_FILE" ]; then
if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then
cat "$RESULT_FILE"
exit 0
fi
fi
# Otherwise return status with progress and logs
LOGS=""
if [ -f "$LOG_FILE" ]; then
LOGS=$(tail -3 "$LOG_FILE" 2>/dev/null | tr '\n' ' ' | sed 's/"/\\"/g' | head -c 300)
fi
cat << EOF
{
"status": "$STATUS",
"job_id": "$JOB_ID",
"progress": $PROGRESS,
"logs": "$LOGS"
}
EOF