mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Fuseaux horaires : - POST /api/recordings rejette en 400 les dates sans fuseau et normalise tout en UTC à l'écriture (l'interprétation des dates naïves dans le TZ du conteneur décalait les enregistrements de 1-2 h) - Helper frontend unique postRecording() : les 2 points de création (modal, guide EPG) envoient la même convention UTC Contrôle d'accès : - stop/delete/logs d'un enregistrement et delete d'un season pass vérifient la propriété (userId ou admin), comme playlists_handler - Suppression des replis 'dev_user_id' et 'admin' (401 sans session) SQLite : - PRAGMA foreign_keys/WAL/busy_timeout (les ON DELETE CASCADE déclarés ne s'appliquaient pas : sessions et playlists orphelines) - Migrations de schéma versionnées (schema_version) + index user_id, start_time, season_passes(user_id) Gestion disque : - Refus explicite de capture sous MIN_FREE_DISK_MB (défaut 500 Mo) - Rotation par quota d'octets (RECORDINGS_QUOTA_GB, opt-in) qui ne touche jamais un enregistrement actif et supprime fichiers + ligne ensemble ; l'ancienne rotation « 50 fichiers » pouvait effacer une capture en cours - DELETE /api/recordings/<id> supprime aussi .mkv/.log/parties (SafePath) Scheduler : - Arrêt gracieux orchestré par server.dart : clôture des enregistrements (fusion des parties, statut) avant killAll des sessions de streaming ; l'ancien handler SIGTERM de FfmpegSessionManager faisait exit(0) direct - Noms de fichiers uniques par fragment d'id (deux enregistrements du même programme s'écrasaient mutuellement avec -y) - Requête filtrée scheduled/recording au lieu de toute la table / 10 s - Statut cancelled pour un scheduled arrêté (completed sans fichier cassait la lecture) Season passes : - Playlist du propriétaire du pass résolue à chaque scan (l'injection figée du 1er utilisateur rendait les passes muets après ajout de playlist, et mélangeait les credentials en multi-utilisateurs) - Correspondance exacte par défaut (match_mode, migration en 'contains' pour l'existant), plafond de créations par scan, réalignement des horaires déplacés dans l'EPG, déduplication tolérante ±2 min - Redaction des erreurs de scan (ClientException contient l'URL amont) API de suivi (polling conservé) : - GET /api/recordings enrichi : progress_pct, file_size_bytes, retry_count, is_active ; barre de progression + taille dans la liste Validé : dart analyze (0 issue) et dart test (48/48) sur bin/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
156 lines
5.0 KiB
Dart
156 lines
5.0 KiB
Dart
import 'dart:convert';
|
|
import 'package:shelf/shelf.dart';
|
|
import '../database/database.dart';
|
|
import '../models/user.dart';
|
|
|
|
/// API Season Passes — enregistrements répétés par titre d'émission
|
|
class SeasonPassesApi {
|
|
final AppDatabase _db;
|
|
|
|
SeasonPassesApi(this._db);
|
|
|
|
/// GET /api/season-passes — liste les season passes de l'utilisateur
|
|
/// (tous les passes pour un admin)
|
|
Response handleGetAll(Request request) {
|
|
try {
|
|
final user = request.context['user'] as User?;
|
|
if (user == null) {
|
|
return Response(
|
|
401,
|
|
body: json.encode({'error': 'Authentification requise'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
final passes = user.isAdmin
|
|
? _db.getAllSeasonPasses()
|
|
: _db.getSeasonPassesForUser(user.id);
|
|
return Response.ok(
|
|
json.encode(passes),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
} catch (e) {
|
|
print('[SeasonPass] Erreur au listage: $e');
|
|
return Response.internalServerError(
|
|
body: json.encode({'error': 'Erreur interne'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// POST /api/season-passes — créer un season pass
|
|
Future<Response> handlePost(Request request) async {
|
|
try {
|
|
final body = await request.readAsString();
|
|
final data = json.decode(body) as Map<String, dynamic>;
|
|
|
|
final showTitle = data['show_title'] as String?;
|
|
final channelId = data['channel_id'] as String?;
|
|
final streamUrl = data['stream_url'] as String?;
|
|
|
|
if (showTitle == null || showTitle.isEmpty) {
|
|
return Response(
|
|
400,
|
|
body: json.encode({'error': 'show_title est requis'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
if (channelId == null || channelId.isEmpty) {
|
|
return Response(
|
|
400,
|
|
body: json.encode({'error': 'channel_id est requis'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
if (streamUrl == null || streamUrl.isEmpty) {
|
|
return Response(
|
|
400,
|
|
body: json.encode({'error': 'stream_url est requis'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
|
|
// Récupérer l'utilisateur depuis le contexte
|
|
final user = request.context['user'] as User?;
|
|
if (user == null) {
|
|
return Response(
|
|
401,
|
|
body: json.encode({'error': 'Authentification requise'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
|
|
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
|
|
// programmes qui contiennent le mot. 'contains' reste disponible.
|
|
final matchMode = data['match_mode'] == 'contains' ? 'contains' : 'exact';
|
|
|
|
// Vérifier si un season pass identique existe déjà pour cet utilisateur
|
|
final existing = _db.getSeasonPassesForUser(user.id);
|
|
final duplicate = existing.any(
|
|
(p) =>
|
|
(p['show_title'] as String).toLowerCase() ==
|
|
showTitle.toLowerCase() &&
|
|
p['channel_id'] == channelId,
|
|
);
|
|
if (duplicate) {
|
|
return Response(
|
|
409,
|
|
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
|
|
final pass = _db.createSeasonPass(
|
|
userId: user.id,
|
|
showTitle: showTitle,
|
|
channelId: channelId,
|
|
streamUrl: streamUrl,
|
|
matchMode: matchMode,
|
|
);
|
|
|
|
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
|
return Response(
|
|
201,
|
|
body: json.encode(pass),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
} catch (e) {
|
|
return Response.internalServerError(
|
|
body: json.encode({'error': 'Erreur: $e'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/season-passes/<id> — supprimer un season pass
|
|
Response handleDelete(Request request, String id) {
|
|
try {
|
|
final user = request.context['user'] as User?;
|
|
final pass = _db.getSeasonPassById(id);
|
|
if (pass == null) {
|
|
return Response.notFound(
|
|
json.encode({'error': 'Season Pass non trouvé'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
// Contrôle de propriété : seul le propriétaire ou un admin supprime.
|
|
if (user == null || (!user.isAdmin && pass['user_id'] != user.id)) {
|
|
return Response.forbidden(
|
|
json.encode({'error': 'Accès refusé'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
_db.deleteSeasonPass(id);
|
|
return Response.ok(
|
|
json.encode({'message': 'Season Pass supprimé'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
} catch (e) {
|
|
print('[SeasonPass] Erreur à la suppression: $e');
|
|
return Response.internalServerError(
|
|
body: json.encode({'error': 'Erreur interne'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
}
|
|
}
|