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.

422
app.py
View File

@ -6,15 +6,16 @@ from urllib.parse import urlencode
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from flask import Flask, request, render_template, Response, abort from flask import Flask, request, render_template, Response, abort
# ---------- Configuration ---------- #DB_PATH = os.environ.get("KODI_DB_PATH", r"/home/pi/.kodi/userdata/Database/MyVideos121.db")
DB_PATH = r"/root/.kodi/userdata/Database/MyVideos131.db" DB_PATH = r"/home/kodi/.kodi/userdata/Database/MyVideos131.db"
DEFAULT_PER_PAGE = 24 # gallery default DEFAULT_PER_PAGE_GALLERY = 24
DEFAULT_VIEW = "gallery" # gallery by default DEFAULT_PER_PAGE_LIST = 25
DEFAULT_VIEW = "gallery"
app = Flask(__name__) app = Flask(__name__)
app.jinja_env.globals.update(range=range) app.jinja_env.globals.update(range=range)
# ---------- DB Helpers ---------- # ---------- DB ----------
def get_db(): def get_db():
if not os.path.exists(DB_PATH): if not os.path.exists(DB_PATH):
raise FileNotFoundError(f"Base Kodi introuvable: {DB_PATH}") raise FileNotFoundError(f"Base Kodi introuvable: {DB_PATH}")
@ -22,58 +23,25 @@ def get_db():
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn 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): def resolve_movie_table(conn):
cur = conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name IN ('movie_view','movie')") for n in ("movie_view", "movie"):
names = [r["name"] for r in cur.fetchall()] if table_exists(conn, n):
if "movie_view" in names: return n
return "movie_view" raise RuntimeError("Ni 'movie_view' ni 'movie' n'existent dans cette base.")
elif "movie" in names:
return "movie"
else:
raise RuntimeError("Ni 'movie_view' ni 'movie' n'existent dans cette base.")
def base_where(filters): def resolve_tvshow_table(conn):
where = [] for n in ("tvshow_view", "tvshow"):
params = [] if table_exists(conn, n):
if filters.get("q"): return n
where.append("(LOWER(c00) LIKE ? OR LOWER(c16) LIKE ? OR LOWER(c01) LIKE ?)") raise RuntimeError("Ni 'tvshow_view' ni 'tvshow' n'existent dans cette base.")
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 = { # ---------- Parsers ----------
"name": "c00", def extract_movie_poster_from_c08(c08: str):
"original": "c16", """Movies: c08 holds <thumb ... aspect='poster'> entries. Prefer TMDB, else first poster."""
"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): if not c08 or not isinstance(c08, str):
return None return None
xml_text = f"<root>{c08}</root>" xml_text = f"<root>{c08}</root>"
@ -92,17 +60,79 @@ def extract_poster_from_c08(c08: str):
return u return u
return posters[0] if posters else None 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): def count_movies(conn, filters):
table = resolve_movie_table(conn) table = resolve_movie_table(conn)
where_sql, params = base_where(filters) where, params = base_where_movies(filters)
sql = f"SELECT COUNT(*) as n FROM {table} {where_sql}" sql = f"SELECT COUNT(*) as n FROM {table} {where}"
return conn.execute(sql, params).fetchone()["n"] return conn.execute(sql, params).fetchone()["n"]
def fetch_movies(conn, filters, page, per_page, sort, direction): def fetch_movies(conn, filters, page, per_page, sort, direction):
table = resolve_movie_table(conn) table = resolve_movie_table(conn)
where_sql, params = base_where(filters) where_sql, params = base_where_movies(filters)
sort_sql = SORT_COLUMNS.get(sort, SORT_COLUMNS[DEFAULT_SORT]) sort_sql = SORT_COLUMNS_MOVIES.get(sort, SORT_COLUMNS_MOVIES[DEFAULT_SORT_MOVIES])
order_clause = f"{sort_sql} {direction.upper()}, c00 ASC" order_clause = f"{sort_sql} {direction.upper()}, c00 ASC"
sql = f""" sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path, 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 = [] result = []
for r in rows: for r in rows:
d = dict(r) 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) result.append(d)
return result return result
def fetch_movie_by_id(conn, movie_id: int): # ---------- TV Shows ----------
table = resolve_movie_table(conn) 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""" sql = f"""
SELECT idMovie AS id, c00 AS nom, c01 AS desc, c06 AS acteurs, c16 AS titre, c21 AS pays, c22 AS path, SELECT idShow AS id, c00 AS titre, c01 AS desc, c05 AS date,
premiered AS date, c08 AS thumbs, c06 AS thumbs, c08 AS genre, c09 AS original, c14 AS diffuseur
CAST(substr(COALESCE(premiered, ''), 1, 4) AS INTEGER) AS year
FROM {table} 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 LIMIT 1
""" """
r = conn.execute(sql, (movie_id,)).fetchone() r = conn.execute(sql, (show_id,)).fetchone()
if not r: return None if not r:
return None
d = dict(r) 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
return d d["poster"] = extract_show_poster_from_c06(d.get("thumbs"))
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 return d
# ---------- Utils ----------
def build_querystring(extras=None, **kwargs): def build_querystring(extras=None, **kwargs):
args = dict(request.args) args = dict(request.args)
args.update(kwargs) args.update(kwargs)
@ -163,32 +239,28 @@ def build_querystring(extras=None, **kwargs):
# ---------- Routes ---------- # ---------- Routes ----------
@app.route("/") @app.route("/")
def index(): def root():
# Filters return movies_page()
@app.route("/movies")
def movies_page():
q = request.args.get("q", "").strip() q = request.args.get("q", "").strip()
actor = request.args.get("actor", "").strip() actor = request.args.get("actor", "").strip()
country = request.args.get("country", "").strip() country = request.args.get("country", "").strip()
year_from = request.args.get("year_from", "").strip() year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip() year_to = request.args.get("year_to", "").strip()
# Sorting sort = request.args.get("sort", DEFAULT_SORT_MOVIES)
sort = request.args.get("sort", "name") direction = request.args.get("dir", DEFAULT_DIR)
direction = request.args.get("dir", "asc") sort, direction = sanitize_sort(sort, SORT_COLUMNS_MOVIES, DEFAULT_SORT_MOVIES, direction)
sort, direction = sanitize_sort(sort, direction)
# View
view = request.args.get("view", DEFAULT_VIEW) view = request.args.get("view", DEFAULT_VIEW)
view = view if view in ("gallery","list") else DEFAULT_VIEW view = view if view in ("gallery","list") else DEFAULT_VIEW
# Paging
page = int(request.args.get("page", 1) or 1) 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)) per_page = max(6, min(per_page, 120))
filters = { filters = {"q": q or None, "actor": actor or None, "country": country or None,
"q": q or None, "year_from": year_from or None, "year_to": year_to or None}
"actor": actor or None,
"country": country or None,
"year_from": year_from or None,
"year_to": year_to or None,
}
try: try:
conn = get_db() conn = get_db()
@ -202,90 +274,142 @@ def index():
start_page = 1 if page <= 3 else page - 2 start_page = 1 if page <= 3 else page - 2
end_page = last_page if page + 2 >= last_page else page + 2 end_page = last_page if page + 2 >= last_page else page + 2
return render_template( return render_template("movies.html",
"movies.html", movies=movies, total=total, page=page, last_page=last_page, per_page=per_page,
movies=movies, sort=sort, direction=direction, view=view,
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, q=q, actor=actor, country=country, year_from=year_from, year_to=year_to,
build_querystring=build_querystring, build_querystring=build_querystring, start_page=start_page, end_page=end_page
start_page=start_page,
end_page=end_page
) )
@app.route("/movie/<int:movie_id>") @app.route("/movie/<int:movie_id>")
def movie_detail(movie_id): def movie_detail(movie_id):
try: try:
conn = get_db() conn = get_db()
movie = fetch_movie_by_id(conn, movie_id) table = resolve_movie_table(conn)
if not movie: sql = f"""
abort(404) 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: except Exception as e:
return render_template("error.html", error=str(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: try:
conn = get_db() conn = get_db()
movie = fetch_movie_by_path(conn, path) total = count_shows(conn, filters)
if not movie: last_page = max(1, (total + per_page - 1) // per_page)
abort(404) page = max(1, min(page, last_page))
shows = fetch_shows(conn, filters, page, per_page, sort, direction)
except Exception as e: except Exception as e:
return render_template("error.html", error=str(e)) return render_template("error.html", error=str(e))
return render_template("movie_detail.html", m=movie)
# CSV export (respects filters/sort/view irrelevant) start_page = 1 if page <= 3 else page - 2
@app.route("/export.csv") end_page = last_page if page + 2 >= last_page else page + 2
def export_csv():
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() q = request.args.get("q", "").strip()
actor = request.args.get("actor", "").strip() actor = request.args.get("actor", "").strip()
country = request.args.get("country", "").strip() country = request.args.get("country", "").strip()
year_from = request.args.get("year_from", "").strip() year_from = request.args.get("year_from", "").strip()
year_to = request.args.get("year_to", "").strip() year_to = request.args.get("year_to", "").strip()
sort = request.args.get("sort", "name") sort = request.args.get("sort", DEFAULT_SORT_MOVIES)
direction = request.args.get("dir", "asc") direction = request.args.get("dir", DEFAULT_DIR)
sort, direction = sanitize_sort(sort, direction) 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,
}
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: try:
conn = get_db() conn = get_db()
# reuse fetch_movies with large per_page to get all (or reimplement full fetch if needed)
total = count_movies(conn, filters) total = count_movies(conn, filters)
rows = fetch_movies(conn, filters, 1, max(total, 1), sort, direction) rows = fetch_movies(conn, filters, 1, max(total, 1), sort, direction)
except Exception as e: except Exception as e:
return render_template("error.html", error=str(e)) return render_template("error.html", error=str(e))
si = StringIO() si = StringIO()
import csv
writer = csv.writer(si) writer = csv.writer(si)
writer.writerow(["nom", "desc", "acteurs", "titre", "pays", "path", "date", "year", "poster"]) writer.writerow(["nom", "desc", "acteurs", "titre", "pays", "path", "date", "year", "poster"])
for r in rows: for r in rows:
writer.writerow([r.get("nom",""), r.get("desc",""), r.get("acteurs",""), writer.writerow([r.get("nom",""), r.get("desc",""), r.get("acteurs",""),
r.get("titre",""), r.get("pays",""), r.get("path",""), r.get("titre",""), r.get("pays",""), r.get("path",""),
r.get("date",""), r.get("year",""), r.get("poster","")]) r.get("date",""), r.get("year",""), r.get("poster","")])
output = si.getvalue() return Response(si.getvalue(), mimetype="text/csv; charset=utf-8",
return Response( headers={"Content-Disposition": "attachment; filename=movies_export.csv"})
output,
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") @app.template_filter("none_to_dash")
def none_to_dash(value): def none_to_dash(value):
return value if value not in (None, "", "None") else "" 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> <!doctype html>
<html lang="fr" class="h-full"> <html lang="fr" class="h-full">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1"> <title>{% block title %}Kodi - Médiathèque{% endblock %}</title>
<title>{% block title %}Kodi - Films{% 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> <script src="https://cdn.tailwindcss.com"></script><script>tailwind.config={darkMode:'class'}</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> </head>
<body class="h-full bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100"> <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"> <div class="max-w-7xl mx-auto p-4 md:p-6">
<header class="flex items-center gap-3 mb-4"> <header class="flex items-center gap-3 mb-4">
<h1 class="text-xl md:text-2xl font-semibold">Kodi - Films</h1> <nav class="flex gap-2">
<div class="ml-auto flex items-center 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>
<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"> <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>
<span class="inline dark:hidden">🌙 Mode sombre</span> </nav>
<span class="hidden dark:inline">☀️ Mode clair</span> <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>
</button>
</div>
</header> </header>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<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>
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> </body>
</html> </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" %} {% extends "base.html" %}{% block title %}Erreur{% endblock %}{% block content %}
{% block title %}Erreur{% endblock %} <div class="bg-red-50 dark:bg-red-950 border text-red-800 dark:text-red-200 p-4 rounded-2xl shadow">
{% 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> <h1 class="text-xl font-semibold mb-2">Erreur</h1>
<pre class="whitespace-pre-wrap text-sm">{{ error }}</pre> <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> </div>
{% endblock %} {% 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" %} {% extends "base.html" %}{% block title %}Détail Film{% endblock %}{% block content %}
{% 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> <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 class="flex gap-6">
<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>
{% 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"> <div class="flex-1">
<h1 class="text-2xl font-bold mb-2">{{ m.nom }}</h1> <h1 class="text-2xl font-bold mb-2">{{ m.nom }}</h1>
<div class="text-gray-600 dark:text-gray-300 mb-4"> <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.year %}<span class="mr-2">{{ m.year }}</span>{% endif %} {% if m.titre %}<div class="mb-2"><span class="font-medium">Titre original :</span> {{ m.titre }}</div>{% endif %}
{% if m.pays %}<span class="mr-2">• {{ m.pays }}</span>{% endif %} {% if m.acteurs %}<div class="mb-2"><span class="font-medium">Acteurs :</span> {{ m.acteurs }}</div>{% endif %}
{% if m.date %}<span class="mr-2">• {{ m.date }}</span>{% 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 %}
</div> {% if m.path %}<div class="mt-4"><h2 class="font-semibold mb-1">Chemin</h2><code class="text-sm">{{ m.path }}</code></div>{% endif %}
{% 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> </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" %} {% extends "base.html" %}
{% block title %}Kodi - Films{% endblock %} {% block title %}Films - Kodi{% endblock %}
{% block content %} {% 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"> <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"> <form method="get" action="/movies" 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="view" value="{{ view }}">
<input type="hidden" name="dir" value="{{ direction }}"> <input type="hidden" name="dir" value="{{ direction }}">
<input type="hidden" name="page" value="{{ page }}"> <input type="hidden" name="page" value="{{ page }}">
<div><label class="block text-sm font-medium mb-1">Recherche</label>
<div> <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">
<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>
<div> <div><label class="block text-sm font-medium mb-1">Acteur</label>
<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 }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
<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>
<div> <div><label class="block text-sm font-medium mb-1">Pays</label>
<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 }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
<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>
<div> <div><label class="block text-sm font-medium mb-1">Année min</label>
<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 }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
<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>
<div> <div><label class="block text-sm font-medium mb-1">Année max</label>
<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 }}" class="w-full rounded-xl border px-3 py-2 dark:bg-gray-900 dark:border-gray-700">
<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>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" <button class="rounded-xl px-4 py-2 bg-indigo-600 text-white">Filtrer</button>
class="rounded-xl px-4 py-2 bg-indigo-600 text-white font-medium hover:bg-indigo-700 shadow"> <a href="/movies?view={{ view }}" class="rounded-xl px-4 py-2 border dark:border-gray-600">Réinitialiser</a>
Filtrer <a href="/export_movies.csv?{{ build_querystring() }}" class="rounded-xl px-4 py-2 bg-emerald-600 text-white">Export CSV</a>
</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>
<div class="md:col-span-6 flex items-center gap-2 mt-1"> <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> <span class="text-sm">Vue :</span>
<a href="/?{{ build_querystring(view='gallery', page=1, per_page=24) }}" <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>
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' }}"> <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>
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"> <div class="ml-auto flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-300">Trier par&nbsp;:</label> <label class="text-sm">Trier par :</label>
<select name="sort" <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">
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() %} {% 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> <option value="{{key}}" {% if sort==key %}selected{% endif %}>{{label}}</option>
{% endfor %} {% endfor %}
</select> </select>
{% set dir_toggle = ('desc' if direction=='asc' else 'asc') %} {% set dir_toggle = ('desc' if direction=='asc' else 'asc') %}
<a href="/?{{ build_querystring(dir=dir_toggle, page=1) }}" <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>
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"> <label class="text-sm ml-3">Par page :</label>
{{ '▲ Asc' if direction=='asc' else '▼ Desc' }} <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">
</a> {% 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 %}
<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> </select>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div class="mb-3 text-sm">Résultats: <span class="font-semibold">{{ total }}</span></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' %} {% if view == 'gallery' %}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{% for m in movies %} {% for m in movies %}
<a href="{% if m.id %}/movie/{{ m.id }}{% elif m.path %}/movie/by-path?path={{ m.path | urlencode }}{% else %}#{% endif %}" <a href="/movie/{{ m.id }}" class="group block border rounded-2xl overflow-hidden shadow dark:border-gray-700">
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 }}" 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 %}
{% 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="p-2">
<div class="text-sm font-medium line-clamp-2 group-hover:underline">{{ m.nom|default('—') }}</div> <div class="text-sm font-medium line-clamp-2 group-hover:underline">{{ m.nom|default('—') }}</div>
{% if m.year %} {% if m.year %}<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ m.year }}</div>{% endif %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ m.year }}</div>
{% endif %}
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
{% if movies|length == 0 %} {% if movies|length == 0 %}<div class="col-span-full text-center py-8">Aucun résultat.</div>{% endif %}
<div class="col-span-full text-center text-gray-500 dark:text-gray-400 py-8">Aucun résultat.</div>
{% endif %}
</div> </div>
{% else %} {% 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"> <table class="min-w-full text-sm">
<thead class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200"> <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>
<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> <tbody>
{% for m in movies %} {% 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"> <tr class="border-t dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<td class="px-4 py-2"> <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>
{% if m.poster %} <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>
<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">{{ 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"><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.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> <td class="px-4 py-2"><code class="text-xs">{{ m.path|none_to_dash }}</code></td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if movies|length == 0 %} {% if movies|length == 0 %}<tr><td colspan="8" class="px-4 py-6 text-center">Aucun résultat.</td></tr>{% endif %}
<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> </tbody>
</table> </table>
</div> </div>
{% endif %} {% endif %}
{% if last_page > 1 %} {% if last_page > 1 %}
<nav class="mt-6 flex items-center justify-center gap-2 flex-wrap"> <nav class="mt-6 flex items-center justify-center gap-2 flex-wrap">
<a href="/?{{ build_querystring(page=1) }}" <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>
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="/movies?{{ build_querystring(page=page-1) }}" class="px-3 py-1 rounded-lg border"></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) %} {% for p in range(start_page, end_page + 1) %}
<a href="/?{{ build_querystring(page=p) }}" <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>
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 %} {% endfor %}
<a href="/movies?{{ build_querystring(page=page+1) }}" class="px-3 py-1 rounded-lg border"></a>
{% if end_page < last_page %} <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>
<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> </nav>
{% endif %} {% endif %}
{% endblock %} {% 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 %}