Cleanup: remove unused code, add CI/CD workflow
Some checks failed
Deploy Backend / deploy (push) Failing after 2m30s

- Remove flask-sse, Redis (unused)
- Remove ImageService, file_utils (unused)
- Remove /download-posters route (frontend handles download client-side)
- Remove LETTERBOXD_API_URL, secure_filename (dead imports)
- Fix duplicate TMDBService instantiation
- Simplify config.py (remove REDIS_URL)
- Fix gunicorn user home dir for control socket
- Add .gitignore for __pycache__
- Add .gitea/workflows/deploy.yml for tag-based CI/CD
This commit is contained in:
2026-04-26 17:25:21 +02:00
parent 8941057f0e
commit 4af947b440
12 changed files with 145 additions and 157 deletions

View File

@@ -0,0 +1,33 @@
name: Deploy Backend
on:
push:
tags:
- 'v*.*.*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version tag
run: echo "VERSION_TAG=${GITHUB_REF_NAME}" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build \
-t poster-picker-backend:latest \
-t poster-picker-backend:${{ env.VERSION_TAG }} .
- name: Stop old container
run: |
cd ~/dev-server/poster-picker
docker compose stop backend || true
- name: Start new container
run: |
cd ~/dev-server/poster-picker
docker compose up -d --no-build backend

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
.env .env
.venv .venv
__pycache__/
*.pyc
*.pyo

View File

@@ -1,7 +1,7 @@
# backend/Dockerfile # backend/Dockerfile
FROM python:3.11-slim FROM python:3.11-slim
RUN addgroup --system app && adduser --system --ingroup app app RUN addgroup --system app && adduser --system --ingroup app --home /home/app app
WORKDIR /srv/app WORKDIR /srv/app
@@ -27,4 +27,4 @@ ENV GUNICORN_TIMEOUT=300
# Commande de démarrage via gunicorn (attache l'app Flask : app:app) # Commande de démarrage via gunicorn (attache l'app Flask : app:app)
# Utilise timeout raisonnable, bind sur 0.0.0.0:5000 # Utilise timeout raisonnable, bind sur 0.0.0.0:5000
CMD ["sh","-c","gunicorn --workers ${GUNICORN_WORKERS} --threads ${GUNICORN_THREADS} --worker-class ${GUNICORN_WORKER_CLASS} --timeout ${GUNICORN_TIMEOUT} --bind 0.0.0.0:5000 --access-logfile - --error-logfile - 'main:create_app()'"] CMD ["sh","-c","gunicorn --workers ${GUNICORN_WORKERS} --threads ${GUNICORN_THREADS} --worker-class ${GUNICORN_WORKER_CLASS} --timeout ${GUNICORN_TIMEOUT} --worker-tmp-dir /dev/shm --bind 0.0.0.0:5000 --access-logfile - --error-logfile - 'main:create_app()'"]

Binary file not shown.

View File

@@ -1,21 +1,14 @@
import os import os
class Config: class Config:
# Configuration générale
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev_key') SECRET_KEY = os.environ.get('SECRET_KEY', 'dev_key')
DEBUG = False DEBUG = False
TESTING = False TESTING = False
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
# Configuration pour le développement local
REDIS_URL = "redis://localhost:6379"
class ProductionConfig(Config): class ProductionConfig(Config):
# Configuration pour la production (Render, etc.)
REDIS_URL = os.environ.get("REDIS_URL")
# Peut désactiver le debug mode pour la prod
DEBUG = False DEBUG = False
config = { config = {

View File

@@ -2,7 +2,6 @@ import os
from flask import Flask from flask import Flask
from flask_cors import CORS from flask_cors import CORS
from src.api.routes import api_bp from src.api.routes import api_bp
from flask_sse import sse
from config import config from config import config
@@ -13,18 +12,18 @@ def create_app():
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000" "http://127.0.0.1:3000"
] ]
frontend_url = os.environ.get("FRONTEND_URL")
if frontend_url:
origins.append(frontend_url)
CORS(app, supports_credentials=True, origins=origins) CORS(app, supports_credentials=True, origins=origins)
env = os.environ.get('FLASK_ENV', 'default') env = os.environ.get('FLASK_ENV', 'default')
if env == "production": if env == "production":
app.config.update(SESSION_COOKIE_SAMESITE="None", SESSION_COOKIE_SECURE=True) app.config.update(SESSION_COOKIE_SAMESITE="None", SESSION_COOKIE_SECURE=True)
else:
app.config.update(SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=False)
app.config.from_object(config[env]) app.config.from_object(config[env])
app.register_blueprint(sse, url_prefix='/stream')
app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(api_bp, url_prefix='/api')
return app return app

