From 9e55dba68775fd77906307752f4bfdd03ca96f02 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Sat, 7 Nov 2026 13:26:44 +0700 Subject: [PATCH] feat(translate): add per-series glossary with auto extraction --- autotests/CMakeLists.txt | 6 ++ autotests/glossarystoretest.cpp | 30 +++++++ src/CMakeLists.txt | 2 + src/qml/Actions.qml | 8 ++ src/qml/Main.qml | 41 +++++++++ src/qml/SubtitlesMenu.qml | 1 + src/qml/TranslationSettings.qml | 7 ++ src/settings/qtpotatosettings.kcfg | 4 + src/translate/glossarystore.cpp | 92 +++++++++++++++++++ src/translate/glossarystore.h | 20 +++++ src/translate/llmclient.cpp | 41 ++++++++- src/translate/llmclient.h | 2 + src/translate/translationservice.cpp | 128 ++++++++++++++++++++++++--- src/translate/translationservice.h | 10 +++ 14 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 autotests/glossarystoretest.cpp create mode 100644 src/translate/glossarystore.cpp create mode 100644 src/translate/glossarystore.h diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 4de64da..3b170b6 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -8,6 +8,12 @@ ecm_add_test(mpvuserconfigtest.cpp LINK_LIBRARIES qtpotato_core Qt6::Test ) +ecm_add_test(glossarystoretest.cpp ../src/translate/glossarystore.cpp + TEST_NAME glossarystoretest + LINK_LIBRARIES Qt6::Core Qt6::Test +) +target_include_directories(glossarystoretest PRIVATE ../src/translate) + ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp TEST_NAME playlistmodeltest LINK_LIBRARIES Qt6::Qml Qt6::Test diff --git a/autotests/glossarystoretest.cpp b/autotests/glossarystoretest.cpp new file mode 100644 index 0000000..6c7f9a1 --- /dev/null +++ b/autotests/glossarystoretest.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "glossarystore.h" + +#include +#include +using namespace Qt::Literals::StringLiterals; + +class GlossaryStoreTest : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void saveLoadMerge() + { + QTemporaryDir temp; + QStandardPaths::setTestModeEnabled(true); + const QString directory = temp.path() + u"/series"_s; + GlossaryStore::Glossary original{directory, {{u"Lin"_s, u"Lâm"_s}}}; + QVERIFY(GlossaryStore::save(original)); + const auto loaded = GlossaryStore::load(directory); + QCOMPARE(loaded.entries.size(), 1); + const auto merged = GlossaryStore::merge(loaded, {{u"Lin"_s, u"Other"_s}, {u"Sect"_s, u"Tông"_s}}); + QCOMPARE(merged.entries.size(), 2); + QCOMPARE(merged.entries.at(0).target, u"Lâm"_s); + QCOMPARE(merged.entries.at(1).source, u"Sect"_s); + } +}; + +QTEST_MAIN(GlossaryStoreTest) +#include "glossarystoretest.moc" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1db6325..894f5e9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -34,6 +34,8 @@ add_executable(qtpotato playlist/playlistmodel.h translate/apikeystore.cpp translate/apikeystore.h + translate/glossarystore.cpp + translate/glossarystore.h translate/llmclient.cpp translate/llmclient.h translate/translationservice.cpp diff --git a/src/qml/Actions.qml b/src/qml/Actions.qml index 3c9ca22..0760410 100644 --- a/src/qml/Actions.qml +++ b/src/qml/Actions.qml @@ -35,6 +35,7 @@ Item { signal headerToggleRequested() signal contextMenuRequested() signal translateRequested() + signal editGlossaryRequested() function seekBy(seconds: real) { mpv.seekBy(seconds) @@ -364,6 +365,13 @@ Item { onTriggered: root.translateRequested() } + property Action editGlossary: Action { + enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:") + text: KI18n.i18nc("@action", "Edit Translation Glossary…") + icon.name: "document-edit" + onTriggered: root.editGlossaryRequested() + } + property Action cancelTranslation: Action { enabled: root.translator.busy text: KI18n.i18nc("@action", "Cancel Translation") diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 174ffe8..44191be 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Dialogs +import QtQuick.Layouts import org.kde.ki18n import org.kde.kirigami as Kirigami import org.kde.config as KConfig @@ -108,6 +109,10 @@ ApplicationWindow { onPlaylistToggleRequested: playlistPanel.toggle() onContextMenuRequested: contextMenu.popup() onTranslateRequested: appWindow.translateCurrentSubtitle() + onEditGlossaryRequested: { + translationService.loadGlossary(video.currentUrl) + glossaryDialog.open() + } onMenuBarToggleRequested: { QtPotatoSettings.showMenuBar = !QtPotatoSettings.showMenuBar QtPotatoSettings.save() @@ -312,4 +317,40 @@ ApplicationWindow { ] onAccepted: video.addSubtitleFile(selectedFile) } + + Kirigami.Dialog { + id: glossaryDialog + title: KI18n.i18nc("@title:window", "Translation Glossary") + standardButtons: Kirigami.Dialog.Save | Kirigami.Dialog.Cancel + width: Math.min(appWindow.width * 0.9, 650) + contentItem: ColumnLayout { + ListView { + id: glossaryList + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 15 + clip: true + model: ListModel { id: glossaryModel } + delegate: RowLayout { + width: glossaryList.width + TextField { text: source; Layout.fillWidth: true; onTextChanged: model.source = text } + TextField { text: target; Layout.fillWidth: true; onTextChanged: model.target = text } + ToolButton { icon.name: "edit-delete-remove"; onClicked: glossaryModel.remove(index) } + } + } + RowLayout { + Button { text: KI18n.i18nc("@action:button", "Add"); onClicked: glossaryModel.append({source: "", target: ""}) } + Button { text: KI18n.i18nc("@action:button", "Clear"); onClicked: glossaryModel.clear() } + } + } + onOpened: { + glossaryModel.clear() + for (const entry of translationService.glossaryEntries) glossaryModel.append(entry) + } + onAccepted: { + const entries = [] + for (let i = 0; i < glossaryModel.count; ++i) entries.push(glossaryModel.get(i)) + translationService.saveGlossary(entries) + } + } } diff --git a/src/qml/SubtitlesMenu.qml b/src/qml/SubtitlesMenu.qml index 5f05236..60fcb0c 100644 --- a/src/qml/SubtitlesMenu.qml +++ b/src/qml/SubtitlesMenu.qml @@ -26,6 +26,7 @@ Menu { } MenuSeparator {} MenuItem { action: root.actions.translateSubtitle } + MenuItem { action: root.actions.editGlossary } MenuItem { action: root.actions.cancelTranslation visible: root.actions.cancelTranslation.enabled diff --git a/src/qml/TranslationSettings.qml b/src/qml/TranslationSettings.qml index 874cc8b..e097cbf 100644 --- a/src/qml/TranslationSettings.qml +++ b/src/qml/TranslationSettings.qml @@ -190,6 +190,13 @@ SettingsBasePage { onEditingFinished: QtPotatoSettings.save() } + CheckBox { + text: KI18n.i18nc("@option:check", "Build a glossary automatically before translating") + checked: QtPotatoSettings.autoBuildGlossary + Layout.columnSpan: 2 + onToggled: { QtPotatoSettings.autoBuildGlossary = checked; QtPotatoSettings.save() } + } + Label { text: KI18n.i18nc("@label:spinbox", "Lines per request") Layout.alignment: Qt.AlignRight diff --git a/src/settings/qtpotatosettings.kcfg b/src/settings/qtpotatosettings.kcfg index 0e4d1bd..b537f37 100644 --- a/src/settings/qtpotatosettings.kcfg +++ b/src/settings/qtpotatosettings.kcfg @@ -130,6 +130,10 @@ + + + true + 40 diff --git a/src/translate/glossarystore.cpp b/src/translate/glossarystore.cpp new file mode 100644 index 0000000..2cdcd45 --- /dev/null +++ b/src/translate/glossarystore.cpp @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "glossarystore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Qt::Literals::StringLiterals; + +QString GlossaryStore::pathForDirectory(const QString &directory) +{ + const QString root = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/glossaries"_s; + const QByteArray digest = QCryptographicHash::hash(QFile::encodeName(QDir::cleanPath(directory)), QCryptographicHash::Sha1).toHex(); + return root + u"/"_s + QString::fromLatin1(digest) + u".json"_s; +} + +static QJsonObject glossaryJson(const GlossaryStore::Glossary &glossary) +{ + QJsonArray entries; + for (const auto &entry : glossary.entries) { + if (!entry.source.trimmed().isEmpty() && !entry.target.trimmed().isEmpty()) { + entries.append(QJsonObject{{u"source"_s, entry.source}, {u"target"_s, entry.target}}); + } + } + return QJsonObject{{u"directory"_s, glossary.directory}, {u"entries"_s, entries}}; +} + +GlossaryStore::Glossary GlossaryStore::load(const QString &directory) +{ + Glossary result{QDir::cleanPath(directory), {}}; + QFile file(pathForDirectory(result.directory)); + if (!file.open(QIODevice::ReadOnly)) { + return result; + } + const QJsonObject object = QJsonDocument::fromJson(file.readAll()).object(); + for (const QJsonValue &value : object.value(u"entries"_s).toArray()) { + const QJsonObject entry = value.toObject(); + const QString source = entry.value(u"source"_s).toString().trimmed(); + const QString target = entry.value(u"target"_s).toString().trimmed(); + if (!source.isEmpty() && !target.isEmpty()) { + result.entries.append({source, target}); + } + } + return result; +} + +bool GlossaryStore::save(const Glossary &glossary, QString *error) +{ + const QString directory = QDir::cleanPath(glossary.directory); + const QString path = pathForDirectory(directory); + QDir().mkpath(QFileInfo(path).absolutePath()); + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(glossaryJson({directory, glossary.entries})).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) { + if (error) { + *error = file.errorString(); + } + return false; + } + return true; +} + +GlossaryStore::Glossary GlossaryStore::merge(const Glossary &existing, const QList &additions) +{ + Glossary result{QDir::cleanPath(existing.directory), existing.entries}; + QSet sources; + for (const Entry &entry : result.entries) { + sources.insert(entry.source.trimmed()); + } + for (const Entry &entry : additions) { + const QString source = entry.source.trimmed(); + const QString target = entry.target.trimmed(); + if (!source.isEmpty() && !target.isEmpty() && !sources.contains(source)) { + result.entries.append({source, target}); + sources.insert(source); + } + } + return result; +} + +QString GlossaryStore::serialize(const Glossary &glossary) +{ + return QString::fromUtf8(QJsonDocument(glossaryJson(glossary)).toJson(QJsonDocument::Compact)); +} diff --git a/src/translate/glossarystore.h b/src/translate/glossarystore.h new file mode 100644 index 0000000..dfc48bb --- /dev/null +++ b/src/translate/glossarystore.h @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +class GlossaryStore +{ +public: + struct Entry { QString source; QString target; }; + struct Glossary { QString directory; QList entries; }; + + static Glossary load(const QString &directory); + static bool save(const Glossary &glossary, QString *error = nullptr); + static Glossary merge(const Glossary &existing, const QList &additions); + static QString serialize(const Glossary &glossary); + static QString pathForDirectory(const QString &directory); +}; diff --git a/src/translate/llmclient.cpp b/src/translate/llmclient.cpp index 0085a9d..d0bf109 100644 --- a/src/translate/llmclient.cpp +++ b/src/translate/llmclient.cpp @@ -30,7 +30,7 @@ struct ReplyDeleter { }; using ReplyPtr = std::unique_ptr; -QString systemPrompt(const QString &language, const QString &presetId, const QString &customInstructions) +QString systemPrompt(const QString &language, const QString &presetId, const QString &glossaryText, const QString &customInstructions) { const auto &preset = TranslationPresets::find(presetId); QString prompt = uR"(You are a professional subtitle translator. Translate subtitles into %1. @@ -47,6 +47,10 @@ Style guidance: )"_s .arg(language, preset.guidance); + if (!glossaryText.trimmed().isEmpty()) { + prompt += u"Glossary — always use these renderings exactly:\n"_s; + prompt += glossaryText.trimmed() + u"\n\n"_s; + } if (!customInstructions.trimmed().isEmpty()) { prompt += u"Additional instructions from the user (follow them unless they conflict with the reply format):\n"_s; prompt += customInstructions.trimmed(); @@ -149,7 +153,7 @@ QCoro::Task LlmClient::translate(QStringList lines, QStr {"model"_L1, m_config.model}, {"messages"_L1, QJsonArray{ - QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage, m_config.translationPreset, m_config.customInstructions)}}, + QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage, m_config.translationPreset, m_config.glossaryText, m_config.customInstructions)}}, QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}}, }}, }; @@ -192,6 +196,39 @@ QCoro::Task LlmClient::translate(QStringList lines, QStr co_return result; } +QCoro::Task LlmClient::extractGlossary(QStringList lines, QString mediaTitle) +{ + const auto &preset = TranslationPresets::find(m_config.translationPreset); + const QString prompt = u"Identify recurring proper nouns and special terms in these subtitles. Return only JSON of the form {\"entries\":[{\"source\":\"...\",\"target\":\"...\"}]}. Include people, places, sects/clans, techniques, ranks and recurring special terms; omit ordinary words. Follow the target language and style preset.\nTarget language: "_s + m_config.targetLanguage + u"\nStyle guidance:\n"_s + preset.guidance; + const QJsonObject userContent{{"title"_L1, mediaTitle}, {"lines"_L1, QJsonArray::fromStringList(lines)}}; + const QJsonObject body{{"model"_L1, m_config.model}, {"messages"_L1, QJsonArray{ + QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, prompt}}, + 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(); + const QJsonObject object = extractJsonObject(content); + if (!object.contains("entries"_L1)) co_return BatchResult{{}, i18n("The model did not return a glossary: %1", content.left(300).simplified()), true}; + QStringList encoded; + for (const QJsonValue &value : object.value("entries"_L1).toArray()) { + const QJsonObject entry = value.toObject(); + const QString source = entry.value("source"_L1).toString().trimmed(); + const QString target = entry.value("target"_L1).toString().trimmed(); + if (!source.isEmpty() && !target.isEmpty()) encoded.append(source + u"\t"_s + target); + } + co_return BatchResult{encoded, {}, false}; +} + QCoro::Task LlmClient::listModels() { QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/models"_s)); diff --git a/src/translate/llmclient.h b/src/translate/llmclient.h index bf50817..c066d6c 100644 --- a/src/translate/llmclient.h +++ b/src/translate/llmclient.h @@ -21,6 +21,7 @@ public: QString targetLanguage; QString translationPreset; QString customInstructions; + QString glossaryText; }; struct BatchResult { @@ -41,6 +42,7 @@ public: // 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 translate(QStringList lines, QStringList context, QString mediaTitle); + QCoro::Task extractGlossary(QStringList lines, QString mediaTitle); QCoro::Task listModels(); // Aborts every request in flight. diff --git a/src/translate/translationservice.cpp b/src/translate/translationservice.cpp index b592725..4d37f99 100644 --- a/src/translate/translationservice.cpp +++ b/src/translate/translationservice.cpp @@ -127,6 +127,48 @@ bool TranslationService::isFetchingModels() const return m_fetchingModels; } +QVariantList TranslationService::glossaryEntries() const +{ + QVariantList result; + for (const auto &entry : m_glossary.entries) result.append(QVariantMap{{u"source"_s, entry.source}, {u"target"_s, entry.target}}); + return result; +} + +QString TranslationService::glossaryDirectory() const +{ + return m_glossary.directory; +} + +void TranslationService::loadGlossary(const QUrl &videoUrl) +{ + const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString(); + m_glossary = directory.isEmpty() ? GlossaryStore::Glossary{} : GlossaryStore::load(directory); + Q_EMIT glossaryChanged(); +} + +void TranslationService::saveGlossary(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()}); + } + m_glossary.entries = result; + if (!m_glossary.directory.isEmpty()) { + GlossaryStore::save(m_glossary); + } + Q_EMIT glossaryChanged(); +} + +void TranslationService::clearGlossary() +{ + m_glossary.entries.clear(); + if (!m_glossary.directory.isEmpty()) { + GlossaryStore::save(m_glossary); + } + Q_EMIT glossaryChanged(); +} + void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle) { cancel(); @@ -172,6 +214,7 @@ QCoro::Task> TranslationService::loadConfi settings->targetLanguage().trimmed(), settings->translationPreset().trimmed(), settings->customInstructions(), + {}, }; if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) { @@ -220,7 +263,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) setBusy(true); setStatusText(i18n("Reading subtitles…")); - const auto [config, configError] = co_await loadConfig(); + auto [config, configError] = co_await loadConfig(); if (job->cancelled) { co_return; } @@ -247,15 +290,25 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) co_return; } - // Same subtitles, model, language, and prompt settings 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(config.translationPreset.toUtf8()); - hash.addData(config.customInstructions.toUtf8()); - hash.addData(Subtitles::toSrt(cues).toUtf8()); - const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage); + // 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 sourceSrt = Subtitles::toSrt(cues); + + // Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once. + const auto outputPathFor = [&](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 cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage); + }; + QString outputPath = outputPathFor(glossary); if (QFileInfo::exists(outputPath)) { m_job.reset(); @@ -265,6 +318,55 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) 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)}); + } + glossary = GlossaryStore::merge(glossary, additions); + GlossaryStore::save(glossary); + } else { + setStatusText(i18n("Glossary unavailable; translating without it.")); + } + outputPath = outputPathFor(glossary); + 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 uniqueIndex; @@ -278,6 +380,12 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) 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; diff --git a/src/translate/translationservice.h b/src/translate/translationservice.h index 72b1b56..7eada2a 100644 --- a/src/translate/translationservice.h +++ b/src/translate/translationservice.h @@ -2,6 +2,7 @@ #include "llmclient.h" #include "subtitleextractor.h" +#include "glossarystore.h" #include @@ -31,6 +32,8 @@ class TranslationService : public QObject Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(QVariantList translationPresets READ translationPresets CONSTANT) Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged) + Q_PROPERTY(QVariantList glossaryEntries READ glossaryEntries NOTIFY glossaryChanged) + Q_PROPERTY(QString glossaryDirectory READ glossaryDirectory NOTIFY glossaryChanged) public: explicit TranslationService(QObject *parent = nullptr); @@ -43,6 +46,8 @@ public: QStringList availableModels() const; QVariantList translationPresets() const; bool isFetchingModels() const; + QVariantList glossaryEntries() const; + QString glossaryDirectory() const; // \a track is an entry of MpvItem::subtitleTracks. Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle); @@ -50,6 +55,9 @@ public: // Asks the API configured in the settings for its models. Q_INVOKABLE void fetchModels(); Q_INVOKABLE void clearError(); + Q_INVOKABLE void loadGlossary(const QUrl &videoUrl); + Q_INVOKABLE void saveGlossary(const QVariantList &entries); + Q_INVOKABLE void clearGlossary(); Q_SIGNALS: void busyChanged(); @@ -58,6 +66,7 @@ Q_SIGNALS: void errorTextChanged(); void availableModelsChanged(); void fetchingModelsChanged(); + void glossaryChanged(); void translated(const QUrl &subtitleUrl, bool fromCache); private: @@ -91,4 +100,5 @@ private: QString m_errorText; QStringList m_availableModels; bool m_fetchingModels = false; + GlossaryStore::Glossary m_glossary; };