Skip to content

Commit a6c373b

Browse files
authored
feat: Enhance audio management and dashboard UI (#18)
- Introduced functions to format audio durations and manage audio file caching, improving audio file handling. - Updated the dashboard to display audio files with their durations, allowing for better user interaction. - Added filtering and sorting options for audio files based on duration, enhancing usability. - Improved the layout of the stats page and dashboard for a more cohesive user experience.
1 parent a29d4c2 commit a6c373b

4 files changed

Lines changed: 420 additions & 88 deletions

File tree

app.py

Lines changed: 223 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,113 @@ def _format_time_played(seconds):
7979
return ' '.join(parts)
8080

8181

82+
def _format_duration(seconds):
83+
"""Format seconds as M:SS (e.g. 245 -> '4:05')."""
84+
if seconds is None or seconds < 0:
85+
return None
86+
s = int(round(seconds))
87+
m, s = divmod(s, 60)
88+
h, m = divmod(m, 60)
89+
if h > 0:
90+
return f'{h}:{m:02d}:{s:02d}'
91+
return f'{m}:{s:02d}'
92+
93+
94+
AUDIO_DURATIONS_CACHE_FILENAME = '.audio_durations.json'
95+
96+
97+
def _audio_duration_sec(path):
98+
"""Get audio duration in seconds via ffprobe. Returns None on failure."""
99+
import subprocess
100+
try:
101+
out = subprocess.check_output(
102+
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
103+
'-of', 'default=noprint_wrappers=1:nokey=1', path],
104+
stderr=subprocess.DEVNULL, timeout=3
105+
)
106+
return float(out.decode('utf-8').strip())
107+
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
108+
return None
109+
110+
111+
def _audio_durations_cache_path():
112+
"""Path to the audio durations cache file (in STATS_DIR)."""
113+
cache_dir = STATS_DIR or CHUNK_FOLDER
114+
return os.path.join(cache_dir, AUDIO_DURATIONS_CACHE_FILENAME)
115+
116+
117+
def _load_audio_durations_cache():
118+
"""Load cached durations from disk. Returns durations_dict or None. Cache persists as long as filenames stay same."""
119+
path = _audio_durations_cache_path()
120+
if not os.path.isfile(path):
121+
return None
122+
try:
123+
with open(path, 'r') as f:
124+
data = json.load(f)
125+
return data.get('durations') or {}
126+
except (json.JSONDecodeError, OSError):
127+
return None
128+
129+
130+
def _save_audio_durations_cache(durations):
131+
"""Save durations cache to disk."""
132+
path = _audio_durations_cache_path()
133+
cache_dir = os.path.dirname(path)
134+
if cache_dir and not os.path.isdir(cache_dir):
135+
return
136+
try:
137+
with open(path, 'w') as f:
138+
json.dump({'durations': durations, 'updated_at': time.time()}, f)
139+
except OSError:
140+
pass
141+
142+
143+
def _audio_files_with_durations(audio_extensions, audio_folder):
144+
"""Build audio_files list. Uses cached durations when fresh; else runs ffprobe and saves cache."""
145+
from concurrent.futures import ThreadPoolExecutor, as_completed
146+
entries = []
147+
for root, _dirs, files in os.walk(audio_folder):
148+
for f in files:
149+
lower = f.lower()
150+
if any(lower.endswith(ext) for ext in audio_extensions):
151+
path = os.path.join(root, f)
152+
try:
153+
stat = os.stat(path)
154+
rel_path = os.path.relpath(path, audio_folder)
155+
entries.append({
156+
'path': path,
157+
'name': os.path.basename(path),
158+
'rel_path': rel_path,
159+
'size_mb': round(stat.st_size / (1024 * 1024), 2),
160+
})
161+
except OSError:
162+
pass
163+
if not entries:
164+
return []
165+
cache = _load_audio_durations_cache()
166+
missing = [e for e in entries if not cache or e['rel_path'] not in cache]
167+
if missing:
168+
# Probe only missing files; merge with existing cache
169+
with ThreadPoolExecutor(max_workers=8) as ex:
170+
futures = {ex.submit(_audio_duration_sec, e['path']): e for e in missing}
171+
for future in as_completed(futures):
172+
e = futures[future]
173+
try:
174+
duration_sec = future.result()
175+
cache = cache or {}
176+
cache[e['rel_path']] = duration_sec
177+
except Exception:
178+
cache = cache or {}
179+
cache[e['rel_path']] = None
180+
_save_audio_durations_cache(cache)
181+
for e in entries:
182+
duration_sec = (cache or {}).get(e['rel_path'])
183+
e['duration_sec'] = duration_sec
184+
e['duration_display'] = _format_duration(duration_sec) if duration_sec else None
185+
return sorted(entries, key=lambda x: x['name'].lower())
186+
187+
188+
82189
def _build_chunks_list(settings=None):
83190
"""Build chunks list (no ffprobe). settings used for days_to_expire."""
84191
from datetime import datetime
@@ -174,27 +281,11 @@ def index():
174281

