Ajout Serie

This commit is contained in:
Pierre 2025-10-25 19:29:37 +02:00
parent 753f31f4dd
commit 7b97e74b39
13 changed files with 1030 additions and 367 deletions

Binary file not shown.

420
app.py
View File

@ -6,15 +6,16 @@ from urllib.parse import urlencode
from xml.etree import ElementTree as ET
from flask import Flask, request, render_template, Response, abort
# ---------- Configuration ----------
DB_PATH = r"/root/.kodi/userdata/Database/MyVideos131.db"
DEFAULT_PER_PAGE = 24 # gallery default
DEFAULT_VIEW = "gallery" # gallery by default
#DB_PATH = os.environ.get("KODI_DB_PATH", r"/home/pi/.kodi/userdata/Database/MyVideos121.db")
DB_PATH = r"/home/kodi/.kodi/userdata/Database/MyVideos131.db"
DEFAULT_PER_PAGE_GALLERY = 24
DEFAULT_PER_PAGE_LIST = 25
DEFAULT_VIEW = "gallery"
app = Flask(__name__)
app.jinja_env.globals.update(range=range)
# ---------- DB Helpers ----------
# ---------- DB ----------
def get_db():
if not os.path.exists(DB_PATH):
raise FileNotFoundError(f"Base Kodi introuvable: {DB_PATH}")
@ -22,58 +23,25 @@ def get_db():
conn.row_factory = sqlite3.Row
return conn
def table_exists(conn, name):
cur = conn.execute("SELECT 1 FROM sqlite_master WHERE (type='table' OR type='view') AND name=?", (name,))
return cur.fetchone() is not None
def resolve_movie_table(conn):
cur = conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name IN ('movie_view','movie')")
names = [r["name"] for r in cur.fetchall()]
if "movie_view" in names:
return "movie_view"
elif "movie" in names:
return "movie"
else:
for n in ("movie_view", "movie"):
if table_exists(conn, n):
return n
raise RuntimeError("Ni 'movie_view' ni 'movie' n'existent dans cette base.")
def base_where(filters):
where = []
params = []
if filters.get("q"):
where.append("(LOWER(c00) LIKE ? OR LOWER(c16) LIKE ? OR LOWER(c01) LIKE ?)")
like = f"%{filters['q'].lower()}%"
params += [like, like, like]
if filters.get("actor"):
where.append("LOWER(c06) LIKE ?")
params.append(f"%{filters['actor'].lower()}%")
if filters.get("country"):
where.append("LOWER(c21) LIKE ?")
params.append(f"%{filters['country'].lower()}%")
if filters.get("year_from"):
where.append("CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) >= ?")
params.append(int(filters["year_from"]))
if filters.get("year_to"):
where.append("CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) <= ?")
params.append(int(filters["year_to"]))
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
return where_sql, params
def resolve_tvshow_table(conn):
for n in ("tvshow_view", "tvshow"):
if table_exists(conn, n):
return n
raise RuntimeError("Ni 'tvshow_view' ni 'tvshow' n'existent dans cette base.")
SORT_COLUMNS = {
"name": "c00",
"original": "c16",
"actors": "c06",
"country": "c21",
"year": "CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER)",
"date": "premiered",
"path": "c22"
}
DEFAULT_SORT = "name"
DEFAULT_DIR = "asc"
def sanitize_sort(sort, direction):
sort = sort if sort in SORT_COLUMNS else DEFAULT_SORT
direction = direction.lower()
direction = "desc" if direction == "desc" else "asc"
return sort, direction
# ---------- c08 parser ----------
def extract_poster_from_c08(c08: str):
# ---------- Parsers ----------
def extract_movie_poster_from_c08(c08: str):
"""Movies: c08 holds <thumb ... aspect='poster'> entries. Prefer TMDB, else first poster."""
if not c08 or not isinstance(c08, str):
return None
xml_text = f"<root>{c08}</root>"
@ -92,17 +60,79 @@ def extract_poster_from_c08(c08: str):
return u
return posters[0] if posters else None
# ---------- Queries ----------
def extract_show_poster_from_c06(c06: str):
"""TV shows: c06 contains <thumb aspect="poster">…</thumb>. Return first poster (prefer TMDB)."""
if not c06 or not isinstance(c06, str):
return None
xml_text = f"<root>{c06}</root>"
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return None
posters = []
for th in root.findall("thumb"):
aspect = (th.attrib.get("aspect") or "").lower()
url = (th.text or "").strip()
if url and (aspect == "poster"):
posters.append(url)
if posters:
for u in posters:
if "image.tmdb.org" in u:
return u
return posters[0]
# fallback: first thumb (any aspect)
th2 = root.find("thumb")
return (th2.text or "").strip() if th2 is not None else None
# ---------- Movies ----------
SORT_COLUMNS_MOVIES = {
"name": "c00",
"original": "c16",
"actors": "c06",
"country": "c21",
"year": "CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER)",
"date": "premiered",
"path": "c22"
}
DEFAULT_SORT_MOVIES = "name"
DEFAULT_DIR = "asc"
def sanitize_sort(key, mapping, default_key, direction):
key = key if key in mapping else default_key
direction = "desc" if (direction or "").lower() == "desc" else "asc"
return key, direction
def base_where_movies(filters):
where = []
params = []
if filters.get("q"):
where.append("(LOWER(c00) LIKE ? OR LOWER(c16) LIKE ? OR LOWER(c01) LIKE ?)")
like = f"%{filters['q'].lower()}%"
params += [like, like, like]
if filters.get("actor"):
where.append("LOWER(c06) LIKE ?")
params.append(f"%{filters['actor'].lower()}%")
if filters.get("country"):
where.append("LOWER(c21) LIKE ?")
params.append(f"%{filters['country'].lower()}%")
if filters.get("year_from"):
where.append("CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) >= ?")
params.append(int(filters["year_from"]))
if filters.get("year_to"):
where.append("CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) <= ?")
params.append(int(filters["year_to"]))
return ("WHERE " + " AND ".join(where)) if where else "", params
def count_movies(conn, filters):
table = resolve_movie_table(conn)
where_sql, params = base_where(filters)
sql = f"SELECT COUNT(*) as n FROM {table} {where_sql}"
where, params = base_where_movies(filters)
sql = f"SELECT COUNT(*) as n FROM {table} {where}"
return conn.execute(sql, params).fetchone()["n"]
def fetch_movies(conn, filters, page, per_page, sort, direction):
table = resolve_movie_table(conn)
where_sql, params = base_where(filters)
sort_sql = SORT_COLUMNS.get(sort, SORT_COLUMNS[DEFAULT_SORT])
where_sql, params = base_where_movies(filters)
sort_sql = SORT_COLUMNS_MOVIES.get(sort, SORT_COLUMNS_MOVIES[DEFAULT_SORT_MOVIES])
order_clause = f"{sort_sql} {direction.upper()}, c00 ASC"
sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path,
@ -117,42 +147,88 @@ def fetch_movies(conn, filters, page, per_page, sort, direction):
result = []
for r in rows:
d = dict(r)
d["poster"] = extract_poster_from_c08(d.get("thumbs"))
d["poster"] = extract_movie_poster_from_c08(d.get("thumbs"))
result.append(d)
return result
def fetch_movie_by_id(conn, movie_id: int):
table = resolve_movie_table(conn)
# ---------- TV Shows ----------
SORT_COLUMNS_SHOWS = {
"title": "c00",
"original": "c09",
"network": "c14",
"genre": "c08",
"year": "CAST(substr(COALESCE(c05, ''), 1, 4) AS INTEGER)",
"date": "c05"
}
DEFAULT_SORT_SHOWS = "title"
def base_where_shows(filters):
where = []
params = []
if filters.get("q"):
where.append("(LOWER(c00) LIKE ? OR LOWER(c09) LIKE ? OR LOWER(c01) LIKE ?)")
like = f"%{filters['q'].lower()}%"
params += [like, like, like]
if filters.get("network"):
where.append("LOWER(c14) LIKE ?")
params.append(f"%{filters['network'].lower()}%")
if filters.get("genre"):
where.append("LOWER(c08) LIKE ?")
params.append(f"%{filters['genre'].lower()}%")
if filters.get("year_from"):
where.append("CAST(substr(COALESCE(c05, ''), 1, 4) AS INTEGER) >= ?")
params.append(int(filters["year_from"]))
if filters.get("year_to"):
where.append("CAST(substr(COALESCE(c05, ''), 1, 4) AS INTEGER) <= ?")
params.append(int(filters["year_to"]))
return ("WHERE " + " AND ".join(where)) if where else "", params
def count_shows(conn, filters):
table = resolve_tvshow_table(conn)
where, params = base_where_shows(filters)
sql = f"SELECT COUNT(*) as n FROM {table} {where}"
return conn.execute(sql, params).fetchone()["n"]
def fetch_shows(conn, filters, page, per_page, sort, direction):
table = resolve_tvshow_table(conn)
where_sql, params = base_where_shows(filters)
sort_sql = SORT_COLUMNS_SHOWS.get(sort, SORT_COLUMNS_SHOWS[DEFAULT_SORT_SHOWS])
order_clause = f"{sort_sql} {direction.upper()}, c00 ASC"
sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path,
premiered AS date, c08 AS thumbs,
CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) AS year
SELECT idShow AS id, c00 AS titre, c01 AS desc, c05 AS date,
c06 AS thumbs, c08 AS genre, c09 AS original, c14 AS diffuseur
FROM {table}
WHERE idMovie = ?
{where_sql}
ORDER BY {order_clause}
LIMIT ? OFFSET ?
"""
rows = conn.execute(sql, params + [per_page, (page - 1) * per_page]).fetchall()
result = []
for r in rows:
d = dict(r)
d["year"] = int((d.get("date") or "")[:4]) if d.get("date") and str(d["date"])[:4].isdigit() else None
d["poster"] = extract_show_poster_from_c06(d.get("thumbs"))
result.append(d)
return result
def fetch_show_by_id(conn, show_id):
table = resolve_tvshow_table(conn)
sql = f"""
SELECT idShow AS id, c00 AS titre, c01 AS desc, c05 AS date,
c06 AS thumbs, c08 AS genre, c09 AS original, c14 AS diffuseur
FROM {table}
WHERE idShow = ?
LIMIT 1
"""
r = conn.execute(sql, (movie_id,)).fetchone()
if not r: return None
r = conn.execute(sql, (show_id,)).fetchone()
if not r:
return None
d = dict(r)
d["poster"] = extract_poster_from_c08(d.get("thumbs"))
return d
def fetch_movie_by_path(conn, movie_path: str):
table = resolve_movie_table(conn)
sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path,
premiered AS date, c08 AS thumbs,
CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) AS year
FROM {table}
WHERE c22 = ?
LIMIT 1
"""
r = conn.execute(sql, (movie_path,)).fetchone()
if not r: return None
d = dict(r)
d["poster"] = extract_poster_from_c08(d.get("thumbs"))
d["year"] = int((d.get("date") or "")[:4]) if d.get("date") and str(d["date"])[:4].isdigit() else None
d["poster"] = extract_show_poster_from_c06(d.get("thumbs"))
return d
# ---------- Utils ----------
def build_querystring(extras=None, **kwargs):
args = dict(request.args)
args.update(kwargs)
@ -163,32 +239,28 @@ def build_querystring(extras=None, **kwargs):
# ---------- Routes ----------
@app.route("/")
def index():
# Filters
def root():
return movies_page()
@app.route("/movies")
def movies_page():
q = request.args.get("q", "").strip()
actor = request.args.get("actor", "").strip()
country = request.args.get("country", "").strip()
year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip()
# Sorting
sort = request.args.get("sort", "name")
direction = request.args.get("dir", "asc")
sort, direction = sanitize_sort(sort, direction)
# View
sort = request.args.get("sort", DEFAULT_SORT_MOVIES)
direction = request.args.get("dir", DEFAULT_DIR)
sort, direction = sanitize_sort(sort, SORT_COLUMNS_MOVIES, DEFAULT_SORT_MOVIES, direction)
view = request.args.get("view", DEFAULT_VIEW)
view = view if view in ("gallery","list") else DEFAULT_VIEW
# Paging
page = int(request.args.get("page", 1) or 1)
per_page = int(request.args.get("per_page", DEFAULT_PER_PAGE) or DEFAULT_PER_PAGE)
default_pp = DEFAULT_PER_PAGE_GALLERY if view == 'gallery' else DEFAULT_PER_PAGE_LIST
per_page = int(request.args.get("per_page", default_pp) or default_pp)
per_page = max(6, min(per_page, 120))
filters = {
"q": q or None,
"actor": actor or None,
"country": country or None,
"year_from": year_from or None,
"year_to": year_to or None,
}
filters = {"q": q or None, "actor": actor or None, "country": country or None,
"year_from": year_from or None, "year_to": year_to or None}
try:
conn = get_db()
@ -202,90 +274,142 @@ def index():
start_page = 1 if page <= 3 else page - 2
end_page = last_page if page + 2 >= last_page else page + 2
return render_template(
"movies.html",
movies=movies,
total=total,
page=page,
last_page=last_page,
per_page=per_page,
sort=sort,
direction=direction,
view=view,
return render_template("movies.html",
movies=movies, total=total, page=page, last_page=last_page, per_page=per_page,
sort=sort, direction=direction, view=view,
q=q, actor=actor, country=country, year_from=year_from, year_to=year_to,
build_querystring=build_querystring,
start_page=start_page,
end_page=end_page
build_querystring=build_querystring, start_page=start_page, end_page=end_page
)
@app.route("/movie/<int:movie_id>")
def movie_detail(movie_id):
try:
conn = get_db()
movie = fetch_movie_by_id(conn, movie_id)
if not movie:
abort(404)
table = resolve_movie_table(conn)
sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path,
premiered AS date, c08 AS thumbs,
CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) AS year
FROM {table} WHERE idMovie = ? LIMIT 1
"""
r = conn.execute(sql, (movie_id,)).fetchone()
if not r: abort(404)
m = dict(r)
m["poster"] = extract_movie_poster_from_c08(m.get("thumbs"))
except Exception as e:
return render_template("error.html", error=str(e))
return render_template("movie_detail.html", m=movie)
return render_template("movie_detail.html", m=m)
@app.route("/shows")
def shows_page():
q = request.args.get("q", "").strip()
network = request.args.get("network", "").strip()
genre = request.args.get("genre", "").strip()
year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip()
sort = request.args.get("sort", DEFAULT_SORT_SHOWS)
direction = request.args.get("dir", DEFAULT_DIR)
sort, direction = sanitize_sort(sort, SORT_COLUMNS_SHOWS, DEFAULT_SORT_SHOWS, direction)
view = request.args.get("view", DEFAULT_VIEW)
view = view if view in ("gallery","list") else DEFAULT_VIEW
page = int(request.args.get("page", 1) or 1)
default_pp = DEFAULT_PER_PAGE_GALLERY if view == 'gallery' else DEFAULT_PER_PAGE_LIST
per_page = int(request.args.get("per_page", default_pp) or default_pp)
per_page = max(6, min(per_page, 120))
filters = {"q": q or None, "network": network or None, "genre": genre or None,
"year_from": year_from or None, "year_to": year_to or None}
@app.route("/movie/by-path")
def movie_by_path():
path = request.args.get("path", "").strip()
if not path:
abort(400)
try:
conn = get_db()
movie = fetch_movie_by_path(conn, path)
if not movie:
abort(404)
total = count_shows(conn, filters)
last_page = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, last_page))
shows = fetch_shows(conn, filters, page, per_page, sort, direction)
except Exception as e:
return render_template("error.html", error=str(e))
return render_template("movie_detail.html", m=movie)
# CSV export (respects filters/sort/view irrelevant)
@app.route("/export.csv")
def export_csv():
start_page = 1 if page <= 3 else page - 2
end_page = last_page if page + 2 >= last_page else page + 2
return render_template("shows.html",
shows=shows, total=total, page=page, last_page=last_page, per_page=per_page,
sort=sort, direction=direction, view=view,
q=q, network=network, genre=genre, year_from=year_from, year_to=year_to,
build_querystring=build_querystring, start_page=start_page, end_page=end_page
)
@app.route("/show/<int:show_id>")
def show_detail(show_id):
try:
conn = get_db()
s = fetch_show_by_id(conn, show_id)
if not s: abort(404)
except Exception as e:
return render_template("error.html", error=str(e))
return render_template("show_detail.html", s=s)
@app.route("/export_movies.csv")
def export_movies_csv():
q = request.args.get("q", "").strip()
actor = request.args.get("actor", "").strip()
country = request.args.get("country", "").strip()
year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip()
sort = request.args.get("sort", "name")
direction = request.args.get("dir", "asc")
sort, direction = sanitize_sort(sort, direction)
filters = {
"q": q or None,
"actor": actor or None,
"country": country or None,
"year_from": year_from or None,
"year_to": year_to or None,
}
sort = request.args.get("sort", DEFAULT_SORT_MOVIES)
direction = request.args.get("dir", DEFAULT_DIR)
sort, direction = sanitize_sort(sort, SORT_COLUMNS_MOVIES, DEFAULT_SORT_MOVIES, direction)
filters = {"q": q or None, "actor": actor or None, "country": country or None,
"year_from": year_from or None, "year_to": year_to or None}
try:
conn = get_db()
# reuse fetch_movies with large per_page to get all (or reimplement full fetch if needed)
total = count_movies(conn, filters)
rows = fetch_movies(conn, filters, 1, max(total, 1), sort, direction)
except Exception as e:
return render_template("error.html", error=str(e))
si = StringIO()
import csv
writer = csv.writer(si)
writer.writerow(["nom", "desc", "acteurs", "titre", "pays", "path", "date", "year", "poster"])
for r in rows:
writer.writerow([r.get("nom",""), r.get("desc",""), r.get("acteurs",""),
r.get("titre",""), r.get("pays",""), r.get("path",""),
r.get("date",""), r.get("year",""), r.get("poster","")])
output = si.getvalue()
return Response(
output,
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=movies_export.csv"}
)
return Response(si.getvalue(), mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=movies_export.csv"})
@app.route("/export_shows.csv")
def export_shows_csv():
q = request.args.get("q", "").strip()
network = request.args.get("network", "").strip()
genre = request.args.get("genre", "").strip()
year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip()
sort = request.args.get("sort", DEFAULT_SORT_SHOWS)
direction = request.args.get("dir", DEFAULT_DIR)
sort, direction = sanitize_sort(sort, SORT_COLUMNS_SHOWS, DEFAULT_SORT_SHOWS, direction)
filters = {"q": q or None, "network": network or None, "genre": genre or None,
"year_from": year_from or None, "year_to": year_to or None}
try:
conn = get_db()
total = count_shows(conn, filters)
rows = fetch_shows(conn, filters, 1, max(total, 1), sort, direction)
except Exception as e:
return render_template("error.html", error=str(e))
si = StringIO()
writer = csv.writer(si)
writer.writerow(["titre", "original", "desc", "genre", "diffuseur", "date", "year", "poster"])
for r in rows:
writer.writerow([r.get("titre",""), r.get("original",""), r.get("desc",""),
r.get("genre",""), r.get("diffuseur",""), r.get("date",""),
r.get("year",""), r.get("poster","")])
return Response(si.getvalue(), mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=shows_export.csv"})
# ---------- Jinja filters ----------
@app.template_filter("none_to_dash")
def none_to_dash(value):
return value if value not in (None, "", "None") else ""

296
app.py.v1 Normal file
View File

@ -0,0 +1,296 @@
import os
import csv
import sqlite3
from io import StringIO
from urllib.parse import urlencode
from xml.etree import ElementTree as ET
from flask import Flask, request, render_template, Response, abort
# ---------- Configuration ----------
DB_PATH = r"./MyVideos131.db"
#DB_PATH = os.environ.get("KODI_DB_PATH", r"/home/pi/.kodi/userdata/Database/MyVideos121.db")
DEFAULT_PER_PAGE = 24 # gallery default
DEFAULT_VIEW = "gallery" # gallery by default
app = Flask(__name__)
app.jinja_env.globals.update(range=range)
# ---------- DB Helpers ----------
def get_db():
if not os.path.exists(DB_PATH):
raise FileNotFoundError(f"Base Kodi introuvable: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def resolve_movie_table(conn):
cur = conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name IN ('movie_view','movie')")
names = [r["name"] for r in cur.fetchall()]
if "movie_view" in names:
return "movie_view"
elif "movie" in names:
return "movie"
else:
raise RuntimeError("Ni 'movie_view' ni 'movie' n'existent dans cette base.")
def base_where(filters):
where = []
params = []
if filters.get("q"):
where.append("(LOWER(c00) LIKE ? OR LOWER(c16) LIKE ? OR LOWER(c01) LIKE ?)")
like = f"%{filters['q'].lower()}%"
params += [like, like, like]
if filters.get("actor"):
where.append("LOWER(c06) LIKE ?")
params.append(f"%{filters['actor'].lower()}%")
if filters.get("country"):
where.append("LOWER(c21) LIKE ?")
params.append(f"%{filters['country'].lower()}%")
if filters.get("year_from"):
where.append("CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) >= ?")
params.append(int(filters["year_from"]))
if filters.get("year_to"):
where.append("CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) <= ?")
params.append(int(filters["year_to"]))
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
return where_sql, params
SORT_COLUMNS = {
"name": "c00",
"original": "c16",
"actors": "c06",
"country": "c21",
"year": "CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER)",
"date": "premiered",
"path": "c22"
}
DEFAULT_SORT = "name"
DEFAULT_DIR = "asc"
def sanitize_sort(sort, direction):
sort = sort if sort in SORT_COLUMNS else DEFAULT_SORT
direction = direction.lower()
direction = "desc" if direction == "desc" else "asc"
return sort, direction
# ---------- c08 parser ----------
def extract_poster_from_c08(c08: str):
if not c08 or not isinstance(c08, str):
return None
xml_text = f"<root>{c08}</root>"
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return None
posters = []
for th in root.findall("thumb"):
aspect = (th.attrib.get("aspect") or "").lower()
url = (th.text or "").strip()
if aspect == "poster" and url:
posters.append(url)
for u in posters:
if "image.tmdb.org" in u:
return u
return posters[0] if posters else None
# ---------- Queries ----------
def count_movies(conn, filters):
table = resolve_movie_table(conn)
where_sql, params = base_where(filters)
sql = f"SELECT COUNT(*) as n FROM {table} {where_sql}"
return conn.execute(sql, params).fetchone()["n"]
def fetch_movies(conn, filters, page, per_page, sort, direction):
table = resolve_movie_table(conn)
where_sql, params = base_where(filters)
sort_sql = SORT_COLUMNS.get(sort, SORT_COLUMNS[DEFAULT_SORT])
order_clause = f"{sort_sql} {direction.upper()}, c00 ASC"
sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path,
premiered AS date, c08 AS thumbs,
CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) AS year
FROM {table}
{where_sql}
ORDER BY {order_clause}
LIMIT ? OFFSET ?
"""
rows = conn.execute(sql, params + [per_page, (page - 1) * per_page]).fetchall()
result = []
for r in rows:
d = dict(r)
d["poster"] = extract_poster_from_c08(d.get("thumbs"))
result.append(d)
return result
def fetch_movie_by_id(conn, movie_id: int):
table = resolve_movie_table(conn)
sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path,
premiered AS date, c08 AS thumbs,
CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) AS year
FROM {table}
WHERE idMovie = ?
LIMIT 1
"""
r = conn.execute(sql, (movie_id,)).fetchone()
if not r: return None
d = dict(r)
d["poster"] = extract_poster_from_c08(d.get("thumbs"))
return d
def fetch_movie_by_path(conn, movie_path: str):
table = resolve_movie_table(conn)
sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path,
premiered AS date, c08 AS thumbs,
CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) AS year
FROM {table}
WHERE c22 = ?
LIMIT 1
"""
r = conn.execute(sql, (movie_path,)).fetchone()
if not r: return None
d = dict(r)
d["poster"] = extract_poster_from_c08(d.get("thumbs"))
return d
def build_querystring(extras=None, **kwargs):
args = dict(request.args)
args.update(kwargs)
if extras:
args.update(extras)
args = {k: v for k, v in args.items() if v not in (None, "", [])}
return urlencode(args)
# ---------- Routes ----------
@app.route("/")
def index():
# Filters
q = request.args.get("q", "").strip()
actor = request.args.get("actor", "").strip()
country = request.args.get("country", "").strip()
year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip()
# Sorting
sort = request.args.get("sort", "name")
direction = request.args.get("dir", "asc")
sort, direction = sanitize_sort(sort, direction)
# View
view = request.args.get("view", DEFAULT_VIEW)
view = view if view in ("gallery","list") else DEFAULT_VIEW
# Paging
page = int(request.args.get("page", 1) or 1)
per_page = int(request.args.get("per_page", DEFAULT_PER_PAGE) or DEFAULT_PER_PAGE)
per_page = max(6, min(per_page, 120))
filters = {
"q": q or None,
"actor": actor or None,
"country": country or None,
"year_from": year_from or None,
"year_to": year_to or None,
}
try:
conn = get_db()
total = count_movies(conn, filters)
last_page = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, last_page))
movies = fetch_movies(conn, filters, page, per_page, sort, direction)
except Exception as e:
return render_template("error.html", error=str(e))
start_page = 1 if page <= 3 else page - 2
end_page = last_page if page + 2 >= last_page else page + 2
return render_template(
"movies.html",
movies=movies,
total=total,
page=page,
last_page=last_page,
per_page=per_page,
sort=sort,
direction=direction,
view=view,
q=q, actor=actor, country=country, year_from=year_from, year_to=year_to,
build_querystring=build_querystring,
start_page=start_page,
end_page=end_page
)
@app.route("/movie/<int:movie_id>")
def movie_detail(movie_id):
try:
conn = get_db()
movie = fetch_movie_by_id(conn, movie_id)
if not movie:
abort(404)
except Exception as e:
return render_template("error.html", error=str(e))
return render_template("movie_detail.html", m=movie)
@app.route("/movie/by-path")
def movie_by_path():
path = request.args.get("path", "").strip()
if not path:
abort(400)
try:
conn = get_db()
movie = fetch_movie_by_path(conn, path)
if not movie:
abort(404)
except Exception as e:
return render_template("error.html", error=str(e))
return render_template("movie_detail.html", m=movie)
# CSV export (respects filters/sort/view irrelevant)
@app.route("/export.csv")
def export_csv():
q = request.args.get("q", "").strip()
actor = request.args.get("actor", "").strip()
country = request.args.get("country", "").strip()
year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip()
sort = request.args.get("sort", "name")
direction = request.args.get("dir", "asc")
sort, direction = sanitize_sort(sort, direction)
filters = {
"q": q or None,
"actor": actor or None,
"country": country or None,
"year_from": year_from or None,
"year_to": year_to or None,
}
try:
conn = get_db()
# reuse fetch_movies with large per_page to get all (or reimplement full fetch if needed)
total = count_movies(conn, filters)
rows = fetch_movies(conn, filters, 1, max(total, 1), sort, direction)
except Exception as e:
return render_template("error.html", error=str(e))
si = StringIO()
import csv
writer = csv.writer(si)
writer.writerow(["nom", "desc", "acteurs", "titre", "pays", "path", "date", "year", "poster"])
for r in rows:
writer.writerow([r.get("nom",""), r.get("desc",""), r.get("acteurs",""),
r.get("titre",""), r.get("pays",""), r.get("path",""),
r.get("date",""), r.get("year",""), r.get("poster","")])
output = si.getvalue()
return Response(
output,
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=movies_export.csv"}
)
@app.template_filter("none_to_dash")
def none_to_dash(value):
return value if value not in (None, "", "None") else "—"
if __name__ == "__main__":
port = int(os.environ.get("PORT", "5000"))
app.run(debug=True, host="0.0.0.0", port=port)

