feat(library): page Tracks from local catalog

Add an opt-in QAbstractListModel backed by keyset pages from the derived catalog, with bounded residency, global query semantics, source-aware actions, and per-session fallback to the existing reader.

Wire profile-scoped bootstrap locations and refresh the native surface only after a complete catalog is active.

cx
This commit is contained in:
vicrodh committed 2026-08-23 09:37:15 -06:00
1 parent 7689fb0df6
commit 1bb3c41dcf
23 files changed
+2634 -130

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
#include "local_tracks_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<LocalTracksModel> g_model;
void registerLocalTracksModel()
{
auto *application = QCoreApplication::instance();
auto *model = new LocalTracksModel(application);
g_model = model;
qmlRegisterSingletonInstance<LocalTracksModel>(
"com.blitzfc.qbz", 1, 0, "QbzLocalTracks", model);
}
Q_COREAPP_STARTUP_FUNCTION(registerLocalTracksModel)
}
LocalTracksModel::LocalTracksModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int LocalTracksModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return int(qMin<qint64>(m_totalCount, std::numeric_limits<int>::max()));
}
QVariant LocalTracksModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || role != ModelDataRole)
return {};
return modelData(index.row());
}
QHash<int, QByteArray> LocalTracksModel::roleNames() const
{
return {{ ModelDataRole, QByteArrayLiteral("modelData") }};
}
QVariantMap LocalTracksModel::rowAt(qint64 row) const
{
if (row < 0 || row >= m_totalCount)
return {};
return modelData(row);
}
QVariantMap LocalTracksModel::modelData(qint64 row) const
{
const int page = int(row / PageRows);
const int offset = int(row % PageRows);
const auto found = m_pages.constFind(page);
if (found == m_pages.cend() || offset >= found->size()) {
requestPage(page, false);
return {
{ QStringLiteral("t"), 1 },
{ QStringLiteral("n"), row + 1 },
{ QStringLiteral("loading"), true },
{ QStringLiteral("groupStart"), false },
{ QStringLiteral("groupLabel"), QString() },
{ QStringLiteral("selected"), false },
{ QStringLiteral("row"), QVariantMap() },
};
}
touch(page);
const TrackRow &track = found->at(offset);
QVariantMap values = track.row;
values.insert(QStringLiteral("selected"), selected(row));
return {
{ QStringLiteral("t"), 1 },
{ QStringLiteral("n"), row + 1 },
{ QStringLiteral("loading"), false },
{ QStringLiteral("groupStart"), track.groupStart },
{ QStringLiteral("groupLabel"), track.groupLabel },
{ QStringLiteral("selected"), selected(row) },
{ QStringLiteral("row"), values },
};
}
void LocalTracksModel::requestPage(int page, bool prefetch) const
{
if (page < 0)
return;
const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalTracksModel::pageMiss);
// The Local Library view is lazy. A prefetch published before its
// Connections object exists would otherwise be lost while leaving this
// page permanently marked pending across the later mount.
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<LocalTracksModel *>(this);
QMetaObject::invokeMethod(
self,
[self, page, generation]() {
if (generation != self->m_generation)
return;
const QMetaMethod missSignal =
QMetaMethod::fromSignal(&LocalTracksModel::pageMiss);
if (self->isSignalConnected(missSignal)) {
emit self->pageMiss(page, generation);
} else {
self->m_pending.remove(page);
}
},
Qt::QueuedConnection);
}
void LocalTracksModel::touch(int page) const
{
m_lru.removeAll(page);
m_lru.append(page);
}
bool LocalTracksModel::selected(qint64 row) const
{
bool inRange = false;
for (const auto &range : m_selectionRanges) {
if (row < range.first)
break;
if (row <= range.second) {
inRange = true;
break;
}
}
return m_selectAll ? !inRange : inRange;
}
void LocalTracksModel::resetQuery(int generation, qint64 totalCount, const QString &group)
{
beginResetModel();
m_generation = generation;
m_totalCount = qMax<qint64>(0, totalCount);
m_group = group;
m_pages.clear();
m_lru.clear();
m_pending.clear();
m_residentRows = 0;
m_evictions = 0;
m_selectAll = false;
m_selectionRanges.clear();
m_selectedCount = 0;
endResetModel();
emit generationChanged();
emit totalCountChanged();
emit residentRowsChanged();
emit evictionsChanged();
emit selectedCountChanged();
}
bool LocalTracksModel::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 rows;
const QJsonArray array = document.array();
rows.reserve(array.size());
for (const QJsonValue &value : array) {
if (!value.isObject())
return false;
const QJsonObject object = value.toObject();
TrackRow track;
track.groupStart = object.value(QStringLiteral("groupStart")).toBool();
track.groupLabel = object.value(QStringLiteral("groupLabel")).toString();
track.row = object.value(QStringLiteral("row")).toObject().toVariantMap();
track.artKey = track.row.value(QStringLiteral("artKey")).toString();
rows.append(std::move(track));
}
const auto previous = m_pages.constFind(page);
if (previous != m_pages.cend())
m_residentRows -= previous->size();
m_pages.insert(page, std::move(rows));
m_residentRows += m_pages.value(page).size();
const bool wasPrefetch = m_pending.take(page);
touch(page);
evictIfNeeded();
const int first = page * PageRows;
const int last = qMin(rowCount() - 1, first + PageRows - 1);
if (first <= last)
emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole });
emit residentRowsChanged();
const int next = page + 1;
if (!wasPrefetch && qint64(next) * PageRows < m_totalCount && !m_pages.contains(next))
requestPage(next, true);
return true;
}
void LocalTracksModel::evictIfNeeded()
{
while (m_pages.size() > MaxResidentPages && !m_lru.isEmpty()) {
const int page = m_lru.takeFirst();
const auto found = m_pages.find(page);
if (found == m_pages.end())
continue;
m_residentRows -= found->size();
m_pages.erase(found);
++m_evictions;
const int first = page * PageRows;
const int last = qMin(rowCount() - 1, first + PageRows - 1);
if (first <= last)
emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole });
}
emit evictionsChanged();
}
void LocalTracksModel::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) {
TrackRow &track = (*page)[offset];
if (track.artKey != artKey)
continue;
track.row.insert(QStringLiteral("artPath"), path);
const int row = page.key() * PageRows + offset;
emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole });
}
}
}
void LocalTracksModel::setSelection(
int generation, const QByteArray &json, qint64 selectedCount)
{
if (generation != m_generation)
return;
const QJsonDocument document = QJsonDocument::fromJson(json);
if (!document.isObject())
return;
const QJsonObject object = document.object();
QList<QPair<qint64, qint64>> ranges;
for (const QJsonValue &value : object.value(QStringLiteral("ranges")).toArray()) {
const QJsonArray pair = value.toArray();
if (pair.size() != 2)
continue;
ranges.append({ qMax<qint64>(0, pair.at(0).toInteger()),
qMax<qint64>(0, pair.at(1).toInteger()) });
}
m_selectAll = object.value(QStringLiteral("all")).toBool();
m_selectionRanges = std::move(ranges);
m_selectedCount = qMax<qint64>(0, selectedCount);
emit selectedCountChanged();
if (rowCount() > 0)
emit dataChanged(index(0, 0), index(rowCount() - 1, 0), { ModelDataRole });
}
extern "C" void qbz_local_tracks_register_qml_type()
{
// Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION.
}
extern "C" void qbz_local_tracks_reset(
int generation, qint64 totalCount, const char *group)
{
if (g_model)
g_model->resetQuery(generation, totalCount, QString::fromUtf8(group ? group : ""));
}
extern "C" bool qbz_local_tracks_apply_page(int generation, int page, const char *json)
{
return g_model && g_model->applyPage(
generation, page, QByteArray(json ? json : "[]"));
}
extern "C" void qbz_local_tracks_set_selection(
int generation, const char *json, qint64 selectedCount)
{
if (g_model)
g_model->setSelection(
generation, QByteArray(json ? json : "{}"), selectedCount);
}
+86
View File
@@ -0,0 +1,86 @@
#ifndef QBZ_LOCAL_TRACKS_MODEL_H
#define QBZ_LOCAL_TRACKS_MODEL_H
#include <QtCore/QAbstractListModel>
#include <QtCore/QHash>
#include <QtCore/QList>
#include <QtCore/QVariantMap>
class LocalTracksModel final : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int generation READ generation NOTIFY generationChanged)
Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged)
Q_PROPERTY(int residentRows READ residentRows NOTIFY residentRowsChanged)
Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged)
Q_PROPERTY(qint64 selectedCount READ selectedCount NOTIFY selectedCountChanged)
public:
enum Role { ModelDataRole = Qt::UserRole + 1 };
explicit LocalTracksModel(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; }
int residentRows() const { return m_residentRows; }
qint64 evictions() const { return m_evictions; }
qint64 selectedCount() const { return m_selectedCount; }
Q_INVOKABLE QVariantMap rowAt(qint64 row) const;
Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path);
void resetQuery(int generation, qint64 totalCount, const QString &group);
bool applyPage(int generation, int page, const QByteArray &json);
void setSelection(int generation, const QByteArray &json, qint64 selectedCount);
signals:
void generationChanged();
void totalCountChanged();
void residentRowsChanged();
void evictionsChanged();
void selectedCountChanged();
void pageMiss(int page, int generation);
private:
struct TrackRow {
QVariantMap row;
QString artKey;
QString groupLabel;
bool groupStart = false;
};
using Page = QList<TrackRow>;
static constexpr int PageRows = 250;
static constexpr int MaxResidentPages = 8;
QVariantMap modelData(qint64 row) const;
void requestPage(int page, bool prefetch = false) const;
void touch(int page) const;
bool selected(qint64 row) const;
void evictIfNeeded();
int m_generation = 0;
qint64 m_totalCount = 0;
QString m_group;
QHash<int, Page> m_pages;
mutable QList<int> m_lru;
mutable QHash<int, bool> m_pending;
int m_residentRows = 0;
qint64 m_evictions = 0;
bool m_selectAll = false;
QList<QPair<qint64, qint64>> m_selectionRanges;
qint64 m_selectedCount = 0;
};
extern "C" {
void qbz_local_tracks_register_qml_type();
void qbz_local_tracks_reset(int generation, qint64 totalCount, const char *group);
bool qbz_local_tracks_apply_page(int generation, int page, const char *json);
void qbz_local_tracks_set_selection(int generation, const char *json, qint64 selectedCount);
}
#endif // QBZ_LOCAL_TRACKS_MODEL_H