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:
tri authored and tri committed 2026-11-13 22:49:56 +07:00
1 parent b34a01a92f
commit 76b12d6632
13 files changed
+716 -65

No files matched your search

+231
View File
@@ -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 &registry, 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 &registry, 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 &registry,
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 &registry, 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 &registry, const QUrl &videoUrl, const QString &suffix)
{
const auto record = registry.find(videoKey(videoUrl), suffix);
return record && QFileInfo::exists(record->path) ? record->path : QString();
}
}