View File

@ -1,45 +1,22 @@
<!doctype html>
<html lang="fr" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Kodi - Films{% endblock %}</title>
<script>
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Kodi - Médiathèque{% endblock %}</title>
<script>(function(){const s=localStorage.getItem('theme');const d=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches;if(s==='dark'||(!s&&d)){document.documentElement.classList.add('dark');}else{document.documentElement.classList.remove('dark');}})();</script>
<script src="https://cdn.tailwindcss.com"></script><script>tailwind.config={darkMode:'class'}</script>
</head>
<body class="h-full bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<div class="max-w-7xl mx-auto p-4 md:p-6">
<header class="flex items-center gap-3 mb-4">
<h1 class="text-xl md:text-2xl font-semibold">Kodi - Films</h1>
<div class="ml-auto flex items-center gap-2">
<button id="themeToggle" class="px-3 py-1 rounded-lg border dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="inline dark:hidden">🌙 Mode sombre</span>
<span class="hidden dark:inline">☀️ Mode clair</span>
</button>
</div>
<nav class="flex gap-2">
<a href="/movies" class="px-3 py-1 rounded-lg border dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700">🎬 Films</a>
<a href="/shows" class="px-3 py-1 rounded-lg border dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700">📺 Séries</a>
</nav>
<div class="ml-auto"><button id="themeToggle" class="px-3 py-1 rounded-lg border dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700"><span class="inline dark:hidden">🌙 Mode sombre</span><span class="hidden dark:inline">☀️ Mode clair</span></button></div>
</header>
{% block content %}{% endblock %}
</div>
<script>
document.getElementById('themeToggle')?.addEventListener('click', () => {
const el = document.documentElement;
const newTheme = el.classList.contains('dark') ? 'light' : 'dark';
el.classList.toggle('dark');
localStorage.setItem('theme', newTheme);
});
</script>
<script>document.getElementById('themeToggle')?.addEventListener('click',()=>{const el=document.documentElement;const t=el.classList.contains('dark')?'light':'dark';el.classList.toggle('dark');localStorage.setItem('theme',t);});</script>
</body>
</html>

