Compare commits

...
Author SHA1 Message Date
tri 5ec31e2d6d ci: drop the AppImage from releases
CI / build (pull_request) Successful in 5m12s
2026-11-14 01:02:25 +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
26 changed files with 1079 additions and 116 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
runs-on: amd64
container: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm --needed git nodejs base-devel cmake ninja extra-cmake-modules \
qt6-base qt6-declarative kirigami ki18n kcoreaddons kcrash kconfig \
mpvqt qcoro qtkeychain-qt6 ffmpeg
- uses: actions/checkout@v4
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON
- name: Build
run: cmake --build build
- name: Test
env:
QT_QPA_PLATFORM: offscreen
run: ctest --test-dir build --output-on-failure
+42
View File
@@ -0,0 +1,42 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
runs-on: amd64
container: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm --needed git nodejs curl jq
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Source tarball
run: |
git config --global --add safe.directory "$PWD"
version="${GITHUB_REF_NAME#v}"
mkdir -p dist
git archive --format=tar --prefix="qtpotato-$version/" "$GITHUB_REF_NAME" \
| gzip -n > "dist/qtpotato-$version.tar.gz"
- name: Publish release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd dist
sha256sum -- * > SHA256SUMS
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
id=$(jq -n --arg tag "$GITHUB_REF_NAME" '{tag_name: $tag, name: ("QtPotato " + ($tag | ltrimstr("v")))}' \
| curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
--data @- "$api/releases" | jq -r .id)
for f in *; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null
done
+11 -1
View File
@@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate)
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
TEST_NAME playlistmodeltest
LINK_LIBRARIES Qt6::Qml Qt6::Test
LINK_LIBRARIES qtpotato_core Qt6::Qml Qt6::Concurrent Qt6::Test
)
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
@@ -29,3 +29,13 @@ 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"
+31
View File
@@ -0,0 +1,31 @@
pkgbase = qtpotato-git
pkgdesc = KDE video player built on mpv that translates subtitles with an LLM (development version)
pkgver = 0.1.0
pkgrel = 1
url = https://git.trivk.dev/tri/QtPotato
arch = x86_64
license = GPL-3.0-or-later
makedepends = git
makedepends = cmake
makedepends = ninja
makedepends = extra-cmake-modules
depends = qt6-base
depends = qt6-declarative
depends = kirigami
depends = ki18n
depends = kcoreaddons
depends = kcrash
depends = kconfig
depends = mpvqt
depends = qcoro
depends = qtkeychain-qt6
depends = ffmpeg
depends = hicolor-icon-theme
optdepends = qqc2-desktop-style: Plasma look for the interface
optdepends = plasma-integration: Plasma file dialogs, colours and icons
provides = qtpotato=0.1.0
conflicts = qtpotato
source = qtpotato::git+https://git.trivk.dev/tri/QtPotato.git
sha256sums = SKIP
pkgname = qtpotato-git
+44
View File
@@ -0,0 +1,44 @@
# Maintainer: Tri Vo Khac
pkgname=qtpotato-git
_pkgname=qtpotato
pkgver=0.1.0
pkgrel=1
pkgdesc='KDE video player built on mpv that translates subtitles with an LLM (development version)'
arch=('x86_64')
url='https://git.trivk.dev/tri/QtPotato'
license=('GPL-3.0-or-later')
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'kcrash' 'kconfig'
'mpvqt' 'qcoro' 'qtkeychain-qt6' 'ffmpeg' 'hicolor-icon-theme')
makedepends=('git' 'cmake' 'ninja' 'extra-cmake-modules')
optdepends=('qqc2-desktop-style: Plasma look for the interface'
'plasma-integration: Plasma file dialogs, colours and icons')
provides=("$_pkgname=$pkgver")
conflicts=("$_pkgname")
source=("$_pkgname::git+$url.git")
sha256sums=('SKIP')
pkgver() {
cd "$_pkgname"
if git describe --tags --long >/dev/null 2>&1; then
git describe --tags --long --abbrev=7 | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g'
else
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)"
fi
}
build() {
cmake -B build -S "$_pkgname" -G Ninja \
-DCMAKE_BUILD_TYPE=None \
-DCMAKE_INSTALL_PREFIX=/usr \
-DBUILD_TESTING=ON
cmake --build build
}
check() {
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
+28
View File
@@ -0,0 +1,28 @@
pkgbase = qtpotato
pkgdesc = KDE video player built on mpv that translates subtitles with an LLM
pkgver = 0.1.0
pkgrel = 1
url = https://git.trivk.dev/tri/QtPotato
arch = x86_64
license = GPL-3.0-or-later
makedepends = cmake
makedepends = ninja
makedepends = extra-cmake-modules
depends = qt6-base
depends = qt6-declarative
depends = kirigami
depends = ki18n
depends = kcoreaddons
depends = kcrash
depends = kconfig
depends = mpvqt
depends = qcoro
depends = qtkeychain-qt6
depends = ffmpeg
depends = hicolor-icon-theme
optdepends = qqc2-desktop-style: Plasma look for the interface
optdepends = plasma-integration: Plasma file dialogs, colours and icons
source = https://git.trivk.dev/tri/QtPotato/releases/download/v0.1.0/qtpotato-0.1.0.tar.gz
sha256sums = SKIP
pkgname = qtpotato
+33
View File
@@ -0,0 +1,33 @@
# Maintainer: Tri Vo Khac
pkgname=qtpotato
pkgver=0.1.0
pkgrel=1
pkgdesc='KDE video player built on mpv that translates subtitles with an LLM'
arch=('x86_64')
url='https://git.trivk.dev/tri/QtPotato'
license=('GPL-3.0-or-later')
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'kcrash' 'kconfig'
'mpvqt' 'qcoro' 'qtkeychain-qt6' 'ffmpeg' 'hicolor-icon-theme')
makedepends=('cmake' 'ninja' 'extra-cmake-modules')
optdepends=('qqc2-desktop-style: Plasma look for the interface'
'plasma-integration: Plasma file dialogs, colours and icons')
source=("$url/releases/download/v$pkgver/$pkgname-$pkgver.tar.gz")
# Filled in by update-pkgbuild.sh from the release's SHA256SUMS.
sha256sums=('SKIP')
build() {
cmake -B build -S "$pkgname-$pkgver" -G Ninja \
-DCMAKE_BUILD_TYPE=None \
-DCMAKE_INSTALL_PREFIX=/usr \
-DBUILD_TESTING=ON
cmake --build build
}
check() {
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Point the qtpotato PKGBUILD at a published release and regenerate .SRCINFO.
#
# Usage: packaging/aur/update-pkgbuild.sh <version> (e.g. 0.2.0, after the v0.2.0 release)
set -euo pipefail
version="${1#v}"
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
url="https://git.trivk.dev/tri/QtPotato/releases/download/v$version"
sum="$(curl -fsSL "$url/SHA256SUMS" | awk -v f="qtpotato-$version.tar.gz" '$2 == f { print $1 }')"
[[ -n "$sum" ]] || { echo "qtpotato-$version.tar.gz not found in $url/SHA256SUMS" >&2; exit 1; }
cd "$dir/qtpotato"
sed -i -e "s/^pkgver=.*/pkgver=$version/" -e "s/^pkgrel=.*/pkgrel=1/" \
-e "s/^sha256sums=.*/sha256sums=('$sum')/" PKGBUILD
makepkg --printsrcinfo > .SRCINFO
cd "$dir/qtpotato-git"
makepkg --printsrcinfo > .SRCINFO
+3 -1
View File
@@ -1,6 +1,8 @@
# 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
@@ -12,7 +14,7 @@ add_library(qtpotato_core STATIC
translate/translationstore.cpp
translate/translationstore.h
)
target_include_directories(qtpotato_core PUBLIC mpv subtitles translate)
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);
}
+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 }
}
}
+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"
+69 -53
View File
@@ -8,20 +8,23 @@ import QtQuick.Layouts
import org.kde.ki18n
import org.kde.kirigami as Kirigami
// One row of the playlist: row number, playing icon and file name.
// One compact row of the playlist: row number (or playing icon), short title, length and,
// on hover or focus, a remove button.
Item {
id: root
required property int index
required property url url
required property string name
required property string title
required property real duration
required property bool isPlaying
required property PlaylistModel playlist
required property MpvItem mpv
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
readonly property bool hovered: hoverHandler.hovered
readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize
readonly property bool highlighted: root.isPlaying && !root.hovered
activeFocusOnTab: true
function activate() {
@@ -32,6 +35,18 @@ Item {
contextMenu.popup()
}
// "1:02:03" or "4:05".
function formatDuration(seconds: real): string {
if (seconds < 0) {
return ""
}
const total = Math.round(seconds)
const hours = Math.floor(total / 3600)
const minutes = Math.floor(total / 60) % 60
const secs = String(total % 60).padStart(2, "0")
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`
}
Keys.onReturnPressed: root.activate()
Keys.onEnterPressed: root.activate()
Keys.onMenuPressed: root.showContextMenu()
@@ -42,15 +57,8 @@ Item {
}
}
function labelColor(): color {
if (root.isPlaying && !root.hovered) {
return Kirigami.Theme.highlightedTextColor
}
return Kirigami.Theme.textColor
}
implicitWidth: ListView.view.width
implicitHeight: fontSize * 3
implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2
HoverHandler {
id: hoverHandler
@@ -71,7 +79,7 @@ Item {
anchors.fill: parent
Kirigami.Theme.colorSet: Kirigami.Theme.View
color: {
if (root.isPlaying && !root.hovered) {
if (root.highlighted) {
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
}
if (root.hovered) {
@@ -85,67 +93,75 @@ Item {
RowLayout {
anchors.fill: parent
anchors.leftMargin: Kirigami.Units.largeSpacing
anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
Label {
text: root.index + 1
color: root.labelColor()
visible: QtPotatoSettings.playlistShowRowNumber
font.pointSize: root.fontSize
font.features: { "tnum": 1 }
horizontalAlignment: Qt.AlignHCenter
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.preferredWidth: rowNumberMetrics.width
}
TextMetrics {
id: rowNumberMetrics
font.pointSize: root.fontSize
text: String(root.playlist.count).replace(/./g, "0")
}
Rectangle {
color: Qt.alpha(Kirigami.Theme.textColor, 0.2)
visible: QtPotatoSettings.playlistShowRowNumber
Layout.preferredWidth: 1
// Same width on every row, so the titles line up.
Item {
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
}
Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
Kirigami.Units.iconSizes.small)
Kirigami.Icon {
source: "media-playback-start"
color: root.labelColor()
visible: root.isPlaying
Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
Label {
anchors.centerIn: parent
text: root.index + 1
visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
color: Kirigami.Theme.disabledTextColor
font: Kirigami.Theme.smallFont
}
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.small
height: Kirigami.Units.iconSizes.small
source: "media-playback-start"
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
visible: root.isPlaying
}
TextMetrics {
id: rowNumberMetrics
font: Kirigami.Theme.smallFont
text: String(root.playlist.count).replace(/./g, "0")
}
}
LabelWithTooltip {
text: root.name
toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url
color: root.labelColor()
id: titleLabel
text: root.title
toolTipText: root.name
alwaysShowToolTip: root.title !== root.name
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
elide: Text.ElideRight
font.pointSize: root.fontSize
font.bold: root.isPlaying
maximumLineCount: 1
verticalAlignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0
Layout.rightMargin: Kirigami.Units.largeSpacing
}
Button {
Label {
text: root.formatDuration(root.duration)
visible: text !== ""
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.disabledTextColor
font: Kirigami.Theme.smallFont
}
ToolButton {
id: removeButton
text: KI18n.i18nc("@action:button remove playlist item", "Remove")
icon.name: "list-remove"
icon.width: Kirigami.Units.iconSizes.small
icon.height: Kirigami.Units.iconSizes.small
display: AbstractButton.IconOnly
flat: true
focusPolicy: Qt.TabFocus
visible: root.hovered || root.activeFocus || activeFocus
padding: 0
Accessible.name: text
ToolTip.text: text
ToolTip.visible: hovered
ToolTip.delay: Kirigami.Units.toolTipDelay
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2
Layout.rightMargin: Kirigami.Units.smallSpacing
Layout.fillHeight: true
Layout.preferredWidth: height
onClicked: root.playlist.remove(root.index)
}
}
+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;
};