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:
Tronax 2026-08-16 12:04:28 +02:00
parent 85f5c6dd4e
commit 2b35139364
30 changed files with 837 additions and 124 deletions

View file

@ -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"

View file

@ -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)

View file

@ -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() }

View file

@ -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?
} }

View file

@ -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.
} }
} }
} }

View file

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

View file

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

View 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
}
}

View file

@ -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(

View file

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

View file

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

View file

@ -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(

View file

@ -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)) {

View file

@ -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."
}
} }
} }
} }

View file

@ -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 {

View file

@ -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
View 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
}

View file

@ -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;
} }

View file

@ -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).

View file

@ -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 {

View file

@ -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;
}; };

View file

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

View file

@ -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:

View file

@ -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;

View file

@ -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

View file

@ -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();
} }
} }

View file

@ -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;

View file

@ -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());

View file

@ -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;

View file

@ -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());