mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Add Timber file logging and Jellyfin log upload
This commit is contained in:
@@ -53,6 +53,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(project(":data"))
|
||||||
implementation(project(":core"))
|
implementation(project(":core"))
|
||||||
implementation(project(":core-model"))
|
implementation(project(":core-model"))
|
||||||
implementation(project(":core-ui"))
|
implementation(project(":core-ui"))
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ package hu.bbara.purefin.tv
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import hu.bbara.purefin.core.logging.PurefinLogger
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class TvApplication : Application()
|
class TvApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
PurefinLogger.initialize(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package hu.bbara.purefin
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import hu.bbara.purefin.core.logging.PurefinLogger
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class PurefinApplication : Application() {
|
class PurefinApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
PurefinLogger.initialize(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -41,5 +41,6 @@ dependencies {
|
|||||||
implementation(libs.media3.datasource.okhttp)
|
implementation(libs.media3.datasource.okhttp)
|
||||||
implementation(libs.datastore)
|
implementation(libs.datastore)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.timber)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package hu.bbara.purefin.core.download
|
package hu.bbara.purefin.core.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
@@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -52,16 +52,16 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
val contentId = download.request.id
|
val contentId = download.request.id
|
||||||
val state = download.toDownloadState()
|
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) {
|
if (finalException != null) {
|
||||||
Log.e(TAG, "Download exception for $contentId", finalException)
|
Timber.tag(TAG).e(finalException, "Download exception for $contentId")
|
||||||
}
|
}
|
||||||
getOrCreateStateFlow(contentId).value = state
|
getOrCreateStateFlow(contentId).value = state
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||||
val contentId = download.request.id
|
val contentId = download.request.id
|
||||||
Log.d(TAG, "Download removed: $contentId")
|
Timber.tag(TAG).d("Download removed: $contentId")
|
||||||
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -91,7 +91,7 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
emit(result)
|
emit(result)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to poll active downloads", e)
|
Timber.tag(TAG).e(e, "Failed to poll active downloads")
|
||||||
emit(emptyMap())
|
emit(emptyMap())
|
||||||
}
|
}
|
||||||
delay(500)
|
delay(500)
|
||||||
@@ -120,13 +120,13 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val source = downloadMediaSourceResolver.resolveMovieDownload(movieId) ?: run {
|
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
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
offlineCatalogStore.saveMovies(listOf(source.movie))
|
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(
|
val request = buildDownloadRequest(
|
||||||
mediaId = movieId,
|
mediaId = movieId,
|
||||||
playbackUrl = source.playbackUrl,
|
playbackUrl = source.playbackUrl,
|
||||||
@@ -134,9 +134,9 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
customCacheKey = source.customCacheKey,
|
customCacheKey = source.customCacheKey,
|
||||||
)
|
)
|
||||||
PurefinDownloadService.sendAddDownload(context, request)
|
PurefinDownloadService.sendAddDownload(context, request)
|
||||||
Log.d(TAG, "Download request sent for $movieId")
|
Timber.tag(TAG).d("Download request sent for $movieId")
|
||||||
} catch (e: Exception) {
|
} 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
|
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
offlineCatalogStore.deleteMovie(movieId)
|
offlineCatalogStore.deleteMovie(movieId)
|
||||||
} catch (e: Exception) {
|
} 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) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val source = downloadMediaSourceResolver.resolveEpisodeDownload(episodeId) ?: run {
|
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
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
|
|
||||||
offlineCatalogStore.saveEpisode(source.episode)
|
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(
|
val request = buildDownloadRequest(
|
||||||
mediaId = episodeId,
|
mediaId = episodeId,
|
||||||
playbackUrl = source.playbackUrl,
|
playbackUrl = source.playbackUrl,
|
||||||
@@ -179,9 +179,9 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
customCacheKey = source.customCacheKey,
|
customCacheKey = source.customCacheKey,
|
||||||
)
|
)
|
||||||
PurefinDownloadService.sendAddDownload(context, request)
|
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) {
|
} 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
|
getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
|
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
|
||||||
} catch (e: Exception) {
|
} 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 {
|
try {
|
||||||
syncSmartDownloadsForSeries(seriesId)
|
syncSmartDownloadsForSeries(seriesId)
|
||||||
} catch (e: Exception) {
|
} 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>()
|
val unwatchedDownloaded = mutableListOf<UUID>()
|
||||||
for (episode in downloadedEpisodes) {
|
for (episode in downloadedEpisodes) {
|
||||||
if (downloadMediaSourceResolver.isEpisodeWatched(episode.id)) {
|
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)
|
cancelEpisodeDownload(episode.id)
|
||||||
} else {
|
} else {
|
||||||
unwatchedDownloaded.add(episode.id)
|
unwatchedDownloaded.add(episode.id)
|
||||||
@@ -264,7 +264,7 @@ class MediaDownloadManager @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (toDownload.isNotEmpty()) {
|
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)
|
downloadEpisodes(toDownload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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""")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.core.player.manager
|
package hu.bbara.purefin.core.player.manager
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import hu.bbara.purefin.core.player.model.SegmentStatus
|
import hu.bbara.purefin.core.player.model.SegmentStatus
|
||||||
import hu.bbara.purefin.model.MediaSegment
|
import hu.bbara.purefin.model.MediaSegment
|
||||||
@@ -11,6 +10,7 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
class MediaSegmentManager(private val player: ExoPlayer) {
|
class MediaSegmentManager(private val player: ExoPlayer) {
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class MediaSegmentManager(private val player: ExoPlayer) {
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun registerListener(listener: MediaSegmentListener) {
|
fun registerListener(listener: MediaSegmentListener) {
|
||||||
this.listener?.let {
|
this.listener?.let {
|
||||||
Log.w("MediaSegmentManager", "Listener was already register")
|
Timber.tag(TAG).w("Listener was already register")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.listener = listener
|
this.listener = listener
|
||||||
@@ -74,10 +74,14 @@ class MediaSegmentManager(private val player: ExoPlayer) {
|
|||||||
private fun notifyListener(mediaSegment: MediaSegment, status: SegmentStatus) {
|
private fun notifyListener(mediaSegment: MediaSegment, status: SegmentStatus) {
|
||||||
val listener = listener
|
val listener = listener
|
||||||
if (listener == null) {
|
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
|
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)
|
listener.onEvent(mediaSegment, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "MediaSegmentManager"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.core.player.manager
|
package hu.bbara.purefin.core.player.manager
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import hu.bbara.purefin.core.data.LocalMediaUpdater
|
import hu.bbara.purefin.core.data.LocalMediaUpdater
|
||||||
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
||||||
@@ -18,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class ProgressManager @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
|
localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
|
||||||
} catch (e: Exception) {
|
} 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)
|
isStop -> playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||||
else -> playbackProgressReporter.reportPlaybackProgress(itemId, ticks, isPaused, 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) {
|
} 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)
|
playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||||
}
|
}
|
||||||
localMediaUpdater.updateWatchProgress(itemId, posMs, durMs)
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e("ProgressManager", "Report failed", e)
|
Timber.tag(TAG).e(e, "Report failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ProgressManager"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.core.player.viewmodel
|
package hu.bbara.purefin.core.player.viewmodel
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -23,6 +22,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import kotlinx.serialization.InternalSerializationApi
|
import kotlinx.serialization.InternalSerializationApi
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -36,6 +36,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
|
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
|
||||||
|
private const val TAG = "PlayerViewModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
val player get() = playerManager.player
|
val player get() = playerManager.player
|
||||||
@@ -282,7 +283,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
is PlayableMedia.Movie -> {
|
is PlayableMedia.Movie -> {
|
||||||
val movie = mediaCatalogReader.getMovie(id).first()
|
val movie = mediaCatalogReader.getMovie(id).first()
|
||||||
if (movie == null) {
|
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
|
null
|
||||||
} else {
|
} else {
|
||||||
PlaylistElementUiModel(
|
PlaylistElementUiModel(
|
||||||
@@ -300,7 +301,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
is PlayableMedia.Series -> {
|
is PlayableMedia.Series -> {
|
||||||
val series = mediaCatalogReader.getSeries(id).first()
|
val series = mediaCatalogReader.getSeries(id).first()
|
||||||
if (series == null) {
|
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
|
null
|
||||||
} else {
|
} else {
|
||||||
PlaylistElementUiModel(
|
PlaylistElementUiModel(
|
||||||
@@ -318,7 +319,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
is PlayableMedia.Episode -> {
|
is PlayableMedia.Episode -> {
|
||||||
val episode = mediaCatalogReader.getEpisode(id).first()
|
val episode = mediaCatalogReader.getEpisode(id).first()
|
||||||
if (episode == null) {
|
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
|
null
|
||||||
} else {
|
} else {
|
||||||
PlaylistElementUiModel(
|
PlaylistElementUiModel(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageInstaller
|
import android.content.pm.PackageInstaller
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import timber.log.Timber
|
||||||
|
|
||||||
class AppUpdateInstallReceiver : BroadcastReceiver() {
|
class AppUpdateInstallReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@@ -18,13 +18,13 @@ class AppUpdateInstallReceiver : BroadcastReceiver() {
|
|||||||
try {
|
try {
|
||||||
context.startActivity(confirmIntent)
|
context.startActivity(confirmIntent)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
Log.e(TAG, "Install confirmation activity missing", e)
|
Timber.tag(TAG).e(e, "Install confirmation activity missing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PackageInstaller.STATUS_SUCCESS -> Unit
|
PackageInstaller.STATUS_SUCCESS -> Unit
|
||||||
else -> {
|
else -> {
|
||||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
Log.e(TAG, "Install failed: $status $message")
|
Timber.tag(TAG).e("Install failed: $status $message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dependencies {
|
|||||||
implementation(libs.jellyfin.core)
|
implementation(libs.jellyfin.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.slf4j.api)
|
implementation(libs.slf4j.api)
|
||||||
|
implementation(libs.timber)
|
||||||
implementation(libs.hilt)
|
implementation(libs.hilt)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.datastore)
|
implementation(libs.datastore)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.data.catalog
|
package hu.bbara.purefin.data.catalog
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import hu.bbara.purefin.core.data.HomeRepository
|
import hu.bbara.purefin.core.data.HomeRepository
|
||||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||||
@@ -41,6 +40,7 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.concurrent.atomics.AtomicBoolean
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class InMemoryAppContentRepository @Inject constructor(
|
class InMemoryAppContentRepository @Inject constructor(
|
||||||
@@ -82,7 +82,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
|
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Initializing home repository")
|
Timber.tag(TAG).d("Initializing home repository")
|
||||||
scope.launch { loadHomeCache() }
|
scope.launch { loadHomeCache() }
|
||||||
scope.launch { refreshHomeData() }
|
scope.launch { refreshHomeData() }
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
val job = synchronized(this) {
|
val job = synchronized(this) {
|
||||||
refreshJob?.takeIf { it.isActive } ?: scope.launch {
|
refreshJob?.takeIf { it.isActive } ?: scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
Log.d(TAG, "Refreshing home data")
|
Timber.tag(TAG).d("Refreshing home data")
|
||||||
if (!networkMonitor.isOnline.first()) {
|
if (!networkMonitor.isOnline.first()) {
|
||||||
return@runCatching
|
return@runCatching
|
||||||
}
|
}
|
||||||
@@ -100,10 +100,10 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
loadContinueWatching()
|
loadContinueWatching()
|
||||||
loadNextUp()
|
loadNextUp()
|
||||||
loadLatestLibraryContent()
|
loadLatestLibraryContent()
|
||||||
Log.d(TAG, "Home refresh successful")
|
Timber.tag(TAG).d("Home refresh successful")
|
||||||
persistHomeCache()
|
persistHomeCache()
|
||||||
}.onFailure { error ->
|
}.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 }
|
}.also { refreshJob = it }
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadHomeCache() {
|
private suspend fun loadHomeCache() {
|
||||||
Log.d(TAG, "Loading home cache")
|
Timber.tag(TAG).d("Loading home cache")
|
||||||
val cache = homeCacheDataStore.data.first()
|
val cache = homeCacheDataStore.data.first()
|
||||||
if (cache.libraries.isNotEmpty()) {
|
if (cache.libraries.isNotEmpty()) {
|
||||||
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
||||||
@@ -147,7 +147,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
if (cache.episodes.isNotEmpty()) {
|
if (cache.episodes.isNotEmpty()) {
|
||||||
onlineMediaRepository.upsertEpisodes(cache.episodes.mapNotNull { it.toEpisode() })
|
onlineMediaRepository.upsertEpisodes(cache.episodes.mapNotNull { it.toEpisode() })
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Home cache loaded")
|
Timber.tag(TAG).d("Home cache loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun persistHomeCache() {
|
private suspend fun persistHomeCache() {
|
||||||
@@ -200,7 +200,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadLibraries() {
|
private suspend fun loadLibraries() {
|
||||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load libraries", error)
|
Timber.tag(TAG).w(error, "Unable to load libraries")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val filteredLibraries = librariesItem.filter {
|
val filteredLibraries = librariesItem.filter {
|
||||||
@@ -222,7 +222,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadLibrary(library: Library): Library {
|
private suspend fun loadLibrary(library: Library): Library {
|
||||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||||
.getOrElse { error ->
|
.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 library
|
||||||
}
|
}
|
||||||
return when (library.type) {
|
return when (library.type) {
|
||||||
@@ -240,7 +240,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadSuggestions() {
|
private suspend fun loadSuggestions() {
|
||||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load suggestions", error)
|
Timber.tag(TAG).w(error, "Unable to load suggestions")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||||
@@ -261,7 +261,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadContinueWatching() {
|
private suspend fun loadContinueWatching() {
|
||||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load continue watching", error)
|
Timber.tag(TAG).w(error, "Unable to load continue watching")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||||
@@ -282,7 +282,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadNextUp() {
|
private suspend fun loadNextUp() {
|
||||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load next up", error)
|
Timber.tag(TAG).w(error, "Unable to load next up")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
nextUpState.value = nextUpItems.map { item ->
|
nextUpState.value = nextUpItems.map { item ->
|
||||||
@@ -297,7 +297,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadLatestLibraryContent() {
|
private suspend fun loadLatestLibraryContent() {
|
||||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load latest library content", error)
|
Timber.tag(TAG).w(error, "Unable to load latest library content")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val filteredLibraries = librariesItem.filter {
|
val filteredLibraries = librariesItem.filter {
|
||||||
@@ -306,7 +306,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||||
.getOrElse { error ->
|
.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()
|
emptyList()
|
||||||
}
|
}
|
||||||
library.id to when (library.collectionType) {
|
library.id to when (library.collectionType) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.data.catalog
|
package hu.bbara.purefin.data.catalog
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.data.converter.toEpisode
|
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.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -52,7 +52,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
if (!moviesState.value.containsKey(id)) {
|
if (!moviesState.value.containsKey(id)) {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.MOVIE) {
|
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)
|
return flowOf(null)
|
||||||
}
|
}
|
||||||
val movie = item.toMovie(serverUrl.first())
|
val movie = item.toMovie(serverUrl.first())
|
||||||
@@ -66,7 +66,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
if (!seriesState.value.containsKey(id)) {
|
if (!seriesState.value.containsKey(id)) {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.SERIES) {
|
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)
|
return flowOf(null)
|
||||||
}
|
}
|
||||||
val series = item.toSeries(serverUrl.first())
|
val series = item.toSeries(serverUrl.first())
|
||||||
@@ -80,7 +80,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
if (!episodesState.value.containsKey(id)) {
|
if (!episodesState.value.containsKey(id)) {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.EPISODE) {
|
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)
|
return flowOf(null)
|
||||||
}
|
}
|
||||||
val episode = item.toEpisode(serverUrl.first())
|
val episode = item.toEpisode(serverUrl.first())
|
||||||
@@ -110,7 +110,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
} catch (error: CancellationException) {
|
} catch (error: CancellationException) {
|
||||||
throw error
|
throw error
|
||||||
} catch (error: Exception) {
|
} 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
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
} catch (error: CancellationException) {
|
} catch (error: CancellationException) {
|
||||||
throw error
|
throw error
|
||||||
} catch (error: Exception) {
|
} 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin
|
package hu.bbara.purefin.data.jellyfin
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.MediaItem
|
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.PREVIEW
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP
|
import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -72,7 +72,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
if (error is CancellationException) throw 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)
|
getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
getPlayableMedia(id)
|
getPlayableMedia(id)
|
||||||
}
|
}
|
||||||
}.getOrElse { error ->
|
}.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()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,4 +325,8 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
endMs = endTicks / 10_000L
|
endMs = endTicks / 10_000L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "PlayableMediaRepo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package hu.bbara.purefin.data.jellyfin.client
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import hu.bbara.purefin.core.data.PlaybackMethod
|
import hu.bbara.purefin.core.data.PlaybackMethod
|
||||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
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.Response
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
|
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
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.genresApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
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.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -194,7 +195,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
searchTerm = searchTerm,
|
searchTerm = searchTerm,
|
||||||
recursive = true
|
recursive = true
|
||||||
)
|
)
|
||||||
Log.d("searchBySearchTerm", response.content.toString())
|
Timber.tag("searchBySearchTerm").d(response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
genres = genres,
|
genres = genres,
|
||||||
recursive = true
|
recursive = true
|
||||||
)
|
)
|
||||||
Log.d("searchByGenre", response.content.toString())
|
Timber.tag("searchByGenre").d(response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +226,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||||
includeHidden = false,
|
includeHidden = false,
|
||||||
)
|
)
|
||||||
Log.d("getLibraries", response.content.toString())
|
Timber.tag("getLibraries").d(response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
recursive = true,
|
recursive = true,
|
||||||
)
|
)
|
||||||
val response = api.itemsApi.getItems(getItemsRequest)
|
val response = api.itemsApi.getItems(getItemsRequest)
|
||||||
Log.d("getLibraryContent", response.content.toString())
|
Timber.tag("getLibraryContent").d(response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +264,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
limit = 8,
|
limit = 8,
|
||||||
enableTotalRecordCount = true,
|
enableTotalRecordCount = true,
|
||||||
)
|
)
|
||||||
Log.d("getSuggestions", response.content.toString())
|
Timber.tag("getSuggestions").d(response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +283,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
)
|
)
|
||||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||||
Log.d("getContinueWatching", response.content.toString())
|
Timber.tag("getContinueWatching").d(response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +299,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
enableResumable = false,
|
enableResumable = false,
|
||||||
)
|
)
|
||||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||||
Log.d("getNextUpEpisodes", result.content.toString())
|
Timber.tag("getNextUpEpisodes").d(result.content.toString())
|
||||||
result.content.items
|
result.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +316,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||||
limit = 10,
|
limit = 10,
|
||||||
)
|
)
|
||||||
Log.d("getLatestFromLibrary", response.content.toString())
|
Timber.tag("getLatestFromLibrary").d(response.content.toString())
|
||||||
response.content
|
response.content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +327,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
return@logRequest null
|
return@logRequest null
|
||||||
}
|
}
|
||||||
val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId())
|
val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId())
|
||||||
Log.d("getItemInfo", result.content.toString())
|
Timber.tag("getItemInfo").d(result.content.toString())
|
||||||
result.content
|
result.content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,7 +343,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
fields = defaultItemFields,
|
fields = defaultItemFields,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
)
|
)
|
||||||
Log.d("getSeasons", result.content.toString())
|
Timber.tag("getSeasons").d(result.content.toString())
|
||||||
result.content.items
|
result.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,7 +360,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
)
|
)
|
||||||
Log.d("getEpisodesInSeason", result.content.toString())
|
Timber.tag("getEpisodesInSeason").d(result.content.toString())
|
||||||
result.content.items
|
result.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,7 +380,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
limit = count,
|
limit = count,
|
||||||
)
|
)
|
||||||
val nextUpEpisodes = nextUpEpisodesResult.content.items
|
val nextUpEpisodes = nextUpEpisodesResult.content.items
|
||||||
Log.d("getNextEpisodes", nextUpEpisodes.toString())
|
Timber.tag("getNextEpisodes").d(nextUpEpisodes.toString())
|
||||||
nextUpEpisodes
|
nextUpEpisodes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,7 +394,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
parentId = id,
|
parentId = id,
|
||||||
)
|
)
|
||||||
Log.d("getGenres", result.toString())
|
Timber.tag("getGenres").d(result.toString())
|
||||||
result.content.items
|
result.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,7 +436,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
maxStreamingBitrate = 100_000_000,
|
maxStreamingBitrate = 100_000_000,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Log.d("getMediaSources", result.toString())
|
Timber.tag("getMediaSources").d(result.toString())
|
||||||
result.content.mediaSources
|
result.content.mediaSources
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +450,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
itemId = mediaId,
|
itemId = mediaId,
|
||||||
//includeSegmentTypes = listOf(MediaSegmentType.INTRO)
|
//includeSegmentTypes = listOf(MediaSegmentType.INTRO)
|
||||||
)
|
)
|
||||||
Log.d("getMediaSegments", result.toString())
|
Timber.tag("getMediaSegments").d(result.toString())
|
||||||
result.content.items
|
result.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,10 +497,19 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
liveStreamId = liveStreamId,
|
liveStreamId = liveStreamId,
|
||||||
)
|
)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.e(TAG, "getVideoStreamUrl($itemId) failed", error)
|
Timber.tag(TAG).e(error, "getVideoStreamUrl($itemId) failed")
|
||||||
throw error
|
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) {
|
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
|
||||||
logRequest("getPublicSystemInfoVersion") {
|
logRequest("getPublicSystemInfoVersion") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
@@ -589,8 +599,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
return try {
|
return try {
|
||||||
val result = block()
|
val result = block()
|
||||||
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
|
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
|
||||||
Log.d(
|
Timber.tag(TAG).d(
|
||||||
TAG,
|
|
||||||
"$operation finished in ${elapsedMs}ms, " +
|
"$operation finished in ${elapsedMs}ms, " +
|
||||||
"fetched ${result.approximateSizeBytes()} bytes${result.itemCountLogText()}"
|
"fetched ${result.approximateSizeBytes()} bytes${result.itemCountLogText()}"
|
||||||
)
|
)
|
||||||
@@ -599,7 +608,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
throw error
|
throw error
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
|
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
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.playback
|
package hu.bbara.purefin.data.jellyfin.playback
|
||||||
|
|
||||||
import android.media.MediaCodecList
|
import android.media.MediaCodecList
|
||||||
import android.util.Log
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to debug available audio/video codecs on the device.
|
* Helper to debug available audio/video codecs on the device.
|
||||||
@@ -17,34 +17,34 @@ object CodecDebugHelper {
|
|||||||
fun logAvailableDecoders() {
|
fun logAvailableDecoders() {
|
||||||
try {
|
try {
|
||||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||||
Log.d(TAG, "=== Available Audio Decoders ===")
|
Timber.tag(TAG).d("=== Available Audio Decoders ===")
|
||||||
|
|
||||||
codecList.codecInfos
|
codecList.codecInfos
|
||||||
.filter { !it.isEncoder }
|
.filter { !it.isEncoder }
|
||||||
.forEach { codecInfo ->
|
.forEach { codecInfo ->
|
||||||
codecInfo.supportedTypes.forEach { mimeType ->
|
codecInfo.supportedTypes.forEach { mimeType ->
|
||||||
if (mimeType.startsWith("audio/")) {
|
if (mimeType.startsWith("audio/")) {
|
||||||
Log.d(TAG, "${codecInfo.name}: $mimeType")
|
Timber.tag(TAG).d("${codecInfo.name}: $mimeType")
|
||||||
if (mimeType.contains("dts", ignoreCase = true) ||
|
if (mimeType.contains("dts", ignoreCase = true) ||
|
||||||
mimeType.contains("truehd", 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
|
codecList.codecInfos
|
||||||
.filter { !it.isEncoder }
|
.filter { !it.isEncoder }
|
||||||
.forEach { codecInfo ->
|
.forEach { codecInfo ->
|
||||||
codecInfo.supportedTypes.forEach { mimeType ->
|
codecInfo.supportedTypes.forEach { mimeType ->
|
||||||
if (mimeType.startsWith("video/")) {
|
if (mimeType.startsWith("video/")) {
|
||||||
Log.d(TAG, "${codecInfo.name}: $mimeType")
|
Timber.tag(TAG).d("${codecInfo.name}: $mimeType")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to list codecs", e)
|
Timber.tag(TAG).e(e, "Failed to list codecs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.playback
|
package hu.bbara.purefin.data.jellyfin.playback
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -11,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.ServerVersion
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class JellyfinPlaybackResolver @Inject constructor(
|
class JellyfinPlaybackResolver @Inject constructor(
|
||||||
@@ -33,7 +33,7 @@ class JellyfinPlaybackResolver @Inject constructor(
|
|||||||
) ?: return@withContext null
|
) ?: return@withContext null
|
||||||
|
|
||||||
if (playbackInfo.errorCode != 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
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +54,9 @@ class JellyfinPlaybackResolver @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (decision == null) {
|
if (decision == null) {
|
||||||
Log.w(TAG, "No compatible playback path for $mediaId")
|
Timber.tag(TAG).w("No compatible playback path for $mediaId")
|
||||||
} else {
|
} 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
|
decision
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ class JellyfinPlaybackResolver @Inject constructor(
|
|||||||
val resolvedVersion = runCatching {
|
val resolvedVersion = runCatching {
|
||||||
jellyfinApiClient.getPublicSystemInfoVersion()?.let(ServerVersion::fromString)
|
jellyfinApiClient.getPublicSystemInfoVersion()?.let(ServerVersion::fromString)
|
||||||
}.onFailure { error ->
|
}.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
|
}.getOrNull() ?: PlaybackProfileDefaults.fallbackServerVersion
|
||||||
|
|
||||||
serverVersionCache[serverUrl] = resolvedVersion
|
serverVersionCache[serverUrl] = resolvedVersion
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.session
|
package hu.bbara.purefin.data.jellyfin.session
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import hu.bbara.purefin.core.data.AuthenticationRepository
|
import hu.bbara.purefin.core.data.AuthenticationRepository
|
||||||
import hu.bbara.purefin.core.data.JellyfinServerCandidate
|
import hu.bbara.purefin.core.data.JellyfinServerCandidate
|
||||||
import hu.bbara.purefin.core.data.QuickConnectSession
|
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 hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -21,22 +21,22 @@ class JellyfinAuthenticationRepository @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun findServer(input: String): JellyfinServerCandidate? =
|
override suspend fun findServer(input: String): JellyfinServerCandidate? =
|
||||||
runCatching { jellyfinApiClient.findServer(input) }
|
runCatching { jellyfinApiClient.findServer(input) }
|
||||||
.onFailure { Log.e(TAG, "Server search failed", it) }
|
.onFailure { Timber.tag(TAG).e(it, "Server search failed") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|
||||||
override suspend fun isQuickConnectEnabled(url: String): Boolean =
|
override suspend fun isQuickConnectEnabled(url: String): Boolean =
|
||||||
runCatching { jellyfinApiClient.isQuickConnectEnabled(url) }
|
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)
|
.getOrDefault(false)
|
||||||
|
|
||||||
override suspend fun initiateQuickConnect(url: String): QuickConnectSession? =
|
override suspend fun initiateQuickConnect(url: String): QuickConnectSession? =
|
||||||
runCatching { jellyfinApiClient.initiateQuickConnect(url) }
|
runCatching { jellyfinApiClient.initiateQuickConnect(url) }
|
||||||
.onFailure { Log.e(TAG, "Quick Connect initiation failed", it) }
|
.onFailure { Timber.tag(TAG).e(it, "Quick Connect initiation failed") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|
||||||
override suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? =
|
override suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? =
|
||||||
runCatching { jellyfinApiClient.getQuickConnectState(url, secret) }
|
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()
|
.getOrNull()
|
||||||
|
|
||||||
override suspend fun login(url: String, username: String, password: String): Boolean {
|
override suspend fun login(url: String, username: String, password: String): Boolean {
|
||||||
@@ -45,7 +45,7 @@ class JellyfinAuthenticationRepository @Inject constructor(
|
|||||||
?: return false
|
?: return false
|
||||||
saveAuthenticationResult(authResult)
|
saveAuthenticationResult(authResult)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Login failed", e)
|
Timber.tag(TAG).e(e, "Login failed")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ class JellyfinAuthenticationRepository @Inject constructor(
|
|||||||
?: return false
|
?: return false
|
||||||
saveAuthenticationResult(authResult)
|
saveAuthenticationResult(authResult)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Quick Connect login failed", e)
|
Timber.tag(TAG).e(e, "Quick Connect login failed")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ media3FfmpegDecoder = "1.9.0+1"
|
|||||||
nav3Core = "1.0.0"
|
nav3Core = "1.0.0"
|
||||||
room = "2.7.0"
|
room = "2.7.0"
|
||||||
slf4j = "2.0.17"
|
slf4j = "2.0.17"
|
||||||
|
timber = "5.0.1"
|
||||||
firebaseCrashlytics = "20.0.5"
|
firebaseCrashlytics = "20.0.5"
|
||||||
googleGmsGoogleServices = "4.4.4"
|
googleGmsGoogleServices = "4.4.4"
|
||||||
googleFirebaseCrashlytics = "3.0.7"
|
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-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
androidx-room-compiler = { module = "androidx.room:room-compiler", 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" }
|
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" }
|
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user