Compare commits

...
5 Commits
Author SHA1 Message Date
tri fb7df904c5 ci: drop the AppImage from releases so uploads fit under Cloudflare's 100 MB limit (#17)
CI / build (push) Successful in 5m4s
Release / release (push) Successful in 19s
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-14 01:08:21 +07:00
tri 8c57c9ddb6 ci: build and test every PR, release on tags, and add AUR packages (#15)
CI / build (push) Successful in 5m11s
Release / release (push) Failing after 7m17s
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-14 00:18:16 +07:00
tri bcfc2f3f6a feat(translate): translate a whole playlist so the next episodes are ready (#16)
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-13 23:27:13 +07:00
tri ad50230829 feat(playlist): compact rows with clean titles and durations (#13)
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-13 22:54:32 +07:00
tri 76b12d6632 feat(translate): keep translated subtitles next to the video so they load on reopen (#14)
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-13 22:49:56 +07:00
34 changed files with 1776 additions and 162 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
runs-on: amd64
container: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm --needed git nodejs base-devel cmake ninja extra-cmake-modules \
qt6-base qt6-declarative kirigami ki18n kcoreaddons kcrash kconfig \
mpvqt qcoro qtkeychain-qt6 ffmpeg
- uses: actions/checkout@v4
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON
- name: Build
run: cmake --build build
- name: Test
env:
QT_QPA_PLATFORM: offscreen
run: ctest --test-dir build --output-on-failure
+42
View File
@@ -0,0 +1,42 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
runs-on: amd64
container: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm --needed git nodejs curl jq
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Source tarball
run: |
git config --global --add safe.directory "$PWD"
version="${GITHUB_REF_NAME#v}"
mkdir -p dist
git archive --format=tar --prefix="qtpotato-$version/" "$GITHUB_REF_NAME" \
| gzip -n > "dist/qtpotato-$version.tar.gz"
- name: Publish release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd dist
sha256sum -- * > SHA256SUMS
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
id=$(jq -n --arg tag "$GITHUB_REF_NAME" '{tag_name: $tag, name: ("QtPotato " + ($tag | ltrimstr("v")))}' \
| curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
--data @- "$api/releases" | jq -r .id)
for f in *; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null
done
+16 -1
View File
@@ -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,18 @@ 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
)
ecm_add_test(subtitleextractortest.cpp
TEST_NAME subtitleextractortest
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"
+33
View File
@@ -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>
@@ -31,6 +33,12 @@ private:
} }
private Q_SLOTS: private Q_SLOTS:
void initTestCase()
{
// The C locale's collation is case-sensitive, so the sort order would depend on $LANG.
QLocale::setDefault(QLocale(QLocale::English, QLocale::UnitedStates));
}
void sortsEpisodesNaturallyAndSkipsOtherFiles() void sortsEpisodesNaturallyAndSkipsOtherFiles()
{ {
QTemporaryDir dir; QTemporaryDir dir;
@@ -98,6 +106,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)
+97
View File
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "subtitleextractor.h"
#include <QFile>
#include <QProcess>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTest>
using namespace Qt::Literals::StringLiterals;
class SubtitleExtractorTest : public QObject
{
Q_OBJECT
private:
// Makes \a name in \a dir with a German audio track plus \a subtitleLanguages as
// SRT subtitle tracks, in that order.
static QString makeSample(const QTemporaryDir &dir, const QString &name, const QStringList &subtitleLanguages)
{
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
const QString srt = dir.filePath(u"line.srt"_s);
QFile file(srt);
if (!file.open(QIODevice::WriteOnly) || file.write("1\n00:00:00,000 --> 00:00:01,000\nHello\n") < 0) {
return {};
}
file.close();
QStringList args{u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=1"_s};
for (qsizetype i = 0; i < subtitleLanguages.size(); ++i) {
args << u"-i"_s << srt;
}
args << u"-map"_s << u"0:a"_s << u"-metadata:s:a:0"_s << u"language=ger"_s;
for (qsizetype i = 0; i < subtitleLanguages.size(); ++i) {
args << u"-map"_s << u"%1:s"_s.arg(i + 1) << u"-metadata:s:s:%1"_s.arg(i) << u"language=%1"_s.arg(subtitleLanguages.at(i));
}
const QString path = dir.filePath(name);
args << u"-c:s"_s << u"srt"_s << path;
QProcess process;
process.start(ffmpeg, args);
if (!process.waitForFinished() || process.exitCode() != 0) {
qWarning() << process.readAllStandardError();
return {};
}
return path;
}
private Q_SLOTS:
void initTestCase()
{
if (QStandardPaths::findExecutable(u"ffmpeg"_s).isEmpty()) {
QSKIP("ffmpeg is needed to make sample files");
}
}
void findTranslationSourcePrefersEnglish()
{
QTemporaryDir dir;
const QString path = makeSample(dir, u"episode.mkv"_s, {u"fre"_s, u"eng"_s});
QVERIFY(!path.isEmpty());
const Subtitles::SourceResult result = Subtitles::findTranslationSource(path);
QVERIFY2(result.error.isEmpty(), qPrintable(result.error));
// Stream 0 is the audio, 1 French, 2 English.
QCOMPARE(result.streamIndex, 2);
std::atomic_bool cancelled = false;
const Subtitles::ExtractResult cues = Subtitles::extractEmbedded(path, result.streamIndex, cancelled);
QCOMPARE(cues.cues.size(), 1);
QCOMPARE(cues.cues.first().text, u"Hello"_s);
}
void findTranslationSourceFallsBackToTheAudioLanguage()
{
QTemporaryDir dir;
const QString path = makeSample(dir, u"episode.mkv"_s, {u"fre"_s, u"ger"_s});
QVERIFY(!path.isEmpty());
QCOMPARE(Subtitles::findTranslationSource(path).streamIndex, 2);
}
void findTranslationSourceWithoutSubtitles()
{
QTemporaryDir dir;
const QString path = makeSample(dir, u"episode.mkv"_s, {});
QVERIFY(!path.isEmpty());
const Subtitles::SourceResult result = Subtitles::findTranslationSource(path);
QVERIFY(result.error.isEmpty());
QCOMPARE(result.streamIndex, -1);
QVERIFY(!Subtitles::findTranslationSource(dir.filePath(u"missing.mkv"_s)).error.isEmpty());
}
};
QTEST_GUILESS_MAIN(SubtitleExtractorTest)
#include "subtitleextractortest.moc"
+13
View File
@@ -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)};
+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"
+31
View File
@@ -0,0 +1,31 @@
pkgbase = qtpotato-git
pkgdesc = KDE video player built on mpv that translates subtitles with an LLM (development version)
pkgver = 0.1.0
pkgrel = 1
url = https://git.trivk.dev/tri/QtPotato
arch = x86_64
license = GPL-3.0-or-later
makedepends = git
makedepends = cmake
makedepends = ninja
makedepends = extra-cmake-modules
depends = qt6-base
depends = qt6-declarative
depends = kirigami
depends = ki18n
depends = kcoreaddons
depends = kcrash
depends = kconfig
depends = mpvqt
depends = qcoro
depends = qtkeychain-qt6
depends = ffmpeg
depends = hicolor-icon-theme
optdepends = qqc2-desktop-style: Plasma look for the interface
optdepends = plasma-integration: Plasma file dialogs, colours and icons
provides = qtpotato=0.1.0
conflicts = qtpotato
source = qtpotato::git+https://git.trivk.dev/tri/QtPotato.git
sha256sums = SKIP
pkgname = qtpotato-git
+44
View File
@@ -0,0 +1,44 @@
# Maintainer: Tri Vo Khac
pkgname=qtpotato-git
_pkgname=qtpotato
pkgver=0.1.0
pkgrel=1
pkgdesc='KDE video player built on mpv that translates subtitles with an LLM (development version)'
arch=('x86_64')
url='https://git.trivk.dev/tri/QtPotato'
license=('GPL-3.0-or-later')
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'kcrash' 'kconfig'
'mpvqt' 'qcoro' 'qtkeychain-qt6' 'ffmpeg' 'hicolor-icon-theme')
makedepends=('git' 'cmake' 'ninja' 'extra-cmake-modules')
optdepends=('qqc2-desktop-style: Plasma look for the interface'
'plasma-integration: Plasma file dialogs, colours and icons')
provides=("$_pkgname=$pkgver")
conflicts=("$_pkgname")
source=("$_pkgname::git+$url.git")
sha256sums=('SKIP')
pkgver() {
cd "$_pkgname"
if git describe --tags --long >/dev/null 2>&1; then
git describe --tags --long --abbrev=7 | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g'
else
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)"
fi
}
build() {
cmake -B build -S "$_pkgname" -G Ninja \
-DCMAKE_BUILD_TYPE=None \
-DCMAKE_INSTALL_PREFIX=/usr \
-DBUILD_TESTING=ON
cmake --build build
}
check() {
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
+28
View File
@@ -0,0 +1,28 @@
pkgbase = qtpotato
pkgdesc = KDE video player built on mpv that translates subtitles with an LLM
pkgver = 0.1.0
pkgrel = 1
url = https://git.trivk.dev/tri/QtPotato
arch = x86_64
license = GPL-3.0-or-later
makedepends = cmake
makedepends = ninja
makedepends = extra-cmake-modules
depends = qt6-base
depends = qt6-declarative
depends = kirigami
depends = ki18n
depends = kcoreaddons
depends = kcrash
depends = kconfig
depends = mpvqt
depends = qcoro
depends = qtkeychain-qt6
depends = ffmpeg
depends = hicolor-icon-theme
optdepends = qqc2-desktop-style: Plasma look for the interface
optdepends = plasma-integration: Plasma file dialogs, colours and icons
source = https://git.trivk.dev/tri/QtPotato/releases/download/v0.1.0/qtpotato-0.1.0.tar.gz
sha256sums = SKIP
pkgname = qtpotato
+33
View File
@@ -0,0 +1,33 @@
# Maintainer: Tri Vo Khac
pkgname=qtpotato
pkgver=0.1.0
pkgrel=1
pkgdesc='KDE video player built on mpv that translates subtitles with an LLM'
arch=('x86_64')
url='https://git.trivk.dev/tri/QtPotato'
license=('GPL-3.0-or-later')
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'kcrash' 'kconfig'
'mpvqt' 'qcoro' 'qtkeychain-qt6' 'ffmpeg' 'hicolor-icon-theme')
makedepends=('cmake' 'ninja' 'extra-cmake-modules')
optdepends=('qqc2-desktop-style: Plasma look for the interface'
'plasma-integration: Plasma file dialogs, colours and icons')
source=("$url/releases/download/v$pkgver/$pkgname-$pkgver.tar.gz")
# Filled in by update-pkgbuild.sh from the release's SHA256SUMS.
sha256sums=('SKIP')
build() {
cmake -B build -S "$pkgname-$pkgver" -G Ninja \
-DCMAKE_BUILD_TYPE=None \
-DCMAKE_INSTALL_PREFIX=/usr \
-DBUILD_TESTING=ON
cmake --build build
}
check() {
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Point the qtpotato PKGBUILD at a published release and regenerate .SRCINFO.
#
# Usage: packaging/aur/update-pkgbuild.sh <version> (e.g. 0.2.0, after the v0.2.0 release)
set -euo pipefail
version="${1#v}"
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
url="https://git.trivk.dev/tri/QtPotato/releases/download/v$version"
sum="$(curl -fsSL "$url/SHA256SUMS" | awk -v f="qtpotato-$version.tar.gz" '$2 == f { print $1 }')"
[[ -n "$sum" ]] || { echo "qtpotato-$version.tar.gz not found in $url/SHA256SUMS" >&2; exit 1; }
cd "$dir/qtpotato"
sed -i -e "s/^pkgver=.*/pkgver=$version/" -e "s/^pkgrel=.*/pkgrel=1/" \
-e "s/^sha256sums=.*/sha256sums=('$sum')/" PKGBUILD
makepkg --printsrcinfo > .SRCINFO
cd "$dir/qtpotato-git"
makepkg --printsrcinfo > .SRCINFO
+7 -3
View File
@@ -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
+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 <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
View File
@@ -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;
+9 -1
View File
@@ -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;
} }
+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); 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,
+46
View File
@@ -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);
} }
@@ -259,6 +278,11 @@ QUrl PlaylistModel::urlAt(int index) const
return m_urls.value(index); return m_urls.value(index);
} }
QList<QUrl> PlaylistModel::urls() const
{
return m_urls;
}
void PlaylistModel::setPlayingIndex(int index) void PlaylistModel::setPlayingIndex(int index)
{ {
if (index == m_playingIndex) { if (index == m_playingIndex) {
@@ -274,3 +298,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});
}
}
});
}
}
+13
View File
@@ -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;
@@ -52,6 +59,7 @@ public:
Q_INVOKABLE void move(int from, int to); Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear(); Q_INVOKABLE void clear();
Q_INVOKABLE QUrl urlAt(int index) const; Q_INVOKABLE QUrl urlAt(int index) const;
Q_INVOKABLE QList<QUrl> urls() const;
// Media files directly inside \a folder, sorted the way a file manager does // Media files directly inside \a folder, sorted the way a file manager does
// ("Episode 2" before "Episode 10"). // ("Episode 2" before "Episode 10").
@@ -66,7 +74,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;
}; };
+8
View File
@@ -38,6 +38,7 @@ Item {
signal headerToggleRequested() signal headerToggleRequested()
signal contextMenuRequested() signal contextMenuRequested()
signal translateRequested() signal translateRequested()
signal translatePlaylistRequested()
signal editGlossaryRequested() signal editGlossaryRequested()
function seekBy(seconds: real) { function seekBy(seconds: real) {
@@ -396,6 +397,13 @@ Item {
onTriggered: root.translateRequested() onTriggered: root.translateRequested()
} }
property Action translatePlaylist: Action {
enabled: root.shortcutsEnabled && root.playlist.count > 1 && !root.translator.busy
text: KI18n.i18nc("@action", "Translate Whole Playlist into %1", QtPotatoSettings.targetLanguage)
icon.name: "translate"
onTriggered: root.translatePlaylistRequested()
}
property Action editGlossary: Action { property Action editGlossary: Action {
enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:") enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:")
text: KI18n.i18nc("@action", "Edit Translation Glossary…") text: KI18n.i18nc("@action", "Edit Translation Glossary…")
+1
View File
@@ -103,6 +103,7 @@ ToolBar {
MenuSeparator {} MenuSeparator {}
AppMenuItem { action: root.actions.translateSubtitle } AppMenuItem { action: root.actions.translateSubtitle }
AppMenuItem { action: root.actions.translatePlaylist }
AppMenuItem { action: root.actions.addSubtitle } AppMenuItem { action: root.actions.addSubtitle }
} }
} }
+36 -12
View File
@@ -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,16 +45,27 @@ ApplicationWindow {
} }
} }
function translateCurrentSubtitle() { function translatedTrackTitle(): string {
return KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)
}
// The subtitle of the current video to translate, or undefined when it has no text one.
function translationSourceTrack() {
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) {
const sourceId = video.translationSourceId() const sourceId = video.translationSourceId()
track = video.subtitleTracks.find(track => track.id === sourceId) track = video.subtitleTracks.find(track => track.id === sourceId)
} }
return track
}
function translateCurrentSubtitle() {
const track = translationSourceTrack()
if (track) { if (track) {
lastTranslationTrack = track lastTranslationTrack = track
lastTranslationVideo = video.currentUrl lastTranslationVideo = video.currentUrl
@@ -124,6 +134,14 @@ ApplicationWindow {
onPlaylistToggleRequested: playlistPanel.toggle() onPlaylistToggleRequested: playlistPanel.toggle()
onContextMenuRequested: contextMenu.popup() onContextMenuRequested: contextMenu.popup()
onTranslateRequested: appWindow.translateCurrentSubtitle() onTranslateRequested: appWindow.translateCurrentSubtitle()
onTranslatePlaylistRequested: {
const track = appWindow.translationSourceTrack()
if (track) {
appWindow.lastTranslationTrack = track
appWindow.lastTranslationVideo = video.currentUrl
}
translationService.translatePlaylist(playlistModel.urls(), track ?? {}, video.mediaTitle)
}
onEditGlossaryRequested: { onEditGlossaryRequested: {
translationService.loadGlossary(video.currentUrl) translationService.loadGlossary(video.currentUrl)
glossaryDialog.open() glossaryDialog.open()
@@ -141,7 +159,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)
} }
} }
@@ -168,26 +187,30 @@ ApplicationWindow {
TranslationService { TranslationService {
id: translationService id: translationService
currentVideo: video.currentUrl
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"))
} }
onPlaylistTranslated: (translatedCount, alreadyTranslatedCount, problemCount) => {
// Skipped episodes are listed in the error message.
if (translatedCount > 0) {
osdItem.message(KI18n.i18ncp("@info:osd", "Translated %1 episode", "Translated %1 episodes", translatedCount))
} else if (problemCount === 0) {
osdItem.message(KI18n.i18nc("@info:osd", "Every episode is already translated"))
}
}
} }
MpvVideo { MpvVideo {
id: video id: video
// TranslationService follows the video itself (currentVideo).
onCurrentUrlChanged: { onCurrentUrlChanged: {
translationService.cancel()
translationService.forgetTranslation()
appWindow.lastTranslationTrack = null appWindow.lastTranslationTrack = null
appWindow.lastTranslationVideo = "" appWindow.lastTranslationVideo = ""
appWindow.translatedSubtitleUrls = []
glossaryDialog.close() glossaryDialog.close()
} }
@@ -245,6 +268,7 @@ ApplicationWindow {
playlist: playlistModel playlist: playlistModel
mpv: video mpv: video
mainWindowWidth: appWindow.width mainWindowWidth: appWindow.width
translateAction: appActions.translatePlaylist
fullscreen: appWindow.isFullScreen fullscreen: appWindow.isFullScreen
height: video.height height: video.height
onAddFilesRequested: addToPlaylistDialog.open() onAddFilesRequested: addToPlaylistDialog.open()
+14
View File
@@ -17,6 +17,8 @@ Page {
required property PlaylistModel playlist required property PlaylistModel playlist
required property MpvItem mpv required property MpvItem mpv
required property int mainWindowWidth required property int mainWindowWidth
// Translates the subtitles of every entry.
required property Action translateAction
property bool fullscreen: false property bool fullscreen: false
readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge
@@ -67,6 +69,18 @@ Page {
onClicked: root.addFilesRequested() onClicked: root.addFilesRequested()
} }
ToolButton {
action: root.translateAction
icon.width: root.buttonSize
icon.height: root.buttonSize
display: AbstractButton.IconOnly
focusPolicy: Qt.TabFocus
ToolTip.text: text
ToolTip.visible: hovered
ToolTip.delay: Kirigami.Units.toolTipDelay
}
ToolButton { ToolButton {
text: KI18n.i18nc("@action:button", "Scroll to Playing Item") text: KI18n.i18nc("@action:button", "Scroll to Playing Item")
icon.name: "go-jump" icon.name: "go-jump"
+69 -53
View File
@@ -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
Label { // Same width on every row, so the titles line up.
text: root.index + 1 Item {
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
Layout.fillHeight: true Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
Layout.bottomMargin: Kirigami.Units.smallSpacing Kirigami.Units.iconSizes.small)
}
Kirigami.Icon { Label {
source: "media-playback-start" anchors.centerIn: parent
color: root.labelColor() text: root.index + 1
visible: root.isPlaying visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing color: Kirigami.Theme.disabledTextColor
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium font: Kirigami.Theme.smallFont
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium }
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 { 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)
} }
} }
+1
View File
@@ -28,6 +28,7 @@ AppMenu {
} }
MenuSeparator {} MenuSeparator {}
AppMenuItem { action: root.actions.translateSubtitle } AppMenuItem { action: root.actions.translateSubtitle }
AppMenuItem { action: root.actions.translatePlaylist }
AppMenuItem { action: root.actions.editGlossary } AppMenuItem { action: root.actions.editGlossary }
AppMenuItem { action: root.actions.cancelTranslation } AppMenuItem { action: root.actions.cancelTranslation }
MenuSeparator {} MenuSeparator {}
+37
View File
@@ -1,5 +1,7 @@
#include "subtitleextractor.h" #include "subtitleextractor.h"
#include "trackselection.h"
#include <KLocalizedString> #include <KLocalizedString>
#include <memory> #include <memory>
@@ -138,4 +140,39 @@ ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::a
} }
return result; return result;
} }
SourceResult findTranslationSource(const QString &path)
{
AVFormatContext *rawFormat = nullptr;
if (const int ret = avformat_open_input(&rawFormat, path.toUtf8().constData(), nullptr, nullptr); ret < 0) {
return {-1, i18n("Could not open %1: %2", path, ffmpegError(ret))};
}
std::unique_ptr<AVFormatContext, FormatContextDeleter> format(rawFormat);
const auto language = [](const AVStream *stream) {
const AVDictionaryEntry *tag = av_dict_get(stream->metadata, "language", nullptr, 0);
return tag ? QString::fromUtf8(tag->value) : QString();
};
// Track ids start at 1, since 0 means "none"; id - 1 is the stream index.
QList<TrackSelection::Track> subtitles;
QString audioLanguage;
bool audioIsDefault = false;
for (unsigned i = 0; i < format->nb_streams; ++i) {
const AVStream *stream = format->streams[i];
const bool isDefault = stream->disposition & AV_DISPOSITION_DEFAULT;
if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
if (audioLanguage.isEmpty() || (isDefault && !audioIsDefault)) {
audioLanguage = language(stream);
audioIsDefault = isDefault;
}
} else if (stream->codecpar->codec_type == AVMEDIA_TYPE_SUBTITLE) {
subtitles.append({static_cast<int>(i) + 1,
language(stream),
QString::fromUtf8(avcodec_get_name(stream->codecpar->codec_id)),
bool(stream->disposition & AV_DISPOSITION_FORCED),
isDefault});
}
}
return {TrackSelection::pickTranslationSource(subtitles, {QStringLiteral("en"), audioLanguage}, 0) - 1, {}};
}
} }
+11
View File
@@ -14,4 +14,15 @@ struct ExtractResult {
// Decodes a text subtitle stream of a media file with FFmpeg. \a streamIndex is the // Decodes a text subtitle stream of a media file with FFmpeg. \a streamIndex is the
// FFmpeg stream index (mpv reports it as "ff-index"). Blocking; run it off the GUI thread. // FFmpeg stream index (mpv reports it as "ff-index"). Blocking; run it off the GUI thread.
ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::atomic_bool &cancelled); ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::atomic_bool &cancelled);
struct SourceResult {
// FFmpeg stream index of the subtitle to translate, or -1 when the file has no
// text subtitle.
int streamIndex = -1;
QString error;
};
// The embedded subtitle of a file that is not open in mpv to translate, chosen like
// MpvItem::translationSourceId(): English first, then the audio language. Blocking.
SourceResult findTranslationSource(const QString &path);
} }
+256 -77
View File
@@ -1,8 +1,10 @@
#include "translationservice.h" #include "translationservice.h"
#include "apikeystore.h" #include "apikeystore.h"
#include "mediainfo.h"
#include "qtpotatosettings.h" #include "qtpotatosettings.h"
#include "translationpresets.h" #include "translationpresets.h"
#include "translationstore.h"
#include <KLocalizedString> #include <KLocalizedString>
@@ -39,28 +41,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)
@@ -87,6 +70,10 @@ struct TranslationService::Job {
std::atomic_bool cancelled = false; std::atomic_bool cancelled = false;
std::unique_ptr<LlmClient> client; std::unique_ptr<LlmClient> client;
QString mediaTitle; QString mediaTitle;
bool playlist = false;
// In a playlist: the episode being translated (from 0) and how many there are.
int episode = 0;
int episodes = 0;
}; };
struct TranslationService::Batches { struct TranslationService::Batches {
@@ -178,6 +165,25 @@ QString TranslationService::glossaryError() const
return m_glossaryError; return m_glossaryError;
} }
QUrl TranslationService::currentVideo() const
{
return m_currentVideo;
}
void TranslationService::setCurrentVideo(const QUrl &url)
{
if (url == m_currentVideo) {
return;
}
m_currentVideo = url;
forgetTranslation();
// A playlist translation is meant to go on while the episodes play.
if (m_job && !m_job->playlist) {
cancel();
}
Q_EMIT currentVideoChanged();
}
void TranslationService::loadGlossary(const QUrl &videoUrl) void TranslationService::loadGlossary(const QUrl &videoUrl)
{ {
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString(); const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
@@ -273,6 +279,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 +331,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);
} }
@@ -331,7 +347,25 @@ void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &trac
cancel(); cancel();
m_job = std::make_shared<Job>(); m_job = std::make_shared<Job>();
m_job->mediaTitle = mediaTitle; m_job->mediaTitle = mediaTitle;
run(videoUrl, track); run(m_job, videoUrl, track);
}
void TranslationService::translatePlaylist(const QList<QUrl> &urls, const QVariantMap &currentTrack, const QString &currentTitle)
{
cancel();
// Start with the episode playing, then the ones after it, which are watched next.
QList<QUrl> ordered;
const qsizetype current = std::max<qsizetype>(0, urls.indexOf(m_currentVideo));
for (qsizetype i = 0; i < urls.size(); ++i) {
const QUrl &url = urls.at((current + i) % urls.size());
if (!ordered.contains(url)) {
ordered.append(url);
}
}
m_job = std::make_shared<Job>();
m_job->playlist = true;
m_job->episodes = ordered.size();
runPlaylist(m_job, ordered, currentTrack, currentTitle);
} }
void TranslationService::cancel() void TranslationService::cancel()
@@ -416,10 +450,8 @@ QCoro::Task<Subtitles::ExtractResult> TranslationService::loadCues(std::shared_p
}); });
} }
QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) QCoro::Task<std::optional<LlmClient::Config>> TranslationService::startJob(std::shared_ptr<Job> job)
{ {
const std::shared_ptr<Job> job = m_job;
setErrorText({}); setErrorText({});
setProgress(0); setProgress(0);
setBusy(true); setBusy(true);
@@ -427,29 +459,123 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
auto [config, configError] = co_await loadConfig(); auto [config, configError] = co_await loadConfig();
if (job->cancelled) { if (job->cancelled) {
co_return; co_return std::nullopt;
}
if (configError.isEmpty() && config.model.isEmpty()) {
configError = i18n("Choose a model in Settings first.");
} }
if (!configError.isEmpty()) { if (!configError.isEmpty()) {
fail(job, configError); fail(job, configError);
co_return; co_return std::nullopt;
}
if (config.model.isEmpty()) {
fail(job, i18n("Choose a model in Settings first."));
co_return;
} }
co_return config;
}
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track); QCoro::Task<> TranslationService::run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track)
{
const std::optional<LlmClient::Config> config = co_await startJob(job);
if (!config) {
co_return;
}
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
if (job->cancelled) { if (job->cancelled) {
co_return; co_return;
} }
if (!source.error.isEmpty()) { const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
fail(job, source.error); if (!error.isEmpty()) {
fail(job, error);
co_return; co_return;
} }
endJob(job);
}
QCoro::Task<> TranslationService::runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle)
{
const std::optional<LlmClient::Config> config = co_await startJob(job);
if (!config) {
co_return;
}
int translatedCount = 0;
int alreadyTranslatedCount = 0;
QStringList problems;
for (qsizetype i = 0; i < urls.size(); ++i) {
const QUrl videoUrl = urls.at(i);
const QString name = videoUrl.fileName();
job->episode = int(i);
setJobProgress(*job, 0);
setJobStatus(*job, i18n("Reading subtitles…"));
if (!savedTranslation(videoUrl).isEmpty()) {
++alreadyTranslatedCount;
continue;
}
QVariantMap track = currentTrack;
job->mediaTitle = currentTitle;
if (videoUrl != m_currentVideo || currentTrack.isEmpty()) {
if (!videoUrl.isLocalFile()) {
problems.append(i18nc("@info %1 is a file name", "%1: only subtitles of local files can be translated", name));
continue;
}
const QString path = videoUrl.toLocalFile();
const Subtitles::SourceResult source = co_await QtConcurrent::run([path] {
return Subtitles::findTranslationSource(path);
});
if (job->cancelled) {
co_return;
}
if (!source.error.isEmpty() || source.streamIndex < 0) {
problems.append(source.error.isEmpty() ? i18nc("@info %1 is a file name", "%1: no text subtitle", name)
: i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, source.error));
continue;
}
track = QVariantMap{{u"ffIndex"_s, source.streamIndex}};
job->mediaTitle = MediaInfo::cleanTitle(name);
}
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
if (job->cancelled) {
co_return;
}
if (!outcome.error.isEmpty() && !outcome.skippable) {
// Errors from the API or the disk would repeat for every episode.
fail(job, i18nc("@info %1 is a file name, %2 an error", "Stopped translating the playlist at %1: %2", name, outcome.error));
co_return;
}
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
if (error.isEmpty()) {
++translatedCount;
} else {
problems.append(i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, error));
}
}
endJob(job);
if (!problems.isEmpty()) {
setErrorText(i18np("Translated %2 of %3 episodes. Skipped one:\n%4",
"Translated %2 of %3 episodes. Skipped %1:\n%4",
problems.size(),
translatedCount + alreadyTranslatedCount,
urls.size(),
problems.join(u'\n')));
}
Q_EMIT playlistTranslated(translatedCount, alreadyTranslatedCount, int(problems.size()));
}
QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track)
{
setJobProgress(*job, 0);
setJobStatus(*job, i18n("Reading subtitles…"));
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
if (job->cancelled) {
co_return Outcome{};
}
if (!source.error.isEmpty()) {
co_return Outcome::failed(source.error, true);
}
const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues); const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues);
if (cues.isEmpty()) { if (cues.isEmpty()) {
fail(job, i18n("This subtitle track has no text to translate.")); co_return Outcome::failed(i18n("This subtitle track has no text to translate."), true);
co_return;
} }
// Episodes in one folder share a glossary. Streams have no folder, so they get none. // Episodes in one folder share a glossary. Streams have no folder, so they get none.
@@ -457,30 +583,37 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
const bool useGlossary = !seriesDirectory.isEmpty(); const bool useGlossary = !seriesDirectory.isEmpty();
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{}; GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle; const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle;
const auto saveGlossaryGroups = [&]() { // Returns an error message.
const auto saveGlossaryGroups = [&]() -> QString {
QString error; QString error;
if (!GlossaryStore::save(glossary, &error)) { if (!GlossaryStore::save(glossary, &error)) {
fail(job, i18n("Could not save the translation glossary: %1", error)); return i18n("Could not save the translation glossary: %1", error);
return false;
} }
m_glossary = glossary; // Another episode of the playlist only updates the terms of the open series.
m_glossaryEpisode = episode; if (!job->playlist || videoUrl == m_currentVideo) {
Q_EMIT glossaryChanged(); m_glossary = glossary;
return true; m_glossaryEpisode = episode;
Q_EMIT glossaryChanged();
} else if (m_glossary.directory == glossary.directory) {
m_glossary = glossary;
Q_EMIT glossaryChanged();
}
return {};
}; };
if (useGlossary) { if (useGlossary) {
// New terms belong to the episode being translated. Migrate legacy or // New terms belong to the episode being translated. Migrate legacy or
// imported entries without provenance to this first episode too. This // imported entries without provenance to this first episode too. This
// happens before the cache shortcut and never changes translation text. // happens before the cache shortcut and never changes translation text.
glossary = GlossaryStore::assignUnassigned(glossary, episode); glossary = GlossaryStore::assignUnassigned(glossary, episode);
if (!saveGlossaryGroups()) { if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
co_return; co_return Outcome::failed(error);
} }
} }
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,21 +622,17 @@ 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; co_return Outcome::ready(outputPath, suffix, configKey, true);
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
co_return;
} }
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) { if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
setStatusText(i18n("Building glossary…")); setJobStatus(*job, i18n("Building glossary…"));
QStringList extractionLines; QStringList extractionLines;
QHash<QString, bool> seen; QHash<QString, bool> seen;
for (const SubtitleCue &cue : cues) { for (const SubtitleCue &cue : cues) {
@@ -528,7 +657,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
job->client = std::make_unique<LlmClient>(m_network, config); job->client = std::make_unique<LlmClient>(m_network, config);
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle); const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
if (job->cancelled) { if (job->cancelled) {
co_return; co_return Outcome{};
} }
if (extracted.error.isEmpty()) { if (extracted.error.isEmpty()) {
QList<GlossaryStore::Entry> additions; QList<GlossaryStore::Entry> additions;
@@ -539,20 +668,16 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
// Suggestions extracted while translating this episode belong to // Suggestions extracted while translating this episode belong to
// this episode. Existing terms keep their original ownership. // this episode. Existing terms keep their original ownership.
glossary = GlossaryStore::merge(glossary, additions, episode); glossary = GlossaryStore::merge(glossary, additions, episode);
if (!saveGlossaryGroups()) { if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
co_return; co_return Outcome::failed(error);
} }
} else { } else {
setStatusText(i18n("Glossary unavailable; translating without it.")); setJobStatus(*job, 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; co_return Outcome::ready(outputPath, suffix, configKey, true);
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
co_return;
} }
} }
@@ -580,7 +705,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
batches->lines = uniqueLines; batches->lines = uniqueLines;
batches->translations.resize(uniqueLines.size()); batches->translations.resize(uniqueLines.size());
batches->batchSize = QtPotatoSettings::self()->batchSize(); batches->batchSize = QtPotatoSettings::self()->batchSize();
setStatusText(i18n("Translating… %1 of %2 lines", 0, uniqueLines.size())); setLinesStatus(*job, 0, uniqueLines.size());
// Tasks start running as soon as they are created; awaiting them just waits for the end. // Tasks start running as soon as they are created; awaiting them just waits for the end.
std::vector<QCoro::Task<>> workers; std::vector<QCoro::Task<>> workers;
@@ -591,11 +716,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
co_await std::move(worker); co_await std::move(worker);
} }
if (job->cancelled) { if (job->cancelled) {
co_return; co_return Outcome{};
} }
if (!batches->error.isEmpty()) { if (!batches->error.isEmpty()) {
fail(job, batches->error); co_return Outcome::failed(batches->error);
co_return;
} }
QList<SubtitleCue> output = cues; QList<SubtitleCue> output = cues;
@@ -606,15 +730,70 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
QDir().mkpath(QFileInfo(outputPath).absolutePath()); QDir().mkpath(QFileInfo(outputPath).absolutePath());
QSaveFile file(outputPath); QSaveFile file(outputPath);
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) { if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
fail(job, i18n("Could not save the translation to %1: %2", outputPath, file.errorString())); co_return Outcome::failed(i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
co_return;
} }
co_return Outcome::ready(outputPath, suffix, configKey, false);
}
QString TranslationService::publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome)
{
QFile file(outcome.cacheFile);
if (!file.open(QIODevice::ReadOnly)) {
return i18n("Could not read the translation from %1: %2", outcome.cacheFile, file.errorString());
}
// 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, outcome.suffix, outcome.configKey, file.readAll(), outcome.cacheFile);
TranslationStore::save(registryFile, registry);
// Other episodes of a playlist load their translation when they are opened.
if (!job->playlist || videoUrl == m_currentVideo) {
m_lastTranslationPath = published.path;
Q_EMIT translated(QUrl::fromLocalFile(published.path), outcome.fromCache);
}
return {};
}
void TranslationService::endJob(const std::shared_ptr<Job> &job)
{
if (m_job != job) {
return;
}
m_job.reset(); m_job.reset();
m_lastTranslationPath = outputPath;
setBusy(false); setBusy(false);
setStatusText({}); setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), false); }
void TranslationService::setJobStatus(const Job &job, const QString &text)
{
if (!job.playlist) {
setStatusText(text);
return;
}
setStatusText(i18nc("@info:status %1 and %2 are numbers, %3 is what is being done",
"Episode %1 of %2: %3",
job.episode + 1,
job.episodes,
text));
}
void TranslationService::setLinesStatus(const Job &job, qsizetype done, qsizetype total)
{
// The line count would not fit after the episode number; the progress bar shows it.
setJobStatus(job, job.playlist ? i18n("Translating…") : i18n("Translating… %1 of %2 lines", done, total));
}
void TranslationService::setJobProgress(const Job &job, double episodeProgress)
{
setProgress(job.playlist ? (job.episode + episodeProgress) / job.episodes : episodeProgress);
}
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)
@@ -702,8 +881,8 @@ QCoro::Task<> TranslationService::translateBatches(std::shared_ptr<Job> job, std
batches->translations[start + i] = result.translations.at(i); batches->translations[start + i] = result.translations.at(i);
} }
batches->doneLines += result.translations.size(); batches->doneLines += result.translations.size();
setProgress(double(batches->doneLines) / batches->lines.size()); setJobProgress(*job, double(batches->doneLines) / batches->lines.size());
setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size())); setLinesStatus(*job, batches->doneLines, batches->lines.size());
} }
} }
+59 -3
View File
@@ -15,11 +15,12 @@
#include <atomic> #include <atomic>
#include <memory> #include <memory>
#include <optional>
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
@@ -37,6 +38,9 @@ class TranslationService : public QObject
Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged) Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged)
Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged) Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged)
Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged) Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged)
// The video playing. Its translations are shown when ready; others are only saved.
// Opening another video stops a single translation but not a playlist one.
Q_PROPERTY(QUrl currentVideo READ currentVideo WRITE setCurrentVideo NOTIFY currentVideoChanged)
public: public:
explicit TranslationService(QObject *parent = nullptr); explicit TranslationService(QObject *parent = nullptr);
@@ -54,14 +58,23 @@ public:
QString glossaryEpisode() const; QString glossaryEpisode() const;
QStringList glossaryEpisodes() const; QStringList glossaryEpisodes() const;
QString glossaryError() const; QString glossaryError() const;
QUrl currentVideo() const;
void setCurrentVideo(const QUrl &url);
// \a track is an entry of MpvItem::subtitleTracks. // \a track is an entry of MpvItem::subtitleTracks.
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle); Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
// Translates every video in \a urls that has no saved translation yet, one after
// another, starting at the current video. The current one uses \a currentTrack when
// set; the others use the embedded subtitle findTranslationSource() picks.
Q_INVOKABLE void translatePlaylist(const QList<QUrl> &urls, const QVariantMap &currentTrack, const QString &currentTitle);
Q_INVOKABLE void cancel(); Q_INVOKABLE void cancel();
// Asks the API configured in the settings for its models. // Asks the API configured in the settings for its models.
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();
@@ -80,12 +93,46 @@ Q_SIGNALS:
void glossaryChanged(); void glossaryChanged();
void glossaryErrorChanged(); void glossaryErrorChanged();
void translated(const QUrl &subtitleUrl, bool fromCache); void translated(const QUrl &subtitleUrl, bool fromCache);
void currentVideoChanged();
// A playlist translation ended without being cancelled. Problems are in errorText.
void playlistTranslated(int translatedCount, int alreadyTranslatedCount, int problemCount);
private: private:
struct Job; struct Job;
struct Batches; struct Batches;
// Where translateVideo() left a translation, or why it could not make one.
struct Outcome {
QString cacheFile;
QString suffix;
QString configKey;
bool fromCache = false;
QString error;
// The error is about this video's subtitles; other videos may still work.
bool skippable = false;
QCoro::Task<> run(QUrl videoUrl, QVariantMap track); static Outcome ready(const QString &cacheFile, const QString &suffix, const QString &configKey, bool fromCache)
{
Outcome outcome;
outcome.cacheFile = cacheFile;
outcome.suffix = suffix;
outcome.configKey = configKey;
outcome.fromCache = fromCache;
return outcome;
}
static Outcome failed(const QString &error, bool skippable = false)
{
Outcome outcome;
outcome.error = error;
outcome.skippable = skippable;
return outcome;
}
};
QCoro::Task<> run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track);
QCoro::Task<> runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle);
// Reads the settings and the API key for \a job; nothing (after failing it) when they are not usable.
QCoro::Task<std::optional<LlmClient::Config>> startJob(std::shared_ptr<Job> job);
QCoro::Task<Outcome> translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track);
QCoro::Task<> runFetchModels(); QCoro::Task<> runFetchModels();
// Translates \a lines, retrying failures and asking again for lines the model skipped. // Translates \a lines, retrying failures and asking again for lines the model skipped.
QCoro::Task<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context); QCoro::Task<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context);
@@ -97,6 +144,14 @@ 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 where players find it and, for the current video, shows it.
// Returns an error message on failure.
QString publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome);
void endJob(const std::shared_ptr<Job> &job);
// In a playlist, prefixes the episode number and counts finished episodes in the progress.
void setJobStatus(const Job &job, const QString &text);
void setJobProgress(const Job &job, double episodeProgress);
void setLinesStatus(const Job &job, qsizetype done, qsizetype total);
void setBusy(bool busy); void setBusy(bool busy);
void setProgress(double progress); void setProgress(double progress);
@@ -117,4 +172,5 @@ private:
GlossaryStore::Glossary m_glossary; GlossaryStore::Glossary m_glossary;
QString m_glossaryEpisode; QString m_glossaryEpisode;
QString m_lastTranslationPath; QString m_lastTranslationPath;
QUrl m_currentVideo;
}; };
+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);
}