Compare commits
1
Commits
main
..
76ba7b0be0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ba7b0be0 |
No files matched your search
@@ -1,7 +0,0 @@
|
||||
# Arch Linux with QtPotato's build dependencies, so CI skips the pacman step.
|
||||
# Built and pushed by .gitea/workflows/ci-image.yml.
|
||||
FROM archlinux:latest
|
||||
RUN pacman -Syu --noconfirm --needed git nodejs base-devel cmake ninja ccache extra-cmake-modules \
|
||||
qt6-base qt6-declarative kirigami ki18n kcoreaddons kcrash kconfig \
|
||||
mpvqt qcoro qtkeychain-qt6 ffmpeg \
|
||||
&& rm -rf /var/cache/pacman/pkg/*
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Arch package
|
||||
|
||||
# Builds the qtpotato package from a release tag and publishes it to this Gitea's Arch
|
||||
# registry, so users can install it with pacman until it is on the AUR.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to package, e.g. v0.1.0
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
package:
|
||||
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
|
||||
runs-on: amd64
|
||||
container: archlinux:latest
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git nodejs base-devel cmake ninja extra-cmake-modules \
|
||||
qt6-base qt6-declarative kirigami ki18n kcoreaddons kcrash kconfig \
|
||||
mpvqt qcoro qtkeychain-qt6 ffmpeg hicolor-icon-theme
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
- name: Build
|
||||
run: |
|
||||
version="${TAG#v}"
|
||||
dir=/tmp/pkg
|
||||
mkdir -p "$dir"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git archive --format=tar --prefix="qtpotato-$version/" HEAD | gzip -n > "$dir/qtpotato-$version.tar.gz"
|
||||
sum="$(sha256sum "$dir/qtpotato-$version.tar.gz" | cut -d' ' -f1)"
|
||||
sed -e "s/^pkgver=.*/pkgver=$version/" -e "s/^pkgrel=.*/pkgrel=1/" \
|
||||
-e 's/^source=.*/source=("$pkgname-$pkgver.tar.gz")/' \
|
||||
-e "s/^sha256sums=.*/sha256sums=('$sum')/" \
|
||||
packaging/aur/qtpotato/PKGBUILD > "$dir/PKGBUILD"
|
||||
# makepkg refuses to run as root.
|
||||
useradd -m builder
|
||||
chown -R builder "$dir"
|
||||
su builder -c "cd $dir && makepkg --noconfirm"
|
||||
- name: Publish
|
||||
env:
|
||||
# The package registry rejects the job token, so this is a personal access token
|
||||
# with the write:package scope.
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
run: |
|
||||
for f in /tmp/pkg/qtpotato-[0-9]*.pkg.tar.zst; do
|
||||
curl -fsS -X PUT -H "Authorization: token $TOKEN" --upload-file "$f" \
|
||||
"$GITHUB_SERVER_URL/api/packages/${GITHUB_REPOSITORY%%/*}/arch/qtpotato"
|
||||
done
|
||||
@@ -1,40 +0,0 @@
|
||||
name: CI image
|
||||
|
||||
# Builds the Arch image that ci.yml runs in, with the build dependencies preinstalled.
|
||||
# Rebuilt weekly to pick up Arch updates.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.gitea/ci-image/**', '.gitea/workflows/ci-image.yml']
|
||||
schedule:
|
||||
- cron: '0 3 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
image:
|
||||
# The build runs on the runner's own Docker daemon, so the amd64 runner has the new
|
||||
# image locally right away. It does not re-pull tags it already has.
|
||||
runs-on: amd64
|
||||
env:
|
||||
IMAGE: git.trivk.dev/tri/qtpotato-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
# No layer cache, so each rebuild picks up the week's Arch updates.
|
||||
run: docker build --pull --no-cache -t "$IMAGE" .gitea/ci-image
|
||||
- name: Push
|
||||
# Gitea's Actions token cannot push to the container registry, so this uses an
|
||||
# access token with the write:package scope. Cloudflare rejects request bodies over
|
||||
# 100 MB and docker push sends each layer in one request, so regctl pushes the
|
||||
# layers in 50 MB chunks instead.
|
||||
env:
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
run: |
|
||||
curl -fsSL -o /usr/local/bin/regctl \
|
||||
https://github.com/regclient/regclient/releases/download/v0.11.7/regctl-linux-amd64
|
||||
echo "3737fbbdd791e04e7a831640b681f3397a376498e80e2ad9b1b2d27296f239bc /usr/local/bin/regctl" | sha256sum -c
|
||||
chmod +x /usr/local/bin/regctl
|
||||
echo "$TOKEN" | regctl registry login git.trivk.dev -u "${{ github.repository_owner }}" --pass-stdin
|
||||
regctl registry set git.trivk.dev --blob-chunk 52428800 --blob-max 52428800
|
||||
docker save "$IMAGE" -o "$RUNNER_TEMP/image.tar"
|
||||
regctl image import "$IMAGE" "$RUNNER_TEMP/image.tar"
|
||||
@@ -1,42 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['README.md', 'LICENSES/**']
|
||||
pull_request:
|
||||
paths-ignore: ['README.md', 'LICENSES/**']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Arch Linux only ships x86_64 images, so pick an amd64 runner.
|
||||
runs-on: amd64
|
||||
# Arch with the build dependencies preinstalled; see ci-image.yml.
|
||||
container: git.trivk.dev/tri/qtpotato-ci:latest
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
# Keep hits across weekly image rebuilds when the compiler did not change.
|
||||
CCACHE_COMPILERCHECK: content
|
||||
CCACHE_MAXSIZE: 500M
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-${{ github.sha }}
|
||||
restore-keys: ccache-
|
||||
- name: Configure
|
||||
# The tests do not load QML, so skip QML cachegen and debug info: they take over
|
||||
# half of the build. Release and AUR builds keep both.
|
||||
run: |
|
||||
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS_DEBUG=-O0 \
|
||||
-DQT_QML_NO_CACHEGEN=ON -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DBUILD_TESTING=ON
|
||||
- name: Build
|
||||
run: |
|
||||
ccache -z
|
||||
cmake --build build
|
||||
ccache -s
|
||||
- name: Test
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
run: ctest --test-dir build --output-on-failure
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
@@ -24,42 +24,6 @@ translate a whole subtitle track with any OpenAI-compatible API and show the res
|
||||
auto-hiding controls.
|
||||
- Follows your Plasma style, colours and icons.
|
||||
|
||||
## Installing on Arch Linux
|
||||
|
||||
### From the pacman repository
|
||||
|
||||
Every release is built and published to this Gitea's package registry. Import the signing key
|
||||
once:
|
||||
|
||||
```sh
|
||||
curl -O https://git.trivk.dev/api/packages/tri/arch/repository.key
|
||||
sudo pacman-key --add repository.key
|
||||
sudo pacman-key --lsign-key 9C48725295A5F54A6A491CF86B273DD49890E8A7
|
||||
```
|
||||
|
||||
Then add the repository to `/etc/pacman.conf` and install:
|
||||
|
||||
```ini
|
||||
[qtpotato]
|
||||
SigLevel = Required
|
||||
Server = https://git.trivk.dev/api/packages/tri/arch/$repo/$arch
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo pacman -Syu qtpotato
|
||||
```
|
||||
|
||||
The package is built against Arch's current Qt and KDE Frameworks. If it breaks after a system
|
||||
update before a new release is out, build it from source as below.
|
||||
|
||||
### From the PKGBUILD
|
||||
|
||||
```sh
|
||||
git clone https://git.trivk.dev/tri/QtPotato.git
|
||||
cd QtPotato/packaging/aur/qtpotato # or qtpotato-git for the latest main
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Dependencies
|
||||
|
||||
@@ -25,17 +25,7 @@ ecm_add_test(trackselectiontest.cpp
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
|
||||
ecm_add_test(translationstoretest.cpp
|
||||
TEST_NAME translationstoretest
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
|
||||
ecm_add_test(mediainfotest.cpp
|
||||
TEST_NAME mediainfotest
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
|
||||
ecm_add_test(subtitleextractortest.cpp
|
||||
TEST_NAME subtitleextractortest
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
@@ -33,12 +33,6 @@ 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;
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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"
|
||||
@@ -67,19 +67,6 @@ private Q_SLOTS:
|
||||
QCOMPARE(TrackSelection::pickSubtitle(tracks, {u"de"_s}, {u"en"_s}, 1, true), 1);
|
||||
}
|
||||
|
||||
// A saved translation is turned on when the file opens, over mpv's choice and the
|
||||
// fallback, but not over a subtitle in a preferred language.
|
||||
void picksSavedTranslation()
|
||||
{
|
||||
const QList<Track> tracks{sub(1, u"rus"_s), sub(2, u"rus"_s, u"subrip"_s, false, true), sub(3, u"eng"_s), sub(4, u"vi"_s)};
|
||||
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, true, 4), 4);
|
||||
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 2, true, 4), 4);
|
||||
QCOMPARE(TrackSelection::pickSubtitle(tracks, {u"en"_s}, {u"en"_s}, 0, true, 4), 3);
|
||||
// Automatic subtitles turned off: keep mpv's choice.
|
||||
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, false, 4), 0);
|
||||
QCOMPARE(TrackSelection::pickSubtitle(tracks, {}, {u"en"_s}, 0, true, 0), 3);
|
||||
}
|
||||
|
||||
void picksAudioByPreference()
|
||||
{
|
||||
const QList<Track> tracks{sub(1, u"en"_s, u"aac"_s), sub(2, u"jpn"_s, u"aac"_s)};
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
#include "translationstore.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTest>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
void writeText(const QString &path, const QByteArray &data)
|
||||
{
|
||||
QFile file(path);
|
||||
QVERIFY(file.open(QIODevice::WriteOnly));
|
||||
file.write(data);
|
||||
}
|
||||
|
||||
QByteArray readText(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
class TranslationStoreTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
QTemporaryDir m_dir;
|
||||
QString m_videos;
|
||||
QString m_cache;
|
||||
QString m_video;
|
||||
QUrl m_videoUrl;
|
||||
|
||||
// The translation as TranslationService leaves it in the cache before publishing.
|
||||
QString cached(const QString &key, const QByteArray &data)
|
||||
{
|
||||
const QString path = TranslationStore::cachePath(m_cache, m_videoUrl, key, u"vi"_s);
|
||||
writeText(path, data);
|
||||
return path;
|
||||
}
|
||||
|
||||
private Q_SLOTS:
|
||||
void init()
|
||||
{
|
||||
QVERIFY(m_dir.isValid());
|
||||
QDir root(m_dir.path());
|
||||
root.removeRecursively();
|
||||
QVERIFY(root.mkpath(u"videos"_s));
|
||||
QVERIFY(root.mkpath(u"cache"_s));
|
||||
m_videos = m_dir.filePath(u"videos"_s);
|
||||
m_cache = m_dir.filePath(u"cache"_s);
|
||||
m_video = m_videos + u"/Show.S01E01.mkv"_s;
|
||||
writeText(m_video, "video");
|
||||
m_videoUrl = QUrl::fromLocalFile(m_video);
|
||||
}
|
||||
|
||||
void namesFiles()
|
||||
{
|
||||
QCOMPARE(TranslationStore::languageSuffix(u"Vietnamese"_s), u"vi"_s);
|
||||
QCOMPARE(TranslationStore::languageSuffix(u"vietnamese"_s), u"vi"_s);
|
||||
QCOMPARE(TranslationStore::languageSuffix(u"Klingon!"_s), u"klingon"_s);
|
||||
QCOMPARE(TranslationStore::sidecarPaths(u"/films/Show.S01E01.mkv"_s, u"vi"_s),
|
||||
QStringList({u"/films/Show.S01E01.vi.srt"_s, u"/films/Show.S01E01.qtpotato.vi.srt"_s}));
|
||||
QCOMPARE(TranslationStore::cachePath(u"/cache"_s, QUrl::fromLocalFile(u"/films/Show.S01E01.mkv"_s), u"abc"_s, u"vi"_s),
|
||||
u"/cache/Show.S01E01.abc.vi.srt"_s);
|
||||
QCOMPARE(TranslationStore::cachePath(u"/cache"_s, QUrl(u"https://example.com/live.m3u8"_s), u"abc"_s, u"vi"_s),
|
||||
u"/cache/subtitles.abc.vi.srt"_s);
|
||||
QCOMPARE(TranslationStore::videoKey(QUrl(u"https://example.com/live.m3u8"_s)), u"https://example.com/live.m3u8"_s);
|
||||
}
|
||||
|
||||
void writesNextToVideo()
|
||||
{
|
||||
TranslationStore::Registry registry;
|
||||
const QString cache = cached(u"k1"_s, "one");
|
||||
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cache);
|
||||
|
||||
const QString sidecar = m_videos + u"/Show.S01E01.vi.srt"_s;
|
||||
QCOMPARE(result.path, sidecar);
|
||||
QVERIFY(result.nextToVideo);
|
||||
QCOMPARE(readText(sidecar), "one");
|
||||
QVERIFY(TranslationStore::isOwned(registry, sidecar));
|
||||
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), sidecar);
|
||||
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"ja"_s), QString());
|
||||
QCOMPARE(registry.find(TranslationStore::videoKey(m_videoUrl), u"vi"_s)->configKey, u"k1"_s);
|
||||
}
|
||||
|
||||
// Different settings give a new translation, which replaces QtPotato's own file.
|
||||
void replacesOwnFileWithNewSettings()
|
||||
{
|
||||
TranslationStore::Registry registry;
|
||||
TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
|
||||
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
|
||||
|
||||
QCOMPARE(result.path, m_videos + u"/Show.S01E01.vi.srt"_s);
|
||||
QCOMPARE(readText(result.path), "two");
|
||||
QCOMPARE(registry.records.size(), 1);
|
||||
QCOMPARE(registry.records.constFirst().configKey, u"k2"_s);
|
||||
QVERIFY(!QFile::exists(m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s));
|
||||
}
|
||||
|
||||
void keepsUserSubtitle()
|
||||
{
|
||||
const QString userFile = m_videos + u"/Show.S01E01.vi.srt"_s;
|
||||
writeText(userFile, "mine");
|
||||
TranslationStore::Registry registry;
|
||||
QVERIFY(!TranslationStore::isOwned(registry, userFile));
|
||||
|
||||
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
|
||||
QCOMPARE(result.path, m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s);
|
||||
QCOMPARE(readText(userFile), "mine");
|
||||
QCOMPARE(readText(result.path), "one");
|
||||
}
|
||||
|
||||
// A file QtPotato wrote and the user then edited counts as the user's.
|
||||
void keepsEditedTranslation()
|
||||
{
|
||||
TranslationStore::Registry registry;
|
||||
const auto first = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
|
||||
writeText(first.path, "one, fixed by hand");
|
||||
QVERIFY(!TranslationStore::isOwned(registry, first.path));
|
||||
|
||||
const auto second = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
|
||||
QCOMPARE(second.path, m_videos + u"/Show.S01E01.qtpotato.vi.srt"_s);
|
||||
QCOMPARE(readText(first.path), "one, fixed by hand");
|
||||
}
|
||||
|
||||
// Once on ".qtpotato.vi.srt", stay there instead of leaving a second copy behind.
|
||||
void staysOnPreviousFile()
|
||||
{
|
||||
const QString userFile = m_videos + u"/Show.S01E01.vi.srt"_s;
|
||||
writeText(userFile, "mine");
|
||||
TranslationStore::Registry registry;
|
||||
const auto first = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
|
||||
QFile::remove(userFile);
|
||||
|
||||
const auto second = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k2"_s, "two", cached(u"k2"_s, "two"));
|
||||
QCOMPARE(second.path, first.path);
|
||||
QCOMPARE(readText(second.path), "two");
|
||||
QVERIFY(!QFile::exists(userFile));
|
||||
}
|
||||
|
||||
void fallsBackToCacheWhenFolderIsReadOnly()
|
||||
{
|
||||
QFile::setPermissions(m_videos, QFileDevice::ReadOwner | QFileDevice::ExeOwner);
|
||||
auto restore = qScopeGuard([this] {
|
||||
QFile::setPermissions(m_videos, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
|
||||
});
|
||||
if (QFileInfo(m_videos).isWritable()) {
|
||||
QSKIP("Permissions are not enforced here (running as root?)");
|
||||
}
|
||||
|
||||
TranslationStore::Registry registry;
|
||||
const QString cache = cached(u"k1"_s, "one");
|
||||
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cache);
|
||||
QCOMPARE(result.path, cache);
|
||||
QVERIFY(!result.nextToVideo);
|
||||
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), cache);
|
||||
}
|
||||
|
||||
void fallsBackToCacheForStreams()
|
||||
{
|
||||
const QUrl stream(u"https://example.com/live.m3u8"_s);
|
||||
const QString cache = TranslationStore::cachePath(m_cache, stream, u"k1"_s, u"vi"_s);
|
||||
writeText(cache, "one");
|
||||
TranslationStore::Registry registry;
|
||||
const auto result = TranslationStore::publish(registry, stream, u"vi"_s, u"k1"_s, "one", cache);
|
||||
QCOMPARE(result.path, cache);
|
||||
QCOMPARE(TranslationStore::savedTranslation(registry, stream, u"vi"_s), cache);
|
||||
}
|
||||
|
||||
void glossaryRewriteKeepsOwnership()
|
||||
{
|
||||
TranslationStore::Registry registry;
|
||||
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
|
||||
writeText(result.path, "one, new glossary");
|
||||
TranslationStore::recordRewrite(registry, result.path, "one, new glossary");
|
||||
QVERIFY(TranslationStore::isOwned(registry, result.path));
|
||||
}
|
||||
|
||||
void savesAndLoadsRegistry()
|
||||
{
|
||||
const QString file = m_dir.filePath(u"data/translations.json"_s);
|
||||
TranslationStore::Registry registry;
|
||||
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
|
||||
// A record whose file is gone is not worth keeping.
|
||||
registry.put({u"/gone.mkv"_s, u"vi"_s, u"/gone.vi.srt"_s, u"k0"_s, u"00"_s});
|
||||
QVERIFY(TranslationStore::save(file, registry));
|
||||
|
||||
const TranslationStore::Registry loaded = TranslationStore::load(file);
|
||||
QCOMPARE(loaded.records.size(), 1);
|
||||
QVERIFY(TranslationStore::isOwned(loaded, result.path));
|
||||
QCOMPARE(TranslationStore::savedTranslation(loaded, m_videoUrl, u"vi"_s), result.path);
|
||||
QVERIFY(TranslationStore::load(m_dir.filePath(u"missing.json"_s)).records.isEmpty());
|
||||
}
|
||||
|
||||
// The registry is no longer right about a file that disappeared.
|
||||
void forgetsDeletedTranslation()
|
||||
{
|
||||
TranslationStore::Registry registry;
|
||||
const auto result = TranslationStore::publish(registry, m_videoUrl, u"vi"_s, u"k1"_s, "one", cached(u"k1"_s, "one"));
|
||||
QFile::remove(result.path);
|
||||
QCOMPARE(TranslationStore::savedTranslation(registry, m_videoUrl, u"vi"_s), QString());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(TranslationStoreTest)
|
||||
#include "translationstoretest.moc"
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
pkgbase = qtpotato
|
||||
pkgdesc = KDE video player built on mpv that translates subtitles with an LLM
|
||||
pkgver = 0.1.0
|
||||
pkgrel = 1
|
||||
url = https://git.trivk.dev/tri/QtPotato
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cmake
|
||||
makedepends = ninja
|
||||
makedepends = extra-cmake-modules
|
||||
depends = qt6-base
|
||||
depends = qt6-declarative
|
||||
depends = kirigami
|
||||
depends = ki18n
|
||||
depends = kcoreaddons
|
||||
depends = kcrash
|
||||
depends = kconfig
|
||||
depends = mpvqt
|
||||
depends = qcoro
|
||||
depends = qtkeychain-qt6
|
||||
depends = ffmpeg
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = qqc2-desktop-style: Plasma look for the interface
|
||||
optdepends = plasma-integration: Plasma file dialogs, colours and icons
|
||||
source = https://git.trivk.dev/tri/QtPotato/releases/download/v0.1.0/qtpotato-0.1.0.tar.gz
|
||||
sha256sums = 8ac4cd95e04880411f16009478765f698c1ba84538c776e68a431033e10ae837
|
||||
|
||||
pkgname = qtpotato
|
||||
@@ -1,33 +0,0 @@
|
||||
# Maintainer: Tri Vo Khac
|
||||
|
||||
pkgname=qtpotato
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc='KDE video player built on mpv that translates subtitles with an LLM'
|
||||
arch=('x86_64')
|
||||
url='https://git.trivk.dev/tri/QtPotato'
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('qt6-base' 'qt6-declarative' 'kirigami' 'ki18n' 'kcoreaddons' 'kcrash' 'kconfig'
|
||||
'mpvqt' 'qcoro' 'qtkeychain-qt6' 'ffmpeg' 'hicolor-icon-theme')
|
||||
makedepends=('cmake' 'ninja' 'extra-cmake-modules')
|
||||
optdepends=('qqc2-desktop-style: Plasma look for the interface'
|
||||
'plasma-integration: Plasma file dialogs, colours and icons')
|
||||
source=("$url/releases/download/v$pkgver/$pkgname-$pkgver.tar.gz")
|
||||
# Filled in by update-pkgbuild.sh from the release's SHA256SUMS.
|
||||
sha256sums=('8ac4cd95e04880411f16009478765f698c1ba84538c776e68a431033e10ae837')
|
||||
|
||||
build() {
|
||||
cmake -B build -S "$pkgname-$pkgver" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=None \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DBUILD_TESTING=ON
|
||||
cmake --build build
|
||||
}
|
||||
|
||||
check() {
|
||||
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
|
||||
}
|
||||
|
||||
package() {
|
||||
DESTDIR="$pkgdir" cmake --install build
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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
-5
@@ -1,5 +1,5 @@
|
||||
# Code without UI (subtitles, mpv.conf handling, where translations are saved), kept
|
||||
# apart from the app so autotests can link it.
|
||||
# Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests
|
||||
# can link it.
|
||||
add_library(qtpotato_core STATIC
|
||||
media/mediainfo.cpp
|
||||
media/mediainfo.h
|
||||
@@ -11,10 +11,8 @@ add_library(qtpotato_core STATIC
|
||||
subtitles/subtitleparser.h
|
||||
subtitles/subtitleextractor.cpp
|
||||
subtitles/subtitleextractor.h
|
||||
translate/translationstore.cpp
|
||||
translate/translationstore.h
|
||||
)
|
||||
target_include_directories(qtpotato_core PUBLIC media mpv subtitles translate)
|
||||
target_include_directories(qtpotato_core PUBLIC media mpv subtitles)
|
||||
target_link_libraries(qtpotato_core PUBLIC
|
||||
Qt6::Core
|
||||
KF6::I18n
|
||||
|
||||
+5
-69
@@ -7,7 +7,6 @@
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QLocale>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
@@ -45,38 +44,6 @@ QList<TrackSelection::Track> selectionTracks(const QVariantList &tracks)
|
||||
return result;
|
||||
}
|
||||
|
||||
QString subtitlePath(const QVariantList &tracks, int id)
|
||||
{
|
||||
for (const QVariant &entry : tracks) {
|
||||
const QVariantMap track = entry.toMap();
|
||||
if (track.value(QStringLiteral("id")).toInt() == id) {
|
||||
return track.value(QStringLiteral("externalFilename")).toString();
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
int subtitleIdForPath(const QVariantList &tracks, const QString &path)
|
||||
{
|
||||
if (path.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
for (const QVariant &entry : tracks) {
|
||||
const QVariantMap track = entry.toMap();
|
||||
if (track.value(QStringLiteral("externalFilename")).toString() == path) {
|
||||
return track.value(QStringLiteral("id")).toInt();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// "movie.abc123.vi.srt" -> "vi", the way mpv tags subtitles it finds next to a video.
|
||||
QString filenameLanguage(const QString &path)
|
||||
{
|
||||
const QStringList parts = QFileInfo(path).completeBaseName().split(QLatin1Char('.'));
|
||||
return parts.size() > 1 ? parts.constLast() : QString();
|
||||
}
|
||||
|
||||
QString trackLanguage(const QVariantList &tracks, int id)
|
||||
{
|
||||
for (const QVariant &entry : tracks) {
|
||||
@@ -145,7 +112,7 @@ MpvItem::MpvItem(QQuickItem *parent)
|
||||
// Before the first file, so its options apply from the start.
|
||||
applyUserConfig();
|
||||
if (m_pendingUrl.isValid()) {
|
||||
loadFile(std::exchange(m_pendingUrl, {}), QString(m_openingTranslatedPath), QString(m_openingTranslatedTitle));
|
||||
loadFile(std::exchange(m_pendingUrl, {}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -189,18 +156,12 @@ bool MpvItem::isFileLoaded() const
|
||||
return m_fileLoaded;
|
||||
}
|
||||
|
||||
void MpvItem::loadFile(const QUrl &url, const QString &translatedSubtitle, const QString &translatedTitle)
|
||||
void MpvItem::loadFile(const QUrl &url)
|
||||
{
|
||||
if (!url.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_translatedPaths.clear();
|
||||
m_openingTranslatedPath = translatedSubtitle;
|
||||
m_openingTranslatedTitle = translatedTitle;
|
||||
if (!translatedSubtitle.isEmpty()) {
|
||||
m_translatedPaths.append(translatedSubtitle);
|
||||
}
|
||||
m_currentUrl = url;
|
||||
Q_EMIT currentUrlChanged();
|
||||
|
||||
@@ -394,9 +355,6 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
|
||||
return;
|
||||
}
|
||||
const QString path = localPathOrUrl(url);
|
||||
if (!m_translatedPaths.contains(path)) {
|
||||
m_translatedPaths.append(path);
|
||||
}
|
||||
for (const QVariant &entry : std::as_const(m_subtitleTracks)) {
|
||||
const QVariantMap track = entry.toMap();
|
||||
if (track.value(QStringLiteral("externalFilename")).toString() == path) {
|
||||
@@ -412,22 +370,11 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
|
||||
|
||||
int MpvItem::translationSourceId() const
|
||||
{
|
||||
QVariantList sources;
|
||||
for (const QVariant &entry : std::as_const(m_subtitleTracks)) {
|
||||
if (!isTranslatedSubtitle(entry.toMap().value(QStringLiteral("id")).toInt())) {
|
||||
sources.append(entry);
|
||||
}
|
||||
}
|
||||
// English first: it is the usual original, and dubbed releases often default to
|
||||
// subtitles translated from it.
|
||||
return TrackSelection::pickTranslationSource(selectionTracks(sources),
|
||||
return TrackSelection::pickTranslationSource(selectionTracks(m_subtitleTracks),
|
||||
{QStringLiteral("en"), trackLanguage(m_audioTracks, m_audioId)},
|
||||
isTranslatedSubtitle(m_subtitleId) ? 0 : m_subtitleId);
|
||||
}
|
||||
|
||||
bool MpvItem::isTranslatedSubtitle(int id) const
|
||||
{
|
||||
return id > 0 && m_translatedPaths.contains(subtitlePath(m_subtitleTracks, id));
|
||||
m_subtitleId);
|
||||
}
|
||||
|
||||
void MpvItem::selectInitialTracks()
|
||||
@@ -435,16 +382,6 @@ void MpvItem::selectInitialTracks()
|
||||
// The observed track-list may still describe the previous file; read it directly.
|
||||
updateTracks(getProperty(QStringLiteral("track-list")).toList());
|
||||
|
||||
// mpv finds a translation next to the video by itself (sub-auto); one kept in the
|
||||
// cache is added here, before choosing, so it can be chosen.
|
||||
const QString translated = std::exchange(m_openingTranslatedPath, {});
|
||||
int translationId = subtitleIdForPath(m_subtitleTracks, translated);
|
||||
if (!translated.isEmpty() && translationId == 0 && QFileInfo::exists(translated)) {
|
||||
commandBlocking(QStringList{QStringLiteral("sub-add"), translated, QStringLiteral("auto"), m_openingTranslatedTitle, filenameLanguage(translated)});
|
||||
updateTracks(getProperty(QStringLiteral("track-list")).toList());
|
||||
translationId = subtitleIdForPath(m_subtitleTracks, translated);
|
||||
}
|
||||
|
||||
const int audioId = TrackSelection::pickAudio(selectionTracks(m_audioTracks), m_preferredAudioLanguages, m_audioId);
|
||||
if (audioId != m_audioId) {
|
||||
setAudioId(audioId);
|
||||
@@ -456,8 +393,7 @@ void MpvItem::selectInitialTracks()
|
||||
m_preferredSubtitleLanguages,
|
||||
fallbackLanguages,
|
||||
m_subtitleId,
|
||||
m_autoSelectSubtitle,
|
||||
translationId);
|
||||
m_autoSelectSubtitle);
|
||||
if (subtitleId != m_subtitleId) {
|
||||
setSubtitleId(subtitleId);
|
||||
}
|
||||
|
||||
+2
-11
@@ -98,9 +98,7 @@ public:
|
||||
QStringList loadedUserConfigFiles() const;
|
||||
QStringList skippedUserOptions() const;
|
||||
|
||||
// \a translatedSubtitle is the file's saved translation (TranslationService). It is
|
||||
// loaded with the file, named \a translatedTitle, and turned on (see TrackSelection).
|
||||
Q_INVOKABLE void loadFile(const QUrl &url, const QString &translatedSubtitle = {}, const QString &translatedTitle = {});
|
||||
Q_INVOKABLE void loadFile(const QUrl &url);
|
||||
// Unloads the current file.
|
||||
Q_INVOKABLE void stop();
|
||||
Q_INVOKABLE void togglePause();
|
||||
@@ -116,10 +114,8 @@ public:
|
||||
// already loaded.
|
||||
Q_INVOKABLE void showTranslatedSubtitleFile(const QUrl &url, const QString &title);
|
||||
// Id of the subtitle to translate: the shown one if it is text, else the best text
|
||||
// subtitle. Translations are skipped. 0 when the file has no text subtitle.
|
||||
// subtitle. 0 when the file has no text subtitle.
|
||||
Q_INVOKABLE int translationSourceId() const;
|
||||
// True for a subtitle QtPotato translated, as opposed to one to translate from.
|
||||
Q_INVOKABLE bool isTranslatedSubtitle(int id) const;
|
||||
|
||||
Q_SIGNALS:
|
||||
void currentUrlChanged();
|
||||
@@ -178,11 +174,6 @@ private:
|
||||
int m_chapter = -1;
|
||||
// File added by showTranslatedSubtitleFile() that should be shown once mpv lists it.
|
||||
QString m_pendingTranslatedPath;
|
||||
// Translated subtitle files of the current file.
|
||||
QStringList m_translatedPaths;
|
||||
// Saved translation to load and turn on when the file opens, and its track title.
|
||||
QString m_openingTranslatedPath;
|
||||
QString m_openingTranslatedTitle;
|
||||
bool m_loadUserConfig = false;
|
||||
QStringList m_loadedUserConfigFiles;
|
||||
QStringList m_skippedUserOptions;
|
||||
|
||||
@@ -98,12 +98,7 @@ int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages,
|
||||
return currentId;
|
||||
}
|
||||
|
||||
int pickSubtitle(const QList<Track> &tracks,
|
||||
const QStringList &preferredLanguages,
|
||||
const QStringList &fallbackLanguages,
|
||||
int currentId,
|
||||
bool fallback,
|
||||
int translationId)
|
||||
int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback)
|
||||
{
|
||||
for (const QString &language : preferredLanguages) {
|
||||
if (const int id = firstFull(tracks, [&](const Track &track) {
|
||||
@@ -112,9 +107,6 @@ int pickSubtitle(const QList<Track> &tracks,
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if (fallback && translationId > 0) {
|
||||
return translationId;
|
||||
}
|
||||
if (currentId > 0 || !fallback) {
|
||||
return currentId;
|
||||
}
|
||||
|
||||
@@ -28,18 +28,11 @@ bool sameLanguage(const QString &a, const QString &b);
|
||||
int pickAudio(const QList<Track> &tracks, const QStringList &preferredLanguages, int currentId);
|
||||
|
||||
// The subtitle to turn on when the file opens. A track in one of \a preferredLanguages
|
||||
// wins over mpv's own choice \a currentId. Next comes \a translationId, the file's
|
||||
// saved translation, when \a fallback is set; without either, \a currentId is kept.
|
||||
// When mpv chose nothing and \a fallback is set, picks a full (not forced) subtitle in
|
||||
// the first of \a fallbackLanguages that has one, then one flagged default, then the
|
||||
// first one. Text subtitles win over bitmap ones in the same step. Returns 0 for no
|
||||
// subtitle.
|
||||
int pickSubtitle(const QList<Track> &tracks,
|
||||
const QStringList &preferredLanguages,
|
||||
const QStringList &fallbackLanguages,
|
||||
int currentId,
|
||||
bool fallback,
|
||||
int translationId = 0);
|
||||
// wins over mpv's own choice \a currentId; without one, \a currentId is kept. When mpv
|
||||
// chose nothing and \a fallback is set, picks a full (not forced) subtitle in the first
|
||||
// of \a fallbackLanguages that has one, then one flagged default, then the first one.
|
||||
// Text subtitles win over bitmap ones in the same step. Returns 0 for no subtitle.
|
||||
int pickSubtitle(const QList<Track> &tracks, const QStringList &preferredLanguages, const QStringList &fallbackLanguages, int currentId, bool fallback);
|
||||
|
||||
// The subtitle to translate: \a currentId when it is a text subtitle, else a full text
|
||||
// subtitle in the first of \a sourceLanguages that has one, then one flagged default,
|
||||
|
||||
@@ -278,11 +278,6 @@ 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) {
|
||||
|
||||
@@ -59,7 +59,6 @@ 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").
|
||||
|
||||
@@ -38,7 +38,6 @@ Item {
|
||||
signal headerToggleRequested()
|
||||
signal contextMenuRequested()
|
||||
signal translateRequested()
|
||||
signal translatePlaylistRequested()
|
||||
signal editGlossaryRequested()
|
||||
|
||||
function seekBy(seconds: real) {
|
||||
@@ -397,13 +396,6 @@ 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…")
|
||||
|
||||
@@ -103,7 +103,6 @@ ToolBar {
|
||||
|
||||
MenuSeparator {}
|
||||
AppMenuItem { action: root.actions.translateSubtitle }
|
||||
AppMenuItem { action: root.actions.translatePlaylist }
|
||||
AppMenuItem { action: root.actions.addSubtitle }
|
||||
}
|
||||
}
|
||||
|
||||
+12
-36
@@ -24,6 +24,7 @@ ApplicationWindow {
|
||||
// subtitle even while that translated track is selected in mpv.
|
||||
property var lastTranslationTrack: null
|
||||
property url lastTranslationVideo: ""
|
||||
property var translatedSubtitleUrls: []
|
||||
|
||||
function openFile(url: url) {
|
||||
playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings)
|
||||
@@ -45,27 +46,16 @@ ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
function translatedTrackTitle(): string {
|
||||
return KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)
|
||||
}
|
||||
|
||||
// The subtitle of the current video to translate, or undefined when it has no text one.
|
||||
function translationSourceTrack() {
|
||||
function translateCurrentSubtitle() {
|
||||
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)) {
|
||||
track = lastTranslationTrack ? video.subtitleTracks.find(track => track.id === lastTranslationTrack.id) : null
|
||||
if (track && translatedSubtitleUrls.includes(track.externalFilename) && lastTranslationTrack) {
|
||||
track = video.subtitleTracks.find(track => track.id === lastTranslationTrack.id)
|
||||
}
|
||||
// No subtitle shown, or a picture-based one: translate the best text subtitle.
|
||||
if (!track || track.bitmap) {
|
||||
const sourceId = video.translationSourceId()
|
||||
track = video.subtitleTracks.find(track => track.id === sourceId)
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
function translateCurrentSubtitle() {
|
||||
const track = translationSourceTrack()
|
||||
if (track) {
|
||||
lastTranslationTrack = track
|
||||
lastTranslationVideo = video.currentUrl
|
||||
@@ -134,14 +124,6 @@ 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()
|
||||
@@ -159,8 +141,7 @@ ApplicationWindow {
|
||||
PlaylistModel {
|
||||
id: playlistModel
|
||||
onPlayRequested: url => {
|
||||
// A translation made earlier is loaded and turned on with the file.
|
||||
video.loadFile(url, translationService.savedTranslation(url), appWindow.translatedTrackTitle())
|
||||
video.loadFile(url)
|
||||
RecentFilesModel.add(url)
|
||||
}
|
||||
}
|
||||
@@ -187,30 +168,26 @@ ApplicationWindow {
|
||||
|
||||
TranslationService {
|
||||
id: translationService
|
||||
currentVideo: video.currentUrl
|
||||
onTranslated: (subtitleUrl, fromCache) => {
|
||||
video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle())
|
||||
// mpv exposes local external subtitle filenames as filesystem paths.
|
||||
appWindow.translatedSubtitleUrls.push(decodeURIComponent(subtitleUrl.toString().replace(/^file:\/\//, "")))
|
||||
video.showTranslatedSubtitleFile(subtitleUrl,
|
||||
KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage))
|
||||
osdItem.message(fromCache
|
||||
? KI18n.i18nc("@info:osd", "Loaded the saved translation")
|
||||
: KI18n.i18nc("@info:osd", "Translation ready"))
|
||||
}
|
||||
onPlaylistTranslated: (translatedCount, alreadyTranslatedCount, problemCount) => {
|
||||
// Skipped episodes are listed in the error message.
|
||||
if (translatedCount > 0) {
|
||||
osdItem.message(KI18n.i18ncp("@info:osd", "Translated %1 episode", "Translated %1 episodes", translatedCount))
|
||||
} else if (problemCount === 0) {
|
||||
osdItem.message(KI18n.i18nc("@info:osd", "Every episode is already translated"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MpvVideo {
|
||||
id: video
|
||||
|
||||
// TranslationService follows the video itself (currentVideo).
|
||||
onCurrentUrlChanged: {
|
||||
translationService.cancel()
|
||||
translationService.forgetTranslation()
|
||||
appWindow.lastTranslationTrack = null
|
||||
appWindow.lastTranslationVideo = ""
|
||||
appWindow.translatedSubtitleUrls = []
|
||||
glossaryDialog.close()
|
||||
}
|
||||
|
||||
@@ -268,7 +245,6 @@ ApplicationWindow {
|
||||
playlist: playlistModel
|
||||
mpv: video
|
||||
mainWindowWidth: appWindow.width
|
||||
translateAction: appActions.translatePlaylist
|
||||
fullscreen: appWindow.isFullScreen
|
||||
height: video.height
|
||||
onAddFilesRequested: addToPlaylistDialog.open()
|
||||
|
||||
@@ -17,8 +17,6 @@ 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
|
||||
@@ -69,18 +67,6 @@ 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"
|
||||
|
||||
@@ -28,7 +28,6 @@ AppMenu {
|
||||
}
|
||||
MenuSeparator {}
|
||||
AppMenuItem { action: root.actions.translateSubtitle }
|
||||
AppMenuItem { action: root.actions.translatePlaylist }
|
||||
AppMenuItem { action: root.actions.editGlossary }
|
||||
AppMenuItem { action: root.actions.cancelTranslation }
|
||||
MenuSeparator {}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#include "subtitleextractor.h"
|
||||
|
||||
#include "trackselection.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <memory>
|
||||
@@ -140,39 +138,4 @@ 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, {}};
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,4 @@ 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);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
#include "translationservice.h"
|
||||
|
||||
#include "apikeystore.h"
|
||||
#include "mediainfo.h"
|
||||
#include "qtpotatosettings.h"
|
||||
#include "translationpresets.h"
|
||||
#include "translationstore.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
@@ -41,9 +39,28 @@ constexpr int ParallelRequests = 3;
|
||||
// Bump when the prompt changes so old cached translations are not reused.
|
||||
constexpr auto CacheVersion = "llm-v2";
|
||||
|
||||
QString cacheDir()
|
||||
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
|
||||
QString languageSuffix(const QString &language)
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
|
||||
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
|
||||
const auto candidate = static_cast<QLocale::Language>(i);
|
||||
if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) {
|
||||
const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1);
|
||||
return code.isEmpty() ? QLocale::languageToCode(candidate) : code;
|
||||
}
|
||||
}
|
||||
QString suffix = language.toLower();
|
||||
suffix.removeIf([](QChar c) {
|
||||
return !c.isLetterOrNumber() && c != u'-';
|
||||
});
|
||||
return suffix.isEmpty() ? u"translated"_s : suffix;
|
||||
}
|
||||
|
||||
QString cachePath(const QUrl &videoUrl, const QString &key, const QString &language)
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s;
|
||||
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
|
||||
return u"%1/%2.%3.%4.srt"_s.arg(dir, baseName, key, languageSuffix(language));
|
||||
}
|
||||
|
||||
QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries)
|
||||
@@ -70,10 +87,6 @@ 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 {
|
||||
@@ -165,25 +178,6 @@ 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();
|
||||
@@ -279,12 +273,6 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
|
||||
if (m_lastTranslationPath.isEmpty() || !QFileInfo::exists(m_lastTranslationPath)) {
|
||||
return;
|
||||
}
|
||||
// A translation the user has edited since is theirs; leave it alone.
|
||||
const QString registryFile = TranslationStore::registryFile();
|
||||
TranslationStore::Registry registry = TranslationStore::load(registryFile);
|
||||
if (!TranslationStore::isOwned(registry, m_lastTranslationPath)) {
|
||||
return;
|
||||
}
|
||||
QFile file(m_lastTranslationPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return;
|
||||
@@ -331,14 +319,10 @@ void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previ
|
||||
text.replace(markers.at(i), replacements.at(i).second);
|
||||
}
|
||||
|
||||
const QByteArray data = text.toUtf8();
|
||||
QSaveFile output(m_lastTranslationPath);
|
||||
if (!output.open(QIODevice::WriteOnly) || output.write(data) < 0 || !output.commit()) {
|
||||
if (!output.open(QIODevice::WriteOnly) || output.write(text.toUtf8()) < 0 || !output.commit()) {
|
||||
return;
|
||||
}
|
||||
// Still QtPotato's file, so a later translation may replace it.
|
||||
TranslationStore::recordRewrite(registry, m_lastTranslationPath, data);
|
||||
TranslationStore::save(registryFile, registry);
|
||||
Q_EMIT translated(QUrl::fromLocalFile(m_lastTranslationPath), true);
|
||||
}
|
||||
|
||||
@@ -347,25 +331,7 @@ void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &trac
|
||||
cancel();
|
||||
m_job = std::make_shared<Job>();
|
||||
m_job->mediaTitle = mediaTitle;
|
||||
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);
|
||||
run(videoUrl, track);
|
||||
}
|
||||
|
||||
void TranslationService::cancel()
|
||||
@@ -450,8 +416,10 @@ QCoro::Task<Subtitles::ExtractResult> TranslationService::loadCues(std::shared_p
|
||||
});
|
||||
}
|
||||
|
||||
QCoro::Task<std::optional<LlmClient::Config>> TranslationService::startJob(std::shared_ptr<Job> job)
|
||||
QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
{
|
||||
const std::shared_ptr<Job> job = m_job;
|
||||
|
||||
setErrorText({});
|
||||
setProgress(0);
|
||||
setBusy(true);
|
||||
@@ -459,123 +427,29 @@ QCoro::Task<std::optional<LlmClient::Config>> TranslationService::startJob(std::
|
||||
|
||||
auto [config, configError] = co_await loadConfig();
|
||||
if (job->cancelled) {
|
||||
co_return std::nullopt;
|
||||
}
|
||||
if (configError.isEmpty() && config.model.isEmpty()) {
|
||||
configError = i18n("Choose a model in Settings first.");
|
||||
co_return;
|
||||
}
|
||||
if (!configError.isEmpty()) {
|
||||
fail(job, configError);
|
||||
co_return std::nullopt;
|
||||
}
|
||||
co_return config;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
if (config.model.isEmpty()) {
|
||||
fail(job, i18n("Choose a model in Settings first."));
|
||||
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{};
|
||||
co_return;
|
||||
}
|
||||
if (!source.error.isEmpty()) {
|
||||
co_return Outcome::failed(source.error, true);
|
||||
fail(job, source.error);
|
||||
co_return;
|
||||
}
|
||||
const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues);
|
||||
if (cues.isEmpty()) {
|
||||
co_return Outcome::failed(i18n("This subtitle track has no text to translate."), true);
|
||||
fail(job, i18n("This subtitle track has no text to translate."));
|
||||
co_return;
|
||||
}
|
||||
|
||||
// Episodes in one folder share a glossary. Streams have no folder, so they get none.
|
||||
@@ -583,37 +457,30 @@ QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std:
|
||||
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;
|
||||
// Returns an error message.
|
||||
const auto saveGlossaryGroups = [&]() -> QString {
|
||||
const auto saveGlossaryGroups = [&]() {
|
||||
QString error;
|
||||
if (!GlossaryStore::save(glossary, &error)) {
|
||||
return i18n("Could not save the translation glossary: %1", error);
|
||||
fail(job, 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_glossaryEpisode = episode;
|
||||
Q_EMIT glossaryChanged();
|
||||
} else if (m_glossary.directory == glossary.directory) {
|
||||
m_glossary = glossary;
|
||||
Q_EMIT glossaryChanged();
|
||||
}
|
||||
return {};
|
||||
return true;
|
||||
};
|
||||
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 (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
|
||||
co_return Outcome::failed(error);
|
||||
if (!saveGlossaryGroups()) {
|
||||
co_return;
|
||||
}
|
||||
}
|
||||
const QString sourceSrt = Subtitles::toSrt(cues);
|
||||
|
||||
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once.
|
||||
const QString suffix = TranslationStore::languageSuffix(config.targetLanguage);
|
||||
const auto configKeyFor = [&](const GlossaryStore::Glossary &g) {
|
||||
const auto outputPathFor = [&](const GlossaryStore::Glossary &g) {
|
||||
QCryptographicHash hash(QCryptographicHash::Sha1);
|
||||
hash.addData(CacheVersion);
|
||||
hash.addData(config.model.toUtf8());
|
||||
@@ -622,17 +489,21 @@ QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std:
|
||||
hash.addData(config.customInstructions.toUtf8());
|
||||
hash.addData(GlossaryStore::serialize(g).toUtf8());
|
||||
hash.addData(sourceSrt.toUtf8());
|
||||
return QString::fromLatin1(hash.result().toHex().left(12));
|
||||
return cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
|
||||
};
|
||||
QString configKey = configKeyFor(glossary);
|
||||
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||
QString outputPath = outputPathFor(glossary);
|
||||
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
co_return Outcome::ready(outputPath, suffix, configKey, true);
|
||||
m_lastTranslationPath = outputPath;
|
||||
m_job.reset();
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
|
||||
co_return;
|
||||
}
|
||||
|
||||
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
|
||||
setJobStatus(*job, i18n("Building glossary…"));
|
||||
setStatusText(i18n("Building glossary…"));
|
||||
QStringList extractionLines;
|
||||
QHash<QString, bool> seen;
|
||||
for (const SubtitleCue &cue : cues) {
|
||||
@@ -657,7 +528,7 @@ QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std:
|
||||
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 Outcome{};
|
||||
co_return;
|
||||
}
|
||||
if (extracted.error.isEmpty()) {
|
||||
QList<GlossaryStore::Entry> additions;
|
||||
@@ -668,16 +539,20 @@ QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std:
|
||||
// Suggestions extracted while translating this episode belong to
|
||||
// this episode. Existing terms keep their original ownership.
|
||||
glossary = GlossaryStore::merge(glossary, additions, episode);
|
||||
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
|
||||
co_return Outcome::failed(error);
|
||||
if (!saveGlossaryGroups()) {
|
||||
co_return;
|
||||
}
|
||||
} else {
|
||||
setJobStatus(*job, i18n("Glossary unavailable; translating without it."));
|
||||
setStatusText(i18n("Glossary unavailable; translating without it."));
|
||||
}
|
||||
configKey = configKeyFor(glossary);
|
||||
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||
outputPath = outputPathFor(glossary);
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
co_return Outcome::ready(outputPath, suffix, configKey, true);
|
||||
m_lastTranslationPath = outputPath;
|
||||
m_job.reset();
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
|
||||
co_return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,7 +580,7 @@ QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std:
|
||||
batches->lines = uniqueLines;
|
||||
batches->translations.resize(uniqueLines.size());
|
||||
batches->batchSize = QtPotatoSettings::self()->batchSize();
|
||||
setLinesStatus(*job, 0, uniqueLines.size());
|
||||
setStatusText(i18n("Translating… %1 of %2 lines", 0, uniqueLines.size()));
|
||||
|
||||
// Tasks start running as soon as they are created; awaiting them just waits for the end.
|
||||
std::vector<QCoro::Task<>> workers;
|
||||
@@ -716,10 +591,11 @@ QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std:
|
||||
co_await std::move(worker);
|
||||
}
|
||||
if (job->cancelled) {
|
||||
co_return Outcome{};
|
||||
co_return;
|
||||
}
|
||||
if (!batches->error.isEmpty()) {
|
||||
co_return Outcome::failed(batches->error);
|
||||
fail(job, batches->error);
|
||||
co_return;
|
||||
}
|
||||
|
||||
QList<SubtitleCue> output = cues;
|
||||
@@ -730,70 +606,15 @@ QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std:
|
||||
QDir().mkpath(QFileInfo(outputPath).absolutePath());
|
||||
QSaveFile file(outputPath);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
|
||||
co_return Outcome::failed(i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
|
||||
fail(job, i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
|
||||
co_return;
|
||||
}
|
||||
co_return Outcome::ready(outputPath, suffix, configKey, false);
|
||||
}
|
||||
|
||||
QString TranslationService::publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome)
|
||||
{
|
||||
QFile file(outcome.cacheFile);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return i18n("Could not read the translation from %1: %2", outcome.cacheFile, file.errorString());
|
||||
}
|
||||
// The cache keeps every version; the published copy is the one players load.
|
||||
const QString registryFile = TranslationStore::registryFile();
|
||||
TranslationStore::Registry registry = TranslationStore::load(registryFile);
|
||||
const TranslationStore::PublishResult published =
|
||||
TranslationStore::publish(registry, videoUrl, outcome.suffix, outcome.configKey, file.readAll(), outcome.cacheFile);
|
||||
TranslationStore::save(registryFile, registry);
|
||||
|
||||
// Other episodes of a playlist load their translation when they are opened.
|
||||
if (!job->playlist || videoUrl == m_currentVideo) {
|
||||
m_lastTranslationPath = published.path;
|
||||
Q_EMIT translated(QUrl::fromLocalFile(published.path), outcome.fromCache);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void TranslationService::endJob(const std::shared_ptr<Job> &job)
|
||||
{
|
||||
if (m_job != job) {
|
||||
return;
|
||||
}
|
||||
m_job.reset();
|
||||
m_lastTranslationPath = outputPath;
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
}
|
||||
|
||||
void TranslationService::setJobStatus(const Job &job, const QString &text)
|
||||
{
|
||||
if (!job.playlist) {
|
||||
setStatusText(text);
|
||||
return;
|
||||
}
|
||||
setStatusText(i18nc("@info:status %1 and %2 are numbers, %3 is what is being done",
|
||||
"Episode %1 of %2: %3",
|
||||
job.episode + 1,
|
||||
job.episodes,
|
||||
text));
|
||||
}
|
||||
|
||||
void TranslationService::setLinesStatus(const Job &job, qsizetype done, qsizetype total)
|
||||
{
|
||||
// The line count would not fit after the episode number; the progress bar shows it.
|
||||
setJobStatus(job, job.playlist ? i18n("Translating…") : i18n("Translating… %1 of %2 lines", done, total));
|
||||
}
|
||||
|
||||
void TranslationService::setJobProgress(const Job &job, double episodeProgress)
|
||||
{
|
||||
setProgress(job.playlist ? (job.episode + episodeProgress) / job.episodes : episodeProgress);
|
||||
}
|
||||
|
||||
QString TranslationService::savedTranslation(const QUrl &videoUrl) const
|
||||
{
|
||||
const QString suffix = TranslationStore::languageSuffix(QtPotatoSettings::self()->targetLanguage().trimmed());
|
||||
return TranslationStore::savedTranslation(TranslationStore::load(TranslationStore::registryFile()), videoUrl, suffix);
|
||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), false);
|
||||
}
|
||||
|
||||
QCoro::Task<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context)
|
||||
@@ -881,8 +702,8 @@ QCoro::Task<> TranslationService::translateBatches(std::shared_ptr<Job> job, std
|
||||
batches->translations[start + i] = result.translations.at(i);
|
||||
}
|
||||
batches->doneLines += result.translations.size();
|
||||
setJobProgress(*job, double(batches->doneLines) / batches->lines.size());
|
||||
setLinesStatus(*job, batches->doneLines, batches->lines.size());
|
||||
setProgress(double(batches->doneLines) / batches->lines.size());
|
||||
setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,11 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
// Translates a whole subtitle track with an LLM, batch by batch, caches the result as an
|
||||
// .srt file and saves a copy next to the video (see TranslationStore).
|
||||
// Translates a whole subtitle track with an LLM, batch by batch, and caches the result
|
||||
// as an .srt file that mpv can load as a subtitle track.
|
||||
class TranslationService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -38,9 +37,6 @@ 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);
|
||||
@@ -58,23 +54,14 @@ 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 ¤tTrack, const QString ¤tTitle);
|
||||
Q_INVOKABLE void cancel();
|
||||
// Asks the API configured in the settings for its models.
|
||||
Q_INVOKABLE void fetchModels();
|
||||
Q_INVOKABLE void clearError();
|
||||
Q_INVOKABLE void forgetTranslation();
|
||||
// Path of the translation of \a videoUrl into the target language set in Settings,
|
||||
// or an empty string when there is none.
|
||||
Q_INVOKABLE QString savedTranslation(const QUrl &videoUrl) const;
|
||||
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
|
||||
Q_INVOKABLE bool saveGlossary(const QVariantList &entries, const QStringList &episodes = {});
|
||||
Q_INVOKABLE void clearGlossary();
|
||||
@@ -93,46 +80,12 @@ 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;
|
||||
|
||||
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<> run(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);
|
||||
@@ -144,14 +97,6 @@ private:
|
||||
// something is missing.
|
||||
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
|
||||
void fail(const std::shared_ptr<Job> &job, const QString &error);
|
||||
// Saves the translation where players find it and, for the current video, shows it.
|
||||
// Returns an error message on failure.
|
||||
QString publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome);
|
||||
void endJob(const std::shared_ptr<Job> &job);
|
||||
// In a playlist, prefixes the episode number and counts finished episodes in the progress.
|
||||
void setJobStatus(const Job &job, const QString &text);
|
||||
void setJobProgress(const Job &job, double episodeProgress);
|
||||
void setLinesStatus(const Job &job, qsizetype done, qsizetype total);
|
||||
|
||||
void setBusy(bool busy);
|
||||
void setProgress(double progress);
|
||||
@@ -172,5 +117,4 @@ private:
|
||||
GlossaryStore::Glossary m_glossary;
|
||||
QString m_glossaryEpisode;
|
||||
QString m_lastTranslationPath;
|
||||
QUrl m_currentVideo;
|
||||
};
|
||||
@@ -1,231 +0,0 @@
|
||||
#include "translationstore.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLocale>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int RegistryVersion = 1;
|
||||
|
||||
QString normalized(const QString &path)
|
||||
{
|
||||
return path.isEmpty() ? QString() : QFileInfo(path).absoluteFilePath();
|
||||
}
|
||||
|
||||
std::optional<QByteArray> readFile(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
bool writeFile(const QString &path, const QByteArray &data)
|
||||
{
|
||||
// No direct-write fallback: a folder that can't take a temporary file is treated
|
||||
// as read-only, and the translation stays in the cache.
|
||||
QSaveFile file(path);
|
||||
return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() && file.commit();
|
||||
}
|
||||
}
|
||||
|
||||
namespace TranslationStore
|
||||
{
|
||||
QString languageSuffix(const QString &language)
|
||||
{
|
||||
for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) {
|
||||
const auto candidate = static_cast<QLocale::Language>(i);
|
||||
if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) {
|
||||
const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1);
|
||||
return code.isEmpty() ? QLocale::languageToCode(candidate) : code;
|
||||
}
|
||||
}
|
||||
QString suffix = language.toLower();
|
||||
suffix.removeIf([](QChar c) {
|
||||
return !c.isLetterOrNumber() && c != u'-';
|
||||
});
|
||||
return suffix.isEmpty() ? u"translated"_s : suffix;
|
||||
}
|
||||
|
||||
QString videoKey(const QUrl &videoUrl)
|
||||
{
|
||||
return videoUrl.isLocalFile() ? normalized(videoUrl.toLocalFile()) : videoUrl.toString();
|
||||
}
|
||||
|
||||
QString cachePath(const QString &cacheDir, const QUrl &videoUrl, const QString &configKey, const QString &suffix)
|
||||
{
|
||||
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
|
||||
return u"%1/%2.%3.%4.srt"_s.arg(cacheDir, baseName, configKey, suffix);
|
||||
}
|
||||
|
||||
QStringList sidecarPaths(const QString &videoPath, const QString &suffix)
|
||||
{
|
||||
const QFileInfo info(videoPath);
|
||||
const QString stem = info.absolutePath() + u'/' + info.completeBaseName();
|
||||
return {u"%1.%2.srt"_s.arg(stem, suffix), u"%1.qtpotato.%2.srt"_s.arg(stem, suffix)};
|
||||
}
|
||||
|
||||
QByteArray contentHash(const QByteArray &data)
|
||||
{
|
||||
return QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
std::optional<Record> Registry::find(const QString &video, const QString &language) const
|
||||
{
|
||||
for (const Record &record : records) {
|
||||
if (record.video == video && record.language == language) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Record> Registry::findPath(const QString &path) const
|
||||
{
|
||||
const QString wanted = normalized(path);
|
||||
for (const Record &record : records) {
|
||||
if (record.path == wanted) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void Registry::put(const Record &record)
|
||||
{
|
||||
Record stored = record;
|
||||
stored.path = normalized(record.path);
|
||||
for (Record &existing : records) {
|
||||
if (existing.video == stored.video && existing.language == stored.language) {
|
||||
existing = stored;
|
||||
return;
|
||||
}
|
||||
}
|
||||
records.append(stored);
|
||||
}
|
||||
|
||||
Registry load(const QString &file)
|
||||
{
|
||||
Registry registry;
|
||||
const auto data = readFile(file);
|
||||
if (!data) {
|
||||
return registry;
|
||||
}
|
||||
const QJsonObject root = QJsonDocument::fromJson(*data).object();
|
||||
for (const QJsonValue &value : root.value(u"translations"_s).toArray()) {
|
||||
const QJsonObject object = value.toObject();
|
||||
Record record{object.value(u"video"_s).toString(),
|
||||
object.value(u"language"_s).toString(),
|
||||
object.value(u"path"_s).toString(),
|
||||
object.value(u"configKey"_s).toString(),
|
||||
object.value(u"contentHash"_s).toString()};
|
||||
if (!record.video.isEmpty() && !record.language.isEmpty() && !record.path.isEmpty()) {
|
||||
registry.put(record);
|
||||
}
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
bool save(const QString &file, const Registry ®istry, QString *error)
|
||||
{
|
||||
QJsonArray translations;
|
||||
for (const Record &record : registry.records) {
|
||||
if (!QFileInfo::exists(record.path)) {
|
||||
continue;
|
||||
}
|
||||
translations.append(QJsonObject{{u"video"_s, record.video},
|
||||
{u"language"_s, record.language},
|
||||
{u"path"_s, record.path},
|
||||
{u"configKey"_s, record.configKey},
|
||||
{u"contentHash"_s, record.contentHash}});
|
||||
}
|
||||
const QJsonObject root{{u"version"_s, RegistryVersion}, {u"translations"_s, translations}};
|
||||
|
||||
QDir().mkpath(QFileInfo(file).absolutePath());
|
||||
QSaveFile output(file);
|
||||
if (!output.open(QIODevice::WriteOnly) || output.write(QJsonDocument(root).toJson()) < 0 || !output.commit()) {
|
||||
if (error) {
|
||||
*error = output.errorString();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QString registryFile()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/translations.json"_s;
|
||||
}
|
||||
|
||||
bool isOwned(const Registry ®istry, const QString &path)
|
||||
{
|
||||
const auto record = registry.findPath(path);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
const auto data = readFile(path);
|
||||
return data && contentHash(*data) == record->contentHash.toLatin1();
|
||||
}
|
||||
|
||||
PublishResult publish(Registry ®istry,
|
||||
const QUrl &videoUrl,
|
||||
const QString &suffix,
|
||||
const QString &configKey,
|
||||
const QByteArray &data,
|
||||
const QString &cacheFile)
|
||||
{
|
||||
const QString video = videoKey(videoUrl);
|
||||
const QByteArray hash = contentHash(data);
|
||||
PublishResult result{normalized(cacheFile), false};
|
||||
|
||||
if (videoUrl.isLocalFile()) {
|
||||
QStringList candidates = sidecarPaths(videoUrl.toLocalFile(), suffix);
|
||||
// Keep using the file written last time, so a translation does not move to
|
||||
// ".vi.srt" and leave its ".qtpotato.vi.srt" copy behind.
|
||||
if (const auto previous = registry.find(video, suffix);
|
||||
previous && candidates.contains(previous->path) && isOwned(registry, previous->path)) {
|
||||
candidates.removeAll(previous->path);
|
||||
candidates.prepend(previous->path);
|
||||
}
|
||||
for (const QString &path : std::as_const(candidates)) {
|
||||
if (QFileInfo::exists(path) && !isOwned(registry, path)) {
|
||||
continue;
|
||||
}
|
||||
const auto current = readFile(path);
|
||||
if ((current && contentHash(*current) == hash) || writeFile(path, data)) {
|
||||
result = {path, true};
|
||||
}
|
||||
// Both names are in the same folder; when one can't be written, neither can.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
registry.put({video, suffix, result.path, configKey, QString::fromLatin1(hash)});
|
||||
return result;
|
||||
}
|
||||
|
||||
void recordRewrite(Registry ®istry, const QString &path, const QByteArray &data)
|
||||
{
|
||||
if (auto record = registry.findPath(path)) {
|
||||
record->contentHash = QString::fromLatin1(contentHash(data));
|
||||
registry.put(*record);
|
||||
}
|
||||
}
|
||||
|
||||
QString savedTranslation(const Registry ®istry, const QUrl &videoUrl, const QString &suffix)
|
||||
{
|
||||
const auto record = registry.find(videoKey(videoUrl), suffix);
|
||||
return record && QFileInfo::exists(record->path) ? record->path : QString();
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <optional>
|
||||
|
||||
// Where translated subtitles live. A translation is written next to the video as
|
||||
// "<video>.<lang>.srt" so mpv and other players load it, and recorded in a registry so
|
||||
// QtPotato knows which files it wrote, with which settings, and can find the copy it
|
||||
// had to keep in the cache when the video's folder was read-only or the video remote.
|
||||
namespace TranslationStore
|
||||
{
|
||||
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
|
||||
QString languageSuffix(const QString &language);
|
||||
|
||||
// The registry's name for a video: its absolute path, or the URL of a stream.
|
||||
QString videoKey(const QUrl &videoUrl);
|
||||
|
||||
// "<cacheDir>/<video basename>.<configKey>.<suffix>.srt". Every settings combination
|
||||
// gets its own file, so going back to earlier settings needs no new translation.
|
||||
QString cachePath(const QString &cacheDir, const QUrl &videoUrl, const QString &configKey, const QString &suffix);
|
||||
|
||||
// Files next to \a videoPath to try, in order: "<base>.<suffix>.srt", then
|
||||
// "<base>.qtpotato.<suffix>.srt" for when the user has a subtitle of their own there.
|
||||
QStringList sidecarPaths(const QString &videoPath, const QString &suffix);
|
||||
|
||||
QByteArray contentHash(const QByteArray &data);
|
||||
|
||||
struct Record {
|
||||
QString video;
|
||||
QString language;
|
||||
QString path;
|
||||
// Hash of the source subtitle and the settings it was translated with.
|
||||
QString configKey;
|
||||
// Hash of the file as QtPotato last wrote it. A file that no longer matches was
|
||||
// changed by someone else and is not overwritten.
|
||||
QString contentHash;
|
||||
};
|
||||
|
||||
// One record per video and language.
|
||||
struct Registry {
|
||||
QList<Record> records;
|
||||
|
||||
std::optional<Record> find(const QString &video, const QString &language) const;
|
||||
std::optional<Record> findPath(const QString &path) const;
|
||||
void put(const Record &record);
|
||||
};
|
||||
|
||||
Registry load(const QString &file);
|
||||
// Records whose file is gone are dropped.
|
||||
bool save(const QString &file, const Registry ®istry, QString *error = nullptr);
|
||||
// ~/.local/share/qtpotato/translations.json
|
||||
QString registryFile();
|
||||
|
||||
// True when \a path holds a translation QtPotato wrote and nobody changed since.
|
||||
bool isOwned(const Registry ®istry, const QString &path);
|
||||
|
||||
struct PublishResult {
|
||||
QString path;
|
||||
bool nextToVideo = false;
|
||||
};
|
||||
|
||||
// Puts \a data, the translation of \a videoUrl already saved at \a cacheFile, where
|
||||
// players find it: next to the video when the folder takes it, without touching a
|
||||
// subtitle QtPotato did not write, otherwise \a cacheFile. Records the result.
|
||||
PublishResult publish(Registry ®istry,
|
||||
const QUrl &videoUrl,
|
||||
const QString &suffix,
|
||||
const QString &configKey,
|
||||
const QByteArray &data,
|
||||
const QString &cacheFile);
|
||||
|
||||
// After QtPotato edited one of its files (glossary changes), keeps it marked as its own.
|
||||
void recordRewrite(Registry ®istry, const QString &path, const QByteArray &data);
|
||||
|
||||
// The saved translation of \a videoUrl into \a suffix that still exists, or an empty
|
||||
// string. Also the check for "has this video been translated yet".
|
||||
QString savedTranslation(const Registry ®istry, const QUrl &videoUrl, const QString &suffix);
|
||||
}
|
||||
Reference in new issue
Block a user