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 @@ -40,5 +40,9 @@ XMLTV_HTTP_TIMEOUT_SECONDS=30
# évite de multiplier les pics RAM/CPU et les écritures concurrentes.
XMLTV_SYNC_WORKERS=1

# Nombre maximal d'arènes de l'allocateur glibc. La valeur 2 évite que la
# décompression 7z conserve durablement son pic RAM après les imports.
MALLOC_ARENA_MAX=2

# User-Agent envoyé lors du téléchargement des sources XMLTV.
#XMLTV_USER_AGENT=tvguide/1.0
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
Ce projet suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et
[SemVer](https://semver.org/lang/fr/).

## [1.16.1] — 2026-07-18

### Performance et mémoire

- Les imports utilisent un pool fixe de workers : les mêmes threads sont
réutilisés au lieu de créer de nouvelles arènes natives à chaque lancement.
- L'image borne glibc à deux arènes (`MALLOC_ARENA_MAX=2`) et restitue les pages
inutilisées avec `malloc_trim(0)` après la collecte des objets d'import.
- Les requêtes de listes utilisent des projections SQL explicites et ne
matérialisent plus les colonnes de détail inutiles.

### Recherche et SQLite

- Recherche migrée vers un index SQLite FTS5 Unicode, insensible aux accents et
compatible avec les préfixes ; des triggers maintiennent l'index pendant les
imports atomiques et les suppressions.
- WAL checkpointé puis tronqué après migration et import réussi ; auto-checkpoint
activé et journal résiduel limité à 16 Mio.
- Migration automatique des programmes existants, avec test de mise à niveau et
de synchronisation de l'index.

## [1.16.0] — 2026-07-18

### Performance et consommation de ressources
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
MALLOC_ARENA_MAX=2 \
DATA_DIR=/data \
APP_PORT=8080

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ publication prévue. Garde-fous :
| `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. |
| `MALLOC_ARENA_MAX` | `2` | Borne les arènes de l'allocateur natif afin que les pics de décompression 7z ne restent pas réservés après l'import. |
| `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 @@ -175,6 +176,28 @@ mv data/backup.db /chemin/sauvegarde/
puis `docker compose up -d`. Les migrations de schéma s'appliquent
automatiquement au démarrage (Alembic).

### Migration vers 1.16.1 (index FTS5)

La mise à jour crée automatiquement un index de recherche plein texte sur les
titres, sous-titres et descriptions existants. Aucune commande SQL manuelle
n'est nécessaire et les sources, chaînes, réglages et programmes sont
conservés. La première ouverture peut prendre plus longtemps selon le nombre
de programmes.

Avant la mise à jour :

1. sauvegardez `data/app.db` avec la procédure ci-dessus ;
2. vérifiez que le volume dispose idéalement de **deux fois la taille actuelle
de `app.db`** en espace libre, pour la construction de l'index et son WAL ;
3. lancez `pull` puis `up -d` et suivez le démarrage avec
`docker compose -f compose.ghcr.yml logs -f tvguide`.

Le fichier WAL est checkpointé et tronqué après la migration puis après chaque
import réussi. L'index FTS5 augmente durablement la taille de la base en échange
d'une recherche qui ne parcourt plus tous les programmes. Pour revenir à
1.16.0, restaurez la sauvegarde réalisée avant migration ; ne démarrez pas
directement l'ancienne image sur une base déjà migrée.

## Reverse proxy

L'application écoute en HTTP simple sur un seul port et honore
Expand Down
71 changes: 71 additions & 0 deletions backend/alembic/versions/b7f4a9c2d1e8_add_programme_fts5.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""add FTS5 search index for programmes

Revision ID: b7f4a9c2d1e8
Revises: 03a3c8d4b621
Create Date: 2026-07-18
"""

from alembic import op

revision = "b7f4a9c2d1e8"
down_revision = "03a3c8d4b621"
branch_labels = None
depends_on = None


def upgrade() -> None:
# External-content FTS avoids duplicating the source text in the virtual
# table. The shadow index is synchronized for regular writes and for the
# bulk DELETE/INSERT transaction used by XMLTV imports.
op.execute(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS programmes_fts USING fts5(
title,
subtitle,
description,
content='programmes',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
)
"""
)
op.execute(
"""
CREATE TRIGGER IF NOT EXISTS programmes_fts_ai AFTER INSERT ON programmes BEGIN
INSERT INTO programmes_fts(rowid, title, subtitle, description)
VALUES (new.id, new.title, new.subtitle, new.description);
END
"""
)
op.execute(
"""
CREATE TRIGGER IF NOT EXISTS programmes_fts_ad AFTER DELETE ON programmes BEGIN
INSERT INTO programmes_fts(programmes_fts, rowid, title, subtitle, description)
VALUES ('delete', old.id, old.title, old.subtitle, old.description);
END
"""
)
op.execute(
"""
CREATE TRIGGER IF NOT EXISTS programmes_fts_au
AFTER UPDATE OF title, subtitle, description
ON programmes BEGIN
INSERT INTO programmes_fts(programmes_fts, rowid, title, subtitle, description)
VALUES ('delete', old.id, old.title, old.subtitle, old.description);
INSERT INTO programmes_fts(rowid, title, subtitle, description)
VALUES (new.id, new.title, new.subtitle, new.description);
END
"""
)
op.execute(
"""
INSERT INTO programmes_fts(programmes_fts) VALUES ('rebuild')
"""
)


