- arte_api.py : GENRE_PAGES devient une liste de (nom, url), chaque concert reçoit un champ "categories" avec ses genres d'appartenance - main.py : endpoint /api/categories + param ?category= sur /api/concerts - index.html : barre de pills catégories (Tout + 10 genres) - style.css : styles .cat-bar / .cat-pill avec pill active en or - app.js : chargement dynamique des pills, filtre catégorie dans le state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+33
-17
@@ -16,21 +16,25 @@ PLAYER_API = "https://api.arte.tv/api/player/v2/config/fr/{pid}"
|
|||||||
SEARCH_URL = "https://www.arte.tv/fr/search/?q={q}"
|
SEARCH_URL = "https://www.arte.tv/fr/search/?q={q}"
|
||||||
|
|
||||||
GENRE_PAGES = [
|
GENRE_PAGES = [
|
||||||
"https://www.arte.tv/fr/p/pop-rock/",
|
("Pop & Rock", "https://www.arte.tv/fr/p/pop-rock/"),
|
||||||
"https://www.arte.tv/fr/p/classique/",
|
("Classique", "https://www.arte.tv/fr/p/classique/"),
|
||||||
"https://www.arte.tv/fr/p/musiques-electroniques/",
|
("Electro", "https://www.arte.tv/fr/p/musiques-electroniques/"),
|
||||||
"https://www.arte.tv/fr/p/jazz",
|
("Jazz", "https://www.arte.tv/fr/p/jazz"),
|
||||||
"https://www.arte.tv/fr/p/arts-de-la-scene",
|
("Arts de la scène", "https://www.arte.tv/fr/p/arts-de-la-scene"),
|
||||||
"https://www.arte.tv/fr/p/hip-hop",
|
("Hip-hop", "https://www.arte.tv/fr/p/hip-hop"),
|
||||||
"https://www.arte.tv/fr/p/metal",
|
("Metal", "https://www.arte.tv/fr/p/metal"),
|
||||||
"https://www.arte.tv/fr/p/opera",
|
("Opéra", "https://www.arte.tv/fr/p/opera"),
|
||||||
"https://www.arte.tv/fr/p/world",
|
("World", "https://www.arte.tv/fr/p/world"),
|
||||||
"https://www.arte.tv/fr/p/musique-baroque/",
|
("Baroque", "https://www.arte.tv/fr/p/musique-baroque/"),
|
||||||
# arte-concert pages pour l'agenda et contenu exclusif
|
]
|
||||||
|
|
||||||
|
EXTRA_PAGES = [
|
||||||
"https://www.arte.tv/fr/arte-concert/agenda/",
|
"https://www.arte.tv/fr/arte-concert/agenda/",
|
||||||
"https://www.arte.tv/fr/arte-concert/",
|
"https://www.arte.tv/fr/arte-concert/",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CATEGORIES = [name for name, _ in GENRE_PAGES]
|
||||||
|
|
||||||
_HEADERS = {
|
_HEADERS = {
|
||||||
"User-Agent": (
|
"User-Agent": (
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
@@ -88,14 +92,23 @@ def _metadata_for_pid(pid: str) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_all_sync() -> list[dict]:
|
def _fetch_all_sync() -> list[dict]:
|
||||||
all_ids: set[str] = set()
|
id_cats: dict[str, list[str]] = {}
|
||||||
for url in GENRE_PAGES:
|
for name, url in GENRE_PAGES:
|
||||||
|
ids = _prog_ids_from_page(url)
|
||||||
|
logger.info(" %s → %d IDs", name, len(ids))
|
||||||
|
for pid in ids:
|
||||||
|
id_cats.setdefault(pid, []).append(name)
|
||||||
|
|
||||||
|
all_ids: set[str] = set(id_cats)
|
||||||
|
for url in EXTRA_PAGES:
|
||||||
ids = _prog_ids_from_page(url)
|
ids = _prog_ids_from_page(url)
|
||||||
logger.info(" %s → %d IDs", url.split("/fr/")[1], len(ids))
|
logger.info(" %s → %d IDs", url.split("/fr/")[1], len(ids))
|
||||||
all_ids |= ids
|
all_ids |= ids
|
||||||
logger.info("Total unique programme IDs: %d", len(all_ids))
|
logger.info("Total unique programme IDs: %d", len(all_ids))
|
||||||
|
|
||||||
concerts = _resolve_ids(all_ids)
|
concerts = _resolve_ids(all_ids)
|
||||||
|
for c in concerts:
|
||||||
|
c["categories"] = id_cats.get(c["id"], [])
|
||||||
concerts.sort(key=lambda c: c.get("expiry") or "", reverse=True)
|
concerts.sort(key=lambda c: c.get("expiry") or "", reverse=True)
|
||||||
return concerts
|
return concerts
|
||||||
|
|
||||||
@@ -131,26 +144,29 @@ async def get_all_concerts() -> list[dict]:
|
|||||||
return _cache["data"]
|
return _cache["data"]
|
||||||
|
|
||||||
|
|
||||||
async def fetch_concerts(page: int = 1, search: str = "", page_size: int = 24) -> dict:
|
async def fetch_concerts(page: int = 1, search: str = "", page_size: int = 24, category: str = "") -> dict:
|
||||||
all_c = await get_all_concerts()
|
all_c = await get_all_concerts()
|
||||||
|
|
||||||
|
if category:
|
||||||
|
all_c = [c for c in all_c if category in (c.get("categories") or [])]
|
||||||
|
|
||||||
cached_ids = {c["id"] for c in all_c}
|
cached_ids = {c["id"] for c in all_c}
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
q = search.lower()
|
q = search.lower()
|
||||||
# local filter
|
|
||||||
local = [
|
local = [
|
||||||
c for c in all_c
|
c for c in all_c
|
||||||
if q in (c.get("title") or "").lower()
|
if q in (c.get("title") or "").lower()
|
||||||
or q in (c.get("subtitle") or "").lower()
|
or q in (c.get("subtitle") or "").lower()
|
||||||
or q in (c.get("description") or "").lower()
|
or q in (c.get("description") or "").lower()
|
||||||
]
|
]
|
||||||
# Arte search for IDs not in cache
|
# Remote search only when no category filter (results have no category info)
|
||||||
|
if not category:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
remote_ids = await loop.run_in_executor(None, _search_sync, search)
|
remote_ids = await loop.run_in_executor(None, _search_sync, search)
|
||||||
new_ids = remote_ids - cached_ids
|
new_ids = remote_ids - cached_ids
|
||||||
if new_ids:
|
if new_ids:
|
||||||
extra = await loop.run_in_executor(None, _resolve_ids, new_ids, None)
|
extra = await loop.run_in_executor(None, _resolve_ids, new_ids, None)
|
||||||
# merge: local results first, then extras not already present
|
|
||||||
local_ids = {c["id"] for c in local}
|
local_ids = {c["id"] for c in local}
|
||||||
for c in extra:
|
for c in extra:
|
||||||
if c["id"] not in local_ids:
|
if c["id"] not in local_ids:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from arte_api import fetch_concerts, invalidate_cache
|
from arte_api import fetch_concerts, invalidate_cache, CATEGORIES
|
||||||
from downloader import DownloadManager
|
from downloader import DownloadManager
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -31,9 +31,14 @@ async def index(request: Request):
|
|||||||
# ------------------------------------------------------------------ API: concerts
|
# ------------------------------------------------------------------ API: concerts
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/categories")
|
||||||
|
async def api_categories():
|
||||||
|
return CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/concerts")
|
@app.get("/api/concerts")
|
||||||
async def api_concerts(page: int = 1, search: str = "", page_size: int = 24):
|
async def api_concerts(page: int = 1, search: str = "", page_size: int = 24, category: str = ""):
|
||||||
return await fetch_concerts(page=page, search=search, page_size=page_size)
|
return await fetch_concerts(page=page, search=search, page_size=page_size, category=category)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/refresh")
|
@app.post("/api/refresh")
|
||||||
|
|||||||
+28
-1
@@ -4,6 +4,7 @@
|
|||||||
const state = {
|
const state = {
|
||||||
page: 1,
|
page: 1,
|
||||||
search: '',
|
search: '',
|
||||||
|
category: '',
|
||||||
pageSize: 24,
|
pageSize: 24,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
current: null, // concert object shown in modal
|
current: null, // concert object shown in modal
|
||||||
@@ -21,6 +22,7 @@ const modalOverlay = $('modal-overlay');
|
|||||||
const dlPanel = $('dl-panel');
|
const dlPanel = $('dl-panel');
|
||||||
const dlPanelBody = $('dl-panel-body');
|
const dlPanelBody = $('dl-panel-body');
|
||||||
const dlBadge = $('dl-badge');
|
const dlBadge = $('dl-badge');
|
||||||
|
const catBar = $('cat-bar');
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function fmtDuration(secs) {
|
function fmtDuration(secs) {
|
||||||
@@ -45,12 +47,37 @@ function debounce(fn, ms) {
|
|||||||
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Categories ───────────────────────────────────────────────────────────────
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
const cats = await fetch('/api/categories').then(r => r.json());
|
||||||
|
cats.forEach(cat => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'cat-pill';
|
||||||
|
btn.dataset.cat = cat;
|
||||||
|
btn.textContent = cat;
|
||||||
|
catBar.appendChild(btn);
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
catBar.addEventListener('click', e => {
|
||||||
|
const pill = e.target.closest('.cat-pill');
|
||||||
|
if (!pill) return;
|
||||||
|
catBar.querySelectorAll('.cat-pill').forEach(p => p.classList.remove('active'));
|
||||||
|
pill.classList.add('active');
|
||||||
|
state.category = pill.dataset.cat;
|
||||||
|
state.page = 1;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Concerts ─────────────────────────────────────────────────────────────────
|
// ── Concerts ─────────────────────────────────────────────────────────────────
|
||||||
async function loadConcerts() {
|
async function loadConcerts() {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: state.page,
|
page: state.page,
|
||||||
search: state.search,
|
search: state.search,
|
||||||
page_size: state.pageSize,
|
page_size: state.pageSize,
|
||||||
|
category: state.category,
|
||||||
});
|
});
|
||||||
const res = await fetch(`/api/concerts?${params}`);
|
const res = await fetch(`/api/concerts?${params}`);
|
||||||
if (!res.ok) throw new Error(res.statusText);
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
@@ -359,6 +386,6 @@ document.addEventListener('keydown', e => {
|
|||||||
|
|
||||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
(async () => {
|
(async () => {
|
||||||
await refreshDlHistory();
|
await Promise.all([loadCategories(), refreshDlHistory()]);
|
||||||
await refresh();
|
await refresh();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -195,6 +195,45 @@ body {
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══ CATEGORY BAR ══════════════════════════════════════════════════════════ */
|
||||||
|
.cat-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-bar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.cat-pill {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color var(--transition), border-color var(--transition), background var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-pill:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-pill.active {
|
||||||
|
background: var(--gold);
|
||||||
|
border-color: var(--gold);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ══ GRID ══════════════════════════════════════════════════════════════════ */
|
/* ══ GRID ══════════════════════════════════════════════════════════════════ */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
<!-- ══ MAIN ═══════════════════════════════════════════════════════════════ -->
|
<!-- ══ MAIN ═══════════════════════════════════════════════════════════════ -->
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- categories -->
|
||||||
|
<div class="cat-bar" id="cat-bar">
|
||||||
|
<button class="cat-pill active" data-cat="">Tout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- status bar -->
|
<!-- status bar -->
|
||||||
<div class="status-bar" id="status-bar">
|
<div class="status-bar" id="status-bar">
|
||||||
<span id="status-text">Chargement du catalogue…</span>
|
<span id="status-text">Chargement du catalogue…</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user