feat: add item notes and duplicate detection across platforms
- Add per-item notes editable in the detail and edit screens, synced between desktop and Android - Guard against duplicates by external ID and by type/year/title, mirroring the desktop findDuplicate logic in MediaDao - Wrap multi-step database operations in Room transactions - Disable Android auto backup and remove the destructive Room migration fallback so schema bumps fail loudly instead of wiping data - Bound crypto envelope KDF parameters when reading untrusted headers - Handle malformed server responses in SyncClient instead of crashing - Extend settings, database, and image cache on the desktop side
This commit is contained in:
parent
85f5c6dd4e
commit
2b35139364
30 changed files with 837 additions and 124 deletions
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
<application
|
||||
android:name=".UmtApp"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,10 +115,30 @@ class LibraryRepository(
|
|||
})
|
||||
}
|
||||
|
||||
private suspend inline fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
|
||||
val current = getItem(id) ?: return
|
||||
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. */
|
||||
suspend fun allGenres(): List<String> = withContext(Dispatchers.IO) {
|
||||
|
|
@ -131,8 +158,11 @@ class LibraryRepository(
|
|||
}
|
||||
|
||||
suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) {
|
||||
val items = dao.getAll().map { it.toDomain() }
|
||||
LibraryStats(
|
||||
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 },
|
||||
|
|
@ -140,7 +170,6 @@ class LibraryRepository(
|
|||
favorites = items.count { it.favorite },
|
||||
perType = items.groupingBy { it.type }.eachCount(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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() }
|
||||
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() }
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
113
android/app/src/main/java/com/umt/tracker/domain/Franchise.kt
Normal file
113
android/app/src/main/java/com/umt/tracker/domain/Franchise.kt
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package com.umt.tracker.domain
|
||||
|
||||
/**
|
||||
* Franchise auto-detection, ported 1:1 from the desktop app (MediaItem.h) so
|
||||
* both platforms group titles identically: a subtitle after a colon/dash, a
|
||||
* trailing "(year)" and a trailing sequel marker (number or roman numeral) are
|
||||
* dropped, and titles sharing a longest common word prefix collapse onto one
|
||||
* franchise label. An explicit franchise on the item always wins.
|
||||
*/
|
||||
object Franchise {
|
||||
|
||||
private val SEPARATORS = listOf(": ", " - ", " – ", " — ")
|
||||
private val TAIL_NUMERAL = Regex("\\s+(?:[0-9]{1,3}|[IVXLCDM]+)$")
|
||||
private val NON_WORD = Regex("[^\\p{L}\\p{N}]+")
|
||||
|
||||
private val STOP_WORDS = setOf(
|
||||
"the", "a", "an", "and", "of", "to", "in", "on", "for",
|
||||
"und", "der", "die", "das", "den", "dem", "ein", "eine",
|
||||
"le", "la", "les", "el", "il", "no", "wa",
|
||||
)
|
||||
|
||||
/** Normalized base name of a title (drops subtitle/year/sequel marker). */
|
||||
fun keyFromTitle(title: String): String {
|
||||
var t = title.trim()
|
||||
for (sep in SEPARATORS) {
|
||||
val idx = t.indexOf(sep)
|
||||
if (idx > 0) { t = t.substring(0, idx); break }
|
||||
}
|
||||
val paren = t.indexOf('(')
|
||||
if (paren > 0) t = t.substring(0, paren)
|
||||
t = t.trim()
|
||||
t = TAIL_NUMERAL.replace(t, "")
|
||||
return t.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The franchise an item belongs to: an explicit value always wins, otherwise
|
||||
* the auto-detected key of its title (optionally resolved through a
|
||||
* precomputed [clusterTitles] map).
|
||||
*/
|
||||
fun effective(item: MediaItem, autoMap: Map<String, String> = emptyMap()): String {
|
||||
item.franchise.trim().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
return autoMap[item.title] ?: keyFromTitle(item.title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups titles into franchises by their longest shared word prefix (used by
|
||||
* at least two titles). Returns a map: original title -> franchise label.
|
||||
*/
|
||||
fun clusterTitles(titlesIn: List<String>): Map<String, String> {
|
||||
// De-duplicate while preserving order.
|
||||
val list = LinkedHashSet<String>()
|
||||
for (t in titlesIn) if (t.isNotBlank()) list.add(t)
|
||||
val n = list.size
|
||||
val titles = list.toList()
|
||||
|
||||
val orig = ArrayList<List<String>>(n)
|
||||
val norm = ArrayList<List<String>>(n)
|
||||
for (i in 0 until n) {
|
||||
val base = keyFromTitle(titles[i])
|
||||
val words = base.split(NON_WORD).filter { it.isNotEmpty() }
|
||||
orig.add(words)
|
||||
norm.add(words.map { it.lowercase() })
|
||||
}
|
||||
|
||||
// Count how many titles share each word-prefix.
|
||||
val prefixCount = HashMap<String, Int>()
|
||||
for (words in norm) {
|
||||
val key = StringBuilder()
|
||||
for (w in words) {
|
||||
if (key.isNotEmpty()) key.append(' ')
|
||||
key.append(w)
|
||||
val k = key.toString()
|
||||
prefixCount[k] = (prefixCount[k] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
val labelForKey = HashMap<String, String>()
|
||||
val result = HashMap<String, String>()
|
||||
for (i in 0 until n) {
|
||||
// Longest prefix shared by at least two titles.
|
||||
var bestL = 0
|
||||
val key = StringBuilder()
|
||||
for (L in norm[i].indices) {
|
||||
if (L > 0) key.append(' ')
|
||||
key.append(norm[i][L])
|
||||
if ((prefixCount[key.toString()] ?: 0) >= 2) bestL = L + 1
|
||||
}
|
||||
if (bestL == 0) {
|
||||
result[titles[i]] = keyFromTitle(titles[i])
|
||||
continue
|
||||
}
|
||||
// Drop trailing stop words from the shared prefix.
|
||||
var len = bestL
|
||||
while (len > 1 && norm[i][len - 1] in STOP_WORDS) --len
|
||||
val hasContent = (0 until len).any { norm[i][it] !in STOP_WORDS }
|
||||
if (!hasContent) {
|
||||
result[titles[i]] = keyFromTitle(titles[i])
|
||||
continue
|
||||
}
|
||||
val nkey = StringBuilder()
|
||||
val disp = StringBuilder()
|
||||
for (j in 0 until len) {
|
||||
if (j > 0) { nkey.append(' '); disp.append(' ') }
|
||||
nkey.append(norm[i][j])
|
||||
disp.append(orig[i][j])
|
||||
}
|
||||
if (nkey.toString() !in labelForKey) labelForKey[nkey.toString()] = disp.toString()
|
||||
result[titles[i]] = labelForKey[nkey.toString()]!!
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
package com.umt.tracker.ui.detail
|
||||
|
||||
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,25 +333,54 @@ private fun DetailContent(
|
|||
}
|
||||
}
|
||||
|
||||
if (item.notes.isNotBlank()) {
|
||||
item {
|
||||
// Editable notes, saved debounced like the desktop dialog.
|
||||
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),
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
48
server/ratelimit.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter is a minimal in-memory fixed-window limiter keyed by string
|
||||
// (bearer-token subject for the TMDB proxy). It blunts runaway clients abusing
|
||||
// the shared API key; it is not a full DoS defense.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
window time.Duration
|
||||
counts map[string]rateEntry
|
||||
}
|
||||
|
||||
type rateEntry struct {
|
||||
windowStart time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
func newRateLimiter(limit int, per time.Duration) *rateLimiter {
|
||||
return &rateLimiter{limit: limit, window: per, counts: map[string]rateEntry{}}
|
||||
}
|
||||
|
||||
func (r *rateLimiter) allow(key string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
e, ok := r.counts[key]
|
||||
if !ok || now.Sub(e.windowStart) >= r.window {
|
||||
// Occasionally garbage-collect stale keys so the map stays bounded.
|
||||
if len(r.counts) > 10_000 {
|
||||
for k, v := range r.counts {
|
||||
if now.Sub(v.windowStart) >= r.window {
|
||||
delete(r.counts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
r.counts[key] = rateEntry{windowStart: now, count: 1}
|
||||
return true
|
||||
}
|
||||
e.count++
|
||||
r.counts[key] = e
|
||||
return e.count <= r.limit
|
||||
}
|
||||
|
|
@ -51,7 +51,10 @@ bool Database::open(const QString &path) {
|
|||
}
|
||||
exec(QStringLiteral("PRAGMA foreign_keys = ON;"));
|
||||
exec(QStringLiteral("PRAGMA 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue