feat(library): page Artists from local catalog

Materialize source-scoped artist aggregates and FTS, then expose exact keyset pages and bounded A-Z metadata for the existing Artists rail.

Query the selected artist's albums through the indexed credit relationship with an independent bounded model, generation cancellation, artwork updates, and per-surface legacy fallback.

cx
This commit is contained in:
vicrodh committed 2026-08-23 12:17:08 -06:00
1 parent 299dcde5b4
commit 6fd83c42df
18 files changed
+3128 -36

No files matched your search

+19
View File
@@ -13,6 +13,7 @@
namespace {
QPointer<LocalAlbumsModel> g_model;
QPointer<LocalAlbumsModel> g_artist_model;
void registerLocalAlbumsModel()
{
@@ -21,6 +22,10 @@ void registerLocalAlbumsModel()
g_model = model;
qmlRegisterSingletonInstance<LocalAlbumsModel>(
"com.blitzfc.qbz", 1, 0, "QbzLocalAlbums", model);
auto *artistModel = new LocalAlbumsModel(application);
g_artist_model = artistModel;
qmlRegisterSingletonInstance<LocalAlbumsModel>(
"com.blitzfc.qbz", 1, 0, "QbzLocalArtistAlbums", artistModel);
}
Q_COREAPP_STARTUP_FUNCTION(registerLocalAlbumsModel)
@@ -303,3 +308,17 @@ extern "C" void qbz_local_albums_set_selection(
g_model->setSelection(
generation, QByteArray(json ? json : "{}"), selectedCount);
}
extern "C" void qbz_local_artist_albums_reset(
int generation, qint64 totalCount, qint64 albumTotal)
{
if (g_artist_model)
g_artist_model->resetQuery(generation, totalCount, albumTotal);
}
extern "C" bool qbz_local_artist_albums_apply_page(
int generation, int page, const char *json)
{
return g_artist_model && g_artist_model->applyPage(
generation, page, QByteArray(json ? json : "[]"));
}
+2
View File
@@ -83,6 +83,8 @@ void qbz_local_albums_register_qml_type();
void qbz_local_albums_reset(int generation, qint64 totalCount, qint64 albumTotal);
bool qbz_local_albums_apply_page(int generation, int page, const char *json);
void qbz_local_albums_set_selection(int generation, const char *json, qint64 selectedCount);
void qbz_local_artist_albums_reset(int generation, qint64 totalCount, qint64 albumTotal);
bool qbz_local_artist_albums_apply_page(int generation, int page, const char *json);
}
#endif // QBZ_LOCAL_ALBUMS_MODEL_H
+235
View File
@@ -0,0 +1,235 @@
#include "local_artists_model.h"
#include <QtCore/QCoreApplication>
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QMetaMethod>
#include <QtCore/QMetaObject>
#include <QtCore/QPointer>
#include <QtQml/qqml.h>
#include <limits>
namespace {
QPointer<LocalArtistsModel> g_model;
void registerLocalArtistsModel()
{
auto *model = new LocalArtistsModel(QCoreApplication::instance());
g_model = model;
qmlRegisterSingletonInstance<LocalArtistsModel>(
"com.blitzfc.qbz", 1, 0, "QbzLocalArtists", model);
}
Q_COREAPP_STARTUP_FUNCTION(registerLocalArtistsModel)
}
LocalArtistsModel::LocalArtistsModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int LocalArtistsModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return int(qMin<qint64>(m_totalCount, std::numeric_limits<int>::max()));
}
QVariant LocalArtistsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || role != ModelDataRole)
return {};
return modelData(index.row());
}
QHash<int, QByteArray> LocalArtistsModel::roleNames() const
{
return {{ ModelDataRole, QByteArrayLiteral("modelData") }};
}
QVariantMap LocalArtistsModel::rowAt(qint64 row) const
{
if (row < 0 || row >= m_totalCount)
return {};
return modelData(row);
}
QVariantMap LocalArtistsModel::modelData(qint64 row) const
{
const int page = int(row / PageEntries);
const int offset = int(row % PageEntries);
const auto found = m_pages.constFind(page);
if (found == m_pages.cend() || offset >= found->size()) {
requestPage(page, false);
return {
{ QStringLiteral("t"), 1 },
{ QStringLiteral("loading"), true },
{ QStringLiteral("base"), 0 },
{ QStringLiteral("item"), QVariantMap() },
};
}
touch(page);
QVariantMap values = found->at(offset).values;
values.insert(QStringLiteral("loading"), false);
return values;
}
void LocalArtistsModel::requestPage(int page, bool prefetch) const
{
if (page < 0)
return;
const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalArtistsModel::pageMiss);
if (!isSignalConnected(missSignal))
return;
if (m_pending.contains(page)) {
if (!prefetch)
m_pending.insert(page, false);
return;
}
m_pending.insert(page, prefetch);
const int generation = m_generation;
auto *self = const_cast<LocalArtistsModel *>(this);
QMetaObject::invokeMethod(
self,
[self, page, generation]() {
if (generation != self->m_generation)
return;
if (self->isSignalConnected(
QMetaMethod::fromSignal(&LocalArtistsModel::pageMiss))) {
emit self->pageMiss(page, generation);
} else {
self->m_pending.remove(page);
}
},
Qt::QueuedConnection);
}
void LocalArtistsModel::touch(int page) const
{
m_lru.removeAll(page);
m_lru.append(page);
}
void LocalArtistsModel::resetQuery(int generation, qint64 totalCount, qint64 artistTotal)
{
beginResetModel();
m_generation = generation;
m_totalCount = qMax<qint64>(0, totalCount);
m_artistTotal = qMax<qint64>(0, artistTotal);
m_pages.clear();
m_lru.clear();
m_pending.clear();
m_residentArtists = 0;
m_evictions = 0;
endResetModel();
emit generationChanged();
emit totalCountChanged();
emit artistTotalChanged();
emit residentArtistsChanged();
emit evictionsChanged();
}
bool LocalArtistsModel::applyPage(int generation, int page, const QByteArray &json)
{
if (generation != m_generation || page < 0)
return false;
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(json, &error);
if (error.error != QJsonParseError::NoError || !document.isArray())
return false;
Page entries;
const QJsonArray array = document.array();
entries.reserve(array.size());
for (const QJsonValue &value : array) {
if (!value.isObject())
return false;
Entry entry;
entry.values = value.toObject().toVariantMap();
entry.isArtist = entry.values.value(QStringLiteral("t")).toInt() == 1;
entry.artKey = entry.values.value(QStringLiteral("item"))
.toMap()
.value(QStringLiteral("artKey"))
.toString();
entries.append(std::move(entry));
}
const auto previous = m_pages.constFind(page);
if (previous != m_pages.cend()) {
for (const Entry &entry : *previous)
m_residentArtists -= int(entry.isArtist);
}
m_pages.insert(page, std::move(entries));
for (const Entry &entry : m_pages.value(page))
m_residentArtists += int(entry.isArtist);
const bool wasPrefetch = m_pending.take(page);
touch(page);
evictIfNeeded();
const int first = page * PageEntries;
const int last = qMin(rowCount() - 1, first + PageEntries - 1);
if (first <= last)
emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole });
emit residentArtistsChanged();
const int next = page + 1;
if (!wasPrefetch && qint64(next) * PageEntries < m_totalCount && !m_pages.contains(next))
requestPage(next, true);
return true;
}
void LocalArtistsModel::evictIfNeeded()
{
while (m_residentArtists > MaxResidentArtists && m_pages.size() > 1 && !m_lru.isEmpty()) {
const int page = m_lru.takeFirst();
const auto found = m_pages.find(page);
if (found == m_pages.end())
continue;
for (const Entry &entry : *found)
m_residentArtists -= int(entry.isArtist);
m_pages.erase(found);
++m_evictions;
const int first = page * PageEntries;
const int last = qMin(rowCount() - 1, first + PageEntries - 1);
if (first <= last)
emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole });
}
emit evictionsChanged();
}
void LocalArtistsModel::setArtwork(const QString &artKey, const QString &path)
{
if (artKey.isEmpty() || path.isEmpty())
return;
for (auto page = m_pages.begin(); page != m_pages.end(); ++page) {
for (int offset = 0; offset < page->size(); ++offset) {
Entry &entry = (*page)[offset];
if (entry.artKey != artKey)
continue;
QVariantMap item = entry.values.value(QStringLiteral("item")).toMap();
item.insert(QStringLiteral("artPath"), path);
entry.values.insert(QStringLiteral("item"), item);
const int row = page.key() * PageEntries + offset;
emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole });
}
}
}
extern "C" void qbz_local_artists_register_qml_type()
{
// Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION.
}
extern "C" void qbz_local_artists_reset(
int generation, qint64 totalCount, qint64 artistTotal)
{
if (g_model)
g_model->resetQuery(generation, totalCount, artistTotal);
}
extern "C" bool qbz_local_artists_apply_page(int generation, int page, const char *json)
{
return g_model && g_model->applyPage(
generation, page, QByteArray(json ? json : "[]"));
}
+79
View File
@@ -0,0 +1,79 @@
#ifndef QBZ_LOCAL_ARTISTS_MODEL_H
#define QBZ_LOCAL_ARTISTS_MODEL_H
#include <QtCore/QAbstractListModel>
#include <QtCore/QHash>
#include <QtCore/QList>
#include <QtCore/QVariantMap>
class LocalArtistsModel final : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int generation READ generation NOTIFY generationChanged)
Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged)
Q_PROPERTY(qint64 artistTotal READ artistTotal NOTIFY artistTotalChanged)
Q_PROPERTY(int residentArtists READ residentArtists NOTIFY residentArtistsChanged)
Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged)
public:
enum Role { ModelDataRole = Qt::UserRole + 1 };
explicit LocalArtistsModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
int generation() const { return m_generation; }
qint64 totalCount() const { return m_totalCount; }
qint64 artistTotal() const { return m_artistTotal; }
int residentArtists() const { return m_residentArtists; }
qint64 evictions() const { return m_evictions; }
Q_INVOKABLE QVariantMap rowAt(qint64 row) const;
Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path);
void resetQuery(int generation, qint64 totalCount, qint64 artistTotal);
bool applyPage(int generation, int page, const QByteArray &json);
signals:
void generationChanged();
void totalCountChanged();
void artistTotalChanged();
void residentArtistsChanged();
void evictionsChanged();
void pageMiss(int page, int generation);
private:
struct Entry {
QVariantMap values;
QString artKey;
bool isArtist = false;
};
using Page = QList<Entry>;
static constexpr int PageEntries = 100;
static constexpr int MaxResidentArtists = 2000;
QVariantMap modelData(qint64 row) const;
void requestPage(int page, bool prefetch = false) const;
void touch(int page) const;
void evictIfNeeded();
int m_generation = 0;
qint64 m_totalCount = 0;
qint64 m_artistTotal = 0;
QHash<int, Page> m_pages;
mutable QList<int> m_lru;
mutable QHash<int, bool> m_pending;
int m_residentArtists = 0;
qint64 m_evictions = 0;
};
extern "C" {
void qbz_local_artists_register_qml_type();
void qbz_local_artists_reset(int generation, qint64 totalCount, qint64 artistTotal);
bool qbz_local_artists_apply_page(int generation, int page, const char *json);
}
#endif // QBZ_LOCAL_ARTISTS_MODEL_H