feat: initial commit for media tracking software
- Setup C++/Qt6 desktop application structure - Added initial Go proxy backend for TMDB/IGDB integration
This commit is contained in:
commit
3f07c78316
63 changed files with 6547 additions and 0 deletions
677
src/core/Database.cpp
Normal file
677
src/core/Database.cpp
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
#include "core/Database.h"
|
||||
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QSqlError>
|
||||
#include <QVariant>
|
||||
#include <QDateTime>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QUuid>
|
||||
#include <QVariantList>
|
||||
#include <QDebug>
|
||||
#include <algorithm>
|
||||
|
||||
namespace umt {
|
||||
|
||||
static QString dtToStr(const QDateTime &dt) {
|
||||
return dt.isValid() ? dt.toString(Qt::ISODate) : QString();
|
||||
}
|
||||
static QDateTime strToDt(const QString &s) {
|
||||
return s.isEmpty() ? QDateTime() : QDateTime::fromString(s, Qt::ISODate);
|
||||
}
|
||||
|
||||
Database::Database(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_connName(QStringLiteral("umt_%1").arg(QUuid::createUuid().toString(QUuid::Id128)))
|
||||
{
|
||||
}
|
||||
|
||||
Database::~Database() {
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName, false);
|
||||
if (db.isOpen()) db.close();
|
||||
}
|
||||
QSqlDatabase::removeDatabase(m_connName);
|
||||
}
|
||||
|
||||
bool Database::isOpen() const {
|
||||
return QSqlDatabase::database(m_connName, false).isOpen();
|
||||
}
|
||||
|
||||
bool Database::open(const QString &path) {
|
||||
QFileInfo fi(path);
|
||||
QDir().mkpath(fi.absolutePath());
|
||||
|
||||
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connName);
|
||||
db.setDatabaseName(path);
|
||||
if (!db.open()) {
|
||||
m_lastError = db.lastError().text();
|
||||
return false;
|
||||
}
|
||||
exec(QStringLiteral("PRAGMA foreign_keys = ON;"));
|
||||
exec(QStringLiteral("PRAGMA journal_mode = WAL;"));
|
||||
initSchema();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::exec(const QString &sql) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
if (!q.exec(sql)) {
|
||||
m_lastError = q.lastError().text();
|
||||
qWarning() << "SQL error:" << m_lastError << "for" << sql;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Database::initSchema() {
|
||||
exec(QStringLiteral(
|
||||
"CREATE TABLE IF NOT EXISTS media ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" type TEXT NOT NULL,"
|
||||
" title TEXT NOT NULL,"
|
||||
" original_title TEXT,"
|
||||
" cover_path TEXT,"
|
||||
" cover_url TEXT,"
|
||||
" status TEXT NOT NULL DEFAULT 'PlanToWatch',"
|
||||
" rating INTEGER NOT NULL DEFAULT 0,"
|
||||
" favorite INTEGER NOT NULL DEFAULT 0,"
|
||||
" year INTEGER NOT NULL DEFAULT 0,"
|
||||
" overview TEXT,"
|
||||
" notes TEXT,"
|
||||
" date_added TEXT,"
|
||||
" date_completed TEXT,"
|
||||
" external_id TEXT,"
|
||||
" external_source TEXT,"
|
||||
" franchise TEXT"
|
||||
");"));
|
||||
|
||||
// Migration: add the franchise column to libraries created before it existed.
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery info(db);
|
||||
bool hasFranchise = false;
|
||||
if (info.exec(QStringLiteral("PRAGMA table_info(media)")))
|
||||
while (info.next())
|
||||
if (info.value(1).toString() == QLatin1String("franchise")) {
|
||||
hasFranchise = true;
|
||||
break;
|
||||
}
|
||||
if (!hasFranchise)
|
||||
exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT"));
|
||||
}
|
||||
|
||||
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(
|
||||
"CREATE TABLE IF NOT EXISTS units ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" segment_id INTEGER NOT NULL,"
|
||||
" number INTEGER NOT NULL DEFAULT 0,"
|
||||
" title TEXT,"
|
||||
" watched INTEGER NOT NULL DEFAULT 0,"
|
||||
" watched_date TEXT,"
|
||||
" FOREIGN KEY(segment_id) REFERENCES segments(id) ON DELETE CASCADE"
|
||||
");"));
|
||||
|
||||
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(
|
||||
"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(
|
||||
"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(
|
||||
"CREATE TABLE IF NOT EXISTS custom_fields ("
|
||||
" media_id INTEGER NOT NULL,"
|
||||
" key TEXT NOT NULL,"
|
||||
" value TEXT,"
|
||||
" PRIMARY KEY(media_id, key),"
|
||||
" FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE"
|
||||
");"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save
|
||||
// ---------------------------------------------------------------------------
|
||||
bool Database::saveItem(MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
db.transaction();
|
||||
|
||||
QSqlQuery q(db);
|
||||
if (item.id < 0) {
|
||||
if (!item.dateAdded.isValid())
|
||||
item.dateAdded = QDateTime::currentDateTime();
|
||||
q.prepare(QStringLiteral(
|
||||
"INSERT INTO media (type,title,original_title,cover_path,cover_url,"
|
||||
"status,rating,favorite,year,overview,notes,date_added,date_completed,"
|
||||
"external_id,external_source,franchise) VALUES "
|
||||
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"));
|
||||
} else {
|
||||
q.prepare(QStringLiteral(
|
||||
"UPDATE media SET type=?,title=?,original_title=?,cover_path=?,cover_url=?,"
|
||||
"status=?,rating=?,favorite=?,year=?,overview=?,notes=?,date_added=?,"
|
||||
"date_completed=?,external_id=?,external_source=?,franchise=? WHERE id=?"));
|
||||
}
|
||||
q.addBindValue(mediaTypeToString(item.type));
|
||||
q.addBindValue(item.title);
|
||||
q.addBindValue(item.originalTitle);
|
||||
q.addBindValue(item.coverPath);
|
||||
q.addBindValue(item.coverUrl);
|
||||
q.addBindValue(statusToString(item.status));
|
||||
q.addBindValue(item.rating);
|
||||
q.addBindValue(item.favorite ? 1 : 0);
|
||||
q.addBindValue(item.year);
|
||||
q.addBindValue(item.overview);
|
||||
q.addBindValue(item.notes);
|
||||
q.addBindValue(dtToStr(item.dateAdded));
|
||||
q.addBindValue(dtToStr(item.dateCompleted));
|
||||
q.addBindValue(item.externalId);
|
||||
q.addBindValue(item.externalSource);
|
||||
q.addBindValue(item.franchise.trimmed());
|
||||
if (item.id >= 0)
|
||||
q.addBindValue(item.id);
|
||||
|
||||
if (!q.exec()) {
|
||||
m_lastError = q.lastError().text();
|
||||
db.rollback();
|
||||
return false;
|
||||
}
|
||||
if (item.id < 0)
|
||||
item.id = q.lastInsertId().toInt();
|
||||
|
||||
if (!saveGenresTags(item) || !saveLocalized(item) ||
|
||||
!saveCustomFields(item) || !saveSegments(item)) {
|
||||
db.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!db.commit()) {
|
||||
m_lastError = db.lastError().text();
|
||||
return false;
|
||||
}
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::saveSegments(MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery del(db);
|
||||
del.prepare(QStringLiteral("DELETE FROM segments WHERE media_id=?"));
|
||||
del.addBindValue(item.id);
|
||||
if (!del.exec()) { m_lastError = del.lastError().text(); return false; }
|
||||
|
||||
for (auto &seg : item.segments) {
|
||||
QSqlQuery sq(db);
|
||||
sq.prepare(QStringLiteral(
|
||||
"INSERT INTO segments (media_id,number,title) VALUES (?,?,?)"));
|
||||
sq.addBindValue(item.id);
|
||||
sq.addBindValue(seg.number);
|
||||
sq.addBindValue(seg.title);
|
||||
if (!sq.exec()) { m_lastError = sq.lastError().text(); return false; }
|
||||
seg.id = sq.lastInsertId().toInt();
|
||||
|
||||
for (auto &u : seg.units) {
|
||||
QSqlQuery uq(db);
|
||||
uq.prepare(QStringLiteral(
|
||||
"INSERT INTO units (segment_id,number,title,watched,watched_date) "
|
||||
"VALUES (?,?,?,?,?)"));
|
||||
uq.addBindValue(seg.id);
|
||||
uq.addBindValue(u.number);
|
||||
uq.addBindValue(u.title);
|
||||
uq.addBindValue(u.watched ? 1 : 0);
|
||||
uq.addBindValue(dtToStr(u.watchedDate));
|
||||
if (!uq.exec()) { m_lastError = uq.lastError().text(); return false; }
|
||||
u.id = uq.lastInsertId().toInt();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::saveGenresTags(const MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
for (const QString &table : {QStringLiteral("genres"), QStringLiteral("tags")}) {
|
||||
QSqlQuery d(db);
|
||||
d.prepare(QStringLiteral("DELETE FROM %1 WHERE media_id=?").arg(table));
|
||||
d.addBindValue(item.id);
|
||||
if (!d.exec()) { m_lastError = d.lastError().text(); return false; }
|
||||
}
|
||||
for (const QString &g : item.genres) {
|
||||
if (g.trimmed().isEmpty()) continue;
|
||||
QSqlQuery i(db);
|
||||
i.prepare(QStringLiteral(
|
||||
"INSERT OR IGNORE INTO genres (media_id,genre) VALUES (?,?)"));
|
||||
i.addBindValue(item.id);
|
||||
i.addBindValue(g.trimmed());
|
||||
if (!i.exec()) { m_lastError = i.lastError().text(); return false; }
|
||||
}
|
||||
for (const QString &t : item.tags) {
|
||||
if (t.trimmed().isEmpty()) continue;
|
||||
QSqlQuery i(db);
|
||||
i.prepare(QStringLiteral(
|
||||
"INSERT OR IGNORE INTO tags (media_id,tag) VALUES (?,?)"));
|
||||
i.addBindValue(item.id);
|
||||
i.addBindValue(t.trimmed());
|
||||
if (!i.exec()) { m_lastError = i.lastError().text(); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::saveLocalized(const MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery d(db);
|
||||
d.prepare(QStringLiteral("DELETE FROM localized_titles WHERE media_id=?"));
|
||||
d.addBindValue(item.id);
|
||||
if (!d.exec()) { m_lastError = d.lastError().text(); return false; }
|
||||
|
||||
for (auto it = item.localizedTitles.cbegin(); it != item.localizedTitles.cend(); ++it) {
|
||||
if (it.value().trimmed().isEmpty()) continue;
|
||||
QSqlQuery i(db);
|
||||
i.prepare(QStringLiteral(
|
||||
"INSERT OR REPLACE INTO localized_titles (media_id,lang,title) VALUES (?,?,?)"));
|
||||
i.addBindValue(item.id);
|
||||
i.addBindValue(it.key());
|
||||
i.addBindValue(it.value());
|
||||
if (!i.exec()) { m_lastError = i.lastError().text(); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::saveCustomFields(const MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery d(db);
|
||||
d.prepare(QStringLiteral("DELETE FROM custom_fields WHERE media_id=?"));
|
||||
d.addBindValue(item.id);
|
||||
if (!d.exec()) { m_lastError = d.lastError().text(); return false; }
|
||||
|
||||
for (auto it = item.customFields.cbegin(); it != item.customFields.cend(); ++it) {
|
||||
if (it.key().trimmed().isEmpty()) continue;
|
||||
QSqlQuery i(db);
|
||||
i.prepare(QStringLiteral(
|
||||
"INSERT OR REPLACE INTO custom_fields (media_id,key,value) VALUES (?,?,?)"));
|
||||
i.addBindValue(item.id);
|
||||
i.addBindValue(it.key());
|
||||
i.addBindValue(it.value());
|
||||
if (!i.exec()) { m_lastError = i.lastError().text(); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load
|
||||
// ---------------------------------------------------------------------------
|
||||
static MediaItem rowToItem(const QSqlQuery &q) {
|
||||
MediaItem m;
|
||||
m.id = q.value(QStringLiteral("id")).toInt();
|
||||
m.type = mediaTypeFromString(q.value(QStringLiteral("type")).toString());
|
||||
m.title = q.value(QStringLiteral("title")).toString();
|
||||
m.originalTitle = q.value(QStringLiteral("original_title")).toString();
|
||||
m.coverPath = q.value(QStringLiteral("cover_path")).toString();
|
||||
m.coverUrl = q.value(QStringLiteral("cover_url")).toString();
|
||||
m.status = statusFromString(q.value(QStringLiteral("status")).toString());
|
||||
m.rating = q.value(QStringLiteral("rating")).toInt();
|
||||
m.favorite = q.value(QStringLiteral("favorite")).toInt() != 0;
|
||||
m.year = q.value(QStringLiteral("year")).toInt();
|
||||
m.overview = q.value(QStringLiteral("overview")).toString();
|
||||
m.notes = q.value(QStringLiteral("notes")).toString();
|
||||
m.dateAdded = strToDt(q.value(QStringLiteral("date_added")).toString());
|
||||
m.dateCompleted = strToDt(q.value(QStringLiteral("date_completed")).toString());
|
||||
m.externalId = q.value(QStringLiteral("external_id")).toString();
|
||||
m.externalSource= q.value(QStringLiteral("external_source")).toString();
|
||||
m.franchise = q.value(QStringLiteral("franchise")).toString();
|
||||
return m;
|
||||
}
|
||||
|
||||
void Database::loadSegments(MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery sq(db);
|
||||
sq.prepare(QStringLiteral(
|
||||
"SELECT id,number,title FROM segments WHERE media_id=? ORDER BY number"));
|
||||
sq.addBindValue(item.id);
|
||||
sq.exec();
|
||||
while (sq.next()) {
|
||||
Segment seg;
|
||||
seg.id = sq.value(0).toInt();
|
||||
seg.number = sq.value(1).toInt();
|
||||
seg.title = sq.value(2).toString();
|
||||
|
||||
QSqlQuery uq(db);
|
||||
uq.prepare(QStringLiteral(
|
||||
"SELECT id,number,title,watched,watched_date FROM units "
|
||||
"WHERE segment_id=? ORDER BY number"));
|
||||
uq.addBindValue(seg.id);
|
||||
uq.exec();
|
||||
while (uq.next()) {
|
||||
Unit u;
|
||||
u.id = uq.value(0).toInt();
|
||||
u.number = uq.value(1).toInt();
|
||||
u.title = uq.value(2).toString();
|
||||
u.watched = uq.value(3).toInt() != 0;
|
||||
u.watchedDate = strToDt(uq.value(4).toString());
|
||||
seg.units.push_back(u);
|
||||
}
|
||||
item.segments.push_back(seg);
|
||||
}
|
||||
}
|
||||
|
||||
void Database::loadGenresTags(MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery gq(db);
|
||||
gq.prepare(QStringLiteral("SELECT genre FROM genres WHERE media_id=? ORDER BY genre"));
|
||||
gq.addBindValue(item.id);
|
||||
gq.exec();
|
||||
while (gq.next()) item.genres << gq.value(0).toString();
|
||||
|
||||
QSqlQuery tq(db);
|
||||
tq.prepare(QStringLiteral("SELECT tag FROM tags WHERE media_id=? ORDER BY tag"));
|
||||
tq.addBindValue(item.id);
|
||||
tq.exec();
|
||||
while (tq.next()) item.tags << tq.value(0).toString();
|
||||
}
|
||||
|
||||
void Database::loadLocalized(MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("SELECT lang,title FROM localized_titles WHERE media_id=?"));
|
||||
q.addBindValue(item.id);
|
||||
q.exec();
|
||||
while (q.next())
|
||||
item.localizedTitles.insert(q.value(0).toString(), q.value(1).toString());
|
||||
}
|
||||
|
||||
void Database::loadCustomFields(MediaItem &item) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("SELECT key,value FROM custom_fields WHERE media_id=?"));
|
||||
q.addBindValue(item.id);
|
||||
q.exec();
|
||||
while (q.next())
|
||||
item.customFields.insert(q.value(0).toString(), q.value(1).toString());
|
||||
}
|
||||
|
||||
std::optional<MediaItem> Database::loadItem(int id) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("SELECT * FROM media WHERE id=?"));
|
||||
q.addBindValue(id);
|
||||
if (!q.exec() || !q.next())
|
||||
return std::nullopt;
|
||||
MediaItem m = rowToItem(q);
|
||||
loadSegments(m);
|
||||
loadGenresTags(m);
|
||||
loadLocalized(m);
|
||||
loadCustomFields(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
QVector<MediaItem> Database::queryItems(const FilterCriteria &c) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
|
||||
QString sql = QStringLiteral("SELECT DISTINCT media.* FROM media");
|
||||
QStringList wheres;
|
||||
QVariantList binds;
|
||||
|
||||
if (!c.genres.isEmpty()) {
|
||||
// require all selected genres -> one join condition per genre
|
||||
for (int i = 0; i < c.genres.size(); ++i) {
|
||||
sql += QStringLiteral(" JOIN genres g%1 ON g%1.media_id=media.id "
|
||||
"AND g%1.genre=?").arg(i);
|
||||
binds << c.genres[i];
|
||||
}
|
||||
}
|
||||
if (!c.tags.isEmpty()) {
|
||||
for (int i = 0; i < c.tags.size(); ++i) {
|
||||
sql += QStringLiteral(" JOIN tags t%1 ON t%1.media_id=media.id "
|
||||
"AND t%1.tag=?").arg(i);
|
||||
binds << c.tags[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (c.type) { wheres << QStringLiteral("media.type=?"); binds << mediaTypeToString(*c.type); }
|
||||
if (c.status) { wheres << QStringLiteral("media.status=?"); binds << statusToString(*c.status); }
|
||||
if (c.favoritesOnly) wheres << QStringLiteral("media.favorite=1");
|
||||
if (c.minRating > 0) { wheres << QStringLiteral("media.rating>=?"); binds << c.minRating; }
|
||||
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());
|
||||
binds << like << like << like;
|
||||
}
|
||||
|
||||
if (!wheres.isEmpty())
|
||||
sql += QStringLiteral(" WHERE ") + wheres.join(QStringLiteral(" AND "));
|
||||
|
||||
QString order;
|
||||
const QString dir = c.sortDescending ? QStringLiteral("DESC") : QStringLiteral("ASC");
|
||||
if (c.sortMode == QLatin1String("title")) order = QStringLiteral("media.title %1").arg(dir);
|
||||
else if (c.sortMode == QLatin1String("rating")) order = QStringLiteral("media.rating %1").arg(dir);
|
||||
else if (c.sortMode == QLatin1String("year")) order = QStringLiteral("media.year %1").arg(dir);
|
||||
else order = QStringLiteral("media.date_added %1").arg(dir);
|
||||
sql += QStringLiteral(" ORDER BY ") + order;
|
||||
|
||||
QSqlQuery q(db);
|
||||
q.prepare(sql);
|
||||
for (const QVariant &b : binds) q.addBindValue(b);
|
||||
|
||||
QVector<MediaItem> result;
|
||||
if (!q.exec()) {
|
||||
m_lastError = q.lastError().text();
|
||||
qWarning() << "queryItems failed:" << m_lastError << sql;
|
||||
return result;
|
||||
}
|
||||
while (q.next()) {
|
||||
MediaItem m = rowToItem(q);
|
||||
loadSegments(m);
|
||||
loadGenresTags(m);
|
||||
loadLocalized(m);
|
||||
result.push_back(m);
|
||||
}
|
||||
|
||||
// Franchise is auto-detected by clustering titles, so it can't be expressed
|
||||
// in SQL; filter the materialised rows instead. An explicit franchise on the
|
||||
// item overrides the auto-detected cluster label.
|
||||
if (!c.franchise.trimmed().isEmpty()) {
|
||||
const QString want = c.franchise.trimmed();
|
||||
const QHash<QString, QString> autoMap = titleFranchiseMap();
|
||||
QVector<MediaItem> filtered;
|
||||
for (const MediaItem &m : result) {
|
||||
const QString explicitF = m.franchise.trimmed();
|
||||
const QString f = explicitF.isEmpty() ? autoMap.value(m.title) : explicitF;
|
||||
if (f == want) filtered.push_back(m);
|
||||
}
|
||||
result = filtered;
|
||||
}
|
||||
|
||||
// progress sort can't be done in SQL cheaply; do it here
|
||||
if (c.sortMode == QLatin1String("progress")) {
|
||||
std::sort(result.begin(), result.end(),
|
||||
[&](const MediaItem &a, const MediaItem &b) {
|
||||
return c.sortDescending ? a.progress() > b.progress()
|
||||
: a.progress() < b.progress();
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Database::deleteItem(int id) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("DELETE FROM media WHERE id=?"));
|
||||
q.addBindValue(id);
|
||||
if (!q.exec()) { m_lastError = q.lastError().text(); return false; }
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::setUnitWatched(int unitId, bool watched) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("UPDATE units SET watched=?, watched_date=? WHERE id=?"));
|
||||
q.addBindValue(watched ? 1 : 0);
|
||||
q.addBindValue(watched ? dtToStr(QDateTime::currentDateTime()) : QString());
|
||||
q.addBindValue(unitId);
|
||||
if (!q.exec()) { m_lastError = q.lastError().text(); return false; }
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::setSegmentWatched(int segmentId, bool watched) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("UPDATE units SET watched=?, watched_date=? WHERE segment_id=?"));
|
||||
q.addBindValue(watched ? 1 : 0);
|
||||
q.addBindValue(watched ? dtToStr(QDateTime::currentDateTime()) : QString());
|
||||
q.addBindValue(segmentId);
|
||||
if (!q.exec()) { m_lastError = q.lastError().text(); return false; }
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::setItemStatus(int itemId, WatchStatus status) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("UPDATE media SET status=?, date_completed=? WHERE id=?"));
|
||||
q.addBindValue(statusToString(status));
|
||||
q.addBindValue(status == WatchStatus::Completed
|
||||
? dtToStr(QDateTime::currentDateTime()) : QString());
|
||||
q.addBindValue(itemId);
|
||||
if (!q.exec()) { m_lastError = q.lastError().text(); return false; }
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::setFavorite(int itemId, bool fav) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("UPDATE media SET favorite=? WHERE id=?"));
|
||||
q.addBindValue(fav ? 1 : 0);
|
||||
q.addBindValue(itemId);
|
||||
if (!q.exec()) { m_lastError = q.lastError().text(); return false; }
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::setRating(int itemId, int rating) {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
q.prepare(QStringLiteral("UPDATE media SET rating=? WHERE id=?"));
|
||||
q.addBindValue(rating);
|
||||
q.addBindValue(itemId);
|
||||
if (!q.exec()) { m_lastError = q.lastError().text(); return false; }
|
||||
emit changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
QStringList Database::allGenres() const {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
QStringList out;
|
||||
if (q.exec(QStringLiteral("SELECT DISTINCT genre FROM genres ORDER BY genre")))
|
||||
while (q.next()) out << q.value(0).toString();
|
||||
return out;
|
||||
}
|
||||
|
||||
QStringList Database::allTags() const {
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
QStringList out;
|
||||
if (q.exec(QStringLiteral("SELECT DISTINCT tag FROM tags ORDER BY tag")))
|
||||
while (q.next()) out << q.value(0).toString();
|
||||
return out;
|
||||
}
|
||||
|
||||
QHash<QString, QString> Database::titleFranchiseMap() const {
|
||||
QStringList titles;
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
if (q.exec(QStringLiteral("SELECT title FROM media")))
|
||||
while (q.next()) titles << q.value(0).toString();
|
||||
return clusterFranchises(titles);
|
||||
}
|
||||
|
||||
QHash<QString, int> Database::franchiseCounts() const {
|
||||
const QHash<QString, QString> autoMap = titleFranchiseMap();
|
||||
QHash<QString, int> counts;
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
if (q.exec(QStringLiteral("SELECT title, franchise FROM media"))) {
|
||||
while (q.next()) {
|
||||
const QString title = q.value(0).toString();
|
||||
const QString explicitF = q.value(1).toString().trimmed();
|
||||
const QString f = explicitF.isEmpty() ? autoMap.value(title) : explicitF;
|
||||
if (!f.isEmpty()) ++counts[f];
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
QStringList Database::allFranchises() const {
|
||||
const QHash<QString, int> counts = franchiseCounts();
|
||||
QStringList out;
|
||||
for (auto it = counts.cbegin(); it != counts.cend(); ++it)
|
||||
if (it.value() >= 2) out << it.key();
|
||||
out.sort(Qt::CaseInsensitive);
|
||||
return out;
|
||||
}
|
||||
|
||||
QStringList Database::franchiseNames() const {
|
||||
QStringList out = franchiseCounts().keys();
|
||||
out.sort(Qt::CaseInsensitive);
|
||||
return out;
|
||||
}
|
||||
|
||||
Database::Stats Database::stats() const {
|
||||
Stats s;
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connName);
|
||||
QSqlQuery q(db);
|
||||
if (q.exec(QStringLiteral("SELECT status, COUNT(*) FROM media GROUP BY status"))) {
|
||||
while (q.next()) {
|
||||
const WatchStatus st = statusFromString(q.value(0).toString());
|
||||
const int n = q.value(1).toInt();
|
||||
s.total += n;
|
||||
switch (st) {
|
||||
case WatchStatus::Completed: s.completed += n; break;
|
||||
case WatchStatus::InProgress: s.inProgress += n; break;
|
||||
case WatchStatus::PlanToWatch:s.planned += n; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
QSqlQuery t(db);
|
||||
if (t.exec(QStringLiteral("SELECT type, COUNT(*) FROM media GROUP BY type"))) {
|
||||
while (t.next())
|
||||
s.perType[int(mediaTypeFromString(t.value(0).toString()))] = t.value(1).toInt();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace umt
|
||||
96
src/core/Database.h
Normal file
96
src/core/Database.h
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#pragma once
|
||||
|
||||
#include "core/MediaItem.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
#include <optional>
|
||||
|
||||
namespace umt {
|
||||
|
||||
// Describes an active set of filters applied to the library listing.
|
||||
struct FilterCriteria {
|
||||
std::optional<MediaType> type; // restrict to one media type
|
||||
std::optional<WatchStatus> status;
|
||||
QString searchText; // matches title / original / overview
|
||||
QStringList genres; // AND-match all selected genres
|
||||
QStringList tags;
|
||||
QString franchise; // restrict to one franchise group
|
||||
int minRating = 0;
|
||||
int yearFrom = 0;
|
||||
int yearTo = 0;
|
||||
bool favoritesOnly = false;
|
||||
QString sortMode = QStringLiteral("dateAdded"); // dateAdded|title|rating|year|progress
|
||||
bool sortDescending = true;
|
||||
};
|
||||
|
||||
// SQLite-backed persistence layer. Owns the schema and all CRUD operations.
|
||||
class Database : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit Database(QObject *parent = nullptr);
|
||||
~Database() override;
|
||||
|
||||
bool open(const QString &path);
|
||||
bool isOpen() const;
|
||||
QString lastError() const { return m_lastError; }
|
||||
|
||||
// --- media CRUD ---
|
||||
// Inserts (id<0) or updates a full item incl. its segment/unit tree.
|
||||
bool saveItem(MediaItem &item);
|
||||
bool deleteItem(int id);
|
||||
std::optional<MediaItem> loadItem(int id);
|
||||
|
||||
// Returns items (with full segment trees) matching the criteria.
|
||||
QVector<MediaItem> queryItems(const FilterCriteria &c);
|
||||
|
||||
// Quick toggles avoid re-serializing the whole tree.
|
||||
bool setUnitWatched(int unitId, bool watched);
|
||||
bool setSegmentWatched(int segmentId, bool watched);
|
||||
bool setItemStatus(int itemId, WatchStatus status);
|
||||
bool setFavorite(int itemId, bool fav);
|
||||
bool setRating(int itemId, int rating);
|
||||
|
||||
// --- aggregate metadata for filter UIs ---
|
||||
QStringList allGenres() const;
|
||||
QStringList allTags() const;
|
||||
// Franchise groups with at least two members (for the filter dropdown).
|
||||
QStringList allFranchises() const;
|
||||
// Every distinct franchise name incl. singletons (for input autocompletion).
|
||||
QStringList franchiseNames() const;
|
||||
|
||||
struct Stats {
|
||||
int total = 0;
|
||||
int completed = 0;
|
||||
int inProgress = 0;
|
||||
int planned = 0;
|
||||
QHash<int, int> perType; // MediaType(int) -> count
|
||||
};
|
||||
Stats stats() const;
|
||||
|
||||
signals:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
bool exec(const QString &sql);
|
||||
void initSchema();
|
||||
QHash<QString, int> franchiseCounts() const;
|
||||
// Auto-detected franchise label per distinct title (explicit overrides not
|
||||
// applied here; callers layer those on top).
|
||||
QHash<QString, QString> titleFranchiseMap() const;
|
||||
void loadSegments(MediaItem &item);
|
||||
bool saveSegments(MediaItem &item);
|
||||
void loadGenresTags(MediaItem &item);
|
||||
bool saveGenresTags(const MediaItem &item);
|
||||
void loadLocalized(MediaItem &item);
|
||||
bool saveLocalized(const MediaItem &item);
|
||||
void loadCustomFields(MediaItem &item);
|
||||
bool saveCustomFields(const MediaItem &item);
|
||||
|
||||
QString m_connName;
|
||||
QString m_lastError;
|
||||
};
|
||||
|
||||
} // namespace umt
|
||||
148
src/core/Enums.h
Normal file
148
src/core/Enums.h
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QColor>
|
||||
#include <QMetaType>
|
||||
|
||||
namespace umt {
|
||||
|
||||
// The four top-level kinds of media this app tracks.
|
||||
enum class MediaType {
|
||||
Movie,
|
||||
Series,
|
||||
Manga,
|
||||
Book,
|
||||
Game
|
||||
};
|
||||
|
||||
// User-facing progress status. Custom statuses are stored separately as tags.
|
||||
enum class WatchStatus {
|
||||
PlanToWatch,
|
||||
InProgress,
|
||||
Completed,
|
||||
OnHold,
|
||||
Dropped
|
||||
};
|
||||
|
||||
inline QString mediaTypeToString(MediaType t) {
|
||||
switch (t) {
|
||||
case MediaType::Movie: return QStringLiteral("Movie");
|
||||
case MediaType::Series: return QStringLiteral("Series");
|
||||
case MediaType::Manga: return QStringLiteral("Manga");
|
||||
case MediaType::Book: return QStringLiteral("Book");
|
||||
case MediaType::Game: return QStringLiteral("Game");
|
||||
}
|
||||
return QStringLiteral("Movie");
|
||||
}
|
||||
|
||||
inline MediaType mediaTypeFromString(const QString &s) {
|
||||
if (s == QLatin1String("Series")) return MediaType::Series;
|
||||
if (s == QLatin1String("Manga")) return MediaType::Manga;
|
||||
if (s == QLatin1String("Book")) return MediaType::Book;
|
||||
if (s == QLatin1String("Game")) return MediaType::Game;
|
||||
return MediaType::Movie;
|
||||
}
|
||||
|
||||
// Localized, human-readable label (German UI).
|
||||
inline QString mediaTypeLabel(MediaType t) {
|
||||
switch (t) {
|
||||
case MediaType::Movie: return QStringLiteral("Filme");
|
||||
case MediaType::Series: return QStringLiteral("Serien");
|
||||
case MediaType::Manga: return QStringLiteral("Mangas");
|
||||
case MediaType::Book: return QStringLiteral("Bücher");
|
||||
case MediaType::Game: return QStringLiteral("Spiele");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
inline QString statusToString(WatchStatus s) {
|
||||
switch (s) {
|
||||
case WatchStatus::PlanToWatch: return QStringLiteral("PlanToWatch");
|
||||
case WatchStatus::InProgress: return QStringLiteral("InProgress");
|
||||
case WatchStatus::Completed: return QStringLiteral("Completed");
|
||||
case WatchStatus::OnHold: return QStringLiteral("OnHold");
|
||||
case WatchStatus::Dropped: return QStringLiteral("Dropped");
|
||||
}
|
||||
return QStringLiteral("PlanToWatch");
|
||||
}
|
||||
|
||||
inline WatchStatus statusFromString(const QString &s) {
|
||||
if (s == QLatin1String("InProgress")) return WatchStatus::InProgress;
|
||||
if (s == QLatin1String("Completed")) return WatchStatus::Completed;
|
||||
if (s == QLatin1String("OnHold")) return WatchStatus::OnHold;
|
||||
if (s == QLatin1String("Dropped")) return WatchStatus::Dropped;
|
||||
return WatchStatus::PlanToWatch;
|
||||
}
|
||||
|
||||
inline QString statusLabel(WatchStatus s) {
|
||||
switch (s) {
|
||||
case WatchStatus::PlanToWatch: return QStringLiteral("Geplant");
|
||||
case WatchStatus::InProgress: return QStringLiteral("Läuft");
|
||||
case WatchStatus::Completed: return QStringLiteral("Abgeschlossen");
|
||||
case WatchStatus::OnHold: return QStringLiteral("Pausiert");
|
||||
case WatchStatus::Dropped: return QStringLiteral("Abgebrochen");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
inline QColor statusColor(WatchStatus s) {
|
||||
switch (s) {
|
||||
case WatchStatus::PlanToWatch: return QColor("#8e8e93");
|
||||
case WatchStatus::InProgress: return QColor("#0a84ff");
|
||||
case WatchStatus::Completed: return QColor("#30d158");
|
||||
case WatchStatus::OnHold: return QColor("#ffd60a");
|
||||
case WatchStatus::Dropped: return QColor("#ff453a");
|
||||
}
|
||||
return QColor("#8e8e93");
|
||||
}
|
||||
|
||||
// Singular, human-readable category label for a single item (German UI).
|
||||
inline QString mediaTypeSingular(MediaType t) {
|
||||
switch (t) {
|
||||
case MediaType::Movie: return QStringLiteral("Film");
|
||||
case MediaType::Series: return QStringLiteral("Serie");
|
||||
case MediaType::Manga: return QStringLiteral("Manga");
|
||||
case MediaType::Book: return QStringLiteral("Buch");
|
||||
case MediaType::Game: return QStringLiteral("Spiel");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// A distinct accent colour per media type, for category chips.
|
||||
inline QColor mediaTypeColor(MediaType t) {
|
||||
switch (t) {
|
||||
case MediaType::Movie: return QColor("#ff9f0a");
|
||||
case MediaType::Series: return QColor("#bf5af2");
|
||||
case MediaType::Manga: return QColor("#ff375f");
|
||||
case MediaType::Book: return QColor("#64d2ff");
|
||||
case MediaType::Game: return QColor("#32d74b");
|
||||
}
|
||||
return QColor("#8e8e93");
|
||||
}
|
||||
|
||||
// Label for the sub-container of a media type (Season vs Volume vs Part).
|
||||
inline QString segmentLabel(MediaType t) {
|
||||
switch (t) {
|
||||
case MediaType::Series: return QStringLiteral("Staffel");
|
||||
case MediaType::Manga: return QStringLiteral("Band");
|
||||
case MediaType::Book: return QStringLiteral("Teil");
|
||||
case MediaType::Game: return QStringLiteral("Akt");
|
||||
default: return QStringLiteral("Abschnitt");
|
||||
}
|
||||
}
|
||||
|
||||
// Label for the leaf unit (Episode vs Chapter vs Page-block).
|
||||
inline QString unitLabel(MediaType t) {
|
||||
switch (t) {
|
||||
case MediaType::Series: return QStringLiteral("Folge");
|
||||
case MediaType::Manga: return QStringLiteral("Kapitel");
|
||||
case MediaType::Book: return QStringLiteral("Kapitel");
|
||||
case MediaType::Game: return QStringLiteral("Kapitel");
|
||||
default: return QStringLiteral("Einheit");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace umt
|
||||
|
||||
Q_DECLARE_METATYPE(umt::MediaType)
|
||||
Q_DECLARE_METATYPE(umt::WatchStatus)
|
||||
8
src/core/MediaItem.cpp
Normal file
8
src/core/MediaItem.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "core/MediaItem.h"
|
||||
|
||||
// All MediaItem logic is currently inline in the header.
|
||||
// This translation unit exists to give the struct a home for future
|
||||
// non-trivial helpers and to keep the build target list stable.
|
||||
|
||||
namespace umt {
|
||||
} // namespace umt
|
||||
234
src/core/MediaItem.h
Normal file
234
src/core/MediaItem.h
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
#pragma once
|
||||
|
||||
#include "core/Enums.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QDateTime>
|
||||
#include <QVector>
|
||||
#include <QHash>
|
||||
#include <QMap>
|
||||
#include <QSet>
|
||||
#include <QVector>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace umt {
|
||||
|
||||
// A leaf unit of progress: a single episode, chapter, etc.
|
||||
struct Unit {
|
||||
int id = -1;
|
||||
int number = 0; // ordinal within its segment
|
||||
QString title;
|
||||
bool watched = false;
|
||||
QDateTime watchedDate;
|
||||
};
|
||||
|
||||
// A container grouping units: a season, a manga volume, a book part.
|
||||
struct Segment {
|
||||
int id = -1;
|
||||
int number = 0;
|
||||
QString title;
|
||||
QVector<Unit> units;
|
||||
|
||||
int watchedCount() const {
|
||||
int c = 0;
|
||||
for (const auto &u : units) if (u.watched) ++c;
|
||||
return c;
|
||||
}
|
||||
bool fullyWatched() const {
|
||||
if (units.isEmpty()) return false;
|
||||
return watchedCount() == units.size();
|
||||
}
|
||||
};
|
||||
|
||||
// The central record. One row per tracked title.
|
||||
struct MediaItem {
|
||||
int id = -1;
|
||||
MediaType type = MediaType::Movie;
|
||||
|
||||
QString title; // primary display title
|
||||
QString originalTitle; // original-language title
|
||||
// Localized alternative titles, keyed by ISO language code ("en", "de", "ja"...).
|
||||
QMap<QString, QString> localizedTitles;
|
||||
|
||||
QString coverPath; // local cached cover image path
|
||||
QString coverUrl; // remote URL it was fetched from (for reference)
|
||||
|
||||
QStringList genres;
|
||||
QStringList tags; // free-form custom tags / custom statuses
|
||||
QString franchise; // explicit franchise/series grouping (optional)
|
||||
|
||||
WatchStatus status = WatchStatus::PlanToWatch;
|
||||
int rating = 0; // 0..10 (0 = unrated)
|
||||
bool favorite = false;
|
||||
|
||||
int year = 0;
|
||||
QString overview; // synopsis / description
|
||||
QString notes; // private user notes
|
||||
|
||||
// For movies/books without segments this can hold a single "watched" flag
|
||||
// expressed through status==Completed. For multi-unit media, segments drive it.
|
||||
QVector<Segment> segments;
|
||||
|
||||
// Arbitrary user-defined fields (high customizability).
|
||||
QMap<QString, QString> customFields;
|
||||
|
||||
QDateTime dateAdded;
|
||||
QDateTime dateCompleted;
|
||||
|
||||
QString externalId; // provider id (e.g. tmdb/anilist/openlibrary)
|
||||
QString externalSource;
|
||||
|
||||
// --- progress helpers ---
|
||||
int totalUnits() const {
|
||||
int n = 0;
|
||||
for (const auto &s : segments) n += s.units.size();
|
||||
return n;
|
||||
}
|
||||
int watchedUnits() const {
|
||||
int n = 0;
|
||||
for (const auto &s : segments)
|
||||
for (const auto &u : s.units) if (u.watched) ++n;
|
||||
return n;
|
||||
}
|
||||
// 0.0 .. 1.0
|
||||
double progress() const {
|
||||
if (totalUnits() == 0)
|
||||
return status == WatchStatus::Completed ? 1.0 : 0.0;
|
||||
return double(watchedUnits()) / double(totalUnits());
|
||||
}
|
||||
bool hasSegments() const { return !segments.isEmpty(); }
|
||||
|
||||
QString bestTitleFor(const QString &langCode) const {
|
||||
if (!langCode.isEmpty() && localizedTitles.contains(langCode))
|
||||
return localizedTitles.value(langCode);
|
||||
return title;
|
||||
}
|
||||
};
|
||||
|
||||
// Derives a franchise key from a title for auto-detection: it drops a subtitle
|
||||
// after a colon/dash, a trailing "(year)" and a trailing sequel marker (a number
|
||||
// or roman numeral) so that e.g. "Zelda: Breath of the Wild", "Zelda - Ocarina"
|
||||
// and "Final Fantasy VII" collapse onto a shared base name.
|
||||
inline QString franchiseKeyFromTitle(const QString &title) {
|
||||
QString t = title.trimmed();
|
||||
static const QStringList seps = {QStringLiteral(": "), QStringLiteral(" - "),
|
||||
QStringLiteral(" – "), QStringLiteral(" — ")};
|
||||
for (const QString &s : seps) {
|
||||
const int idx = t.indexOf(s);
|
||||
if (idx > 0) { t = t.left(idx); break; }
|
||||
}
|
||||
const int paren = t.indexOf(QLatin1Char('('));
|
||||
if (paren > 0) t = t.left(paren);
|
||||
t = t.trimmed();
|
||||
static const QRegularExpression tail(
|
||||
QStringLiteral("\\s+(?:[0-9]{1,3}|[IVXLCDM]+)$"));
|
||||
t.remove(tail);
|
||||
return t.trimmed();
|
||||
}
|
||||
|
||||
// The franchise an item belongs to: an explicit value always wins, otherwise the
|
||||
// auto-detected key from its primary title.
|
||||
inline QString effectiveFranchise(const MediaItem &m) {
|
||||
const QString explicitF = m.franchise.trimmed();
|
||||
if (!explicitF.isEmpty()) return explicitF;
|
||||
return franchiseKeyFromTitle(m.title);
|
||||
}
|
||||
|
||||
// Words too generic to form a franchise on their own; also trimmed from the end
|
||||
// of a detected prefix so e.g. "Harry Potter und der …" collapses to "Harry
|
||||
// Potter" instead of "Harry Potter Und Der".
|
||||
inline const QSet<QString> &franchiseStopWords() {
|
||||
static const QSet<QString> s = {
|
||||
QStringLiteral("the"), QStringLiteral("a"), QStringLiteral("an"),
|
||||
QStringLiteral("and"), QStringLiteral("of"), QStringLiteral("to"),
|
||||
QStringLiteral("in"), QStringLiteral("on"), QStringLiteral("for"),
|
||||
QStringLiteral("und"), QStringLiteral("der"), QStringLiteral("die"),
|
||||
QStringLiteral("das"), QStringLiteral("den"), QStringLiteral("dem"),
|
||||
QStringLiteral("ein"), QStringLiteral("eine"), QStringLiteral("le"),
|
||||
QStringLiteral("la"), QStringLiteral("les"), QStringLiteral("el"),
|
||||
QStringLiteral("il"), QStringLiteral("no"), QStringLiteral("wa"),
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
// Auto-groups a set of titles into franchises by their longest shared word
|
||||
// prefix. Two or more titles that begin with the same words (e.g. several
|
||||
// "Harry Potter …" entries) collapse onto a shared label; titles that share no
|
||||
// meaningful prefix with any other keep their own normalized base name.
|
||||
// Returns a map: original title -> franchise label.
|
||||
inline QHash<QString, QString> clusterFranchises(const QStringList &titlesIn) {
|
||||
static const QRegularExpression nonWord(QStringLiteral("[^\\p{L}\\p{N}]+"));
|
||||
|
||||
// De-duplicate while preserving order.
|
||||
QStringList titles;
|
||||
{
|
||||
QSet<QString> seen;
|
||||
for (const QString &t : titlesIn)
|
||||
if (!t.trimmed().isEmpty() && !seen.contains(t)) {
|
||||
seen.insert(t);
|
||||
titles << t;
|
||||
}
|
||||
}
|
||||
|
||||
const int n = titles.size();
|
||||
QVector<QStringList> norm(n), orig(n);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const QString base = franchiseKeyFromTitle(titles[i]);
|
||||
orig[i] = base.split(nonWord, Qt::SkipEmptyParts);
|
||||
for (const QString &w : orig[i]) norm[i] << w.toLower();
|
||||
}
|
||||
|
||||
// Count how many titles share each word-prefix.
|
||||
QHash<QString, int> prefixCount;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
QString key;
|
||||
for (int L = 0; L < norm[i].size(); ++L) {
|
||||
if (L) key += QLatin1Char(' ');
|
||||
key += norm[i][L];
|
||||
++prefixCount[key];
|
||||
}
|
||||
}
|
||||
|
||||
const QSet<QString> &stop = franchiseStopWords();
|
||||
QHash<QString, QString> labelForKey; // normalized prefix -> display label
|
||||
QHash<QString, QString> result;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
// Longest prefix shared by at least two titles.
|
||||
int bestL = 0;
|
||||
{
|
||||
QString key;
|
||||
for (int L = 0; L < norm[i].size(); ++L) {
|
||||
if (L) key += QLatin1Char(' ');
|
||||
key += norm[i][L];
|
||||
if (prefixCount.value(key) >= 2) bestL = L + 1;
|
||||
}
|
||||
}
|
||||
if (bestL == 0) {
|
||||
result.insert(titles[i], franchiseKeyFromTitle(titles[i]));
|
||||
continue;
|
||||
}
|
||||
// Drop trailing stop words from the shared prefix.
|
||||
int L = bestL;
|
||||
while (L > 1 && stop.contains(norm[i][L - 1])) --L;
|
||||
// Reject a prefix that is only stop words (e.g. "The") — don't group on it.
|
||||
bool hasContent = false;
|
||||
for (int j = 0; j < L; ++j)
|
||||
if (!stop.contains(norm[i][j])) { hasContent = true; break; }
|
||||
if (!hasContent) {
|
||||
result.insert(titles[i], franchiseKeyFromTitle(titles[i]));
|
||||
continue;
|
||||
}
|
||||
QString nkey, disp;
|
||||
for (int j = 0; j < L; ++j) {
|
||||
if (j) { nkey += QLatin1Char(' '); disp += QLatin1Char(' '); }
|
||||
nkey += norm[i][j];
|
||||
disp += orig[i][j];
|
||||
}
|
||||
if (!labelForKey.contains(nkey)) labelForKey.insert(nkey, disp);
|
||||
result.insert(titles[i], labelForKey.value(nkey));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace umt
|
||||
107
src/core/Settings.cpp
Normal file
107
src/core/Settings.cpp
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#include "core/Settings.h"
|
||||
|
||||
namespace umt {
|
||||
|
||||
AppSettings::AppSettings(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_s(QStringLiteral("UMT"), QStringLiteral("UltimateMediaTracker"))
|
||||
{
|
||||
}
|
||||
|
||||
bool AppSettings::darkMode() const {
|
||||
return m_s.value(QStringLiteral("theme/dark"), true).toBool();
|
||||
}
|
||||
void AppSettings::setDarkMode(bool on) {
|
||||
if (darkMode() == on) return;
|
||||
m_s.setValue(QStringLiteral("theme/dark"), on);
|
||||
emit themeChanged();
|
||||
}
|
||||
|
||||
QColor AppSettings::accentColor() const {
|
||||
return QColor(m_s.value(QStringLiteral("theme/accent"),
|
||||
QStringLiteral("#0a84ff")).toString());
|
||||
}
|
||||
void AppSettings::setAccentColor(const QColor &c) {
|
||||
if (accentColor() == c) return;
|
||||
m_s.setValue(QStringLiteral("theme/accent"), c.name());
|
||||
emit accentChanged(c);
|
||||
emit themeChanged();
|
||||
}
|
||||
|
||||
QString AppSettings::preferredLanguage() const {
|
||||
return m_s.value(QStringLiteral("locale/language"),
|
||||
QStringLiteral("de")).toString();
|
||||
}
|
||||
void AppSettings::setPreferredLanguage(const QString &code) {
|
||||
m_s.setValue(QStringLiteral("locale/language"), code);
|
||||
}
|
||||
|
||||
QString AppSettings::tmdbMode() const {
|
||||
return m_s.value(QStringLiteral("providers/tmdbMode"),
|
||||
QStringLiteral("key")).toString();
|
||||
}
|
||||
void AppSettings::setTmdbMode(const QString &mode) {
|
||||
m_s.setValue(QStringLiteral("providers/tmdbMode"), mode);
|
||||
}
|
||||
|
||||
QString AppSettings::tmdbApiKey() const {
|
||||
return m_s.value(QStringLiteral("providers/tmdbKey")).toString();
|
||||
}
|
||||
void AppSettings::setTmdbApiKey(const QString &key) {
|
||||
m_s.setValue(QStringLiteral("providers/tmdbKey"), key);
|
||||
}
|
||||
|
||||
QString AppSettings::proxyUrl() const {
|
||||
return m_s.value(QStringLiteral("providers/proxyUrl")).toString();
|
||||
}
|
||||
void AppSettings::setProxyUrl(const QString &url) {
|
||||
m_s.setValue(QStringLiteral("providers/proxyUrl"), url);
|
||||
}
|
||||
|
||||
QString AppSettings::proxyToken() const {
|
||||
return m_s.value(QStringLiteral("providers/proxyToken")).toString();
|
||||
}
|
||||
void AppSettings::setProxyToken(const QString &token) {
|
||||
m_s.setValue(QStringLiteral("providers/proxyToken"), token);
|
||||
}
|
||||
|
||||
QString AppSettings::rawgApiKey() const {
|
||||
return m_s.value(QStringLiteral("providers/rawgKey")).toString();
|
||||
}
|
||||
void AppSettings::setRawgApiKey(const QString &key) {
|
||||
m_s.setValue(QStringLiteral("providers/rawgKey"), key);
|
||||
}
|
||||
|
||||
bool AppSettings::autoFetchCovers() const {
|
||||
return m_s.value(QStringLiteral("providers/autoCovers"), true).toBool();
|
||||
}
|
||||
void AppSettings::setAutoFetchCovers(bool on) {
|
||||
m_s.setValue(QStringLiteral("providers/autoCovers"), on);
|
||||
}
|
||||
|
||||
int AppSettings::cardWidth() const {
|
||||
return m_s.value(QStringLiteral("view/cardWidth"), 180).toInt();
|
||||
}
|
||||
void AppSettings::setCardWidth(int px) {
|
||||
if (cardWidth() == px) return;
|
||||
m_s.setValue(QStringLiteral("view/cardWidth"), px);
|
||||
emit cardSizeChanged(px);
|
||||
}
|
||||
|
||||
QString AppSettings::defaultView() const {
|
||||
return m_s.value(QStringLiteral("view/default"),
|
||||
QStringLiteral("grid")).toString();
|
||||
}
|
||||
void AppSettings::setDefaultView(const QString &v) {
|
||||
m_s.setValue(QStringLiteral("view/default"), v);
|
||||
}
|
||||
|
||||
QString AppSettings::sortMode() const {
|
||||
return m_s.value(QStringLiteral("view/sort"),
|
||||
QStringLiteral("dateAdded")).toString();
|
||||
}
|
||||
void AppSettings::setSortMode(const QString &v) {
|
||||
m_s.setValue(QStringLiteral("view/sort"), v);
|
||||
}
|
||||
|
||||
} // namespace umt
|
||||
71
src/core/Settings.h
Normal file
71
src/core/Settings.h
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QColor>
|
||||
#include <QSettings>
|
||||
|
||||
namespace umt {
|
||||
|
||||
// Thin, signal-emitting wrapper over QSettings for app-wide preferences.
|
||||
// High customizability lives here: theme, accent colour, language, providers,
|
||||
// card size, default view, etc.
|
||||
class AppSettings : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AppSettings(QObject *parent = nullptr);
|
||||
|
||||
// Theme
|
||||
bool darkMode() const;
|
||||
void setDarkMode(bool on);
|
||||
|
||||
QColor accentColor() const;
|
||||
void setAccentColor(const QColor &c);
|
||||
|
||||
// Localization
|
||||
QString preferredLanguage() const; // ISO code, e.g. "de"
|
||||
void setPreferredLanguage(const QString &code);
|
||||
|
||||
// Providers / API keys
|
||||
// How TMDB requests are authorized: "key" (direct, own API key) or
|
||||
// "proxy" (route through a shared OIDC-protected proxy server).
|
||||
QString tmdbMode() const; // "key" | "proxy"
|
||||
void setTmdbMode(const QString &mode);
|
||||
|
||||
QString tmdbApiKey() const;
|
||||
void setTmdbApiKey(const QString &key);
|
||||
|
||||
// Proxy server base URL (e.g. https://tmdb.example.com) and the access token
|
||||
// obtained from its OIDC login page.
|
||||
QString proxyUrl() const;
|
||||
void setProxyUrl(const QString &url);
|
||||
QString proxyToken() const;
|
||||
void setProxyToken(const QString &token);
|
||||
|
||||
// Optional RAWG API key for video-game metadata (free key from rawg.io).
|
||||
QString rawgApiKey() const;
|
||||
void setRawgApiKey(const QString &key);
|
||||
|
||||
bool autoFetchCovers() const;
|
||||
void setAutoFetchCovers(bool on);
|
||||
|
||||
// View preferences
|
||||
int cardWidth() const;
|
||||
void setCardWidth(int px);
|
||||
|
||||
QString defaultView() const; // "grid" | "list"
|
||||
void setDefaultView(const QString &v);
|
||||
|
||||
QString sortMode() const;
|
||||
void setSortMode(const QString &v);
|
||||
|
||||
signals:
|
||||
void themeChanged();
|
||||
void accentChanged(const QColor &c);
|
||||
void cardSizeChanged(int px);
|
||||
|
||||
private:
|
||||
QSettings m_s;
|
||||
};
|
||||
|
||||
} // namespace umt
|
||||
Loading…
Add table
Add a link
Reference in a new issue