1179 lines
28 KiB
C++
1179 lines
28 KiB
C++
#include "lyrics.hpp"
|
|
|
|
#include <qdiriterator.h>
|
|
#include <qfileinfo.h>
|
|
#include <qjsonarray.h>
|
|
#include <qnetworkcookiejar.h>
|
|
#include <qsavefile.h>
|
|
#include <qurlquery.h>
|
|
|
|
#include "config/rootnodes.hpp"
|
|
#include "config/general.hpp"
|
|
#include "config/services.hpp"
|
|
|
|
namespace {
|
|
|
|
Q_LOGGING_CATEGORY(lcLyrics, "ZShell.lyrics", QtInfoMsg)
|
|
|
|
} // namespace
|
|
|
|
namespace ZShell::services {
|
|
|
|
using Qt::StringLiterals::operator""_s;
|
|
using Qt::StringLiterals::operator""_ba;
|
|
|
|
namespace {
|
|
|
|
constexpr int k_loadDebounceMs = 50;
|
|
constexpr qreal k_indexFudge = 0.1;
|
|
|
|
[[nodiscard]] const QHash<QByteArray, QByteArray>& netEaseHeaders() {
|
|
static const QHash<QByteArray, QByteArray> k_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 k_h;
|
|
}
|
|
|
|
[[nodiscard]] const QHash<QByteArray, QByteArray>& lrclibHeaders() {
|
|
static const QHash<QByteArray, QByteArray> k_h = {
|
|
{"User-Agent"_ba,
|
|
"ZShell-shell (https://github.com/ZShell-dots/shell)"_ba},
|
|
};
|
|
return k_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 == u'/' || c == u'\0') {
|
|
out.append(u'_');
|
|
} 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(k_loadDebounceMs);
|
|
QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad);
|
|
|
|
const auto* cfg = config::ConfigSingleton::instance();
|
|
const auto* svcCfg = cfg->services();
|
|
const auto* genCfg = cfg->general();
|
|
|
|
m_preferredBackend = svcCfg->lyricsBackend();
|
|
|
|
QObject::connect(
|
|
svcCfg,
|
|
&config::Services::lyricsBackendChanged,
|
|
this,
|
|
&Lyrics::onPreferredBackendConfigChanged);
|
|
QObject::connect(
|
|
genCfg,
|
|
&config::General::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();
|
|
|
|
config::ConfigSingleton::instance()->services()->set_lyricsBackend(value);
|
|
|
|
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 == LyricsBackend::LRCLIB || b == LyricsBackend::NetEase) {
|
|
const QString cached = readCachedLrc(b, value.id());
|
|
if (!cached.isEmpty()) {
|
|
const auto lines = parseLrc(cached);
|
|
if (!lines.isEmpty()) {
|
|
setLines(lines, b);
|
|
setLoading(false);
|
|
if (!m_settingFromPrefs) {
|
|
persistTrackPrefs();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (b == LyricsBackend::LRCLIB) {
|
|
fetchLrclibById(value.id(), reqId);
|
|
} else if (b == LyricsBackend::NetEase) {
|
|
fetchNetEaseLyricsById(value.id(), reqId);
|
|
} else if (b == LyricsBackend::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), LyricsBackend::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 + k_indexFudge;
|
|
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);
|
|
}
|
|
|
|
qreal Lyrics::timeForIndex(int index) const {
|
|
if (index < 0 || index >= m_lines.size()) {
|
|
return -1.0;
|
|
}
|
|
return m_lines.at(index).time + m_offset;
|
|
}
|
|
|
|
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::ranges::sort(lines, [](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() {
|
|
if (!m_hasLyrics) {
|
|
return;
|
|
}
|
|
|
|
// 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 LyricsBackend::Local:
|
|
tryLocal(reqId);
|
|
break;
|
|
case LyricsBackend::LRCLIB:
|
|
tryLrclib(reqId);
|
|
break;
|
|
case LyricsBackend::NetEase:
|
|
tryNetEase(reqId);
|
|
break;
|
|
case LyricsBackend::Auto:
|
|
default:
|
|
tryLocal(reqId);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void Lyrics::chainNext(LyricsBackend justFailed, int reqId) {
|
|
if (m_preferredBackend != LyricsBackend::Auto) {
|
|
// Non-auto modes don't chain
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
switch (justFailed) {
|
|
case LyricsBackend::Local:
|
|
tryLrclib(reqId);
|
|
return;
|
|
case LyricsBackend::LRCLIB:
|
|
tryNetEase(reqId);
|
|
return;
|
|
case LyricsBackend::NetEase:
|
|
default:
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
void Lyrics::tryLocal(int reqId) {
|
|
if (reqId != m_currentRequestId) {
|
|
return;
|
|
}
|
|
|
|
setBackend(LyricsBackend::Local);
|
|
|
|
const QString dir = lyricsDir();
|
|
if (dir.isEmpty()) {
|
|
chainNext(LyricsBackend::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, LyricsBackend::Local);
|
|
appendCandidates({LyricCandidate(
|
|
LyricsBackend::Local,
|
|
direct,
|
|
m_title,
|
|
m_artist,
|
|
m_album,
|
|
m_duration)});
|
|
m_selected = LyricCandidate(
|
|
LyricsBackend::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, LyricsBackend::Local);
|
|
appendCandidates({LyricCandidate(
|
|
LyricsBackend::Local,
|
|
recursive,
|
|
m_title,
|
|
m_artist,
|
|
m_album,
|
|
m_duration)});
|
|
m_selected = LyricCandidate(
|
|
LyricsBackend::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(LyricsBackend::Local, reqId);
|
|
}
|
|
|
|
void Lyrics::tryLrclib(int reqId) {
|
|
if (reqId != m_currentRequestId) {
|
|
return;
|
|
}
|
|
|
|
setBackend(LyricsBackend::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);
|
|
}
|
|
|
|
constexpr qreal k_maxDurationSecs = std::numeric_limits<int>::max();
|
|
if (m_duration > 0 && qIsFinite(m_duration) &&
|
|
m_duration < k_maxDurationSecs) {
|
|
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(LyricsBackend::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(LyricsBackend::LRCLIB, reqId);
|
|
return;
|
|
}
|
|
|
|
const auto lines = parseLrc(synced);
|
|
if (lines.isEmpty()) {
|
|
chainNext(LyricsBackend::LRCLIB, reqId);
|
|
return;
|
|
}
|
|
|
|
writeCachedLrc(LyricsBackend::LRCLIB, QString::number(id), synced);
|
|
setLines(lines, LyricsBackend::LRCLIB);
|
|
const LyricCandidate cand(
|
|
LyricsBackend::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(LyricsBackend::NetEase);
|
|
|
|
// Reset cookies (LyricsBackend::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(LyricsBackend::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(LyricsBackend::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(
|
|
LyricsBackend::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(
|
|
LyricsBackend::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) {
|
|
const 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, id] {
|
|
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;
|
|
}
|
|
writeCachedLrc(LyricsBackend::LRCLIB, id, synced);
|
|
setLines(parseLrc(synced), LyricsBackend::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;
|
|
}
|
|
writeCachedLrc(LyricsBackend::NetEase, id, lrc);
|
|
setLines(parseLrc(lrc), LyricsBackend::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() {
|
|
const LyricsBackend desired =
|
|
config::ConfigSingleton::instance()->services()->lyricsBackend();
|
|
if (desired == m_preferredBackend) {
|
|
return;
|
|
}
|
|
m_preferredBackend = desired;
|
|
emit preferredBackendChanged();
|
|
scheduleLoad();
|
|
}
|
|
|
|
void Lyrics::onLyricsDirChanged() {
|
|
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);
|
|
|
|
QDir().mkpath(stateDir());
|
|
|
|
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() {
|
|
QString dir = config::ConfigSingleton::instance()->general()->lyricsDir();
|
|
if (dir.isEmpty()) {
|
|
return {};
|
|
}
|
|
if (dir == u"~"_s) {
|
|
dir = QDir::homePath();
|
|
} else if (dir.startsWith(u"~/"_s)) {
|
|
dir.replace(0, 1, QDir::homePath());
|
|
}
|
|
while (dir.endsWith(u'/') && dir.size() > 1) {
|
|
dir.chop(1);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
QString Lyrics::lyricsMapPath() {
|
|
return stateDir() + 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 LyricsBackend::Local:
|
|
return u"Local"_s;
|
|
case LyricsBackend::LRCLIB:
|
|
return u"LRCLIB"_s;
|
|
case LyricsBackend::NetEase:
|
|
return u"NetEase"_s;
|
|
case LyricsBackend::Auto:
|
|
default:
|
|
return u"Auto"_s;
|
|
}
|
|
}
|
|
|
|
LyricsBackend Lyrics::backendFromKey(const QString& key) {
|
|
if (key.compare(u"Local"_s, Qt::CaseInsensitive) == 0) {
|
|
return LyricsBackend::Local;
|
|
}
|
|
if (key.compare(u"LRCLIB"_s, Qt::CaseInsensitive) == 0) {
|
|
return LyricsBackend::LRCLIB;
|
|
}
|
|
if (key.compare(u"NetEase"_s, Qt::CaseInsensitive) == 0) {
|
|
return LyricsBackend::NetEase;
|
|
}
|
|
return LyricsBackend::Auto;
|
|
}
|
|
|
|
const QString& Lyrics::stateDir() {
|
|
static const QString k_dir = [] {
|
|
QString state = qEnvironmentVariable("XDG_STATE_HOME");
|
|
if (state.isEmpty()) {
|
|
state = QDir::homePath() + u"/.local/state"_s;
|
|
}
|
|
return state + u"/ZShell/lyrics"_s;
|
|
}();
|
|
return k_dir;
|
|
}
|
|
|
|
const QString& Lyrics::cacheDir() {
|
|
static const QString k_dir = [] {
|
|
QString cache = qEnvironmentVariable("XDG_CACHE_HOME");
|
|
if (cache.isEmpty()) {
|
|
cache = QDir::homePath() + u"/.cache"_s;
|
|
}
|
|
return cache + u"/ZShell/lyrics"_s;
|
|
}();
|
|
return k_dir;
|
|
}
|
|
|
|
QString Lyrics::cachePathFor(LyricsBackend backend, const QString& id) {
|
|
if (id.isEmpty() || backend == LyricsBackend::Auto ||
|
|
backend == LyricsBackend::Local) {
|
|
return {};
|
|
}
|
|
return u"%1/%2/%3.lrc"_s.arg(
|
|
cacheDir(), backendKey(backend), sanitizeFilenamePart(id));
|
|
}
|
|
|
|
QString Lyrics::readCachedLrc(LyricsBackend backend, const QString& id) {
|
|
const QString path = cachePathFor(backend, id);
|
|
if (path.isEmpty()) {
|
|
return {};
|
|
}
|
|
QFile f(path);
|
|
if (!f.open(QIODevice::ReadOnly)) {
|
|
return {};
|
|
}
|
|
return QString::fromUtf8(f.readAll());
|
|
}
|
|
|
|
void Lyrics::writeCachedLrc(
|
|
LyricsBackend backend, const QString& id, const QString& text) {
|
|
if (text.isEmpty()) {
|
|
return;
|
|
}
|
|
const QString path = cachePathFor(backend, id);
|
|
if (path.isEmpty()) {
|
|
return;
|
|
}
|
|
QDir().mkpath(QFileInfo(path).absolutePath());
|
|
|
|
QSaveFile out(path);
|
|
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
qCWarning(lcLyrics)
|
|
<< "cannot open" << path << "for write:" << out.errorString();
|
|
return;
|
|
}
|
|
const QByteArray bytes = text.toUtf8();
|
|
if (out.write(bytes) != bytes.size()) {
|
|
qCWarning(lcLyrics) << "short write to" << path;
|
|
out.cancelWriting();
|
|
return;
|
|
}
|
|
if (!out.commit()) {
|
|
qCWarning(lcLyrics)
|
|
<< "commit failed for" << path << ":" << out.errorString();
|
|
}
|
|
}
|
|
|
|
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 k_timeRegex(
|
|
u"\\[(\\d+):(\\d+(?:\\.\\d+)?)\\]"_s);
|
|
static const QStringList k_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(u'\n');
|
|
for (const QString& line : lines) {
|
|
QList<QRegularExpressionMatch> matches;
|
|
auto it = k_timeRegex.globalMatch(line);
|
|
while (it.hasNext()) {
|
|
matches.append(it.next());
|
|
}
|
|
if (matches.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
QString lyric = line;
|
|
lyric.replace(k_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 : k_creditKeywords) {
|
|
if (lyric.contains(k, Qt::CaseInsensitive)) {
|
|
isCredit = true;
|
|
break;
|
|
}
|
|
}
|
|
if (isCredit &&
|
|
(lyric.contains(u':') || 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{.time = t, .text = lyric});
|
|
}
|
|
}
|
|
|
|
std::ranges::sort(result, [](const LyricLine& a, const LyricLine& b) {
|
|
return a.time < b.time;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
} // namespace ZShell::services
|