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 topos media mp3/pos media mp4, adds--bestfor 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) forGRAB_DEFAULTmode. - 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-grabgets auto-discovery viaposdispatcher,# POS:headers,make gendoc tables, completion.features/scripts are for user-customizable logic (currently:autostart.sh,usb-automount.sh). They are installed on demand via./install.sh --featureand never overwritten on install.pos media grabis 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 mp3orpos 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.comURLs 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
--audioforces mp3 regardless of classification--videoforces mp4 regardless of classification--bestis 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 mp3hasOUT_DIR=$HOME/Musicpos media mp4hasOUT_DIR=$HOME/Videos- Both accept
--outputflag 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 mp4lines 92-116: interactiveread -rp "Format ID"when no--format/--best/--worstis specifiedposdispatcher line 287: interactive tools are inINTERACTIVE_CMDSwhich skipsteelogging- The listener's
run_and_reply(line 349) runs commands viatimeout "$tmo" bash -c "$cmdline"— no TTY spawnhelper (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
--worstflag 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 mp3line 85:spawn "downloading audio → $OUT_DIR"— prints via spawn (OK/FAIL + elapsed)pos media mp4line 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 healthprints 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:
- Runs
yt-dlp --print title --print duration_string --print filesize_approx "$URL"to fetch metadata (fast, no download) - Runs
stat --printf='%s' "$filepath"to get actual file size - 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_CONFenv-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 mp3lines 17-18: guardsyt-dlpandffmpegpos media mp4lines 17-18: same guardspos media grabdelegates 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)
--bestmay not be what the user always wants for video (mitigated:--worstoverride)- 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.