Top100
Your Personal Movie List (C++17 CLI + library)
Loading...
Searching...
No Matches
Top100ListModel.h
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2//-------------------------------------------------------------------------------
3// Top100 — Your Personal Movie List
4//
5// File: ui/common/Top100ListModel.h
6// Purpose: Shared QAbstractListModel for Top100 UI (Qt/KDE).
7// Language: C++17 (header)
8//
9// Author: Andy McCall, mailme@andymccall.co.uk
10// Date: September 18, 2025
11//-------------------------------------------------------------------------------
12//
20#pragma once
21
22#include <QAbstractListModel>
23#include <QHash>
24#include <QByteArray>
25#include <vector>
26#include <string>
27#include <QString>
28#include <QVariantMap>
29#include <QFutureWatcher>
30#include <QDebug>
31#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
32#include <QtConcurrent>
33#else
34#include <QtConcurrent/QtConcurrentRun>
35#endif
36
37#include "../../lib/Movie.h"
38#include "../../lib/top100.h"
39#include "../../lib/config.h"
40#include "../../lib/posting.h"
41#include "../../lib/omdb.h"
42
43class Top100ListModel : public QAbstractListModel {
44 Q_OBJECT
45 Q_PROPERTY(int sortOrder READ sortOrder WRITE setSortOrder NOTIFY sortOrderChanged)
46public:
48 enum Roles {
49 TitleRole = Qt::UserRole + 1,
50 YearRole,
51 RankRole,
52 PosterUrlRole,
53 PlotFullRole,
54 ImdbIdRole,
55 DirectorRole,
56 ActorsRole,
57 GenresRole,
58 RuntimeMinutesRole
59 };
60 Q_ENUM(Roles)
61
62
63 explicit Top100ListModel(QObject* parent = nullptr)
64 : QAbstractListModel(parent) {
65 // Initialize sort order from config
66 try {
67 AppConfig cfg = loadConfig();
69 } catch (...) {
70 // fall back to default
71 currentOrder_ = SortOrder::DEFAULT;
72 }
73 reload();
74 }
75
76 int rowCount(const QModelIndex& parent = QModelIndex()) const override {
77 if (parent.isValid()) return 0;
78 return static_cast<int>(movies_.size());
79 }
80
81 QVariant data(const QModelIndex& index, int role) const override {
82 if (!index.isValid() || index.row() < 0 || index.row() >= static_cast<int>(movies_.size()))
83 return {};
84 const auto& m = movies_[index.row()];
85 switch (role) {
86 case Qt::DisplayRole: {
87 QString prefix = (m.userRank > 0) ? QString("#%1 ").arg(m.userRank) : QString();
88 return prefix + QString::fromStdString(m.title) + QString(" (%1)").arg(m.year);
89 }
90 case TitleRole: return QString::fromStdString(m.title);
91 case YearRole: return m.year;
92 case RankRole: return m.userRank;
93 case PosterUrlRole: return QString::fromStdString(m.posterUrl);
94 case PlotFullRole: return QString::fromStdString(m.plotFull);
95 case ImdbIdRole: return QString::fromStdString(m.imdbID);
96 case DirectorRole: return QString::fromStdString(m.director);
97 case ActorsRole: {
98 QStringList list;
99 for (const auto& a : m.actors) list << QString::fromStdString(a);
100 return list;
101 }
102 case GenresRole: {
103 QStringList list;
104 for (const auto& g : m.genres) list << QString::fromStdString(g);
105 return list;
106 }
107 case RuntimeMinutesRole: return m.runtimeMinutes;
108 default: return {};
109 }
110 }
111
112 QHash<int, QByteArray> roleNames() const override {
113 QHash<int, QByteArray> r;
114 r[Qt::DisplayRole] = "display";
115 r[TitleRole] = "title";
116 r[YearRole] = "year";
117 r[RankRole] = "rank";
118 r[PosterUrlRole] = "posterUrl";
119 r[PlotFullRole] = "plotFull";
120 r[ImdbIdRole] = "imdbID";
121 r[DirectorRole] = "director";
122 r[ActorsRole] = "actors";
123 r[GenresRole] = "genres";
124 r[RuntimeMinutesRole] = "runtimeMinutes";
125 return r;
126 }
127
129 Q_INVOKABLE void reload() {
130 beginResetModel();
131 movies_.clear();
132 try {
133 AppConfig cfg = loadConfig();
134 Top100 list(cfg.dataFile);
135 // Load using current sort order (defaults to insertion order)
136 auto movies = list.getMovies(currentOrder_);
137 movies_.assign(movies.begin(), movies.end());
138 } catch (...) {
139 movies_.clear();
140 }
141 endResetModel();
142 qInfo() << "Top100ListModel: loaded" << movies_.size() << "movies";
143 emit reloadCompleted();
144 }
145
146 // Current sort order as int (maps to SortOrder enum). Useful for QML bindings.
147 int sortOrder() const { return static_cast<int>(currentOrder_); }
148
150 Q_INVOKABLE void setSortOrder(int order) {
151 SortOrder newOrder = currentOrder_;
152 switch (order) {
153 case static_cast<int>(SortOrder::DEFAULT): newOrder = SortOrder::DEFAULT; break;
154 case static_cast<int>(SortOrder::BY_YEAR): newOrder = SortOrder::BY_YEAR; break;
155 case static_cast<int>(SortOrder::ALPHABETICAL): newOrder = SortOrder::ALPHABETICAL; break;
156 case static_cast<int>(SortOrder::BY_USER_RANK): newOrder = SortOrder::BY_USER_RANK; break;
157 case static_cast<int>(SortOrder::BY_USER_SCORE): newOrder = SortOrder::BY_USER_SCORE; break;
158 default: newOrder = SortOrder::DEFAULT; break;
159 }
160 if (newOrder == currentOrder_) return;
161 currentOrder_ = newOrder;
162 // Persist preference
163 try {
164 AppConfig cfg = loadConfig();
165 cfg.uiSortOrder = static_cast<int>(currentOrder_);
166 saveConfig(cfg);
167 } catch (...) { /* ignore */ }
168 emit sortOrderChanged(static_cast<int>(currentOrder_));
169 reload();
170 }
171
174 Q_INVOKABLE QVariantMap get(int row) const {
175 QVariantMap m;
176 if (row < 0 || row >= static_cast<int>(movies_.size())) return m;
177 const auto& mv = movies_[row];
178 m["title"] = QString::fromStdString(mv.title);
179 m["year"] = mv.year;
180 m["rank"] = mv.userRank;
181 m["posterUrl"] = QString::fromStdString(mv.posterUrl);
182 m["plotFull"] = QString::fromStdString(mv.plotFull.empty() ? mv.plotShort : mv.plotFull);
183 m["imdbID"] = QString::fromStdString(mv.imdbID);
184 m["director"] = QString::fromStdString(mv.director);
185 {
186 QVariantList actors;
187 for (const auto& a : mv.actors) actors << QString::fromStdString(a);
188 m["actors"] = actors;
189 }
190 {
191 QVariantList genres;
192 for (const auto& g : mv.genres) genres << QString::fromStdString(g);
193 m["genres"] = genres;
194 }
195 m["runtimeMinutes"] = mv.runtimeMinutes;
196 return m;
197 }
198
200 Q_INVOKABLE bool addMovieByImdbId(const QString& imdbId) {
201 try {
202 AppConfig cfg = loadConfig();
203 if (!cfg.omdbEnabled || cfg.omdbApiKey.empty()) return false;
204 auto maybe = omdbGetById(cfg.omdbApiKey, imdbId.toStdString());
205 if (!maybe) return false;
206 Movie mv = *maybe;
207 // Ensure it's appended at the end by direct add (insertion order)
208 Top100 list(cfg.dataFile);
209 list.addMovie(mv);
210 list.recomputeRanks();
211 // Force persistence by destructing list (save in destructor)
212 } catch (...) { return false; }
213 reload();
214 return true;
215 }
216
218 Q_INVOKABLE bool deleteByImdbId(const QString& imdbId) {
219 try {
220 AppConfig cfg = loadConfig();
221 Top100 list(cfg.dataFile);
222 bool removed = list.removeByImdbId(imdbId.toStdString());
223 if (!removed) return false;
224 list.recomputeRanks();
225 } catch (...) { return false; }
226 reload();
227 return true;
228 }
229
231 Q_INVOKABLE bool deleteByTitle(const QString& title) {
232 try {
233 AppConfig cfg = loadConfig();
234 Top100 list(cfg.dataFile);
235 list.removeMovie(title.toStdString());
236 } catch (...) { return false; }
237 reload();
238 return true;
239 }
240
242 Q_INVOKABLE QVariantList searchOmdb(const QString& query) {
243 QVariantList out;
244 try {
245 AppConfig cfg = loadConfig();
246 if (!cfg.omdbEnabled || cfg.omdbApiKey.empty()) return out;
247 auto results = omdbSearch(cfg.omdbApiKey, query.toStdString());
248 for (const auto& r : results) {
249 QVariantMap m;
250 m["title"] = QString::fromStdString(r.title);
251 m["year"] = r.year;
252 m["imdbID"] = QString::fromStdString(r.imdbID);
253 out.push_back(m);
254 }
255 } catch (...) {
256 // ignore, return empty
257 }
258 return out;
259 }
260
263 Q_INVOKABLE QVariantMap omdbGetByIdMap(const QString& imdbId) const {
264 QVariantMap m;
265 try {
266 AppConfig cfg = loadConfig();
267 if (!cfg.omdbEnabled || cfg.omdbApiKey.empty()) return m;
268 auto maybe = omdbGetById(cfg.omdbApiKey, imdbId.toStdString());
269 if (!maybe) return m;
270 const Movie& mv = *maybe;
271 m["title"] = QString::fromStdString(mv.title);
272 m["year"] = mv.year;
273 m["posterUrl"] = QString::fromStdString(mv.posterUrl);
274 m["plotShort"] = QString::fromStdString(mv.plotShort);
275 m["plotFull"] = QString::fromStdString(mv.plotFull);
276 } catch (...) {
277 // ignore
278 }
279 return m;
280 }
282 Q_INVOKABLE bool updateFromOmdbByImdbId(const QString& imdbId) {
283 try {
284 AppConfig cfg = loadConfig();
285 if (!cfg.omdbEnabled || cfg.omdbApiKey.empty()) return false;
286 auto maybe = omdbGetById(cfg.omdbApiKey, imdbId.toStdString());
287 if (!maybe) return false;
288 Top100 list(cfg.dataFile);
289 bool ok = list.mergeFromOmdbByImdbId(*maybe);
290 if (!ok) return false;
291 } catch (...) { return false; }
292 // Preserve current selection by imdb when reloading
293 QString imdb = imdbId;
294 QMetaObject::Connection conn;
295 conn = connect(this, &Top100ListModel::reloadCompleted, this, [this, imdb, &conn]() {
296 for (int i = 0; i < rowCount(); ++i) {
297 if (get(i).value("imdbID").toString() == imdb) { emit requestSelectRow(i); break; }
298 }
299 disconnect(conn);
300 });
301 reload();
302 return true;
303 }
304
306 Q_INVOKABLE int count() const { return rowCount(); }
307
319 Q_INVOKABLE bool recordPairwiseResult(int leftRow, int rightRow, int winner);
320
322 Q_INVOKABLE bool postToBlueSky(int row) {
323 if (row < 0 || row >= static_cast<int>(movies_.size())) return false;
324 try {
325 AppConfig cfg = loadConfig();
326 if (!cfg.blueSkyEnabled || cfg.blueSkyIdentifier.empty() || cfg.blueSkyAppPassword.empty()) return false;
327 const Movie& m = movies_[row];
328 return postMovieToBlueSky(cfg, m);
329 } catch (...) {
330 return false;
331 }
332 }
333
335 Q_INVOKABLE bool postToMastodon(int row) {
336 if (row < 0 || row >= static_cast<int>(movies_.size())) return false;
337 try {
338 AppConfig cfg = loadConfig();
339 if (!cfg.mastodonEnabled || cfg.mastodonInstance.empty() || cfg.mastodonAccessToken.empty()) return false;
340 const Movie& m = movies_[row];
341 return postMovieToMastodon(cfg, m);
342 } catch (...) {
343 return false;
344 }
345 }
346
348 Q_INVOKABLE void postToBlueSkyAsync(int row) {
349 if (row < 0 || row >= static_cast<int>(movies_.size())) return;
350 AppConfig cfg;
351 try { cfg = loadConfig(); } catch (...) { emit postingFinished("BlueSky", row, false); return; }
352 if (!cfg.blueSkyEnabled || cfg.blueSkyIdentifier.empty() || cfg.blueSkyAppPassword.empty()) { emit postingFinished("BlueSky", row, false); return; }
353 Movie mv = movies_[row];
354 auto future = QtConcurrent::run([cfg, mv]() { return postMovieToBlueSky(cfg, mv); });
355 auto *watcher = new QFutureWatcher<bool>(this);
356 connect(watcher, &QFutureWatcher<bool>::finished, this, [this, watcher, row]() {
357 bool ok = watcher->result();
358 emit postingFinished("BlueSky", row, ok);
359 watcher->deleteLater();
360 });
361 watcher->setFuture(future);
362 }
363
365 Q_INVOKABLE void postToMastodonAsync(int row) {
366 if (row < 0 || row >= static_cast<int>(movies_.size())) return;
367 AppConfig cfg;
368 try { cfg = loadConfig(); } catch (...) { emit postingFinished("Mastodon", row, false); return; }
369 if (!cfg.mastodonEnabled || cfg.mastodonInstance.empty() || cfg.mastodonAccessToken.empty()) { emit postingFinished("Mastodon", row, false); return; }
370 Movie mv = movies_[row];
371 auto future = QtConcurrent::run([cfg, mv]() { return postMovieToMastodon(cfg, mv); });
372 auto *watcher = new QFutureWatcher<bool>(this);
373 connect(watcher, &QFutureWatcher<bool>::finished, this, [this, watcher, row]() {
374 bool ok = watcher->result();
375 emit postingFinished("Mastodon", row, ok);
376 watcher->deleteLater();
377 });
378 watcher->setFuture(future);
379 }
380
381signals:
383 void requestSelectRow(int row);
385 void postingFinished(const QString& service, int row, bool success);
387 void sortOrderChanged(int sortOrder);
390
391private:
392 std::vector<Movie> movies_;
393 SortOrder currentOrder_ = SortOrder::DEFAULT;
394};
Definition Top100ListModel.h:43
Roles
Definition Top100ListModel.h:48
Q_INVOKABLE QVariantMap get(int row) const
Definition Top100ListModel.h:174
Q_INVOKABLE bool deleteByImdbId(const QString &imdbId)
Definition Top100ListModel.h:218
void requestSelectRow(int row)
Q_INVOKABLE QVariantList searchOmdb(const QString &query)
Definition Top100ListModel.h:242
Q_INVOKABLE bool addMovieByImdbId(const QString &imdbId)
Definition Top100ListModel.h:200
Q_INVOKABLE bool postToMastodon(int row)
Definition Top100ListModel.h:335
Q_INVOKABLE bool updateFromOmdbByImdbId(const QString &imdbId)
Definition Top100ListModel.h:282
Q_INVOKABLE QVariantMap omdbGetByIdMap(const QString &imdbId) const
Definition Top100ListModel.h:263
Q_INVOKABLE bool deleteByTitle(const QString &title)
Definition Top100ListModel.h:231
Q_INVOKABLE bool recordPairwiseResult(int leftRow, int rightRow, int winner)
Record a pairwise ranking result between two rows in the current model view.
Definition Top100ListModel.cpp:27
Q_INVOKABLE void reload()
Definition Top100ListModel.h:129
void reloadCompleted()
Q_INVOKABLE void setSortOrder(int order)
Definition Top100ListModel.h:150
void sortOrderChanged(int sortOrder)
void postingFinished(const QString &service, int row, bool success)
Q_INVOKABLE void postToBlueSkyAsync(int row)
Definition Top100ListModel.h:348
Q_INVOKABLE void postToMastodonAsync(int row)
Definition Top100ListModel.h:365
Q_INVOKABLE int count() const
QML-friendly row count accessor.
Definition Top100ListModel.h:306
Q_INVOKABLE bool postToBlueSky(int row)
Definition Top100ListModel.h:322
Persistent container for up to 100 movies, with ranking.
Definition top100.h:41
std::vector< Movie > getMovies(SortOrder order=SortOrder::DEFAULT) const
Return a copy of movies in the requested sort order.
Definition top100.cpp:44
void removeMovie(const std::string &title)
Remove by title (first match). No-op if not found.
Definition top100.cpp:30
void addMovie(const Movie &movie)
Add a movie; replaces existing title+year duplicates.
Definition top100.cpp:26
bool mergeFromOmdbByImdbId(const Movie &omdbMovie)
Merge updated metadata into an existing movie by IMDb ID. Copies all metadata fields from the provide...
Definition top100.cpp:149
void recomputeRanks()
Recompute 1-based userRank from userScore descending.
Definition top100.cpp:132
bool removeByImdbId(const std::string &imdbID)
Remove by IMDb ID (preferred precise delete).
Definition top100.cpp:36
AppConfig loadConfig()
Load configuration from disk, creating defaults if missing.
Definition config.cpp:81
void saveConfig(const AppConfig &cfg)
Persist configuration to disk.
Definition config.cpp:111
SortOrder
Sort orders for listing movies.
Definition top100.h:25
std::optional< Movie > omdbGetById(const std::string &apiKey, const std::string &imdbID)
Definition omdb.cpp:69
std::vector< OmdbSearchResult > omdbSearch(const std::string &apiKey, const std::string &query)
Definition omdb.cpp:51
Persistent application configuration stored in a single JSON file.
Definition config.h:39
bool mastodonEnabled
Whether Mastodon posting is enabled.
Definition config.h:55
int uiSortOrder
Persisted sort order (matches SortOrder enum values)
Definition config.h:60
std::string omdbApiKey
OMDb API key (empty if not configured)
Definition config.h:42
std::string blueSkyAppPassword
App password (keep private)
Definition config.h:47
std::string dataFile
Absolute path to your movie data JSON.
Definition config.h:40
std::string mastodonAccessToken
User access token (keep private)
Definition config.h:57
bool blueSkyEnabled
Whether BlueSky posting is enabled.
Definition config.h:45
std::string blueSkyIdentifier
Handle or email used to login.
Definition config.h:46
std::string mastodonInstance
Instance base URL (e.g., https://mastodon.social)
Definition config.h:56
bool omdbEnabled
Whether OMDb features are enabled in the UI.
Definition config.h:41
Movie domain model and metadata.
Definition Movie.h:27