Compare commits

...
2 Commits
20 changed files with 1085 additions and 119 deletions

No files matched your search

+11 -1
View File
@@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate)
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
TEST_NAME playlistmodeltest
LINK_LIBRARIES Qt6::Qml Qt6::Test
LINK_LIBRARIES qtpotato_core Qt6::Qml Qt6::Concurrent Qt6::Test
)
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
@@ -24,3 +24,13 @@ 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
)
ecm_add_test(mediainfotest.cpp
TEST_NAME mediainfotest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "mediainfo.h"
#include <QProcess>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTest>
using namespace Qt::Literals::StringLiterals;
class MediaInfoTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void cleanTitle_data()
{
QTest::addColumn<QString>("fileName");
QTest::addColumn<QString>("title");
QTest::newRow("scene episode") << u"VisionQuest.S01E01.No.Strings.DSNP.DV.HDR.WEB-DL.DDP5.1.Atmos.H.265-GRP.mkv"_s
<< u"S01E01 · No Strings"_s;
QTest::newRow("episode without title") << u"The.Show.S02E10.1080p.WEB.h264-GRP.mkv"_s << u"The Show · S02E10"_s;
QTest::newRow("lowercase double episode") << u"show.s01e01e02.pilot.720p.hdtv.mkv"_s << u"S01E01E02 · pilot"_s;
QTest::newRow("spaced episode") << u"Mr. Robot - S01E01 - eps1.0_hellofriend.mov.mkv"_s << u"S01E01 · eps1.0 hellofriend.mov"_s;
QTest::newRow("movie with year") << u"Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP.mkv"_s << u"Dune Part Two (2024)"_s;
QTest::newRow("anime release") << u"[SubsPlease] Frieren - 05 (1080p) [A1B2C3D4].mkv"_s << u"Frieren - 05"_s;
QTest::newRow("plain name") << u"Holiday 2019 at the beach.mp4"_s << u"Holiday 2019 at the beach"_s;
QTest::newRow("music") << u"01 - Intro.flac"_s << u"01 - Intro"_s;
QTest::newRow("only tags") << u"1080p.mkv"_s << u"1080p"_s;
}
void cleanTitle()
{
QFETCH(QString, fileName);
QFETCH(QString, title);
QCOMPARE(MediaInfo::cleanTitle(fileName), title);
}
void probeDurationReadsTheLength()
{
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
if (ffmpeg.isEmpty()) {
QSKIP("ffmpeg is needed to make a sample file");
}
QTemporaryDir dir;
const QString path = dir.filePath(u"tone.mka"_s);
QProcess process;
process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=3"_s, path});
QVERIFY(process.waitForFinished());
QCOMPARE(process.exitCode(), 0);
const std::optional<double> seconds = MediaInfo::probeDuration(path);
QVERIFY(seconds);
QVERIFY2(qAbs(*seconds - 3) < 0.1, qPrintable(QString::number(*seconds)));
QVERIFY(!MediaInfo::probeDuration(dir.filePath(u"missing.mkv"_s)));
}
};
QTEST_GUILESS_MAIN(MediaInfoTest)
#include "mediainfotest.moc"
+27
View File
@@ -4,7 +4,9 @@
#include "playlistmodel.h"
#include <QFile>
#include <QProcess>
#include <QSignalSpy>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTest>
@@ -98,6 +100,31 @@ private Q_SLOTS:
QCOMPARE(model.playingIndex(), -1);
QCOMPARE(model.count(), 1);
}
void titleAndDurationRoles()
{
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
if (ffmpeg.isEmpty()) {
QSKIP("ffmpeg is needed to make a sample file");
}
QTemporaryDir dir;
const QString path = dir.filePath(u"Show.S01E02.The.Tone.1080p.WEB.mka"_s);
QProcess process;
process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=2"_s, path});
QVERIFY(process.waitForFinished());
QCOMPARE(process.exitCode(), 0);
PlaylistModel model;
QSignalSpy changed(&model, &QAbstractItemModel::dataChanged);
model.append({QUrl::fromLocalFile(path), QUrl(u"https://example.com/a"_s)});
QCOMPARE(model.data(model.index(0), PlaylistModel::TitleRole).toString(), u"S01E02 · The Tone"_s);
QCOMPARE(model.data(model.index(1), PlaylistModel::TitleRole).toString(), u"https://example.com/a"_s);
QCOMPARE(model.data(model.index(1), PlaylistModel::DurationRole).toDouble(), -1.0);
QTRY_COMPARE(changed.count(), 1);
QCOMPARE(changed.first().at(2).value<QList<int>>(), QList<int>{PlaylistModel::DurationRole});
QVERIFY(qAbs(model.data(model.index(0), PlaylistModel::DurationRole).toDouble() - 2) < 0.1);
}
};
QTEST_GUILESS_MAIN(PlaylistModelTest)
+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"
+7 -3
View File
@@ -1,6 +1,8 @@
# 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
media/mediainfo.cpp
media/mediainfo.h
mpv/mpvuserconfig.cpp
mpv/mpvuserconfig.h
mpv/trackselection.cpp
@@ -9,8 +11,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 media mpv subtitles translate)
target_link_libraries(qtpotato_core PUBLIC
Qt6::Core
KF6::I18n
+129
View File
@@ -0,0 +1,129 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "mediainfo.h"
#include <QRegularExpression>
#include <QSet>
#include <QStringList>
#include <algorithm>
extern "C" {
#include <libavformat/avformat.h>
}
using namespace Qt::Literals::StringLiterals;
namespace
{
// Words that start the release details in scene-style names; everything after them is dropped.
bool isReleaseTag(const QString &word)
{
static const QSet<QString> tags{
u"web"_s, u"web-dl"_s, u"webdl"_s, u"webrip"_s, u"bluray"_s, u"blu-ray"_s, u"bdrip"_s, u"brrip"_s, u"hdtv"_s,
u"dvdrip"_s, u"hdrip"_s, u"remux"_s, u"uhd"_s, u"dsnp"_s, u"amzn"_s, u"nf"_s, u"hmax"_s, u"atvp"_s,
u"hulu"_s, u"pcok"_s, u"dv"_s, u"hdr"_s, u"hdr10"_s, u"hdr10+"_s, u"sdr"_s, u"x264"_s, u"x265"_s,
u"h264"_s, u"h265"_s, u"hevc"_s, u"avc"_s, u"aac"_s, u"ac3"_s, u"dts"_s, u"ddp"_s, u"10bit"_s,
u"8bit"_s, u"repack"_s, u"proper"_s, u"internal"_s, u"imax"_s, u"multi"_s,
};
static const QRegularExpression pattern(uR"(^(\d{3,4}[pi]|[248]k|ddp?\d.*|h\d{3}|aac\d.*|dts-.*)$)"_s, QRegularExpression::CaseInsensitiveOption);
const QString lower = word.toLower();
return tags.contains(lower) || pattern.match(lower).hasMatch();
}
bool isYear(const QString &word)
{
static const QRegularExpression pattern(uR"(^\(?(19|20)\d\d\)?$)"_s);
return pattern.match(word).hasMatch();
}
// The words before the first release tag, joined by spaces.
QString untilReleaseTags(const QString &text)
{
QStringList kept;
const QStringList words = text.split(u' ', Qt::SkipEmptyParts);
for (const QString &word : words) {
if (isReleaseTag(word)) {
break;
}
kept.append(word);
}
// "Movie 2024" reads better as "Movie (2024)".
if (kept.size() > 1 && isYear(kept.last())) {
QString year = kept.takeLast();
year.remove(u'(').remove(u')');
kept.append(u"(%1)"_s.arg(year));
}
QString result = kept.join(u' ');
// Leftover separators like "Show -" when the tags followed a dash.
static const QRegularExpression trailing(uR"([\s\-–—:·]+$)"_s);
static const QRegularExpression leading(uR"(^[\s\-–—:·]+)"_s);
return result.remove(trailing).remove(leading);
}
}
namespace MediaInfo
{
QString cleanTitle(const QString &fileName)
{
QString base = fileName;
static const QRegularExpression extension(uR"(\.[A-Za-z0-9]{2,4}$)"_s);
base.remove(extension);
const QString fallback = base;
// "[Group] Name [1080p] (ABCD1234)": bracketed parts are release details.
static const QRegularExpression brackets(uR"(\[[^\]]*\]|\{[^}]*\})"_s);
base.remove(brackets);
// Parentheses too, when they hold release tags or a checksum, but not "(2024)".
static const QRegularExpression parens(uR"(\(([^)]*)\))"_s);
static const QRegularExpression wordSeparator(uR"([\s,]+)"_s);
static const QRegularExpression checksum(uR"(^[0-9A-Fa-f]{8}$)"_s);
for (auto it = parens.globalMatch(QString(base)); it.hasNext();) {
const auto match = it.next();
const QStringList words = match.captured(1).split(wordSeparator, Qt::SkipEmptyParts);
if (std::any_of(words.begin(), words.end(), isReleaseTag) || checksum.match(match.captured(1)).hasMatch()) {
base.replace(match.captured(0), u" "_s);
}
}
// Scene names use dots or underscores between words.
base.replace(u'_', u' ');
if (!base.trimmed().contains(u' ')) {
base.replace(u'.', u' ');
}
base = base.simplified();
static const QRegularExpression episode(uR"(\b[Ss](\d{1,2})[ ._-]?[Ee](\d{1,3})(?:-?[Ee]\d{1,3})*\b)"_s);
if (const auto match = episode.match(base); match.hasMatch()) {
const QString code = match.captured(0).toUpper().remove(u' ').remove(u'.').remove(u'_');
const QString episodeTitle = untilReleaseTags(base.mid(match.capturedEnd()));
if (!episodeTitle.isEmpty()) {
return u"%1 · %2"_s.arg(code, episodeTitle);
}
const QString show = untilReleaseTags(base.left(match.capturedStart()));
return show.isEmpty() ? code : u"%1 · %2"_s.arg(show, code);
}
const QString title = untilReleaseTags(base);
return title.isEmpty() ? fallback : title;
}
std::optional<double> probeDuration(const QString &path)
{
AVFormatContext *context = nullptr;
if (avformat_open_input(&context, path.toUtf8().constData(), nullptr, nullptr) < 0) {
return std::nullopt;
}
// Most containers store the length in their header; others need a look at the streams.
if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) {
avformat_find_stream_info(context, nullptr);
}
std::optional<double> seconds;
if (context->duration != AV_NOPTS_VALUE && context->duration > 0) {
seconds = double(context->duration) / AV_TIME_BASE;
}
avformat_close_input(&context);
return seconds;
}
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <optional>
namespace MediaInfo
{
// A short title for a media file name, without the release tags: "Show.S01E01.Pilot.1080p.WEB.mkv"
// becomes "S01E01 · Pilot" and "Movie.2024.2160p.BluRay.mkv" becomes "Movie (2024)". Returns the
// name without its extension when nothing better is found.
QString cleanTitle(const QString &fileName);
// Length of the media file at \a path in seconds, or nothing when it can't be read.
std::optional<double> probeDuration(const QString &path);
}
+69 -5
View File
@@ -7,6 +7,7 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLocale>
#include <QSaveFile>
#include <QStandardPaths>
@@ -44,6 +45,38 @@ QList<TrackSelection::Track> 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);
}
+11 -2
View File
@@ -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;
+9 -1
View File
@@ -98,7 +98,12 @@ int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages,
return currentId;
}
int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback)
int pickSubtitle(const QList<Track> &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<Track> &tracks, const QStringList &preferredLanguag
return id;
}
}
if (fallback && translationId > 0) {
return translationId;
}
if (currentId > 0 || !fallback) {
return currentId;
}
+12 -5
View File
@@ -28,11 +28,18 @@ bool sameLanguage(const QString &a, const QString &b);
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
// 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<Track> &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<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
// subtitle in the first of \a sourceLanguages that has one, then one flagged default,
+41
View File
@@ -3,10 +3,13 @@
#include "playlistmodel.h"
#include "mediainfo.h"
#include <QCollator>
#include <QDir>
#include <QFileInfo>
#include <QMimeDatabase>
#include <QtConcurrentRun>
#include <algorithm>
@@ -15,6 +18,13 @@ using namespace Qt::Literals::StringLiterals;
PlaylistModel::PlaylistModel(QObject *parent)
: QAbstractListModel(parent)
{
// Reading many files at once from one disk is slower than a few at a time.
m_probePool.setMaxThreadCount(2);
}
PlaylistModel::~PlaylistModel()
{
m_probePool.clear();
}
int PlaylistModel::rowCount(const QModelIndex &parent) const
@@ -36,6 +46,10 @@ QVariant PlaylistModel::data(const QModelIndex &index, int role) const
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
case IsPlayingRole:
return index.row() == m_playingIndex;
case TitleRole:
return url.isLocalFile() ? MediaInfo::cleanTitle(url.fileName()) : url.toDisplayString();
case DurationRole:
return m_durations.value(url, -1);
}
return {};
}
@@ -46,6 +60,8 @@ QHash<int, QByteArray> PlaylistModel::roleNames() const
{UrlRole, "url"},
{NameRole, "name"},
{IsPlayingRole, "isPlaying"},
{TitleRole, "title"},
{DurationRole, "duration"},
};
}
@@ -127,6 +143,7 @@ void PlaylistModel::openFile(const QUrl &url, bool loadSiblings)
m_playingIndex = -1;
endResetModel();
Q_EMIT countChanged();
probeDurations(m_urls);
play(std::max<int>(0, int(m_urls.indexOf(url))));
}
@@ -140,6 +157,7 @@ void PlaylistModel::append(const QList<QUrl> &urls)
m_urls.append(added);
endInsertRows();
Q_EMIT countChanged();
probeDurations(added);
}
void PlaylistModel::replace(const QList<QUrl> &urls)
@@ -153,6 +171,7 @@ void PlaylistModel::replace(const QList<QUrl> &urls)
m_playingIndex = -1;
endResetModel();
Q_EMIT countChanged();
probeDurations(m_urls);
play(0);
}
@@ -274,3 +293,25 @@ void PlaylistModel::setPlayingIndex(int index)
}
Q_EMIT playingIndexChanged();
}
void PlaylistModel::probeDurations(const QList<QUrl> &urls)
{
for (const QUrl &url : urls) {
// Streams are skipped: opening them can take long and costs bandwidth.
if (!url.isLocalFile() || m_durations.contains(url)) {
continue;
}
m_durations.insert(url, -1);
QtConcurrent::run(&m_probePool, MediaInfo::probeDuration, url.toLocalFile()).then(this, [this, url](std::optional<double> seconds) {
if (!seconds) {
return;
}
m_durations.insert(url, *seconds);
for (int row = 0; row < m_urls.size(); ++row) {
if (m_urls.at(row) == url) {
Q_EMIT dataChanged(index(row), index(row), {DurationRole});
}
}
});
}
}
+12
View File
@@ -4,7 +4,9 @@
#pragma once
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QThreadPool>
#include <QUrl>
#include <QtQml/qqmlregistration.h>
@@ -24,10 +26,15 @@ public:
UrlRole = Qt::UserRole + 1,
NameRole,
IsPlayingRole,
// Short name without release tags; see MediaInfo::cleanTitle().
TitleRole,
// Length in seconds, or -1 while unknown. Local files are read in the background.
DurationRole,
};
Q_ENUM(Roles)
explicit PlaylistModel(QObject *parent = nullptr);
~PlaylistModel() override;
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
@@ -66,7 +73,12 @@ Q_SIGNALS:
private:
QList<QUrl> expand(const QList<QUrl> &urls) const;
void setPlayingIndex(int index);
void probeDurations(const QList<QUrl> &urls);
QList<QUrl> m_urls;
int m_playingIndex = -1;
// Known lengths in seconds; -1 for files that couldn't be read or are still being read.
QHash<QUrl, double> m_durations;
// Declared last so it is destroyed first and waits for running probes.
QThreadPool m_probePool;
};
+10 -9
View File
@@ -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()
}
+69 -53
View File
@@ -8,20 +8,23 @@ import QtQuick.Layouts
import org.kde.ki18n
import org.kde.kirigami as Kirigami
// One row of the playlist: row number, playing icon and file name.
// One compact row of the playlist: row number (or playing icon), short title, length and,
// on hover or focus, a remove button.
Item {
id: root
required property int index
required property url url
required property string name
required property string title
required property real duration
required property bool isPlaying
required property PlaylistModel playlist
required property MpvItem mpv
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
readonly property bool hovered: hoverHandler.hovered
readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize
readonly property bool highlighted: root.isPlaying && !root.hovered
activeFocusOnTab: true
function activate() {
@@ -32,6 +35,18 @@ Item {
contextMenu.popup()
}
// "1:02:03" or "4:05".
function formatDuration(seconds: real): string {
if (seconds < 0) {
return ""
}
const total = Math.round(seconds)
const hours = Math.floor(total / 3600)
const minutes = Math.floor(total / 60) % 60
const secs = String(total % 60).padStart(2, "0")
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`
}
Keys.onReturnPressed: root.activate()
Keys.onEnterPressed: root.activate()
Keys.onMenuPressed: root.showContextMenu()
@@ -42,15 +57,8 @@ Item {
}
}
function labelColor(): color {
if (root.isPlaying && !root.hovered) {
return Kirigami.Theme.highlightedTextColor
}
return Kirigami.Theme.textColor
}
implicitWidth: ListView.view.width
implicitHeight: fontSize * 3
implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2
HoverHandler {
id: hoverHandler
@@ -71,7 +79,7 @@ Item {
anchors.fill: parent
Kirigami.Theme.colorSet: Kirigami.Theme.View
color: {
if (root.isPlaying && !root.hovered) {
if (root.highlighted) {
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
}
if (root.hovered) {
@@ -85,67 +93,75 @@ Item {
RowLayout {
anchors.fill: parent
anchors.leftMargin: Kirigami.Units.largeSpacing
anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
Label {
text: root.index + 1
color: root.labelColor()
visible: QtPotatoSettings.playlistShowRowNumber
font.pointSize: root.fontSize
font.features: { "tnum": 1 }
horizontalAlignment: Qt.AlignHCenter
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.preferredWidth: rowNumberMetrics.width
}
TextMetrics {
id: rowNumberMetrics
font.pointSize: root.fontSize
text: String(root.playlist.count).replace(/./g, "0")
}
Rectangle {
color: Qt.alpha(Kirigami.Theme.textColor, 0.2)
visible: QtPotatoSettings.playlistShowRowNumber
Layout.preferredWidth: 1
// Same width on every row, so the titles line up.
Item {
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
}
Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
Kirigami.Units.iconSizes.small)
Kirigami.Icon {
source: "media-playback-start"
color: root.labelColor()
visible: root.isPlaying
Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
Label {
anchors.centerIn: parent
text: root.index + 1
visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
color: Kirigami.Theme.disabledTextColor
font: Kirigami.Theme.smallFont
}
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.small
height: Kirigami.Units.iconSizes.small
source: "media-playback-start"
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
visible: root.isPlaying
}
TextMetrics {
id: rowNumberMetrics
font: Kirigami.Theme.smallFont
text: String(root.playlist.count).replace(/./g, "0")
}
}
LabelWithTooltip {
text: root.name
toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url
color: root.labelColor()
id: titleLabel
text: root.title
toolTipText: root.name
alwaysShowToolTip: root.title !== root.name
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
elide: Text.ElideRight
font.pointSize: root.fontSize
font.bold: root.isPlaying
maximumLineCount: 1
verticalAlignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0
Layout.rightMargin: Kirigami.Units.largeSpacing
}
Button {
Label {
text: root.formatDuration(root.duration)
visible: text !== ""
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.disabledTextColor
font: Kirigami.Theme.smallFont
}
ToolButton {
id: removeButton
text: KI18n.i18nc("@action:button remove playlist item", "Remove")
icon.name: "list-remove"
icon.width: Kirigami.Units.iconSizes.small
icon.height: Kirigami.Units.iconSizes.small
display: AbstractButton.IconOnly
flat: true
focusPolicy: Qt.TabFocus
visible: root.hovered || root.activeFocus || activeFocus
padding: 0
Accessible.name: text
ToolTip.text: text
ToolTip.visible: hovered
ToolTip.delay: Kirigami.Units.toolTipDelay
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2
Layout.rightMargin: Kirigami.Units.smallSpacing
Layout.fillHeight: true
Layout.preferredWidth: height
onClicked: root.playlist.remove(root.index)
}
}
+52 -38
View File
@@ -3,6 +3,7 @@
#include "apikeystore.h"
#include "qtpotatosettings.h"
#include "translationpresets.h"
#include "translationstore.h"
#include <KLocalizedString>
@@ -39,28 +40,9 @@ constexpr int ParallelRequests = 3;
// Bump when the prompt changes so old cached translations are not reused.
constexpr auto CacheVersion = "llm-v2";
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language)
QString cacheDir()
{
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
const auto candidate = static_cast<QLocale::Language>(i);
if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) {
const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1);
return code.isEmpty() ? QLocale::languageToCode(candidate) : code;
}
}
QString suffix = language.toLower();
suffix.removeIf([](QChar c) {
return !c.isLetterOrNumber() && c != u'-';
});
return suffix.isEmpty() ? u"translated"_s : suffix;
}
QString cachePath(const QUrl &videoUrl, const QString &key, const QString &language)
{
const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
return u"%1/%2.%3.%4.srt"_s.arg(dir, baseName, key, languageSuffix(language));
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
}
QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries)
@@ -273,6 +255,12 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
if (m_lastTranslationPath.isEmpty() || !QFileInfo::exists(m_lastTranslationPath)) {
return;
}
// A translation the user has edited since is theirs; leave it alone.
const QString registryFile = TranslationStore::registryFile();
TranslationStore::Registry registry = TranslationStore::load(registryFile);
if (!TranslationStore::isOwned(registry, m_lastTranslationPath)) {
return;
}
QFile file(m_lastTranslationPath);
if (!file.open(QIODevice::ReadOnly)) {
return;
@@ -319,10 +307,14 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
text.replace(markers.at(i), replacements.at(i).second);
}
const QByteArray data = text.toUtf8();
QSaveFile output(m_lastTranslationPath);
if (!output.open(QIODevice::WriteOnly) || output.write(text.toUtf8()) < 0 || !output.commit()) {
if (!output.open(QIODevice::WriteOnly) || output.write(data) < 0 || !output.commit()) {
return;
}
// Still QtPotato's file, so a later translation may replace it.
TranslationStore::recordRewrite(registry, m_lastTranslationPath, data);
TranslationStore::save(registryFile, registry);
Q_EMIT translated(QUrl::fromLocalFile(m_lastTranslationPath), true);
}
@@ -480,7 +472,8 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
const QString sourceSrt = Subtitles::toSrt(cues);
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once.
const auto outputPathFor = [&](const GlossaryStore::Glossary &g) {
const QString suffix = TranslationStore::languageSuffix(config.targetLanguage);
const auto configKeyFor = [&](const GlossaryStore::Glossary &g) {
QCryptographicHash hash(QCryptographicHash::Sha1);
hash.addData(CacheVersion);
hash.addData(config.model.toUtf8());
@@ -489,16 +482,13 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
hash.addData(config.customInstructions.toUtf8());
hash.addData(GlossaryStore::serialize(g).toUtf8());
hash.addData(sourceSrt.toUtf8());
return cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
return QString::fromLatin1(hash.result().toHex().left(12));
};
QString outputPath = outputPathFor(glossary);
QString configKey = configKeyFor(glossary);
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) {
m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
finish(job, videoUrl, suffix, configKey, outputPath, true);
co_return;
}
@@ -545,13 +535,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
} else {
setStatusText(i18n("Glossary unavailable; translating without it."));
}
outputPath = outputPathFor(glossary);
configKey = configKeyFor(glossary);
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) {
m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
finish(job, videoUrl, suffix, configKey, outputPath, true);
co_return;
}
}
@@ -610,11 +597,38 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
co_return;
}
finish(job, videoUrl, suffix, configKey, outputPath, false);
}
void TranslationService::finish(const std::shared_ptr<Job> &job,
const QUrl &videoUrl,
const QString &suffix,
const QString &configKey,
const QString &cacheFile,
bool fromCache)
{
QFile file(cacheFile);
if (!file.open(QIODevice::ReadOnly)) {
fail(job, i18n("Could not read the translation from %1: %2", cacheFile, file.errorString()));
return;
}
// The cache keeps every version; the published copy is the one players load.
const QString registryFile = TranslationStore::registryFile();
TranslationStore::Registry registry = TranslationStore::load(registryFile);
const TranslationStore::PublishResult published = TranslationStore::publish(registry, videoUrl, suffix, configKey, file.readAll(), cacheFile);
TranslationStore::save(registryFile, registry);
m_job.reset();
m_lastTranslationPath = outputPath;
m_lastTranslationPath = published.path;
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), false);
Q_EMIT translated(QUrl::fromLocalFile(published.path), fromCache);
}
QString TranslationService::savedTranslation(const QUrl &videoUrl) const
{
const QString suffix = TranslationStore::languageSuffix(QtPotatoSettings::self()->targetLanguage().trimmed());
return TranslationStore::savedTranslation(TranslationStore::load(TranslationStore::registryFile()), videoUrl, suffix);
}
QCoro::Task<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context)
+7 -2
View File
@@ -18,8 +18,8 @@
class QNetworkAccessManager;
// Translates a whole subtitle track with an LLM, batch by batch, and caches the result
// as an .srt file that mpv can load as a subtitle track.
// Translates a whole subtitle track with an LLM, batch by batch, caches the result as an
// .srt file and saves a copy next to the video (see TranslationStore).
class TranslationService : public QObject
{
Q_OBJECT
@@ -62,6 +62,9 @@ public:
Q_INVOKABLE void fetchModels();
Q_INVOKABLE void clearError();
Q_INVOKABLE void forgetTranslation();
// Path of the translation of \a videoUrl into the target language set in Settings,
// or an empty string when there is none.
Q_INVOKABLE QString savedTranslation(const QUrl &videoUrl) const;
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
Q_INVOKABLE bool saveGlossary(const QVariantList &entries, const QStringList &episodes = {});
Q_INVOKABLE void clearGlossary();
@@ -97,6 +100,8 @@ private:
// something is missing.
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
void fail(const std::shared_ptr<Job> &job, const QString &error);
// Saves the translation in \a cacheFile where players find it and announces it.
void finish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache);
void setBusy(bool busy);
void setProgress(double progress);
+231
View File
@@ -0,0 +1,231 @@
#include "translationstore.h"
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocale>
#include <QSaveFile>
#include <QStandardPaths>
using namespace Qt::Literals::StringLiterals;
namespace
{
constexpr int RegistryVersion = 1;
QString normalized(const QString &path)
{
return path.isEmpty() ? QString() : QFileInfo(path).absoluteFilePath();
}
std::optional<QByteArray> readFile(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return std::nullopt;
}
return file.readAll();
}
bool writeFile(const QString &path, const QByteArray &data)
{
// No direct-write fallback: a folder that can't take a temporary file is treated
// as read-only, and the translation stays in the cache.
QSaveFile file(path);
return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() && file.commit();
}
}
namespace TranslationStore
{
QString languageSuffix(const QString &language)
{
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
const auto candidate = static_cast<QLocale::Language>(i);
if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) {
const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1);
return code.isEmpty() ? QLocale::languageToCode(candidate) : code;
}
}
QString suffix = language.toLower();
suffix.removeIf([](QChar c) {
return !c.isLetterOrNumber() && c != u'-';
});
return suffix.isEmpty() ? u"translated"_s : suffix;
}
QString videoKey(const QUrl &videoUrl)
{
return videoUrl.isLocalFile() ? normalized(videoUrl.toLocalFile()) : videoUrl.toString();
}
QString cachePath(const QString &cacheDir, const QUrl &videoUrl, const QString &configKey, const QString &suffix)
{
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
return u"%1/%2.%3.%4.srt"_s.arg(cacheDir, baseName, configKey, suffix);
}
QStringList sidecarPaths(const QString &videoPath, const QString &suffix)
{
const QFileInfo info(videoPath);
const QString stem = info.absolutePath() + u'/' + info.completeBaseName();
return {u"%1.%2.srt"_s.arg(stem, suffix), u"%1.qtpotato.%2.srt"_s.arg(stem, suffix)};
}
QByteArray contentHash(const QByteArray &data)
{
return QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex();
}
std::optional<Record> Registry::find(const QString &video, const QString &language) const
{
for (const Record &record : records) {
if (record.video == video && record.language == language) {
return record;
}
}
return std::nullopt;
}
std::optional<Record> Registry::findPath(const QString &path) const
{
const QString wanted = normalized(path);
for (const Record &record : records) {
if (record.path == wanted) {
return record;
}
}
return std::nullopt;
}
void Registry::put(const Record &record)
{
Record stored = record;
stored.path = normalized(record.path);
for (Record &existing : records) {
if (existing.video == stored.video && existing.language == stored.language) {
existing = stored;
return;
}
}
records.append(stored);
}
Registry load(const QString &file)
{
Registry registry;
const auto data = readFile(file);
if (!data) {
return registry;
}
const QJsonObject root = QJsonDocument::fromJson(*data).object();
for (const QJsonValue &value : root.value(u"translations"_s).toArray()) {
const QJsonObject object = value.toObject();
Record record{object.value(u"video"_s).toString(),
object.value(u"language"_s).toString(),
object.value(u"path"_s).toString(),
object.value(u"configKey"_s).toString(),
object.value(u"contentHash"_s).toString()};
if (!record.video.isEmpty() && !record.language.isEmpty() && !record.path.isEmpty()) {
registry.put(record);
}
}
return registry;
}
bool save(const QString &file, const Registry &registry, QString *error)
{
QJsonArray translations;
for (const Record &record : registry.records) {
if (!QFileInfo::exists(record.path)) {
continue;
}
translations.append(QJsonObject{{u"video"_s, record.video},
{u"language"_s, record.language},
{u"path"_s, record.path},
{u"configKey"_s, record.configKey},
{u"contentHash"_s, record.contentHash}});
}
const QJsonObject root{{u"version"_s, RegistryVersion}, {u"translations"_s, translations}};
QDir().mkpath(QFileInfo(file).absolutePath());
QSaveFile output(file);
if (!output.open(QIODevice::WriteOnly) || output.write(QJsonDocument(root).toJson()) < 0 || !output.commit()) {
if (error) {
*error = output.errorString();
}
return false;
}
return true;
}
QString registryFile()
{
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/translations.json"_s;
}
bool isOwned(const Registry &registry, const QString &path)
{
const auto record = registry.findPath(path);
if (!record) {
return false;
}
const auto data = readFile(path);
return data && contentHash(*data) == record->contentHash.toLatin1();
}
PublishResult publish(Registry &registry,
const QUrl &videoUrl,
const QString &suffix,
const QString &configKey,
const QByteArray &data,
const QString &cacheFile)
{
const QString video = videoKey(videoUrl);
const QByteArray hash = contentHash(data);
PublishResult result{normalized(cacheFile), false};
if (videoUrl.isLocalFile()) {
QStringList candidates = sidecarPaths(videoUrl.toLocalFile(), suffix);
// Keep using the file written last time, so a translation does not move to
// ".vi.srt" and leave its ".qtpotato.vi.srt" copy behind.
if (const auto previous = registry.find(video, suffix);
previous && candidates.contains(previous->path) && isOwned(registry, previous->path)) {
candidates.removeAll(previous->path);
candidates.prepend(previous->path);
}
for (const QString &path : std::as_const(candidates)) {
if (QFileInfo::exists(path) && !isOwned(registry, path)) {
continue;
}
const auto current = readFile(path);
if ((current && contentHash(*current) == hash) || writeFile(path, data)) {
result = {path, true};
}
// Both names are in the same folder; when one can't be written, neither can.
break;
}
}
registry.put({video, suffix, result.path, configKey, QString::fromLatin1(hash)});
return result;
}
void recordRewrite(Registry &registry, const QString &path, const QByteArray &data)
{
if (auto record = registry.findPath(path)) {
record->contentHash = QString::fromLatin1(contentHash(data));
registry.put(*record);
}
}
QString savedTranslation(const Registry &registry, const QUrl &videoUrl, const QString &suffix)
{
const auto record = registry.find(videoKey(videoUrl), suffix);
return record && QFileInfo::exists(record->path) ? record->path : QString();
}
}
+83
View File
@@ -0,0 +1,83 @@
#pragma once
#include <QByteArray>
#include <QList>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <optional>
// Where translated subtitles live. A translation is written next to the video as
// "<video>.<lang>.srt" so mpv and other players load it, and recorded in a registry so
// QtPotato knows which files it wrote, with which settings, and can find the copy it
// had to keep in the cache when the video's folder was read-only or the video remote.
namespace TranslationStore
{
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language);
// The registry's name for a video: its absolute path, or the URL of a stream.
QString videoKey(const QUrl &videoUrl);
// "<cacheDir>/<video basename>.<configKey>.<suffix>.srt". Every settings combination
// gets its own file, so going back to earlier settings needs no new translation.
QString cachePath(const QString &cacheDir, const QUrl &videoUrl, const QString &configKey, const QString &suffix);
// Files next to \a videoPath to try, in order: "<base>.<suffix>.srt", then
// "<base>.qtpotato.<suffix>.srt" for when the user has a subtitle of their own there.
QStringList sidecarPaths(const QString &videoPath, const QString &suffix);
QByteArray contentHash(const QByteArray &data);
struct Record {
QString video;
QString language;
QString path;
// Hash of the source subtitle and the settings it was translated with.
QString configKey;
// Hash of the file as QtPotato last wrote it. A file that no longer matches was
// changed by someone else and is not overwritten.
QString contentHash;
};
// One record per video and language.
struct Registry {
QList<Record> records;
std::optional<Record> find(const QString &video, const QString &language) const;
std::optional<Record> findPath(const QString &path) const;
void put(const Record &record);
};
Registry load(const QString &file);
// Records whose file is gone are dropped.
bool save(const QString &file, const Registry &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);
}