#include "translationservice.h" #include "apikeystore.h" #include "qtpotatosettings.h" #include "translationpresets.h" #include "translationstore.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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-v2"; QString cacheDir() { return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s; } QList glossaryEntriesFromVariants(const QVariantList &entries) { QList result; for (const QVariant &value : entries) { const QVariantMap map = value.toMap(); result.append({map.value(u"source"_s).toString().trimmed(), map.value(u"target"_s).toString().trimmed(), map.value(u"episode"_s).toString()}); } return result; } QVariantList glossaryEntriesToVariants(const QList &entries) { QVariantList result; for (const auto &entry : entries) { result.append(QVariantMap{{u"source"_s, entry.source}, {u"target"_s, entry.target}, {u"episode"_s, entry.episode}}); } return result; } } struct TranslationService::Job { std::atomic_bool cancelled = false; std::unique_ptr 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; } QVariantList TranslationService::translationPresets() const { QVariantList result; for (const auto &preset : TranslationPresets::all()) { result.append(QVariantMap{{u"id"_s, preset.id}, {u"name"_s, preset.name}, {u"description"_s, preset.description}, {u"guidance"_s, preset.guidance}}); } return result; } bool TranslationService::isFetchingModels() const { return m_fetchingModels; } QVariantList TranslationService::glossaryEntries() const { return glossaryEntriesToVariants(m_glossary.entries); } QString TranslationService::glossaryDirectory() const { return m_glossary.directory; } QString TranslationService::glossaryEpisode() const { return m_glossaryEpisode; } QStringList TranslationService::glossaryEpisodes() const { return GlossaryStore::sortedEpisodes(m_glossary.episodes); } QStringList TranslationService::sortedGlossaryEpisodes(const QStringList &episodes) const { return GlossaryStore::sortedEpisodes(episodes); } QString TranslationService::glossaryError() const { return m_glossaryError; } void TranslationService::loadGlossary(const QUrl &videoUrl) { const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString(); m_glossaryEpisode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : QString(); m_glossary = directory.isEmpty() ? GlossaryStore::Glossary{} : GlossaryStore::load(directory); // A legacy glossary has no provenance for its old entries. Once it is // opened in an episode, keep it with that episode instead of presenting a // misleading shared bucket. Later episodes still reuse every term. if (!m_glossaryEpisode.isEmpty()) { m_glossary = GlossaryStore::assignUnassigned(m_glossary, m_glossaryEpisode); } if (!m_glossaryError.isEmpty()) { m_glossaryError.clear(); Q_EMIT glossaryErrorChanged(); } Q_EMIT glossaryChanged(); } bool TranslationService::saveGlossary(const QVariantList &entries, const QStringList &episodes) { GlossaryStore::Glossary candidate = m_glossary; candidate.entries = glossaryEntriesFromVariants(entries); candidate.episodes = episodes; if (!m_glossaryEpisode.isEmpty()) { candidate = GlossaryStore::assignUnassigned(candidate, m_glossaryEpisode); } const bool changed = GlossaryStore::serialize(candidate) != GlossaryStore::serialize(m_glossary); const GlossaryStore::Glossary previous = m_glossary; if (!candidate.directory.isEmpty()) { QString error; if (!GlossaryStore::save(candidate, &error)) { const QString message = error.isEmpty() ? i18n("Could not save the translation glossary.") : i18n("Could not save the translation glossary: %1", error); if (m_glossaryError != message) { m_glossaryError = message; Q_EMIT glossaryErrorChanged(); } return false; } } m_glossary = candidate; if (!m_glossaryError.isEmpty()) { m_glossaryError.clear(); Q_EMIT glossaryErrorChanged(); } Q_EMIT glossaryChanged(); if (changed) { applyGlossaryEdits(previous, candidate); } return true; } void TranslationService::clearGlossary() { saveGlossary({}, {}); } QVariantMap TranslationService::importGlossaryFile(const QUrl &url, const QVariantList &entries, const QStringList &episodes) { GlossaryStore::Glossary imported; QString error; if (!url.isLocalFile()) { return {{u"ok"_s, false}, {u"error"_s, i18n("Choose a local glossary file.")}}; } if (!GlossaryStore::importFile(url.toLocalFile(), &imported, &error)) { return {{u"ok"_s, false}, {u"error"_s, i18n("Could not import glossary: %1", error)}}; } const GlossaryStore::Glossary current{m_glossary.directory, glossaryEntriesFromVariants(entries), episodes}; auto merged = GlossaryStore::mergeImported(current, imported); if (!m_glossaryEpisode.isEmpty()) { merged = GlossaryStore::assignUnassigned(merged, m_glossaryEpisode); } const qsizetype added = merged.entries.size() - current.entries.size(); return {{u"ok"_s, true}, {u"entries"_s, glossaryEntriesToVariants(merged.entries)}, {u"episodes"_s, merged.episodes}, {u"added"_s, added}, {u"skipped"_s, imported.entries.size() - added}}; } QVariantMap TranslationService::exportGlossaryFile(const QUrl &url, const QVariantList &entries, const QStringList &episodes) { if (!url.isLocalFile()) { return {{u"ok"_s, false}, {u"error"_s, i18n("Choose a local destination file.")}}; } const GlossaryStore::Glossary draft{{}, glossaryEntriesFromVariants(entries), episodes}; QString error; if (!GlossaryStore::exportFile(url.toLocalFile(), draft, &error)) { return {{u"ok"_s, false}, {u"error"_s, i18n("Could not export glossary: %1", error)}}; } return {{u"ok"_s, true}}; } void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previous, const GlossaryStore::Glossary &updated) { if (m_lastTranslationPath.isEmpty() || !QFileInfo::exists(m_lastTranslationPath)) { return; } // A translation the user has edited since is theirs; leave it alone. const QString registryFile = TranslationStore::registryFile(); TranslationStore::Registry registry = TranslationStore::load(registryFile); if (!TranslationStore::isOwned(registry, m_lastTranslationPath)) { return; } QFile file(m_lastTranslationPath); if (!file.open(QIODevice::ReadOnly)) { return; } QString text = QString::fromUtf8(file.readAll()); file.close(); QHash oldTargets; for (const auto &entry : previous.entries) { oldTargets.insert(entry.source.trimmed(), entry.target.trimmed()); } QList> replacements; for (const auto &entry : updated.entries) { const QString source = entry.source.trimmed(); const QString target = entry.target.trimmed(); if (source.isEmpty() || target.isEmpty()) { continue; } const QString oldTarget = oldTargets.value(source); if (!oldTarget.isEmpty() && oldTarget != target) { replacements.append({oldTarget, target}); } else if (oldTarget.isEmpty()) { replacements.append({source, target}); } } QSet updatedSources; for (const auto &entry : updated.entries) { updatedSources.insert(entry.source.trimmed()); } for (const auto &entry : previous.entries) { const QString source = entry.source.trimmed(); const QString target = entry.target.trimmed(); if (!source.isEmpty() && !target.isEmpty() && !updatedSources.contains(source)) { replacements.append({target, source}); } } QStringList markers; for (qsizetype i = 0; i < replacements.size(); ++i) { const QString marker = u"\\uE000qtpotato-glossary-%1\\uE001"_s.arg(i); text.replace(replacements.at(i).first, marker); markers.append(marker); } for (qsizetype i = 0; i < markers.size(); ++i) { text.replace(markers.at(i), replacements.at(i).second); } const QByteArray data = text.toUtf8(); QSaveFile output(m_lastTranslationPath); if (!output.open(QIODevice::WriteOnly) || output.write(data) < 0 || !output.commit()) { return; } // Still QtPotato's file, so a later translation may replace it. TranslationStore::recordRewrite(registry, m_lastTranslationPath, data); TranslationStore::save(registryFile, registry); Q_EMIT translated(QUrl::fromLocalFile(m_lastTranslationPath), true); } void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle) { cancel(); m_job = std::make_shared(); 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({}); } void TranslationService::forgetTranslation() { m_lastTranslationPath.clear(); } QCoro::Task> TranslationService::loadConfig() { const auto settings = QtPotatoSettings::self(); LlmClient::Config config{ LlmClient::normalizedBaseUrl(settings->apiBaseUrl()), {}, settings->model().trimmed(), settings->targetLanguage().trimmed(), settings->translationPreset().trimmed(), settings->customInstructions(), {}, }; 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 TranslationService::loadCues(std::shared_ptr 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 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 = m_job; setErrorText({}); setProgress(0); setBusy(true); setStatusText(i18n("Reading subtitles…")); 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 cues = Subtitles::mergeRepeated(source.cues); if (cues.isEmpty()) { fail(job, i18n("This subtitle track has no text to translate.")); co_return; } // Episodes in one folder share a glossary. Streams have no folder, so they get none. const QString seriesDirectory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString(); const bool useGlossary = !seriesDirectory.isEmpty(); GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{}; const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle; const auto saveGlossaryGroups = [&]() { QString error; if (!GlossaryStore::save(glossary, &error)) { fail(job, i18n("Could not save the translation glossary: %1", error)); return false; } m_glossary = glossary; m_glossaryEpisode = episode; Q_EMIT glossaryChanged(); return true; }; if (useGlossary) { // New terms belong to the episode being translated. Migrate legacy or // imported entries without provenance to this first episode too. This // happens before the cache shortcut and never changes translation text. glossary = GlossaryStore::assignUnassigned(glossary, episode); if (!saveGlossaryGroups()) { co_return; } } const QString sourceSrt = Subtitles::toSrt(cues); // Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once. const QString suffix = TranslationStore::languageSuffix(config.targetLanguage); const auto configKeyFor = [&](const GlossaryStore::Glossary &g) { QCryptographicHash hash(QCryptographicHash::Sha1); hash.addData(CacheVersion); hash.addData(config.model.toUtf8()); hash.addData(config.targetLanguage.toUtf8()); hash.addData(config.translationPreset.toUtf8()); hash.addData(config.customInstructions.toUtf8()); hash.addData(GlossaryStore::serialize(g).toUtf8()); hash.addData(sourceSrt.toUtf8()); return QString::fromLatin1(hash.result().toHex().left(12)); }; QString configKey = configKeyFor(glossary); QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix); if (QFileInfo::exists(outputPath)) { finish(job, videoUrl, suffix, configKey, outputPath, true); co_return; } if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) { setStatusText(i18n("Building glossary…")); QStringList extractionLines; QHash seen; for (const SubtitleCue &cue : cues) { if (!seen.contains(cue.text)) { seen.insert(cue.text, true); extractionLines.append(cue.text); } } constexpr qsizetype MaxGlossaryChars = 30000; // Long subtitles are sampled evenly so names from the whole episode are seen. qsizetype allChars = 0; for (const QString &line : std::as_const(extractionLines)) { allChars += line.size() + 1; } const qsizetype step = std::max(1, (allChars + MaxGlossaryChars - 1) / MaxGlossaryChars); QStringList sampled; qsizetype total = 0; for (qsizetype i = 0; i < extractionLines.size(); i += step) { if (total + extractionLines.at(i).size() + 1 > MaxGlossaryChars) { break; } sampled.append(extractionLines.at(i)); total += extractionLines.at(i).size() + 1; } job->client = std::make_unique(m_network, config); const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle); if (job->cancelled) { co_return; } if (extracted.error.isEmpty()) { QList additions; for (const QString &encoded : extracted.translations) { const qsizetype tab = encoded.indexOf(u'\t'); if (tab > 0) additions.append({encoded.left(tab), encoded.mid(tab + 1)}); } // Suggestions extracted while translating this episode belong to // this episode. Existing terms keep their original ownership. glossary = GlossaryStore::merge(glossary, additions, episode); if (!saveGlossaryGroups()) { co_return; } } else { setStatusText(i18n("Glossary unavailable; translating without it.")); } configKey = configKeyFor(glossary); outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix); if (QFileInfo::exists(outputPath)) { finish(job, videoUrl, suffix, configKey, outputPath, true); co_return; } } // Repeated lines (songs, catchphrases) are sent once. QStringList uniqueLines; QHash uniqueIndex; QList 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); } QStringList glossaryLines; for (const auto &entry : std::as_const(glossary.entries)) { glossaryLines.append(entry.source + u" → "_s + entry.target); } config.glossaryText = glossaryLines.join(u'\n'); job->client = std::make_unique(m_network, config); auto batches = std::make_shared(); 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> 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 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; } finish(job, videoUrl, suffix, configKey, outputPath, false); } void TranslationService::finish(const std::shared_ptr &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache) { QFile file(cacheFile); if (!file.open(QIODevice::ReadOnly)) { fail(job, i18n("Could not read the translation from %1: %2", cacheFile, file.errorString())); return; } // The cache keeps every version; the published copy is the one players load. const QString registryFile = TranslationStore::registryFile(); TranslationStore::Registry registry = TranslationStore::load(registryFile); const TranslationStore::PublishResult published = TranslationStore::publish(registry, videoUrl, suffix, configKey, file.readAll(), cacheFile); TranslationStore::save(registryFile, registry); m_job.reset(); m_lastTranslationPath = published.path; setBusy(false); setStatusText({}); Q_EMIT translated(QUrl::fromLocalFile(published.path), fromCache); } QString TranslationService::savedTranslation(const QUrl &videoUrl) const { const QString suffix = TranslationStore::languageSuffix(QtPotatoSettings::self()->targetLanguage().trimmed()); return TranslationStore::savedTranslation(TranslationStore::load(TranslationStore::registryFile()), videoUrl, suffix); } QCoro::Task TranslationService::translateLines(std::shared_ptr 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 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(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, std::shared_ptr 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(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 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, 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(); } }