mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
- Ajout de PeerTubeService::deleteVideo() qui appelle DELETE /api/v1/videos/{uuid}
- deleteThesisFileToTrash() appelle maintenant deleteVideo() pour les fichiers peertube_ids:
- hardDeleteThesis() supprime aussi les vidéos PeerTube associées
485 lines
18 KiB
PHP
485 lines
18 KiB
PHP
<?php
|
|
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\GuzzleException;
|
|
|
|
/**
|
|
* PeerTubeService
|
|
*
|
|
* Handles video/audio uploads to a PeerTube instance via its REST API.
|
|
*
|
|
* 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 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 simple multipart upload API:
|
|
* POST /api/v1/videos/upload — multipart form upload via Guzzle
|
|
*/
|
|
class PeerTubeService
|
|
{
|
|
/** @var array<string,array{client_id:string,client_secret:string}> In-memory OAuth cache. */
|
|
private static array $oauthCache = [];
|
|
|
|
/** @var array<string,int> In-memory channel name → ID cache. */
|
|
private static array $channelCache = [];
|
|
|
|
/** @var Client|null Shared Guzzle client (lazy-init). */
|
|
private static ?Client $httpClient = null;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// DB CRUD
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return PeerTube settings merged with SMTP credentials.
|
|
*
|
|
* @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, channel_name, privacy,
|
|
peertube_video_label, peertube_audio_label
|
|
FROM peertube_settings WHERE id = 1 LIMIT 1'
|
|
);
|
|
$stmt->execute();
|
|
$row = $stmt->fetch() ?: [];
|
|
|
|
require_once __DIR__ . '/SmtpRelay.php';
|
|
$smtp = SmtpRelay::getSettings($db);
|
|
|
|
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-specific settings.
|
|
*/
|
|
public static function updateSettings(Database $db, array $data): void
|
|
{
|
|
$current = self::getSettings($db);
|
|
$merged = array_merge($current, $data);
|
|
|
|
$instanceUrl = rtrim(trim($merged['instance_url']), '/');
|
|
$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();
|
|
|
|
$exists = $pdo->query('SELECT COUNT(*) FROM peertube_settings WHERE id = 1')->fetchColumn();
|
|
if ($exists) {
|
|
$stmt = $pdo->prepare(
|
|
'UPDATE peertube_settings
|
|
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, channel_name, privacy, peertube_video_label, peertube_audio_label)
|
|
VALUES (1, :url, :chan, :priv, :vlabel, :alabel)'
|
|
);
|
|
}
|
|
|
|
$stmt->execute([
|
|
':url' => $instanceUrl,
|
|
':chan' => $channelName,
|
|
':priv' => $privacy,
|
|
':vlabel' => $videoLabel,
|
|
':alabel' => $audioLabel,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Whether PeerTube is fully configured.
|
|
*/
|
|
public static function isConfigured(Database $db): bool
|
|
{
|
|
$s = self::getSettings($db);
|
|
return $s['instance_url'] !== '' && $s['username'] !== '' && $s['channel_name'] !== '';
|
|
}
|
|
|
|
/**
|
|
* Whether the PeerTube upload feature flag is enabled.
|
|
*/
|
|
public static function isEnabled(Database $db): bool
|
|
{
|
|
return $db->getSetting('peertube_upload_enabled', '0') === '1';
|
|
}
|
|
|
|
/**
|
|
* Test connectivity: obtain a token and resolve the channel.
|
|
*
|
|
* @return array{ok:bool, error:string}
|
|
*/
|
|
public static function test(Database $db): array
|
|
{
|
|
$s = self::getSettings($db);
|
|
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 {
|
|
$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()];
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Upload — resumable protocol
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Upload a local file to PeerTube using the simple multipart upload API.
|
|
*
|
|
* @param string $originalName The original client filename (e.g. "video.mp4") sent in the upload form.
|
|
* @return array{uuid:string, watchUrl:string}
|
|
* @throws \RuntimeException
|
|
*/
|
|
public static function upload(
|
|
Database $db,
|
|
string $filePath,
|
|
string $originalName,
|
|
string $title,
|
|
string $description = ''
|
|
): array {
|
|
$s = self::getSettings($db);
|
|
if ($s['instance_url'] === '') {
|
|
throw new \RuntimeException('PeerTube non configuré.');
|
|
}
|
|
|
|
$channelId = self::resolveChannelId($s);
|
|
if ($channelId === null) {
|
|
throw new \RuntimeException('Chaîne PeerTube introuvable : ' . $s['channel_name']);
|
|
}
|
|
|
|
$token = self::obtainToken($s);
|
|
$baseUrl = $s['instance_url'];
|
|
|
|
// ── Simple multipart upload (non-resumable) ──
|
|
$uploadUrl = $baseUrl . '/api/v1/videos/upload';
|
|
|
|
$multipart = [
|
|
['name' => 'channelId', 'contents' => $channelId],
|
|
['name' => 'name', 'contents' => $title],
|
|
['name' => 'privacy', 'contents' => (int)$s['privacy']],
|
|
['name' => 'commentsEnabled', 'contents' => 'true'],
|
|
['name' => 'category', 'contents' => '15'],
|
|
['name' => 'videofile', 'contents' => fopen($filePath, 'r'), 'filename' => $originalName],
|
|
];
|
|
if ($description !== '') {
|
|
$multipart[] = ['name' => 'description', 'contents' => $description];
|
|
}
|
|
|
|
$resp = self::httpRequest($uploadUrl, 'POST', [
|
|
'headers' => ['Authorization' => 'Bearer ' . $token],
|
|
'multipart' => $multipart,
|
|
'timeout' => 600,
|
|
]);
|
|
|
|
if ($resp['status'] < 200 || $resp['status'] >= 300) {
|
|
$errJson = json_decode($resp['body'], true);
|
|
$msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body'];
|
|
error_log('PeerTubeService: simple upload FAILED | status=' . $resp['status'] . ' | body=' . substr($resp['body'], 0, 500));
|
|
throw new \RuntimeException('PeerTube upload failed (' . $resp['status'] . '): ' . $msg);
|
|
}
|
|
|
|
$json = json_decode($resp['body'], true);
|
|
$shortUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null;
|
|
if ($shortUuid === null) {
|
|
error_log('PeerTubeService: simple upload OK but no UUID | body=' . substr($resp['body'], 0, 500));
|
|
throw new \RuntimeException('PeerTube upload: no video UUID in response.');
|
|
}
|
|
|
|
$watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
|
|
error_log('PeerTubeService: simple upload OK | uuid=' . $shortUuid . ' | watchUrl=' . $watchUrl);
|
|
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', [
|
|
'headers' => ['Authorization' => 'Bearer ' . $token],
|
|
'timeout' => 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;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Delete
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Delete a video from PeerTube by UUID or shortUUID.
|
|
*
|
|
* DELETE /api/v1/videos/{uuid}
|
|
*
|
|
* @return bool true on success, false on failure
|
|
*/
|
|
public static function deleteVideo(Database $db, string $uuid): bool
|
|
{
|
|
$s = self::getSettings($db);
|
|
if ($s['instance_url'] === '') {
|
|
error_log('PeerTubeService::deleteVideo: instance not configured');
|
|
return false;
|
|
}
|
|
try {
|
|
$token = self::obtainToken($s);
|
|
$url = rtrim($s['instance_url'], '/') . '/api/v1/videos/' . urlencode($uuid);
|
|
$resp = self::httpRequest($url, 'DELETE', [
|
|
'headers' => ['Authorization' => 'Bearer ' . $token],
|
|
'timeout' => 30,
|
|
]);
|
|
if ($resp['status'] === 204 || $resp['status'] === 200) {
|
|
error_log('PeerTubeService: deleted video ' . $uuid);
|
|
return true;
|
|
}
|
|
error_log('PeerTubeService::deleteVideo: unexpected status ' . $resp['status'] . ' for ' . $uuid . ' | body=' . substr($resp['body'], 0, 300));
|
|
return false;
|
|
} catch (\Throwable $e) {
|
|
error_log('PeerTubeService::deleteVideo failed: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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', [
|
|
'headers' => ['Authorization' => 'Bearer ' . $token],
|
|
'timeout' => 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', ['timeout' => 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.
|
|
*/
|
|
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' => $oauth['client_id'],
|
|
'client_secret' => $oauth['client_secret'],
|
|
'grant_type' => 'password',
|
|
'response_type' => 'code',
|
|
'username' => $s['username'],
|
|
'password' => $s['password'],
|
|
]);
|
|
|
|
$response = self::httpRequest($tokenUrl, 'POST', [
|
|
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
|
'body' => $body,
|
|
]);
|
|
|
|
$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);
|
|
}
|
|
|
|
return $json['access_token'];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// HTTP helper
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Shared Guzzle HTTP client (lazy-init with SSL verification, HTTP/2, 15s connect timeout).
|
|
*/
|
|
private static function client(): Client
|
|
{
|
|
if (self::$httpClient === null) {
|
|
self::$httpClient = new Client([
|
|
'http_errors' => false,
|
|
'allow_redirects' => false,
|
|
'connect_timeout' => 15,
|
|
'version' => 2.0, // HTTP/2
|
|
]);
|
|
}
|
|
return self::$httpClient;
|
|
}
|
|
|
|
/**
|
|
* Perform an HTTP request via Guzzle.
|
|
*
|
|
* @param array $options Guzzle request options (headers, body, multipart, timeout, etc.)
|
|
* @return array{status:int, body:string, headers:array<string,string>}
|
|
*/
|
|
public static function httpRequest(string $url, string $method, array $options = []): array
|
|
{
|
|
try {
|
|
$response = self::client()->request($method, $url, $options);
|
|
$headers = [];
|
|
foreach ($response->getHeaders() as $name => $values) {
|
|
$headers[strtolower($name)] = end($values);
|
|
}
|
|
return [
|
|
'status' => $response->getStatusCode(),
|
|
'body' => (string)$response->getBody(),
|
|
'headers' => $headers,
|
|
];
|
|
} catch (GuzzleException $e) {
|
|
throw new \RuntimeException('Erreur réseau PeerTube : ' . $e->getMessage(), 0, $e);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Progress reporting (for upload-progress.js polling)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Write upload progress to a temp file polled by the progress endpoint.
|
|
*/
|
|
public static function writeProgress(string $token, string $stage, int $pct, string $file = ''): void
|
|
{
|
|
$progressFile = sys_get_temp_dir() . '/xamxam_upload_' . $token . '.json';
|
|
file_put_contents($progressFile, json_encode([
|
|
'stage' => $stage,
|
|
'pct' => $pct,
|
|
'file' => $file,
|
|
]), LOCK_EX);
|
|
}
|
|
|
|
/**
|
|
* Remove the progress file for a given token.
|
|
*/
|
|
public static function clearProgress(string $token): void
|
|
{
|
|
$progressFile = sys_get_temp_dir() . '/xamxam_upload_' . $token . '.json';
|
|
@unlink($progressFile);
|
|
}
|
|
}
|