feat(translate): keep translated subtitles next to the video so they load on reopen (#14)
Co-authored-by: Tri Vo Khac <git@trivk.dev>
This commit was merged in pull request #14.
This commit is contained in:
1 parent
b34a01a92f
commit
76b12d6632
13 files changed
+716
-65
No files matched your search
@@ -3,6 +3,7 @@
|
||||
#include "apikeystore.h"
|
||||
#include "qtpotatosettings.h"
|
||||
#include "translationpresets.h"
|
||||
#include "translationstore.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
@@ -39,28 +40,9 @@ constexpr int ParallelRequests = 3;
|
||||
// Bump when the prompt changes so old cached translations are not reused.
|
||||
constexpr auto CacheVersion = "llm-v2";
|
||||
|
||||
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
|
||||
QString languageSuffix(const QString &language)
|
||||
QString cacheDir()
|
||||
{
|
||||
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
|
||||
const auto candidate = static_cast<QLocale::Language>(i);
|
||||
if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) {
|
||||
const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1);
|
||||
return code.isEmpty() ? QLocale::languageToCode(candidate) : code;
|
||||
}
|
||||
}
|
||||
QString suffix = language.toLower();
|
||||
suffix.removeIf([](QChar c) {
|
||||
return !c.isLetterOrNumber() && c != u'-';
|
||||
});
|
||||
return suffix.isEmpty() ? u"translated"_s : suffix;
|
||||
}
|
||||
|
||||
QString cachePath(const QUrl &videoUrl, const QString &key, const QString &language)
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
|
||||
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
|
||||
return u"%1/%2.%3.%4.srt"_s.arg(dir, baseName, key, languageSuffix(language));
|
||||
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
|
||||
}
|
||||
|
||||
QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries)
|
||||
@@ -273,6 +255,12 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
|
||||
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;
|
||||
@@ -319,10 +307,14 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
|
||||
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(text.toUtf8()) < 0 || !output.commit()) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -480,7 +472,8 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
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) {
|
||||
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());
|
||||
@@ -489,16 +482,13 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
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);
|
||||
return QString::fromLatin1(hash.result().toHex().left(12));
|
||||
};
|
||||
QString outputPath = outputPathFor(glossary);
|
||||
QString configKey = configKeyFor(glossary);
|
||||
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
m_lastTranslationPath = outputPath;
|
||||
m_job.reset();
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
|
||||
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
||||
co_return;
|
||||
}
|
||||
|
||||
@@ -545,13 +535,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
} else {
|
||||
setStatusText(i18n("Glossary unavailable; translating without it."));
|
||||
}
|
||||
outputPath = outputPathFor(glossary);
|
||||
configKey = configKeyFor(glossary);
|
||||
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
m_lastTranslationPath = outputPath;
|
||||
m_job.reset();
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
|
||||
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
||||
co_return;
|
||||
}
|
||||
}
|
||||
@@ -610,11 +597,38 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
co_return;
|
||||
}
|
||||
|
||||
finish(job, videoUrl, suffix, configKey, outputPath, false);
|
||||
}
|
||||
|
||||
void TranslationService::finish(const std::shared_ptr<Job> &job,
|
||||
const QUrl &videoUrl,
|
||||
const QString &suffix,
|
||||
const QString &configKey,
|
||||
const QString &cacheFile,
|
||||
bool fromCache)
|
||||
{
|
||||
QFile file(cacheFile);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
fail(job, i18n("Could not read the translation from %1: %2", cacheFile, file.errorString()));
|
||||
return;
|
||||
}
|
||||
// The cache keeps every version; the published copy is the one players load.
|
||||
const QString registryFile = TranslationStore::registryFile();
|
||||
TranslationStore::Registry registry = TranslationStore::load(registryFile);
|
||||
const TranslationStore::PublishResult published = TranslationStore::publish(registry, videoUrl, suffix, configKey, file.readAll(), cacheFile);
|
||||
TranslationStore::save(registryFile, registry);
|
||||
|
||||
m_job.reset();
|
||||
m_lastTranslationPath = outputPath;
|
||||
m_lastTranslationPath = published.path;
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), false);
|
||||
Q_EMIT translated(QUrl::fromLocalFile(published.path), fromCache);
|
||||
}
|
||||
|
||||
QString TranslationService::savedTranslation(const QUrl &videoUrl) const
|
||||
{
|
||||
const QString suffix = TranslationStore::languageSuffix(QtPotatoSettings::self()->targetLanguage().trimmed());
|
||||
return TranslationStore::savedTranslation(TranslationStore::load(TranslationStore::registryFile()), videoUrl, suffix);
|
||||
}
|
||||
|
||||
QCoro::Task<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context)
|
||||
|
||||
Reference in new issue
Block a user