969 lines
34 KiB
C++
969 lines
34 KiB
C++
#include "translationservice.h"
|
|
|
|
#include "apikeystore.h"
|
|
#include "mediainfo.h"
|
|
#include "qtpotatosettings.h"
|
|
#include "translationpresets.h"
|
|
#include "translationstore.h"
|
|
|
|
#include <KLocalizedString>
|
|
|
|
#include <QCoroFuture>
|
|
#include <QCoroTimer>
|
|
|
|
#include <QCryptographicHash>
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
#include <QHash>
|
|
#include <QLocale>
|
|
#include <QNetworkAccessManager>
|
|
#include <QPointer>
|
|
#include <QSaveFile>
|
|
#include <QStandardPaths>
|
|
#include <QFile>
|
|
#include <QSet>
|
|
#include <QtConcurrentRun>
|
|
|
|
#include <chrono>
|
|
#include <vector>
|
|
|
|
using namespace Qt::Literals::StringLiterals;
|
|
using namespace std::chrono_literals;
|
|
|
|
namespace
|
|
{
|
|
// Source lines before a batch that the model sees for context.
|
|
constexpr qsizetype ContextLines = 6;
|
|
constexpr int MaxAttempts = 3;
|
|
// Requests in flight at once. Batches only use source lines as context, so they don't
|
|
// depend on each other.
|
|
constexpr int ParallelRequests = 3;
|
|
// Bump when the prompt changes so old cached translations are not reused.
|
|
constexpr auto CacheVersion = "llm-v2";
|
|
|
|
QString cacheDir()
|
|
{
|
|
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
|
|
}
|
|
|
|
QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries)
|
|
{
|
|
QList<GlossaryStore::Entry> 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<GlossaryStore::Entry> &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<LlmClient> 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<QString, QString> oldTargets;
|
|
for (const auto &entry : previous.entries) {
|
|
oldTargets.insert(entry.source.trimmed(), entry.target.trimmed());
|
|
}
|
|
QList<QPair<QString, QString>> 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<QString> 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<Job>();
|
|
m_job->mediaTitle = mediaTitle;
|
|
run(m_job, videoUrl, track);
|
|
}
|
|
|
|
void TranslationService::translatePlaylist(const QList<QUrl> &urls, const QVariantMap ¤tTrack, const QString ¤tTitle)
|
|
{
|
|
cancel();
|
|
// Start with the episode playing, then the ones after it, which are watched next.
|
|
QList<QUrl> ordered;
|
|
const qsizetype current = std::max<qsizetype>(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<Job>();
|
|
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<std::pair<LlmClient::Config, QString>> TranslationService::loadConfig()
|
|
{
|
|
const auto settings = QtPotatoSettings::self();
|
|
LlmClient::Config config{
|
|
LlmClient::normalizedBaseUrl(settings->apiBaseUrl()),
|
|
{},
|
|
settings->model().trimmed(),
|
|
settings->targetLanguage().trimmed(),
|
|
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<Subtitles::ExtractResult> TranslationService::loadCues(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track)
|
|
{
|
|
if (track.value(u"external"_s).toBool()) {
|
|
const QString path = track.value(u"externalFilename"_s).toString();
|
|
co_return co_await QtConcurrent::run([path] {
|
|
QString error;
|
|
QList<SubtitleCue> cues = Subtitles::parseFile(path, &error);
|
|
return Subtitles::ExtractResult{cues, error};
|
|
});
|
|
}
|
|
|
|
if (!videoUrl.isLocalFile()) {
|
|
co_return Subtitles::ExtractResult{{}, i18n("Only subtitles of local files can be translated.")};
|
|
}
|
|
const int streamIndex = track.value(u"ffIndex"_s, -1).toInt();
|
|
if (streamIndex < 0) {
|
|
co_return Subtitles::ExtractResult{{}, i18n("mpv did not report where this subtitle track is stored.")};
|
|
}
|
|
|
|
const QString path = videoUrl.toLocalFile();
|
|
co_return co_await QtConcurrent::run([path, streamIndex, job] {
|
|
return Subtitles::extractEmbedded(path, streamIndex, job->cancelled);
|
|
});
|
|
}
|
|
|
|
QCoro::Task<std::optional<LlmClient::Config>> TranslationService::startJob(std::shared_ptr<Job> 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> job, QUrl videoUrl, QVariantMap track)
|
|
{
|
|
const std::optional<LlmClient::Config> 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> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle)
|
|
{
|
|
const std::optional<LlmClient::Config> 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::Outcome> TranslationService::translateVideo(std::shared_ptr<Job> 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<SubtitleCue> 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<QString, bool> 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<qsizetype>(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<LlmClient>(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<GlossaryStore::Entry> 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<QString, qsizetype> uniqueIndex;
|
|
QList<qsizetype> cueToUnique;
|
|
for (const SubtitleCue &cue : cues) {
|
|
auto it = uniqueIndex.constFind(cue.text);
|
|
if (it == uniqueIndex.cend()) {
|
|
it = uniqueIndex.insert(cue.text, uniqueLines.size());
|
|
uniqueLines.append(cue.text);
|
|
}
|
|
cueToUnique.append(*it);
|
|
}
|
|
|
|
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<LlmClient>(m_network, config);
|
|
auto batches = std::make_shared<Batches>();
|
|
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<QCoro::Task<>> workers;
|
|
for (int i = 0; i < ParallelRequests; ++i) {
|
|
workers.push_back(translateBatches(job, batches));
|
|
}
|
|
for (QCoro::Task<> &worker : workers) {
|
|
co_await std::move(worker);
|
|
}
|
|
if (job->cancelled) {
|
|
co_return Outcome{};
|
|
}
|
|
if (!batches->error.isEmpty()) {
|
|
co_return Outcome::failed(batches->error);
|
|
}
|
|
|
|
QList<SubtitleCue> output = cues;
|
|
for (qsizetype i = 0; i < output.size(); ++i) {
|
|
output[i].text = batches->translations.at(cueToUnique.at(i));
|
|
}
|
|
|
|
QDir().mkpath(QFileInfo(outputPath).absolutePath());
|
|
QSaveFile file(outputPath);
|
|
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
|
|
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> &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> &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<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context)
|
|
{
|
|
LlmClient::BatchResult result;
|
|
for (int attempt = 1; attempt <= MaxAttempts; ++attempt) {
|
|
result = co_await job->client->translate(lines, context, job->mediaTitle);
|
|
if (job->cancelled || result.error.isEmpty() || !result.retryable || attempt == MaxAttempts) {
|
|
break;
|
|
}
|
|
co_await QCoro::sleepFor(2s * attempt);
|
|
if (job->cancelled) {
|
|
co_return result;
|
|
}
|
|
}
|
|
if (job->cancelled || !result.error.isEmpty()) {
|
|
co_return result;
|
|
}
|
|
|
|
QList<qsizetype> missing;
|
|
for (qsizetype i = 0; i < result.translations.size(); ++i) {
|
|
if (result.translations.at(i).isNull()) {
|
|
missing.append(i);
|
|
}
|
|
}
|
|
if (missing.isEmpty()) {
|
|
co_return result;
|
|
}
|
|
if (lines.size() == 1) {
|
|
co_return LlmClient::BatchResult{{}, i18n("The model did not translate this line: %1", lines.first()), false};
|
|
}
|
|
|
|
if (missing.size() == lines.size()) {
|
|
// Nothing came back usable; smaller requests are easier for the model.
|
|
const qsizetype half = lines.size() / 2;
|
|
const LlmClient::BatchResult first = co_await translateLines(job, lines.first(half), context);
|
|
if (job->cancelled || !first.error.isEmpty()) {
|
|
co_return first;
|
|
}
|
|
QStringList secondContext = context + lines.first(half);
|
|
secondContext = secondContext.mid(std::max<qsizetype>(0, secondContext.size() - ContextLines));
|
|
const LlmClient::BatchResult second = co_await translateLines(job, lines.sliced(half), secondContext);
|
|
if (job->cancelled || !second.error.isEmpty()) {
|
|
co_return second;
|
|
}
|
|
co_return LlmClient::BatchResult{first.translations + second.translations, {}, false};
|
|
}
|
|
|
|
// Ask again only for the lines the model skipped.
|
|
QStringList retryLines;
|
|
for (const qsizetype i : missing) {
|
|
retryLines.append(lines.at(i));
|
|
}
|
|
const LlmClient::BatchResult retry = co_await translateLines(job, retryLines, context);
|
|
if (job->cancelled || !retry.error.isEmpty()) {
|
|
co_return retry;
|
|
}
|
|
for (qsizetype i = 0; i < missing.size(); ++i) {
|
|
result.translations[missing.at(i)] = retry.translations.at(i);
|
|
}
|
|
co_return result;
|
|
}
|
|
|
|
QCoro::Task<> TranslationService::translateBatches(std::shared_ptr<Job> job, std::shared_ptr<Batches> batches)
|
|
{
|
|
// After an error, requests already sent may finish, but no new ones start.
|
|
while (!job->cancelled && batches->error.isEmpty() && batches->nextStart < batches->lines.size()) {
|
|
const qsizetype start = batches->nextStart;
|
|
batches->nextStart += batches->batchSize;
|
|
|
|
const qsizetype contextStart = std::max<qsizetype>(0, start - ContextLines);
|
|
const LlmClient::BatchResult result =
|
|
co_await translateLines(job, batches->lines.mid(start, batches->batchSize), batches->lines.mid(contextStart, start - contextStart));
|
|
if (job->cancelled) {
|
|
co_return;
|
|
}
|
|
if (!result.error.isEmpty()) {
|
|
if (batches->error.isEmpty()) {
|
|
batches->error = result.error;
|
|
}
|
|
co_return;
|
|
}
|
|
|
|
for (qsizetype i = 0; i < result.translations.size(); ++i) {
|
|
batches->translations[start + i] = result.translations.at(i);
|
|
}
|
|
batches->doneLines += result.translations.size();
|
|
setJobProgress(*job, double(batches->doneLines) / batches->lines.size());
|
|
setLinesStatus(*job, batches->doneLines, batches->lines.size());
|
|
}
|
|
}
|
|
|
|
QCoro::Task<> TranslationService::runFetchModels()
|
|
{
|
|
QPointer<TranslationService> self(this);
|
|
setFetchingModels(true);
|
|
setErrorText({});
|
|
|
|
const auto [config, configError] = co_await loadConfig();
|
|
if (!self) {
|
|
co_return;
|
|
}
|
|
if (!configError.isEmpty()) {
|
|
setErrorText(configError);
|
|
setFetchingModels(false);
|
|
co_return;
|
|
}
|
|
|
|
LlmClient client(m_network, config);
|
|
const LlmClient::ModelsResult result = co_await client.listModels();
|
|
if (!self) {
|
|
co_return;
|
|
}
|
|
if (!result.error.isEmpty()) {
|
|
setErrorText(i18n("Could not list models: %1", result.error));
|
|
} else if (result.models != m_availableModels) {
|
|
m_availableModels = result.models;
|
|
Q_EMIT availableModelsChanged();
|
|
}
|
|
setFetchingModels(false);
|
|
}
|
|
|
|
void TranslationService::fail(const std::shared_ptr<Job> &job, const QString &error)
|
|
{
|
|
if (m_job != job) {
|
|
return;
|
|
}
|
|
m_job.reset();
|
|
setErrorText(error);
|
|
setBusy(false);
|
|
setStatusText({});
|
|
}
|
|
|
|
void TranslationService::setBusy(bool busy)
|
|
{
|
|
if (busy != m_busy) {
|
|
m_busy = busy;
|
|
Q_EMIT busyChanged();
|
|
}
|
|
}
|
|
|
|
void TranslationService::setProgress(double progress)
|
|
{
|
|
if (progress != m_progress) {
|
|
m_progress = progress;
|
|
Q_EMIT progressChanged();
|
|
}
|
|
}
|
|
|
|
void TranslationService::setStatusText(const QString &text)
|
|
{
|
|
if (text != m_statusText) {
|
|
m_statusText = text;
|
|
Q_EMIT statusTextChanged();
|
|
}
|
|
}
|
|
|
|
void TranslationService::setErrorText(const QString &text)
|
|
{
|
|
if (text != m_errorText) {
|
|
m_errorText = text;
|
|
Q_EMIT errorTextChanged();
|
|
}
|
|
}
|
|
|
|
void TranslationService::setFetchingModels(bool fetching)
|
|
{
|
|
if (fetching != m_fetchingModels) {
|
|
m_fetchingModels = fetching;
|
|
Q_EMIT fetchingModelsChanged();
|
|
}
|
|
}
|