feat: add c++ lyrics service

This commit is contained in:
2 * r + 2 * t 2026-05-04 01:31:47 +10:00
parent 5e59b5e2a4
commit 8457438f14
5 changed files with 1216 additions and 0 deletions

View file

@ -15,6 +15,8 @@ qml_module(caelestia-services
memory.cpp
diskinfo.cpp
storage.cpp
lyriccandidate.cpp
lyrics.cpp
LIBRARIES
PkgConfig::Pipewire
PkgConfig::Aubio

View file

@ -0,0 +1,52 @@
#include "lyriccandidate.hpp"
#include <utility>
namespace caelestia::services {
LyricCandidate::LyricCandidate(
LyricsBackend backend, QString id, QString title, QString artist, QString album, qreal duration)
: m_backend(backend)
, m_id(std::move(id))
, m_title(std::move(title))
, m_artist(std::move(artist))
, m_album(std::move(album))
, m_duration(duration) {}
LyricsBackend LyricCandidate::backend() const {
return m_backend;
}
QString LyricCandidate::id() const {
return m_id;
}
QString LyricCandidate::title() const {
return m_title;
}
QString LyricCandidate::artist() const {
return m_artist;
}
QString LyricCandidate::album() const {
return m_album;
}
qreal LyricCandidate::duration() const {
return m_duration;
}
bool LyricCandidate::isValid() const {
return !m_id.isEmpty();
}
bool LyricCandidate::operator==(const LyricCandidate& o) const noexcept {
return m_backend == o.m_backend && m_id == o.m_id;
}
bool LyricCandidate::operator!=(const LyricCandidate& o) const noexcept {
return !(*this == o);
}
} // namespace caelestia::services

View file

@ -0,0 +1,53 @@
#pragma once
#include <qqmlintegration.h>
namespace caelestia::services {
Q_NAMESPACE
enum LyricsBackend {
Auto = 0,
Local = 1,
LRCLIB = 2,
NetEase = 3,
};
Q_ENUM_NS(LyricsBackend)
class LyricCandidate {
Q_GADGET
QML_VALUE_TYPE(lyricCandidate)
Q_PROPERTY(LyricsBackend backend READ backend)
Q_PROPERTY(QString id READ id)
Q_PROPERTY(QString title READ title)
Q_PROPERTY(QString artist READ artist)
Q_PROPERTY(QString album READ album)
Q_PROPERTY(qreal duration READ duration)
public:
LyricCandidate() = default;
LyricCandidate(
LyricsBackend backend, QString id, QString title, QString artist, QString album = {}, qreal duration = 0.0);
[[nodiscard]] LyricsBackend backend() const;
[[nodiscard]] QString id() const;
[[nodiscard]] QString title() const;
[[nodiscard]] QString artist() const;
[[nodiscard]] QString album() const;
[[nodiscard]] qreal duration() const;
[[nodiscard]] bool isValid() const;
bool operator==(const LyricCandidate& o) const noexcept;
bool operator!=(const LyricCandidate& o) const noexcept;
private:
LyricsBackend m_backend = Auto;
QString m_id;
QString m_title;
QString m_artist;
QString m_album;
qreal m_duration = 0.0;
};
} // namespace caelestia::services

View file

@ -0,0 +1,969 @@
#include "lyrics.hpp"
#include "../Config/config.hpp"
#include "../Config/serviceconfig.hpp"
#include "../Config/userpaths.hpp"
#include <qdiriterator.h>
#include <qjsonarray.h>
#include <qnetworkcookiejar.h>
#include <qsavefile.h>
#include <qurlquery.h>
Q_LOGGING_CATEGORY(lcLyrics, "caelestia.lyrics", QtInfoMsg)
namespace caelestia::services {
using Qt::StringLiterals::operator""_s;
using Qt::StringLiterals::operator""_ba;
namespace {
constexpr int kLoadDebounceMs = 50;
constexpr qreal kIndexFudge = 0.1;
[[nodiscard]] const QHash<QByteArray, QByteArray>& netEaseHeaders() {
static const QHash<QByteArray, QByteArray> h = {
{ "User-Agent"_ba, "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"_ba },
{ "Referer"_ba, "https://music.163.com/"_ba },
};
return h;
}
[[nodiscard]] const QHash<QByteArray, QByteArray>& lrclibHeaders() {
static const QHash<QByteArray, QByteArray> h = {
{ "User-Agent"_ba, "caelestia-shell (https://github.com/caelestia-dots/shell)"_ba },
};
return h;
}
[[nodiscard]] QString joinArtists(const QString& s) {
return s.trimmed();
}
[[nodiscard]] QString sanitizeFilenamePart(const QString& s) {
QString out;
out.reserve(s.size());
for (const QChar c : s) {
if (c == QLatin1Char('/') || c == QLatin1Char('\0')) {
out.append(QLatin1Char('_'));
} else {
out.append(c);
}
}
return out;
}
[[nodiscard]] bool containsCi(const QString& haystack, const QString& needle) {
return haystack.contains(needle, Qt::CaseInsensitive);
}
} // namespace
Lyrics::Lyrics(QObject* parent)
: QObject(parent)
, m_nam(new QNetworkAccessManager(this))
, m_loadDebounce(new QTimer(this)) {
m_loadDebounce->setSingleShot(true);
m_loadDebounce->setInterval(kLoadDebounceMs);
QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad);
const auto* cfg = config::GlobalConfig::instance();
const auto* svcCfg = cfg->services();
const auto* paths = cfg->paths();
m_preferredBackend = backendFromKey(svcCfg->lyricsBackend());
QObject::connect(
svcCfg, &config::ServiceConfig::lyricsBackendChanged, this, &Lyrics::onPreferredBackendConfigChanged);
QObject::connect(paths, &config::UserPaths::lyricsDirChanged, this, &Lyrics::onLyricsDirChanged);
loadLyricsMap();
}
QStringList Lyrics::lyrics() const {
return m_lyrics;
}
LyricsBackend Lyrics::backend() const {
return m_backend;
}
LyricsBackend Lyrics::preferredBackend() const {
return m_preferredBackend;
}
void Lyrics::setPreferredBackend(LyricsBackend value) {
if (m_preferredBackend == value) {
return;
}
m_preferredBackend = value;
emit preferredBackendChanged();
auto* const svcCfg = config::GlobalConfig::instance()->services();
const QString key = backendKey(value);
if (svcCfg->lyricsBackend() != key) {
svcCfg->set_lyricsBackend(key);
}
scheduleLoad();
}
QList<LyricCandidate> Lyrics::lyricCandidates() const {
return m_candidates;
}
LyricCandidate Lyrics::selectedCandidate() const {
return m_selected;
}
void Lyrics::setSelectedCandidate(const LyricCandidate& value) {
if (m_selected == value) {
return;
}
m_selected = value;
emit selectedCandidateChanged();
if (!value.isValid()) {
return;
}
const auto b = value.backend();
setBackend(b);
setLoading(true);
cancelInFlight();
const int reqId = newRequestId();
if (b == LRCLIB) {
fetchLrclibById(value.id(), reqId);
} else if (b == NetEase) {
fetchNetEaseLyricsById(value.id(), reqId);
} else if (b == Local) {
// For local, the id is the file path. Read directly.
QFile f(value.id());
if (f.open(QIODevice::ReadOnly)) {
const QString text = QString::fromUtf8(f.readAll());
setLines(parseLrc(text), Local);
setLoading(false);
} else {
qCWarning(lcLyrics) << "selectedCandidate: cannot open local file" << value.id();
setLoading(false);
}
}
if (!m_settingFromPrefs) {
persistTrackPrefs();
}
}
bool Lyrics::loading() const {
return m_loading;
}
bool Lyrics::hasLyrics() const {
return m_hasLyrics;
}
qreal Lyrics::offset() const {
return m_offset;
}
void Lyrics::setOffset(qreal value) {
if (qFuzzyCompare(m_offset, value)) {
return;
}
m_offset = value;
emit offsetChanged();
if (!m_settingFromPrefs) {
persistTrackPrefs();
}
}
QString Lyrics::trackArtist() const {
return m_artist;
}
QString Lyrics::trackTitle() const {
return m_title;
}
int Lyrics::indexForTime(qreal time) const {
if (m_lines.isEmpty()) {
return -1;
}
const qreal target = time - m_offset + kIndexFudge;
qsizetype lo = 0;
qsizetype hi = m_lines.size();
while (lo < hi) {
const qsizetype mid = lo + (hi - lo) / 2;
if (m_lines.at(mid).time <= target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return static_cast<int>(lo - 1);
}
void Lyrics::setTrack(const QString& artist, const QString& title, const QString& album, qreal duration) {
const QString a = artist.trimmed();
const QString t = title.trimmed();
if (a == m_artist && t == m_title && album == m_album && qFuzzyCompare(duration + 1.0, m_duration + 1.0)) {
return;
}
m_artist = a;
m_title = t;
m_album = album;
m_duration = duration;
emit trackChanged();
scheduleLoad();
}
void Lyrics::clearTrack() {
cancelInFlight();
m_artist.clear();
m_title.clear();
m_album.clear();
m_duration = 0.0;
emit trackChanged();
clearCandidates();
clearLines();
setLoading(false);
}
void Lyrics::refresh() {
scheduleLoad();
}
void Lyrics::setBackend(LyricsBackend value) {
if (m_backend == value) {
return;
}
m_backend = value;
emit backendChanged();
}
void Lyrics::setLoading(bool value) {
if (m_loading == value) {
return;
}
m_loading = value;
emit loadingChanged();
}
void Lyrics::setLines(QVector<LyricLine> lines, LyricsBackend source) {
std::sort(lines.begin(), lines.end(), [](const LyricLine& a, const LyricLine& b) {
return a.time < b.time;
});
m_lines = std::move(lines);
QStringList list;
list.reserve(m_lines.size());
for (const auto& l : std::as_const(m_lines)) {
list.append(l.text);
}
m_lyrics = std::move(list);
setBackend(source);
emit lyricsChanged();
const auto hasLyrics = !m_lines.isEmpty();
if (hasLyrics != m_hasLyrics) {
m_hasLyrics = hasLyrics;
emit hasLyricsChanged();
}
}
void Lyrics::clearLines() {
// Doesn't actually clear lines, set a flag instead so anims can run
m_hasLyrics = false;
emit hasLyricsChanged();
}
void Lyrics::appendCandidates(const QList<LyricCandidate>& add) {
if (add.isEmpty()) {
return;
}
bool changed = false;
for (const auto& c : add) {
if (!m_candidates.contains(c)) {
m_candidates.append(c);
changed = true;
}
}
if (changed) {
emit lyricCandidatesChanged();
}
}
void Lyrics::clearCandidates() {
if (m_candidates.isEmpty()) {
return;
}
m_candidates.clear();
emit lyricCandidatesChanged();
}
void Lyrics::scheduleLoad() {
m_loadDebounce->start();
}
int Lyrics::newRequestId() {
return ++m_currentRequestId;
}
void Lyrics::cancelInFlight() {
for (auto it = m_pendingReplies.begin(); it != m_pendingReplies.end(); ++it) {
for (auto& ptr : it.value()) {
if (auto* reply = ptr.data()) {
reply->abort();
reply->deleteLater();
}
}
}
m_pendingReplies.clear();
}
void Lyrics::trackReply(int reqId, QNetworkReply* reply) {
if (!reply) {
return;
}
m_pendingReplies[reqId].append(QPointer<QNetworkReply>(reply));
}
void Lyrics::doLoad() {
if (m_artist.isEmpty() && m_title.isEmpty()) {
clearLines();
clearCandidates();
setLoading(false);
return;
}
cancelInFlight();
const int reqId = newRequestId();
setLoading(true);
clearLines();
clearCandidates();
// Restore per-track prefs (offset, last-selected backend/id)
m_settingFromPrefs = true;
const QJsonObject saved = m_lyricsMap.value(trackKey()).toObject();
setOffset(saved.value(u"offset"_s).toDouble(0.0));
LyricCandidate restored;
const QString savedBackendKey = saved.value(u"backend"_s).toString();
const QString savedId = saved.value(u"id"_s).toString();
if (!savedBackendKey.isEmpty() && !savedId.isEmpty()) {
restored = LyricCandidate(backendFromKey(savedBackendKey), savedId, m_title, m_artist, m_album, m_duration);
}
m_settingFromPrefs = false;
// Always populate online candidates for the picker, regardless of preferred backend
searchLrclibCandidates(reqId);
searchNetEaseCandidates(reqId);
if (restored.isValid()) {
// Honor saved selection for this track
m_settingFromPrefs = true;
setSelectedCandidate(restored);
m_settingFromPrefs = false;
return;
}
// Primary attempt by preferred backend
switch (m_preferredBackend) {
case Local:
tryLocal(reqId);
break;
case LRCLIB:
tryLrclib(reqId);
break;
case NetEase:
tryNetEase(reqId);
break;
case Auto:
default:
tryLocal(reqId);
break;
}
}
void Lyrics::chainNext(LyricsBackend just_failed, int reqId) {
if (m_preferredBackend != Auto) {
// Non-auto modes don't chain
setLoading(false);
return;
}
switch (just_failed) {
case Local:
tryLrclib(reqId);
return;
case LRCLIB:
tryNetEase(reqId);
return;
case NetEase:
default:
setLoading(false);
return;
}
}
void Lyrics::tryLocal(int reqId) {
if (reqId != m_currentRequestId) {
return;
}
setBackend(Local);
const QString dir = lyricsDir();
if (dir.isEmpty()) {
chainNext(Local, reqId);
return;
}
const QString direct = tryReadLocalLrc(dir, m_artist, m_title);
if (!direct.isEmpty()) {
QFile f(direct);
if (f.open(QIODevice::ReadOnly)) {
const QString text = QString::fromUtf8(f.readAll());
const auto lines = parseLrc(text);
if (!lines.isEmpty()) {
setLines(lines, Local);
appendCandidates({ LyricCandidate(Local, direct, m_title, m_artist, m_album, m_duration) });
m_selected = LyricCandidate(Local, direct, m_title, m_artist, m_album, m_duration);
emit selectedCandidateChanged();
if (!m_settingFromPrefs) {
persistTrackPrefs();
}
setLoading(false);
return;
}
}
}
const QString recursive = findLocalLrcRecursive(dir, m_artist, m_title);
if (!recursive.isEmpty()) {
QFile f(recursive);
if (f.open(QIODevice::ReadOnly)) {
const QString text = QString::fromUtf8(f.readAll());
const auto lines = parseLrc(text);
if (!lines.isEmpty()) {
setLines(lines, Local);
appendCandidates({ LyricCandidate(Local, recursive, m_title, m_artist, m_album, m_duration) });
m_selected = LyricCandidate(Local, recursive, m_title, m_artist, m_album, m_duration);
emit selectedCandidateChanged();
if (!m_settingFromPrefs) {
persistTrackPrefs();
}
setLoading(false);
return;
}
}
}
qCDebug(lcLyrics) << "no local lrc for" << m_artist << "-" << m_title;
chainNext(Local, reqId);
}
void Lyrics::tryLrclib(int reqId) {
if (reqId != m_currentRequestId) {
return;
}
setBackend(LRCLIB);
QUrl url(u"https://lrclib.net/api/get"_s);
QUrlQuery q;
q.addQueryItem(u"track_name"_s, m_title);
q.addQueryItem(u"artist_name"_s, m_artist);
if (!m_album.isEmpty()) {
q.addQueryItem(u"album_name"_s, m_album);
}
if (m_duration > 0) {
q.addQueryItem(u"duration"_s, QString::number(qRound(m_duration)));
}
url.setQuery(q);
auto* reply = getJson(url, lrclibHeaders());
trackReply(reqId, reply);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] {
reply->deleteLater();
if (reqId != m_currentRequestId) {
return;
}
if (reply->error() != QNetworkReply::NoError) {
qCDebug(lcLyrics) << "lrclib /get error:" << reply->errorString();
chainNext(LRCLIB, reqId);
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const QJsonObject obj = doc.object();
const QString synced = obj.value(u"syncedLyrics"_s).toString();
const qint64 id = static_cast<qint64>(obj.value(u"id"_s).toDouble());
if (synced.isEmpty()) {
qCDebug(lcLyrics) << "lrclib: no syncedLyrics for" << m_artist << "-" << m_title;
chainNext(LRCLIB, reqId);
return;
}
const auto lines = parseLrc(synced);
if (lines.isEmpty()) {
chainNext(LRCLIB, reqId);
return;
}
setLines(lines, LRCLIB);
const LyricCandidate cand(LRCLIB, QString::number(id), obj.value(u"trackName"_s).toString(),
obj.value(u"artistName"_s).toString(), obj.value(u"albumName"_s).toString(),
obj.value(u"duration"_s).toDouble());
appendCandidates({ cand });
m_selected = cand;
emit selectedCandidateChanged();
if (!m_settingFromPrefs) {
persistTrackPrefs();
}
setLoading(false);
});
}
void Lyrics::tryNetEase(int reqId) {
if (reqId != m_currentRequestId) {
return;
}
setBackend(NetEase);
// Reset cookies (NetEase rejects requests with stale cookies sometimes)
m_nam->setCookieJar(new QNetworkCookieJar(m_nam));
QUrl url(u"https://music.163.com/api/search/get"_s);
QUrlQuery q;
q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist));
q.addQueryItem(u"type"_s, u"1"_s);
q.addQueryItem(u"limit"_s, u"5"_s);
url.setQuery(q);
auto* reply = getJson(url, netEaseHeaders());
trackReply(reqId, reply);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] {
reply->deleteLater();
if (reqId != m_currentRequestId) {
return;
}
if (reply->error() != QNetworkReply::NoError) {
qCDebug(lcLyrics) << "netease /search error:" << reply->errorString();
chainNext(NetEase, reqId);
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const QJsonArray songs = doc.object().value(u"result"_s).toObject().value(u"songs"_s).toArray();
// Find best match by artist substring
qint64 bestId = -1;
for (const auto& v : songs) {
const QJsonObject s = v.toObject();
const QJsonArray artists = s.value(u"artists"_s).toArray();
if (artists.isEmpty()) {
continue;
}
const QString sArtist = artists.first().toObject().value(u"name"_s).toString();
if (containsCi(m_artist, sArtist) || containsCi(sArtist, m_artist)) {
bestId = static_cast<qint64>(s.value(u"id"_s).toDouble());
break;
}
}
if (bestId < 0) {
qCDebug(lcLyrics) << "netease: no artist match for" << m_artist << "-" << m_title;
chainNext(NetEase, reqId);
return;
}
fetchNetEaseLyricsById(QString::number(bestId), reqId);
});
}
void Lyrics::searchLrclibCandidates(int reqId) {
QUrl url(u"https://lrclib.net/api/search"_s);
QUrlQuery q;
q.addQueryItem(u"track_name"_s, m_title);
q.addQueryItem(u"artist_name"_s, m_artist);
url.setQuery(q);
auto* reply = getJson(url, lrclibHeaders());
trackReply(reqId, reply);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] {
reply->deleteLater();
if (reqId != m_currentRequestId) {
return;
}
if (reply->error() != QNetworkReply::NoError) {
qCDebug(lcLyrics) << "lrclib /search error:" << reply->errorString();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const QJsonArray arr = doc.array();
QList<LyricCandidate> add;
add.reserve(arr.size());
for (const auto& v : arr) {
const QJsonObject o = v.toObject();
if (o.value(u"syncedLyrics"_s).isNull() && o.value(u"plainLyrics"_s).isNull()) {
continue;
}
add.append(LyricCandidate(LRCLIB, QString::number(static_cast<qint64>(o.value(u"id"_s).toDouble())),
o.value(u"trackName"_s).toString(), o.value(u"artistName"_s).toString(),
o.value(u"albumName"_s).toString(), o.value(u"duration"_s).toDouble()));
}
appendCandidates(add);
});
}
void Lyrics::searchNetEaseCandidates(int reqId) {
m_nam->setCookieJar(new QNetworkCookieJar(m_nam));
QUrl url(u"https://music.163.com/api/search/get"_s);
QUrlQuery q;
q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist));
q.addQueryItem(u"type"_s, u"1"_s);
q.addQueryItem(u"limit"_s, u"5"_s);
url.setQuery(q);
auto* reply = getJson(url, netEaseHeaders());
trackReply(reqId, reply);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] {
reply->deleteLater();
if (reqId != m_currentRequestId) {
return;
}
if (reply->error() != QNetworkReply::NoError) {
qCDebug(lcLyrics) << "netease candidates error:" << reply->errorString();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const QJsonArray songs = doc.object().value(u"result"_s).toObject().value(u"songs"_s).toArray();
QList<LyricCandidate> add;
add.reserve(songs.size());
for (const auto& v : songs) {
const QJsonObject s = v.toObject();
QStringList artistNames;
const QJsonArray artists = s.value(u"artists"_s).toArray();
artistNames.reserve(artists.size());
for (const auto& a : artists) {
artistNames.append(a.toObject().value(u"name"_s).toString());
}
add.append(LyricCandidate(NetEase, QString::number(static_cast<qint64>(s.value(u"id"_s).toDouble())),
s.value(u"name"_s).toString(), artistNames.join(u", "_s)));
}
appendCandidates(add);
});
}
void Lyrics::fetchLrclibById(const QString& id, int reqId) {
QUrl url(u"https://lrclib.net/api/get/"_s + id);
auto* reply = getJson(url, lrclibHeaders());
trackReply(reqId, reply);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] {
reply->deleteLater();
if (reqId != m_currentRequestId) {
return;
}
if (reply->error() != QNetworkReply::NoError) {
qCWarning(lcLyrics) << "lrclib /get/{id} error:" << reply->errorString();
setLoading(false);
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const QString synced = doc.object().value(u"syncedLyrics"_s).toString();
if (synced.isEmpty()) {
qCDebug(lcLyrics) << "lrclib /get/{id}: no syncedLyrics";
setLoading(false);
return;
}
setLines(parseLrc(synced), LRCLIB);
setLoading(false);
});
}
void Lyrics::fetchNetEaseLyricsById(const QString& id, int reqId) {
QUrl url(u"https://music.163.com/api/song/lyric"_s);
QUrlQuery q;
q.addQueryItem(u"id"_s, id);
q.addQueryItem(u"lv"_s, u"1"_s);
q.addQueryItem(u"kv"_s, u"1"_s);
q.addQueryItem(u"tv"_s, u"-1"_s);
url.setQuery(q);
auto* reply = getJson(url, netEaseHeaders());
trackReply(reqId, reply);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId, id] {
reply->deleteLater();
if (reqId != m_currentRequestId) {
return;
}
if (reply->error() != QNetworkReply::NoError) {
qCWarning(lcLyrics) << "netease /lyric error:" << reply->errorString();
setLoading(false);
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const QString lrc = doc.object().value(u"lrc"_s).toObject().value(u"lyric"_s).toString();
if (lrc.isEmpty()) {
qCDebug(lcLyrics) << "netease /lyric: empty for id" << id;
setLoading(false);
return;
}
setLines(parseLrc(lrc), NetEase);
setLoading(false);
});
}
QNetworkReply* Lyrics::getJson(const QUrl& url, const QHash<QByteArray, QByteArray>& headers) {
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork);
req.setRawHeader("Cache-Control"_ba, "no-cache, no-store"_ba);
req.setRawHeader("Pragma"_ba, "no-cache"_ba);
req.setRawHeader("Connection"_ba, "close"_ba);
req.setRawHeader("Accept"_ba, "application/json"_ba);
for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) {
req.setRawHeader(it.key(), it.value());
}
return m_nam->get(req);
}
void Lyrics::onPreferredBackendConfigChanged() {
auto* svcCfg = config::GlobalConfig::instance()->services();
const LyricsBackend desired = backendFromKey(svcCfg->lyricsBackend());
if (desired == m_preferredBackend) {
return;
}
m_preferredBackend = desired;
emit preferredBackendChanged();
scheduleLoad();
}
void Lyrics::onLyricsDirChanged() {
loadLyricsMap();
scheduleLoad();
}
void Lyrics::loadLyricsMap() {
m_lyricsMap = {};
m_lyricsMapLoaded = false;
QFile f(lyricsMapPath());
if (!f.open(QIODevice::ReadOnly)) {
m_lyricsMapLoaded = true;
return;
}
const QByteArray bytes = f.readAll();
f.close();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(bytes, &err);
if (err.error != QJsonParseError::NoError) {
qCWarning(lcLyrics) << "lyrics_map.json parse error:" << err.errorString();
m_lyricsMapLoaded = true;
return;
}
m_lyricsMap = doc.object();
m_lyricsMapLoaded = true;
}
void Lyrics::persistTrackPrefs() {
if (!m_lyricsMapLoaded || trackKey().isEmpty()) {
return;
}
const QString key = trackKey();
QJsonObject entry = m_lyricsMap.value(key).toObject();
entry.insert(u"offset"_s, m_offset);
if (m_selected.isValid()) {
entry.insert(u"backend"_s, backendKey(m_selected.backend()));
entry.insert(u"id"_s, m_selected.id());
}
m_lyricsMap.insert(key, entry);
const QString dir = lyricsDir();
if (dir.isEmpty()) {
return;
}
QDir().mkpath(dir);
QSaveFile out(lyricsMapPath());
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
qCWarning(lcLyrics) << "cannot open" << lyricsMapPath() << "for write:" << out.errorString();
return;
}
const QByteArray bytes = QJsonDocument(m_lyricsMap).toJson(QJsonDocument::Compact);
if (out.write(bytes) != bytes.size()) {
qCWarning(lcLyrics) << "short write to" << lyricsMapPath();
out.cancelWriting();
return;
}
if (!out.commit()) {
qCWarning(lcLyrics) << "commit failed for" << lyricsMapPath() << ":" << out.errorString();
}
}
QString Lyrics::lyricsDir() const {
QString dir = config::GlobalConfig::instance()->paths()->lyricsDir();
if (dir.isEmpty()) {
return {};
}
while (dir.endsWith(QLatin1Char('/')) && dir.size() > 1) {
dir.chop(1);
}
return dir;
}
QString Lyrics::lyricsMapPath() const {
const QString dir = lyricsDir();
return dir.isEmpty() ? QString() : dir + u"/lyrics_map.json"_s;
}
QString Lyrics::trackKey() const {
if (m_artist.isEmpty() && m_title.isEmpty()) {
return {};
}
return u"%1 - %2"_s.arg(joinArtists(m_artist), m_title);
}
QString Lyrics::backendKey(LyricsBackend value) {
switch (value) {
case Local:
return u"Local"_s;
case LRCLIB:
return u"LRCLIB"_s;
case NetEase:
return u"NetEase"_s;
case Auto:
default:
return u"Auto"_s;
}
}
LyricsBackend Lyrics::backendFromKey(const QString& key) {
if (key.compare(u"Local"_s, Qt::CaseInsensitive) == 0) {
return Local;
}
if (key.compare(u"LRCLIB"_s, Qt::CaseInsensitive) == 0) {
return LRCLIB;
}
if (key.compare(u"NetEase"_s, Qt::CaseInsensitive) == 0) {
return NetEase;
}
return Auto;
}
QString Lyrics::tryReadLocalLrc(const QString& dir, const QString& artist, const QString& title) {
if (artist.isEmpty() && title.isEmpty()) {
return {};
}
const QString flat = u"%1/%2 - %3.lrc"_s.arg(dir, sanitizeFilenamePart(artist), sanitizeFilenamePart(title));
return QFile::exists(flat) ? flat : QString();
}
QString Lyrics::findLocalLrcRecursive(const QString& dir, const QString& artist, const QString& title) {
if (dir.isEmpty()) {
return {};
}
if (artist.isEmpty() && title.isEmpty()) {
return {};
}
QDirIterator it(dir, QStringList{ u"*.lrc"_s }, QDir::Files | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
while (it.hasNext()) {
const QString path = it.next();
const QString name = it.fileName();
if ((artist.isEmpty() || containsCi(name, artist)) && (title.isEmpty() || containsCi(name, title))) {
return path;
}
}
return {};
}
QVector<LyricLine> Lyrics::parseLrc(const QString& text) {
QVector<LyricLine> result;
if (text.isEmpty()) {
return result;
}
static const QRegularExpression timeRegex(u"\\[(\\d+):(\\d+(?:\\.\\d+)?)\\]"_s);
static const QStringList creditKeywords = {
u"作词"_s,
u"作曲"_s,
u"编曲"_s,
u"制作"_s,
u"收录"_s,
u"演奏"_s,
u"词:"_s,
u"曲:"_s,
u"Lyricist"_s,
u"Composer"_s,
u"Arranger"_s,
u"Producer"_s,
u"Mixing"_s,
u"Mastering"_s,
};
const QStringList lines = text.split(QLatin1Char('\n'));
for (const QString& line : lines) {
QList<QRegularExpressionMatch> matches;
auto it = timeRegex.globalMatch(line);
while (it.hasNext()) {
matches.append(it.next());
}
if (matches.isEmpty()) {
continue;
}
QString lyric = line;
lyric.replace(timeRegex, QString());
lyric = lyric.trimmed();
const qreal firstTime = matches.first().captured(1).toInt() * 60.0 + matches.first().captured(2).toDouble();
if (firstTime < 20.0) {
bool isCredit = false;
for (const QString& k : creditKeywords) {
if (lyric.contains(k, Qt::CaseInsensitive)) {
isCredit = true;
break;
}
}
if (isCredit && (lyric.contains(QLatin1Char(':')) || lyric.contains(QChar(0xFF1A)) || lyric.size() < 25)) {
continue;
}
}
for (const auto& m : matches) {
const qreal t = m.captured(1).toInt() * 60.0 + m.captured(2).toDouble();
result.append(LyricLine{ t, lyric });
}
}
std::sort(result.begin(), result.end(), [](const LyricLine& a, const LyricLine& b) {
return a.time < b.time;
});
return result;
}
} // namespace caelestia::services

