Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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)

Expand All @@ -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). |

Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions backend/alembic/versions/03a3c8d4b621_drop_unused_title_index.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 15 additions & 1 deletion backend/app/api/routes/programmes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 7 additions & 1 deletion backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
34 changes: 25 additions & 9 deletions backend/app/jobs/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion backend/app/models/programme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)

Expand Down
30 changes: 24 additions & 6 deletions backend/app/services/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import os
import threading
import time
from collections import OrderedDict
from pathlib import Path

from app.core.config import Settings
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading