feat: Add metadata caching and proper integration for external media files

This commit is contained in:
le-firehawk 2025-09-16 23:22:18 +09:30
parent 24864637f9
commit 682f63ef38
17 changed files with 515 additions and 136 deletions

View file

@ -85,6 +85,13 @@ object Constants {
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
const val DOWNLOAD_URI = "rest/download"
const val ACTION_PLAY_EXTERNAL_DOWNLOAD = "com.cappielloantonio.tempo.action.PLAY_EXTERNAL_DOWNLOAD"
const val EXTRA_DOWNLOAD_URI = "EXTRA_DOWNLOAD_URI"
const val EXTRA_DOWNLOAD_MEDIA_ID = "EXTRA_DOWNLOAD_MEDIA_ID"
const val EXTRA_DOWNLOAD_TITLE = "EXTRA_DOWNLOAD_TITLE"
const val EXTRA_DOWNLOAD_ARTIST = "EXTRA_DOWNLOAD_ARTIST"
const val EXTRA_DOWNLOAD_ALBUM = "EXTRA_DOWNLOAD_ALBUM"
const val EXTRA_DOWNLOAD_DURATION = "EXTRA_DOWNLOAD_DURATION"
const val DOWNLOAD_TYPE_TRACK = "download_type_track"
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"

View file

@ -10,8 +10,10 @@ import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public class ExternalAudioReader {
@ -36,6 +38,7 @@ public class ExternalAudioReader {
if (uriString == null) {
cache.clear();
cachedDirUri = null;
ExternalDownloadMetadataStore.clear();
return;
}
@ -43,12 +46,36 @@ public class ExternalAudioReader {
cache.clear();
DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString));
Map<String, Long> expectedSizes = ExternalDownloadMetadataStore.snapshot();
Set<String> verifiedKeys = new HashSet<>();
if (directory != null && directory.canRead()) {
for (DocumentFile file : directory.listFiles()) {
if (file == null || file.isDirectory()) continue;
String existing = file.getName();
if (existing != null) {
String base = existing.replaceFirst("\\.[^\\.]+$", "");
cache.put(normalizeForComparison(base), file);
if (existing == null) continue;
String base = existing.replaceFirst("\\.[^\\.]+$", "");
String key = normalizeForComparison(base);
Long expected = expectedSizes.get(key);
long actualLength = file.length();
if (expected != null && expected > 0 && actualLength == expected) {
cache.put(key, file);
verifiedKeys.add(key);
} else {
ExternalDownloadMetadataStore.remove(key);
}
}
}
if (!expectedSizes.isEmpty()) {
if (verifiedKeys.isEmpty()) {
ExternalDownloadMetadataStore.clear();
} else {
for (String key : expectedSizes.keySet()) {
if (!verifiedKeys.contains(key)) {
ExternalDownloadMetadataStore.remove(key);
}
}
}
}
@ -56,7 +83,6 @@ public class ExternalAudioReader {
cachedDirUri = uriString;
}
/** Rebuilds the cache on next access. */
public static synchronized void refreshCache() {
cachedDirUri = null;
cache.clear();
@ -96,6 +122,7 @@ public class ExternalAudioReader {
}
if (deleted) {
cache.remove(key);
ExternalDownloadMetadataStore.remove(key);
}
return deleted;
}

View file

@ -12,8 +12,8 @@ import androidx.core.app.NotificationCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.media3.common.MediaItem;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import java.io.InputStream;
import java.io.OutputStream;
@ -21,9 +21,19 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.text.Normalizer;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExternalAudioWriter {
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
private static final int BUFFER_SIZE = 8192;
private static final int CONNECT_TIMEOUT_MS = 15_000;
private static final int READ_TIMEOUT_MS = 60_000;
private ExternalAudioWriter() {
}
private static String sanitizeFileName(String name) {
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
sanitized = sanitized.replaceAll("\\s+", " ").trim();
@ -40,6 +50,7 @@ public class ExternalAudioWriter {
private static DocumentFile findFile(DocumentFile dir, String fileName) {
String normalized = normalizeForComparison(fileName);
for (DocumentFile file : dir.listFiles()) {
if (file.isDirectory()) continue;
String existing = file.getName();
if (existing != null && normalizeForComparison(existing).equals(normalized)) {
return file;
@ -48,115 +59,182 @@ public class ExternalAudioWriter {
return null;
}
public static void downloadToUserDirectory(Context context, MediaItem mediaItem, String fallbackName) {
new Thread(() -> {
String uriString = Preferences.getDownloadDirectoryUri();
public static void downloadToUserDirectory(Context context, Child child) {
if (context == null || child == null) {
return;
}
Context appContext = context.getApplicationContext();
MediaItem mediaItem = MappingUtil.mapDownload(child);
String fallbackName = child.getTitle() != null ? child.getTitle() : child.getId();
EXECUTOR.execute(() -> performDownload(appContext, mediaItem, fallbackName, child));
}
if (uriString == null) {
notifyUnavailable(context);
private static void performDownload(Context context, MediaItem mediaItem, String fallbackName, Child child) {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
notifyUnavailable(context);
return;
}
DocumentFile directory = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
if (directory == null || !directory.canWrite()) {
notifyFailure(context, "Cannot write to folder.");
return;
}
String artist = child.getArtist() != null ? child.getArtist() : "";
String title = child.getTitle() != null ? child.getTitle() : fallbackName;
String album = child.getAlbum() != null ? child.getAlbum() : "";
String baseName = artist.isEmpty() ? title : artist + " - " + title;
if (!album.isEmpty()) baseName += " (" + album + ")";
if (baseName.isEmpty()) {
baseName = fallbackName != null ? fallbackName : "download";
}
String metadataKey = normalizeForComparison(baseName);
Uri mediaUri = mediaItem != null && mediaItem.requestMetadata != null
? mediaItem.requestMetadata.mediaUri
: null;
if (mediaUri == null) {
notifyFailure(context, "Invalid media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
String scheme = mediaUri.getScheme();
if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) {
notifyFailure(context, "Unsupported media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
HttpURLConnection connection = null;
DocumentFile targetFile = null;
try {
connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestProperty("Accept-Encoding", "identity");
connection.connect();
int responseCode = connection.getResponseCode();
if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) {
notifyFailure(context, "Server returned " + responseCode);
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
Uri treeUri = Uri.parse(uriString);
DocumentFile directory = DocumentFile.fromTreeUri(context, treeUri);
if (directory == null || !directory.canWrite()) {
notifyFailure(context, "Cannot write to folder.");
String mimeType = connection.getContentType();
if (mimeType == null || mimeType.isEmpty()) {
mimeType = "application/octet-stream";
}
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if (extension == null || extension.isEmpty()) {
String suffix = child.getSuffix();
if (suffix != null && !suffix.isEmpty()) {
extension = suffix;
} else {
extension = "bin";
}
}
String sanitized = sanitizeFileName(baseName);
if (sanitized.isEmpty()) sanitized = sanitizeFileName(fallbackName);
if (sanitized.isEmpty()) sanitized = "download";
String fileName = sanitized + "." + extension;
DocumentFile existingFile = findFile(directory, fileName);
long remoteLength = connection.getContentLengthLong();
Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
if (existingFile != null && existingFile.exists()) {
long localLength = existingFile.length();
boolean matches = false;
if (remoteLength > 0 && localLength == remoteLength) {
matches = true;
} else if (remoteLength <= 0 && recordedSize != null && localLength == recordedSize) {
matches = true;
}
if (matches) {
ExternalDownloadMetadataStore.recordSize(metadataKey, localLength);
ExternalAudioReader.refreshCache();
notifyExists(context, fileName);
return;
} else {
existingFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
}
}
targetFile = directory.createFile(mimeType, fileName);
if (targetFile == null) {
notifyFailure(context, "Failed to create file.");
return;
}
try {
Uri mediaUri = mediaItem.requestMetadata.mediaUri;
if (mediaUri == null) {
notifyFailure(context, "Invalid media URI.");
Uri targetUri = targetFile.getUri();
try (InputStream in = connection.getInputStream();
OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
if (out == null) {
notifyFailure(context, "Cannot open output stream.");
targetFile.delete();
return;
}
String scheme = mediaUri.getScheme();
if (scheme == null || (!scheme.equals("http") && !scheme.equals("https"))) {
notifyExists(context, fallbackName);
byte[] buffer = new byte[BUFFER_SIZE];
int len;
long total = 0;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
total += len;
}
out.flush();
if (total <= 0) {
targetFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, "Empty download.");
return;
}
HttpURLConnection connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection();
connection.connect();
String mimeType = connection.getContentType();
if (mimeType == null) mimeType = "application/octet-stream";
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if (extension == null) extension = "bin";
String artist = mediaItem.mediaMetadata.artist != null ? mediaItem.mediaMetadata.artist.toString() : "";
String title = mediaItem.mediaMetadata.title != null ? mediaItem.mediaMetadata.title.toString() : fallbackName;
String album = mediaItem.mediaMetadata.albumTitle != null ? mediaItem.mediaMetadata.albumTitle.toString() : "";
String name = artist.isEmpty() ? title : artist + " - " + title;
if (!album.isEmpty()) name += " (" + album + ")";
String sanitized = sanitizeFileName(name);
String fullName = sanitized + "." + extension;
DocumentFile existingFile = findFile(directory, fullName);
long remoteLength = connection.getContentLengthLong();
if (existingFile != null && existingFile.exists()) {
long localLength = existingFile.length();
if (remoteLength > 0 && localLength == remoteLength) {
notifyExists(context, fullName);
return;
} else {
existingFile.delete();
}
}
DocumentFile targetFile = directory.createFile(mimeType, fullName);
if (targetFile == null) {
notifyFailure(context, "Failed to create file.");
if (remoteLength > 0 && total != remoteLength) {
targetFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, "Incomplete download.");
return;
}
try (InputStream in = connection.getInputStream();
OutputStream out = context.getContentResolver().openOutputStream(targetFile.getUri())) {
if (out == null) {
notifyFailure(context, "Cannot open output stream.");
return;
}
byte[] buffer = new byte[8192];
int len;
long total = 0;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
total += len;
}
if (remoteLength > 0 && total != remoteLength) {
targetFile.delete();
notifyFailure(context, "Incomplete download.");
} else {
notifySuccess(context, fullName);
ExternalAudioReader.refreshCache();
}
}
} catch (Exception e) {
notifyFailure(context, e.getMessage());
ExternalDownloadMetadataStore.recordSize(metadataKey, total);
notifySuccess(context, fileName, child, targetUri);
ExternalAudioReader.refreshCache();
}
}).start();
} catch (Exception e) {
if (targetFile != null) {
targetFile.delete();
}
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, e.getMessage() != null ? e.getMessage() : "Download failed");
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private static void notifyUnavailable(Context context) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.getPackageName(), null));
Uri.fromParts("package", context.getPackageName(), null));
PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("No download folder set")
.setContentText("Tap to set one in settings")
.setSmallIcon(android.R.drawable.stat_notify_error)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.setContentIntent(openSettings)
.setAutoCancel(true);
.setContentTitle("No download folder set")
.setContentText("Tap to set one in settings")
.setSmallIcon(android.R.drawable.stat_notify_error)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.setContentIntent(openSettings)
.setAutoCancel(true);
manager.notify(1011, builder.build());
}
@ -164,30 +242,63 @@ public class ExternalAudioWriter {
private static void notifyFailure(Context context, String message) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Download failed")
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true);
.setContentTitle("Download failed")
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true);
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static void notifySuccess(Context context, String name) {
private static void notifySuccess(Context context, String name, Child child, Uri fileUri) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Download complete")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setAutoCancel(true);
.setContentTitle("Download complete")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setAutoCancel(true);
PendingIntent playIntent = buildPlayIntent(context, child, fileUri);
if (playIntent != null) {
builder.setContentIntent(playIntent);
}
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static void notifyExists(Context context, String name) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Already downloaded")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setAutoCancel(true);
.setContentTitle("Already downloaded")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setAutoCancel(true);
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static PendingIntent buildPlayIntent(Context context, Child child, Uri fileUri) {
if (fileUri == null) return null;
Intent intent = new Intent(context, MainActivity.class)
.setAction(Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD)
.putExtra(Constants.EXTRA_DOWNLOAD_URI, fileUri.toString())
.putExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID, child.getId())
.putExtra(Constants.EXTRA_DOWNLOAD_TITLE, child.getTitle())
.putExtra(Constants.EXTRA_DOWNLOAD_ARTIST, child.getArtist())
.putExtra(Constants.EXTRA_DOWNLOAD_ALBUM, child.getAlbum())
.putExtra(Constants.EXTRA_DOWNLOAD_DURATION, child.getDuration() != null ? child.getDuration() : 0)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
int requestCode;
if (child.getId() != null) {
requestCode = Math.abs(child.getId().hashCode());
} else {
requestCode = Math.abs(fileUri.toString().hashCode());
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
}

View file

@ -0,0 +1,123 @@
package com.cappielloantonio.tempo.util;
import android.content.SharedPreferences;
import androidx.annotation.Nullable;
import com.cappielloantonio.tempo.App;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
public final class ExternalDownloadMetadataStore {
private static final String PREF_KEY = "external_download_metadata";
private ExternalDownloadMetadataStore() {
}
private static SharedPreferences preferences() {
return App.getInstance().getPreferences();
}
private static JSONObject readAll() {
String raw = preferences().getString(PREF_KEY, "{}");
try {
return new JSONObject(raw);
} catch (JSONException e) {
return new JSONObject();
}
}
private static void writeAll(JSONObject object) {
preferences().edit().putString(PREF_KEY, object.toString()).apply();
}
public static synchronized void clear() {
writeAll(new JSONObject());
}
public static synchronized void recordSize(String key, long size) {
if (key == null || size <= 0) {
return;
}
JSONObject object = readAll();
try {
object.put(key, size);
} catch (JSONException ignored) {
}
writeAll(object);
}
public static synchronized void remove(String key) {
if (key == null) {
return;
}
JSONObject object = readAll();
object.remove(key);
writeAll(object);
}
@Nullable
public static synchronized Long getSize(String key) {
if (key == null) {
return null;
}
JSONObject object = readAll();
if (!object.has(key)) {
return null;
}
long size = object.optLong(key, -1L);
return size > 0 ? size : null;
}
public static synchronized Map<String, Long> snapshot() {
JSONObject object = readAll();
if (object.length() == 0) {
return Collections.emptyMap();
}
Map<String, Long> sizes = new HashMap<>();
Iterator<String> keys = object.keys();
while (keys.hasNext()) {
String key = keys.next();
long size = object.optLong(key, -1L);
if (size > 0) {
sizes.put(key, size);
}
}
return sizes;
}
public static synchronized void retainOnly(Set<String> keysToKeep) {
if (keysToKeep == null || keysToKeep.isEmpty()) {
clear();
return;
}
JSONObject object = readAll();
if (object.length() == 0) {
return;
}
Set<String> keys = new HashSet<>();
Iterator<String> iterator = object.keys();
while (iterator.hasNext()) {
keys.add(iterator.next());
}
boolean changed = false;
for (String key : keys) {
if (!keysToKeep.contains(key)) {
object.remove(key);
changed = true;
}
}
if (changed) {
writeAll(object);
}
}
}

View file

@ -460,6 +460,10 @@ object Preferences {
@JvmStatic
fun setDownloadDirectoryUri(uri: String?) {
val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
if (current != uri) {
ExternalDownloadMetadataStore.clear()
}
App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply()
}