feat: translate subtitles with an OpenAI-compatible LLM

This commit is contained in:
tri committed 2026-10-27 22:43:03 +07:00
1 parent 48cac86d60
commit 4665bf632a
21 files changed
+1877 -5

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
#include "apikeystore.h"
#include <QCoroSignal>
#include <QPointer>
#include <qt6keychain/keychain.h>
#include <memory>
using namespace Qt::Literals::StringLiterals;
namespace
{
const QString Service = u"dev.trivk.qtpotato"_s;
const QString Key = u"translation-api-key"_s;
struct JobDeleter {
void operator()(QKeychain::Job *job) const
{
job->deleteLater();
}
};
template<typename T>
std::unique_ptr<T, JobDeleter> makeJob()
{
auto job = std::unique_ptr<T, JobDeleter>(new T(Service));
job->setAutoDelete(false);
job->setKey(Key);
return job;
}
}
ApiKeyStore::ApiKeyStore(QObject *parent)
: QObject(parent)
{
refresh();
}
bool ApiKeyStore::hasKey() const
{
return m_hasKey;
}
QString ApiKeyStore::error() const
{
return m_error;
}
void ApiKeyStore::setKey(const QString &key)
{
store(key.trimmed());
}
QCoro::Task<ApiKeyStore::ReadResult> ApiKeyStore::readKey()
{
auto job = makeJob<QKeychain::ReadPasswordJob>();
job->start();
co_await qCoro(job.get(), &QKeychain::Job::finished);
if (job->error() == QKeychain::EntryNotFound) {
co_return ReadResult{};
}
if (job->error() != QKeychain::NoError) {
co_return ReadResult{{}, job->errorString()};
}
co_return ReadResult{job->textData(), {}};
}
QCoro::Task<> ApiKeyStore::refresh()
{
QPointer<ApiKeyStore> self(this);
const ReadResult result = co_await readKey();
if (!self) {
co_return;
}
setHasKey(!result.key.isEmpty());
setError(result.error);
}
QCoro::Task<> ApiKeyStore::store(QString key)
{
QPointer<ApiKeyStore> self(this);
std::unique_ptr<QKeychain::Job, JobDeleter> job;
if (key.isEmpty()) {
job = makeJob<QKeychain::DeletePasswordJob>();
} else {
auto write = makeJob<QKeychain::WritePasswordJob>();
write->setTextData(key);
job.reset(write.release());
}
job->start();
co_await qCoro(job.get(), &QKeychain::Job::finished);
if (!self) {
co_return;
}
if (job->error() != QKeychain::NoError && job->error() != QKeychain::EntryNotFound) {
setError(job->errorString());
co_return;
}
setError({});
setHasKey(!key.isEmpty());
}
void ApiKeyStore::setHasKey(bool hasKey)
{
if (hasKey != m_hasKey) {
m_hasKey = hasKey;
Q_EMIT hasKeyChanged();
}
}
void ApiKeyStore::setError(const QString &error)
{
if (error != m_error) {
m_error = error;
Q_EMIT errorChanged();
}
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <QCoroTask>
#include <QObject>
#include <QtQml/qqmlregistration.h>
// Keeps the translation API key in the system keychain (KWallet / Secret Service)
// instead of the plain-text config file.
class ApiKeyStore : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool hasKey READ hasKey NOTIFY hasKeyChanged)
Q_PROPERTY(QString error READ error NOTIFY errorChanged)
public:
struct ReadResult {
QString key;
QString error;
};
explicit ApiKeyStore(QObject *parent = nullptr);
bool hasKey() const;
QString error() const;
// Stores \a key, or removes the stored key when it is empty.
Q_INVOKABLE void setKey(const QString &key);
// An empty key without error means none is stored.
static QCoro::Task<ReadResult> readKey();
Q_SIGNALS:
void hasKeyChanged();
void errorChanged();
private:
QCoro::Task<> refresh();
QCoro::Task<> store(QString key);
void setHasKey(bool hasKey);
void setError(const QString &error);
bool m_hasKey = false;
QString m_error;
};
+213
View File
@@ -0,0 +1,213 @@
#include "llmclient.h"
#include <KLocalizedString>
#include <QCoroNetworkReply>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <memory>
using namespace Qt::Literals::StringLiterals;
namespace
{
constexpr int RequestTimeoutMs = 180 * 1000;
struct ReplyDeleter {
void operator()(QNetworkReply *reply) const
{
reply->deleteLater();
}
};
using ReplyPtr = std::unique_ptr<QNetworkReply, ReplyDeleter>;
QString systemPrompt(const QString &language)
{
return uR"(You are a professional subtitle translator. Translate subtitles into %1.
The user sends a JSON object:
- "title": the video title, for context.
- "context": the subtitle lines right before this batch, for context only. Do not translate them.
- "lines": an object that maps ids to subtitle lines.
Translate every entry of "lines" into natural, spoken %1 that fits the scene and the speaker. Keep names and the tone. Translate each entry on its own; never merge, split, skip or reorder entries.
Reply with only a JSON object that maps every id to its translation, for example {"1": "...", "2": "..."}. No explanations, no Markdown.)"_s
.arg(language);
}
// Pulls the error message out of an OpenAI-style error body, falling back to the raw text.
QString describeHttpError(QNetworkReply *reply, const QByteArray &body)
{
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QJsonObject json = QJsonDocument::fromJson(body).object();
QString message = json.value("error"_L1).toObject().value("message"_L1).toString();
if (message.isEmpty()) {
message = json.value("error"_L1).toString();
}
if (message.isEmpty()) {
message = QString::fromUtf8(body.left(300)).simplified();
}
if (message.isEmpty()) {
message = reply->errorString();
}
return status > 0 ? i18n("HTTP %1: %2", status, message) : message;
}
bool isRetryable(QNetworkReply *reply)
{
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status == 429 || status >= 500) {
return true;
}
switch (reply->error()) {
case QNetworkReply::TimeoutError:
case QNetworkReply::OperationCanceledError: // transfer timeout reports this
case QNetworkReply::RemoteHostClosedError:
case QNetworkReply::TemporaryNetworkFailureError:
case QNetworkReply::NetworkSessionFailedError:
return true;
default:
return false;
}
}
// Models sometimes wrap the JSON in Markdown fences or prepend their reasoning.
QJsonObject extractJsonObject(QString content)
{
static const QRegularExpression thinkBlock(u"<think>.*?</think>"_s, QRegularExpression::DotMatchesEverythingOption);
content.remove(thinkBlock);
const qsizetype start = content.indexOf(u'{');
const qsizetype end = content.lastIndexOf(u'}');
if (start < 0 || end <= start) {
return {};
}
return QJsonDocument::fromJson(QStringView(content).mid(start, end - start + 1).toUtf8()).object();
}
}
LlmClient::LlmClient(QNetworkAccessManager *network, Config config)
: m_network(network)
, m_config(std::move(config))
{
}
QUrl LlmClient::normalizedBaseUrl(const QString &url)
{
QString base = url.trimmed();
while (base.endsWith(u'/')) {
base.chop(1);
}
if (base.endsWith(u"/chat/completions"_s)) {
base.chop(u"/chat/completions"_s.size());
}
return QUrl(base);
}
void LlmClient::abort()
{
// Copy: aborting finishes the reply, which resumes its coroutine and edits the list.
const auto replies = m_replies;
for (const QPointer<QNetworkReply> &reply : replies) {
if (reply) {
reply->abort();
}
}
}
QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStringList context, QString mediaTitle)
{
QJsonObject linesObject;
for (qsizetype i = 0; i < lines.size(); ++i) {
linesObject.insert(QString::number(i + 1), lines.at(i));
}
const QJsonObject userContent{
{"title"_L1, mediaTitle},
{"context"_L1, QJsonArray::fromStringList(context)},
{"lines"_L1, linesObject},
};
const QJsonObject body{
{"model"_L1, m_config.model},
{"messages"_L1,
QJsonArray{
QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage)}},
QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}},
}},
};
QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/chat/completions"_s));
request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s);
if (!m_config.apiKey.isEmpty()) {
request.setRawHeader("Authorization", "Bearer " + m_config.apiKey.toUtf8());
}
request.setTransferTimeout(RequestTimeoutMs);
ReplyPtr reply(m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)));
m_replies.append(reply.get());
co_await reply.get();
m_replies.removeAll(reply.get());
const QByteArray responseBody = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
co_return BatchResult{{}, describeHttpError(reply.get(), responseBody), isRetryable(reply.get())};
}
const QJsonObject response = QJsonDocument::fromJson(responseBody).object();
const QString content = response.value("choices"_L1).toArray().at(0).toObject().value("message"_L1).toObject().value("content"_L1).toString();
if (content.isEmpty()) {
co_return BatchResult{{}, i18n("The API returned no message: %1", QString::fromUtf8(responseBody.left(300)).simplified()), false};
}
const QJsonObject translated = extractJsonObject(content);
if (translated.isEmpty()) {
// Malformed output is often a one-off; asking again usually works.
co_return BatchResult{{}, i18n("The model did not reply with JSON: %1", content.left(300).simplified()), true};
}
BatchResult result;
result.translations.reserve(lines.size());
for (qsizetype i = 0; i < lines.size(); ++i) {
const QJsonValue value = translated.value(QString::number(i + 1));
result.translations.append(value.isString() && !value.toString().trimmed().isEmpty() ? value.toString().trimmed() : QString());
}
co_return result;
}
QCoro::Task<LlmClient::ModelsResult> LlmClient::listModels()
{
QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/models"_s));
if (!m_config.apiKey.isEmpty()) {
request.setRawHeader("Authorization", "Bearer " + m_config.apiKey.toUtf8());
}
request.setTransferTimeout(30 * 1000);
ReplyPtr reply(m_network->get(request));
m_replies.append(reply.get());
co_await reply.get();
m_replies.removeAll(reply.get());
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
co_return ModelsResult{{}, describeHttpError(reply.get(), body)};
}
ModelsResult result;
for (const QJsonValue &model : QJsonDocument::fromJson(body).object().value("data"_L1).toArray()) {
const QString id = model.toObject().value("id"_L1).toString();
if (!id.isEmpty()) {
result.models.append(id);
}
}
result.models.sort(Qt::CaseInsensitive);
if (result.models.isEmpty()) {
result.error = i18n("The API returned no models: %1", QString::fromUtf8(body.left(300)).simplified());
}
co_return result;
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <QCoroTask>
#include <QList>
#include <QPointer>
#include <QStringList>
#include <QUrl>
class QNetworkAccessManager;
class QNetworkReply;
// Talks to an OpenAI-compatible API (POST {base}/chat/completions, GET {base}/models).
class LlmClient
{
public:
struct Config {
QUrl baseUrl;
QString apiKey;
QString model;
QString targetLanguage;
};
struct BatchResult {
// Same size as the input; a null QString marks a line the model left out.
QStringList translations;
QString error;
// Worth trying the same request again (rate limit, server or network error).
bool retryable = false;
};
struct ModelsResult {
QStringList models;
QString error;
};
LlmClient(QNetworkAccessManager *network, Config config);
// Translates \a lines. \a context holds the source lines right before them, so the
// model can follow the conversation; they are not translated.
QCoro::Task<BatchResult> translate(QStringList lines, QStringList context, QString mediaTitle);
QCoro::Task<ModelsResult> listModels();
// Aborts every request in flight.
void abort();
// Accepts ".../v1", ".../v1/" and ".../v1/chat/completions".
static QUrl normalizedBaseUrl(const QString &url);
private:
QNetworkAccessManager *m_network;
Config m_config;
QList<QPointer<QNetworkReply>> m_replies;
};
+477
View File
@@ -0,0 +1,477 @@
#include "translationservice.h"
#include "apikeystore.h"
#include "qtpotatosettings.h"
#include <KLocalizedString>
#include <QCoroFuture>
#include <QCoroTimer>
#include <QCryptographicHash>
#include <QDir>
#include <QFileInfo>
#include <QHash>
#include <QLocale>
#include <QNetworkAccessManager>
#include <QPointer>
#include <QSaveFile>
#include <QStandardPaths>
#include <QtConcurrentRun>
#include <chrono>
#include <vector>
using namespace Qt::Literals::StringLiterals;
using namespace std::chrono_literals;
namespace
{
// Source lines before a batch that the model sees for context.
constexpr qsizetype ContextLines = 6;
constexpr int MaxAttempts = 3;
// Requests in flight at once. Batches only use source lines as context, so they don't
// depend on each other.
constexpr int ParallelRequests = 3;
// Bump when the prompt changes so old cached translations are not reused.
constexpr auto CacheVersion = "llm-v1";
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language)
{
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
const auto candidate = static_cast<QLocale::Language>(i);
if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) {
const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1);
return code.isEmpty() ? QLocale::languageToCode(candidate) : code;
}
}
QString suffix = language.toLower();
suffix.removeIf([](QChar c) {
return !c.isLetterOrNumber() && c != u'-';
});
return suffix.isEmpty() ? u"translated"_s : suffix;
}
QString cachePath(const QUrl &videoUrl, const QString &key, const QString &language)
{
const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
return u"%1/%2.%3.%4.srt"_s.arg(dir, baseName, key, languageSuffix(language));
}
}
struct TranslationService::Job {
std::atomic_bool cancelled = false;
std::unique_ptr<LlmClient> client;
QString mediaTitle;
};
struct TranslationService::Batches {
QStringList lines;
QStringList translations;
qsizetype batchSize = 0;
qsizetype nextStart = 0;
qsizetype doneLines = 0;
QString error;
};
TranslationService::TranslationService(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
{
}
TranslationService::~TranslationService()
{
cancel();
}
bool TranslationService::isBusy() const
{
return m_busy;
}
double TranslationService::progress() const
{
return m_progress;
}
QString TranslationService::statusText() const
{
return m_statusText;
}
QString TranslationService::errorText() const
{
return m_errorText;
}
QStringList TranslationService::availableModels() const
{
return m_availableModels;
}
bool TranslationService::isFetchingModels() const
{
return m_fetchingModels;
}
void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle)
{
cancel();
m_job = std::make_shared<Job>();
m_job->mediaTitle = mediaTitle;
run(videoUrl, track);
}
void TranslationService::cancel()
{
if (!m_job) {
return;
}
// The running coroutine keeps its own reference and stops at its next check.
m_job->cancelled = true;
if (m_job->client) {
m_job->client->abort();
}
m_job.reset();
setBusy(false);
setStatusText({});
}
void TranslationService::fetchModels()
{
if (!m_fetchingModels) {
runFetchModels();
}
}
void TranslationService::clearError()
{
setErrorText({});
}
QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfig()
{
const auto settings = QtPotatoSettings::self();
LlmClient::Config config{
LlmClient::normalizedBaseUrl(settings->apiBaseUrl()),
{},
settings->model().trimmed(),
settings->targetLanguage().trimmed(),
};
if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) {
co_return std::pair{config, i18n("Set the API base URL in Settings first.")};
}
const ApiKeyStore::ReadResult key = co_await ApiKeyStore::readKey();
if (!key.error.isEmpty()) {
co_return std::pair{config, i18n("Could not read the API key from the wallet: %1", key.error)};
}
config.apiKey = key.key;
co_return std::pair{config, QString()};
}
QCoro::Task<Subtitles::ExtractResult> TranslationService::loadCues(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track)
{
if (track.value(u"external"_s).toBool()) {
const QString path = track.value(u"externalFilename"_s).toString();
co_return co_await QtConcurrent::run([path] {
QString error;
QList<SubtitleCue> cues = Subtitles::parseFile(path, &error);
return Subtitles::ExtractResult{cues, error};
});
}
if (!videoUrl.isLocalFile()) {
co_return Subtitles::ExtractResult{{}, i18n("Only subtitles of local files can be translated.")};
}
const int streamIndex = track.value(u"ffIndex"_s, -1).toInt();
if (streamIndex < 0) {
co_return Subtitles::ExtractResult{{}, i18n("mpv did not report where this subtitle track is stored.")};
}
const QString path = videoUrl.toLocalFile();
co_return co_await QtConcurrent::run([path, streamIndex, job] {
return Subtitles::extractEmbedded(path, streamIndex, job->cancelled);
});
}
QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
{
const std::shared_ptr<Job> job = m_job;
setErrorText({});
setProgress(0);
setBusy(true);
setStatusText(i18n("Reading subtitles…"));
const auto [config, configError] = co_await loadConfig();
if (job->cancelled) {
co_return;
}
if (!configError.isEmpty()) {
fail(job, configError);
co_return;
}
if (config.model.isEmpty()) {
fail(job, i18n("Choose a model in Settings first."));
co_return;
}
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
if (job->cancelled) {
co_return;
}
if (!source.error.isEmpty()) {
fail(job, source.error);
co_return;
}
const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues);
if (cues.isEmpty()) {
fail(job, i18n("This subtitle track has no text to translate."));
co_return;
}
// Same subtitles, model and language give the same file, so it is translated once.
QCryptographicHash hash(QCryptographicHash::Sha1);
hash.addData(CacheVersion);
hash.addData(config.model.toUtf8());
hash.addData(config.targetLanguage.toUtf8());
hash.addData(Subtitles::toSrt(cues).toUtf8());
const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
if (QFileInfo::exists(outputPath)) {
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
co_return;
}
// Repeated lines (songs, catchphrases) are sent once.
QStringList uniqueLines;
QHash<QString, qsizetype> uniqueIndex;
QList<qsizetype> cueToUnique;
for (const SubtitleCue &cue : cues) {
auto it = uniqueIndex.constFind(cue.text);
if (it == uniqueIndex.cend()) {
it = uniqueIndex.insert(cue.text, uniqueLines.size());
uniqueLines.append(cue.text);
}
cueToUnique.append(*it);
}
job->client = std::make_unique<LlmClient>(m_network, config);
auto batches = std::make_shared<Batches>();
batches->lines = uniqueLines;
batches->translations.resize(uniqueLines.size());
batches->batchSize = QtPotatoSettings::self()->batchSize();
setStatusText(i18n("Translating… %1 of %2 lines", 0, uniqueLines.size()));
// Tasks start running as soon as they are created; awaiting them just waits for the end.
std::vector<QCoro::Task<>> workers;
for (int i = 0; i < ParallelRequests; ++i) {
workers.push_back(translateBatches(job, batches));
}
for (QCoro::Task<> &worker : workers) {
co_await std::move(worker);
}
if (job->cancelled) {
co_return;
}
if (!batches->error.isEmpty()) {
fail(job, batches->error);
co_return;
}
QList<SubtitleCue> output = cues;
for (qsizetype i = 0; i < output.size(); ++i) {
output[i].text = batches->translations.at(cueToUnique.at(i));
}
QDir().mkpath(QFileInfo(outputPath).absolutePath());
QSaveFile file(outputPath);
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
fail(job, i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
co_return;
}
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), false);
}
QCoro::Task<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context)
{
LlmClient::BatchResult result;
for (int attempt = 1; attempt <= MaxAttempts; ++attempt) {
result = co_await job->client->translate(lines, context, job->mediaTitle);
if (job->cancelled || result.error.isEmpty() || !result.retryable || attempt == MaxAttempts) {
break;
}
co_await QCoro::sleepFor(2s * attempt);
if (job->cancelled) {
co_return result;
}
}
if (job->cancelled || !result.error.isEmpty()) {
co_return result;
}
QList<qsizetype> missing;
for (qsizetype i = 0; i < result.translations.size(); ++i) {
if (result.translations.at(i).isNull()) {
missing.append(i);
}
}
if (missing.isEmpty()) {
co_return result;
}
if (lines.size() == 1) {
co_return LlmClient::BatchResult{{}, i18n("The model did not translate this line: %1", lines.first()), false};
}
if (missing.size() == lines.size()) {
// Nothing came back usable; smaller requests are easier for the model.
const qsizetype half = lines.size() / 2;
const LlmClient::BatchResult first = co_await translateLines(job, lines.first(half), context);
if (job->cancelled || !first.error.isEmpty()) {
co_return first;
}
QStringList secondContext = context + lines.first(half);
secondContext = secondContext.mid(std::max<qsizetype>(0, secondContext.size() - ContextLines));
const LlmClient::BatchResult second = co_await translateLines(job, lines.sliced(half), secondContext);
if (job->cancelled || !second.error.isEmpty()) {
co_return second;
}
co_return LlmClient::BatchResult{first.translations + second.translations, {}, false};
}
// Ask again only for the lines the model skipped.
QStringList retryLines;
for (const qsizetype i : missing) {
retryLines.append(lines.at(i));
}
const LlmClient::BatchResult retry = co_await translateLines(job, retryLines, context);
if (job->cancelled || !retry.error.isEmpty()) {
co_return retry;
}
for (qsizetype i = 0; i < missing.size(); ++i) {
result.translations[missing.at(i)] = retry.translations.at(i);
}
co_return result;
}
QCoro::Task<> TranslationService::translateBatches(std::shared_ptr<Job> job, std::shared_ptr<Batches> batches)
{
// After an error, requests already sent may finish, but no new ones start.
while (!job->cancelled && batches->error.isEmpty() && batches->nextStart < batches->lines.size()) {
const qsizetype start = batches->nextStart;
batches->nextStart += batches->batchSize;
const qsizetype contextStart = std::max<qsizetype>(0, start - ContextLines);
const LlmClient::BatchResult result =
co_await translateLines(job, batches->lines.mid(start, batches->batchSize), batches->lines.mid(contextStart, start - contextStart));
if (job->cancelled) {
co_return;
}
if (!result.error.isEmpty()) {
if (batches->error.isEmpty()) {
batches->error = result.error;
}
co_return;
}
for (qsizetype i = 0; i < result.translations.size(); ++i) {
batches->translations[start + i] = result.translations.at(i);
}
batches->doneLines += result.translations.size();
setProgress(double(batches->doneLines) / batches->lines.size());
setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size()));
}
}
QCoro::Task<> TranslationService::runFetchModels()
{
QPointer<TranslationService> self(this);
setFetchingModels(true);
setErrorText({});
const auto [config, configError] = co_await loadConfig();
if (!self) {
co_return;
}
if (!configError.isEmpty()) {
setErrorText(configError);
setFetchingModels(false);
co_return;
}
LlmClient client(m_network, config);
const LlmClient::ModelsResult result = co_await client.listModels();
if (!self) {
co_return;
}
if (!result.error.isEmpty()) {
setErrorText(i18n("Could not list models: %1", result.error));
} else if (result.models != m_availableModels) {
m_availableModels = result.models;
Q_EMIT availableModelsChanged();
}
setFetchingModels(false);
}
void TranslationService::fail(const std::shared_ptr<Job> &job, const QString &error)
{
if (m_job != job) {
return;
}
m_job.reset();
setErrorText(error);
setBusy(false);
setStatusText({});
}
void TranslationService::setBusy(bool busy)
{
if (busy != m_busy) {
m_busy = busy;
Q_EMIT busyChanged();
}
}
void TranslationService::setProgress(double progress)
{
if (progress != m_progress) {
m_progress = progress;
Q_EMIT progressChanged();
}
}
void TranslationService::setStatusText(const QString &text)
{
if (text != m_statusText) {
m_statusText = text;
Q_EMIT statusTextChanged();
}
}
void TranslationService::setErrorText(const QString &text)
{
if (text != m_errorText) {
m_errorText = text;
Q_EMIT errorTextChanged();
}
}
void TranslationService::setFetchingModels(bool fetching)
{
if (fetching != m_fetchingModels) {
m_fetchingModels = fetching;
Q_EMIT fetchingModelsChanged();
}
}
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include "llmclient.h"
#include "subtitleextractor.h"
#include <QCoroTask>
#include <QObject>
#include <QStringList>
#include <QUrl>
#include <QVariantMap>
#include <QtQml/qqmlregistration.h>
#include <atomic>
#include <memory>
class QNetworkAccessManager;
// Translates a whole subtitle track with an LLM, batch by batch, and caches the result
// as an .srt file that mpv can load as the secondary subtitle.
class TranslationService : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(bool busy READ isBusy NOTIFY busyChanged)
Q_PROPERTY(double progress READ progress NOTIFY progressChanged)
Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged)
Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged)
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged)
public:
explicit TranslationService(QObject *parent = nullptr);
~TranslationService() override;
bool isBusy() const;
double progress() const;
QString statusText() const;
QString errorText() const;
QStringList availableModels() const;
bool isFetchingModels() const;
// \a track is an entry of MpvItem::subtitleTracks.
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
Q_INVOKABLE void cancel();
// Asks the API configured in the settings for its models.
Q_INVOKABLE void fetchModels();
Q_INVOKABLE void clearError();
Q_SIGNALS:
void busyChanged();
void progressChanged();
void statusTextChanged();
void errorTextChanged();
void availableModelsChanged();
void fetchingModelsChanged();
void translated(const QUrl &subtitleUrl, bool fromCache);
private:
struct Job;
struct Batches;
QCoro::Task<> run(QUrl videoUrl, QVariantMap track);
QCoro::Task<> runFetchModels();
// Translates \a lines, retrying failures and asking again for lines the model skipped.
QCoro::Task<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context);
// Takes batches from \a batches until none are left; several run side by side.
QCoro::Task<> translateBatches(std::shared_ptr<Job> job, std::shared_ptr<Batches> batches);
QCoro::Task<Subtitles::ExtractResult> loadCues(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track);
// Reads the settings and the API key. The second value is an error message when
// something is missing.
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
void fail(const std::shared_ptr<Job> &job, const QString &error);
void setBusy(bool busy);
void setProgress(double progress);
void setStatusText(const QString &text);
void setErrorText(const QString &text);
void setFetchingModels(bool fetching);
QNetworkAccessManager *m_network;
std::shared_ptr<Job> m_job;
bool m_busy = false;
double m_progress = 0.0;
QString m_statusText;
QString m_errorText;
QStringList m_availableModels;
bool m_fetchingModels = false;
};