Compare commits
4
Commits
3e65f21fcb
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb7df904c5 | ||
|
|
8c57c9ddb6 | ||
|
|
bcfc2f3f6a | ||
|
|
ad50230829 |
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
|
||||||
|
runs-on: amd64
|
||||||
|
container: archlinux:latest
|
||||||
|
steps:
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pacman -Syu --noconfirm --needed git nodejs curl jq
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Source tarball
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
mkdir -p dist
|
||||||
|
git archive --format=tar --prefix="qtpotato-$version/" "$GITHUB_REF_NAME" \
|
||||||
|
| gzip -n > "dist/qtpotato-$version.tar.gz"
|
||||||
|
- name: Publish release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
sha256sum -- * > SHA256SUMS
|
||||||
|
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||||
|
id=$(jq -n --arg tag "$GITHUB_REF_NAME" '{tag_name: $tag, name: ("QtPotato " + ($tag | ltrimstr("v")))}' \
|
||||||
|
| curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
--data @- "$api/releases" | jq -r .id)
|
||||||
|
for f in *; do
|
||||||
|
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null
|
||||||
|
done
|
||||||
@@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate)
|
|||||||
|
|
||||||
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
|
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
|
||||||
TEST_NAME playlistmodeltest
|
TEST_NAME playlistmodeltest
|
||||||
LINK_LIBRARIES Qt6::Qml Qt6::Test
|
LINK_LIBRARIES qtpotato_core Qt6::Qml Qt6::Concurrent Qt6::Test
|
||||||
)
|
)
|
||||||
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
|
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
|
||||||
|
|
||||||
@@ -29,3 +29,13 @@ ecm_add_test(translationstoretest.cpp
|
|||||||
TEST_NAME translationstoretest
|
TEST_NAME translationstoretest
|
||||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "mediainfo.h"
|
||||||
|
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
using namespace Qt::Literals::StringLiterals;
|
||||||
|
|
||||||
|
class MediaInfoTest : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private Q_SLOTS:
|
||||||
|
void cleanTitle_data()
|
||||||
|
{
|
||||||
|
QTest::addColumn<QString>("fileName");
|
||||||
|
QTest::addColumn<QString>("title");
|
||||||
|
|
||||||
|
QTest::newRow("scene episode") << u"VisionQuest.S01E01.No.Strings.DSNP.DV.HDR.WEB-DL.DDP5.1.Atmos.H.265-GRP.mkv"_s
|
||||||
|
<< u"S01E01 · No Strings"_s;
|
||||||
|
QTest::newRow("episode without title") << u"The.Show.S02E10.1080p.WEB.h264-GRP.mkv"_s << u"The Show · S02E10"_s;
|
||||||
|
QTest::newRow("lowercase double episode") << u"show.s01e01e02.pilot.720p.hdtv.mkv"_s << u"S01E01E02 · pilot"_s;
|
||||||
|
QTest::newRow("spaced episode") << u"Mr. Robot - S01E01 - eps1.0_hellofriend.mov.mkv"_s << u"S01E01 · eps1.0 hellofriend.mov"_s;
|
||||||
|
QTest::newRow("movie with year") << u"Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP.mkv"_s << u"Dune Part Two (2024)"_s;
|
||||||
|
QTest::newRow("anime release") << u"[SubsPlease] Frieren - 05 (1080p) [A1B2C3D4].mkv"_s << u"Frieren - 05"_s;
|
||||||
|
QTest::newRow("plain name") << u"Holiday 2019 at the beach.mp4"_s << u"Holiday 2019 at the beach"_s;
|
||||||
|
QTest::newRow("music") << u"01 - Intro.flac"_s << u"01 - Intro"_s;
|
||||||
|
QTest::newRow("only tags") << u"1080p.mkv"_s << u"1080p"_s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanTitle()
|
||||||
|
{
|
||||||
|
QFETCH(QString, fileName);
|
||||||
|
QFETCH(QString, title);
|
||||||
|
QCOMPARE(MediaInfo::cleanTitle(fileName), title);
|
||||||
|
}
|
||||||
|
|
||||||
|
void probeDurationReadsTheLength()
|
||||||
|
{
|
||||||
|
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
|
||||||
|
if (ffmpeg.isEmpty()) {
|
||||||
|
QSKIP("ffmpeg is needed to make a sample file");
|
||||||
|
}
|
||||||
|
QTemporaryDir dir;
|
||||||
|
const QString path = dir.filePath(u"tone.mka"_s);
|
||||||
|
QProcess process;
|
||||||
|
process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=3"_s, path});
|
||||||
|
QVERIFY(process.waitForFinished());
|
||||||
|
QCOMPARE(process.exitCode(), 0);
|
||||||
|
|
||||||
|
const std::optional<double> seconds = MediaInfo::probeDuration(path);
|
||||||
|
QVERIFY(seconds);
|
||||||
|
QVERIFY2(qAbs(*seconds - 3) < 0.1, qPrintable(QString::number(*seconds)));
|
||||||
|
QVERIFY(!MediaInfo::probeDuration(dir.filePath(u"missing.mkv"_s)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(MediaInfoTest)
|
||||||
|
|
||||||
|
#include "mediainfotest.moc"
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
#include "playlistmodel.h"
|
#include "playlistmodel.h"
|
||||||
|
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QProcess>
|
||||||
#include <QSignalSpy>
|
#include <QSignalSpy>
|
||||||
|
#include <QStandardPaths>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QTest>
|
#include <QTest>
|
||||||
|
|
||||||
@@ -31,6 +33,12 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Q_SLOTS:
|
private Q_SLOTS:
|
||||||
|
void initTestCase()
|
||||||
|
{
|
||||||
|
// The C locale's collation is case-sensitive, so the sort order would depend on $LANG.
|
||||||
|
QLocale::setDefault(QLocale(QLocale::English, QLocale::UnitedStates));
|
||||||
|
}
|
||||||
|
|
||||||
void sortsEpisodesNaturallyAndSkipsOtherFiles()
|
void sortsEpisodesNaturallyAndSkipsOtherFiles()
|
||||||
{
|
{
|
||||||
QTemporaryDir dir;
|
QTemporaryDir dir;
|
||||||
@@ -98,6 +106,31 @@ private Q_SLOTS:
|
|||||||
QCOMPARE(model.playingIndex(), -1);
|
QCOMPARE(model.playingIndex(), -1);
|
||||||
QCOMPARE(model.count(), 1);
|
QCOMPARE(model.count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void titleAndDurationRoles()
|
||||||
|
{
|
||||||
|
const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s);
|
||||||
|
if (ffmpeg.isEmpty()) {
|
||||||
|
QSKIP("ffmpeg is needed to make a sample file");
|
||||||
|
}
|
||||||
|
QTemporaryDir dir;
|
||||||
|
const QString path = dir.filePath(u"Show.S01E02.The.Tone.1080p.WEB.mka"_s);
|
||||||
|
QProcess process;
|
||||||
|
process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=2"_s, path});
|
||||||
|
QVERIFY(process.waitForFinished());
|
||||||
|
QCOMPARE(process.exitCode(), 0);
|
||||||
|
|
||||||
|
PlaylistModel model;
|
||||||
|
QSignalSpy changed(&model, &QAbstractItemModel::dataChanged);
|
||||||
|
model.append({QUrl::fromLocalFile(path), QUrl(u"https://example.com/a"_s)});
|
||||||
|
QCOMPARE(model.data(model.index(0), PlaylistModel::TitleRole).toString(), u"S01E02 · The Tone"_s);
|
||||||
|
QCOMPARE(model.data(model.index(1), PlaylistModel::TitleRole).toString(), u"https://example.com/a"_s);
|
||||||
|
QCOMPARE(model.data(model.index(1), PlaylistModel::DurationRole).toDouble(), -1.0);
|
||||||
|
|
||||||
|
QTRY_COMPARE(changed.count(), 1);
|
||||||
|
QCOMPARE(changed.first().at(2).value<QList<int>>(), QList<int>{PlaylistModel::DurationRole});
|
||||||
|
QVERIFY(qAbs(model.data(model.index(0), PlaylistModel::DurationRole).toDouble() - 2) < 0.1);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
QTEST_GUILESS_MAIN(PlaylistModelTest)
|
QTEST_GUILESS_MAIN(PlaylistModelTest)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
+18
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept
|
# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept
|
||||||
# apart from the app so autotests can link it.
|
# apart from the app so autotests can link it.
|
||||||
add_library(qtpotato_core STATIC
|
add_library(qtpotato_core STATIC
|
||||||
|
media/mediainfo.cpp
|
||||||
|
media/mediainfo.h
|
||||||
mpv/mpvuserconfig.cpp
|
mpv/mpvuserconfig.cpp
|
||||||
mpv/mpvuserconfig.h
|
mpv/mpvuserconfig.h
|
||||||
mpv/trackselection.cpp
|
mpv/trackselection.cpp
|
||||||
@@ -12,7 +14,7 @@ add_library(qtpotato_core STATIC
|
|||||||
translate/translationstore.cpp
|
translate/translationstore.cpp
|
||||||
translate/translationstore.h
|
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
|
target_link_libraries(qtpotato_core PUBLIC
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
KF6::I18n
|
KF6::I18n
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "mediainfo.h"
|
||||||
|
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
using namespace Qt::Literals::StringLiterals;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Words that start the release details in scene-style names; everything after them is dropped.
|
||||||
|
bool isReleaseTag(const QString &word)
|
||||||
|
{
|
||||||
|
static const QSet<QString> tags{
|
||||||
|
u"web"_s, u"web-dl"_s, u"webdl"_s, u"webrip"_s, u"bluray"_s, u"blu-ray"_s, u"bdrip"_s, u"brrip"_s, u"hdtv"_s,
|
||||||
|
u"dvdrip"_s, u"hdrip"_s, u"remux"_s, u"uhd"_s, u"dsnp"_s, u"amzn"_s, u"nf"_s, u"hmax"_s, u"atvp"_s,
|
||||||
|
u"hulu"_s, u"pcok"_s, u"dv"_s, u"hdr"_s, u"hdr10"_s, u"hdr10+"_s, u"sdr"_s, u"x264"_s, u"x265"_s,
|
||||||
|
u"h264"_s, u"h265"_s, u"hevc"_s, u"avc"_s, u"aac"_s, u"ac3"_s, u"dts"_s, u"ddp"_s, u"10bit"_s,
|
||||||
|
u"8bit"_s, u"repack"_s, u"proper"_s, u"internal"_s, u"imax"_s, u"multi"_s,
|
||||||
|
};
|
||||||
|
static const QRegularExpression pattern(uR"(^(\d{3,4}[pi]|[248]k|ddp?\d.*|h\d{3}|aac\d.*|dts-.*)$)"_s, QRegularExpression::CaseInsensitiveOption);
|
||||||
|
const QString lower = word.toLower();
|
||||||
|
return tags.contains(lower) || pattern.match(lower).hasMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isYear(const QString &word)
|
||||||
|
{
|
||||||
|
static const QRegularExpression pattern(uR"(^\(?(19|20)\d\d\)?$)"_s);
|
||||||
|
return pattern.match(word).hasMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The words before the first release tag, joined by spaces.
|
||||||
|
QString untilReleaseTags(const QString &text)
|
||||||
|
{
|
||||||
|
QStringList kept;
|
||||||
|
const QStringList words = text.split(u' ', Qt::SkipEmptyParts);
|
||||||
|
for (const QString &word : words) {
|
||||||
|
if (isReleaseTag(word)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
kept.append(word);
|
||||||
|
}
|
||||||
|
// "Movie 2024" reads better as "Movie (2024)".
|
||||||
|
if (kept.size() > 1 && isYear(kept.last())) {
|
||||||
|
QString year = kept.takeLast();
|
||||||
|
year.remove(u'(').remove(u')');
|
||||||
|
kept.append(u"(%1)"_s.arg(year));
|
||||||
|
}
|
||||||
|
QString result = kept.join(u' ');
|
||||||
|
// Leftover separators like "Show -" when the tags followed a dash.
|
||||||
|
static const QRegularExpression trailing(uR"([\s\-–—:·]+$)"_s);
|
||||||
|
static const QRegularExpression leading(uR"(^[\s\-–—:·]+)"_s);
|
||||||
|
return result.remove(trailing).remove(leading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace MediaInfo
|
||||||
|
{
|
||||||
|
QString cleanTitle(const QString &fileName)
|
||||||
|
{
|
||||||
|
QString base = fileName;
|
||||||
|
static const QRegularExpression extension(uR"(\.[A-Za-z0-9]{2,4}$)"_s);
|
||||||
|
base.remove(extension);
|
||||||
|
const QString fallback = base;
|
||||||
|
|
||||||
|
// "[Group] Name [1080p] (ABCD1234)": bracketed parts are release details.
|
||||||
|
static const QRegularExpression brackets(uR"(\[[^\]]*\]|\{[^}]*\})"_s);
|
||||||
|
base.remove(brackets);
|
||||||
|
// Parentheses too, when they hold release tags or a checksum, but not "(2024)".
|
||||||
|
static const QRegularExpression parens(uR"(\(([^)]*)\))"_s);
|
||||||
|
static const QRegularExpression wordSeparator(uR"([\s,]+)"_s);
|
||||||
|
static const QRegularExpression checksum(uR"(^[0-9A-Fa-f]{8}$)"_s);
|
||||||
|
for (auto it = parens.globalMatch(QString(base)); it.hasNext();) {
|
||||||
|
const auto match = it.next();
|
||||||
|
const QStringList words = match.captured(1).split(wordSeparator, Qt::SkipEmptyParts);
|
||||||
|
if (std::any_of(words.begin(), words.end(), isReleaseTag) || checksum.match(match.captured(1)).hasMatch()) {
|
||||||
|
base.replace(match.captured(0), u" "_s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scene names use dots or underscores between words.
|
||||||
|
base.replace(u'_', u' ');
|
||||||
|
if (!base.trimmed().contains(u' ')) {
|
||||||
|
base.replace(u'.', u' ');
|
||||||
|
}
|
||||||
|
base = base.simplified();
|
||||||
|
|
||||||
|
static const QRegularExpression episode(uR"(\b[Ss](\d{1,2})[ ._-]?[Ee](\d{1,3})(?:-?[Ee]\d{1,3})*\b)"_s);
|
||||||
|
if (const auto match = episode.match(base); match.hasMatch()) {
|
||||||
|
const QString code = match.captured(0).toUpper().remove(u' ').remove(u'.').remove(u'_');
|
||||||
|
const QString episodeTitle = untilReleaseTags(base.mid(match.capturedEnd()));
|
||||||
|
if (!episodeTitle.isEmpty()) {
|
||||||
|
return u"%1 · %2"_s.arg(code, episodeTitle);
|
||||||
|
}
|
||||||
|
const QString show = untilReleaseTags(base.left(match.capturedStart()));
|
||||||
|
return show.isEmpty() ? code : u"%1 · %2"_s.arg(show, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString title = untilReleaseTags(base);
|
||||||
|
return title.isEmpty() ? fallback : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<double> probeDuration(const QString &path)
|
||||||
|
{
|
||||||
|
AVFormatContext *context = nullptr;
|
||||||
|
if (avformat_open_input(&context, path.toUtf8().constData(), nullptr, nullptr) < 0) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
// Most containers store the length in their header; others need a look at the streams.
|
||||||
|
if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) {
|
||||||
|
avformat_find_stream_info(context, nullptr);
|
||||||
|
}
|
||||||
|
std::optional<double> seconds;
|
||||||
|
if (context->duration != AV_NOPTS_VALUE && context->duration > 0) {
|
||||||
|
seconds = double(context->duration) / AV_TIME_BASE;
|
||||||
|
}
|
||||||
|
avformat_close_input(&context);
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
namespace MediaInfo
|
||||||
|
{
|
||||||
|
// A short title for a media file name, without the release tags: "Show.S01E01.Pilot.1080p.WEB.mkv"
|
||||||
|
// becomes "S01E01 · Pilot" and "Movie.2024.2160p.BluRay.mkv" becomes "Movie (2024)". Returns the
|
||||||
|
// name without its extension when nothing better is found.
|
||||||
|
QString cleanTitle(const QString &fileName);
|
||||||
|
|
||||||
|
// Length of the media file at \a path in seconds, or nothing when it can't be read.
|
||||||
|
std::optional<double> probeDuration(const QString &path);
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
|
|
||||||
#include "playlistmodel.h"
|
#include "playlistmodel.h"
|
||||||
|
|
||||||
|
#include "mediainfo.h"
|
||||||
|
|
||||||
#include <QCollator>
|
#include <QCollator>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QMimeDatabase>
|
#include <QMimeDatabase>
|
||||||
|
#include <QtConcurrentRun>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
@@ -15,6 +18,13 @@ using namespace Qt::Literals::StringLiterals;
|
|||||||
PlaylistModel::PlaylistModel(QObject *parent)
|
PlaylistModel::PlaylistModel(QObject *parent)
|
||||||
: QAbstractListModel(parent)
|
: QAbstractListModel(parent)
|
||||||
{
|
{
|
||||||
|
// Reading many files at once from one disk is slower than a few at a time.
|
||||||
|
m_probePool.setMaxThreadCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistModel::~PlaylistModel()
|
||||||
|
{
|
||||||
|
m_probePool.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
int PlaylistModel::rowCount(const QModelIndex &parent) const
|
int PlaylistModel::rowCount(const QModelIndex &parent) const
|
||||||
@@ -36,6 +46,10 @@ QVariant PlaylistModel::data(const QModelIndex &index, int role) const
|
|||||||
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
|
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
|
||||||
case IsPlayingRole:
|
case IsPlayingRole:
|
||||||
return index.row() == m_playingIndex;
|
return index.row() == m_playingIndex;
|
||||||
|
case TitleRole:
|
||||||
|
return url.isLocalFile() ? MediaInfo::cleanTitle(url.fileName()) : url.toDisplayString();
|
||||||
|
case DurationRole:
|
||||||
|
return m_durations.value(url, -1);
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -46,6 +60,8 @@ QHash<int, QByteArray> PlaylistModel::roleNames() const
|
|||||||
{UrlRole, "url"},
|
{UrlRole, "url"},
|
||||||
{NameRole, "name"},
|
{NameRole, "name"},
|
||||||
{IsPlayingRole, "isPlaying"},
|
{IsPlayingRole, "isPlaying"},
|
||||||
|
{TitleRole, "title"},
|
||||||
|
{DurationRole, "duration"},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +143,7 @@ void PlaylistModel::openFile(const QUrl &url, bool loadSiblings)
|
|||||||
m_playingIndex = -1;
|
m_playingIndex = -1;
|
||||||
endResetModel();
|
endResetModel();
|
||||||
Q_EMIT countChanged();
|
Q_EMIT countChanged();
|
||||||
|
probeDurations(m_urls);
|
||||||
play(std::max<int>(0, int(m_urls.indexOf(url))));
|
play(std::max<int>(0, int(m_urls.indexOf(url))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +157,7 @@ void PlaylistModel::append(const QList<QUrl> &urls)
|
|||||||
m_urls.append(added);
|
m_urls.append(added);
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
Q_EMIT countChanged();
|
Q_EMIT countChanged();
|
||||||
|
probeDurations(added);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlaylistModel::replace(const QList<QUrl> &urls)
|
void PlaylistModel::replace(const QList<QUrl> &urls)
|
||||||
@@ -153,6 +171,7 @@ void PlaylistModel::replace(const QList<QUrl> &urls)
|
|||||||
m_playingIndex = -1;
|
m_playingIndex = -1;
|
||||||
endResetModel();
|
endResetModel();
|
||||||
Q_EMIT countChanged();
|
Q_EMIT countChanged();
|
||||||
|
probeDurations(m_urls);
|
||||||
play(0);
|
play(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +278,11 @@ QUrl PlaylistModel::urlAt(int index) const
|
|||||||
return m_urls.value(index);
|
return m_urls.value(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QList<QUrl> PlaylistModel::urls() const
|
||||||
|
{
|
||||||
|
return m_urls;
|
||||||
|
}
|
||||||
|
|
||||||
void PlaylistModel::setPlayingIndex(int index)
|
void PlaylistModel::setPlayingIndex(int index)
|
||||||
{
|
{
|
||||||
if (index == m_playingIndex) {
|
if (index == m_playingIndex) {
|
||||||
@@ -274,3 +298,25 @@ void PlaylistModel::setPlayingIndex(int index)
|
|||||||
}
|
}
|
||||||
Q_EMIT playingIndexChanged();
|
Q_EMIT playingIndexChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PlaylistModel::probeDurations(const QList<QUrl> &urls)
|
||||||
|
{
|
||||||
|
for (const QUrl &url : urls) {
|
||||||
|
// Streams are skipped: opening them can take long and costs bandwidth.
|
||||||
|
if (!url.isLocalFile() || m_durations.contains(url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m_durations.insert(url, -1);
|
||||||
|
QtConcurrent::run(&m_probePool, MediaInfo::probeDuration, url.toLocalFile()).then(this, [this, url](std::optional<double> seconds) {
|
||||||
|
if (!seconds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_durations.insert(url, *seconds);
|
||||||
|
for (int row = 0; row < m_urls.size(); ++row) {
|
||||||
|
if (m_urls.at(row) == url) {
|
||||||
|
Q_EMIT dataChanged(index(row), index(row), {DurationRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <QAbstractListModel>
|
#include <QAbstractListModel>
|
||||||
|
#include <QHash>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
|
#include <QThreadPool>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QtQml/qqmlregistration.h>
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
@@ -24,10 +26,15 @@ public:
|
|||||||
UrlRole = Qt::UserRole + 1,
|
UrlRole = Qt::UserRole + 1,
|
||||||
NameRole,
|
NameRole,
|
||||||
IsPlayingRole,
|
IsPlayingRole,
|
||||||
|
// Short name without release tags; see MediaInfo::cleanTitle().
|
||||||
|
TitleRole,
|
||||||
|
// Length in seconds, or -1 while unknown. Local files are read in the background.
|
||||||
|
DurationRole,
|
||||||
};
|
};
|
||||||
Q_ENUM(Roles)
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
explicit PlaylistModel(QObject *parent = nullptr);
|
explicit PlaylistModel(QObject *parent = nullptr);
|
||||||
|
~PlaylistModel() override;
|
||||||
|
|
||||||
int rowCount(const QModelIndex &parent = {}) const override;
|
int rowCount(const QModelIndex &parent = {}) const override;
|
||||||
QVariant data(const QModelIndex &index, int role) const override;
|
QVariant data(const QModelIndex &index, int role) const override;
|
||||||
@@ -52,6 +59,7 @@ public:
|
|||||||
Q_INVOKABLE void move(int from, int to);
|
Q_INVOKABLE void move(int from, int to);
|
||||||
Q_INVOKABLE void clear();
|
Q_INVOKABLE void clear();
|
||||||
Q_INVOKABLE QUrl urlAt(int index) const;
|
Q_INVOKABLE QUrl urlAt(int index) const;
|
||||||
|
Q_INVOKABLE QList<QUrl> urls() const;
|
||||||
|
|
||||||
// Media files directly inside \a folder, sorted the way a file manager does
|
// Media files directly inside \a folder, sorted the way a file manager does
|
||||||
// ("Episode 2" before "Episode 10").
|
// ("Episode 2" before "Episode 10").
|
||||||
@@ -66,7 +74,12 @@ Q_SIGNALS:
|
|||||||
private:
|
private:
|
||||||
QList<QUrl> expand(const QList<QUrl> &urls) const;
|
QList<QUrl> expand(const QList<QUrl> &urls) const;
|
||||||
void setPlayingIndex(int index);
|
void setPlayingIndex(int index);
|
||||||
|
void probeDurations(const QList<QUrl> &urls);
|
||||||
|
|
||||||
QList<QUrl> m_urls;
|
QList<QUrl> m_urls;
|
||||||
int m_playingIndex = -1;
|
int m_playingIndex = -1;
|
||||||
|
// Known lengths in seconds; -1 for files that couldn't be read or are still being read.
|
||||||
|
QHash<QUrl, double> m_durations;
|
||||||
|
// Declared last so it is destroyed first and waits for running probes.
|
||||||
|
QThreadPool m_probePool;
|
||||||
};
|
};
|
||||||
@@ -38,6 +38,7 @@ Item {
|
|||||||
signal headerToggleRequested()
|
signal headerToggleRequested()
|
||||||
signal contextMenuRequested()
|
signal contextMenuRequested()
|
||||||
signal translateRequested()
|
signal translateRequested()
|
||||||
|
signal translatePlaylistRequested()
|
||||||
signal editGlossaryRequested()
|
signal editGlossaryRequested()
|
||||||
|
|
||||||
function seekBy(seconds: real) {
|
function seekBy(seconds: real) {
|
||||||
@@ -396,6 +397,13 @@ Item {
|
|||||||
onTriggered: root.translateRequested()
|
onTriggered: root.translateRequested()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
property Action translatePlaylist: Action {
|
||||||
|
enabled: root.shortcutsEnabled && root.playlist.count > 1 && !root.translator.busy
|
||||||
|
text: KI18n.i18nc("@action", "Translate Whole Playlist into %1", QtPotatoSettings.targetLanguage)
|
||||||
|
icon.name: "translate"
|
||||||
|
onTriggered: root.translatePlaylistRequested()
|
||||||
|
}
|
||||||
|
|
||||||
property Action editGlossary: Action {
|
property Action editGlossary: Action {
|
||||||
enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:")
|
enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:")
|
||||||
text: KI18n.i18nc("@action", "Edit Translation Glossary…")
|
text: KI18n.i18nc("@action", "Edit Translation Glossary…")
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ ToolBar {
|
|||||||
|
|
||||||
MenuSeparator {}
|
MenuSeparator {}
|
||||||
AppMenuItem { action: root.actions.translateSubtitle }
|
AppMenuItem { action: root.actions.translateSubtitle }
|
||||||
|
AppMenuItem { action: root.actions.translatePlaylist }
|
||||||
AppMenuItem { action: root.actions.addSubtitle }
|
AppMenuItem { action: root.actions.addSubtitle }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-3
@@ -49,7 +49,8 @@ ApplicationWindow {
|
|||||||
return KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)
|
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)
|
let track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
||||||
// Translate the original again, not the translation on screen.
|
// Translate the original again, not the translation on screen.
|
||||||
if (track && video.isTranslatedSubtitle(track.id)) {
|
if (track && video.isTranslatedSubtitle(track.id)) {
|
||||||
@@ -60,6 +61,11 @@ ApplicationWindow {
|
|||||||
const sourceId = video.translationSourceId()
|
const sourceId = video.translationSourceId()
|
||||||
track = video.subtitleTracks.find(track => track.id === sourceId)
|
track = video.subtitleTracks.find(track => track.id === sourceId)
|
||||||
}
|
}
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateCurrentSubtitle() {
|
||||||
|
const track = translationSourceTrack()
|
||||||
if (track) {
|
if (track) {
|
||||||
lastTranslationTrack = track
|
lastTranslationTrack = track
|
||||||
lastTranslationVideo = video.currentUrl
|
lastTranslationVideo = video.currentUrl
|
||||||
@@ -128,6 +134,14 @@ ApplicationWindow {
|
|||||||
onPlaylistToggleRequested: playlistPanel.toggle()
|
onPlaylistToggleRequested: playlistPanel.toggle()
|
||||||
onContextMenuRequested: contextMenu.popup()
|
onContextMenuRequested: contextMenu.popup()
|
||||||
onTranslateRequested: appWindow.translateCurrentSubtitle()
|
onTranslateRequested: appWindow.translateCurrentSubtitle()
|
||||||
|
onTranslatePlaylistRequested: {
|
||||||
|
const track = appWindow.translationSourceTrack()
|
||||||
|
if (track) {
|
||||||
|
appWindow.lastTranslationTrack = track
|
||||||
|
appWindow.lastTranslationVideo = video.currentUrl
|
||||||
|
}
|
||||||
|
translationService.translatePlaylist(playlistModel.urls(), track ?? {}, video.mediaTitle)
|
||||||
|
}
|
||||||
onEditGlossaryRequested: {
|
onEditGlossaryRequested: {
|
||||||
translationService.loadGlossary(video.currentUrl)
|
translationService.loadGlossary(video.currentUrl)
|
||||||
glossaryDialog.open()
|
glossaryDialog.open()
|
||||||
@@ -173,20 +187,28 @@ ApplicationWindow {
|
|||||||
|
|
||||||
TranslationService {
|
TranslationService {
|
||||||
id: translationService
|
id: translationService
|
||||||
|
currentVideo: video.currentUrl
|
||||||
onTranslated: (subtitleUrl, fromCache) => {
|
onTranslated: (subtitleUrl, fromCache) => {
|
||||||
video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle())
|
video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle())
|
||||||
osdItem.message(fromCache
|
osdItem.message(fromCache
|
||||||
? KI18n.i18nc("@info:osd", "Loaded the saved translation")
|
? KI18n.i18nc("@info:osd", "Loaded the saved translation")
|
||||||
: KI18n.i18nc("@info:osd", "Translation ready"))
|
: KI18n.i18nc("@info:osd", "Translation ready"))
|
||||||
}
|
}
|
||||||
|
onPlaylistTranslated: (translatedCount, alreadyTranslatedCount, problemCount) => {
|
||||||
|
// Skipped episodes are listed in the error message.
|
||||||
|
if (translatedCount > 0) {
|
||||||
|
osdItem.message(KI18n.i18ncp("@info:osd", "Translated %1 episode", "Translated %1 episodes", translatedCount))
|
||||||
|
} else if (problemCount === 0) {
|
||||||
|
osdItem.message(KI18n.i18nc("@info:osd", "Every episode is already translated"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MpvVideo {
|
MpvVideo {
|
||||||
id: video
|
id: video
|
||||||
|
|
||||||
|
// TranslationService follows the video itself (currentVideo).
|
||||||
onCurrentUrlChanged: {
|
onCurrentUrlChanged: {
|
||||||
translationService.cancel()
|
|
||||||
translationService.forgetTranslation()
|
|
||||||
appWindow.lastTranslationTrack = null
|
appWindow.lastTranslationTrack = null
|
||||||
appWindow.lastTranslationVideo = ""
|
appWindow.lastTranslationVideo = ""
|
||||||
glossaryDialog.close()
|
glossaryDialog.close()
|
||||||
@@ -246,6 +268,7 @@ ApplicationWindow {
|
|||||||
playlist: playlistModel
|
playlist: playlistModel
|
||||||
mpv: video
|
mpv: video
|
||||||
mainWindowWidth: appWindow.width
|
mainWindowWidth: appWindow.width
|
||||||
|
translateAction: appActions.translatePlaylist
|
||||||
fullscreen: appWindow.isFullScreen
|
fullscreen: appWindow.isFullScreen
|
||||||
height: video.height
|
height: video.height
|
||||||
onAddFilesRequested: addToPlaylistDialog.open()
|
onAddFilesRequested: addToPlaylistDialog.open()
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ Page {
|
|||||||
required property PlaylistModel playlist
|
required property PlaylistModel playlist
|
||||||
required property MpvItem mpv
|
required property MpvItem mpv
|
||||||
required property int mainWindowWidth
|
required property int mainWindowWidth
|
||||||
|
// Translates the subtitles of every entry.
|
||||||
|
required property Action translateAction
|
||||||
property bool fullscreen: false
|
property bool fullscreen: false
|
||||||
|
|
||||||
readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge
|
readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge
|
||||||
@@ -67,6 +69,18 @@ Page {
|
|||||||
onClicked: root.addFilesRequested()
|
onClicked: root.addFilesRequested()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolButton {
|
||||||
|
action: root.translateAction
|
||||||
|
icon.width: root.buttonSize
|
||||||
|
icon.height: root.buttonSize
|
||||||
|
display: AbstractButton.IconOnly
|
||||||
|
focusPolicy: Qt.TabFocus
|
||||||
|
|
||||||
|
ToolTip.text: text
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
|
}
|
||||||
|
|
||||||
ToolButton {
|
ToolButton {
|
||||||
text: KI18n.i18nc("@action:button", "Scroll to Playing Item")
|
text: KI18n.i18nc("@action:button", "Scroll to Playing Item")
|
||||||
icon.name: "go-jump"
|
icon.name: "go-jump"
|
||||||
|
|||||||
+63
-47
@@ -8,20 +8,23 @@ import QtQuick.Layouts
|
|||||||
import org.kde.ki18n
|
import org.kde.ki18n
|
||||||
import org.kde.kirigami as Kirigami
|
import org.kde.kirigami as Kirigami
|
||||||
|
|
||||||
// One row of the playlist: row number, playing icon and file name.
|
// One compact row of the playlist: row number (or playing icon), short title, length and,
|
||||||
|
// on hover or focus, a remove button.
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
required property int index
|
required property int index
|
||||||
required property url url
|
required property url url
|
||||||
required property string name
|
required property string name
|
||||||
|
required property string title
|
||||||
|
required property real duration
|
||||||
required property bool isPlaying
|
required property bool isPlaying
|
||||||
required property PlaylistModel playlist
|
required property PlaylistModel playlist
|
||||||
required property MpvItem mpv
|
required property MpvItem mpv
|
||||||
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
|
property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1
|
||||||
|
|
||||||
readonly property bool hovered: hoverHandler.hovered
|
readonly property bool hovered: hoverHandler.hovered
|
||||||
readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize
|
readonly property bool highlighted: root.isPlaying && !root.hovered
|
||||||
activeFocusOnTab: true
|
activeFocusOnTab: true
|
||||||
|
|
||||||
function activate() {
|
function activate() {
|
||||||
@@ -32,6 +35,18 @@ Item {
|
|||||||
contextMenu.popup()
|
contextMenu.popup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "1:02:03" or "4:05".
|
||||||
|
function formatDuration(seconds: real): string {
|
||||||
|
if (seconds < 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const total = Math.round(seconds)
|
||||||
|
const hours = Math.floor(total / 3600)
|
||||||
|
const minutes = Math.floor(total / 60) % 60
|
||||||
|
const secs = String(total % 60).padStart(2, "0")
|
||||||
|
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`
|
||||||
|
}
|
||||||
|
|
||||||
Keys.onReturnPressed: root.activate()
|
Keys.onReturnPressed: root.activate()
|
||||||
Keys.onEnterPressed: root.activate()
|
Keys.onEnterPressed: root.activate()
|
||||||
Keys.onMenuPressed: root.showContextMenu()
|
Keys.onMenuPressed: root.showContextMenu()
|
||||||
@@ -42,15 +57,8 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelColor(): color {
|
|
||||||
if (root.isPlaying && !root.hovered) {
|
|
||||||
return Kirigami.Theme.highlightedTextColor
|
|
||||||
}
|
|
||||||
return Kirigami.Theme.textColor
|
|
||||||
}
|
|
||||||
|
|
||||||
implicitWidth: ListView.view.width
|
implicitWidth: ListView.view.width
|
||||||
implicitHeight: fontSize * 3
|
implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2
|
||||||
|
|
||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hoverHandler
|
id: hoverHandler
|
||||||
@@ -71,7 +79,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
Kirigami.Theme.colorSet: Kirigami.Theme.View
|
Kirigami.Theme.colorSet: Kirigami.Theme.View
|
||||||
color: {
|
color: {
|
||||||
if (root.isPlaying && !root.hovered) {
|
if (root.highlighted) {
|
||||||
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
|
return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha)
|
||||||
}
|
}
|
||||||
if (root.hovered) {
|
if (root.hovered) {
|
||||||
@@ -85,67 +93,75 @@ Item {
|
|||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Kirigami.Units.largeSpacing
|
||||||
|
anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing
|
||||||
spacing: Kirigami.Units.largeSpacing
|
spacing: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
// Same width on every row, so the titles line up.
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0,
|
||||||
|
Kirigami.Units.iconSizes.small)
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
text: root.index + 1
|
text: root.index + 1
|
||||||
color: root.labelColor()
|
visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying
|
||||||
visible: QtPotatoSettings.playlistShowRowNumber
|
color: Kirigami.Theme.disabledTextColor
|
||||||
font.pointSize: root.fontSize
|
font: Kirigami.Theme.smallFont
|
||||||
font.features: { "tnum": 1 }
|
}
|
||||||
horizontalAlignment: Qt.AlignHCenter
|
|
||||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
Kirigami.Icon {
|
||||||
Layout.preferredWidth: rowNumberMetrics.width
|
anchors.centerIn: parent
|
||||||
|
width: Kirigami.Units.iconSizes.small
|
||||||
|
height: Kirigami.Units.iconSizes.small
|
||||||
|
source: "media-playback-start"
|
||||||
|
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
|
||||||
|
visible: root.isPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
TextMetrics {
|
TextMetrics {
|
||||||
id: rowNumberMetrics
|
id: rowNumberMetrics
|
||||||
font.pointSize: root.fontSize
|
font: Kirigami.Theme.smallFont
|
||||||
text: String(root.playlist.count).replace(/./g, "0")
|
text: String(root.playlist.count).replace(/./g, "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
color: Qt.alpha(Kirigami.Theme.textColor, 0.2)
|
|
||||||
visible: QtPotatoSettings.playlistShowRowNumber
|
|
||||||
Layout.preferredWidth: 1
|
|
||||||
Layout.fillHeight: true
|
|
||||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
|
||||||
Layout.bottomMargin: Kirigami.Units.smallSpacing
|
|
||||||
}
|
|
||||||
|
|
||||||
Kirigami.Icon {
|
|
||||||
source: "media-playback-start"
|
|
||||||
color: root.labelColor()
|
|
||||||
visible: root.isPlaying
|
|
||||||
Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing
|
|
||||||
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
|
|
||||||
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LabelWithTooltip {
|
LabelWithTooltip {
|
||||||
text: root.name
|
id: titleLabel
|
||||||
toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url
|
text: root.title
|
||||||
color: root.labelColor()
|
toolTipText: root.name
|
||||||
|
alwaysShowToolTip: root.title !== root.name
|
||||||
|
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.pointSize: root.fontSize
|
maximumLineCount: 1
|
||||||
font.bold: root.isPlaying
|
|
||||||
verticalAlignment: Qt.AlignVCenter
|
verticalAlignment: Qt.AlignVCenter
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0
|
|
||||||
Layout.rightMargin: Kirigami.Units.largeSpacing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Label {
|
||||||
|
text: root.formatDuration(root.duration)
|
||||||
|
visible: text !== ""
|
||||||
|
color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.disabledTextColor
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolButton {
|
||||||
|
id: removeButton
|
||||||
text: KI18n.i18nc("@action:button remove playlist item", "Remove")
|
text: KI18n.i18nc("@action:button remove playlist item", "Remove")
|
||||||
icon.name: "list-remove"
|
icon.name: "list-remove"
|
||||||
|
icon.width: Kirigami.Units.iconSizes.small
|
||||||
|
icon.height: Kirigami.Units.iconSizes.small
|
||||||
display: AbstractButton.IconOnly
|
display: AbstractButton.IconOnly
|
||||||
flat: true
|
focusPolicy: Qt.TabFocus
|
||||||
|
visible: root.hovered || root.activeFocus || activeFocus
|
||||||
|
padding: 0
|
||||||
Accessible.name: text
|
Accessible.name: text
|
||||||
ToolTip.text: text
|
ToolTip.text: text
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
ToolTip.delay: Kirigami.Units.toolTipDelay
|
ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2
|
Layout.fillHeight: true
|
||||||
Layout.rightMargin: Kirigami.Units.smallSpacing
|
Layout.preferredWidth: height
|
||||||
onClicked: root.playlist.remove(root.index)
|
onClicked: root.playlist.remove(root.index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ AppMenu {
|
|||||||
}
|
}
|
||||||
MenuSeparator {}
|
MenuSeparator {}
|
||||||
AppMenuItem { action: root.actions.translateSubtitle }
|
AppMenuItem { action: root.actions.translateSubtitle }
|
||||||
|
AppMenuItem { action: root.actions.translatePlaylist }
|
||||||
AppMenuItem { action: root.actions.editGlossary }
|
AppMenuItem { action: root.actions.editGlossary }
|
||||||
AppMenuItem { action: root.actions.cancelTranslation }
|
AppMenuItem { action: root.actions.cancelTranslation }
|
||||||
MenuSeparator {}
|
MenuSeparator {}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "subtitleextractor.h"
|
#include "subtitleextractor.h"
|
||||||
|
|
||||||
|
#include "trackselection.h"
|
||||||
|
|
||||||
#include <KLocalizedString>
|
#include <KLocalizedString>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -138,4 +140,39 @@ ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::a
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SourceResult findTranslationSource(const QString &path)
|
||||||
|
{
|
||||||
|
AVFormatContext *rawFormat = nullptr;
|
||||||
|
if (const int ret = avformat_open_input(&rawFormat, path.toUtf8().constData(), nullptr, nullptr); ret < 0) {
|
||||||
|
return {-1, i18n("Could not open %1: %2", path, ffmpegError(ret))};
|
||||||
|
}
|
||||||
|
std::unique_ptr<AVFormatContext, FormatContextDeleter> format(rawFormat);
|
||||||
|
|
||||||
|
const auto language = [](const AVStream *stream) {
|
||||||
|
const AVDictionaryEntry *tag = av_dict_get(stream->metadata, "language", nullptr, 0);
|
||||||
|
return tag ? QString::fromUtf8(tag->value) : QString();
|
||||||
|
};
|
||||||
|
// Track ids start at 1, since 0 means "none"; id - 1 is the stream index.
|
||||||
|
QList<TrackSelection::Track> subtitles;
|
||||||
|
QString audioLanguage;
|
||||||
|
bool audioIsDefault = false;
|
||||||
|
for (unsigned i = 0; i < format->nb_streams; ++i) {
|
||||||
|
const AVStream *stream = format->streams[i];
|
||||||
|
const bool isDefault = stream->disposition & AV_DISPOSITION_DEFAULT;
|
||||||
|
if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
|
||||||
|
if (audioLanguage.isEmpty() || (isDefault && !audioIsDefault)) {
|
||||||
|
audioLanguage = language(stream);
|
||||||
|
audioIsDefault = isDefault;
|
||||||
|
}
|
||||||
|
} else if (stream->codecpar->codec_type == AVMEDIA_TYPE_SUBTITLE) {
|
||||||
|
subtitles.append({static_cast<int>(i) + 1,
|
||||||
|
language(stream),
|
||||||
|
QString::fromUtf8(avcodec_get_name(stream->codecpar->codec_id)),
|
||||||
|
bool(stream->disposition & AV_DISPOSITION_FORCED),
|
||||||
|
isDefault});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {TrackSelection::pickTranslationSource(subtitles, {QStringLiteral("en"), audioLanguage}, 0) - 1, {}};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,4 +14,15 @@ struct ExtractResult {
|
|||||||
// Decodes a text subtitle stream of a media file with FFmpeg. \a streamIndex is the
|
// Decodes a text subtitle stream of a media file with FFmpeg. \a streamIndex is the
|
||||||
// FFmpeg stream index (mpv reports it as "ff-index"). Blocking; run it off the GUI thread.
|
// FFmpeg stream index (mpv reports it as "ff-index"). Blocking; run it off the GUI thread.
|
||||||
ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::atomic_bool &cancelled);
|
ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::atomic_bool &cancelled);
|
||||||
|
|
||||||
|
struct SourceResult {
|
||||||
|
// FFmpeg stream index of the subtitle to translate, or -1 when the file has no
|
||||||
|
// text subtitle.
|
||||||
|
int streamIndex = -1;
|
||||||
|
QString error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The embedded subtitle of a file that is not open in mpv to translate, chosen like
|
||||||
|
// MpvItem::translationSourceId(): English first, then the audio language. Blocking.
|
||||||
|
SourceResult findTranslationSource(const QString &path);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "translationservice.h"
|
#include "translationservice.h"
|
||||||
|
|
||||||
#include "apikeystore.h"
|
#include "apikeystore.h"
|
||||||
|
#include "mediainfo.h"
|
||||||
#include "qtpotatosettings.h"
|
#include "qtpotatosettings.h"
|
||||||
#include "translationpresets.h"
|
#include "translationpresets.h"
|
||||||
#include "translationstore.h"
|
#include "translationstore.h"
|
||||||
@@ -69,6 +70,10 @@ struct TranslationService::Job {
|
|||||||
std::atomic_bool cancelled = false;
|
std::atomic_bool cancelled = false;
|
||||||
std::unique_ptr<LlmClient> client;
|
std::unique_ptr<LlmClient> client;
|
||||||
QString mediaTitle;
|
QString mediaTitle;
|
||||||
|
bool playlist = false;
|
||||||
|
// In a playlist: the episode being translated (from 0) and how many there are.
|
||||||
|
int episode = 0;
|
||||||
|
int episodes = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct TranslationService::Batches {
|
struct TranslationService::Batches {
|
||||||
@@ -160,6 +165,25 @@ QString TranslationService::glossaryError() const
|
|||||||
return m_glossaryError;
|
return m_glossaryError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QUrl TranslationService::currentVideo() const
|
||||||
|
{
|
||||||
|
return m_currentVideo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TranslationService::setCurrentVideo(const QUrl &url)
|
||||||
|
{
|
||||||
|
if (url == m_currentVideo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_currentVideo = url;
|
||||||
|
forgetTranslation();
|
||||||
|
// A playlist translation is meant to go on while the episodes play.
|
||||||
|
if (m_job && !m_job->playlist) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
Q_EMIT currentVideoChanged();
|
||||||
|
}
|
||||||
|
|
||||||
void TranslationService::loadGlossary(const QUrl &videoUrl)
|
void TranslationService::loadGlossary(const QUrl &videoUrl)
|
||||||
{
|
{
|
||||||
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
|
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
|
||||||
@@ -323,7 +347,25 @@ void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &trac
|
|||||||
cancel();
|
cancel();
|
||||||
m_job = std::make_shared<Job>();
|
m_job = std::make_shared<Job>();
|
||||||
m_job->mediaTitle = mediaTitle;
|
m_job->mediaTitle = mediaTitle;
|
||||||
run(videoUrl, track);
|
run(m_job, videoUrl, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TranslationService::translatePlaylist(const QList<QUrl> &urls, const QVariantMap ¤tTrack, const QString ¤tTitle)
|
||||||
|
{
|
||||||
|
cancel();
|
||||||
|
// Start with the episode playing, then the ones after it, which are watched next.
|
||||||
|
QList<QUrl> ordered;
|
||||||
|
const qsizetype current = std::max<qsizetype>(0, urls.indexOf(m_currentVideo));
|
||||||
|
for (qsizetype i = 0; i < urls.size(); ++i) {
|
||||||
|
const QUrl &url = urls.at((current + i) % urls.size());
|
||||||
|
if (!ordered.contains(url)) {
|
||||||
|
ordered.append(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_job = std::make_shared<Job>();
|
||||||
|
m_job->playlist = true;
|
||||||
|
m_job->episodes = ordered.size();
|
||||||
|
runPlaylist(m_job, ordered, currentTrack, currentTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TranslationService::cancel()
|
void TranslationService::cancel()
|
||||||
@@ -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({});
|
setErrorText({});
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -419,29 +459,123 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
|
|
||||||
auto [config, configError] = co_await loadConfig();
|
auto [config, configError] = co_await loadConfig();
|
||||||
if (job->cancelled) {
|
if (job->cancelled) {
|
||||||
co_return;
|
co_return std::nullopt;
|
||||||
|
}
|
||||||
|
if (configError.isEmpty() && config.model.isEmpty()) {
|
||||||
|
configError = i18n("Choose a model in Settings first.");
|
||||||
}
|
}
|
||||||
if (!configError.isEmpty()) {
|
if (!configError.isEmpty()) {
|
||||||
fail(job, configError);
|
fail(job, configError);
|
||||||
co_return;
|
co_return std::nullopt;
|
||||||
}
|
}
|
||||||
if (config.model.isEmpty()) {
|
co_return config;
|
||||||
fail(job, i18n("Choose a model in Settings first."));
|
|
||||||
co_return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
|
QCoro::Task<> TranslationService::run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track)
|
||||||
|
{
|
||||||
|
const std::optional<LlmClient::Config> config = co_await startJob(job);
|
||||||
|
if (!config) {
|
||||||
|
co_return;
|
||||||
|
}
|
||||||
|
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
|
||||||
if (job->cancelled) {
|
if (job->cancelled) {
|
||||||
co_return;
|
co_return;
|
||||||
}
|
}
|
||||||
if (!source.error.isEmpty()) {
|
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
|
||||||
fail(job, source.error);
|
if (!error.isEmpty()) {
|
||||||
|
fail(job, error);
|
||||||
co_return;
|
co_return;
|
||||||
}
|
}
|
||||||
|
endJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
QCoro::Task<> TranslationService::runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle)
|
||||||
|
{
|
||||||
|
const std::optional<LlmClient::Config> config = co_await startJob(job);
|
||||||
|
if (!config) {
|
||||||
|
co_return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int translatedCount = 0;
|
||||||
|
int alreadyTranslatedCount = 0;
|
||||||
|
QStringList problems;
|
||||||
|
for (qsizetype i = 0; i < urls.size(); ++i) {
|
||||||
|
const QUrl videoUrl = urls.at(i);
|
||||||
|
const QString name = videoUrl.fileName();
|
||||||
|
job->episode = int(i);
|
||||||
|
setJobProgress(*job, 0);
|
||||||
|
setJobStatus(*job, i18n("Reading subtitles…"));
|
||||||
|
if (!savedTranslation(videoUrl).isEmpty()) {
|
||||||
|
++alreadyTranslatedCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap track = currentTrack;
|
||||||
|
job->mediaTitle = currentTitle;
|
||||||
|
if (videoUrl != m_currentVideo || currentTrack.isEmpty()) {
|
||||||
|
if (!videoUrl.isLocalFile()) {
|
||||||
|
problems.append(i18nc("@info %1 is a file name", "%1: only subtitles of local files can be translated", name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString path = videoUrl.toLocalFile();
|
||||||
|
const Subtitles::SourceResult source = co_await QtConcurrent::run([path] {
|
||||||
|
return Subtitles::findTranslationSource(path);
|
||||||
|
});
|
||||||
|
if (job->cancelled) {
|
||||||
|
co_return;
|
||||||
|
}
|
||||||
|
if (!source.error.isEmpty() || source.streamIndex < 0) {
|
||||||
|
problems.append(source.error.isEmpty() ? i18nc("@info %1 is a file name", "%1: no text subtitle", name)
|
||||||
|
: i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, source.error));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
track = QVariantMap{{u"ffIndex"_s, source.streamIndex}};
|
||||||
|
job->mediaTitle = MediaInfo::cleanTitle(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
|
||||||
|
if (job->cancelled) {
|
||||||
|
co_return;
|
||||||
|
}
|
||||||
|
if (!outcome.error.isEmpty() && !outcome.skippable) {
|
||||||
|
// Errors from the API or the disk would repeat for every episode.
|
||||||
|
fail(job, i18nc("@info %1 is a file name, %2 an error", "Stopped translating the playlist at %1: %2", name, outcome.error));
|
||||||
|
co_return;
|
||||||
|
}
|
||||||
|
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
|
||||||
|
if (error.isEmpty()) {
|
||||||
|
++translatedCount;
|
||||||
|
} else {
|
||||||
|
problems.append(i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endJob(job);
|
||||||
|
if (!problems.isEmpty()) {
|
||||||
|
setErrorText(i18np("Translated %2 of %3 episodes. Skipped one:\n%4",
|
||||||
|
"Translated %2 of %3 episodes. Skipped %1:\n%4",
|
||||||
|
problems.size(),
|
||||||
|
translatedCount + alreadyTranslatedCount,
|
||||||
|
urls.size(),
|
||||||
|
problems.join(u'\n')));
|
||||||
|
}
|
||||||
|
Q_EMIT playlistTranslated(translatedCount, alreadyTranslatedCount, int(problems.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track)
|
||||||
|
{
|
||||||
|
setJobProgress(*job, 0);
|
||||||
|
setJobStatus(*job, i18n("Reading subtitles…"));
|
||||||
|
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
|
||||||
|
if (job->cancelled) {
|
||||||
|
co_return Outcome{};
|
||||||
|
}
|
||||||
|
if (!source.error.isEmpty()) {
|
||||||
|
co_return Outcome::failed(source.error, true);
|
||||||
|
}
|
||||||
const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues);
|
const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues);
|
||||||
if (cues.isEmpty()) {
|
if (cues.isEmpty()) {
|
||||||
fail(job, i18n("This subtitle track has no text to translate."));
|
co_return Outcome::failed(i18n("This subtitle track has no text to translate."), true);
|
||||||
co_return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Episodes in one folder share a glossary. Streams have no folder, so they get none.
|
// Episodes in one folder share a glossary. Streams have no folder, so they get none.
|
||||||
@@ -449,24 +583,30 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
const bool useGlossary = !seriesDirectory.isEmpty();
|
const bool useGlossary = !seriesDirectory.isEmpty();
|
||||||
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
|
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
|
||||||
const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle;
|
const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle;
|
||||||
const auto saveGlossaryGroups = [&]() {
|
// Returns an error message.
|
||||||
|
const auto saveGlossaryGroups = [&]() -> QString {
|
||||||
QString error;
|
QString error;
|
||||||
if (!GlossaryStore::save(glossary, &error)) {
|
if (!GlossaryStore::save(glossary, &error)) {
|
||||||
fail(job, i18n("Could not save the translation glossary: %1", error));
|
return i18n("Could not save the translation glossary: %1", error);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
// Another episode of the playlist only updates the terms of the open series.
|
||||||
|
if (!job->playlist || videoUrl == m_currentVideo) {
|
||||||
m_glossary = glossary;
|
m_glossary = glossary;
|
||||||
m_glossaryEpisode = episode;
|
m_glossaryEpisode = episode;
|
||||||
Q_EMIT glossaryChanged();
|
Q_EMIT glossaryChanged();
|
||||||
return true;
|
} else if (m_glossary.directory == glossary.directory) {
|
||||||
|
m_glossary = glossary;
|
||||||
|
Q_EMIT glossaryChanged();
|
||||||
|
}
|
||||||
|
return {};
|
||||||
};
|
};
|
||||||
if (useGlossary) {
|
if (useGlossary) {
|
||||||
// New terms belong to the episode being translated. Migrate legacy or
|
// New terms belong to the episode being translated. Migrate legacy or
|
||||||
// imported entries without provenance to this first episode too. This
|
// imported entries without provenance to this first episode too. This
|
||||||
// happens before the cache shortcut and never changes translation text.
|
// happens before the cache shortcut and never changes translation text.
|
||||||
glossary = GlossaryStore::assignUnassigned(glossary, episode);
|
glossary = GlossaryStore::assignUnassigned(glossary, episode);
|
||||||
if (!saveGlossaryGroups()) {
|
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
|
||||||
co_return;
|
co_return Outcome::failed(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const QString sourceSrt = Subtitles::toSrt(cues);
|
const QString sourceSrt = Subtitles::toSrt(cues);
|
||||||
@@ -488,12 +628,11 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||||
|
|
||||||
if (QFileInfo::exists(outputPath)) {
|
if (QFileInfo::exists(outputPath)) {
|
||||||
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
co_return Outcome::ready(outputPath, suffix, configKey, true);
|
||||||
co_return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
|
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
|
||||||
setStatusText(i18n("Building glossary…"));
|
setJobStatus(*job, i18n("Building glossary…"));
|
||||||
QStringList extractionLines;
|
QStringList extractionLines;
|
||||||
QHash<QString, bool> seen;
|
QHash<QString, bool> seen;
|
||||||
for (const SubtitleCue &cue : cues) {
|
for (const SubtitleCue &cue : cues) {
|
||||||
@@ -518,7 +657,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
job->client = std::make_unique<LlmClient>(m_network, config);
|
job->client = std::make_unique<LlmClient>(m_network, config);
|
||||||
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
|
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
|
||||||
if (job->cancelled) {
|
if (job->cancelled) {
|
||||||
co_return;
|
co_return Outcome{};
|
||||||
}
|
}
|
||||||
if (extracted.error.isEmpty()) {
|
if (extracted.error.isEmpty()) {
|
||||||
QList<GlossaryStore::Entry> additions;
|
QList<GlossaryStore::Entry> additions;
|
||||||
@@ -529,17 +668,16 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
// Suggestions extracted while translating this episode belong to
|
// Suggestions extracted while translating this episode belong to
|
||||||
// this episode. Existing terms keep their original ownership.
|
// this episode. Existing terms keep their original ownership.
|
||||||
glossary = GlossaryStore::merge(glossary, additions, episode);
|
glossary = GlossaryStore::merge(glossary, additions, episode);
|
||||||
if (!saveGlossaryGroups()) {
|
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
|
||||||
co_return;
|
co_return Outcome::failed(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setStatusText(i18n("Glossary unavailable; translating without it."));
|
setJobStatus(*job, i18n("Glossary unavailable; translating without it."));
|
||||||
}
|
}
|
||||||
configKey = configKeyFor(glossary);
|
configKey = configKeyFor(glossary);
|
||||||
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||||
if (QFileInfo::exists(outputPath)) {
|
if (QFileInfo::exists(outputPath)) {
|
||||||
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
co_return Outcome::ready(outputPath, suffix, configKey, true);
|
||||||
co_return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +705,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
batches->lines = uniqueLines;
|
batches->lines = uniqueLines;
|
||||||
batches->translations.resize(uniqueLines.size());
|
batches->translations.resize(uniqueLines.size());
|
||||||
batches->batchSize = QtPotatoSettings::self()->batchSize();
|
batches->batchSize = QtPotatoSettings::self()->batchSize();
|
||||||
setStatusText(i18n("Translating… %1 of %2 lines", 0, uniqueLines.size()));
|
setLinesStatus(*job, 0, uniqueLines.size());
|
||||||
|
|
||||||
// Tasks start running as soon as they are created; awaiting them just waits for the end.
|
// Tasks start running as soon as they are created; awaiting them just waits for the end.
|
||||||
std::vector<QCoro::Task<>> workers;
|
std::vector<QCoro::Task<>> workers;
|
||||||
@@ -578,11 +716,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
co_await std::move(worker);
|
co_await std::move(worker);
|
||||||
}
|
}
|
||||||
if (job->cancelled) {
|
if (job->cancelled) {
|
||||||
co_return;
|
co_return Outcome{};
|
||||||
}
|
}
|
||||||
if (!batches->error.isEmpty()) {
|
if (!batches->error.isEmpty()) {
|
||||||
fail(job, batches->error);
|
co_return Outcome::failed(batches->error);
|
||||||
co_return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<SubtitleCue> output = cues;
|
QList<SubtitleCue> output = cues;
|
||||||
@@ -593,36 +730,64 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
|||||||
QDir().mkpath(QFileInfo(outputPath).absolutePath());
|
QDir().mkpath(QFileInfo(outputPath).absolutePath());
|
||||||
QSaveFile file(outputPath);
|
QSaveFile file(outputPath);
|
||||||
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
|
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
|
||||||
fail(job, i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
|
co_return Outcome::failed(i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
|
||||||
co_return;
|
}
|
||||||
|
co_return Outcome::ready(outputPath, suffix, configKey, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
finish(job, videoUrl, suffix, configKey, outputPath, false);
|
QString TranslationService::publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome)
|
||||||
}
|
|
||||||
|
|
||||||
void TranslationService::finish(const std::shared_ptr<Job> &job,
|
|
||||||
const QUrl &videoUrl,
|
|
||||||
const QString &suffix,
|
|
||||||
const QString &configKey,
|
|
||||||
const QString &cacheFile,
|
|
||||||
bool fromCache)
|
|
||||||
{
|
{
|
||||||
QFile file(cacheFile);
|
QFile file(outcome.cacheFile);
|
||||||
if (!file.open(QIODevice::ReadOnly)) {
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
fail(job, i18n("Could not read the translation from %1: %2", cacheFile, file.errorString()));
|
return i18n("Could not read the translation from %1: %2", outcome.cacheFile, file.errorString());
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// The cache keeps every version; the published copy is the one players load.
|
// The cache keeps every version; the published copy is the one players load.
|
||||||
const QString registryFile = TranslationStore::registryFile();
|
const QString registryFile = TranslationStore::registryFile();
|
||||||
TranslationStore::Registry registry = TranslationStore::load(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);
|
TranslationStore::save(registryFile, registry);
|
||||||
|
|
||||||
m_job.reset();
|
// Other episodes of a playlist load their translation when they are opened.
|
||||||
|
if (!job->playlist || videoUrl == m_currentVideo) {
|
||||||
m_lastTranslationPath = published.path;
|
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();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setStatusText({});
|
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
|
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->translations[start + i] = result.translations.at(i);
|
||||||
}
|
}
|
||||||
batches->doneLines += result.translations.size();
|
batches->doneLines += result.translations.size();
|
||||||
setProgress(double(batches->doneLines) / batches->lines.size());
|
setJobProgress(*job, double(batches->doneLines) / batches->lines.size());
|
||||||
setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size()));
|
setLinesStatus(*job, batches->doneLines, batches->lines.size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
class QNetworkAccessManager;
|
class QNetworkAccessManager;
|
||||||
|
|
||||||
@@ -37,6 +38,9 @@ class TranslationService : public QObject
|
|||||||
Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged)
|
Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged)
|
||||||
Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged)
|
Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged)
|
||||||
Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged)
|
Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged)
|
||||||
|
// The video playing. Its translations are shown when ready; others are only saved.
|
||||||
|
// Opening another video stops a single translation but not a playlist one.
|
||||||
|
Q_PROPERTY(QUrl currentVideo READ currentVideo WRITE setCurrentVideo NOTIFY currentVideoChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit TranslationService(QObject *parent = nullptr);
|
explicit TranslationService(QObject *parent = nullptr);
|
||||||
@@ -54,9 +58,15 @@ public:
|
|||||||
QString glossaryEpisode() const;
|
QString glossaryEpisode() const;
|
||||||
QStringList glossaryEpisodes() const;
|
QStringList glossaryEpisodes() const;
|
||||||
QString glossaryError() const;
|
QString glossaryError() const;
|
||||||
|
QUrl currentVideo() const;
|
||||||
|
void setCurrentVideo(const QUrl &url);
|
||||||
|
|
||||||
// \a track is an entry of MpvItem::subtitleTracks.
|
// \a track is an entry of MpvItem::subtitleTracks.
|
||||||
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
|
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
|
||||||
|
// Translates every video in \a urls that has no saved translation yet, one after
|
||||||
|
// another, starting at the current video. The current one uses \a currentTrack when
|
||||||
|
// set; the others use the embedded subtitle findTranslationSource() picks.
|
||||||
|
Q_INVOKABLE void translatePlaylist(const QList<QUrl> &urls, const QVariantMap ¤tTrack, const QString ¤tTitle);
|
||||||
Q_INVOKABLE void cancel();
|
Q_INVOKABLE void cancel();
|
||||||
// Asks the API configured in the settings for its models.
|
// Asks the API configured in the settings for its models.
|
||||||
Q_INVOKABLE void fetchModels();
|
Q_INVOKABLE void fetchModels();
|
||||||
@@ -83,12 +93,46 @@ Q_SIGNALS:
|
|||||||
void glossaryChanged();
|
void glossaryChanged();
|
||||||
void glossaryErrorChanged();
|
void glossaryErrorChanged();
|
||||||
void translated(const QUrl &subtitleUrl, bool fromCache);
|
void translated(const QUrl &subtitleUrl, bool fromCache);
|
||||||
|
void currentVideoChanged();
|
||||||
|
// A playlist translation ended without being cancelled. Problems are in errorText.
|
||||||
|
void playlistTranslated(int translatedCount, int alreadyTranslatedCount, int problemCount);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Job;
|
struct Job;
|
||||||
struct Batches;
|
struct Batches;
|
||||||
|
// Where translateVideo() left a translation, or why it could not make one.
|
||||||
|
struct Outcome {
|
||||||
|
QString cacheFile;
|
||||||
|
QString suffix;
|
||||||
|
QString configKey;
|
||||||
|
bool fromCache = false;
|
||||||
|
QString error;
|
||||||
|
// The error is about this video's subtitles; other videos may still work.
|
||||||
|
bool skippable = false;
|
||||||
|
|
||||||
QCoro::Task<> run(QUrl videoUrl, QVariantMap track);
|
static Outcome ready(const QString &cacheFile, const QString &suffix, const QString &configKey, bool fromCache)
|
||||||
|
{
|
||||||
|
Outcome outcome;
|
||||||
|
outcome.cacheFile = cacheFile;
|
||||||
|
outcome.suffix = suffix;
|
||||||
|
outcome.configKey = configKey;
|
||||||
|
outcome.fromCache = fromCache;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
static Outcome failed(const QString &error, bool skippable = false)
|
||||||
|
{
|
||||||
|
Outcome outcome;
|
||||||
|
outcome.error = error;
|
||||||
|
outcome.skippable = skippable;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QCoro::Task<> run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track);
|
||||||
|
QCoro::Task<> runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle);
|
||||||
|
// Reads the settings and the API key for \a job; nothing (after failing it) when they are not usable.
|
||||||
|
QCoro::Task<std::optional<LlmClient::Config>> startJob(std::shared_ptr<Job> job);
|
||||||
|
QCoro::Task<Outcome> translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track);
|
||||||
QCoro::Task<> runFetchModels();
|
QCoro::Task<> runFetchModels();
|
||||||
// Translates \a lines, retrying failures and asking again for lines the model skipped.
|
// Translates \a lines, retrying failures and asking again for lines the model skipped.
|
||||||
QCoro::Task<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context);
|
QCoro::Task<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context);
|
||||||
@@ -100,8 +144,14 @@ private:
|
|||||||
// something is missing.
|
// something is missing.
|
||||||
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
|
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
|
||||||
void fail(const std::shared_ptr<Job> &job, const QString &error);
|
void fail(const std::shared_ptr<Job> &job, const QString &error);
|
||||||
// Saves the translation in \a cacheFile where players find it and announces it.
|
// Saves the translation where players find it and, for the current video, shows it.
|
||||||
void finish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache);
|
// Returns an error message on failure.
|
||||||
|
QString publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome);
|
||||||
|
void endJob(const std::shared_ptr<Job> &job);
|
||||||
|
// In a playlist, prefixes the episode number and counts finished episodes in the progress.
|
||||||
|
void setJobStatus(const Job &job, const QString &text);
|
||||||
|
void setJobProgress(const Job &job, double episodeProgress);
|
||||||
|
void setLinesStatus(const Job &job, qsizetype done, qsizetype total);
|
||||||
|
|
||||||
void setBusy(bool busy);
|
void setBusy(bool busy);
|
||||||
void setProgress(double progress);
|
void setProgress(double progress);
|
||||||
@@ -122,4 +172,5 @@ private:
|
|||||||
GlossaryStore::Glossary m_glossary;
|
GlossaryStore::Glossary m_glossary;
|
||||||
QString m_glossaryEpisode;
|
QString m_glossaryEpisode;
|
||||||
QString m_lastTranslationPath;
|
QString m_lastTranslationPath;
|
||||||
|
QUrl m_currentVideo;
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user