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