- >
- MP4, WebM ou MOV. La vidéo sera hébergée sur PeerTube. Max 500 MB.
+ MP4, WebM ou MOV. Max 500 MB. Glissez pour réordonner. Hébergé sur PeerTube.
-
+
@@ -291,27 +317,25 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
MP4, WebM ou MOV. Max 500 MB. Glissez pour réordonner.
-
- -->
-
-
-
-
-
+
+
- >
- MP3, OGG, WAV, FLAC ou AAC. Le fichier sera hébergé sur PeerTube. Max 500 MB.
+ MP3, OGG, WAV, FLAC ou AAC. Max 500 MB. Glissez pour réordonner. Hébergé sur PeerTube.
-
+
@@ -324,12 +348,9 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
MP3, OGG, WAV, FLAC ou AAC. Max 500 MB. Glissez pour réordonner.
-
- -->
-
diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php
index a7004c2..fb98971 100644
--- a/app/src/Controllers/ThesisCreateController.php
+++ b/app/src/Controllers/ThesisCreateController.php
@@ -209,9 +209,11 @@ class ThesisCreateController
$nextNum = $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $nextNum);
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
- // ── 5b. PeerTube video / audio uploads ────────────────────────────────
- $this->handlePeerTubeUpload($thesisId, $data['titre'], $files, 'peertube_video');
- $this->handlePeerTubeUpload($thesisId, $data['titre'], $files, 'peertube_audio');
+ // ── 5b. PeerTube video / audio uploads (from FilePond queue) ──────────
+ $qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
+ $qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
+ $this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTVideo, 'video');
+ $this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTAudio, 'audio');
// ── 6. Website URL — stored as thesis_files row ──────────────────────
$this->handleWebsiteUrl($thesisId, $post);
@@ -581,17 +583,19 @@ class ThesisCreateController
}
/**
- * Upload a video or audio file to PeerTube when the feature is enabled.
+ * Upload PeerTube video/audio files from FilePond queue.
*
- * @param int $thesisId Thesis to attach the result to.
- * @param string $title Title to use on PeerTube.
- * @param array $files $_FILES array.
- * @param string $inputName 'peertube_video' or 'peertube_audio'.
+ * Files arrive via PHP's nested $_FILES structure from
+ * .
+ *
+ * @param int $thesisId Thesis to attach the results to.
+ * @param string $title Title to use on PeerTube.
+ * @param array|null $uploads Flat $_FILES-style array from extractFilesSubArray().
+ * @param string $fileType 'video' or 'audio'.
*/
- protected function handlePeerTubeUpload(int $thesisId, string $title, array $files, string $inputName): void
+ protected function handlePeerTubeQueueFiles(int $thesisId, string $title, ?array $uploads, string $fileType): void
{
- $upload = $files[$inputName] ?? null;
- if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
+ if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
}
@@ -600,29 +604,37 @@ class ThesisCreateController
return;
}
- try {
- $watchUrl = PeerTubeService::upload(
- $this->db,
- $upload['tmp_name'],
- $title,
- ''
- );
+ $count = count($uploads['name']);
+ for ($i = 0; $i < $count; $i++) {
+ if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+ continue;
+ }
- $fileType = str_contains($inputName, 'audio') ? 'audio' : 'video';
- $this->db->insertThesisFile(
- $thesisId,
- $fileType,
- $watchUrl, // stored as the watch URL (no local file)
- basename($upload['name']),
- $upload['size'],
- $upload['type'] ?? 'application/octet-stream',
- null,
- null
- );
- error_log("ThesisCreateController: PeerTube upload OK → $watchUrl");
- } catch (\Throwable $e) {
- error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage());
- // Non-fatal: the thesis is already saved; admin can re-upload manually.
+ try {
+ $result = PeerTubeService::upload(
+ $this->db,
+ $uploads['tmp_name'][$i],
+ $title,
+ ''
+ );
+
+ // Store as peertube_ids:{uuid} so the embed template can extract the UUID
+ $storedPath = 'peertube_ids:' . $result['uuid'];
+ $this->db->insertThesisFile(
+ $thesisId,
+ $fileType,
+ $storedPath,
+ basename($uploads['name'][$i]),
+ $uploads['size'][$i],
+ $uploads['type'][$i] ?? 'application/octet-stream',
+ null,
+ null
+ );
+ error_log("ThesisCreateController: PeerTube upload OK → " . $result['watchUrl']);
+ } catch (\Throwable $e) {
+ error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage());
+ // Non-fatal: thesis already saved; admin can re-upload manually.
+ }
}
}
diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php
index cb8b9e5..fd8e589 100644
--- a/app/src/Controllers/ThesisEditController.php
+++ b/app/src/Controllers/ThesisEditController.php
@@ -437,9 +437,11 @@ class ThesisEditController
$this->handleAnnexeFiles($thesisId, $files['annexes'], $folderPath, $filePrefix, $post);
}
- // ── PeerTube video / audio uploads ────────────────────────────────────
- $this->handlePeerTubeUpload($thesisId, trim($post['titre'] ?? ''), $files, 'peertube_video');
- $this->handlePeerTubeUpload($thesisId, trim($post['titre'] ?? ''), $files, 'peertube_audio');
+ // ── PeerTube video / audio uploads (from FilePond queue) ──────────────
+ $qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
+ $qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
+ $this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTVideo, 'video');
+ $this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTAudio, 'audio');
// ── Website URL — add or update ──────────────────────────────────────
$this->handleWebsiteUrl($thesisId, $post);
@@ -571,17 +573,19 @@ class ThesisEditController
}
/**
- * Upload a video or audio file to PeerTube when the feature is enabled.
+ * Upload PeerTube video/audio files from FilePond queue.
*
- * @param int $thesisId Thesis to attach the result to.
- * @param string $title Title to use on PeerTube.
- * @param array $files $_FILES array.
- * @param string $inputName 'peertube_video' or 'peertube_audio'.
+ * Files arrive via PHP's nested $_FILES structure from
+ * .
+ *
+ * @param int $thesisId Thesis to attach the results to.
+ * @param string $title Title to use on PeerTube.
+ * @param array|null $uploads Flat $_FILES-style array from extractFilesSubArray().
+ * @param string $fileType 'video' or 'audio'.
*/
- private function handlePeerTubeUpload(int $thesisId, string $title, array $files, string $inputName): void
+ private function handlePeerTubeQueueFiles(int $thesisId, string $title, ?array $uploads, string $fileType): void
{
- $upload = $files[$inputName] ?? null;
- if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
+ if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
}
@@ -590,28 +594,35 @@ class ThesisEditController
return;
}
- try {
- $watchUrl = PeerTubeService::upload(
- $this->db,
- $upload['tmp_name'],
- $title,
- ''
- );
+ $count = count($uploads['name']);
+ for ($i = 0; $i < $count; $i++) {
+ if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+ continue;
+ }
- $fileType = str_contains($inputName, 'audio') ? 'audio' : 'video';
- $this->db->insertThesisFile(
- $thesisId,
- $fileType,
- $watchUrl,
- basename($upload['name']),
- $upload['size'],
- $upload['type'] ?? 'application/octet-stream',
- null,
- null
- );
- error_log("ThesisEditController: PeerTube upload OK → $watchUrl");
- } catch (\Throwable $e) {
- error_log('ThesisEditController: PeerTube upload failed — ' . $e->getMessage());
+ try {
+ $result = PeerTubeService::upload(
+ $this->db,
+ $uploads['tmp_name'][$i],
+ $title,
+ ''
+ );
+
+ $storedPath = 'peertube_ids:' . $result['uuid'];
+ $this->db->insertThesisFile(
+ $thesisId,
+ $fileType,
+ $storedPath,
+ basename($uploads['name'][$i]),
+ $uploads['size'][$i],
+ $uploads['type'][$i] ?? 'application/octet-stream',
+ null,
+ null
+ );
+ error_log("ThesisEditController: PeerTube upload OK → " . $result['watchUrl']);
+ } catch (\Throwable $e) {
+ error_log('ThesisEditController: PeerTube upload failed — ' . $e->getMessage());
+ }
}
}
diff --git a/app/src/PeerTubeService.php b/app/src/PeerTubeService.php
index 28307da..87193f6 100644
--- a/app/src/PeerTubeService.php
+++ b/app/src/PeerTubeService.php
@@ -3,108 +3,118 @@
/**
* PeerTubeService
*
- * Handles credential storage and video/audio uploads to a PeerTube instance
- * via its REST API. Follows the same patterns as SmtpRelay:
- * - Static CRUD on a dedicated settings table (peertube_settings)
- * - A feature-flag setting (peertube_upload_enabled) in site_settings
- * - An upload() method that POSTs a file to the PeerTube /api/v1/videos/upload
- * endpoint and returns the resulting watch URL
+ * Handles video/audio uploads to a PeerTube instance via its REST API.
*
- * PeerTube API reference:
- * POST /api/v1/videos/upload — resumable / direct upload
- * POST /api/v1/users/token — OAuth2 password grant
+ * Credentials are shared with SmtpRelay: the SMTP username/password are
+ * reused for PeerTube OAuth2 password-grant authentication. Only
+ * PeerTube-specific settings (instance URL, channel name, privacy, labels)
+ * live in peertube_settings.
*
- * The stored access token is refreshed automatically when it expires (401).
- * Credentials (password + token) are encrypted at rest via Crypto.php.
+ * The channel is stored by its full handle (name@host). The numeric ID
+ * is resolved via GET /api/v1/video-channels/{handle} at upload time.
+ *
+ * OAuth client_id / client_secret are fetched once from the PeerTube
+ * instance's /api/v1/oauth-clients/local endpoint and cached in-memory
+ * per process lifetime.
+ *
+ * Upload uses the resumable protocol:
+ * POST /api/v1/videos/upload-resumable — init
+ * PUT /api/v1/videos/upload-resumable — send chunk
+ * DELETE /api/v1/videos/upload-resumable — cancel
*/
class PeerTubeService
{
+ /** @var array In-memory OAuth cache. */
+ private static array $oauthCache = [];
+
+ /** @var array In-memory channel name → ID cache. */
+ private static array $channelCache = [];
+
// -------------------------------------------------------------------------
// DB CRUD
// -------------------------------------------------------------------------
/**
- * Return current PeerTube settings from the DB.
+ * Return PeerTube settings merged with SMTP credentials.
*
- * @return array{instance_url:string,username:string,password:string,channel_id:int,privacy:int}
+ * @return array{instance_url:string,username:string,password:string,channel_name:string,privacy:int,peertube_video_label:string,peertube_audio_label:string}
*/
public static function getSettings(Database $db): array
{
$stmt = $db->getPDO()->prepare(
- 'SELECT instance_url, username, password, channel_id, privacy
+ 'SELECT instance_url, channel_name, privacy,
+ peertube_video_label, peertube_audio_label
FROM peertube_settings WHERE id = 1 LIMIT 1'
);
$stmt->execute();
- $row = $stmt->fetch();
+ $row = $stmt->fetch() ?: [];
- if ($row) {
- require_once __DIR__ . '/Crypto.php';
- $row['password'] = Crypto::decrypt($row['password']);
- }
+ require_once __DIR__ . '/SmtpRelay.php';
+ $smtp = SmtpRelay::getSettings($db);
- return $row ?: [
- 'instance_url' => '',
- 'username' => '',
- 'password' => '',
- 'channel_id' => 1,
- 'privacy' => 1, // 1=Public, 2=Unlisted, 3=Private
+ return [
+ 'instance_url' => $row['instance_url'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'channel_name' => $row['channel_name'] ?? '',
+ 'privacy' => (int)($row['privacy'] ?? 1),
+ 'peertube_video_label' => $row['peertube_video_label'] ?? '',
+ 'peertube_audio_label' => $row['peertube_audio_label'] ?? '',
];
}
/**
- * Upsert PeerTube settings.
+ * Upsert PeerTube-specific settings.
*/
public static function updateSettings(Database $db, array $data): void
{
$current = self::getSettings($db);
$merged = array_merge($current, $data);
- require_once __DIR__ . '/Crypto.php';
-
- // Normalise instance URL: strip trailing slash
$instanceUrl = rtrim(trim($merged['instance_url']), '/');
- $channelId = max(1, (int)$merged['channel_id']);
+ $channelName = trim($merged['channel_name'] ?? $current['channel_name'] ?? '');
$privacy = in_array((int)$merged['privacy'], [1, 2, 3], true)
? (int)$merged['privacy'] : 1;
+ $videoLabel = trim($merged['peertube_video_label'] ?? '');
+ $audioLabel = trim($merged['peertube_audio_label'] ?? '');
$pdo = $db->getPDO();
- // Upsert row (id=1 is always the singleton row)
$exists = $pdo->query('SELECT COUNT(*) FROM peertube_settings WHERE id = 1')->fetchColumn();
if ($exists) {
$stmt = $pdo->prepare(
'UPDATE peertube_settings
- SET instance_url = :url,
- username = :user,
- password = :pass,
- channel_id = :chan,
- privacy = :priv,
- updated_at = CURRENT_TIMESTAMP
+ SET instance_url = :url,
+ channel_name = :chan,
+ privacy = :priv,
+ peertube_video_label = :vlabel,
+ peertube_audio_label = :alabel,
+ updated_at = CURRENT_TIMESTAMP
WHERE id = 1'
);
} else {
$stmt = $pdo->prepare(
- 'INSERT INTO peertube_settings (id, instance_url, username, password, channel_id, privacy)
- VALUES (1, :url, :user, :pass, :chan, :priv)'
+ 'INSERT INTO peertube_settings (id, instance_url, channel_name, privacy, peertube_video_label, peertube_audio_label)
+ VALUES (1, :url, :chan, :priv, :vlabel, :alabel)'
);
}
$stmt->execute([
- ':url' => $instanceUrl,
- ':user' => trim($merged['username']),
- ':pass' => Crypto::encrypt($merged['password']),
- ':chan' => $channelId,
- ':priv' => $privacy,
+ ':url' => $instanceUrl,
+ ':chan' => $channelName,
+ ':priv' => $privacy,
+ ':vlabel' => $videoLabel,
+ ':alabel' => $audioLabel,
]);
}
/**
- * Whether PeerTube credentials are fully configured.
+ * Whether PeerTube is fully configured.
*/
public static function isConfigured(Database $db): bool
{
$s = self::getSettings($db);
- return $s['instance_url'] !== '' && $s['username'] !== '';
+ return $s['instance_url'] !== '' && $s['username'] !== '' && $s['channel_name'] !== '';
}
/**
@@ -116,7 +126,7 @@ class PeerTubeService
}
/**
- * Test connectivity: obtain a token without uploading anything.
+ * Test connectivity: obtain a token and resolve the channel.
*
* @return array{ok:bool, error:string}
*/
@@ -126,8 +136,15 @@ class PeerTubeService
if ($s['instance_url'] === '') {
return ['ok' => false, 'error' => "URL de l'instance PeerTube non configurée."];
}
+ if ($s['channel_name'] === '') {
+ return ['ok' => false, 'error' => 'Nom de la chaîne PeerTube non configuré.'];
+ }
try {
- self::obtainToken($s);
+ $token = self::obtainToken($s);
+ $chId = self::resolveChannelId($s);
+ if ($chId === null) {
+ return ['ok' => false, 'error' => "Chaîne « {$s['channel_name']} » introuvable sur l'instance."];
+ }
return ['ok' => true, 'error' => ''];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
@@ -135,97 +152,266 @@ class PeerTubeService
}
// -------------------------------------------------------------------------
- // Upload
+ // Upload — resumable protocol
// -------------------------------------------------------------------------
/**
- * Upload a local file to PeerTube.
+ * Upload a local file to PeerTube using the resumable upload protocol.
*
- * @param string $filePath Absolute path to the file on disk.
- * @param string $title Video/audio title shown on PeerTube.
- * @param string $description Optional description.
- * @return string The public watch URL of the uploaded video.
- * @throws \RuntimeException On any API or network error.
+ * @return array{uuid:string, watchUrl:string}
+ * @throws \RuntimeException
*/
public static function upload(
Database $db,
string $filePath,
string $title,
string $description = ''
- ): string {
+ ): array {
$s = self::getSettings($db);
if ($s['instance_url'] === '') {
throw new \RuntimeException('PeerTube non configuré.');
}
- $token = self::obtainToken($s);
-
- $baseUrl = $s['instance_url'];
- $uploadUrl = $baseUrl . '/api/v1/videos/upload';
-
- // Build multipart body
- $boundary = '----XamxamPT' . bin2hex(random_bytes(8));
- $body = '';
-
- $fields = [
- 'channelId' => (string)$s['channel_id'],
- 'name' => $title,
- 'description' => $description,
- 'privacy' => (string)$s['privacy'],
- 'waitTranscoding' => 'false',
- ];
- foreach ($fields as $k => $v) {
- $body .= "--{$boundary}\r\n";
- $body .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n";
- $body .= $v . "\r\n";
+ $channelId = self::resolveChannelId($s);
+ if ($channelId === null) {
+ throw new \RuntimeException('Chaîne PeerTube introuvable : ' . $s['channel_name']);
}
- // File part
+ $token = self::obtainToken($s);
+ $baseUrl = $s['instance_url'];
+ $fileSize = filesize($filePath);
$fileName = basename($filePath);
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($filePath);
- $body .= "--{$boundary}\r\n";
- $body .= "Content-Disposition: form-data; name=\"videofile\"; filename=\"{$fileName}\"\r\n";
- $body .= "Content-Type: {$mimeType}\r\n\r\n";
- $body .= file_get_contents($filePath) . "\r\n";
- $body .= "--{$boundary}--\r\n";
- $response = self::httpRequest($uploadUrl, 'POST', $body, [
+ // ── Step 1: Initialize resumable upload ──
+ $initUrl = $baseUrl . '/api/v1/videos/upload-resumable';
+ $initData = [
+ 'channelId' => $channelId,
+ 'name' => $title,
+ 'privacy' => (int)$s['privacy'],
+ 'waitTranscoding' => false,
+ 'filename' => $fileName,
+ ];
+ if ($description !== '') {
+ $initData['description'] = $description;
+ }
+ $initBody = json_encode($initData);
+
+ $initResponse = self::httpRequest($initUrl, 'POST', $initBody, [
'Authorization: Bearer ' . $token,
- 'Content-Type: multipart/form-data; boundary=' . $boundary,
- 'Content-Length: ' . strlen($body),
+ 'Content-Type: application/json',
+ 'X-Upload-Content-Length: ' . $fileSize,
+ 'X-Upload-Content-Type: ' . $mimeType,
+ 'Content-Length: ' . strlen($initBody),
]);
- $json = json_decode($response['body'], true);
-
- if ($response['status'] < 200 || $response['status'] >= 300) {
- $msg = $json['error'] ?? $json['detail'] ?? $response['body'];
- throw new \RuntimeException('PeerTube upload failed (' . $response['status'] . '): ' . $msg);
+ $initJson = json_decode($initResponse['body'], true);
+ if ($initResponse['status'] < 200 || $initResponse['status'] >= 300) {
+ $msg = $initJson['error'] ?? $initJson['detail'] ?? $initResponse['body'];
+ throw new \RuntimeException('PeerTube upload init failed (' . $initResponse['status'] . '): ' . $msg);
}
- $shortUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null;
+ // Small files may complete in one shot
+ $shortUuid = $initJson['video']['shortUUID'] ?? $initJson['video']['uuid'] ?? null;
+ if ($shortUuid && !isset($initJson['video']['id'])) {
+ $watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
+ return ['uuid' => $shortUuid, 'watchUrl' => $watchUrl];
+ }
+
+ $uploadId = $initJson['video']['id'] ?? null;
+ if (!$uploadId) {
+ throw new \RuntimeException('PeerTube upload init: no upload session returned.');
+ }
+
+ // ── Step 2: Send chunks ──
+ $chunkUrl = $baseUrl . '/api/v1/videos/upload-resumable?upload_id=' . urlencode((string)$uploadId);
+
+ $fh = fopen($filePath, 'rb');
+ if (!$fh) {
+ throw new \RuntimeException('Cannot open file for resumable upload.');
+ }
+
+ $chunkSize = 4 * 1024 * 1024;
+ $offset = 0;
+ $lastResponse = null;
+
+ while ($offset < $fileSize) {
+ $chunk = fread($fh, $chunkSize);
+ $chunkLen = strlen($chunk);
+ $end = $offset + $chunkLen - 1;
+
+ $resp = self::httpRequest($chunkUrl, 'PUT', $chunk, [
+ 'Authorization: Bearer ' . $token,
+ 'Content-Type: application/octet-stream',
+ 'Content-Range: bytes ' . $offset . '-' . $end . '/' . $fileSize,
+ 'Content-Length: ' . $chunkLen,
+ ], 600);
+
+ $offset += $chunkLen;
+
+ if ($resp['status'] >= 200 && $resp['status'] < 300) {
+ $json = json_decode($resp['body'], true);
+ if (isset($json['video']['shortUUID']) || isset($json['video']['uuid'])) {
+ $lastResponse = $resp;
+ break;
+ }
+ } elseif ($resp['status'] === 308) {
+ continue;
+ } else {
+ fclose($fh);
+ try {
+ self::httpRequest($chunkUrl, 'DELETE', '', [
+ 'Authorization: Bearer ' . $token, 'Content-Length: 0',
+ ], 10);
+ } catch (\Throwable $e) { /* ignore */ }
+ $errJson = json_decode($resp['body'], true);
+ $msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body'];
+ throw new \RuntimeException('PeerTube chunk upload failed (' . $resp['status'] . '): ' . $msg);
+ }
+ }
+ fclose($fh);
+
+ if (!$lastResponse) {
+ throw new \RuntimeException('PeerTube upload: no completion response.');
+ }
+
+ $finalJson = json_decode($lastResponse['body'], true);
+ $shortUuid = $finalJson['video']['shortUUID'] ?? $finalJson['video']['uuid'] ?? null;
if ($shortUuid === null) {
throw new \RuntimeException('PeerTube upload: no video UUID in response.');
}
- return rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
+ $watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
+ return ['uuid' => $shortUuid, 'watchUrl' => $watchUrl];
+ }
+
+ // -------------------------------------------------------------------------
+ // Fetch video info / watch URL
+ // -------------------------------------------------------------------------
+
+ /**
+ * Fetch video metadata from PeerTube by UUID or shortUUID.
+ */
+ public static function fetchVideoInfo(Database $db, string $uuid): ?array
+ {
+ $s = self::getSettings($db);
+ if ($s['instance_url'] === '') {
+ return null;
+ }
+ try {
+ $token = self::obtainToken($s);
+ $url = $s['instance_url'] . '/api/v1/videos/' . urlencode($uuid);
+ $resp = self::httpRequest($url, 'GET', '', [
+ 'Authorization: Bearer ' . $token,
+ ], 10);
+ if ($resp['status'] !== 200) {
+ return null;
+ }
+ return json_decode($resp['body'], true);
+ } catch (\Throwable $e) {
+ error_log('PeerTubeService::fetchVideoInfo failed: ' . $e->getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Get the watch URL for a PeerTube video by UUID.
+ */
+ public static function getWatchUrl(Database $db, string $uuid): string
+ {
+ $s = self::getSettings($db);
+ return rtrim($s['instance_url'], '/') . '/videos/watch/' . $uuid;
+ }
+
+ // -------------------------------------------------------------------------
+ // Channel resolution
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolve a channel handle (name@host) to its numeric ID via the PeerTube API.
+ *
+ * GET /api/v1/video-channels/{nameWithHost}
+ *
+ * @return int|null The channel numeric ID, or null if not found.
+ */
+ public static function resolveChannelId(array $s): ?int
+ {
+ $name = $s['channel_name'] ?? '';
+ if ($name === '' || empty($s['instance_url'])) {
+ return null;
+ }
+
+ $cacheKey = $s['instance_url'] . '|' . $name;
+ if (isset(self::$channelCache[$cacheKey])) {
+ return self::$channelCache[$cacheKey];
+ }
+
+ try {
+ $token = self::obtainToken($s);
+ $url = rtrim($s['instance_url'], '/') . '/api/v1/video-channels/' . urlencode($name);
+ $resp = self::httpRequest($url, 'GET', '', [
+ 'Authorization: Bearer ' . $token,
+ ], 10);
+
+ if ($resp['status'] !== 200) {
+ return null;
+ }
+ $json = json_decode($resp['body'], true);
+ $id = (int)($json['id'] ?? 0);
+ if ($id > 0) {
+ self::$channelCache[$cacheKey] = $id;
+ return $id;
+ }
+ return null;
+ } catch (\Throwable $e) {
+ error_log('PeerTubeService::resolveChannelId failed: ' . $e->getMessage());
+ return null;
+ }
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
+ /**
+ * Fetch OAuth client credentials from the PeerTube instance.
+ * Results are cached in-memory per process.
+ */
+ private static function getOAuthClient(string $instanceUrl): array
+ {
+ $key = $instanceUrl;
+ if (isset(self::$oauthCache[$key])) {
+ return self::$oauthCache[$key];
+ }
+
+ $url = rtrim($instanceUrl, '/') . '/api/v1/oauth-clients/local';
+ $response = self::httpRequest($url, 'GET', '', [], 10);
+
+ $json = json_decode($response['body'], true);
+ if ($response['status'] !== 200 || empty($json['client_id'])) {
+ throw new \RuntimeException(
+ 'Impossible de récupérer les identifiants OAuth de l\'instance PeerTube (' . $response['status'] . ').'
+ );
+ }
+
+ self::$oauthCache[$key] = [
+ 'client_id' => $json['client_id'],
+ 'client_secret' => $json['client_secret'],
+ ];
+ return self::$oauthCache[$key];
+ }
+
/**
* Obtain an OAuth2 access token via password grant.
- *
- * @throws \RuntimeException on failure.
*/
private static function obtainToken(array $s): string
{
+ $oauth = self::getOAuthClient($s['instance_url']);
$tokenUrl = $s['instance_url'] . '/api/v1/users/token';
$body = http_build_query([
- 'client_id' => self::getClientId($s),
- 'client_secret' => self::getClientSecret($s),
+ 'client_id' => $oauth['client_id'],
+ 'client_secret' => $oauth['client_secret'],
'grant_type' => 'password',
'response_type' => 'code',
'username' => $s['username'],
@@ -238,7 +424,6 @@ class PeerTubeService
]);
$json = json_decode($response['body'], true);
-
if ($response['status'] !== 200 || empty($json['access_token'])) {
$msg = $json['error_description'] ?? $json['error'] ?? $response['body'];
throw new \RuntimeException('PeerTube auth failed (' . $response['status'] . '): ' . $msg);
@@ -247,40 +432,16 @@ class PeerTubeService
return $json['access_token'];
}
- /**
- * Fetch the OAuth2 client_id from the PeerTube instance.
- */
- private static function getClientId(array $s): string
- {
- return self::getOAuthClient($s)['client_id'];
- }
-
- private static function getClientSecret(array $s): string
- {
- return self::getOAuthClient($s)['client_secret'];
- }
-
- private static function getOAuthClient(array $s): array
- {
- $url = $s['instance_url'] . '/api/v1/oauth-clients/local';
- $response = self::httpRequest($url, 'GET', '', []);
-
- $json = json_decode($response['body'], true);
- if ($response['status'] !== 200 || empty($json['client_id'])) {
- throw new \RuntimeException(
- 'Impossible de récupérer les identifiants OAuth de l\'instance PeerTube (' . $response['status'] . ').'
- );
- }
-
- return $json;
- }
+ // -------------------------------------------------------------------------
+ // HTTP helper
+ // -------------------------------------------------------------------------
/**
* Minimal cURL HTTP helper.
*
* @return array{status:int, body:string}
*/
- private static function httpRequest(
+ public static function httpRequest(
string $url,
string $method,
string $body,
@@ -306,12 +467,16 @@ class PeerTubeService
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ } elseif ($method === 'PUT') {
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ } elseif ($method === 'DELETE') {
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
}
$responseBody = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
- curl_close($ch);
if ($responseBody === false) {
throw new \RuntimeException('Erreur réseau PeerTube : ' . $error);
diff --git a/app/storage/schema.sql b/app/storage/schema.sql
index f75798c..65df00a 100644
--- a/app/storage/schema.sql
+++ b/app/storage/schema.sql
@@ -295,11 +295,12 @@ CREATE TABLE IF NOT EXISTS admin_audit_log (
CREATE TABLE IF NOT EXISTS peertube_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_url TEXT NOT NULL DEFAULT '',
- username TEXT NOT NULL DEFAULT '',
- password TEXT NOT NULL DEFAULT '',
channel_id INTEGER NOT NULL DEFAULT 1,
privacy INTEGER NOT NULL DEFAULT 1,
- updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ peertube_video_label TEXT NOT NULL DEFAULT '',
+ peertube_audio_label TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ channel_name TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS audit_log (
@@ -557,7 +558,7 @@ INSERT OR IGNORE INTO site_settings (key, value) VALUES ('access_type_interne_en
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('access_type_libre_enabled', '0');
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_frart_enabled', '0');
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_these_enabled', '0');
-INSERT OR IGNORE INTO site_settings (key, value) VALUES ('peertube_upload_enabled', '0');
+INSERT OR IGNORE INTO site_settings (key, value) VALUES ('peertube_upload_enabled', '1');
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('restricted_files_enabled', '1');
INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('about', 'À propos', 'Contenu à venir', 1);
diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php
index 21d6276..10c3f36 100644
--- a/app/templates/admin/acces.php
+++ b/app/templates/admin/acces.php
@@ -779,6 +779,58 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: qrtmmwro c96116c4 "fix: make schema.sql fully idempotent — add IF NOT EXISTS to all CREATE INDEX, CREATE TRIGGER, and CREATE VIEW statements" (rebased revision)
++ $linkName = $link['name'] ?? '';
+++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: qrtmmwro c96116c4 "fix: make schema.sql fully idempotent — add IF NOT EXISTS to all CREATE INDEX, CREATE TRIGGER, and CREATE VIEW statements" (rebased revision)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+- $linkName = $link['name'] ?? '';
+- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: sxpsrqsl 9b084ccb "feat: add PeerTube alternate audio/video labels and FilePond pools" (rebased revision)
+ $linkName = $link['name'] ?? '';
+ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+ $linkLockedYear = $link['locked_year'] ?? null;
++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
++\\\\\\\ to: sxpsrqsl 32184a7a "feat: add PeerTube alternate audio/video labels and FilePond pools" (rebased revision)
+++ $linkName = $link['name'] ?? '';
+++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: sxpsrqsl 32184a7a "feat: add PeerTube alternate audio/video labels and FilePond pools" (rebased revision)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+- $linkName = $link['name'] ?? '';
+- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: qmoswsvt 5f00501e "feat: shared SMTP credentials + resumable PeerTube upload + embed improvements" (rebased revision)
+ $linkName = $link['name'] ?? '';
+ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+ $linkLockedYear = $link['locked_year'] ?? null;
++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
++\\\\\\\ to: qmoswsvt 5cc189d2 "feat: shared SMTP credentials + resumable PeerTube upload + embed improvements" (rebased revision)
+++ $linkName = $link['name'] ?? '';
+++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: qmoswsvt 5cc189d2 "feat: shared SMTP credentials + resumable PeerTube upload + embed improvements" (rebased revision)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+- $linkName = $link['name'] ?? '';
+- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: mqoyqups 641a4db3 "feat: PeerTube channel by name, test button, always-visible FilePond pools" (rebased revision)
+ $linkName = $link['name'] ?? '';
+ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+ $linkLockedYear = $link['locked_year'] ?? null;
++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
++\\\\\\\ to: mqoyqups 03f89c7d "feat: PeerTube channel by name, test button, always-visible FilePond pools" (rebased revision)
+++ $linkName = $link['name'] ?? '';
+++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: mqoyqups 03f89c7d "feat: PeerTube channel by name, test button, always-visible FilePond pools" (rebased revision)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+- $linkName = $link['name'] ?? '';
+- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
+\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: rxwmppwn 3060cae8 "fix: remove alt labels, fix curl_close deprecation, fix PeerTube description param" (rebased revision)
+ $linkName = $link['name'] ?? '';
+ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
+ $linkLockedYear = $link['locked_year'] ?? null;
++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
++\\\\\\\ to: rxwmppwn 5ae93b41 "fix: remove alt labels, fix curl_close deprecation, fix PeerTube description param" (rebased revision)
+++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
?>