diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..5c15e1f --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,84 @@ +# Build Linux packages and attach them to the Codeberg release when a +# version tag (v*) is pushed. +# +# Codeberg runs Forgejo Actions, which is GitHub-Actions-compatible. The +# heavy lifting is in scripts/package.sh — this workflow just provisions +# a build environment, runs it, and uploads the artifacts. +# +# Manual trigger (workflow_dispatch) is included so you can smoke-test the +# build without cutting a tag; that path builds the packages but skips the +# release-upload step. +name: release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + packages: + runs-on: docker + # A Debian-based image gives us dpkg + the build toolchain cargo-deb + # expects, and rustup for a pinned toolchain. + container: + image: rust:1.85-bookworm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install build dependencies + # libxcb + friends are needed to *link* the eframe/winit GUI even + # in headless CI. curl + file are used by the packaging script and + # appimagetool. rpm provides rpmbuild for cargo-generate-rpm's + # payload assembly on a Debian host. + run: | + apt-get update + apt-get install -y --no-install-recommends \ + libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + curl file rpm ca-certificates + + - name: Cache cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + + - name: Build packages + # FUSE isn't available in most CI containers, so tell appimagetool + # to extract-and-run instead of mounting. The script downloads + # appimagetool itself. + env: + APPIMAGE_EXTRACT_AND_RUN: '1' + run: | + chmod +x scripts/package.sh + scripts/package.sh all + + - name: List artifacts + run: ls -la dist/ + + - name: Upload to release + # Only attaches to a release when triggered by a tag push. The + # forgejo-release action creates the release if it doesn't exist + # and uploads every file under dist/. + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/forgejo-release@v2 + with: + direction: upload + url: https://codeberg.org + repo: ${{ github.repository }} + tag: ${{ github.ref_name }} + token: ${{ secrets.RELEASE_TOKEN }} + release-dir: dist + release-notes: | + Automated build for ${{ github.ref_name }}. + + Artifacts: + - `.deb` — Debian / Ubuntu / Mint + - `.rpm` — Fedora / RHEL / openSUSE + - `.AppImage` — any Linux (chmod +x and run) + + Runtime requirements: yt-dlp, ffmpeg, mpv, xdg-utils on PATH. diff --git a/.gitignore b/.gitignore index b9de2fb..c75be1f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Rust build artifacts /target/ +# Packaging output (.deb / .rpm / .AppImage + downloaded appimagetool) +/dist/ + # makepkg build artifacts (finished .pkg.tar.zst is NOT ignored) /pkg/ /src/yt-offline/ diff --git a/Cargo.toml b/Cargo.toml index 73df74a..33e004b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,11 @@ name = "yt-offline" version = "0.1.0" edition = "2021" description = "Self-hosted archive for YouTube, TikTok, Twitch, Vimeo, Bandcamp, SoundCloud, Odysee and more. Desktop GUI + web UI, bundled yt-dlp with curl_cffi impersonation, Plex export, SQLite-backed resume tracking. AGPL-3.0." +license = "AGPL-3.0-only" +repository = "https://codeberg.org/anassaeneroi/yt-offline" +homepage = "https://codeberg.org/anassaeneroi/yt-offline" +authors = ["InannaBeloved "] +readme = "README.md" [dependencies] eframe = "0.29" @@ -42,3 +47,47 @@ codegen-units = 1 # runtime; those are less informative without symbols but usable with # addr2line against the unstripped build artifact. strip = "debuginfo" + +# ── Debian / Ubuntu package (cargo-deb) ────────────────────────────────────── +# `cargo deb` reads this to build a .deb. Runtime deps mirror the PKGBUILD: +# yt-dlp + ffmpeg + mpv are invoked as subprocesses, xdg-utils provides +# xdg-open, and libxcb is needed by the eframe/winit windowing stack. +[package.metadata.deb] +maintainer = "InannaBeloved " +copyright = "2026, InannaBeloved " +license-file = ["LICENSE", "0"] +extended-description = """\ +Self-hosted archive for YouTube, TikTok, Twitch, Vimeo, Bandcamp, \ +SoundCloud, Odysee and more. Ships a desktop GUI and a mobile-friendly \ +web UI, with a bundled yt-dlp (curl_cffi impersonation), Plex export, \ +and SQLite-backed resume tracking.""" +section = "video" +priority = "optional" +# yt-dlp / ffmpeg / mpv are runtime subprocess deps, not link-time, so +# cargo-deb's auto-detection won't find them — list explicitly. libxcb +# is the one shared library the GUI actually dlopens. +depends = "libc6, libxcb1, yt-dlp, ffmpeg, mpv, xdg-utils" +recommends = "libnotify4" +assets = [ + ["target/release/yt-offline", "usr/bin/", "755"], + ["youtube-backup.desktop", "usr/share/applications/yt-offline.desktop", "644"], + ["icon.png", "usr/share/pixmaps/yt-offline.png", "644"], + ["README.md", "usr/share/doc/yt-offline/README.md", "644"], +] + +# ── Fedora / RHEL / openSUSE package (cargo-generate-rpm) ───────────────────── +# `cargo generate-rpm` reads this. Run `cargo build --release` first; it +# packages the already-built binary rather than building itself. +[package.metadata.generate-rpm] +summary = "Self-hosted multi-platform video archiver with desktop + web UI" +license = "AGPLv3" +# RPM distro package names differ from Debian's. yt-dlp is `yt-dlp` on +# Fedora; ffmpeg lives in RPM Fusion but we still declare the dep. +requires = { yt-dlp = "*", ffmpeg = "*", mpv = "*", "xdg-utils" = "*" } +assets = [ + { source = "target/release/yt-offline", dest = "/usr/bin/yt-offline", mode = "755" }, + { source = "youtube-backup.desktop", dest = "/usr/share/applications/yt-offline.desktop", mode = "644" }, + { source = "icon.png", dest = "/usr/share/pixmaps/yt-offline.png", mode = "644" }, + { source = "README.md", dest = "/usr/share/doc/yt-offline/README.md", mode = "644" }, + { source = "LICENSE", dest = "/usr/share/licenses/yt-offline/LICENSE", mode = "644" }, +] diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..19e20d7 --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,102 @@ +# Packaging yt-offline + +This document covers building distributable packages. The one-liner: + +```sh +scripts/package.sh all # .deb + .rpm + .AppImage into dist/ +scripts/package.sh deb # just the .deb +scripts/package.sh rpm # just the .rpm +scripts/package.sh appimage +``` + +The script builds the release binary once and reuses it for every +format. Output lands in `dist/` (gitignored). + +## Linux formats + +### .deb (Debian / Ubuntu / Mint) + +Built by [`cargo-deb`](https://github.com/kornelski/cargo-deb), driven by +`[package.metadata.deb]` in `Cargo.toml`. The script installs cargo-deb +on demand. Runtime deps declared: `yt-dlp`, `ffmpeg`, `mpv`, `xdg-utils`, +`libxcb1`, `libc6` (and `libnotify4` recommended). + +```sh +scripts/package.sh deb +sudo apt install ./dist/yt-offline_*_amd64.deb +``` + +### .rpm (Fedora / RHEL / openSUSE) + +Built by [`cargo-generate-rpm`](https://github.com/cat-in-136/cargo-generate-rpm), +driven by `[package.metadata.generate-rpm]`. It packages the +already-built (and release-profile-stripped) binary. + +```sh +scripts/package.sh rpm +sudo dnf install ./dist/yt-offline-*.x86_64.rpm +``` + +Note: `ffmpeg` on Fedora lives in [RPM Fusion](https://rpmfusion.org/); +the dependency is declared but the user may need that repo enabled. + +### AppImage (any Linux) + +A hand-rolled AppDir + [`appimagetool`](https://github.com/AppImage/appimagetool) +(downloaded to `dist/tools/` on first run). The image bundles the GUI +binary and its shared-library closure only — `yt-dlp`/`ffmpeg`/`mpv` are +still expected on the host PATH, same as the package deps. Bundling them +would balloon the image and tangle the licensing. + +```sh +scripts/package.sh appimage +chmod +x dist/yt-offline-*-x86_64.AppImage +./dist/yt-offline-*-x86_64.AppImage +``` + +## Arch Linux + +Use the `PKGBUILD` in the repo root (not this script). It builds from a +fresh git clone, so run it from a clean directory: + +```sh +mkdir build && cd build +cp /path/to/repo/PKGBUILD . +makepkg -si +``` + +For repeated builds after pushing new commits, always pass `-C` +(cleanbuild) so makepkg re-checks out the latest source instead of +reusing a stale cached clone. + +## Windows — experimental + +Windows is **not** currently a first-class target. Two Linux-only +dependencies block a clean `--target x86_64-pc-windows-gnu` build: + +- **`ksni`** (system tray) — talks to the freedesktop StatusNotifierItem + D-Bus spec, which doesn't exist on Windows. Needs replacing with + `tray-icon` behind `#[cfg(windows)]`, or stubbing `src/tray.rs` to a + no-op on non-Unix. +- **`rfd` xdg-portal backend** — the file picker uses the XDG desktop + portal. The `rfd` crate does support a native Windows backend, but the + feature flags in `Cargo.toml` would need to be made target-conditional. + +Once those are addressed, the rest (eframe, axum, rusqlite-bundled) is +already cross-platform — `bundled_ytdlp_path()` and friends already have +`cfg!(windows)` branches. The path to a Windows `.exe`/`.msi` (via +[`cargo-wix`](https://github.com/volks73/cargo-wix)) is then mechanical. + +Tracked as a follow-up; PRs welcome. + +## macOS + +Same shape as Windows: the tray needs a macOS backend. eframe runs fine +on macOS otherwise. A `.app` bundle + `.dmg` would follow once the tray +is abstracted behind a trait with per-OS implementations. + +## CI + +`.forgejo/workflows/release.yml` runs `scripts/package.sh all` on every +pushed tag (`v*`) and attaches the resulting `.deb`/`.rpm`/`.AppImage` to +the Codeberg release. See that file for the runner setup. diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..c7ce697 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Build distributable packages for yt-offline. +# +# Usage: +# scripts/package.sh [deb|rpm|appimage|all] +# +# With no argument, builds everything that's buildable on the current host. +# Output lands in dist/. Each format is independent — a failure in one +# doesn't abort the others (the script reports a summary at the end). +# +# Prerequisites are installed on demand where possible: +# - .deb → cargo-deb (cargo install cargo-deb) +# - .rpm → cargo-generate-rpm (cargo install cargo-generate-rpm) +# - AppImage → appimagetool (downloaded to dist/tools/ if missing) +# +# The release binary is built once up front and reused by every format. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +DIST="$ROOT/dist" +TOOLS="$DIST/tools" +mkdir -p "$DIST" "$TOOLS" + +# Pull version + name straight from Cargo.toml so packages stay in sync. +VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" +PKG="yt-offline" +ARCH="$(uname -m)" + +say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +err() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; } + +WANT="${1:-all}" +declare -A RESULT # format → "ok " | "skip " | "fail " + +# ── Build the release binary once ──────────────────────────────────────────── +build_binary() { + say "building release binary (this is the slow part)…" + if ! cargo build --release; then + err "cargo build failed — aborting, no packages can be made" + exit 1 + fi + if [[ ! -x target/release/$PKG ]]; then + err "expected target/release/$PKG to exist after build" + exit 1 + fi +} + +# ── .deb ───────────────────────────────────────────────────────────────────── +build_deb() { + say "building .deb" + # Check via `cargo deb` rather than `command -v cargo-deb`: cargo + # subcommands live in ~/.cargo/bin which may not be on PATH in CI, + # but `cargo deb` resolves them through cargo itself. + if ! cargo deb --help >/dev/null 2>&1; then + say "installing cargo-deb…" + cargo install cargo-deb || { RESULT[deb]="fail cargo-deb install failed"; return; } + fi + # --no-build: reuse the binary we already compiled above. + if cargo deb --no-build --output "$DIST/"; then + # cargo-deb names it ${PKG}_${VERSION}-1_${arch}.deb; find it. + local out + out="$(ls -t "$DIST"/${PKG}_*.deb 2>/dev/null | head -1)" + RESULT[deb]="ok ${out:-$DIST}" + else + RESULT[deb]="fail cargo deb returned nonzero" + fi +} + +# ── .rpm ───────────────────────────────────────────────────────────────────── +build_rpm() { + say "building .rpm" + if ! cargo generate-rpm --help >/dev/null 2>&1; then + say "installing cargo-generate-rpm…" + cargo install cargo-generate-rpm || { RESULT[rpm]="fail cargo-generate-rpm install failed"; return; } + fi + # cargo-generate-rpm packages the already-built binary; we stripped it + # via the release profile so an explicit strip step isn't needed. + if cargo generate-rpm --output "$DIST/"; then + local out + out="$(ls -t "$DIST"/${PKG}-*.rpm 2>/dev/null | head -1)" + RESULT[rpm]="ok ${out:-$DIST}" + else + RESULT[rpm]="fail cargo generate-rpm returned nonzero" + fi +} + +# ── AppImage ───────────────────────────────────────────────────────────────── +# Hand-rolled AppDir + appimagetool. We don't bundle the subprocess deps +# (yt-dlp / ffmpeg / mpv) — they're expected on PATH like the package deps, +# and bundling them would balloon the image and complicate licensing. The +# AppImage carries the GUI binary + its shared-lib closure only. +build_appimage() { + say "building AppImage" + local tool="$TOOLS/appimagetool" + if [[ ! -x "$tool" ]]; then + local tool_url="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" + say "downloading appimagetool…" + if ! curl -fL --retry 3 -o "$tool" "$tool_url"; then + RESULT[appimage]="fail could not download appimagetool" + return + fi + chmod +x "$tool" + fi + + local appdir="$DIST/${PKG}.AppDir" + rm -rf "$appdir" + mkdir -p "$appdir/usr/bin" "$appdir/usr/share/applications" "$appdir/usr/share/icons/hicolor/256x256/apps" + + install -Dm755 "target/release/$PKG" "$appdir/usr/bin/$PKG" + install -Dm644 youtube-backup.desktop "$appdir/usr/share/applications/$PKG.desktop" + # AppImage wants the desktop file + icon at the AppDir root too. + cp youtube-backup.desktop "$appdir/$PKG.desktop" + install -Dm644 icon.png "$appdir/usr/share/icons/hicolor/256x256/apps/$PKG.png" + cp icon.png "$appdir/$PKG.png" + + # AppRun is the entry point. Exec the bundled binary, forwarding args. + cat > "$appdir/AppRun" <<'APPRUN' +#!/usr/bin/env bash +HERE="$(dirname "$(readlink -f "${0}")")" +exec "$HERE/usr/bin/yt-offline" "$@" +APPRUN + chmod +x "$appdir/AppRun" + + local out="$DIST/${PKG}-${VERSION}-${ARCH}.AppImage" + # ARCH env var tells appimagetool which arch to stamp into the runtime. + if ARCH="$ARCH" "$tool" --no-appstream "$appdir" "$out" 2>&1; then + RESULT[appimage]="ok $out" + else + RESULT[appimage]="fail appimagetool returned nonzero" + fi + rm -rf "$appdir" +} + +# ── Dispatch ───────────────────────────────────────────────────────────────── +build_binary + +case "$WANT" in + deb) build_deb ;; + rpm) build_rpm ;; + appimage) build_appimage ;; + all) build_deb; build_rpm; build_appimage ;; + *) err "unknown target '$WANT' (want: deb|rpm|appimage|all)"; exit 2 ;; +esac + +# ── Summary ────────────────────────────────────────────────────────────────── +echo +say "package summary" +status=0 +for fmt in deb rpm appimage; do + [[ -v RESULT[$fmt] ]] || continue + state="${RESULT[$fmt]%% *}" + detail="${RESULT[$fmt]#* }" + case "$state" in + ok) printf ' \033[1;32m✓\033[0m %-9s %s\n' "$fmt" "$detail" ;; + skip) printf ' \033[1;33m–\033[0m %-9s %s\n' "$fmt" "$detail" ;; + fail) printf ' \033[1;31m✗\033[0m %-9s %s\n' "$fmt" "$detail"; status=1 ;; + esac +done +exit $status