175282
chunks = _build_chunks_list(settings)
176283

177-
# List audio files (same extensions as clip_pusher)
284+
# List audio files (same extensions as clip_pusher); ffprobe runs in parallel
178285
audio_files = []
179286
audio_extensions = ('.mp3', '.aac', '.flac', '.ogg', '.wav', '.m4a')
180287
if AUDIO_FOLDER and os.path.isdir(AUDIO_FOLDER):
181-
for root, _dirs, files in os.walk(AUDIO_FOLDER):
182-
for f in files:
183-
lower = f.lower()
184-
if any(lower.endswith(ext) for ext in audio_extensions):
185-
path = os.path.join(root, f)
186-
try:
187-
stat = os.stat(path)
188-
rel_path = os.path.relpath(path, AUDIO_FOLDER)
189-
audio_files.append({
190-
'name': os.path.basename(path),
191-
'path': path,
192-
'rel_path': rel_path,
193-
'size_mb': round(stat.st_size / (1024 * 1024), 2)
194-
})
195-
except OSError:
196-
pass
197-
audio_files.sort(key=lambda x: x['name'].lower())
288+
audio_files = _audio_files_with_durations(audio_extensions, AUDIO_FOLDER)
198289

199290
current_audio = current_status.get('current_audio')
200291

@@ -438,6 +529,106 @@ def _fetch_og_meta(url: str, timeout: float = 5.0) -> dict:
438529
return result
439530

440531