45
templates/base.html.v1 Normal file
View File

@ -0,0 +1,45 @@
<!doctype html>
<html lang="fr" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Kodi - Films{% endblock %}</title>
<script>
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
</head>
<body class="h-full bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<div class="max-w-7xl mx-auto p-4 md:p-6">
<header class="flex items-center gap-3 mb-4">
<h1 class="text-xl md:text-2xl font-semibold">Kodi - Films</h1>
<div class="ml-auto flex items-center gap-2">
<button id="themeToggle" class="px-3 py-1 rounded-lg border dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="inline dark:hidden">🌙 Mode sombre</span>
<span class="hidden dark:inline">☀️ Mode clair</span>
</button>
</div>
</header>
{% block content %}{% endblock %}
</div>
<script>
document.getElementById('themeToggle')?.addEventListener('click', () => {
const el = document.documentElement;
const newTheme = el.classList.contains('dark') ? 'light' : 'dark';
el.classList.toggle('dark');
localStorage.setItem('theme', newTheme);
});
</script>
</body>
</html>

View File

@ -1,9 +1,7 @@
{% extends "base.html" %}
{% block title %}Erreur{% endblock %}
{% block content %}
<div class="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 p-4 rounded-2xl shadow">
{% extends "base.html" %}{% block title %}Erreur{% endblock %}{% block content %}
<div class="bg-red-50 dark:bg-red-950 border text-red-800 dark:text-red-200 p-4 rounded-2xl shadow">
<h1 class="text-xl font-semibold mb-2">Erreur</h1>
<pre class="whitespace-pre-wrap text-sm">{{ error }}</pre>
<p class="mt-4 text-sm text-gray-600 dark:text-gray-300">Vérifie le chemin de la base (<code>KODI_DB_PATH</code>) et tes permissions.</p>
<p class="mt-4 text-sm">Vérifie le chemin de la base (<code>KODI_DB_PATH</code>) et tes permissions.</p>
</div>
{% endblock %}

