diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 8b350d8..b93c93c 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -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 +) diff --git a/autotests/trackselectiontest.cpp b/autotests/trackselectiontest.cpp index daff88f..7db7c50 100644 --- a/autotests/trackselectiontest.cpp +++ b/autotests/trackselectiontest.cpp @@ -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 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 tracks{sub(1, u"en"_s, u"aac"_s), sub(2, u"jpn"_s, u"aac"_s)}; diff --git a/autotests/translationstoretest.cpp b/autotests/translationstoretest.cpp new file mode 100644 index 0000000..8b2fd11 --- /dev/null +++ b/autotests/translationstoretest.cpp @@ -0,0 +1,209 @@ +#include "translationstore.h" + +#include +#include +#include + +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" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index da3aad5..cb70a00 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,5 +1,5 @@ -# Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests -# can link it. +# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept +# apart from the app so autotests can link it. add_library(qtpotato_core STATIC mpv/mpvuserconfig.cpp mpv/mpvuserconfig.h @@ -9,8 +9,10 @@ add_library(qtpotato_core STATIC subtitles/subtitleparser.h subtitles/subtitleextractor.cpp subtitles/subtitleextractor.h + translate/translationstore.cpp + translate/translationstore.h ) -target_include_directories(qtpotato_core PUBLIC mpv subtitles) +target_include_directories(qtpotato_core PUBLIC mpv subtitles translate) target_link_libraries(qtpotato_core PUBLIC Qt6::Core KF6::I18n diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp index fd6e409..f259962 100644 --- a/src/mpv/mpvitem.cpp +++ b/src/mpv/mpvitem.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -44,6 +45,38 @@ QList selectionTracks(const QVariantList &tracks) return result; } +QString subtitlePath(const QVariantList &tracks, int id) +{ + for (const QVariant &entry : tracks) { + const QVariantMap track = entry.toMap(); + if (track.value(QStringLiteral("id")).toInt() == id) { + return track.value(QStringLiteral("externalFilename")).toString(); + } + } + return {}; +} + +int subtitleIdForPath(const QVariantList &tracks, const QString &path) +{ + if (path.isEmpty()) { + return 0; + } + for (const QVariant &entry : tracks) { + const QVariantMap track = entry.toMap(); + if (track.value(QStringLiteral("externalFilename")).toString() == path) { + return track.value(QStringLiteral("id")).toInt(); + } + } + return 0; +} + +// "movie.abc123.vi.srt" -> "vi", the way mpv tags subtitles it finds next to a video. +QString filenameLanguage(const QString &path) +{ + const QStringList parts = QFileInfo(path).completeBaseName().split(QLatin1Char('.')); + return parts.size() > 1 ? parts.constLast() : QString(); +} + QString trackLanguage(const QVariantList &tracks, int id) { for (const QVariant &entry : tracks) { @@ -112,7 +145,7 @@ MpvItem::MpvItem(QQuickItem *parent) // Before the first file, so its options apply from the start. applyUserConfig(); if (m_pendingUrl.isValid()) { - loadFile(std::exchange(m_pendingUrl, {})); + loadFile(std::exchange(m_pendingUrl, {}), QString(m_openingTranslatedPath), QString(m_openingTranslatedTitle)); } }); } @@ -156,12 +189,18 @@ bool MpvItem::isFileLoaded() const return m_fileLoaded; } -void MpvItem::loadFile(const QUrl &url) +void MpvItem::loadFile(const QUrl &url, const QString &translatedSubtitle, const QString &translatedTitle) { if (!url.isValid()) { return; } + m_translatedPaths.clear(); + m_openingTranslatedPath = translatedSubtitle; + m_openingTranslatedTitle = translatedTitle; + if (!translatedSubtitle.isEmpty()) { + m_translatedPaths.append(translatedSubtitle); + } m_currentUrl = url; Q_EMIT currentUrlChanged(); @@ -355,6 +394,9 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title) return; } const QString path = localPathOrUrl(url); + if (!m_translatedPaths.contains(path)) { + m_translatedPaths.append(path); + } for (const QVariant &entry : std::as_const(m_subtitleTracks)) { const QVariantMap track = entry.toMap(); if (track.value(QStringLiteral("externalFilename")).toString() == path) { @@ -370,11 +412,22 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title) int MpvItem::translationSourceId() const { + QVariantList sources; + for (const QVariant &entry : std::as_const(m_subtitleTracks)) { + if (!isTranslatedSubtitle(entry.toMap().value(QStringLiteral("id")).toInt())) { + sources.append(entry); + } + } // English first: it is the usual original, and dubbed releases often default to // subtitles translated from it. - return TrackSelection::pickTranslationSource(selectionTracks(m_subtitleTracks), + return TrackSelection::pickTranslationSource(selectionTracks(sources), {QStringLiteral("en"), trackLanguage(m_audioTracks, m_audioId)}, - m_subtitleId); + isTranslatedSubtitle(m_subtitleId) ? 0 : m_subtitleId); +} + +bool MpvItem::isTranslatedSubtitle(int id) const +{ + return id > 0 && m_translatedPaths.contains(subtitlePath(m_subtitleTracks, id)); } void MpvItem::selectInitialTracks() @@ -382,6 +435,16 @@ void MpvItem::selectInitialTracks() // The observed track-list may still describe the previous file; read it directly. updateTracks(getProperty(QStringLiteral("track-list")).toList()); + // mpv finds a translation next to the video by itself (sub-auto); one kept in the + // cache is added here, before choosing, so it can be chosen. + const QString translated = std::exchange(m_openingTranslatedPath, {}); + int translationId = subtitleIdForPath(m_subtitleTracks, translated); + if (!translated.isEmpty() && translationId == 0 && QFileInfo::exists(translated)) { + commandBlocking(QStringList{QStringLiteral("sub-add"), translated, QStringLiteral("auto"), m_openingTranslatedTitle, filenameLanguage(translated)}); + updateTracks(getProperty(QStringLiteral("track-list")).toList()); + translationId = subtitleIdForPath(m_subtitleTracks, translated); + } + const int audioId = TrackSelection::pickAudio(selectionTracks(m_audioTracks), m_preferredAudioLanguages, m_audioId); if (audioId != m_audioId) { setAudioId(audioId); @@ -393,7 +456,8 @@ void MpvItem::selectInitialTracks() m_preferredSubtitleLanguages, fallbackLanguages, m_subtitleId, - m_autoSelectSubtitle); + m_autoSelectSubtitle, + translationId); if (subtitleId != m_subtitleId) { setSubtitleId(subtitleId); } diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h index 94fbc8b..1d8b2ec 100644 --- a/src/mpv/mpvitem.h +++ b/src/mpv/mpvitem.h @@ -98,7 +98,9 @@ public: QStringList loadedUserConfigFiles() const; QStringList skippedUserOptions() const; - Q_INVOKABLE void loadFile(const QUrl &url); + // \a translatedSubtitle is the file's saved translation (TranslationService). It is + // loaded with the file, named \a translatedTitle, and turned on (see TrackSelection). + Q_INVOKABLE void loadFile(const QUrl &url, const QString &translatedSubtitle = {}, const QString &translatedTitle = {}); // Unloads the current file. Q_INVOKABLE void stop(); Q_INVOKABLE void togglePause(); @@ -114,8 +116,10 @@ public: // already loaded. Q_INVOKABLE void showTranslatedSubtitleFile(const QUrl &url, const QString &title); // Id of the subtitle to translate: the shown one if it is text, else the best text - // subtitle. 0 when the file has no text subtitle. + // subtitle. Translations are skipped. 0 when the file has no text subtitle. Q_INVOKABLE int translationSourceId() const; + // True for a subtitle QtPotato translated, as opposed to one to translate from. + Q_INVOKABLE bool isTranslatedSubtitle(int id) const; Q_SIGNALS: void currentUrlChanged(); @@ -174,6 +178,11 @@ private: int m_chapter = -1; // File added by showTranslatedSubtitleFile() that should be shown once mpv lists it. QString m_pendingTranslatedPath; + // Translated subtitle files of the current file. + QStringList m_translatedPaths; + // Saved translation to load and turn on when the file opens, and its track title. + QString m_openingTranslatedPath; + QString m_openingTranslatedTitle; bool m_loadUserConfig = false; QStringList m_loadedUserConfigFiles; QStringList m_skippedUserOptions; diff --git a/src/mpv/trackselection.cpp b/src/mpv/trackselection.cpp index 3fd4827..0214242 100644 --- a/src/mpv/trackselection.cpp +++ b/src/mpv/trackselection.cpp @@ -98,7 +98,12 @@ int pickAudio(const QList &tracks, const QStringList &preferredLanguages, return currentId; } -int pickSubtitle(const QList &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback) +int pickSubtitle(const QList &tracks, + const QStringList &preferredLanguages, + const QStringList &fallbackLanguages, + int currentId, + bool fallback, + int translationId) { for (const QString &language : preferredLanguages) { if (const int id = firstFull(tracks, [&](const Track &track) { @@ -107,6 +112,9 @@ int pickSubtitle(const QList &tracks, const QStringList &preferredLanguag return id; } } + if (fallback && translationId > 0) { + return translationId; + } if (currentId > 0 || !fallback) { return currentId; } diff --git a/src/mpv/trackselection.h b/src/mpv/trackselection.h index 36de0ff..4861ee7 100644 --- a/src/mpv/trackselection.h +++ b/src/mpv/trackselection.h @@ -28,11 +28,18 @@ bool sameLanguage(const QString &a, const QString &b); int pickAudio(const QList &tracks, const QStringList &preferredLanguages, int currentId); // The subtitle to turn on when the file opens. A track in one of \a preferredLanguages -// wins over mpv's own choice \a currentId; without one, \a currentId is kept. When mpv -// chose nothing and \a fallback is set, picks a full (not forced) subtitle in the first -// of \a fallbackLanguages that has one, then one flagged default, then the first one. -// Text subtitles win over bitmap ones in the same step. Returns 0 for no subtitle. -int pickSubtitle(const QList &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback); +// wins over mpv's own choice \a currentId. Next comes \a translationId, the file's +// saved translation, when \a fallback is set; without either, \a currentId is kept. +// When mpv chose nothing and \a fallback is set, picks a full (not forced) subtitle in +// the first of \a fallbackLanguages that has one, then one flagged default, then the +// first one. Text subtitles win over bitmap ones in the same step. Returns 0 for no +// subtitle. +int pickSubtitle(const QList &tracks, + const QStringList &preferredLanguages, + const QStringList &fallbackLanguages, + int currentId, + bool fallback, + int translationId = 0); // The subtitle to translate: \a currentId when it is a text subtitle, else a full text // subtitle in the first of \a sourceLanguages that has one, then one flagged default, diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 4949163..a6c467d 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -24,7 +24,6 @@ ApplicationWindow { // subtitle even while that translated track is selected in mpv. property var lastTranslationTrack: null property url lastTranslationVideo: "" - property var translatedSubtitleUrls: [] function openFile(url: url) { playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings) @@ -46,10 +45,15 @@ ApplicationWindow { } } + function translatedTrackTitle(): string { + return KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage) + } + function translateCurrentSubtitle() { let track = video.subtitleTracks.find(track => track.id === video.subtitleId) - if (track && translatedSubtitleUrls.includes(track.externalFilename) && lastTranslationTrack) { - track = video.subtitleTracks.find(track => track.id === lastTranslationTrack.id) + // Translate the original again, not the translation on screen. + if (track && video.isTranslatedSubtitle(track.id)) { + track = lastTranslationTrack ? video.subtitleTracks.find(track => track.id === lastTranslationTrack.id) : null } // No subtitle shown, or a picture-based one: translate the best text subtitle. if (!track || track.bitmap) { @@ -141,7 +145,8 @@ ApplicationWindow { PlaylistModel { id: playlistModel onPlayRequested: url => { - video.loadFile(url) + // A translation made earlier is loaded and turned on with the file. + video.loadFile(url, translationService.savedTranslation(url), appWindow.translatedTrackTitle()) RecentFilesModel.add(url) } } @@ -169,10 +174,7 @@ ApplicationWindow { TranslationService { id: translationService onTranslated: (subtitleUrl, fromCache) => { - // mpv exposes local external subtitle filenames as filesystem paths. - appWindow.translatedSubtitleUrls.push(decodeURIComponent(subtitleUrl.toString().replace(/^file:\/\//, ""))) - video.showTranslatedSubtitleFile(subtitleUrl, - KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)) + video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle()) osdItem.message(fromCache ? KI18n.i18nc("@info:osd", "Loaded the saved translation") : KI18n.i18nc("@info:osd", "Translation ready")) @@ -187,7 +189,6 @@ ApplicationWindow { translationService.forgetTranslation() appWindow.lastTranslationTrack = null appWindow.lastTranslationVideo = "" - appWindow.translatedSubtitleUrls = [] glossaryDialog.close() } diff --git a/src/translate/translationservice.cpp b/src/translate/translationservice.cpp index 1138bd7..5a069fd 100644 --- a/src/translate/translationservice.cpp +++ b/src/translate/translationservice.cpp @@ -3,6 +3,7 @@ #include "apikeystore.h" #include "qtpotatosettings.h" #include "translationpresets.h" +#include "translationstore.h" #include @@ -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(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 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, + 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 TranslationService::translateLines(std::shared_ptr job, QStringList lines, QStringList context) diff --git a/src/translate/translationservice.h b/src/translate/translationservice.h index 942784d..ade6bff 100644 --- a/src/translate/translationservice.h +++ b/src/translate/translationservice.h @@ -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> loadConfig(); void fail(const std::shared_ptr &job, const QString &error); + // Saves the translation in \a cacheFile where players find it and announces it. + void finish(const std::shared_ptr &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache); void setBusy(bool busy); void setProgress(double progress); diff --git a/src/translate/translationstore.cpp b/src/translate/translationstore.cpp new file mode 100644 index 0000000..117995d --- /dev/null +++ b/src/translate/translationstore.cpp @@ -0,0 +1,231 @@ +#include "translationstore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Qt::Literals::StringLiterals; + +namespace +{ +constexpr int RegistryVersion = 1; + +QString normalized(const QString &path) +{ + return path.isEmpty() ? QString() : QFileInfo(path).absoluteFilePath(); +} + +std::optional 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(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 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 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(); +} +} diff --git a/src/translate/translationstore.h b/src/translate/translationstore.h new file mode 100644 index 0000000..bfd7766 --- /dev/null +++ b/src/translate/translationstore.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +// Where translated subtitles live. A translation is written next to the video as +// "