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)
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
// Translates a whole subtitle track with an LLM, batch by batch, and caches the result
|
||||
// as an .srt file that mpv can load as a subtitle track.
|
||||
// Translates a whole subtitle track with an LLM, batch by batch, caches the result as an
|
||||
// .srt file and saves a copy next to the video (see TranslationStore).
|
||||
class TranslationService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -62,6 +62,9 @@ public:
|
||||
Q_INVOKABLE void fetchModels();
|
||||
Q_INVOKABLE void clearError();
|
||||
Q_INVOKABLE void forgetTranslation();
|
||||
// Path of the translation of \a videoUrl into the target language set in Settings,
|
||||
// or an empty string when there is none.
|
||||
Q_INVOKABLE QString savedTranslation(const QUrl &videoUrl) const;
|
||||
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
|
||||
Q_INVOKABLE bool saveGlossary(const QVariantList &entries, const QStringList &episodes = {});
|
||||
Q_INVOKABLE void clearGlossary();
|
||||
@@ -97,6 +100,8 @@ private:
|
||||
// something is missing.
|
||||
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
|
||||
void fail(const std::shared_ptr<Job> &job, const QString &error);
|
||||
// Saves the translation in \a cacheFile where players find it and announces it.
|
||||
void finish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache);
|
||||
|
||||
void setBusy(bool busy);
|
||||
void setProgress(double progress);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
#include "translationstore.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLocale>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int RegistryVersion = 1;
|
||||
|
||||
QString normalized(const QString &path)
|
||||
{
|
||||
return path.isEmpty() ? QString() : QFileInfo(path).absoluteFilePath();
|
||||
}
|
||||
|
||||
std::optional<QByteArray> readFile(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
bool writeFile(const QString &path, const QByteArray &data)
|
||||
{
|
||||
// No direct-write fallback: a folder that can't take a temporary file is treated
|
||||
// as read-only, and the translation stays in the cache.
|
||||
QSaveFile file(path);
|
||||
return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() && file.commit();
|
||||
}
|
||||
}
|
||||
|
||||
namespace TranslationStore
|
||||
{
|
||||
QString languageSuffix(const QString &language)
|
||||
{
|
||||
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 videoKey(const QUrl &videoUrl)
|
||||
{
|
||||
return videoUrl.isLocalFile() ? normalized(videoUrl.toLocalFile()) : videoUrl.toString();
|
||||
}
|
||||
|
||||
QString cachePath(const QString &cacheDir, const QUrl &videoUrl, const QString &configKey, const QString &suffix)
|
||||
{
|
||||
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
|
||||
return u"%1/%2.%3.%4.srt"_s.arg(cacheDir, baseName, configKey, suffix);
|
||||
}
|
||||
|
||||
QStringList sidecarPaths(const QString &videoPath, const QString &suffix)
|
||||
{
|
||||
const QFileInfo info(videoPath);
|
||||
const QString stem = info.absolutePath() + u'/' + info.completeBaseName();
|
||||
return {u"%1.%2.srt"_s.arg(stem, suffix), u"%1.qtpotato.%2.srt"_s.arg(stem, suffix)};
|
||||
}
|
||||
|
||||
QByteArray contentHash(const QByteArray &data)
|
||||
{
|
||||
return QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
std::optional<Record> Registry::find(const QString &video, const QString &language) const
|
||||
{
|
||||
for (const Record &record : records) {
|
||||
if (record.video == video && record.language == language) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Record> Registry::findPath(const QString &path) const
|
||||
{
|
||||
const QString wanted = normalized(path);
|
||||
for (const Record &record : records) {
|
||||
if (record.path == wanted) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void Registry::put(const Record &record)
|
||||
{
|
||||
Record stored = record;
|
||||
stored.path = normalized(record.path);
|
||||
for (Record &existing : records) {
|
||||
if (existing.video == stored.video && existing.language == stored.language) {
|
||||
existing = stored;
|
||||
return;
|
||||
}
|
||||
}
|
||||
records.append(stored);
|
||||
}
|
||||
|
||||
Registry load(const QString &file)
|
||||
{
|
||||
Registry registry;
|
||||
const auto data = readFile(file);
|
||||
if (!data) {
|
||||
return registry;
|
||||
}
|
||||
const QJsonObject root = QJsonDocument::fromJson(*data).object();
|
||||
for (const QJsonValue &value : root.value(u"translations"_s).toArray()) {
|
||||
const QJsonObject object = value.toObject();
|
||||
Record record{object.value(u"video"_s).toString(),
|
||||
object.value(u"language"_s).toString(),
|
||||
object.value(u"path"_s).toString(),
|
||||
object.value(u"configKey"_s).toString(),
|
||||
object.value(u"contentHash"_s).toString()};
|
||||
if (!record.video.isEmpty() && !record.language.isEmpty() && !record.path.isEmpty()) {
|
||||
registry.put(record);
|
||||
}
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
bool save(const QString &file, const Registry ®istry, QString *error)
|
||||
{
|
||||
QJsonArray translations;
|
||||
for (const Record &record : registry.records) {
|
||||
if (!QFileInfo::exists(record.path)) {
|
||||
continue;
|
||||
}
|
||||
translations.append(QJsonObject{{u"video"_s, record.video},
|
||||
{u"language"_s, record.language},
|
||||
{u"path"_s, record.path},
|
||||
{u"configKey"_s, record.configKey},
|
||||
{u"contentHash"_s, record.contentHash}});
|
||||
}
|
||||
const QJsonObject root{{u"version"_s, RegistryVersion}, {u"translations"_s, translations}};
|
||||
|
||||
QDir().mkpath(QFileInfo(file).absolutePath());
|
||||
QSaveFile output(file);
|
||||
if (!output.open(QIODevice::WriteOnly) || output.write(QJsonDocument(root).toJson()) < 0 || !output.commit()) {
|
||||
if (error) {
|
||||
*error = output.errorString();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QString registryFile()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/translations.json"_s;
|
||||
}
|
||||
|
||||
bool isOwned(const Registry ®istry, const QString &path)
|
||||
{
|
||||
const auto record = registry.findPath(path);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
const auto data = readFile(path);
|
||||
return data && contentHash(*data) == record->contentHash.toLatin1();
|
||||
}
|
||||
|
||||
PublishResult publish(Registry ®istry,
|
||||
const QUrl &videoUrl,
|
||||
const QString &suffix,
|
||||
const QString &configKey,
|
||||
const QByteArray &data,
|
||||
const QString &cacheFile)
|
||||
{
|
||||
const QString video = videoKey(videoUrl);
|
||||
const QByteArray hash = contentHash(data);
|
||||
PublishResult result{normalized(cacheFile), false};
|
||||
|
||||
if (videoUrl.isLocalFile()) {
|
||||
QStringList candidates = sidecarPaths(videoUrl.toLocalFile(), suffix);
|
||||
// Keep using the file written last time, so a translation does not move to
|
||||
// ".vi.srt" and leave its ".qtpotato.vi.srt" copy behind.
|
||||
if (const auto previous = registry.find(video, suffix);
|
||||
previous && candidates.contains(previous->path) && isOwned(registry, previous->path)) {
|
||||
candidates.removeAll(previous->path);
|
||||
candidates.prepend(previous->path);
|
||||
}
|
||||
for (const QString &path : std::as_const(candidates)) {
|
||||
if (QFileInfo::exists(path) && !isOwned(registry, path)) {
|
||||
continue;
|
||||
}
|
||||
const auto current = readFile(path);
|
||||
if ((current && contentHash(*current) == hash) || writeFile(path, data)) {
|
||||
result = {path, true};
|
||||
}
|
||||
// Both names are in the same folder; when one can't be written, neither can.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
registry.put({video, suffix, result.path, configKey, QString::fromLatin1(hash)});
|
||||
return result;
|
||||
}
|
||||
|
||||
void recordRewrite(Registry ®istry, const QString &path, const QByteArray &data)
|
||||
{
|
||||
if (auto record = registry.findPath(path)) {
|
||||
record->contentHash = QString::fromLatin1(contentHash(data));
|
||||
registry.put(*record);
|
||||
}
|
||||
}
|
||||
|
||||
QString savedTranslation(const Registry ®istry, const QUrl &videoUrl, const QString &suffix)
|
||||
{
|
||||
const auto record = registry.find(videoKey(videoUrl), suffix);
|
||||
return record && QFileInfo::exists(record->path) ? record->path : QString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <optional>
|
||||
|
||||
// Where translated subtitles live. A translation is written next to the video as
|
||||
// "<video>.<lang>.srt" so mpv and other players load it, and recorded in a registry so
|
||||
// QtPotato knows which files it wrote, with which settings, and can find the copy it
|
||||
// had to keep in the cache when the video's folder was read-only or the video remote.
|
||||
namespace TranslationStore
|
||||
{
|
||||
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
|
||||
QString languageSuffix(const QString &language);
|
||||
|
||||
// The registry's name for a video: its absolute path, or the URL of a stream.
|
||||
QString videoKey(const QUrl &videoUrl);
|
||||
|
||||
// "<cacheDir>/<video basename>.<configKey>.<suffix>.srt". Every settings combination
|
||||
// gets its own file, so going back to earlier settings needs no new translation.
|
||||
QString cachePath(const QString &cacheDir, const QUrl &videoUrl, const QString &configKey, const QString &suffix);
|
||||
|
||||
// Files next to \a videoPath to try, in order: "<base>.<suffix>.srt", then
|
||||
// "<base>.qtpotato.<suffix>.srt" for when the user has a subtitle of their own there.
|
||||
QStringList sidecarPaths(const QString &videoPath, const QString &suffix);
|
||||
|
||||
QByteArray contentHash(const QByteArray &data);
|
||||
|
||||
struct Record {
|
||||
QString video;
|
||||
QString language;
|
||||
QString path;
|
||||
// Hash of the source subtitle and the settings it was translated with.
|
||||
QString configKey;
|
||||
// Hash of the file as QtPotato last wrote it. A file that no longer matches was
|
||||
// changed by someone else and is not overwritten.
|
||||
QString contentHash;
|
||||
};
|
||||
|
||||
// One record per video and language.
|
||||
struct Registry {
|
||||
QList<Record> records;
|
||||
|
||||
std::optional<Record> find(const QString &video, const QString &language) const;
|
||||
std::optional<Record> findPath(const QString &path) const;
|
||||
void put(const Record &record);
|
||||
};
|
||||
|
||||
Registry load(const QString &file);
|
||||
// Records whose file is gone are dropped.
|
||||
bool save(const QString &file, const Registry ®istry, QString *error = nullptr);
|
||||
// ~/.local/share/qtpotato/translations.json
|
||||
QString registryFile();
|
||||
|
||||
// True when \a path holds a translation QtPotato wrote and nobody changed since.
|
||||
bool isOwned(const Registry ®istry, const QString &path);
|
||||
|
||||
struct PublishResult {
|
||||
QString path;
|
||||
bool nextToVideo = false;
|
||||
};
|
||||
|
||||
// Puts \a data, the translation of \a videoUrl already saved at \a cacheFile, where
|
||||
// players find it: next to the video when the folder takes it, without touching a
|
||||
// subtitle QtPotato did not write, otherwise \a cacheFile. Records the result.
|
||||
PublishResult publish(Registry ®istry,
|
||||
const QUrl &videoUrl,
|
||||
const QString &suffix,
|
||||
const QString &configKey,
|
||||
const QByteArray &data,
|
||||
const QString &cacheFile);
|
||||
|
||||
// After QtPotato edited one of its files (glossary changes), keeps it marked as its own.
|
||||
void recordRewrite(Registry ®istry, const QString &path, const QByteArray &data);
|
||||
|
||||
// The saved translation of \a videoUrl into \a suffix that still exists, or an empty
|
||||
// string. Also the check for "has this video been translated yet".
|
||||
QString savedTranslation(const Registry ®istry, const QUrl &videoUrl, const QString &suffix);
|
||||
}
|
||||
Reference in new issue
Block a user