9
templates/error.html.v1 Normal file
View File

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Erreur{% endblock %}
{% block content %}
<div class="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 p-4 rounded-2xl shadow">
<h1 class="text-xl font-semibold mb-2">Erreur</h1>
<pre class="whitespace-pre-wrap text-sm">{{ error }}</pre>
<p class="mt-4 text-sm text-gray-600 dark:text-gray-300">Vérifie le chemin de la base (<code>KODI_DB_PATH</code>) et tes permissions.</p>
</div>
{% endblock %}

View File

@ -1,41 +1,15 @@
{% extends "base.html" %}
{% block title %}{{ m.nom }} - Détail{% endblock %}
{% block content %}
{% extends "base.html" %}{% block title %}Détail Film{% endblock %}{% block content %}
<a href="javascript:history.back()" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">← Retour</a>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow p-6 mt-3 border border-gray-200 dark:border-gray-700">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow p-6 mt-3 border">
<div class="flex gap-6">
<div>
{% if m.poster %}
<img src="{{ m.poster }}" alt="poster" class="w-40 object-cover rounded-xl shadow">
{% else %}
<div class="w-40 h-60 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
{% endif %}
</div>
<div>{% if m.poster %}<img src="{{ m.poster }}" alt="poster" class="w-40 object-cover rounded-xl shadow">{% else %}<div class="w-40 h-60 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>{% endif %}</div>
<div class="flex-1">
<h1 class="text-2xl font-bold mb-2">{{ m.nom }}</h1>
<div class="text-gray-600 dark:text-gray-300 mb-4">
{% if m.year %}<span class="mr-2">{{ m.year }}</span>{% endif %}
{% if m.pays %}<span class="mr-2">• {{ m.pays }}</span>{% endif %}
{% if m.date %}<span class="mr-2">• {{ m.date }}</span>{% endif %}
</div>
{% if m.titre %}
<div class="mb-2"><span class="font-medium">Titre original :</span> {{ m.titre }}</div>
{% endif %}
{% if m.acteurs %}
<div class="mb-2"><span class="font-medium">Acteurs :</span> {{ m.acteurs }}</div>
{% endif %}
{% if m.desc %}
<div class="mt-4">
<h2 class="font-semibold mb-1">Résumé</h2>
<p class="whitespace-pre-wrap">{{ m.desc }}</p>
</div>
{% endif %}
{% if m.path %}
<div class="mt-4">
<h2 class="font-semibold mb-1">Chemin</h2>
<code class="text-sm">{{ m.path }}</code>
</div>
{% endif %}
<div class="text-gray-600 dark:text-gray-300 mb-4">{% if m.year %}<span class="mr-2">{{ m.year }}</span>{% endif %}{% if m.pays %}<span class="mr-2">• {{ m.pays }}</span>{% endif %}{% if m.date %}<span class="mr-2">• {{ m.date }}</span>{% endif %}</div>
{% if m.titre %}<div class="mb-2"><span class="font-medium">Titre original :</span> {{ m.titre }}</div>{% endif %}
{% if m.acteurs %}<div class="mb-2"><span class="font-medium">Acteurs :</span> {{ m.acteurs }}</div>{% endif %}
{% if m.desc %}<div class="mt-4"><h2 class="font-semibold mb-1">Résumé</h2><p class="whitespace-pre-wrap">{{ m.desc }}</p></div>{% endif %}
{% if m.path %}<div class="mt-4"><h2 class="font-semibold mb-1">Chemin</h2><code class="text-sm">{{ m.path }}</code></div>{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}{{ m.nom }} - Détail{% endblock %}
{% block content %}
<a href="javascript:history.back()" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">← Retour</a>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow p-6 mt-3 border border-gray-200 dark:border-gray-700">
<div class="flex gap-6">
<div>
{% if m.poster %}
<img src="{{ m.poster }}" alt="poster" class="w-40 object-cover rounded-xl shadow">
{% else %}
<div class="w-40 h-60 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
{% endif %}
</div>
<div class="flex-1">
<h1 class="text-2xl font-bold mb-2">{{ m.nom }}</h1>
<div class="text-gray-600 dark:text-gray-300 mb-4">
{% if m.year %}<span class="mr-2">{{ m.year }}</span>{% endif %}
{% if m.pays %}<span class="mr-2">• {{ m.pays }}</span>{% endif %}
{% if m.date %}<span class="mr-2">• {{ m.date }}</span>{% endif %}
</div>
{% if m.titre %}
<div class="mb-2"><span class="font-medium">Titre original :</span> {{ m.titre }}</div>
{% endif %}
{% if m.acteurs %}
<div class="mb-2"><span class="font-medium">Acteurs :</span> {{ m.acteurs }}</div>
{% endif %}
{% if m.desc %}
<div class="mt-4">
<h2 class="font-semibold mb-1">Résumé</h2>
<p class="whitespace-pre-wrap">{{ m.desc }}</p>
</div>
{% endif %}
{% if m.path %}
<div class="mt-4">
<h2 class="font-semibold mb-1">Chemin</h2>
<code class="text-sm">{{ m.path }}</code>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -1,160 +1,76 @@
{% extends "base.html" %}
{% block title %}Kodi - Films{% endblock %}
{% block title %}Films - Kodi{% endblock %}
{% block content %}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow p-4 md:p-6 mb-6 border border-gray-200 dark:border-gray-700">
<form method="get" class="grid grid-cols-1 md:grid-cols-6 gap-3 items-end">
<!-- Hidden to preserve state -->
<form method="get" action="/movies" class="grid grid-cols-1 md:grid-cols-6 gap-3 items-end">
<input type="hidden" name="view" value="{{ view }}">
<input type="hidden" name="dir" value="{{ direction }}">
<input type="hidden" name="page" value="{{ page }}">
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Recherche</label>
<input type="text" name="q" value="{{ q }}" placeholder="Titre / synopsis"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div><label class="block text-sm font-medium mb-1">Recherche</label>
<input type="text" name="q" value="{{ q }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Acteur</label>
<input type="text" name="actor" value="{{ actor }}" placeholder="ex: Tom Hanks"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div><label class="block text-sm font-medium mb-1">Acteur</label>
<input type="text" name="actor" value="{{ actor }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Pays</label>
<input type="text" name="country" value="{{ country }}" placeholder="ex: France"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div><label class="block text-sm font-medium mb-1">Pays</label>
<input type="text" name="country" value="{{ country }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Année min</label>
<input type="number" name="year_from" value="{{ year_from }}" min="1900" max="2100"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div><label class="block text-sm font-medium mb-1">Année min</label>
<input type="number" name="year_from" value="{{ year_from }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Année max</label>
<input type="number" name="year_to" value="{{ year_to }}" min="1900" max="2100"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div><label class="block text-sm font-medium mb-1">Année max</label>
<input type="number" name="year_to" value="{{ year_to }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
</div>
<div class="flex gap-2">
<button type="submit"
class="rounded-xl px-4 py-2 bg-indigo-600 text-white font-medium hover:bg-indigo-700 shadow">
Filtrer
</button>
<a href="/?view={{ view }}"
class="rounded-xl px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 font-medium hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600">
Réinitialiser
</a>
<a href="/export.csv?{{ build_querystring() }}"
class="rounded-xl px-4 py-2 bg-emerald-600 text-white font-medium hover:bg-emerald-700 shadow">
Export CSV
</a>
<button class="rounded-xl px-4 py-2 bg-indigo-600 text-white">Filtrer</button>
<a href="/movies?view={{ view }}" class="rounded-xl px-4 py-2 border dark:border-gray-600">Réinitialiser</a>
<a href="/export_movies.csv?{{ build_querystring() }}" class="rounded-xl px-4 py-2 bg-emerald-600 text-white">Export CSV</a>
</div>
<div class="md:col-span-6 flex items-center gap-2 mt-1">
<span class="text-sm text-gray-600 dark:text-gray-300">Vue :</span>
<a href="/?{{ build_querystring(view='gallery', page=1, per_page=24) }}"
class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='gallery' else 'bg-white dark:bg-gray-900 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600' }}">
Galerie
</a>
<a href="/?{{ build_querystring(view='list', page=1, per_page=25) }}"
class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='list' else 'bg-white dark:bg-gray-900 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600' }}">
Liste
</a>
<span class="text-sm">Vue :</span>
<a href="/movies?{{ build_querystring(view='gallery', page=1, per_page=24) }}" class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='gallery' else '' }}">Galerie</a>
<a href="/movies?{{ build_querystring(view='list', page=1, per_page=25) }}" class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='list' else '' }}">Liste</a>
<div class="ml-auto flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-300">Trier par&nbsp;:</label>
<select name="sort"
onchange="this.form.page.value=1; this.form.submit()"
class="rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1">
<label class="text-sm">Trier par :</label>
<select name="sort" onchange="this.form.page.value=1; this.form.submit()" class="rounded-xl border px-2 py-1 dark:bg-gray-900 dark:border-gray-700">
{% for key,label in {'name':'Nom','original':'Titre','actors':'Acteurs','country':'Pays','year':'Année','date':'Date','path':'Chemin'}.items() %}
<option value="{{key}}" {% if sort==key %}selected{% endif %}>{{label}}</option>
{% endfor %}
</select>
{% set dir_toggle = ('desc' if direction=='asc' else 'asc') %}
<a href="/?{{ build_querystring(dir=dir_toggle, page=1) }}"
class="text-sm px-3 py-1 rounded-lg border bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
{{ '▲ Asc' if direction=='asc' else '▼ Desc' }}
</a>
<label class="text-sm text-gray-600 dark:text-gray-300 ml-3">Par page&nbsp;:</label>
<select name="per_page"
onchange="this.form.page.value=1; this.form.submit()"
class="rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1">
{% if view=='gallery' %}
{% for n in (12,24,48,96) %}
<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>
{% endfor %}
{% else %}
{% for n in (10,25,50,100) %}
<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>
{% endfor %}
{% endif %}
<a href="/movies?{{ build_querystring(dir=dir_toggle, page=1) }}" class="text-sm px-3 py-1 rounded-lg border">{{ '▲ Asc' if direction=='asc' else '▼ Desc' }}</a>
<label class="text-sm ml-3">Par page :</label>
<select name="per_page" onchange="this.form.page.value=1; this.form.submit()" class="rounded-xl border px-2 py-1 dark:bg-gray-900 dark:border-gray-700">
{% if view=='gallery' %}{% for n in (12,24,48,96) %}<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>{% endfor %}
{% else %}{% for n in (10,25,50,100) %}<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>{% endfor %}{% endif %}
</select>
</div>
</div>
</form>
</div>
<div class="mb-3 text-sm text-gray-600 dark:text-gray-300">
Résultats: <span class="font-semibold">{{ total }}</span>
</div>
<div class="mb-3 text-sm">Résultats: <span class="font-semibold">{{ total }}</span></div>
{% if view == 'gallery' %}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{% for m in movies %}
<a href="{% if m.id %}/movie/{{ m.id }}{% elif m.path %}/movie/by-path?path={{ m.path | urlencode }}{% else %}#{% endif %}"
class="group block bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl overflow-hidden shadow hover:shadow-md transition">
{% if m.poster %}
<img src="{{ m.poster }}" alt="poster" class="w-full aspect-[2/3] object-cover">
{% else %}
<div class="w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700"></div>
{% endif %}
<a href="/movie/{{ m.id }}" class="group block border rounded-2xl overflow-hidden shadow dark:border-gray-700">
{% if m.poster %}<img src="{{ m.poster }}" class="w-full aspect-[2/3] object-cover">{% else %}<div class="w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700"></div>{% endif %}
<div class="p-2">
<div class="text-sm font-medium line-clamp-2 group-hover:underline">{{ m.nom|default('—') }}</div>
{% if m.year %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ m.year }}</div>
{% endif %}
{% if m.year %}<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ m.year }}</div>{% endif %}
</div>
</a>
{% endfor %}
{% if movies|length == 0 %}
<div class="col-span-full text-center text-gray-500 dark:text-gray-400 py-8">Aucun résultat.</div>
{% endif %}
{% if movies|length == 0 %}<div class="col-span-full text-center py-8">Aucun résultat.</div>{% endif %}
</div>
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow overflow-x-auto border border-gray-200 dark:border-gray-700">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow overflow-x-auto border">
<table class="min-w-full text-sm">
<thead class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
<tr>
<th class="text-left px-4 py-2">Poster</th>
<th class="text-left px-4 py-2">Nom</th>
<th class="text-left px-4 py-2">Titre originel</th>
<th class="text-left px-4 py-2">Acteurs</th>
<th class="text-left px-4 py-2">Pays</th>
<th class="text-left px-4 py-2">Année</th>
<th class="text-left px-4 py-2">Date</th>
<th class="text-left px-4 py-2">Chemin</th>
</tr>
</thead>
<thead><tr><th class="text-left px-4 py-2">Poster</th><th class="text-left px-4 py-2">Nom</th><th class="text-left px-4 py-2">Titre originel</th><th class="text-left px-4 py-2">Acteurs</th><th class="text-left px-4 py-2">Pays</th><th class="text-left px-4 py-2">Année</th><th class="text-left px-4 py-2">Date</th><th class="text-left px-4 py-2">Chemin</th></tr></thead>
<tbody>
{% for m in movies %}
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<td class="px-4 py-2">
{% if m.poster %}
<img src="{{ m.poster }}" alt="poster" class="w-10 h-16 object-cover rounded-lg shadow">
{% else %}
<div class="w-10 h-16 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
{% endif %}
</td>
<td class="px-4 py-2">
{% if m.id %}
<a href="/movie/{{ m.id }}" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ m.nom|default('—') }}</a>
{% elif m.path %}
<a href="/movie/by-path?path={{ m.path | urlencode }}" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ m.nom|default('—') }}</a>
{% else %}
<span>{{ m.nom|default('—') }}</span>
{% endif %}
</td>
<tr class="border-t dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<td class="px-4 py-2">{% if m.poster %}<img src="{{ m.poster }}" class="w-10 h-16 object-cover rounded-lg shadow">{% else %}<div class="w-10 h-16 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>{% endif %}</td>
<td class="px-4 py-2"><a href="/movie/{{ m.id }}" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ m.nom|default('—') }}</a></td>
<td class="px-4 py-2">{{ m.titre|none_to_dash }}</td>
<td class="px-4 py-2"><div class="line-clamp-2">{{ m.acteurs|none_to_dash }}</div></td>
<td class="px-4 py-2">{{ m.pays|none_to_dash }}</td>
@ -163,39 +79,20 @@
<td class="px-4 py-2"><code class="text-xs">{{ m.path|none_to_dash }}</code></td>
</tr>
{% endfor %}
{% if movies|length == 0 %}
<tr><td colspan="8" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">Aucun résultat.</td></tr>
{% endif %}
{% if movies|length == 0 %}<tr><td colspan="8" class="px-4 py-6 text-center">Aucun résultat.</td></tr>{% endif %}
</tbody>
</table>
</div>
{% endif %}
{% if last_page > 1 %}
<nav class="mt-6 flex items-center justify-center gap-2 flex-wrap">
<a href="/?{{ build_querystring(page=1) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 {{ 'opacity-60 pointer-events-none' if page==1 else 'hover:bg-gray-100 dark:hover:bg-gray-800' }}">«</a>
<a href="/?{{ build_querystring(page=page-1) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800"></a>
{% if start_page > 1 %}
<span class="px-2"></span>
{% endif %}
<a href="/movies?{{ build_querystring(page=1) }}" class="px-3 py-1 rounded-lg border {{ 'opacity-60 pointer-events-none' if page==1 else '' }}">«</a>
<a href="/movies?{{ build_querystring(page=page-1) }}" class="px-3 py-1 rounded-lg border"></a>
{% for p in range(start_page, end_page + 1) %}
<a href="/?{{ build_querystring(page=p) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 {{ 'bg-indigo-600 text-white' if p==page else 'bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800' }}">{{ p }}</a>
<a href="/movies?{{ build_querystring(page=p) }}" class="px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if p==page else '' }}">{{ p }}</a>
{% endfor %}
{% if end_page < last_page %}
<span class="px-2"></span>
{% endif %}
<a href="/?{{ build_querystring(page=page+1) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800"></a>
<a href="/?{{ build_querystring(page=last_page) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 {{ 'opacity-60 pointer-events-none' if page==last_page else 'hover:bg-gray-100 dark:hover:bg-gray-800' }}">»</a>
<a href="/movies?{{ build_querystring(page=page+1) }}" class="px-3 py-1 rounded-lg border"></a>
<a href="/movies?{{ build_querystring(page=last_page) }}" class="px-3 py-1 rounded-lg border {{ 'opacity-60 pointer-events-none' if page==last_page else '' }}">»</a>
</nav>
{% endif %}
{% endblock %}

201
templates/movies.html.v1 Normal file
View File

@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}Kodi - Films{% endblock %}
{% block content %}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow p-4 md:p-6 mb-6 border border-gray-200 dark:border-gray-700">
<form method="get" class="grid grid-cols-1 md:grid-cols-6 gap-3 items-end">
<!-- Hidden to preserve state -->
<input type="hidden" name="view" value="{{ view }}">
<input type="hidden" name="dir" value="{{ direction }}">
<input type="hidden" name="page" value="{{ page }}">
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Recherche</label>
<input type="text" name="q" value="{{ q }}" placeholder="Titre / synopsis"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Acteur</label>
<input type="text" name="actor" value="{{ actor }}" placeholder="ex: Tom Hanks"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Pays</label>
<input type="text" name="country" value="{{ country }}" placeholder="ex: France"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Année min</label>
<input type="number" name="year_from" value="{{ year_from }}" min="1900" max="2100"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Année max</label>
<input type="number" name="year_to" value="{{ year_to }}" min="1900" max="2100"
class="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div class="flex gap-2">
<button type="submit"
class="rounded-xl px-4 py-2 bg-indigo-600 text-white font-medium hover:bg-indigo-700 shadow">
Filtrer
</button>
<a href="/?view={{ view }}"
class="rounded-xl px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 font-medium hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600">
Réinitialiser
</a>
<a href="/export.csv?{{ build_querystring() }}"
class="rounded-xl px-4 py-2 bg-emerald-600 text-white font-medium hover:bg-emerald-700 shadow">
Export CSV
</a>
</div>
<div class="md:col-span-6 flex items-center gap-2 mt-1">
<span class="text-sm text-gray-600 dark:text-gray-300">Vue :</span>
<a href="/?{{ build_querystring(view='gallery', page=1, per_page=24) }}"
class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='gallery' else 'bg-white dark:bg-gray-900 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600' }}">
Galerie
</a>
<a href="/?{{ build_querystring(view='list', page=1, per_page=25) }}"
class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='list' else 'bg-white dark:bg-gray-900 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600' }}">
Liste
</a>
<div class="ml-auto flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-300">Trier par&nbsp;:</label>
<select name="sort"
onchange="this.form.page.value=1; this.form.submit()"
class="rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1">
{% for key,label in {'name':'Nom','original':'Titre','actors':'Acteurs','country':'Pays','year':'Année','date':'Date','path':'Chemin'}.items() %}
<option value="{{key}}" {% if sort==key %}selected{% endif %}>{{label}}</option>
{% endfor %}
</select>
{% set dir_toggle = ('desc' if direction=='asc' else 'asc') %}
<a href="/?{{ build_querystring(dir=dir_toggle, page=1) }}"
class="text-sm px-3 py-1 rounded-lg border bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
{{ '▲ Asc' if direction=='asc' else '▼ Desc' }}
</a>
<label class="text-sm text-gray-600 dark:text-gray-300 ml-3">Par page&nbsp;:</label>
<select name="per_page"
onchange="this.form.page.value=1; this.form.submit()"
class="rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1">
{% if view=='gallery' %}
{% for n in (12,24,48,96) %}
<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>
{% endfor %}
{% else %}
{% for n in (10,25,50,100) %}
<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>
{% endfor %}
{% endif %}
</select>
</div>
</div>
</form>
</div>
<div class="mb-3 text-sm text-gray-600 dark:text-gray-300">
Résultats: <span class="font-semibold">{{ total }}</span>
</div>
{% if view == 'gallery' %}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{% for m in movies %}
<a href="{% if m.id %}/movie/{{ m.id }}{% elif m.path %}/movie/by-path?path={{ m.path | urlencode }}{% else %}#{% endif %}"
class="group block bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl overflow-hidden shadow hover:shadow-md transition">
{% if m.poster %}
<img src="{{ m.poster }}" alt="poster" class="w-full aspect-[2/3] object-cover">
{% else %}
<div class="w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700"></div>
{% endif %}
<div class="p-2">
<div class="text-sm font-medium line-clamp-2 group-hover:underline">{{ m.nom|default('—') }}</div>
{% if m.year %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ m.year }}</div>
{% endif %}
</div>
</a>
{% endfor %}
{% if movies|length == 0 %}
<div class="col-span-full text-center text-gray-500 dark:text-gray-400 py-8">Aucun résultat.</div>
{% endif %}
</div>
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow overflow-x-auto border border-gray-200 dark:border-gray-700">
<table class="min-w-full text-sm">
<thead class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
<tr>
<th class="text-left px-4 py-2">Poster</th>
<th class="text-left px-4 py-2">Nom</th>
<th class="text-left px-4 py-2">Titre originel</th>
<th class="text-left px-4 py-2">Acteurs</th>
<th class="text-left px-4 py-2">Pays</th>
<th class="text-left px-4 py-2">Année</th>
<th class="text-left px-4 py-2">Date</th>
<th class="text-left px-4 py-2">Chemin</th>
</tr>
</thead>
<tbody>
{% for m in movies %}
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<td class="px-4 py-2">
{% if m.poster %}
<img src="{{ m.poster }}" alt="poster" class="w-10 h-16 object-cover rounded-lg shadow">
{% else %}
<div class="w-10 h-16 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
{% endif %}
</td>
<td class="px-4 py-2">
{% if m.id %}
<a href="/movie/{{ m.id }}" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ m.nom|default('—') }}</a>
{% elif m.path %}
<a href="/movie/by-path?path={{ m.path | urlencode }}" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ m.nom|default('—') }}</a>
{% else %}
<span>{{ m.nom|default('—') }}</span>
{% endif %}
</td>
<td class="px-4 py-2">{{ m.titre|none_to_dash }}</td>
<td class="px-4 py-2"><div class="line-clamp-2">{{ m.acteurs|none_to_dash }}</div></td>
<td class="px-4 py-2">{{ m.pays|none_to_dash }}</td>
<td class="px-4 py-2">{{ m.year if m.year else '—' }}</td>
<td class="px-4 py-2">{{ m.date if m.date else '—' }}</td>
<td class="px-4 py-2"><code class="text-xs">{{ m.path|none_to_dash }}</code></td>
</tr>
{% endfor %}
{% if movies|length == 0 %}
<tr><td colspan="8" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">Aucun résultat.</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% endif %}
{% if last_page > 1 %}
<nav class="mt-6 flex items-center justify-center gap-2 flex-wrap">
<a href="/?{{ build_querystring(page=1) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 {{ 'opacity-60 pointer-events-none' if page==1 else 'hover:bg-gray-100 dark:hover:bg-gray-800' }}">«</a>
<a href="/?{{ build_querystring(page=page-1) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800"></a>
{% if start_page > 1 %}
<span class="px-2">…</span>
{% endif %}
{% for p in range(start_page, end_page + 1) %}
<a href="/?{{ build_querystring(page=p) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 {{ 'bg-indigo-600 text-white' if p==page else 'bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800' }}">{{ p }}</a>
{% endfor %}
{% if end_page < last_page %}
<span class="px-2">…</span>
{% endif %}
<a href="/?{{ build_querystring(page=page+1) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800"></a>
<a href="/?{{ build_querystring(page=last_page) }}"
class="px-3 py-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 {{ 'opacity-60 pointer-events-none' if page==last_page else 'hover:bg-gray-100 dark:hover:bg-gray-800' }}">»</a>
</nav>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}{% block title %}Détail Série{% endblock %}{% block content %}
<a href="javascript:history.back()" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">← Retour</a>
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow p-6 mt-3 border">
<div class="flex gap-6">
<div>{% if s.poster %}<img src="{{ s.poster }}" alt="poster" class="w-40 object-cover rounded-xl shadow">{% else %}<div class="w-40 h-60 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>{% endif %}</div>
<div class="flex-1">
<h1 class="text-2xl font-bold mb-2">{{ s.titre }}</h1>
<div class="text-gray-600 dark:text-gray-300 mb-4">{% if s.year %}<span class="mr-2">{{ s.year }}</span>{% endif %}{% if s.diffuseur %}<span class="mr-2">• {{ s.diffuseur }}</span>{% endif %}{% if s.date %}<span class="mr-2">• {{ s.date }}</span>{% endif %}</div>
{% if s.original %}<div class="mb-2"><span class="font-medium">Titre original :</span> {{ s.original }}</div>{% endif %}
{% if s.genre %}<div class="mb-2"><span class="font-medium">Genre :</span> {{ s.genre }}</div>{% endif %}
{% if s.desc %}<div class="mt-4"><h2 class="font-semibold mb-1">Résumé</h2><p class="whitespace-pre-wrap">{{ s.desc }}</p></div>{% endif %}
</div>
</div>
</div>
{% endblock %}

