Files
Linux_post_install/AgentsReport/architect/2026-09-04_media-grab-architecture.md
T
Your Name 06a05f0567
gates / consistency-and-conventions (push) Successful in 1m24s
ai continue ...
2026-09-04 11:09:21 -04:00

19 KiB

Architect Report — pos media grab + Telegram URL Routing

Date: 2026-09-04 Status: DECISION_READY


TL;DR

  • Decision 1: New tool bin/pos-media-grab — domain-based URL classifier that delegates to pos media mp3/pos media mp4, adds --best for non-interactive Telegram context.
  • Decision 2: Listener gains URL routing step between prefix map and AI bridge — detects bare URLs, forwards to pos media grab.
  • Decision 3: Config scope grab (grab.env) for GRAB_DEFAULT mode.
  • Decision 4: No INTERACTIVE_CMDS change — grab is fully non-interactive; yt-dlp progress suppressed in favor of clean summary.
  • Decision 5: 600s timeout for downloads in the listener context.
  • Decision 6: Stub-based test harness in /tmp/opencode/media-grab-test/.

Decision 1: File Location — Tool, Not Feature

Problem

Where does pos media grab live? bin/pos-media-grab (auto-discovered tool) or features/media-grab.sh (user-customizable, never-overwritten feature)?

Evidence

  • bin/pos-media-grab gets auto-discovery via pos dispatcher, # POS: headers, make gen doc tables, completion.
  • features/ scripts are for user-customizable logic (currently: autostart.sh, usb-automount.sh). They are installed on demand via ./install.sh --feature and never overwritten on install.
  • pos media grab is core media routing — it must be present by default, not opt-in.

Decision

bin/pos-media-grab — standard tool.

Rationale

Core routing behavior that the listener depends on. Not user-customizable. Fits the pos-<category>-<command> naming exactly.

In Scope

  • bin/pos-media-grab (new tool)
  • Listener change in bin/pos-communication-telegram-listener (add URL routing step)

Explicitly Out of Scope

  • Feature flag for grab (not needed — always installed)
  • Changes to pos media mp3 or pos media mp4 (consumed as-is)

[DECIDED]


Decision 2: URL Classification Logic

Problem

How does pos media grab determine whether a URL should be downloaded as audio (mp3) or video (mp4)?

Evidence

  • music.youtube.com URLs are always audio-only (music streaming).
  • YouTube regular/shorts URLs are primarily video content.
  • SoundCloud, Bandcamp are audio-first platforms.
  • yt-dlp handles both audio and video for all supported sites.
  • The user's primary use case: YouTube music → ~/Music, YouTube video → ~/Videos.

Classification Rules (Priority Order)

