feat(translate): translate a whole playlist so the next episodes are ready #16
No files matched your search
@@ -34,3 +34,8 @@ ecm_add_test(mediainfotest.cpp
|
||||
TEST_NAME mediainfotest
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
|
||||
ecm_add_test(subtitleextractortest.cpp
|
||||
TEST_NAME subtitleextractortest
|
||||
LINK_LIBRARIES qtpotato_core Qt6::Test
|
||||
)
|
||||
@@ -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"
|
||||
@@ -278,6 +278,11 @@ QUrl PlaylistModel::urlAt(int index) const
|
||||
return m_urls.value(index);
|
||||
}
|
||||
|
||||
QList<QUrl> PlaylistModel::urls() const
|
||||
{
|
||||
return m_urls;
|
||||
}
|
||||
|
||||
void PlaylistModel::setPlayingIndex(int index)
|
||||
{
|
||||
if (index == m_playingIndex) {
|
||||
|
||||
@@ -59,6 +59,7 @@ public:
|
||||
Q_INVOKABLE void move(int from, int to);
|
||||
Q_INVOKABLE void clear();
|
||||
Q_INVOKABLE QUrl urlAt(int index) const;
|
||||
Q_INVOKABLE QList<QUrl> urls() const;
|
||||
|
||||
// Media files directly inside \a folder, sorted the way a file manager does
|
||||
// ("Episode 2" before "Episode 10").
|
||||
|
||||
@@ -38,6 +38,7 @@ Item {
|
||||
signal headerToggleRequested()
|
||||
signal contextMenuRequested()
|
||||
signal translateRequested()
|
||||
signal translatePlaylistRequested()
|
||||
signal editGlossaryRequested()
|
||||
|
||||
function seekBy(seconds: real) {
|
||||
@@ -396,6 +397,13 @@ Item {
|
||||
onTriggered: root.translateRequested()
|
||||
}
|
||||
|
||||
property Action translatePlaylist: Action {
|
||||
enabled: root.shortcutsEnabled && root.playlist.count > 1 && !root.translator.busy
|
||||
text: KI18n.i18nc("@action", "Translate Whole Playlist into %1", QtPotatoSettings.targetLanguage)
|
||||
icon.name: "translate"
|
||||
onTriggered: root.translatePlaylistRequested()
|
||||
}
|
||||
|
||||
property Action editGlossary: Action {
|
||||
enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:")
|
||||
text: KI18n.i18nc("@action", "Edit Translation Glossary…")
|
||||
|
||||
@@ -103,6 +103,7 @@ ToolBar {
|
||||
|
||||
MenuSeparator {}
|
||||
AppMenuItem { action: root.actions.translateSubtitle }
|
||||
AppMenuItem { action: root.actions.translatePlaylist }
|
||||
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)
|
||||
}
|
||||
|
||||
function translateCurrentSubtitle() {
|
||||
// The subtitle of the current video to translate, or undefined when it has no text one.
|
||||
function translationSourceTrack() {
|
||||
let track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
||||
// Translate the original again, not the translation on screen.
|
||||
if (track && video.isTranslatedSubtitle(track.id)) {
|
||||
@@ -60,6 +61,11 @@ ApplicationWindow {
|
||||
const sourceId = video.translationSourceId()
|
||||
track = video.subtitleTracks.find(track => track.id === sourceId)
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
function translateCurrentSubtitle() {
|
||||
const track = translationSourceTrack()
|
||||
if (track) {
|
||||
lastTranslationTrack = track
|
||||
lastTranslationVideo = video.currentUrl
|
||||
@@ -128,6 +134,14 @@ ApplicationWindow {
|
||||
onPlaylistToggleRequested: playlistPanel.toggle()
|
||||
onContextMenuRequested: contextMenu.popup()
|
||||
onTranslateRequested: appWindow.translateCurrentSubtitle()
|
||||
onTranslatePlaylistRequested: {
|
||||
const track = appWindow.translationSourceTrack()
|
||||
if (track) {
|
||||
appWindow.lastTranslationTrack = track
|
||||
appWindow.lastTranslationVideo = video.currentUrl
|
||||
}
|
||||
translationService.translatePlaylist(playlistModel.urls(), track ?? {}, video.mediaTitle)
|
||||
}
|
||||
onEditGlossaryRequested: {
|
||||
translationService.loadGlossary(video.currentUrl)
|
||||
glossaryDialog.open()
|
||||
@@ -173,20 +187,28 @@ ApplicationWindow {
|
||||
|
||||
TranslationService {
|
||||
id: translationService
|
||||
currentVideo: video.currentUrl
|
||||
onTranslated: (subtitleUrl, fromCache) => {
|
||||
video.showTranslatedSubtitleFile(subtitleUrl, appWindow.translatedTrackTitle())
|
||||
osdItem.message(fromCache
|
||||
? KI18n.i18nc("@info:osd", "Loaded the saved translation")
|
||||
: KI18n.i18nc("@info:osd", "Translation ready"))
|
||||
}
|
||||
onPlaylistTranslated: (translatedCount, alreadyTranslatedCount, problemCount) => {
|
||||
// Skipped episodes are listed in the error message.
|
||||
if (translatedCount > 0) {
|
||||
osdItem.message(KI18n.i18ncp("@info:osd", "Translated %1 episode", "Translated %1 episodes", translatedCount))
|
||||
} else if (problemCount === 0) {
|
||||
osdItem.message(KI18n.i18nc("@info:osd", "Every episode is already translated"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MpvVideo {
|
||||
id: video
|
||||
|
||||
// TranslationService follows the video itself (currentVideo).
|
||||
onCurrentUrlChanged: {
|
||||
translationService.cancel()
|
||||
translationService.forgetTranslation()
|
||||
appWindow.lastTranslationTrack = null
|
||||
appWindow.lastTranslationVideo = ""
|
||||
glossaryDialog.close()
|
||||
@@ -246,6 +268,7 @@ ApplicationWindow {
|
||||
playlist: playlistModel
|
||||
mpv: video
|
||||
mainWindowWidth: appWindow.width
|
||||
translateAction: appActions.translatePlaylist
|
||||
fullscreen: appWindow.isFullScreen
|
||||
height: video.height
|
||||
onAddFilesRequested: addToPlaylistDialog.open()
|
||||
|
||||
@@ -17,6 +17,8 @@ Page {
|
||||
required property PlaylistModel playlist
|
||||
required property MpvItem mpv
|
||||
required property int mainWindowWidth
|
||||
// Translates the subtitles of every entry.
|
||||
required property Action translateAction
|
||||
property bool fullscreen: false
|
||||
|
||||
readonly property int edge: QtPotatoSettings.playlistPosition === "left" ? Qt.LeftEdge : Qt.RightEdge
|
||||
@@ -67,6 +69,18 @@ Page {
|
||||
onClicked: root.addFilesRequested()
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
action: root.translateAction
|
||||
icon.width: root.buttonSize
|
||||
icon.height: root.buttonSize
|
||||
display: AbstractButton.IconOnly
|
||||
focusPolicy: Qt.TabFocus
|
||||
|
||||
ToolTip.text: text
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
text: KI18n.i18nc("@action:button", "Scroll to Playing Item")
|
||||
icon.name: "go-jump"
|
||||
|
||||
@@ -28,6 +28,7 @@ AppMenu {
|
||||
}
|
||||
MenuSeparator {}
|
||||
AppMenuItem { action: root.actions.translateSubtitle }
|
||||
AppMenuItem { action: root.actions.translatePlaylist }
|
||||
AppMenuItem { action: root.actions.editGlossary }
|
||||
AppMenuItem { action: root.actions.cancelTranslation }
|
||||
MenuSeparator {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "subtitleextractor.h"
|
||||
|
||||
#include "trackselection.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <memory>
|
||||
@@ -138,4 +140,39 @@ ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::a
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
SourceResult findTranslationSource(const QString &path)
|
||||
{
|
||||
AVFormatContext *rawFormat = nullptr;
|
||||
if (const int ret = avformat_open_input(&rawFormat, path.toUtf8().constData(), nullptr, nullptr); ret < 0) {
|
||||
return {-1, i18n("Could not open %1: %2", path, ffmpegError(ret))};
|
||||
}
|
||||
std::unique_ptr<AVFormatContext, FormatContextDeleter> format(rawFormat);
|
||||
|
||||
const auto language = [](const AVStream *stream) {
|
||||
const AVDictionaryEntry *tag = av_dict_get(stream->metadata, "language", nullptr, 0);
|
||||
return tag ? QString::fromUtf8(tag->value) : QString();
|
||||
};
|
||||
// Track ids start at 1, since 0 means "none"; id - 1 is the stream index.
|
||||
QList<TrackSelection::Track> subtitles;
|
||||
QString audioLanguage;
|
||||
bool audioIsDefault = false;
|
||||
for (unsigned i = 0; i < format->nb_streams; ++i) {
|
||||
const AVStream *stream = format->streams[i];
|
||||
const bool isDefault = stream->disposition & AV_DISPOSITION_DEFAULT;
|
||||
if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
|
||||
if (audioLanguage.isEmpty() || (isDefault && !audioIsDefault)) {
|
||||
audioLanguage = language(stream);
|
||||
audioIsDefault = isDefault;
|
||||
}
|
||||
} else if (stream->codecpar->codec_type == AVMEDIA_TYPE_SUBTITLE) {
|
||||
subtitles.append({static_cast<int>(i) + 1,
|
||||
language(stream),
|
||||
QString::fromUtf8(avcodec_get_name(stream->codecpar->codec_id)),
|
||||
bool(stream->disposition & AV_DISPOSITION_FORCED),
|
||||
isDefault});
|
||||
}
|
||||
}
|
||||
return {TrackSelection::pickTranslationSource(subtitles, {QStringLiteral("en"), audioLanguage}, 0) - 1, {}};
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,15 @@ struct ExtractResult {
|
||||
// Decodes a text subtitle stream of a media file with FFmpeg. \a streamIndex is the
|
||||
// FFmpeg stream index (mpv reports it as "ff-index"). Blocking; run it off the GUI thread.
|
||||
ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::atomic_bool &cancelled);
|
||||
|
||||
struct SourceResult {
|
||||
// FFmpeg stream index of the subtitle to translate, or -1 when the file has no
|
||||
// text subtitle.
|
||||
int streamIndex = -1;
|
||||
QString error;
|
||||
};
|
||||
|
||||
// The embedded subtitle of a file that is not open in mpv to translate, chosen like
|
||||
// MpvItem::translationSourceId(): English first, then the audio language. Blocking.
|
||||
SourceResult findTranslationSource(const QString &path);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "translationservice.h"
|
||||
|
||||
#include "apikeystore.h"
|
||||
#include "mediainfo.h"
|
||||
#include "qtpotatosettings.h"
|
||||
#include "translationpresets.h"
|
||||
#include "translationstore.h"
|
||||
@@ -69,6 +70,10 @@ struct TranslationService::Job {
|
||||
std::atomic_bool cancelled = false;
|
||||
std::unique_ptr<LlmClient> client;
|
||||
QString mediaTitle;
|
||||
bool playlist = false;
|
||||
// In a playlist: the episode being translated (from 0) and how many there are.
|
||||
int episode = 0;
|
||||
int episodes = 0;
|
||||
};
|
||||
|
||||
struct TranslationService::Batches {
|
||||
@@ -160,6 +165,25 @@ QString TranslationService::glossaryError() const
|
||||
return m_glossaryError;
|
||||
}
|
||||
|
||||
QUrl TranslationService::currentVideo() const
|
||||
{
|
||||
return m_currentVideo;
|
||||
}
|
||||
|
||||
void TranslationService::setCurrentVideo(const QUrl &url)
|
||||
{
|
||||
if (url == m_currentVideo) {
|
||||
return;
|
||||
}
|
||||
m_currentVideo = url;
|
||||
forgetTranslation();
|
||||
// A playlist translation is meant to go on while the episodes play.
|
||||
if (m_job && !m_job->playlist) {
|
||||
cancel();
|
||||
}
|
||||
Q_EMIT currentVideoChanged();
|
||||
}
|
||||
|
||||
void TranslationService::loadGlossary(const QUrl &videoUrl)
|
||||
{
|
||||
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
|
||||
@@ -323,7 +347,25 @@ void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &trac
|
||||
cancel();
|
||||
m_job = std::make_shared<Job>();
|
||||
m_job->mediaTitle = mediaTitle;
|
||||
run(videoUrl, track);
|
||||
run(m_job, videoUrl, track);
|
||||
}
|
||||
|
||||
void TranslationService::translatePlaylist(const QList<QUrl> &urls, const QVariantMap ¤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()
|
||||
@@ -408,10 +450,8 @@ QCoro::Task<Subtitles::ExtractResult> TranslationService::loadCues(std::shared_p
|
||||
});
|
||||
}
|
||||
|
||||
QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
QCoro::Task<std::optional<LlmClient::Config>> TranslationService::startJob(std::shared_ptr<Job> job)
|
||||
{
|
||||
const std::shared_ptr<Job> job = m_job;
|
||||
|
||||
setErrorText({});
|
||||
setProgress(0);
|
||||
setBusy(true);
|
||||
@@ -419,29 +459,123 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
|
||||
auto [config, configError] = co_await loadConfig();
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
co_return std::nullopt;
|
||||
}
|
||||
if (configError.isEmpty() && config.model.isEmpty()) {
|
||||
configError = i18n("Choose a model in Settings first.");
|
||||
}
|
||||
if (!configError.isEmpty()) {
|
||||
fail(job, configError);
|
||||
co_return;
|
||||
}
|
||||
if (config.model.isEmpty()) {
|
||||
fail(job, i18n("Choose a model in Settings first."));
|
||||
co_return;
|
||||
co_return std::nullopt;
|
||||
}
|
||||
co_return config;
|
||||
}
|
||||
|
||||
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
|
||||
QCoro::Task<> TranslationService::run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track)
|
||||
{
|
||||
const std::optional<LlmClient::Config> config = co_await startJob(job);
|
||||
if (!config) {
|
||||
co_return;
|
||||
}
|
||||
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
}
|
||||
if (!source.error.isEmpty()) {
|
||||
fail(job, source.error);
|
||||
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
|
||||
if (!error.isEmpty()) {
|
||||
fail(job, error);
|
||||
co_return;
|
||||
}
|
||||
endJob(job);
|
||||
}
|
||||
|
||||
QCoro::Task<> TranslationService::runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle)
|
||||
{
|
||||
const std::optional<LlmClient::Config> config = co_await startJob(job);
|
||||
if (!config) {
|
||||
co_return;
|
||||
}
|
||||
|
||||
int translatedCount = 0;
|
||||
int alreadyTranslatedCount = 0;
|
||||
QStringList problems;
|
||||
for (qsizetype i = 0; i < urls.size(); ++i) {
|
||||
const QUrl videoUrl = urls.at(i);
|
||||
const QString name = videoUrl.fileName();
|
||||
job->episode = int(i);
|
||||
setJobProgress(*job, 0);
|
||||
setJobStatus(*job, i18n("Reading subtitles…"));
|
||||
if (!savedTranslation(videoUrl).isEmpty()) {
|
||||
++alreadyTranslatedCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
QVariantMap track = currentTrack;
|
||||
job->mediaTitle = currentTitle;
|
||||
if (videoUrl != m_currentVideo || currentTrack.isEmpty()) {
|
||||
if (!videoUrl.isLocalFile()) {
|
||||
problems.append(i18nc("@info %1 is a file name", "%1: only subtitles of local files can be translated", name));
|
||||
continue;
|
||||
}
|
||||
const QString path = videoUrl.toLocalFile();
|
||||
const Subtitles::SourceResult source = co_await QtConcurrent::run([path] {
|
||||
return Subtitles::findTranslationSource(path);
|
||||
});
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
}
|
||||
if (!source.error.isEmpty() || source.streamIndex < 0) {
|
||||
problems.append(source.error.isEmpty() ? i18nc("@info %1 is a file name", "%1: no text subtitle", name)
|
||||
: i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, source.error));
|
||||
continue;
|
||||
}
|
||||
track = QVariantMap{{u"ffIndex"_s, source.streamIndex}};
|
||||
job->mediaTitle = MediaInfo::cleanTitle(name);
|
||||
}
|
||||
|
||||
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
}
|
||||
if (!outcome.error.isEmpty() && !outcome.skippable) {
|
||||
// Errors from the API or the disk would repeat for every episode.
|
||||
fail(job, i18nc("@info %1 is a file name, %2 an error", "Stopped translating the playlist at %1: %2", name, outcome.error));
|
||||
co_return;
|
||||
}
|
||||
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
|
||||
if (error.isEmpty()) {
|
||||
++translatedCount;
|
||||
} else {
|
||||
problems.append(i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, error));
|
||||
}
|
||||
}
|
||||
|
||||
endJob(job);
|
||||
if (!problems.isEmpty()) {
|
||||
setErrorText(i18np("Translated %2 of %3 episodes. Skipped one:\n%4",
|
||||
"Translated %2 of %3 episodes. Skipped %1:\n%4",
|
||||
problems.size(),
|
||||
translatedCount + alreadyTranslatedCount,
|
||||
urls.size(),
|
||||
problems.join(u'\n')));
|
||||
}
|
||||
Q_EMIT playlistTranslated(translatedCount, alreadyTranslatedCount, int(problems.size()));
|
||||
}
|
||||
|
||||
QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track)
|
||||
{
|
||||
setJobProgress(*job, 0);
|
||||
setJobStatus(*job, i18n("Reading subtitles…"));
|
||||
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
|
||||
if (job->cancelled) {
|
||||
co_return Outcome{};
|
||||
}
|
||||
if (!source.error.isEmpty()) {
|
||||
co_return Outcome::failed(source.error, true);
|
||||
}
|
||||
const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues);
|
||||
if (cues.isEmpty()) {
|
||||
fail(job, i18n("This subtitle track has no text to translate."));
|
||||
co_return;
|
||||
co_return Outcome::failed(i18n("This subtitle track has no text to translate."), true);
|
||||
}
|
||||
|
||||
// Episodes in one folder share a glossary. Streams have no folder, so they get none.
|
||||
@@ -449,24 +583,30 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
const bool useGlossary = !seriesDirectory.isEmpty();
|
||||
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
|
||||
const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle;
|
||||
const auto saveGlossaryGroups = [&]() {
|
||||
// Returns an error message.
|
||||
const auto saveGlossaryGroups = [&]() -> QString {
|
||||
QString error;
|
||||
if (!GlossaryStore::save(glossary, &error)) {
|
||||
fail(job, i18n("Could not save the translation glossary: %1", error));
|
||||
return false;
|
||||
return i18n("Could not save the translation glossary: %1", error);
|
||||
}
|
||||
m_glossary = glossary;
|
||||
m_glossaryEpisode = episode;
|
||||
Q_EMIT glossaryChanged();
|
||||
return true;
|
||||
// Another episode of the playlist only updates the terms of the open series.
|
||||
if (!job->playlist || videoUrl == m_currentVideo) {
|
||||
m_glossary = glossary;
|
||||
m_glossaryEpisode = episode;
|
||||
Q_EMIT glossaryChanged();
|
||||
} else if (m_glossary.directory == glossary.directory) {
|
||||
m_glossary = glossary;
|
||||
Q_EMIT glossaryChanged();
|
||||
}
|
||||
return {};
|
||||
};
|
||||
if (useGlossary) {
|
||||
// New terms belong to the episode being translated. Migrate legacy or
|
||||
// imported entries without provenance to this first episode too. This
|
||||
// happens before the cache shortcut and never changes translation text.
|
||||
glossary = GlossaryStore::assignUnassigned(glossary, episode);
|
||||
if (!saveGlossaryGroups()) {
|
||||
co_return;
|
||||
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
|
||||
co_return Outcome::failed(error);
|
||||
}
|
||||
}
|
||||
const QString sourceSrt = Subtitles::toSrt(cues);
|
||||
@@ -488,12 +628,11 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
||||
co_return;
|
||||
co_return Outcome::ready(outputPath, suffix, configKey, true);
|
||||
}
|
||||
|
||||
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
|
||||
setStatusText(i18n("Building glossary…"));
|
||||
setJobStatus(*job, i18n("Building glossary…"));
|
||||
QStringList extractionLines;
|
||||
QHash<QString, bool> seen;
|
||||
for (const SubtitleCue &cue : cues) {
|
||||
@@ -518,7 +657,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
job->client = std::make_unique<LlmClient>(m_network, config);
|
||||
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
co_return Outcome{};
|
||||
}
|
||||
if (extracted.error.isEmpty()) {
|
||||
QList<GlossaryStore::Entry> additions;
|
||||
@@ -529,17 +668,16 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
// Suggestions extracted while translating this episode belong to
|
||||
// this episode. Existing terms keep their original ownership.
|
||||
glossary = GlossaryStore::merge(glossary, additions, episode);
|
||||
if (!saveGlossaryGroups()) {
|
||||
co_return;
|
||||
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
|
||||
co_return Outcome::failed(error);
|
||||
}
|
||||
} else {
|
||||
setStatusText(i18n("Glossary unavailable; translating without it."));
|
||||
setJobStatus(*job, i18n("Glossary unavailable; translating without it."));
|
||||
}
|
||||
configKey = configKeyFor(glossary);
|
||||
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
finish(job, videoUrl, suffix, configKey, outputPath, true);
|
||||
co_return;
|
||||
co_return Outcome::ready(outputPath, suffix, configKey, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +705,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
batches->lines = uniqueLines;
|
||||
batches->translations.resize(uniqueLines.size());
|
||||
batches->batchSize = QtPotatoSettings::self()->batchSize();
|
||||
setStatusText(i18n("Translating… %1 of %2 lines", 0, uniqueLines.size()));
|
||||
setLinesStatus(*job, 0, uniqueLines.size());
|
||||
|
||||
// Tasks start running as soon as they are created; awaiting them just waits for the end.
|
||||
std::vector<QCoro::Task<>> workers;
|
||||
@@ -578,11 +716,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
co_await std::move(worker);
|
||||
}
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
co_return Outcome{};
|
||||
}
|
||||
if (!batches->error.isEmpty()) {
|
||||
fail(job, batches->error);
|
||||
co_return;
|
||||
co_return Outcome::failed(batches->error);
|
||||
}
|
||||
|
||||
QList<SubtitleCue> output = cues;
|
||||
@@ -593,36 +730,64 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
QDir().mkpath(QFileInfo(outputPath).absolutePath());
|
||||
QSaveFile file(outputPath);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
|
||||
fail(job, i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
|
||||
co_return;
|
||||
co_return Outcome::failed(i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
|
||||
}
|
||||
|
||||
finish(job, videoUrl, suffix, configKey, outputPath, false);
|
||||
co_return Outcome::ready(outputPath, suffix, configKey, false);
|
||||
}
|
||||
|
||||
void TranslationService::finish(const std::shared_ptr<Job> &job,
|
||||
const QUrl &videoUrl,
|
||||
const QString &suffix,
|
||||
const QString &configKey,
|
||||
const QString &cacheFile,
|
||||
bool fromCache)
|
||||
QString TranslationService::publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome)
|
||||
{
|
||||
QFile file(cacheFile);
|
||||
QFile file(outcome.cacheFile);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
fail(job, i18n("Could not read the translation from %1: %2", cacheFile, file.errorString()));
|
||||
return;
|
||||
return i18n("Could not read the translation from %1: %2", outcome.cacheFile, file.errorString());
|
||||
}
|
||||
// The cache keeps every version; the published copy is the one players load.
|
||||
const QString registryFile = TranslationStore::registryFile();
|
||||
TranslationStore::Registry registry = TranslationStore::load(registryFile);
|
||||
const TranslationStore::PublishResult published = TranslationStore::publish(registry, videoUrl, suffix, configKey, file.readAll(), cacheFile);
|
||||
const TranslationStore::PublishResult published =
|
||||
TranslationStore::publish(registry, videoUrl, outcome.suffix, outcome.configKey, file.readAll(), outcome.cacheFile);
|
||||
TranslationStore::save(registryFile, registry);
|
||||
|
||||
// Other episodes of a playlist load their translation when they are opened.
|
||||
if (!job->playlist || videoUrl == m_currentVideo) {
|
||||
m_lastTranslationPath = published.path;
|
||||
Q_EMIT translated(QUrl::fromLocalFile(published.path), outcome.fromCache);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void TranslationService::endJob(const std::shared_ptr<Job> &job)
|
||||
{
|
||||
if (m_job != job) {
|
||||
return;
|
||||
}
|
||||
m_job.reset();
|
||||
m_lastTranslationPath = published.path;
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
Q_EMIT translated(QUrl::fromLocalFile(published.path), fromCache);
|
||||
}
|
||||
|
||||
void TranslationService::setJobStatus(const Job &job, const QString &text)
|
||||
{
|
||||
if (!job.playlist) {
|
||||
setStatusText(text);
|
||||
return;
|
||||
}
|
||||
setStatusText(i18nc("@info:status %1 and %2 are numbers, %3 is what is being done",
|
||||
"Episode %1 of %2: %3",
|
||||
job.episode + 1,
|
||||
job.episodes,
|
||||
text));
|
||||
}
|
||||
|
||||
void TranslationService::setLinesStatus(const Job &job, qsizetype done, qsizetype total)
|
||||
{
|
||||
// The line count would not fit after the episode number; the progress bar shows it.
|
||||
setJobStatus(job, job.playlist ? i18n("Translating…") : i18n("Translating… %1 of %2 lines", done, total));
|
||||
}
|
||||
|
||||
void TranslationService::setJobProgress(const Job &job, double episodeProgress)
|
||||
{
|
||||
setProgress(job.playlist ? (job.episode + episodeProgress) / job.episodes : episodeProgress);
|
||||
}
|
||||
|
||||
QString TranslationService::savedTranslation(const QUrl &videoUrl) const
|
||||
@@ -716,8 +881,8 @@ QCoro::Task<> TranslationService::translateBatches(std::shared_ptr<Job> job, std
|
||||
batches->translations[start + i] = result.translations.at(i);
|
||||
}
|
||||
batches->doneLines += result.translations.size();
|
||||
setProgress(double(batches->doneLines) / batches->lines.size());
|
||||
setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size()));
|
||||
setJobProgress(*job, double(batches->doneLines) / batches->lines.size());
|
||||
setLinesStatus(*job, batches->doneLines, batches->lines.size());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
@@ -37,6 +38,9 @@ class TranslationService : public QObject
|
||||
Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged)
|
||||
Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged)
|
||||
Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged)
|
||||
// The video playing. Its translations are shown when ready; others are only saved.
|
||||
// Opening another video stops a single translation but not a playlist one.
|
||||
Q_PROPERTY(QUrl currentVideo READ currentVideo WRITE setCurrentVideo NOTIFY currentVideoChanged)
|
||||
|
||||
public:
|
||||
explicit TranslationService(QObject *parent = nullptr);
|
||||
@@ -54,9 +58,15 @@ public:
|
||||
QString glossaryEpisode() const;
|
||||
QStringList glossaryEpisodes() const;
|
||||
QString glossaryError() const;
|
||||
QUrl currentVideo() const;
|
||||
void setCurrentVideo(const QUrl &url);
|
||||
|
||||
// \a track is an entry of MpvItem::subtitleTracks.
|
||||
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
|
||||
// Translates every video in \a urls that has no saved translation yet, one after
|
||||
// another, starting at the current video. The current one uses \a currentTrack when
|
||||
// set; the others use the embedded subtitle findTranslationSource() picks.
|
||||
Q_INVOKABLE void translatePlaylist(const QList<QUrl> &urls, const QVariantMap ¤tTrack, const QString ¤tTitle);
|
||||
Q_INVOKABLE void cancel();
|
||||
// Asks the API configured in the settings for its models.
|
||||
Q_INVOKABLE void fetchModels();
|
||||
@@ -83,12 +93,46 @@ Q_SIGNALS:
|
||||
void glossaryChanged();
|
||||
void glossaryErrorChanged();
|
||||
void translated(const QUrl &subtitleUrl, bool fromCache);
|
||||
void currentVideoChanged();
|
||||
// A playlist translation ended without being cancelled. Problems are in errorText.
|
||||
void playlistTranslated(int translatedCount, int alreadyTranslatedCount, int problemCount);
|
||||
|
||||
private:
|
||||
struct Job;
|
||||
struct Batches;
|
||||
// Where translateVideo() left a translation, or why it could not make one.
|
||||
struct Outcome {
|
||||
QString cacheFile;
|
||||
QString suffix;
|
||||
QString configKey;
|
||||
bool fromCache = false;
|
||||
QString error;
|
||||
// The error is about this video's subtitles; other videos may still work.
|
||||
bool skippable = false;
|
||||
|
||||
QCoro::Task<> run(QUrl videoUrl, QVariantMap track);
|
||||
static Outcome ready(const QString &cacheFile, const QString &suffix, const QString &configKey, bool fromCache)
|
||||
{
|
||||
Outcome outcome;
|
||||
outcome.cacheFile = cacheFile;
|
||||
outcome.suffix = suffix;
|
||||
outcome.configKey = configKey;
|
||||
outcome.fromCache = fromCache;
|
||||
return outcome;
|
||||
}
|
||||
static Outcome failed(const QString &error, bool skippable = false)
|
||||
{
|
||||
Outcome outcome;
|
||||
outcome.error = error;
|
||||
outcome.skippable = skippable;
|
||||
return outcome;
|
||||
}
|
||||
};
|
||||
|
||||
QCoro::Task<> run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track);
|
||||
QCoro::Task<> runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle);
|
||||
// Reads the settings and the API key for \a job; nothing (after failing it) when they are not usable.
|
||||
QCoro::Task<std::optional<LlmClient::Config>> startJob(std::shared_ptr<Job> job);
|
||||
QCoro::Task<Outcome> translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track);
|
||||
QCoro::Task<> runFetchModels();
|
||||
// Translates \a lines, retrying failures and asking again for lines the model skipped.
|
||||
QCoro::Task<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context);
|
||||
@@ -100,8 +144,14 @@ private:
|
||||
// something is missing.
|
||||
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
|
||||
void fail(const std::shared_ptr<Job> &job, const QString &error);
|
||||
// Saves the translation in \a cacheFile where players find it and announces it.
|
||||
void finish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache);
|
||||
// Saves the translation where players find it and, for the current video, shows it.
|
||||
// Returns an error message on failure.
|
||||
QString publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome);
|
||||
void endJob(const std::shared_ptr<Job> &job);
|
||||
// In a playlist, prefixes the episode number and counts finished episodes in the progress.
|
||||
void setJobStatus(const Job &job, const QString &text);
|
||||
void setJobProgress(const Job &job, double episodeProgress);
|
||||
void setLinesStatus(const Job &job, qsizetype done, qsizetype total);
|
||||
|
||||
void setBusy(bool busy);
|
||||
void setProgress(double progress);
|
||||
@@ -122,4 +172,5 @@ private:
|
||||
GlossaryStore::Glossary m_glossary;
|
||||
QString m_glossaryEpisode;
|
||||
QString m_lastTranslationPath;
|
||||
QUrl m_currentVideo;
|
||||
};
|
||||
Reference in new issue
Block a user