feat: integrated audio player + animated transcript follower

- Video detail (done videos): added 'Download audio' button; removed the
  manual 'Thumbnail' button (thumbnails auto-download now, so it was redundant).
- New 'GET /api/videos/{id}/audio' endpoint (also serves HEAD for probing),
  serving data/audio/<video_id>.mp3. Audio job outtmpl switched to %(id)s so
  files are addressable per video.
- Integrated <audio> player appears in the detail view once the MP3 is present
  (probed via HEAD on openVideo; polled after a download job until ready).
- Synced transcript follower: as audio plays, the matching segment is
  highlighted (seg-active) and auto-scrolled into view like a karaoke/lyrics
  tracker. Clicking any segment timestamp or chapter seeks the audio to that
  point (falls back to scroll when no audio). Playback indicator pulses while
  playing.
This commit is contained in:
urieljareth
2026-07-27 00:00:28 -06:00
parent 0866c92650
commit 06497299ab
51 changed files with 16013 additions and 17 deletions
+1
View File
@@ -0,0 +1 @@
{"0": "Webapp Frontend (JS)", "1": "Transcript Extraction & Parsing", "2": "Config & Discovery Pipeline", "3": "Store & Export Layer", "4": "CLI Command Layer", "5": "Platform Design & Proposals", "6": "Segments Search & Backfill", "7": "Store Platform Tests", "8": "Content Analysis", "9": "README & Config Docs", "10": "Server Start Script", "11": "Package Manifest", "12": "Server Stop Script", "13": "Webapp Package Init"}
+1
View File
@@ -0,0 +1 @@
C:\Users\Uriel Jareth\AppData\Local\Programs\Python\Python312\python.exe
+1
View File
@@ -0,0 +1 @@
D:\yt-channel-scraper
+115
View File
@@ -0,0 +1,115 @@
# Graph Report - . (2026-07-26)
## Corpus Check
- Corpus is ~28,854 words - fits in a single context window. You may not need a graph.
## Summary
- 392 nodes · 951 edges · 14 communities (12 shown, 2 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 65 edges (avg confidence: 0.76)
- Token cost: 11,800 input · 4,100 output
## Community Hubs (Navigation)
- Webapp Frontend (JS)
- Transcript Extraction & Parsing
- Config & Discovery Pipeline
- Store & Export Layer
- CLI Command Layer
- Platform Design & Proposals
- Segments Search & Backfill
- Store Platform Tests
- Content Analysis
- README & Config Docs
- Package Manifest
## God Nodes (most connected - your core abstractions)
1. `Store` - 81 edges
2. `Segment` - 32 edges
3. `api()` - 31 edges
4. `toast()` - 29 edges
5. `Config` - 23 edges
6. `process_video()` - 19 edges
7. `JobManager` - 17 edges
8. `resolve_active_path()` - 13 edges
9. `discover_channel()` - 13 edges
10. `backfill_from_markdown()` - 13 edges
## Surprising Connections (you probably didn't know these)
- `test_dashboard_aggregates()` --calls--> `Segment` [INFERRED]
tests/test_store_platform.py → src/yt_scraper/parse.py
- `test_store_and_search_segments()` --calls--> `Segment` [INFERRED]
tests/test_store_platform.py → src/yt_scraper/parse.py
- `test_store_segments_overwrites()` --calls--> `Segment` [INFERRED]
tests/test_store_platform.py → src/yt_scraper/parse.py
- `store()` --calls--> `Store` [INFERRED]
tests/test_store_platform.py → src/yt_scraper/store.py
- `test_migration_idempotent()` --calls--> `Store` [INFERRED]
tests/test_store_platform.py → src/yt_scraper/store.py
## Import Cycles
- None detected.
## Hyperedges (group relationships)
- **Workstream B webapp stack (FastAPI + Alpine SPA + subagent)** — opencode_agent_webapp_builder, opencode_goals_webapp_build, src_yt_scraper_webapp_static_index, docs_superpowers_specs_2026_07_26_platform_design_dark_command_center, docs_superpowers_specs_2026_07_26_platform_design_sse_job_runner [EXTRACTED 0.95]
- **FTS5 transcript search flow (proposal -> spec -> SPA view)** — opportunities_search_fts5, docs_superpowers_specs_2026_07_26_platform_design_transcript_fts5, src_yt_scraper_webapp_static_index [INFERRED 0.85]
- **Cookie auth chain (vault -> resolve_active_path -> scrape job)** — docs_superpowers_specs_2026_07_26_platform_design_cookie_vault_ux, docs_superpowers_specs_2026_07_26_platform_design_sse_job_runner, docs_superpowers_specs_2026_07_26_platform_design_cookies_bug_fix, src_yt_scraper_webapp_static_index [INFERRED 0.85]
## Communities (14 total, 2 thin omitted)
### Community 0 - "Webapp Frontend (JS)"
Cohesion: 0.06
Nodes (59): activateCookie(), addChannel(), api(), axisOpts(), barOpts(), cancelScrape(), checkHealth(), closeStream() (+51 more)
### Community 1 - "Transcript Extraction & Parsing"
Cohesion: 0.07
Nodes (57): Environment, align_chapters(), Chapter, chapters_from_info(), Section, _parse_tags(), Regenerar Markdown desde segmentos almacenados., re_render_cmd() (+49 more)
### Community 2 - "Config & Discovery Pipeline"
Cohesion: 0.06
Nodes (40): APIRouter, FastAPI, _build_config(), Config, DelayConfig, load_config(), Any, Path (+32 more)
### Community 3 - "Store & Export Layer"
Cohesion: 0.07
Nodes (28): Connection, Cursor, Row, is_expired(), list_vault(), export_csv(), export_html(), export_json() (+20 more)
### Community 4 - "CLI Command Layer"
Cohesion: 0.08
Nodes (41): analyze_cmd(), _apply_filters(), audio_cmd(), _channel_targets(), channels_add(), cli(), export_cmd(), _extract_handle() (+33 more)
### Community 5 - "Platform Design & Proposals"
Cohesion: 0.12
Nodes (20): Platform Implementation Plan, Platform Design Spec, Cookie drag-and-drop vault UX, cli.py cookies scope bug fix, Design language: dark data command center, Architecture: Monorepo in-package webapp (Approach A), Scope rule: No AI features, Single-threaded scrape job runner + SSE event bus (+12 more)
### Community 6 - "Segments Search & Backfill"
Cohesion: 0.26
Nodes (11): backfill_from_markdown(), parse_markdown(), ParsedMarkdown, Path, Parse all done .md files under md_root, populate segments/FTS/metadata. Idempote, search(), store_segments(), _strip_quotes() (+3 more)
### Community 7 - "Store Platform Tests"
Cohesion: 0.17
Nodes (5): store(), test_dashboard_aggregates(), test_migration_idempotent(), test_store_and_search_segments(), test_store_segments_overwrites()
### Community 8 - "Content Analysis"
Cohesion: 0.27
Nodes (10): _iter_texts(), Path, Return [(YYYY-MM, count)] of months where `term` appears in transcripts., render_timeline_chart(), render_top_words_chart(), render_wordcloud(), term_timeline(), _tokenize() (+2 more)
## Knowledge Gaps
- **10 isolated node(s):** `yt-channel-scraper`, `OPPORTUNITIES.md - extension audit`, `Design language: dark data command center`, `yt-dlp InnerTube mechanism (ANDROID/IOS/WEB clients)`, `Feature proposal: search (FTS5 transcript search)` (+5 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **2 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `Store` connect `Store & Export Layer` to `Transcript Extraction & Parsing`, `Config & Discovery Pipeline`, `CLI Command Layer`, `Segments Search & Backfill`, `Store Platform Tests`, `Content Analysis`?**
_High betweenness centrality (0.194) - this node is a cross-community bridge._
- **Why does `Segment` connect `Transcript Extraction & Parsing` to `CLI Command Layer`, `Segments Search & Backfill`, `Store Platform Tests`?**
_High betweenness centrality (0.057) - this node is a cross-community bridge._
- **Why does `Config` connect `Config & Discovery Pipeline` to `Transcript Extraction & Parsing`, `CLI Command Layer`?**
_High betweenness centrality (0.024) - this node is a cross-community bridge._
- **Are the 4 inferred relationships involving `Store` (e.g. with `ParsedMarkdown` and `store()`) actually correct?**
_`Store` has 4 INFERRED edges - model-reasoned connections that need verification._
- **Are the 17 inferred relationships involving `Segment` (e.g. with `Chapter` and `Section`) actually correct?**
_`Segment` has 17 INFERRED edges - model-reasoned connections that need verification._
- **What connects `yt-channel-scraper`, `OPPORTUNITIES.md - extension audit`, `Design language: dark data command center` to the rest of the system?**
_10 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Webapp Frontend (JS)` be split into smaller, more focused modules?**
_Cohesion score 0.062317429406037 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "d_yt_channel_scraper_scripts_start_server_ps1", "label": "start-server.ps1", "file_type": "code", "source_file": "scripts/start-server.ps1", "source_location": "L1"}, {"id": "d_yt_channel_scraper_scripts_start_server_open_browser", "label": "Open-Browser()", "file_type": "code", "source_file": "scripts/start-server.ps1", "source_location": "L6"}], "edges": [{"source": "d_yt_channel_scraper_scripts_start_server_ps1", "target": "d_yt_channel_scraper_scripts_start_server_open_browser", "relation": "contains", "confidence": "EXTRACTED", "source_file": "scripts/start-server.ps1", "source_location": "L6", "weight": 1.0}], "raw_calls": [{"caller_nid": "d_yt_channel_scraper_scripts_start_server_open_browser", "callee": "Start-Process", "is_member_call": false, "source_file": "scripts/start-server.ps1", "source_location": "L9"}, {"caller_nid": "d_yt_channel_scraper_scripts_start_server_open_browser", "callee": "Write-Host", "is_member_call": false, "source_file": "scripts/start-server.ps1", "source_location": "L18"}, {"caller_nid": "d_yt_channel_scraper_scripts_start_server_open_browser", "callee": "Write-Host", "is_member_call": false, "source_file": "scripts/start-server.ps1", "source_location": "L19"}]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "d_yt_channel_scraper_src_yt_scraper_ratelimit_py", "label": "ratelimit.py", "file_type": "code", "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L1"}, {"id": "d_yt_channel_scraper_src_yt_scraper_ratelimit_polite_sleep", "label": "polite_sleep()", "file_type": "code", "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L7", "_callable": true}, {"id": "d_yt_channel_scraper_src_yt_scraper_ratelimit_backoff_sleep", "label": "backoff_sleep()", "file_type": "code", "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L12", "_callable": true}], "edges": [{"source": "d_yt_channel_scraper_src_yt_scraper_ratelimit_py", "target": "random", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L3", "weight": 1.0}, {"source": "d_yt_channel_scraper_src_yt_scraper_ratelimit_py", "target": "time", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L4", "weight": 1.0}, {"source": "d_yt_channel_scraper_src_yt_scraper_ratelimit_py", "target": "d_yt_channel_scraper_src_yt_scraper_ratelimit_polite_sleep", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L7", "weight": 1.0}, {"source": "d_yt_channel_scraper_src_yt_scraper_ratelimit_py", "target": "d_yt_channel_scraper_src_yt_scraper_ratelimit_backoff_sleep", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L12", "weight": 1.0}], "raw_calls": [{"caller_nid": "d_yt_channel_scraper_src_yt_scraper_ratelimit_polite_sleep", "callee": "uniform", "is_member_call": true, "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L8", "receiver": "random"}, {"caller_nid": "d_yt_channel_scraper_src_yt_scraper_ratelimit_polite_sleep", "callee": "sleep", "is_member_call": true, "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L9", "receiver": "time"}, {"caller_nid": "d_yt_channel_scraper_src_yt_scraper_ratelimit_backoff_sleep", "callee": "uniform", "is_member_call": true, "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L13", "receiver": "random"}, {"caller_nid": "d_yt_channel_scraper_src_yt_scraper_ratelimit_backoff_sleep", "callee": "sleep", "is_member_call": true, "source_file": "src/yt_scraper/ratelimit.py", "source_location": "L14", "receiver": "time"}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "d_yt_channel_scraper_src_yt_scraper_webapp_init_py", "label": "__init__.py", "file_type": "code", "source_file": "src/yt_scraper/webapp/__init__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "d_yt_channel_scraper_src_yt_scraper_init_py", "label": "__init__.py", "file_type": "code", "source_file": "src/yt_scraper/__init__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "d_yt_channel_scraper_scripts_stop_server_ps1", "label": "stop-server.ps1", "file_type": "code", "source_file": "scripts/stop-server.ps1", "source_location": "L1"}], "edges": [], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "pkg_yt_channel_scraper", "label": "yt-channel-scraper", "file_type": "code", "type": "package", "ecosystem": "python", "source_file": "pyproject.toml", "source_location": "L1", "version": "0.1.0"}], "edges": [{"source": "pkg_yt_channel_scraper", "target": "pkg_yt_dlp", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "pyproject.toml", "source_location": "L1", "weight": 1.0}, {"source": "pkg_yt_channel_scraper", "target": "pkg_click", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "pyproject.toml", "source_location": "L1", "weight": 1.0}, {"source": "pkg_yt_channel_scraper", "target": "pkg_rich", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "pyproject.toml", "source_location": "L1", "weight": 1.0}, {"source": "pkg_yt_channel_scraper", "target": "pkg_jinja2", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "pyproject.toml", "source_location": "L1", "weight": 1.0}, {"source": "pkg_yt_channel_scraper", "target": "pkg_pyyaml", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "pyproject.toml", "source_location": "L1", "weight": 1.0}, {"source": "pkg_yt_channel_scraper", "target": "pkg_python_slugify", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "pyproject.toml", "source_location": "L1", "weight": 1.0}, {"source": "pkg_yt_channel_scraper", "target": "pkg_requests", "relation": "depends_on", "context": "dependency", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "pyproject.toml", "source_location": "L1", "weight": 1.0}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "src_yt_scraper_webapp_static_index", "label": "SPA shell (index.html)", "file_type": "document", "source_file": "src/yt_scraper/webapp/static/index.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "src_yt_scraper_webapp_static_index", "target": "docs_superpowers_specs_2026_07_26_platform_design_dark_command_center", "relation": "implements", "confidence": "INFERRED", "confidence_score": 0.95, "source_file": "src/yt_scraper/webapp/static/index.html", "source_location": null, "weight": 1.0}, {"source": "src_yt_scraper_webapp_static_index", "target": "docs_superpowers_specs_2026_07_26_platform_design_cookie_vault_ux", "relation": "implements", "confidence": "INFERRED", "confidence_score": 0.95, "source_file": "src/yt_scraper/webapp/static/index.html", "source_location": "Cookies view dropzone", "weight": 1.0}], "hyperedges": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "opencode_agent_webapp_builder", "label": "webapp-builder agent definition", "file_type": "document", "source_file": ".opencode/agent/webapp-builder.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "opencode_agent_webapp_builder_store_data_layer", "label": "Store API surface (data layer the webapp reuses)", "file_type": "concept", "source_file": ".opencode/agent/webapp-builder.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null, "rationale": "Agent must REUSE not duplicate: Store(db_path), cookies.py, segments.py, analysis.py, export.py, pipeline.process_video, config.Config. Active cookie (cookies.resolve_active_path) MUST be passed as cookies_file to every scrape job."}], "edges": [{"source": "opencode_agent_webapp_builder", "target": "docs_superpowers_specs_2026_07_26_platform_design", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".opencode/agent/webapp-builder.md", "source_location": "last line", "weight": 1.0}, {"source": "opencode_agent_webapp_builder", "target": "docs_superpowers_plans_2026_07_26_platform_build", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".opencode/agent/webapp-builder.md", "source_location": "last line", "weight": 1.0}, {"source": "opencode_agent_webapp_builder", "target": "opencode_agent_webapp_builder_store_data_layer", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".opencode/agent/webapp-builder.md", "source_location": "What already exists section", "weight": 1.0}, {"source": "opencode_agent_webapp_builder", "target": "src_yt_scraper_webapp_static_index", "relation": "references", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": ".opencode/agent/webapp-builder.md", "source_location": null, "weight": 1.0}], "hyperedges": [{"id": "webapp_stack_b", "label": "Workstream B webapp stack (FastAPI + Alpine SPA + subagent)", "nodes": ["opencode_agent_webapp_builder", "opencode_goals_webapp_build", "src_yt_scraper_webapp_static_index", "docs_superpowers_specs_2026_07_26_platform_design_dark_command_center", "docs_superpowers_specs_2026_07_26_platform_design_sse_job_runner"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 0.95, "source_file": ".opencode/agent/webapp-builder.md"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "opencode_goals_webapp_build", "label": "Sub-goal: Build the local webapp (Workstream B)", "file_type": "document", "source_file": ".opencode/goals/webapp-build.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "opencode_goals_webapp_build", "target": "opencode_agent_webapp_builder", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".opencode/goals/webapp-build.md", "source_location": "Owner agent line", "weight": 1.0}, {"source": "opencode_goals_webapp_build", "target": "docs_superpowers_specs_2026_07_26_platform_design", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": ".opencode/goals/webapp-build.md", "source_location": "Spec line", "weight": 1.0}], "hyperedges": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "docs_superpowers_plans_2026_07_26_platform_build", "label": "Platform Implementation Plan", "file_type": "document", "source_file": "docs/superpowers/plans/2026-07-26-platform-build.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "docs_superpowers_plans_2026_07_26_platform_build", "target": "docs_superpowers_specs_2026_07_26_platform_design", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "docs/superpowers/plans/2026-07-26-platform-build.md", "source_location": "Spec line", "weight": 1.0}], "hyperedges": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "readme", "label": "yt-channel-scraper README", "file_type": "document", "source_file": "README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_yt_dlp_innertube", "label": "yt-dlp InnerTube mechanism (ANDROID/IOS/WEB clients)", "file_type": "concept", "source_file": "README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "readme", "target": "config_example", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Configuraci\u00f3n section", "weight": 1.0}, {"source": "readme", "target": "readme_yt_dlp_innertube", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "header", "weight": 1.0}], "hyperedges": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "opportunities", "label": "OPPORTUNITIES.md - extension audit", "file_type": "document", "source_file": "OPPORTUNITIES.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "opportunities_search_fts5", "label": "Feature proposal: search (FTS5 transcript search)", "file_type": "concept", "source_file": "OPPORTUNITIES.md", "source_location": "Tier 1 #1", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "opportunities_llm_summaries", "label": "Feature proposal: LLM summaries per video", "file_type": "concept", "source_file": "OPPORTUNITIES.md", "source_location": "Tier 3 #12", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "opportunities_rag_qa", "label": "Feature proposal: Q&A / RAG over channel", "file_type": "concept", "source_file": "OPPORTUNITIES.md", "source_location": "Tier 3 #13", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "opportunities_export_formats", "label": "Feature proposal: multi-format export (json/csv/srt/html)", "file_type": "concept", "source_file": "OPPORTUNITIES.md", "source_location": "Tier 1 #3", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "opportunities_analysis_wordcloud", "label": "Feature proposal: content analysis (wordcloud/top-words/timeline)", "file_type": "concept", "source_file": "OPPORTUNITIES.md", "source_location": "Tier 2 #9", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "opportunities_watch_mode", "label": "Feature proposal: watch mode (periodic discovery)", "file_type": "concept", "source_file": "OPPORTUNITIES.md", "source_location": "Tier 2 #8", "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "opportunities_search_fts5", "target": "docs_superpowers_specs_2026_07_26_platform_design_transcript_fts5", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.95, "source_file": "OPPORTUNITIES.md", "source_location": null, "weight": 1.0}, {"source": "opportunities_export_formats", "target": "opencode_agent_webapp_builder_store_data_layer", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.65, "source_file": "OPPORTUNITIES.md", "source_location": null, "weight": 1.0}, {"source": "opportunities_watch_mode", "target": "docs_superpowers_specs_2026_07_26_platform_design_sse_job_runner", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.65, "source_file": "OPPORTUNITIES.md", "source_location": null, "weight": 1.0}, {"source": "opportunities_analysis_wordcloud", "target": "opencode_agent_webapp_builder_store_data_layer", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.65, "source_file": "OPPORTUNITIES.md", "source_location": null, "weight": 1.0}], "hyperedges": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "config_example", "label": "config.example.yaml", "file_type": "document", "source_file": "config.example.yaml", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [], "hyperedges": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
{
"runs": [
{
"date": "2026-07-27T05:54:56.822038+00:00",
"input_tokens": 11800,
"output_tokens": 4100,
"files": 37
}
],
"total_input_tokens": 11800,
"total_output_tokens": 4100
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
{
"pyproject.toml": {
"mtime": 1785126752.9759955,
"ast_hash": "f80dc007b3b67c7f7a78d9df01a81ead",
"semantic_hash": "f80dc007b3b67c7f7a78d9df01a81ead"
},
"scripts/start-server.ps1": {
"mtime": 1785130613.533591,
"ast_hash": "9baf397df6c14b7917ca59fbe6df61cc",
"semantic_hash": "9baf397df6c14b7917ca59fbe6df61cc"
},
"scripts/stop-server.ps1": {
"mtime": 1785127403.2329483,
"ast_hash": "4398501c202a3eb624fb439f6d03fcf5",
"semantic_hash": "4398501c202a3eb624fb439f6d03fcf5"
},
"src/yt_scraper/__init__.py": {
"mtime": 1785120608.3336751,
"ast_hash": "4867131295172353fe6c2295851defc7",
"semantic_hash": "4867131295172353fe6c2295851defc7"
},
"src/yt_scraper/analysis.py": {
"mtime": 1785126463.1545029,
"ast_hash": "c28d08131e6594e1e7c6111ff01b2d37",
"semantic_hash": "c28d08131e6594e1e7c6111ff01b2d37"
},
"src/yt_scraper/chapters.py": {
"mtime": 1785120675.3308973,
"ast_hash": "1bf51b7c13486b9db9bcc3420fba2593",
"semantic_hash": "1bf51b7c13486b9db9bcc3420fba2593"
},
"src/yt_scraper/cli.py": {
"mtime": 1785126572.0575886,
"ast_hash": "9734cedf7c1e045e1ab0e4a102860e29",
"semantic_hash": "9734cedf7c1e045e1ab0e4a102860e29"
},
"src/yt_scraper/config.py": {
"mtime": 1785120620.335521,
"ast_hash": "2f3c8f7768a942e12c0a9e41b54891be",
"semantic_hash": "2f3c8f7768a942e12c0a9e41b54891be"
},
"src/yt_scraper/cookies.py": {
"mtime": 1785126417.4218228,
"ast_hash": "dd5ec44bb4c6e6375be80f21307fc73d",
"semantic_hash": "dd5ec44bb4c6e6375be80f21307fc73d"
},
"src/yt_scraper/discover.py": {
"mtime": 1785120708.331971,
"ast_hash": "8db06d0b131575f709b55467eabdc2a9",
"semantic_hash": "8db06d0b131575f709b55467eabdc2a9"
},
"src/yt_scraper/export.py": {
"mtime": 1785126439.6829402,
"ast_hash": "f1cff31fb1004f08e997655f838e3a58",
"semantic_hash": "f1cff31fb1004f08e997655f838e3a58"
},
"src/yt_scraper/extract.py": {
"mtime": 1785122183.7294595,
"ast_hash": "3e2179db2f7fd316d2b3acedee536086",
"semantic_hash": "3e2179db2f7fd316d2b3acedee536086"
},
"src/yt_scraper/monitor.py": {
"mtime": 1785126496.288271,
"ast_hash": "a64188f938dc4f8917335ef41d333ead",
"semantic_hash": "a64188f938dc4f8917335ef41d333ead"
},
"src/yt_scraper/parse.py": {
"mtime": 1785120665.3384771,
"ast_hash": "25354365ac1ab52f1561c97256573806",
"semantic_hash": "25354365ac1ab52f1561c97256573806"
},
"src/yt_scraper/pipeline.py": {
"mtime": 1785131195.7311583,
"ast_hash": "ebce1b294140bf40f809848ef280c290",
"semantic_hash": "ebce1b294140bf40f809848ef280c290"
},
"src/yt_scraper/ratelimit.py": {
"mtime": 1785120650.3795395,
"ast_hash": "6082befe56d342fc43abd5299dd6c6ea",
"semantic_hash": "6082befe56d342fc43abd5299dd6c6ea"
},
"src/yt_scraper/render.py": {
"mtime": 1785120688.3247268,
"ast_hash": "ff83764f3a8c8874557993e11eab1f89",
"semantic_hash": "ff83764f3a8c8874557993e11eab1f89"
},
"src/yt_scraper/segments.py": {
"mtime": 1785128385.6652262,
"ast_hash": "09c7da35bc09a3f75fd0a627c0534746",
"semantic_hash": "09c7da35bc09a3f75fd0a627c0534746"
},
"src/yt_scraper/store.py": {
"mtime": 1785131583.548138,
"ast_hash": "9a3d4286214aa54ae4ac4df2097b643d",
"semantic_hash": "9a3d4286214aa54ae4ac4df2097b643d"
},
"src/yt_scraper/webapp/__init__.py": {
"mtime": 1785126827.4957752,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"src/yt_scraper/webapp/api.py": {
"mtime": 1785131595.9914868,
"ast_hash": "e64174f04d6eab0747b1d2a7c2a96a01",
"semantic_hash": "e64174f04d6eab0747b1d2a7c2a96a01"
},
"src/yt_scraper/webapp/app.py": {
"mtime": 1785126888.8032887,
"ast_hash": "e0b1925b4318305dcf746d360373ff50",
"semantic_hash": "e0b1925b4318305dcf746d360373ff50"
},
"src/yt_scraper/webapp/jobs.py": {
"mtime": 1785131211.0397103,
"ast_hash": "8b6c80001ff8c95eaaa16f7b61d1f207",
"semantic_hash": "8b6c80001ff8c95eaaa16f7b61d1f207"
},
"src/yt_scraper/webapp/static/app.js": {
"mtime": 1785131664.8792574,
"ast_hash": "f6b081fadc5884d379152abbd1e59700",
"semantic_hash": "f6b081fadc5884d379152abbd1e59700"
},
"tests/test_chapters.py": {
"mtime": 1785120789.3300896,
"ast_hash": "6d5303fa86de8dc99af6ae2ee17da033",
"semantic_hash": "6d5303fa86de8dc99af6ae2ee17da033"
},
"tests/test_features.py": {
"mtime": 1785126709.65728,
"ast_hash": "21f9c44ce7cc1d618ece402ab7c2b057",
"semantic_hash": "21f9c44ce7cc1d618ece402ab7c2b057"
},
"tests/test_parse.py": {
"mtime": 1785120974.3283188,
"ast_hash": "57ce74ee10fe6f9a90126ec7d9fd96b0",
"semantic_hash": "57ce74ee10fe6f9a90126ec7d9fd96b0"
},
"tests/test_render.py": {
"mtime": 1785120806.3260238,
"ast_hash": "27125960e235813724fd669001faab59",
"semantic_hash": "27125960e235813724fd669001faab59"
},
"tests/test_store_platform.py": {
"mtime": 1785126662.4772756,
"ast_hash": "bad13dcf37be7a414ffae8018c29e55b",
"semantic_hash": "bad13dcf37be7a414ffae8018c29e55b"
},
".opencode/agent/webapp-builder.md": {
"mtime": 1785126783.7152567,
"ast_hash": "58e92641724400370d7823dce1e02ca3",
"semantic_hash": "58e92641724400370d7823dce1e02ca3"
},
".opencode/goals/webapp-build.md": {
"mtime": 1785126796.8402326,
"ast_hash": "478e7a2350ded2fd273cfdb0714d228d",
"semantic_hash": "478e7a2350ded2fd273cfdb0714d228d"
},
"OPPORTUNITIES.md": {
"mtime": 1785122706.3466253,
"ast_hash": "4af60e3106b394664fbf5b5c5f645804",
"semantic_hash": "4af60e3106b394664fbf5b5c5f645804"
},
"README.md": {
"mtime": 1785121499.3282604,
"ast_hash": "a0a8e6b62a991fce5d6c58171f80075e",
"semantic_hash": "a0a8e6b62a991fce5d6c58171f80075e"
},
"config.example.yaml": {
"mtime": 1785120602.4114335,
"ast_hash": "415b0fc8e71ecc305d21e9c3c08d585a",
"semantic_hash": "415b0fc8e71ecc305d21e9c3c08d585a"
},
"docs/superpowers/plans/2026-07-26-platform-build.md": {
"mtime": 1785126240.4211702,
"ast_hash": "8097056ef23068f22c48151746de305b",
"semantic_hash": "8097056ef23068f22c48151746de305b"
},
"docs/superpowers/specs/2026-07-26-platform-design.md": {
"mtime": 1785126024.4879057,
"ast_hash": "9b85f02fd45c93cdabfd2e1c6d1e0c9e",
"semantic_hash": "9b85f02fd45c93cdabfd2e1c6d1e0c9e"
},
"src/yt_scraper/webapp/static/index.html": {
"mtime": 1785131695.7653384,
"ast_hash": "13591b7c8c662d8e7c2540f3032ae26d",
"semantic_hash": "13591b7c8c662d8e7c2540f3032ae26d"
}
}
+16
View File
@@ -575,6 +575,22 @@ class Store:
cur.execute("SELECT * FROM scrape_jobs ORDER BY started_at DESC LIMIT ?", (limit,))
return [_row_to_jobrow(r) for r in cur.fetchall()]
TERMINAL_STATUSES = ("done", "error", "cancelled")
def delete_job(self, job_id: str) -> bool:
with self._cursor() as cur:
cur.execute("DELETE FROM scrape_jobs WHERE id = ?", (job_id,))
return cur.rowcount > 0
def delete_terminal_jobs(self) -> int:
placeholders = ",".join("?" for _ in self.TERMINAL_STATUSES)
with self._cursor() as cur:
cur.execute(
f"DELETE FROM scrape_jobs WHERE status IN ({placeholders})",
list(self.TERMINAL_STATUSES),
)
return cur.rowcount
# ------------------------------------------------------------------ aggregates
def stats(self, channel_id: str | None = None) -> dict[str, int]:
+20 -1
View File
@@ -134,8 +134,19 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
await asyncio.sleep(0.25)
return EventSourceResponse(gen())
@r.delete("/scrape/history")
def clear_job_history():
n = store.delete_terminal_jobs()
return {"deleted": n}
@r.delete("/scrape/{job_id}")
def cancel_job(job_id: str):
def delete_or_cancel_job(job_id: str):
job = store.get_job(job_id)
if not job:
raise HTTPException(404, "job not found")
if job.status in store.TERMINAL_STATUSES:
store.delete_job(job_id)
return {"deleted": job_id}
ok = jobs.cancel(job_id)
return {"cancelled": ok}
@@ -329,6 +340,14 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
raise HTTPException(404, "markdown file missing on disk")
return FileResponse(str(p), filename=p.name, media_type="text/markdown")
@r.api_route("/videos/{video_id}/audio", methods=["GET", "HEAD"])
def video_audio(video_id: str, request: Request):
# audio is stored as data/audio/<video_id>.mp3 (see jobs._run_audio outtmpl)
p = Path(cfg.output_dir_resolved).parent / "audio" / f"{video_id}.mp3"
if not p.exists():
raise HTTPException(404, "audio not downloaded yet")
return FileResponse(str(p), filename=f"{video_id}.mp3", media_type="audio/mpeg")
# -------------------------------------------------- thumbnails (local cache)
@r.post("/tools/thumbnails")
async def download_thumbnails(payload: dict):
+1 -1
View File
@@ -167,7 +167,7 @@ class JobManager:
self._emit(job_id, "log", {"msg": f"downloading {total} audio tracks -> {out_dir}"})
ydl_opts = {
"format": "bestaudio/best",
"outtmpl": str(out_dir / "%(title)s.%(ext)s"),
"outtmpl": str(out_dir / "%(id)s.%(ext)s"),
"postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "128"}],
"quiet": True, "no_warnings": True, "noprogress": True,
}
+101 -4
View File
@@ -37,7 +37,7 @@
dash: { channels: [], status_breakdown: {}, uploads_over_time: [], duration_histogram: [], top_tags: [] },
channels: { items: [], pending: {} },
videos: { items: [], total: 0, page: 1, size: 25, selected: [] },
detail: { video: null, transcript: [] },
detail: { video: null, transcript: [], hasAudio: false, audioPlaying: false, activeSeg: -1 },
search: { q: "", channel: "", items: [], ran: false },
analysis: { tab: "wordcloud", channel: "", term: "", words: [], timeline: [] },
@@ -55,7 +55,7 @@
stats: { open: false, data: null, loading: false, channel: "" },
// ----- floating job widget -----
jobWidget: { collapsed: false },
jobWidget: { collapsed: false, dismissed: false, _hideTimer: null },
// ----- tools / folders -----
folders: { markdown: { path: "", exists: false }, exports: { path: "", exists: false }, audio: { path: "", exists: false }, analysis: { path: "", exists: false }, database: { path: "", exists: false } },
@@ -228,7 +228,7 @@
this.view = "detail";
this.loading.detail = true;
this.loading.transcript = true;
this.detail = { video: null, transcript: [] };
this.detail = { video: null, transcript: [], hasAudio: false, audioPlaying: false, activeSeg: -1 };
try {
const v = await this.api("/api/videos/" + encodeURIComponent(id));
// chapters may come embedded or be absent
@@ -241,6 +241,73 @@
this.detail.transcript = (t && t.items) || [];
} catch (_) { this.detail.transcript = []; }
finally { this.loading.transcript = false; }
// probe audio availability (HEAD) for the integrated player
this.checkAudio(id);
},
async checkAudio(id) {
try {
const r = await fetch("/api/videos/" + encodeURIComponent(id) + "/audio", { method: "HEAD" });
const ok = !!(r && r.ok);
if (this.detail.video && this.detail.video.video_id === id) this.detail.hasAudio = ok;
return ok;
} catch (_) { return false; }
},
async downloadAudioOne(id) {
try {
const d = await this.api("/api/tools/audio", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: [id] }) });
this.toast("Audio download started — player will activate when ready");
this.subscribeJob(d.job_id, "audio");
this._pollAudio(id);
} catch (e) { this.toast("Audio failed: " + e.message, "error"); }
},
_pollAudio(id) {
if (this._audioPoll) clearInterval(this._audioPoll);
let attempts = 0;
this._audioPoll = setInterval(async () => {
attempts++;
const ready = await this.checkAudio(id);
if ((ready && this.detail.video && this.detail.video.video_id === id) || attempts > 90) {
clearInterval(this._audioPoll); this._audioPoll = null;
if (ready && this.detail.video && this.detail.video.video_id === id) this.toast("Audio ready — press play ▶");
}
}, 4000);
},
// audio playback -> transcript follower
onAudioTime(e) {
const t = (e && e.target && typeof e.target.currentTime === "number") ? e.target.currentTime : 0;
const segs = this.detail.transcript || [];
if (!segs.length) return;
let active = -1;
for (let i = 0; i < segs.length; i++) {
if ((segs[i].start_sec || 0) <= t + 0.05) active = segs[i].idx;
else break;
}
if (active !== this.detail.activeSeg) {
this.detail.activeSeg = active;
this.$nextTick(() => {
const el = document.getElementById("seg-" + active);
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
});
}
},
seekAudio(sec) {
const a = this.$refs.audioPlayer;
if (!a) return false;
try { a.currentTime = sec; a.play().catch(() => {}); } catch (_) {}
return true;
},
seekTo(sec) {
// seek the local audio player if available; otherwise just scroll the transcript
if (this.detail.hasAudio && this.$refs.audioPlayer) {
if (this.seekAudio(sec)) return;
}
this.seekTranscript(sec);
},
seekTranscript(sec) {
@@ -563,6 +630,8 @@
this.scrape.log = ["[stream] connecting…"];
this.scrape.progress = { completed: 0, total: 0 };
this.jobWidget.collapsed = false;
this.jobWidget.dismissed = false;
this._disarmAutoHide();
const es = new EventSource("/api/scrape/" + encodeURIComponent(jobId) + "/stream");
this.scrape.es = es;
es.addEventListener("log", e => { try { const d = JSON.parse(e.data); this.scrape.log.push(d.msg || ""); } catch (_) {} });
@@ -577,13 +646,15 @@
if (this.view === "videos") this.loadVideos();
if (this.view === "channels") this.loadChannels();
if (this.view === "dashboard") this.loadDashboard();
this._armAutoHide();
});
es.addEventListener("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); });
es.addEventListener("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); this._armAutoHide(); });
es.addEventListener("error", e => {
if (this.scrape.es === null) return; // already closed by done/cancelled
let msg = "connection error";
try { if (e.data) { const d = JSON.parse(e.data); msg = d.msg || msg; this.scrape.error = msg; this.scrape.log.push("[error] " + msg); } } catch (_) { this.scrape.error = msg; }
this.closeStream(); this.loadJobs();
this._armAutoHide();
});
},
@@ -599,6 +670,32 @@
catch (e) { this.toast("Delete failed: " + e.message, "error"); }
},
closeJobWidget() {
if (this.scrape.jobId && !this.scrape.done && !this.scrape.error) {
this.jobWidget.collapsed = true;
return;
}
this._disarmAutoHide();
this.jobWidget.dismissed = true;
},
_armAutoHide() {
this._disarmAutoHide();
this.jobWidget._hideTimer = setTimeout(() => { this.jobWidget.dismissed = true; }, 6000);
},
_disarmAutoHide() {
if (this.jobWidget._hideTimer) { clearTimeout(this.jobWidget._hideTimer); this.jobWidget._hideTimer = null; }
},
async clearJobHistory() {
if (!confirm("Clear all finished jobs from history?\nDownloaded .md files and video data are NOT affected.")) return;
try {
const d = await this.api("/api/scrape/history", { method: "DELETE" });
this.toast("Cleared " + ((d && d.deleted) || 0) + " finished job(s)", "success");
this.loadJobs();
} catch (e) { this.toast("Clear failed: " + e.message, "error"); }
},
jobIsTerminal(j) { return j && ["done", "error", "cancelled"].includes(j.status); },
jobActionLabel(j) { return this.jobIsTerminal(j) ? "Clear" : "Cancel"; },
scrapePct() {
const p = this.scrape.progress || {};
if (!p.total) return this.scrape.done ? 100 : 0;
+48 -11
View File
@@ -292,7 +292,9 @@
</div>
<div class="flex flex-wrap gap-2 mt-4">
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="detail.video.status==='done' ? downloadMd(detail.video.video_id) : processOne(detail.video.video_id)" x-text="detail.video.status==='done' ? 'Download .md' : 'Process to .md'"></button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadThumbnails([detail.video.video_id])">Thumbnail</button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudioOne(detail.video.video_id)" x-show="detail.video.status==='done'" x-text="detail.hasAudio ? 'Re-download audio' : 'Download audio'">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="openClip(detail.video.video_id)">Clip</button>
<template x-if="detail.video.url"><a class="btn btn-ghost !py-1.5 !px-3 text-xs" :href="detail.video.url" target="_blank" rel="noopener">Open on YouTube ↗</a></template>
</div>
@@ -302,13 +304,38 @@
</div>
</div>
<!-- audio player + synced transcript follower -->
<div class="glass p-4" x-show="detail.video && detail.video.status==='done'">
<template x-if="detail.hasAudio">
<div class="space-y-2">
<div class="flex items-center gap-2">
<audio x-ref="audioPlayer" :src="'/api/videos/'+detail.video.video_id+'/audio'" controls preload="metadata" class="w-full audio-bar"
@timeupdate="onAudioTime($event)" @pause="detail.audioPlaying=false" @play="detail.audioPlaying=true" @ended="detail.activeSeg=-1; detail.audioPlaying=false"></audio>
</div>
<div class="text-xs text-zinc-500 flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full" :class="detail.audioPlaying ? 'bg-rose-500 animate-pulse' : 'bg-zinc-600'"></span>
<span x-text="detail.audioPlaying ? 'transcript following playback' : 'press play — transcript follows the audio'"></span>
</div>
</div>
</template>
<template x-if="!detail.hasAudio">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="text-sm text-zinc-400">No audio downloaded — download the MP3 to unlock the synced transcript player.</div>
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadAudioOne(detail.video.video_id)">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Download audio
</button>
</div>
</template>
</div>
<div class="grid lg:grid-cols-3 gap-5">
<!-- chapters -->
<div class="glass p-5 lg:col-span-1" x-show="detail.video.has_chapters && (detail.video.chapters||[]).length">
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Chapters</h3>
<div class="space-y-1 max-h-[28rem] overflow-y-auto">
<template x-for="(ch, i) in (detail.video.chapters||[])" :key="i">
<button class="w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded hover:bg-zinc-800/60" @click="seekTranscript(ch.start_sec)">
<button class="w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded hover:bg-zinc-800/60" @click="seekTo(ch.start_sec)">
<span class="font-mono accent-text shrink-0" x-text="ts(ch.start_sec)"></span>
<span class="text-zinc-300 truncate" x-text="ch.title"></span>
</button>
@@ -321,11 +348,15 @@
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Transcript</h3>
<template x-if="loading.transcript"><div class="text-zinc-500 text-sm">loading…</div></template>
<template x-if="!loading.transcript && detail.transcript.length===0"><div class="text-zinc-500 text-sm">No transcript available.</div></template>
<div class="space-y-2 max-h-[34rem] overflow-y-auto pr-1">
<div class="space-y-1 max-h-[34rem] overflow-y-auto pr-1 transcript-scroll">
<template x-for="seg in detail.transcript" :key="seg.idx">
<div class="flex gap-3 text-sm leading-relaxed hover:bg-zinc-800/40 rounded px-2 py-1" :id="'seg-'+seg.idx">
<a class="font-mono accent-text shrink-0 mt-0.5 cursor-pointer" :href="detail.video.url ? detail.video.url+'&t='+Math.floor(seg.start_sec)+'s' : null" target="_blank" rel="noopener" x-text="ts(seg.start_sec)"></a>
<span class="text-zinc-300" x-text="seg.text"></span>
<div class="seg-row flex gap-3 text-sm leading-relaxed rounded-lg px-3 py-1.5 transition-all duration-200"
:class="detail.activeSeg===seg.idx ? 'seg-active' : 'hover:bg-zinc-800/40'"
:id="'seg-'+seg.idx">
<button class="font-mono shrink-0 mt-0.5 cursor-pointer hover:text-rose-300 transition-colors"
:class="detail.activeSeg===seg.idx ? 'accent-text font-bold' : 'text-zinc-500'"
@click="seekTo(seg.start_sec)" x-text="ts(seg.start_sec)"></button>
<span :class="detail.activeSeg===seg.idx ? 'text-white' : 'text-zinc-300'" x-text="seg.text"></span>
</div>
</template>
</div>
@@ -467,8 +498,14 @@
<!-- recent jobs -->
<div class="glass overflow-hidden">
<div class="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between">
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="loadJobs()">Refresh</button>
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
<span class="text-[0.65rem] text-zinc-600 mt-0.5">Clearing history removes job records only — downloaded .md files stay.</span>
</div>
<div class="flex gap-1.5">
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="loadJobs()">Refresh</button>
<button class="btn btn-danger !py-1 !px-2 text-xs" @click="clearJobHistory()" :disabled="scrape.jobs.length === 0">Clear history</button>
</div>
</div>
<table class="tbl">
<thead><tr><th>Channel</th><th>Status</th><th>Progress</th><th>Started</th><th>Finished</th><th></th></tr></thead>
@@ -482,7 +519,7 @@
<td class="font-mono text-xs text-zinc-400" x-text="(j.completed||0)+' / '+(j.total||0)"></td>
<td class="font-mono text-xs text-zinc-500" x-text="j.started_at ? fmtDateTime(j.started_at) : '—'"></td>
<td class="font-mono text-xs text-zinc-500" x-text="j.finished_at ? fmtDateTime(j.finished_at) : '—'"></td>
<td class="text-right"><button class="btn btn-danger !py-1 !px-2 text-xs" @click="deleteJob(j.id)">Delete</button></td>
<td class="text-right"><button class="btn !py-1 !px-2 text-xs" :class="jobIsTerminal(j) ? 'btn-ghost' : 'btn-danger'" @click="deleteJob(j.id)" x-text="jobActionLabel(j)" :title="jobIsTerminal(j) ? 'Remove this job from history (downloads stay)' : 'Cancel this running job'"></button></td>
</tr>
</template>
</tbody>
@@ -749,7 +786,7 @@
</main>
<!-- ============ FLOATING JOB WIDGET ============ -->
<template x-if="scrape.jobId && view !== 'scrape'">
<template x-if="scrape.jobId && view !== 'scrape' && !jobWidget.dismissed">
<div class="job-widget" x-transition.opacity>
<template x-if="!jobWidget.collapsed">
<div class="space-y-2.5">
@@ -759,7 +796,7 @@
<span class="text-xs font-semibold text-white uppercase tracking-wider" x-text="scrape.kind ? scrape.kind + ' job' : 'job'"></span>
<span class="pill !text-[0.6rem] !py-0" :class="scrape.error ? 'st-error' : (scrape.done ? 'st-done' : 'st-running')" x-text="scrape.error ? 'error' : (scrape.done ? 'done' : 'running')"></span>
</div>
<button class="jw-x" @click="jobWidget.collapsed = true" title="Collapse"></button>
<button class="jw-x" @click="closeJobWidget()" :title="(!scrape.done && !scrape.error) ? 'Minimize' : 'Dismiss'"></button>
</div>
<div class="flex justify-between text-[0.7rem] text-zinc-500">
<span>Progress</span>
+18
View File
@@ -235,3 +235,21 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp
}
.stat-lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; color: #71717a; font-weight: 600; }
.stat-val { font-size: 1.25rem; font-weight: 700; color: #fff; font-family: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace; margin-top: 0.15rem; }
/* ===== audio player + synced transcript follower ===== */
.audio-bar { filter: invert(0.92) hue-rotate(170deg) sepia(0.15); height: 36px; }
.audio-bar::-webkit-media-controls-panel { background: rgba(24,24,27,0.85); }
/* the transcript segment currently matching audio playback */
.seg-row { border-left: 2px solid transparent; }
.seg-active {
background: linear-gradient(90deg, rgba(244,63,94,0.18) 0%, rgba(244,63,94,0.04) 100%);
border-left-color: #f43f5e;
box-shadow: 0 0 0 1px rgba(244,63,94,0.15);
}
.seg-active .accent-text { color: #fb7185; }
/* smooth scrolling within the transcript pane */
.transcript-scroll { scroll-behavior: smooth; }
.transcript-scroll::-webkit-scrollbar { width: 6px; }
.transcript-scroll::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 4px; }