feat: add item notes and duplicate detection across platforms
- Add per-item notes editable in the detail and edit screens, synced between desktop and Android - Guard against duplicates by external ID and by type/year/title, mirroring the desktop findDuplicate logic in MediaDao - Wrap multi-step database operations in Room transactions - Disable Android auto backup and remove the destructive Room migration fallback so schema bumps fail loudly instead of wiping data - Bound crypto envelope KDF parameters when reading untrusted headers - Handle malformed server responses in SyncClient instead of crashing - Extend settings, database, and image cache on the desktop side
This commit is contained in:
parent
85f5c6dd4e
commit
2b35139364
30 changed files with 837 additions and 124 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".UmtApp"
|
android:name=".UmtApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ object Graph {
|
||||||
fun provide(context: Context) {
|
fun provide(context: Context) {
|
||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
val db = UmtDatabase.get(app)
|
val db = UmtDatabase.get(app)
|
||||||
repository = LibraryRepository(app, db.mediaDao())
|
repository = LibraryRepository(app, db)
|
||||||
syncSettings = SyncSettings(app)
|
syncSettings = SyncSettings(app)
|
||||||
syncClient = SyncClient(repository, syncSettings)
|
syncClient = SyncClient(repository, syncSettings)
|
||||||
appPrefs = AppPrefs(app)
|
appPrefs = AppPrefs(app)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package com.umt.tracker.data
|
package com.umt.tracker.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.room.withTransaction
|
||||||
import com.umt.tracker.data.json.LibraryExport
|
import com.umt.tracker.data.json.LibraryExport
|
||||||
import com.umt.tracker.data.json.LibraryJson
|
import com.umt.tracker.data.json.LibraryJson
|
||||||
import com.umt.tracker.data.json.toDomain
|
import com.umt.tracker.data.json.toDomain
|
||||||
import com.umt.tracker.data.json.toDto
|
import com.umt.tracker.data.json.toDto
|
||||||
import com.umt.tracker.data.local.MediaDao
|
import com.umt.tracker.data.local.MediaDao
|
||||||
|
import com.umt.tracker.data.local.UmtDatabase
|
||||||
import com.umt.tracker.data.local.toDomain
|
import com.umt.tracker.data.local.toDomain
|
||||||
import com.umt.tracker.data.local.toEntity
|
import com.umt.tracker.data.local.toEntity
|
||||||
import com.umt.tracker.domain.MediaItem
|
import com.umt.tracker.domain.MediaItem
|
||||||
|
|
@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
|
/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
|
||||||
|
|
@ -40,8 +43,10 @@ data class LibraryStats(
|
||||||
*/
|
*/
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val dao: MediaDao,
|
db: UmtDatabase,
|
||||||
) {
|
) {
|
||||||
|
private val dao: MediaDao = db.mediaDao()
|
||||||
|
private val database: UmtDatabase = db
|
||||||
fun observeLibrary(): Flow<List<MediaItem>> =
|
fun observeLibrary(): Flow<List<MediaItem>> =
|
||||||
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||||
|
|
||||||
|
|
@ -81,6 +86,8 @@ class LibraryRepository(
|
||||||
/** Replaces the item's cover with a remote image URL (from a metadata provider). */
|
/** Replaces the item's cover with a remote image URL (from a metadata provider). */
|
||||||
suspend fun setCover(id: Long, coverUrl: String) = updateItem(id) { it.copy(coverUrl = coverUrl) }
|
suspend fun setCover(id: Long, coverUrl: String) = updateItem(id) { it.copy(coverUrl = coverUrl) }
|
||||||
|
|
||||||
|
suspend fun setNotes(id: Long, notes: String) = updateItem(id) { it.copy(notes = notes) }
|
||||||
|
|
||||||
suspend fun setStatus(id: Long, status: WatchStatus) = updateItem(id) { it.copy(status = status) }
|
suspend fun setStatus(id: Long, status: WatchStatus) = updateItem(id) { it.copy(status = status) }
|
||||||
|
|
||||||
/** Marks one unit (episode/chapter) watched or not, by its (segment, unit) number. */
|
/** Marks one unit (episode/chapter) watched or not, by its (segment, unit) number. */
|
||||||
|
|
@ -108,10 +115,30 @@ class LibraryRepository(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend inline fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
|
private suspend fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
|
||||||
val current = getItem(id) ?: return
|
// One transaction around read + write so concurrent toggles can't
|
||||||
|
// silently clobber each other's updates.
|
||||||
|
database.withTransaction {
|
||||||
|
val current = dao.getById(id)?.toDomain() ?: return@withTransaction
|
||||||
save(transform(current))
|
save(transform(current))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies an existing item the given one would duplicate, mirroring the
|
||||||
|
* desktop guard: match by (externalSource, externalId) when both are set,
|
||||||
|
* otherwise by (type, title, year). Returns the duplicate's title, or null.
|
||||||
|
* The item's own id is excluded so an edit never matches itself.
|
||||||
|
*/
|
||||||
|
suspend fun findDuplicate(item: MediaItem): String? = withContext(Dispatchers.IO) {
|
||||||
|
val dupId = when {
|
||||||
|
item.externalId.isNotBlank() && item.externalSource.isNotBlank() ->
|
||||||
|
dao.findDuplicateByExternalId(item.externalSource, item.externalId, item.id)
|
||||||
|
else ->
|
||||||
|
dao.findDuplicateByTitle(item.type.storage, item.year, item.title.trim(), item.id)
|
||||||
|
}
|
||||||
|
dupId?.let { dao.getTitle(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Distinct sorted genres across the whole library, for filter chips. */
|
/** Distinct sorted genres across the whole library, for filter chips. */
|
||||||
suspend fun allGenres(): List<String> = withContext(Dispatchers.IO) {
|
suspend fun allGenres(): List<String> = withContext(Dispatchers.IO) {
|
||||||
|
|
@ -131,8 +158,11 @@ class LibraryRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) {
|
suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) {
|
||||||
val items = dao.getAll().map { it.toDomain() }
|
statsOf(dao.getAll().map { it.toDomain() })
|
||||||
LibraryStats(
|
}
|
||||||
|
|
||||||
|
/** Pure computation so UI flows can re-derive stats from observed items. */
|
||||||
|
fun statsOf(items: List<MediaItem>): LibraryStats = LibraryStats(
|
||||||
total = items.size,
|
total = items.size,
|
||||||
completed = items.count { it.status == WatchStatus.COMPLETED },
|
completed = items.count { it.status == WatchStatus.COMPLETED },
|
||||||
inProgress = items.count { it.status == WatchStatus.IN_PROGRESS },
|
inProgress = items.count { it.status == WatchStatus.IN_PROGRESS },
|
||||||
|
|
@ -140,7 +170,6 @@ class LibraryRepository(
|
||||||
favorites = items.count { it.favorite },
|
favorites = items.count { it.favorite },
|
||||||
perType = items.groupingBy { it.type }.eachCount(),
|
perType = items.groupingBy { it.type }.eachCount(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the whole library with the contents of [stream]. Import is
|
* Replaces the whole library with the contents of [stream]. Import is
|
||||||
|
|
@ -153,20 +182,28 @@ class LibraryRepository(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the whole library with [text] (a JSON [LibraryExport]). Used by
|
* Replaces the whole library with [text] (a JSON [LibraryExport]). Used by
|
||||||
* both the file importer and the sync pull. All-or-nothing.
|
* both the file importer and the sync pull. All-or-nothing: the wipe and
|
||||||
|
* the inserts run in one Room transaction.
|
||||||
*/
|
*/
|
||||||
suspend fun replaceFromJson(text: String): ImportResult = withContext(Dispatchers.IO) {
|
suspend fun replaceFromJson(text: String): ImportResult = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val export = LibraryJson.instance.decodeFromString(LibraryExport.serializer(), text)
|
val export = LibraryJson.instance.decodeFromString(LibraryExport.serializer(), text)
|
||||||
val entities = export.items.map { it.toDomain().toEntity() }
|
val entities = export.items.map { it.toDomain().toEntity() }
|
||||||
|
database.withTransaction {
|
||||||
dao.clear()
|
dao.clear()
|
||||||
dao.upsertAll(entities)
|
dao.upsertAll(entities)
|
||||||
|
}
|
||||||
ImportResult.Success(entities.size)
|
ImportResult.Success(entities.size)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ImportResult.Failure(e.message ?: "Unbekannter Fehler beim Import")
|
ImportResult.Failure(e.message ?: "Unbekannter Fehler beim Import")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes the canonical JSON snapshot to [stream] (file export / backup). */
|
||||||
|
suspend fun exportTo(stream: OutputStream) = withContext(Dispatchers.IO) {
|
||||||
|
stream.bufferedWriter().use { it.write(snapshotJson()) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializes the entire library to the canonical JSON snapshot for sync push. */
|
/** Serializes the entire library to the canonical JSON snapshot for sync push. */
|
||||||
suspend fun snapshotJson(): String = withContext(Dispatchers.IO) {
|
suspend fun snapshotJson(): String = withContext(Dispatchers.IO) {
|
||||||
val items = dao.getAll().map { it.toDomain().toDto() }
|
val items = dao.getAll().map { it.toDomain().toDto() }
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,21 @@ interface MediaDao {
|
||||||
|
|
||||||
@Query("DELETE FROM media")
|
@Query("DELETE FROM media")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
|
// --- duplicate guard (mirrors the desktop findDuplicate) ---
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM media WHERE externalSource = :source AND externalId = :extId " +
|
||||||
|
"AND id <> :selfId LIMIT 1"
|
||||||
|
)
|
||||||
|
suspend fun findDuplicateByExternalId(source: String, extId: String, selfId: Long): Long?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT id FROM media WHERE type = :type AND year = :year " +
|
||||||
|
"AND LOWER(TRIM(title)) = LOWER(TRIM(:title)) AND id <> :selfId LIMIT 1"
|
||||||
|
)
|
||||||
|
suspend fun findDuplicateByTitle(type: String, year: Int, title: String, selfId: Long): Long?
|
||||||
|
|
||||||
|
@Query("SELECT title FROM media WHERE id = :id")
|
||||||
|
suspend fun getTitle(id: Long): String?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ abstract class UmtDatabase : RoomDatabase() {
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
UmtDatabase::class.java,
|
UmtDatabase::class.java,
|
||||||
"umt-library.db"
|
"umt-library.db"
|
||||||
).fallbackToDestructiveMigration().build().also { instance = it }
|
).build().also { instance = it }
|
||||||
|
// No fallbackToDestructiveMigration(): a schema bump without a
|
||||||
|
// Migration must fail loudly instead of silently wiping the
|
||||||
|
// user's library.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,13 @@ object CryptoEnvelope {
|
||||||
private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE
|
private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE
|
||||||
private const val MEMLIMIT = 67108864L // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
|
private const val MEMLIMIT = 67108864L // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
|
||||||
|
|
||||||
|
// Upper bounds accepted when reading an envelope. The header is untrusted
|
||||||
|
// input (a malicious server can craft it); unbounded values would let a
|
||||||
|
// crafted snapshot force huge Argon2 allocations (OOM/crash). Legit
|
||||||
|
// envelopes carry OPSLIMIT/MEMLIMIT. Must stay in sync with the desktop.
|
||||||
|
private const val MAX_OPS = 8L
|
||||||
|
private const val MAX_MEM = 268435456L // 256 MiB
|
||||||
|
|
||||||
private const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES
|
private const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES
|
||||||
|
|
||||||
private val random = SecureRandom()
|
private val random = SecureRandom()
|
||||||
|
|
@ -86,6 +93,8 @@ object CryptoEnvelope {
|
||||||
if (version != VERSION) throw CryptoException("Nicht unterstützte Version: $version")
|
if (version != VERSION) throw CryptoException("Nicht unterstützte Version: $version")
|
||||||
val ops = buf.int.toLong() and 0xFFFFFFFFL
|
val ops = buf.int.toLong() and 0xFFFFFFFFL
|
||||||
val mem = buf.int.toLong() and 0xFFFFFFFFL
|
val mem = buf.int.toLong() and 0xFFFFFFFFL
|
||||||
|
if (ops == 0L || mem == 0L || ops > MAX_OPS || mem > MAX_MEM)
|
||||||
|
throw CryptoException("Ungültige Krypto-Parameter im Datenformat")
|
||||||
val salt = ByteArray(SALT_BYTES).also { buf.get(it) }
|
val salt = ByteArray(SALT_BYTES).also { buf.get(it) }
|
||||||
val nonce = ByteArray(NONCE_BYTES).also { buf.get(it) }
|
val nonce = ByteArray(NONCE_BYTES).also { buf.get(it) }
|
||||||
val cipher = ByteArray(buf.remaining()).also { buf.get(it) }
|
val cipher = ByteArray(buf.remaining()).also { buf.get(it) }
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ class SyncClient(
|
||||||
SyncResult.Error(e.message ?: "Entschlüsselung fehlgeschlagen.")
|
SyncResult.Error(e.message ?: "Entschlüsselung fehlgeschlagen.")
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
SyncResult.Error("Netzwerkfehler: ${e.message}")
|
SyncResult.Error("Netzwerkfehler: ${e.message}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Malformed server responses (bad JSON, bad base64, ...) must never
|
||||||
|
// crash the app — surface them like any other sync error instead.
|
||||||
|
SyncResult.Error("Antwort des Servers war ungültig: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +115,8 @@ class SyncClient(
|
||||||
SyncResult.Error(e.message ?: "Verschlüsselung fehlgeschlagen.")
|
SyncResult.Error(e.message ?: "Verschlüsselung fehlgeschlagen.")
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
SyncResult.Error("Netzwerkfehler: ${e.message}")
|
SyncResult.Error("Netzwerkfehler: ${e.message}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SyncResult.Error("Antwort des Servers war ungültig: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
113
android/app/src/main/java/com/umt/tracker/domain/Franchise.kt
Normal file
113
android/app/src/main/java/com/umt/tracker/domain/Franchise.kt
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package com.umt.tracker.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Franchise auto-detection, ported 1:1 from the desktop app (MediaItem.h) so
|
||||||
|
* both platforms group titles identically: a subtitle after a colon/dash, a
|
||||||
|
* trailing "(year)" and a trailing sequel marker (number or roman numeral) are
|
||||||
|
* dropped, and titles sharing a longest common word prefix collapse onto one
|
||||||
|
* franchise label. An explicit franchise on the item always wins.
|
||||||
|
*/
|
||||||
|
object Franchise {
|
||||||
|
|
||||||
|
private val SEPARATORS = listOf(": ", " - ", " – ", " — ")
|
||||||
|
private val TAIL_NUMERAL = Regex("\\s+(?:[0-9]{1,3}|[IVXLCDM]+)$")
|
||||||
|
private val NON_WORD = Regex("[^\\p{L}\\p{N}]+")
|
||||||
|
|
||||||
|
private val STOP_WORDS = setOf(
|
||||||
|
"the", "a", "an", "and", "of", "to", "in", "on", "for",
|
||||||
|
"und", "der", "die", "das", "den", "dem", "ein", "eine",
|
||||||
|
"le", "la", "les", "el", "il", "no", "wa",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Normalized base name of a title (drops subtitle/year/sequel marker). */
|
||||||
|
fun keyFromTitle(title: String): String {
|
||||||
|
var t = title.trim()
|
||||||
|
for (sep in SEPARATORS) {
|
||||||
|
val idx = t.indexOf(sep)
|
||||||
|
if (idx > 0) { t = t.substring(0, idx); break }
|
||||||
|
}
|
||||||
|
val paren = t.indexOf('(')
|
||||||
|
if (paren > 0) t = t.substring(0, paren)
|
||||||
|
t = t.trim()
|
||||||
|
t = TAIL_NUMERAL.replace(t, "")
|
||||||
|
return t.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The franchise an item belongs to: an explicit value always wins, otherwise
|
||||||
|
* the auto-detected key of its title (optionally resolved through a
|
||||||
|
* precomputed [clusterTitles] map).
|
||||||
|
*/
|
||||||
|
fun effective(item: MediaItem, autoMap: Map<String, String> = emptyMap()): String {
|
||||||
|
item.franchise.trim().takeIf { it.isNotEmpty() }?.let { return it }
|
||||||
|
return autoMap[item.title] ?: keyFromTitle(item.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups titles into franchises by their longest shared word prefix (used by
|
||||||
|
* at least two titles). Returns a map: original title -> franchise label.
|
||||||
|
*/
|
||||||
|
fun clusterTitles(titlesIn: List<String>): Map<String, String> {
|
||||||
|
// De-duplicate while preserving order.
|
||||||
|
val list = LinkedHashSet<String>()
|
||||||
|
for (t in titlesIn) if (t.isNotBlank()) list.add(t)
|
||||||
|
val n = list.size
|
||||||
|
val titles = list.toList()
|
||||||
|
|
||||||
|
val orig = ArrayList<List<String>>(n)
|
||||||
|
val norm = ArrayList<List<String>>(n)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val base = keyFromTitle(titles[i])
|
||||||
|
val words = base.split(NON_WORD).filter { it.isNotEmpty() }
|
||||||
|
orig.add(words)
|
||||||
|
norm.add(words.map { it.lowercase() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count how many titles share each word-prefix.
|
||||||
|
val prefixCount = HashMap<String, Int>()
|
||||||
|
for (words in norm) {
|
||||||
|
val key = StringBuilder()
|
||||||
|
for (w in words) {
|
||||||
|
if (key.isNotEmpty()) key.append(' ')
|
||||||
|
key.append(w)
|
||||||
|
val k = key.toString()
|
||||||
|
prefixCount[k] = (prefixCount[k] ?: 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val labelForKey = HashMap<String, String>()
|
||||||
|
val result = HashMap<String, String>()
|
||||||
|
for (i in 0 until n) {
|
||||||
|
// Longest prefix shared by at least two titles.
|
||||||
|
var bestL = 0
|
||||||
|
val key = StringBuilder()
|
||||||
|
for (L in norm[i].indices) {
|
||||||
|
if (L > 0) key.append(' ')
|
||||||
|
key.append(norm[i][L])
|
||||||
|
if ((prefixCount[key.toString()] ?: 0) >= 2) bestL = L + 1
|
||||||
|
}
|
||||||
|
if (bestL == 0) {
|
||||||
|
result[titles[i]] = keyFromTitle(titles[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Drop trailing stop words from the shared prefix.
|
||||||
|
var len = bestL
|
||||||
|
while (len > 1 && norm[i][len - 1] in STOP_WORDS) --len
|
||||||
|
val hasContent = (0 until len).any { norm[i][it] !in STOP_WORDS }
|
||||||
|
if (!hasContent) {
|
||||||
|
result[titles[i]] = keyFromTitle(titles[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val nkey = StringBuilder()
|
||||||
|
val disp = StringBuilder()
|
||||||
|
for (j in 0 until len) {
|
||||||
|
if (j > 0) { nkey.append(' '); disp.append(' ') }
|
||||||
|
nkey.append(norm[i][j])
|
||||||
|
disp.append(orig[i][j])
|
||||||
|
}
|
||||||
|
if (nkey.toString() !in labelForKey) labelForKey[nkey.toString()] = disp.toString()
|
||||||
|
result[titles[i]] = labelForKey[nkey.toString()]!!
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package com.umt.tracker.ui.detail
|
package com.umt.tracker.ui.detail
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -26,6 +30,7 @@ import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
import androidx.compose.material.icons.filled.Image
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -33,12 +38,14 @@ import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -49,6 +56,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
@ -66,6 +74,9 @@ import com.umt.tracker.domain.unitLabel
|
||||||
import com.umt.tracker.ui.components.LabeledDropdown
|
import com.umt.tracker.ui.components.LabeledDropdown
|
||||||
import com.umt.tracker.ui.components.StarRatingInput
|
import com.umt.tracker.ui.components.StarRatingInput
|
||||||
import com.umt.tracker.ui.components.TagChip
|
import com.umt.tracker.ui.components.TagChip
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -79,6 +90,17 @@ fun DetailScreen(
|
||||||
) {
|
) {
|
||||||
val item by viewModel.item.collectAsStateWithLifecycle()
|
val item by viewModel.item.collectAsStateWithLifecycle()
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Gallery picker for the cover (parity with the desktop "Datei…" button).
|
||||||
|
val galleryPicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
val path = copyCoverToFiles(context, uri)
|
||||||
|
if (path != null) viewModel.setCover(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -122,6 +144,7 @@ fun DetailScreen(
|
||||||
item = current,
|
item = current,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onChangeCover = { onChangeCover(current.type, current.title) },
|
onChangeCover = { onChangeCover(current.type, current.title) },
|
||||||
|
onPickCoverFromGallery = { galleryPicker.launch("image/*") },
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +173,7 @@ private fun DetailContent(
|
||||||
item: MediaItem,
|
item: MediaItem,
|
||||||
viewModel: DetailViewModel,
|
viewModel: DetailViewModel,
|
||||||
onChangeCover: () -> Unit,
|
onChangeCover: () -> Unit,
|
||||||
|
onPickCoverFromGallery: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
|
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
|
||||||
|
|
@ -195,6 +219,14 @@ private fun DetailContent(
|
||||||
)
|
)
|
||||||
Text("Cover ändern", modifier = Modifier.padding(start = 6.dp))
|
Text("Cover ändern", modifier = Modifier.padding(start = 6.dp))
|
||||||
}
|
}
|
||||||
|
TextButton(onClick = onPickCoverFromGallery) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PhotoLibrary,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Text("Aus Galerie", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.padding(start = 16.dp)) {
|
Column(modifier = Modifier.padding(start = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -301,25 +333,54 @@ private fun DetailContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.notes.isNotBlank()) {
|
|
||||||
item {
|
item {
|
||||||
|
// Editable notes, saved debounced like the desktop dialog.
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "Notizen",
|
text = "Notizen",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
var notes by remember(item.id) { mutableStateOf(item.notes) }
|
||||||
text = item.notes,
|
var initialized by remember(item.id) { mutableStateOf(false) }
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
LaunchedEffect(notes) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
if (!initialized) {
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
initialized = true
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
delay(600)
|
||||||
|
viewModel.setNotes(notes)
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = { notes = it },
|
||||||
|
placeholder = { Text("Persönliche Notizen…") },
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Copies a picked gallery image into the app's private covers dir and returns a
|
||||||
|
* file:// URI Coil can render, or null if the copy failed.
|
||||||
|
*/
|
||||||
|
private fun copyCoverToFiles(context: Context, uri: Uri): String? = runCatching {
|
||||||
|
val dir = File(context.filesDir, "covers").apply { mkdirs() }
|
||||||
|
val ext = context.contentResolver.getType(uri)
|
||||||
|
?.substringAfterLast('/')
|
||||||
|
?.takeIf { it.length in 2..5 }
|
||||||
|
?: "jpg"
|
||||||
|
val dest = File(dir, "${UUID.randomUUID()}.$ext")
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
dest.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: return null
|
||||||
|
"file://${dest.absolutePath}"
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SegmentRow(
|
private fun SegmentRow(
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,14 @@ class DetailViewModel(
|
||||||
|
|
||||||
fun setStatus(status: WatchStatus) = launch { repo.setStatus(itemId, status) }
|
fun setStatus(status: WatchStatus) = launch { repo.setStatus(itemId, status) }
|
||||||
|
|
||||||
|
/** Persists edited free-text notes (detail screen editor). */
|
||||||
|
fun setNotes(notes: String) = launch { repo.setNotes(itemId, notes) }
|
||||||
|
|
||||||
fun setRating(rating: Int) = launch { repo.setRating(itemId, rating) }
|
fun setRating(rating: Int) = launch { repo.setRating(itemId, rating) }
|
||||||
|
|
||||||
|
/** Sets the cover to a URL or local file:// path (gallery pick). */
|
||||||
|
fun setCover(coverUrl: String) = launch { repo.setCover(itemId, coverUrl) }
|
||||||
|
|
||||||
fun setUnitWatched(segmentNumber: Int, unitNumber: Int, watched: Boolean) =
|
fun setUnitWatched(segmentNumber: Int, unitNumber: Int, watched: Boolean) =
|
||||||
launch { repo.setUnitWatched(itemId, segmentNumber, unitNumber, watched) }
|
launch { repo.setUnitWatched(itemId, segmentNumber, unitNumber, watched) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
|
@ -58,6 +61,7 @@ fun EditScreen(
|
||||||
) {
|
) {
|
||||||
val form by viewModel.form.collectAsStateWithLifecycle()
|
val form by viewModel.form.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var saveError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -70,7 +74,17 @@ fun EditScreen(
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
scope.launch { viewModel.save()?.let(onSaved) }
|
scope.launch {
|
||||||
|
when (val r = viewModel.save()) {
|
||||||
|
is EditViewModel.SaveResult.Saved -> onSaved(r.id)
|
||||||
|
is EditViewModel.SaveResult.EmptyTitle ->
|
||||||
|
saveError = "Bitte einen Titel angeben."
|
||||||
|
is EditViewModel.SaveResult.Duplicate ->
|
||||||
|
saveError = "„${r.existingTitle}“ ist bereits in deiner " +
|
||||||
|
"Bibliothek vorhanden. Es wurde nichts hinzugefügt, damit " +
|
||||||
|
"dein bestehender Fortschritt nicht überschrieben wird."
|
||||||
|
}
|
||||||
|
}
|
||||||
}) { Text("Speichern") }
|
}) { Text("Speichern") }
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
|
@ -88,6 +102,13 @@ fun EditScreen(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
saveError?.let { err ->
|
||||||
|
Text(
|
||||||
|
text = err,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
LabeledDropdown(
|
LabeledDropdown(
|
||||||
label = "Typ",
|
label = "Typ",
|
||||||
selected = form.type,
|
selected = form.type,
|
||||||
|
|
@ -221,7 +242,19 @@ fun EditScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { scope.launch { viewModel.save()?.let(onSaved) } },
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
when (val r = viewModel.save()) {
|
||||||
|
is EditViewModel.SaveResult.Saved -> onSaved(r.id)
|
||||||
|
is EditViewModel.SaveResult.EmptyTitle ->
|
||||||
|
saveError = "Bitte einen Titel angeben."
|
||||||
|
is EditViewModel.SaveResult.Duplicate ->
|
||||||
|
saveError = "„${r.existingTitle}“ ist bereits in deiner " +
|
||||||
|
"Bibliothek vorhanden. Es wurde nichts hinzugefügt, damit " +
|
||||||
|
"dein bestehender Fortschritt nicht überschrieben wird."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
) { Text("Speichern") }
|
) { Text("Speichern") }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,11 +112,27 @@ class EditViewModel(
|
||||||
it.copy(customFields = it.customFields.mapIndexed { i, p -> if (i == index) key to value else p })
|
it.copy(customFields = it.customFields.mapIndexed { i, p -> if (i == index) key to value else p })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists the form. Returns the saved item id, or null if the title is empty. */
|
/**
|
||||||
suspend fun save(): Long? {
|
* Outcome of saving: either the persisted id, a rejection because the title
|
||||||
|
* is empty, or a rejection because the item would duplicate an existing one
|
||||||
|
* (same guard as the desktop app).
|
||||||
|
*/
|
||||||
|
sealed interface SaveResult {
|
||||||
|
data class Saved(val id: Long) : SaveResult
|
||||||
|
data object EmptyTitle : SaveResult
|
||||||
|
data class Duplicate(val existingTitle: String) : SaveResult
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun save(): SaveResult {
|
||||||
val s = _form.value
|
val s = _form.value
|
||||||
if (s.title.isBlank()) return null
|
if (s.title.isBlank()) return SaveResult.EmptyTitle
|
||||||
return repo.save(s.toMediaItem())
|
val item = s.toMediaItem()
|
||||||
|
// Only new items are checked; an edit keeps its own id and is therefore
|
||||||
|
// never flagged (mirrors the desktop behaviour).
|
||||||
|
if (item.id == 0L) {
|
||||||
|
repo.findDuplicate(item)?.let { return SaveResult.Duplicate(it) }
|
||||||
|
}
|
||||||
|
return SaveResult.Saved(repo.save(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun EditFormState.toMediaItem(): MediaItem = MediaItem(
|
private fun EditFormState.toMediaItem(): MediaItem = MediaItem(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.BarChart
|
import androidx.compose.material.icons.filled.BarChart
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
import androidx.compose.material.icons.filled.FilterList
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
|
@ -93,6 +94,15 @@ fun LibraryScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System file picker for the JSON export target.
|
||||||
|
val exportPicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.CreateDocument("application/json")
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
viewModel.export(uri) { context.contentResolver.openOutputStream(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(message) {
|
LaunchedEffect(message) {
|
||||||
message?.let {
|
message?.let {
|
||||||
snackbarHostState.showSnackbar(it)
|
snackbarHostState.showSnackbar(it)
|
||||||
|
|
@ -131,6 +141,9 @@ fun LibraryScreen(
|
||||||
IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
|
IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
|
||||||
Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren")
|
Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren")
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = { exportPicker.launch("media-library.json") }) {
|
||||||
|
Icon(Icons.Filled.FileUpload, contentDescription = "Bibliothek exportieren")
|
||||||
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
Icon(Icons.Filled.Settings, contentDescription = "Einstellungen")
|
Icon(Icons.Filled.Settings, contentDescription = "Einstellungen")
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +201,7 @@ fun LibraryScreen(
|
||||||
FilterSheet(
|
FilterSheet(
|
||||||
filter = state.filter,
|
filter = state.filter,
|
||||||
allGenres = state.allGenres,
|
allGenres = state.allGenres,
|
||||||
|
allTags = state.allTags,
|
||||||
allFranchises = state.allFranchises,
|
allFranchises = state.allFranchises,
|
||||||
onChange = viewModel::updateFilter,
|
onChange = viewModel::updateFilter,
|
||||||
onClear = viewModel::clearFilters,
|
onClear = viewModel::clearFilters,
|
||||||
|
|
@ -252,6 +266,7 @@ private fun EmptyState(onImport: () -> Unit) {
|
||||||
private fun FilterSheet(
|
private fun FilterSheet(
|
||||||
filter: LibraryFilter,
|
filter: LibraryFilter,
|
||||||
allGenres: List<String>,
|
allGenres: List<String>,
|
||||||
|
allTags: List<String>,
|
||||||
allFranchises: List<String>,
|
allFranchises: List<String>,
|
||||||
onChange: ((LibraryFilter) -> LibraryFilter) -> Unit,
|
onChange: ((LibraryFilter) -> LibraryFilter) -> Unit,
|
||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
|
|
@ -333,6 +348,30 @@ private fun FilterSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionTitle(
|
||||||
|
"Jahr: ${filter.yearFrom.takeIf { it > 0 } ?: "…"} – ${filter.yearTo.takeIf { it > 0 } ?: "…"}"
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = filter.yearFrom.takeIf { it > 0 }?.toString() ?: "",
|
||||||
|
onValueChange = { v ->
|
||||||
|
onChange { it.copy(yearFrom = v.filter(Char::isDigit).take(4).toIntOrNull() ?: 0) }
|
||||||
|
},
|
||||||
|
label = { Text("von") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = filter.yearTo.takeIf { it > 0 }?.toString() ?: "",
|
||||||
|
onValueChange = { v ->
|
||||||
|
onChange { it.copy(yearTo = v.filter(Char::isDigit).take(4).toIntOrNull() ?: 0) }
|
||||||
|
},
|
||||||
|
label = { Text("bis") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (allGenres.isNotEmpty()) {
|
if (allGenres.isNotEmpty()) {
|
||||||
SectionTitle("Genres")
|
SectionTitle("Genres")
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|
@ -351,6 +390,24 @@ private fun FilterSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (allTags.isNotEmpty()) {
|
||||||
|
SectionTitle("Tags")
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
allTags.forEach { t ->
|
||||||
|
FilterChip(
|
||||||
|
selected = t in filter.tags,
|
||||||
|
onClick = {
|
||||||
|
onChange {
|
||||||
|
val next = if (t in it.tags) it.tags - t else it.tags + t
|
||||||
|
it.copy(tags = next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(t) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (allFranchises.isNotEmpty()) {
|
if (allFranchises.isNotEmpty()) {
|
||||||
SectionTitle("Reihe / Franchise")
|
SectionTitle("Reihe / Franchise")
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import com.umt.tracker.data.LibraryRepository
|
||||||
import com.umt.tracker.data.sync.SyncClient
|
import com.umt.tracker.data.sync.SyncClient
|
||||||
import com.umt.tracker.data.sync.SyncResult
|
import com.umt.tracker.data.sync.SyncResult
|
||||||
import com.umt.tracker.data.sync.SyncSettings
|
import com.umt.tracker.data.sync.SyncSettings
|
||||||
|
import com.umt.tracker.domain.Franchise
|
||||||
import com.umt.tracker.domain.MediaItem
|
import com.umt.tracker.domain.MediaItem
|
||||||
import com.umt.tracker.domain.MediaType
|
import com.umt.tracker.domain.MediaType
|
||||||
import com.umt.tracker.domain.WatchStatus
|
import com.umt.tracker.domain.WatchStatus
|
||||||
|
|
@ -34,8 +35,11 @@ data class LibraryFilter(
|
||||||
val status: WatchStatus? = null,
|
val status: WatchStatus? = null,
|
||||||
val favoritesOnly: Boolean = false,
|
val favoritesOnly: Boolean = false,
|
||||||
val genres: Set<String> = emptySet(),
|
val genres: Set<String> = emptySet(),
|
||||||
|
val tags: Set<String> = emptySet(),
|
||||||
val franchise: String? = null,
|
val franchise: String? = null,
|
||||||
val minRating: Int = 0,
|
val minRating: Int = 0,
|
||||||
|
val yearFrom: Int = 0,
|
||||||
|
val yearTo: Int = 0,
|
||||||
val sortMode: SortMode = SortMode.DATE_ADDED,
|
val sortMode: SortMode = SortMode.DATE_ADDED,
|
||||||
val sortDescending: Boolean = true,
|
val sortDescending: Boolean = true,
|
||||||
) {
|
) {
|
||||||
|
|
@ -44,8 +48,11 @@ data class LibraryFilter(
|
||||||
get() = (if (status != null) 1 else 0) +
|
get() = (if (status != null) 1 else 0) +
|
||||||
(if (favoritesOnly) 1 else 0) +
|
(if (favoritesOnly) 1 else 0) +
|
||||||
genres.size +
|
genres.size +
|
||||||
|
tags.size +
|
||||||
(if (franchise != null) 1 else 0) +
|
(if (franchise != null) 1 else 0) +
|
||||||
(if (minRating > 0) 1 else 0)
|
(if (minRating > 0) 1 else 0) +
|
||||||
|
(if (yearFrom > 0) 1 else 0) +
|
||||||
|
(if (yearTo > 0) 1 else 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LibraryUiState(
|
data class LibraryUiState(
|
||||||
|
|
@ -54,6 +61,7 @@ data class LibraryUiState(
|
||||||
val typeFilter: MediaType? = null,
|
val typeFilter: MediaType? = null,
|
||||||
val filter: LibraryFilter = LibraryFilter(),
|
val filter: LibraryFilter = LibraryFilter(),
|
||||||
val allGenres: List<String> = emptyList(),
|
val allGenres: List<String> = emptyList(),
|
||||||
|
val allTags: List<String> = emptyList(),
|
||||||
val allFranchises: List<String> = emptyList(),
|
val allFranchises: List<String> = emptyList(),
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val syncEnabled: Boolean = false,
|
val syncEnabled: Boolean = false,
|
||||||
|
|
@ -74,12 +82,14 @@ class LibraryViewModel(
|
||||||
|
|
||||||
val uiState: StateFlow<LibraryUiState> =
|
val uiState: StateFlow<LibraryUiState> =
|
||||||
combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f ->
|
combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f ->
|
||||||
|
val autoMap = Franchise.clusterTitles(items.map { it.title })
|
||||||
val filtered = items
|
val filtered = items
|
||||||
.filter { it.matches(q, type, f) }
|
.filter { it.matches(q, type, f, autoMap) }
|
||||||
.sortedWith(f.comparator())
|
.sortedWith(f.comparator())
|
||||||
// Facets reflect the whole library, not the filtered view.
|
// Facets reflect the whole library, not the filtered view.
|
||||||
val genres = items.flatMap { it.genres }.distinct().sorted()
|
val genres = items.flatMap { it.genres }.distinct().sorted()
|
||||||
val franchises = items.map { it.franchise }
|
val tags = items.flatMap { it.tags }.distinct().sorted()
|
||||||
|
val franchises = items.map { Franchise.effective(it, autoMap) }
|
||||||
.filter { it.isNotBlank() }
|
.filter { it.isNotBlank() }
|
||||||
.groupingBy { it }.eachCount()
|
.groupingBy { it }.eachCount()
|
||||||
.filterValues { it >= 2 }.keys.sorted()
|
.filterValues { it >= 2 }.keys.sorted()
|
||||||
|
|
@ -89,6 +99,7 @@ class LibraryViewModel(
|
||||||
typeFilter = type,
|
typeFilter = type,
|
||||||
filter = f,
|
filter = f,
|
||||||
allGenres = genres,
|
allGenres = genres,
|
||||||
|
allTags = tags,
|
||||||
allFranchises = franchises,
|
allFranchises = franchises,
|
||||||
loading = false,
|
loading = false,
|
||||||
syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
|
syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
|
||||||
|
|
@ -99,7 +110,12 @@ class LibraryViewModel(
|
||||||
initialValue = LibraryUiState(),
|
initialValue = LibraryUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun MediaItem.matches(q: String, type: MediaType?, f: LibraryFilter): Boolean {
|
private fun MediaItem.matches(
|
||||||
|
q: String,
|
||||||
|
type: MediaType?,
|
||||||
|
f: LibraryFilter,
|
||||||
|
autoMap: Map<String, String>,
|
||||||
|
): Boolean {
|
||||||
if (type != null && this.type != type) return false
|
if (type != null && this.type != type) return false
|
||||||
if (q.isNotBlank() && !title.contains(q, true) &&
|
if (q.isNotBlank() && !title.contains(q, true) &&
|
||||||
!originalTitle.contains(q, true) && !overview.contains(q, true)
|
!originalTitle.contains(q, true) && !overview.contains(q, true)
|
||||||
|
|
@ -108,7 +124,10 @@ class LibraryViewModel(
|
||||||
if (f.favoritesOnly && !favorite) return false
|
if (f.favoritesOnly && !favorite) return false
|
||||||
if (f.minRating > 0 && rating < f.minRating) return false
|
if (f.minRating > 0 && rating < f.minRating) return false
|
||||||
if (f.genres.isNotEmpty() && !f.genres.all { it in genres }) return false
|
if (f.genres.isNotEmpty() && !f.genres.all { it in genres }) return false
|
||||||
if (f.franchise != null && franchise != f.franchise) return false
|
if (f.tags.isNotEmpty() && !f.tags.all { it in tags }) return false
|
||||||
|
if (f.yearFrom > 0 && year != 0 && year < f.yearFrom) return false
|
||||||
|
if (f.yearTo > 0 && year != 0 && year > f.yearTo) return false
|
||||||
|
if (f.franchise != null && Franchise.effective(this, autoMap) != f.franchise) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,13 +175,38 @@ class LibraryViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-tap pull from the server (when cloud sync is configured). */
|
/** Writes the whole library as JSON to the user-picked document. */
|
||||||
|
fun export(uri: Uri, openStream: (Uri) -> java.io.OutputStream?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val stream = openStream(uri)
|
||||||
|
if (stream == null) {
|
||||||
|
messages.value = "Datei konnte nicht geöffnet werden."
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runCatching { stream.use { repo.exportTo(it) } }
|
||||||
|
.onSuccess { messages.value = "Bibliothek exportiert." }
|
||||||
|
.onFailure { messages.value = "Export fehlgeschlagen: ${it.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-tap sync like the desktop: push local changes first, then pull the
|
||||||
|
* canonical server state back down. A push conflict is surfaced for the
|
||||||
|
* settings screen, where it can be resolved (pull server vs. overwrite).
|
||||||
|
*/
|
||||||
fun syncNow() {
|
fun syncNow() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
messages.value = when (val r = syncClient.pull()) {
|
messages.value = when (val pushed = syncClient.push()) {
|
||||||
is SyncResult.Success -> r.message
|
is SyncResult.Conflict ->
|
||||||
is SyncResult.Conflict -> "Konflikt – bitte in den Einstellungen auflösen."
|
"Konflikt: Der Server hat eine neuere Version (Revision " +
|
||||||
is SyncResult.Error -> r.message
|
"${pushed.serverRevision}). Bitte in den Einstellungen auflösen."
|
||||||
|
is SyncResult.Error -> pushed.message
|
||||||
|
is SyncResult.Success -> when (val pulled = syncClient.pull()) {
|
||||||
|
is SyncResult.Success -> "Bibliothek synchronisiert."
|
||||||
|
is SyncResult.Error -> pulled.message
|
||||||
|
is SyncResult.Conflict ->
|
||||||
|
"Konflikt – bitte in den Einstellungen auflösen."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,21 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.umt.tracker.Graph
|
import com.umt.tracker.Graph
|
||||||
import com.umt.tracker.data.LibraryRepository
|
import com.umt.tracker.data.LibraryRepository
|
||||||
import com.umt.tracker.data.LibraryStats
|
import com.umt.tracker.data.LibraryStats
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
class StatsViewModel(private val repo: LibraryRepository) : ViewModel() {
|
class StatsViewModel(private val repo: LibraryRepository) : ViewModel() {
|
||||||
|
|
||||||
private val _stats = MutableStateFlow<LibraryStats?>(null)
|
/** Recomputed whenever the library changes, so stats never go stale. */
|
||||||
val stats: StateFlow<LibraryStats?> = _stats.asStateFlow()
|
val stats: StateFlow<LibraryStats?> = repo.observeLibrary()
|
||||||
|
.map { repo.statsOf(it) }
|
||||||
init {
|
.stateIn(
|
||||||
viewModelScope.launch { _stats.value = repo.stats() }
|
scope = viewModelScope,
|
||||||
}
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val Factory = object : ViewModelProvider.Factory {
|
val Factory = object : ViewModelProvider.Factory {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ type server struct {
|
||||||
oauth oauth2.Config
|
oauth oauth2.Config
|
||||||
client *http.Client
|
client *http.Client
|
||||||
store *store // nil when DATABASE_URL is unset (sync disabled)
|
store *store // nil when DATABASE_URL is unset (sync disabled)
|
||||||
|
tmdbLim *rateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -54,6 +55,8 @@ func main() {
|
||||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
},
|
},
|
||||||
client: &http.Client{Timeout: 15 * time.Second},
|
client: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
// Generous per-user cap on proxied TMDB calls (shared API key).
|
||||||
|
tmdbLim: newRateLimiter(120, time.Minute),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.DatabaseURL != "" {
|
if cfg.DatabaseURL != "" {
|
||||||
|
|
@ -184,7 +187,9 @@ func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
oauthToken, err := s.oauth.Exchange(ctx, r.URL.Query().Get("code"),
|
oauthToken, err := s.oauth.Exchange(ctx, r.URL.Query().Get("code"),
|
||||||
oauth2.VerifierOption(verifierCookie.Value))
|
oauth2.VerifierOption(verifierCookie.Value))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Token-Austausch fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
// Log the details server-side; don't leak upstream internals to clients.
|
||||||
|
log.Printf("oauth exchange failed: %v", err)
|
||||||
|
http.Error(w, "Token-Austausch fehlgeschlagen", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +200,8 @@ func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
idToken, err := s.verifier.Verify(ctx, rawIDToken)
|
idToken, err := s.verifier.Verify(ctx, rawIDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "id_token-Prüfung fehlgeschlagen: "+err.Error(), http.StatusUnauthorized)
|
log.Printf("id_token verification failed: %v", err)
|
||||||
|
http.Error(w, "id_token-Prüfung fehlgeschlagen", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,10 +299,15 @@ func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
||||||
if _, err := verifyToken(s.cfg.SessionKey, bearer); err != nil {
|
claims, err := verifyToken(s.cfg.SessionKey, bearer)
|
||||||
|
if err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !s.tmdbLim.allow(claims.Subject) {
|
||||||
|
http.Error(w, "rate limit exceeded, try again later", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Build upstream URL: /3/<path> -> <TMDBBase>/<path>, keep the query, force api_key.
|
// Build upstream URL: /3/<path> -> <TMDBBase>/<path>, keep the query, force api_key.
|
||||||
upstreamPath := strings.TrimPrefix(r.URL.Path, "/3")
|
upstreamPath := strings.TrimPrefix(r.URL.Path, "/3")
|
||||||
|
|
@ -319,7 +330,8 @@ func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
|
log.Printf("tmdb upstream error for %s: %v", r.URL.Path, err)
|
||||||
|
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
|
||||||
48
server/ratelimit.go
Normal file
48
server/ratelimit.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimiter is a minimal in-memory fixed-window limiter keyed by string
|
||||||
|
// (bearer-token subject for the TMDB proxy). It blunts runaway clients abusing
|
||||||
|
// the shared API key; it is not a full DoS defense.
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
counts map[string]rateEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateEntry struct {
|
||||||
|
windowStart time.Time
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(limit int, per time.Duration) *rateLimiter {
|
||||||
|
return &rateLimiter{limit: limit, window: per, counts: map[string]rateEntry{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) allow(key string) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
e, ok := r.counts[key]
|
||||||
|
if !ok || now.Sub(e.windowStart) >= r.window {
|
||||||
|
// Occasionally garbage-collect stale keys so the map stays bounded.
|
||||||
|
if len(r.counts) > 10_000 {
|
||||||
|
for k, v := range r.counts {
|
||||||
|
if now.Sub(v.windowStart) >= r.window {
|
||||||
|
delete(r.counts, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.counts[key] = rateEntry{windowStart: now, count: 1}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
e.count++
|
||||||
|
r.counts[key] = e
|
||||||
|
return e.count <= r.limit
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,10 @@ bool Database::open(const QString &path) {
|
||||||
}
|
}
|
||||||
exec(QStringLiteral("PRAGMA foreign_keys = ON;"));
|
exec(QStringLiteral("PRAGMA foreign_keys = ON;"));
|
||||||
exec(QStringLiteral("PRAGMA journal_mode = WAL;"));
|
exec(QStringLiteral("PRAGMA journal_mode = WAL;"));
|
||||||
initSchema();
|
if (!initSchema()) {
|
||||||
|
m_lastError = QStringLiteral("Datenbank-Schema konnte nicht initialisiert werden");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,8 +69,8 @@ bool Database::exec(const QString &sql) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Database::initSchema() {
|
bool Database::initSchema() {
|
||||||
exec(QStringLiteral(
|
if (!exec(QStringLiteral(
|
||||||
"CREATE TABLE IF NOT EXISTS media ("
|
"CREATE TABLE IF NOT EXISTS media ("
|
||||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||||
" type TEXT NOT NULL,"
|
" type TEXT NOT NULL,"
|
||||||
|
|
@ -86,7 +89,8 @@ void Database::initSchema() {
|
||||||
" external_id TEXT,"
|
" external_id TEXT,"
|
||||||
" external_source TEXT,"
|
" external_source TEXT,"
|
||||||
" franchise TEXT"
|
" franchise TEXT"
|
||||||
");"));
|
");")))
|
||||||
|
return false;
|
||||||
|
|
||||||
// Migration: add the franchise column to libraries created before it existed.
|
// Migration: add the franchise column to libraries created before it existed.
|
||||||
{
|
{
|
||||||
|
|
@ -99,20 +103,20 @@ void Database::initSchema() {
|
||||||
hasFranchise = true;
|
hasFranchise = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!hasFranchise)
|
if (!hasFranchise &&
|
||||||
exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT"));
|
!exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT")))
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
exec(QStringLiteral(
|
return exec(QStringLiteral(
|
||||||
"CREATE TABLE IF NOT EXISTS segments ("
|
"CREATE TABLE IF NOT EXISTS segments ("
|
||||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||||
" media_id INTEGER NOT NULL,"
|
" media_id INTEGER NOT NULL,"
|
||||||
" number INTEGER NOT NULL DEFAULT 0,"
|
" number INTEGER NOT NULL DEFAULT 0,"
|
||||||
" title TEXT,"
|
" title TEXT,"
|
||||||
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
||||||
");"));
|
");"))
|
||||||
|
&& exec(QStringLiteral(
|
||||||
exec(QStringLiteral(
|
|
||||||
"CREATE TABLE IF NOT EXISTS units ("
|
"CREATE TABLE IF NOT EXISTS units ("
|
||||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||||
" segment_id INTEGER NOT NULL,"
|
" segment_id INTEGER NOT NULL,"
|
||||||
|
|
@ -121,34 +125,30 @@ void Database::initSchema() {
|
||||||
" watched INTEGER NOT NULL DEFAULT 0,"
|
" watched INTEGER NOT NULL DEFAULT 0,"
|
||||||
" watched_date TEXT,"
|
" watched_date TEXT,"
|
||||||
" FOREIGN KEY(segment_id) REFERENCES segments(id) ON DELETE CASCADE"
|
" FOREIGN KEY(segment_id) REFERENCES segments(id) ON DELETE CASCADE"
|
||||||
");"));
|
");"))
|
||||||
|
&& exec(QStringLiteral(
|
||||||
exec(QStringLiteral(
|
|
||||||
"CREATE TABLE IF NOT EXISTS genres ("
|
"CREATE TABLE IF NOT EXISTS genres ("
|
||||||
" media_id INTEGER NOT NULL,"
|
" media_id INTEGER NOT NULL,"
|
||||||
" genre TEXT NOT NULL,"
|
" genre TEXT NOT NULL,"
|
||||||
" PRIMARY KEY(media_id, genre),"
|
" PRIMARY KEY(media_id, genre),"
|
||||||
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
||||||
");"));
|
");"))
|
||||||
|
&& exec(QStringLiteral(
|
||||||
exec(QStringLiteral(
|
|
||||||
"CREATE TABLE IF NOT EXISTS tags ("
|
"CREATE TABLE IF NOT EXISTS tags ("
|
||||||
" media_id INTEGER NOT NULL,"
|
" media_id INTEGER NOT NULL,"
|
||||||
" tag TEXT NOT NULL,"
|
" tag TEXT NOT NULL,"
|
||||||
" PRIMARY KEY(media_id, tag),"
|
" PRIMARY KEY(media_id, tag),"
|
||||||
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
||||||
");"));
|
");"))
|
||||||
|
&& exec(QStringLiteral(
|
||||||
exec(QStringLiteral(
|
|
||||||
"CREATE TABLE IF NOT EXISTS localized_titles ("
|
"CREATE TABLE IF NOT EXISTS localized_titles ("
|
||||||
" media_id INTEGER NOT NULL,"
|
" media_id INTEGER NOT NULL,"
|
||||||
" lang TEXT NOT NULL,"
|
" lang TEXT NOT NULL,"
|
||||||
" title TEXT NOT NULL,"
|
" title TEXT NOT NULL,"
|
||||||
" PRIMARY KEY(media_id, lang),"
|
" PRIMARY KEY(media_id, lang),"
|
||||||
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
||||||
");"));
|
");"))
|
||||||
|
&& exec(QStringLiteral(
|
||||||
exec(QStringLiteral(
|
|
||||||
"CREATE TABLE IF NOT EXISTS custom_fields ("
|
"CREATE TABLE IF NOT EXISTS custom_fields ("
|
||||||
" media_id INTEGER NOT NULL,"
|
" media_id INTEGER NOT NULL,"
|
||||||
" key TEXT NOT NULL,"
|
" key TEXT NOT NULL,"
|
||||||
|
|
@ -163,7 +163,25 @@ void Database::initSchema() {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
bool Database::saveItem(MediaItem &item) {
|
bool Database::saveItem(MediaItem &item) {
|
||||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||||
db.transaction();
|
if (!db.transaction())
|
||||||
|
return saveItemInternal(item);
|
||||||
|
if (!saveItemInternal(item)) {
|
||||||
|
db.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!db.commit()) {
|
||||||
|
m_lastError = db.lastError().text();
|
||||||
|
db.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
emit changed();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserts/updates one item inside the caller's transaction (or autocommit when
|
||||||
|
// none is active). The caller emits changed() once its transaction is committed.
|
||||||
|
bool Database::saveItemInternal(MediaItem &item) {
|
||||||
|
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||||
|
|
||||||
QSqlQuery q(db);
|
QSqlQuery q(db);
|
||||||
if (item.id < 0) {
|
if (item.id < 0) {
|
||||||
|
|
@ -201,7 +219,6 @@ bool Database::saveItem(MediaItem &item) {
|
||||||
|
|
||||||
if (!q.exec()) {
|
if (!q.exec()) {
|
||||||
m_lastError = q.lastError().text();
|
m_lastError = q.lastError().text();
|
||||||
db.rollback();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (item.id < 0)
|
if (item.id < 0)
|
||||||
|
|
@ -209,12 +226,35 @@ bool Database::saveItem(MediaItem &item) {
|
||||||
|
|
||||||
if (!saveGenresTags(item) || !saveLocalized(item) ||
|
if (!saveGenresTags(item) || !saveLocalized(item) ||
|
||||||
!saveCustomFields(item) || !saveSegments(item)) {
|
!saveCustomFields(item) || !saveSegments(item)) {
|
||||||
db.rollback();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Database::replaceAll(const QVector<MediaItem> &items, QString *error) {
|
||||||
|
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||||
|
if (!db.transaction()) {
|
||||||
|
if (error) *error = QStringLiteral("Datenbank-Transaktion konnte nicht gestartet werden");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QSqlQuery wipe(db);
|
||||||
|
if (!wipe.exec(QStringLiteral("DELETE FROM media"))) {
|
||||||
|
if (error) *error = wipe.lastError().text();
|
||||||
|
db.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (MediaItem m : items) {
|
||||||
|
m.id = -1; // insert as fresh rows
|
||||||
|
if (!saveItemInternal(m)) {
|
||||||
|
if (error) *error = m_lastError;
|
||||||
|
db.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!db.commit()) {
|
if (!db.commit()) {
|
||||||
m_lastError = db.lastError().text();
|
if (error) *error = db.lastError().text();
|
||||||
|
db.rollback();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
emit changed();
|
emit changed();
|
||||||
|
|
@ -487,9 +527,14 @@ QVector<MediaItem> Database::queryItems(const FilterCriteria &c) {
|
||||||
if (c.yearFrom > 0) { wheres << QStringLiteral("media.year>=?"); binds << c.yearFrom; }
|
if (c.yearFrom > 0) { wheres << QStringLiteral("media.year>=?"); binds << c.yearFrom; }
|
||||||
if (c.yearTo > 0) { wheres << QStringLiteral("media.year<=?"); binds << c.yearTo; }
|
if (c.yearTo > 0) { wheres << QStringLiteral("media.year<=?"); binds << c.yearTo; }
|
||||||
if (!c.searchText.trimmed().isEmpty()) {
|
if (!c.searchText.trimmed().isEmpty()) {
|
||||||
wheres << QStringLiteral("(media.title LIKE ? OR media.original_title LIKE ? "
|
wheres << QStringLiteral("(media.title LIKE ? ESCAPE '\\' OR media.original_title LIKE ? ESCAPE '\\' "
|
||||||
"OR media.overview LIKE ?)");
|
"OR media.overview LIKE ? ESCAPE '\\')");
|
||||||
const QString like = QStringLiteral("%%1%").arg(c.searchText.trimmed());
|
// Escape LIKE wildcards so user input matches literally.
|
||||||
|
QString like = c.searchText.trimmed();
|
||||||
|
like.replace(QLatin1Char('\\'), QStringLiteral("\\\\"))
|
||||||
|
.replace(QLatin1Char('%'), QStringLiteral("\\%"))
|
||||||
|
.replace(QLatin1Char('_'), QStringLiteral("\\_"));
|
||||||
|
like = QStringLiteral("%%1%").arg(like);
|
||||||
binds << like << like << like;
|
binds << like << like << like;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ public:
|
||||||
// Returns items (with full segment trees) matching the criteria.
|
// Returns items (with full segment trees) matching the criteria.
|
||||||
QVector<MediaItem> queryItems(const FilterCriteria &c);
|
QVector<MediaItem> queryItems(const FilterCriteria &c);
|
||||||
|
|
||||||
|
// Replaces the whole library with the given items in ONE transaction
|
||||||
|
// (all-or-nothing). Emits changed() exactly once on success. Used by sync
|
||||||
|
// pull and JSON import so a failure can never leave a half-replaced library.
|
||||||
|
bool replaceAll(const QVector<MediaItem> &items, QString *error = nullptr);
|
||||||
|
|
||||||
// Quick toggles avoid re-serializing the whole tree.
|
// Quick toggles avoid re-serializing the whole tree.
|
||||||
bool setUnitWatched(int unitId, bool watched);
|
bool setUnitWatched(int unitId, bool watched);
|
||||||
bool setSegmentWatched(int segmentId, bool watched);
|
bool setSegmentWatched(int segmentId, bool watched);
|
||||||
|
|
@ -82,7 +87,10 @@ signals:
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool exec(const QString &sql);
|
bool exec(const QString &sql);
|
||||||
void initSchema();
|
bool initSchema();
|
||||||
|
// Core insert/update used by both saveItem() and replaceAll(); runs inside
|
||||||
|
// the caller's transaction and never emits changed() itself.
|
||||||
|
bool saveItemInternal(MediaItem &item);
|
||||||
QHash<QString, int> franchiseCounts() const;
|
QHash<QString, int> franchiseCounts() const;
|
||||||
// Auto-detected franchise label per distinct title (explicit overrides not
|
// Auto-detected franchise label per distinct title (explicit overrides not
|
||||||
// applied here; callers layer those on top).
|
// applied here; callers layer those on top).
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,25 @@
|
||||||
#include "core/Settings.h"
|
#include "core/Settings.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
|
||||||
namespace umt {
|
namespace umt {
|
||||||
|
|
||||||
AppSettings::AppSettings(QObject *parent)
|
AppSettings::AppSettings(QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, m_s(QStringLiteral("UMT"), QStringLiteral("UltimateMediaTracker"))
|
, m_s(QStringLiteral("UMT"), QStringLiteral("UltimateMediaTracker"))
|
||||||
{
|
{
|
||||||
|
protectSettingsFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppSettings::protectSettingsFile() {
|
||||||
|
#if defined(Q_OS_UNIX)
|
||||||
|
m_s.sync();
|
||||||
|
const QString path = m_s.fileName();
|
||||||
|
if (QFile::exists(path))
|
||||||
|
QFile::setPermissions(path, QFile::ReadOwner | QFile::WriteOwner);
|
||||||
|
#else
|
||||||
|
// Windows: the native format lives in HKCU, which is per-user by default.
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AppSettings::darkMode() const {
|
bool AppSettings::darkMode() const {
|
||||||
|
|
@ -49,6 +63,7 @@ QString AppSettings::tmdbApiKey() const {
|
||||||
}
|
}
|
||||||
void AppSettings::setTmdbApiKey(const QString &key) {
|
void AppSettings::setTmdbApiKey(const QString &key) {
|
||||||
m_s.setValue(QStringLiteral("providers/tmdbKey"), key);
|
m_s.setValue(QStringLiteral("providers/tmdbKey"), key);
|
||||||
|
protectSettingsFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
QString AppSettings::proxyUrl() const {
|
QString AppSettings::proxyUrl() const {
|
||||||
|
|
@ -63,6 +78,7 @@ QString AppSettings::proxyToken() const {
|
||||||
}
|
}
|
||||||
void AppSettings::setProxyToken(const QString &token) {
|
void AppSettings::setProxyToken(const QString &token) {
|
||||||
m_s.setValue(QStringLiteral("providers/proxyToken"), token);
|
m_s.setValue(QStringLiteral("providers/proxyToken"), token);
|
||||||
|
protectSettingsFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
QString AppSettings::rawgApiKey() const {
|
QString AppSettings::rawgApiKey() const {
|
||||||
|
|
@ -70,6 +86,7 @@ QString AppSettings::rawgApiKey() const {
|
||||||
}
|
}
|
||||||
void AppSettings::setRawgApiKey(const QString &key) {
|
void AppSettings::setRawgApiKey(const QString &key) {
|
||||||
m_s.setValue(QStringLiteral("providers/rawgKey"), key);
|
m_s.setValue(QStringLiteral("providers/rawgKey"), key);
|
||||||
|
protectSettingsFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
QString AppSettings::storageMode() const {
|
QString AppSettings::storageMode() const {
|
||||||
|
|
@ -85,6 +102,7 @@ QString AppSettings::syncPassphrase() const {
|
||||||
}
|
}
|
||||||
void AppSettings::setSyncPassphrase(const QString &pass) {
|
void AppSettings::setSyncPassphrase(const QString &pass) {
|
||||||
m_s.setValue(QStringLiteral("sync/passphrase"), pass);
|
m_s.setValue(QStringLiteral("sync/passphrase"), pass);
|
||||||
|
protectSettingsFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
qlonglong AppSettings::syncRevision() const {
|
qlonglong AppSettings::syncRevision() const {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ signals:
|
||||||
void cardSizeChanged(int px);
|
void cardSizeChanged(int px);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// QSettings persists with default (often world-readable) permissions; the
|
||||||
|
// file contains API keys, the proxy token and the sync passphrase, so lock
|
||||||
|
// it down to the current user after every secret write.
|
||||||
|
void protectSettingsFile();
|
||||||
|
|
||||||
QSettings m_s;
|
QSettings m_s;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QUrl>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
|
||||||
namespace umt {
|
namespace umt {
|
||||||
|
|
@ -27,6 +28,19 @@ QString ImageCache::coversDir() {
|
||||||
return base + QStringLiteral("/covers");
|
return base + QStringLiteral("/covers");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Covers only ever come from http(s) metadata providers. Restricting the scheme
|
||||||
|
// keeps URLs injected via imported/synced data (e.g. file://) from turning the
|
||||||
|
// cache into a local file reader.
|
||||||
|
constexpr qint64 MAX_IMAGE_BYTES = 15LL * 1024 * 1024; // 15 MiB
|
||||||
|
|
||||||
|
bool isFetchableUrl(const QString &url) {
|
||||||
|
const QUrl u(url, QUrl::StrictMode);
|
||||||
|
return u.isValid() && (u.scheme() == QLatin1String("http") ||
|
||||||
|
u.scheme() == QLatin1String("https"));
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
QString ImageCache::cachePathFor(const QString &url) const {
|
QString ImageCache::cachePathFor(const QString &url) const {
|
||||||
const QByteArray hash = QCryptographicHash::hash(
|
const QByteArray hash = QCryptographicHash::hash(
|
||||||
url.toUtf8(), QCryptographicHash::Sha1).toHex();
|
url.toUtf8(), QCryptographicHash::Sha1).toHex();
|
||||||
|
|
@ -49,6 +63,16 @@ QPixmap ImageCache::loadLocal(const QString &path) {
|
||||||
|
|
||||||
QPixmap ImageCache::get(const QString &url) {
|
QPixmap ImageCache::get(const QString &url) {
|
||||||
if (url.isEmpty()) return {};
|
if (url.isEmpty()) return {};
|
||||||
|
// Snapshots synced from the Android app can carry local file:// covers
|
||||||
|
// (picked from the gallery there); render them straight from disk.
|
||||||
|
if (url.startsWith(QLatin1String("file://"), Qt::CaseInsensitive)) {
|
||||||
|
const QString local = QUrl(url).toLocalFile();
|
||||||
|
QPixmap pm;
|
||||||
|
if (!local.isEmpty() && pm.load(local))
|
||||||
|
return pm;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!isFetchableUrl(url)) return {};
|
||||||
if (m_mem.contains(url)) return m_mem.value(url);
|
if (m_mem.contains(url)) return m_mem.value(url);
|
||||||
|
|
||||||
const QString cp = cachePathFor(url);
|
const QString cp = cachePathFor(url);
|
||||||
|
|
@ -72,7 +96,10 @@ QPixmap ImageCache::get(const QString &url) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImageCache::downloadToLibrary(const QString &url) {
|
void ImageCache::downloadToLibrary(const QString &url) {
|
||||||
if (url.isEmpty()) return;
|
if (url.isEmpty() || !isFetchableUrl(url)) {
|
||||||
|
emit failed(url, QStringLiteral("Ungültige Cover-URL"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
QNetworkRequest req{QUrl(url)};
|
QNetworkRequest req{QUrl(url)};
|
||||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
|
@ -92,7 +119,18 @@ void ImageCache::onFinished(QNetworkReply *reply) {
|
||||||
emit failed(url, reply->errorString());
|
emit failed(url, reply->errorString());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Reject oversized bodies announced upfront (protects the disk cache).
|
||||||
|
const QVariant len = reply->header(QNetworkRequest::ContentLengthHeader);
|
||||||
|
if (len.isValid() && len.toLongLong() > MAX_IMAGE_BYTES) {
|
||||||
|
reply->abort();
|
||||||
|
emit failed(url, QStringLiteral("Bild ist zu groß"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const QByteArray data = reply->readAll();
|
const QByteArray data = reply->readAll();
|
||||||
|
if (data.size() > MAX_IMAGE_BYTES) {
|
||||||
|
emit failed(url, QStringLiteral("Bild ist zu groß"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
QPixmap pm;
|
QPixmap pm;
|
||||||
if (!pm.loadFromData(data)) {
|
if (!pm.loadFromData(data)) {
|
||||||
emit failed(url, QStringLiteral("Bild konnte nicht dekodiert werden"));
|
emit failed(url, QStringLiteral("Bild konnte nicht dekodiert werden"));
|
||||||
|
|
@ -115,7 +153,7 @@ void ImageCache::onFinished(QNetworkReply *reply) {
|
||||||
if (out.open(QIODevice::WriteOnly)) {
|
if (out.open(QIODevice::WriteOnly)) {
|
||||||
out.write(data);
|
out.write(data);
|
||||||
out.close();
|
out.close();
|
||||||
emit saved(name); // store library-relative name
|
emit saved(url, name); // store library-relative name
|
||||||
} else {
|
} else {
|
||||||
emit failed(url, QStringLiteral("Cover konnte nicht gespeichert werden"));
|
emit failed(url, QStringLiteral("Cover konnte nicht gespeichert werden"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ public:
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void ready(const QString &url, const QPixmap &pixmap);
|
void ready(const QString &url, const QPixmap &pixmap);
|
||||||
void saved(const QString &localPath);
|
// Fires with the source url and the library-relative file name so callers
|
||||||
|
// can match the download they started (guards against mixed-up covers when
|
||||||
|
// several downloads finish in any order).
|
||||||
|
void saved(const QString &url, const QString &localPath);
|
||||||
void failed(const QString &url, const QString &error);
|
void failed(const QString &url, const QString &error);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,14 @@ constexpr int ABYTES = 16; // crypto_aead_xchacha20poly1305_ietf_ABYTES
|
||||||
constexpr quint64 OPSLIMIT = 2ULL; // crypto_pwhash_OPSLIMIT_INTERACTIVE
|
constexpr quint64 OPSLIMIT = 2ULL; // crypto_pwhash_OPSLIMIT_INTERACTIVE
|
||||||
constexpr quint64 MEMLIMIT = 67108864ULL; // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
|
constexpr quint64 MEMLIMIT = 67108864ULL; // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
|
||||||
|
|
||||||
|
// Upper bounds accepted when reading an envelope. The header is untrusted
|
||||||
|
// input (a malicious server can craft it), so unbounded values would let a
|
||||||
|
// crafted snapshot force huge Argon2 allocations (DoS). Legit envelopes carry
|
||||||
|
// OPSLIMIT/MEMLIMIT; the headroom keeps future, slightly stronger defaults
|
||||||
|
// decryptable. Must stay in sync with the Android client.
|
||||||
|
constexpr quint64 MAX_OPS = 8ULL;
|
||||||
|
constexpr quint64 MAX_MEM = 268435456ULL; // 256 MiB
|
||||||
|
|
||||||
constexpr int HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES;
|
constexpr int HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES;
|
||||||
|
|
||||||
void setError(QString *error, const QString &msg) {
|
void setError(QString *error, const QString &msg) {
|
||||||
|
|
@ -124,6 +132,10 @@ QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
|
||||||
memcpy(&memBE, p + off, 4); off += 4;
|
memcpy(&memBE, p + off, 4); off += 4;
|
||||||
const quint64 ops = qFromBigEndian<quint32>(opsBE);
|
const quint64 ops = qFromBigEndian<quint32>(opsBE);
|
||||||
const quint64 mem = qFromBigEndian<quint32>(memBE);
|
const quint64 mem = qFromBigEndian<quint32>(memBE);
|
||||||
|
if (ops == 0 || mem == 0 || ops > MAX_OPS || mem > MAX_MEM) {
|
||||||
|
setError(error, QStringLiteral("Ungültige Krypto-Parameter im Datenformat"));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
const QByteArray salt = envelope.mid(off, SALT_BYTES); off += SALT_BYTES;
|
const QByteArray salt = envelope.mid(off, SALT_BYTES); off += SALT_BYTES;
|
||||||
const QByteArray nonce = envelope.mid(off, NONCE_BYTES); off += NONCE_BYTES;
|
const QByteArray nonce = envelope.mid(off, NONCE_BYTES); off += NONCE_BYTES;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ SyncClient::HttpResponse SyncClient::request(const QString &method,
|
||||||
const QByteArray &body) {
|
const QByteArray &body) {
|
||||||
HttpResponse out;
|
HttpResponse out;
|
||||||
QNetworkRequest req(QUrl(baseUrl() + path));
|
QNetworkRequest req(QUrl(baseUrl() + path));
|
||||||
|
// Bound every request so a dead/unreachable server can never hang the
|
||||||
|
// (user-triggered) sync forever.
|
||||||
|
req.setTransferTimeout(30000);
|
||||||
req.setHeader(QNetworkRequest::UserAgentHeader,
|
req.setHeader(QNetworkRequest::UserAgentHeader,
|
||||||
QStringLiteral("UltimateMediaTracker/1.0"));
|
QStringLiteral("UltimateMediaTracker/1.0"));
|
||||||
req.setRawHeader("Authorization",
|
req.setRawHeader("Authorization",
|
||||||
|
|
@ -185,15 +188,8 @@ bool SyncClient::replaceLibrary(const QByteArray &snapshotJson, QString *error)
|
||||||
if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error))
|
if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// All-or-nothing replace: wipe the current library, then insert the snapshot.
|
// All-or-nothing replace in a single DB transaction; changed() fires once.
|
||||||
const QVector<MediaItem> existing = m_db->queryItems(FilterCriteria{});
|
return m_db->replaceAll(incoming, error);
|
||||||
for (const MediaItem &m : existing)
|
|
||||||
m_db->deleteItem(m.id);
|
|
||||||
for (MediaItem m : incoming) {
|
|
||||||
m.id = -1;
|
|
||||||
m_db->saveItem(m);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace umt
|
} // namespace umt
|
||||||
|
|
|
||||||
|
|
@ -375,10 +375,19 @@ void DetailDialog::changeCoverViaSearch()
|
||||||
const SearchResult res = dlg.selectedResult();
|
const SearchResult res = dlg.selectedResult();
|
||||||
if (res.coverUrl.isEmpty()) return;
|
if (res.coverUrl.isEmpty()) return;
|
||||||
|
|
||||||
connect(m_cache, &ImageCache::saved, this, [this](const QString &localName){
|
const QString wantUrl = res.coverUrl;
|
||||||
m_item.coverPath = localName;
|
const int itemId = m_id;
|
||||||
m_item.coverUrl.clear();
|
Database *db = m_db;
|
||||||
m_db->saveItem(m_item);
|
connect(m_cache, &ImageCache::saved, this,
|
||||||
|
[this, db, itemId, wantUrl](const QString &url, const QString &localName){
|
||||||
|
if (url != wantUrl) return; // never apply a different download's cover
|
||||||
|
// Re-read the row so progress changed in the meantime survives the save.
|
||||||
|
auto fresh = db->loadItem(itemId);
|
||||||
|
if (!fresh) return;
|
||||||
|
fresh->coverPath = localName;
|
||||||
|
fresh->coverUrl.clear();
|
||||||
|
db->saveItem(*fresh);
|
||||||
|
m_item = *fresh;
|
||||||
QPixmap pm = ImageCache::loadLocal(localName);
|
QPixmap pm = ImageCache::loadLocal(localName);
|
||||||
m_cover->setPixmap(rounded(pm, 244, 354, 12));
|
m_cover->setPixmap(rounded(pm, 244, 354, 12));
|
||||||
emit itemModified();
|
emit itemModified();
|
||||||
|
|
@ -389,8 +398,14 @@ void DetailDialog::changeCoverViaSearch()
|
||||||
void DetailDialog::persistNotes()
|
void DetailDialog::persistNotes()
|
||||||
{
|
{
|
||||||
if (m_notes->toPlainText() != m_item.notes) {
|
if (m_notes->toPlainText() != m_item.notes) {
|
||||||
m_item.notes = m_notes->toPlainText();
|
// Re-read the row first: progress toggles written directly to the DB
|
||||||
m_db->saveItem(m_item);
|
// since reload() must survive this full-item save, and m_item's stale
|
||||||
|
// segment tree would otherwise revert them.
|
||||||
|
auto fresh = m_db->loadItem(m_id);
|
||||||
|
if (!fresh) return;
|
||||||
|
fresh->notes = m_notes->toPlainText();
|
||||||
|
m_db->saveItem(*fresh);
|
||||||
|
m_item.notes = fresh->notes;
|
||||||
emit itemModified();
|
emit itemModified();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -487,11 +487,15 @@ void EditDialog::accept()
|
||||||
if (!m_pendingCoverUrl.isEmpty() && m_settings->autoFetchCovers()) {
|
if (!m_pendingCoverUrl.isEmpty() && m_settings->autoFetchCovers()) {
|
||||||
const int id = m_item.id;
|
const int id = m_item.id;
|
||||||
Database *db = m_db;
|
Database *db = m_db;
|
||||||
|
const QString wantUrl = m_pendingCoverUrl;
|
||||||
// Bind the context to the long-lived database, NOT to this dialog:
|
// Bind the context to the long-lived database, NOT to this dialog:
|
||||||
// the dialog is destroyed right after accept(), well before the async
|
// the dialog is destroyed right after accept(), well before the async
|
||||||
// download finishes, which previously dropped the cover update.
|
// download finishes, which previously dropped the cover update.
|
||||||
connect(m_cache, &ImageCache::saved, db,
|
connect(m_cache, &ImageCache::saved, db,
|
||||||
[db, id](const QString &localName){
|
[db, id, wantUrl](const QString &url, const QString &localName){
|
||||||
|
// Only accept the download this item started — several downloads
|
||||||
|
// can finish in any order and must not swap covers.
|
||||||
|
if (url != wantUrl) return;
|
||||||
if (auto opt = db->loadItem(id)) {
|
if (auto opt = db->loadItem(id)) {
|
||||||
MediaItem mi = *opt;
|
MediaItem mi = *opt;
|
||||||
mi.coverPath = localName;
|
mi.coverPath = localName;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#include "ui/SettingsDialog.h"
|
#include "ui/SettingsDialog.h"
|
||||||
#include "core/Settings.h"
|
#include "core/Settings.h"
|
||||||
#include "sync/SyncClient.h"
|
#include "sync/SyncClient.h"
|
||||||
|
#include "sync/LibrarySerializer.h"
|
||||||
#include "providers/ProviderManager.h"
|
#include "providers/ProviderManager.h"
|
||||||
#include "providers/ImageCache.h"
|
#include "providers/ImageCache.h"
|
||||||
|
|
||||||
|
|
@ -27,6 +28,8 @@
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QStackedWidget>
|
#include <QStackedWidget>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFile>
|
||||||
|
|
||||||
namespace umt {
|
namespace umt {
|
||||||
|
|
||||||
|
|
@ -46,6 +49,8 @@ MainWindow::MainWindow(Database *db, AppSettings *settings, ThemeManager *theme,
|
||||||
|
|
||||||
buildUi();
|
buildUi();
|
||||||
|
|
||||||
|
m_syncClient = new SyncClient(m_db, m_settings, this);
|
||||||
|
|
||||||
connect(m_db, &Database::changed, this, [this]{
|
connect(m_db, &Database::changed, this, [this]{
|
||||||
rebuildFilterLists();
|
rebuildFilterLists();
|
||||||
});
|
});
|
||||||
|
|
@ -156,6 +161,19 @@ void MainWindow::buildUi()
|
||||||
connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings);
|
connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings);
|
||||||
tb->addWidget(settingsBtn);
|
tb->addWidget(settingsBtn);
|
||||||
|
|
||||||
|
auto *menuBtn = new QToolButton(topBar);
|
||||||
|
menuBtn->setText(QStringLiteral("⋮"));
|
||||||
|
menuBtn->setObjectName(QStringLiteral("IconButton"));
|
||||||
|
menuBtn->setFixedSize(40, rowH);
|
||||||
|
auto *libMenu = new QMenu(menuBtn);
|
||||||
|
libMenu->addAction(QStringLiteral("Bibliothek exportieren…"), this,
|
||||||
|
&MainWindow::exportLibrary);
|
||||||
|
libMenu->addAction(QStringLiteral("Bibliothek importieren…"), this,
|
||||||
|
&MainWindow::importLibrary);
|
||||||
|
menuBtn->setMenu(libMenu);
|
||||||
|
menuBtn->setPopupMode(QToolButton::InstantPopup);
|
||||||
|
tb->addWidget(menuBtn);
|
||||||
|
|
||||||
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
|
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
|
||||||
|
|
||||||
libLayout->addWidget(topBar);
|
libLayout->addWidget(topBar);
|
||||||
|
|
@ -383,8 +401,7 @@ void MainWindow::openSettings()
|
||||||
|
|
||||||
void MainWindow::syncNow()
|
void MainWindow::syncNow()
|
||||||
{
|
{
|
||||||
SyncClient client(m_db, m_settings, this);
|
if (!m_syncClient->isConfigured()) {
|
||||||
if (!client.isConfigured()) {
|
|
||||||
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
|
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
|
||||||
QStringLiteral("Cloud-Sync ist nicht vollständig konfiguriert. "
|
QStringLiteral("Cloud-Sync ist nicht vollständig konfiguriert. "
|
||||||
"Bitte in den Einstellungen den Speicherort auf „Cloud-Sync“ "
|
"Bitte in den Einstellungen den Speicherort auf „Cloud-Sync“ "
|
||||||
|
|
@ -392,13 +409,22 @@ void MainWindow::syncNow()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sync runs a nested event loop; locking the whole window prevents
|
||||||
|
// re-entrant edits/deletes while the library is being replaced.
|
||||||
|
QWidget *ui = centralWidget();
|
||||||
|
ui->setEnabled(false);
|
||||||
m_syncBtn->setEnabled(false);
|
m_syncBtn->setEnabled(false);
|
||||||
QApplication::setOverrideCursor(Qt::WaitCursor);
|
QApplication::setOverrideCursor(Qt::WaitCursor);
|
||||||
|
auto unlock = [this, ui]() {
|
||||||
|
QApplication::restoreOverrideCursor();
|
||||||
|
ui->setEnabled(true);
|
||||||
|
m_syncBtn->setEnabled(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Upload local changes first; resolve a conflict by asking the user.
|
// Upload local changes first; resolve a conflict by asking the user.
|
||||||
SyncClient::Result up = client.push(false);
|
SyncClient::Result up = m_syncClient->push(false);
|
||||||
if (up.status == SyncClient::Status::Conflict) {
|
if (up.status == SyncClient::Status::Conflict) {
|
||||||
QApplication::restoreOverrideCursor();
|
unlock();
|
||||||
QMessageBox box(this);
|
QMessageBox box(this);
|
||||||
box.setIcon(QMessageBox::Question);
|
box.setIcon(QMessageBox::Question);
|
||||||
box.setWindowTitle(QStringLiteral("Sync-Konflikt"));
|
box.setWindowTitle(QStringLiteral("Sync-Konflikt"));
|
||||||
|
|
@ -413,11 +439,12 @@ void MainWindow::syncNow()
|
||||||
box.addButton(QStringLiteral("Lokal überschreiben"), QMessageBox::DestructiveRole);
|
box.addButton(QStringLiteral("Lokal überschreiben"), QMessageBox::DestructiveRole);
|
||||||
box.exec();
|
box.exec();
|
||||||
const bool loadServer = (box.clickedButton() == loadBtn);
|
const bool loadServer = (box.clickedButton() == loadBtn);
|
||||||
|
ui->setEnabled(false);
|
||||||
|
m_syncBtn->setEnabled(false);
|
||||||
QApplication::setOverrideCursor(Qt::WaitCursor);
|
QApplication::setOverrideCursor(Qt::WaitCursor);
|
||||||
if (loadServer) {
|
if (loadServer) {
|
||||||
const SyncClient::Result pulled = client.pull();
|
const SyncClient::Result pulled = m_syncClient->pull();
|
||||||
QApplication::restoreOverrideCursor();
|
unlock();
|
||||||
m_syncBtn->setEnabled(true);
|
|
||||||
if (pulled.status == SyncClient::Status::Success) {
|
if (pulled.status == SyncClient::Status::Success) {
|
||||||
rebuildFilterLists();
|
rebuildFilterLists();
|
||||||
refresh();
|
refresh();
|
||||||
|
|
@ -428,20 +455,18 @@ void MainWindow::syncNow()
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
up = client.push(true); // overwrite
|
up = m_syncClient->push(true); // overwrite
|
||||||
}
|
}
|
||||||
|
|
||||||
if (up.status != SyncClient::Status::Success) {
|
if (up.status != SyncClient::Status::Success) {
|
||||||
QApplication::restoreOverrideCursor();
|
unlock();
|
||||||
m_syncBtn->setEnabled(true);
|
|
||||||
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), up.message);
|
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), up.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then pull the canonical server state back down so this device matches it.
|
// Then pull the canonical server state back down so this device matches it.
|
||||||
const SyncClient::Result down = client.pull();
|
const SyncClient::Result down = m_syncClient->pull();
|
||||||
QApplication::restoreOverrideCursor();
|
unlock();
|
||||||
m_syncBtn->setEnabled(true);
|
|
||||||
if (down.status != SyncClient::Status::Success) {
|
if (down.status != SyncClient::Status::Success) {
|
||||||
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), down.message);
|
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), down.message);
|
||||||
return;
|
return;
|
||||||
|
|
@ -452,6 +477,65 @@ void MainWindow::syncNow()
|
||||||
QStringLiteral("Bibliothek synchronisiert."));
|
QStringLiteral("Bibliothek synchronisiert."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::exportLibrary()
|
||||||
|
{
|
||||||
|
const QString path = QFileDialog::getSaveFileName(
|
||||||
|
this, QStringLiteral("Bibliothek exportieren"), QString(),
|
||||||
|
QStringLiteral("JSON (*.json);;Alle Dateien (*)"));
|
||||||
|
if (path.isEmpty()) return;
|
||||||
|
|
||||||
|
const QByteArray json = LibrarySerializer::toJson(m_db->queryItems(FilterCriteria{}));
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::WriteOnly)) {
|
||||||
|
QMessageBox::warning(this, QStringLiteral("Export"),
|
||||||
|
QStringLiteral("Datei konnte nicht geschrieben werden:\n%1").arg(path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
f.write(json);
|
||||||
|
f.close();
|
||||||
|
QMessageBox::information(this, QStringLiteral("Export"),
|
||||||
|
QStringLiteral("Bibliothek exportiert nach:\n%1").arg(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::importLibrary()
|
||||||
|
{
|
||||||
|
const QString path = QFileDialog::getOpenFileName(
|
||||||
|
this, QStringLiteral("Bibliothek importieren"), QString(),
|
||||||
|
QStringLiteral("JSON (*.json);;Alle Dateien (*)"));
|
||||||
|
if (path.isEmpty()) return;
|
||||||
|
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) {
|
||||||
|
QMessageBox::warning(this, QStringLiteral("Import"),
|
||||||
|
QStringLiteral("Datei konnte nicht gelesen werden:\n%1").arg(path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QByteArray json = f.readAll();
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
QVector<MediaItem> items;
|
||||||
|
QString err;
|
||||||
|
if (!LibrarySerializer::fromJson(json, &items, &err)) {
|
||||||
|
QMessageBox::warning(this, QStringLiteral("Import"),
|
||||||
|
QStringLiteral("Ungültige Datei:\n%1").arg(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (QMessageBox::question(this, QStringLiteral("Import"),
|
||||||
|
QStringLiteral("%1 Einträge importieren?\n\n"
|
||||||
|
"Die aktuelle Bibliothek wird dabei vollständig "
|
||||||
|
"ersetzt.").arg(items.size()))
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!m_db->replaceAll(items, &err)) {
|
||||||
|
QMessageBox::critical(this, QStringLiteral("Import"),
|
||||||
|
QStringLiteral("Import fehlgeschlagen:\n%1").arg(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rebuildFilterLists();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::toggleTheme()
|
void MainWindow::toggleTheme()
|
||||||
{
|
{
|
||||||
m_settings->setDarkMode(!m_settings->darkMode());
|
m_settings->setDarkMode(!m_settings->darkMode());
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class ProviderManager;
|
||||||
class ImageCache;
|
class ImageCache;
|
||||||
class FilterPanel;
|
class FilterPanel;
|
||||||
class FlowLayout;
|
class FlowLayout;
|
||||||
|
class SyncClient;
|
||||||
|
|
||||||
// Top-level window: sidebar (media-type sections + favorites + stats),
|
// Top-level window: sidebar (media-type sections + favorites + stats),
|
||||||
// toolbar (search, sort, add, settings, theme) and the card grid.
|
// toolbar (search, sort, add, settings, theme) and the card grid.
|
||||||
|
|
@ -43,6 +44,8 @@ private slots:
|
||||||
void openSettings();
|
void openSettings();
|
||||||
void syncNow();
|
void syncNow();
|
||||||
void toggleTheme();
|
void toggleTheme();
|
||||||
|
void importLibrary();
|
||||||
|
void exportLibrary();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void buildUi();
|
void buildUi();
|
||||||
|
|
@ -60,6 +63,7 @@ private:
|
||||||
ThemeManager *m_theme;
|
ThemeManager *m_theme;
|
||||||
ProviderManager *m_providers;
|
ProviderManager *m_providers;
|
||||||
ImageCache *m_cache;
|
ImageCache *m_cache;
|
||||||
|
SyncClient *m_syncClient = nullptr; // reused for every sync
|
||||||
|
|
||||||
std::optional<MediaType> m_section; // nullopt = "Alle"
|
std::optional<MediaType> m_section; // nullopt = "Alle"
|
||||||
bool m_favoritesSection = false;
|
bool m_favoritesSection = false;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
#include <QColorDialog>
|
#include <QColorDialog>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
namespace umt {
|
namespace umt {
|
||||||
|
|
||||||
|
|
@ -211,6 +212,22 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
|
||||||
connect(m_tmdbMode, &QComboBox::currentIndexChanged,
|
connect(m_tmdbMode, &QComboBox::currentIndexChanged,
|
||||||
this, &SettingsDialog::updateTmdbModeUi);
|
this, &SettingsDialog::updateTmdbModeUi);
|
||||||
connect(bb, &QDialogButtonBox::accepted, this, [this]{
|
connect(bb, &QDialogButtonBox::accepted, this, [this]{
|
||||||
|
const QString purl = m_proxyUrl->text().trimmed();
|
||||||
|
if (purl.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive)) {
|
||||||
|
const QString host = QUrl(purl).host();
|
||||||
|
const bool local = (host == QLatin1String("localhost") ||
|
||||||
|
host == QLatin1String("127.0.0.1") ||
|
||||||
|
host == QLatin1String("::1"));
|
||||||
|
if (!local &&
|
||||||
|
QMessageBox::warning(this, QStringLiteral("Unverschlüsselte Verbindung"),
|
||||||
|
QStringLiteral("Die Proxy-URL verwendet http://. Token und "
|
||||||
|
"Sync-Daten würden unverschlüsselt übertragen "
|
||||||
|
"und könnten mitgelesen werden.\n\n"
|
||||||
|
"Wirklich fortfahren?"),
|
||||||
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No)
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
}
|
||||||
m_settings->setDarkMode(m_theme->currentData().toBool());
|
m_settings->setDarkMode(m_theme->currentData().toBool());
|
||||||
m_settings->setPreferredLanguage(m_language->currentData().toString());
|
m_settings->setPreferredLanguage(m_language->currentData().toString());
|
||||||
m_settings->setTmdbMode(m_tmdbMode->currentData().toString());
|
m_settings->setTmdbMode(m_tmdbMode->currentData().toString());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue