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 23899cf..894f5e9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -34,10 +34,14 @@ 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 translate/translationservice.h + translate/translationpresets.cpp + translate/translationpresets.h ) kconfig_target_kcfg_file(qtpotato 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 e78c2e6..e097cbf 100644 --- a/src/qml/TranslationSettings.qml +++ b/src/qml/TranslationSettings.qml @@ -145,6 +145,58 @@ SettingsBasePage { onEditingFinished: QtPotatoSettings.save() } + Label { + text: KI18n.i18nc("@label:listbox", "Preset") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + } + + ColumnLayout { + Layout.fillWidth: true + + ComboBox { + id: presetCombo + Layout.fillWidth: true + model: root.translator.translationPresets + textRole: "name" + valueRole: "id" + Component.onCompleted: currentIndex = Math.max(0, indexOfValue(QtPotatoSettings.translationPreset)) + onActivated: { + QtPotatoSettings.translationPreset = currentValue + QtPotatoSettings.save() + } + } + + Label { + text: presetCombo.currentIndex >= 0 ? presetCombo.model[presetCombo.currentIndex].guidance : "" + wrapMode: Text.WordWrap + opacity: 0.7 + Layout.fillWidth: true + } + } + + Label { + text: KI18n.i18nc("@label:textbox", "Additional instructions") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + } + + TextArea { + text: QtPotatoSettings.customInstructions + placeholderText: KI18n.i18nc("@info:placeholder", "e.g. Translate 'Young Master' as 'Thiếu gia'. Use 'ta/ngươi' between enemies.") + wrapMode: TextArea.Wrap + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 4 + // TextArea has no textEdited before Qt 6.9. + onTextChanged: QtPotatoSettings.customInstructions = text + 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 0ee2dbb..b537f37 100644 --- a/src/settings/qtpotatosettings.kcfg +++ b/src/settings/qtpotatosettings.kcfg @@ -122,6 +122,18 @@ Vietnamese + + + general + + + + + + + + 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 c1557bb..d0bf109 100644 --- a/src/translate/llmclient.cpp +++ b/src/translate/llmclient.cpp @@ -1,5 +1,7 @@ #include "llmclient.h" +#include "translationpresets.h" + #include #include @@ -28,19 +30,33 @@ struct ReplyDeleter { }; using ReplyPtr = std::unique_ptr; -QString systemPrompt(const QString &language) +QString systemPrompt(const QString &language, const QString &presetId, const QString &glossaryText, const QString &customInstructions) { - return uR"(You are a professional subtitle translator. Translate subtitles into %1. + const auto &preset = TranslationPresets::find(presetId); + QString prompt = 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. +Translate every entry of "lines" into natural, spoken %1 that fits the scene and the speaker. 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); +Style guidance: +%2 + +)"_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(); + } + prompt += u"\n\nReply with only a JSON object that maps every id to its translation, for example {\"1\": \"...\", \"2\": \"...\"}. No explanations, no Markdown."_s; + return prompt; } // Pulls the error message out of an OpenAI-style error body, falling back to the raw text. @@ -137,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)}}, + 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))}}, }}, }; @@ -180,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 d860578..c066d6c 100644 --- a/src/translate/llmclient.h +++ b/src/translate/llmclient.h @@ -19,6 +19,9 @@ public: QString apiKey; QString model; QString targetLanguage; + QString translationPreset; + QString customInstructions; + QString glossaryText; }; struct BatchResult { @@ -39,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/translationpresets.cpp b/src/translate/translationpresets.cpp new file mode 100644 index 0000000..1a56ac9 --- /dev/null +++ b/src/translate/translationpresets.cpp @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "translationpresets.h" + +#include + +using namespace Qt::Literals::StringLiterals; + +namespace TranslationPresets +{ +const QList &all() +{ + static const QList presets{ + { + u"general"_s, + i18nc("@item:inlistbox translation preset", "General"), + u"Keep names as they are conventionally rendered and use a natural, conversational tone appropriate to the scene."_s, + }, + { + u"chinese-donghua"_s, + i18nc("@item:inlistbox translation preset", "Chinese animation / wuxia / xianxia"), + u"For Chinese animation, wuxia, xianxia, and historical settings, the source is often an English subtitle with pinyin names. When translating into Vietnamese, convert Chinese personal names, place names, sect and clan names, techniques, and cultivation terms into their Sino-Vietnamese (Hán-Việt) forms (for example, Xiao Yan → Tiêu Viêm, Dou Qi → Đấu Khí, Sect Master → Tông chủ). For other target languages, use that language's conventional rendering. Be consistent and prefer well-known established renderings from famous works. When translating into Vietnamese, use period-appropriate forms of address such as ta/ngươi, huynh/đệ, sư phụ, and tiền bối."_s, + }, + { + u"japanese-anime"_s, + i18nc("@item:inlistbox translation preset", "Japanese anime"), + u"Keep Japanese names in romaji. Keep honorifics such as -san, -kun, and -senpai as-is. Use a natural, casual tone."_s, + }, + { + u"korean-drama"_s, + i18nc("@item:inlistbox translation preset", "Korean drama"), + u"Keep Korean names. Use modern forms of address that fit the relationships; when translating into Vietnamese, use forms such as anh/em, and oppa may be kept when appropriate."_s, + }, + { + u"western"_s, + i18nc("@item:inlistbox translation preset", "Western"), + u"Keep names. Use a neutral modern tone and adapt idioms naturally for the target language."_s, + }, + { + u"documentary"_s, + i18nc("@item:inlistbox translation preset", "Documentary"), + u"Keep names. Use a formal, precise tone and translate terminology accurately."_s, + }, + }; + return presets; +} + +const Preset &find(const QString &id) +{ + const auto &presets = all(); + for (const Preset &preset : presets) { + if (preset.id == id) { + return preset; + } + } + return presets.constFirst(); +} +} diff --git a/src/translate/translationpresets.h b/src/translate/translationpresets.h new file mode 100644 index 0000000..7ac9ae6 --- /dev/null +++ b/src/translate/translationpresets.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace TranslationPresets +{ +struct Preset { + QString id; + QString name; + QString guidance; +}; + +const QList &all(); +const Preset &find(const QString &id); +} diff --git a/src/translate/translationservice.cpp b/src/translate/translationservice.cpp index 1a976e7..4d37f99 100644 --- a/src/translate/translationservice.cpp +++ b/src/translate/translationservice.cpp @@ -2,6 +2,7 @@ #include "apikeystore.h" #include "qtpotatosettings.h" +#include "translationpresets.h" #include @@ -34,7 +35,7 @@ constexpr int MaxAttempts = 3; // 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"; +constexpr auto CacheVersion = "llm-v2"; // "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect. QString languageSuffix(const QString &language) @@ -112,11 +113,62 @@ 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"guidance"_s, preset.guidance}}); + } + return result; +} + 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(); @@ -160,6 +212,9 @@ QCoro::Task> TranslationService::loadConfi {}, settings->model().trimmed(), settings->targetLanguage().trimmed(), + settings->translationPreset().trimmed(), + settings->customInstructions(), + {}, }; if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) { @@ -208,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; } @@ -235,13 +290,25 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) 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); + // 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(); @@ -251,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; @@ -264,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 0b0795a..7eada2a 100644 --- a/src/translate/translationservice.h +++ b/src/translate/translationservice.h @@ -2,12 +2,14 @@ #include "llmclient.h" #include "subtitleextractor.h" +#include "glossarystore.h" #include #include #include #include +#include #include #include @@ -28,7 +30,10 @@ class TranslationService : public QObject 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(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); @@ -39,7 +44,10 @@ public: QString statusText() const; QString errorText() const; 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); @@ -47,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(); @@ -55,6 +66,7 @@ Q_SIGNALS: void errorTextChanged(); void availableModelsChanged(); void fetchingModelsChanged(); + void glossaryChanged(); void translated(const QUrl &subtitleUrl, bool fromCache); private: @@ -88,4 +100,5 @@ private: QString m_errorText; QStringList m_availableModels; bool m_fetchingModels = false; + GlossaryStore::Glossary m_glossary; };