Compare commits

...
8 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
25 changed files with 865 additions and 61 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
+5
View File
@@ -34,3 +34,8 @@ 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
)
+6
View File
@@ -33,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;
+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"
+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
+5
View File
@@ -278,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) {
+1
View File
@@ -59,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").
+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 }
}
}
+26 -3
View File
@@ -49,7 +49,8 @@ ApplicationWindow {
return KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)
}
function translateCurrentSubtitle() {
// 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)
// Translate the original again, not the translation on screen.
if (track && video.isTranslatedSubtitle(track.id)) {
@@ -60,6 +61,11 @@ ApplicationWindow {
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
@@ -128,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()
@@ -173,20 +187,28 @@ ApplicationWindow {
TranslationService {
id: translationService
currentVideo: video.currentUrl
onTranslated: (subtitleUrl, fromCache) => {
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 = ""
glossaryDialog.close()
@@ -246,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"
+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);
}
+220 -55
View File
@@ -1,6 +1,7 @@
#include "translationservice.h"
#include "apikeystore.h"
#include "mediainfo.h"
#include "qtpotatosettings.h"
#include "translationpresets.h"
#include "translationstore.h"
@@ -69,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 {
@@ -160,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();
@@ -323,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()
@@ -408,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);
@@ -419,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.
@@ -449,24 +583,30 @@ 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);
}
m_glossary = glossary;
m_glossaryEpisode = episode;
Q_EMIT glossaryChanged();
return true;
// 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();
} 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);
@@ -488,12 +628,11 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) {
finish(job, videoUrl, suffix, configKey, 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) {
@@ -518,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;
@@ -529,17 +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."));
}
configKey = configKeyFor(glossary);
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) {
finish(job, videoUrl, suffix, configKey, outputPath, true);
co_return;
co_return Outcome::ready(outputPath, suffix, configKey, true);
}
}
@@ -567,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;
@@ -578,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;
@@ -593,36 +730,64 @@ 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()));
}
finish(job, videoUrl, suffix, configKey, outputPath, false);
co_return Outcome::ready(outputPath, suffix, configKey, false);
}
void TranslationService::finish(const std::shared_ptr<Job> &job,
const QUrl &videoUrl,
const QString &suffix,
const QString &configKey,
const QString &cacheFile,
bool fromCache)
QString TranslationService::publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome)
{
QFile file(cacheFile);
QFile file(outcome.cacheFile);
if (!file.open(QIODevice::ReadOnly)) {
fail(job, i18n("Could not read the translation from %1: %2", cacheFile, file.errorString()));
return;
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, suffix, configKey, file.readAll(), cacheFile);
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 = published.path;
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(published.path), fromCache);
}
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
@@ -716,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());
}
}
+54 -3
View File
@@ -15,6 +15,7 @@
#include <atomic>
#include <memory>
#include <optional>
class QNetworkAccessManager;
@@ -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,9 +58,15 @@ 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();
@@ -83,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);
@@ -100,8 +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 in \a cacheFile where players find it and announces it.
void finish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache);
// 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);
@@ -122,4 +172,5 @@ private:
GlossaryStore::Glossary m_glossary;
QString m_glossaryEpisode;
QString m_lastTranslationPath;
QUrl m_currentVideo;
};