feat: orden por canal y estado + selects oscuros legibles (color-scheme)

This commit is contained in:
urieljareth
2026-08-22 17:21:24 -06:00
parent e219cfcbfa
commit ca2ae23f70
4 changed files with 188 additions and 3 deletions
+55
View File
@@ -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,
)
+44 -3
View File
@@ -313,6 +313,8 @@
<option value="like_count">Likes ▼</option>
<option value="duration">Duration ▼</option>
<option value="title">Title A→Z</option>
<option value="channel">Channel A→Z</option>
<option value="status">Status</option>
</select>
</th>
<th>Duration</th><th>Views</th><th>Status</th><th class="text-right">Actions</th>
@@ -402,6 +404,11 @@
<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 accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadVideoOne(detail.video.video_id)" x-show="detail.video.status==='done' && detail.video.video_download_status!=='done'" :disabled="detail.video.video_download_status==='queued' || detail.video.video_download_status==='downloading'">
<svg x-show="detail.video.video_download_status==='queued' || detail.video.video_download_status==='downloading'" class="spin w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
<span x-text="detail.video.video_download_status==='queued' || detail.video.video_download_status==='downloading' ? 'Downloading video…' : 'Download video · 1080p MKV'"></span>
</button>
<a class="btn btn-ghost !py-1.5 !px-3 text-xs" x-show="detail.video.video_download_status==='done'" :href="'/api/videos/'+detail.video.video_id+'/media'" :download="detail.video.video_filename || ''">Download MKV</a>
<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>
@@ -418,6 +425,40 @@
</div>
</div>
<!-- local cinema player; the browser supplies the decoder while this shell supplies the experience -->
<div class="cinema-card" x-show="detail.video && detail.video.video_download_status==='done'">
<div class="cinema-stage">
<video x-ref="videoPlayer" class="cinema-video" controls playsinline preload="metadata"
:src="'/api/videos/'+detail.video.video_id+'/media'"
@error="onVideoError()"></video>
<div class="cinema-error" x-show="detail.videoError">
<div class="text-white font-semibold">Este navegador no pudo reproducir el MKV directamente.</div>
<div class="text-sm text-zinc-400 mt-1">Puedes descargar el archivo y abrirlo con VLC u otro reproductor local.</div>
<a class="btn btn-ghost mt-3" :href="'/api/videos/'+detail.video.video_id+'/media'" :download="detail.video.video_filename || ''">Descargar MKV</a>
</div>
</div>
<div class="cinema-meta">
<div>
<div class="text-white font-semibold" x-text="detail.video.title"></div>
<div class="text-xs text-zinc-500 mt-1">1080p · MKV · <span x-text="fmtBytes(detail.video.video_size)"></span></div>
</div>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="openVideoPlayer()">Play</button>
</div>
</div>
<div class="glass p-4" x-show="detail.video && (detail.video.video_download_status==='queued' || detail.video.video_download_status==='downloading')">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-zinc-300">Downloading video…</span>
<span class="font-mono text-zinc-400" x-text="detail.video.video_progress && detail.video.video_progress.percent != null ? detail.video.video_progress.percent + '%' : 'preparing' "></span>
</div>
<div class="progress-track mt-3"><div class="progress-fill" :style="'width:' + ((detail.video.video_progress && detail.video.video_progress.percent) || 0) + '%' "></div></div>
<div class="text-xs text-zinc-500 mt-2" x-show="detail.video.video_progress">
<span x-text="fmtBytes(detail.video.video_progress.downloaded_bytes)"></span>
<span x-show="detail.video.video_progress.total_bytes"> / <span x-text="fmtBytes(detail.video.video_progress.total_bytes)"></span></span>
<span class="ml-2" x-show="detail.video.video_progress.speed">· <span x-text="fmtSpeed(detail.video.video_progress.speed)"></span></span>
</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">
@@ -624,11 +665,11 @@
<div class="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between">
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
<span class="text-[0.65rem] text-zinc-500 mt-0.5">Clearing history removes job records only — downloaded .md files stay.</span>
<span class="text-[0.65rem] text-zinc-500 mt-0.5">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.</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>
<button class="btn btn-danger !py-1 !px-2 text-xs" @click="clearJobHistory()" :disabled="scrape.jobs.length === 0" title="Remove finished job records from this list. Does not change video statuses.">Clear history</button>
</div>
</div>
<table class="tbl">
@@ -643,7 +684,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 !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>
<td class="text-right"><button class="btn btn-ghost !py-1 !px-2 text-xs" @click="deleteJob(j.id)" x-text="'Clear'" :title="jobIsTerminal(j) ? 'Remove this finished job from history (downloads stay)' : 'Force-remove this row (the job itself is being managed elsewhere or never started)'"></button></td>
</tr>
</template>
</tbody>
+36
View File
@@ -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 <select> 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 {
+53
View File
@@ -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:]