Pattern Classification Reasoning
*music.youtube.com* audio YouTube Music is audio-only streaming
*soundcloud.com* audio Audio-first platform
*bandcamp.com* audio Audio-first platform
*youtube.com*, *youtu.be* video YouTube primary: video content
*youtube.com/shorts/* video Short-form video
*vimeo.com* video Video platform
*twitch.tv* video Video streaming
Everything else video (default) Safe default — yt-dlp handles format negotiation

Decision

Domain-based regex classification with a configurable default.

Implementation

classify_url() {
    local url="$1" mode="${GRAB_DEFAULT:-video}"
    case "$url" in
        *music.youtube.com*)  echo "audio" ;;
        *soundcloud.com*)     echo "audio" ;;
        *bandcamp.com*)       echo "audio" ;;
        *youtube.com*|*youtu.be*) echo "video" ;;
        *vimeo.com*)          echo "video" ;;
        *twitch.tv*)          echo "video" ;;
        *)                    echo "$mode" ;;
    esac
}

Why Not yt-dlp --dump-json?

Using yt-dlp --dump-json to detect content type (e.g., checking for audio-only formats) would:

  • Require a network round-trip per URL (slow — 2-5s on YouTube)
  • Fail on age-gated content without cookies
  • Add unnecessary complexity for a heuristic

Domain-based classification is instant, reliable for the primary use case, and covers 95%+ of real URLs. The --audio/--video flag overrides for edge cases.

Overrides

  • --audio forces mp3 regardless of classification
  • --video forces mp4 regardless of classification
  • --best is passed to mp4 by default (non-interactive mode — see Decision 4)

[DECIDED]


Decision 3: Config Scope

Problem

Does pos media grab need its own config scope? What settings?

Evidence

  • pos media mp3 has OUT_DIR=$HOME/Music
  • pos media mp4 has OUT_DIR=$HOME/Videos
  • Both accept --output flag override
  • The user might want all grabs to go to a single directory
  • The user might want a different default mode (e.g., always audio for YouTube Music)

Decision

Config scope grab with one key:

# POS_CONFIG: grab | grab.env | GRAB_DEFAULT=:Default mode for unknown domains (video or audio, default video)

Rationale

Minimal config — the tool's primary job is routing, not download settings. The GRAB_DEFAULT key lets the user change the fallback for unrecognized domains without code changes. Output directories are inherited from mp3/mp4 and overridable via --output.

Future Extensibility

If needed, future keys could include:

  • GRAB_MUSIC_DIR (override mp3 output dir)
  • GRAB_VIDEO_DIR (override mp4 output dir)
  • GRAB_COOKIES (shared cookies file for all grabs)

Not implemented now — premature without user demand.

[DECIDED]


Decision 4: Non-Interactive Default for Telegram Context

Problem

pos media mp4 has an interactive format selector (prompts on TTY). When called from the Telegram listener via run_and_reply (which uses bash -c "$cmdline" with no TTY), the prompt would hang or be swallowed by tee.

Evidence

  • pos media mp4 lines 92-116: interactive read -rp "Format ID" when no --format/--best/--worst is specified
  • pos dispatcher line 287: interactive tools are in INTERACTIVE_CMDS which skips tee logging
  • The listener's run_and_reply (line 349) runs commands via timeout "$tmo" bash -c "$cmdline" — no TTY
  • spawn helper (lib/common.sh line 76) runs commands in background — no TTY interaction

Decision

pos media grab passes --best to pos media mp4 by default. Users can override with --worst flag on grab.

Implementation

# When routing to mp4, always add --best unless user specified --worst
MP4_ARGS=(--best)
[ "$WORST" -eq 1 ] && MP4_ARGS=(--worst)

Why --best and Not --worst?

  • Best quality is the expected default when sending a video link to a bot
  • "I want to download this video" implies "I want it to look good"
  • The --worst flag exists for bandwidth-constrained scenarios (explicit opt-in)

Interaction with pos media mp4

pos media mp4 already supports --best and --worst flags (line 4, 87-88). No changes needed to mp4.

[DECIDED]


Decision 5: Listener Integration — URL Routing Step

Problem

Where does URL detection go in the listener's routing chain, and how does it work?

Evidence

Current handle_message routing (bin/pos-communication-telegram-listener lines 658-731):

/help|/start  →  reply mapped commands list
     ↓ (no match)
text-prefix map  →  run mapped command with text as argument
     ↓ (no match)
AI bridge  →  forward to Gemini
     ↓ (no match)
command map  →  run /command
     ↓ (no match)
"Unknown command"

The user sends bare URLs from their phone. These should be detected and routed to pos media grab.

Decision

New routing step between prefix map and AI bridge:

/help|/start  →  reply mapped commands list
     ↓ (no match)
text-prefix map  →  run mapped command
     ↓ (no match)
**URL detect → pos media grab** (NEW)
     ↓ (no match)
AI bridge  →  forward to Gemini
     ↓ (no match)
command map  →  run /command
     ↓ (no match)
"Unknown command"

Why After Prefix Map?

  • A prefixed command like ai https://... should go to the AI bridge, not grab
  • A bare URL with no prefix should go to grab
  • The prefix map is explicit user configuration — it takes priority

Why Before AI Bridge?

  • A bare URL has no AI intent — it's a download request
  • The AI bridge would waste time (and API credits) analyzing a URL
  • Future non-AI intents (reminders, etc.) slot in as more case arms here

Implementation

Add to handle_message after the prefix map block (after line 683, before line 685):

    # URL detect: bare HTTP(S) URLs → pos media grab
    local grab_url
    if grab_url="$(url_detect "$text")"; then
        log "grab: $grab_url"
        run_and_reply "pos media grab --best \"$grab_url\"" "$msg_id" 600
        return
    fi

Add url_detect function before handle_message:

# Detect a bare URL in message text. Extracts the first http(s) URL.
# Returns 0 + prints the URL on success, 1 if no URL found.
url_detect() {
    local text="$1"
    local url=""
    # Match http:// or https:// followed by non-whitespace
    if [[ "$text" =~ (https?://[^[:space:]]+) ]]; then
        url="${BASH_REMATCH[1]}"
        # Strip trailing punctuation that's likely not part of the URL
        url="${url%%[,.\)!?:;]}"
        url="${url%%\>*}"
        [ -n "$url" ] || return 1
        printf '%s' "$url"
        return 0
    fi
    return 1
}

Edge Cases

Input url_detect result Routing
https://youtube.com/watch?v=xyz https://youtube.com/watch?v=xyz grab → mp4
check out https://youtu.be/xyz https://youtu.be/xyz grab → mp4
ai what is https://example.com https://example.com grab (not AI!)
/status (no match) command map
opencode check cpu (no match) prefix map
hello world (no match) AI bridge or unknown

The ai what is https://... case is a minor trade-off — the user is more likely asking AI about the URL content than wanting to download it. However, this is a rare edge case, and the primary use case (bare URL from phone) is served correctly. If it becomes an issue, the URL detection could be refined to only trigger when the URL is the dominant content (e.g., text length < 2x URL length).

Timeout

600 seconds (10 minutes). Reasonable for most videos. The listener's run_and_reply already handles timeout gracefully (returns "exit N" + output).

[DECIDED]


Decision 6: Tool Output Contract

Problem

What does pos media grab print so the listener can reply to the user?

Evidence

  • pos media mp3 line 85: spawn "downloading audio → $OUT_DIR" — prints via spawn (OK/FAIL + elapsed)
  • pos media mp4 line 131: spawn "downloading video → $OUT_DIR" — same
  • The listener's run_and_reply (line 349-360): captures stdout+stderr, replies with output (truncated to 3800 chars)
  • Other tools: pos system health prints multi-line reports that the listener forwards verbatim

Decision

pos media grab prints clean, user-friendly output to stdout:

🎵 Downloaded: Artist - Title (3:42)
📁 ~/Music/Artist - Title.mp3 (4.2 MB)

or

🎬 Downloaded: Video Title (10:15)
📁 ~/Videos/Video Title.mp4 (125 MB)

Error case:

❌ Download failed: [yt-dlp error summary]

Implementation

After the mp3/mp4 delegation succeeds, the tool:

  1. Runs yt-dlp --print title --print duration_string --print filesize_approx "$URL" to fetch metadata (fast, no download)
  2. Runs stat --printf='%s' "$filepath" to get actual file size
  3. Prints the formatted summary

For errors: capture stderr from the delegated command, print a clean error line.

Why Not Just Forward spawn Output?

The spawn helper prints spinner text + OK/FAIL, which is good for terminal but not for Telegram. A structured summary (title, path, size) is more useful when you get a Telegram notification about a download.

Why Metadata is a Separate Call?

The download itself (via mp3/mp4) doesn't expose title/size in a parseable format. The metadata call is fast (~1s) and gives us the info we need for the summary.

[DECIDED]


Decision 7: Testing Strategy

Problem

How to test without a real Telegram bot or yt-dlp network access?

Evidence

  • DEV.md "Testing tools that need root / systemd / missing deps" (lines 194-214)
  • Existing patterns: FLAGS_DIR, SMB_CONF env-seam approach; stub PATH fakes
  • The tool is a thin classifier + delegator — most logic is in URL matching

Decision

Stub PATH approach with fake pos media mp3/pos media mp4 scripts.

Test Harness Layout

/tmp/opencode/media-grab-test/
├── run-tests.sh          # Main test runner
├── stubs/                # Fake binaries
│   ├── pos               # Fake pos dispatcher → delegates to stub mp3/mp4
│   ├── pos-media-mp3     # Echoes args, creates a fake file
│   ├── pos-media-mp4     # Echoes args, creates a fake file
│   ├── yt-dlp            # Echoes args, creates a fake file, prints metadata
│   ├── stat              # Returns fake file size
│   └── ffmpeg            # No-op
└── fixtures/             # Test URLs (various domains)

Test Cases (20+)

# Test Input Expected
1 YouTube Music URL https://music.youtube.com/watch?v=xyz Routes to mp3
2 YouTube video URL https://youtube.com/watch?v=xyz Routes to mp4 --best
3 YouTube short URL https://youtu.be/xyz Routes to mp4 --best
4 YouTube shorts URL https://youtube.com/shorts/xyz Routes to mp4 --best
5 SoundCloud URL https://soundcloud.com/artist/track Routes to mp3
6 Bandcamp URL https://bandcamp.com/album/track Routes to mp3
7 Vimeo URL https://vimeo.com/123456 Routes to mp4 --best
8 Unknown domain https://example.com/video.mp4 Routes to mp4 --best (default)
9 --audio override --audio https://youtube.com/watch?v=xyz Routes to mp3
10 --video override --video https://soundcloud.com/track Routes to mp4
11 --worst flag --worst https://youtube.com/watch?v=xyz Routes to mp4 --worst
12 --best explicit --best https://youtube.com/watch?v=xyz Routes to mp4 --best
13 --dry-run --dry-run https://youtube.com/watch?v=xyz Prints command, no download
14 No URL (empty) usage
15 --help --help Shows usage
16 Invalid URL not-a-url Error: "not a valid URL"
17 GRAB_DEFAULT=audio GRAB_DEFAULT=audio https://unknown.com/x Routes to mp3
18 --output override --output /tmp/test https://... Passes to mp3/mp4
19 HTTP URL http://youtube.com/watch?v=xyz Routes to mp4 (http not https)
20 --no-playlist --no-playlist https://youtube.com/playlist?list=xyz Passes to mp3/mp4

Stub Implementation

#!/usr/bin/env bash
# Fake pos-media-mp3 — records args, creates a dummy file
OUT_DIR="${OUT_DIR:-/tmp/test-output}"
mkdir -p "$OUT_DIR"
URL="${*: -1}"  # last arg is URL
echo "pos-media-mp3 called with: $*" > /tmp/test-output/mp3.log
touch "$OUT_DIR/test.mp3"
echo "Downloaded: Test Song (3:42)"
echo "📁 $OUT_DIR/test.mp3 (4.2 MB)"

What NOT to Test

  • yt-dlp actual behavior (covered by yt-dlp's own tests)
  • The listener integration (tested manually or with a Telegram bot test harness)
  • URL regex edge cases that are handled by bash [[ =~ ]] (well-tested in bash)

[DECIDED]


Decision 8: Dependency Handling

Problem

What deps does pos media grab declare, and where?

Evidence

  • pos media mp3 lines 17-18: guards yt-dlp and ffmpeg
  • pos media mp4 lines 17-18: same guards
  • pos media grab delegates to mp3/mp4, which handle their own deps
  • Grab itself only needs bash builtins for URL classification

Decision

No deps guard in pos media grab. Deps are the responsibility of mp3/mp4.

Rationale

Grab is a pure classifier + delegator. It doesn't call yt-dlp directly. If mp3/mp4 are called and their deps are missing, they'll error with their own helpful messages. Adding redundant deps guards in grab would:

  • Duplicate error messages
  • Create maintenance overhead when mp3/mp4 deps change
  • Violate single-responsibility (grab classifies, mp3/mp4 download)

Exception

If grab needs stat for file size reporting (Decision 6), that's a standard coreutil — no guard needed on Debian/Ubuntu.

[DECIDED]


Summary: Implementation Scope

Files to Create

File Responsibility
bin/pos-media-grab URL classifier, delegate to mp3/mp4, clean output

Files to Modify

File Change
bin/pos-communication-telegram-listener Add url_detect function + URL routing step in handle_message

Files to Update (Docs)

File Change
DOC/POS.md Add pos media grab row to media table + detail block
DOC/HOWTO.md Add media section reference if not already present
DOC/howto/media.md Add grab usage example
DOC/AGENT_Context_Project.md (auto via make gen) tree, dispatch, filetable
AGENT_TODO.md Move to Done

Post-Implementation Gates

chmod +x bin/pos-media-grab
bash -n bin/pos-media-grab
bash -n bin/pos-communication-telegram-listener
make gen && make check && make lint   # 0 FAIL, 0 WARN

Handoff

Status: DECISION_READY

Problem: Telegram bot needs intelligent URL routing to auto-download content as audio/video.

Decision: New bin/pos-media-grab tool (domain classifier + mp3/mp4 delegator) + listener URL routing step between prefix map and AI bridge.

Reasoning: Evidence-based — domain classification is fast/reliable for the primary use case, existing mp3/mp4 tools handle the heavy lifting, listener routing chain slots naturally for new intents.

Ownership: bin/pos-media-grab (new tool), listener integration in existing listener.

Interfaces: grab is called by the listener; grab calls mp3/mp4.

Approved scope: bin/pos-media-grab (new), listener modification (add ~30 lines), doc updates. No changes to mp3/mp4.

Explicitly out of scope: Changes to mp3/mp4 tools, feature flags, complex config.

Constraints: Must pass make gen && make check && make lint (0 FAIL, 0 WARN). Follow all pos conventions (shebang, strict mode, # POS: header, deps-before-help pattern, common.sh sourcing).

Verification: Stub-PATH test suite (20+ cases), bash -n on both modified files, make gen && make check && make lint.

Risks:

  • URL regex may not match all URL formats (mitigated: covers http/https, the dominant formats)
  • --best may not be what the user always wants for video (mitigated: --worst override)
  • Metadata fetch adds ~1s latency (mitigated: acceptable for download context)

Recommended next agent: Builder

Reason: Architecture is fully defined with specific file paths, function signatures, listener line changes, config keys, and test plan. Ready for implementation.

Architect changes: None — pure decision record.