CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 55s
CI / integration (push) Successful in 2m4s
Build images / sign-extension (push) Successful in 11s
Build images / build-agent (push) Successful in 2m41s
Build images / build-web (push) Successful in 2m3s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 5m40s
Build images / promote (push) Skipped
`make_run_stats` has always declared `tier_gated_count`, and DownloadDetailModal has always rendered it. gallery-dl populated it; `ingest_core._result` did not — it built run_stats with six keys and let the seventh default to 0, while the very same walk counted gated posts into `gated_skipped` and spent the number on a log line. So on Patreon, SubscribeStar and pixiv — the three platforms we now own — the Downloads modal read "Tier-gated: 0" for a walk that skipped N paywalled posts. A creator we've lost access to was indistinguishable from a creator who stopped posting. Migrating Patreon off gallery-dl is what dropped the signal. Pass the count through, and tick it in the live-progress payload too, so a long backfill on an inaccessible creator explains itself while it runs rather than only at finalization. ActiveDownloadsPanel renders it only when non-zero, coloured 'info' to match the severity FailingSourcesCard already assigns tier_limited — this is not a failure. Tests assert the run_stats key the UI actually reads rather than the ingester's internal counter, so the guard tracks the property and not a name. Falsification is structural: `make_run_stats` defaults the key to 0, so both assertions fail against the pre-fix call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
163 lines
5.9 KiB
Vue
163 lines
5.9 KiB
Vue
<template>
|
|
<v-card v-if="active.length" variant="tonal" color="info" class="fc-active mb-4">
|
|
<div class="fc-active__head">
|
|
<span class="fc-active__pulse" />
|
|
<span class="fc-active__title">
|
|
<template v-if="running.length">
|
|
{{ running.length }} downloading
|
|
</template>
|
|
<template v-if="running.length && queued.length"> · </template>
|
|
<template v-if="queued.length">
|
|
{{ queued.length }} queued
|
|
</template>
|
|
</span>
|
|
<v-spacer />
|
|
<span class="fc-active__live">live</span>
|
|
</div>
|
|
|
|
<div class="fc-active__body">
|
|
<div
|
|
v-for="e in running" :key="e.id"
|
|
class="fc-active__row fc-active__row--running"
|
|
>
|
|
<span class="fc-active__dot" />
|
|
<PlatformChip :platform="e.platform" size="x-small" />
|
|
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
|
|
<!-- #709: live per-file progress for native (Patreon) downloads. -->
|
|
<span v-if="e.live" class="fc-active__counts">
|
|
<span class="fc-active__count" title="downloaded">↓ {{ e.live.downloaded }}</span>
|
|
<span v-if="e.live.skipped" class="fc-active__count" title="skipped">
|
|
⤼ {{ e.live.skipped }}</span>
|
|
<span
|
|
v-if="e.live.errors" class="fc-active__count fc-active__count--err"
|
|
title="errors"
|
|
>✕ {{ e.live.errors }}</span>
|
|
<!-- Tier-gated posts (#874): not an error — the walk is working, the
|
|
content just isn't ours. Shown only when non-zero so a healthy
|
|
run stays uncluttered. -->
|
|
<span
|
|
v-if="e.live.gated" class="fc-active__count fc-active__count--gated"
|
|
title="posts skipped — no access at your tier"
|
|
>🔒 {{ e.live.gated }}</span>
|
|
<span class="fc-active__count fc-active__count--posts" title="posts scanned">
|
|
{{ e.live.posts }} posts</span>
|
|
</span>
|
|
<v-spacer />
|
|
<span class="fc-active__timer" :title="`started ${e.started_at}`">
|
|
{{ elapsed(e.started_at) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
v-for="e in queued" :key="e.id"
|
|
class="fc-active__row fc-active__row--queued"
|
|
>
|
|
<v-icon size="x-small" class="fc-active__queue-icon">mdi-clock-outline</v-icon>
|
|
<PlatformChip :platform="e.platform" size="x-small" />
|
|
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
|
|
<v-spacer />
|
|
<span class="fc-active__queued">queued</span>
|
|
</div>
|
|
</div>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
import { useDownloadsStore } from '../../stores/downloads.js'
|
|
import PlatformChip from './PlatformChip.vue'
|
|
|
|
const store = useDownloadsStore()
|
|
|
|
const running = computed(() => store.activeEvents.filter((e) => e.status === 'running'))
|
|
const queued = computed(() => store.activeEvents.filter((e) => e.status === 'pending'))
|
|
const active = computed(() => [...running.value, ...queued.value])
|
|
|
|
// Ticking clock so the running timers update every second. started_at is
|
|
// tz-aware UTC; Date parses the instant, so (now - start) is correct in
|
|
// any viewer timezone.
|
|
const now = ref(Date.now())
|
|
let timer = null
|
|
onMounted(() => { timer = setInterval(() => { now.value = Date.now() }, 1000) })
|
|
onUnmounted(() => { if (timer) clearInterval(timer) })
|
|
|
|
function elapsed (startedIso) {
|
|
if (!startedIso) return '—'
|
|
const start = new Date(startedIso).getTime()
|
|
if (isNaN(start)) return '—'
|
|
const secs = Math.max(0, Math.floor((now.value - start) / 1000))
|
|
const h = Math.floor(secs / 3600)
|
|
const m = Math.floor((secs % 3600) / 60)
|
|
const s = secs % 60
|
|
const mm = String(m).padStart(2, '0')
|
|
const ss = String(s).padStart(2, '0')
|
|
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-active { border-radius: 8px; }
|
|
.fc-active__head {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 14px;
|
|
}
|
|
.fc-active__title {
|
|
font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.85rem;
|
|
}
|
|
.fc-active__pulse {
|
|
width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto;
|
|
background: rgb(var(--v-theme-info));
|
|
animation: fc-active-pulse 1.4s ease-in-out infinite;
|
|
}
|
|
.fc-active__live {
|
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
|
color: rgb(var(--v-theme-info));
|
|
}
|
|
.fc-active__body {
|
|
padding: 0 14px 12px;
|
|
display: flex; flex-direction: column; gap: 4px;
|
|
}
|
|
.fc-active__row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 7px 10px;
|
|
border-radius: 6px;
|
|
background: rgb(var(--v-theme-surface) / 0.5);
|
|
}
|
|
.fc-active__row--queued { opacity: 0.75; }
|
|
.fc-active__dot {
|
|
width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
|
|
background: rgb(var(--v-theme-info));
|
|
animation: fc-active-pulse 1.4s ease-in-out infinite;
|
|
}
|
|
.fc-active__queue-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
.fc-active__artist { font-weight: 600; }
|
|
.fc-active__timer {
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 700;
|
|
color: rgb(var(--v-theme-info));
|
|
}
|
|
.fc-active__queued {
|
|
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-active__counts {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
font-variant-numeric: tabular-nums; font-size: 0.78rem;
|
|
}
|
|
.fc-active__count { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
.fc-active__count--err { color: rgb(var(--v-theme-error)); }
|
|
/* Matches FailingSourcesCard's severity map, which already colours
|
|
tier_limited as 'info' — no-access is information, not a failure. */
|
|
.fc-active__count--gated { color: rgb(var(--v-theme-info)); }
|
|
.fc-active__count--posts { opacity: 0.7; }
|
|
@keyframes fc-active-pulse {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.35; transform: scale(0.7); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.fc-active__pulse, .fc-active__dot { animation: none; }
|
|
}
|
|
</style>
|