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