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

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