feat(external): per-host enable toggles in Settings (Phase 4d)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m23s

Operator lever: disable a single file host (e.g. mega.nz when it's banning)
without touching the others. Five booleans on import_settings
(extdl_<host>_enabled, default true — works out of the box, rule #26); the
worker already reads them via getattr so no worker change. Migration 0050 +
model fields + settings GET/PATCH (uniform boolean validation) + a
'External file-host downloads' card in the subscriptions Settings tab.

Completes Phase 4. Refs FC #830.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:57:42 -04:00
parent 05f226a8f6
commit 8dbf29f803
5 changed files with 151 additions and 0 deletions
@@ -0,0 +1,38 @@
"""import_settings: per-host enable toggles for external file-host downloads
Operator levers (#830): disable a single host (e.g. mega.nz when it's
rate-limiting/banning) without touching the others. The worker reads these via
getattr and defaults to enabled, so the toggles default TRUE (works out of the
box, rule #26).
Revision ID: 0050
Revises: 0049
Create Date: 2026-06-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0050"
down_revision: Union[str, None] = "0049"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
def upgrade() -> None:
for host in _HOSTS:
op.add_column(
"import_settings",
sa.Column(
f"extdl_{host}_enabled", sa.Boolean(), nullable=False,
server_default=sa.true(),
),
)
def downgrade() -> None:
for host in _HOSTS:
op.drop_column("import_settings", f"extdl_{host}_enabled")
+22
View File
@@ -27,6 +27,20 @@ _EDITABLE_FIELDS = (
"download_failure_warning_threshold", "download_failure_warning_threshold",
"series_suggest_enabled", "series_suggest_enabled",
"series_suggest_threshold", "series_suggest_threshold",
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
)
# Per-host external-download toggles — all plain booleans, validated uniformly.
_EXTDL_TOGGLE_FIELDS = (
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
) )
@@ -50,6 +64,11 @@ async def get_import_settings():
"download_failure_warning_threshold": row.download_failure_warning_threshold, "download_failure_warning_threshold": row.download_failure_warning_threshold,
"series_suggest_enabled": row.series_suggest_enabled, "series_suggest_enabled": row.series_suggest_enabled,
"series_suggest_threshold": row.series_suggest_threshold, "series_suggest_threshold": row.series_suggest_threshold,
"extdl_mega_enabled": row.extdl_mega_enabled,
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
}) })
@@ -106,6 +125,9 @@ async def update_import_settings():
return jsonify( return jsonify(
{"error": "series_suggest_enabled must be a boolean"} {"error": "series_suggest_enabled must be a boolean"}
), 400 ), 400
for tog in _EXTDL_TOGGLE_FIELDS:
if tog in body and not isinstance(body[tog], bool):
return jsonify({"error": f"{tog} must be a boolean"}), 400
if "series_suggest_threshold" in body: if "series_suggest_threshold" in body:
v = body["series_suggest_threshold"] v = body["series_suggest_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1: if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
+19
View File
@@ -73,6 +73,25 @@ class ImportSettings(Base):
Float, nullable=False, default=0.5, Float, nullable=False, default=0.5,
) )
# #830 off-platform file-host downloads — per-host enable lever (default on,
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
# via getattr(settings, f"extdl_{host}_enabled", True).
extdl_mega_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_gdrive_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_mediafire_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_dropbox_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_pixeldrain_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
@classmethod @classmethod
async def load(cls, session) -> ImportSettings: async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session.""" """The singleton settings row (id=1), via an async session."""
@@ -52,6 +52,52 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<h3 class="text-h6 mb-3 mt-6">External file-host downloads</h3>
<v-card variant="outlined">
<v-card-text v-if="importStore.settings">
<div class="fc-help mb-2">
When a creator links files on these hosts in a post, Curator downloads
them into the post automatically (tagged + provenance-linked like any
other media). Turn a host off if it starts rate-limiting or banning.
</div>
<v-row>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_mega_enabled" label="mega.nz"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_gdrive_enabled" label="Google Drive"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_pixeldrain_enabled" label="Pixeldrain"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_mediafire_enabled" label="MediaFire"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_dropbox_enabled" label="Dropbox"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else>
<v-skeleton-loader type="paragraph" />
</v-card-text>
</v-card>
<h3 class="text-h6 mb-3 mt-6">Schedule defaults</h3> <h3 class="text-h6 mb-3 mt-6">Schedule defaults</h3>
<v-card variant="outlined"> <v-card variant="outlined">
<v-card-text v-if="importStore.settings"> <v-card-text v-if="importStore.settings">
@@ -151,6 +197,11 @@ const dl = reactive({
download_schedule_default_seconds: 28800, download_schedule_default_seconds: 28800,
download_event_retention_days: 90, download_event_retention_days: 90,
download_failure_warning_threshold: 5, download_failure_warning_threshold: 5,
extdl_mega_enabled: true,
extdl_gdrive_enabled: true,
extdl_mediafire_enabled: true,
extdl_dropbox_enabled: true,
extdl_pixeldrain_enabled: true,
}) })
watch(() => importStore.settings, (s) => { if (s) Object.assign(dl, s) }, { immediate: true }) watch(() => importStore.settings, (s) => { if (s) Object.assign(dl, s) }, { immediate: true })
+21
View File
@@ -44,3 +44,24 @@ async def test_patch_rejects_non_bool_validate(client):
"/api/settings/import", json={"download_validate_files": "yes"} "/api/settings/import", json={"download_validate_files": "yes"}
) )
assert resp.status_code == 400 assert resp.status_code == 400
@pytest.mark.asyncio
async def test_extdl_host_toggles_default_on_and_patch(client):
body = await (await client.get("/api/settings/import")).get_json()
assert body["extdl_mega_enabled"] is True
assert body["extdl_pixeldrain_enabled"] is True
resp = await client.patch(
"/api/settings/import", json={"extdl_mega_enabled": False}
)
assert resp.status_code == 200
assert (await resp.get_json())["extdl_mega_enabled"] is False
@pytest.mark.asyncio
async def test_extdl_toggle_rejects_non_bool(client):
resp = await client.patch(
"/api/settings/import", json={"extdl_gdrive_enabled": "nope"}
)
assert resp.status_code == 400