Skip to content

Commit f7015af

Browse files
committed
fix: optimized media type extraction on saf content
1 parent e37727e commit f7015af

2 files changed

Lines changed: 193 additions & 6 deletions

File tree

yaacc/src/main/java/de/yaacc/upnp/server/contentdirectory/SafFolderBrowser.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,12 @@ public List<Item> browseItem(YaaccContentDirectory contentDirectory, String myId
229229
DocumentFile file = DocumentFile.fromSingleUri(getContext(), Uri.parse(path));
230230
if (file != null && !file.isDirectory()) {
231231
Item item = createItem(contentDirectory, path, file, myId, !file.canRead());
232-
if (item != null) result.add(item);
232+
if (item != null) {
233+
result.add(item);
234+
YaaccLogger.d(getClass().getName(), "✓ Added to result: Item[" + (result.size() - 1) + "] " + (file.getName() != null ? file.getName() : "unknown"));
235+
} else {
236+
YaaccLogger.d(getClass().getName(), "✗ Skipped (null item): " + (file.getName() != null ? file.getName() : "unknown"));
237+
}
233238
YaaccLogger.d(getClass().getName(), "Item[" + i + "] " + (file.getName() != null ? file.getName() : "unknown") + " (took " + (System.currentTimeMillis() - itemStart) + "ms)");
234239
}
235240
}
@@ -294,15 +299,28 @@ private Item createItem(YaaccContentDirectory contentDirectory, String path, Doc
294299

295300
// Get all metadata from cache (duration, MIME type, short ID)
296301
SAFMetadata metadata = SAFCacheManager.getInstance(getContext()).getMetadata(file);
297-
if (metadata == null || metadata.mimeType == null) {
302+
if (metadata == null) {
298303
return null;
299304
}
305+
306+
// If MIME type is null or invalid, try to guess from filename
307+
String mimeTypeStr = metadata.mimeType;
308+
if (mimeTypeStr == null || mimeTypeStr.equals("null") || !mimeTypeStr.contains("/")) {
309+
mimeTypeStr = SAFCacheManager.getInstance(getContext()).guessMimeTypeFromExtension(file.getName());
310+
if (mimeTypeStr == null) {
311+
return null; // Still couldn't determine MIME type
312+
}
313+
}
300314

301-
MimeType mimeType = MimeType.valueOf(metadata.mimeType);
315+
MimeType mimeType = MimeType.valueOf(mimeTypeStr);
302316
String mimeTypeMain = mimeType.getType();
303317

304318
String id = ContentDirectoryIDs.SAF_PREFIX.getId() + metadata.shortId;
305319
String title = file.getName() != null ? file.getName() : extractFilenameFromUri(path);
320+
if (file.getName() == null) {
321+
YaaccLogger.d(getClass().getName(), "file.getName() is null for URI: " + file.getUri());
322+
}
323+
306324
if (restricted) {
307325
title = "[X] " + title;
308326
}

yaacc/src/main/java/de/yaacc/util/SAFCacheManager.java

Lines changed: 172 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)