mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
logging: add release-visible breadcrumbs and a verbose toggle
Playback now logs each track transition and end at info (title/artist/album/source/reason), sync logs per-entity counts and pruned rows, and the Subsonic client logs server error codes and the token-auth fallback. Add a Verbose logging setting that drops the file level to debug at runtime (persisted) so a support session can capture full detail without flooding normal release logs.
This commit is contained in:
@@ -121,6 +121,14 @@ class UserPreferences(
|
||||
|
||||
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
||||
|
||||
fun verboseLogging(): Flow<Boolean> = store.data.map { prefs -> prefs[verboseLoggingKey] ?: false }
|
||||
|
||||
suspend fun setVerboseLogging(enabled: Boolean) {
|
||||
store.edit { prefs -> prefs[verboseLoggingKey] = enabled }
|
||||
}
|
||||
|
||||
private val verboseLoggingKey = booleanPreferencesKey("verbose_logging")
|
||||
|
||||
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||
val prefs = store.data.first()
|
||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||
|
||||
@@ -111,7 +111,10 @@ class SourceRepository(
|
||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||
_quality.value = quality
|
||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
||||
scope.launch {
|
||||
runCatching { config.toClient().check("ping") }
|
||||
.onSuccess { Log.i("net", "connected (ping ok)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.data.source.subsonic
|
||||
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.log.Log
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -60,7 +61,11 @@ class SubsonicClient(
|
||||
return try {
|
||||
execute(method, params, body)
|
||||
} catch (failure: SubsonicException) {
|
||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure
|
||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||
throw failure
|
||||
}
|
||||
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||
disableTokenAuth()
|
||||
execute(method, params, body)
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ class SyncManager(
|
||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||
Log.i("sync", "started (source ${source.id})")
|
||||
serverActions.flush()
|
||||
SyncService(db, source).sync()
|
||||
Log.i("sync", "finished in ${elapsedMs(startedAt)}ms")
|
||||
val summary = SyncService(db, source).sync()
|
||||
Log.i("sync", "finished in ${elapsedMs(startedAt)}ms: $summary")
|
||||
SyncStatus.Success
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
|
||||
@@ -20,13 +20,21 @@ class SyncService(
|
||||
private val db: SubtracksDatabase,
|
||||
private val source: MusicSource,
|
||||
) {
|
||||
suspend fun sync() {
|
||||
private var artistsSynced = 0
|
||||
private var albumsSynced = 0
|
||||
private var songsSynced = 0
|
||||
private var playlistsSynced = 0
|
||||
private var playlistSongsSynced = 0
|
||||
private var pruned = 0
|
||||
|
||||
suspend fun sync(): SyncSummary {
|
||||
syncArtists()
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
aggregatePlayData()
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
return SyncSummary(artistsSynced, albumsSynced, songsSynced, playlistsSynced, playlistSongsSynced, pruned)
|
||||
}
|
||||
|
||||
private suspend fun syncArtists() {
|
||||
@@ -36,8 +44,9 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
artistsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||
}
|
||||
|
||||
private suspend fun syncAlbums() {
|
||||
@@ -53,11 +62,13 @@ class SyncService(
|
||||
writeAlbumsAndDiscs(batch, discs)
|
||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||
albumsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
}
|
||||
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||
pruned +=
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncSongs() {
|
||||
@@ -67,8 +78,9 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
writeSongs(batch)
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
songsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||
}
|
||||
|
||||
private suspend fun writeSongs(batch: List<Song>) {
|
||||
@@ -106,8 +118,9 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
playlistsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||
}
|
||||
|
||||
private suspend fun syncPlaylistSongs() {
|
||||
@@ -118,6 +131,7 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||
playlistSongsSynced += batch.size
|
||||
}
|
||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||
library.deleteOrphanPlaylistSongs(source.id)
|
||||
@@ -173,15 +187,17 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterId: String) -> List<String>,
|
||||
delete: suspend (Collection<String>) -> Unit,
|
||||
) {
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterId = ""
|
||||
var pruned = 0
|
||||
while (true) {
|
||||
val ids = pageOf(afterId)
|
||||
if (ids.isEmpty()) return
|
||||
if (ids.isEmpty()) return pruned
|
||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||
if (ids.size < PRUNE_PAGE) return
|
||||
pruned += stale.size
|
||||
if (ids.size < PRUNE_PAGE) return pruned
|
||||
afterId = ids.last()
|
||||
}
|
||||
}
|
||||
@@ -190,22 +206,33 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||
) {
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterAlbumId = ""
|
||||
var afterDisc = 0L
|
||||
var pruned = 0
|
||||
while (true) {
|
||||
val keys = pageOf(afterAlbumId, afterDisc)
|
||||
if (keys.isEmpty()) return
|
||||
if (keys.isEmpty()) return pruned
|
||||
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||
if (keys.size < PRUNE_PAGE) return
|
||||
pruned += stale.size
|
||||
if (keys.size < PRUNE_PAGE) return pruned
|
||||
afterAlbumId = keys.last().albumId
|
||||
afterDisc = keys.last().disc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SyncSummary(
|
||||
val artists: Int,
|
||||
val albums: Int,
|
||||
val songs: Int,
|
||||
val playlists: Int,
|
||||
val playlistSongs: Int,
|
||||
val pruned: Int,
|
||||
)
|
||||
|
||||
private const val DELETE_CHUNK = 500
|
||||
private const val PRUNE_PAGE = 500
|
||||
private const val INITIAL_HASH_CAPACITY = 256
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.playback.MediaSessionConnection
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.PlayerConnection
|
||||
@@ -48,6 +49,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
@@ -68,6 +71,7 @@ fun appModule(
|
||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||
single {
|
||||
SourceRepository(
|
||||
get(),
|
||||
|
||||
@@ -21,19 +21,24 @@ object Log {
|
||||
private var fileStore: LogFileStore? = null
|
||||
private var appContext: Context? = null
|
||||
private var consoleLevel = LogLevel.DEBUG
|
||||
private var defaultFileLevel = LogLevel.INFO
|
||||
private var crashHandlerInstalled = false
|
||||
|
||||
fun init(context: Context) {
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
|
||||
val fileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||
defaultFileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||
fileStore?.shutdown()
|
||||
fileStore = LogFileStore(File(app.filesDir, "logs"), fileLevel)
|
||||
fileStore = LogFileStore(File(app.filesDir, "logs"), defaultFileLevel)
|
||||
installCrashHandler()
|
||||
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
fun setVerbose(enabled: Boolean) {
|
||||
fileStore?.setMinLevel(if (enabled) LogLevel.DEBUG else defaultFileLevel)
|
||||
}
|
||||
|
||||
fun v(
|
||||
tag: String,
|
||||
message: String,
|
||||
|
||||
@@ -12,7 +12,7 @@ enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
|
||||
|
||||
class LogFileStore(
|
||||
private val rootDir: File,
|
||||
private val minLevel: LogLevel,
|
||||
@Volatile private var minLevel: LogLevel,
|
||||
private val maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES,
|
||||
private val maxFiles: Int = DEFAULT_MAX_FILES,
|
||||
) {
|
||||
@@ -55,6 +55,10 @@ class LogFileStore(
|
||||
runCatching { executor.submit {}.get() }
|
||||
}
|
||||
|
||||
fun setMinLevel(level: LogLevel) {
|
||||
minLevel = level
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class Media3PlayerHandle(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
Log.d("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||
listener.onTransition()
|
||||
}
|
||||
|
||||
@@ -166,6 +166,9 @@ class Media3PlayerHandle(
|
||||
"state=$playbackState position=${controller.currentPosition} " +
|
||||
"duration=${duration(controller.duration)} itemDuration=${currentItem?.durationMs}",
|
||||
)
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
Log.i("playback", "ended ${describe(controller.currentMediaItem)}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -103,6 +103,7 @@ fun SettingsRoute(
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
@@ -115,6 +116,7 @@ fun SettingsRoute(
|
||||
downloadOverMetered = downloadOverMetered,
|
||||
scrobbling = scrobbling,
|
||||
offline = offline,
|
||||
verboseLogging = verboseLogging,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
onEditServer = onEditServer,
|
||||
onWifiQualityChange = viewModel::setWifiQuality,
|
||||
@@ -124,6 +126,7 @@ fun SettingsRoute(
|
||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||
onAddServer = onAddServer,
|
||||
onOpenDownloads = onOpenDownloads,
|
||||
onOpenLicenses = onOpenLicenses,
|
||||
@@ -162,6 +165,7 @@ fun SettingsScreen(
|
||||
scrobbling: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
offline: Boolean = false,
|
||||
verboseLogging: Boolean = false,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -171,6 +175,7 @@ fun SettingsScreen(
|
||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||
onAddServer: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onOpenLicenses: () -> Unit,
|
||||
@@ -400,6 +405,26 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_about_verbose_logging)) },
|
||||
supportingContent = { Text(stringResource(R.string.settings_about_verbose_logging_description)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = verboseLogging,
|
||||
onCheckedChange = onVerboseLoggingChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onVerboseLoggingChange(!verboseLogging) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,19 @@ class SettingsViewModel(
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val verboseLogging: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.verboseLogging()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
sourceRepository.setOfflineMode(enabled)
|
||||
}
|
||||
|
||||
fun setVerboseLogging(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setVerboseLogging(enabled) }
|
||||
}
|
||||
|
||||
fun selectSource(id: Long) {
|
||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
<string name="settings_about_share_logs_empty">No logs to send yet</string>
|
||||
<string name="settings_about_share_logs_failed">Couldn\'t prepare the logs</string>
|
||||
<string name="settings_about_share_logs_subject">Subtracks logs</string>
|
||||
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting. Log files get larger.</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_music_name">Playback</string>
|
||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||
|
||||
Reference in New Issue
Block a user