From f5ab461cd82c013ce2e4b6f50a3e331bfa8ee19f Mon Sep 17 00:00:00 2001 From: Nacer eddine houidi Date: Sat, 29 Aug 2026 17:43:56 +0100 Subject: [PATCH] Trim a video straight from a link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+V hands whatever link is on the clipboard to yt-dlp, downloads it into the cache, and opens it for trimming as soon as it lands — the same filmstrip, preview and export as any other file, because by then it is one. Links come down at up to 1080p and prefer h264, which is what exports are re-encoded to anyway and what stays smooth while scrubbing. The finished path comes back from yt-dlp itself, so pasting a link already in the cache reopens it instantly. A downloaded source is a cache entry rather than a file the user keeps, so its export is suggested among their videos instead of beside it. yt-dlp is only needed to open links; nothing else about omacut requires it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016rcJ8LUydunVeEgaT9hHDr --- README.md | 10 ++- omacut.pro | 2 + pkgbuild/PKGBUILD | 1 + src/Main.qml | 20 ++++- src/backend.cpp | 194 ++++++++++++++++++++++++++++++++++++++-- src/backend.h | 17 ++++ src/main.cpp | 12 ++- src/ytdlp.cpp | 106 ++++++++++++++++++++++ src/ytdlp.h | 35 ++++++++ tests/backend_tests.cpp | 108 ++++++++++++++++++++++ tests/backend_tests.pro | 2 + 11 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 src/ytdlp.cpp create mode 100644 src/ytdlp.h diff --git a/README.md b/README.md index 956460d..7496d93 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Omacut -A dead-simple video **length** trimmer. Open a video, drag the two handles to pick a start and end, preview the clip, and export. On Omarchy, the interface follows your theme's accent color. +A dead-simple video **length** trimmer. Open a video — or paste a link to one — drag the two handles to pick a start and end, preview the clip, and export. On Omarchy, the interface follows your theme's accent color. Built using **Qt Quick (QML)** UI with the Material style — the same Qt stack Quickshell builds on — and **ffmpeg** for the cut. The C++ side compiles to a single executable; the QML is embedded in it via Qt resources. @@ -16,6 +16,7 @@ Built using **Qt Quick (QML)** UI with the Material style — the same Qt stack - *Alt+Space*: Move the end of the trim to the playhead. - *Z*: Zoom into the trimmed selection for fine tuning (Z again zooms back out). - *Ctrl+O*: Open a new file to trim. +- *Ctrl+V*: Paste a link and trim the video behind it. - *Ctrl+S*: Export the current trim. - *Q*: Quit (asks first if the trim hasn't been exported). - *?*: Show the hotkeys in the app. @@ -28,9 +29,16 @@ Install via the Omarchy Package Repository via the `omacut` package. It's instal - `xdg-desktop-portal` and a portal backend for the file picker - `ffmpeg` and `ffprobe` on your PATH (used at runtime) +- `yt-dlp` on your PATH, but only to open links Exports are always written as MP4 files, regardless of the input video's container. The export dialog offers Original/1080p/720p quality — never upscaling, and always preserving the aspect ratio. +## Links + +Copy a video's address and press *Ctrl+V* to trim it without downloading it yourself first. omacut hands the link to `yt-dlp`, which fetches the video into `~/.cache/omacut/downloads`, and it opens for trimming as soon as it lands. Pasting the same link again reuses what's already there. + +Links come down at up to 1080p, preferring H.264 — trimming is a length edit, not a mastering job, and that's the codec exports are re-encoded to anyway. Because a downloaded source is a cache entry rather than a file you keep, its export is suggested in your videos folder instead of next to it. + ## Build Uses Qt's own build tool, `qmake6` (no cmake needed): diff --git a/omacut.pro b/omacut.pro index f466a7c..762a54c 100644 --- a/omacut.pro +++ b/omacut.pro @@ -8,6 +8,7 @@ HEADERS += \ src/filepicker.h \ src/portalfilepicker.h \ src/ffmpeg.h \ + src/ytdlp.h \ src/thumbworker.h \ src/thumbprovider.h \ src/backend.h @@ -16,6 +17,7 @@ SOURCES += \ src/main.cpp \ src/portalfilepicker.cpp \ src/ffmpeg.cpp \ + src/ytdlp.cpp \ src/thumbworker.cpp \ src/thumbprovider.cpp \ src/backend.cpp diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index 8ca8faf..e96d2d8 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -9,6 +9,7 @@ url='https://github.com/omacom-io/omacut' license=('MIT') install='omacut.install' depends=('ffmpeg' 'qt6-base' 'qt6-declarative' 'qt6-multimedia' 'xdg-desktop-portal') +optdepends=('yt-dlp: open videos straight from a link') makedepends=('gcc' 'make' 'qt6-base' 'qt6-declarative' 'qt6-multimedia') source=() sha256sums=() diff --git a/src/Main.qml b/src/Main.qml index f150b4a..99c6e9a 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -251,6 +251,13 @@ ApplicationWindow { onActivated: openVideo() } + Shortcut { + sequence: "Ctrl+V" + context: Qt.ApplicationShortcut + enabled: !win.quitConfirmVisible && !backend.busy + onActivated: backend.openClipboardLink() + } + Shortcut { sequence: "Q" context: Qt.ApplicationShortcut @@ -513,6 +520,16 @@ ApplicationWindow { } onClicked: openVideo() } + + Label { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: openVideoButton.bottom + anchors.topMargin: 16 + visible: !win.hasVideo + text: "or press Ctrl+V to paste a video link" + color: "#7a7a80" + font.pixelSize: 13 + } } // --- timeline --- @@ -554,7 +571,7 @@ ApplicationWindow { // --- status line --- Item { - visible: win.hasVideo + visible: win.hasVideo || win.statusText !== "" Layout.fillWidth: true Layout.preferredHeight: 26 @@ -651,6 +668,7 @@ ApplicationWindow { { keys: "Alt Space", action: "Trim end to playhead" }, { keys: "Z", action: "Zoom the selection" }, { keys: "Ctrl O", action: "Open a video" }, + { keys: "Ctrl V", action: "Paste a video link" }, { keys: "Ctrl S", action: "Export" }, { keys: "Q", action: "Quit" }, { keys: "?", action: "Show these shortcuts" } diff --git a/src/backend.cpp b/src/backend.cpp index ee86584..f3e9205 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -1,10 +1,13 @@ #include "backend.h" +#include #include #include #include #include +#include #include +#include #include #include @@ -14,10 +17,16 @@ #include "portalfilepicker.h" #include "thumbprovider.h" #include "thumbworker.h" +#include "ytdlp.h" namespace { constexpr int kThumbCount = 12; constexpr int kThumbRevealMs = 70; +// Trimming is a length edit, not a mastering job, so a link is fetched at a +// sane size rather than at whatever 4K the source happens to offer. +constexpr int kMaxDownloadHeight = 1080; +// How long a killed download gets to die before the app stops waiting on it. +constexpr int kDownloadStopMs = 2000; const QString kDefaultAccent = QStringLiteral("#FFD60A"); QString omarchyCurrentDir() { @@ -39,6 +48,43 @@ QString mp4PathFor(const QString &path) { return file.dir().filePath(baseName + QStringLiteral(".mp4")); } +// Downloads are re-fetchable, so they belong in the cache rather than in the +// user's own folders. +QString downloadDir() { + const QString base = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (base.isEmpty()) + return {}; + const QString dir = base + QStringLiteral("/downloads"); + return QDir().mkpath(dir) ? dir : QString(); +} + +// Somewhere the user will actually look for a finished clip. +QString videosDir() { + for (const QStandardPaths::StandardLocation location : {QStandardPaths::MoviesLocation, + QStandardPaths::DownloadLocation, + QStandardPaths::HomeLocation}) { + const QString dir = QStandardPaths::writableLocation(location); + if (!dir.isEmpty() && QFileInfo(dir).isWritable()) + return dir; + } + return QDir::homePath(); +} + +QString pathFromFile(const QString &pathFile) { + QFile file(pathFile); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + + QString last; + QTextStream in(&file); + while (!in.atEnd()) { + const QString line = in.readLine().trimmed(); + if (!line.isEmpty()) + last = line; + } + return last; +} + bool replaceWithTemp(const QString &tmpPath, const QString &outPath) { const QByteArray tmpName = QFile::encodeName(tmpPath); const QByteArray outName = QFile::encodeName(outPath); @@ -71,6 +117,7 @@ Backend::Backend(ThumbProvider *provider, FilePicker *filePicker, QObject *paren } Backend::~Backend() { + stopDownload(); stopThumbs(); } @@ -157,7 +204,14 @@ void Backend::watchTheme() { } bool Backend::load(const QUrl &url) { - const QString path = url.toLocalFile(); + return loadPath(url.toLocalFile(), false); +} + +bool Backend::loadPath(const QString &path, bool fromLink) { + // Opening a file by hand abandons whatever a link was still fetching. + if (!fromLink) + stopDownload(); + const ffmpeg::VideoInfo info = ffmpeg::probe(path); if (!info.ok) { emit loadError(info.error); @@ -166,7 +220,8 @@ bool Backend::load(const QUrl &url) { m_info = info; m_path = path; - m_source = url; + m_source = QUrl::fromLocalFile(path); + m_sourceIsDownload = fromLink; // New video: drop the old filmstrip and bump the revision so QML reloads. stopThumbs(); @@ -189,6 +244,28 @@ bool Backend::load(const QUrl &url) { return true; } +void Backend::openClipboardLink() { + const QString text = QGuiApplication::clipboard()->text().trimmed(); + if (text.isEmpty()) { + emit loadError(QStringLiteral("The clipboard is empty. Copy a video link first.")); + return; + } + openLink(text); +} + +void Backend::openLink(const QString &text) { + const QString link = text.trimmed(); + if (!ytdlp::isLink(link)) { + emit loadError(QStringLiteral("That isn't a video link.")); + return; + } + // An export in flight owns the status line and the ffmpeg it's driving. + if (m_busy) + return; + + startDownload(link); +} + void Backend::openVideoDialog() { m_filePicker->openVideo(); } @@ -211,6 +288,106 @@ QList Backend::exportHeights(int width, int height) { return heights; } +void Backend::startDownload(const QString &url) { + const QString tool = ytdlp::toolPath(); + if (tool.isEmpty()) { + emit loadError(QStringLiteral("`yt-dlp` was not found on your PATH. " + "Install yt-dlp to open links.")); + return; + } + + const QString dir = downloadDir(); + if (dir.isEmpty()) { + emit loadError(QStringLiteral("Could not create a folder to download into.")); + return; + } + + stopDownload(); + + // yt-dlp writes the finished file's path here. A leftover from an earlier + // fetch would otherwise be read back as this download's result. + const QString pathFile = dir + QStringLiteral("/.omacut-filepath"); + QFile::remove(pathFile); + + setBusy(true); + setStatus(QStringLiteral("Fetching...")); + + auto *proc = new QProcess(this); + m_download = proc; + auto completed = std::make_shared(false); + auto progressBuf = std::make_shared(); + + connect(proc, &QProcess::readyReadStandardOutput, this, [this, proc, progressBuf] { + progressBuf->append(proc->readAllStandardOutput()); + int newline; + while ((newline = progressBuf->indexOf('\n')) >= 0) { + const QString line = QString::fromUtf8(progressBuf->left(newline)).trimmed(); + progressBuf->remove(0, newline + 1); + + const double percent = ytdlp::percentFromProgress(line); + if (percent >= 0.0) + setStatus(QStringLiteral("Downloading %1%").arg(qRound(percent))); + else if (line.startsWith(QLatin1String("[Merger]"))) + setStatus(QStringLiteral("Merging...")); + } + }); + + connect(proc, &QProcess::finished, this, + [this, proc, pathFile, completed](int code, QProcess::ExitStatus exitStatus) { + if (*completed) + return; + *completed = true; + const QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed(); + m_download = nullptr; + proc->deleteLater(); + setBusy(false); + setStatus(QString()); + + if (exitStatus != QProcess::NormalExit || code != 0) { + emit loadError(ytdlp::errorFrom(err)); + return; + } + + const QString path = pathFromFile(pathFile); + if (path.isEmpty()) { + emit loadError(QStringLiteral("yt-dlp did not report a downloaded file.")); + return; + } + loadPath(path, true); + }); + + connect(proc, &QProcess::errorOccurred, this, + [this, proc, completed](QProcess::ProcessError error) { + if (error != QProcess::FailedToStart || *completed) + return; + *completed = true; + const QString err = proc->errorString(); + m_download = nullptr; + proc->deleteLater(); + setBusy(false); + setStatus(QString()); + emit loadError(err.isEmpty() ? QStringLiteral("Could not start yt-dlp.") : err); + }); + + proc->start(tool, ytdlp::downloadArgs(url, dir, pathFile, kMaxDownloadHeight)); +} + +void Backend::stopDownload() { + if (!m_download) + return; + + QProcess *proc = m_download; + m_download = nullptr; + // Drop the handlers first: killing the child fires finished(), which would + // otherwise report the abandoned download as a failure. + proc->disconnect(this); + proc->kill(); + proc->waitForFinished(kDownloadStopMs); + proc->deleteLater(); + setBusy(false); + setStatus(QString()); +} + void Backend::startThumbs() { auto *worker = new ThumbWorker(m_path, m_thumbStart, m_thumbLen, kThumbCount); m_thumbWorker = worker; @@ -307,11 +484,16 @@ void Backend::requestThumbs(double start, double end) { } QUrl Backend::suggestedExportUrl() const { - if (m_path.isEmpty()) + return suggestedExportUrlFor(m_path, m_sourceIsDownload, videosDir()); +} + +QUrl Backend::suggestedExportUrlFor(const QString &sourcePath, bool sourceIsDownload, + const QString &downloadsTarget) { + if (sourcePath.isEmpty()) return {}; - const QFileInfo src(m_path); - const QString target = src.dir().filePath(src.completeBaseName() + "_trimmed.mp4"); - return QUrl::fromLocalFile(target); + const QFileInfo src(sourcePath); + const QDir dir = sourceIsDownload ? QDir(downloadsTarget) : src.dir(); + return QUrl::fromLocalFile(dir.filePath(src.completeBaseName() + "_trimmed.mp4")); } void Backend::exportClip(const QUrl &dst, double start, double end, int scaleHeight) { diff --git a/src/backend.h b/src/backend.h index 9c96ba4..2a2aa68 100644 --- a/src/backend.h +++ b/src/backend.h @@ -10,6 +10,7 @@ #include "ffmpeg.h" +class QProcess; class ThumbProvider; class FilePicker; class ThumbWorker; @@ -54,6 +55,11 @@ class Backend : public QObject { // Load a video (probes it, then kicks off thumbnail generation). Q_INVOKABLE bool load(const QUrl &url); + // Download a video from a link with yt-dlp and load it once it lands. + Q_INVOKABLE void openLink(const QString &text); + // The same, for whatever is on the clipboard — what Ctrl+V runs. + Q_INVOKABLE void openClipboardLink(); + // Open native desktop file dialogs. Q_INVOKABLE void openVideoDialog(); Q_INVOKABLE void exportDialog(double start, double end); @@ -61,6 +67,12 @@ class Backend : public QObject { // Suggested "_trimmed.mp4" target next to the source. Q_INVOKABLE QUrl suggestedExportUrl() const; + // Where that suggestion lands. A downloaded source sits in the cache, which + // is no place to leave an export, so those are suggested in downloadsTarget + // instead of beside the source. + static QUrl suggestedExportUrlFor(const QString &sourcePath, bool sourceIsDownload, + const QString &downloadsTarget); + // Write [start, end] (seconds) of the loaded video to dst. A non-zero // scaleHeight downscales the shorter side to that size. Q_INVOKABLE void exportClip(const QUrl &dst, double start, double end, @@ -85,6 +97,9 @@ class Backend : public QObject { void loadError(const QString &message); private: + bool loadPath(const QString &path, bool fromLink); + void startDownload(const QString &url); + void stopDownload(); void setBusy(bool busy); void setStatus(const QString &status); void failExport(const QString &tmpPath, const QString &message); @@ -98,9 +113,11 @@ class Backend : public QObject { ThumbProvider *m_provider; FilePicker *m_filePicker; ThumbWorker *m_thumbWorker = nullptr; + QProcess *m_download = nullptr; ffmpeg::VideoInfo m_info; QString m_path; QUrl m_source; + bool m_sourceIsDownload = false; double m_thumbStart = 0.0; double m_thumbLen = 0.0; QVector m_fullThumbs; diff --git a/src/main.cpp b/src/main.cpp index 562d1ac..be35286 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ #include "backend.h" #include "thumbprovider.h" +#include "ytdlp.h" int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); @@ -36,10 +37,15 @@ int main(int argc, char *argv[]) { if (engine.rootObjects().isEmpty()) return -1; - // Optionally open a file passed on the command line. + // Optionally open a file — or a link — passed on the command line. const QStringList args = app.arguments(); - if (args.size() > 1) - backend.load(QUrl::fromLocalFile(args.at(1))); + if (args.size() > 1) { + const QString target = args.at(1); + if (ytdlp::isLink(target)) + backend.openLink(target); + else + backend.load(QUrl::fromLocalFile(target)); + } return app.exec(); } diff --git a/src/ytdlp.cpp b/src/ytdlp.cpp new file mode 100644 index 0000000..6020038 --- /dev/null +++ b/src/ytdlp.cpp @@ -0,0 +1,106 @@ +#include "ytdlp.h" + +#include +#include + +namespace ytdlp { + +namespace { +// Marks our own progress lines so they can be told apart from the ordinary +// chatter yt-dlp writes to stdout. +const QString kProgressMarker = QStringLiteral("omacut-progress"); +const QString kErrorPrefix = QStringLiteral("ERROR:"); +} + +QString toolPath() { + return QStandardPaths::findExecutable(QStringLiteral("yt-dlp")); +} + +bool isLink(const QString &text) { + const QString candidate = text.trimmed(); + if (candidate.isEmpty()) + return false; + // Anything with whitespace inside it is prose that happens to hold a URL, + // not a link the user meant to paste. + for (const QChar c : candidate) { + if (c.isSpace()) + return false; + } + + const QUrl url(candidate, QUrl::StrictMode); + return url.isValid() && !url.host().isEmpty() + && (url.scheme() == QLatin1String("http") || url.scheme() == QLatin1String("https")); +} + +QStringList downloadArgs(const QString &url, const QString &dir, + const QString &pathFile, int maxHeight) { + // A "watch?v=...&list=..." link is a video the user is watching, not a + // request for the whole playlist behind it. + QStringList args = {"--no-playlist", "--no-warnings", "--newline"}; + + // Machine-readable progress on stdout, mirroring ffmpeg's -progress, so the + // percentage doesn't depend on scraping yt-dlp's human-facing output. + args << "--progress-template" + << QStringLiteral("download:") + kProgressMarker + + QStringLiteral(" %(progress.downloaded_bytes)s" + " %(progress.total_bytes)s" + " %(progress.total_bytes_estimate)s"); + + args << "--print-to-file" << "after_move:filepath" << pathFile; + args << "-P" << dir << "-o" << "%(title).80B [%(id)s].%(ext)s"; + + // Prefer a separate video and audio stream and let yt-dlp mux them, since + // the pre-muxed formats sites still offer are the low-quality ones. + args << "-f" << (maxHeight > 0 + ? QStringLiteral("bv*[height<=%1]+ba/b[height<=%1]/b").arg(maxHeight) + : QStringLiteral("bv*+ba/b")) + << "--merge-output-format" << "mp4"; + + // Sites increasingly serve AV1, which plenty of machines can only decode in + // software — a scrub-and-preview tool wants the codec that plays smoothly + // everywhere, and it's the one exports are re-encoded to anyway. A sort + // rather than a filter, so a source without h264 still downloads. + args << "-S" << "vcodec:h264,acodec:aac,ext:mp4"; + + // Fragmented (DASH/HLS) sources arrive far faster in parallel. + args << "-N" << "4"; + + args << url; + return args; +} + +double percentFromProgress(const QString &line) { + const QStringList parts = line.trimmed().split(QLatin1Char(' '), Qt::SkipEmptyParts); + if (parts.size() != 4 || parts.first() != kProgressMarker) + return -1.0; + + bool ok = false; + const double done = parts.at(1).toDouble(&ok); + if (!ok) + return -1.0; + + // total_bytes is "NA" on streams that only ever report an estimate. + double total = parts.at(2).toDouble(&ok); + if (!ok || total <= 0.0) { + total = parts.at(3).toDouble(&ok); + if (!ok || total <= 0.0) + return -1.0; + } + + return qBound(0.0, done / total * 100.0, 100.0); +} + +QString errorFrom(const QString &stderrText) { + const QStringList lines = stderrText.split(QLatin1Char('\n'), Qt::SkipEmptyParts); + // The last ERROR: line is the one that stopped the download; anything above + // it is warnings and progress noise not worth putting in front of the user. + for (auto line = lines.crbegin(); line != lines.crend(); ++line) { + const QString trimmed = line->trimmed(); + if (trimmed.startsWith(kErrorPrefix)) + return trimmed.mid(kErrorPrefix.size()).trimmed(); + } + return lines.isEmpty() ? QStringLiteral("yt-dlp could not download that link.") + : lines.last().trimmed(); +} + +} // namespace ytdlp diff --git a/src/ytdlp.h b/src/ytdlp.h new file mode 100644 index 0000000..73b9d29 --- /dev/null +++ b/src/ytdlp.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +// Thin wrapper around the yt-dlp command-line tool, in the same shape as +// ffmpeg.h: the argument lists and the output parsing live here, the process +// itself is run from the backend. +namespace ytdlp { + +// True when text looks like something worth handing to yt-dlp. Which sites +// actually work is yt-dlp's business, not ours — this only keeps stray +// clipboard text from starting a download. +bool isLink(const QString &text); + +// Build the argument list that downloads `url` into `dir` as a single mp4, +// capped at maxHeight so trimming a phone clip doesn't pull down 4K. The final +// file's path is written to pathFile, which is the only reliable way to learn +// the name yt-dlp settled on after merging — and it's written for an already +// downloaded file too, which is what makes reopening a link instant. +QStringList downloadArgs(const QString &url, const QString &dir, + const QString &pathFile, int maxHeight); + +// The percentage from one machine-readable progress line, or -1 for any other +// line. Each file reports its own 0-100, so video and audio each run the bar +// once. +double percentFromProgress(const QString &line); + +// What actually went wrong, out of everything yt-dlp wrote to stderr. +QString errorFrom(const QString &stderrText); + +// The path to yt-dlp, or an empty string when it isn't installed. +QString toolPath(); + +} // namespace ytdlp diff --git a/tests/backend_tests.cpp b/tests/backend_tests.cpp index 68f35b3..8ad54e2 100644 --- a/tests/backend_tests.cpp +++ b/tests/backend_tests.cpp @@ -18,6 +18,7 @@ #include "filepicker.h" #include "thumbprovider.h" #include "thumbworker.h" +#include "ytdlp.h" class FakeFilePicker : public FilePicker { Q_OBJECT @@ -88,6 +89,7 @@ class ShortcutBackend : public QObject { Q_INVOKABLE bool load(const QUrl &) { return false; } Q_INVOKABLE void openVideoDialog() { ++openCount; } + Q_INVOKABLE void openClipboardLink() { ++linkCount; } Q_INVOKABLE void exportDialog(double start, double end) { ++exportCount; lastStart = start; @@ -105,6 +107,7 @@ class ShortcutBackend : public QObject { void announceExportDone() { emit exportDone(QStringLiteral("/tmp/exported.mp4")); } int openCount = 0; + int linkCount = 0; int exportCount = 0; double lastStart = 0; double lastEnd = 0; @@ -197,6 +200,11 @@ private slots: void trimArgsReencodeForPreciseCuts(); void trimArgsScaleTheShorterSide(); void exportHeightsNeverUpscale(); + void linksAreToldApartFromOtherPastes(); + void downloadArgsFetchOneCappedMp4(); + void downloadProgressReadsByteCounts(); + void downloadErrorsUseYtDlpsOwnMessage(); + void downloadedSourcesExportOutsideTheCache(); void themeAccentReadsOmarchyColors(); void themeAccentForegroundKeepsContrast(); @@ -622,6 +630,9 @@ void BackendTests::qmlShortcutsTriggerBackendActions() { QTest::keyClick(window, Qt::Key_O, Qt::ControlModifier); QTRY_COMPARE_WITH_TIMEOUT(backend.openCount, 1, 3000); + QTest::keyClick(window, Qt::Key_V, Qt::ControlModifier); + QTRY_COMPARE_WITH_TIMEOUT(backend.linkCount, 1, 3000); + // ? toggles the hotkey overlay, and Escape closes it again. QTest::keyClick(window, Qt::Key_Question); QTRY_COMPARE_WITH_TIMEOUT(window->property("helpVisible").toBool(), true, 3000); @@ -894,6 +905,103 @@ void BackendTests::exportHeightsNeverUpscale() { QCOMPARE(Backend::exportHeights(0, 0), QList{}); } +void BackendTests::linksAreToldApartFromOtherPastes() { + QVERIFY(ytdlp::isLink(QStringLiteral("https://www.youtube.com/watch?v=aqz-KE-bpKQ"))); + QVERIFY(ytdlp::isLink(QStringLiteral(" http://example.com/v/1 "))); + + // Whatever else happened to be on the clipboard isn't a download request. + QVERIFY(!ytdlp::isLink(QString())); + QVERIFY(!ytdlp::isLink(QStringLiteral("watch this: https://example.com/v/1"))); + QVERIFY(!ytdlp::isLink(QStringLiteral("example.com"))); + // Local files have their own way in, and it doesn't involve the network. + QVERIFY(!ytdlp::isLink(QStringLiteral("/home/me/clip.mp4"))); + QVERIFY(!ytdlp::isLink(QStringLiteral("file:///home/me/clip.mp4"))); +} + +void BackendTests::downloadArgsFetchOneCappedMp4() { + const QString link = QStringLiteral("https://example.com/watch?v=abc&list=xyz"); + const QStringList args = ytdlp::downloadArgs(link, QStringLiteral("/cache/downloads"), + QStringLiteral("/cache/downloads/.omacut-filepath"), + 1080); + + // A link to a video inside a playlist is one video, not the playlist. + QVERIFY(args.contains(QStringLiteral("--no-playlist"))); + + const int formatAt = args.indexOf(QStringLiteral("-f")); + QVERIFY(formatAt >= 0); + QVERIFY(args.value(formatAt + 1).contains(QStringLiteral("height<=1080"))); + + const int mergeAt = args.indexOf(QStringLiteral("--merge-output-format")); + QVERIFY(mergeAt >= 0); + QCOMPARE(args.value(mergeAt + 1), QStringLiteral("mp4")); + + // h264 is preferred, not required, so an AV1-only source still downloads. + const int sortAt = args.indexOf(QStringLiteral("-S")); + QVERIFY(sortAt >= 0); + QVERIFY(args.value(sortAt + 1).contains(QStringLiteral("vcodec:h264"))); + + // The finished path has to come back out of yt-dlp, since yt-dlp is what + // names the file. + const int printAt = args.indexOf(QStringLiteral("--print-to-file")); + QVERIFY(printAt >= 0); + QCOMPARE(args.value(printAt + 1), QStringLiteral("after_move:filepath")); + QCOMPARE(args.value(printAt + 2), QStringLiteral("/cache/downloads/.omacut-filepath")); + + const int dirAt = args.indexOf(QStringLiteral("-P")); + QVERIFY(dirAt >= 0); + QCOMPARE(args.value(dirAt + 1), QStringLiteral("/cache/downloads")); + + // The link goes last, where a positional argument belongs. + QCOMPARE(args.last(), link); + + // No cap means no height filter to fall foul of on a small source. + const QStringList uncapped = ytdlp::downloadArgs(link, QStringLiteral("/cache/downloads"), + QStringLiteral("/cache/downloads/.path"), 0); + QVERIFY(!uncapped.value(uncapped.indexOf(QStringLiteral("-f")) + 1) + .contains(QStringLiteral("height"))); +} + +void BackendTests::downloadProgressReadsByteCounts() { + QCOMPARE(ytdlp::percentFromProgress(QStringLiteral("omacut-progress 50 200 NA")), 25.0); + + // Streams that only ever estimate their size still move the bar. + QCOMPARE(ytdlp::percentFromProgress(QStringLiteral("omacut-progress 50 NA 200")), 25.0); + + // A size neither known nor estimated has no percentage to report. + QCOMPARE(ytdlp::percentFromProgress(QStringLiteral("omacut-progress 50 NA NA")), -1.0); + + // yt-dlp's ordinary chatter shares the same stream. + QCOMPARE(ytdlp::percentFromProgress(QStringLiteral("[Merger] Merging formats into \"a.mp4\"")), -1.0); + QCOMPARE(ytdlp::percentFromProgress(QString()), -1.0); +} + +void BackendTests::downloadErrorsUseYtDlpsOwnMessage() { + QCOMPARE(ytdlp::errorFrom(QStringLiteral("WARNING: falling back to the web client\n" + "ERROR: [youtube] abc: This video is unavailable")), + QStringLiteral("[youtube] abc: This video is unavailable")); + + // Without an ERROR: line, the last thing yt-dlp said beats saying nothing. + QCOMPARE(ytdlp::errorFrom(QStringLiteral("could not resolve host\n")), + QStringLiteral("could not resolve host")); + QVERIFY(!ytdlp::errorFrom(QString()).isEmpty()); +} + +void BackendTests::downloadedSourcesExportOutsideTheCache() { + const QString videos = QStringLiteral("/home/me/Videos"); + + // A downloaded source is a cache entry, so its trim is suggested where the + // user actually keeps videos. + QCOMPARE(Backend::suggestedExportUrlFor( + QStringLiteral("/home/me/.cache/omacut/downloads/A Talk [abc].mp4"), true, videos), + QUrl::fromLocalFile(QStringLiteral("/home/me/Videos/A Talk [abc]_trimmed.mp4"))); + + // A file the user opened themselves still exports right next to itself. + QCOMPARE(Backend::suggestedExportUrlFor(QStringLiteral("/home/me/clip.mkv"), false, videos), + QUrl::fromLocalFile(QStringLiteral("/home/me/clip_trimmed.mp4"))); + + QCOMPARE(Backend::suggestedExportUrlFor(QString(), true, videos), QUrl()); +} + void BackendTests::themeAccentReadsOmarchyColors() { const QString fallback = QStringLiteral("#FFD60A"); const QString colorsPath = m_dir.filePath(QStringLiteral("colors.toml")); diff --git a/tests/backend_tests.pro b/tests/backend_tests.pro index 11c3aa8..16f7872 100644 --- a/tests/backend_tests.pro +++ b/tests/backend_tests.pro @@ -8,6 +8,7 @@ INCLUDEPATH += ../src HEADERS += \ ../src/backend.h \ ../src/ffmpeg.h \ + ../src/ytdlp.h \ ../src/filepicker.h \ ../src/portalfilepicker.h \ ../src/thumbprovider.h \ @@ -17,6 +18,7 @@ SOURCES += \ backend_tests.cpp \ ../src/backend.cpp \ ../src/ffmpeg.cpp \ + ../src/ytdlp.cpp \ ../src/portalfilepicker.cpp \ ../src/thumbprovider.cpp \ ../src/thumbworker.cpp