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

@@ -53,6 +53,7 @@ kotlin {
}
dependencies {
implementation(project(":data"))
implementation(project(":core"))
implementation(project(":core-model"))
implementation(project(":core-ui"))

View File

@@ -2,6 +2,12 @@ package hu.bbara.purefin.tv
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import hu.bbara.purefin.core.logging.PurefinLogger
@HiltAndroidApp
class TvApplication : Application()
class TvApplication : Application() {
override fun onCreate() {
super.onCreate()
PurefinLogger.initialize(this)
}
}

View File

@@ -2,8 +2,12 @@ package hu.bbara.purefin
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import hu.bbara.purefin.core.logging.PurefinLogger
@HiltAndroidApp
class PurefinApplication : Application() {
override fun onCreate() {
super.onCreate()
PurefinLogger.initialize(this)
}
}

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")
}
}
}

View File

@@ -36,6 +36,7 @@ dependencies {
implementation(libs.jellyfin.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.slf4j.api)
implementation(libs.timber)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.datastore)

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.data.catalog
import android.util.Log
import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.NetworkMonitor
@@ -41,6 +40,7 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import timber.log.Timber
@Singleton
class InMemoryAppContentRepository @Inject constructor(
@@ -82,7 +82,7 @@ class InMemoryAppContentRepository @Inject constructor(
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
return
}
Log.d(TAG, "Initializing home repository")
Timber.tag(TAG).d("Initializing home repository")
scope.launch { loadHomeCache() }
scope.launch { refreshHomeData() }
}
@@ -91,7 +91,7 @@ class InMemoryAppContentRepository @Inject constructor(
val job = synchronized(this) {
refreshJob?.takeIf { it.isActive } ?: scope.launch {
runCatching {
Log.d(TAG, "Refreshing home data")
Timber.tag(TAG).d("Refreshing home data")
if (!networkMonitor.isOnline.first()) {
return@runCatching
}
@@ -100,10 +100,10 @@ class InMemoryAppContentRepository @Inject constructor(
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
Log.d(TAG, "Home refresh successful")
Timber.tag(TAG).d("Home refresh successful")
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content")
}
}.also { refreshJob = it }
}
@@ -111,7 +111,7 @@ class InMemoryAppContentRepository @Inject constructor(
}
private suspend fun loadHomeCache() {
Log.d(TAG, "Loading home cache")
Timber.tag(TAG).d("Loading home cache")
val cache = homeCacheDataStore.data.first()
if (cache.libraries.isNotEmpty()) {
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
@@ -147,7 +147,7 @@ class InMemoryAppContentRepository @Inject constructor(
if (cache.episodes.isNotEmpty()) {
onlineMediaRepository.upsertEpisodes(cache.episodes.mapNotNull { it.toEpisode() })
}
Log.d(TAG, "Home cache loaded")
Timber.tag(TAG).d("Home cache loaded")
}
private suspend fun persistHomeCache() {
@@ -200,7 +200,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load libraries", error)
Timber.tag(TAG).w(error, "Unable to load libraries")
return
}
val filteredLibraries = librariesItem.filter {
@@ -222,7 +222,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLibrary(library: Library): Library {
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
.getOrElse { error ->
Log.w(TAG, "Unable to load library ${library.id}", error)
Timber.tag(TAG).w(error, "Unable to load library ${library.id}")
return library
}
return when (library.type) {
@@ -240,7 +240,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadSuggestions() {
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error ->
Log.w(TAG, "Unable to load suggestions", error)
Timber.tag(TAG).w(error, "Unable to load suggestions")
return
}
suggestionsState.value = suggestionsItems.mapNotNull { item ->
@@ -261,7 +261,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadContinueWatching() {
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error ->
Log.w(TAG, "Unable to load continue watching", error)
Timber.tag(TAG).w(error, "Unable to load continue watching")
return
}
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
@@ -282,7 +282,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadNextUp() {
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
.getOrElse { error ->
Log.w(TAG, "Unable to load next up", error)
Timber.tag(TAG).w(error, "Unable to load next up")
return
}
nextUpState.value = nextUpItems.map { item ->
@@ -297,7 +297,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLatestLibraryContent() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load latest library content", error)
Timber.tag(TAG).w(error, "Unable to load latest library content")
return
}
val filteredLibraries = librariesItem.filter {
@@ -306,7 +306,7 @@ class InMemoryAppContentRepository @Inject constructor(
val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error ->
Log.w(TAG, "Unable to load latest items for library ${library.id}", error)
Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}")
emptyList()
}
library.id to when (library.collectionType) {

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.data.catalog
import android.util.Log
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
@@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -52,7 +52,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
if (!moviesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.MOVIE) {
Log.d("InMemoryMediaRepository", "Item is not an movie: ${item.type}")
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
return flowOf(null)
}
val movie = item.toMovie(serverUrl.first())
@@ -66,7 +66,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
if (!seriesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.SERIES) {
Log.d("InMemoryMediaRepository", "Item is not an series: ${item.type}")
Timber.tag(TAG).d("Item is not an series: ${item.type}")
return flowOf(null)
}
val series = item.toSeries(serverUrl.first())
@@ -80,7 +80,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
if (!episodesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.EPISODE) {
Log.d("InMemoryMediaRepository", "Item is not an episode: ${item.type}")
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
return flowOf(null)
}
val episode = item.toEpisode(serverUrl.first())
@@ -110,7 +110,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error)
Timber.tag(TAG).e(error, "Failed to load content for series $seriesId")
throw error
}
}
@@ -121,7 +121,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Log.w("InMemoryMediaRepository", "Unable to preload seasons for episode series $seriesId", error)
Timber.tag(TAG).w(error, "Unable to preload seasons for episode series $seriesId")
}
}
@@ -231,4 +231,8 @@ class InMemoryLocalMediaRepository @Inject constructor(
}
}
}
private companion object {
const val TAG = "InMemoryMediaRepository"
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.data.jellyfin
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
@@ -36,6 +35,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO
import org.jellyfin.sdk.model.api.MediaSegmentType.PREVIEW
import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP
import org.jellyfin.sdk.model.api.MediaSourceInfo
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -72,7 +72,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}.getOrElse { error ->
if (error is CancellationException) throw error
Log.w("PlayableMediaRepo", "Unable to load online metadata for downloaded media $mediaId", error)
Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId")
getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}
}
@@ -232,7 +232,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
getPlayableMedia(id)
}
}.getOrElse { error ->
Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error)
Timber.tag(TAG).w(error, "Unable to load next-up items for $episodeId")
emptyList()
}
}
@@ -325,4 +325,8 @@ class DefaultPlayableMediaRepository @Inject constructor(
endMs = endTicks / 10_000L
)
}
private companion object {
const val TAG = "PlayableMediaRepo"
}
}

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.data.jellyfin.client
import android.content.Context
import android.os.SystemClock
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
@@ -19,6 +18,7 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
@@ -57,6 +57,7 @@ import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -194,7 +195,7 @@ class JellyfinApiClient @Inject constructor(
searchTerm = searchTerm,
recursive = true
)
Log.d("searchBySearchTerm", response.content.toString())
Timber.tag("searchBySearchTerm").d(response.content.toString())
response.content.items
}
}
@@ -210,7 +211,7 @@ class JellyfinApiClient @Inject constructor(
genres = genres,
recursive = true
)
Log.d("searchByGenre", response.content.toString())
Timber.tag("searchByGenre").d(response.content.toString())
response.content.items
}
}
@@ -225,7 +226,7 @@ class JellyfinApiClient @Inject constructor(
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
includeHidden = false,
)
Log.d("getLibraries", response.content.toString())
Timber.tag("getLibraries").d(response.content.toString())
response.content.items
}
}
@@ -245,7 +246,7 @@ class JellyfinApiClient @Inject constructor(
recursive = true,
)
val response = api.itemsApi.getItems(getItemsRequest)
Log.d("getLibraryContent", response.content.toString())
Timber.tag("getLibraryContent").d(response.content.toString())
response.content.items
}
}
@@ -263,7 +264,7 @@ class JellyfinApiClient @Inject constructor(
limit = 8,
enableTotalRecordCount = true,
)
Log.d("getSuggestions", response.content.toString())
Timber.tag("getSuggestions").d(response.content.toString())
response.content.items
}
}
@@ -282,7 +283,7 @@ class JellyfinApiClient @Inject constructor(
startIndex = 0,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
Log.d("getContinueWatching", response.content.toString())
Timber.tag("getContinueWatching").d(response.content.toString())
response.content.items
}
}
@@ -298,7 +299,7 @@ class JellyfinApiClient @Inject constructor(
enableResumable = false,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
Log.d("getNextUpEpisodes", result.content.toString())
Timber.tag("getNextUpEpisodes").d(result.content.toString())
result.content.items
}
}
@@ -315,7 +316,7 @@ class JellyfinApiClient @Inject constructor(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 10,
)
Log.d("getLatestFromLibrary", response.content.toString())
Timber.tag("getLatestFromLibrary").d(response.content.toString())
response.content
}
}
@@ -326,7 +327,7 @@ class JellyfinApiClient @Inject constructor(
return@logRequest null
}
val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId())
Log.d("getItemInfo", result.content.toString())
Timber.tag("getItemInfo").d(result.content.toString())
result.content
}
}
@@ -342,7 +343,7 @@ class JellyfinApiClient @Inject constructor(
fields = defaultItemFields,
enableUserData = true,
)
Log.d("getSeasons", result.content.toString())
Timber.tag("getSeasons").d(result.content.toString())
result.content.items
}
}
@@ -359,7 +360,7 @@ class JellyfinApiClient @Inject constructor(
fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true,
)
Log.d("getEpisodesInSeason", result.content.toString())
Timber.tag("getEpisodesInSeason").d(result.content.toString())
result.content.items
}
}
@@ -379,7 +380,7 @@ class JellyfinApiClient @Inject constructor(
limit = count,
)
val nextUpEpisodes = nextUpEpisodesResult.content.items
Log.d("getNextEpisodes", nextUpEpisodes.toString())
Timber.tag("getNextEpisodes").d(nextUpEpisodes.toString())
nextUpEpisodes
}
}
@@ -393,7 +394,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(),
parentId = id,
)
Log.d("getGenres", result.toString())
Timber.tag("getGenres").d(result.toString())
result.content.items
}
}
@@ -435,7 +436,7 @@ class JellyfinApiClient @Inject constructor(
maxStreamingBitrate = 100_000_000,
),
)
Log.d("getMediaSources", result.toString())
Timber.tag("getMediaSources").d(result.toString())
result.content.mediaSources
}
}
@@ -449,7 +450,7 @@ class JellyfinApiClient @Inject constructor(
itemId = mediaId,
//includeSegmentTypes = listOf(MediaSegmentType.INTRO)
)
Log.d("getMediaSegments", result.toString())
Timber.tag("getMediaSegments").d(result.toString())
result.content.items
}
}
@@ -496,10 +497,19 @@ class JellyfinApiClient @Inject constructor(
liveStreamId = liveStreamId,
)
} catch (error: Exception) {
Log.e(TAG, "getVideoStreamUrl($itemId) failed", error)
Timber.tag(TAG).e(error, "getVideoStreamUrl($itemId) failed")
throw error
}
suspend fun uploadLogFile(data: String): String? = withContext(Dispatchers.IO) {
logRequest("uploadLogFile") {
if (!ensureConfigured()) {
return@logRequest null
}
api.clientLogApi.logFile(data).content.fileName
}
}
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
logRequest("getPublicSystemInfoVersion") {
if (!ensureConfigured()) {
@@ -589,8 +599,7 @@ class JellyfinApiClient @Inject constructor(
return try {
val result = block()
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
Log.d(
TAG,
Timber.tag(TAG).d(
"$operation finished in ${elapsedMs}ms, " +
"fetched ${result.approximateSizeBytes()} bytes${result.itemCountLogText()}"
)
@@ -599,7 +608,7 @@ class JellyfinApiClient @Inject constructor(
throw error
} catch (error: Exception) {
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
Log.e(TAG, "$operation failed after ${elapsedMs}ms", error)
Timber.tag(TAG).e(error, "$operation failed after ${elapsedMs}ms")
throw error
}
}

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.data.jellyfin.playback
import android.media.MediaCodecList
import android.util.Log
import timber.log.Timber
/**
* Helper to debug available audio/video codecs on the device.
@@ -17,34 +17,34 @@ object CodecDebugHelper {
fun logAvailableDecoders() {
try {
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
Log.d(TAG, "=== Available Audio Decoders ===")
Timber.tag(TAG).d("=== Available Audio Decoders ===")
codecList.codecInfos
.filter { !it.isEncoder }
.forEach { codecInfo ->
codecInfo.supportedTypes.forEach { mimeType ->
if (mimeType.startsWith("audio/")) {
Log.d(TAG, "${codecInfo.name}: $mimeType")
Timber.tag(TAG).d("${codecInfo.name}: $mimeType")
if (mimeType.contains("dts", ignoreCase = true) ||
mimeType.contains("truehd", ignoreCase = true)) {
Log.w(TAG, " ^^^ DTS/TrueHD decoder found! ^^^")
Timber.tag(TAG).w(" ^^^ DTS/TrueHD decoder found! ^^^")
}
}
}
}
Log.d(TAG, "=== Available Video Decoders ===")
Timber.tag(TAG).d("=== Available Video Decoders ===")
codecList.codecInfos
.filter { !it.isEncoder }
.forEach { codecInfo ->
codecInfo.supportedTypes.forEach { mimeType ->
if (mimeType.startsWith("video/")) {
Log.d(TAG, "${codecInfo.name}: $mimeType")
Timber.tag(TAG).d("${codecInfo.name}: $mimeType")
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to list codecs", e)
Timber.tag(TAG).e(e, "Failed to list codecs")
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.data.jellyfin.playback
import android.util.Log
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import java.util.UUID
@@ -11,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber
@Singleton
class JellyfinPlaybackResolver @Inject constructor(
@@ -33,7 +33,7 @@ class JellyfinPlaybackResolver @Inject constructor(
) ?: return@withContext null
if (playbackInfo.errorCode != null) {
Log.w(TAG, "Playback info failed for $mediaId with ${playbackInfo.errorCode}")
Timber.tag(TAG).w("Playback info failed for $mediaId with ${playbackInfo.errorCode}")
return@withContext null
}
@@ -54,9 +54,9 @@ class JellyfinPlaybackResolver @Inject constructor(
)
if (decision == null) {
Log.w(TAG, "No compatible playback path for $mediaId")
Timber.tag(TAG).w("No compatible playback path for $mediaId")
} else {
Log.d(TAG, "Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}")
Timber.tag(TAG).d("Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}")
}
decision
}
@@ -67,7 +67,7 @@ class JellyfinPlaybackResolver @Inject constructor(
val resolvedVersion = runCatching {
jellyfinApiClient.getPublicSystemInfoVersion()?.let(ServerVersion::fromString)
}.onFailure { error ->
Log.w(TAG, "Unable to fetch server version for $serverUrl", error)
Timber.tag(TAG).w(error, "Unable to fetch server version for $serverUrl")
}.getOrNull() ?: PlaybackProfileDefaults.fallbackServerVersion
serverVersionCache[serverUrl] = resolvedVersion

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.data.jellyfin.session
import android.util.Log
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.QuickConnectSession
@@ -8,6 +7,7 @@ import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.model.api.AuthenticationResult
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -21,22 +21,22 @@ class JellyfinAuthenticationRepository @Inject constructor(
override suspend fun findServer(input: String): JellyfinServerCandidate? =
runCatching { jellyfinApiClient.findServer(input) }
.onFailure { Log.e(TAG, "Server search failed", it) }
.onFailure { Timber.tag(TAG).e(it, "Server search failed") }
.getOrNull()
override suspend fun isQuickConnectEnabled(url: String): Boolean =
runCatching { jellyfinApiClient.isQuickConnectEnabled(url) }
.onFailure { Log.e(TAG, "Quick Connect check failed", it) }
.onFailure { Timber.tag(TAG).e(it, "Quick Connect check failed") }
.getOrDefault(false)
override suspend fun initiateQuickConnect(url: String): QuickConnectSession? =
runCatching { jellyfinApiClient.initiateQuickConnect(url) }
.onFailure { Log.e(TAG, "Quick Connect initiation failed", it) }
.onFailure { Timber.tag(TAG).e(it, "Quick Connect initiation failed") }
.getOrNull()
override suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? =
runCatching { jellyfinApiClient.getQuickConnectState(url, secret) }
.onFailure { Log.e(TAG, "Quick Connect state check failed", it) }
.onFailure { Timber.tag(TAG).e(it, "Quick Connect state check failed") }
.getOrNull()
override suspend fun login(url: String, username: String, password: String): Boolean {
@@ -45,7 +45,7 @@ class JellyfinAuthenticationRepository @Inject constructor(
?: return false
saveAuthenticationResult(authResult)
} catch (e: Exception) {
Log.e(TAG, "Login failed", e)
Timber.tag(TAG).e(e, "Login failed")
false
}
}
@@ -56,7 +56,7 @@ class JellyfinAuthenticationRepository @Inject constructor(
?: return false
saveAuthenticationResult(authResult)
} catch (e: Exception) {
Log.e(TAG, "Quick Connect login failed", e)
Timber.tag(TAG).e(e, "Quick Connect login failed")
false
}
}

View File

@@ -0,0 +1,17 @@
package hu.bbara.purefin.data.logging
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import hu.bbara.purefin.core.settings.SettingsGroupProvider
@Module
@InstallIn(SingletonComponent::class)
abstract class LogUploadSettingsModule {
@Binds
@IntoSet
abstract fun bindLogUploadSettingsProvider(impl: LogUploadSettingsProvider): SettingsGroupProvider
}

View File

@@ -0,0 +1,58 @@
package hu.bbara.purefin.data.logging
import hu.bbara.purefin.core.logging.PurefinLogger
import hu.bbara.purefin.core.settings.SettingGroup
import hu.bbara.purefin.core.settings.SettingsGroupProvider
import hu.bbara.purefin.core.settings.VoidSetting
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LogUploadSettingsProvider @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
) : SettingsGroupProvider {
private val uploadMutex = Mutex()
override val settingGroups: Flow<List<SettingGroup>> = flowOf(
listOf(
SettingGroup(
title = "Logs",
options = listOf(
VoidSetting(
key = UPLOAD_LOGS_KEY,
title = "Upload logs",
onClick = { uploadLogs() }
)
)
)
)
)
private suspend fun uploadLogs() {
uploadMutex.withLock {
val logFiles = PurefinLogger.prepareFilesForUpload()
if (logFiles.isEmpty()) {
Timber.tag(TAG).d("No log files to upload")
return
}
logFiles.forEach { logFile ->
val uploadedName = jellyfinApiClient.uploadLogFile(logFile.data)
?: error("Log upload failed")
PurefinLogger.deleteUploadedFile(logFile)
Timber.tag(TAG).d("Uploaded log file ${logFile.name} as $uploadedName and deleted it locally")
}
}
}
private companion object {
const val TAG = "LogUploadSettings"
const val UPLOAD_LOGS_KEY = "upload_logs"
}
}

View File

@@ -24,6 +24,7 @@ media3FfmpegDecoder = "1.9.0+1"
nav3Core = "1.0.0"
room = "2.7.0"
slf4j = "2.0.17"
timber = "5.0.1"
firebaseCrashlytics = "20.0.5"
googleGmsGoogleServices = "4.4.4"
googleFirebaseCrashlytics = "3.0.7"
@@ -72,6 +73,7 @@ androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", vers
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }