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:
Tronax 2026-06-15 02:02:25 +02:00
commit 3f07c78316
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
63 changed files with 6547 additions and 0 deletions

677
src/core/Database.cpp Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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