From 06cef2e54c06672b0ecdffd52fa2a587107d5ddf Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 18 Jul 2026 16:13:40 +0200 Subject: [PATCH] optimize image and XMLTV runtime --- .env.example | 4 + .github/workflows/ci.yml | 14 ++ CHANGELOG.md | 24 +++ Dockerfile | 3 +- README.md | 11 +- .../03a3c8d4b621_drop_unused_title_index.py | 22 +++ backend/app/api/routes/programmes.py | 16 +- backend/app/api/routes/search.py | 8 +- backend/app/core/config.py | 8 + backend/app/jobs/scheduler.py | 34 +++- backend/app/main.py | 7 +- backend/app/models/programme.py | 1 - backend/app/services/auth.py | 30 +++- backend/app/services/xmltv/fetcher.py | 18 +- backend/app/services/xmltv/importer.py | 125 ++++++++++++-- backend/app/services/xmltv/parser.py | 65 +++++-- backend/pyproject.toml | 6 +- backend/tests/test_auth.py | 11 ++ backend/tests/test_fetcher.py | 4 +- backend/tests/test_importer.py | 20 +++ backend/tests/test_parser.py | 20 ++- backend/tests/test_scheduler.py | 15 ++ backend/tests/test_smart_sync.py | 24 +++ backend/uv.lock | 163 +----------------- compose.ghcr.yml | 3 +- compose.yml | 3 +- frontend/src/api/client.ts | 3 +- frontend/src/views/SearchView.vue | 18 +- 28 files changed, 459 insertions(+), 221 deletions(-) create mode 100644 backend/alembic/versions/03a3c8d4b621_drop_unused_title_index.py create mode 100644 backend/tests/test_scheduler.py diff --git a/.env.example b/.env.example index 8d7481f..ba49301 100644 --- a/.env.example +++ b/.env.example @@ -36,5 +36,9 @@ XMLTV_MAX_UNCOMPRESSED_MB=500 # Timeout des requêtes HTTP vers les sources XMLTV, en secondes. XMLTV_HTTP_TIMEOUT_SECONDS=30 +# Nombre maximal d'imports XMLTV simultanés. 1 est recommandé avec SQLite : +# évite de multiplier les pics RAM/CPU et les écritures concurrentes. +XMLTV_SYNC_WORKERS=1 + # User-Agent envoyé lors du téléchargement des sources XMLTV. #XMLTV_USER_AGENT=tvguide/1.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d637432..9e7aac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,18 @@ jobs: context: . platforms: linux/amd64 push: false + load: true + tags: tvguide:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Enforce unpacked image size budget + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + size=$(docker image inspect tvguide:ci --format '{{.Size}}') + limit=$((185 * 1024 * 1024)) + echo "Image size: $((size / 1024 / 1024)) MiB (limit: 185 MiB)" + test "$size" -le "$limit" - name: Log in to GHCR if: startsWith(github.ref, 'refs/tags/v') @@ -97,3 +109,5 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb10e4..d296773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ Ce projet suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et [SemVer](https://semver.org/lang/fr/). +## [1.16.0] — 2026-07-18 + +### Performance et consommation de ressources + +- Import XMLTV réellement streaming : les programmes sont traités par lots via + un spool disque temporaire au lieu de conserver deux copies complètes en + mémoire. Les fichiers téléchargés/décompressés utilisent désormais le volume + `/data`, pas le tmpfs RAM de `/tmp`. +- Imports simultanés bornés à un worker par défaut (`XMLTV_SYNC_WORKERS`) afin + de ne pas multiplier les pics RAM/CPU et les writers SQLite. +- Empreinte SHA-256 de secours pour les serveurs sans ETag/Last-Modified : un + téléchargement identique ne déclenche plus parsing et réécriture en base. +- Les vues de listes ne chargent plus les métadonnées de détail inutilisées ; + l'index B-tree `title`, inutilisable par la recherche sous-chaîne, est retiré. +- Les recherches frontend précédentes sont annulées lors d'une nouvelle saisie. + +### Image et robustesse + +- Image allégée sans bytecode Python précompilé ni extras Uvicorn inutilisés ; + `uvloop`, `httptools` et le support 7-Zip sont conservés. +- `py7zr` est importé uniquement lors de l'ouverture effective d'une archive + 7-Zip, réduisant la RAM au repos pour les autres formats. +- Le suivi anti-brute-force est maintenant borné et expire les anciennes IP. + ## [1.15.1] — 2026-07-11 ### Corrigé — PWA diff --git a/Dockerfile b/Dockerfile index 83fb325..f849cd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN npm run build FROM python:3.13-slim AS backend-deps COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv WORKDIR /app -ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +ENV UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never COPY backend/pyproject.toml backend/uv.lock ./ RUN uv sync --frozen --no-dev @@ -25,6 +25,7 @@ RUN groupadd -r -g 1000 tvguide \ WORKDIR /app ENV PATH="/app/.venv/bin:$PATH" \ PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ DATA_DIR=/data \ APP_PORT=8080 diff --git a/README.md b/README.md index 5abb0a3..e2ee5d2 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ docker run -d --name tvguide \ -v "$(pwd)/data:/data" \ --restart unless-stopped \ --security-opt no-new-privileges:true \ - --read-only --tmpfs /tmp \ + --read-only --tmpfs /tmp:size=16m,mode=1777 \ ghcr.io/cerede2000/tvguide:latest ``` @@ -127,8 +127,9 @@ publication prévue. Garde-fous : - l'« intervalle » configuré sur la source devient une borne maximale ; - le bouton **« Forcer l'import »** de l'administration télécharge et importe sans tenir compte de l'empreinte ; -- si le serveur n'expose ni ETag ni Last-Modified, chaque synchronisation - fait un import complet (comportement d'avant). +- si le serveur n'expose ni ETag ni Last-Modified, le fichier téléchargé est + comparé par SHA-256 : une copie identique évite le parsing et la réécriture + de la base. ## Configuration (variables d'environnement) @@ -143,6 +144,7 @@ publication prévue. Garde-fous : | `XMLTV_MAX_DOWNLOAD_MB` | `100` | Taille maximale d'un téléchargement XMLTV. | | `XMLTV_MAX_UNCOMPRESSED_MB` | `500` | Taille maximale après décompression GZIP (protection contre les bombes gzip/zip/xz). | | `XMLTV_HTTP_TIMEOUT_SECONDS` | `30` | Timeout HTTP des téléchargements. | +| `XMLTV_SYNC_WORKERS` | `1` | Nombre maximal d'imports XMLTV simultanés. Garder `1` avec SQLite limite la RAM/CPU et évite la concurrence entre writers. | | `XMLTV_USER_AGENT` | `tvguide/1.0` | User-Agent envoyé aux serveurs XMLTV. | | `FORWARDED_ALLOW_IPS` | `*` | IPs de reverse proxy autorisées à définir les en-têtes `X-Forwarded-*` (voir ci-dessous). | @@ -304,7 +306,8 @@ Le `HEALTHCHECK` Docker intégré interroge cet endpoint toutes les 30 s ## Sécurité - Conteneur **non-root** (uid 1000), `no-new-privileges`, système de fichiers - racine en **lecture seule** (`/tmp` en tmpfs, écritures limitées à `/data`). + racine en **lecture seule** (`/tmp` en petit tmpfs, fichiers de travail XMLTV + volumineux et écritures persistantes limités à `/data`). - **SSRF** : seuls http/https sont acceptés ; les adresses privées, loopback, link-local (dont les métadonnées cloud 169.254.169.254) sont refusées à la configuration **et** à chaque redirection, sauf diff --git a/backend/alembic/versions/03a3c8d4b621_drop_unused_title_index.py b/backend/alembic/versions/03a3c8d4b621_drop_unused_title_index.py new file mode 100644 index 0000000..76206a9 --- /dev/null +++ b/backend/alembic/versions/03a3c8d4b621_drop_unused_title_index.py @@ -0,0 +1,22 @@ +"""drop unused programme title index + +Revision ID: 03a3c8d4b621 +Revises: 849e860670eb +Create Date: 2026-07-18 +""" + +from alembic import op + +revision = "03a3c8d4b621" +down_revision = "849e860670eb" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Searches use lower(column) LIKE '%term%', which cannot use this B-tree. + op.drop_index("ix_programmes_title", table_name="programmes") + + +def downgrade() -> None: + op.create_index("ix_programmes_title", "programmes", ["title"], unique=False) diff --git a/backend/app/api/routes/programmes.py b/backend/app/api/routes/programmes.py index 3ee0454..376e217 100644 --- a/backend/app/api/routes/programmes.py +++ b/backend/app/api/routes/programmes.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from app.db.base import utcnow from app.db.session import get_session @@ -26,6 +26,16 @@ _MAX_GRID_HOURS = 48 +def _card_columns_only(): + """Avoid loading detail-only JSON/text fields for programme collections.""" + return ( + defer(Programme.raw_metadata), + defer(Programme.year), + defer(Programme.country), + defer(Programme.created_at), + ) + + def _enabled_channels(session: Session) -> list[Channel]: return list( session.execute( @@ -60,6 +70,7 @@ def _next_per_channel( ) rows = session.execute( select(Programme) + .options(*_card_columns_only()) .where(Programme.id.in_(select(subq.c.pid).where(subq.c.rn <= count))) .order_by(Programme.start_at_utc) ).scalars() @@ -111,6 +122,7 @@ def live(at: datetime | None = None, session: Session = Depends(get_session)) -> current_rows = session.execute( select(Programme) + .options(*_card_columns_only()) .join(Channel, Channel.id == Programme.channel_id) .where( Channel.enabled.is_(True), @@ -152,6 +164,7 @@ def _evening_response( # real start time — not leave the channel empty. rows = session.execute( select(Programme) + .options(*_card_columns_only()) .join(Channel, Channel.id == Programme.channel_id) .where( Channel.enabled.is_(True), @@ -214,6 +227,7 @@ def grid( channels = _enabled_channels(session) rows = session.execute( select(Programme) + .options(*_card_columns_only()) .join(Channel, Channel.id == Programme.channel_id) .where( Channel.enabled.is_(True), diff --git a/backend/app/api/routes/search.py b/backend/app/api/routes/search.py index 8e15e4f..65d7e27 100644 --- a/backend/app/api/routes/search.py +++ b/backend/app/api/routes/search.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from app.db.session import get_session from app.models import Channel, Programme @@ -30,6 +30,12 @@ def search( pattern = _like_pattern(q) stmt = ( select(Programme, Channel) + .options( + defer(Programme.raw_metadata), + defer(Programme.year), + defer(Programme.country), + defer(Programme.created_at), + ) .join(Channel, Channel.id == Programme.channel_id) .where( Channel.enabled.is_(True), diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ae8d1a9..60c004b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -20,6 +20,9 @@ class Settings(BaseSettings): xmltv_http_timeout_seconds: int = 30 xmltv_user_agent: str = "tvguide/1.0" xmltv_download_retries: int = 3 + # SQLite has a single writer and XMLTV parsing is memory intensive. Keep + # one import active by default; small installations may explicitly raise it. + xmltv_sync_workers: int = 1 scheduler_enabled: bool = True @@ -40,6 +43,11 @@ def resolved_database_url(self) -> str: def imports_dir(self) -> Path: return self.data_dir / "imports" + @property + def temp_dir(self) -> Path: + """Disk-backed work area for large downloads and decompressed guides.""" + return self.data_dir / "tmp" + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/jobs/scheduler.py b/backend/app/jobs/scheduler.py index 1f0112f..f7cb66a 100644 --- a/backend/app/jobs/scheduler.py +++ b/backend/app/jobs/scheduler.py @@ -22,6 +22,19 @@ logger = logging.getLogger(__name__) _scheduler: BackgroundScheduler | None = None +_sync_slots: threading.BoundedSemaphore | None = None +_sync_slots_lock = threading.Lock() + + +def _get_sync_slots() -> threading.BoundedSemaphore: + global _sync_slots + if _sync_slots is None: + with _sync_slots_lock: + if _sync_slots is None: + workers = max(1, min(get_settings().xmltv_sync_workers, 8)) + _sync_slots = threading.BoundedSemaphore(workers) + logger.info("XMLTV worker concurrency limited to %d", workers) + return _sync_slots def run_sync_in_thread(source_id: int, force: bool = False) -> bool: @@ -31,16 +44,19 @@ def run_sync_in_thread(source_id: int, force: bool = False) -> bool: if not sync_state.try_start(source_id): return False + slots = _get_sync_slots() + def _worker() -> None: - session = get_session_factory()() - try: - outcome = sync_source(session, source_id, get_settings(), force=force) - sync_state.finish(source_id, error=outcome.error) - except Exception as exc: # defensive: never leave the slot claimed - logger.exception("sync worker crashed source_id=%d", source_id) - sync_state.finish(source_id, error=str(exc)) - finally: - session.close() + with slots: + session = get_session_factory()() + try: + outcome = sync_source(session, source_id, get_settings(), force=force) + sync_state.finish(source_id, error=outcome.error) + except Exception as exc: # defensive: never leave the slot claimed + logger.exception("sync worker crashed source_id=%d", source_id) + sync_state.finish(source_id, error=str(exc)) + finally: + session.close() threading.Thread(target=_worker, name=f"xmltv-sync-{source_id}", daemon=True).start() return True diff --git a/backend/app/main.py b/backend/app/main.py index d127524..f8cbd45 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,7 +32,12 @@ async def lifespan(app: FastAPI): settings = get_settings() setup_logging(settings.log_level) - for directory in (settings.data_dir, settings.imports_dir, settings.data_dir / "cache"): + for directory in ( + settings.data_dir, + settings.imports_dir, + settings.data_dir / "cache", + settings.temp_dir, + ): directory.mkdir(parents=True, exist_ok=True) init_db() diff --git a/backend/app/models/programme.py b/backend/app/models/programme.py index fc92697..570f578 100644 --- a/backend/app/models/programme.py +++ b/backend/app/models/programme.py @@ -14,7 +14,6 @@ class Programme(Base): Index("ix_programmes_channel_stop", "channel_id", "stop_at_utc"), Index("ix_programmes_start", "start_at_utc"), Index("ix_programmes_stop", "stop_at_utc"), - Index("ix_programmes_title", "title"), Index("ix_programmes_category", "category"), ) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 2cf344c..a9a838f 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -16,6 +16,7 @@ import os import threading import time +from collections import OrderedDict from pathlib import Path from app.core.config import Settings @@ -25,10 +26,24 @@ TOKEN_TTL_SECONDS = 24 * 3600 LOCKOUT_THRESHOLD = 5 LOCKOUT_SECONDS = 60 +ATTEMPT_RETENTION_SECONDS = 3600 +MAX_TRACKED_IPS = 4096 _attempts_lock = threading.Lock() -# ip -> (consecutive failures, locked_until_epoch) -_attempts: dict[str, tuple[int, float]] = {} +# ip -> (consecutive failures, locked_until_epoch, last_attempt_epoch) +_attempts: OrderedDict[str, tuple[int, float, float]] = OrderedDict() + + +def _prune_attempts(now: float) -> None: + stale = [ + ip + for ip, (_, locked_until, last_attempt) in _attempts.items() + if locked_until <= now and now - last_attempt > ATTEMPT_RETENTION_SECONDS + ] + for ip in stale: + _attempts.pop(ip, None) + while len(_attempts) >= MAX_TRACKED_IPS: + _attempts.popitem(last=False) def _secret_file(settings: Settings) -> Path: @@ -74,16 +89,19 @@ def verify_token(settings: Settings, token: str) -> bool: def lockout_remaining(ip: str) -> float: with _attempts_lock: - _, locked_until = _attempts.get(ip, (0, 0.0)) + _, locked_until, _ = _attempts.get(ip, (0, 0.0, 0.0)) return max(0.0, locked_until - time.time()) def record_failure(ip: str) -> None: with _attempts_lock: - failures, _ = _attempts.get(ip, (0, 0.0)) + now = time.time() + _prune_attempts(now) + failures, _, _ = _attempts.get(ip, (0, 0.0, 0.0)) failures += 1 - locked_until = time.time() + LOCKOUT_SECONDS if failures >= LOCKOUT_THRESHOLD else 0.0 - _attempts[ip] = (failures, locked_until) + locked_until = now + LOCKOUT_SECONDS if failures >= LOCKOUT_THRESHOLD else 0.0 + _attempts[ip] = (failures, locked_until, now) + _attempts.move_to_end(ip) if locked_until: logger.warning("Admin login locked out for %ss ip=%s", LOCKOUT_SECONDS, ip) diff --git a/backend/app/services/xmltv/fetcher.py b/backend/app/services/xmltv/fetcher.py index 3049d6d..aa7c374 100644 --- a/backend/app/services/xmltv/fetcher.py +++ b/backend/app/services/xmltv/fetcher.py @@ -14,6 +14,7 @@ import bz2 import gzip +import hashlib import ipaddress import logging import lzma @@ -24,7 +25,6 @@ from urllib.parse import urljoin, urlsplit import httpx -import py7zr from app.core.config import Settings @@ -98,6 +98,15 @@ def file_signature(path: Path) -> str: return f"file|mtime={stat.st_mtime_ns}|size={stat.st_size}" +def content_signature(path: Path) -> str: + """Stable fallback for providers without useful HTTP validators.""" + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + return f"sha256={digest.hexdigest()}" + + def probe_signature( source_type: str, url: str | None, local_path: str | None, settings: Settings ) -> str | None: @@ -286,6 +295,10 @@ def decompress_if_compressed(path: Path, settings: Settings, workdir: Path) -> P def _extract_sevenzip(path: Path, settings: Settings, workdir: Path) -> Path: + # py7zr pulls several compression backends and Cryptodome. Import it only + # for actual 7-Zip sources so the common XML/gzip/zip path stays lean. + import py7zr + max_bytes = settings.xmltv_max_uncompressed_mb * 1024 * 1024 outdir = workdir / "sevenzip" try: @@ -327,9 +340,10 @@ def fetch_source_file( response_headers = download_url(url, settings, downloaded) size_mb = downloaded.stat().st_size / (1024 * 1024) logger.info("Download complete size=%.1fMB", size_mb) + signature = build_signature(response_headers) or content_signature(downloaded) return ( decompress_if_compressed(downloaded, settings, workdir), - build_signature(response_headers), + signature, ) if source_type == "file": if not local_path: diff --git a/backend/app/services/xmltv/importer.py b/backend/app/services/xmltv/importer.py index f3d77a5..f8fd677 100644 --- a/backend/app/services/xmltv/importer.py +++ b/backend/app/services/xmltv/importer.py @@ -11,8 +11,10 @@ import itertools import logging +import pickle import statistics import tempfile +from collections.abc import Iterable, Iterator from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path @@ -26,7 +28,12 @@ from app.models import Channel, Programme, Source from app.services.xmltv import sync_state from app.services.xmltv.fetcher import FetchError, fetch_source_file, probe_signature -from app.services.xmltv.parser import ParseResult, XMLTVParseError, parse_xmltv +from app.services.xmltv.parser import ( + ParsedChannel, + ParsedProgramme, + XMLTVParseError, + stream_xmltv, +) logger = logging.getLogger(__name__) @@ -98,12 +105,57 @@ def _record_change(source: Source, now: datetime) -> None: source.detected_interval_minutes = detect_interval_minutes(source.change_history) -def _parse_file(xml_path: Path, default_tz: ZoneInfo) -> ParseResult: - with xml_path.open("rb") as fh: - return parse_xmltv(fh, default_tz) +def _parse_file_to_spool( + xml_path: Path, default_tz: ZoneInfo, spool_path: Path +) -> tuple[list[ParsedChannel], int]: + """Validate once while spooling programmes to disk. - -def _apply_import(session: Session, source: Source, result: ParseResult) -> tuple[int, int]: + Channel metadata is small and stays in memory. Programme objects are + serialized to the disk-backed import work directory, keeping peak Python + allocations independent of guide size while preserving support for feeds + that declare channels after programme elements. + """ + channels: list[ParsedChannel] = [] + programme_batch: list[ParsedProgramme] = [] + with xml_path.open("rb") as source, spool_path.open("wb") as spool: + + def spool_programme(programme: ParsedProgramme) -> None: + programme_batch.append(programme) + if len(programme_batch) == 1000: + pickle.dump(programme_batch, spool, protocol=pickle.HIGHEST_PROTOCOL) + programme_batch.clear() + + stats = stream_xmltv( + source, + default_tz, + on_channel=channels.append, + on_programme=spool_programme, + ) + if programme_batch: + pickle.dump(programme_batch, spool, protocol=pickle.HIGHEST_PROTOCOL) + return channels, stats.programmes + + +def _iter_spooled_programmes(spool_path: Path) -> Iterator[ParsedProgramme]: + with spool_path.open("rb") as spool: + while True: + try: + batch = pickle.load(spool) + except EOFError: + return + if not isinstance(batch, list) or not all( + isinstance(programme, ParsedProgramme) for programme in batch + ): + raise XMLTVParseError("données temporaires d'import invalides") + yield from batch + + +def _apply_import( + session: Session, + source: Source, + channels: Iterable[ParsedChannel], + programmes: Iterable[ParsedProgramme], +) -> tuple[int, int]: """Single-transaction swap: upsert channels, replace programmes. Caller commits.""" existing = { c.xmltv_id: c @@ -112,7 +164,9 @@ def _apply_import(session: Session, source: Source, result: ParseResult) -> tupl max_order = session.execute(select(func.max(Channel.display_order))).scalar() or 0 channel_ids: dict[str, int] = {} - for parsed in result.channels: + channel_count = 0 + for parsed in channels: + channel_count += 1 channel = existing.get(parsed.xmltv_id) if channel is None: max_order += 1 @@ -137,12 +191,16 @@ def _apply_import(session: Session, source: Source, result: ParseResult) -> tupl ) ) - rows = [] + rows: list[dict] = [] + programme_count = 0 + skipped_unknown = 0 unknown_channels: set[str] = set() - for p in result.programmes: + created_at = utcnow() + for p in programmes: channel_id = channel_ids.get(p.channel_xmltv_id) if channel_id is None: unknown_channels.add(p.channel_xmltv_id) + skipped_unknown += 1 continue rows.append( { @@ -162,19 +220,23 @@ def _apply_import(session: Session, source: Source, result: ParseResult) -> tupl "is_new": p.is_new, "is_repeat": p.is_repeat, "raw_metadata": p.raw_metadata or None, - "created_at": utcnow(), + "created_at": created_at, } ) + programme_count += 1 + if len(rows) == 1000: + session.execute(insert(Programme), rows) + rows.clear() if unknown_channels: logger.warning( "XMLTV import: %d programmes reference channels missing from " "declarations (e.g. %s); skipped", - len(result.programmes) - len(rows), + skipped_unknown, next(iter(unknown_channels)), ) - for start in range(0, len(rows), 1000): - session.execute(insert(Programme), rows[start : start + 1000]) - return len(result.channels), len(rows) + if rows: + session.execute(insert(Programme), rows) + return channel_count, programme_count def sync_source( @@ -219,21 +281,46 @@ def sync_source( default_tz = ZoneInfo("UTC") try: - with tempfile.TemporaryDirectory(prefix="tvguide-sync-") as tmp: + settings.temp_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="tvguide-sync-", dir=settings.temp_dir) as tmp: workdir = Path(tmp) sync_state.set_stage(source_id, "downloading") xml_path, signature = fetch_source_file( source.source_type, source.url, source.local_path, settings, workdir ) + # Some providers reject HEAD or expose no ETag/Last-Modified. The + # downloader then supplies a content hash: skip the expensive parse + # and database rewrite when that downloaded payload is unchanged. + if ( + not force + and fresh_enough + and signature is not None + and signature == source.head_signature + ): + now = utcnow() + source.last_check_at = now + source.next_sync_at = plan_recheck(source, now) + session.commit() + logger.info( + "XMLTV unchanged (download hash), import skipped source=%r", + source.name, + ) + return SyncOutcome(success=True, skipped=True) + sync_state.set_stage(source_id, "validating") - result = _parse_file(xml_path, default_tz) - if not result.channels: + spool_path = workdir / "programmes.pickle" + channels, parsed_programme_count = _parse_file_to_spool( + xml_path, default_tz, spool_path + ) + if not channels: raise XMLTVParseError("aucune chaîne trouvée dans le document XMLTV") - logger.info("XML validated") + logger.info("XML validated programmes=%d", parsed_programme_count) sync_state.set_stage(source_id, "importing") - channel_count, programme_count = _apply_import(session, source, result) + channel_count, programme_count = _apply_import( + session, source, channels, _iter_spooled_programmes(spool_path) + ) now = utcnow() source.last_success_at = now diff --git a/backend/app/services/xmltv/parser.py b/backend/app/services/xmltv/parser.py index 136e0e6..ff7b594 100644 --- a/backend/app/services/xmltv/parser.py +++ b/backend/app/services/xmltv/parser.py @@ -6,6 +6,7 @@ """ import logging +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime from typing import IO, Any @@ -71,6 +72,13 @@ class ParseResult: skipped_programmes: int = 0 +@dataclass +class ParseStats: + channels: int = 0 + programmes: int = 0 + skipped_programmes: int = 0 + + def _text(elem: Element, tag: str) -> str | None: child = elem.find(tag) if child is None or child.text is None: @@ -190,11 +198,15 @@ def _parse_programme(elem: Element, default_tz: ZoneInfo) -> ParsedProgramme | N ) -def parse_xmltv(source: IO[bytes], default_tz: ZoneInfo) -> ParseResult: - """Parse an XMLTV byte stream. Raises XMLTVParseError on malformed input.""" - channels: list[ParsedChannel] = [] - programmes: list[ParsedProgramme] = [] - skipped = 0 +def stream_xmltv( + source: IO[bytes], + default_tz: ZoneInfo, + *, + on_channel: Callable[[ParsedChannel], None], + on_programme: Callable[[ParsedProgramme], None], +) -> ParseStats: + """Parse XMLTV and emit records without retaining the document in memory.""" + stats = ParseStats() root: Element | None = None try: @@ -211,15 +223,23 @@ def parse_xmltv(source: IO[bytes], default_tz: ZoneInfo) -> ParseResult: if elem.tag == "channel": parsed_channel = _parse_channel(elem) if parsed_channel is not None: - channels.append(parsed_channel) + on_channel(parsed_channel) + stats.channels += 1 elem.clear() + # ElementTree otherwise retains every cleared top-level child + # in the root's child list for the lifetime of the document. + assert root is not None + root.clear() elif elem.tag == "programme": parsed_programme = _parse_programme(elem, default_tz) if parsed_programme is not None: - programmes.append(parsed_programme) + on_programme(parsed_programme) + stats.programmes += 1 else: - skipped += 1 + stats.skipped_programmes += 1 elem.clear() + assert root is not None + root.clear() except ParseError as exc: raise XMLTVParseError(f"XML invalide : {exc}") from exc except DefusedXmlException as exc: @@ -229,6 +249,29 @@ def parse_xmltv(source: IO[bytes], default_tz: ZoneInfo) -> ParseResult: if root is None: raise XMLTVParseError("document vide") - if skipped: - logger.warning("XMLTV parse: skipped %d malformed programme entries", skipped) - return ParseResult(channels=channels, programmes=programmes, skipped_programmes=skipped) + if stats.skipped_programmes: + logger.warning( + "XMLTV parse: skipped %d malformed programme entries", stats.skipped_programmes + ) + return stats + + +def parse_xmltv(source: IO[bytes], default_tz: ZoneInfo) -> ParseResult: + """Compatibility collector for callers that need an in-memory result. + + Production imports use :func:`stream_xmltv`; tests and small utility callers + retain the convenient historical API. + """ + channels: list[ParsedChannel] = [] + programmes: list[ParsedProgramme] = [] + stats = stream_xmltv( + source, + default_tz, + on_channel=channels.append, + on_programme=programmes.append, + ) + return ParseResult( + channels=channels, + programmes=programmes, + skipped_programmes=stats.skipped_programmes, + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3ffdf84..9efbb58 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,7 +5,11 @@ description = "Self-hosted XMLTV TV guide - backend API" requires-python = ">=3.13" dependencies = [ "fastapi>=0.115", - "uvicorn[standard]>=0.32", + # Keep the two production performance extensions, but avoid the unused + # reload, YAML and WebSocket dependencies pulled by uvicorn[standard]. + "uvicorn>=0.32", + "uvloop>=0.21; sys_platform != 'win32'", + "httptools>=0.6", "sqlalchemy>=2.0.36", "pydantic>=2.9", "pydantic-settings>=2.6", diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index ee28634..0521b12 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -73,3 +73,14 @@ def test_login_lockout_after_repeated_failures(client, admin_password): assert _login(client, admin_password).status_code == 429 auth.reset_all_failures() assert _login(client, admin_password).status_code == 200 + + +def test_login_attempt_tracking_is_bounded(monkeypatch): + auth.reset_all_failures() + monkeypatch.setattr(auth, "MAX_TRACKED_IPS", 3) + for index in range(4): + auth.record_failure(f"192.0.2.{index}") + + assert len(auth._attempts) == 3 + assert "192.0.2.0" not in auth._attempts + auth.reset_all_failures() diff --git a/backend/tests/test_fetcher.py b/backend/tests/test_fetcher.py index 14c6e2f..3eaeb67 100644 --- a/backend/tests/test_fetcher.py +++ b/backend/tests/test_fetcher.py @@ -188,11 +188,13 @@ def test_download_size_cap(tmp_path): @respx.mock def test_fetch_source_file_gzip_url(tmp_path): + import hashlib + payload = gzip.compress(b"compressed") respx.get(PUBLIC).mock(return_value=httpx.Response(200, content=payload)) result, signature = fetch_source_file("url", PUBLIC, None, _settings(), tmp_path) assert result.read_bytes() == b"compressed" - assert signature is None # respx response exposes no ETag/Last-Modified + assert signature == f"sha256={hashlib.sha256(payload).hexdigest()}" # ─── ZIP / XZ ──────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_importer.py b/backend/tests/test_importer.py index 530065b..f2935d8 100644 --- a/backend/tests/test_importer.py +++ b/backend/tests/test_importer.py @@ -145,6 +145,26 @@ def test_overlapping_programmes_kept_as_is(session, settings, imports_dir): assert progs[1].start_at_utc < progs[0].stop_at_utc # overlap preserved verbatim +def test_programme_before_channel_declaration_is_imported(session, settings, imports_dir): + """The disk spool preserves the permissive ordering supported historically.""" + (imports_dir / "late-channel.xml").write_text( + "" + "" + "A" + "C" + "" + ) + source = _make_file_source(session, "imports/late-channel.xml") + + outcome = sync_source(session, source.id, settings) + + assert outcome.success, outcome.error + assert outcome.channels == 1 + assert outcome.programmes == 1 + assert [programme.title for programme in _programmes(session, source)] == ["A"] + assert not list(settings.temp_dir.glob("tvguide-sync-*")) + + def test_import_zip_file(session, settings, imports_dir): import zipfile diff --git a/backend/tests/test_parser.py b/backend/tests/test_parser.py index e3b2720..4efa9c1 100644 --- a/backend/tests/test_parser.py +++ b/backend/tests/test_parser.py @@ -4,7 +4,7 @@ import pytest -from app.services.xmltv.parser import XMLTVParseError, parse_xmltv +from app.services.xmltv.parser import XMLTVParseError, parse_xmltv, stream_xmltv from tests.conftest import FIXTURES PARIS = ZoneInfo("Europe/Paris") @@ -65,6 +65,24 @@ def test_parse_basic_fixture_programmes(): assert minimal.stop_at_utc > minimal.start_at_utc +def test_stream_parser_emits_records_without_collecting_them(): + channels = [] + programme_titles = [] + with (FIXTURES / "guide_basic.xml").open("rb") as fh: + stats = stream_xmltv( + fh, + PARIS, + on_channel=channels.append, + on_programme=lambda programme: programme_titles.append(programme.title), + ) + + assert stats.channels == 2 + assert stats.programmes == 4 + assert stats.skipped_programmes == 1 + assert [channel.xmltv_id for channel in channels] == ["TF1.fr", "France2.fr"] + assert programme_titles[0] == "Film du dimanche" + + def test_invalid_xml_raises(): with pytest.raises(XMLTVParseError, match="XML invalide"): _parse_bytes(b"") diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py new file mode 100644 index 0000000..b658f7f --- /dev/null +++ b/backend/tests/test_scheduler.py @@ -0,0 +1,15 @@ +from app.jobs import scheduler + + +def test_xmltv_worker_concurrency_is_bounded(settings): + previous = settings.xmltv_sync_workers + scheduler._sync_slots = None + settings.xmltv_sync_workers = 1 + try: + slots = scheduler._get_sync_slots() + assert slots.acquire(blocking=False) + assert not slots.acquire(blocking=False) + slots.release() + finally: + settings.xmltv_sync_workers = previous + scheduler._sync_slots = None diff --git a/backend/tests/test_smart_sync.py b/backend/tests/test_smart_sync.py index 5a74579..b59a8ad 100644 --- a/backend/tests/test_smart_sync.py +++ b/backend/tests/test_smart_sync.py @@ -5,6 +5,9 @@ import shutil from datetime import UTC, datetime, timedelta +import httpx +import respx + from app.db.base import utcnow from app.models import Source from app.services.xmltv.importer import ( @@ -61,6 +64,27 @@ def test_modified_file_triggers_reimport(session, settings, imports_dir): assert len(source.change_history) == 2 +@respx.mock +def test_unchanged_url_without_http_validators_skips_after_download(session, settings, monkeypatch): + url = "https://example.org/guide.xml" + monkeypatch.setattr( + "app.services.xmltv.fetcher.validate_source_url", lambda *_args, **_kwargs: None + ) + get_route = respx.get(url).mock(return_value=httpx.Response(200, content=BASIC.read_bytes())) + head_route = respx.head(url).mock(return_value=httpx.Response(405)) + source = Source(name="Sans validateurs", source_type="url", url=url) + session.add(source) + session.commit() + + first = sync_source(session, source.id, settings) + second = sync_source(session, source.id, settings) + + assert first.success and not first.skipped + assert second.success and second.skipped + assert get_route.call_count == 2 + assert head_route.call_count == 1 + + def test_force_bypasses_fingerprint(session, settings, imports_dir): shutil.copy(BASIC, imports_dir / "guide.xml") source = _make_source(session) diff --git a/backend/uv.lock b/backend/uv.lock index 674da3c..0741d54 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -777,42 +777,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "respx" version = "0.23.1" @@ -914,12 +878,14 @@ dependencies = [ { name = "apscheduler" }, { name = "defusedxml" }, { name = "fastapi" }, + { name = "httptools" }, { name = "httpx" }, { name = "py7zr" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, ] [package.dev-dependencies] @@ -935,12 +901,14 @@ requires-dist = [ { name = "apscheduler", specifier = ">=3.10,<4" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "httptools", specifier = ">=0.6" }, { name = "httpx", specifier = ">=0.27" }, { name = "py7zr", specifier = ">=1.1.3" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, + { name = "uvicorn", specifier = ">=0.32" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21" }, ] [package.metadata.requires-dev] @@ -1005,17 +973,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" }, ] -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - [[package]] name = "uvloop" version = "0.22.1" @@ -1041,111 +998,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] - -[[package]] -name = "watchfiles" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, - { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, - { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, - { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, - { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, - { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, - { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, - { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, - { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, - { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, - { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, - { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, - { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, - { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, - { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, -] - -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] diff --git a/compose.ghcr.yml b/compose.ghcr.yml index 3358b93..49429c4 100644 --- a/compose.ghcr.yml +++ b/compose.ghcr.yml @@ -23,6 +23,7 @@ services: XMLTV_MAX_DOWNLOAD_MB: ${XMLTV_MAX_DOWNLOAD_MB:-100} XMLTV_MAX_UNCOMPRESSED_MB: ${XMLTV_MAX_UNCOMPRESSED_MB:-500} XMLTV_HTTP_TIMEOUT_SECONDS: ${XMLTV_HTTP_TIMEOUT_SECONDS:-30} + XMLTV_SYNC_WORKERS: ${XMLTV_SYNC_WORKERS:-1} volumes: - ./data:/data @@ -32,4 +33,4 @@ services: read_only: true tmpfs: - - /tmp + - /tmp:size=16m,mode=1777 diff --git a/compose.yml b/compose.yml index 6810a54..d8140ba 100644 --- a/compose.yml +++ b/compose.yml @@ -17,6 +17,7 @@ services: XMLTV_MAX_DOWNLOAD_MB: ${XMLTV_MAX_DOWNLOAD_MB:-100} XMLTV_MAX_UNCOMPRESSED_MB: ${XMLTV_MAX_UNCOMPRESSED_MB:-500} XMLTV_HTTP_TIMEOUT_SECONDS: ${XMLTV_HTTP_TIMEOUT_SECONDS:-30} + XMLTV_SYNC_WORKERS: ${XMLTV_SYNC_WORKERS:-1} volumes: - ./data:/data @@ -27,4 +28,4 @@ services: # The application only writes under /data and /tmp. read_only: true tmpfs: - - /tmp + - /tmp:size=16m,mode=1777 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 163caa5..3ae4a4e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -78,12 +78,13 @@ export const api = { channel_id?: number | null category?: string | null date?: string | null + signal?: AbortSignal }) => { const query = new URLSearchParams({ q: params.q }) if (params.channel_id) query.set('channel_id', String(params.channel_id)) if (params.category) query.set('category', params.category) if (params.date) query.set('date', params.date) - return request(`/api/search?${query}`) + return request(`/api/search?${query}`, { signal: params.signal }) }, categories: () => request('/api/categories'), diff --git a/frontend/src/views/SearchView.vue b/frontend/src/views/SearchView.vue index b7ceafc..5028112 100644 --- a/frontend/src/views/SearchView.vue +++ b/frontend/src/views/SearchView.vue @@ -1,5 +1,5 @@