View file

@ -0,0 +1,140 @@
#pragma once
#include "lyriccandidate.hpp"
#include <qhash.h>
#include <qjsonobject.h>
#include <qnetworkaccessmanager.h>
#include <qnetworkreply.h>
namespace caelestia::services {
struct LyricLine {
qreal time = 0.0;
QString text;
};
class Lyrics : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
QML_EXTENDED_NAMESPACE(caelestia::services)
Q_PROPERTY(QStringList lyrics READ lyrics NOTIFY lyricsChanged)
Q_PROPERTY(LyricsBackend backend READ backend NOTIFY backendChanged)
Q_PROPERTY(
LyricsBackend preferredBackend READ preferredBackend WRITE setPreferredBackend NOTIFY preferredBackendChanged)
Q_PROPERTY(
QList<caelestia::services::LyricCandidate> lyricCandidates READ lyricCandidates NOTIFY lyricCandidatesChanged)
Q_PROPERTY(caelestia::services::LyricCandidate selectedCandidate READ selectedCandidate WRITE setSelectedCandidate
NOTIFY selectedCandidateChanged)
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
Q_PROPERTY(bool hasLyrics READ hasLyrics NOTIFY lyricsChanged)
Q_PROPERTY(qreal offset READ offset WRITE setOffset NOTIFY offsetChanged)
Q_PROPERTY(QString trackArtist READ trackArtist NOTIFY trackChanged)
Q_PROPERTY(QString trackTitle READ trackTitle NOTIFY trackChanged)
public:
explicit Lyrics(QObject* parent = nullptr);
[[nodiscard]] QStringList lyrics() const;
[[nodiscard]] LyricsBackend backend() const;
[[nodiscard]] LyricsBackend preferredBackend() const;
void setPreferredBackend(LyricsBackend value);
[[nodiscard]] QList<LyricCandidate> lyricCandidates() const;
[[nodiscard]] LyricCandidate selectedCandidate() const;
void setSelectedCandidate(const LyricCandidate& value);
[[nodiscard]] bool loading() const;
[[nodiscard]] bool hasLyrics() const;
[[nodiscard]] qreal offset() const;
void setOffset(qreal value);
[[nodiscard]] QString trackArtist() const;
[[nodiscard]] QString trackTitle() const;
[[nodiscard]] Q_INVOKABLE int indexForTime(qreal time) const;
Q_INVOKABLE void setTrack(
const QString& artist, const QString& title, const QString& album = {}, qreal duration = 0.0);
Q_INVOKABLE void clearTrack();
Q_INVOKABLE void refresh();
signals:
void lyricsChanged();
void backendChanged();
void preferredBackendChanged();
void lyricCandidatesChanged();
void selectedCandidateChanged();
void loadingChanged();
void hasLyricsChanged();
void offsetChanged();
void trackChanged();
private:
void setBackend(LyricsBackend value);
void setLoading(bool value);
void setLines(QVector<LyricLine> lines, LyricsBackend source);
void clearLines();
void appendCandidates(const QList<LyricCandidate>& add);
void clearCandidates();
void scheduleLoad();
void doLoad();
void cancelInFlight();
int newRequestId();
void tryLocal(int reqId);
void tryLrclib(int reqId);
void tryNetEase(int reqId);
void chainNext(LyricsBackend just_failed, int reqId);
void searchLrclibCandidates(int reqId);
void searchNetEaseCandidates(int reqId);
void fetchLrclibById(const QString& id, int reqId);
void fetchNetEaseLyricsById(const QString& id, int reqId);
QNetworkReply* getJson(const QUrl& url, const QHash<QByteArray, QByteArray>& headers = {});
void trackReply(int reqId, QNetworkReply* reply);
void onPreferredBackendConfigChanged();
void onLyricsDirChanged();
void loadLyricsMap();
void persistTrackPrefs();
[[nodiscard]] QString lyricsDir() const;
[[nodiscard]] QString lyricsMapPath() const;
[[nodiscard]] QString trackKey() const;
[[nodiscard]] static QString backendKey(LyricsBackend value);
[[nodiscard]] static LyricsBackend backendFromKey(const QString& key);
[[nodiscard]] static QVector<LyricLine> parseLrc(const QString& text);
[[nodiscard]] static QString tryReadLocalLrc(const QString& dir, const QString& artist, const QString& title);
[[nodiscard]] static QString findLocalLrcRecursive(const QString& dir, const QString& artist, const QString& title);
QNetworkAccessManager* m_nam;
QTimer* m_loadDebounce;
QVector<LyricLine> m_lines;
QStringList m_lyrics;
LyricsBackend m_backend = Auto;
LyricsBackend m_preferredBackend = Auto;
QList<LyricCandidate> m_candidates;
LyricCandidate m_selected;
bool m_loading = false;
bool m_hasLyrics = false;
qreal m_offset = 0.0;
QString m_artist;
QString m_title;
QString m_album;
qreal m_duration = 0.0;
int m_currentRequestId = 0;
QHash<int, QList<QPointer<QNetworkReply>>> m_pendingReplies;
QJsonObject m_lyricsMap;
bool m_lyricsMapLoaded = false;
bool m_settingFromPrefs = false;
};
} // namespace caelestia::services