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
android:name=".UmtApp"
android:allowBackup="true"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"

View file

@ -35,7 +35,7 @@ object Graph {
fun provide(context: Context) {
val app = context.applicationContext
val db = UmtDatabase.get(app)
repository = LibraryRepository(app, db.mediaDao())
repository = LibraryRepository(app, db)
syncSettings = SyncSettings(app)
syncClient = SyncClient(repository, syncSettings)
appPrefs = AppPrefs(app)

View file

@ -1,11 +1,13 @@
package com.umt.tracker.data
import android.content.Context
import androidx.room.withTransaction
import com.umt.tracker.data.json.LibraryExport
import com.umt.tracker.data.json.LibraryJson
import com.umt.tracker.data.json.toDomain
import com.umt.tracker.data.json.toDto
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.toEntity
import com.umt.tracker.domain.MediaItem
@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
@ -40,8 +43,10 @@ data class LibraryStats(
*/
class LibraryRepository(
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>> =
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). */
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) }
/** Marks one unit (episode/chapter) watched or not, by its (segment, unit) number. */
@ -108,9 +115,29 @@ class LibraryRepository(
})
}
private suspend inline fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
val current = getItem(id) ?: return
save(transform(current))
private suspend fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
// 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))
}
}
/**
* 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. */
@ -131,17 +158,19 @@ class LibraryRepository(
}
suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) {
val items = dao.getAll().map { it.toDomain() }
LibraryStats(
total = items.size,
completed = items.count { it.status == WatchStatus.COMPLETED },
inProgress = items.count { it.status == WatchStatus.IN_PROGRESS },
planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH },
favorites = items.count { it.favorite },
perType = items.groupingBy { it.type }.eachCount(),
)
statsOf(dao.getAll().map { it.toDomain() })
}
/** Pure computation so UI flows can re-derive stats from observed items. */
fun statsOf(items: List<MediaItem>): LibraryStats = LibraryStats(
total = items.size,
completed = items.count { it.status == WatchStatus.COMPLETED },
inProgress = items.count { it.status == WatchStatus.IN_PROGRESS },
planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH },
favorites = items.count { it.favorite },
perType = items.groupingBy { it.type }.eachCount(),
)
/**
* Replaces the whole library with the contents of [stream]. Import is
* all-or-nothing: a parse error leaves the existing data untouched.
@ -153,20 +182,28 @@ class LibraryRepository(
/**
* 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) {
try {
val export = LibraryJson.instance.decodeFromString(LibraryExport.serializer(), text)
val entities = export.items.map { it.toDomain().toEntity() }
dao.clear()
dao.upsertAll(entities)
database.withTransaction {
dao.clear()
dao.upsertAll(entities)
}
ImportResult.Success(entities.size)
} catch (e: Exception) {
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. */
suspend fun snapshotJson(): String = withContext(Dispatchers.IO) {
val items = dao.getAll().map { it.toDomain().toDto() }

View file

@ -40,4 +40,21 @@ interface MediaDao {
@Query("DELETE FROM media")
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,
UmtDatabase::class.java,
"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 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 val random = SecureRandom()
@ -86,6 +93,8 @@ object CryptoEnvelope {
if (version != VERSION) throw CryptoException("Nicht unterstützte Version: $version")
val ops = 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 nonce = ByteArray(NONCE_BYTES).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.")
} catch (e: IOException) {
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.")
} catch (e: IOException) {
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
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.foundation.background
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.FavoriteBorder
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
@ -33,12 +38,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@ -49,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.StarRatingInput
import com.umt.tracker.ui.components.TagChip
import kotlinx.coroutines.delay
import java.io.File
import java.util.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -79,6 +90,17 @@ fun DetailScreen(
) {
val item by viewModel.item.collectAsStateWithLifecycle()
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(
topBar = {
@ -122,6 +144,7 @@ fun DetailScreen(
item = current,
viewModel = viewModel,
onChangeCover = { onChangeCover(current.type, current.title) },
onPickCoverFromGallery = { galleryPicker.launch("image/*") },
modifier = Modifier.padding(padding),
)
}
@ -150,6 +173,7 @@ private fun DetailContent(
item: MediaItem,
viewModel: DetailViewModel,
onChangeCover: () -> Unit,
onPickCoverFromGallery: () -> Unit,
modifier: Modifier = Modifier,
) {
// 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))
}
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)) {
Text(
@ -301,26 +333,55 @@ private fun DetailContent(
}
}
if (item.notes.isNotBlank()) {
item {
Column {
Text(
text = "Notizen",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = item.notes,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
item {
// Editable notes, saved debounced like the desktop dialog.
Column {
Text(
text = "Notizen",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
var notes by remember(item.id) { mutableStateOf(item.notes) }
var initialized by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(notes) {
if (!initialized) {
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
private fun SegmentRow(
segment: Segment,

View file

@ -28,8 +28,14 @@ class DetailViewModel(
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) }
/** 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) =
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
@ -58,6 +61,7 @@ fun EditScreen(
) {
val form by viewModel.form.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var saveError by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
@ -70,7 +74,17 @@ fun EditScreen(
},
actions = {
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") }
},
colors = TopAppBarDefaults.topAppBarColors(
@ -88,6 +102,13 @@ fun EditScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
saveError?.let { err ->
Text(
text = err,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
LabeledDropdown(
label = "Typ",
selected = form.type,
@ -221,7 +242,19 @@ fun EditScreen(
}
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),
) { 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 })
}
/** 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
if (s.title.isBlank()) return null
return repo.save(s.toMediaItem())
if (s.title.isBlank()) return SaveResult.EmptyTitle
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(

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.BarChart
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.Search
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) {
message?.let {
snackbarHostState.showSnackbar(it)
@ -131,6 +141,9 @@ fun LibraryScreen(
IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren")
}
IconButton(onClick = { exportPicker.launch("media-library.json") }) {
Icon(Icons.Filled.FileUpload, contentDescription = "Bibliothek exportieren")
}
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Einstellungen")
}
@ -188,6 +201,7 @@ fun LibraryScreen(
FilterSheet(
filter = state.filter,
allGenres = state.allGenres,
allTags = state.allTags,
allFranchises = state.allFranchises,
onChange = viewModel::updateFilter,
onClear = viewModel::clearFilters,
@ -252,6 +266,7 @@ private fun EmptyState(onImport: () -> Unit) {
private fun FilterSheet(
filter: LibraryFilter,
allGenres: List<String>,
allTags: List<String>,
allFranchises: List<String>,
onChange: ((LibraryFilter) -> LibraryFilter) -> 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()) {
SectionTitle("Genres")
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()) {
SectionTitle("Reihe / Franchise")
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.SyncResult
import com.umt.tracker.data.sync.SyncSettings
import com.umt.tracker.domain.Franchise
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.WatchStatus
@ -34,8 +35,11 @@ data class LibraryFilter(
val status: WatchStatus? = null,
val favoritesOnly: Boolean = false,
val genres: Set<String> = emptySet(),
val tags: Set<String> = emptySet(),
val franchise: String? = null,
val minRating: Int = 0,
val yearFrom: Int = 0,
val yearTo: Int = 0,
val sortMode: SortMode = SortMode.DATE_ADDED,
val sortDescending: Boolean = true,
) {
@ -44,8 +48,11 @@ data class LibraryFilter(
get() = (if (status != null) 1 else 0) +
(if (favoritesOnly) 1 else 0) +
genres.size +
tags.size +
(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(
@ -54,6 +61,7 @@ data class LibraryUiState(
val typeFilter: MediaType? = null,
val filter: LibraryFilter = LibraryFilter(),
val allGenres: List<String> = emptyList(),
val allTags: List<String> = emptyList(),
val allFranchises: List<String> = emptyList(),
val loading: Boolean = true,
val syncEnabled: Boolean = false,
@ -74,12 +82,14 @@ class LibraryViewModel(
val uiState: StateFlow<LibraryUiState> =
combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f ->
val autoMap = Franchise.clusterTitles(items.map { it.title })
val filtered = items
.filter { it.matches(q, type, f) }
.filter { it.matches(q, type, f, autoMap) }
.sortedWith(f.comparator())
// Facets reflect the whole library, not the filtered view.
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() }
.groupingBy { it }.eachCount()
.filterValues { it >= 2 }.keys.sorted()
@ -89,6 +99,7 @@ class LibraryViewModel(
typeFilter = type,
filter = f,
allGenres = genres,
allTags = tags,
allFranchises = franchises,
loading = false,
syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
@ -99,7 +110,12 @@ class LibraryViewModel(
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 (q.isNotBlank() && !title.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.minRating > 0 && rating < f.minRating) 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
}
@ -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() {
viewModelScope.launch {
messages.value = when (val r = syncClient.pull()) {
is SyncResult.Success -> r.message
is SyncResult.Conflict -> "Konflikt bitte in den Einstellungen auflösen."
is SyncResult.Error -> r.message
messages.value = when (val pushed = syncClient.push()) {
is SyncResult.Conflict ->
"Konflikt: Der Server hat eine neuere Version (Revision " +
"${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.data.LibraryRepository
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.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class StatsViewModel(private val repo: LibraryRepository) : ViewModel() {
private val _stats = MutableStateFlow<LibraryStats?>(null)
val stats: StateFlow<LibraryStats?> = _stats.asStateFlow()
init {
viewModelScope.launch { _stats.value = repo.stats() }
}
/** Recomputed whenever the library changes, so stats never go stale. */
val stats: StateFlow<LibraryStats?> = repo.observeLibrary()
.map { repo.statsOf(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
companion object {
val Factory = object : ViewModelProvider.Factory {

View file

@ -28,6 +28,7 @@ type server struct {
oauth oauth2.Config
client *http.Client
store *store // nil when DATABASE_URL is unset (sync disabled)
tmdbLim *rateLimiter
}
func main() {
@ -54,6 +55,8 @@ func main() {
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
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 != "" {
@ -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"),
oauth2.VerifierOption(verifierCookie.Value))
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
}
@ -195,7 +200,8 @@ func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
}
idToken, err := s.verifier.Verify(ctx, rawIDToken)
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
}
@ -293,10 +299,15 @@ func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
}
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)
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.
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)
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
}
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 journal_mode = WAL;"));
initSchema();
if (!initSchema()) {
m_lastError = QStringLiteral("Datenbank-Schema konnte nicht initialisiert werden");
return false;
}
return true;
}
@ -66,8 +69,8 @@ bool Database::exec(const QString &sql) {
return true;
}
void Database::initSchema() {
exec(QStringLiteral(
bool Database::initSchema() {
if (!exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS media ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" type TEXT NOT NULL,"
@ -86,7 +89,8 @@ void Database::initSchema() {
" external_id TEXT,"
" external_source TEXT,"
" franchise TEXT"
");"));
");")))
return false;
// Migration: add the franchise column to libraries created before it existed.
{
@ -99,20 +103,20 @@ void Database::initSchema() {
hasFranchise = true;
break;
}
if (!hasFranchise)
exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT"));
if (!hasFranchise &&
!exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT")))
return false;
}
exec(QStringLiteral(
return exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" media_id INTEGER NOT NULL,"
" number INTEGER NOT NULL DEFAULT 0,"
" title TEXT,"
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
");"));
exec(QStringLiteral(
");"))
&& exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS units ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" segment_id INTEGER NOT NULL,"
@ -121,34 +125,30 @@ void Database::initSchema() {
" watched INTEGER NOT NULL DEFAULT 0,"
" watched_date TEXT,"
" FOREIGN KEY(segment_id) REFERENCES segments(id) ON DELETE CASCADE"
");"));
exec(QStringLiteral(
");"))
&& exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS genres ("
" media_id INTEGER NOT NULL,"
" genre TEXT NOT NULL,"
" PRIMARY KEY(media_id, genre),"
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
");"));
exec(QStringLiteral(
");"))
&& exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS tags ("
" media_id INTEGER NOT NULL,"
" tag TEXT NOT NULL,"
" PRIMARY KEY(media_id, tag),"
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
");"));
exec(QStringLiteral(
");"))
&& exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS localized_titles ("
" media_id INTEGER NOT NULL,"
" lang TEXT NOT NULL,"
" title TEXT NOT NULL,"
" PRIMARY KEY(media_id, lang),"
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
");"));
exec(QStringLiteral(
");"))
&& exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS custom_fields ("
" media_id INTEGER NOT NULL,"
" key TEXT NOT NULL,"
@ -163,7 +163,25 @@ void Database::initSchema() {
// ---------------------------------------------------------------------------
bool Database::saveItem(MediaItem &item) {
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);
if (item.id < 0) {
@ -201,7 +219,6 @@ bool Database::saveItem(MediaItem &item) {
if (!q.exec()) {
m_lastError = q.lastError().text();
db.rollback();
return false;
}
if (item.id < 0)
@ -209,12 +226,35 @@ bool Database::saveItem(MediaItem &item) {
if (!saveGenresTags(item) || !saveLocalized(item) ||
!saveCustomFields(item) || !saveSegments(item)) {
db.rollback();
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()) {
m_lastError = db.lastError().text();
if (error) *error = db.lastError().text();
db.rollback();
return false;
}
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.yearTo > 0) { wheres << QStringLiteral("media.year<=?"); binds << c.yearTo; }
if (!c.searchText.trimmed().isEmpty()) {
wheres << QStringLiteral("(media.title LIKE ? OR media.original_title LIKE ? "
"OR media.overview LIKE ?)");
const QString like = QStringLiteral("%%1%").arg(c.searchText.trimmed());
wheres << QStringLiteral("(media.title LIKE ? ESCAPE '\\' OR media.original_title LIKE ? ESCAPE '\\' "
"OR media.overview LIKE ? ESCAPE '\\')");
// 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;
}

View file

@ -53,6 +53,11 @@ public:
// Returns items (with full segment trees) matching the criteria.
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.
bool setUnitWatched(int unitId, bool watched);
bool setSegmentWatched(int segmentId, bool watched);
@ -82,7 +87,10 @@ signals:
private:
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;
// Auto-detected franchise label per distinct title (explicit overrides not
// applied here; callers layer those on top).

View file

@ -1,11 +1,25 @@
#include "core/Settings.h"
#include <QFile>
namespace umt {
AppSettings::AppSettings(QObject *parent)
: QObject(parent)
, 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 {
@ -49,6 +63,7 @@ QString AppSettings::tmdbApiKey() const {
}
void AppSettings::setTmdbApiKey(const QString &key) {
m_s.setValue(QStringLiteral("providers/tmdbKey"), key);
protectSettingsFile();
}
QString AppSettings::proxyUrl() const {
@ -63,6 +78,7 @@ QString AppSettings::proxyToken() const {
}
void AppSettings::setProxyToken(const QString &token) {
m_s.setValue(QStringLiteral("providers/proxyToken"), token);
protectSettingsFile();
}
QString AppSettings::rawgApiKey() const {
@ -70,6 +86,7 @@ QString AppSettings::rawgApiKey() const {
}
void AppSettings::setRawgApiKey(const QString &key) {
m_s.setValue(QStringLiteral("providers/rawgKey"), key);
protectSettingsFile();
}
QString AppSettings::storageMode() const {
@ -85,6 +102,7 @@ QString AppSettings::syncPassphrase() const {
}
void AppSettings::setSyncPassphrase(const QString &pass) {
m_s.setValue(QStringLiteral("sync/passphrase"), pass);
protectSettingsFile();
}
qlonglong AppSettings::syncRevision() const {

View file

@ -79,6 +79,11 @@ signals:
void cardSizeChanged(int px);
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;
};

View file

@ -8,6 +8,7 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QUrl>
#include <QUuid>
namespace umt {
@ -27,6 +28,19 @@ QString ImageCache::coversDir() {
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 {
const QByteArray hash = QCryptographicHash::hash(
url.toUtf8(), QCryptographicHash::Sha1).toHex();
@ -49,6 +63,16 @@ QPixmap ImageCache::loadLocal(const QString &path) {
QPixmap ImageCache::get(const QString &url) {
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);
const QString cp = cachePathFor(url);
@ -72,7 +96,10 @@ QPixmap ImageCache::get(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)};
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
@ -92,7 +119,18 @@ void ImageCache::onFinished(QNetworkReply *reply) {
emit failed(url, reply->errorString());
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();
if (data.size() > MAX_IMAGE_BYTES) {
emit failed(url, QStringLiteral("Bild ist zu groß"));
return;
}
QPixmap pm;
if (!pm.loadFromData(data)) {
emit failed(url, QStringLiteral("Bild konnte nicht dekodiert werden"));
@ -115,7 +153,7 @@ void ImageCache::onFinished(QNetworkReply *reply) {
if (out.open(QIODevice::WriteOnly)) {
out.write(data);
out.close();
emit saved(name); // store library-relative name
emit saved(url, name); // store library-relative name
} else {
emit failed(url, QStringLiteral("Cover konnte nicht gespeichert werden"));
}

View file

@ -32,7 +32,10 @@ public:
signals:
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);
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 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;
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;
const quint64 ops = qFromBigEndian<quint32>(opsBE);
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 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) {
HttpResponse out;
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,
QStringLiteral("UltimateMediaTracker/1.0"));
req.setRawHeader("Authorization",
@ -185,15 +188,8 @@ bool SyncClient::replaceLibrary(const QByteArray &snapshotJson, QString *error)
if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error))
return false;
// All-or-nothing replace: wipe the current library, then insert the snapshot.
const QVector<MediaItem> existing = m_db->queryItems(FilterCriteria{});
for (const MediaItem &m : existing)
m_db->deleteItem(m.id);
for (MediaItem m : incoming) {
m.id = -1;
m_db->saveItem(m);
}
return true;
// All-or-nothing replace in a single DB transaction; changed() fires once.
return m_db->replaceAll(incoming, error);
}
} // namespace umt

View file

@ -375,10 +375,19 @@ void DetailDialog::changeCoverViaSearch()
const SearchResult res = dlg.selectedResult();
if (res.coverUrl.isEmpty()) return;
connect(m_cache, &ImageCache::saved, this, [this](const QString &localName){
m_item.coverPath = localName;
m_item.coverUrl.clear();
m_db->saveItem(m_item);
const QString wantUrl = res.coverUrl;
const int itemId = m_id;
Database *db = m_db;
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);
m_cover->setPixmap(rounded(pm, 244, 354, 12));
emit itemModified();
@ -389,8 +398,14 @@ void DetailDialog::changeCoverViaSearch()
void DetailDialog::persistNotes()
{
if (m_notes->toPlainText() != m_item.notes) {
m_item.notes = m_notes->toPlainText();
m_db->saveItem(m_item);
// Re-read the row first: progress toggles written directly to the DB
// 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();
}
}

View file

@ -487,11 +487,15 @@ void EditDialog::accept()
if (!m_pendingCoverUrl.isEmpty() && m_settings->autoFetchCovers()) {
const int id = m_item.id;
Database *db = m_db;
const QString wantUrl = m_pendingCoverUrl;
// Bind the context to the long-lived database, NOT to this dialog:
// the dialog is destroyed right after accept(), well before the async
// download finishes, which previously dropped the cover update.
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)) {
MediaItem mi = *opt;
mi.coverPath = localName;

View file

@ -8,6 +8,7 @@
#include "ui/SettingsDialog.h"
#include "core/Settings.h"
#include "sync/SyncClient.h"
#include "sync/LibrarySerializer.h"
#include "providers/ProviderManager.h"
#include "providers/ImageCache.h"
@ -27,6 +28,8 @@
#include <QToolButton>
#include <QStackedWidget>
#include <QDialog>
#include <QFileDialog>
#include <QFile>
namespace umt {
@ -46,6 +49,8 @@ MainWindow::MainWindow(Database *db, AppSettings *settings, ThemeManager *theme,
buildUi();
m_syncClient = new SyncClient(m_db, m_settings, this);
connect(m_db, &Database::changed, this, [this]{
rebuildFilterLists();
});
@ -156,6 +161,19 @@ void MainWindow::buildUi()
connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings);
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"));
libLayout->addWidget(topBar);
@ -383,8 +401,7 @@ void MainWindow::openSettings()
void MainWindow::syncNow()
{
SyncClient client(m_db, m_settings, this);
if (!client.isConfigured()) {
if (!m_syncClient->isConfigured()) {
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
QStringLiteral("Cloud-Sync ist nicht vollständig konfiguriert. "
"Bitte in den Einstellungen den Speicherort auf „Cloud-Sync“ "
@ -392,13 +409,22 @@ void MainWindow::syncNow()
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);
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.
SyncClient::Result up = client.push(false);
SyncClient::Result up = m_syncClient->push(false);
if (up.status == SyncClient::Status::Conflict) {
QApplication::restoreOverrideCursor();
unlock();
QMessageBox box(this);
box.setIcon(QMessageBox::Question);
box.setWindowTitle(QStringLiteral("Sync-Konflikt"));
@ -413,11 +439,12 @@ void MainWindow::syncNow()
box.addButton(QStringLiteral("Lokal überschreiben"), QMessageBox::DestructiveRole);
box.exec();
const bool loadServer = (box.clickedButton() == loadBtn);
ui->setEnabled(false);
m_syncBtn->setEnabled(false);
QApplication::setOverrideCursor(Qt::WaitCursor);
if (loadServer) {
const SyncClient::Result pulled = client.pull();
QApplication::restoreOverrideCursor();
m_syncBtn->setEnabled(true);
const SyncClient::Result pulled = m_syncClient->pull();
unlock();
if (pulled.status == SyncClient::Status::Success) {
rebuildFilterLists();
refresh();
@ -428,20 +455,18 @@ void MainWindow::syncNow()
}
return;
}
up = client.push(true); // overwrite
up = m_syncClient->push(true); // overwrite
}
if (up.status != SyncClient::Status::Success) {
QApplication::restoreOverrideCursor();
m_syncBtn->setEnabled(true);
unlock();
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), up.message);
return;
}
// Then pull the canonical server state back down so this device matches it.
const SyncClient::Result down = client.pull();
QApplication::restoreOverrideCursor();
m_syncBtn->setEnabled(true);
const SyncClient::Result down = m_syncClient->pull();
unlock();
if (down.status != SyncClient::Status::Success) {
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), down.message);
return;
@ -452,6 +477,65 @@ void MainWindow::syncNow()
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()
{
m_settings->setDarkMode(!m_settings->darkMode());

View file

@ -24,6 +24,7 @@ class ProviderManager;
class ImageCache;
class FilterPanel;
class FlowLayout;
class SyncClient;
// Top-level window: sidebar (media-type sections + favorites + stats),
// toolbar (search, sort, add, settings, theme) and the card grid.
@ -43,6 +44,8 @@ private slots:
void openSettings();
void syncNow();
void toggleTheme();
void importLibrary();
void exportLibrary();
private:
void buildUi();
@ -60,6 +63,7 @@ private:
ThemeManager *m_theme;
ProviderManager *m_providers;
ImageCache *m_cache;
SyncClient *m_syncClient = nullptr; // reused for every sync
std::optional<MediaType> m_section; // nullopt = "Alle"
bool m_favoritesSection = false;

View file

@ -14,6 +14,7 @@
#include <QDialogButtonBox>
#include <QColorDialog>
#include <QMessageBox>
#include <QUrl>
namespace umt {
@ -211,6 +212,22 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
connect(m_tmdbMode, &QComboBox::currentIndexChanged,
this, &SettingsDialog::updateTmdbModeUi);
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->setPreferredLanguage(m_language->currentData().toString());
m_settings->setTmdbMode(m_tmdbMode->currentData().toString());