Files
qobuz-qt/src/model/tracklistmodel.cpp
joren 5bda2396d1 fix: security hardening and code quality improvements
Build hardening:
- Add -fstack-protector-strong, -D_FORTIFY_SOURCE=2, PIE, full RELRO
- Enable overflow-checks in Rust release profile

Rust backend:
- Return null (not panic) if Tokio runtime or QobuzClient init fails
- Strip null bytes in FFI JSON callback to prevent CString panics
- Document MD5 and password-in-query as Qobuz API constraints

C++ frontend:
- Validate JSON document before accessing fields in onEvent()
- Handle null backend pointer from failed init
- Set biography label to PlainText and decode HTML entities to prevent
  rendering injected content from API responses
- Clamp slider position and guard negative durations
- Use qint64 for duration formatting to avoid int truncation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 23:19:04 +01:00

302 lines
9.3 KiB
C++

#include "tracklistmodel.hpp"
#include <QJsonValue>
#include <QColor>
#include <QIcon>
#include <algorithm>
TrackListModel::TrackListModel(QObject *parent)
: QAbstractTableModel(parent)
{}
void TrackListModel::setTracks(const QJsonArray &tracks,
bool usePosition,
bool useSequential)
{
beginResetModel();
m_tracks.clear();
m_tracks.reserve(tracks.size());
// Parse into a temporary list first so we can detect multi-disc
QVector<TrackItem> parsed;
parsed.reserve(tracks.size());
int seq = 1;
for (const QJsonValue &v : tracks) {
const QJsonObject t = v.toObject();
TrackItem item;
item.id = static_cast<qint64>(t["id"].toDouble());
item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
item.discNumber = t["media_number"].toInt(1);
item.duration = static_cast<qint64>(t["duration"].toDouble());
item.streamable = t["streamable"].toBool(true);
item.hiRes = t["hires_streamable"].toBool();
item.raw = t;
// Combine title + version ("Melody" + "Vocal Remix" → "Melody (Vocal Remix)")
const QString base = t["title"].toString();
const QString version = t["version"].toString().trimmed();
item.title = version.isEmpty() ? base
: base + QStringLiteral(" (") + version + QLatin1Char(')');
if (useSequential) {
item.number = seq++;
} else if (usePosition) {
const int pos = t["position"].toInt();
item.number = pos > 0 ? pos : seq;
++seq;
} else {
item.number = t["track_number"].toInt();
}
const QJsonObject performer = t["performer"].toObject();
item.artist = performer["name"].toString();
if (item.artist.isEmpty())
item.artist = t["album"].toObject()["artist"].toObject()["name"].toString();
const QJsonObject album = t["album"].toObject();
item.album = album["title"].toString();
item.albumId = album["id"].toString();
parsed.append(item);
}
// Multi-disc only makes sense for album context (not playlists / fav / search)
int maxDisc = 1;
if (!usePosition && !useSequential) {
for (const TrackItem &t : parsed)
maxDisc = qMax(maxDisc, t.discNumber);
}
m_hasMultipleDiscs = (maxDisc > 1);
if (m_hasMultipleDiscs) {
// Sort by disc then track number
std::stable_sort(parsed.begin(), parsed.end(), [](const TrackItem &a, const TrackItem &b) {
return a.discNumber != b.discNumber ? a.discNumber < b.discNumber
: a.number < b.number;
});
// Interleave disc header items
int currentDisc = -1;
for (const TrackItem &t : parsed) {
if (t.discNumber != currentDisc) {
TrackItem header;
header.isDiscHeader = true;
header.discNumber = t.discNumber;
header.title = tr("Disc %1").arg(t.discNumber);
m_tracks.append(header);
currentDisc = t.discNumber;
}
m_tracks.append(t);
}
} else {
m_tracks = parsed;
// Re-apply sort silently inside the reset
if (m_sortColumn >= 0)
sortData(m_sortColumn, m_sortOrder);
}
endResetModel();
if (!m_hasMultipleDiscs && m_sortColumn >= 0)
emit sortApplied();
}
void TrackListModel::clear()
{
beginResetModel();
m_tracks.clear();
endResetModel();
}
void TrackListModel::removeTrack(int row)
{
if (row < 0 || row >= m_tracks.size()) return;
beginRemoveRows({}, row, row);
m_tracks.removeAt(row);
endRemoveRows();
}
void TrackListModel::setFavIds(const QSet<qint64> &ids)
{
m_favIds = ids;
if (!m_tracks.isEmpty())
emit dataChanged(index(0, ColTitle), index(rowCount() - 1, ColTitle),
{Qt::DecorationRole});
}
void TrackListModel::addFavId(qint64 id)
{
m_favIds.insert(id);
for (int r = 0; r < m_tracks.size(); ++r) {
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
}
void TrackListModel::removeFavId(qint64 id)
{
m_favIds.remove(id);
for (int r = 0; r < m_tracks.size(); ++r) {
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
}
void TrackListModel::setPlayingId(qint64 id)
{
m_playingId = id;
if (!m_tracks.isEmpty())
emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1),
{Qt::FontRole, Qt::DecorationRole});
}
Qt::ItemFlags TrackListModel::flags(const QModelIndex &index) const
{
if (!index.isValid() || index.row() >= m_tracks.size())
return Qt::NoItemFlags;
if (m_tracks.at(index.row()).isDiscHeader)
return Qt::ItemIsEnabled;
return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}
QVector<int> TrackListModel::discHeaderRows() const
{
QVector<int> rows;
for (int i = 0; i < m_tracks.size(); ++i)
if (m_tracks[i].isDiscHeader) rows.append(i);
return rows;
}
int TrackListModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_tracks.size();
}
int TrackListModel::columnCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : ColCount;
}
QVariant TrackListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_tracks.size())
return {};
const TrackItem &t = m_tracks.at(index.row());
// Disc header rows: styled separator spanning all columns via setFirstColumnSpanned
if (t.isDiscHeader) {
if (role == Qt::DisplayRole && index.column() == ColNumber)
return t.title;
if (role == Qt::FontRole) {
QFont f; f.setBold(true); return f;
}
if (role == Qt::ForegroundRole)
return QColor(0xFF, 0xB2, 0x32);
return {};
}
const bool isPlaying = (t.id == m_playingId && m_playingId != 0);
if (role == Qt::DisplayRole) {
switch (index.column()) {
case ColNumber: return t.number > 0 ? QString::number(t.number) : QString();
case ColTitle: return t.title;
case ColArtist: return t.artist;
case ColAlbum: return t.album;
case ColDuration: return formatDuration(t.duration);
}
}
if (role == Qt::FontRole && isPlaying) {
QFont f;
f.setBold(true);
return f;
}
if (role == Qt::ForegroundRole) {
if (!t.streamable) return QColor(0x55, 0x55, 0x55);
if (isPlaying) return QColor(0xFF, 0xB2, 0x32); // Qobuz orange
}
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
}
if (role == Qt::DecorationRole && index.column() == ColTitle && m_favIds.contains(t.id)) {
return QIcon(QStringLiteral(":/res/icons/starred-symbolic.svg"));
}
if (role == TrackIdRole) return t.id;
if (role == TrackJsonRole) return t.raw;
if (role == HiResRole) return t.hiRes;
if (role == PlaylistTrackIdRole) return t.playlistTrackId;
return {};
}
QVariant TrackListModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (orientation != Qt::Horizontal) return {};
if (role == Qt::DisplayRole) {
switch (section) {
case ColNumber: return tr("#");
case ColTitle: return tr("Title");
case ColArtist: return tr("Artist");
case ColAlbum: return tr("Album");
case ColDuration: return tr("Duration");
}
}
if (role == Qt::DecorationRole && section == m_sortColumn) {
return QIcon(QStringLiteral(":/res/icons/view-sort-ascending.svg"));
}
return {};
}
void TrackListModel::sortData(int column, Qt::SortOrder order)
{
auto cmp = [&](const TrackItem &a, const TrackItem &b) -> bool {
bool less = false;
switch (column) {
case ColNumber: less = a.number < b.number; break;
case ColTitle: less = a.title < b.title; break;
case ColArtist: less = a.artist < b.artist; break;
case ColAlbum: less = a.album < b.album; break;
case ColDuration: less = a.duration < b.duration; break;
default: less = false;
}
return order == Qt::AscendingOrder ? less : !less;
};
std::stable_sort(m_tracks.begin(), m_tracks.end(), cmp);
}
void TrackListModel::sort(int column, Qt::SortOrder order)
{
m_sortColumn = column;
m_sortOrder = order;
// Multi-disc albums keep their disc-ordered layout; don't re-sort
if (m_hasMultipleDiscs || m_tracks.isEmpty()) return;
emit layoutAboutToBeChanged();
sortData(column, order);
emit layoutChanged();
emit sortApplied();
}
QString TrackListModel::formatDuration(qint64 secs)
{
if (secs < 0) secs = 0;
const qint64 m = secs / 60;
const qint64 s = secs % 60;
return QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0'));
}