@@ -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+
82189def _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+
441632def _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' ])
8011003def delete_audio ():
8021004 """Delete an audio file from the filesystem. Requires path within AUDIO_FOLDER.
0 commit comments