Routine subscription polls walked the entire post history every tick
even when nothing had changed, because gallery-dl's default `skip: True`
continues iterating archived posts. A creator with ~550 archived posts
(Knuxy patreon) saturates the 870s wall-clock cap before completing,
even with zero downloads needed. Plus, a tier-limited run that
downloaded hundreds of files but ran out the clock should be a
warning, not an error.
Two coupled changes, both operator-flagged 2026-06-01:
* **Tick mode (default, cron polls).** New `TICK_SKIP_VALUE = "exit:20"`
asks gallery-dl to exit after 20 contiguous archived items. Fresh
subscriptions + new-content cases still walk normally; established
subscription with zero new content exits in ~30s of HEAD requests
instead of pegging the timeout. 20 (not 5) gives headroom against
paywall warnings interleaving with archived items.
* **Backfill mode (explicit, operator-triggered).** Sticky for N runs
via new `Source.backfill_runs_remaining` (alembic 0031). While > 0,
downloads use `skip: True` + 1800s timeout. Auto-decrements per run
with early-reset to 0 when a clean run finds zero files (queue
drained). N defaults to 3 — multiple runs give the system enough
budget to finish a deep walk across timeout boundaries. New
`POST /api/sources/{id}/backfill` arms the source; "Deep scan"
button on each SourceRow (chip shows remaining count) wires it.
Plus partial-success classifier: non-zero gallery-dl exit + ≥1 file
downloaded + no source-level error fires `ErrorType.PARTIAL`, which
download_service maps to `status=\"ok\"`. The run did real work; the
next tick continues via gallery-dl's archive. No more red events for
"timed out mid-walk after downloading 300 files."
Retires `SourceConfig.skip_existing` — skip value is now derived from
the source state and passed as a separate `skip_value` parameter
through download() / _build_config_for_source(). `GD_DEFAULTS` drops
the now-dead key (was inert data after this refactor).
Tests cover:
* tick + backfill skip-value emission in _build_config_for_source
* PARTIAL classifier branch + TIER_LIMITED-wins-over-PARTIAL ordering
* SourceService.set_backfill_runs validation + persistence
* /api/sources/{id}/backfill 200/400/404 paths
* download_service auto-decrement / auto-reset / tick-mode-no-touch
* PARTIAL → status=ok in the orchestrator (no consecutive_failures bump)
128 lines
3.7 KiB
Vue
128 lines
3.7 KiB
Vue
<template>
|
||
<tr class="fc-source-row">
|
||
<td class="fc-source-row__health">
|
||
<SourceHealthDot :source="source" :warning-threshold="warningThreshold" />
|
||
</td>
|
||
<td>
|
||
<v-chip size="x-small" variant="tonal" label>{{ source.platform }}</v-chip>
|
||
</td>
|
||
<td class="fc-source-row__url-cell">
|
||
<a :href="source.url" target="_blank" rel="noopener" class="fc-source-row__url"
|
||
@click.stop>
|
||
{{ source.url }}
|
||
</a>
|
||
</td>
|
||
<td>
|
||
<v-switch
|
||
:model-value="source.enabled"
|
||
density="compact" hide-details color="accent"
|
||
@click.stop
|
||
@update:model-value="onToggleEnabled"
|
||
/>
|
||
</td>
|
||
<td class="fc-source-row__when">
|
||
{{ formatRelative(source.last_checked_at) }}
|
||
</td>
|
||
<td class="fc-source-row__when">
|
||
{{ formatRelative(source.next_check_at, { future: true }) }}
|
||
</td>
|
||
<td>
|
||
<v-chip
|
||
v-if="(source.consecutive_failures || 0) > 0"
|
||
size="x-small" color="error" variant="tonal" label
|
||
>{{ source.consecutive_failures }}</v-chip>
|
||
<v-chip
|
||
v-else-if="(source.backfill_runs_remaining || 0) > 0"
|
||
size="x-small" color="info" variant="tonal" label
|
||
>
|
||
backfill ({{ source.backfill_runs_remaining }}×)
|
||
</v-chip>
|
||
<span v-else class="fc-source-row__zero">0</span>
|
||
</td>
|
||
<td class="fc-source-row__actions">
|
||
<v-btn
|
||
icon="mdi-play" size="x-small" variant="text"
|
||
:loading="checking"
|
||
@click.stop="$emit('check', source)"
|
||
>
|
||
<v-icon>mdi-play</v-icon>
|
||
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
|
||
</v-btn>
|
||
<v-btn
|
||
icon="mdi-magnify-scan" size="x-small" variant="text"
|
||
:disabled="(source.backfill_runs_remaining || 0) > 0"
|
||
@click.stop="$emit('backfill', source)"
|
||
>
|
||
<v-icon>mdi-magnify-scan</v-icon>
|
||
<v-tooltip activator="parent" location="top">
|
||
Deep scan — walk full history for next few runs
|
||
</v-tooltip>
|
||
</v-btn>
|
||
<v-btn
|
||
icon="mdi-pencil" size="x-small" variant="text"
|
||
@click.stop="$emit('edit', source)"
|
||
>
|
||
<v-icon>mdi-pencil</v-icon>
|
||
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
|
||
</v-btn>
|
||
<v-btn
|
||
icon="mdi-close" size="x-small" variant="text" color="error"
|
||
@click.stop="$emit('remove', source)"
|
||
>
|
||
<v-icon>mdi-close</v-icon>
|
||
<v-tooltip activator="parent" location="top">Remove</v-tooltip>
|
||
</v-btn>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
|
||
<script setup>
|
||
import SourceHealthDot from './SourceHealthDot.vue'
|
||
import { formatRelative } from '../../utils/date.js'
|
||
|
||
const props = defineProps({
|
||
source: { type: Object, required: true },
|
||
checking: { type: Boolean, default: false },
|
||
warningThreshold: { type: Number, default: 5 },
|
||
})
|
||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
|
||
|
||
function onToggleEnabled(value) {
|
||
emit('toggle', { source: props.source, enabled: value })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fc-source-row__health {
|
||
width: 24px;
|
||
padding-right: 0 !important;
|
||
}
|
||
.fc-source-row__url-cell {
|
||
max-width: 400px;
|
||
overflow: hidden;
|
||
}
|
||
.fc-source-row__url {
|
||
color: rgb(var(--v-theme-on-surface-variant));
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.fc-source-row__url:hover { color: rgb(var(--v-theme-accent)); }
|
||
.fc-source-row__when {
|
||
color: rgb(var(--v-theme-on-surface-variant));
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.fc-source-row__zero {
|
||
color: rgb(var(--v-theme-on-surface-variant));
|
||
opacity: 0.6;
|
||
}
|
||
.fc-source-row__actions {
|
||
white-space: nowrap;
|
||
text-align: right;
|
||
}
|
||
</style>
|