def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS programmes_fts_au")
op.execute("DROP TRIGGER IF EXISTS programmes_fts_ad")
op.execute("DROP TRIGGER IF EXISTS programmes_fts_ai")
op.execute("DROP TABLE IF EXISTS programmes_fts")
19 changes: 17 additions & 2 deletions backend/app/api/routes/channels.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, load_only

from app.db.session import get_session
from app.models import Channel
Expand All @@ -12,6 +12,21 @@
@router.get("/channels", response_model=list[ChannelOut])
def list_channels(session: Session = Depends(get_session)) -> list[ChannelOut]:
rows = session.execute(
select(Channel).where(Channel.enabled.is_(True)).order_by(Channel.display_order, Channel.id)
select(Channel)
.options(
load_only(
Channel.id,
Channel.xmltv_id,
Channel.name,
Channel.custom_name,
Channel.icon_url,
Channel.custom_icon_url,
Channel.display_order,
Channel.enabled,
raiseload=True,
)
)
.where(Channel.enabled.is_(True))
.order_by(Channel.display_order, Channel.id)
).scalars()
return [ChannelOut.from_model(c) for c in rows]
48 changes: 37 additions & 11 deletions 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, defer
from sqlalchemy.orm import Session, load_only

from app.db.base import utcnow
from app.db.session import get_session
Expand All @@ -27,19 +27,45 @@


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),
"""Exact SQL projection used by every programme-card collection."""
return load_only(
Programme.id,
Programme.channel_id,
Programme.start_at_utc,
Programme.stop_at_utc,
Programme.title,
Programme.subtitle,
Programme.description,
Programme.category,
Programme.icon_url,
Programme.season_number,
Programme.episode_number,
Programme.rating,
Programme.is_new,
Programme.is_repeat,
raiseload=True,
)


def _channel_card_columns_only():
return load_only(
Channel.id,
Channel.xmltv_id,
Channel.name,
Channel.custom_name,
Channel.icon_url,
Channel.custom_icon_url,
Channel.display_order,
Channel.enabled,
raiseload=True,
)


def _enabled_channels(session: Session) -> list[Channel]:
return list(
session.execute(
select(Channel)
.options(_channel_card_columns_only())
.where(Channel.enabled.is_(True))
.order_by(Channel.display_order, Channel.id)
).scalars()
Expand Down Expand Up @@ -70,7 +96,7 @@ def _next_per_channel(
)
rows = session.execute(
select(Programme)
.options(*_card_columns_only())
.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 @@ -122,7 +148,7 @@ def live(at: datetime | None = None, session: Session = Depends(get_session)) ->

current_rows = session.execute(
select(Programme)
.options(*_card_columns_only())
.options(_card_columns_only())
.join(Channel, Channel.id == Programme.channel_id)
.where(
Channel.enabled.is_(True),
Expand Down Expand Up @@ -164,7 +190,7 @@ def _evening_response(
# real start time — not leave the channel empty.
rows = session.execute(
select(Programme)
.options(*_card_columns_only())
.options(_card_columns_only())
.join(Channel, Channel.id == Programme.channel_id)
.where(
Channel.enabled.is_(True),
Expand Down Expand Up @@ -227,7 +253,7 @@ def grid(
channels = _enabled_channels(session)
rows = session.execute(
select(Programme)
.options(*_card_columns_only())
.options(_card_columns_only())
.join(Channel, Channel.id == Programme.channel_id)
.where(
Channel.enabled.is_(True),
Expand Down
70 changes: 52 additions & 18 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import re
from datetime import date

from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, defer
from sqlalchemy import Integer, func, select, text
from sqlalchemy.orm import Session, load_only

from app.db.session import get_session
from app.models import Channel, Programme
Expand All @@ -13,9 +14,35 @@
router = APIRouter(tags=["search"])


def _like_pattern(query: str) -> str:
escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped.lower()}%"
_SEARCH_TOKEN = re.compile(r"\w+", flags=re.UNICODE)


def _fts_query(query: str) -> str | None:
"""Build a literal, prefix-enabled FTS query without exposing operators."""
tokens = _SEARCH_TOKEN.findall(query.casefold())
if not tokens:
return None
return " AND ".join(f'"{token}"*' for token in tokens)


def _card_projection():
return load_only(
Programme.id,
Programme.channel_id,
Programme.start_at_utc,
Programme.stop_at_utc,
Programme.title,
Programme.subtitle,
Programme.description,
Programme.category,
Programme.icon_url,
Programme.season_number,
Programme.episode_number,
Programme.rating,
Programme.is_new,
Programme.is_repeat,
raiseload=True,
)


@router.get("/search", response_model=list[SearchResult])
Expand All @@ -27,24 +54,31 @@ def search(
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
) -> list[SearchResult]:
pattern = _like_pattern(q)
match_query = _fts_query(q)
if match_query is None:
return []
matched = (
text("SELECT rowid FROM programmes_fts WHERE programmes_fts MATCH :match_query")
.bindparams(match_query=match_query)
.columns(rowid=Integer)
.subquery("matched_programmes")
)
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),
or_(
func.lower(Programme.title).like(pattern, escape="\\"),
func.lower(Programme.subtitle).like(pattern, escape="\\"),
func.lower(Programme.description).like(pattern, escape="\\"),
_card_projection(),
load_only(
Channel.id,
Channel.name,
Channel.custom_name,
Channel.icon_url,
Channel.custom_icon_url,
raiseload=True,
),
)
.join(matched, matched.c.rowid == Programme.id)
.join(Channel, Channel.id == Programme.channel_id)
.where(Channel.enabled.is_(True))
.order_by(Programme.start_at_utc)
.limit(limit)
)
Expand Down
Loading
Loading