Skip to content
This repository was archived by the owner on Aug 26, 2026. It is now read-only.

Commit 00e4e28

Browse files
raccommodeclaude
andcommitted
feat: add recording settings (disable auto-convert, keep TS, retention forever)
- Add auto_convert and keep_ts toggles in Settings UI (runtime changeable) - Skip TS deletion when keep_ts is enabled - Index TS-only recordings when auto-convert is disabled - Show TS recordings in Recordings page (fallback from MP4) - retention_days=0 now means keep recordings forever - Add GET/PUT /api/settings/recording endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16d3772 commit 00e4e28

9 files changed

Lines changed: 352 additions & 133 deletions

File tree

app/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
AUTO_RECORD_INTERVAL = int(os.getenv("AUTO_RECORD_INTERVAL", "120")) # secondes
4444
CLEANUP_INTERVAL = int(os.getenv("CLEANUP_INTERVAL", "3600")) # secondes
4545

46+
# Recording settings (defaults, overridden by DB settings at runtime)
47+
AUTO_CONVERT = os.getenv("AUTO_CONVERT", "true").lower() in {"1", "true", "yes"}
48+
KEEP_TS = os.getenv("KEEP_TS", "false").lower() in {"1", "true", "yes"}
49+
4650
# Timezone
4751
TZ = os.getenv("TZ", "UTC")
4852

app/core/database.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,12 @@ async def get_recordings(self, username: str) -> List[Dict[str, Any]]:
303303
return [dict(row) for row in rows]
304304

