From bcfc2f3f6a027e39ce909b657cdd8700b44ef44a Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Fri, 13 Nov 2026 23:27:13 +0700 Subject: [PATCH] feat(translate): translate a whole playlist so the next episodes are ready (#16) Co-authored-by: Tri Vo Khac --- autotests/CMakeLists.txt | 5 + autotests/subtitleextractortest.cpp | 97 ++++++++++ src/playlist/playlistmodel.cpp | 5 + src/playlist/playlistmodel.h | 1 + src/qml/Actions.qml | 8 + src/qml/Header.qml | 1 + src/qml/Main.qml | 29 ++- src/qml/Playlist.qml | 14 ++ src/qml/SubtitlesMenu.qml | 1 + src/subtitles/subtitleextractor.cpp | 37 ++++ src/subtitles/subtitleextractor.h | 11 ++ src/translate/translationservice.cpp | 275 +++++++++++++++++++++------ src/translate/translationservice.h | 57 +++++- 13 files changed, 480 insertions(+), 61 deletions(-) create mode 100644 autotests/subtitleextractortest.cpp diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 628fbc7..15f669d 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -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 +) diff --git a/autotests/subtitleextractortest.cpp b/autotests/subtitleextractortest.cpp new file mode 100644 index 0000000..3043a2b --- /dev/null +++ b/autotests/subtitleextractortest.cpp @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "subtitleextractor.h" + +#include +#include +#include +#include +#include + +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" diff --git a/src/playlist/playlistmodel.cpp b/src/playlist/playlistmodel.cpp index a971134..dbb22e7 100644 --- a/src/playlist/playlistmodel.cpp +++ b/src/playlist/playlistmodel.cpp @@ -278,6 +278,11 @@ QUrl PlaylistModel::urlAt(int index) const return m_urls.value(index); } +QList PlaylistModel::urls() const +{ + return m_urls; +} + void PlaylistModel::setPlayingIndex(int index) { if (index == m_playingIndex) { diff --git a/src/playlist/playlistmodel.h b/src/playlist/playlistmodel.h index 5d81656..ecd679f 100644 --- a/src/playlist/playlistmodel.h +++ b/src/playlist/playlistmodel.h @@ -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 urls() const; // Media files directly inside \a folder, sorted the way a file manager does // ("Episode 2" before "Episode 10"). diff --git a/src/qml/Actions.qml b/src/qml/Actions.qml index b09a49e..432d07d 100644 --- a/src/qml/Actions.qml +++ b/src/qml/Actions.qml @@ -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…") diff --git a/src/qml/Header.qml b/src/qml/Header.qml index 121c807..f4f04d8 100644 --- a/src/qml/Header.qml +++ b/src/qml/Header.qml @@ -103,6 +103,7 @@ ToolBar { MenuSeparator {} AppMenuItem { action: root.actions.translateSubtitle } + AppMenuItem { action: root.actions.translatePlaylist } AppMenuItem { action: root.actions.addSubtitle } } } diff --git a/src/qml/Main.qml b/src/qml/Main.qml index a6c467d..f6cff17 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -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() diff --git a/src/qml/Playlist.qml b/src/qml/Playlist.qml index c3536a7..7cdfd17 100644 --- a/src/qml/Playlist.qml +++ b/src/qml/Playlist.qml @@ -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" diff --git a/src/qml/SubtitlesMenu.qml b/src/qml/SubtitlesMenu.qml index 6bb2b80..577b091 100644 --- a/src/qml/SubtitlesMenu.qml +++ b/src/qml/SubtitlesMenu.qml @@ -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 {} diff --git a/src/subtitles/subtitleextractor.cpp b/src/subtitles/subtitleextractor.cpp index ff0ee8d..fcfdcb1 100644 --- a/src/subtitles/subtitleextractor.cpp +++ b/src/subtitles/subtitleextractor.cpp @@ -1,5 +1,7 @@ #include "subtitleextractor.h" +#include "trackselection.h" + #include #include @@ -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 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 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(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, {}}; +} } diff --git a/src/subtitles/subtitleextractor.h b/src/subtitles/subtitleextractor.h index ae38584..8e29913 100644 --- a/src/subtitles/subtitleextractor.h +++ b/src/subtitles/subtitleextractor.h @@ -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); } diff --git a/src/translate/translationservice.cpp b/src/translate/translationservice.cpp index 5a069fd..a35c2bb 100644 --- a/src/translate/translationservice.cpp +++ b/src/translate/translationservice.cpp @@ -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 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(); m_job->mediaTitle = mediaTitle; - run(videoUrl, track); + run(m_job, videoUrl, track); +} + +void TranslationService::translatePlaylist(const QList &urls, const QVariantMap ¤tTrack, const QString ¤tTitle) +{ + cancel(); + // Start with the episode playing, then the ones after it, which are watched next. + QList ordered; + const qsizetype current = std::max(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(); + m_job->playlist = true; + m_job->episodes = ordered.size(); + runPlaylist(m_job, ordered, currentTrack, currentTitle); } void TranslationService::cancel() @@ -408,10 +450,8 @@ QCoro::Task TranslationService::loadCues(std::shared_p }); } -QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) +QCoro::Task> TranslationService::startJob(std::shared_ptr job) { - const std::shared_ptr 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, QUrl videoUrl, QVariantMap track) +{ + const std::optional 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, QList urls, QVariantMap currentTrack, QString currentTitle) +{ + const std::optional 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::translateVideo(std::shared_ptr 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 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 seen; for (const SubtitleCue &cue : cues) { @@ -518,7 +657,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) job->client = std::make_unique(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 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> 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 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, - const QUrl &videoUrl, - const QString &suffix, - const QString &configKey, - const QString &cacheFile, - bool fromCache) +QString TranslationService::publish(const std::shared_ptr &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) +{ + 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, 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()); } } diff --git a/src/translate/translationservice.h b/src/translate/translationservice.h index ade6bff..27c159b 100644 --- a/src/translate/translationservice.h +++ b/src/translate/translationservice.h @@ -15,6 +15,7 @@ #include #include +#include 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 &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, QUrl videoUrl, QVariantMap track); + QCoro::Task<> runPlaylist(std::shared_ptr job, QList 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> startJob(std::shared_ptr job); + QCoro::Task translateVideo(std::shared_ptr 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 translateLines(std::shared_ptr job, QStringList lines, QStringList context); @@ -100,8 +144,14 @@ private: // something is missing. QCoro::Task> loadConfig(); void fail(const std::shared_ptr &job, const QString &error); - // Saves the translation in \a cacheFile where players find it and announces it. - void finish(const std::shared_ptr &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, const QUrl &videoUrl, const Outcome &outcome); + void endJob(const std::shared_ptr &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; };