feat: orden por canal y estado + selects oscuros legibles (color-scheme)
This commit is contained in:
@@ -58,6 +58,12 @@ _VIDEO_COLUMNS: dict[str, str] = {
|
|||||||
# 1 cuando upload_date viene del discovery aproximado (texto relativo de
|
# 1 cuando upload_date viene del discovery aproximado (texto relativo de
|
||||||
# YouTube: "hace 3 semanas"), 0/NULL cuando es exacto (extraccion).
|
# YouTube: "hace 3 semanas"), 0/NULL cuando es exacto (extraccion).
|
||||||
"upload_date_approx": "INTEGER DEFAULT 0",
|
"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] = {
|
_CHANNEL_COLUMNS: dict[str, str] = {
|
||||||
@@ -247,6 +253,18 @@ def _order_clause(sort: str | None) -> str:
|
|||||||
"likes_desc": by_likes,
|
"likes_desc": by_likes,
|
||||||
"title": f"videos.title COLLATE NOCASE ASC, {_NEWEST_FIRST}",
|
"title": f"videos.title COLLATE NOCASE ASC, {_NEWEST_FIRST}",
|
||||||
"title_asc": 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)
|
}.get((sort or "").strip(), _NEWEST_FIRST)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +314,12 @@ class VideoRow:
|
|||||||
channel_seq: int | None = None
|
channel_seq: int | None = None
|
||||||
# 1 cuando upload_date es aproximado (discovery), 0/NULL si es exacto.
|
# 1 cuando upload_date es aproximado (discovery), 0/NULL si es exacto.
|
||||||
upload_date_approx: int | None = None
|
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
|
# 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`
|
# video discovery has not extracted yet it is inferred from `channel_seq`
|
||||||
# (see `query_videos`). Only populated by queries that compute it.
|
# (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),
|
(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:
|
def set_availability(self, video_id: str, availability: str | None) -> None:
|
||||||
"""Record what a full extraction learned; more authoritative than the
|
"""Record what a full extraction learned; more authoritative than the
|
||||||
flat listing, which omits the field for most entries."""
|
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,
|
availability=row["availability"] if "availability" in keys else None,
|
||||||
channel_seq=row["channel_seq"] if "channel_seq" 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,
|
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,
|
sort_date=row["sort_date"] if "sort_date" in keys else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,8 @@
|
|||||||
<option value="like_count">Likes ▼</option>
|
<option value="like_count">Likes ▼</option>
|
||||||
<option value="duration">Duration ▼</option>
|
<option value="duration">Duration ▼</option>
|
||||||
<option value="title">Title A→Z</option>
|
<option value="title">Title A→Z</option>
|
||||||
|
<option value="channel">Channel A→Z</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
</select>
|
</select>
|
||||||
</th>
|
</th>
|
||||||
<th>Duration</th><th>Views</th><th>Status</th><th class="text-right">Actions</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'">
|
<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>
|
<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>
|
||||||
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -418,6 +425,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- audio player + synced transcript follower -->
|
||||||
<div class="glass p-4" x-show="detail.video && detail.video.status==='done'">
|
<div class="glass p-4" x-show="detail.video && detail.video.status==='done'">
|
||||||
<template x-if="detail.hasAudio">
|
<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="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
|
<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>
|
||||||
<div class="flex gap-1.5">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
<table class="tbl">
|
<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-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.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="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>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ body::before {
|
|||||||
.nav-item .nav-ico { width: 1.1rem; height: 1.1rem; opacity: 0.9; }
|
.nav-item .nav-ico { width: 1.1rem; height: 1.1rem; opacity: 0.9; }
|
||||||
|
|
||||||
/* ---- form controls ---- */
|
/* ---- 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 {
|
.field {
|
||||||
background: #09090b;
|
background: #09090b;
|
||||||
border: 1px solid #27272a;
|
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-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-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); }
|
.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 bar ---- */
|
||||||
.progress-track { background: #18181b; border-radius: 999px; overflow: hidden; height: 0.6rem; border: 1px solid #27272a; }
|
.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 */
|
/* thumbnail */
|
||||||
.thumb { border-radius: 0.5rem; object-fit: cover; background: #18181b; }
|
.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-up transition for views */
|
||||||
.fade-enter-active { transition: all 0.22s ease; }
|
.fade-enter-active { transition: all 0.22s ease; }
|
||||||
.fade-enter-from { opacity: 0; transform: translateY(6px); }
|
.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 { 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); }
|
.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 */
|
/* the transcript segment currently matching audio playback */
|
||||||
.seg-row { border-left: 2px solid transparent; }
|
.seg-row { border-left: 2px solid transparent; }
|
||||||
.seg-active {
|
.seg-active {
|
||||||
|
|||||||
@@ -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:]
|
||||||
Reference in New Issue
Block a user