Repository navigation
Feat/audio export #4448
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/audio export #4448
Changes from all commits
490e9dc
4320253
44a706c
b06e76c
2f87687
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| # feat(export): add public audio exporter and playlist search | ||
|
|
||
| ## Problem | ||
| 1. Users were unable to export/download standalone audio files (`.mp3`/`.m4a`) to public device storage (`Music/Metrolist`) for playback in external media players or PC file transfers. | ||
| 2. Duplicate songs appeared in library lists, artist song lists, album song lists, and queue continuations due to missing SQL query DISTINCT clauses and lack of metadata deduplication. | ||
| 3. Users lacked the ability to search for saved playlists in the Library Playlists tab and filter songs inside open playlists. | ||
|
|
||
| ## Cause | ||
| 1. In-app downloads were stored in ExoPlayer's private sandboxed cache directory (`/data/data/com.metrolist...`), making files inaccessible to external apps. | ||
| 2. SQL queries joining relational mapping tables (`song_artist_map` and `song_album_map`) without `DISTINCT` clauses returned multiple duplicate `Song` rows when a track was linked to multiple artists or albums. | ||
| 3. `PlaylistsViewModel` and `LocalPlaylistViewModel` did not expose debounced search query flows or normalized text matching filters. | ||
|
|
||
| ## Solution | ||
| 1. **Public Audio Exporter (`LatikeshExporter.kt`):** | ||
| * Implemented `LatikeshExporter.exportToPublicStorage(...)` which resolves playback audio stream, streams audio bytes with a 16KB buffer on `Dispatchers.IO`, and saves files to `Environment.DIRECTORY_MUSIC + "/Metrolist"` via `MediaStore.Audio.Media`. | ||
| * Added an existing file check (`isAudioFileAlreadyExported`) to prevent duplicate file downloads. | ||
| * Added live progress notifications in the notification shade using `NotificationCompat.Builder`. | ||
| * Added **"Export audio to public storage"** option with storage icon to `SongMenu.kt`. | ||
|
|
||
| 2. **Deduplication Fixes (`DatabaseDao.kt`, `ListExt.kt`, `LibraryViewModels.kt`, `YouTubeQueue.kt`):** | ||
| * Added `DISTINCT` to SQL queries in `DatabaseDao.kt` (`albumSongs`, `artistSongsByCreateDateAsc`, `artistSongsByNameAsc`, `artistSongsByPlayTimeAsc`, `artistSongsPreview`). | ||
| * Implemented smart metadata fingerprinting (`deduplicationKey()`) and `deduplicateSongs()` in `ListExt.kt`. | ||
| * Applied `.deduplicateSongs()` across all song list flows in `LibraryViewModels.kt`. | ||
| * Applied `.distinctBy { it.mediaId }` to initial queue and pagination items in `YouTubeQueue.kt`. | ||
|
|
||
| 3. **Playlist Search Features (`PlaylistsViewModel.kt`, `LocalPlaylistViewModel.kt`, `LibraryPlaylistsScreen.kt`):** | ||
| * Added `searchQuery` and `debouncedSearchQuery` filtering using `matchesNormalizedQuery` in `PlaylistsViewModel.kt` and `LocalPlaylistViewModel.kt`. | ||
|
|
||
| ## Testing | ||
| - [x] **Gradle Build:** Verified compilation using `./gradlew :app:assembleFossDebug` (`BUILD SUCCESSFUL`). | ||
| - [x] **Device Deployment:** Installed and tested on connected device (`SM-E156B`). | ||
| - [x] **Export Verification:** Verified audio file exporting to `Music/Metrolist` with live notification progress bar. | ||
| - [x] **Existing File Detection:** Verified that attempting to export an already saved song skips downloading and displays `"File already exists in Music/Metrolist"`. | ||
| - [x] **UI Performance:** Verified zero UI freeze or stutter during audio export. | ||
|
|
||
| ## Related Issues | ||
| None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,3 +50,26 @@ fun List<Playlist>.filterYoutubeShorts(enabled: Boolean = false) = | |
| } else { | ||
| this | ||
| } | ||
|
|
||
| private val songNoiseRegex = Regex("(?i)\\(official.*?\\)|\\[official.*?]|\\(audio\\)|\\(lyric.*?\\)|official video|official audio|4k|hd") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Restrict quality markers to standalone title text. The unbounded 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Computes a normalized smart metadata fingerprint key for a song to detect duplicate uploads. | ||
| * | ||
| * @return Normalized fingerprint string based on title, primary artist, and duration bucket. | ||
| */ | ||
| fun Song.deduplicationKey(): String { | ||
| val cleanTitle = song.title | ||
| .replace(songNoiseRegex, "") | ||
| .normalizeForSearch() | ||
| val primaryArtist = orderedArtists.firstOrNull()?.name?.normalizeForSearch() ?: "" | ||
| val durationBucket = if (song.duration > 0) song.duration / 3 else 0 | ||
| return if (cleanTitle.isBlank()) song.id else "$cleanTitle|$primaryArtist|$durationBucket" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Preserve distinct songs that share metadata. Two song IDs with the same normalized title, first artist, and duration bucket receive the same key. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Extension function to deduplicate a list of songs using smart metadata fingerprinting. | ||
| * | ||
| * @return List of unique songs with duplicate uploads removed. | ||
| */ | ||
| fun List<Song>.deduplicateSongs(): List<Song> = distinctBy { it.deduplicationKey() } | ||
|
Comment on lines
+65
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This key treats different song IDs as the same song when their cleaned titles, first artists, and three-second duration buckets match. Different recordings or uploads can share those values, including tracks with different additional artists. Applying this to every library filter silently hides saved songs and their menu actions. Use song IDs for duplicate removal, or make approximate matching an explicit user choice. Prompt To Fix With AIThis is a comment left during a code review.
Path: app/src/main/kotlin/com/metrolist/music/extensions/ListExt.kt
Line: 60-65
Comment:
**Distinct saved songs disappear**
This key treats different song IDs as the same song when their cleaned titles, first artists, and three-second duration buckets match. Different recordings or uploads can share those values, including tracks with different additional artists. Applying this to every library filter silently hides saved songs and their menu actions. Use song IDs for duplicate removal, or make approximate matching an explicit user choice.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,12 @@ import com.metrolist.music.models.MediaMetadata | |
| import kotlinx.coroutines.Dispatchers.IO | ||
| import kotlinx.coroutines.withContext | ||
|
|
||
| /** | ||
| * Queue implementation for YouTube Music player streams and radio queues. | ||
| * | ||
| * @property endpoint Initial watch endpoint. | ||
| * @property preloadItem Optional preloaded media metadata. | ||
| */ | ||
| class YouTubeQueue( | ||
| private var endpoint: WatchEndpoint, | ||
| override val preloadItem: MediaMetadata? = null, | ||
|
|
@@ -23,6 +29,11 @@ class YouTubeQueue( | |
|
|
||
| private class EmptyRadioQueueException : IllegalStateException() | ||
|
|
||
| /** | ||
| * Loads the initial status and items for the queue, deduplicating track IDs. | ||
| * | ||
| * @return Initial queue status containing title, media items, and starting index. | ||
| */ | ||
| override suspend fun getInitialStatus(): Queue.Status { | ||
| return withContext(IO) { | ||
| var lastException: Throwable? = null | ||
|
|
@@ -62,7 +73,7 @@ class YouTubeQueue( | |
| retryCount = 0 | ||
| return@withContext Queue.Status( | ||
| title = nextResult.title, | ||
| items = items.map { it.toMediaItem() }, | ||
| items = items.map { it.toMediaItem() }.distinctBy { it.mediaId }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Recalculate If the initial items are 🤖 Prompt for AI Agents |
||
| mediaItemIndex = nextResult.currentIndex ?: 0, | ||
|
Comment on lines
+76
to
77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Removing duplicate items changes their positions, but Prompt To Fix With AIThis is a comment left during a code review.
Path: app/src/main/kotlin/com/metrolist/music/playback/queues/YouTubeQueue.kt
Line: 65-66
Comment:
**Playlist starts the wrong song**
Removing duplicate items changes their positions, but `mediaItemIndex` still uses the position from the original list. For example, `[A, A, B, C]` with `B` selected at index 2 becomes `[A, B, C]` and starts `C`. Playback also uses this position to split the queue, which can fail when the old position is outside the shortened list. Recompute the selected position after removing duplicates, or preserve repeated playlist entries.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| ) | ||
| } catch (e: Exception) { | ||
|
|
@@ -81,8 +92,18 @@ class YouTubeQueue( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Checks if more queue continuation pages are available. | ||
| * | ||
| * @return True if continuation token exists, false otherwise. | ||
| */ | ||
| override fun hasNextPage(): Boolean = continuation != null | ||
|
|
||
| /** | ||
| * Loads the next continuation page of queue items, deduplicating media items. | ||
| * | ||
| * @return List of deduplicated MediaItems for upcoming tracks. | ||
| */ | ||
| override suspend fun nextPage(): List<MediaItem> { | ||
| return withContext(IO) { | ||
| var lastException: Throwable? = null | ||
|
|
@@ -93,7 +114,7 @@ class YouTubeQueue( | |
| endpoint = nextResult.endpoint | ||
| continuation = nextResult.continuation | ||
| retryCount = 0 | ||
| return@withContext nextResult.items.map { it.toMediaItem() } | ||
| return@withContext nextResult.items.map { it.toMediaItem() }.distinctBy { it.mediaId } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Deduplicate against items from earlier pages.
🤖 Prompt for AI Agents |
||
| } catch (e: Exception) { | ||
| lastException = e | ||
| retryCount++ | ||
|
|
@@ -110,6 +131,9 @@ class YouTubeQueue( | |
| /** | ||
| * Creates a radio queue based on a song. | ||
| * Explicitly requests the RDAMVM playlist to trigger automotive/radio mixing. | ||
| * | ||
| * @param song Target track metadata to start radio from. | ||
| * @return YouTubeQueue instance configured for radio playback. | ||
| */ | ||
| fun radio(song: MediaMetadata): YouTubeQueue { | ||
| return YouTubeQueue( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Describe both storage paths.
Line 15 says all exports use
MediaStore.Audio.Media. The exporter uses MediaStore on Android Q and later, but writes withFileOutputStreamto the public music directory on earlier versions. Update the description to state both paths.🤖 Prompt for AI Agents