@@ -180,14 +180,64 @@ public SAFMetadata getMetadata(DocumentFile file) {
180180
181181 /**
182182 * Extract all metadata for a file (duration, MIME type, short ID).
183+ * NEVER extracts duration on current thread - always queues for background.
183184 */
184185 private SAFMetadata extractMetadata (DocumentFile file ) {
185186 String uri = file .getUri ().toString ();
186- String duration = extractDuration (file .getUri ());
187187 String mimeType = extractMimeType (file );
188188 String shortId = getOrCreateShortId (uri );
189189 long fileSize = file .length ();
190- return new SAFMetadata (duration , mimeType , shortId , fileSize );
190+
191+ // Queue duration extraction for background (don't block HTTP thread!)
192+ if (mimeType != null && (mimeType .startsWith ("audio/" ) || mimeType .startsWith ("video/" ))) {
193+ queueForDurationExtraction (file .getUri (), mimeType );
194+ }
195+
196+ // Return immediately with empty duration
197+ return new SAFMetadata ("" , mimeType , shortId , fileSize );
198+ }
199+
200+ /**
201+ * Queue file for background duration extraction.
202+ */
203+ private void queueForDurationExtraction (Uri uri , String mimeType ) {
204+ preloadExecutor .execute (() -> {
205+ try {
206+ String duration ;
207+ if (mimeType .startsWith ("video/" )) {
208+ // Try quick extraction first
209+ duration = extractVideoDurationQuick (uri );
210+ if (duration == null || duration .isEmpty ()) {
211+ // Full extraction as fallback
212+ duration = extractAudioDuration (uri );
213+ }
214+ } else {
215+ // Audio: full extraction
216+ duration = extractAudioDuration (uri );
217+ }
218+
219+ if (duration != null && !duration .isEmpty ()) {
220+ String key = CACHE_PREFIX + uri .toString ();
221+
222+ // Get the cached metadata (without duration) and update it
223+ String serialized = preferences .getString (key , null );
224+ if (serialized != null ) {
225+ SAFMetadata metadata = SAFMetadata .deserialize (serialized );
226+ if (metadata != null ) {
227+ // Create new metadata with the extracted duration
228+ SAFMetadata updated = new SAFMetadata (duration , metadata .mimeType , metadata .shortId , metadata .fileSize );
229+ String updatedSerialized = updated .serialize ();
230+
231+ lruCache .put (key , updatedSerialized );
232+ preferences .edit ().putString (key , updatedSerialized ).apply ();
233+ YaaccLogger .d (getClass ().getName (), "DURATION_EXTRACTED: " + uri .getLastPathSegment () + " -> " + duration );
234+ }
235+ }
236+ }
237+ } catch (Exception e ) {
238+ YaaccLogger .w (getClass ().getName (), "DURATION_EXTRACT_FAILED: " + uri .getLastPathSegment (), e );
239+ }
240+ });
191241 }
192242
193243 /**
@@ -227,10 +277,129 @@ private String extractMimeType(DocumentFile file) {
227277 if (mimeType != null ) return mimeType ;
228278 }
229279 }
280+
230281 // Fall back to system lookup
231- return file .getType ();
282+ String mimeType = file .getType ();
283+ if (mimeType != null && !mimeType .equals ("null" ) && mimeType .contains ("/" )) {
284+ return mimeType ;
285+ }
286+
287+ // Heuristic: guess from extension (returns null if unknown)
288+ return file .getName () != null ? guessMimeTypeFromExtension (file .getName ()) : null ;
289+ }
290+
291+ public String guessMimeTypeFromExtension (String filename ) {
292+ if (filename == null ) return null ;
293+ int dotIndex = filename .lastIndexOf ('.' );
294+ if (dotIndex <= 0 ) return null ;
295+
296+ String ext = filename .substring (dotIndex + 1 ).toLowerCase ();
297+ switch (ext ) {
298+ case "mp4" : case "m4v" : case "mov" : return "video/mp4" ;
299+ case "avi" : case "divx" : return "video/x-msvideo" ;
300+ case "mkv" : case "webm" : return "video/x-matroska" ;
301+ case "mp3" : return "audio/mpeg" ;
302+ case "aac" : case "m4a" : return "audio/aac" ;
303+ case "wav" : return "audio/wav" ;
304+ case "ogg" : case "oga" : return "audio/ogg" ;
305+ case "flac" : return "audio/flac" ;
306+ case "jpg" : case "jpeg" : return "image/jpeg" ;
307+ case "png" : return "image/png" ;
308+ case "gif" : return "image/gif" ;
309+ default : return null ;
310+ }
232311 }
233312
313+ private String extractAudioDuration (Uri uri ) {
314+ MediaMetadataRetriever retriever = null ;
315+ try {
316+ retriever = new MediaMetadataRetriever ();
317+ retriever .setDataSource (context , uri );
318+ String durationStr = retriever .extractMetadata (MediaMetadataRetriever .METADATA_KEY_DURATION );
319+ if (durationStr != null ) {
320+ long durationMs = Long .parseLong (durationStr );
321+ return FormatHelper .parseMillisToTimeStringTo (durationMs );
322+ }
323+ } catch (Exception e ) {
324+ YaaccLogger .d (getClass ().getName (), "Duration not available: " + uri .getLastPathSegment ());
325+ } finally {
326+ if (retriever != null ) {
327+ try { retriever .release (); } catch (Exception ignored ) {}
328+ }
329+ }
330+ return "" ;
331+ }
332+
333+ /**
334+ * Quick video duration extraction from first 1MB (MOOV atom).
335+ */
336+ private String extractVideoDurationQuick (Uri uri ) {
337+ try (java .io .InputStream is = context .getContentResolver ().openInputStream (uri )) {
338+ if (is == null ) return null ;
339+
340+ byte [] buffer = new byte [1024 * 1024 ]; // 1MB
341+ int bytesRead = is .read (buffer );
342+
343+ // Try to find duration from MP4 MOOV atom
344+ String duration = parseMp4Duration (buffer , bytesRead );
345+ if (duration != null && !duration .isEmpty ()) {
346+ YaaccLogger .d (getClass ().getName (), "VIDEO_QUICK_EXTRACT: " + uri .getLastPathSegment () + " -> " + duration );
347+ return duration ;
348+ }
349+ } catch (Exception e ) {
350+ YaaccLogger .d (getClass ().getName (), "VIDEO_QUICK_EXTRACT_FAILED: " + uri .getLastPathSegment ());
351+ }
352+ return null ;
353+ }
354+
355+ /**
356+ * Parse MP4 MOOV atom to extract duration.
357+ */
358+ private String parseMp4Duration (byte [] buffer , int bytesRead ) {
359+ // Look for 'mvhd' (movie header) atom which contains duration
360+ String mvhd = "mvhd" ;
361+ for (int i = 0 ; i < bytesRead - 20 ; i ++) {
362+ if (buffer [i ] == 'm' && buffer [i + 1 ] == 'v' &&
363+ buffer [i + 2 ] == 'h' && buffer [i + 3 ] == 'd' ) {
364+
365+ // Found mvhd atom, duration is at specific offset
366+ try {
367+ // Version at i+4, flags at i+5-7
368+ int version = buffer [i + 4 ] & 0xFF ;
369+
370+ if (version == 0 ) {
371+ // 32-bit duration at offset 16
372+ int durationSecs = ((buffer [i + 16 ] & 0xFF ) << 24 ) |
373+ ((buffer [i + 17 ] & 0xFF ) << 16 ) |
374+ ((buffer [i + 18 ] & 0xFF ) << 8 ) |
375+ (buffer [i + 19 ] & 0xFF );
376+
377+ if (durationSecs > 0 && durationSecs < 1000000 ) {
378+ return FormatHelper .parseMillisToTimeStringTo (durationSecs * 1000L );
379+ }
380+ } else if (version == 1 ) {
381+ // 64-bit duration at offset 28 (different offset in version 1)
382+ long durationSecs = ((long )(buffer [i + 28 ] & 0xFF ) << 56 ) |
383+ ((long )(buffer [i + 29 ] & 0xFF ) << 48 ) |
384+ ((long )(buffer [i + 30 ] & 0xFF ) << 40 ) |
385+ ((long )(buffer [i + 31 ] & 0xFF ) << 32 ) |
386+ ((long )(buffer [i + 32 ] & 0xFF ) << 24 ) |
387+ ((long )(buffer [i + 33 ] & 0xFF ) << 16 ) |
388+ ((long )(buffer [i + 34 ] & 0xFF ) << 8 ) |
389+ (buffer [i + 35 ] & 0xFF );
390+
391+ if (durationSecs > 0 && durationSecs < 1000000 ) {
392+ return FormatHelper .parseMillisToTimeStringTo (durationSecs * 1000L );
393+ }
394+ }
395+ } catch (Exception e ) {
396+ YaaccLogger .d (getClass ().getName (), "Error parsing mvhd atom" );
397+ }
398+ }
399+ }
400+ return null ;
401+ }
402+
234403 private String extractDuration (Uri uri ) {
235404 MediaMetadataRetriever retriever = null ;
236405 try {
0 commit comments