532+
MODEL_THUMBNAIL_CACHE_FILENAME = '.model_thumbnails.json'
533+
534+
535+
def _extract_video_id(path):
536+
"""Extract 11-char YouTube video ID from path (e.g. .../abc123.mp4 -> abc123)."""
537+
stem = os.path.splitext(os.path.basename(path))[0]
538+
if stem and len(stem) == 11 and stem.replace('-', '').replace('_', '').isalnum():
539+
return stem
540+
return None
541+
542+
543+
def _find_video_id_for_model(model):
544+
"""Scan chunk metas for a source video that has this model; return video_id (random if multiple)."""
545+
import random as _random
546+
candidates = []
547+
if not os.path.isdir(CHUNK_FOLDER):
548+
return None
549+
for f in os.listdir(CHUNK_FOLDER):
550+
if not f.endswith('.mp4') or f.startswith('chunk_temp'):
551+
continue
552+
meta_path = os.path.join(CHUNK_FOLDER, f.replace('.mp4', '.meta.json'))
553+
if not os.path.isfile(meta_path):
554+
continue
555+
try:
556+
with open(meta_path, 'r') as fp:
557+
meta = json.load(fp)
558+
except (json.JSONDecodeError, OSError):
559+
continue
560+
sources = meta.get('source_videos') or []
561+
model_info = meta.get('model_info') or []
562+
chunk_candidates = []
563+
for item in sources:
564+
if not isinstance(item, dict):
565+
continue
566+
m = item.get('model')
567+
if m and m == model:
568+
path = item.get('path')
569+
if path:
570+
vid = _extract_video_id(path)
571+
if vid:
572+
chunk_candidates.append(vid)
573+
if not chunk_candidates and model in model_info:
574+
for item in sources:
575+
if not isinstance(item, dict):
576+
continue
577+
path = item.get('path')
578+
if path:
579+
vid = _extract_video_id(path)
580+
if vid:
581+
chunk_candidates.append(vid)
582+
candidates.extend(chunk_candidates)
583+
return _random.choice(candidates) if candidates else None
584+
585+
586+
def _model_thumbnail_cache_path():
587+
cache_dir = STATS_DIR or CHUNK_FOLDER
588+
return os.path.join(cache_dir, MODEL_THUMBNAIL_CACHE_FILENAME)
589+
590+
591+
def _load_model_thumbnail_cache():
592+
path = _model_thumbnail_cache_path()
593+
if not os.path.isfile(path):
594+
return {}
595+
try:
596+
with open(path, 'r') as f:
597+
return json.load(f)
598+
except (json.JSONDecodeError, OSError):
599+
return {}
600+
601+
602+
def _save_model_thumbnail_cache(cache):
603+
path = _model_thumbnail_cache_path()
604+
cache_dir = os.path.dirname(path)
605+
if cache_dir and not os.path.isdir(cache_dir):
606+
return
607+
try:
608+
with open(path, 'w') as f:
609+
json.dump(cache, f)
610+
except OSError:
611+
pass
612+
613+
614+
def _get_youtube_thumbnail_for_model(model, video_id_from_play_counts, stored_thumb_url):
615+
"""Get YouTube thumbnail URL for model. Uses play_counts video_id, then cache, then scans chunks."""
616+
if video_id_from_play_counts and len(video_id_from_play_counts) == 11:
617+
return f"https://img.youtube.com/vi/{video_id_from_play_counts}/hqdefault.jpg"
618+
cache = _load_model_thumbnail_cache()
619+
cached = cache.get(model)
620+
if cached and len(cached) == 11:
621+
return f"https://img.youtube.com/vi/{cached}/hqdefault.jpg"
622+
vid = _find_video_id_for_model(model)
623+
if vid:
624+
cache[model] = vid
625+
_save_model_thumbnail_cache(cache)
626+
return f"https://img.youtube.com/vi/{vid}/hqdefault.jpg"
627+
if stored_thumb_url and 'img.youtube.com' in str(stored_thumb_url):
628+
return stored_thumb_url
629+
return None
630+
631+
441632
def _stats_context():
442633
"""Build stream_stats and play_counts for stats page."""
443634
current_status = clip_pusher.get_status()
@@ -448,7 +639,6 @@ def _stats_context():
448639
'total_seconds_streamed': current_status.get('total_seconds_streamed'),
449640
}
450641
play_counts = clip_pusher.get_play_counts()
451-
# Enrich models with og:title, og:image, and YouTube thumbnail (when video_id available)
452642
models_enriched = []
453643
for item in play_counts.get('models', []):
454644
model, count = item[0], item[1]
@@ -457,8 +647,7 @@ def _stats_context():
457647
url = model if model.startswith('http') else 'https://' + model
458648
meta = _fetch_og_meta(url)
459649
title = html.unescape(meta.get('title') or url)
460-
# Prefer YouTube thumbnail from video_id (play_counts) when available; else stored_thumb, else OG image
461-
thumbnail = (f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg" if video_id else None) or stored_thumb or meta.get('image')
650+
thumbnail = _get_youtube_thumbnail_for_model(model, video_id, stored_thumb)
462651
models_enriched.append({
463652
'url': url,
464653
'count': count,
@@ -797,6 +986,19 @@ def play_chunk():
797986
return jsonify({'success': True})
798987

799988

989+
@app.route('/api/play_audio', methods=['POST'])
990+
def play_audio():
991+
"""Switch to a specific audio track in the stream."""
992+
data = request.get_json()
993+
audio_name = data.get('audio_name') if data else None
994+
if not audio_name or not isinstance(audio_name, str):
995+
return jsonify({'success': False, 'error': 'Missing or invalid audio_name'}), 400
996+
ok = clip_pusher.play_audio(audio_name)
997+
if not ok:
998+
return jsonify({'success': False, 'error': 'Audio not found'}), 404
999+
return jsonify({'success': True})
1000+
1001+
8001002
@app.route('/api/delete_audio', methods=['POST'])
8011003
def delete_audio():
8021004
"""Delete an audio file from the filesystem. Requires path within AUDIO_FOLDER.

clip_pusher.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,36 @@ def play_chunk(self, chunk_name: str) -> bool:
322322
self.skip_to_next()
323323
return True
324324

325+
def play_audio(self, audio_name: str) -> bool:
326+
"""Switch to a specific audio track. Stops current stream and restarts with the new track."""
327+
if not self._audio_files:
328+
return False
329+
name = os.path.basename(audio_name)
330+
match = next((p for p in self._audio_files if os.path.basename(p) == name), None)
331+
if not match or not os.path.isfile(match):
332+
return False
333+
self._persistent_audio_path = match
334+
self._current_audio = os.path.basename(match)
335+
self._audio_position = 0.0
336+
try:
337+
out = subprocess.check_output(
338+
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
339+
'-of', 'default=noprint_wrappers=1:nokey=1', match]
340+
)
341+
self._persistent_audio_duration = float(out.decode('utf-8').strip())
342+
except Exception:
343+
self._persistent_audio_duration = 3600.0
344+
if self._streamer_process and self._streamer_process.poll() is None:
345+
try:
346+
self._streamer_process.terminate()
347+
self._streamer_process.wait(timeout=3)
348+
except subprocess.TimeoutExpired:
349+
self._streamer_process.kill()
350+
except Exception:
351+
pass
352+
return True
353+
return True
354+
325355
# ── Internal ──────────────────────────────────────────────────
326356

327357
def _audio_queue_path(self) -> str:

0 commit comments

Comments
 (0)