diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae4ab85..0fe5a44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,11 +45,33 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Media notification | ✅ | ✅ |
| ABS notification (modern style) | ❌ | ✅ |
| Android Auto | — | ✅ |
+| Offline download (albums/playlists) | ❌ | ✅ |
+| Offline playback | ❌ | ✅ |
+| Offline detail / manage downloads | ❌ | ✅ |
| Romaji / Japanese sort | ✅ | ❌ |
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
---
+## [0.10] — 2026-06-25
+
+### Added
+- **Android** — Offline downloads: download albums and playlists to device storage with a single tap; album download button is in the album hero row (▶ / shuffle / ↓), playlist button next to the edit toggle. Tapping again resumes / re-downloads any missing songs (already-downloaded tracks are skipped)
+- **Android** — Offline tab in Library (position 2, next to Playlists): lists downloaded groups with cover art, track count, and an in-progress bar; shows total MB used
+- **Android** — Offline detail screen: tap a group to view its songs (with cover art, like the online playlist), play / shuffle, and remove individual songs from the device via the edit toggle. Works fully offline (reads from Room, no network)
+- **Android** — Offline playback: tap ▶ on a fully-downloaded group to play locally; downloaded songs are also served from disk automatically when opened from any album or playlist view
+- **Android** — Downloaded badge: albums and playlists in the Library tabs show a ↓-in-circle badge on the cover when fully downloaded
+- **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it
+
+### Fixed
+- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
+- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
+
+### Internal
+- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
+
+---
+
## [0.9] — 2026-06-22
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 978d911..19f09ed 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,6 +70,15 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
+## Android offline system
+
+- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
+- **Database:** Room DB `novasonic_offline.db` (version 1) with three tables: `downloaded_tracks` (track metadata + local paths + state), `download_groups` (album/playlist/song group + progress), `group_track_cross_refs` (many-to-many). See [OfflineEntities.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt) and [OfflineDao.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt).
+- **OfflineRepository** ([OfflineRepository.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt)): holds hot `StateFlow` maps (`_audioMap`, `_coverMap`) keyed by songId/coverArtId for O(1) lookups in `MusicRepository.streamUrl()`. Call `enqueueGroup()` to schedule a download.
+- **DownloadWorker** ([DownloadWorker.kt](android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt)): `CoroutineWorker` using plain `HttpURLConnection` streaming to `.tmp` → rename. **No `setExpedited()` and no `setForeground()`** — `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work.
+- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
+- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
+
## UI / references
When referencing files in chat, use clickable markdown links, not backticks:
diff --git a/README.md b/README.md
index 9d35372..3e003a4 100644
--- a/README.md
+++ b/README.md
@@ -7,70 +7,17 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
## Features
-### Music Library
-- Stream music from your Navidrome / Subsonic library
-- Browse artists, albums, playlists, starred tracks, genres, and random picks
-- Search across songs, albums, and artists
-- Star and context menu on every song row
-- Play count display (optional)
+- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
+- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
+- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
+- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and iTunes cross-reference showing owned vs. missing tracks.
+- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
+- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with iTunes metadata enrichment and duplicate detection.
+- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
+- **Offline** (Android) — Download albums/playlists to private storage; an Offline tab to play and manage them; downloaded tracks play from disk on every screen.
+- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
-### Lyrics
-- Synced and plain lyrics via lrclib.net, auto-fetched per track
-- Fallback chain: exact match → no-duration match → fuzzy artist+title search → title-only search
-- In-memory cache per session
-
-### Artist Pages
-- Hero card with artist photo and blurred background
-- Bio, similar artists, top songs from Last.fm
-- Concert events via Ticketmaster
-- Albums cross-referenced against iTunes catalog — shows downloaded vs. missing tracks
-- Artist cards with images in search results
-
-### Playback
-- Queue with shuffle, repeat (all / one), and radio mode (endless similar songs)
-- Now Playing overlay with synced lyrics, queue view, star, and multi-artist links
-- Gapless-style playback — next track pre-buffered before current track ends
-- Lock screen / notification media controls
-- Scrobbling to Navidrome (configurable delay)
-
-### Discovery
-- iTunes / Last.fm — charts, global search, 30-second previews, similar artists
-- Song Importer — paste a Spotify, YouTube, YouTube Music, or SoundCloud link to download
-
-### Downloads
-- Download tracks, albums, or entire artists from YouTube Music / SoundCloud / Spotify via yt-dlp
-- iTunes metadata enrichment — album art, year, track number, genre
-- Duplicate detection, standard folder/filename structure
-
-### Metadata Editor
-- Edit tags (title, artist, album, genre, year, track, disc, cover art)
-- File renamed and moved automatically; Navidrome rescan triggered on save
-
-### Playlists
-- Create, rename, and delete playlists
-- Add tracks via "Add to Playlist" context menu
-- Remove tracks from a playlist (edit mode on Android; inline on Web)
-- Cover image upload (Web + Android)
-
-### Audiobookshelf Integration
-- Browse libraries and audiobooks on both Web and Android
-- Stream audiobooks with chapter-by-chapter navigation
-- Playback speed control (0.5× – 2.0×)
-- Sleep timer
-- Progress sync — current position saved back to ABS server every 30 s
-- Chapter track mode — progress bar shows current chapter length instead of full book (toggle in Settings)
-- Tab/section hidden until ABS is configured in Settings
-
-### App — Web PWA
-- Installable on Android / iOS / desktop
-- Multiple color themes, German UI
-
-### App — Android (native Jetpack Compose)
-- Media3 / ExoPlayer backend
-- OS notification with playback controls
-- ABS audiobook notification: chapter name shown as title, book title as artist; extra buttons — Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor (compact + expanded)
-- Last played song shown in mini player bar on restart; tap to resume
-- Full playlist management: create, rename (with description), delete, remove tracks, cover upload
+Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
---
@@ -119,6 +66,7 @@ NovaSonic/
│ └── app/src/main/kotlin/com/novasonic/app/
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient
│ ├── data/ # PrefsStore (DataStore), MusicRepository
+│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
│ ├── player/ # PlayerController, MusicService (Media3)
│ ├── ui/
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 9f3c9ad..f6c9dac 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -87,6 +87,9 @@ dependencies {
implementation(libs.room.ktx)
ksp(libs.room.compiler)
+ // WorkManager
+ implementation(libs.work.runtime.ktx)
+
// DataStore
implementation(libs.datastore.preferences)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fddbd2f..6ec18a9 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt
index 8ffbd0a..53c3ca3 100644
--- a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt
+++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt
@@ -2,6 +2,7 @@ package com.novasonic.app
import android.app.Application
import com.novasonic.app.data.MusicRepository
+import com.novasonic.app.offline.OfflineRepository
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.player.PlayerController
@@ -13,9 +14,12 @@ class NovaSonicApp : Application() {
private set
lateinit var absPlayerController: AbsPlayerController
private set
+ lateinit var offlineRepository: OfflineRepository
+ private set
override fun onCreate() {
super.onCreate()
+ offlineRepository = OfflineRepository(applicationContext) // must be first
repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository)
playerController.connect()
diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt
index 71c315a..c0c6424 100644
--- a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt
+++ b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt
@@ -126,7 +126,8 @@ object LrcLibClient {
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
val pool = synced.ifEmpty { candidates }
return if (durationSecs != null && durationSecs > 0) {
- val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }!!
+ val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
+ ?: return synced.firstOrNull() ?: candidates.firstOrNull()
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
else synced.firstOrNull() ?: candidates.firstOrNull()
} else {
diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt
index 2f98d8b..0ef7932 100644
--- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt
+++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt
@@ -1,9 +1,13 @@
package com.novasonic.app.data
import android.content.Context
+import android.net.Uri
+import com.novasonic.app.NovaSonicApp
import com.novasonic.app.api.SubsonicClient
import com.novasonic.app.api.model.*
+import com.novasonic.app.offline.OfflineRepository
import kotlinx.coroutines.flow.first
+import java.io.File
object ClientHolder {
@Volatile private var client: SubsonicClient? = null
@@ -65,16 +69,30 @@ class MusicRepository(private val context: Context) {
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
fun coverArtUrl(id: String?, size: Int = 300): String? {
+ if (id != null) {
+ offlineRepo()?.localCoverPath(id)?.let { path ->
+ val f = File(path)
+ if (f.exists()) return Uri.fromFile(f).toString()
+ }
+ }
val creds = lastCredentials ?: return null
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
.coverArtUrl(id ?: return null, size)
}
fun streamUrl(id: String): String? {
+ offlineRepo()?.localAudioPath(id)?.let { path ->
+ val f = File(path)
+ if (f.exists()) return Uri.fromFile(f).toString()
+ }
val creds = lastCredentials ?: return null
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
}
+ private fun offlineRepo(): OfflineRepository? = runCatching {
+ (context.applicationContext as NovaSonicApp).offlineRepository
+ }.getOrNull()
+
var lastCredentials: PrefsStore.Credentials? = null
fun credentialsFlow() = prefs.credentials
diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt
new file mode 100644
index 0000000..8539870
--- /dev/null
+++ b/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt
@@ -0,0 +1,114 @@
+package com.novasonic.app.offline
+
+import android.content.Context
+import android.util.Log
+import androidx.work.*
+import com.novasonic.app.app
+import com.novasonic.app.data.PrefsStore
+import com.novasonic.app.api.SubsonicClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+class DownloadWorker(
+ appContext: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(appContext, params) {
+
+ companion object {
+ const val EXTRA_GROUP_ID = "group_id"
+ }
+
+ private val offlineRepo = applicationContext.app.offlineRepository
+
+ override suspend fun doWork(): Result {
+ val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
+ val group = offlineRepo.getGroup(groupId) ?: return Result.failure()
+ val songIds = offlineRepo.songIdsForGroup(groupId)
+ if (songIds.isEmpty()) return Result.success()
+
+ val creds = PrefsStore(applicationContext).credentials.first()
+ if (!creds.isValid) return Result.failure()
+
+ val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
+ var done = group.downloadedTracks
+ val dao = OfflineDatabase.get(applicationContext).offlineDao()
+
+ return try {
+ for (songId in songIds) {
+ if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
+ done++
+ dao.updateGroupProgress(groupId, done)
+ continue
+ }
+
+ val track = offlineRepo.getTrack(songId) ?: continue
+ dao.updateState(songId, DownloadState.DOWNLOADING, 0f)
+
+ val suffix = track.suffix?.takeIf { it.isNotBlank() } ?: "mp3"
+ val audioFile = offlineRepo.audioFile(songId, suffix)
+
+ try {
+ streamToFile(subsonicClient.streamUrl(songId), audioFile)
+
+ val coverPath = track.coverArt?.let { coverArtId ->
+ val existing = offlineRepo.localCoverPath(coverArtId)
+ if (existing != null && File(existing).exists()) {
+ existing
+ } else {
+ val coverFile = offlineRepo.coverFile(coverArtId)
+ runCatching { streamToFile(subsonicClient.coverArtUrl(coverArtId, 512), coverFile) }
+ if (coverFile.exists()) coverFile.absolutePath else null
+ }
+ }
+
+ offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
+ done++
+ } catch (e: Exception) {
+ Log.e("DownloadWorker", "Failed to download song $songId", e)
+ audioFile.delete()
+ offlineRepo.markFailed(songId)
+ }
+
+ dao.updateGroupProgress(groupId, done)
+ }
+ Result.success()
+ } catch (e: Exception) {
+ Log.e("DownloadWorker", "Worker failed for group $groupId", e)
+ Result.failure()
+ }
+ }
+
+ private suspend fun streamToFile(url: String, dest: File) = withContext(Dispatchers.IO) {
+ val tmp = File("${dest.absolutePath}.tmp")
+ try {
+ val conn = URL(url).openConnection() as HttpURLConnection
+ conn.connectTimeout = 30_000
+ conn.readTimeout = 120_000
+ conn.connect()
+ if (conn.responseCode !in 200..299) {
+ throw Exception("HTTP ${conn.responseCode} for ${dest.name}")
+ }
+ conn.inputStream.use { input ->
+ FileOutputStream(tmp).use { out ->
+ val buf = ByteArray(32_768)
+ var n: Int
+ while (input.read(buf).also { n = it } >= 0) {
+ out.write(buf, 0, n)
+ }
+ }
+ }
+ if (!tmp.renameTo(dest)) {
+ tmp.copyTo(dest, overwrite = true)
+ tmp.delete()
+ }
+ } catch (e: Exception) {
+ tmp.delete()
+ throw e
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt
new file mode 100644
index 0000000..bdaa0cb
--- /dev/null
+++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt
@@ -0,0 +1,93 @@
+package com.novasonic.app.offline
+
+import androidx.room.*
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface OfflineDao {
+
+ // ── Tracks ────────────────────────────────────────────────────────────────
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertTrack(track: DownloadedTrackEntity)
+
+ @Query("UPDATE downloaded_tracks SET state = :state, progress = :progress WHERE songId = :songId")
+ suspend fun updateState(songId: String, state: DownloadState, progress: Float)
+
+ @Query("UPDATE downloaded_tracks SET state = 'DONE', progress = 1, localPath = :localPath, coverPath = :coverPath, downloadedAt = :at WHERE songId = :songId")
+ suspend fun markDone(songId: String, localPath: String, coverPath: String?, at: Long)
+
+ /** Emits whenever any downloaded track changes — drives the hot in-memory maps. */
+ @Query("SELECT songId, localPath, coverArt, coverPath FROM downloaded_tracks WHERE state = 'DONE'")
+ fun doneTracksFlow(): Flow>
+
+ @Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
+ fun trackFlow(songId: String): Flow
+
+ @Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
+ suspend fun getTrackById(songId: String): DownloadedTrackEntity?
+
+ @Query("SELECT state FROM downloaded_tracks WHERE songId = :songId")
+ suspend fun getTrackStateRaw(songId: String): String?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertTrackIfAbsent(track: DownloadedTrackEntity)
+
+ @Query("SELECT * FROM downloaded_tracks ORDER BY downloadedAt DESC")
+ fun allTracksFlow(): Flow>
+
+ @Query("SELECT COUNT(*) FROM group_track_cross_refs WHERE songId = :songId")
+ suspend fun groupCountForSong(songId: String): Int
+
+ @Query("DELETE FROM downloaded_tracks WHERE songId = :songId")
+ suspend fun deleteTrackById(songId: String)
+
+ // ── Groups ────────────────────────────────────────────────────────────────
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertGroup(group: DownloadGroupEntity)
+
+ @Query("SELECT * FROM download_groups ORDER BY createdAt DESC")
+ fun allGroupsFlow(): Flow>
+
+ @Query("SELECT * FROM download_groups WHERE groupId = :groupId")
+ suspend fun getGroup(groupId: String): DownloadGroupEntity?
+
+ @Query("DELETE FROM download_groups WHERE groupId = :groupId")
+ suspend fun deleteGroup(groupId: String)
+
+ @Query("UPDATE download_groups SET downloadedTracks = :count WHERE groupId = :groupId")
+ suspend fun updateGroupProgress(groupId: String, count: Int)
+
+ @Query("UPDATE download_groups SET totalTracks = :total WHERE groupId = :groupId")
+ suspend fun updateGroupTotal(groupId: String, total: Int)
+
+ // ── Cross-refs ────────────────────────────────────────────────────────────
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertCrossRef(ref: GroupTrackCrossRef)
+
+ @Query("SELECT songId FROM group_track_cross_refs WHERE groupId = :groupId")
+ suspend fun songIdsForGroup(groupId: String): List
+
+ @Query("""
+ SELECT t.* FROM downloaded_tracks t
+ INNER JOIN group_track_cross_refs r ON t.songId = r.songId
+ WHERE r.groupId = :groupId
+ """)
+ suspend fun tracksForGroup(groupId: String): List
+
+ @Query("""
+ SELECT t.* FROM downloaded_tracks t
+ INNER JOIN group_track_cross_refs r ON t.songId = r.songId
+ WHERE r.groupId = :groupId
+ """)
+ fun tracksForGroupFlow(groupId: String): Flow>
+
+ @Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
+ suspend fun removeCrossRef(groupId: String, songId: String)
+
+ /** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
+ @Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
+ suspend fun localCoverPathByCoverArt(coverArtId: String): String?
+}
diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDatabase.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDatabase.kt
new file mode 100644
index 0000000..babc45d
--- /dev/null
+++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDatabase.kt
@@ -0,0 +1,38 @@
+package com.novasonic.app.offline
+
+import android.content.Context
+import androidx.room.*
+
+@TypeConverters(OfflineConverters::class)
+@Database(
+ entities = [
+ DownloadedTrackEntity::class,
+ DownloadGroupEntity::class,
+ GroupTrackCrossRef::class,
+ ],
+ version = 1,
+ exportSchema = false,
+)
+abstract class OfflineDatabase : RoomDatabase() {
+
+ abstract fun offlineDao(): OfflineDao
+
+ companion object {
+ @Volatile private var INSTANCE: OfflineDatabase? = null
+
+ fun get(context: Context): OfflineDatabase = INSTANCE ?: synchronized(this) {
+ INSTANCE ?: Room.databaseBuilder(
+ context.applicationContext,
+ OfflineDatabase::class.java,
+ "novasonic_offline.db",
+ ).build().also { INSTANCE = it }
+ }
+ }
+}
+
+class OfflineConverters {
+ @TypeConverter fun fromState(v: DownloadState): String = v.name
+ @TypeConverter fun toState(v: String): DownloadState = DownloadState.valueOf(v)
+ @TypeConverter fun fromType(v: GroupType): String = v.name
+ @TypeConverter fun toType(v: String): GroupType = GroupType.valueOf(v)
+}
diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt
new file mode 100644
index 0000000..56aff56
--- /dev/null
+++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt
@@ -0,0 +1,91 @@
+package com.novasonic.app.offline
+
+import androidx.room.*
+import com.novasonic.app.api.model.Song
+
+enum class DownloadState { QUEUED, DOWNLOADING, DONE, FAILED }
+enum class GroupType { SONG, ALBUM, PLAYLIST, FAVORITES }
+
+/** Maps a stored offline track back to a domain [Song] for playback / list display. */
+fun DownloadedTrackEntity.toSong() = Song(
+ id = songId,
+ title = title,
+ album = album,
+ albumId = albumId,
+ artist = artist,
+ artistId = artistId,
+ track = track,
+ year = year,
+ genre = genre,
+ coverArt = coverArt,
+ duration = duration,
+ suffix = suffix,
+ contentType = contentType,
+ bitRate = bitRate,
+ starred = starred,
+)
+
+/** Lightweight projection used to keep the hot in-memory maps up to date. */
+data class DoneTrackInfo(
+ val songId: String,
+ val localPath: String,
+ val coverArt: String?,
+ val coverPath: String?,
+)
+
+@Entity(tableName = "downloaded_tracks")
+data class DownloadedTrackEntity(
+ @PrimaryKey val songId: String,
+ val title: String,
+ val artist: String?,
+ val artistId: String?,
+ val album: String?,
+ val albumId: String?,
+ val coverArt: String?,
+ val track: Int?,
+ val year: Int?,
+ val genre: String?,
+ val duration: Int?,
+ val suffix: String?,
+ val contentType: String?,
+ val bitRate: Int?,
+ val starred: String?,
+ val localPath: String,
+ val coverPath: String?,
+ val state: DownloadState,
+ val progress: Float = 0f,
+ val downloadedAt: Long = 0L,
+)
+
+@Entity(tableName = "download_groups")
+data class DownloadGroupEntity(
+ @PrimaryKey val groupId: String, // e.g. "album:abc123", "playlist:xyz", "favorites"
+ val type: GroupType,
+ val sourceId: String,
+ val name: String,
+ val coverArt: String?,
+ val totalTracks: Int = 0,
+ val downloadedTracks: Int = 0,
+ val createdAt: Long = System.currentTimeMillis(),
+)
+
+@Entity(
+ tableName = "group_track_cross_refs",
+ primaryKeys = ["groupId", "songId"],
+ foreignKeys = [
+ ForeignKey(
+ entity = DownloadGroupEntity::class,
+ parentColumns = ["groupId"],
+ childColumns = ["groupId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ForeignKey(
+ entity = DownloadedTrackEntity::class,
+ parentColumns = ["songId"],
+ childColumns = ["songId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index("songId")],
+)
+data class GroupTrackCrossRef(val groupId: String, val songId: String)
diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt
new file mode 100644
index 0000000..6d2fb22
--- /dev/null
+++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt
@@ -0,0 +1,211 @@
+package com.novasonic.app.offline
+
+import android.content.Context
+import android.util.Log
+import androidx.work.*
+import com.novasonic.app.api.model.Song
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import java.io.File
+
+class OfflineRepository(context: Context) {
+
+ private val appContext = context.applicationContext
+ private val dao = OfflineDatabase.get(appContext).offlineDao()
+ private val scope = CoroutineScope(
+ SupervisorJob() + Dispatchers.IO +
+ CoroutineExceptionHandler { _, t -> Log.e("OfflineRepository", "Unhandled exception", t) }
+ )
+
+ // ── Hot in-memory maps (no suspend needed for lookups) ────────────────────
+ // songId → absolute local audio path
+ private val _audioMap = MutableStateFlow