Compare commits

..
1 Commits
Author SHA1 Message Date
tri 76ba7b0be0 feat(playlist): compact rows with clean titles and durations 2026-11-13 21:57:34 +07:00
13 changed files with 65 additions and 716 deletions

No files matched your search

-5
View File
@@ -25,11 +25,6 @@ ecm_add_test(trackselectiontest.cpp
LINK_LIBRARIES qtpotato_core Qt6::Test LINK_LIBRARIES qtpotato_core Qt6::Test
) )
ecm_add_test(translationstoretest.cpp
TEST_NAME translationstoretest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
ecm_add_test(mediainfotest.cpp ecm_add_test(mediainfotest.cpp
TEST_NAME mediainfotest TEST_NAME mediainfotest
LINK_LIBRARIES qtpotato_core Qt6::Test LINK_LIBRARIES qtpotato_core Qt6::Test
-13
View File
@@ -67,19 +67,6 @@ private Q_SLOTS:
QCOMPARE(TrackSelection::pickSubtitle(tracks, {u"de"_s}, {u"en"_s}, 1, true), 1); 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() void picksAudioByPreference()
{ {
const QList<Track> tracks{sub(1, u"en"_s, u"aac"_s), sub(2, u"jpn"_s, u"aac"_s)}; const QList<Track> tracks{sub(1, u"en"_s, u"aac"_s), sub(2, u"jpn"_s, u"aac"_s)};
-209
View File
@@ -1,209 +0,0 @@
#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"
+3 -5
View File
@@ -1,5 +1,5 @@
# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept # Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests
# apart from the app so autotests can link it. # can link it.
add_library(qtpotato_core STATIC add_library(qtpotato_core STATIC
media/mediainfo.cpp media/mediainfo.cpp
media/mediainfo.h media/mediainfo.h
@@ -11,10 +11,8 @@ add_library(qtpotato_core STATIC
subtitles/subtitleparser.h subtitles/subtitleparser.h
subtitles/subtitleextractor.cpp subtitles/subtitleextractor.cpp
subtitles/subtitleextractor.h subtitles/subtitleextractor.h
translate/translationstore.cpp
translate/translationstore.h
) )
target_include_directories(qtpotato_core PUBLIC media mpv subtitles translate) target_include_directories(qtpotato_core PUBLIC media mpv subtitles)
target_link_libraries(qtpotato_core PUBLIC target_link_libraries(qtpotato_core PUBLIC
Qt6::Core Qt6::Core
KF6::I18n KF6::I18n
+5 -69
View File
@@ -7,7 +7,6 @@
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QLocale> #include <QLocale>
#include <QSaveFile> #include <QSaveFile>
#include <QStandardPaths> #include <QStandardPaths>
@@ -45,38 +44,6 @@ QList<TrackSelection::Track> selectionTracks(const QVariantList &tracks)
return result; 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) QString trackLanguage(const QVariantList &tracks, int id)
{ {
for (const QVariant &entry : tracks) { for (const QVariant &entry : tracks) {
@@ -145,7 +112,7 @@ MpvItem::MpvItem(QQuickItem *parent)
// Before the first file, so its options apply from the start. // Before the first file, so its options apply from the start.
applyUserConfig(); applyUserConfig();
if (m_pendingUrl.isValid()) { if (m_pendingUrl.isValid()) {
loadFile(std::exchange(m_pendingUrl, {}), QString(m_openingTranslatedPath), QString(m_openingTranslatedTitle)); loadFile(std::exchange(m_pendingUrl, {}));
} }
}); });
} }
@@ -189,18 +156,12 @@ bool MpvItem::isFileLoaded() const
return m_fileLoaded; return m_fileLoaded;
} }
void MpvItem::loadFile(const QUrl &url, const QString &translatedSubtitle, const QString &translatedTitle) void MpvItem::loadFile(const QUrl &url)
{ {
if (!url.isValid()) { if (!url.isValid()) {
return; return;
} }
m_translatedPaths.clear();
m_openingTranslatedPath = translatedSubtitle;
m_openingTranslatedTitle = translatedTitle;
if (!translatedSubtitle.isEmpty()) {
m_translatedPaths.append(translatedSubtitle);
}
m_currentUrl = url; m_currentUrl = url;
Q_EMIT currentUrlChanged(); Q_EMIT currentUrlChanged();
@@ -394,9 +355,6 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
return; return;
} }
const QString path = localPathOrUrl(url); const QString path = localPathOrUrl(url);
if (!m_translatedPaths.contains(path)) {
m_translatedPaths.append(path);
}
for (const QVariant &entry : std::as_const(m_subtitleTracks)) { for (const QVariant &entry : std::as_const(m_subtitleTracks)) {
const QVariantMap track = entry.toMap(); const QVariantMap track = entry.toMap();
if (track.value(QStringLiteral("externalFilename")).toString() == path) { if (track.value(QStringLiteral("externalFilename")).toString() == path) {
@@ -412,22 +370,11 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
int MpvItem::translationSourceId() const 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 // English first: it is the usual original, and dubbed releases often default to
// subtitles translated from it. // subtitles translated from it.
return TrackSelection::pickTranslationSource(selectionTracks(sources), return TrackSelection::pickTranslationSource(selectionTracks(m_subtitleTracks),
{QStringLiteral("en"), trackLanguage(m_audioTracks, m_audioId)}, {QStringLiteral("en"), trackLanguage(m_audioTracks, m_audioId)},
isTranslatedSubtitle(m_subtitleId) ? 0 : m_subtitleId); m_subtitleId);
}
bool MpvItem::isTranslatedSubtitle(int id) const
{
return id > 0 && m_translatedPaths.contains(subtitlePath(m_subtitleTracks, id));
} }
void MpvItem::selectInitialTracks() void MpvItem::selectInitialTracks()
@@ -435,16 +382,6 @@ void MpvItem::selectInitialTracks()
// The observed track-list may still describe the previous file; read it directly. // The observed track-list may still describe the previous file; read it directly.
updateTracks(getProperty(QStringLiteral("track-list")).toList()); 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); const int audioId = TrackSelection::pickAudio(selectionTracks(m_audioTracks), m_preferredAudioLanguages, m_audioId);
if (audioId != m_audioId) { if (audioId != m_audioId) {
setAudioId(audioId); setAudioId(audioId);
@@ -456,8 +393,7 @@ void MpvItem::selectInitialTracks()
m_preferredSubtitleLanguages, m_preferredSubtitleLanguages,
fallbackLanguages, fallbackLanguages,
m_subtitleId, m_subtitleId,
m_autoSelectSubtitle, m_autoSelectSubtitle);
translationId);
if (subtitleId != m_subtitleId) { if (subtitleId != m_subtitleId) {
setSubtitleId(subtitleId); setSubtitleId(subtitleId);
} }
+2 -11
View File
@@ -98,9 +98,7 @@ public:
QStringList loadedUserConfigFiles() const; QStringList loadedUserConfigFiles() const;
QStringList skippedUserOptions() const; QStringList skippedUserOptions() const;
// \a translatedSubtitle is the file's saved translation (TranslationService). It is Q_INVOKABLE void loadFile(const QUrl &url);
// 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. // Unloads the current file.
Q_INVOKABLE void stop(); Q_INVOKABLE void stop();
Q_INVOKABLE void togglePause(); Q_INVOKABLE void togglePause();
@@ -116,10 +114,8 @@ public:
// already loaded. // already loaded.
Q_INVOKABLE void showTranslatedSubtitleFile(const QUrl &url, const QString &title); 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 // Id of the subtitle to translate: the shown one if it is text, else the best text
// subtitle. Translations are skipped. 0 when the file has no text subtitle. // subtitle. 0 when the file has no text subtitle.
Q_INVOKABLE int translationSourceId() const; 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: Q_SIGNALS:
void currentUrlChanged(); void currentUrlChanged();
@@ -178,11 +174,6 @@ private:
int m_chapter = -1; int m_chapter = -1;
// File added by showTranslatedSubtitleFile() that should be shown once mpv lists it. // File added by showTranslatedSubtitleFile() that should be shown once mpv lists it.
QString m_pendingTranslatedPath; 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; bool m_loadUserConfig = false;
QStringList m_loadedUserConfigFiles; QStringList m_loadedUserConfigFiles;
QStringList m_skippedUserOptions; QStringList m_skippedUserOptions;
+1 -9
View File
@@ -98,12 +98,7 @@ int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages,
return currentId; return currentId;
} }
int pickSubtitle(const QList<Track> &tracks, int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback)
const QStringList &preferredLanguages,
const QStringList &fallbackLanguages,
int currentId,
bool fallback,
int translationId)
{ {
for (const QString &language : preferredLanguages) { for (const QString &language : preferredLanguages) {
if (const int id = firstFull(tracks, [&](const Track &track) { if (const int id = firstFull(tracks, [&](const Track &track) {
@@ -112,9 +107,6 @@ int pickSubtitle(const QList<Track> &tracks,
return id; return id;
} }
} }
if (fallback && translationId > 0) {
return translationId;
}
if (currentId > 0 || !fallback) { if (currentId > 0 || !fallback) {
return currentId; return currentId;
} }
+5 -12
View File
@@ -28,18 +28,11 @@ bool sameLanguage(const QString &a, const QString &b);
int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages, int currentId); int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages, int currentId);
// The subtitle to turn on when the file opens. A track in one of \a preferredLanguages // The subtitle to turn on when the file opens. A track in one of \a preferredLanguages
// wins over mpv's own choice \a currentId. Next comes \a translationId, the file's // wins over mpv's own choice \a currentId; without one, \a currentId is kept. When mpv
// saved translation, when \a fallback is set; without either, \a currentId is kept. // chose nothing and \a fallback is set, picks a full (not forced) subtitle in the first
// When mpv chose nothing and \a fallback is set, picks a full (not forced) subtitle in // of \a fallbackLanguages that has one, then one flagged default, then the first one.
// the first of \a fallbackLanguages that has one, then one flagged default, then the // Text subtitles win over bitmap ones in the same step. Returns 0 for no subtitle.
// first one. Text subtitles win over bitmap ones in the same step. Returns 0 for no int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback);
// subtitle.
int pickSubtitle(const QList<Track> &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 // 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, // subtitle in the first of \a sourceLanguages that has one, then one flagged default,
+9 -10
View File
@@ -24,6 +24,7 @@ ApplicationWindow {
// subtitle even while that translated track is selected in mpv. // subtitle even while that translated track is selected in mpv.
property var lastTranslationTrack: null property var lastTranslationTrack: null
property url lastTranslationVideo: "" property url lastTranslationVideo: ""
property var translatedSubtitleUrls: []
function openFile(url: url) { function openFile(url: url) {
playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings) playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings)
@@ -45,15 +46,10 @@ ApplicationWindow {
} }
} }
function translatedTrackTitle(): string {
return KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)
}
function translateCurrentSubtitle() { function translateCurrentSubtitle() {
let track = video.subtitleTracks.find(track => track.id === video.subtitleId) let track = video.subtitleTracks.find(track => track.id === video.subtitleId)
// Translate the original again, not the translation on screen. if (track && translatedSubtitleUrls.includes(track.externalFilename) && lastTranslationTrack) {
if (track && video.isTranslatedSubtitle(track.id)) { track = video.subtitleTracks.find(track => track.id === lastTranslationTrack.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. // No subtitle shown, or a picture-based one: translate the best text subtitle.
if (!track || track.bitmap) { if (!track || track.bitmap) {
@@ -145,8 +141,7 @@ ApplicationWindow {
PlaylistModel { PlaylistModel {
id: playlistModel id: playlistModel
onPlayRequested: url => { onPlayRequested: url => {
// A translation made earlier is loaded and turned on with the file. video.loadFile(url)
video.loadFile(url, translationService.savedTranslation(url), appWindow.translatedTrackTitle())
RecentFilesModel.add(url) RecentFilesModel.add(url)
} }
} }
@@ -174,7 +169,10 @@ ApplicationWindow {
TranslationService { TranslationService {
id: translationService id: translationService
onTranslated: (subtitleUrl, fromCache) => { onTranslated: (subtitleUrl, fromCache) => {
video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle()) // 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))
osdItem.message(fromCache osdItem.message(fromCache
? KI18n.i18nc("@info:osd", "Loaded the saved translation") ? KI18n.i18nc("@info:osd", "Loaded the saved translation")
: KI18n.i18nc("@info:osd", "Translation ready")) : KI18n.i18nc("@info:osd", "Translation ready"))
@@ -189,6 +187,7 @@ ApplicationWindow {
translationService.forgetTranslation() translationService.forgetTranslation()
appWindow.lastTranslationTrack = null appWindow.lastTranslationTrack = null
appWindow.lastTranslationVideo = "" appWindow.lastTranslationVideo = ""
appWindow.translatedSubtitleUrls = []
glossaryDialog.close() glossaryDialog.close()
} }
+38 -52
View File
@@ -3,7 +3,6 @@
#include "apikeystore.h" #include "apikeystore.h"
#include "qtpotatosettings.h" #include "qtpotatosettings.h"
#include "translationpresets.h" #include "translationpresets.h"
#include "translationstore.h"
#include <KLocalizedString> #include <KLocalizedString>
@@ -40,9 +39,28 @@ constexpr int ParallelRequests = 3;
// Bump when the prompt changes so old cached translations are not reused. // Bump when the prompt changes so old cached translations are not reused.
constexpr auto CacheVersion = "llm-v2"; constexpr auto CacheVersion = "llm-v2";
QString cacheDir() // "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language)
{ {
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s; 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));
} }
QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries) QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries)
@@ -255,12 +273,6 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
if (m_lastTranslationPath.isEmpty() || !QFileInfo::exists(m_lastTranslationPath)) { if (m_lastTranslationPath.isEmpty() || !QFileInfo::exists(m_lastTranslationPath)) {
return; 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); QFile file(m_lastTranslationPath);
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
return; return;
@@ -307,14 +319,10 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
text.replace(markers.at(i), replacements.at(i).second); text.replace(markers.at(i), replacements.at(i).second);
} }
const QByteArray data = text.toUtf8();
QSaveFile output(m_lastTranslationPath); QSaveFile output(m_lastTranslationPath);
if (!output.open(QIODevice::WriteOnly) || output.write(data) < 0 || !output.commit()) { if (!output.open(QIODevice::WriteOnly) || output.write(text.toUtf8()) < 0 || !output.commit()) {
return; 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); Q_EMIT translated(QUrl::fromLocalFile(m_lastTranslationPath), true);
} }
@@ -472,8 +480,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
const QString sourceSrt = Subtitles::toSrt(cues); const QString sourceSrt = Subtitles::toSrt(cues);
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once. // Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once.
const QString suffix = TranslationStore::languageSuffix(config.targetLanguage); const auto outputPathFor = [&](const GlossaryStore::Glossary &g) {
const auto configKeyFor = [&](const GlossaryStore::Glossary &g) {
QCryptographicHash hash(QCryptographicHash::Sha1); QCryptographicHash hash(QCryptographicHash::Sha1);
hash.addData(CacheVersion); hash.addData(CacheVersion);
hash.addData(config.model.toUtf8()); hash.addData(config.model.toUtf8());
@@ -482,13 +489,16 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
hash.addData(config.customInstructions.toUtf8()); hash.addData(config.customInstructions.toUtf8());
hash.addData(GlossaryStore::serialize(g).toUtf8()); hash.addData(GlossaryStore::serialize(g).toUtf8());
hash.addData(sourceSrt.toUtf8()); hash.addData(sourceSrt.toUtf8());
return QString::fromLatin1(hash.result().toHex().left(12)); return cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
}; };
QString configKey = configKeyFor(glossary); QString outputPath = outputPathFor(glossary);
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) { if (QFileInfo::exists(outputPath)) {
finish(job, videoUrl, suffix, configKey, outputPath, true); m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
co_return; co_return;
} }
@@ -535,10 +545,13 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
} else { } else {
setStatusText(i18n("Glossary unavailable; translating without it.")); setStatusText(i18n("Glossary unavailable; translating without it."));
} }
configKey = configKeyFor(glossary); outputPath = outputPathFor(glossary);
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) { if (QFileInfo::exists(outputPath)) {
finish(job, videoUrl, suffix, configKey, outputPath, true); m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
co_return; co_return;
} }
} }
@@ -597,38 +610,11 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
co_return; 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_job.reset();
m_lastTranslationPath = published.path; m_lastTranslationPath = outputPath;
setBusy(false); setBusy(false);
setStatusText({}); setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(published.path), fromCache); Q_EMIT translated(QUrl::fromLocalFile(outputPath), false);
}
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) QCoro::Task<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context)
+2 -7
View File
@@ -18,8 +18,8 @@
class QNetworkAccessManager; class QNetworkAccessManager;
// Translates a whole subtitle track with an LLM, batch by batch, caches the result as an // Translates a whole subtitle track with an LLM, batch by batch, and caches the result
// .srt file and saves a copy next to the video (see TranslationStore). // as an .srt file that mpv can load as a subtitle track.
class TranslationService : public QObject class TranslationService : public QObject
{ {
Q_OBJECT Q_OBJECT
@@ -62,9 +62,6 @@ public:
Q_INVOKABLE void fetchModels(); Q_INVOKABLE void fetchModels();
Q_INVOKABLE void clearError(); Q_INVOKABLE void clearError();
Q_INVOKABLE void forgetTranslation(); 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 void loadGlossary(const QUrl &videoUrl);
Q_INVOKABLE bool saveGlossary(const QVariantList &entries, const QStringList &episodes = {}); Q_INVOKABLE bool saveGlossary(const QVariantList &entries, const QStringList &episodes = {});
Q_INVOKABLE void clearGlossary(); Q_INVOKABLE void clearGlossary();
@@ -100,8 +97,6 @@ private:
// something is missing. // something is missing.
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig(); QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
void fail(const std::shared_ptr<Job> &job, const QString &error); 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 setBusy(bool busy);
void setProgress(double progress); void setProgress(double progress);
-231
View File
@@ -1,231 +0,0 @@
#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();
}
}
-83
View File
@@ -1,83 +0,0 @@
#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 &registry, 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 &registry, 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 &registry,
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 &registry, 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 &registry, const QUrl &videoUrl, const QString &suffix);
}