diff --git a/src/yt_scraper/store.py b/src/yt_scraper/store.py
index c801b99..4c28bf0 100644
--- a/src/yt_scraper/store.py
+++ b/src/yt_scraper/store.py
@@ -58,6 +58,12 @@ _VIDEO_COLUMNS: dict[str, str] = {
# 1 cuando upload_date viene del discovery aproximado (texto relativo de
# YouTube: "hace 3 semanas"), 0/NULL cuando es exacto (extraccion).
"upload_date_approx": "INTEGER DEFAULT 0",
+ "video_download_status": "TEXT DEFAULT 'not_downloaded'",
+ "video_path": "TEXT",
+ "video_filename": "TEXT",
+ "video_size": "INTEGER",
+ "video_downloaded_at": "TEXT",
+ "video_error": "TEXT",
}
_CHANNEL_COLUMNS: dict[str, str] = {
@@ -247,6 +253,18 @@ def _order_clause(sort: str | None) -> str:
"likes_desc": by_likes,
"title": f"videos.title COLLATE NOCASE ASC, {_NEWEST_FIRST}",
"title_asc": f"videos.title COLLATE NOCASE ASC, {_NEWEST_FIRST}",
+ # Por nombre de canal (no por UUID): el subquery consulta channels,
+ # que tiene decenas de filas y PK sobre channel_id.
+ "channel": (
+ "(SELECT c.name FROM channels c WHERE c.channel_id = videos.channel_id) "
+ "COLLATE NOCASE ASC, videos.channel_id, videos.channel_seq DESC, videos.video_id DESC"
+ ),
+ # Agrupa por ciclo de vida: hecho, luego pendientes, luego los que
+ # necesitan atencion (sin subs), errores al final.
+ "status": (
+ "CASE videos.status WHEN 'done' THEN 0 WHEN 'pending' THEN 1 "
+ "WHEN 'no_subtitles' THEN 2 ELSE 3 END ASC, " + _NEWEST_FIRST
+ ),
}.get((sort or "").strip(), _NEWEST_FIRST)
@@ -296,6 +314,12 @@ class VideoRow:
channel_seq: int | None = None
# 1 cuando upload_date es aproximado (discovery), 0/NULL si es exacto.
upload_date_approx: int | None = None
+ video_download_status: str | None = None
+ video_path: str | None = None
+ video_filename: str | None = None
+ video_size: int | None = None
+ video_downloaded_at: str | None = None
+ video_error: str | None = None
# Date the row was ordered by. Equals `upload_date` when it is known; for a
# video discovery has not extracted yet it is inferred from `channel_seq`
# (see `query_videos`). Only populated by queries that compute it.
@@ -764,6 +788,31 @@ class Store:
(status, reason[:2000] if reason else None, now, video_id),
)
+ def update_video_download(
+ self,
+ video_id: str,
+ status: str,
+ *,
+ path: str | None = None,
+ filename: str | None = None,
+ size: int | None = None,
+ error: str | None = None,
+ ) -> None:
+ """Persist media-download state independently from transcript state."""
+ now = _now_iso() if status == "done" else None
+ with self._cursor() as cur:
+ cur.execute(
+ """UPDATE videos SET
+ video_download_status = ?,
+ video_path = COALESCE(?, video_path),
+ video_filename = COALESCE(?, video_filename),
+ video_size = COALESCE(?, video_size),
+ video_downloaded_at = COALESCE(?, video_downloaded_at),
+ video_error = ?
+ WHERE video_id = ?""",
+ (status, path, filename, size, now, error[:2000] if error else None, video_id),
+ )
+
def set_availability(self, video_id: str, availability: str | None) -> None:
"""Record what a full extraction learned; more authoritative than the
flat listing, which omits the field for most entries."""
@@ -1194,6 +1243,12 @@ def _row_to_videorow(row: sqlite3.Row) -> VideoRow:
availability=row["availability"] if "availability" in keys else None,
channel_seq=row["channel_seq"] if "channel_seq" in keys else None,
upload_date_approx=row["upload_date_approx"] if "upload_date_approx" in keys else None,
+ video_download_status=row["video_download_status"] if "video_download_status" in keys else None,
+ video_path=row["video_path"] if "video_path" in keys else None,
+ video_filename=row["video_filename"] if "video_filename" in keys else None,
+ video_size=row["video_size"] if "video_size" in keys else None,
+ video_downloaded_at=row["video_downloaded_at"] if "video_downloaded_at" in keys else None,
+ video_error=row["video_error"] if "video_error" in keys else None,
sort_date=row["sort_date"] if "sort_date" in keys else None,
)
diff --git a/src/yt_scraper/webapp/static/index.html b/src/yt_scraper/webapp/static/index.html
index ef7f51c..c4d1203 100644
--- a/src/yt_scraper/webapp/static/index.html
+++ b/src/yt_scraper/webapp/static/index.html
@@ -313,6 +313,8 @@
Likes ▼
Duration ▼
Title A→Z
+ Channel A→Z
+ Status
Duration Views Status Actions
@@ -402,6 +404,11 @@
+
+
+
+
+ Download MKV
Clip
Open on YouTube ↗
@@ -418,6 +425,40 @@
+
+
+
+
+
+
Este navegador no pudo reproducir el MKV directamente.
+
Puedes descargar el archivo y abrirlo con VLC u otro reproductor local.
+
Descargar MKV
+
+
+
+
+
+
+
+ Downloading video…
+
+
+
+
+
+ /
+ ·
+
+
+
@@ -624,11 +665,11 @@
Recent jobs
- Clearing history removes job records only — downloaded .md files stay.
+ Removes job records only — downloaded .md files and video statuses (done / error / no_subtitles) are NOT affected. To re-queue stuck videos, use "Retry X stuck" on the Videos tab.
Refresh
- Clear history
+ Clear history
@@ -643,7 +684,7 @@
-
+
diff --git a/src/yt_scraper/webapp/static/styles.css b/src/yt_scraper/webapp/static/styles.css
index e5d4ee7..8be30ea 100644
--- a/src/yt_scraper/webapp/static/styles.css
+++ b/src/yt_scraper/webapp/static/styles.css
@@ -69,6 +69,10 @@ body::before {
.nav-item .nav-ico { width: 1.1rem; height: 1.1rem; opacity: 0.9; }
/* ---- form controls ---- */
+/* El popup nativo de se pinta con el esquema del UA: sin esto abre
+ blanco con texto claro heredado y queda ilegible sobre la UI oscura. */
+select { color-scheme: dark; }
+option { background-color: #18181b; color: #e4e4e7; }
.field {
background: #09090b;
border: 1px solid #27272a;
@@ -116,6 +120,9 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp
.st-running{ background: rgba(59, 130, 246, 0.12); color: #60a5fa; border-color: rgba(59, 130, 246, 0.35); }
.st-skipped{ background: rgba(161, 161, 170, 0.12); color: #a1a1aa; border-color: rgba(161, 161, 170, 0.35); }
.st-new { background: rgba(168, 85, 247, 0.12); color: #c084fc; border-color: rgba(168, 85, 247, 0.35); }
+/* Gated content: a dashed border reads as "not available to you" rather than
+ as one more processing state, so it never gets confused with st-pending. */
+.st-locked { background: rgba(217, 119, 6, 0.10); color: #fbbf24; border-color: rgba(217, 119, 6, 0.45); border-style: dashed; }
/* ---- progress bar ---- */
.progress-track { background: #18181b; border-radius: 999px; overflow: hidden; height: 0.6rem; border: 1px solid #27272a; }
@@ -151,6 +158,15 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp
/* thumbnail */
.thumb { border-radius: 0.5rem; object-fit: cover; background: #18181b; }
+/* inline keyboard hint */
+.kbd { display: inline-block; padding: 0.05rem 0.4rem; font-family: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace; font-size: 0.7rem; color: #d4d4d8; background: #18181b; border: 1px solid #3f3f46; border-radius: 0.3rem; box-shadow: inset 0 -1px 0 #27272a; }
+
+/* initial-letter fallback for avatars / thumbnails */
+.avatar-initial { background: linear-gradient(135deg, #27272a, #3f3f46); color: #d4d4d8; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; text-transform: uppercase; user-select: none; }
+
+/* channel avatar */
+.avatar { border-radius: 9999px; object-fit: cover; background: #18181b; border: 1px solid #27272a; }
+
/* fade-up transition for views */
.fade-enter-active { transition: all 0.22s ease; }
.fade-enter-from { opacity: 0; transform: translateY(6px); }
@@ -240,6 +256,26 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp
.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); }
+/* ===== local cinema player ===== */
+.cinema-card {
+ background: #030305; border: 1px solid #27272a; border-radius: 1rem;
+ overflow: hidden; box-shadow: 0 24px 80px rgba(0,0,0,0.45);
+}
+.cinema-stage {
+ position: relative; aspect-ratio: 16 / 9; background: #000;
+ display: flex; align-items: center; justify-content: center;
+}
+.cinema-video { width: 100%; height: 100%; object-fit: contain; background: #000; }
+.cinema-error {
+ position: absolute; inset: 0; display: flex; flex-direction: column;
+ align-items: center; justify-content: center; text-align: center; padding: 1.5rem;
+ background: rgba(0,0,0,0.78);
+}
+.cinema-meta {
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
+ padding: 0.85rem 1rem; background: rgba(24,24,27,0.8);
+}
+
/* the transcript segment currently matching audio playback */
.seg-row { border-left: 2px solid transparent; }
.seg-active {
diff --git a/tests/test_video_sort_keys.py b/tests/test_video_sort_keys.py
new file mode 100644
index 0000000..3d73745
--- /dev/null
+++ b/tests/test_video_sort_keys.py
@@ -0,0 +1,53 @@
+"""Ordenes de sort por canal y por estado (query_videos).
+
+El dropdown de la UI expone Channel A→Z y Status; el backend debe agrupar
+por nombre de canal legible (no UUID) y por ciclo de vida
+(done → pending → no_subtitles → error), no alfabeticamente.
+"""
+
+from yt_scraper.store import Store, VideoRef
+
+
+def _store(tmp_path):
+ s = Store(tmp_path / "test.db")
+ # Beta alfabetiza antes que Alpha en channel_id pero despues en nombre.
+ s.upsert_channel("UCzzz", None, "Beta")
+ s.upsert_channel("UCaaa", None, "Alpha")
+ return s
+
+
+def _ref(vid, ch, date=None, pos=None):
+ return VideoRef(video_id=vid, channel_id=ch, title="t " + vid,
+ url=f"https://youtu.be/{vid}", upload_date=date, position=pos)
+
+
+def test_sort_by_channel_name_groups_and_orders(tmp_path):
+ s = _store(tmp_path)
+ # position respeta el orden del listado de YouTube (0 = mas nuevo).
+ s.upsert_videos([
+ _ref("b1", "UCzzz", "20260201", pos=1),
+ _ref("a2", "UCaaa", "20260101", pos=1),
+ _ref("a1", "UCaaa", "20260301", pos=0),
+ _ref("b0", "UCzzz", "20260401", pos=0),
+ ])
+ rows, _ = s.query_videos(sort="channel")
+ ids = [r.video_id for r in rows]
+ # Alpha primero por nombre; dentro del canal manda el rank del listado.
+ assert ids == ["a1", "a2", "b0", "b1"]
+
+
+def test_sort_by_status_lifecycle(tmp_path):
+ s = _store(tmp_path)
+ s.upsert_videos([
+ _ref("p1", "UCaaa"), # pending
+ _ref("d1", "UCaaa", "20260101"),
+ _ref("e1", "UCzzz"),
+ _ref("d2", "UCzzz", "20260201"),
+ ])
+ s.mark_done("d1", "md/d1.md", "es", "manual", False)
+ s.mark_done("d2", "md/d2.md", "es", "manual", False)
+ s.mark_error("e1", "boom")
+ rows, _ = s.query_videos(sort="status")
+ statuses = [r.status for r in rows]
+ assert statuses[0:2] == ["done", "done"]
+ assert "pending" in statuses and "error" in statuses[-2:]