305305
async def get_recordings_count(self, username: str) -> int:
306-
"""Compte uniquement les enregistrements convertis en MP4"""
306+
"""Compte les enregistrements (convertis ou non)"""
307307
await self.initialize()
308-
308+
309309
async with aiosqlite.connect(self.db_path) as db:
310310
cursor = await db.execute(
311-
"SELECT COUNT(*) FROM recordings WHERE username = ? AND is_converted = 1",
311+
"SELECT COUNT(*) FROM recordings WHERE username = ?",
312312
(username,)
313313
)
314314
row = await cursor.fetchone()
@@ -480,7 +480,7 @@ async def get_all_recordings_paginated(
480480
db.row_factory = aiosqlite.Row
481481

482482
# Count total
483-
count_sql = "SELECT COUNT(*) FROM recordings WHERE is_converted = 1"
483+
count_sql = "SELECT COUNT(*) FROM recordings WHERE 1=1"
484484
count_params = []
485485
if username_filter:
486486
count_sql += " AND username = ?"
@@ -494,7 +494,7 @@ async def get_all_recordings_paginated(
494494
offset = (page - 1) * limit
495495
query_sql = """
496496
SELECT * FROM recordings
497-
WHERE is_converted = 1
497+
WHERE 1=1
498498
"""
499499
query_params = []
500500
if username_filter:
@@ -507,10 +507,10 @@ async def get_all_recordings_paginated(
507507
rows = await cursor.fetchall()
508508

509509
# Total size
510-
size_sql = "SELECT COALESCE(SUM(COALESCE(mp4_size, file_size)), 0) FROM recordings WHERE is_converted = 1"
510+
size_sql = "SELECT COALESCE(SUM(COALESCE(mp4_size, file_size)), 0) FROM recordings"
511511
size_params = []
512512
if username_filter:
513-
size_sql = "SELECT COALESCE(SUM(COALESCE(mp4_size, file_size)), 0) FROM recordings WHERE is_converted = 1 AND username = ?"
513+
size_sql = "SELECT COALESCE(SUM(COALESCE(mp4_size, file_size)), 0) FROM recordings WHERE username = ?"
514514
size_params.append(username_filter)
515515

516516
cursor = await db.execute(size_sql, size_params)
@@ -643,7 +643,6 @@ async def get_recordings_grouped_by_model(self) -> List[Dict[str, Any]]:
643643
MAX(created_at) as last_recording_at,
644644
COALESCE(SUM(duration_seconds), 0) as total_duration
645645
FROM recordings
646-
WHERE is_converted = 1
647646
GROUP BY username
648647
ORDER BY last_recording_at DESC
649648
""")

app/main.py

Lines changed: 108 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -818,33 +818,37 @@ async def get_dashboard():
818818

819819
@app.get("/api/recordings/{username}")
820820
async def list_recordings(username: str):
821-
"""Liste uniquement les enregistrements MP4 convertis"""
821+
"""Liste les enregistrements (MP4 convertis ou TS bruts)"""
822822
from datetime import datetime
823823
from .core.utils import format_bytes
824-
824+
825825
# Récupérer depuis SQLite
826826
recordings_db = await db.get_recordings(username)
827-
827+
828828
recordings = []
829829
thumbnails_dir = OUTPUT_DIR / "thumbnails" / username
830-
830+
831831
for rec in recordings_db:
832-
# FILTRER: Ne retourner que les enregistrements convertis avec MP4
833-
if not rec.get('is_converted') or not rec.get('mp4_path'):
834-
continue
835-
836-
mp4_path = Path(rec['mp4_path'])
837-
838-
# Vérifier que le fichier MP4 existe
839-
if not mp4_path.exists():
832+
# Determine the playable file: prefer MP4, fall back to TS
833+
is_converted = bool(rec.get('is_converted'))
834+
mp4_raw = rec.get('mp4_path')
835+
ts_raw = rec.get('file_path')
836+
837+
if is_converted and mp4_raw and Path(mp4_raw).exists():
838+
serve_path = Path(mp4_raw)
839+
file_size = rec.get('mp4_size') or serve_path.stat().st_size
840+
elif ts_raw and Path(ts_raw).exists():
841+
serve_path = Path(ts_raw)
842+
file_size = rec.get('file_size') or serve_path.stat().st_size
843+
else:
840844
continue
841-
842-
stat = mp4_path.stat()
843-
845+
846+
stat = serve_path.stat()
847+
844848
# Miniature
845-
thumb_path = thumbnails_dir / f"{mp4_path.stem}.jpg"
846-
thumb_url = f"/api/recording-thumbnail/{username}/{mp4_path.stem}.jpg"
847-
849+
thumb_path = thumbnails_dir / f"{serve_path.stem}.jpg"
850+
thumb_url = f"/api/recording-thumbnail/{username}/{serve_path.stem}.jpg"
851+
848852
# Formater la durée
849853
duration_seconds = rec.get('duration_seconds', 0)
850854
hours = duration_seconds // 3600
@@ -854,36 +858,35 @@ async def list_recordings(username: str):
854858
duration_str = f"{hours}h{minutes:02d}m"
855859
else:
856860
duration_str = f"{minutes}m{seconds:02d}s"
857-
861+
858862
# Calculer la taille en MB ou GB
859-
mp4_size = rec.get('mp4_size', 0)
860-
if mp4_size >= 1000 * 1024 * 1024: # >= 1000 MB
861-
size_display = f"{mp4_size / 1024 / 1024 / 1024:.2f} GB"
863+
if file_size >= 1000 * 1024 * 1024: # >= 1000 MB
864+
size_display = f"{file_size / 1024 / 1024 / 1024:.2f} GB"
862865
else:
863-
size_display = f"{mp4_size / 1024 / 1024:.0f} MB"
864-
866+
size_display = f"{file_size / 1024 / 1024:.0f} MB"
867+
865868
recordings.append({
866-
"recordingId": rec.get('recording_id', mp4_path.stem),
867-
"filename": mp4_path.name,
868-
"date": mp4_path.stem,
869-
"size": mp4_size,
870-
"size_formatted": format_bytes(mp4_size),
871-
"size_mb": round(mp4_size / 1024 / 1024, 2),
869+
"recordingId": rec.get('recording_id', serve_path.stem),
870+
"filename": serve_path.name,
871+
"date": serve_path.stem,
872+
"size": file_size,
873+
"size_formatted": format_bytes(file_size),
874+
"size_mb": round(file_size / 1024 / 1024, 2),
872875
"size_display": size_display,
873876
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
874-
"url": f"/streams/records/{username}/{mp4_path.name}",
877+
"url": f"/streams/records/{username}/{serve_path.name}",
875878
"thumbnail": thumb_url if thumb_path.exists() else None,
876879
"duration": duration_seconds,
877880
"duration_str": duration_str,
878-
"isConverted": True,
881+
"isConverted": is_converted,
879882
"mp4": {
880-
"filename": mp4_path.name,
881-
"size": mp4_size,
882-
"size_formatted": format_bytes(mp4_size),
883-
"url": f"/streams/records/{username}/{mp4_path.name}"
884-
}
883+
"filename": Path(mp4_raw).name,
884+
"size": rec.get('mp4_size', 0),
885+
"size_formatted": format_bytes(rec.get('mp4_size', 0)),
886+
"url": f"/streams/records/{username}/{Path(mp4_raw).name}"
887+
} if is_converted and mp4_raw else None
885888
})
886-
889+
887890
return {"recordings": recordings}
888891

889892

@@ -904,13 +907,22 @@ async def get_all_recordings(
904907

905908
recordings = []
906909
for rec in result["recordings"]:
907-
mp4_path = rec.get("mp4_path")
908-
if not mp4_path:
910+
rec_username = rec.get("username", "")
911+
is_converted = bool(rec.get("is_converted"))
912+
mp4_raw = rec.get("mp4_path")
913+
ts_raw = rec.get("file_path")
914+
915+
# Determine the playable file: prefer MP4, fall back to TS
916+
if is_converted and mp4_raw and Path(mp4_raw).exists():
917+
serve_file = Path(mp4_raw)
918+
file_size = rec.get("mp4_size") or serve_file.stat().st_size
919+
elif ts_raw and Path(ts_raw).exists():
920+
serve_file = Path(ts_raw)
921+
file_size = rec.get("file_size") or serve_file.stat().st_size
922+
else:
909923
continue
910924

911-
mp4_file = Path(mp4_path)
912-
file_stem = mp4_file.stem
913-
rec_username = rec.get("username", "")
925+
file_stem = serve_file.stem
914926

915927
# Format duration
916928
duration_seconds = rec.get("duration_seconds", 0)
@@ -922,22 +934,19 @@ async def get_all_recordings(
922934
else:
923935
duration_str = f"{minutes}m{seconds:02d}s"
924936

925-
# Format size
926-
mp4_size = rec.get("mp4_size") or rec.get("file_size", 0)
927-
928937
# Thumbnail
929938
thumb_path = OUTPUT_DIR / "thumbnails" / rec_username / f"{file_stem}.jpg"
930939

931940
recordings.append({
932941
"recordingId": rec.get("recording_id", file_stem),
933942
"username": rec_username,
934-
"filename": mp4_file.name,
943+
"filename": serve_file.name,
935944
"date": file_stem,
936-
"size": mp4_size,
937-
"size_formatted": format_bytes(mp4_size),
945+
"size": file_size,
946+
"size_formatted": format_bytes(file_size),
938947
"duration": duration_seconds,
939948
"duration_str": duration_str,
940-
"url": f"/streams/records/{rec_username}/{mp4_file.name}",
949+
"url": f"/streams/records/{rec_username}/{serve_file.name}",
941950
"thumbnail": f"/api/recording-thumbnail/{rec_username}/{file_stem}.jpg" if thumb_path.exists() else None,
942951
"createdAt": rec.get("created_at"),
943952
})
@@ -1187,6 +1196,44 @@ async def set_blacklisted_tags(body: dict):
11871196
return {"tags": tags}
11881197

11891198

1199+
# ============================================
1200+
# Recording Settings Endpoints
1201+
# ============================================
1202+
1203+
@app.get("/api/settings/recording")
1204+
async def get_recording_settings():
1205+
"""Get recording settings (auto_convert, keep_ts)"""
1206+
from .core.config import AUTO_CONVERT, KEEP_TS
1207+
1208+
auto_convert_val = await db.get_setting("auto_convert")
1209+
keep_ts_val = await db.get_setting("keep_ts")
1210+
1211+
# Fall back to env var defaults if not set in DB
1212+
if auto_convert_val is not None:
1213+
auto_convert = auto_convert_val.lower() in {"1", "true", "yes"}
1214+
else:
1215+
auto_convert = AUTO_CONVERT
1216+
1217+
if keep_ts_val is not None:
1218+
keep_ts = keep_ts_val.lower() in {"1", "true", "yes"}
1219+
else:
1220+
keep_ts = KEEP_TS
1221+
1222+
return {"auto_convert": auto_convert, "keep_ts": keep_ts}
1223+
1224+
1225+
@app.put("/api/settings/recording")
1226+
async def update_recording_settings(body: dict):
1227+
"""Update recording settings (auto_convert, keep_ts)"""
1228+
if "auto_convert" in body:
1229+
await db.set_setting("auto_convert", str(body["auto_convert"]).lower())
1230+
if "keep_ts" in body:
1231+
await db.set_setting("keep_ts", str(body["keep_ts"]).lower())
1232+
1233+
# Return current state
1234+
return await get_recording_settings()
1235+
1236+
11901237
# ============================================
11911238
# Follow/Unfollow on Chaturbate
11921239
# ============================================
@@ -1512,16 +1559,23 @@ async def cleanup_old_recordings_task():
15121559
for model in models:
15131560
username = model.get('username')
15141561
retention_days = model.get('retention_days', 30) # Défaut 30 jours
1515-
1562+
15161563
if not username:
15171564
continue
1518-
1565+
1566+
# retention_days == 0 means keep forever
1567+
if retention_days == 0:
1568+
logger.debug("Rétention infinie, skip",
1569+
task="cleanup",
1570+
username=username)
1571+
continue
1572+
15191573
records_dir = OUTPUT_DIR / "records" / username
15201574
thumbnails_dir = OUTPUT_DIR / "thumbnails" / username
1521-
1575+
15221576
if not records_dir.exists():
15231577
continue
1524-
1578+
15251579
# Date limite (aujourd'hui - rétention)
15261580
cutoff_date = datetime.now() - timedelta(days=retention_days)
15271581

app/tasks/cleanup.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,28 @@ async def cleanup_old_recordings_task():
4949
for model in models:
5050
username = model.get('username')
5151
retention_days = model.get('retentionDays', 30) # Défaut 30 jours
52-
52+
5353
if not username:
5454
continue
55-
55+
56+
# retention_days == 0 means keep forever
57+
if retention_days == 0:
58+
logger.debug("Rétention infinie, skip",
59+
task="cleanup",
60+
username=username)
61+
continue
62+
5663
records_dir = OUTPUT_DIR / "records" / username
5764
thumbnails_dir = OUTPUT_DIR / "thumbnails" / username
58-
65+
5966
if not records_dir.exists():
6067
continue
61-
68+
6269
logger.debug("Nettoyage modèle",
6370
task="cleanup",
6471
username=username,
6572
retention_days=retention_days)
66-
73+
6774
# Date limite (aujourd'hui - rétention)
6875
cutoff_date = datetime.now() - timedelta(days=retention_days)
6976

0 commit comments

Comments
 (0)