lov-media-fetch
Use when the user asks to find and download a film, series, or long video. 以 aria2 为默认传输后端,完成多源测速、续传、容量预检、版本核验与字幕验收;也适用于“帮我下载这部电影”。
Install
npx skills add https://github.com/lovstudio/skills/tree/main/skills/media-fetch
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
git clone https://github.com/lovstudio/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.
README
影视寻宝 · Media Finder
一键完成长视频的检索、版本选择、磁盘预检、多源测速下载、可恢复续传、慢源切换和文件验收。
本地安装
通过 Agent Skills 安装器:
npx skills add https://github.com/lovstudio/media-fetch-skill --skill lov-media-fetch
或在本仓库根目录创建开发链接:
export SKILL_SOURCE_DIR="$(pwd)"
mkdir -p "${SKILL_SKILLS_INSTALL_DIR:?请设置本地 Skills 目录}"
ln -s "$SKILL_SOURCE_DIR" \
"$SKILL_SKILLS_INSTALL_DIR/lov-media-fetch"
安装链接必须解析到当前源码目录;打包文件不等于本地安装。
用户配置
默认下载到 $HOME/Downloads/Media。首次运行先查看解析结果:
python3 scripts/media_config.py show
确认后写入共享配置:
python3 scripts/media_config.py init --write
显式请求和环境变量始终覆盖配置。默认只启用 aria2;可选 qBittorrent 密码只通过
QBITTORRENT_PASSWORD 或系统凭据提供。
使用
- “帮我找并下载《影片名》的导演剪辑版,优先中英字幕,体积控制在 20GB 内。”
- “Find and download the best compact 4K release of TITLE, then verify the English and Chinese subtitles.”
- “这个 Magnet 帮我下载;先确认磁盘够用,太慢就自动换另一个版本。”
自然语言调用默认运行 full 流水线。已有链接使用 download-known;本地文件验收使用 verify。
aria2 默认负责 HTTP(S)、Magnet 和 Torrent 的测速、下载与续传。qBittorrent 仅在
需要搜索插件、队列界面、BT 深度管理或长期做种时启用。每次后端选择与切换都写入
transport trace,不把客户端进度当作最终完成证据。
可选 qBittorrent 连接
启用该适配器时,将 WebUI 限定在本机回环地址。已有兼容客户端时直接复用,密码 保存在系统凭据中,再通过环境变量注入当前任务。
export QBITTORRENT_URL="http://127.0.0.1:8080"
export QBITTORRENT_USERNAME="admin"
export QBITTORRENT_PASSWORD="从安全凭据读取"
主链路依次运行:
python3 scripts/rank_candidates.py --input candidates.json --output decision.json
python3 scripts/storage_preflight.py --decision decision.json
python3 scripts/aria2_acquire.py \
--input INPUT --job-id JOB_ID --output-dir "$HOME/Downloads/Media" \
--result aria2-acquisition.json --watch --no-proxy
python3 scripts/verify_media.py --path "$HOME/Downloads/Media" --output verification.json
若直接 URL 的路径没有媒体扩展名,给 aria2 增加
--output-name "TITLE (YEAR).mp4"。可选 qBittorrent 搜索与获取脚本仍保留:
python3 scripts/qbittorrent_search.py --query "TITLE YEAR" --output candidates.json
python3 scripts/qbittorrent_acquire.py \
--decision decision.json --wait-complete --result qbit-acquisition.json
质量门
python3 scripts/validate_skill.py .
python3 scripts/test_aria2_acquire.py
python3 scripts/media_config.py show --json
python3 scripts/rank_candidates.py \
--input assets/example-candidates.json \
--output /tmp/media-fetch-decision.json
python3 scripts/storage_preflight.py \
--decision /tmp/media-fetch-decision.json \
--output-dir /tmp
python3 scripts/aria2_acquire.py \
--input 'https://example.invalid/movie.mp4' \
--job-id validation-direct --output-dir /tmp \
--result /tmp/media-fetch-aria2-direct.json --dry-run
python3 scripts/aria2_acquire.py \
--input 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567' \
--job-id validation-bt --output-dir /tmp \
--result /tmp/media-fetch-aria2-bt.json --dry-run
依赖
- Python 3.9+
- PyYAML(Skill 源码校验)
- aria2 1.36+(默认 HTTP、Magnet 与 Torrent 下载后端)
- FFmpeg /
ffprobe(媒体验收) - 可选:Rats Search(独立 DHT 检索)
- 可选:qBittorrent 5.x WebUI(搜索插件、队列管理、做种)
字幕分支
默认验收 zh-Hans 与 en。如果成片只有英文字幕,先匹配同一发行版本的外置
SRT,再交给 lov-subtitle-freedom-skill 做时间轴、UTF-8 和 SRT 保真处理。该
Skill 的英文学习提示、人物卡和 ASS 样式均需要明确开启;Media Fetch 不会把它们
作为简中字幕输出。
用户案例
《指环王》三部曲的实际任务验证了后端解耦的价值:可选 qBittorrent 搜索并测速后,
主候选在首轮实测偏慢,aria2 使用同一信息哈希、DHT/PeX/LSD 与 Tracker 续传,最终得到
3 个可读的 1080p HEVC 文件。下载报告为 complete,媒体验收为
passed_with_warnings,唯一开放项是原发行文件未嵌入 zh-Hans,因此报告继续
保留字幕缺口而不是虚报“双语完成”。完整脱敏证据位于 cases/evidence/。
License
MIT
Skill manifest
影视寻宝 · Media Finder
Turn one natural-language request into a verified local media file. Search broadly, identify the actual cut, balance picture quality against size, check storage before transfer, race viable sources, monitor the winner, recover from stalls, and inspect the completed file.
Triggers
Activate when
- 用户说“帮我找并下载这部电影”“下载导演剪辑版,画质好一点但别太大”“找带中英字幕的完整版”。
- 用户给出片名、年份、版本偏好,或已有 Magnet/Torrent,希望自动完成选择、下载和验收。
- The user asks to find and download the best release, fetch an extended cut, or download a compact high-quality copy with Chinese and English subtitles.
Do not activate when
- 用户只想了解影片资料、比较不同剪辑或获得观看建议,不要求取得本地文件。
- 用户只要下载普通网页文件、软件安装包、网页视频片段或直播流。
- 用户明确要求发布、上传、转码压制或制作字幕;这些是下载完成后的独立任务。
Product contract
- One request should normally run end to end without repeated confirmation.
- Prefer an edition whose identity is supported by runtime, release metadata, or file-level evidence. A filename alone is weak evidence.
- “Best” means the highest useful viewing quality inside the user's size and disk budget, not the largest file or highest advertised resolution.
- Default to embedded Simplified Chinese plus English subtitles. Treat filename claims as hints until streams or synchronized external subtitles are inspected.
- Ask the user only when title identity is ambiguous, editions contain materially different content, or the top candidates are close enough that taste decides.
- Never begin payload transfer before the destination capacity check passes.
- Keep observing an active job. A task added to a client is not a completed result.
- Completion means that the final local payload exists and the verification report is written. A client-reported 100% is only an intermediate signal.
- Keep advertised seeders, observed peers, metadata readiness, received bytes, and sustained speed as separate evidence fields. A large seeder count is not a speed promise.
- Use aria2 as the default transfer backend for direct URLs, Metalinks, Magnets, and Torrent inputs. Enable qBittorrent only when its search plugins, queue UI, swarm inspection, or long-term seeding materially helps the task. Record every backend choice and switch; do not create a second full payload by accident.
- A missing
zh-Hansstream is a recoverable subtitle gap, not a reason to mislabel the media. The subtitle branch may consultlov-subtitle-freedom-skillfor timestamp-preserving UTF-8 SRT handling. Its English-learning gloss and ASS modes stay opt-in; plain Chinese subtitle delivery remains a separate, clearly named SRT.
User configuration
Resolve defaults through $KIT_DIR/references/user-config.md. On first use, show the
resolved output directory and preferences before persisting them. Keep credentials in
environment variables or the operating system credential store.
Skill Kit modules
Load the selected module completely before acting:
$SKILL_DIR/skills/media-discovery/SKILL.md— identify the title and collect normalized candidates from several independent discovery paths.$SKILL_DIR/skills/media-selection/SKILL.md— verify editions and rank picture, codec, size, audio, subtitles, health, and evidence.$SKILL_DIR/skills/media-acquisition/SKILL.md— capacity preflight, parallel swarm probing, winner selection, progress observation, stall recovery, and cleanup.$SKILL_DIR/skills/media-verification/SKILL.md— inspect the downloaded files, edition runtime, streams, subtitle coverage, completeness, and final path.
kit.yaml defines the available pipelines. Shared schemas and decision rules live in
$KIT_DIR/references/.
Workflow (MANDATORY)
You MUST follow these steps in order.
Step 0: Resolve runtime and select a pipeline
- Resolve
SKILL_DIR,KIT_DIR, configuration, aria2 availability, andffprobe. Detect qBittorrent as an optional capability; its absence must not block discovery, transfer, resume, verification, or reporting. - On first use, bootstrap missing stable aria2 and ffprobe dependencies through the platform's native package manager. When qBittorrent is explicitly enabled, keep its WebUI on loopback and its credential in the operating system credential store. Do not put secrets in profile or reports.
- Preserve existing client tasks. Every task created by this Skill must receive a unique job tag and an isolated probe directory.
- Select
fullfor a title request,choosefor comparison only,download-knownfor supplied links,resumefor an existing job, orverifyfor a local file. - Read
$KIT_DIR/references/candidate-schema.md, then validate all handoff JSON.
Step 1: Resolve the requested work
Capture title, year, media type, season/episode when relevant, edition preference, maximum size, destination override, audio/subtitle preference, and urgency. Infer omitted values from the portable profile. Do not ask the user to choose tooling.
Step 2: Discover independent candidates
Run the discovery module. Use at least two independent discovery paths when possible:
direct web or catalog research, a local DHT index such as Rats Search, user-supplied
links, or the optional qBittorrent search API. Deduplicate by info hash and canonical release identity. Preserve
a .torrent URL or local Torrent path even when its info hash is not known until
metadata resolution.
For title and edition truth, prefer distributor, studio, disc, catalog, or reliable release metadata. Keep search-result claims separate from verified facts.
Step 3: Rank releases and resolve genuine ambiguity
Run the selection module and scripts/rank_candidates.py. Apply
$KIT_DIR/references/quality-policy.md.
- Auto-select when one candidate clearly leads and its edition is supported.
- Show at most three concise choices when the output says
choice_required=true. - Explain only the user-facing tradeoff: edition/content, picture, size, subtitles, and current health. Do not expose internal scoring mechanics unless asked.
Step 4: Preflight destination capacity
Resolve the destination, candidate size, probe concurrency, temporary probe budget,
fallback resume allowance, and free-space reserve. Run scripts/storage_preflight.py
before starting either backend.
If capacity is short, report available, required, and shortfall immediately. Offer the best smaller candidate or a different destination, then wait for that user-facing decision. Never silently consume the reserve.
Step 5: Probe, select, and download
Run the acquisition module and $KIT_DIR/references/acquisition-policy.md.
- Probe up to the configured concurrency in isolated per-candidate directories. Use aria2 by default and allocate distinct listen/RPC ports for concurrent jobs.
- Observe warm speed, sustained speed, availability, peers, metadata readiness, and ETA; a short burst alone does not win.
- Pause non-winners, move the winner to the final destination, and continue polling.
- If the winner stalls beyond the configured threshold, pause it and first try the
next proven candidate. Preserve the same aria2 job identity and
.aria2state when restarting an input. Switch to qBittorrent only when it is enabled and measured evidence shows a healthier swarm or the user needs its queue/seeding behavior. Record the reason, backend, and observed rate for each switch. - If all candidates are slow, return to discovery for another wave.
- Keep the terminal session alive and poll at intervals short enough to provide the user a progress update at least once per minute during active work.
- Clean only exact job-tagged losing tasks and their isolated probe files after the final candidate is complete. Leave pre-existing client tasks untouched.
Step 6: Verify the completed media
Run the verification module and scripts/verify_media.py.
- Confirm a readable video stream, non-zero duration, expected resolution and codec, audio tracks, subtitle streams, and duration close to the selected edition.
- Inspect every episode for episodic requests; a season folder is complete only when the requested episode set is present.
- When preferred subtitles are missing, search for a subtitle from the exact release
or a synchronized subtitle checked against duration and scene boundaries. For a
Simplified Chinese SRT handoff, preserve cue timing, UTF-8, source immutability,
and adjacent naming as described in
references/subtitle-handoff.md; do not create an English-learning gloss or ASS file unless explicitly requested. - Recheck final free space and ensure no partial suffix remains on the primary file.
Step 7: Report the result
Lead with completion status and the exact local path. Include title/edition, video and
audio summary, subtitle coverage, final size, advertised versus observed source
health, transport trace, elapsed time, and any remaining evidence gap. Distinguish
download_status, verification_status, and subtitle_status.
References
$KIT_DIR/references/candidate-schema.md— normalized candidate and decision JSON.$KIT_DIR/references/quality-policy.md— edition, picture, codec, size, language, and ambiguity rules.$KIT_DIR/references/acquisition-policy.md— capacity, probing, monitoring, switching, and cleanup rules.$KIT_DIR/references/user-config.md— portable defaults and secrets handling.$KIT_DIR/references/subtitle-handoff.md— Simplified Chinese SRT matching and the opt-in handoff tolov-subtitle-freedom-skill.
Dependencies
- Python 3.9+ for deterministic helpers.
- aria2 1.36+ for primary HTTP(S), Metalink, Magnet, and Torrent acquisition.
- Optional qBittorrent 5.x with WebUI enabled for integrated search, BT management, queue visibility, or long-term seeding.
- Search plugins or another discovery adapter for title search.
ffprobefrom FFmpeg for final stream and duration inspection.- Optional Rats Search for independent DHT discovery.
Runtime context (shared)
运行前读取本 Skill 包的 skill.yaml,由宿主提供 skill-runtime/v1 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。
- 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。
required: true字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。- 报错提供可复制的
context_id、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
Files (skills)
-
assets
-
example-candidates.json 2.8 KB
{ "schema_version": "1.0", "query": { "title": "Example Feature", "original_title": "Example Feature", "year": 2026, "media_type": "movie", "requested_editions": ["director-cut", "extended"] }, "edition_facts": [ { "edition": "director-cut", "duration_minutes": 132, "source_url": "https://example.com/example-feature/director-cut", "confidence": "primary" }, { "edition": "theatrical", "duration_minutes": 118, "source_url": "https://example.com/example-feature/theatrical", "confidence": "primary" } ], "candidates": [ { "id": "compact-4k", "name": "Example.Feature.2026.Directors.Cut.2160p.HEVC.HDR.CHS.ENG", "uri": "magnet:?xt=urn:btih:1111111111111111111111111111111111111111", "info_hash": "1111111111111111111111111111111111111111", "source": "example-index-a", "source_url": "https://example.com/result/compact-4k", "size_bytes": 12884901888, "seeders": 48, "leechers": 6, "resolution": "2160p", "video_codec": "hevc", "hdr": "hdr10", "edition": "director-cut", "duration_minutes": 132, "audio_languages": ["en", "zh"], "subtitle_languages": ["zh-Hans", "en"], "subtitle_verified": true, "metadata_confidence": "verified", "trusted_source": true }, { "id": "large-remux", "name": "Example.Feature.2026.Directors.Cut.2160p.REMUX.HEVC", "uri": "magnet:?xt=urn:btih:2222222222222222222222222222222222222222", "info_hash": "2222222222222222222222222222222222222222", "source": "example-index-b", "source_url": "https://example.com/result/large-remux", "size_bytes": 53687091200, "seeders": 16, "leechers": 2, "resolution": "2160p", "video_codec": "hevc", "edition": "director-cut", "duration_minutes": 132, "audio_languages": ["en"], "subtitle_languages": ["en"], "subtitle_verified": true, "metadata_confidence": "release-record", "trusted_source": true }, { "id": "healthy-1080p", "name": "Example.Feature.2026.Theatrical.1080p.HEVC.CHS.ENG", "uri": "magnet:?xt=urn:btih:3333333333333333333333333333333333333333", "info_hash": "3333333333333333333333333333333333333333", "source": "example-index-c", "source_url": "https://example.com/result/healthy-1080p", "size_bytes": 7516192768, "seeders": 92, "leechers": 8, "resolution": "1080p", "video_codec": "hevc", "edition": "theatrical", "duration_minutes": 118, "audio_languages": ["en"], "subtitle_languages": ["zh-Hans", "en"], "subtitle_verified": true, "metadata_confidence": "verified", "trusted_source": true } ] } -
example-qbit-results.json 708 B
{ "status": "Stopped", "total": 2, "results": [ { "fileName": "Example.Feature.2026.Directors.Cut.2160p.HEVC.CHS.ENG", "fileSize": 12884901888, "fileUrl": "magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "nbSeeders": 48, "nbLeechers": 6, "siteUrl": "example-index-a", "descrLink": "https://example.com/result/a" }, { "fileName": "Example.Feature.2026.1080p.HEVC.CHS.ENG", "fileSize": 7516192768, "fileUrl": "magnet:?xt=urn:btih:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "nbSeeders": 92, "nbLeechers": 8, "siteUrl": "example-index-b", "descrLink": "https://example.com/result/b" } ] }
-
-
cases
-
evidence
-
lotr-download.json 1.3 KB
{ "schema_version": "1.0", "case": "指环王三部曲", "observed_date": "2026-08-08", "selected_release": "The Lord of the Rings Trilogy 2002 EXTENDED REMASTERED 1080p BluRay HEVC x265 5.1 BONE", "info_hash": "AB25E785ABEA95343249A300A516455D14E54144", "payload_bytes": 13028499982, "payload_gib": 12.13, "qbit_first_probe": { "status": "needs_more_sources", "peak_bytes_per_second": 97076, "average_bytes_per_second": 36988, "median_bytes_per_second": 30976, "advertised_seeders": 19, "availability": 18.996, "bytes_received_during_probe": 6232270 }, "transport_handoff": { "from": "qBittorrent", "to": "aria2", "same_input": true, "continuation_state": ".aria2", "network_features": ["DHT", "LPD", "PeX", "configured trackers", "direct connection mode"], "reason": "The qBittorrent probe remained slow while the same swarm provided better peer discovery to aria2." }, "aria2_observation": { "completed_at": "2026-08-09T00:35:07+08:00", "late_stage_speed_range": "4-10 MiB/s", "uploaded_bytes": 10737418240, "share_ratio": 0.9, "status": "complete" }, "evidence_boundary": "Advertised seeders and first-probe health are preserved separately from the final completed payload and ffprobe report." } -
lotr-verification.json 1.2 KB
{ "schema_version": "1.0", "case": "指环王三部曲", "verification_status": "passed_with_warnings", "files": [ { "title": "The Return of the King", "duration_minutes": 263.295, "video": "1920x804 HEVC", "audio": "AAC English 6-channel", "subtitles": ["English SubRip"] }, { "title": "The Two Towers", "duration_minutes": 235.536, "video": "1920x804 HEVC", "audio": "AAC English 6-channel", "subtitles": ["English SubRip"] }, { "title": "The Fellowship of the Ring", "duration_minutes": 228.307, "video": "1920x804 HEVC", "audio": "AAC English 6-channel", "subtitles": ["English SubRip"] } ], "total_bytes": 13028499982, "errors": [], "warnings": ["preferred zh-Hans subtitle stream was absent"], "subtitle_handoff": { "status": "missing", "next_operation": "match an exact-release Simplified Chinese SRT and preserve cue timing and UTF-8 encoding", "companion_reference": "lov-subtitle-freedom-skill" }, "completion_boundary": "The three local media files were readable and complete; subtitle coverage remains explicitly open." }
-
-
cases.json 1.6 KB
[ { "title": "指环王三部曲:qBittorrent 慢源切换到 aria2 续传", "description": "用户只给出片名并偏好加长版、原声、合理体积和简中字幕;任务通过候选检索、版本排序、容量预检、qBittorrent 首轮测速、aria2 同输入续传和最终 ffprobe 验收完成。", "input": { "request": "指环王三部曲", "preferences": { "edition": "extended", "quality": "balanced", "audio": "original", "subtitles": ["zh-Hans", "en"] }, "starting_state": "qBittorrent search plugins returned several candidates; the strongest candidate had a large advertised swarm but a weak sustained first probe." }, "prompt": "$lov-media-fetch 指环王三部曲", "output": { "download_status": "complete", "release": "The Lord of the Rings Trilogy 2002 EXTENDED REMASTERED 1080p BluRay HEVC x265 5.1 BONE", "info_hash": "AB25E785ABEA95343249A300A516455D14E54144", "transport_trace": ["qBittorrent discovery", "qBittorrent first probe", "aria2 resumed same input"], "files": 3, "size_bytes": 13028499982, "verification_status": "passed_with_warnings", "media_observation": "three readable 1920x804 HEVC files with original English AAC 6-channel audio and embedded English SubRip subtitles", "subtitle_status": "missing", "open_item": "zh-Hans was absent from the verified embedded streams; the exact-release subtitle handoff remains a separate follow-up.", "evidence": [ "cases/evidence/lotr-download.json", "cases/evidence/lotr-verification.json" ] } } ]
-
-
references
-
acquisition-policy.md 4 KB
# Acquisition, Monitoring, and Recovery Policy ## Capacity calculation Before transfer, require: ```text selected payload + incomplete-file overhead (10%) + parallel probe count × probe budget + fallback continuation metadata and temporary control files + configured free-space reserve ``` Use the filesystem containing the closest existing parent of the destination. Report bytes in both GiB and human-readable form. If the destination does not exist, capacity belongs to its nearest existing parent. ## Probe policy - Default concurrency: 3. - Default duration: 180 seconds after a 60-second warm-up. - Default temporary budget: 512 MiB per candidate. - Default slow threshold: sustained speed below 1 MiB/s. - Deduplicate by info hash before adding. - Use a unique tag `media-fetch-<job-id>` and exact per-candidate directories. - Existing hashes are read-only observations; never alter or clean them. - Prefer aria2 for direct URLs, Metalinks, Magnets, and Torrent inputs. Its `.aria2` control file is evidence of continuation state, not a completed payload. - Treat qBittorrent as optional. Enable it for reviewed search plugins, queue UI, deeper swarm inspection, or long-term seeding; do not make WebUI login a prerequisite for an aria2-capable run. - Allocate distinct listen and RPC ports for concurrent aria2 probes. - Configure DHT, PeX, LSD, and a bounded tracker set. Direct connections are preferred when proxy environment variables produce a slow or incomplete swarm. Record the selected backend and connection mode in the report. A probe winner should combine sustained speed, availability, peers, progress, and ETA. Prefer stability over a single peak sample. ## Active monitoring - Poll the client every 5–15 seconds inside the worker. - Surface a user update at least every 60 seconds while the agent is running tools. - Track progress delta and received bytes, not only reported state. - Treat metadata retrieval separately from payload speed. - A candidate is stalled when both progress delta and download traffic remain below thresholds for the configured `stall_seconds` after metadata is available. ## Switching 1. Pause the stalled winner. 2. Resume the next candidate with a successful probe. 3. Recalculate storage if the next candidate is larger than the planned payload. 4. When the ranked list is exhausted, keep the best partial task paused, return to discovery, add a new wave, and probe again. 5. Record every switch and reason in the acquisition report. 6. When switching transports for the same input, preserve the isolated job directory, reuse partial data only when the backend can validate it, and never report two parallel full copies as one completed artifact. ## Cleanup Cleanup starts only after the winner reports complete and the final file path exists. - Resolve exact task hashes created in this job. - Delete losing qBittorrent tasks using exact hashes only when that backend was used. - Delete files only inside the resolved job probe directory. - Preserve the winner, final destination, report JSON, and every pre-existing task. - If path resolution falls outside the job directory, skip file cleanup and report it. ## Terminal states - `complete`: client complete and local files present; ready for media verification. - `needs_more_sources`: all tested candidates are below thresholds or unavailable. - `capacity_shortfall`: no payload transfer started. - `client_error`: connection or task API failure with copyable diagnostic details. - `cancelled`: user-requested stop; created tasks are paused and exact state is reported. ## Backend routing Run `scripts/aria2_acquire.py` first for supported inputs. Use a stable `job_id`, `--watch` for bounded restarts, `--output-name` for opaque direct URLs, and a report containing `backend_started`, `backend_restarted`, `last_snapshot`, and `final_snapshot` events. Switch to qBittorrent only after an enabled probe supplies measured evidence or the requested queue/seeding behavior requires it. The final report must separate `download_status` from the later media and subtitle verdicts. -
candidate-schema.md 3.5 KB
# Candidate and Decision Schema All discovery adapters normalize into one UTF-8 JSON document. Unknown fields should be omitted or set to `null`; do not invent zero values. ## Candidate manifest ```json { "schema_version": "1.0", "query": { "title": "Example Title", "original_title": "Example Title", "year": 2026, "media_type": "movie", "season": null, "episodes": [], "requested_editions": ["director-cut", "extended"], "title_ambiguous": false, "edition_choice_required": false }, "edition_facts": [ { "edition": "director-cut", "duration_minutes": 132, "source_url": "https://example.com/release-record", "confidence": "primary" } ], "candidates": [ { "id": "candidate-1", "name": "Example.Title.2026.Directors.Cut.2160p.HEVC", "uri": "magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567", "info_hash": "0123456789ABCDEF0123456789ABCDEF01234567", "transport_inputs": ["magnet"], "source": "adapter-name", "source_url": "https://example.com/result", "observed_at": "2026-08-08T00:00:00Z", "size_bytes": 12884901888, "seeders": 42, "leechers": 5, "resolution": "2160p", "video_codec": "hevc", "hdr": "hdr10", "edition": "director-cut", "duration_minutes": 132, "audio_languages": ["en", "zh"], "subtitle_languages": ["zh-Hans", "en"], "subtitle_verified": false, "source_health": { "advertised_seeders": 42, "observed_peers": null, "observed_at": "2026-08-08T00:00:00Z", "sustained_speed_bytes_per_second": null, "metadata_ready": false }, "metadata_confidence": "filename", "trusted_source": false } ] } ``` `source_url` may point to a description page while `uri` is the actual transfer input. Never print private tracker query tokens in the user-facing result. - `transport_inputs` may contain `magnet`, `torrent-file`, or `torrent-url`. Keep a Torrent URL or local path when `info_hash` is not available yet; fill the hash after metadata resolution instead of discarding the candidate. - `source_health.advertised_seeders` comes from discovery. `observed_peers`, `metadata_ready`, and `sustained_speed_bytes_per_second` come from a live probe and must remain distinct. ## Normalization - `edition`: `director-cut`, `extended`, `uncut`, `complete`, `theatrical`, `restored`, `regional`, or `unknown`. - `resolution`: normalized vertical resolution such as `2160p`, `1080p`, `720p`. - `video_codec`: `av1`, `hevc`, `h264`, `vp9`, or `unknown`. - Language tags: prefer BCP 47 where available; normalize common `chs`/`zh-cn` to `zh-Hans`, `cht`/`zh-tw` to `zh-Hant`, and preserve `en`. - `metadata_confidence`: `verified`, `release-record`, `filename`, or `unknown`. - `info_hash`: uppercase hexadecimal when known. Deduplicate case-insensitively. - `transport_inputs`: the inputs accepted by the acquisition layer; aria2 is the default backend, while optional qBittorrent may consume the same Torrent identity. ## Decision document `scripts/rank_candidates.py` returns: ```json { "schema_version": "1.0", "selected_id": "candidate-1", "choice_required": false, "reasons": [], "ranked": [ { "candidate": {}, "score": 118.4, "strengths": ["verified edition runtime", "efficient 2160p"], "warnings": [] } ] } ``` Acquisition must consume the complete `ranked` list so it retains tested fallbacks. -
quality-policy.md 3.3 KB
# Quality and Edition Policy The default `balanced` mode maximizes useful viewing quality under a size guardrail. ## Decision order 1. Correct title, year, season, and episode. 2. Edition identity and runtime evidence. 3. Source/master credibility and absence of obvious corruption markers. 4. Useful picture quality and efficient codec. 5. Original audio plus requested additional tracks. 6. Verified Simplified Chinese and English subtitles. 7. Current source health and sustained acquisition prospects. 8. Size and storage fit. 9. Evidence quality: advertised health is provisional; observed speed and final stream inspection decide whether the release is actually ready. ## Edition rules - A director's cut is a creative variant, not automatically a superset. - Extended, uncut, complete, theatrical, restored, and regional cuts may differ in scenes, pacing, grading, dubbing, or censorship. - Prefer the user's named edition. Without a named choice, prefer an evidenced director's/extended/uncut/complete release only when it is broadly a fuller version and does not replace a materially different creative decision. - When two cuts are both credible and differ materially, require a user choice. - Runtime within two minutes of a reliable release record is strong evidence; allow a larger tolerance only for frame-rate or logo/credits differences that are explained. ## Picture and size rules Balanced movie defaults: - Prefer credible 2160p HEVC/AV1 up to 24 GiB when it materially improves the source. - Otherwise prefer 1080p HEVC/AV1, commonly 5–14 GiB for feature films. - H.264 remains viable when source health or compatibility outweighs the size penalty. - Remux/raw-disc files lose balanced-mode rank unless explicitly requested. - A very small “4K” file receives a credibility penalty; resolution alone does not prove detail, bitrate, dynamic range, or source quality. - HDR is valuable only when the display path and release metadata support it. Dolby Vision without a compatible fallback receives a compatibility warning. For episodic content, apply the configured per-episode cap and then compute the pack's aggregate size before storage preflight. ## Audio and subtitle rules - Prefer original-language audio; additional Chinese or English audio is a bonus. - Prefer embedded `zh-Hans` and `en` subtitles. - `中字`, `双语`, `CHS`, or `ENG` in a filename is discovery evidence only. - External subtitles should match exact release timing or be checked against duration and several scene boundaries before final acceptance. - A missing `zh-Hans` stream is a warning on the media verdict until an exact-release SRT is matched and validated. Keep `subtitle_status` separate from `verification_status`. - Image-based subtitles satisfy language coverage when the target player supports them; text subtitles are more portable and searchable. ## Automatic decision boundary Require user choice when any of these remain after research: - two plausible works share the title; - top candidates represent materially different cuts and no preference resolves them; - the first two candidates are within eight ranking points but trade resolution against edition, subtitles, or more than 35% size; - the preferred edition has no runtime or release evidence; - the only high-ranked candidate exceeds the explicit cap. Otherwise auto-select and continue. -
subtitle-handoff.md 1.5 KB
# Simplified Chinese Subtitle Handoff Media Fetch owns the exact-release match and the final media report. The companion `lov-subtitle-freedom-skill` is a subtitle operation, not a release identity oracle. Use this boundary when the selected media has English subtitles but no `zh-Hans` track. ## Handoff contract 1. Match the subtitle to the exact release using file identity, runtime, frame rate, scene boundaries, or several synchronized cues. A language label alone is weak evidence. 2. Keep the original video and embedded tracks untouched. Save an external subtitle beside the media with a player-compatible name such as `TITLE.zh-Hans.srt`. 3. Preserve cue order, timestamps, and UTF-8 encoding. Validate that timestamps are monotonic, cues have text, and the subtitle duration is compatible with the media. 4. The default output is SRT. Learning glosses, character cards, and ASS styling remain opt-in operations from `lov-subtitle-freedom-skill`; they are not silently generated as a substitute for Simplified Chinese. 5. Report `subtitle_status` as `embedded`, `external_matched`, `missing`, or `generated_pending_review`. Keep it independent of the media's technical verdict. ## Evidence to retain Record the source label, release match method, cue count, encoding, duration delta, and validation result in the verification report. If an English track is extracted for a subtitle operation, use a temporary UTF-8 SRT and never overwrite the source media. -
user-config.md 3.1 KB
# User Configuration Media Fetch keeps portable preferences across runs while leaving passwords and private tokens outside the profile. ## Resolution order 1. Current request or explicit CLI flag. 2. `MEDIA_FETCH_*` and `QBITTORRENT_*` environment variables. 3. `media_fetch` in the shared profile. 4. Defaults below. 5. One focused user question only when a remaining choice changes the output. Shared profile: ```bash ${SKILL_PROFILE_PATH:-$HOME/.skill-publisher/skills/profile.json} ``` ## Defaults ```json { "media_fetch": { "config_version": 2, "output_dir": "$HOME/Downloads/Media", "quality_mode": "balanced", "max_movie_size_gib": 24, "max_episode_size_gib": 6, "reserve_free_gib": 15, "parallel_probes": 3, "probe_seconds": 180, "warmup_seconds": 60, "probe_budget_mib": 512, "slow_speed_mib_s": 1.0, "stall_seconds": 180, "max_search_waves": 3, "preferred_audio": ["original", "zh", "en"], "preferred_subtitles": ["zh-Hans", "en"], "preferred_editions": [ "director-cut", "extended", "uncut", "complete", "theatrical" ], "transport_backends": ["aria2"], "aria2_binary": "aria2c", "aria2_listen_port": 0, "aria2_max_peers": 200, "aria2_max_restarts": 2, "subtitle_repair": "match-exact-release", "qbittorrent_url": "http://127.0.0.1:8080", "qbittorrent_username": "admin" } } ``` The default size cap is a guardrail, not a target. Explicit per-request values win. Series packs use the aggregate requested episode size when that is known. Profiles without `config_version: 2` migrate the former `qbittorrent,aria2` ordering to the aria2-only default and replace the fixed aria2 listen port with automatic port selection. Explicit request and environment overrides still win. ## First-run initialization 1. Run `python3 scripts/media_config.py show`. 2. Show the resolved output directory, caps, language preferences, and probe policy. 3. If the user already requested different values, pass them as flags. 4. Run `python3 scripts/media_config.py init --write` only after the values are visible in the conversation. 5. Preserve all unrelated profile keys. ## Environment overrides ```bash export MEDIA_FETCH_OUTPUT_DIR="$HOME/Downloads/Media" export MEDIA_FETCH_MAX_MOVIE_SIZE_GIB="24" export MEDIA_FETCH_MAX_EPISODE_SIZE_GIB="6" export MEDIA_FETCH_RESERVE_FREE_GIB="15" export MEDIA_FETCH_PARALLEL_PROBES="3" export MEDIA_FETCH_SLOW_SPEED_MIB_S="1.0" export MEDIA_FETCH_TRANSPORT_BACKENDS="aria2" export MEDIA_FETCH_ARIA2_BIN="aria2c" export MEDIA_FETCH_ARIA2_LISTEN_PORT="0" export MEDIA_FETCH_ARIA2_MAX_PEERS="200" export MEDIA_FETCH_ARIA2_MAX_RESTARTS="2" export QBITTORRENT_URL="http://127.0.0.1:8080" export QBITTORRENT_USERNAME="admin" export QBITTORRENT_PASSWORD="read-from-a-secure-source" ``` Add `qbittorrent` to `MEDIA_FETCH_TRANSPORT_BACKENDS` only when its optional search, queue, swarm-inspection, or seeding capabilities are wanted for the current run. Do not place `QBITTORRENT_PASSWORD`, cookies, private tracker keys, or provider tokens in committed Skill source or the shared profile.
-
-
scripts
-
aria2_acquire.py 19.5 KB
#!/usr/bin/env python3 """Run a resumable aria2 transfer for a direct URL, Magnet, or Torrent input.""" from __future__ import annotations import argparse import hashlib import json import os import shutil import signal import socket import subprocess import sys import time import urllib.request from urllib.parse import urlparse from datetime import datetime, timezone from pathlib import Path from typing import Any VIDEO_SUFFIXES = {".mkv", ".mp4", ".m4v", ".mov", ".ts", ".m2ts", ".webm", ".avi"} DEFAULT_TRACKERS = ( "udp://tracker.opentrackr.org:1337/announce", "udp://open.stealth.si:80/announce", "udp://tracker.torrent.eu.org:451/announce", "udp://exodus.desync.com:6969/announce", "udp://tracker.cyberia.is:6969/announce", "udp://tracker.moeking.me:6969/announce", "udp://tracker1.bt.moack.co.kr:80/announce", "udp://tracker.tiny-vps.com:6969/announce", "http://tracker.openbittorrent.com:80/announce", "udp://tracker.openbittorrent.com:6969/announce", "udp://tracker.publictracker.xyz:6969/announce", "udp://tracker.dler.org:6969/announce", ) def now() -> str: return datetime.now(timezone.utc).isoformat() def media_files(root: Path) -> list[Path]: files: list[Path] = [] for item in root.rglob("*"): if not item.is_file() or item.suffix.lower() not in VIDEO_SUFFIXES: continue try: item.stat() except FileNotFoundError: continue files.append(item) return sorted(files, key=lambda item: item.stat().st_size, reverse=True) def control_files(root: Path) -> list[Path]: return sorted(item for item in root.rglob("*.aria2") if item.is_file()) def without_proxy(environment: dict[str, str]) -> dict[str, str]: return {key: value for key, value in environment.items() if "proxy" not in key.lower()} def load_trackers(args: argparse.Namespace) -> list[str]: values = list(DEFAULT_TRACKERS) for tracker_file in args.tracker_file: path = Path(tracker_file).expanduser() values.extend( line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") ) values.extend(args.tracker) return list(dict.fromkeys(values)) def resolve_binary(value: str | None) -> str: candidate = value or os.environ.get("MEDIA_FETCH_ARIA2_BIN") or "aria2c" expanded = str(Path(candidate).expanduser()) resolved = shutil.which(expanded) or (expanded if Path(expanded).is_file() else None) if not resolved: raise SystemExit("ERROR: aria2c is required for the primary transfer backend") return resolved def classify_input(value: str) -> str: lowered = value.lower() if lowered.startswith("magnet:"): return "bittorrent" parsed = urlparse(value) path = parsed.path if parsed.scheme else value if path.lower().endswith(".torrent"): return "bittorrent" return "direct" def validate_output_name(value: str | None) -> str | None: if value is None: return None name = value.strip() if not name or Path(name).name != name or name in {".", ".."}: raise SystemExit("ERROR: --output-name must be a plain file name") return name def port_is_available(port: int, *, udp: bool) -> bool: socket_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM with socket.socket(socket.AF_INET, socket_type) as handle: try: handle.bind(("127.0.0.1", port)) except OSError: return False return True def find_port(seed: int, *, require_udp: bool, excluded: set[int]) -> int: for offset in range(20000): port = 40000 + ((seed - 40000 + offset) % 20000) if port in excluded: continue if port_is_available(port, udp=False) and ( not require_udp or port_is_available(port, udp=True) ): return port raise SystemExit("ERROR: no available local port found for aria2") def resolve_ports( job_id: str, input_kind: str, requested_listen: int, requested_rpc: int | None, ) -> tuple[int | None, int]: seed = 40000 + int(hashlib.sha1(job_id.encode("utf-8")).hexdigest()[:8], 16) % 20000 listen_port: int | None = None if input_kind == "bittorrent": if requested_listen > 0: listen_port = requested_listen else: listen_port = find_port(seed, require_udp=True, excluded=set()) if requested_rpc and requested_rpc > 0: rpc_port = requested_rpc else: rpc_port = find_port( seed + 1, require_udp=False, excluded={listen_port} if listen_port else set(), ) return listen_port, rpc_port def build_command( args: argparse.Namespace, probe_root: Path, binary: str, trackers: list[str], input_kind: str, listen_port: int | None, rpc_port: int, ) -> list[str]: command = [ binary, f"--dir={probe_root}", "--file-allocation=none", "--continue=true", "--enable-rpc=true", "--rpc-listen-all=false", f"--rpc-listen-port={rpc_port}", "--max-connection-per-server=16", "--split=16", "--min-split-size=1M", "--max-tries=0", "--retry-wait=2", "--connect-timeout=15", "--timeout=60", f"--summary-interval={args.summary_interval}", "--console-log-level=notice", "--auto-file-renaming=false", ] if args.output_name: command.append(f"--out={args.output_name}") if input_kind == "bittorrent": if listen_port is None: raise ValueError("BitTorrent input requires a listen port") command.extend( [ "--seed-time=0", "--enable-dht=true", "--bt-enable-lpd=true", "--enable-peer-exchange=true", f"--dht-listen-port={listen_port}", f"--listen-port={listen_port}", "--bt-tracker-connect-timeout=15", "--bt-tracker-interval=30", f"--bt-max-peers={args.max_peers}", "--bt-request-peer-speed-limit=1M", f"--bt-tracker={','.join(trackers)}", ] ) command.append(args.input) return command def rpc_request(rpc_port: int, method: str, params: list[Any]) -> Any: payload = json.dumps( { "jsonrpc": "2.0", "id": "media-fetch", "method": method, "params": params, } ).encode("utf-8") request = urllib.request.Request( f"http://127.0.0.1:{rpc_port}/jsonrpc", data=payload, headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(request, timeout=5) as response: document = json.loads(response.read().decode("utf-8")) except (OSError, ValueError, TypeError, json.JSONDecodeError): return None return document.get("result") if isinstance(document, dict) else None def rpc_transfer(rpc_port: int) -> dict[str, Any] | None: keys = ["status", "completedLength", "totalLength", "downloadSpeed"] tasks = rpc_request(rpc_port, "aria2.tellActive", [keys]) source = "active" if not isinstance(tasks, list) or not tasks: tasks = rpc_request(rpc_port, "aria2.tellStopped", [0, 10, keys]) source = "stopped" if not isinstance(tasks, list) or not tasks: return None statuses = [str(item.get("status") or "") for item in tasks] return { "completed_bytes": sum(int(item.get("completedLength") or 0) for item in tasks), "total_bytes": sum(int(item.get("totalLength") or 0) for item in tasks), "download_speed_bytes_per_second": sum( int(item.get("downloadSpeed") or 0) for item in tasks ), "rpc_source": source, "rpc_statuses": statuses, "rpc_complete": source == "stopped" and all(status == "complete" for status in statuses), } def snapshot(probe_root: Path) -> dict[str, Any]: files = media_files(probe_root) stats = [item.stat() for item in files] return { "media_files": len(files), "media_bytes_logical": sum(info.st_size for info in stats), "media_bytes_allocated": sum( info.st_size if getattr(info, "st_blocks", None) is None else int(info.st_blocks) * 512 for info in stats ), "control_files": [str(item.relative_to(probe_root)) for item in control_files(probe_root)], } def write_result(path: Path, result: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--input", required=True, help="HTTP(S) URL, Magnet URI, local .torrent, or .torrent URL", ) parser.add_argument( "--output-name", help="Plain output file name for a direct URL whose path has no useful media suffix", ) parser.add_argument("--output-dir", type=Path, default=Path.home() / "Downloads/Media") parser.add_argument("--result", required=True, type=Path) parser.add_argument("--job-id", required=True) parser.add_argument("--aria2-bin") parser.add_argument( "--listen-port", type=int, default=0, help="BitTorrent listen port; 0 selects a job-specific available port", ) parser.add_argument("--rpc-listen-port", type=int) parser.add_argument("--max-peers", type=int, default=200) parser.add_argument("--summary-interval", type=int, default=15) parser.add_argument("--poll-seconds", type=int, default=15) parser.add_argument("--stall-seconds", type=int, default=180) parser.add_argument("--slow-speed-mib-s", type=float, default=1.0) parser.add_argument("--max-runtime-hours", type=float, default=48.0) parser.add_argument("--max-restarts", type=int, default=2) parser.add_argument("--restart-delay-seconds", type=int, default=15) parser.add_argument("--tracker", action="append", default=[]) parser.add_argument("--tracker-file", action="append", default=[]) parser.add_argument("--no-proxy", action="store_true") parser.add_argument("--watch", action="store_true", help="Restart a stalled aria2 process while its .aria2 state remains") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() args.output_name = validate_output_name(args.output_name) output_dir = args.output_dir.expanduser().resolve(strict=False) job_id = "".join( character if character.isalnum() or character in "-_." else "_" for character in args.job_id ).strip("._") or "media-job" probe_root = output_dir / ".media-fetch-probes" / job_id probe_root.mkdir(parents=True, exist_ok=True) log_path = probe_root / "aria2.log" binary = resolve_binary(args.aria2_bin) trackers = load_trackers(args) input_kind = classify_input(args.input) listen_port, rpc_port = resolve_ports( job_id, input_kind, args.listen_port, args.rpc_listen_port, ) command = build_command( args, probe_root, binary, trackers, input_kind, listen_port, rpc_port, ) result: dict[str, Any] = { "schema_version": "1.0", "job_id": job_id, "backend": "aria2", "input_kind": input_kind, "input": args.input, "destination": str(output_dir), "probe_root": str(probe_root), "log_path": str(log_path), "started_at": now(), "status": "planned" if args.dry_run else "running", "trackers_count": len(trackers) if input_kind == "bittorrent" else 0, "listen_port": listen_port, "rpc_port": rpc_port, "events": [], } if args.dry_run: result["command"] = command result["environment_mode"] = "direct" if args.no_proxy else "inherited" result["download_status"] = result["status"] write_result(args.result, result) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 environment = without_proxy(dict(os.environ)) if args.no_proxy else dict(os.environ) deadline = time.monotonic() + max(0.1, args.max_runtime_hours) * 3600 restarts = 0 process: subprocess.Popen[Any] | None = None try: while time.monotonic() < deadline: stalled = False stall_reason = "" completed_via_rpc = False started_monotonic = time.monotonic() last_progress_monotonic = started_monotonic last_completed_bytes = 0 with log_path.open("a", encoding="utf-8") as log: log.write(f"\n[{now()}] starting aria2: {' '.join(command)}\n") log.flush() process = subprocess.Popen( command, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, env=environment, ) result["events"].append({"at": now(), "type": "backend_started", "pid": process.pid}) write_result(args.result, result) while process.poll() is None and time.monotonic() < deadline: state = snapshot(probe_root) sample_monotonic = time.monotonic() transfer = rpc_transfer(rpc_port) or { "completed_bytes": last_completed_bytes, "total_bytes": 0, "download_speed_bytes_per_second": 0, "rpc_source": "unavailable", "rpc_statuses": [], "rpc_complete": False, } completed_bytes = int(transfer["completed_bytes"]) delta_bytes = max(0, completed_bytes - last_completed_bytes) observed_speed = int(transfer["download_speed_bytes_per_second"]) if delta_bytes > 0: last_progress_monotonic = sample_monotonic last_completed_bytes = completed_bytes print( json.dumps( { "event": "aria2_progress", "elapsed_seconds": int(max(0, sample_monotonic - started_monotonic)), "completed_bytes": completed_bytes, "total_bytes": int(transfer["total_bytes"]), "observed_speed_bytes_per_second": observed_speed, "rpc_source": transfer["rpc_source"], "rpc_statuses": transfer["rpc_statuses"], **state, }, ensure_ascii=False, ), flush=True, ) has_payload = state["media_files"] > 0 if ( bool(transfer["rpc_complete"]) and has_payload and not state["control_files"] ): completed_via_rpc = True result["events"].append( { "at": now(), "type": "backend_completed", "completed_bytes": completed_bytes, } ) process.send_signal(signal.SIGTERM) process.wait(timeout=30) break stalled_for = sample_monotonic - last_progress_monotonic if ( args.watch and has_payload and stalled_for >= max(1, args.stall_seconds) and observed_speed < max(0.0, args.slow_speed_mib_s) * 1024 * 1024 ): stalled = True stall_reason = ( f"no payload growth for {int(stalled_for)}s; " f"observed speed {observed_speed} B/s" ) result["events"].append( {"at": now(), "type": "backend_stalled", "reason": stall_reason} ) write_result(args.result, result) process.send_signal(signal.SIGTERM) process.wait(timeout=30) break time.sleep(max(1, args.poll_seconds)) if process.poll() is None: process.send_signal(signal.SIGTERM) process.wait(timeout=30) result["status"] = "runtime_limit" break if completed_via_rpc: result["status"] = "complete" result["completed_at"] = now() result["last_snapshot"] = snapshot(probe_root) break if stalled: state = snapshot(probe_root) if restarts >= max(0, args.max_restarts): result["status"] = "needs_more_sources" result["last_snapshot"] = state break restarts += 1 result["events"].append( { "at": now(), "type": "backend_restarted", "restart": restarts, "reason": stall_reason, } ) write_result(args.result, result) time.sleep(max(1, args.restart_delay_seconds)) continue return_code = int(process.returncode or 0) state = snapshot(probe_root) result["last_exit_code"] = return_code result["last_snapshot"] = state if return_code == 0 and state["media_files"] > 0 and not state["control_files"]: result["status"] = "complete" result["completed_at"] = now() break if not args.watch or restarts >= max(0, args.max_restarts): result["status"] = "needs_more_sources" if state["control_files"] else "client_error" break restarts += 1 result["events"].append({"at": now(), "type": "backend_restarted", "restart": restarts, "exit_code": return_code}) write_result(args.result, result) time.sleep(max(1, args.restart_delay_seconds)) except KeyboardInterrupt: if process and process.poll() is None: process.send_signal(signal.SIGTERM) process.wait(timeout=30) result["status"] = "cancelled" finally: result["finished_at"] = now() result["restarts"] = restarts result["final_snapshot"] = snapshot(probe_root) result["download_status"] = result["status"] write_result(args.result, result) print(json.dumps({"status": result["status"], "result": str(args.result)}, ensure_ascii=False, indent=2)) return 0 if result["status"] == "complete" else 3 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, RuntimeError, ValueError, TypeError, json.JSONDecodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) from exc -
media_config.py 6.8 KB
#!/usr/bin/env python3 """Resolve or initialize portable Media Fetch preferences.""" from __future__ import annotations import argparse import json import os import sys from copy import deepcopy from pathlib import Path from typing import Any DEFAULTS: dict[str, Any] = { "config_version": 2, "output_dir": "$HOME/Downloads/Media", "quality_mode": "balanced", "max_movie_size_gib": 24.0, "max_episode_size_gib": 6.0, "reserve_free_gib": 15.0, "parallel_probes": 3, "probe_seconds": 180, "warmup_seconds": 60, "probe_budget_mib": 512, "slow_speed_mib_s": 1.0, "stall_seconds": 180, "max_search_waves": 3, "preferred_audio": ["original", "zh", "en"], "preferred_subtitles": ["zh-Hans", "en"], "preferred_editions": [ "director-cut", "extended", "uncut", "complete", "theatrical", ], "transport_backends": ["aria2"], "aria2_binary": "aria2c", "aria2_listen_port": 0, "aria2_max_peers": 200, "aria2_max_restarts": 2, "subtitle_repair": "match-exact-release", "qbittorrent_url": "http://127.0.0.1:8080", "qbittorrent_username": "admin", } ENV_MAP = { "output_dir": "MEDIA_FETCH_OUTPUT_DIR", "max_movie_size_gib": "MEDIA_FETCH_MAX_MOVIE_SIZE_GIB", "max_episode_size_gib": "MEDIA_FETCH_MAX_EPISODE_SIZE_GIB", "reserve_free_gib": "MEDIA_FETCH_RESERVE_FREE_GIB", "parallel_probes": "MEDIA_FETCH_PARALLEL_PROBES", "slow_speed_mib_s": "MEDIA_FETCH_SLOW_SPEED_MIB_S", "transport_backends": "MEDIA_FETCH_TRANSPORT_BACKENDS", "aria2_binary": "MEDIA_FETCH_ARIA2_BIN", "aria2_listen_port": "MEDIA_FETCH_ARIA2_LISTEN_PORT", "aria2_max_peers": "MEDIA_FETCH_ARIA2_MAX_PEERS", "aria2_max_restarts": "MEDIA_FETCH_ARIA2_MAX_RESTARTS", "qbittorrent_url": "QBITTORRENT_URL", "qbittorrent_username": "QBITTORRENT_USERNAME", } def profile_path() -> Path: value = os.environ.get( "SKILL_PROFILE_PATH", str(Path.home() / ".skill-publisher/skills/profile.json") ) return Path(os.path.expandvars(value)).expanduser() def load_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise SystemExit(f"ERROR: invalid JSON in {path}: {exc}") from exc if not isinstance(data, dict): raise SystemExit(f"ERROR: profile root must be an object: {path}") return data def coerce(value: str, template: Any) -> Any: if isinstance(template, bool): return value.lower() in {"1", "true", "yes", "on"} if isinstance(template, int): return int(value) if isinstance(template, float): return float(value) if isinstance(template, list): return [item.strip() for item in value.split(",") if item.strip()] return value def expand_value(key: str, value: Any) -> Any: if key.endswith("_dir") and isinstance(value, str): return str(Path(os.path.expandvars(value)).expanduser()) return value def resolve(profile: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: result = deepcopy(DEFAULTS) saved = profile.get("media_fetch") if isinstance(saved, dict): saved_values = {key: value for key, value in saved.items() if key in DEFAULTS} if int(saved_values.get("config_version") or 1) < 2: if saved_values.get("transport_backends") == ["qbittorrent", "aria2"]: saved_values["transport_backends"] = ["aria2"] if saved_values.get("aria2_listen_port") == 53555: saved_values["aria2_listen_port"] = 0 saved_values["config_version"] = 2 result.update(saved_values) for key, env_name in ENV_MAP.items(): if env_name in os.environ: result[key] = coerce(os.environ[env_name], DEFAULTS[key]) for key, value in overrides.items(): if value is None: continue if key in DEFAULTS: value = coerce(value, DEFAULTS[key]) if isinstance(value, str) else value result[key] = value return {key: expand_value(key, value) for key, value in result.items()} def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser(description=__doc__) sub = root.add_subparsers(dest="command", required=True) for name in ("show", "init"): cmd = sub.add_parser(name) cmd.add_argument("--output-dir") cmd.add_argument("--max-movie-size-gib", type=float) cmd.add_argument("--max-episode-size-gib", type=float) cmd.add_argument("--reserve-free-gib", type=float) cmd.add_argument("--parallel-probes", type=int) cmd.add_argument("--slow-speed-mib-s", type=float) cmd.add_argument("--transport-backends") cmd.add_argument("--aria2-binary") cmd.add_argument("--aria2-listen-port", type=int) cmd.add_argument("--aria2-max-peers", type=int) cmd.add_argument("--aria2-max-restarts", type=int) cmd.add_argument("--json", action="store_true") if name == "init": cmd.add_argument("--write", action="store_true") return root def main() -> int: args = parser().parse_args() path = profile_path() profile = load_profile(path) overrides = { "output_dir": args.output_dir, "max_movie_size_gib": args.max_movie_size_gib, "max_episode_size_gib": args.max_episode_size_gib, "reserve_free_gib": args.reserve_free_gib, "parallel_probes": args.parallel_probes, "slow_speed_mib_s": args.slow_speed_mib_s, "transport_backends": args.transport_backends, "aria2_binary": args.aria2_binary, "aria2_listen_port": args.aria2_listen_port, "aria2_max_peers": args.aria2_max_peers, "aria2_max_restarts": args.aria2_max_restarts, } resolved = resolve(profile, overrides) wrote = False if args.command == "init" and args.write: path.parent.mkdir(parents=True, exist_ok=True) profile["media_fetch"] = resolved path.write_text( json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) wrote = True payload = {"profile_path": str(path), "resolved": resolved, "written": wrote} if args.json: print(json.dumps(payload, ensure_ascii=False, indent=2)) else: print(f"profile={path}") print(f"written={'yes' if wrote else 'no'}") for key, value in resolved.items(): print(f"{key}={json.dumps(value, ensure_ascii=False)}") if args.command == "init" and not args.write: print("next=review these values, then rerun with --write") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, ValueError) as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) from exc -
qbittorrent_acquire.py 18 KB
#!/usr/bin/env python3 """Probe ranked Magnet candidates, monitor the winner, and recover from stalls.""" from __future__ import annotations import argparse import base64 import http.cookiejar import json import os import re import statistics import sys import time import urllib.error import urllib.parse import urllib.request import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any MIB = 1024**2 COMPLETE_STATES = {"uploading", "stalledup", "pausedup", "stoppedup", "forcedup"} class QBitClient: def __init__(self, base_url: str, username: str, password: str) -> None: self.base_url = base_url.rstrip("/") jar = http.cookiejar.CookieJar() self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) response = self.request( "/api/v2/auth/login", {"username": username, "password": password}, raw=True ) if response.strip() not in {"", "Ok."}: raise RuntimeError(f"qBittorrent login failed: {response[:120]}") def request( self, path: str, data: dict[str, Any] | None = None, raw: bool = False ) -> Any: encoded = urllib.parse.urlencode(data).encode("utf-8") if data is not None else None request = urllib.request.Request(self.base_url + path, data=encoded) request.add_header("Referer", self.base_url) try: with self.opener.open(request, timeout=30) as response: text = response.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"qBittorrent HTTP {exc.code} for {path}: {detail[:300]}") from exc if raw: return text return json.loads(text) if text else {} def torrents(self) -> list[dict[str, Any]]: payload = self.request("/api/v2/torrents/info") return payload if isinstance(payload, list) else [] def add(self, uri: str, save_path: Path, tag: str) -> None: self.request( "/api/v2/torrents/add", { "urls": uri, "savepath": str(save_path), "tags": tag, "stopped": "false", "paused": "false", }, raw=True, ) def action(self, action: str, hashes: list[str]) -> None: if not hashes: return joined = "|".join(hashes) paths = [f"/api/v2/torrents/{action}"] if action == "start": paths.append("/api/v2/torrents/resume") elif action == "stop": paths.append("/api/v2/torrents/pause") last_error: RuntimeError | None = None for path in paths: try: self.request(path, {"hashes": joined}, raw=True) return except RuntimeError as exc: last_error = exc if last_error: raise last_error def set_location(self, hash_value: str, location: Path) -> None: self.request( "/api/v2/torrents/setLocation", {"hashes": hash_value, "location": str(location)}, raw=True, ) def delete(self, hash_value: str, delete_files: bool) -> None: self.request( "/api/v2/torrents/delete", {"hashes": hash_value, "deleteFiles": str(delete_files).lower()}, raw=True, ) def now() -> str: return datetime.now(timezone.utc).isoformat() def parse_info_hash(uri: str) -> str | None: match = re.search(r"(?:[?&]xt=urn:btih:)([A-Za-z0-9]+)", uri, re.I) if not match: return None value = match.group(1) if re.fullmatch(r"[0-9A-Fa-f]{40}", value): return value.lower() if re.fullmatch(r"[A-Z2-7]{32}", value.upper()): return base64.b32decode(value.upper()).hex().lower() return None def load_decision(path: Path) -> dict[str, Any]: try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise SystemExit(f"ERROR: cannot read decision JSON {path}: {exc}") from exc if not isinstance(data, dict) or not isinstance(data.get("ranked"), list): raise SystemExit("ERROR: decision document must contain a ranked array") return data def controlled_candidates( decision: dict[str, Any], selected_id: str | None, limit: int ) -> list[dict[str, Any]]: selected = selected_id or decision.get("selected_id") if decision.get("choice_required") and not selected_id: reasons = "; ".join(str(value) for value in decision.get("reasons") or []) raise SystemExit(f"ERROR: choice_required; provide --selected-id ({reasons})") candidates: list[dict[str, Any]] = [] for row in decision["ranked"]: candidate = row.get("candidate") if isinstance(row, dict) else None if not isinstance(candidate, dict): continue uri = str(candidate.get("uri") or "") hash_value = str(candidate.get("info_hash") or "").lower() or parse_info_hash(uri) if not uri.startswith("magnet:") or not hash_value: continue item = dict(candidate) item["info_hash"] = hash_value cap_gib = float(decision.get("max_size_gib") or 0) size_bytes = int(item.get("size_bytes") or 0) if cap_gib and size_bytes > cap_gib * 1024**3 and item.get("id") != selected: continue candidates.append(item) candidates.sort(key=lambda item: item.get("id") != selected) return candidates[: max(1, limit)] def is_within(path_value: str, parent: Path) -> bool: try: Path(path_value).expanduser().resolve(strict=False).relative_to(parent.resolve(strict=False)) return True except ValueError: return False def task_map(client: QBitClient, hashes: set[str]) -> dict[str, dict[str, Any]]: return { str(task.get("hash", "")).lower(): task for task in client.torrents() if str(task.get("hash", "")).lower() in hashes } def write_result(path: Path, result: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--decision", required=True, type=Path) parser.add_argument("--selected-id") parser.add_argument("--output-dir", type=Path, default=Path.home() / "Downloads/Media") parser.add_argument("--result", required=True, type=Path) parser.add_argument("--job-id") parser.add_argument("--parallel", type=int, default=3) parser.add_argument("--probe-seconds", type=int, default=180) parser.add_argument("--warmup-seconds", type=int, default=60) parser.add_argument("--probe-budget-mib", type=int, default=512) parser.add_argument("--poll-seconds", type=int, default=10) parser.add_argument("--slow-speed-mib-s", type=float, default=1.0) parser.add_argument("--stall-seconds", type=int, default=180) parser.add_argument("--wait-complete", action="store_true") parser.add_argument("--max-runtime-hours", type=float, default=48.0) parser.add_argument("--cleanup-losers", action="store_true") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() decision = load_decision(args.decision) candidates = controlled_candidates(decision, args.selected_id, args.parallel) if not candidates: raise SystemExit("ERROR: no probeable Magnet candidates") output_dir = args.output_dir.expanduser().resolve(strict=False) job_id = args.job_id or uuid.uuid4().hex[:12] tag = f"media-fetch-{job_id}" probe_root = output_dir / ".media-fetch-probes" / job_id result: dict[str, Any] = { "schema_version": "1.0", "job_id": job_id, "tag": tag, "started_at": now(), "status": "planned" if args.dry_run else "probing", "destination": str(output_dir), "probe_root": str(probe_root), "candidate_ids": [item.get("id") for item in candidates], "events": [], } if args.dry_run: result["selected_id"] = candidates[0].get("id") result["planned_hashes"] = [item["info_hash"] for item in candidates] write_result(args.result, result) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 password = os.environ.get("QBITTORRENT_PASSWORD", "") if not password: raise SystemExit("ERROR: QBITTORRENT_PASSWORD is required") client = QBitClient( os.environ.get("QBITTORRENT_URL", "http://127.0.0.1:8080"), os.environ.get("QBITTORRENT_USERNAME", "admin"), password, ) output_dir.mkdir(parents=True, exist_ok=True) probe_root.mkdir(parents=True, exist_ok=False) hashes = {item["info_hash"] for item in candidates} preexisting = set(task_map(client, hashes)) created: list[dict[str, Any]] = [] for candidate in candidates: hash_value = candidate["info_hash"] if hash_value in preexisting: result["events"].append( {"at": now(), "type": "preexisting_skipped", "hash": hash_value, "candidate_id": candidate.get("id")} ) continue candidate_dir = probe_root / str(candidate.get("id")) candidate_dir.mkdir(parents=True, exist_ok=False) client.add(str(candidate["uri"]), candidate_dir, tag) created.append(candidate) result["events"].append( {"at": now(), "type": "added", "hash": hash_value, "candidate_id": candidate.get("id"), "path": str(candidate_dir)} ) if not created: result["status"] = "client_error" result["error"] = "all candidate hashes already exist; existing tasks were left unchanged" write_result(args.result, result) print(json.dumps(result, ensure_ascii=False, indent=2)) return 4 created_hashes = {item["info_hash"] for item in created} ready_deadline = time.monotonic() + 60 while time.monotonic() < ready_deadline: if created_hashes.issubset(task_map(client, created_hashes)): break time.sleep(2) metrics: dict[str, dict[str, Any]] = { item["info_hash"]: { "speeds": [], "warm_speeds": [], "peak_speed": 0, "probe_capped": False, "candidate": item, } for item in created } probe_started = time.monotonic() probe_end = probe_started + max(args.probe_seconds, args.warmup_seconds + 1) while time.monotonic() < probe_end: current = task_map(client, created_hashes) elapsed = time.monotonic() - probe_started for hash_value, metric in metrics.items(): task = current.get(hash_value, {}) speed = int(task.get("dlspeed") or 0) metric["peak_speed"] = max(metric["peak_speed"], speed) metric["availability"] = float(task.get("availability") or 0) metric["num_seeds"] = int(task.get("num_seeds") or 0) metric["progress"] = float(task.get("progress") or 0) metric["state"] = str(task.get("state") or "missing") downloaded = int(task.get("downloaded") or task.get("total_downloaded") or 0) metric["downloaded_bytes"] = downloaded if not metric["probe_capped"] and elapsed < args.warmup_seconds: metric["warm_speeds"].append(speed) elif not metric["probe_capped"]: metric["speeds"].append(speed) if ( not metric["probe_capped"] and downloaded >= max(1, args.probe_budget_mib) * MIB ): client.action("stop", [hash_value]) metric["probe_capped"] = True if int(elapsed) % 30 < max(1, args.poll_seconds): print(json.dumps({"event": "probe_progress", "elapsed_seconds": int(elapsed), "tasks": {key: {"speed_mib_s": round((value["speeds"][-1] if value["speeds"] else 0) / MIB, 2), "progress": round(float(value.get("progress", 0)) * 100, 2)} for key, value in metrics.items()}}, ensure_ascii=False), flush=True) time.sleep(max(1, args.poll_seconds)) ranked_probe: list[dict[str, Any]] = [] for hash_value, metric in metrics.items(): speeds = metric.pop("speeds") or metric.pop("warm_speeds") metric.pop("warm_speeds", None) metric["average_speed"] = int(statistics.fmean(speeds)) if speeds else 0 metric["median_speed"] = int(statistics.median(speeds)) if speeds else 0 metric["hash"] = hash_value metric["candidate_id"] = metric["candidate"].get("id") ranked_probe.append(metric) ranked_probe.sort( key=lambda item: ( item["median_speed"], item["average_speed"], float(item.get("availability", 0)), int(item.get("num_seeds", 0)), ), reverse=True, ) result["probe_results"] = [ {key: value for key, value in item.items() if key != "candidate"} for item in ranked_probe ] threshold = int(max(0.0, args.slow_speed_mib_s) * MIB) if not ranked_probe or ranked_probe[0]["median_speed"] < threshold: client.action("stop", list(created_hashes)) result["status"] = "needs_more_sources" result["finished_at"] = now() write_result(args.result, result) print(json.dumps({"status": result["status"], "result": str(args.result)}, ensure_ascii=False, indent=2)) return 3 order = [item["hash"] for item in ranked_probe] client.action("stop", order) active_index = 0 active_hash = order[active_index] client.action("start", [active_hash]) result["selected_hash"] = active_hash result["selected_id"] = metrics[active_hash]["candidate"].get("id") result["status"] = "downloading" result["events"].append({"at": now(), "type": "winner_started", "hash": active_hash}) write_result(args.result, result) if not args.wait_complete: print(json.dumps({"status": "downloading", "selected_id": result["selected_id"], "result": str(args.result)}, ensure_ascii=False, indent=2)) return 0 runtime_deadline = time.monotonic() + max(0.1, args.max_runtime_hours) * 3600 last_progress = 0.0 last_activity = time.monotonic() last_report = 0.0 winner_task: dict[str, Any] = {} while time.monotonic() < runtime_deadline: current = task_map(client, created_hashes) task = current.get(active_hash, {}) winner_task = task progress = float(task.get("progress") or 0) speed = int(task.get("dlspeed") or 0) state = str(task.get("state") or "").lower() if progress > last_progress + 0.000001 or speed >= threshold: last_activity = time.monotonic() last_progress = max(last_progress, progress) if progress >= 0.999999 or state in COMPLETE_STATES: result["status"] = "complete" break if time.monotonic() - last_activity >= max(30, args.stall_seconds): if active_index + 1 >= len(order): result["status"] = "needs_more_sources" result["events"].append({"at": now(), "type": "all_candidates_stalled", "hash": active_hash}) client.action("stop", [active_hash]) break client.action("stop", [active_hash]) previous = active_hash active_index += 1 active_hash = order[active_index] client.action("start", [active_hash]) last_progress = 0.0 last_activity = time.monotonic() result["events"].append({"at": now(), "type": "switched_after_stall", "from": previous, "to": active_hash}) result["selected_hash"] = active_hash result["selected_id"] = metrics[active_hash]["candidate"].get("id") write_result(args.result, result) if time.monotonic() - last_report >= 60: print(json.dumps({"event": "download_progress", "candidate_id": result["selected_id"], "progress_percent": round(progress * 100, 2), "speed_mib_s": round(speed / MIB, 2), "state": state, "eta_seconds": int(task.get("eta") or 0)}, ensure_ascii=False), flush=True) last_report = time.monotonic() time.sleep(max(1, args.poll_seconds)) else: result["status"] = "runtime_limit" if result["status"] == "complete": client.set_location(active_hash, output_dir) move_deadline = time.monotonic() + 120 final_path = "" while time.monotonic() < move_deadline: moved = task_map(client, {active_hash}).get(active_hash, {}) candidate_path = str(moved.get("content_path") or "") if candidate_path and is_within(candidate_path, output_dir) and Path(candidate_path).exists(): final_path = candidate_path break time.sleep(2) if not final_path: result["status"] = "client_error" result["error"] = "download completed but final relocation was not confirmed within 120 seconds" else: result["final_path"] = final_path result["completed_at"] = now() if args.cleanup_losers: for hash_value in order: if hash_value == active_hash: continue task = task_map(client, {hash_value}).get(hash_value, {}) safe = is_within(str(task.get("save_path") or ""), probe_root) client.delete(hash_value, delete_files=safe) result["events"].append({"at": now(), "type": "loser_removed", "hash": hash_value, "files_deleted": safe}) result["finished_at"] = now() write_result(args.result, result) print(json.dumps({"status": result["status"], "selected_id": result.get("selected_id"), "result": str(args.result)}, ensure_ascii=False, indent=2)) return 0 if result["status"] == "complete" else 3 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, RuntimeError, ValueError, TypeError, json.JSONDecodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) from exc -
qbittorrent_search.py 7.5 KB
#!/usr/bin/env python3 """Search enabled qBittorrent plugins and emit normalized Media Fetch candidates.""" from __future__ import annotations import argparse import base64 import http.cookiejar import json import os import re import sys import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone from pathlib import Path from typing import Any class QBitClient: def __init__(self, base_url: str, username: str, password: str) -> None: self.base_url = base_url.rstrip("/") jar = http.cookiejar.CookieJar() self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) payload = self.request( "/api/v2/auth/login", {"username": username, "password": password}, raw=True ) if payload.strip() not in {"", "Ok."}: raise RuntimeError(f"qBittorrent login failed: {payload[:120]}") def request( self, path: str, data: dict[str, Any] | None = None, raw: bool = False ) -> Any: encoded = None if data is not None: encoded = urllib.parse.urlencode(data).encode("utf-8") request = urllib.request.Request(self.base_url + path, data=encoded) request.add_header("Referer", self.base_url) try: with self.opener.open(request, timeout=20) as response: text = response.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"qBittorrent HTTP {exc.code} for {path}: {detail[:300]}") from exc if raw: return text return json.loads(text) if text else {} def info_hash(uri: str) -> str | None: match = re.search(r"(?:[?&]xt=urn:btih:)([A-Za-z0-9]+)", uri, re.I) if not match: return None value = match.group(1) if re.fullmatch(r"[0-9A-Fa-f]{40}", value): return value.upper() if re.fullmatch(r"[A-Z2-7]{32}", value.upper()): return base64.b32decode(value.upper()).hex().upper() return value.upper() def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--query", required=True) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--plugins", default="all") parser.add_argument("--category", default="all") parser.add_argument("--timeout", type=int, default=90) parser.add_argument("--limit", type=int, default=200) parser.add_argument("--title") parser.add_argument("--year", type=int) parser.add_argument("--media-type", default="movie") parser.add_argument( "--results-json", type=Path, help="Normalize a saved qBittorrent search response without connecting", ) args = parser.parse_args() if args.results_json: payload = json.loads(args.results_json.read_text(encoding="utf-8")) search_id = None status = "Fixture" else: base_url = os.environ.get("QBITTORRENT_URL", "http://127.0.0.1:8080") username = os.environ.get("QBITTORRENT_USERNAME", "admin") password = os.environ.get("QBITTORRENT_PASSWORD", "") if not password: raise SystemExit("ERROR: QBITTORRENT_PASSWORD is required") client = QBitClient(base_url, username, password) started = client.request( "/api/v2/search/start", {"pattern": args.query, "plugins": args.plugins, "category": args.category}, ) search_id = int(started.get("id")) deadline = time.monotonic() + max(5, args.timeout) status = "Running" try: while time.monotonic() < deadline: statuses = client.request(f"/api/v2/search/status?id={search_id}") row = statuses[0] if isinstance(statuses, list) and statuses else {} status = str(row.get("status") or "") if status.lower() not in {"running", "queued"}: break time.sleep(2) client.request("/api/v2/search/stop", {"id": search_id}, raw=True) payload = client.request( f"/api/v2/search/results?id={search_id}&limit={max(1, args.limit)}&offset=0" ) finally: try: client.request("/api/v2/search/delete", {"id": search_id}, raw=True) except RuntimeError: pass observed = datetime.now(timezone.utc).isoformat() candidates: list[dict[str, Any]] = [] seen: set[str] = set() for index, row in enumerate(payload.get("results") or []): if not isinstance(row, dict): continue uri = str(row.get("fileUrl") or row.get("file_url") or "") if not uri: continue hash_value = info_hash(uri) key = hash_value or uri if key in seen: continue seen.add(key) uri_without_query = uri.split("?", 1)[0].lower() if uri.lower().startswith("magnet:"): transport_inputs = ["magnet"] elif uri_without_query.endswith(".torrent") and uri.lower().startswith(("http://", "https://")): transport_inputs = ["torrent-url"] elif uri_without_query.endswith(".torrent"): transport_inputs = ["torrent-file"] else: transport_inputs = ["url"] seeders = int(row.get("nbSeeders") or row.get("seeders") or 0) leechers = int(row.get("nbLeechers") or row.get("leechers") or 0) candidates.append( { "id": f"qbt-{index + 1}", "name": str(row.get("fileName") or row.get("file_name") or key), "uri": uri, "info_hash": hash_value, "transport_inputs": transport_inputs, "source": str(row.get("siteUrl") or row.get("site_url") or "qBittorrent plugin"), "source_url": str(row.get("descrLink") or row.get("descr_link") or ""), "observed_at": observed, "size_bytes": int(row.get("fileSize") or row.get("file_size") or 0), "seeders": seeders, "leechers": leechers, "source_health": { "advertised_seeders": seeders, "advertised_leechers": leechers, "observed_peers": None, "observed_at": observed, "sustained_speed_bytes_per_second": None, "metadata_ready": False, }, "metadata_confidence": "filename", "trusted_source": False, } ) document = { "schema_version": "1.0", "query": { "title": args.title or args.query, "year": args.year, "media_type": args.media_type, "requested_editions": [], }, "search": {"pattern": args.query, "plugins": args.plugins, "status": status}, "edition_facts": [], "candidates": candidates, } args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps({"search_id": search_id, "status": status, "candidates": len(candidates), "output": str(args.output)}, ensure_ascii=False, indent=2)) return 0 if candidates else 3 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, RuntimeError, ValueError, TypeError, json.JSONDecodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) from exc -
rank_candidates.py 9.9 KB
#!/usr/bin/env python3 """Rank normalized Media Fetch candidates and identify genuine user choices.""" from __future__ import annotations import argparse import json import math import re import sys from pathlib import Path from typing import Any GIB = 1024**3 EDITION_SCORES = { "director-cut": 30, "extended": 26, "uncut": 24, "complete": 22, "restored": 18, "theatrical": 12, "original": 12, "regional": 5, "unknown": 0, } RESOLUTION_SCORES = {"4320p": 18, "2160p": 36, "1080p": 31, "720p": 13} CODEC_SCORES = {"av1": 16, "hevc": 15, "h265": 15, "h264": 8, "avc": 8, "vp9": 9} def load_json(path: Path) -> dict[str, Any]: try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise SystemExit(f"ERROR: cannot read candidate JSON {path}: {exc}") from exc if not isinstance(data, dict) or not isinstance(data.get("candidates"), list): raise SystemExit("ERROR: candidate document must contain a candidates array") return data def infer(candidate: dict[str, Any]) -> dict[str, Any]: item = dict(candidate) name = str(item.get("name", "")) lower = name.lower().replace("_", ".").replace(" ", ".") if not item.get("resolution"): match = re.search(r"(?<!\d)(4320|2160|1080|720)p(?!\d)", lower) item["resolution"] = f"{match.group(1)}p" if match else "unknown" if not item.get("video_codec"): if re.search(r"\b(av1)\b", lower): item["video_codec"] = "av1" elif re.search(r"\b(hevc|h\.?265|x265)\b", lower): item["video_codec"] = "hevc" elif re.search(r"\b(avc|h\.?264|x264)\b", lower): item["video_codec"] = "h264" else: item["video_codec"] = "unknown" if not item.get("edition"): if re.search(r"director.?s?.?cut|directors.?cut", lower): item["edition"] = "director-cut" elif "extended" in lower: item["edition"] = "extended" elif re.search(r"uncut|unrated", lower): item["edition"] = "uncut" elif re.search(r"complete|完整版", lower): item["edition"] = "complete" elif re.search(r"theatrical|影院版", lower): item["edition"] = "theatrical" else: item["edition"] = "unknown" languages = set(item.get("subtitle_languages") or []) if re.search(r"\b(chs|zh-cn|zh-hans|中字|简中|双语)\b", lower): languages.add("zh-Hans") if re.search(r"\b(eng|english|双语)\b", lower): languages.add("en") item["subtitle_languages"] = sorted(languages) return item def score_candidate( candidate: dict[str, Any], max_size_gib: float, requested_editions: list[str] ) -> dict[str, Any]: item = infer(candidate) score = 0.0 strengths: list[str] = [] warnings: list[str] = [] edition = str(item.get("edition") or "unknown") edition_score = EDITION_SCORES.get(edition, 0) if requested_editions and edition in requested_editions: edition_score += max(0, 12 - requested_editions.index(edition) * 2) strengths.append(f"matches requested edition: {edition}") score += edition_score confidence = str(item.get("metadata_confidence") or "unknown") if confidence == "verified": score += 18 strengths.append("verified release metadata") elif confidence == "release-record": score += 12 strengths.append("release-record evidence") elif confidence == "filename": warnings.append("edition and stream claims rely on filename") else: score -= 4 warnings.append("weak metadata evidence") resolution = str(item.get("resolution") or "unknown").lower() codec = str(item.get("video_codec") or "unknown").lower() score += RESOLUTION_SCORES.get(resolution, 0) score += CODEC_SCORES.get(codec, 0) if resolution == "2160p" and codec in {"hevc", "h265", "av1"}: strengths.append("efficient 2160p") elif resolution == "1080p" and codec in {"hevc", "h265", "av1"}: strengths.append("compact high-quality 1080p") size_bytes = int(item.get("size_bytes") or 0) size_gib = size_bytes / GIB if size_bytes else 0.0 if size_bytes: if size_gib > max_size_gib: over = size_gib / max_size_gib score -= 55 + min(35, (over - 1) * 30) warnings.append(f"exceeds size cap ({size_gib:.1f} GiB > {max_size_gib:.1f} GiB)") elif resolution == "2160p" and size_gib < 2.5: score -= 20 warnings.append("implausibly small for a typical 2160p feature") elif resolution == "1080p" and size_gib < 1.2: score -= 12 warnings.append("very small for a typical 1080p feature") else: score += 10 strengths.append(f"inside size cap at {size_gib:.1f} GiB") if "remux" in str(item.get("name", "")).lower() and size_gib > max_size_gib * 0.8: score -= 12 warnings.append("large remux in balanced mode") else: score -= 10 warnings.append("size unknown") subtitles = {str(value).lower() for value in item.get("subtitle_languages") or []} has_zh = bool(subtitles & {"zh-hans", "zh", "chs", "zh-cn"}) has_en = bool(subtitles & {"en", "eng", "english"}) if has_zh and has_en: score += 20 strengths.append("Chinese and English subtitles advertised") elif has_zh or has_en: score += 7 warnings.append("only one preferred subtitle language advertised") else: score -= 8 warnings.append("preferred subtitles absent or unknown") if bool(item.get("subtitle_verified")): score += 8 strengths.append("subtitle streams verified") if bool(item.get("trusted_source")): score += 8 strengths.append("trusted source") seeders = max(0, int(item.get("seeders") or 0)) score += min(18, math.log2(seeders + 1) * 2.4) if seeders >= 20: strengths.append(f"healthy seeder count ({seeders})") elif seeders == 0: warnings.append("no current seeders reported") return { "candidate": item, "score": round(score, 2), "strengths": strengths, "warnings": warnings, } def choice_reasons( ranked: list[dict[str, Any]], cap: float, query: dict[str, Any] ) -> list[str]: if not ranked: return ["no viable candidates"] reasons: list[str] = [] if bool(query.get("title_ambiguous")): reasons.append("multiple plausible works share the requested identity") if bool(query.get("edition_choice_required")): reasons.append("credible editions contain materially different content") first = ranked[0] candidate = first["candidate"] if str(candidate.get("edition") or "unknown") != "theatrical" and str( candidate.get("metadata_confidence") or "unknown" ) not in {"verified", "release-record"}: reasons.append("preferred edition lacks independent release evidence") size_gib = int(candidate.get("size_bytes") or 0) / GIB if size_gib > cap: reasons.append("top candidate exceeds the explicit size cap") if len(ranked) >= 2 and first["score"] - ranked[1]["score"] <= 8: second = ranked[1]["candidate"] edition_diff = candidate.get("edition") != second.get("edition") size_a = int(candidate.get("size_bytes") or 0) size_b = int(second.get("size_bytes") or 0) size_diff = max(size_a, size_b) / max(1, min(size_a, size_b)) if size_a and size_b else 1 resolution_diff = candidate.get("resolution") != second.get("resolution") if edition_diff or resolution_diff or size_diff > 1.35: reasons.append("top candidates are close but trade edition, picture, or size") return reasons def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--max-size-gib", type=float) args = parser.parse_args() document = load_json(args.input) query = document.get("query") if isinstance(document.get("query"), dict) else {} media_type = str(query.get("media_type") or "movie") default_cap = 6.0 if media_type == "episode" else 24.0 max_size_gib = args.max_size_gib or float(query.get("max_size_gib") or default_cap) requested = [str(value) for value in query.get("requested_editions") or []] seen: set[str] = set() ranked: list[dict[str, Any]] = [] for index, raw in enumerate(document["candidates"]): if not isinstance(raw, dict): continue item = dict(raw) item.setdefault("id", f"candidate-{index + 1}") key = str(item.get("info_hash") or item.get("uri") or item["id"]).upper() if key in seen: continue seen.add(key) ranked.append(score_candidate(item, max_size_gib, requested)) ranked.sort(key=lambda item: item["score"], reverse=True) reasons = choice_reasons(ranked, max_size_gib, query) result = { "schema_version": "1.0", "query": query, "max_size_gib": max_size_gib, "selected_id": ranked[0]["candidate"]["id"] if ranked else None, "choice_required": bool(reasons), "reasons": reasons, "ranked": ranked, } args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps({ "selected_id": result["selected_id"], "choice_required": result["choice_required"], "reasons": reasons, "candidates": len(ranked), "output": str(args.output), }, ensure_ascii=False, indent=2)) return 0 if ranked else 2 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, ValueError, TypeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) from exc -
storage_preflight.py 4.4 KB
#!/usr/bin/env python3 """Check destination capacity before Media Fetch starts payload transfer.""" from __future__ import annotations import argparse import json import shutil import sys from pathlib import Path from typing import Any GIB = 1024**3 MIB = 1024**2 def read_decision(path: Path) -> dict[str, Any]: try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise SystemExit(f"ERROR: cannot read decision JSON {path}: {exc}") from exc if not isinstance(data, dict) or not isinstance(data.get("ranked"), list): raise SystemExit("ERROR: decision document must contain a ranked array") return data def candidate_for(decision: dict[str, Any], selected_id: str | None) -> dict[str, Any]: target = selected_id or decision.get("selected_id") for row in decision["ranked"]: if not isinstance(row, dict) or not isinstance(row.get("candidate"), dict): continue candidate = row["candidate"] if candidate.get("id") == target: return candidate raise SystemExit(f"ERROR: selected candidate not found: {target}") def existing_parent(path: Path) -> Path: current = path.expanduser().resolve(strict=False) while not current.exists() and current != current.parent: current = current.parent if not current.exists(): raise SystemExit(f"ERROR: no existing parent for destination: {path}") return current def human(value: int) -> str: return f"{value / GIB:.2f} GiB" def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--decision", required=True, type=Path) parser.add_argument("--selected-id") parser.add_argument("--output-dir", type=Path, default=Path.home() / "Downloads/Media") parser.add_argument("--reserve-free-gib", type=float, default=15.0) parser.add_argument("--probe-count", type=int, default=3) parser.add_argument("--probe-budget-mib", type=int, default=512) parser.add_argument("--overhead-ratio", type=float, default=0.10) parser.add_argument("--json-output", type=Path) args = parser.parse_args() decision = read_decision(args.decision) candidate = candidate_for(decision, args.selected_id) payload_bytes = int(candidate.get("size_bytes") or 0) destination = args.output_dir.expanduser().resolve(strict=False) filesystem_path = existing_parent(destination) usage = shutil.disk_usage(filesystem_path) if payload_bytes <= 0: result = { "ok": False, "status": "size_unknown", "candidate_id": candidate.get("id"), "destination": str(destination), "filesystem_path": str(filesystem_path), "available_bytes": usage.free, "message": "candidate size is required before payload transfer", } exit_code = 2 else: overhead = int(payload_bytes * max(0.0, args.overhead_ratio)) probe = max(0, args.probe_count) * max(0, args.probe_budget_mib) * MIB reserve = int(max(0.0, args.reserve_free_gib) * GIB) required = payload_bytes + overhead + probe + reserve shortfall = max(0, required - usage.free) result = { "ok": shortfall == 0, "status": "ready" if shortfall == 0 else "capacity_shortfall", "candidate_id": candidate.get("id"), "destination": str(destination), "filesystem_path": str(filesystem_path), "payload_bytes": payload_bytes, "overhead_bytes": overhead, "probe_bytes": probe, "reserve_bytes": reserve, "required_bytes": required, "available_bytes": usage.free, "shortfall_bytes": shortfall, "required_human": human(required), "available_human": human(usage.free), "shortfall_human": human(shortfall), } exit_code = 0 if shortfall == 0 else 3 text = json.dumps(result, ensure_ascii=False, indent=2) print(text) if args.json_output: args.json_output.parent.mkdir(parents=True, exist_ok=True) args.json_output.write_text(text + "\n", encoding="utf-8") return exit_code if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, ValueError, TypeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) from exc -
test_aria2_acquire.py 3.2 KB
#!/usr/bin/env python3 """Regression tests for aria2-first command routing.""" from __future__ import annotations import argparse import unittest from pathlib import Path from unittest.mock import patch import aria2_acquire def command_args(input_value: str, output_name: str | None = None) -> argparse.Namespace: return argparse.Namespace( input=input_value, output_name=output_name, listen_port=0, max_peers=200, summary_interval=15, ) class Aria2RoutingTests(unittest.TestCase): def test_classifies_direct_and_bittorrent_inputs(self) -> None: self.assertEqual(aria2_acquire.classify_input("https://example.test/movie.mp4"), "direct") self.assertEqual(aria2_acquire.classify_input("magnet:?xt=urn:btih:abc"), "bittorrent") self.assertEqual(aria2_acquire.classify_input("https://example.test/file.torrent"), "bittorrent") def test_direct_command_uses_http_options_without_bt_listeners(self) -> None: args = command_args("https://example.test/download?id=1", "Movie.mp4") command = aria2_acquire.build_command( args, Path("/tmp/media-fetch-test"), "/usr/bin/aria2c", [], "direct", None, 41001, ) self.assertIn("--split=16", command) self.assertIn("--out=Movie.mp4", command) self.assertFalse(any(item.startswith("--listen-port=") for item in command)) self.assertFalse(any(item.startswith("--bt-tracker=") for item in command)) def test_bittorrent_command_enables_swarm_features(self) -> None: args = command_args("magnet:?xt=urn:btih:abc") command = aria2_acquire.build_command( args, Path("/tmp/media-fetch-test"), "/usr/bin/aria2c", ["udp://tracker.example:80/announce"], "bittorrent", 41002, 41003, ) self.assertIn("--enable-dht=true", command) self.assertIn("--enable-peer-exchange=true", command) self.assertIn("--listen-port=41002", command) self.assertIn("--rpc-listen-port=41003", command) def test_auto_ports_are_separate(self) -> None: listen_port, rpc_port = aria2_acquire.resolve_ports("job-a", "bittorrent", 0, None) self.assertIsNotNone(listen_port) self.assertNotEqual(listen_port, rpc_port) def test_output_name_rejects_paths(self) -> None: with self.assertRaises(SystemExit): aria2_acquire.validate_output_name("nested/Movie.mp4") def test_rpc_uses_stopped_task_to_detect_completion(self) -> None: stopped = [ { "status": "complete", "completedLength": "2048", "totalLength": "2048", "downloadSpeed": "0", } ] with patch.object(aria2_acquire, "rpc_request", side_effect=[[], stopped]): transfer = aria2_acquire.rpc_transfer(41003) self.assertIsNotNone(transfer) assert transfer is not None self.assertTrue(transfer["rpc_complete"]) self.assertEqual(transfer["rpc_source"], "stopped") self.assertEqual(transfer["completed_bytes"], 2048) if __name__ == "__main__": unittest.main() -
validate_skill.py 17.5 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = { "name", "description", "license", "compatibility", "allowed-tools", "metadata" } TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions need at least three entries") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks need risk and mitigation entries") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution needs paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: unresolved placeholder") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: missing '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: unresolved placeholder") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: at least one user case is required") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: unresolved placeholder") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: unresolved placeholder") def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main()) -
verify_media.py 7.9 KB
#!/usr/bin/env python3 """Inspect completed media with ffprobe and emit a structured verification report.""" from __future__ import annotations import argparse import json import re import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any VIDEO_SUFFIXES = {".mkv", ".mp4", ".m4v", ".mov", ".ts", ".m2ts", ".webm", ".avi"} PARTIAL_SUFFIXES = {".part", ".partial", ".crdownload", ".tmp", ".!qB".lower()} def media_files(path: Path) -> list[Path]: if path.is_file(): return [path] if path.suffix.lower() in VIDEO_SUFFIXES else [] return sorted( (item for item in path.rglob("*") if item.is_file() and item.suffix.lower() in VIDEO_SUFFIXES), key=lambda item: item.stat().st_size, reverse=True, ) def partial_files(path: Path) -> list[str]: root = path if path.is_dir() else path.parent return [ str(item) for item in root.rglob("*") if item.is_file() and item.suffix.lower() in PARTIAL_SUFFIXES ] def ffprobe(path: Path) -> dict[str, Any]: command = [ "ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", str(path), ] completed = subprocess.run(command, check=False, capture_output=True, text=True) if completed.returncode != 0: raise RuntimeError(f"ffprobe failed for {path}: {completed.stderr.strip()[:500]}") return json.loads(completed.stdout) def language(stream: dict[str, Any]) -> str: tags = stream.get("tags") if isinstance(stream.get("tags"), dict) else {} return str(tags.get("language") or "und") def inspect(path: Path) -> dict[str, Any]: probe = ffprobe(path) streams = probe.get("streams") if isinstance(probe.get("streams"), list) else [] videos = [item for item in streams if item.get("codec_type") == "video"] audios = [item for item in streams if item.get("codec_type") == "audio"] subtitles = [item for item in streams if item.get("codec_type") == "subtitle"] format_data = probe.get("format") if isinstance(probe.get("format"), dict) else {} duration = float(format_data.get("duration") or 0) primary = videos[0] if videos else {} tags = primary.get("tags") if isinstance(primary.get("tags"), dict) else {} transfer = str(primary.get("color_transfer") or "").lower() hdr = "hdr10" if transfer in {"smpte2084", "pq"} else "hlg" if transfer == "arib-std-b67" else None return { "path": str(path.resolve()), "size_bytes": path.stat().st_size, "container": str(format_data.get("format_name") or "unknown"), "duration_seconds": round(duration, 3), "duration_minutes": round(duration / 60, 3), "video": { "streams": len(videos), "width": int(primary.get("width") or 0), "height": int(primary.get("height") or 0), "codec": str(primary.get("codec_name") or "unknown"), "pixel_format": str(primary.get("pix_fmt") or "unknown"), "hdr": hdr, "title": tags.get("title"), }, "audio": [ { "codec": str(item.get("codec_name") or "unknown"), "language": language(item), "channels": int(item.get("channels") or 0), } for item in audios ], "subtitles": [ {"codec": str(item.get("codec_name") or "unknown"), "language": language(item)} for item in subtitles ], } def episode_key(name: str) -> str | None: match = re.search(r"(?i)S(\d{1,2})E(\d{1,3})", name) return f"S{int(match.group(1)):02d}E{int(match.group(2)):02d}" if match else None def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--path", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--expected-duration-minutes", type=float) parser.add_argument("--duration-tolerance-minutes", type=float, default=2.0) parser.add_argument("--expected-resolution") parser.add_argument("--preferred-subtitle", action="append", default=["zh-Hans", "en"]) parser.add_argument("--expected-episode", action="append", default=[]) args = parser.parse_args() if shutil.which("ffprobe") is None: raise SystemExit("ERROR: ffprobe is required") target = args.path.expanduser().resolve() if not target.exists(): raise SystemExit(f"ERROR: media path does not exist: {target}") files = media_files(target) if not files: raise SystemExit(f"ERROR: no supported media files under {target}") inspected: list[dict[str, Any]] = [] errors: list[str] = [] for path in files: try: inspected.append(inspect(path)) except (RuntimeError, json.JSONDecodeError, OSError) as exc: errors.append(str(exc)) primary = inspected[0] if inspected else {} warnings: list[str] = [] if not primary or primary.get("video", {}).get("streams", 0) < 1: errors.append("no readable video stream") if float(primary.get("duration_seconds") or 0) <= 0: errors.append("media duration is zero or unavailable") if args.expected_duration_minutes and primary: delta = abs(float(primary.get("duration_minutes") or 0) - args.expected_duration_minutes) if delta > args.duration_tolerance_minutes: errors.append( f"duration differs from expected edition by {delta:.2f} minutes" ) if args.expected_resolution and primary: height = int(primary.get("video", {}).get("height") or 0) expected = int(re.sub(r"\D", "", args.expected_resolution) or 0) if expected and abs(height - expected) > max(16, expected * 0.08): warnings.append(f"observed height {height} differs from {args.expected_resolution}") subtitle_languages = { str(item.get("language") or "und").lower() for media in inspected for item in media.get("subtitles") or [] } aliases = { "zh-hans": {"zh-hans", "zho", "chi", "chs", "zh", "zh-cn"}, "en": {"en", "eng"}, } missing: list[str] = [] for preferred in dict.fromkeys(args.preferred_subtitle): accepted = aliases.get(preferred.lower(), {preferred.lower()}) if not subtitle_languages & accepted: missing.append(preferred) if missing: warnings.append("missing preferred subtitle streams: " + ", ".join(missing)) observed_episodes = {key for path in files if (key := episode_key(path.name))} missing_episodes = sorted(set(args.expected_episode) - observed_episodes) if missing_episodes: errors.append("missing requested episodes: " + ", ".join(missing_episodes)) partials = partial_files(target) if partials: warnings.append(f"partial-suffix files remain: {len(partials)}") status = "failed" if errors else "passed_with_warnings" if warnings else "passed" report = { "schema_version": "1.0", "verified_at": datetime.now(timezone.utc).isoformat(), "status": status, "target": str(target), "files": inspected, "preferred_subtitles_missing": missing, "observed_episodes": sorted(observed_episodes), "partial_files": partials, "warnings": warnings, "errors": errors, } args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps({"status": status, "files": len(inspected), "warnings": len(warnings), "errors": len(errors), "output": str(args.output)}, ensure_ascii=False, indent=2)) return 0 if status != "failed" else 3 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, RuntimeError, ValueError, TypeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(1) from exc
-
-
skills
-
media-acquisition
-
SKILL.md 5.2 KB
--- name: lov-media-acquisition description: > Use when a selected media release must be downloaded, raced, resumed, or recovered. 通过 aria2 默认执行容量预检、候选测速、持续观测与慢源切换;适用于“开始下载,太慢就换源”。 license: MIT compatibility: > Python 3.9+ and aria2 1.36+; qBittorrent 5.x Web API is optional for search, BitTorrent queue management, live swarm inspection, or long-term seeding. metadata: author: contributors version: "0.4.0" tags: - storage-preflight - qbittorrent - aria2 - download-monitoring - failover dependencies: - python - aria2 --- # 影视下载 · Media Download Treat download as an observed job with recovery, not a fire-and-forget client action. Use aria2 as the primary transfer engine for direct and BitTorrent inputs. Treat qBittorrent as an optional adapter when its search, queue, inspection, or seeding capabilities add value. ## Triggers ### Activate when - 用户说“开始下载,太慢就自动换源”“同时测试几个,选最快的”。 - 用户给出一个或多个 Magnet,希望下载完成并保留最优任务。 - The user asks to race sources, monitor a download, or recover a stalled transfer. ### Do not activate when - 用户还在比较内容不同的剪辑版本,尚未作出用户偏好决定。 - 目的磁盘的容量预检没有通过。 - The user asks only to search or rank releases without downloading. ## Workflow (MANDATORY) ### Step 0: Load safeguards and connection Read `$KIT_DIR/references/acquisition-policy.md` and resolved configuration. Verify aria2 and assign an isolated job directory plus unique listen/RPC ports. If optional qBittorrent is enabled, verify its login, keep the WebUI on loopback, and query existing hashes before adding tasks. Mark those hashes as pre-existing; never delete, relocate, or retag them. ### Step 1: Capacity preflight Run `scripts/storage_preflight.py` with the decision file, destination, probe count, temporary probe allowance, and free-space reserve. Stop before transfer when the JSON result says `ok=false`; surface required, available, and shortfall. ### Step 2: Create an isolated probe job Use a unique tag and `$OUTPUT/.media-fetch-probes/$JOB_ID`. Probe no more than the configured concurrency. Keep each candidate in its own child directory so exact losing payloads can be cleaned without broad path deletion. ### Step 3: Measure sustained usefulness Run `scripts/aria2_acquire.py` for the selected inputs, using distinct job identities and ports when probes overlap. Ignore the warm-up window when comparing. Score sustained speed, availability, peers, progress, and ETA. A candidate with a brief burst followed by zero is weaker than a stable source. Keep advertised seeders and observed health in separate fields. Use `scripts/qbittorrent_acquire.py` only when that optional backend is enabled for this run. ### Step 4: Continue and recover - Move the winner to the final destination and keep polling until completion. - Provide a user progress update at least once per minute while tools are active. - If progress and traffic remain below thresholds for `stall_seconds`, pause the current task and resume the next proven candidate. - Start or resume the default aria2 transfer with: ```bash python3 "$KIT_DIR/scripts/aria2_acquire.py" \ --input INPUT --job-id JOB_ID --output-dir OUTPUT_DIR \ --result ACQUISITION_JSON --watch --no-proxy ``` Keep the `.aria2` state in the isolated job directory and verify the final payload before relocation. For a direct URL with an opaque path, pass `--output-name` so the completed media retains a verifiable suffix. - Switch to qBittorrent only after an enabled qBittorrent probe shows better sustained health or the user needs its queue/seeding behavior. Record the evidence and avoid a second full payload. - If the complete candidate list is exhausted, preserve the best paused job, return to discovery for the next wave, preflight the incremental probe budget, and continue. - Use a finite configured search wave per process; the agent owns the outer retry loop and remains in conversation with the user. ### Step 5: Finish exact cleanup After one task reports complete and its files exist, remove only tasks created by this job and exact isolated losing directories. Keep logs and the JSON acquisition report. Never delete by wildcard, parent directory, candidate name alone, or unresolved path. ## Dependencies aria2 1.36+ is required. Optional qBittorrent connection values come from `QBITTORRENT_URL`, `QBITTORRENT_USERNAME`, `QBITTORRENT_PASSWORD`, or the shared profile; secrets stay outside committed source. ## Runtime context (shared) 运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。 - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。 - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。 - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
-
-
media-discovery
-
SKILL.md 4.2 KB
--- name: lov-media-discovery description: > 识别电影或剧集并从多个独立渠道收集、去重、归一化可下载候选;适用于“帮我找这部片的不同版本”、"search releases for this title",输出可审计的候选清单。 license: MIT metadata: author: contributors version: "0.4.0" tags: - media-search - dht - candidate-normalization compatibility: "Agent runtime with web research; optional qBittorrent search API, Rats Search, and Torrent inputs." dependencies: - python --- # 影视找片 · Media Discovery Identify the requested work before collecting releases, then return normalized, deduplicated candidates with evidence kept separate from search-result claims. Keep Magnet, local Torrent, and remote `.torrent` inputs usable for later transport choice. ## Triggers ### Activate when - 用户说“帮我找这部片的不同版本”“找导演剪辑版的下载候选”。 - 用户希望比较同一影片的多个 Magnet、Torrent 或发布版本。 - The user asks to search releases for a title or find several downloadable candidates. ### Do not activate when - 用户已经给出唯一链接且只要求立即下载。 - 用户只询问剧情、影评、演员或上映日期。 - The user asks only to verify an existing local media file. ## Workflow (MANDATORY) ### Step 0: Load the shared contract - Resolve `KIT_DIR` and read `$KIT_DIR/references/candidate-schema.md` plus `$KIT_DIR/references/user-config.md`. - Establish the output JSON path before searching. ### Step 1: Disambiguate the work Resolve canonical title, original title, year, media type, season/episode, and likely alternate spellings. Ask only if two different works remain plausible after research. ### Step 2: Establish edition truth Collect reliable evidence for known cuts and their runtimes: theatrical, original, director's cut, extended, uncut, complete, restoration, or regional variants. Record the source and confidence; do not treat a release filename as authoritative. ### Step 3: Search independent paths Use at least two available paths: 1. direct web research or catalog/index queries; 2. a local DHT index such as Rats Search; 3. user-supplied Magnet, Torrent, hash, or URL inputs; 4. optional `scripts/qbittorrent_search.py` across enabled, reviewed search plugins. Run independent searches in parallel when the environment supports it. Use title, original title, year, edition terms, resolution, and subtitle markers as separate queries; avoid one over-constrained query that hides viable candidates. ### Step 4: Normalize and deduplicate - Emit UTF-8 JSON matching `$KIT_DIR/references/candidate-schema.md`. - Deduplicate Magnet entries by normalized info hash, then by release identity. A Torrent URL may have a null hash until metadata is resolved; preserve the input. - Preserve `source`, `source_url`, observed time, and edition evidence. - Preserve advertised seeders separately from live observation fields; discovery data never counts as sustained download health. - Infer filename features conservatively; mark inferred values with `metadata_confidence: filename`. - Retain at least three healthy candidates when available so acquisition can race independent swarms. ### Step 5: Validate handoff Check that each candidate has `id`, `name`, `uri`, source, size when known, and current health fields when available. Report discovery gaps instead of inventing metadata. ## Dependencies No mandatory external search service. qBittorrent search plugins and Rats Search are optional adapters; direct web research remains available when configured adapters are absent. ## Runtime context (shared) 运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。 - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。 - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。 - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
-
-
media-selection
-
SKILL.md 4 KB
--- name: lov-media-selection description: > 比较媒体候选的剪辑版本、画质、编码、体积、音轨、字幕、活跃度与证据,自动选出最合适版本;适用于“哪个版本最值得下”、"choose the best compact release"。 license: MIT metadata: author: contributors version: "0.4.0" tags: - release-ranking - video-quality - subtitle-selection compatibility: "Python 3.9+; consumes the Media Fetch candidate JSON schema and separates advertised from observed health." dependencies: - python --- # 影视选片 · Media Selection Choose for viewing value, not label prestige: confirm the cut, then balance useful detail, efficient encoding, size, language coverage, current health, and evidence. Treat advertised seeders as a discovery hint and observed throughput as an acquisition signal; retain both in the decision for later review. ## Triggers ### Activate when - 用户说“哪个版本最值得下”“画质尽量高但不要太大”。 - 用户希望导演剪辑版、加长版、原版或完整版,并偏好中英字幕。 - The user asks to choose the best compact release or compare multiple cuts. ### Do not activate when - 用户只给出一个链接且明确不要比较其他版本。 - 用户要求开始下载但尚未完成磁盘容量预检。 - The user asks only to inspect a completed local file. ## Workflow (MANDATORY) ### Step 0: Load policy and inputs Read `$KIT_DIR/references/quality-policy.md`, `$KIT_DIR/references/candidate-schema.md`, and resolved preferences. Validate the candidate JSON before ranking. ### Step 1: Verify identity before quality Reject or penalize candidates for the wrong title, year, season, episode, or cut. Edition labels supported only by filenames remain provisional. Compare candidate duration to reliable edition runtimes when available. ### Step 2: Rank deterministically Run: ```bash python3 "$KIT_DIR/scripts/rank_candidates.py" \ --input CANDIDATES_JSON \ --output DECISION_JSON ``` Apply explicit user caps before defaults. Prefer efficient 2160p when its size and source quality are credible; otherwise prefer strong 1080p HEVC/AV1. Penalize tiny files with implausible quality claims and oversized remuxes in balanced mode. ### Step 3: Handle editions as a content decision Director's cut, extended, uncut, complete, restored, and theatrical editions are not interchangeable quality levels. Ask the user to choose when the cuts contain materially different scenes or intent and no preference resolves the difference. ### Step 4: Handle subtitles and audio Prefer verified embedded Simplified Chinese and English subtitles. Bilingual filename markers improve discovery rank but do not count as verified streams. Prefer original audio; treat dubs as additional value rather than a replacement unless requested. ### Step 5: Produce a concise decision - Auto-select when `choice_required=false`. - When choice is needed, present at most three options with edition, resolution/codec, size, subtitle status, runtime evidence, and health. - Preserve the complete scored list in `DECISION_JSON` for acquisition fallback. - Preserve a subtitle gap as an explicit warning. Missing `zh-Hans` may open the exact-release subtitle branch after media verification; it does not silently lower the release identity claim. ## Dependencies Python 3.9+. No network dependency after the candidate manifest and edition evidence are complete. ## Runtime context (shared) 运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。 - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。 - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。 - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
-
-
media-verification
-
SKILL.md 3.6 KB
--- name: lov-media-verification description: > 使用 ffprobe 验证已下载视频的可读性、时长、分辨率、编码、音轨、字幕和剧集完整性;适用于“检查下载是否完整”、"verify the downloaded media",输出结构化验收报告。 license: MIT metadata: author: contributors version: "0.4.0" tags: - ffprobe - media-validation - subtitle-audit compatibility: "Python 3.9+ and ffprobe from FFmpeg; optional exact-release subtitle handoff." dependencies: - python - ffprobe --- # 影视验片 · Media Verification Confirm the local artifact, selected edition, and language coverage before declaring the request complete. A missing Simplified Chinese stream opens a subtitle handoff, while the media artifact retains its own technical verdict. ## Triggers ### Activate when - 用户说“检查下载是否完整”“确认是不是导演剪辑版和中英字幕”。 - 下载客户端显示完成,需要验证实际本地文件。 - The user asks to verify the downloaded media or inspect its audio and subtitles. ### Do not activate when - 用户只想搜索候选或测速,尚无完成文件。 - 用户要重新压制、转码、剪辑或翻译字幕。 - The user asks only for a plot summary or release recommendation. ## Workflow (MANDATORY) ### Step 0: Resolve expected truth Load the acquisition report, selected candidate, expected edition runtime, requested episode set, and preferred languages. Locate the exact final path; do not scan unrelated user directories broadly. ### Step 1: Inspect the container Run: ```bash python3 "$KIT_DIR/scripts/verify_media.py" \ --path FINAL_PATH \ --output VERIFICATION_JSON ``` Require at least one readable video stream and positive duration. Capture container, resolution, video codec, HDR hints, audio codecs/languages/channels, subtitle codecs and languages, total size, and remaining partial suffixes. ### Step 2: Verify edition and completeness Compare duration with reliable edition runtime using the configured tolerance. For episodes, match the requested season/episode set rather than accepting a folder name. Record conflicts between filename claims and observed duration. ### Step 3: Repair subtitle gaps If preferred subtitle streams are absent, look for synchronized external subtitles matching the exact release or runtime. Store beside the media using player-compatible naming and inspect its declared language. Follow `references/subtitle-handoff.md` for UTF-8 SRT and timing preservation, then consult `lov-subtitle-freedom-skill` only for the requested subtitle operation. Do not label unsynchronized text as complete. ### Step 4: Issue the verdict Use `passed`, `passed_with_warnings`, or `failed`. Report exact local path, edition confidence, technical summary, subtitle coverage, size, and any open evidence. A client state of 100% alone is insufficient evidence. ## Dependencies Python 3.9+ and `ffprobe`. External subtitle repair additionally needs an available subtitle discovery path. ## Runtime context (shared) 运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。 - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。 - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。 - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
-
-
-
.gitignore 84 B · in bundle
-
AGENTS.md 1 KB
- aria2 进度与停滞判定必须读取 RPC 的 completedLength 和 downloadSpeed,不得用稀疏文件逻辑大小或 APFS 分配块数代替(2026-08-26, eb14185) - HLS 遇到单分片 5xx 时必须按分片重试并对照清单核齐数量,避免解复用器跳片后仍产出删节成片(2026-08-26, eb14185) - macOS 的 /usr/bin/trash 不接受 -- 选项,清理前先校验绝对路径并直接传入目标(2026-08-26, eb14185) - 直链或 aria2 成片交付后若 qBittorrent 是下载中心,必须为最终文件生成本地 torrent、执行 recheck 并保留 stoppedUP 归档记录(2026-08-26, ea15931) - 媒体获取默认以 aria2 执行直链、Magnet 和 Torrent 传输,qBittorrent 只在搜索插件、队列管理、深度 BT 管理或长期做种有明确收益时启用(2026-08-26, 9e1c780) - aria2 开启 RPC 后任务完成时进程仍可能驻留,监控器必须读取 tellStopped 的 complete 状态、确认控制文件消失并主动结束 RPC 进程(2026-08-26, 9e1c780) -
CHANGELOG.md 1.4 KB
# Changelog ## [0.4.0] - 2026-08-26 ### Added - make aria2 the primary transport backend - treat qBittorrent as an optional search, queue-management, and seeding adapter - support direct HTTP inputs, opaque output names, RPC progress, and job-specific ports in aria2 acquisition - detect RPC-resident completion through aria2.tellStopped and terminate the worker cleanly - migrate legacy transport preferences and document the aria2-first workflow ## [0.3.0] - 2026-08-24 ### Added - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill ## 0.2.0 - Added an aria2 fallback runner with DHT, PeX, LSD, bounded trackers, `.aria2` continuation state, bounded restarts, and structured transport events. - Separated advertised source health from live probe evidence and final verification. - Added a Simplified Chinese subtitle handoff with exact-release matching, UTF-8 SRT preservation, and explicit opt-in boundaries for learning gloss and ASS output. - Added the LovStudio Skill Card, pricing card, sanitized evidence bundle, and the 《指环王》三部曲 user case. ## 0.1.0 - Added a four-module discovery, selection, acquisition, and verification Skill Kit. - Added portable media preferences and a default download destination. - Added qBittorrent search, ranking, capacity preflight, parallel probing, stall recovery, and media verification helpers. -
kit.yaml 874 B
name: media-fetch display_name: "Media Fetch" version: "0.4.0" entrypoint: lov-media-fetch runtime: skill-runtime/v1 profile: manifest: skill.yaml modules: - id: media-discovery skill: lov-media-discovery path: skills/media-discovery - id: media-selection skill: lov-media-selection path: skills/media-selection - id: media-acquisition skill: lov-media-acquisition path: skills/media-acquisition - id: media-verification skill: lov-media-verification path: skills/media-verification pipelines: full: - media-discovery - media-selection - media-acquisition - media-verification choose: - media-discovery - media-selection download-known: - media-selection - media-acquisition - media-verification resume: - media-acquisition - media-verification verify: - media-verification -
LICENSE 1 KB · in bundle
-
pricing-card.yaml 648 B
schema: lovstudio/pricing-card/v1 model: free currency: CNY list_price_cny: 0 basis: "以本地长视频任务的完整链路为计价单位:候选归一化、版本判断、容量预检、aria2 默认续传、可选 qBittorrent 编排、媒体验收和字幕缺口证据均包含在源码内。" boundary: "交付范围是本地 Skill 与验证工具;来源可用性、网络速度、外部字幕、播放器兼容性和远程平台发布不在交付范围内。" review_trigger: "新增三类媒体任务、transport backend 主版本变化、ffprobe 输出结构变化或 subtitle companion 约定变化时复评。" confidence: case-backed -
README.md 4.8 KB
# 影视寻宝 · Media Finder  一键完成长视频的检索、版本选择、磁盘预检、多源测速下载、可恢复续传、慢源切换和文件验收。 ## 本地安装 通过 Agent Skills 安装器: ```bash npx skills add https://github.com/lovstudio/media-fetch-skill --skill lov-media-fetch ``` 或在本仓库根目录创建开发链接: ```bash export SKILL_SOURCE_DIR="$(pwd)" mkdir -p "${SKILL_SKILLS_INSTALL_DIR:?请设置本地 Skills 目录}" ln -s "$SKILL_SOURCE_DIR" \ "$SKILL_SKILLS_INSTALL_DIR/lov-media-fetch" ``` 安装链接必须解析到当前源码目录;打包文件不等于本地安装。 ## 用户配置 默认下载到 `$HOME/Downloads/Media`。首次运行先查看解析结果: ```bash python3 scripts/media_config.py show ``` 确认后写入共享配置: ```bash python3 scripts/media_config.py init --write ``` 显式请求和环境变量始终覆盖配置。默认只启用 aria2;可选 qBittorrent 密码只通过 `QBITTORRENT_PASSWORD` 或系统凭据提供。 ## 使用 - “帮我找并下载《影片名》的导演剪辑版,优先中英字幕,体积控制在 20GB 内。” - “Find and download the best compact 4K release of TITLE, then verify the English and Chinese subtitles.” - “这个 Magnet 帮我下载;先确认磁盘够用,太慢就自动换另一个版本。” 自然语言调用默认运行 `full` 流水线。已有链接使用 `download-known`;本地文件验收使用 `verify`。 aria2 默认负责 HTTP(S)、Magnet 和 Torrent 的测速、下载与续传。qBittorrent 仅在 需要搜索插件、队列界面、BT 深度管理或长期做种时启用。每次后端选择与切换都写入 transport trace,不把客户端进度当作最终完成证据。 ## 可选 qBittorrent 连接 启用该适配器时,将 WebUI 限定在本机回环地址。已有兼容客户端时直接复用,密码 保存在系统凭据中,再通过环境变量注入当前任务。 ```bash export QBITTORRENT_URL="http://127.0.0.1:8080" export QBITTORRENT_USERNAME="admin" export QBITTORRENT_PASSWORD="从安全凭据读取" ``` 主链路依次运行: ```bash python3 scripts/rank_candidates.py --input candidates.json --output decision.json python3 scripts/storage_preflight.py --decision decision.json python3 scripts/aria2_acquire.py \ --input INPUT --job-id JOB_ID --output-dir "$HOME/Downloads/Media" \ --result aria2-acquisition.json --watch --no-proxy python3 scripts/verify_media.py --path "$HOME/Downloads/Media" --output verification.json ``` 若直接 URL 的路径没有媒体扩展名,给 aria2 增加 `--output-name "TITLE (YEAR).mp4"`。可选 qBittorrent 搜索与获取脚本仍保留: ```bash python3 scripts/qbittorrent_search.py --query "TITLE YEAR" --output candidates.json python3 scripts/qbittorrent_acquire.py \ --decision decision.json --wait-complete --result qbit-acquisition.json ``` ## 质量门 ```bash python3 scripts/validate_skill.py . python3 scripts/test_aria2_acquire.py python3 scripts/media_config.py show --json python3 scripts/rank_candidates.py \ --input assets/example-candidates.json \ --output /tmp/media-fetch-decision.json python3 scripts/storage_preflight.py \ --decision /tmp/media-fetch-decision.json \ --output-dir /tmp python3 scripts/aria2_acquire.py \ --input 'https://example.invalid/movie.mp4' \ --job-id validation-direct --output-dir /tmp \ --result /tmp/media-fetch-aria2-direct.json --dry-run python3 scripts/aria2_acquire.py \ --input 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567' \ --job-id validation-bt --output-dir /tmp \ --result /tmp/media-fetch-aria2-bt.json --dry-run ``` ## 依赖 - Python 3.9+ - PyYAML(Skill 源码校验) - aria2 1.36+(默认 HTTP、Magnet 与 Torrent 下载后端) - FFmpeg / `ffprobe`(媒体验收) - 可选:Rats Search(独立 DHT 检索) - 可选:qBittorrent 5.x WebUI(搜索插件、队列管理、做种) ## 字幕分支 默认验收 `zh-Hans` 与 `en`。如果成片只有英文字幕,先匹配同一发行版本的外置 SRT,再交给 `lov-subtitle-freedom-skill` 做时间轴、UTF-8 和 SRT 保真处理。该 Skill 的英文学习提示、人物卡和 ASS 样式均需要明确开启;Media Fetch 不会把它们 作为简中字幕输出。 ## 用户案例 《指环王》三部曲的实际任务验证了后端解耦的价值:可选 qBittorrent 搜索并测速后, 主候选在首轮实测偏慢,aria2 使用同一信息哈希、DHT/PeX/LSD 与 Tracker 续传,最终得到 3 个可读的 1080p HEVC 文件。下载报告为 `complete`,媒体验收为 `passed_with_warnings`,唯一开放项是原发行文件未嵌入 `zh-Hans`,因此报告继续 保留字幕缺口而不是虚报“双语完成”。完整脱敏证据位于 `cases/evidence/`。 ## License MIT -
skill-card.md 4.2 KB
# 影视寻宝 · Media Finder · Skill Card `lov-media-fetch` turns a natural-language movie or series request into a normalized candidate list, an evidence-backed edition choice, a capacity-checked acquisition, and a final local media verification. It uses aria2 as the default direct and BitTorrent transport, with qBittorrent available as an optional search, queue, and seeding adapter. # Owner LovStudio / 手工川工作室. The source is maintained as a portable local Skill Kit. # License MIT. See [LICENSE](LICENSE). # Use Case The primary audience is a user who wants to save a film or series locally while keeping edition, quality, size, subtitle coverage, and completion evidence visible. The minimum prompt can be a title, an edition preference, or a Magnet/Torrent input. # Deployment Geography The source is ready for local macOS or Linux Agent runtime deployment. It keeps credentials outside the source and writes reports beside the selected job. # Requirements Python 3.9+, PyYAML, FFmpeg/ffprobe, and aria2 1.36+ are the core requirements. qBittorrent 5.x is optional. Storage preflight must pass before payload transfer; when qBittorrent is enabled, credentials stay in a secure external store. # Known Risks - Advertised source health may differ from live sustained speed; the kit records both. - Similar releases can represent different cuts; runtime and release evidence decide. - A completed media file can still lack the requested subtitle language. - A `.aria2` file indicates resumable state, not a finished local artifact. # References - [workflow](SKILL.md) - [candidate schema](references/candidate-schema.md) - [acquisition policy](references/acquisition-policy.md) - [subtitle handoff](references/subtitle-handoff.md) - [download evidence](cases/evidence/lotr-download.json) - [verification evidence](cases/evidence/lotr-verification.json) # Skill Output The output is a normalized candidate manifest, ranked decision JSON, acquisition and transport trace JSON, verified local media, and optionally an exact-release UTF-8 Simplified Chinese SRT. `download_status`, `verification_status`, and `subtitle_status` remain separate fields. # Skill Version 0.4.0. This release makes aria2 the primary transport, moves qBittorrent behind an optional capability boundary, adds direct-URL routing and job-specific ports, and migrates legacy backend preferences. # Ethical Considerations Respect applicable rights, source terms, and privacy. Keep credentials outside reports, label discovery claims versus observed evidence, and state subtitle gaps instead of presenting an incomplete language package as complete. # User Cases ## 指环王三部曲 Input: `指环王三部曲`, with extended edition, balanced quality, original audio, and `zh-Hans` plus English subtitle preferences. Prompt: `$lov-media-fetch 指环王三部曲`. Output: the selected Extended Remastered 1080p HEVC release completed through an optional qBittorrent discovery/probe followed by aria2 same-input continuation. Three media files passed stream and duration inspection. The final warning precisely records the missing `zh-Hans` stream and opens the exact-release SRT handoff. # Dimension Map | Dimension | Evidence | State | | --- | --- | --- | | 剪辑版本识别 | Extended Remastered candidate plus three duration readings | verified | | 下载韧性 | aria2 primary transfer, optional qBittorrent probe, `.aria2` state, final completion | verified | | 容量纪律 | Preflight formula includes payload, probes, continuation files, and reserve | verified | | 媒体验收 | Three readable 1920x804 HEVC files, no errors | verified | | 字幕保真 | English embedded; `zh-Hans` gap is isolated for exact-release SRT matching | warning | # Pricing Basis Free local source. The value is the complete evidence-backed workflow: it reduces wrong-edition downloads, duplicate transfers, unverified client states, and silent subtitle gaps. The boundary ends at local orchestration and verification; external source availability and subtitle content are not bundled. # Distribution - `lovstudio`: local-ready source and trust bundle. - `workbuddy`: prepared, not published. - `skillpay`: pricing card prepared, not published as a paid product. - `github`: source structure prepared, not remotely published. -
skill-card.yaml 6.2 KB
schema: lovstudio/skill-card/v1 description: "把片名或 Magnet/Torrent 请求变成经过容量预检、版本核验、可恢复下载和 ffprobe 验收的本地媒体,并把字幕缺口单独呈现。" owner: team: "LovStudio / 手工川工作室" contact: "LovStudio local skill maintainers" license: name: MIT terms: "可在保留许可与版权声明的前提下使用、修改和分发。" url: "LICENSE" use_case: audience: "需要保存长视频、重视剪辑版本、体积、字幕和完整性证据的用户" scenario: "从片名或已有 Magnet/Torrent 识别、选择、下载并验收电影或剧集" tasks: - "独立检索并归一化候选" - "根据剪辑、画质、体积、字幕和实时健康度排序" - "aria2 默认测速、下载与续传,qBittorrent 按需启用" - "ffprobe 验收文件、音视频流、时长和字幕" deployment: geography: "global" environments: - "macOS 或 Linux 本地 Agent runtime" - "Portable Agent Skills format" state: "source_ready_local_validation" requirements: runtime: - "Python 3.9+" - "PyYAML" - "FFmpeg / ffprobe" transport: - "aria2 1.36+ for primary direct and BitTorrent acquisition" - "qBittorrent 5.x Web API optional for search, queue management, and seeding" credentials: "Optional qBittorrent credentials remain in environment variables or an operating-system credential store; reports contain no secrets." storage: "Destination capacity must cover payload, partial-file overhead, probe budget, continuation metadata, and configured reserve." risks: - risk: "搜索结果中的做种数和文件名会随时间漂移,且不等同于稳定下载速度。" mitigation: "保留 advertised 与 observed 两套字段,执行暖机后的持续测速,并记录 backend 与切换事件。" - risk: "导演剪辑、加长版和院线版可能是不同内容,而非单纯画质等级。" mitigation: "使用时长、发行资料和媒体流证据核验剪辑;存在实质差异时保留用户选择。" - risk: "选中的发行文件可能缺少简中字幕。" mitigation: "将 subtitle_status 独立于媒体 verdict,匹配同一发行版本的外置 SRT,并保留时间轴与编码证据。" - risk: "长任务中断后局部文件可能被误认为完成。" mitigation: "将 .aria2 控制文件视为续传状态,完成后再执行完整性、流信息和剩余部分检查。" references: - title: "Media Fetch workflow" path: "SKILL.md" - title: "Candidate and decision schema" path: "references/candidate-schema.md" - title: "Acquisition, monitoring, and recovery policy" path: "references/acquisition-policy.md" - title: "Simplified Chinese subtitle handoff" path: "references/subtitle-handoff.md" - title: "Verified user case evidence" path: "cases/evidence/lotr-download.json" - title: "Final media verification evidence" path: "cases/evidence/lotr-verification.json" output: types: - "normalized candidate manifest" - "ranked decision JSON" - "acquisition and transport trace JSON" - "verified local media" - "optional exact-release zh-Hans SRT" formats: - "JSON" - "MKV / MP4 / supported container" - "UTF-8 SRT" validation: "Final output requires local file presence, readable video stream, positive duration, expected edition evidence, stream inspection, subtitle status, and no unresolved partial suffix." version: "0.4.0" ethical_considerations: "尊重适用的版权、来源条款和隐私边界;凭据留在外部安全存储;报告明确区分发现线索、实时证据和最终验收,不把缺失字幕或客户端进度包装成完整结果。" dimensions: - id: "edition-identity" label: "剪辑版本识别" description: "先确认作品与剪辑版本,再比较画质和体积。" evidence: "quality-policy.md 的时长容差规则;LOTR 案例的 Extended Remastered 候选与三部片时长回读。" - id: "transport-resilience" label: "下载韧性" description: "以持续实测速度选择后端,并在同一输入上保留可恢复状态。" evidence: "aria2_acquire.py 的直链与 DHT/PeX/LSD/Tracker 路由、RPC 进度、.aria2 状态和 LOTR 传输记录。" - id: "storage-discipline" label: "容量纪律" description: "把成片、局部文件、探测预算和空间保留一起纳入预检。" evidence: "storage_preflight.py 与 acquisition-policy.md 的计算式和终态。" - id: "media-verification" label: "媒体验收" description: "客户端完成信号之后,逐文件检查可读性、时长、编码、音轨和字幕。" evidence: "verify_media.py 的结构化报告;LOTR 案例 3 个文件均可读且无错误。" - id: "subtitle-fidelity" label: "字幕保真" description: "将简中字幕作为独立状态,遵循时间轴、UTF-8、SRT 和原文件不变原则。" evidence: "references/subtitle-handoff.md 与 lov-subtitle-freedom-skill 的 SRT 保真约定;LOTR 报告明确标出 zh-Hans 缺口。" pricing: model: "free" currency: "CNY" list_price_cny: 0 basis: "本地 Skill 源码、脚本、结构化案例和验证门禁共同交付;价值来自减少错误版本、重复下载和未验收文件。" boundary: "免费版本覆盖本地检索、选择、aria2 默认下载、可选 qBittorrent 编排、容量预检、媒体验收和字幕状态记录;外部来源、网络可达性、播放器和字幕内容本身不随 Skill 交付。" review_trigger: "当 aria2/qBittorrent API、FFmpeg、Skill runtime 或字幕 companion 的接口发生变化,或新增三类真实媒体任务后复评价格和交付边界。" confidence: "case-backed" distribution: paid: - channel: "workbuddy" state: "prepared_not_published" note: "已具备 Skill Card 与案例材料,等待独立发布流程。" - channel: "skillpay" state: "prepared_not_published" note: "定价依据已记录,尚未创建付费商品。" free: - channel: "lovstudio" state: "local_ready" note: "源码、案例和本地校验已准备。" - channel: "github" state: "prepared_not_published" note: "源码结构适合公开仓库,当前未执行远程发布。" -
SKILL.md 11.6 KB
--- name: lov-media-fetch description: > Use when the user asks to find and download a film, series, or long video. 以 aria2 为默认传输后端,完成多源测速、续传、容量预检、版本核验与字幕验收;也适用于“帮我下载这部电影”。 license: MIT compatibility: > Portable Agent Skills format; Python 3.9+, aria2 1.36+, and ffprobe recommended. qBittorrent 5.x Web API is an optional discovery, BitTorrent-management, and seeding adapter. metadata: author: contributors version: "0.4.0" tags: - media-discovery - release-selection - download-orchestration - quality-control - aria2-primary - optional-qbittorrent - resumable-download - subtitle-handoff card_standard: "lovstudio/skill-card/v1" dependencies: - python - pyyaml - aria2 - ffprobe --- # 影视寻宝 · Media Finder Turn one natural-language request into a verified local media file. Search broadly, identify the actual cut, balance picture quality against size, check storage before transfer, race viable sources, monitor the winner, recover from stalls, and inspect the completed file. ## Triggers ### Activate when - 用户说“帮我找并下载这部电影”“下载导演剪辑版,画质好一点但别太大”“找带中英字幕的完整版”。 - 用户给出片名、年份、版本偏好,或已有 Magnet/Torrent,希望自动完成选择、下载和验收。 - The user asks to find and download the best release, fetch an extended cut, or download a compact high-quality copy with Chinese and English subtitles. ### Do not activate when - 用户只想了解影片资料、比较不同剪辑或获得观看建议,不要求取得本地文件。 - 用户只要下载普通网页文件、软件安装包、网页视频片段或直播流。 - 用户明确要求发布、上传、转码压制或制作字幕;这些是下载完成后的独立任务。 ## Product contract - One request should normally run end to end without repeated confirmation. - Prefer an edition whose identity is supported by runtime, release metadata, or file-level evidence. A filename alone is weak evidence. - “Best” means the highest useful viewing quality inside the user's size and disk budget, not the largest file or highest advertised resolution. - Default to embedded Simplified Chinese plus English subtitles. Treat filename claims as hints until streams or synchronized external subtitles are inspected. - Ask the user only when title identity is ambiguous, editions contain materially different content, or the top candidates are close enough that taste decides. - Never begin payload transfer before the destination capacity check passes. - Keep observing an active job. A task added to a client is not a completed result. - Completion means that the final local payload exists and the verification report is written. A client-reported 100% is only an intermediate signal. - Keep advertised seeders, observed peers, metadata readiness, received bytes, and sustained speed as separate evidence fields. A large seeder count is not a speed promise. - Use aria2 as the default transfer backend for direct URLs, Metalinks, Magnets, and Torrent inputs. Enable qBittorrent only when its search plugins, queue UI, swarm inspection, or long-term seeding materially helps the task. Record every backend choice and switch; do not create a second full payload by accident. - A missing `zh-Hans` stream is a recoverable subtitle gap, not a reason to mislabel the media. The subtitle branch may consult `lov-subtitle-freedom-skill` for timestamp-preserving UTF-8 SRT handling. Its English-learning gloss and ASS modes stay opt-in; plain Chinese subtitle delivery remains a separate, clearly named SRT. ## User configuration Resolve defaults through `$KIT_DIR/references/user-config.md`. On first use, show the resolved output directory and preferences before persisting them. Keep credentials in environment variables or the operating system credential store. ## Skill Kit modules Load the selected module completely before acting: - `$SKILL_DIR/skills/media-discovery/SKILL.md` — identify the title and collect normalized candidates from several independent discovery paths. - `$SKILL_DIR/skills/media-selection/SKILL.md` — verify editions and rank picture, codec, size, audio, subtitles, health, and evidence. - `$SKILL_DIR/skills/media-acquisition/SKILL.md` — capacity preflight, parallel swarm probing, winner selection, progress observation, stall recovery, and cleanup. - `$SKILL_DIR/skills/media-verification/SKILL.md` — inspect the downloaded files, edition runtime, streams, subtitle coverage, completeness, and final path. `kit.yaml` defines the available pipelines. Shared schemas and decision rules live in `$KIT_DIR/references/`. ## Workflow (MANDATORY) **You MUST follow these steps in order.** ### Step 0: Resolve runtime and select a pipeline 1. Resolve `SKILL_DIR`, `KIT_DIR`, configuration, aria2 availability, and `ffprobe`. Detect qBittorrent as an optional capability; its absence must not block discovery, transfer, resume, verification, or reporting. 2. On first use, bootstrap missing stable aria2 and ffprobe dependencies through the platform's native package manager. When qBittorrent is explicitly enabled, keep its WebUI on loopback and its credential in the operating system credential store. Do not put secrets in profile or reports. 3. Preserve existing client tasks. Every task created by this Skill must receive a unique job tag and an isolated probe directory. 4. Select `full` for a title request, `choose` for comparison only, `download-known` for supplied links, `resume` for an existing job, or `verify` for a local file. 5. Read `$KIT_DIR/references/candidate-schema.md`, then validate all handoff JSON. ### Step 1: Resolve the requested work Capture title, year, media type, season/episode when relevant, edition preference, maximum size, destination override, audio/subtitle preference, and urgency. Infer omitted values from the portable profile. Do not ask the user to choose tooling. ### Step 2: Discover independent candidates Run the discovery module. Use at least two independent discovery paths when possible: direct web or catalog research, a local DHT index such as Rats Search, user-supplied links, or the optional qBittorrent search API. Deduplicate by info hash and canonical release identity. Preserve a `.torrent` URL or local Torrent path even when its info hash is not known until metadata resolution. For title and edition truth, prefer distributor, studio, disc, catalog, or reliable release metadata. Keep search-result claims separate from verified facts. ### Step 3: Rank releases and resolve genuine ambiguity Run the selection module and `scripts/rank_candidates.py`. Apply `$KIT_DIR/references/quality-policy.md`. - Auto-select when one candidate clearly leads and its edition is supported. - Show at most three concise choices when the output says `choice_required=true`. - Explain only the user-facing tradeoff: edition/content, picture, size, subtitles, and current health. Do not expose internal scoring mechanics unless asked. ### Step 4: Preflight destination capacity Resolve the destination, candidate size, probe concurrency, temporary probe budget, fallback resume allowance, and free-space reserve. Run `scripts/storage_preflight.py` before starting either backend. If capacity is short, report available, required, and shortfall immediately. Offer the best smaller candidate or a different destination, then wait for that user-facing decision. Never silently consume the reserve. ### Step 5: Probe, select, and download Run the acquisition module and `$KIT_DIR/references/acquisition-policy.md`. 1. Probe up to the configured concurrency in isolated per-candidate directories. Use aria2 by default and allocate distinct listen/RPC ports for concurrent jobs. 2. Observe warm speed, sustained speed, availability, peers, metadata readiness, and ETA; a short burst alone does not win. 3. Pause non-winners, move the winner to the final destination, and continue polling. 4. If the winner stalls beyond the configured threshold, pause it and first try the next proven candidate. Preserve the same aria2 job identity and `.aria2` state when restarting an input. Switch to qBittorrent only when it is enabled and measured evidence shows a healthier swarm or the user needs its queue/seeding behavior. Record the reason, backend, and observed rate for each switch. 5. If all candidates are slow, return to discovery for another wave. 6. Keep the terminal session alive and poll at intervals short enough to provide the user a progress update at least once per minute during active work. 7. Clean only exact job-tagged losing tasks and their isolated probe files after the final candidate is complete. Leave pre-existing client tasks untouched. ### Step 6: Verify the completed media Run the verification module and `scripts/verify_media.py`. - Confirm a readable video stream, non-zero duration, expected resolution and codec, audio tracks, subtitle streams, and duration close to the selected edition. - Inspect every episode for episodic requests; a season folder is complete only when the requested episode set is present. - When preferred subtitles are missing, search for a subtitle from the exact release or a synchronized subtitle checked against duration and scene boundaries. For a Simplified Chinese SRT handoff, preserve cue timing, UTF-8, source immutability, and adjacent naming as described in `references/subtitle-handoff.md`; do not create an English-learning gloss or ASS file unless explicitly requested. - Recheck final free space and ensure no partial suffix remains on the primary file. ### Step 7: Report the result Lead with completion status and the exact local path. Include title/edition, video and audio summary, subtitle coverage, final size, advertised versus observed source health, transport trace, elapsed time, and any remaining evidence gap. Distinguish `download_status`, `verification_status`, and `subtitle_status`. ## References - `$KIT_DIR/references/candidate-schema.md` — normalized candidate and decision JSON. - `$KIT_DIR/references/quality-policy.md` — edition, picture, codec, size, language, and ambiguity rules. - `$KIT_DIR/references/acquisition-policy.md` — capacity, probing, monitoring, switching, and cleanup rules. - `$KIT_DIR/references/user-config.md` — portable defaults and secrets handling. - `$KIT_DIR/references/subtitle-handoff.md` — Simplified Chinese SRT matching and the opt-in handoff to `lov-subtitle-freedom-skill`. ## Dependencies - Python 3.9+ for deterministic helpers. - aria2 1.36+ for primary HTTP(S), Metalink, Magnet, and Torrent acquisition. - Optional qBittorrent 5.x with WebUI enabled for integrated search, BT management, queue visibility, or long-term seeding. - Search plugins or another discovery adapter for title search. - `ffprobe` from FFmpeg for final stream and duration inspection. - Optional Rats Search for independent DHT discovery. ## Runtime context (shared) 运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。 - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。 - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。 - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。 -
skill.yaml 749 B
schema: skill-manifest/v1 id: lov-media-fetch version: "0.4.0" runtime: skill-runtime/v1 context: profile: fields: [] preferences: namespace: lov_media_fetch fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? - path: media_fetch.transport_backends required: false question: 是否为本次任务启用可选的 qBittorrent 搜索、队列管理或长期做种能力? - path: media_fetch.preferred_subtitles required: false question: 需要优先验收哪些字幕语言? interaction: ask_missing: true max_questions: 1
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.