Compare commits
2
Commits
76ba7b0be0
...
3e65f21fcb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e65f21fcb | ||
|
|
76b12d6632 |
No files matched your search
@@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate)
|
|||||||
|
|
||||||
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
|
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
|
||||||
TEST_NAME playlistmodeltest
|
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)
|
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
|
||||||
|
|
||||||
@@ -24,3 +24,13 @@ ecm_add_test(trackselectiontest.cpp
|
|||||||
TEST_NAME trackselectiontest
|
TEST_NAME trackselectiontest
|
||||||
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
|
||||||
|
TEST_NAME mediainfotest
|
||||||
|
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
#include "playlistmodel.h"
|
#include "playlistmodel.h"
|
||||||
|
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QProcess>
|
||||||
#include <QSignalSpy>
|
#include <QSignalSpy>
|
||||||
|
#include <QStandardPaths>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QTest>
|
#include <QTest>
|
||||||
|
|
||||||
@@ -98,6 +100,31 @@ private Q_SLOTS:
|
|||||||
QCOMPARE(model.playingIndex(), -1);
|
QCOMPARE(model.playingIndex(), -1);
|
||||||
QCOMPARE(model.count(), 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)
|
QTEST_GUILESS_MAIN(PlaylistModelTest)
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ 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)};
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
# Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests
|
# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept
|
||||||
# can link it.
|
# apart from the app so autotests can link it.
|
||||||
add_library(qtpotato_core STATIC
|
add_library(qtpotato_core STATIC
|
||||||
|
media/mediainfo.cpp
|
||||||
|
media/mediainfo.h
|
||||||
mpv/mpvuserconfig.cpp
|
mpv/mpvuserconfig.cpp
|
||||||
mpv/mpvuserconfig.h
|
mpv/mpvuserconfig.h
|
||||||
mpv/trackselection.cpp
|
mpv/trackselection.cpp
|
||||||
@@ -9,8 +11,10 @@ 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 mpv subtitles)
|
target_include_directories(qtpotato_core PUBLIC media mpv subtitles translate)
|
||||||
target_link_libraries(qtpotato_core PUBLIC
|
target_link_libraries(qtpotato_core PUBLIC
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
KF6::I18n
|
KF6::I18n
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
@@ -44,6 +45,38 @@ 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) {
|
||||||
@@ -112,7 +145,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, {}));
|
loadFile(std::exchange(m_pendingUrl, {}), QString(m_openingTranslatedPath), QString(m_openingTranslatedTitle));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,12 +189,18 @@ bool MpvItem::isFileLoaded() const
|
|||||||
return m_fileLoaded;
|
return m_fileLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvItem::loadFile(const QUrl &url)
|
void MpvItem::loadFile(const QUrl &url, const QString &translatedSubtitle, const QString &translatedTitle)
|
||||||
{
|
{
|
||||||
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();
|
||||||
|
|
||||||
@@ -355,6 +394,9 @@ 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) {
|
||||||
@@ -370,11 +412,22 @@ 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(m_subtitleTracks),
|
return TrackSelection::pickTranslationSource(selectionTracks(sources),
|
||||||
{QStringLiteral("en"), trackLanguage(m_audioTracks, m_audioId)},
|
{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()
|
void MpvItem::selectInitialTracks()
|
||||||
@@ -382,6 +435,16 @@ 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);
|
||||||
@@ -393,7 +456,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -98,7 +98,9 @@ public:
|
|||||||
QStringList loadedUserConfigFiles() const;
|
QStringList loadedUserConfigFiles() const;
|
||||||
QStringList skippedUserOptions() 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.
|
// Unloads the current file.
|
||||||
Q_INVOKABLE void stop();
|
Q_INVOKABLE void stop();
|
||||||
Q_INVOKABLE void togglePause();
|
Q_INVOKABLE void togglePause();
|
||||||
@@ -114,8 +116,10 @@ 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. 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;
|
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();
|
||||||
@@ -174,6 +178,11 @@ 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;
|
||||||
|
|||||||
@@ -98,7 +98,12 @@ int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages,
|
|||||||
return currentId;
|
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) {
|
for (const QString &language : preferredLanguages) {
|
||||||
if (const int id = firstFull(tracks, [&](const Track &track) {
|
if (const int id = firstFull(tracks, [&](const Track &track) {
|
||||||
@@ -107,6 +112,9 @@ int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguag
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (fallback && translationId > 0) {
|
||||||
|
return translationId;
|
||||||
|
}
|
||||||
if (currentId > 0 || !fallback) {
|
if (currentId > 0 || !fallback) {
|
||||||
return currentId;
|
return currentId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,18 @@ 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; without one, \a currentId is kept. When mpv
|
// wins over mpv's own choice \a currentId. Next comes \a translationId, the file's
|
||||||
// chose nothing and \a fallback is set, picks a full (not forced) subtitle in the first
|
// saved translation, when \a fallback is set; without either, \a currentId is kept.
|
||||||
// of \a fallbackLanguages that has one, then one flagged default, then the first one.
|
// When mpv chose nothing and \a fallback is set, picks a full (not forced) subtitle in
|
||||||
// Text subtitles win over bitmap ones in the same step. Returns 0 for no subtitle.
|
// the first of \a fallbackLanguages that has one, then one flagged default, then the
|
||||||
int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback);
|
// 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
|
// 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,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
|
|
||||||
#include "playlistmodel.h"
|
#include "playlistmodel.h"
|
||||||
|
|
||||||
|
#include "mediainfo.h"
|
||||||
|
|
||||||
#include <QCollator>
|
#include <QCollator>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QMimeDatabase>
|
#include <QMimeDatabase>
|
||||||
|
#include <QtConcurrentRun>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
@@ -15,6 +18,13 @@ using namespace Qt::Literals::StringLiterals;
|
|||||||
PlaylistModel::PlaylistModel(QObject *parent)
|
PlaylistModel::PlaylistModel(QObject *parent)
|
||||||
: QAbstractListModel(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
|
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();
|
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
|
||||||
case IsPlayingRole:
|
case IsPlayingRole:
|
||||||
return index.row() == m_playingIndex;
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -46,6 +60,8 @@ QHash<int, QByteArray> PlaylistModel::roleNames() const
|
|||||||
{UrlRole, "url"},
|
{UrlRole, "url"},
|
||||||
{NameRole, "name"},
|
{NameRole, "name"},
|
||||||
{IsPlayingRole, "isPlaying"},
|
{IsPlayingRole, "isPlaying"},
|
||||||
|
{TitleRole, "title"},
|
||||||
|
{DurationRole, "duration"},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +143,7 @@ void PlaylistModel::openFile(const QUrl &url, bool loadSiblings)
|
|||||||
m_playingIndex = -1;
|
m_playingIndex = -1;
|
||||||
endResetModel();
|
endResetModel();
|
||||||
Q_EMIT countChanged();
|
Q_EMIT countChanged();
|
||||||
|
probeDurations(m_urls);
|
||||||
play(std::max<int>(0, int(m_urls.indexOf(url))));
|
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);
|
m_urls.append(added);
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
Q_EMIT countChanged();
|
Q_EMIT countChanged();
|
||||||
|
probeDurations(added);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlaylistModel::replace(const QList<QUrl> &urls)
|
void PlaylistModel::replace(const QList<QUrl> &urls)
|
||||||
@@ -153,6 +171,7 @@ void PlaylistModel::replace(const QList<QUrl> &urls)
|
|||||||
m_playingIndex = -1;
|
m_playingIndex = -1;
|
||||||
endResetModel();
|
endResetModel();
|
||||||
Q_EMIT countChanged();
|
Q_EMIT countChanged();
|
||||||
|
probeDurations(m_urls);
|
||||||
play(0);
|
play(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,3 +293,25 @@ void PlaylistModel::setPlayingIndex(int index)
|
|||||||
}
|
}
|
||||||
Q_EMIT playingIndexChanged();
|
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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <QAbstractListModel>
|
#include <QAbstractListModel>
|
||||||
|
#include <QHash>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
|
#include <QThreadPool>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QtQml/qqmlregistration.h>
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
@@ -24,10 +26,15 @@ public:
|
|||||||
UrlRole = Qt::UserRole + 1,
|
UrlRole = Qt::UserRole + 1,
|
||||||
NameRole,
|
NameRole,
|
||||||
IsPlayingRole,
|
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)
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
explicit PlaylistModel(QObject *parent = nullptr);
|
explicit PlaylistModel(QObject *parent = nullptr);
|
||||||
|
~PlaylistModel() override;
|
||||||
|
|
||||||
int rowCount(const QModelIndex &parent = {}) const override;
|
int rowCount(const QModelIndex &parent = {}) const override;
|
||||||
QVariant data(const QModelIndex &index, int role) const override;
|
QVariant data(const QModelIndex &index, int role) const override;
|
||||||
@@ -66,7 +73,12 @@ Q_SIGNALS:
|
|||||||
private:
|
private:
|
||||||
QList<QUrl> expand(const QList<QUrl> &urls) const;
|
QList<QUrl> expand(const QList<QUrl> &urls) const;
|
||||||
void setPlayingIndex(int index);
|
void setPlayingIndex(int index);
|
||||||
|
void probeDurations(const QList<QUrl> &urls);
|
||||||
|
|
||||||
QList<QUrl> m_urls;
|
QList<QUrl> m_urls;
|
||||||
int m_playingIndex = -1;
|
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
@@ -24,7 +24,6 @@ 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)
|
||||||
@@ -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() {
|
function translateCurrentSubtitle() {
|
||||||
let track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
let track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
||||||
if (track && translatedSubtitleUrls.includes(track.externalFilename) && lastTranslationTrack) {
|
// Translate the original again, not the translation on screen.
|
||||||
track = video.subtitleTracks.find(track => track.id === lastTranslationTrack.id)
|
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.
|
// No subtitle shown, or a picture-based one: translate the best text subtitle.
|
||||||
if (!track || track.bitmap) {
|
if (!track || track.bitmap) {
|
||||||
@@ -141,7 +145,8 @@ ApplicationWindow {
|
|||||||
PlaylistModel {
|
PlaylistModel {
|
||||||
id: playlistModel
|
id: playlistModel
|
||||||
onPlayRequested: url => {
|
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)
|
RecentFilesModel.add(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,10 +174,7 @@ ApplicationWindow {
|
|||||||
TranslationService {
|
TranslationService {
|
||||||
id: translationService
|
id: translationService
|
||||||
onTranslated: (subtitleUrl, fromCache) => {
|
onTranslated: (subtitleUrl, fromCache) => {
|
||||||
// mpv exposes local external subtitle filenames as filesystem paths.
|
video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle())
|
||||||
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"))
|
||||||
@@ -187,7 +189,6 @@ ApplicationWindow {
|
|||||||
translationService.forgetTranslation()
|
translationService.forgetTranslation()
|
||||||
appWindow.lastTranslationTrack = null
|
appWindow.lastTranslationTrack = null
|
||||||
appWindow.lastTranslationVideo = ""
|
appWindow.lastTranslationVideo = ""
|
||||||
appWindow.translatedSubtitleUrls = []
|
|
||||||
glossaryDialog.close()
|
glossaryDialog.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-47
@@ -8,20 +8,23 @@ import QtQuick.Layouts
|
|||||||
import org.kde.ki18n
|
import org.kde.ki18n
|
||||||
import org.kde.kirigami as Kirigami
|
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 {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
required property int index
|
required property int index
|
||||||
required property url url
|
required property url url
|
||||||
required property string name
|
required property string name
|
||||||
|
required property string title
|
||||||
|
required property real duration
|
||||||
required property bool isPlaying
|
required property bool isPlaying
|
||||||
required property PlaylistModel playlist
|
required property PlaylistModel playlist
|
||||||
required property MpvItem mpv
|
required property MpvItem mpv
|
||||||
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
|
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
|
||||||
|
|
||||||
readonly property bool hovered: hoverHandler.hovered
|
readonly property bool hovered: hoverHandler.hovered
|
||||||
readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize
|
readonly property bool highlighted: root.isPlaying && !root.hovered
|
||||||
activeFocusOnTab: true
|
activeFocusOnTab: true
|
||||||
|
|
||||||
function activate() {
|
function activate() {
|
||||||
@@ -32,6 +35,18 @@ Item {
|
|||||||
contextMenu.popup()
|
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.onReturnPressed: root.activate()
|
||||||
Keys.onEnterPressed: root.activate()
|
Keys.onEnterPressed: root.activate()
|
||||||
Keys.onMenuPressed: root.showContextMenu()
|
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
|
implicitWidth: ListView.view.width
|
||||||
implicitHeight: fontSize * 3
|
implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2
|
||||||
|
|
||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hoverHandler
|
id: hoverHandler
|
||||||
@@ -71,7 +79,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
Kirigami.Theme.colorSet: Kirigami.Theme.View
|
Kirigami.Theme.colorSet: Kirigami.Theme.View
|
||||||
color: {
|
color: {
|
||||||
if (root.isPlaying && !root.hovered) {
|
if (root.highlighted) {
|
||||||
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
|
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
|
||||||
}
|
}
|
||||||
if (root.hovered) {
|
if (root.hovered) {
|
||||||
@@ -85,67 +93,75 @@ Item {
|
|||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Kirigami.Units.largeSpacing
|
||||||
|
anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
|
||||||
spacing: Kirigami.Units.largeSpacing
|
spacing: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
// Same width on every row, so the titles line up.
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
|
||||||
|
Kirigami.Units.iconSizes.small)
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
text: root.index + 1
|
text: root.index + 1
|
||||||
color: root.labelColor()
|
visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
|
||||||
visible: QtPotatoSettings.playlistShowRowNumber
|
color: Kirigami.Theme.disabledTextColor
|
||||||
font.pointSize: root.fontSize
|
font: Kirigami.Theme.smallFont
|
||||||
font.features: { "tnum": 1 }
|
}
|
||||||
horizontalAlignment: Qt.AlignHCenter
|
|
||||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
Kirigami.Icon {
|
||||||
Layout.preferredWidth: rowNumberMetrics.width
|
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 {
|
TextMetrics {
|
||||||
id: rowNumberMetrics
|
id: rowNumberMetrics
|
||||||
font.pointSize: root.fontSize
|
font: Kirigami.Theme.smallFont
|
||||||
text: String(root.playlist.count).replace(/./g, "0")
|
text: String(root.playlist.count).replace(/./g, "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
color: Qt.alpha(Kirigami.Theme.textColor, 0.2)
|
|
||||||
visible: QtPotatoSettings.playlistShowRowNumber
|
|
||||||
Layout.preferredWidth: 1
|
|
||||||
Layout.fillHeight: true
|
|
||||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
|
||||||
Layout.bottomMargin: Kirigami.Units.smallSpacing
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LabelWithTooltip {
|
LabelWithTooltip {
|
||||||
text: root.name
|
id: titleLabel
|
||||||
toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url
|
text: root.title
|
||||||
color: root.labelColor()
|
toolTipText: root.name
|
||||||
|
alwaysShowToolTip: root.title !== root.name
|
||||||
|
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.pointSize: root.fontSize
|
maximumLineCount: 1
|
||||||
font.bold: root.isPlaying
|
|
||||||
verticalAlignment: Qt.AlignVCenter
|
verticalAlignment: Qt.AlignVCenter
|
||||||
Layout.fillWidth: true
|
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")
|
text: KI18n.i18nc("@action:button remove playlist item", "Remove")
|
||||||
icon.name: "list-remove"
|
icon.name: "list-remove"
|
||||||
|
icon.width: Kirigami.Units.iconSizes.small
|
||||||
|
icon.height: Kirigami.Units.iconSizes.small
|
||||||
display: AbstractButton.IconOnly
|
display: AbstractButton.IconOnly
|
||||||
flat: true
|
focusPolicy: Qt.TabFocus
|
||||||
|
visible: root.hovered || root.activeFocus || activeFocus
|
||||||
|
padding: 0
|
||||||
Accessible.name: text
|
Accessible.name: text
|
||||||
ToolTip.text: text
|
ToolTip.text: text
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
ToolTip.delay: Kirigami.Units.toolTipDelay
|
ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2
|
Layout.fillHeight: true
|
||||||
Layout.rightMargin: Kirigami.Units.smallSpacing
|
Layout.preferredWidth: height
|
||||||
onClicked: root.playlist.remove(root.index)
|
onClicked: root.playlist.remove(root.index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#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>
|
||||||
|
|
||||||
@@ -39,28 +40,9 @@ 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";
|
||||||
|
|
||||||
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
|
QString cacheDir()
|
||||||
QString languageSuffix(const QString &language)
|
|
||||||
{
|
{
|
||||||
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
|
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
|
||||||
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)
|
||||||
@@ -273,6 +255,12 @@ 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;
|
||||||
@@ -319,10 +307,14 @@ 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(text.toUtf8()) < 0 || !output.commit()) {
|
if (!output.open(QIODevice::WriteOnly) || output.write(data) < 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +472,8 @@ 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 auto outputPathFor = [&](const GlossaryStore::Glossary &g) {
|
const QString suffix = TranslationStore::languageSuffix(config.targetLanguage);
|
||||||
|
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());
|
||||||
@@ -489,16 +482,13 @@ 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 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)) {
|
if (QFileInfo::exists(outputPath)) {
|
||||||
m_lastTranslationPath = outputPath;
|
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
||||||
m_job.reset();
|
|
||||||
setBusy(false);
|
|
||||||
setStatusText({});
|
|
||||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
|
|
||||||
co_return;
|
co_return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,13 +535,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
} else {
|
} else {
|
||||||
setStatusText(i18n("Glossary unavailable; translating without it."));
|
setStatusText(i18n("Glossary unavailable; translating without it."));
|
||||||
}
|
}
|
||||||
outputPath = outputPathFor(glossary);
|
configKey = configKeyFor(glossary);
|
||||||
|
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||||
if (QFileInfo::exists(outputPath)) {
|
if (QFileInfo::exists(outputPath)) {
|
||||||
m_lastTranslationPath = outputPath;
|
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
||||||
m_job.reset();
|
|
||||||
setBusy(false);
|
|
||||||
setStatusText({});
|
|
||||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
|
|
||||||
co_return;
|
co_return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,11 +597,38 @@ 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 = outputPath;
|
m_lastTranslationPath = published.path;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setStatusText({});
|
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)
|
QCoro::Task<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
|
|
||||||
class QNetworkAccessManager;
|
class QNetworkAccessManager;
|
||||||
|
|
||||||
// Translates a whole subtitle track with an LLM, batch by batch, and caches the result
|
// Translates a whole subtitle track with an LLM, batch by batch, caches the result as an
|
||||||
// as an .srt file that mpv can load as a subtitle track.
|
// .srt file and saves a copy next to the video (see TranslationStore).
|
||||||
class TranslationService : public QObject
|
class TranslationService : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -62,6 +62,9 @@ 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();
|
||||||
@@ -97,6 +100,8 @@ 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);
|
||||||
|
|||||||
@@ -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 ®istry, QString *error)
|
||||||
|
{
|
||||||
|
QJsonArray translations;
|
||||||
|
for (const Record &record : registry.records) {
|
||||||
|
if (!QFileInfo::exists(record.path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
translations.append(QJsonObject{{u"video"_s, record.video},
|
||||||
|
{u"language"_s, record.language},
|
||||||
|
{u"path"_s, record.path},
|
||||||
|
{u"configKey"_s, record.configKey},
|
||||||
|
{u"contentHash"_s, record.contentHash}});
|
||||||
|
}
|
||||||
|
const QJsonObject root{{u"version"_s, RegistryVersion}, {u"translations"_s, translations}};
|
||||||
|
|
||||||
|
QDir().mkpath(QFileInfo(file).absolutePath());
|
||||||
|
QSaveFile output(file);
|
||||||
|
if (!output.open(QIODevice::WriteOnly) || output.write(QJsonDocument(root).toJson()) < 0 || !output.commit()) {
|
||||||
|
if (error) {
|
||||||
|
*error = output.errorString();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString registryFile()
|
||||||
|
{
|
||||||
|
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/translations.json"_s;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isOwned(const Registry ®istry, const QString &path)
|
||||||
|
{
|
||||||
|
const auto record = registry.findPath(path);
|
||||||
|
if (!record) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto data = readFile(path);
|
||||||
|
return data && contentHash(*data) == record->contentHash.toLatin1();
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishResult publish(Registry ®istry,
|
||||||
|
const QUrl &videoUrl,
|
||||||
|
const QString &suffix,
|
||||||
|
const QString &configKey,
|
||||||
|
const QByteArray &data,
|
||||||
|
const QString &cacheFile)
|
||||||
|
{
|
||||||
|
const QString video = videoKey(videoUrl);
|
||||||
|
const QByteArray hash = contentHash(data);
|
||||||
|
PublishResult result{normalized(cacheFile), false};
|
||||||
|
|
||||||
|
if (videoUrl.isLocalFile()) {
|
||||||
|
QStringList candidates = sidecarPaths(videoUrl.toLocalFile(), suffix);
|
||||||
|
// Keep using the file written last time, so a translation does not move to
|
||||||
|
// ".vi.srt" and leave its ".qtpotato.vi.srt" copy behind.
|
||||||
|
if (const auto previous = registry.find(video, suffix);
|
||||||
|
previous && candidates.contains(previous->path) && isOwned(registry, previous->path)) {
|
||||||
|
candidates.removeAll(previous->path);
|
||||||
|
candidates.prepend(previous->path);
|
||||||
|
}
|
||||||
|
for (const QString &path : std::as_const(candidates)) {
|
||||||
|
if (QFileInfo::exists(path) && !isOwned(registry, path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto current = readFile(path);
|
||||||
|
if ((current && contentHash(*current) == hash) || writeFile(path, data)) {
|
||||||
|
result = {path, true};
|
||||||
|
}
|
||||||
|
// Both names are in the same folder; when one can't be written, neither can.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.put({video, suffix, result.path, configKey, QString::fromLatin1(hash)});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordRewrite(Registry ®istry, const QString &path, const QByteArray &data)
|
||||||
|
{
|
||||||
|
if (auto record = registry.findPath(path)) {
|
||||||
|
record->contentHash = QString::fromLatin1(contentHash(data));
|
||||||
|
registry.put(*record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString savedTranslation(const Registry ®istry, const QUrl &videoUrl, const QString &suffix)
|
||||||
|
{
|
||||||
|
const auto record = registry.find(videoKey(videoUrl), suffix);
|
||||||
|
return record && QFileInfo::exists(record->path) ? record->path : QString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ®istry, 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 ®istry, 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 ®istry,
|
||||||
|
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 ®istry, 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 ®istry, const QUrl &videoUrl, const QString &suffix);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user