Compare commits

...
10 Commits
Author SHA1 Message Date
tri f2ba1d0100 ci: cut PR CI time with a prebuilt image, ccache, and no QML cachegen (#22)
CI image / image (push) Successful in 10m15s
CI / build (push) Successful in 1m51s
CI took about 5 min 10 s per PR on vm-201: 1.5 min of `pacman -Syu` and 3.5 min of build, half of it qmlcachegen compiling QML the tests never load. Now it takes **1 min 49 s with a cold ccache and 26 s with a warm one**.

- CI runs in a prebuilt Arch image with the build deps (`git.trivk.dev/tri/qtpotato-ci:latest`). `ci-image.yml` rebuilds it weekly, when its Dockerfile changes, or on demand. It builds on the runner's own Docker daemon, so vm-201 has the new image right away, since the runner does not re-pull tags it already has.
- CI configures with `QT_QML_NO_CACHEGEN=ON` and no debug info. Release and AUR builds keep both.
- ccache, kept between runs with `actions/cache`.
- Docs-only changes (`README.md`, `LICENSES/`) skip CI.

Pushing the image to the registry needed two workarounds. Gitea's Actions token cannot push container images, so it uses the `PACKAGE_TOKEN` secret from #21. Cloudflare rejects request bodies over 100 MB and `docker push` sends each layer in one request, so `regctl` (pinned by sha256) pushes the layers in 50 MB chunks.

Giving vm-201 more vCPUs is left out; it is an infra change.

Refs #20

Proof: on this PR, CI run 328 (cold ccache) took 1 min 49 s and run 332 (warm, 41/44 hits) took 26 s, both 8/8 tests. Image run 333 pushed the image, and the registry digest matches the runner's local image. A local build with the CI flags takes 74 steps instead of 154.

Reviewed with sol-loop: 3 rounds with gpt-6.1-sol. Sol verdict: approved

Created with Claude Opus 5.5 in Claude Code (T3 Code).
Reviewed-on: #22
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-14 10:39:36 +07:00
tri 8a99cf0f7c docs: install with pacman -Syu so the repo setup does not fail on a partial upgrade (#23)
CI / build (push) Successful in 5m54s
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-14 08:26:31 +07:00
tri 015626b65b ci: upload the Arch package with a package token, since the registry rejects the job token (#21)
CI / build (push) Successful in 6m17s
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-14 02:42:32 +07:00
tri c449402b04 ci: publish an Arch package to the Gitea registry so pacman users can install while the AUR is closed (#19)
CI / build (push) Successful in 4m45s
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-14 02:21:22 +07:00
tri 35cc4eb39b chore(aur): pin the qtpotato package to the v0.1.0 release (#18)
CI / build (push) Successful in 5m19s
Co-authored-by: Tri Vo Khac <git@trivk.dev>
2026-11-14 01:58:49 +07:00
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
38 changed files with 1923 additions and 153 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Arch Linux with QtPotato's build dependencies, so CI skips the pacman step.
# Built and pushed by .gitea/workflows/ci-image.yml.
FROM archlinux:latest
RUN pacman -Syu --noconfirm --needed git nodejs base-devel cmake ninja ccache extra-cmake-modules \
qt6-base qt6-declarative kirigami ki18n kcoreaddons kcrash kconfig \
mpvqt qcoro qtkeychain-qt6 ffmpeg \
&& rm -rf /var/cache/pacman/pkg/*
+58
View File
@@ -0,0 +1,58 @@
name: Arch package
# Builds the qtpotato package from a release tag and publishes it to this Gitea's Arch
# registry, so users can install it with pacman until it is on the AUR.
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Release tag to package, e.g. v0.1.0
required: true
permissions:
contents: read
jobs:
package:
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
runs-on: amd64
container: archlinux:latest
env:
TAG: ${{ inputs.tag || github.ref_name }}
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 hicolor-icon-theme
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- name: Build
run: |
version="${TAG#v}"
dir=/tmp/pkg
mkdir -p "$dir"
git config --global --add safe.directory "$PWD"
git archive --format=tar --prefix="qtpotato-$version/" HEAD | gzip -n > "$dir/qtpotato-$version.tar.gz"
sum="$(sha256sum "$dir/qtpotato-$version.tar.gz" | cut -d' ' -f1)"
sed -e "s/^pkgver=.*/pkgver=$version/" -e "s/^pkgrel=.*/pkgrel=1/" \
-e 's/^source=.*/source=("$pkgname-$pkgver.tar.gz")/' \
-e "s/^sha256sums=.*/sha256sums=('$sum')/" \
packaging/aur/qtpotato/PKGBUILD > "$dir/PKGBUILD"
# makepkg refuses to run as root.
useradd -m builder
chown -R builder "$dir"
su builder -c "cd $dir && makepkg --noconfirm"
- name: Publish
env:
# The package registry rejects the job token, so this is a personal access token
# with the write:package scope.
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
for f in /tmp/pkg/qtpotato-[0-9]*.pkg.tar.zst; do
curl -fsS -X PUT -H "Authorization: token $TOKEN" --upload-file "$f" \
"$GITHUB_SERVER_URL/api/packages/${GITHUB_REPOSITORY%%/*}/arch/qtpotato"
done
+40
View File
@@ -0,0 +1,40 @@
name: CI image
# Builds the Arch image that ci.yml runs in, with the build dependencies preinstalled.
# Rebuilt weekly to pick up Arch updates.
on:
push:
branches: [main]
paths: ['.gitea/ci-image/**', '.gitea/workflows/ci-image.yml']
schedule:
- cron: '0 3 * * 1'
workflow_dispatch:
jobs:
image:
# The build runs on the runner's own Docker daemon, so the amd64 runner has the new
# image locally right away. It does not re-pull tags it already has.
runs-on: amd64
env:
IMAGE: git.trivk.dev/tri/qtpotato-ci:latest
steps:
- uses: actions/checkout@v4
- name: Build
# No layer cache, so each rebuild picks up the week's Arch updates.
run: docker build --pull --no-cache -t "$IMAGE" .gitea/ci-image
- name: Push
# Gitea's Actions token cannot push to the container registry, so this uses an
# access token with the write:package scope. Cloudflare rejects request bodies over
# 100 MB and docker push sends each layer in one request, so regctl pushes the
# layers in 50 MB chunks instead.
env:
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
curl -fsSL -o /usr/local/bin/regctl \
https://github.com/regclient/regclient/releases/download/v0.11.7/regctl-linux-amd64
echo "3737fbbdd791e04e7a831640b681f3397a376498e80e2ad9b1b2d27296f239bc /usr/local/bin/regctl" | sha256sum -c
chmod +x /usr/local/bin/regctl
echo "$TOKEN" | regctl registry login git.trivk.dev -u "${{ github.repository_owner }}" --pass-stdin
regctl registry set git.trivk.dev --blob-chunk 52428800 --blob-max 52428800
docker save "$IMAGE" -o "$RUNNER_TEMP/image.tar"
regctl image import "$IMAGE" "$RUNNER_TEMP/image.tar"
+42
View File
@@ -0,0 +1,42 @@
name: CI
on:
push:
branches: [main]
paths-ignore: ['README.md', 'LICENSES/**']
pull_request:
paths-ignore: ['README.md', 'LICENSES/**']
jobs:
build:
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
runs-on: amd64
# Arch with the build dependencies preinstalled; see ci-image.yml.
container: git.trivk.dev/tri/qtpotato-ci:latest
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
# Keep hits across weekly image rebuilds when the compiler did not change.
CCACHE_COMPILERCHECK: content
CCACHE_MAXSIZE: 500M
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: .ccache
key: ccache-${{ github.sha }}
restore-keys: ccache-
- name: Configure
# The tests do not load QML, so skip QML cachegen and debug info: they take over
# half of the build. Release and AUR builds keep both.
run: |
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS_DEBUG=-O0 \
-DQT_QML_NO_CACHEGEN=ON -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DBUILD_TESTING=ON
- name: Build
run: |
ccache -z
cmake --build build
ccache -s
- 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
+36
View File
@@ -24,6 +24,42 @@ translate a whole subtitle track with any OpenAI-compatible API and show the res
auto-hiding controls.
- Follows your Plasma style, colours and icons.
## Installing on Arch Linux
### From the pacman repository
Every release is built and published to this Gitea's package registry. Import the signing key
once:
```sh
curl -O https://git.trivk.dev/api/packages/tri/arch/repository.key
sudo pacman-key --add repository.key
sudo pacman-key --lsign-key 9C48725295A5F54A6A491CF86B273DD49890E8A7
```
Then add the repository to `/etc/pacman.conf` and install:
```ini
[qtpotato]
SigLevel = Required
Server = https://git.trivk.dev/api/packages/tri/arch/$repo/$arch
```
```sh
sudo pacman -Syu qtpotato
```
The package is built against Arch's current Qt and KDE Frameworks. If it breaks after a system
update before a new release is out, build it from source as below.
### From the PKGBUILD
```sh
git clone https://git.trivk.dev/tri/QtPotato.git
cd QtPotato/packaging/aur/qtpotato # or qtpotato-git for the latest main
makepkg -si
```
## Building
### Dependencies
+16 -1
View File
@@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate)
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
TEST_NAME playlistmodeltest
LINK_LIBRARIES Qt6::Qml Qt6::Test
LINK_LIBRARIES qtpotato_core Qt6::Qml Qt6::Concurrent Qt6::Test
)
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
@@ -24,3 +24,18 @@ ecm_add_test(trackselectiontest.cpp
TEST_NAME trackselectiontest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
ecm_add_test(translationstoretest.cpp
TEST_NAME translationstoretest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
ecm_add_test(mediainfotest.cpp
TEST_NAME mediainfotest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
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 <QFile>
#include <QProcess>
#include <QSignalSpy>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTest>
@@ -31,6 +33,12 @@ private:
}
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()
{
QTemporaryDir dir;
@@ -98,6 +106,31 @@ private Q_SLOTS:
QCOMPARE(model.playingIndex(), -1);
QCOMPARE(model.count(), 1);
}
void titleAndDurationRoles()
{
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
if (ffmpeg.isEmpty()) {
QSKIP("ffmpeg is needed to make a sample file");
}
QTemporaryDir dir;
const QString path = dir.filePath(u"Show.S01E02.The.Tone.1080p.WEB.mka"_s);
QProcess process;
process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=2"_s, path});
QVERIFY(process.waitForFinished());
QCOMPARE(process.exitCode(), 0);
PlaylistModel model;
QSignalSpy changed(&model, &QAbstractItemModel::dataChanged);
model.append({QUrl::fromLocalFile(path), QUrl(u"https://example.com/a"_s)});
QCOMPARE(model.data(model.index(0), PlaylistModel::TitleRole).toString(), u"S01E02 · The Tone"_s);
QCOMPARE(model.data(model.index(1), PlaylistModel::TitleRole).toString(), u"https://example.com/a"_s);
QCOMPARE(model.data(model.index(1), PlaylistModel::DurationRole).toDouble(), -1.0);
QTRY_COMPARE(changed.count(), 1);
QCOMPARE(changed.first().at(2).value<QList<int>>(), QList<int>{PlaylistModel::DurationRole});
QVERIFY(qAbs(model.data(model.index(0), PlaylistModel::DurationRole).toDouble() - 2) < 0.1);
}
};
QTEST_GUILESS_MAIN(PlaylistModelTest)
+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);
}
// A saved translation is turned on when the file opens, over mpv's choice and the
// fallback, but not over a subtitle in a preferred language.
void picksSavedTranslation()
{
const QList<Track> tracks{sub(1, u"rus"_s), sub(2, u"rus"_s, u"subrip"_s, false, true), sub(3, u"eng"_s), sub(4, u"vi"_s)};
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, true, 4), 4);
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 2, true, 4), 4);
QCOMPARE(TrackSelection::pickSubtitle(tracks, {u"en"_s}, {u"en"_s}, 0, true, 4), 3);
// Automatic subtitles turned off: keep mpv's choice.
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, false, 4), 0);
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, true, 0), 3);
}
void picksAudioByPreference()
{
const QList<Track> tracks{sub(1, u"en"_s, u"aac"_s), sub(2, u"jpn"_s, u"aac"_s)};
+209
View File
@@ -0,0 +1,209 @@
#include "translationstore.h"
#include <QFile>
#include <QTemporaryDir>
#include <QTest>
using namespace Qt::Literals::StringLiterals;
namespace
{
void writeText(const QString &path, const QByteArray &data)
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(data);
}
QByteArray readText(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
}
class TranslationStoreTest : public QObject
{
Q_OBJECT
private:
QTemporaryDir m_dir;
QString m_videos;
QString m_cache;
QString m_video;
QUrl m_videoUrl;
// The translation as TranslationService leaves it in the cache before publishing.
QString cached(const QString &key, const QByteArray &data)
{
const QString path = TranslationStore::cachePath(m_cache, m_videoUrl, key, u"vi"_s);
writeText(path, data);
return path;
}
private Q_SLOTS:
void init()
{
QVERIFY(m_dir.isValid());
QDir root(m_dir.path());
root.removeRecursively();
QVERIFY(root.mkpath(u"videos"_s));
QVERIFY(root.mkpath(u"cache"_s));
m_videos = m_dir.filePath(u"videos"_s);
m_cache = m_dir.filePath(u"cache"_s);
m_video = m_videos + u"/Show.S01E01.mkv"_s;
writeText(m_video, "video");
m_videoUrl = QUrl::fromLocalFile(m_video);
}
void namesFiles()
{
QCOMPARE(TranslationStore::languageSuffix(u"Vietnamese"_s), u"vi"_s);
QCOMPARE(TranslationStore::languageSuffix(u"vietnamese"_s), u"vi"_s);
QCOMPARE(TranslationStore::languageSuffix(u"Klingon!"_s), u"klingon"_s);
QCOMPARE(TranslationStore::sidecarPaths(u"/films/Show.S01E01.mkv"_s, u"vi"_s),
QStringList({u"/films/Show.S01E01.vi.srt"_s, u"/films/Show.S01E01.qtpotato.vi.srt"_s}));
QCOMPARE(TranslationStore::cachePath(u"/cache"_s, QUrl::fromLocalFile(u"/films/Show.S01E01.mkv"_s), u"abc"_s, u"vi"_s),
u"/cache/Show.S01E01.abc.vi.srt"_s);
QCOMPARE(TranslationStore::cachePath(u"/cache"_s, QUrl(u"https://example.com/live.m3u8"_s), u"abc"_s, u"vi"_s),
u"/cache/subtitles.abc.vi.srt"_s);
QCOMPARE(TranslationStore::videoKey(QUrl(u"https://example.com/live.m3u8"_s)), u"https://example.com/live.m3u8"_s);
}
void writesNextToVideo()
{
TranslationStore::Registry registry;
const QString cache = cached(u"k1"_s, "one");
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cache);
const QString sidecar = m_videos + u"/Show.S01E01.vi.srt"_s;
QCOMPARE(result.path, sidecar);
QVERIFY(result.nextToVideo);
QCOMPARE(readText(sidecar), "one");
QVERIFY(TranslationStore::isOwned(registry, sidecar));
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), sidecar);
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"ja"_s), QString());
QCOMPARE(registry.find(TranslationStore::videoKey(m_videoUrl), u"vi"_s)->configKey, u"k1"_s);
}
// Different settings give a new translation, which replaces QtPotato's own file.
void replacesOwnFileWithNewSettings()
{
TranslationStore::Registry registry;
TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
QCOMPARE(result.path, m_videos + u"/Show.S01E01.vi.srt"_s);
QCOMPARE(readText(result.path), "two");
QCOMPARE(registry.records.size(), 1);
QCOMPARE(registry.records.constFirst().configKey, u"k2"_s);
QVERIFY(!QFile::exists(m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s));
}
void keepsUserSubtitle()
{
const QString userFile = m_videos + u"/Show.S01E01.vi.srt"_s;
writeText(userFile, "mine");
TranslationStore::Registry registry;
QVERIFY(!TranslationStore::isOwned(registry, userFile));
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
QCOMPARE(result.path, m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s);
QCOMPARE(readText(userFile), "mine");
QCOMPARE(readText(result.path), "one");
}
// A file QtPotato wrote and the user then edited counts as the user's.
void keepsEditedTranslation()
{
TranslationStore::Registry registry;
const auto first = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
writeText(first.path, "one, fixed by hand");
QVERIFY(!TranslationStore::isOwned(registry, first.path));
const auto second = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
QCOMPARE(second.path, m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s);
QCOMPARE(readText(first.path), "one, fixed by hand");
}
// Once on ".qtpotato.vi.srt", stay there instead of leaving a second copy behind.
void staysOnPreviousFile()
{
const QString userFile = m_videos + u"/Show.S01E01.vi.srt"_s;
writeText(userFile, "mine");
TranslationStore::Registry registry;
const auto first = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
QFile::remove(userFile);
const auto second = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
QCOMPARE(second.path, first.path);
QCOMPARE(readText(second.path), "two");
QVERIFY(!QFile::exists(userFile));
}
void fallsBackToCacheWhenFolderIsReadOnly()
{
QFile::setPermissions(m_videos, QFileDevice::ReadOwner | QFileDevice::ExeOwner);
auto restore = qScopeGuard([this] {
QFile::setPermissions(m_videos, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
});
if (QFileInfo(m_videos).isWritable()) {
QSKIP("Permissions are not enforced here (running as root?)");
}
TranslationStore::Registry registry;
const QString cache = cached(u"k1"_s, "one");
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cache);
QCOMPARE(result.path, cache);
QVERIFY(!result.nextToVideo);
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), cache);
}
void fallsBackToCacheForStreams()
{
const QUrl stream(u"https://example.com/live.m3u8"_s);
const QString cache = TranslationStore::cachePath(m_cache, stream, u"k1"_s, u"vi"_s);
writeText(cache, "one");
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, stream, u"vi"_s, u"k1"_s, "one", cache);
QCOMPARE(result.path, cache);
QCOMPARE(TranslationStore::savedTranslation(registry, stream, u"vi"_s), cache);
}
void glossaryRewriteKeepsOwnership()
{
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
writeText(result.path, "one, new glossary");
TranslationStore::recordRewrite(registry, result.path, "one, new glossary");
QVERIFY(TranslationStore::isOwned(registry, result.path));
}
void savesAndLoadsRegistry()
{
const QString file = m_dir.filePath(u"data/translations.json"_s);
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
// A record whose file is gone is not worth keeping.
registry.put({u"/gone.mkv"_s, u"vi"_s, u"/gone.vi.srt"_s, u"k0"_s, u"00"_s});
QVERIFY(TranslationStore::save(file, registry));
const TranslationStore::Registry loaded = TranslationStore::load(file);
QCOMPARE(loaded.records.size(), 1);
QVERIFY(TranslationStore::isOwned(loaded, result.path));
QCOMPARE(TranslationStore::savedTranslation(loaded, m_videoUrl, u"vi"_s), result.path);
QVERIFY(TranslationStore::load(m_dir.filePath(u"missing.json"_s)).records.isEmpty());
}
// The registry is no longer right about a file that disappeared.
void forgetsDeletedTranslation()
{
TranslationStore::Registry registry;
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
QFile::remove(result.path);
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), QString());
}
};
QTEST_GUILESS_MAIN(TranslationStoreTest)
#include "translationstoretest.moc"
+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 = 8ac4cd95e04880411f16009478765f698c1ba84538c776e68a431033e10ae837
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=('8ac4cd95e04880411f16009478765f698c1ba84538c776e68a431033e10ae837')
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
# can link it.
# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept
# apart from the app so autotests can link it.
add_library(qtpotato_core STATIC
media/mediainfo.cpp
media/mediainfo.h
mpv/mpvuserconfig.cpp
mpv/mpvuserconfig.h
mpv/trackselection.cpp
@@ -9,8 +11,10 @@ add_library(qtpotato_core STATIC
subtitles/subtitleparser.h
subtitles/subtitleextractor.cpp
subtitles/subtitleextractor.h
translate/translationstore.cpp
translate/translationstore.h
)
target_include_directories(qtpotato_core PUBLIC mpv subtitles)
target_include_directories(qtpotato_core PUBLIC media mpv subtitles translate)
target_link_libraries(qtpotato_core PUBLIC
Qt6::Core
KF6::I18n
+129
View File
@@ -0,0 +1,129 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "mediainfo.h"
#include <QRegularExpression>
#include <QSet>
#include <QStringList>
#include <algorithm>
extern "C" {
#include <libavformat/avformat.h>
}
using namespace Qt::Literals::StringLiterals;
namespace
{
// Words that start the release details in scene-style names; everything after them is dropped.
bool isReleaseTag(const QString &word)
{
static const QSet<QString> tags{
u"web"_s, u"web-dl"_s, u"webdl"_s, u"webrip"_s, u"bluray"_s, u"blu-ray"_s, u"bdrip"_s, u"brrip"_s, u"hdtv"_s,
u"dvdrip"_s, u"hdrip"_s, u"remux"_s, u"uhd"_s, u"dsnp"_s, u"amzn"_s, u"nf"_s, u"hmax"_s, u"atvp"_s,
u"hulu"_s, u"pcok"_s, u"dv"_s, u"hdr"_s, u"hdr10"_s, u"hdr10+"_s, u"sdr"_s, u"x264"_s, u"x265"_s,
u"h264"_s, u"h265"_s, u"hevc"_s, u"avc"_s, u"aac"_s, u"ac3"_s, u"dts"_s, u"ddp"_s, u"10bit"_s,
u"8bit"_s, u"repack"_s, u"proper"_s, u"internal"_s, u"imax"_s, u"multi"_s,
};
static const QRegularExpression pattern(uR"(^(\d{3,4}[pi]|[248]k|ddp?\d.*|h\d{3}|aac\d.*|dts-.*)$)"_s, QRegularExpression::CaseInsensitiveOption);
const QString lower = word.toLower();
return tags.contains(lower) || pattern.match(lower).hasMatch();
}
bool isYear(const QString &word)
{
static const QRegularExpression pattern(uR"(^\(?(19|20)\d\d\)?$)"_s);
return pattern.match(word).hasMatch();
}
// The words before the first release tag, joined by spaces.
QString untilReleaseTags(const QString &text)
{
QStringList kept;
const QStringList words = text.split(u' ', Qt::SkipEmptyParts);
for (const QString &word : words) {
if (isReleaseTag(word)) {
break;
}
kept.append(word);
}
// "Movie 2024" reads better as "Movie (2024)".
if (kept.size() > 1 && isYear(kept.last())) {
QString year = kept.takeLast();
year.remove(u'(').remove(u')');
kept.append(u"(%1)"_s.arg(year));
}
QString result = kept.join(u' ');
// Leftover separators like "Show -" when the tags followed a dash.
static const QRegularExpression trailing(uR"([\s\-–—:·]+$)"_s);
static const QRegularExpression leading(uR"(^[\s\-–—:·]+)"_s);
return result.remove(trailing).remove(leading);
}
}
namespace MediaInfo
{
QString cleanTitle(const QString &fileName)
{
QString base = fileName;
static const QRegularExpression extension(uR"(\.[A-Za-z0-9]{2,4}$)"_s);
base.remove(extension);
const QString fallback = base;
// "[Group] Name [1080p] (ABCD1234)": bracketed parts are release details.
static const QRegularExpression brackets(uR"(\[[^\]]*\]|\{[^}]*\})"_s);
base.remove(brackets);
// Parentheses too, when they hold release tags or a checksum, but not "(2024)".
static const QRegularExpression parens(uR"(\(([^)]*)\))"_s);
static const QRegularExpression wordSeparator(uR"([\s,]+)"_s);
static const QRegularExpression checksum(uR"(^[0-9A-Fa-f]{8}$)"_s);
for (auto it = parens.globalMatch(QString(base)); it.hasNext();) {
const auto match = it.next();
const QStringList words = match.captured(1).split(wordSeparator, Qt::SkipEmptyParts);
if (std::any_of(words.begin(), words.end(), isReleaseTag) || checksum.match(match.captured(1)).hasMatch()) {
base.replace(match.captured(0), u" "_s);
}
}
// Scene names use dots or underscores between words.
base.replace(u'_', u' ');
if (!base.trimmed().contains(u' ')) {
base.replace(u'.', u' ');
}
base = base.simplified();
static const QRegularExpression episode(uR"(\b[Ss](\d{1,2})[ ._-]?[Ee](\d{1,3})(?:-?[Ee]\d{1,3})*\b)"_s);
if (const auto match = episode.match(base); match.hasMatch()) {
const QString code = match.captured(0).toUpper().remove(u' ').remove(u'.').remove(u'_');
const QString episodeTitle = untilReleaseTags(base.mid(match.capturedEnd()));
if (!episodeTitle.isEmpty()) {
return u"%1 · %2"_s.arg(code, episodeTitle);
}
const QString show = untilReleaseTags(base.left(match.capturedStart()));
return show.isEmpty() ? code : u"%1 · %2"_s.arg(show, code);
}
const QString title = untilReleaseTags(base);
return title.isEmpty() ? fallback : title;
}
std::optional<double> probeDuration(const QString &path)
{
AVFormatContext *context = nullptr;
if (avformat_open_input(&context, path.toUtf8().constData(), nullptr, nullptr) < 0) {
return std::nullopt;
}
// Most containers store the length in their header; others need a look at the streams.
if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) {
avformat_find_stream_info(context, nullptr);
}
std::optional<double> seconds;
if (context->duration != AV_NOPTS_VALUE && context->duration > 0) {
seconds = double(context->duration) / AV_TIME_BASE;
}
avformat_close_input(&context);
return seconds;
}
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <optional>
namespace MediaInfo
{
// A short title for a media file name, without the release tags: "Show.S01E01.Pilot.1080p.WEB.mkv"
// becomes "S01E01 · Pilot" and "Movie.2024.2160p.BluRay.mkv" becomes "Movie (2024)". Returns the
// name without its extension when nothing better is found.
QString cleanTitle(const QString &fileName);
// Length of the media file at \a path in seconds, or nothing when it can't be read.
std::optional<double> probeDuration(const QString &path);
}
+69 -5
View File
@@ -7,6 +7,7 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLocale>
#include <QSaveFile>
#include <QStandardPaths>
@@ -44,6 +45,38 @@ QList<TrackSelection::Track> selectionTracks(const QVariantList &tracks)
return result;
}
QString subtitlePath(const QVariantList &tracks, int id)
{
for (const QVariant &entry : tracks) {
const QVariantMap track = entry.toMap();
if (track.value(QStringLiteral("id")).toInt() == id) {
return track.value(QStringLiteral("externalFilename")).toString();
}
}
return {};
}
int subtitleIdForPath(const QVariantList &tracks, const QString &path)
{
if (path.isEmpty()) {
return 0;
}
for (const QVariant &entry : tracks) {
const QVariantMap track = entry.toMap();
if (track.value(QStringLiteral("externalFilename")).toString() == path) {
return track.value(QStringLiteral("id")).toInt();
}
}
return 0;
}
// "movie.abc123.vi.srt" -> "vi", the way mpv tags subtitles it finds next to a video.
QString filenameLanguage(const QString &path)
{
const QStringList parts = QFileInfo(path).completeBaseName().split(QLatin1Char('.'));
return parts.size() > 1 ? parts.constLast() : QString();
}
QString trackLanguage(const QVariantList &tracks, int id)
{
for (const QVariant &entry : tracks) {
@@ -112,7 +145,7 @@ MpvItem::MpvItem(QQuickItem *parent)
// Before the first file, so its options apply from the start.
applyUserConfig();
if (m_pendingUrl.isValid()) {
loadFile(std::exchange(m_pendingUrl, {}));
loadFile(std::exchange(m_pendingUrl, {}), QString(m_openingTranslatedPath), QString(m_openingTranslatedTitle));
}
});
}
@@ -156,12 +189,18 @@ bool MpvItem::isFileLoaded() const
return m_fileLoaded;
}
void MpvItem::loadFile(const QUrl &url)
void MpvItem::loadFile(const QUrl &url, const QString &translatedSubtitle, const QString &translatedTitle)
{
if (!url.isValid()) {
return;
}
m_translatedPaths.clear();
m_openingTranslatedPath = translatedSubtitle;
m_openingTranslatedTitle = translatedTitle;
if (!translatedSubtitle.isEmpty()) {
m_translatedPaths.append(translatedSubtitle);
}
m_currentUrl = url;
Q_EMIT currentUrlChanged();
@@ -355,6 +394,9 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
return;
}
const QString path = localPathOrUrl(url);
if (!m_translatedPaths.contains(path)) {
m_translatedPaths.append(path);
}
for (const QVariant &entry : std::as_const(m_subtitleTracks)) {
const QVariantMap track = entry.toMap();
if (track.value(QStringLiteral("externalFilename")).toString() == path) {
@@ -370,11 +412,22 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
int MpvItem::translationSourceId() const
{
QVariantList sources;
for (const QVariant &entry : std::as_const(m_subtitleTracks)) {
if (!isTranslatedSubtitle(entry.toMap().value(QStringLiteral("id")).toInt())) {
sources.append(entry);
}
}
// English first: it is the usual original, and dubbed releases often default to
// subtitles translated from it.
return TrackSelection::pickTranslationSource(selectionTracks(m_subtitleTracks),
return TrackSelection::pickTranslationSource(selectionTracks(sources),
{QStringLiteral("en"), trackLanguage(m_audioTracks, m_audioId)},
m_subtitleId);
isTranslatedSubtitle(m_subtitleId) ? 0 : m_subtitleId);
}
bool MpvItem::isTranslatedSubtitle(int id) const
{
return id > 0 && m_translatedPaths.contains(subtitlePath(m_subtitleTracks, id));
}
void MpvItem::selectInitialTracks()
@@ -382,6 +435,16 @@ void MpvItem::selectInitialTracks()
// The observed track-list may still describe the previous file; read it directly.
updateTracks(getProperty(QStringLiteral("track-list")).toList());
// mpv finds a translation next to the video by itself (sub-auto); one kept in the
// cache is added here, before choosing, so it can be chosen.
const QString translated = std::exchange(m_openingTranslatedPath, {});
int translationId = subtitleIdForPath(m_subtitleTracks, translated);
if (!translated.isEmpty() && translationId == 0 && QFileInfo::exists(translated)) {
commandBlocking(QStringList{QStringLiteral("sub-add"), translated, QStringLiteral("auto"), m_openingTranslatedTitle, filenameLanguage(translated)});
updateTracks(getProperty(QStringLiteral("track-list")).toList());
translationId = subtitleIdForPath(m_subtitleTracks, translated);
}
const int audioId = TrackSelection::pickAudio(selectionTracks(m_audioTracks), m_preferredAudioLanguages, m_audioId);
if (audioId != m_audioId) {
setAudioId(audioId);
@@ -393,7 +456,8 @@ void MpvItem::selectInitialTracks()
m_preferredSubtitleLanguages,
fallbackLanguages,
m_subtitleId,
m_autoSelectSubtitle);
m_autoSelectSubtitle,
translationId);
if (subtitleId != m_subtitleId) {
setSubtitleId(subtitleId);
}
+11 -2
View File
@@ -98,7 +98,9 @@ public:
QStringList loadedUserConfigFiles() const;
QStringList skippedUserOptions() const;
Q_INVOKABLE void loadFile(const QUrl &url);
// \a translatedSubtitle is the file's saved translation (TranslationService). It is
// loaded with the file, named \a translatedTitle, and turned on (see TrackSelection).
Q_INVOKABLE void loadFile(const QUrl &url, const QString &translatedSubtitle = {}, const QString &translatedTitle = {});
// Unloads the current file.
Q_INVOKABLE void stop();
Q_INVOKABLE void togglePause();
@@ -114,8 +116,10 @@ public:
// already loaded.
Q_INVOKABLE void showTranslatedSubtitleFile(const QUrl &url, const QString &title);
// Id of the subtitle to translate: the shown one if it is text, else the best text
// subtitle. 0 when the file has no text subtitle.
// subtitle. Translations are skipped. 0 when the file has no text subtitle.
Q_INVOKABLE int translationSourceId() const;
// True for a subtitle QtPotato translated, as opposed to one to translate from.
Q_INVOKABLE bool isTranslatedSubtitle(int id) const;
Q_SIGNALS:
void currentUrlChanged();
@@ -174,6 +178,11 @@ private:
int m_chapter = -1;
// File added by showTranslatedSubtitleFile() that should be shown once mpv lists it.
QString m_pendingTranslatedPath;
// Translated subtitle files of the current file.
QStringList m_translatedPaths;
// Saved translation to load and turn on when the file opens, and its track title.
QString m_openingTranslatedPath;
QString m_openingTranslatedTitle;
bool m_loadUserConfig = false;
QStringList m_loadedUserConfigFiles;
QStringList m_skippedUserOptions;
+9 -1
View File
@@ -98,7 +98,12 @@ int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages,
return currentId;
}
int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback)
int pickSubtitle(const QList<Track> &tracks,
const QStringList &preferredLanguages,
const QStringList &fallbackLanguages,
int currentId,
bool fallback,
int translationId)
{
for (const QString &language : preferredLanguages) {
if (const int id = firstFull(tracks, [&](const Track &track) {
@@ -107,6 +112,9 @@ int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguag
return id;
}
}
if (fallback && translationId > 0) {
return translationId;
}
if (currentId > 0 || !fallback) {
return currentId;
}
+12 -5
View File
@@ -28,11 +28,18 @@ bool sameLanguage(const QString &a, const QString &b);
int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages, int currentId);
// The subtitle to turn on when the file opens. A track in one of \a preferredLanguages
// wins over mpv's own choice \a currentId; without one, \a currentId is kept. When mpv
// chose nothing and \a fallback is set, picks a full (not forced) subtitle in the first
// of \a fallbackLanguages that has one, then one flagged default, then the first one.
// Text subtitles win over bitmap ones in the same step. Returns 0 for no subtitle.
int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback);
// wins over mpv's own choice \a currentId. Next comes \a translationId, the file's
// saved translation, when \a fallback is set; without either, \a currentId is kept.
// When mpv chose nothing and \a fallback is set, picks a full (not forced) subtitle in
// the first of \a fallbackLanguages that has one, then one flagged default, then the
// first one. Text subtitles win over bitmap ones in the same step. Returns 0 for no
// subtitle.
int pickSubtitle(const QList<Track> &tracks,
const QStringList &preferredLanguages,
const QStringList &fallbackLanguages,
int currentId,
bool fallback,
int translationId = 0);
// The subtitle to translate: \a currentId when it is a text subtitle, else a full text
// subtitle in the first of \a sourceLanguages that has one, then one flagged default,
+46
View File
@@ -3,10 +3,13 @@
#include "playlistmodel.h"
#include "mediainfo.h"
#include <QCollator>
#include <QDir>
#include <QFileInfo>
#include <QMimeDatabase>
#include <QtConcurrentRun>
#include <algorithm>
@@ -15,6 +18,13 @@ using namespace Qt::Literals::StringLiterals;
PlaylistModel::PlaylistModel(QObject *parent)
: QAbstractListModel(parent)
{
// Reading many files at once from one disk is slower than a few at a time.
m_probePool.setMaxThreadCount(2);
}
PlaylistModel::~PlaylistModel()
{
m_probePool.clear();
}
int PlaylistModel::rowCount(const QModelIndex &parent) const
@@ -36,6 +46,10 @@ QVariant PlaylistModel::data(const QModelIndex &index, int role) const
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
case IsPlayingRole:
return index.row() == m_playingIndex;
case TitleRole:
return url.isLocalFile() ? MediaInfo::cleanTitle(url.fileName()) : url.toDisplayString();
case DurationRole:
return m_durations.value(url, -1);
}
return {};
}
@@ -46,6 +60,8 @@ QHash<int, QByteArray> PlaylistModel::roleNames() const
{UrlRole, "url"},
{NameRole, "name"},
{IsPlayingRole, "isPlaying"},
{TitleRole, "title"},
{DurationRole, "duration"},
};
}
@@ -127,6 +143,7 @@ void PlaylistModel::openFile(const QUrl &url, bool loadSiblings)
m_playingIndex = -1;
endResetModel();
Q_EMIT countChanged();
probeDurations(m_urls);
play(std::max<int>(0, int(m_urls.indexOf(url))));
}
@@ -140,6 +157,7 @@ void PlaylistModel::append(const QList<QUrl> &urls)
m_urls.append(added);
endInsertRows();
Q_EMIT countChanged();
probeDurations(added);
}
void PlaylistModel::replace(const QList<QUrl> &urls)
@@ -153,6 +171,7 @@ void PlaylistModel::replace(const QList<QUrl> &urls)
m_playingIndex = -1;
endResetModel();
Q_EMIT countChanged();
probeDurations(m_urls);
play(0);
}
@@ -259,6 +278,11 @@ QUrl PlaylistModel::urlAt(int index) const
return m_urls.value(index);
}
QList<QUrl> PlaylistModel::urls() const
{
return m_urls;
}
void PlaylistModel::setPlayingIndex(int index)
{
if (index == m_playingIndex) {
@@ -274,3 +298,25 @@ void PlaylistModel::setPlayingIndex(int index)
}
Q_EMIT playingIndexChanged();
}
void PlaylistModel::probeDurations(const QList<QUrl> &urls)
{
for (const QUrl &url : urls) {
// Streams are skipped: opening them can take long and costs bandwidth.
if (!url.isLocalFile() || m_durations.contains(url)) {
continue;
}
m_durations.insert(url, -1);
QtConcurrent::run(&m_probePool, MediaInfo::probeDuration, url.toLocalFile()).then(this, [this, url](std::optional<double> seconds) {
if (!seconds) {
return;
}
m_durations.insert(url, *seconds);
for (int row = 0; row < m_urls.size(); ++row) {
if (m_urls.at(row) == url) {
Q_EMIT dataChanged(index(row), index(row), {DurationRole});
}
}
});
}
}
+13
View File
@@ -4,7 +4,9 @@
#pragma once
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QThreadPool>
#include <QUrl>
#include <QtQml/qqmlregistration.h>
@@ -24,10 +26,15 @@ public:
UrlRole = Qt::UserRole + 1,
NameRole,
IsPlayingRole,
// Short name without release tags; see MediaInfo::cleanTitle().
TitleRole,
// Length in seconds, or -1 while unknown. Local files are read in the background.
DurationRole,
};
Q_ENUM(Roles)
explicit PlaylistModel(QObject *parent = nullptr);
~PlaylistModel() override;
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
@@ -52,6 +59,7 @@ public:
Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear();
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
// ("Episode 2" before "Episode 10").
@@ -66,7 +74,12 @@ Q_SIGNALS:
private:
QList<QUrl> expand(const QList<QUrl> &urls) const;
void setPlayingIndex(int index);
void probeDurations(const QList<QUrl> &urls);
QList<QUrl> m_urls;
int m_playingIndex = -1;
// Known lengths in seconds; -1 for files that couldn't be read or are still being read.
QHash<QUrl, double> m_durations;
// Declared last so it is destroyed first and waits for running probes.
QThreadPool m_probePool;
};
+8
View File
@@ -38,6 +38,7 @@ Item {
signal headerToggleRequested()
signal contextMenuRequested()
signal translateRequested()
signal translatePlaylistRequested()
signal editGlossaryRequested()
function seekBy(seconds: real) {
@@ -396,6 +397,13 @@ Item {
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 {
enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:")
text: KI18n.i18nc("@action", "Edit Translation Glossary…")
+1
View File
@@ -103,6 +103,7 @@ ToolBar {
MenuSeparator {}
AppMenuItem { action: root.actions.translateSubtitle }
AppMenuItem { action: root.actions.translatePlaylist }
AppMenuItem { action: root.actions.addSubtitle }
}
}
+36 -12
View File
@@ -24,7 +24,6 @@ ApplicationWindow {
// subtitle even while that translated track is selected in mpv.
property var lastTranslationTrack: null
property url lastTranslationVideo: ""
property var translatedSubtitleUrls: []
function openFile(url: url) {
playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings)
@@ -46,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)
if (track && translatedSubtitleUrls.includes(track.externalFilename) && lastTranslationTrack) {
track = video.subtitleTracks.find(track => track.id === lastTranslationTrack.id)
// Translate the original again, not the translation on screen.
if (track && video.isTranslatedSubtitle(track.id)) {
track = lastTranslationTrack ? video.subtitleTracks.find(track => track.id === lastTranslationTrack.id) : null
}
// No subtitle shown, or a picture-based one: translate the best text subtitle.
if (!track || track.bitmap) {
const sourceId = video.translationSourceId()
track = video.subtitleTracks.find(track => track.id === sourceId)
}
return track
}
function translateCurrentSubtitle() {
const track = translationSourceTrack()
if (track) {
lastTranslationTrack = track
lastTranslationVideo = video.currentUrl
@@ -124,6 +134,14 @@ ApplicationWindow {
onPlaylistToggleRequested: playlistPanel.toggle()
onContextMenuRequested: contextMenu.popup()
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: {
translationService.loadGlossary(video.currentUrl)
glossaryDialog.open()
@@ -141,7 +159,8 @@ ApplicationWindow {
PlaylistModel {
id: playlistModel
onPlayRequested: url => {
video.loadFile(url)
// A translation made earlier is loaded and turned on with the file.
video.loadFile(url, translationService.savedTranslation(url), appWindow.translatedTrackTitle())
RecentFilesModel.add(url)
}
}
@@ -168,26 +187,30 @@ ApplicationWindow {
TranslationService {
id: translationService
currentVideo: video.currentUrl
onTranslated: (subtitleUrl, fromCache) => {
// mpv exposes local external subtitle filenames as filesystem paths.
appWindow.translatedSubtitleUrls.push(decodeURIComponent(subtitleUrl.toString().replace(/^file:\/\//, "")))
video.showTranslatedSubtitleFile(subtitleUrl,
KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage))
video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle())
osdItem.message(fromCache
? KI18n.i18nc("@info:osd", "Loaded the saved translation")
: KI18n.i18nc("@info:osd", "Translation ready"))
}
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 {
id: video
// TranslationService follows the video itself (currentVideo).
onCurrentUrlChanged: {
translationService.cancel()
translationService.forgetTranslation()
appWindow.lastTranslationTrack = null
appWindow.lastTranslationVideo = ""
appWindow.translatedSubtitleUrls = []
glossaryDialog.close()
}
@@ -245,6 +268,7 @@ ApplicationWindow {
playlist: playlistModel
mpv: video
mainWindowWidth: appWindow.width
translateAction: appActions.translatePlaylist
fullscreen: appWindow.isFullScreen
height: video.height
onAddFilesRequested: addToPlaylistDialog.open()
+14
View File
@@ -17,6 +17,8 @@ Page {
required property PlaylistModel playlist
required property MpvItem mpv
required property int mainWindowWidth
// Translates the subtitles of every entry.
required property Action translateAction
property bool fullscreen: false
readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge
@@ -67,6 +69,18 @@ Page {
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 {
text: KI18n.i18nc("@action:button", "Scroll to Playing Item")
icon.name: "go-jump"
+63 -47
View File
@@ -8,20 +8,23 @@ import QtQuick.Layouts
import org.kde.ki18n
import org.kde.kirigami as Kirigami
// One row of the playlist: row number, playing icon and file name.
// One compact row of the playlist: row number (or playing icon), short title, length and,
// on hover or focus, a remove button.
Item {
id: root
required property int index
required property url url
required property string name
required property string title
required property real duration
required property bool isPlaying
required property PlaylistModel playlist
required property MpvItem mpv
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
readonly property bool hovered: hoverHandler.hovered
readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize
readonly property bool highlighted: root.isPlaying && !root.hovered
activeFocusOnTab: true
function activate() {
@@ -32,6 +35,18 @@ Item {
contextMenu.popup()
}
// "1:02:03" or "4:05".
function formatDuration(seconds: real): string {
if (seconds < 0) {
return ""
}
const total = Math.round(seconds)
const hours = Math.floor(total / 3600)
const minutes = Math.floor(total / 60) % 60
const secs = String(total % 60).padStart(2, "0")
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`
}
Keys.onReturnPressed: root.activate()
Keys.onEnterPressed: root.activate()
Keys.onMenuPressed: root.showContextMenu()
@@ -42,15 +57,8 @@ Item {
}
}
function labelColor(): color {
if (root.isPlaying && !root.hovered) {
return Kirigami.Theme.highlightedTextColor
}
return Kirigami.Theme.textColor
}
implicitWidth: ListView.view.width
implicitHeight: fontSize * 3
implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2
HoverHandler {
id: hoverHandler
@@ -71,7 +79,7 @@ Item {
anchors.fill: parent
Kirigami.Theme.colorSet: Kirigami.Theme.View
color: {
if (root.isPlaying && !root.hovered) {
if (root.highlighted) {
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
}
if (root.hovered) {
@@ -85,67 +93,75 @@ Item {
RowLayout {
anchors.fill: parent
anchors.leftMargin: Kirigami.Units.largeSpacing
anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
// Same width on every row, so the titles line up.
Item {
Layout.fillHeight: true
Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
Kirigami.Units.iconSizes.small)
Label {
anchors.centerIn: parent
text: root.index + 1
color: root.labelColor()
visible: QtPotatoSettings.playlistShowRowNumber
font.pointSize: root.fontSize
font.features: { "tnum": 1 }
horizontalAlignment: Qt.AlignHCenter
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.preferredWidth: rowNumberMetrics.width
visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
color: Kirigami.Theme.disabledTextColor
font: Kirigami.Theme.smallFont
}
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.small
height: Kirigami.Units.iconSizes.small
source: "media-playback-start"
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
visible: root.isPlaying
}
TextMetrics {
id: rowNumberMetrics
font.pointSize: root.fontSize
font: Kirigami.Theme.smallFont
text: String(root.playlist.count).replace(/./g, "0")
}
Rectangle {
color: Qt.alpha(Kirigami.Theme.textColor, 0.2)
visible: QtPotatoSettings.playlistShowRowNumber
Layout.preferredWidth: 1
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
}
Kirigami.Icon {
source: "media-playback-start"
color: root.labelColor()
visible: root.isPlaying
Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
}
LabelWithTooltip {
text: root.name
toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url
color: root.labelColor()
id: titleLabel
text: root.title
toolTipText: root.name
alwaysShowToolTip: root.title !== root.name
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
elide: Text.ElideRight
font.pointSize: root.fontSize
font.bold: root.isPlaying
maximumLineCount: 1
verticalAlignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0
Layout.rightMargin: Kirigami.Units.largeSpacing
}
Button {
Label {
text: root.formatDuration(root.duration)
visible: text !== ""
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.disabledTextColor
font: Kirigami.Theme.smallFont
}
ToolButton {
id: removeButton
text: KI18n.i18nc("@action:button remove playlist item", "Remove")
icon.name: "list-remove"
icon.width: Kirigami.Units.iconSizes.small
icon.height: Kirigami.Units.iconSizes.small
display: AbstractButton.IconOnly
flat: true
focusPolicy: Qt.TabFocus
visible: root.hovered || root.activeFocus || activeFocus
padding: 0
Accessible.name: text
ToolTip.text: text
ToolTip.visible: hovered
ToolTip.delay: Kirigami.Units.toolTipDelay
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2
Layout.rightMargin: Kirigami.Units.smallSpacing
Layout.fillHeight: true
Layout.preferredWidth: height
onClicked: root.playlist.remove(root.index)
}
}
+1
View File
@@ -28,6 +28,7 @@ AppMenu {
}
MenuSeparator {}
AppMenuItem { action: root.actions.translateSubtitle }
AppMenuItem { action: root.actions.translatePlaylist }
AppMenuItem { action: root.actions.editGlossary }
AppMenuItem { action: root.actions.cancelTranslation }
MenuSeparator {}
+37
View File
@@ -1,5 +1,7 @@
#include "subtitleextractor.h"
#include "trackselection.h"
#include <KLocalizedString>
#include <memory>
@@ -138,4 +140,39 @@ ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::a
}
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
// 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);
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);
}
+253 -74
View File
@@ -1,8 +1,10 @@
#include "translationservice.h"
#include "apikeystore.h"
#include "mediainfo.h"
#include "qtpotatosettings.h"
#include "translationpresets.h"
#include "translationstore.h"
#include <KLocalizedString>
@@ -39,28 +41,9 @@ constexpr int ParallelRequests = 3;
// Bump when the prompt changes so old cached translations are not reused.
constexpr auto CacheVersion = "llm-v2";
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language)
QString cacheDir()
{
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
const auto candidate = static_cast<QLocale::Language>(i);
if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) {
const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1);
return code.isEmpty() ? QLocale::languageToCode(candidate) : code;
}
}
QString suffix = language.toLower();
suffix.removeIf([](QChar c) {
return !c.isLetterOrNumber() && c != u'-';
});
return suffix.isEmpty() ? u"translated"_s : suffix;
}
QString cachePath(const QUrl &videoUrl, const QString &key, const QString &language)
{
const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
return u"%1/%2.%3.%4.srt"_s.arg(dir, baseName, key, languageSuffix(language));
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
}
QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries)
@@ -87,6 +70,10 @@ struct TranslationService::Job {
std::atomic_bool cancelled = false;
std::unique_ptr<LlmClient> client;
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 {
@@ -178,6 +165,25 @@ QString TranslationService::glossaryError() const
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)
{
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)) {
return;
}
// A translation the user has edited since is theirs; leave it alone.
const QString registryFile = TranslationStore::registryFile();
TranslationStore::Registry registry = TranslationStore::load(registryFile);
if (!TranslationStore::isOwned(registry, m_lastTranslationPath)) {
return;
}
QFile file(m_lastTranslationPath);
if (!file.open(QIODevice::ReadOnly)) {
return;
@@ -319,10 +331,14 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
text.replace(markers.at(i), replacements.at(i).second);
}
const QByteArray data = text.toUtf8();
QSaveFile output(m_lastTranslationPath);
if (!output.open(QIODevice::WriteOnly) || output.write(text.toUtf8()) < 0 || !output.commit()) {
if (!output.open(QIODevice::WriteOnly) || output.write(data) < 0 || !output.commit()) {
return;
}
// Still QtPotato's file, so a later translation may replace it.
TranslationStore::recordRewrite(registry, m_lastTranslationPath, data);
TranslationStore::save(registryFile, registry);
Q_EMIT translated(QUrl::fromLocalFile(m_lastTranslationPath), true);
}
@@ -331,7 +347,25 @@ void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &trac
cancel();
m_job = std::make_shared<Job>();
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()
@@ -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({});
setProgress(0);
setBusy(true);
@@ -427,29 +459,123 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
auto [config, configError] = co_await loadConfig();
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()) {
fail(job, configError);
co_return;
}
if (config.model.isEmpty()) {
fail(job, i18n("Choose a model in Settings first."));
co_return;
co_return std::nullopt;
}
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) {
co_return;
}
if (!source.error.isEmpty()) {
fail(job, source.error);
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
if (!error.isEmpty()) {
fail(job, error);
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);
if (cues.isEmpty()) {
fail(job, i18n("This subtitle track has no text to translate."));
co_return;
co_return Outcome::failed(i18n("This subtitle track has no text to translate."), true);
}
// 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();
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle;
const auto saveGlossaryGroups = [&]() {
// Returns an error message.
const auto saveGlossaryGroups = [&]() -> QString {
QString error;
if (!GlossaryStore::save(glossary, &error)) {
fail(job, i18n("Could not save the translation glossary: %1", error));
return false;
return i18n("Could not save the translation glossary: %1", error);
}
// Another episode of the playlist only updates the terms of the open series.
if (!job->playlist || videoUrl == m_currentVideo) {
m_glossary = glossary;
m_glossaryEpisode = episode;
Q_EMIT glossaryChanged();
return true;
} else if (m_glossary.directory == glossary.directory) {
m_glossary = glossary;
Q_EMIT glossaryChanged();
}
return {};
};
if (useGlossary) {
// New terms belong to the episode being translated. Migrate legacy or
// imported entries without provenance to this first episode too. This
// happens before the cache shortcut and never changes translation text.
glossary = GlossaryStore::assignUnassigned(glossary, episode);
if (!saveGlossaryGroups()) {
co_return;
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
co_return Outcome::failed(error);
}
}
const QString sourceSrt = Subtitles::toSrt(cues);
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once.
const auto outputPathFor = [&](const GlossaryStore::Glossary &g) {
const QString suffix = TranslationStore::languageSuffix(config.targetLanguage);
const auto configKeyFor = [&](const GlossaryStore::Glossary &g) {
QCryptographicHash hash(QCryptographicHash::Sha1);
hash.addData(CacheVersion);
hash.addData(config.model.toUtf8());
@@ -489,21 +622,17 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
hash.addData(config.customInstructions.toUtf8());
hash.addData(GlossaryStore::serialize(g).toUtf8());
hash.addData(sourceSrt.toUtf8());
return cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
return QString::fromLatin1(hash.result().toHex().left(12));
};
QString outputPath = outputPathFor(glossary);
QString configKey = configKeyFor(glossary);
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) {
m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
co_return;
co_return Outcome::ready(outputPath, suffix, configKey, true);
}
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
setStatusText(i18n("Building glossary…"));
setJobStatus(*job, i18n("Building glossary…"));
QStringList extractionLines;
QHash<QString, bool> seen;
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);
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
if (job->cancelled) {
co_return;
co_return Outcome{};
}
if (extracted.error.isEmpty()) {
QList<GlossaryStore::Entry> additions;
@@ -539,20 +668,16 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
// Suggestions extracted while translating this episode belong to
// this episode. Existing terms keep their original ownership.
glossary = GlossaryStore::merge(glossary, additions, episode);
if (!saveGlossaryGroups()) {
co_return;
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
co_return Outcome::failed(error);
}
} 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)) {
m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
co_return;
co_return Outcome::ready(outputPath, suffix, configKey, true);
}
}
@@ -580,7 +705,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
batches->lines = uniqueLines;
batches->translations.resize(uniqueLines.size());
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.
std::vector<QCoro::Task<>> workers;
@@ -591,11 +716,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
co_await std::move(worker);
}
if (job->cancelled) {
co_return;
co_return Outcome{};
}
if (!batches->error.isEmpty()) {
fail(job, batches->error);
co_return;
co_return Outcome::failed(batches->error);
}
QList<SubtitleCue> output = cues;
@@ -606,15 +730,70 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
QDir().mkpath(QFileInfo(outputPath).absolutePath());
QSaveFile file(outputPath);
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;
co_return Outcome::failed(i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
}
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_lastTranslationPath = outputPath;
setBusy(false);
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)
@@ -702,8 +881,8 @@ QCoro::Task<> TranslationService::translateBatches(std::shared_ptr<Job> job, std
batches->translations[start + i] = result.translations.at(i);
}
batches->doneLines += result.translations.size();
setProgress(double(batches->doneLines) / batches->lines.size());
setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size()));
setJobProgress(*job, double(batches->doneLines) / batches->lines.size());
setLinesStatus(*job, batches->doneLines, batches->lines.size());
}
}
+59 -3
View File
@@ -15,11 +15,12 @@
#include <atomic>
#include <memory>
#include <optional>
class QNetworkAccessManager;
// Translates a whole subtitle track with an LLM, batch by batch, and caches the result
// as an .srt file that mpv can load as a subtitle track.
// Translates a whole subtitle track with an LLM, batch by batch, caches the result as an
// .srt file and saves a copy next to the video (see TranslationStore).
class TranslationService : public QObject
{
Q_OBJECT
@@ -37,6 +38,9 @@ class TranslationService : public QObject
Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged)
Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged)
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:
explicit TranslationService(QObject *parent = nullptr);
@@ -54,14 +58,23 @@ public:
QString glossaryEpisode() const;
QStringList glossaryEpisodes() const;
QString glossaryError() const;
QUrl currentVideo() const;
void setCurrentVideo(const QUrl &url);
// \a track is an entry of MpvItem::subtitleTracks.
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();
// Asks the API configured in the settings for its models.
Q_INVOKABLE void fetchModels();
Q_INVOKABLE void clearError();
Q_INVOKABLE void forgetTranslation();
// Path of the translation of \a videoUrl into the target language set in Settings,
// or an empty string when there is none.
Q_INVOKABLE QString savedTranslation(const QUrl &videoUrl) const;
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
Q_INVOKABLE bool saveGlossary(const QVariantList &entries, const QStringList &episodes = {});
Q_INVOKABLE void clearGlossary();
@@ -80,12 +93,46 @@ Q_SIGNALS:
void glossaryChanged();
void glossaryErrorChanged();
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:
struct Job;
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();
// 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);
@@ -97,6 +144,14 @@ private:
// something is missing.
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
void fail(const std::shared_ptr<Job> &job, const QString &error);
// Saves the translation 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 setProgress(double progress);
@@ -117,4 +172,5 @@ private:
GlossaryStore::Glossary m_glossary;
QString m_glossaryEpisode;
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);
}