Add Timber file logging and Jellyfin log upload

This commit is contained in:
2026-05-14 18:06:54 +02:00
parent f95fbf72b3
commit 4b87f93dee
21 changed files with 430 additions and 100 deletions

View File

@@ -41,5 +41,6 @@ dependencies {
implementation(libs.media3.datasource.okhttp)
implementation(libs.datastore)
implementation(libs.okhttp)
implementation(libs.timber)
testImplementation(libs.junit)
}

View File

@@ -1,7 +1,6 @@
package hu.bbara.purefin.core.download
import android.content.Context
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
@@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@@ -52,16 +52,16 @@ class MediaDownloadManager @Inject constructor(
) {
val contentId = download.request.id
val state = download.toDownloadState()
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
Timber.tag(TAG).d("Download changed: $contentId -> $state (${download.percentDownloaded}%)")
if (finalException != null) {
Log.e(TAG, "Download exception for $contentId", finalException)
Timber.tag(TAG).e(finalException, "Download exception for $contentId")
}
getOrCreateStateFlow(contentId).value = state
}
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
val contentId = download.request.id
Log.d(TAG, "Download removed: $contentId")
Timber.tag(TAG).d("Download removed: $contentId")
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
}
})
@@ -91,7 +91,7 @@ class MediaDownloadManager @Inject constructor(
}
emit(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to poll active downloads", e)
Timber.tag(TAG).e(e, "Failed to poll active downloads")
emit(emptyMap())
}
delay(500)
@@ -120,13 +120,13 @@ class MediaDownloadManager @Inject constructor(
withContext(Dispatchers.IO) {
try {
val source = downloadMediaSourceResolver.resolveMovieDownload(movieId) ?: run {
Log.e(TAG, "No downloadable movie source for $movieId")
Timber.tag(TAG).e("No downloadable movie source for $movieId")
return@withContext
}
offlineCatalogStore.saveMovies(listOf(source.movie))
Log.d(TAG, "Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
Timber.tag(TAG).d("Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
val request = buildDownloadRequest(
mediaId = movieId,
playbackUrl = source.playbackUrl,
@@ -134,9 +134,9 @@ class MediaDownloadManager @Inject constructor(
customCacheKey = source.customCacheKey,
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for $movieId")
Timber.tag(TAG).d("Download request sent for $movieId")
} catch (e: Exception) {
Log.e(TAG, "Failed to start download for $movieId", e)
Timber.tag(TAG).e(e, "Failed to start download for $movieId")
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
}
}
@@ -148,7 +148,7 @@ class MediaDownloadManager @Inject constructor(
try {
offlineCatalogStore.deleteMovie(movieId)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove movie from offline DB", e)
Timber.tag(TAG).e(e, "Failed to remove movie from offline DB")
}
}
}
@@ -157,7 +157,7 @@ class MediaDownloadManager @Inject constructor(
withContext(Dispatchers.IO) {
try {
val source = downloadMediaSourceResolver.resolveEpisodeDownload(episodeId) ?: run {
Log.e(TAG, "No downloadable episode source for $episodeId")
Timber.tag(TAG).e("No downloadable episode source for $episodeId")
return@withContext
}
@@ -171,7 +171,7 @@ class MediaDownloadManager @Inject constructor(
offlineCatalogStore.saveEpisode(source.episode)
Log.d(TAG, "Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
Timber.tag(TAG).d("Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
val request = buildDownloadRequest(
mediaId = episodeId,
playbackUrl = source.playbackUrl,
@@ -179,9 +179,9 @@ class MediaDownloadManager @Inject constructor(
customCacheKey = source.customCacheKey,
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for episode $episodeId")
Timber.tag(TAG).d("Download request sent for episode $episodeId")
} catch (e: Exception) {
Log.e(TAG, "Failed to start download for episode $episodeId", e)
Timber.tag(TAG).e(e, "Failed to start download for episode $episodeId")
getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed
}
}
@@ -201,7 +201,7 @@ class MediaDownloadManager @Inject constructor(
try {
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove episode from offline DB", e)
Timber.tag(TAG).e(e, "Failed to remove episode from offline DB")
}
}
}
@@ -231,7 +231,7 @@ class MediaDownloadManager @Inject constructor(
try {
syncSmartDownloadsForSeries(seriesId)
} catch (e: Exception) {
Log.e(TAG, "Smart download sync failed for series $seriesId", e)
Timber.tag(TAG).e(e, "Smart download sync failed for series $seriesId")
}
}
}
@@ -246,7 +246,7 @@ class MediaDownloadManager @Inject constructor(
val unwatchedDownloaded = mutableListOf<UUID>()
for (episode in downloadedEpisodes) {
if (downloadMediaSourceResolver.isEpisodeWatched(episode.id)) {
Log.d(TAG, "Smart download: removing watched episode ${episode.title}")
Timber.tag(TAG).d("Smart download: removing watched episode ${episode.title}")
cancelEpisodeDownload(episode.id)
} else {
unwatchedDownloaded.add(episode.id)
@@ -264,7 +264,7 @@ class MediaDownloadManager @Inject constructor(
)
if (toDownload.isNotEmpty()) {
Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId")
Timber.tag(TAG).d("Smart download: queuing ${toDownload.size} episodes for series $seriesId")
downloadEpisodes(toDownload)
}
}

View File

@@ -0,0 +1,214 @@
package hu.bbara.purefin.core.logging
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Process
import android.util.Log
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.system.exitProcess
object PurefinLogger {
private val lock = Any()
private var logStore: LogFileStore? = null
private var initialized = false
fun initialize(context: Context) {
synchronized(lock) {
if (initialized) {
return
}
val store = LogFileStore(File(context.filesDir, LOG_DIRECTORY))
logStore = store
val isDebuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
if (isDebuggable) {
Timber.plant(Timber.DebugTree())
}
Timber.plant(FileLogTree(store))
installUncaughtExceptionHandler(store)
initialized = true
}
}
fun prepareFilesForUpload(): List<UploadLogFile> {
return logStore?.prepareFilesForUpload().orEmpty()
}
fun deleteUploadedFile(uploadLogFile: UploadLogFile) {
if (uploadLogFile.file.exists() && !uploadLogFile.file.delete()) {
error("Uploaded log file could not be deleted: ${uploadLogFile.name}")
}
}
private fun installUncaughtExceptionHandler(store: LogFileStore) {
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
Timber.e(throwable, "Uncaught exception in %s", thread.name)
store.flush()
} finally {
if (previousHandler != null) {
previousHandler.uncaughtException(thread, throwable)
} else {
Process.killProcess(Process.myPid())
exitProcess(2)
}
}
}
}
private const val LOG_DIRECTORY = "purefin-logs"
}
data class UploadLogFile(
val name: String,
val data: String,
internal val file: File,
)
private class FileLogTree(
private val logStore: LogFileStore,
) : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
logStore.write(priority = priority, tag = tag, message = message, throwable = t)
}
}
private class LogFileStore(
private val directory: File,
) {
private val activeFile = File(directory, ACTIVE_LOG_FILE)
private val timestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
private val uploadNameFormatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")
@Synchronized
fun write(priority: Int, tag: String?, message: String, throwable: Throwable?) {
directory.mkdirs()
val entry = buildEntry(priority, tag, message, throwable).toByteArray(Charsets.UTF_8)
if (activeFile.exists() && activeFile.length() + entry.size > MAX_LOG_BYTES) {
rotate()
}
FileOutputStream(activeFile, true).use { output ->
output.write(entry)
output.flush()
}
}
@Synchronized
fun flush() {
directory.mkdirs()
}
@Synchronized
fun prepareFilesForUpload(): List<UploadLogFile> {
directory.mkdirs()
val candidates = uploadFiles() + rotatedFilesOldestFirst() + listOfNotNull(activeFile.takeIf { it.isLogFile() })
if (candidates.isEmpty()) {
return emptyList()
}
val preparedFiles = renameForUpload(candidates)
return preparedFiles.map { file ->
UploadLogFile(
name = file.name,
data = file.readText(Charsets.UTF_8),
file = file,
)
}
}
private fun buildEntry(priority: Int, tag: String?, message: String, throwable: Throwable?): String {
return buildString {
append(timestampFormatter.format(LocalDateTime.now()))
append(' ')
append(priorityLabel(priority))
append('/')
append(tag ?: "Purefin")
append(" [")
append(Thread.currentThread().name)
append("] ")
append(message)
append('\n')
if (throwable != null) {
append(Log.getStackTraceString(throwable))
append('\n')
}
}
}
private fun priorityLabel(priority: Int): String = when (priority) {
Log.VERBOSE -> "V"
Log.DEBUG -> "D"
Log.INFO -> "I"
Log.WARN -> "W"
Log.ERROR -> "E"
Log.ASSERT -> "A"
else -> priority.toString()
}
private fun rotate() {
File(directory, "purefin.${MAX_LOG_FILES - 1}.log").delete()
for (index in MAX_LOG_FILES - 2 downTo 1) {
val file = File(directory, "purefin.$index.log")
if (file.exists()) {
file.renameTo(File(directory, "purefin.${index + 1}.log"))
}
}
if (activeFile.exists()) {
activeFile.renameTo(File(directory, "purefin.1.log"))
}
}
private fun renameForUpload(files: List<File>): List<File> {
val uploadTimestamp = uploadNameFormatter.format(LocalDateTime.now())
val tempFiles = files.mapIndexed { index, file ->
val tempFile = File(directory, ".upload-$uploadTimestamp-$index.tmp")
tempFile.delete()
if (!file.renameTo(tempFile)) {
error("Log file could not be prepared for upload: ${file.name}")
}
tempFile
}
return tempFiles.mapIndexed { index, file ->
val uploadName = if (tempFiles.size == 1) {
"$uploadTimestamp.log"
} else {
"${uploadTimestamp}_${(index + 1).toString().padStart(3, '0')}.log"
}
val uploadFile = File(directory, uploadName)
uploadFile.delete()
if (!file.renameTo(uploadFile)) {
error("Log file could not be named for upload: $uploadName")
}
uploadFile
}
}
private fun uploadFiles(): List<File> {
return directory.listFiles()
.orEmpty()
.filter { it.isFile && UPLOAD_LOG_FILE_REGEX.matches(it.name) }
.sortedWith(compareBy<File> { it.lastModified() }.thenBy { it.name })
}
private fun rotatedFilesOldestFirst(): List<File> {
return (MAX_LOG_FILES - 1 downTo 1)
.map { File(directory, "purefin.$it.log") }
.filter { it.isLogFile() }
}
private fun File.isLogFile(): Boolean {
return isFile && length() > 0L
}
private companion object {
const val ACTIVE_LOG_FILE = "purefin.log"
const val MAX_LOG_BYTES = 512 * 1024
const val MAX_LOG_FILES = 4
val UPLOAD_LOG_FILE_REGEX = Regex("""\d{8}_\d{6}(?:_\d{3})?\.log""")
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.core.player.manager
import android.util.Log
import androidx.media3.exoplayer.ExoPlayer
import hu.bbara.purefin.core.player.model.SegmentStatus
import hu.bbara.purefin.model.MediaSegment
@@ -11,6 +10,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
class MediaSegmentManager(private val player: ExoPlayer) {
@@ -30,7 +30,7 @@ class MediaSegmentManager(private val player: ExoPlayer) {
@Synchronized
fun registerListener(listener: MediaSegmentListener) {
this.listener?.let {
Log.w("MediaSegmentManager", "Listener was already register")
Timber.tag(TAG).w("Listener was already register")
return
}
this.listener = listener
@@ -74,10 +74,14 @@ class MediaSegmentManager(private val player: ExoPlayer) {
private fun notifyListener(mediaSegment: MediaSegment, status: SegmentStatus) {
val listener = listener
if (listener == null) {
Log.w("MediaSegmentManager", "Listener was not register therefore it cannot notify")
Timber.tag(TAG).w("Listener was not register therefore it cannot notify")
return
}
Log.d("MediaSegmentManager", "Notify listener about $mediaSegment with status $status")
Timber.tag(TAG).d("Notify listener about $mediaSegment with status $status")
listener.onEvent(mediaSegment, status)
}
private companion object {
const val TAG = "MediaSegmentManager"
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.core.player.manager
import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.LocalMediaUpdater
import hu.bbara.purefin.core.data.PlaybackProgressReporter
@@ -18,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@@ -82,7 +82,7 @@ class ProgressManager @Inject constructor(
try {
localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
} catch (e: Exception) {
Log.e("ProgressManager", "Local cache update failed", e)
Timber.tag(TAG).e(e, "Local cache update failed")
}
}
}
@@ -109,9 +109,9 @@ class ProgressManager @Inject constructor(
isStop -> playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
else -> playbackProgressReporter.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
}
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
Timber.tag(TAG).d("${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
} catch (e: Exception) {
Log.e("ProgressManager", "Report failed", e)
Timber.tag(TAG).e(e, "Report failed")
}
}
}
@@ -128,12 +128,16 @@ class ProgressManager @Inject constructor(
playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
}
localMediaUpdater.updateWatchProgress(itemId, posMs, durMs)
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
Timber.tag(TAG).d("Stop: $itemId at ${posMs}ms")
} catch (e: Exception) {
Log.e("ProgressManager", "Report failed", e)
Timber.tag(TAG).e(e, "Report failed")
}
}
}
scope.cancel()
}
private companion object {
const val TAG = "ProgressManager"
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.core.player.viewmodel
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -23,6 +22,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlinx.serialization.InternalSerializationApi
import java.util.UUID
import javax.inject.Inject
@@ -36,6 +36,7 @@ class PlayerViewModel @Inject constructor(
) : ViewModel() {
companion object {
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
private const val TAG = "PlayerViewModel"
}
val player get() = playerManager.player
@@ -282,7 +283,7 @@ class PlayerViewModel @Inject constructor(
is PlayableMedia.Movie -> {
val movie = mediaCatalogReader.getMovie(id).first()
if (movie == null) {
Log.e("PlayerViewModel", "Movie not found for playlist item: $id")
Timber.tag(TAG).e("Movie not found for playlist item: $id")
null
} else {
PlaylistElementUiModel(
@@ -300,7 +301,7 @@ class PlayerViewModel @Inject constructor(
is PlayableMedia.Series -> {
val series = mediaCatalogReader.getSeries(id).first()
if (series == null) {
Log.e("PlayerViewModel", "Series not found for playlist item: $id")
Timber.tag(TAG).e("Series not found for playlist item: $id")
null
} else {
PlaylistElementUiModel(
@@ -318,7 +319,7 @@ class PlayerViewModel @Inject constructor(
is PlayableMedia.Episode -> {
val episode = mediaCatalogReader.getEpisode(id).first()
if (episode == null) {
Log.e("PlayerViewModel", "Episode not found for playlist item: $id")
Timber.tag(TAG).e("Episode not found for playlist item: $id")
null
} else {
PlaylistElementUiModel(

View File

@@ -6,7 +6,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.util.Log
import timber.log.Timber
class AppUpdateInstallReceiver : BroadcastReceiver() {
@@ -18,13 +18,13 @@ class AppUpdateInstallReceiver : BroadcastReceiver() {
try {
context.startActivity(confirmIntent)
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "Install confirmation activity missing", e)
Timber.tag(TAG).e(e, "Install confirmation activity missing")
}
}
PackageInstaller.STATUS_SUCCESS -> Unit
else -> {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Log.e(TAG, "Install failed: $status $message")
Timber.tag(TAG).e("Install failed: $status $message")
}
}
}