85
templates/shows.html Normal file
View File

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Séries - Kodi{% endblock %}
{% block content %}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow p-4 md:p-6 mb-6 border">
<form method="get" action="/shows" class="grid grid-cols-1 md:grid-cols-6 gap-3 items-end">
<input type="hidden" name="view" value="{{ view }}"><input type="hidden" name="dir" value="{{ direction }}"><input type="hidden" name="page" value="{{ page }}">
<div><label class="block text-sm font-medium mb-1">Recherche</label><input type="text" name="q" value="{{ q }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700"></div>
<div><label class="block text-sm font-medium mb-1">Diffuseur</label><input type="text" name="network" value="{{ network }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700"></div>
<div><label class="block text-sm font-medium mb-1">Genre</label><input type="text" name="genre" value="{{ genre }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700"></div>
<div><label class="block text-sm font-medium mb-1">Année min</label><input type="number" name="year_from" value="{{ year_from }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700"></div>
<div><label class="block text-sm font-medium mb-1">Année max</label><input type="number" name="year_to" value="{{ year_to }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700"></div>
<div class="flex gap-2">
<button class="rounded-xl px-4 py-2 bg-indigo-600 text-white">Filtrer</button>
<a href="/shows?view={{ view }}" class="rounded-xl px-4 py-2 border dark:border-gray-600">Réinitialiser</a>
<a href="/export_shows.csv?{{ build_querystring() }}" class="rounded-xl px-4 py-2 bg-emerald-600 text-white">Export CSV</a>
</div>
<div class="md:col-span-6 flex items-center gap-2 mt-1">
<span class="text-sm">Vue :</span>
<a href="/shows?{{ build_querystring(view='gallery', page=1, per_page=24) }}" class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='gallery' else '' }}">Galerie</a>
<a href="/shows?{{ build_querystring(view='list', page=1, per_page=25) }}" class="text-sm px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if view=='list' else '' }}">Liste</a>
<div class="ml-auto flex items-center gap-2">
<label class="text-sm">Trier par :</label>
<select name="sort" onchange="this.form.page.value=1; this.form.submit()" class="rounded-xl border px-2 py-1 dark:bg-gray-900 dark:border-gray-700">
{% for key,label in {'title':'Titre','original':'Original','network':'Diffuseur','genre':'Genre','year':'Année','date':'Date'}.items() %}
<option value="{{key}}" {% if sort==key %}selected{% endif %}>{{label}}</option>
{% endfor %}
</select>
{% set dir_toggle = ('desc' if direction=='asc' else 'asc') %}
<a href="/shows?{{ build_querystring(dir=dir_toggle, page=1) }}" class="text-sm px-3 py-1 rounded-lg border">{{ '▲ Asc' if direction=='asc' else '▼ Desc' }}</a>
<label class="text-sm ml-3">Par page :</label>
<select name="per_page" onchange="this.form.page.value=1; this.form.submit()" class="rounded-xl border px-2 py-1 dark:bg-gray-900 dark:border-gray-700">
{% if view=='gallery' %}{% for n in (12,24,48,96) %}<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>{% endfor %}
{% else %}{% for n in (10,25,50,100) %}<option value="{{n}}" {% if per_page==n %}selected{% endif %}>{{n}}</option>{% endfor %}{% endif %}
</select>
</div>
</div>
</form>
</div>
<div class="mb-3 text-sm">Résultats: <span class="font-semibold">{{ total }}</span></div>
{% if view == 'gallery' %}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{% for s in shows %}
<a href="/show/{{ s.id }}" class="group block border rounded-2xl overflow-hidden shadow dark:border-gray-700">
{% if s.poster %}<img src="{{ s.poster }}" class="w-full aspect-[2/3] object-cover">{% else %}<div class="w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700"></div>{% endif %}
<div class="p-2">
<div class="text-sm font-medium line-clamp-2 group-hover:underline">{{ s.titre|default('—') }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{% if s.year %}{{ s.year }}{% endif %}{% if s.genre %} • {{ s.genre }}{% endif %}</div>
</div>
</a>
{% endfor %}
{% if shows|length == 0 %}<div class="col-span-full text-center py-8">Aucun résultat.</div>{% endif %}
</div>
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow overflow-x-auto border">
<table class="min-w-full text-sm">
<thead><tr><th class="text-left px-4 py-2">Poster</th><th class="text-left px-4 py-2">Titre</th><th class="text-left px-4 py-2">Original</th><th class="text-left px-4 py-2">Genre</th><th class="text-left px-4 py-2">Diffuseur</th><th class="text-left px-4 py-2">Année</th><th class="text-left px-4 py-2">Date</th></tr></thead>
<tbody>
{% for s in shows %}
<tr class="border-t dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<td class="px-4 py-2">{% if s.poster %}<img src="{{ s.poster }}" class="w-10 h-16 object-cover rounded-lg shadow">{% else %}<div class="w-10 h-16 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>{% endif %}</td>
<td class="px-4 py-2"><a href="/show/{{ s.id }}" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ s.titre|default('—') }}</a></td>
<td class="px-4 py-2">{{ s.original|none_to_dash }}</td>
<td class="px-4 py-2">{{ s.genre|none_to_dash }}</td>
<td class="px-4 py-2">{{ s.diffuseur|none_to_dash }}</td>
<td class="px-4 py-2">{{ s.year if s.year else '—' }}</td>
<td class="px-4 py-2">{{ s.date if s.date else '—' }}</td>
</tr>
{% endfor %}
{% if shows|length == 0 %}<tr><td colspan="7" class="px-4 py-6 text-center">Aucun résultat.</td></tr>{% endif %}
</tbody>
</table>
</div>
{% endif %}
{% if last_page > 1 %}
<nav class="mt-6 flex items-center justify-center gap-2 flex-wrap">
<a href="/shows?{{ build_querystring(page=1) }}" class="px-3 py-1 rounded-lg border {{ 'opacity-60 pointer-events-none' if page==1 else '' }}">«</a>
<a href="/shows?{{ build_querystring(page=page-1) }}" class="px-3 py-1 rounded-lg border"></a>
{% for p in range(start_page, end_page + 1) %}
<a href="/shows?{{ build_querystring(page=p) }}" class="px-3 py-1 rounded-lg border {{ 'bg-indigo-600 text-white' if p==page else '' }}">{{ p }}</a>
{% endfor %}
<a href="/shows?{{ build_querystring(page=page+1) }}" class="px-3 py-1 rounded-lg border"></a>
<a href="/shows?{{ build_querystring(page=last_page) }}" class="px-3 py-1 rounded-lg border {{ 'opacity-60 pointer-events-none' if page==last_page else '' }}">»</a>
</nav>
{% endif %}
{% endblock %}