Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,904 changes: 2,904 additions & 0 deletions app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/43.json

Large diffs are not rendered by default.

2,909 changes: 2,909 additions & 0 deletions app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/44.json

Large diffs are not rendered by default.

2,916 changes: 2,916 additions & 0 deletions app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/45.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@
android:theme="@style/Theme.PixelPlay"
android:windowSoftInputMode="adjustResize" />

<activity
android:name=".presentation.s3.auth.S3LoginActivity"
android:exported="false"
android:label="S3 Storage"
android:theme="@style/Theme.PixelPlay"
android:windowSoftInputMode="adjustResize" />


<activity
android:name=".ExternalPlayerActivity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
@Inject
lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy<AdvancedPerformanceDiagnosticsController>

@Inject
lateinit var s3Repository: dagger.Lazy<com.theveloper.pixelplay.data.s3.S3Repository>

private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// AÑADE EL COMPANION OBJECT
Expand Down Expand Up @@ -121,6 +124,8 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver)
advancedPerformanceDiagnosticsController.get().start(startupScope)

startupScope.launch { s3Repository.get().hideTracksWithLocalCopies() }

startupScope.launch {
AlbumArtUtils.migrateLegacyCacheLocation(this@PixelPlayApplication)
val savedLimit = runCatching {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.theveloper.pixelplay.data.database

import androidx.paging.PagingSource
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
Expand Down Expand Up @@ -273,6 +274,16 @@ interface MusicDao {
@Query("SELECT id FROM songs WHERE source_type = 6")
suspend fun getAllJellyfinSongIds(): List<Long>

@Query("SELECT id FROM songs WHERE source_type = 7")
suspend fun getAllS3SongIds(): List<Long>

/** Title/artist/duration of on-device songs, used to hide cloud copies of the same track. */
@Query("SELECT COUNT(*) FROM songs WHERE source_type = 0")
fun observeLocalSongCount(): Flow<Int>

@Query("SELECT title, artist_name, duration, file_path FROM songs WHERE source_type = 0")
suspend fun getLocalSongMatchKeys(): List<SongMatchKey>

@Transaction
suspend fun deleteSongsAndRelatedData(songIds: List<Long>) {
if (songIds.isEmpty()) return
Expand Down Expand Up @@ -321,6 +332,13 @@ interface MusicDao {
deleteSongsAndRelatedData(jellyfinSongIds)
}

@Transaction
suspend fun clearAllS3Songs() {
val s3SongIds = getAllS3SongIds()
if (s3SongIds.isEmpty()) return
deleteSongsAndRelatedData(s3SongIds)
}

@Transaction
suspend fun clearAllTelegramSongs() {
val telegramSongIds = getAllTelegramSongIds()
Expand Down Expand Up @@ -1950,3 +1968,10 @@ interface MusicDao {
const val SONG_BATCH_SIZE = 500
}
}

data class SongMatchKey(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "artist_name") val artistName: String,
@ColumnInfo(name = "duration") val duration: Long,
@ColumnInfo(name = "file_path") val filePath: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TelegramTopicEntity::class,
JellyfinSongEntity::class,
JellyfinPlaylistEntity::class,
S3SongEntity::class,
S3FolderEntity::class,
AiCacheEntity::class,
AiUsageEntity::class
],
version = 42,
version = 45,
exportSchema = true
)
abstract class PixelPlayDatabase : RoomDatabase() {
Expand All @@ -54,6 +56,7 @@ abstract class PixelPlayDatabase : RoomDatabase() {
abstract fun qqmusicDao(): QqMusicDao
abstract fun navidromeDao(): NavidromeDao
abstract fun jellyfinDao(): JellyfinDao
abstract fun s3Dao(): S3Dao
abstract fun aiCacheDao(): AiCacheDao
abstract fun aiUsageDao(): AiUsageDao

Expand Down Expand Up @@ -747,6 +750,62 @@ abstract class PixelPlayDatabase : RoomDatabase() {
}
}

/**
* Add S3-compatible storage tables (s3_songs, s3_folders).
*/
val MIGRATION_42_43 = object : Migration(42, 43) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS s3_songs (
id TEXT NOT NULL PRIMARY KEY,
object_key TEXT NOT NULL,
folder_id TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT NOT NULL,
album_id INTEGER NOT NULL,
duration INTEGER NOT NULL,
mime_type TEXT NOT NULL,
bitrate INTEGER,
file_size INTEGER NOT NULL,
date_added INTEGER NOT NULL,
date_modified INTEGER NOT NULL
)
""".trimIndent())

db.execSQL("""
CREATE TABLE IF NOT EXISTS s3_folders (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
song_count INTEGER NOT NULL DEFAULT 0,
last_sync_time INTEGER NOT NULL DEFAULT 0
)
""".trimIndent())

db.execSQL("CREATE INDEX IF NOT EXISTS index_s3_songs_object_key ON s3_songs(object_key)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_s3_songs_folder_id ON s3_songs(folder_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_s3_songs_folder_id_date_added ON s3_songs(folder_id, date_added)")
}
}

/**
* Add embedded artwork column to s3_songs.
*/
val MIGRATION_43_44 = object : Migration(43, 44) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE s3_songs ADD COLUMN album_art_uri TEXT")
}
}

/**
* Add tag-probe flag to s3_songs (background metadata queue).
*/
val MIGRATION_44_45 = object : Migration(44, 45) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE s3_songs ADD COLUMN metadata_probed INTEGER NOT NULL DEFAULT 0")
}
}

private fun ensureSongsTableHasDateAdded(db: SupportSQLiteDatabase) {
if (!tableExists(db, "songs")) {
recreateSongsTable(db)
Expand Down
68 changes: 68 additions & 0 deletions app/src/main/java/com/theveloper/pixelplay/data/database/S3Dao.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.theveloper.pixelplay.data.database

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface S3Dao {

// --- Songs ---

@Query("SELECT * FROM s3_songs ORDER BY date_added DESC")
fun getAllS3Songs(): Flow<List<S3SongEntity>>

@Query("SELECT * FROM s3_songs ORDER BY date_added DESC")
suspend fun getAllS3SongsList(): List<S3SongEntity>

@Query("SELECT * FROM s3_songs WHERE folder_id = :folderId ORDER BY title ASC")
fun getSongsByFolder(folderId: String): Flow<List<S3SongEntity>>

@Query("SELECT * FROM s3_songs WHERE metadata_probed = 0 LIMIT :limit")
suspend fun getUnprobedBatch(limit: Int = 8): List<S3SongEntity>

@Query("SELECT COUNT(*) FROM s3_songs WHERE metadata_probed = 0")
fun countUnprobedFlow(): Flow<Int>

@Query(
"""UPDATE s3_songs SET title = :title, artist = :artist, album = :album,
duration = :duration, album_art_uri = :albumArtUri, metadata_probed = 1 WHERE id = :id"""
)
suspend fun applyProbeResult(
id: String, title: String, artist: String, album: String,
duration: Long, albumArtUri: String?
)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSongs(songs: List<S3SongEntity>)

@Query("DELETE FROM s3_songs WHERE id IN (:ids)")
suspend fun deleteSongsByIds(ids: List<String>)

@Query("DELETE FROM s3_songs WHERE folder_id = :folderId")
suspend fun deleteSongsByFolder(folderId: String)

// --- Folders ---

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFolder(folder: S3FolderEntity)

@Query("SELECT * FROM s3_folders ORDER BY name ASC")
fun getAllFolders(): Flow<List<S3FolderEntity>>

@Query("SELECT * FROM s3_folders")
suspend fun getAllFoldersList(): List<S3FolderEntity>

@Query("DELETE FROM s3_folders WHERE id = :folderId")
suspend fun deleteFolder(folderId: String)

// --- Clear All ---

@Query("DELETE FROM s3_songs")
suspend fun clearAllSongs()

@Query("DELETE FROM s3_folders")
suspend fun clearAllFolders()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.theveloper.pixelplay.data.database

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "s3_folders")
data class S3FolderEntity(
@PrimaryKey val id: String, // S3 prefix, e.g. "Music/"
val name: String, // display name (last segment)
@ColumnInfo(name = "song_count") val songCount: Int = 0,
@ColumnInfo(name = "last_sync_time") val lastSyncTime: Long = 0
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.theveloper.pixelplay.data.database

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.theveloper.pixelplay.data.model.Song
import com.theveloper.pixelplay.data.s3.S3Repository

@Entity(
tableName = "s3_songs",
indices = [
Index(value = ["object_key"]),
Index(value = ["folder_id"]),
Index(value = ["folder_id", "date_added"])
]
)
data class S3SongEntity(
@PrimaryKey val id: String, // "{folderId}_{objectKey}"
@ColumnInfo(name = "object_key") val objectKey: String, // full S3 object key
@ColumnInfo(name = "folder_id") val folderId: String,
val title: String,
val artist: String,
val album: String,
@ColumnInfo(name = "album_id") val albumId: Long,
val duration: Long, // milliseconds (0 = unknown)
val mimeType: String,
val bitrate: Int?,
@ColumnInfo(name = "file_size") val fileSize: Long,
@ColumnInfo(name = "date_added") val dateAdded: Long,
@ColumnInfo(name = "date_modified") val dateModified: Long,
@ColumnInfo(name = "album_art_uri") val albumArtUri: String? = null,
@ColumnInfo(name = "metadata_probed", defaultValue = "0") val metadataProbed: Boolean = false
)

fun S3SongEntity.toSong(): Song {
return Song(
id = "s3_$objectKey",
title = title,
artist = artist,
artistId = artistIdFor(artist),
album = album,
albumId = albumIdFor(album),
path = "",
contentUriString = "s3://$objectKey",
albumArtUriString = albumArtUri,
duration = duration,
genre = S3Repository.S3_GENRE,
mimeType = mimeType,
bitrate = bitrate,
sampleRate = 0,
year = 0,
trackNumber = 0,
dateAdded = dateAdded,
isFavorite = false,
s3ObjectKey = objectKey
)
}

fun Song.toS3SongEntity(folderId: String): S3SongEntity {
val resolvedKey = s3ObjectKey ?: ""
return S3SongEntity(
id = "${folderId}_${resolvedKey}",
objectKey = resolvedKey,
folderId = folderId,
title = title,
artist = artist,
album = album,
albumId = albumId,
duration = duration,
mimeType = mimeType ?: "audio/mpeg",
bitrate = bitrate,
fileSize = 0L,
dateAdded = dateAdded,
dateModified = dateAdded
)
}

internal fun artistIdFor(artist: String): Long =
-(S3Repository.S3_ARTIST_ID_OFFSET + artist.lowercase().hashCode().toLong().let { if (it < 0) -it else it })

internal fun albumIdFor(album: String): Long =
-(S3Repository.S3_ALBUM_ID_OFFSET + album.lowercase().hashCode().toLong().let { if (it < 0) -it else it })
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ object SourceType {
const val QQMUSIC = 4
const val NAVIDROME = 5
const val JELLYFIN = 6
const val S3 = 7

/** Derive source type from a content URI string (fallback for migration / conversion). */
fun fromContentUri(uri: String): Int = when {
Expand All @@ -31,6 +32,7 @@ object SourceType {
uri.startsWith("qqmusic://") -> QQMUSIC
uri.startsWith("navidrome://") -> NAVIDROME
uri.startsWith("jellyfin://") -> JELLYFIN
uri.startsWith("s3://") -> S3
else -> LOCAL
}
}
Expand Down Expand Up @@ -145,6 +147,9 @@ private fun SongEntity.toSongInternal(artists: List<ArtistRef>): Song {
jellyfinId = if (this.contentUriString.startsWith("jellyfin://")) {
this.contentUriString.removePrefix("jellyfin://")
} else null,
s3ObjectKey = if (this.contentUriString.startsWith("s3://")) {
this.contentUriString.removePrefix("s3://")
} else null,
mimeType = this.mimeType,
bitrate = this.bitrate,
sampleRate = this.sampleRate
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/theveloper/pixelplay/data/model/Song.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize

private val CLOUD_URI_SCHEMES = listOf(
"telegram://", "netease://", "qqmusic://", "navidrome://", "jellyfin://", "gdrive://", "s3://",
)

@Immutable
@Parcelize
data class Song(
Expand Down Expand Up @@ -42,7 +46,12 @@ data class Song(
val qqMusicMid: String? = null, // QQ Music song MID
val navidromeId: String? = null, // Navidrome song ID
val jellyfinId: String? = null, // Jellyfin item ID
val s3ObjectKey: String? = null // S3-compatible object key
) : Parcelable {
/** True when the song streams from a cloud source rather than a file on this device. */
val isCloud: Boolean
get() = CLOUD_URI_SCHEMES.any { contentUriString.startsWith(it, ignoreCase = true) }

/**
* Returns the display string for artists.
* If multiple artists exist (populated during sync), joins them with ", ".
Expand Down
Loading