feat(translate): translate a whole playlist so the next episodes are ready (#16)

Co-authored-by: Tri Vo Khac <git@trivk.dev>
This commit was merged in pull request #16.
This commit is contained in:
tri authored and tri committed 2026-11-13 23:27:13 +07:00
1 parent ad50230829
commit bcfc2f3f6a
13 files changed
+480 -61

No files matched your search

+220 -55
View File
@@ -1,6 +1,7 @@
#include "translationservice.h"
#include "apikeystore.h"
#include "mediainfo.h"
#include "qtpotatosettings.h"
#include "translationpresets.h"
#include "translationstore.h"
@@ -69,6 +70,10 @@ struct TranslationService::Job {
std::atomic_bool cancelled = false;
std::unique_ptr<LlmClient> client;
QString mediaTitle;
bool playlist = false;
// In a playlist: the episode being translated (from 0) and how many there are.
int episode = 0;
int episodes = 0;
};
struct TranslationService::Batches {
@@ -160,6 +165,25 @@ QString TranslationService::glossaryError() const
return m_glossaryError;
}
QUrl TranslationService::currentVideo() const
{
return m_currentVideo;
}
void TranslationService::setCurrentVideo(const QUrl &url)
{
if (url == m_currentVideo) {
return;
}
m_currentVideo = url;
forgetTranslation();
// A playlist translation is meant to go on while the episodes play.
if (m_job && !m_job->playlist) {
cancel();
}
Q_EMIT currentVideoChanged();
}
void TranslationService::loadGlossary(const QUrl &videoUrl)
{
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
@@ -323,7 +347,25 @@ void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &trac
cancel();
m_job = std::make_shared<Job>();
m_job->mediaTitle = mediaTitle;
run(videoUrl, track);
run(m_job, videoUrl, track);
}
void TranslationService::translatePlaylist(const QList<QUrl> &urls, const QVariantMap &currentTrack, const QString &currentTitle)
{
cancel();
// Start with the episode playing, then the ones after it, which are watched next.
QList<QUrl> ordered;
const qsizetype current = std::max<qsizetype>(0, urls.indexOf(m_currentVideo));
for (qsizetype i = 0; i < urls.size(); ++i) {
const QUrl &url = urls.at((current + i) % urls.size());
if (!ordered.contains(url)) {
ordered.append(url);
}
}
m_job = std::make_shared<Job>();
m_job->playlist = true;
m_job->episodes = ordered.size();
runPlaylist(m_job, ordered, currentTrack, currentTitle);
}
void TranslationService::cancel()
@@ -408,10 +450,8 @@ QCoro::Task<Subtitles::ExtractResult> TranslationService::loadCues(std::shared_p
});
}
QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
QCoro::Task<std::optional<LlmClient::Config>> TranslationService::startJob(std::shared_ptr<Job> job)
{
const std::shared_ptr<Job> job = m_job;
setErrorText({});
setProgress(0);
setBusy(true);
@@ -419,29 +459,123 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
auto [config, configError] = co_await loadConfig();
if (job->cancelled) {
co_return;
co_return std::nullopt;
}
if (configError.isEmpty() && config.model.isEmpty()) {
configError = i18n("Choose a model in Settings first.");
}
if (!configError.isEmpty()) {
fail(job, configError);
co_return;
}
if (config.model.isEmpty()) {
fail(job, i18n("Choose a model in Settings first."));
co_return;
co_return std::nullopt;
}
co_return config;
}
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
QCoro::Task<> TranslationService::run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track)
{
const std::optional<LlmClient::Config> config = co_await startJob(job);
if (!config) {
co_return;
}
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
if (job->cancelled) {
co_return;
}
if (!source.error.isEmpty()) {
fail(job, source.error);
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
if (!error.isEmpty()) {
fail(job, error);
co_return;
}
endJob(job);
}
QCoro::Task<> TranslationService::runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle)
{
const std::optional<LlmClient::Config> config = co_await startJob(job);
if (!config) {
co_return;
}
int translatedCount = 0;
int alreadyTranslatedCount = 0;
QStringList problems;
for (qsizetype i = 0; i < urls.size(); ++i) {
const QUrl videoUrl = urls.at(i);
const QString name = videoUrl.fileName();
job->episode = int(i);
setJobProgress(*job, 0);
setJobStatus(*job, i18n("Reading subtitles…"));
if (!savedTranslation(videoUrl).isEmpty()) {
++alreadyTranslatedCount;
continue;
}
QVariantMap track = currentTrack;
job->mediaTitle = currentTitle;
if (videoUrl != m_currentVideo || currentTrack.isEmpty()) {
if (!videoUrl.isLocalFile()) {
problems.append(i18nc("@info %1 is a file name", "%1: only subtitles of local files can be translated", name));
continue;
}
const QString path = videoUrl.toLocalFile();
const Subtitles::SourceResult source = co_await QtConcurrent::run([path] {
return Subtitles::findTranslationSource(path);
});
if (job->cancelled) {
co_return;
}
if (!source.error.isEmpty() || source.streamIndex < 0) {
problems.append(source.error.isEmpty() ? i18nc("@info %1 is a file name", "%1: no text subtitle", name)
: i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, source.error));
continue;
}
track = QVariantMap{{u"ffIndex"_s, source.streamIndex}};
job->mediaTitle = MediaInfo::cleanTitle(name);
}
const Outcome outcome = co_await translateVideo(job, *config, videoUrl, track);
if (job->cancelled) {
co_return;
}
if (!outcome.error.isEmpty() && !outcome.skippable) {
// Errors from the API or the disk would repeat for every episode.
fail(job, i18nc("@info %1 is a file name, %2 an error", "Stopped translating the playlist at %1: %2", name, outcome.error));
co_return;
}
const QString error = outcome.error.isEmpty() ? publish(job, videoUrl, outcome) : outcome.error;
if (error.isEmpty()) {
++translatedCount;
} else {
problems.append(i18nc("@info %1 is a file name, %2 an error", "%1: %2", name, error));
}
}
endJob(job);
if (!problems.isEmpty()) {
setErrorText(i18np("Translated %2 of %3 episodes. Skipped one:\n%4",
"Translated %2 of %3 episodes. Skipped %1:\n%4",
problems.size(),
translatedCount + alreadyTranslatedCount,
urls.size(),
problems.join(u'\n')));
}
Q_EMIT playlistTranslated(translatedCount, alreadyTranslatedCount, int(problems.size()));
}
QCoro::Task<TranslationService::Outcome> TranslationService::translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track)
{
setJobProgress(*job, 0);
setJobStatus(*job, i18n("Reading subtitles…"));
Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track);
if (job->cancelled) {
co_return Outcome{};
}
if (!source.error.isEmpty()) {
co_return Outcome::failed(source.error, true);
}
const QList<SubtitleCue> cues = Subtitles::mergeRepeated(source.cues);
if (cues.isEmpty()) {
fail(job, i18n("This subtitle track has no text to translate."));
co_return;
co_return Outcome::failed(i18n("This subtitle track has no text to translate."), true);
}
// Episodes in one folder share a glossary. Streams have no folder, so they get none.
@@ -449,24 +583,30 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
const bool useGlossary = !seriesDirectory.isEmpty();
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle;
const auto saveGlossaryGroups = [&]() {
// Returns an error message.
const auto saveGlossaryGroups = [&]() -> QString {
QString error;
if (!GlossaryStore::save(glossary, &error)) {
fail(job, i18n("Could not save the translation glossary: %1", error));
return false;
return i18n("Could not save the translation glossary: %1", error);
}
m_glossary = glossary;
m_glossaryEpisode = episode;
Q_EMIT glossaryChanged();
return true;
// Another episode of the playlist only updates the terms of the open series.
if (!job->playlist || videoUrl == m_currentVideo) {
m_glossary = glossary;
m_glossaryEpisode = episode;
Q_EMIT glossaryChanged();
} else if (m_glossary.directory == glossary.directory) {
m_glossary = glossary;
Q_EMIT glossaryChanged();
}
return {};
};
if (useGlossary) {
// New terms belong to the episode being translated. Migrate legacy or
// imported entries without provenance to this first episode too. This
// happens before the cache shortcut and never changes translation text.
glossary = GlossaryStore::assignUnassigned(glossary, episode);
if (!saveGlossaryGroups()) {
co_return;
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
co_return Outcome::failed(error);
}
}
const QString sourceSrt = Subtitles::toSrt(cues);
@@ -488,12 +628,11 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
QString outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) {
finish(job, videoUrl, suffix, configKey, outputPath, true);
co_return;
co_return Outcome::ready(outputPath, suffix, configKey, true);
}
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
setStatusText(i18n("Building glossary…"));
setJobStatus(*job, i18n("Building glossary…"));
QStringList extractionLines;
QHash<QString, bool> seen;
for (const SubtitleCue &cue : cues) {
@@ -518,7 +657,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
job->client = std::make_unique<LlmClient>(m_network, config);
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
if (job->cancelled) {
co_return;
co_return Outcome{};
}
if (extracted.error.isEmpty()) {
QList<GlossaryStore::Entry> additions;
@@ -529,17 +668,16 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
// Suggestions extracted while translating this episode belong to
// this episode. Existing terms keep their original ownership.
glossary = GlossaryStore::merge(glossary, additions, episode);
if (!saveGlossaryGroups()) {
co_return;
if (const QString error = saveGlossaryGroups(); !error.isEmpty()) {
co_return Outcome::failed(error);
}
} else {
setStatusText(i18n("Glossary unavailable; translating without it."));
setJobStatus(*job, i18n("Glossary unavailable; translating without it."));
}
configKey = configKeyFor(glossary);
outputPath = TranslationStore::cachePath(cacheDir(), videoUrl, configKey, suffix);
if (QFileInfo::exists(outputPath)) {
finish(job, videoUrl, suffix, configKey, outputPath, true);
co_return;
co_return Outcome::ready(outputPath, suffix, configKey, true);
}
}
@@ -567,7 +705,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
batches->lines = uniqueLines;
batches->translations.resize(uniqueLines.size());
batches->batchSize = QtPotatoSettings::self()->batchSize();
setStatusText(i18n("Translating… %1 of %2 lines", 0, uniqueLines.size()));
setLinesStatus(*job, 0, uniqueLines.size());
// Tasks start running as soon as they are created; awaiting them just waits for the end.
std::vector<QCoro::Task<>> workers;
@@ -578,11 +716,10 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
co_await std::move(worker);
}
if (job->cancelled) {
co_return;
co_return Outcome{};
}
if (!batches->error.isEmpty()) {
fail(job, batches->error);
co_return;
co_return Outcome::failed(batches->error);
}
QList<SubtitleCue> output = cues;
@@ -593,36 +730,64 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
QDir().mkpath(QFileInfo(outputPath).absolutePath());
QSaveFile file(outputPath);
if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) {
fail(job, i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
co_return;
co_return Outcome::failed(i18n("Could not save the translation to %1: %2", outputPath, file.errorString()));
}
finish(job, videoUrl, suffix, configKey, outputPath, false);
co_return Outcome::ready(outputPath, suffix, configKey, false);
}
void TranslationService::finish(const std::shared_ptr<Job> &job,
const QUrl &videoUrl,
const QString &suffix,
const QString &configKey,
const QString &cacheFile,
bool fromCache)
QString TranslationService::publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome)
{
QFile file(cacheFile);
QFile file(outcome.cacheFile);
if (!file.open(QIODevice::ReadOnly)) {
fail(job, i18n("Could not read the translation from %1: %2", cacheFile, file.errorString()));
return;
return i18n("Could not read the translation from %1: %2", outcome.cacheFile, file.errorString());
}
// The cache keeps every version; the published copy is the one players load.
const QString registryFile = TranslationStore::registryFile();
TranslationStore::Registry registry = TranslationStore::load(registryFile);
const TranslationStore::PublishResult published = TranslationStore::publish(registry, videoUrl, suffix, configKey, file.readAll(), cacheFile);
const TranslationStore::PublishResult published =
TranslationStore::publish(registry, videoUrl, outcome.suffix, outcome.configKey, file.readAll(), outcome.cacheFile);
TranslationStore::save(registryFile, registry);
// Other episodes of a playlist load their translation when they are opened.
if (!job->playlist || videoUrl == m_currentVideo) {
m_lastTranslationPath = published.path;
Q_EMIT translated(QUrl::fromLocalFile(published.path), outcome.fromCache);
}
return {};
}
void TranslationService::endJob(const std::shared_ptr<Job> &job)
{
if (m_job != job) {
return;
}
m_job.reset();
m_lastTranslationPath = published.path;
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(published.path), fromCache);
}
void TranslationService::setJobStatus(const Job &job, const QString &text)
{
if (!job.playlist) {
setStatusText(text);
return;
}
setStatusText(i18nc("@info:status %1 and %2 are numbers, %3 is what is being done",
"Episode %1 of %2: %3",
job.episode + 1,
job.episodes,
text));
}
void TranslationService::setLinesStatus(const Job &job, qsizetype done, qsizetype total)
{
// The line count would not fit after the episode number; the progress bar shows it.
setJobStatus(job, job.playlist ? i18n("Translating…") : i18n("Translating… %1 of %2 lines", done, total));
}
void TranslationService::setJobProgress(const Job &job, double episodeProgress)
{
setProgress(job.playlist ? (job.episode + episodeProgress) / job.episodes : episodeProgress);
}
QString TranslationService::savedTranslation(const QUrl &videoUrl) const
@@ -716,8 +881,8 @@ QCoro::Task<> TranslationService::translateBatches(std::shared_ptr<Job> job, std
batches->translations[start + i] = result.translations.at(i);
}
batches->doneLines += result.translations.size();
setProgress(double(batches->doneLines) / batches->lines.size());
setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size()));
setJobProgress(*job, double(batches->doneLines) / batches->lines.size());
setLinesStatus(*job, batches->doneLines, batches->lines.size());
}
}
+54 -3
View File
@@ -15,6 +15,7 @@
#include <atomic>
#include <memory>
#include <optional>
class QNetworkAccessManager;
@@ -37,6 +38,9 @@ class TranslationService : public QObject
Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged)
Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged)
Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged)
// The video playing. Its translations are shown when ready; others are only saved.
// Opening another video stops a single translation but not a playlist one.
Q_PROPERTY(QUrl currentVideo READ currentVideo WRITE setCurrentVideo NOTIFY currentVideoChanged)
public:
explicit TranslationService(QObject *parent = nullptr);
@@ -54,9 +58,15 @@ public:
QString glossaryEpisode() const;
QStringList glossaryEpisodes() const;
QString glossaryError() const;
QUrl currentVideo() const;
void setCurrentVideo(const QUrl &url);
// \a track is an entry of MpvItem::subtitleTracks.
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
// Translates every video in \a urls that has no saved translation yet, one after
// another, starting at the current video. The current one uses \a currentTrack when
// set; the others use the embedded subtitle findTranslationSource() picks.
Q_INVOKABLE void translatePlaylist(const QList<QUrl> &urls, const QVariantMap &currentTrack, const QString &currentTitle);
Q_INVOKABLE void cancel();
// Asks the API configured in the settings for its models.
Q_INVOKABLE void fetchModels();
@@ -83,12 +93,46 @@ Q_SIGNALS:
void glossaryChanged();
void glossaryErrorChanged();
void translated(const QUrl &subtitleUrl, bool fromCache);
void currentVideoChanged();
// A playlist translation ended without being cancelled. Problems are in errorText.
void playlistTranslated(int translatedCount, int alreadyTranslatedCount, int problemCount);
private:
struct Job;
struct Batches;
// Where translateVideo() left a translation, or why it could not make one.
struct Outcome {
QString cacheFile;
QString suffix;
QString configKey;
bool fromCache = false;
QString error;
// The error is about this video's subtitles; other videos may still work.
bool skippable = false;
QCoro::Task<> run(QUrl videoUrl, QVariantMap track);
static Outcome ready(const QString &cacheFile, const QString &suffix, const QString &configKey, bool fromCache)
{
Outcome outcome;
outcome.cacheFile = cacheFile;
outcome.suffix = suffix;
outcome.configKey = configKey;
outcome.fromCache = fromCache;
return outcome;
}
static Outcome failed(const QString &error, bool skippable = false)
{
Outcome outcome;
outcome.error = error;
outcome.skippable = skippable;
return outcome;
}
};
QCoro::Task<> run(std::shared_ptr<Job> job, QUrl videoUrl, QVariantMap track);
QCoro::Task<> runPlaylist(std::shared_ptr<Job> job, QList<QUrl> urls, QVariantMap currentTrack, QString currentTitle);
// Reads the settings and the API key for \a job; nothing (after failing it) when they are not usable.
QCoro::Task<std::optional<LlmClient::Config>> startJob(std::shared_ptr<Job> job);
QCoro::Task<Outcome> translateVideo(std::shared_ptr<Job> job, LlmClient::Config config, QUrl videoUrl, QVariantMap track);
QCoro::Task<> runFetchModels();
// Translates \a lines, retrying failures and asking again for lines the model skipped.
QCoro::Task<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> job, QStringList lines, QStringList context);
@@ -100,8 +144,14 @@ private:
// something is missing.
QCoro::Task<std::pair<LlmClient::Config, QString>> loadConfig();
void fail(const std::shared_ptr<Job> &job, const QString &error);
// Saves the translation in \a cacheFile where players find it and announces it.
void finish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const QString &suffix, const QString &configKey, const QString &cacheFile, bool fromCache);
// Saves the translation where players find it and, for the current video, shows it.
// Returns an error message on failure.
QString publish(const std::shared_ptr<Job> &job, const QUrl &videoUrl, const Outcome &outcome);
void endJob(const std::shared_ptr<Job> &job);
// In a playlist, prefixes the episode number and counts finished episodes in the progress.
void setJobStatus(const Job &job, const QString &text);
void setJobProgress(const Job &job, double episodeProgress);
void setLinesStatus(const Job &job, qsizetype done, qsizetype total);
void setBusy(bool busy);
void setProgress(double progress);
@@ -122,4 +172,5 @@ private:
GlossaryStore::Glossary m_glossary;
QString m_glossaryEpisode;
QString m_lastTranslationPath;
QUrl m_currentVideo;
};