View File

@@ -1,28 +1,30 @@
import io import io
import math import math
import os import os
import zipfile import uuid
import requests import requests
import uuid from flask import Blueprint, jsonify, request, session
from flask import Blueprint, jsonify, request, session, send_file
from werkzeug.utils import secure_filename
from src.services.tmdb_service import TMDBService from src.services.tmdb_service import TMDBService
from src.services.image_service import ImageService
import pandas as pd import pandas as pd
from functools import lru_cache from functools import lru_cache
api_bp = Blueprint('api', __name__) api_bp = Blueprint('api', __name__)
tmdb_service = TMDBService() tmdb_service = TMDBService()
image_service = ImageService()
def get_user_id():
user_id = request.headers.get('X-User-ID') or session.get('user_id')
if not user_id:
user_id = str(uuid.uuid4())
session['user_id'] = user_id
return user_id
ALLOWED_EXTENSIONS = {'csv'} ALLOWED_EXTENSIONS = {'csv'}
user_csvs = {} user_csvs = {}
LETTERBOXD_API_URL = os.getenv('LETTERBOXD_API_URL')
@lru_cache(maxsize=1000) @lru_cache(maxsize=1000)
def get_poster_url(movie_name, movie_year): def get_poster_url(movie_name, movie_year):
@@ -34,27 +36,45 @@ def get_poster_url(movie_name, movie_year):
@api_bp.route('/movies', methods=['GET']) @api_bp.route('/movies', methods=['GET'])
def get_movies(): def get_movies():
user_id = session.get('user_id') # Récupérer l'ID de l'utilisateur de la session user_id = get_user_id()
if user_id not in user_csvs: if user_id not in user_csvs:
return jsonify({'movies': [], 'message': 'No movies found. Please upload a CSV file.'}), 200 return jsonify({'movies': [], 'message': 'No movies found. Please upload a CSV file.'}), 200
# Lire le DataFrame à partir du cache
csv_content = user_csvs[user_id] csv_content = user_csvs[user_id]
try: try:
# Si csv_content est une chaîne, il n'est pas nécessaire de décoder
if isinstance(csv_content, bytes): if isinstance(csv_content, bytes):
df = pd.read_csv(io.StringIO(csv_content.decode('utf-8'))) df = pd.read_csv(io.StringIO(csv_content.decode('utf-8')))
else: else:
df = pd.read_csv(io.StringIO(csv_content)) df = pd.read_csv(io.StringIO(csv_content))
# Reste de votre code pour la pagination et le traitement... month = request.args.get('month') # e.g. "2024-01"
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
df = df.iloc[::-1].reset_index(drop=True) df = df.iloc[::-1].reset_index(drop=True)
total_movies = len(df) total_movies = len(df)
if month:
df['_date_parsed'] = pd.to_datetime(df['Watched Date'], errors='coerce')
df = df[df['_date_parsed'].dt.strftime('%Y-%m') == month]
df = df.sort_values('_date_parsed')
df = df.drop(columns=['_date_parsed'])
movies = df.to_dict('records')
movies_with_posters = [
{
'id': i + 1,
'Watched Date': movie['Watched Date'],
'Name': movie['Name'],
'Year': movie['Year'],
'Poster': get_poster_url(movie['Name'], movie['Year']),
}
for i, movie in enumerate(movies)
]
return jsonify({'movies': movies_with_posters, 'total': len(movies_with_posters)})
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
total_pages = math.ceil(total_movies / limit) total_pages = math.ceil(total_movies / limit)
start_index = (page - 1) * limit start_index = (page - 1) * limit
@@ -88,8 +108,6 @@ def get_movies():
@api_bp.route('/posters/<movie_name>/<movie_year>', methods=['GET']) @api_bp.route('/posters/<movie_name>/<movie_year>', methods=['GET'])
def get_movie_posters(movie_name, movie_year): def get_movie_posters(movie_name, movie_year):
tmdb_service = TMDBService()
movie = tmdb_service.search_movie(movie_name, movie_year) movie = tmdb_service.search_movie(movie_name, movie_year)
if movie: if movie:
@@ -105,11 +123,7 @@ def allowed_file(filename):
@api_bp.route('/upload-csv', methods=['POST']) @api_bp.route('/upload-csv', methods=['POST'])
def upload_csv(): def upload_csv():
user_id = session.get('user_id') user_id = get_user_id()
if not user_id:
user_id = str(uuid.uuid4())
session['user_id'] = user_id
if 'file' not in request.files: if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400 return jsonify({'error': 'No file part'}), 400
@@ -122,11 +136,16 @@ def upload_csv():
try: try:
df = pd.read_csv(io.StringIO(content.decode('utf-8'))) df = pd.read_csv(io.StringIO(content.decode('utf-8')))
df = df.rename(columns={
'Date': 'Watched Date', if 'Watched Date' in df.columns:
'Name': 'Name', pass
'Year': 'Year' elif 'Date' in df.columns:
}) df = df.rename(columns={'Date': 'Watched Date'})
else:
return jsonify({'error': 'CSV must contain a "Watched Date" or "Date" column'}), 400
if 'Name' not in df.columns or 'Year' not in df.columns:
return jsonify({'error': 'CSV must contain "Name" and "Year" columns'}), 400
user_csvs[user_id] = df.to_csv(index=False) user_csvs[user_id] = df.to_csv(index=False)
@@ -138,7 +157,7 @@ def upload_csv():
@api_bp.route('/check-csv', methods=['GET']) @api_bp.route('/check-csv', methods=['GET'])
def check_csv(): def check_csv():
user_id = session.get('user_id') user_id = get_user_id()
try: try:
if user_id in user_csvs: if user_id in user_csvs:
@@ -152,7 +171,7 @@ def check_csv():
@api_bp.route('/delete-csv', methods=['DELETE']) @api_bp.route('/delete-csv', methods=['DELETE'])
def delete_csv(): def delete_csv():
user_id = session.get('user_id') user_id = get_user_id()
try: try:
if user_id in user_csvs: if user_id in user_csvs:
@@ -165,106 +184,71 @@ def delete_csv():
return jsonify({'error': 'An error occurred while deleting the CSV file'}), 500 return jsonify({'error': 'An error occurred while deleting the CSV file'}), 500
@api_bp.route('/fetch-diary', methods=['POST']) def _parse_letterboxd_rss(xml_bytes):
def fetch_diary_from_username(): import xml.etree.ElementTree as ET
""" root = ET.fromstring(xml_bytes)
Récupère le diary via l'API publique (api.hugo-pierret.be), transforme en CSV et entries = []
le stocke dans user_csvs[user_id] pour que le front continue à utiliser le même flux que l'upload. for item in root.findall('.//item'):
""" watched_date = film_title = film_year = None
for child in item:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'watchedDate':
watched_date = child.text
elif tag == 'filmTitle':
film_title = child.text
elif tag == 'filmYear':
film_year = child.text
if watched_date and film_title:
entries.append({
'Watched Date': watched_date,
'Name': film_title,
'Year': film_year or '',
})
return entries
@api_bp.route('/sync-rss', methods=['POST'])
def sync_rss():
try: try:
data = request.get_json() or {} data = request.get_json() or {}
username = data.get('username') username = data.get('username', '').strip()
if not username: if not username:
return jsonify({'error': 'username required'}), 400 return jsonify({'error': 'username required'}), 400
user_id = session.get('user_id') user_id = get_user_id()
if not user_id:
user_id = str(uuid.uuid4())
session['user_id'] = user_id
api_url = f"{LETTERBOXD_API_URL}/letterboxd/diary?username={username}" rss_url = f"https://letterboxd.com/{username}/rss/"
resp = requests.get(api_url) resp = requests.get(rss_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
if resp.status_code != 200: if resp.status_code != 200:
return jsonify({'error': 'Failed to fetch diary from upstream API'}), 502 return jsonify({'error': f'Could not fetch RSS feed (HTTP {resp.status_code})'}), 502
payload = resp.json() rss_entries = _parse_letterboxd_rss(resp.content)
entries = payload.get('entries', []) if not rss_entries:
if not entries: return jsonify({'error': 'No diary entries found in RSS feed'}), 404
return jsonify({'error': 'No entries returned by upstream API'}), 404
rows = [] if user_id not in user_csvs:
import re df = pd.DataFrame(rss_entries)
for e in entries: df = df.sort_values('Watched Date').reset_index(drop=True)
title = e.get('title') or "" user_csvs[user_id] = df.to_csv(index=False)
date = e.get('date') or "" return jsonify({'success': True, 'fresh': True, 'added': len(rss_entries), 'total': len(rss_entries)}), 200
m = re.match(r"^(?P<name>.+?)\s*\((?P<year>\d{4})\)\s*$", title) existing_content = user_csvs[user_id]
if m: df_existing = pd.read_csv(
name = m.group('name').strip() io.StringIO(existing_content.decode('utf-8') if isinstance(existing_content, bytes) else existing_content)
year = m.group('year')
else:
year_match = re.search(r"(\d{4})", title)
year = year_match.group(1) if year_match else ""
name = re.sub(r"\(\d{4}\)", "", title).strip()
rows.append({
"Watched Date": date,
"Name": name,
"Year": year
})
import pandas as pd, io
rows.reverse()
df = pd.DataFrame(rows)
csv_str = df.to_csv(index=False)
user_csvs[user_id] = csv_str
return jsonify({'success': True, 'message': 'Diary fetched and stored', 'total': len(rows)}), 200
except Exception as e:
print("Error in fetch_diary_from_username:", str(e))
return jsonify({'error': 'An internal error occurred'}), 500
@api_bp.route('/download-posters', methods=['POST'])
def download_posters():
try:
data = request.get_json()
selected_posters = data.get('posters', [])
if not selected_posters:
return jsonify({'error': 'No posters selected'}), 400
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# Télécharger chaque poster
for poster in selected_posters:
movie_id = poster['movieId']
poster_id = poster['posterId']
watched_date = poster.get('watchedDate', 'unknown-date')
poster_url = f"https://image.tmdb.org/t/p/original{poster_id}"
try:
response = requests.get(poster_url)
if response.status_code == 200:
file_name = f"{watched_date}_{movie_id}.jpg"
zip_file.writestr(file_name, response.content)
else:
print(f"Failed to download poster for movie ID {movie_id}")
except Exception as e:
print(f"Error downloading poster for movie ID {movie_id}: {str(e)}")
# Retourner le fichier ZIP
zip_buffer.seek(0)
return send_file(
zip_buffer,
mimetype='application/zip',
as_attachment=True,
download_name='posters.zip'
) )
existing_keys = set(zip(df_existing['Name'].astype(str), df_existing['Watched Date'].astype(str)))
new_rows = [e for e in rss_entries if (e['Name'], e['Watched Date']) not in existing_keys]
if not new_rows:
return jsonify({'success': True, 'fresh': False, 'added': 0, 'total': len(df_existing)}), 200
df_merged = pd.concat([df_existing, pd.DataFrame(new_rows)], ignore_index=True)
df_merged = df_merged.sort_values('Watched Date').reset_index(drop=True)
user_csvs[user_id] = df_merged.to_csv(index=False)
return jsonify({'success': True, 'fresh': False, 'added': len(new_rows), 'total': len(df_merged)}), 200
except Exception as e: except Exception as e:
print(f"Error in download_posters: {str(e)}") print("Error in sync_rss:", str(e))
return jsonify({'error': 'An error occurred while fetching movie posters'}), 500 return jsonify({'error': 'An internal error occurred'}), 500

View File

@@ -1,10 +0,0 @@
import re
import os
def clean_filename(filename):
return re.sub(r'[\\/*?:"<>|]', '_', filename)
def ensure_directory(directory):
os.makedirs(directory, exist_ok=True)

View File

@@ -1,14 +0,0 @@
import requests
from PIL import Image
import io
class ImageService:
def download_image(self, image_url, save_path=None):
response = requests.get(image_url, stream=True)
if response.status_code == 200:
image = Image.open(io.BytesIO(response.content))
if save_path:
image.save(save_path)
return image
return None

View File

@@ -24,7 +24,7 @@ class TMDBService:
return None return None
def get_movie_posters(self, movie_id): def get_movie_posters(self, movie_id):
url = f"{self.base_url}/movie/{movie_id}/images?include_image_language=en,fr,null" url = f"{self.base_url}/movie/{movie_id}/images?include_image_language=en,fr,ja,null&language=null"
headers = { headers = {
"accept": "application/json", "accept": "application/json",
"Authorization": f"Bearer {self.access_token}" "Authorization": f"Bearer {self.access_token}"