#include "translationservice.h" #include "apikeystore.h" #include "mediainfo.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; bool playlist = false; // In a playlist: the episode being translated (from 0) and how many there are. int episode = 0; int episodes = 0; }; 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; } QUrl TranslationService::currentVideo() const { return m_currentVideo; } void TranslationService::setCurrentVideo(const QUrl &url) { if (url == m_currentVideo) { return; } m_currentVideo = url; forgetTranslation(); // A playlist translation is meant to go on while the episodes play. if (m_job && !m_job->playlist) { cancel(); } Q_EMIT currentVideoChanged(); } 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(m_job, videoUrl, track); } void TranslationService::translatePlaylist(const QList &urls, const QVariantMap ¤tTrack, const QString ¤tTitle) { cancel(); // Start with the episode playing, then the ones after it, which are watched next. QList ordered; const qsizetype current = std::max(0, urls.indexOf(m_currentVideo)); for (qsizetype i = 0; i < urls.size(); ++i) { const QUrl &url = urls.at((current + i) % urls.size()); if (!ordered.contains(url)) { ordered.append(url); } } m_job = std::make_shared(); m_job->playlist = true; m_job->episodes = ordered.size(); runPlaylist(m_job, ordered, currentTrack, currentTitle); } 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::startJob(std::shared_ptr job) { setErrorText({}); setProgress(0); setBusy(true); setStatusText(i18n("Reading subtitles…")); auto [config, configError] = co_await loadConfig(); if (job->cancelled) { co_return std::nullopt; } if (configError.isEmpty() && config.model.isEmpty()) { configError = i18n("Choose a model in Settings first."); } if (!configError.isEmpty()) { fail(job, configError); co_return std::nullopt; } co_return config; } QCoro::Task<> TranslationService::run(std::shared_ptr job, QUrl videoUrl, QVariantMap track) { const std::optional config = co_await startJob(job); if (!config) { co_return; } const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track); if (job->cancelled) { co_return; } const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error; if (!error.isEmpty()) { fail(job, error); co_return; } endJob(job); } QCoro::Task<> TranslationService::runPlaylist(std::shared_ptr job, QList urls, QVariantMap currentTrack, QString currentTitle) { const std::optional config = co_await startJob(job); if (!config) { co_return; } int translatedCount = 0; int alreadyTranslatedCount = 0; QStringList problems; for (qsizetype i = 0; i < urls.size(); ++i) { const QUrl videoUrl = urls.at(i); const QString name = videoUrl.fileName(); job->episode = int(i); setJobProgress(*job, 0); setJobStatus(*job, i18n("Reading subtitles…")); if (!savedTranslation(videoUrl).isEmpty()) { ++alreadyTranslatedCount; continue; } QVariantMap track = currentTrack; job->mediaTitle = currentTitle; if (videoUrl != m_currentVideo || currentTrack.isEmpty()) { if (!videoUrl.isLocalFile()) { problems.append(i18nc("@info %1 is a file name", "%1: only subtitles of local files can be translated", name)); continue; } const QString path = videoUrl.toLocalFile(); const Subtitles::SourceResult source = co_await QtConcurrent::run([path] { return Subtitles::findTranslationSource(path); }); if (job->cancelled) { co_return; } if (!source.error.isEmpty() || source.streamIndex < 0) { problems.append(source.error.isEmpty() ? i18nc("@info %1 is a file name", "%1: no text subtitle", name) : i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, source.error)); continue; } track = QVariantMap{{u"ffIndex"_s, source.streamIndex}}; job->mediaTitle = MediaInfo::cleanTitle(name); } const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track); if (job->cancelled) { co_return; } if (!outcome.error.isEmpty() && !outcome.skippable) { // Errors from the API or the disk would repeat for every episode. fail(job, i18nc("@info %1 is a file name, %2 an error", "Stopped translating the playlist at %1: %2", name, outcome.error)); co_return; } const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error; if (error.isEmpty()) { ++translatedCount; } else { problems.append(i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, error)); } } endJob(job); if (!problems.isEmpty()) { setErrorText(i18np("Translated %2 of %3 episodes. Skipped one:\n%4", "Translated %2 of %3 episodes. Skipped %1:\n%4", problems.size(), translatedCount + alreadyTranslatedCount, urls.size(), problems.join(u'\n'))); } Q_EMIT playlistTranslated(translatedCount, alreadyTranslatedCount, int(problems.size())); } QCoro::Task TranslationService::translateVideo(std::shared_ptr job, LlmClient::Config config, QUrl videoUrl, QVariantMap track) { setJobProgress(*job, 0); setJobStatus(*job, i18n("Reading subtitles…")); Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track); if (job->cancelled) { co_return Outcome{}; } if (!source.error.isEmpty()) { co_return Outcome::failed(source.error, true); } const QList cues = Subtitles::mergeRepeated(source.cues); if (cues.isEmpty()) { co_return Outcome::failed(i18n("This subtitle track has no text to translate."), true); } // 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; // Returns an error message. const auto saveGlossaryGroups = [&]() -> QString { QString error; if (!GlossaryStore::save(glossary, &error)) { return i18n("Could not save the translation glossary: %1", error); } // Another episode of the playlist only updates the terms of the open series. if (!job->playlist || videoUrl == m_currentVideo) { m_glossary = glossary; m_glossaryEpisode = episode; Q_EMIT glossaryChanged(); } else if (m_glossary.directory == glossary.directory) { m_glossary = glossary; Q_EMIT glossaryChanged(); } return {}; }; 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 (const QString error = saveGlossaryGroups(); !error.isEmpty()) { co_return Outcome::failed(error); } } 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)) { co_return Outcome::ready(outputPath, suffix, configKey, true); } if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) { setJobStatus(*job, 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 Outcome{}; } 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 (const QString error = saveGlossaryGroups(); !error.isEmpty()) { co_return Outcome::failed(error); } } else { setJobStatus(*job, i18n("Glossary unavailable; translating without it.")); } configKey = configKeyFor(glossary); outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix); if (QFileInfo::exists(outputPath)) { co_return Outcome::ready(outputPath, suffix, configKey, true); } } // 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(); setLinesStatus(*job, 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 Outcome{}; } if (!batches->error.isEmpty()) { co_return Outcome::failed(batches->error); } 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()) { co_return Outcome::failed(i18n("Could not save the translation to %1: %2", outputPath, file.errorString())); } co_return Outcome::ready(outputPath, suffix, configKey, false); } QString TranslationService::publish(const std::shared_ptr &job, const QUrl &videoUrl, const Outcome &outcome) { QFile file(outcome.cacheFile); if (!file.open(QIODevice::ReadOnly)) { return i18n("Could not read the translation from %1: %2", outcome.cacheFile, file.errorString()); } // 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, outcome.suffix, outcome.configKey, file.readAll(), outcome.cacheFile); TranslationStore::save(registryFile, registry); // Other episodes of a playlist load their translation when they are opened. if (!job->playlist || videoUrl == m_currentVideo) { m_lastTranslationPath = published.path; Q_EMIT translated(QUrl::fromLocalFile(published.path), outcome.fromCache); } return {}; } void TranslationService::endJob(const std::shared_ptr &job) { if (m_job != job) { return; } m_job.reset(); setBusy(false); setStatusText({}); } void TranslationService::setJobStatus(const Job &job, const QString &text) { if (!job.playlist) { setStatusText(text); return; } setStatusText(i18nc("@info:status %1 and %2 are numbers, %3 is what is being done", "Episode %1 of %2: %3", job.episode + 1, job.episodes, text)); } void TranslationService::setLinesStatus(const Job &job, qsizetype done, qsizetype total) { // The line count would not fit after the episode number; the progress bar shows it. setJobStatus(job, job.playlist ? i18n("Translating…") : i18n("Translating… %1 of %2 lines", done, total)); } void TranslationService::setJobProgress(const Job &job, double episodeProgress) { setProgress(job.playlist ? (job.episode + episodeProgress) / job.episodes : episodeProgress); } 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(); setJobProgress(*job, double(batches->doneLines) / batches->lines.size()); setLinesStatus(*job, 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(); } }