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

+5
View File
@@ -24,3 +24,8 @@ ecm_add_test(trackselectiontest.cpp
TEST_NAME trackselectiontest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
ecm_add_test(translationstoretest.cpp
TEST_NAME translationstoretest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
+13
View File
@@ -67,6 +67,19 @@ private Q_SLOTS:
QCOMPARE(TrackSelection::pickSubtitle(tracks, {u"de"_s}, {u"en"_s}, 1, true), 1);
}
// A saved translation is turned on when the file opens, over mpv's choice and the
// fallback, but not over a subtitle in a preferred language.
void picksSavedTranslation()
{
const QList<Track> tracks{sub(1, u"rus"_s), sub(2, u"rus"_s, u"subrip"_s, false, true), sub(3, u"eng"_s), sub(4, u"vi"_s)};
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, true, 4), 4);
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 2, true, 4), 4);
QCOMPARE(TrackSelection::pickSubtitle(tracks, {u"en"_s}, {u"en"_s}, 0, true, 4), 3);
// Automatic subtitles turned off: keep mpv's choice.
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, false, 4), 0);
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, true, 0), 3);
}
void picksAudioByPreference()
{
const QList<Track> tracks{sub(1, u"en"_s, u"aac"_s), sub(2, u"jpn"_s, u"aac"_s)};
+209
View File
@@ -0,0 +1,209 @@
#include "translationstore.h"
#include <QFile>
#include <QTemporaryDir>
#include <QTest>
using namespace Qt::Literals::StringLiterals;
namespace
{
void writeText(const QString &path, const QByteArray &data)
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(data);
}
QByteArray readText(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
}
class TranslationStoreTest : public QObject
{
Q_OBJECT
private:
QTemporaryDir m_dir;
QString m_videos;
QString m_cache;
QString m_video;
QUrl m_videoUrl;
// The translation as TranslationService leaves it in the cache before publishing.
QString cached(const QString &key, const QByteArray &data)
{
const QString path = TranslationStore::cachePath(m_cache, m_videoUrl, key, u"vi"_s);
writeText(path, data);
return path;
}
private Q_SLOTS:
void init()
{
QVERIFY(m_dir.isValid());
QDir root(m_dir.path());
root.removeRecursively();
QVERIFY(root.mkpath(u"videos"_s));
QVERIFY(root.mkpath(u"cache"_s));
m_videos = m_dir.filePath(u"videos"_s);
m_cache = m_dir.filePath(u"cache"_s);
m_video = m_videos + u"/Show.S01E01.mkv"_s;
writeText(m_video, "video");
m_videoUrl = QUrl::fromLocalFile(m_video);
}
void namesFiles()
{
QCOMPARE(TranslationStore::languageSuffix(u"Vietnamese"_s), u"vi"_s);
QCOMPARE(TranslationStore::languageSuffix(u"vietnamese"_s), u"vi"_s);
QCOMPARE(TranslationStore::languageSuffix(u"Klingon!"_s), u"klingon"_s);
QCOMPARE(TranslationStore::sidecarPaths(u"/films/Show.S01E01.mkv"_s, u"vi"_s),
QStringList({u"/films/Show.S01E01.vi.srt"_s, u"/films/Show.S01E01.qtpotato.vi.srt"_s}));
QCOMPARE(TranslationStore::cachePath(u"/cache"_s, QUrl::fromLocalFile(u"/films/Show.S01E01.mkv"_s), u"abc"_s, u"vi"_s),
u"/cache/Show.S01E01.abc.vi.srt"_s);
QCOMPARE(TranslationStore::cachePath(u"/cache"_s, QUrl(u"https://example.com/live.m3u8"_s), u"abc"_s, u"vi"_s),
u"/cache/subtitles.abc.vi.srt"_s);
QCOMPARE(TranslationStore::videoKey(QUrl(u"https://example.com/live.m3u8"_s)), u"https://example.com/live.m3u8"_s);
}
void writesNextToVideo()
{
TranslationStore::Registry registry;
const QString cache = cached(u"k1"_s, "one");
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cache);
const QString sidecar = m_videos + u"/Show.S01E01.vi.srt"_s;
QCOMPARE(result.path, sidecar);
QVERIFY(result.nextToVideo);
QCOMPARE(readText(sidecar), "one");
QVERIFY(TranslationStore::isOwned(registry, sidecar));
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), sidecar);
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"ja"_s), QString());
QCOMPARE(registry.find(TranslationStore::videoKey(m_videoUrl), u"vi"_s)->configKey, u"k1"_s);
}
// Different settings give a new translation, which replaces QtPotato's own file.
void replacesOwnFileWithNewSettings()
{
TranslationStore::Registry registry;
TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
QCOMPARE(result.path, m_videos + u"/Show.S01E01.vi.srt"_s);
QCOMPARE(readText(result.path), "two");
QCOMPARE(registry.records.size(), 1);
QCOMPARE(registry.records.constFirst().configKey, u"k2"_s);
QVERIFY(!QFile::exists(m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s));
}
void keepsUserSubtitle()
{
const QString userFile = m_videos + u"/Show.S01E01.vi.srt"_s;
writeText(userFile, "mine");
TranslationStore::Registry registry;
QVERIFY(!TranslationStore::isOwned(registry, userFile));
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
QCOMPARE(result.path, m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s);
QCOMPARE(readText(userFile), "mine");
QCOMPARE(readText(result.path), "one");
}
// A file QtPotato wrote and the user then edited counts as the user's.
void keepsEditedTranslation()
{
TranslationStore::Registry registry;
const auto first = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
writeText(first.path, "one, fixed by hand");
QVERIFY(!TranslationStore::isOwned(registry, first.path));
const auto second = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
QCOMPARE(second.path, m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s);
QCOMPARE(readText(first.path), "one, fixed by hand");
}
// Once on ".qtpotato.vi.srt", stay there instead of leaving a second copy behind.
void staysOnPreviousFile()
{
const QString userFile = m_videos + u"/Show.S01E01.vi.srt"_s;
writeText(userFile, "mine");
TranslationStore::Registry registry;
const auto first = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
QFile::remove(userFile);
const auto second = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
QCOMPARE(second.path, first.path);
QCOMPARE(readText(second.path), "two");
QVERIFY(!QFile::exists(userFile));
}
void fallsBackToCacheWhenFolderIsReadOnly()
{
QFile::setPermissions(m_videos, QFileDevice::ReadOwner | QFileDevice::ExeOwner);
auto restore = qScopeGuard([this] {
QFile::setPermissions(m_videos, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
});
if (QFileInfo(m_videos).isWritable()) {
QSKIP("Permissions are not enforced here (running as root?)");
}
TranslationStore::Registry registry;
const QString cache = cached(u"k1"_s, "one");
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cache);
QCOMPARE(result.path, cache);
QVERIFY(!result.nextToVideo);
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), cache);
}
void fallsBackToCacheForStreams()
{
const QUrl stream(u"https://example.com/live.m3u8"_s);
const QString cache = TranslationStore::cachePath(m_cache, stream, u"k1"_s, u"vi"_s);
writeText(cache, "one");
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, stream, u"vi"_s, u"k1"_s, "one", cache);
QCOMPARE(result.path, cache);
QCOMPARE(TranslationStore::savedTranslation(registry, stream, u"vi"_s), cache);
}
void glossaryRewriteKeepsOwnership()
{
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
writeText(result.path, "one, new glossary");
TranslationStore::recordRewrite(registry, result.path, "one, new glossary");
QVERIFY(TranslationStore::isOwned(registry, result.path));
}
void savesAndLoadsRegistry()
{
const QString file = m_dir.filePath(u"data/translations.json"_s);
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
// A record whose file is gone is not worth keeping.
registry.put({u"/gone.mkv"_s, u"vi"_s, u"/gone.vi.srt"_s, u"k0"_s, u"00"_s});
QVERIFY(TranslationStore::save(file, registry));
const TranslationStore::Registry loaded = TranslationStore::load(file);
QCOMPARE(loaded.records.size(), 1);
QVERIFY(TranslationStore::isOwned(loaded, result.path));
QCOMPARE(TranslationStore::savedTranslation(loaded, m_videoUrl, u"vi"_s), result.path);
QVERIFY(TranslationStore::load(m_dir.filePath(u"missing.json"_s)).records.isEmpty());
}
// The registry is no longer right about a file that disappeared.
void forgetsDeletedTranslation()
{
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
QFile::remove(result.path);
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), QString());
}
};
QTEST_GUILESS_MAIN(TranslationStoreTest)
#include "translationstoretest.moc"