feat: auth screen with auto-redirect, sync favorites/history with backend
This commit is contained in:
@@ -2,16 +2,55 @@ package com.radiola.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.radiola.data.local.dao.RecordingDao
|
||||
import com.radiola.data.local.dao.StationDao
|
||||
import com.radiola.data.local.dao.TagDao
|
||||
import com.radiola.data.local.dao.TrackHistoryDao
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import com.radiola.data.local.entity.StationEntity
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
import com.radiola.data.local.entity.TrackHistoryEntity
|
||||
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS tags (name TEXT PRIMARY KEY NOT NULL)")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE stations ADD COLUMN source TEXT NOT NULL DEFAULT 'record'")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
stationName TEXT NOT NULL,
|
||||
stationId INTEGER NOT NULL,
|
||||
filePath TEXT NOT NULL,
|
||||
startTime INTEGER NOT NULL,
|
||||
endTime INTEGER,
|
||||
trackName TEXT,
|
||||
duration INTEGER
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class],
|
||||
version = 1
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
||||
version = 4
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun stationDao(): StationDao
|
||||
abstract fun trackHistoryDao(): TrackHistoryDao
|
||||
abstract fun tagDao(): TagDao
|
||||
abstract fun recordingDao(): RecordingDao
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.radiola.data.local
|
||||
|
||||
import android.content.Context
|
||||
import com.radiola.data.local.dto.LocalGroupDto
|
||||
import com.radiola.data.local.dto.LocalStationsResponse
|
||||
import com.radiola.domain.model.Station
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.BufferedReader
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LocalStationDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val json: Json
|
||||
) {
|
||||
|
||||
private var cachedResponse: LocalStationsResponse? = null
|
||||
|
||||
fun loadStations(): List<Station> {
|
||||
android.util.Log.d("LocalStationDS", "loadStations() called")
|
||||
val response = getResponse()
|
||||
android.util.Log.d("LocalStationDS", "Parsed: ${response.stations.size} stations, ${response.groups.size} groups")
|
||||
val groupMap = response.groups.associateBy { it.id }
|
||||
|
||||
return response.stations
|
||||
.filter { it.enabled && !it.notWorked && it.stream != null }
|
||||
.map { dto ->
|
||||
val group = groupMap[dto.groupId]
|
||||
val prefix = generatePrefix(dto.name)
|
||||
Station(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
prefix = prefix,
|
||||
streamUrl = dto.stream!!,
|
||||
coverUrl = group?.let { generateCoverUrl(it.name, dto.name) } ?: "",
|
||||
genre = group?.name ?: "",
|
||||
tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }),
|
||||
sortOrder = dto.id,
|
||||
source = "local"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGroups(): List<LocalGroupDto> {
|
||||
return getResponse().groups.filter { it.name.isNotBlank() }
|
||||
}
|
||||
|
||||
fun getGroupNames(): List<String> {
|
||||
return loadGroups().map { it.name }
|
||||
}
|
||||
|
||||
private fun getResponse(): LocalStationsResponse {
|
||||
android.util.Log.d("LocalStationDS", "getResponse() called")
|
||||
cachedResponse?.let { return it }
|
||||
val text = context.assets.open("stations.json").bufferedReader().use(BufferedReader::readText)
|
||||
val parsed = json.decodeFromString(LocalStationsResponse.serializer(), text)
|
||||
cachedResponse = parsed
|
||||
return parsed
|
||||
}
|
||||
|
||||
private fun generatePrefix(name: String): String {
|
||||
return name.lowercase()
|
||||
.replace(Regex("[^a-z0-9а-яё]+"), "_")
|
||||
.trim('_')
|
||||
.take(30)
|
||||
}
|
||||
|
||||
private fun generateCoverUrl(groupName: String, stationName: String): String {
|
||||
// Placeholder: return empty for now; Record API will override with real covers when available
|
||||
return ""
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/radiola/data/local/TokenDataStore.kt
Normal file
59
app/src/main/java/com/radiola/data/local/TokenDataStore.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.radiola.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_prefs")
|
||||
|
||||
@Singleton
|
||||
class TokenDataStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val dataStore = context.dataStore
|
||||
|
||||
@Volatile
|
||||
var currentToken: String? = null
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private val TOKEN_KEY = stringPreferencesKey("jwt_token")
|
||||
private val USER_ID_KEY = stringPreferencesKey("user_id")
|
||||
private val USER_EMAIL_KEY = stringPreferencesKey("user_email")
|
||||
private val USER_NAME_KEY = stringPreferencesKey("user_name")
|
||||
}
|
||||
|
||||
val token: Flow<String?> = dataStore.data.map { it[TOKEN_KEY] }
|
||||
val userId: Flow<String?> = dataStore.data.map { it[USER_ID_KEY] }
|
||||
val userEmail: Flow<String?> = dataStore.data.map { it[USER_EMAIL_KEY] }
|
||||
val userName: Flow<String?> = dataStore.data.map { it[USER_NAME_KEY] }
|
||||
|
||||
val isLoggedIn: Flow<Boolean> = token.map { !it.isNullOrBlank() }
|
||||
|
||||
suspend fun saveToken(token: String, userId: String, email: String, name: String?) {
|
||||
currentToken = token
|
||||
dataStore.edit { prefs ->
|
||||
prefs[TOKEN_KEY] = token
|
||||
prefs[USER_ID_KEY] = userId
|
||||
prefs[USER_EMAIL_KEY] = email
|
||||
name?.let { prefs[USER_NAME_KEY] = it }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
currentToken = null
|
||||
dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
suspend fun preload() {
|
||||
token.collect { currentToken = it }
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt
Normal file
26
app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.radiola.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface RecordingDao {
|
||||
|
||||
@Query("SELECT * FROM recordings ORDER BY startTime DESC")
|
||||
fun getAll(): Flow<List<RecordingEntity>>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(recording: RecordingEntity)
|
||||
|
||||
@Query("DELETE FROM recordings WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id")
|
||||
suspend fun updateEndTime(id: Long, endTime: Long, duration: Long)
|
||||
|
||||
@Query("SELECT * FROM recordings WHERE id = :id")
|
||||
suspend fun getById(id: Long): RecordingEntity?
|
||||
}
|
||||
@@ -31,4 +31,7 @@ interface StationDao {
|
||||
|
||||
@Query("SELECT isFavorite FROM stations WHERE id = :id")
|
||||
fun isFavorite(id: Int): Flow<Boolean>
|
||||
|
||||
@Query("SELECT id FROM stations WHERE isFavorite = 1")
|
||||
fun getFavoriteIds(): Flow<List<Int>>
|
||||
}
|
||||
|
||||
20
app/src/main/java/com/radiola/data/local/dao/TagDao.kt
Normal file
20
app/src/main/java/com/radiola/data/local/dao/TagDao.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.radiola.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TagDao {
|
||||
@Query("SELECT * FROM tags ORDER BY name")
|
||||
fun getAll(): Flow<List<TagEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(tags: List<TagEntity>)
|
||||
|
||||
@Query("DELETE FROM tags")
|
||||
suspend fun clearAll()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.radiola.data.local.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LocalStationDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("groupId") val groupId: Int,
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("bitrate") val bitrate: String? = null,
|
||||
@SerialName("site") val site: String? = null,
|
||||
@SerialName("stream") val stream: String? = null,
|
||||
@SerialName("type") val type: String? = null,
|
||||
@SerialName("iconText") val iconText: String? = null,
|
||||
@SerialName("textColor") val textColor: String? = null,
|
||||
@SerialName("bgColor") val bgColor: String? = null,
|
||||
@SerialName("enabled") val enabled: Boolean = true,
|
||||
@SerialName("notWorked") val notWorked: Boolean = false,
|
||||
@SerialName("isNew") val isNew: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalGroupDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("textColor") val textColor: String? = null,
|
||||
@SerialName("bgColor") val bgColor: String? = null,
|
||||
@SerialName("info") val info: String? = null,
|
||||
@SerialName("stations") val stations: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalStationsResponse(
|
||||
@SerialName("stations") val stations: List<LocalStationDto> = emptyList(),
|
||||
@SerialName("groups") val groups: List<LocalGroupDto> = emptyList(),
|
||||
@SerialName("config") val config: LocalConfigDto? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalConfigDto(
|
||||
@SerialName("stationsBeforeGroups") val stationsBeforeGroups: Boolean = false,
|
||||
@SerialName("version") val version: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.radiola.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "recordings")
|
||||
data class RecordingEntity(
|
||||
@PrimaryKey val id: Long,
|
||||
val stationName: String,
|
||||
val stationId: Int,
|
||||
val filePath: String,
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val trackName: String?,
|
||||
val duration: Long?
|
||||
)
|
||||
@@ -13,5 +13,6 @@ data class StationEntity(
|
||||
val genre: String,
|
||||
val tags: String,
|
||||
val sortOrder: Int,
|
||||
val source: String = "record",
|
||||
val isFavorite: Boolean = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.radiola.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "tags")
|
||||
data class TagEntity(
|
||||
@PrimaryKey val name: String
|
||||
)
|
||||
@@ -8,25 +8,26 @@ import com.radiola.domain.model.Track
|
||||
object ApiMapper {
|
||||
|
||||
fun StationDto.toDomain(): Station {
|
||||
val cover = iconPng ?: iconSvg ?: ""
|
||||
val cover = iconFillColored ?: bgImageMobile ?: bgImage ?: ""
|
||||
val stream = stream128 ?: stream320 ?: streamHls ?: "https://air.radiorecord.ru:805/${prefix}_128"
|
||||
return Station(
|
||||
id = id,
|
||||
name = name,
|
||||
prefix = prefix,
|
||||
streamUrl = "https://air.radiorecord.ru:805/${prefix}_128",
|
||||
streamUrl = stream,
|
||||
coverUrl = cover,
|
||||
genre = genre ?: "",
|
||||
tags = emptyList(),
|
||||
sortOrder = id
|
||||
genre = tooltip ?: "",
|
||||
tags = tags.map { it.name },
|
||||
sortOrder = sort
|
||||
)
|
||||
}
|
||||
|
||||
fun NowPlayingItemDto.toDomain(): Track {
|
||||
return Track(
|
||||
artist = artist,
|
||||
song = song,
|
||||
coverUrl = image600 ?: image100,
|
||||
stationName = prefix
|
||||
artist = track.artist,
|
||||
song = track.song,
|
||||
coverUrl = track.image600 ?: track.image100,
|
||||
stationName = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
25
app/src/main/java/com/radiola/data/remote/AuthInterceptor.kt
Normal file
25
app/src/main/java/com/radiola/data/remote/AuthInterceptor.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.local.TokenDataStore
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val token = tokenDataStore.currentToken
|
||||
|
||||
return if (token != null) {
|
||||
chain.proceed(
|
||||
request.newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/com/radiola/data/remote/RadiolaApi.kt
Normal file
48
app/src/main/java/com/radiola/data/remote/RadiolaApi.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.remote.dto.AuthResponseDto
|
||||
import com.radiola.data.remote.dto.BackendStationDto
|
||||
import com.radiola.data.remote.dto.HistoryResponseDto
|
||||
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
||||
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
||||
import com.radiola.data.remote.dto.UserSettingsDto
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface RadiolaApi {
|
||||
|
||||
@POST("auth/magic-link")
|
||||
suspend fun requestMagicLink(@Body dto: MagicLinkRequestDto): JsonObject
|
||||
|
||||
@POST("auth/verify")
|
||||
suspend fun verifyMagicLink(@Body dto: MagicLinkVerifyDto): AuthResponseDto
|
||||
|
||||
@GET("users/me")
|
||||
suspend fun getMe(): JsonObject
|
||||
|
||||
@GET("users/me/settings")
|
||||
suspend fun getSettings(): UserSettingsDto
|
||||
|
||||
@PATCH("users/me/settings")
|
||||
suspend fun updateSettings(@Body dto: UserSettingsDto): UserSettingsDto
|
||||
|
||||
@GET("users/me/favorites")
|
||||
suspend fun getFavorites(): List<BackendStationDto>
|
||||
|
||||
@POST("users/me/favorites/{stationId}")
|
||||
suspend fun addFavorite(@Path("stationId") stationId: String): JsonObject
|
||||
|
||||
@DELETE("users/me/favorites/{stationId}")
|
||||
suspend fun removeFavorite(@Path("stationId") stationId: String): JsonObject
|
||||
|
||||
@GET("users/me/history")
|
||||
suspend fun getHistory(): HistoryResponseDto
|
||||
|
||||
@POST("users/me/history/{stationId}")
|
||||
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AuthResponseDto(
|
||||
val accessToken: String,
|
||||
val user: UserDto
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BackendStationDto(
|
||||
val id: String,
|
||||
val stationId: Int,
|
||||
val name: String,
|
||||
val prefix: String,
|
||||
val streamUrl: String,
|
||||
val coverUrl: String? = null,
|
||||
val genre: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val sortOrder: Int = 0,
|
||||
val source: String = "local",
|
||||
val isOnline: Boolean = true
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FavoritesResponseDto(
|
||||
val favorites: List<BackendStationDto> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class HistoryResponseDto(
|
||||
val items: List<BackendStationDto> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val limit: Int = 50,
|
||||
val offset: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MagicLinkRequestDto(
|
||||
val email: String
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MagicLinkVerifyDto(
|
||||
val email: String,
|
||||
val code: String
|
||||
)
|
||||
@@ -4,15 +4,20 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NowPlayingItemDto(
|
||||
data class TrackDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("prefix") val prefix: String,
|
||||
@SerialName("artist") val artist: String,
|
||||
@SerialName("song") val song: String,
|
||||
@SerialName("image600") val image600: String? = null,
|
||||
@SerialName("image100") val image100: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NowPlayingItemDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("track") val track: TrackDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NowPlayingResponse(
|
||||
@SerialName("result") val result: List<NowPlayingItemDto>
|
||||
|
||||
@@ -8,12 +8,30 @@ data class StationDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("title") val name: String,
|
||||
@SerialName("prefix") val prefix: String,
|
||||
@SerialName("genre") val genre: String? = null,
|
||||
@SerialName("icon_png") val iconPng: String? = null,
|
||||
@SerialName("icon_svg") val iconSvg: String? = null
|
||||
@SerialName("tooltip") val tooltip: String? = null,
|
||||
@SerialName("sort") val sort: Int = 0,
|
||||
@SerialName("bg_image") val bgImage: String? = null,
|
||||
@SerialName("bg_image_mobile") val bgImageMobile: String? = null,
|
||||
@SerialName("icon_fill_colored") val iconFillColored: String? = null,
|
||||
@SerialName("stream_128") val stream128: String? = null,
|
||||
@SerialName("stream_320") val stream320: String? = null,
|
||||
@SerialName("stream_hls") val streamHls: String? = null,
|
||||
@SerialName("tags") val tags: List<TagDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TagDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("name") val name: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StationsResult(
|
||||
@SerialName("stations") val stations: List<StationDto> = emptyList(),
|
||||
@SerialName("tags") val tags: List<TagDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StationsResponse(
|
||||
@SerialName("result") val result: List<StationDto>
|
||||
@SerialName("result") val result: StationsResult
|
||||
)
|
||||
|
||||
10
app/src/main/java/com/radiola/data/remote/dto/UserDto.kt
Normal file
10
app/src/main/java/com/radiola/data/remote/dto/UserDto.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
val id: String,
|
||||
val email: String,
|
||||
val name: String? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserSettingsDto(
|
||||
val theme: String? = null,
|
||||
val language: String? = null,
|
||||
val autoPlay: Boolean? = null,
|
||||
val showOffline: Boolean? = null,
|
||||
val sleepTimerMinutes: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.local.TokenDataStore
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
||||
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
||||
import com.radiola.domain.model.User
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
private val api: RadiolaApi,
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun requestMagicLink(email: String): Result<Unit> = try {
|
||||
api.requestMagicLink(MagicLinkRequestDto(email))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
override suspend fun verifyMagicLink(email: String, code: String): Result<User> = try {
|
||||
val response = api.verifyMagicLink(MagicLinkVerifyDto(email, code))
|
||||
tokenDataStore.saveToken(
|
||||
token = response.accessToken,
|
||||
userId = response.user.id,
|
||||
email = response.user.email,
|
||||
name = response.user.name
|
||||
)
|
||||
Result.success(
|
||||
User(
|
||||
id = response.user.id,
|
||||
email = response.user.email,
|
||||
name = response.user.name
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
override fun isLoggedIn(): Flow<Boolean> = tokenDataStore.isLoggedIn
|
||||
|
||||
override fun currentUser(): Flow<User?> = combine(
|
||||
tokenDataStore.userId,
|
||||
tokenDataStore.userEmail,
|
||||
tokenDataStore.userName
|
||||
) { id, email, name ->
|
||||
if (id != null && email != null) {
|
||||
User(id = id, email = email, name = name)
|
||||
} else null
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
tokenDataStore.clear()
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,18 @@ class FavoritesRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFavoriteIds(): Flow<Set<Int>> {
|
||||
return db.stationDao().getFavoriteIds().map { it.toSet() }
|
||||
}
|
||||
|
||||
override suspend fun addFavorite(station: Station) {
|
||||
db.stationDao().setFavorite(station.id, true)
|
||||
}
|
||||
|
||||
override suspend fun addFavorite(stationId: Int) {
|
||||
db.stationDao().setFavorite(stationId, true)
|
||||
}
|
||||
|
||||
override suspend fun removeFavorite(stationId: Int) {
|
||||
db.stationDao().setFavorite(stationId, false)
|
||||
}
|
||||
@@ -42,6 +50,7 @@ class FavoritesRepositoryImpl @Inject constructor(
|
||||
coverUrl = coverUrl,
|
||||
genre = genre,
|
||||
tags = tags.split(",").filter { it.isNotBlank() },
|
||||
sortOrder = sortOrder
|
||||
sortOrder = sortOrder,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,18 +13,18 @@ class NowPlayingRepositoryImpl @Inject constructor(
|
||||
private val api: RecordApi
|
||||
) : NowPlayingRepository {
|
||||
|
||||
private val _nowPlaying = MutableStateFlow<Map<String, Track>>(emptyMap())
|
||||
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
|
||||
|
||||
override fun getNowPlaying(stationPrefix: String): Flow<Track?> {
|
||||
return _nowPlaying.map { it[stationPrefix] }
|
||||
override fun getNowPlaying(stationId: Int): Flow<Track?> {
|
||||
return _nowPlaying.map { it[stationId] }
|
||||
}
|
||||
|
||||
override fun getAllNowPlaying(): Flow<Map<String, Track>> = _nowPlaying
|
||||
override fun getAllNowPlaying(): Flow<Map<Int, Track>> = _nowPlaying
|
||||
|
||||
override suspend fun refreshNowPlaying(): Result<Unit> {
|
||||
return try {
|
||||
val response = api.getNowPlaying()
|
||||
val map = response.result.associate { it.prefix to it.toDomain() }
|
||||
val map = response.result.associate { it.id to it.toDomain() }
|
||||
_nowPlaying.value = map
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.radiola.data.local.AppDatabase
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import com.radiola.domain.model.Recording
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.RecordingRepository
|
||||
import com.radiola.service.RecordingService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class RecordingRepositoryImpl @Inject constructor(
|
||||
private val db: AppDatabase,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
@ApplicationContext private val context: Context
|
||||
) : RecordingRepository {
|
||||
|
||||
private val _isRecording = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||
override val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
|
||||
|
||||
private var recordingJob: Job? = null
|
||||
private var currentCall: okhttp3.Call? = null
|
||||
private var currentRecordingId: Long? = null
|
||||
|
||||
override fun getRecordings(): Flow<List<Recording>> {
|
||||
return db.recordingDao().getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startRecording(station: Station, track: Track?) {
|
||||
if (_isRecording.value) return
|
||||
|
||||
val id = System.currentTimeMillis()
|
||||
currentRecordingId = id
|
||||
|
||||
val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings")
|
||||
dir.mkdirs()
|
||||
|
||||
val ext = when {
|
||||
station.streamUrl.contains(".aac", ignoreCase = true) -> "aac"
|
||||
station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3"
|
||||
else -> "audio"
|
||||
}
|
||||
val safeName = station.name.replace(Regex("[^a-zA-Z0-9а-яА-ЯёЁ]"), "_").take(30)
|
||||
val file = File(dir, "${safeName}_${id}.$ext")
|
||||
|
||||
val entity = RecordingEntity(
|
||||
id = id,
|
||||
stationName = station.name,
|
||||
stationId = station.id,
|
||||
filePath = file.absolutePath,
|
||||
startTime = id,
|
||||
endTime = null,
|
||||
trackName = track?.let { "${it.artist} - ${it.song}" },
|
||||
duration = null
|
||||
)
|
||||
db.recordingDao().insert(entity)
|
||||
_isRecording.value = true
|
||||
|
||||
// Start foreground service to keep process alive during recording
|
||||
val serviceIntent = Intent(context, RecordingService::class.java).apply {
|
||||
putExtra(RecordingService.EXTRA_STATION_NAME, station.name)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
|
||||
recordingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||
var output: FileOutputStream? = null
|
||||
try {
|
||||
val request = Request.Builder().url(station.streamUrl).build()
|
||||
val call = okHttpClient.newCall(request)
|
||||
currentCall = call
|
||||
val response = call.execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Log.e("RecordingRepo", "HTTP error: ${response.code}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
output = FileOutputStream(file)
|
||||
val input = response.body?.byteStream()
|
||||
if (input == null) {
|
||||
Log.e("RecordingRepo", "Empty response body")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (isActive) {
|
||||
bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
output.write(buffer, 0, bytesRead)
|
||||
}
|
||||
input.close()
|
||||
} catch (e: IOException) {
|
||||
if (e.message?.contains("Canceled") == true) {
|
||||
Log.d("RecordingRepo", "Recording cancelled normally")
|
||||
} else {
|
||||
Log.e("RecordingRepo", "Recording error", e)
|
||||
}
|
||||
} finally {
|
||||
try { output?.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stopRecording() {
|
||||
currentCall?.cancel()
|
||||
currentCall = null
|
||||
recordingJob?.cancelAndJoin()
|
||||
recordingJob = null
|
||||
_isRecording.value = false
|
||||
|
||||
// Stop foreground service
|
||||
context.stopService(Intent(context, RecordingService::class.java))
|
||||
|
||||
currentRecordingId?.let { id ->
|
||||
val endTime = System.currentTimeMillis()
|
||||
val duration = endTime - id
|
||||
try {
|
||||
db.recordingDao().updateEndTime(id, endTime, duration)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RecordingRepo", "Failed to update recording end time", e)
|
||||
}
|
||||
}
|
||||
currentRecordingId = null
|
||||
}
|
||||
|
||||
override suspend fun deleteRecording(id: Long) {
|
||||
val entity = db.recordingDao().getById(id)
|
||||
entity?.let {
|
||||
try { File(it.filePath).delete() } catch (_: Exception) {}
|
||||
}
|
||||
db.recordingDao().deleteById(id)
|
||||
}
|
||||
|
||||
private fun RecordingEntity.toDomain(): Recording = Recording(
|
||||
id = id,
|
||||
stationName = stationName,
|
||||
stationId = stationId,
|
||||
filePath = filePath,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
trackName = trackName,
|
||||
duration = duration
|
||||
)
|
||||
}
|
||||
@@ -32,20 +32,21 @@ class SettingsRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
|
||||
override suspend fun setLastStationId(id: Int) = dataStore.edit { it[LAST_STATION_ID] = id }
|
||||
override suspend fun setLastStationId(id: Int) { dataStore.edit { it[LAST_STATION_ID] = id } }
|
||||
|
||||
override fun getSleepTimerMinutes(): Flow<Int> = dataStore.data.map { it[SLEEP_TIMER] ?: 30 }
|
||||
override suspend fun setSleepTimerMinutes(minutes: Int) = dataStore.edit { it[SLEEP_TIMER] = minutes }
|
||||
override suspend fun setSleepTimerMinutes(minutes: Int) { dataStore.edit { it[SLEEP_TIMER] = minutes } }
|
||||
|
||||
override fun getEnabledDeeplinkServices(): Flow<Set<String>> = dataStore.data.map {
|
||||
it[ENABLED_SERVICES] ?: setOf("yandex", "vk", "spotify", "apple", "youtube")
|
||||
}
|
||||
override suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>) =
|
||||
override suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>) {
|
||||
dataStore.edit { it[ENABLED_SERVICES] = serviceIds }
|
||||
}
|
||||
|
||||
override fun getEqualizerPreset(): Flow<String> = dataStore.data.map { it[EQUALIZER_PRESET] ?: "Flat" }
|
||||
override suspend fun setEqualizerPreset(preset: String) = dataStore.edit { it[EQUALIZER_PRESET] = preset }
|
||||
override suspend fun setEqualizerPreset(preset: String) { dataStore.edit { it[EQUALIZER_PRESET] = preset } }
|
||||
|
||||
override fun isRecordingEnabled(): Flow<Boolean> = dataStore.data.map { it[RECORDING_ENABLED] ?: false }
|
||||
override suspend fun setRecordingEnabled(enabled: Boolean) = dataStore.edit { it[RECORDING_ENABLED] = enabled }
|
||||
override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.local.LocalStationDataSource
|
||||
import com.radiola.data.local.AppDatabase
|
||||
import com.radiola.data.local.entity.StationEntity
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
import com.radiola.data.remote.RecordApi
|
||||
import com.radiola.data.remote.ApiMapper.toDomain
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class StationRepositoryImpl @Inject constructor(
|
||||
private val api: RecordApi,
|
||||
private val db: AppDatabase
|
||||
private val db: AppDatabase,
|
||||
private val localDataSource: LocalStationDataSource
|
||||
) : StationRepository {
|
||||
|
||||
private val _tags = MutableStateFlow<List<String>>(emptyList())
|
||||
|
||||
override fun getStations(): Flow<List<Station>> {
|
||||
return db.stationDao().getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
@@ -22,29 +29,73 @@ class StationRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun refreshStations(): Result<Unit> {
|
||||
android.util.Log.d("StationRepo", "refreshStations() called")
|
||||
return try {
|
||||
val response = api.getStations()
|
||||
val entities = response.result.mapIndexed { index, dto ->
|
||||
val domain = dto.toDomain()
|
||||
// 1. Load local stations from assets
|
||||
val localStations = localDataSource.loadStations()
|
||||
android.util.Log.d("StationRepo", "Loaded ${localStations.size} local stations")
|
||||
val localGroups = localDataSource.loadGroups()
|
||||
|
||||
// 2. Try to enrich with Record API data (covers, streams, tags)
|
||||
val apiResponse = try { api.getStations() } catch (e: Exception) { null }
|
||||
val apiStations = apiResponse?.result?.stations ?: emptyList()
|
||||
val apiTags = apiResponse?.result?.tags?.map { it.name } ?: emptyList()
|
||||
|
||||
// 3. Merge: local stations enriched with API data where IDs match
|
||||
val merged = localStations.map { local ->
|
||||
val apiStation = apiStations.find { it.id == local.id }
|
||||
if (apiStation != null) {
|
||||
val domain = apiStation.toDomain()
|
||||
local.copy(
|
||||
coverUrl = domain.coverUrl,
|
||||
streamUrl = domain.streamUrl,
|
||||
genre = local.genre, // keep group name for filtering
|
||||
tags = (local.tags + domain.tags).distinct(), // merge tags
|
||||
prefix = domain.prefix,
|
||||
source = "record"
|
||||
)
|
||||
} else {
|
||||
local
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
android.util.Log.d("StationRepo", "Saving ${merged.size} merged stations to DB")
|
||||
val entities = merged.mapIndexed { index, station ->
|
||||
StationEntity(
|
||||
id = domain.id,
|
||||
name = domain.name,
|
||||
prefix = domain.prefix,
|
||||
streamUrl = domain.streamUrl,
|
||||
coverUrl = domain.coverUrl,
|
||||
genre = domain.genre,
|
||||
tags = domain.tags.joinToString(","),
|
||||
id = station.id,
|
||||
name = station.name,
|
||||
prefix = station.prefix,
|
||||
streamUrl = station.streamUrl,
|
||||
coverUrl = station.coverUrl,
|
||||
genre = station.genre,
|
||||
tags = station.tags.joinToString(","),
|
||||
sortOrder = index,
|
||||
source = station.source,
|
||||
isFavorite = false
|
||||
)
|
||||
}
|
||||
db.stationDao().insertAll(entities)
|
||||
android.util.Log.d("StationRepo", "Inserted ${entities.size} stations into DB")
|
||||
|
||||
// 5. Update tags: group names + API tags
|
||||
val groupNames = localGroups.map { it.name }.filter { it.isNotBlank() }
|
||||
val allTags = (groupNames + apiTags).distinct().sorted()
|
||||
db.tagDao().clearAll()
|
||||
db.tagDao().insertAll(allTags.map { TagEntity(it) })
|
||||
_tags.value = allTags
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("StationRepo", "refreshStations() failed", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTags(): Flow<List<String>> {
|
||||
return _tags.asStateFlow()
|
||||
}
|
||||
|
||||
override fun getStationById(id: Int): Flow<Station?> {
|
||||
return db.stationDao().getById(id).map { it?.toDomain() }
|
||||
}
|
||||
@@ -57,6 +108,7 @@ class StationRepositoryImpl @Inject constructor(
|
||||
coverUrl = coverUrl,
|
||||
genre = genre,
|
||||
tags = tags.split(",").filter { it.isNotBlank() },
|
||||
sortOrder = sortOrder
|
||||
sortOrder = sortOrder,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
|
||||
116
app/src/main/java/com/radiola/data/repository/StationTester.kt
Normal file
116
app/src/main/java/com/radiola/data/repository/StationTester.kt
Normal file
@@ -0,0 +1,116 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.StationTestResult
|
||||
import com.radiola.domain.model.StationTestStatus
|
||||
import com.radiola.domain.model.Track
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StationTester @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient
|
||||
) {
|
||||
|
||||
suspend fun test(station: Station, nowPlaying: Track?): StationTestResult {
|
||||
val testClient = okHttpClient.newBuilder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(station.streamUrl)
|
||||
.header("Icy-Metadata", "1")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
testClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = StationTestStatus.OFFLINE,
|
||||
httpCode = response.code,
|
||||
errorMessage = "HTTP ${response.code}"
|
||||
)
|
||||
}
|
||||
|
||||
val contentType = response.header("Content-Type")
|
||||
val hasAudio = contentType?.startsWith("audio") == true
|
||||
val metaint = response.header("icy-metaint")?.toIntOrNull()
|
||||
val hasIcy = metaint != null
|
||||
|
||||
val icyTitle = if (hasIcy && metaint != null) {
|
||||
readIcyTitle(response, metaint)
|
||||
} else null
|
||||
|
||||
StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = when {
|
||||
icyTitle != null || nowPlaying != null -> StationTestStatus.OK
|
||||
hasAudio -> StationTestStatus.OK_NO_META
|
||||
else -> StationTestStatus.OK_NO_META
|
||||
},
|
||||
httpCode = response.code,
|
||||
contentType = contentType,
|
||||
hasIcyMetadata = hasIcy,
|
||||
icyTitle = icyTitle,
|
||||
hasNowPlaying = nowPlaying != null,
|
||||
nowPlayingTrack = nowPlaying?.let { "${it.artist} - ${it.song}" },
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = StationTestStatus.OFFLINE,
|
||||
errorMessage = e.message ?: "Network error"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = StationTestStatus.ERROR,
|
||||
errorMessage = e.message ?: "Unknown error"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readIcyTitle(response: okhttp3.Response, metaint: Int): String? {
|
||||
return try {
|
||||
val source = response.body?.source() ?: return null
|
||||
val buffer = Buffer()
|
||||
|
||||
var skipped = 0L
|
||||
while (skipped < metaint) {
|
||||
val toSkip = (metaint - skipped).coerceAtMost(8192)
|
||||
val actual = source.read(buffer, toSkip)
|
||||
if (actual == -1L) return null
|
||||
skipped += actual
|
||||
}
|
||||
buffer.clear()
|
||||
|
||||
val metaLengthByte = source.readByte().toInt() and 0xFF
|
||||
if (metaLengthByte == 0) return null
|
||||
|
||||
val metaBytes = source.readByteArray((metaLengthByte * 16).toLong())
|
||||
val metadata = String(metaBytes, Charsets.UTF_8).trim('\u0000')
|
||||
|
||||
val regex = Regex("StreamTitle='([^']*)'")
|
||||
regex.find(metadata)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.local.TokenDataStore
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.domain.repository.SyncRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SyncRepositoryImpl @Inject constructor(
|
||||
private val api: RadiolaApi,
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) : SyncRepository {
|
||||
|
||||
private fun isLoggedIn(): Boolean = tokenDataStore.currentToken != null
|
||||
|
||||
override suspend fun pushFavorite(stationId: Int): Result<Unit> {
|
||||
if (!isLoggedIn()) return Result.success(Unit)
|
||||
return try {
|
||||
api.addFavorite(stationId.toString())
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeFavorite(stationId: Int): Result<Unit> {
|
||||
if (!isLoggedIn()) return Result.success(Unit)
|
||||
return try {
|
||||
api.removeFavorite(stationId.toString())
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pushHistory(stationId: Int): Result<Unit> {
|
||||
if (!isLoggedIn()) return Result.success(Unit)
|
||||
return try {
|
||||
api.addHistory(stationId.toString())
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchRemoteFavorites(): Result<List<Int>> {
|
||||
if (!isLoggedIn()) return Result.success(emptyList())
|
||||
return try {
|
||||
val stations = api.getFavorites()
|
||||
Result.success(stations.map { it.stationId })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchRemoteHistory(): Result<List<Int>> {
|
||||
if (!isLoggedIn()) return Result.success(emptyList())
|
||||
return try {
|
||||
val response = api.getHistory()
|
||||
Result.success(response.items.map { it.stationId })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user