Compare commits

..

10 Commits

Author SHA1 Message Date
4af947b440 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
2026-04-26 17:25:21 +02:00
8941057f0e Use internal ip for api in production 2025-10-06 21:04:53 +02:00
6ebd69422d reduce specs and augment timeout 2025-10-06 18:50:18 +02:00
9052e04d6c add gevent to requirements 2025-09-30 18:30:38 +02:00
99fbe9d515 Try to resolve latency between front and back 2025-09-30 18:03:28 +02:00
d050afff13 test before push .... 2025-09-29 01:15:00 +02:00
e656893c68 ensure https in backend 2025-09-29 01:10:19 +02:00
07ecae67fe Fix gunicorn app 2025-09-28 15:58:29 +02:00
ad8ccb183e add gunicorn ton requirements 2025-09-26 17:42:32 +02:00
511fdcbc26 Dockerify 2025-09-25 16:22:04 +02:00
13 changed files with 176 additions and 155 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
.venv
__pycache__/
*.pyc
*.pyo

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# backend/Dockerfile
FROM python:3.11-slim
RUN addgroup --system app && adduser --system --ingroup app --home /home/app app
WORKDIR /srv/app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Assurer permissions
RUN chown -R app:app /srv/app
USER app
# Port interne
EXPOSE 5000
# Variable d'environnement par défaut
ENV FLASK_ENV=production
ENV GUNICORN_WORKERS=2
ENV GUNICORN_THREADS=2
ENV GUNICORN_WORKER_CLASS=gevent
ENV GUNICORN_TIMEOUT=300
# Commande de démarrage via gunicorn (attache l'app Flask : app:app)
# 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} --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
class Config:
# Configuration générale
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev_key')
DEBUG = False
TESTING = False
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
class DevelopmentConfig(Config):
DEBUG = True
# Configuration pour le développement local
REDIS_URL = "redis://localhost:6379"
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
config = {

14
main.py
View File

@@ -2,7 +2,6 @@ import os
from flask import Flask
from flask_cors import CORS
from src.api.routes import api_bp
from flask_sse import sse
from config import config
@@ -13,17 +12,18 @@ def create_app():
"http://localhost: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)
app.config.update(
SESSION_COOKIE_SAMESITE="None", # necessary for cross-site cookies
SESSION_COOKIE_SECURE=False # True in prod (HTTPS)
)
env = os.environ.get('FLASK_ENV', 'default')
if env == "production":
app.config.update(SESSION_COOKIE_SAMESITE="None", SESSION_COOKIE_SECURE=True)
app.config.from_object(config[env])
app.register_blueprint(sse, url_prefix='/stream')
app.register_blueprint(api_bp, url_prefix='/api')
return app

Binary file not shown.

View File

@@ -1,21 +1,25 @@
import io
import math
import os
import zipfile
import uuid
import requests
import uuid
from flask import Blueprint, jsonify, request, session, send_file
from werkzeug.utils import secure_filename
from flask import Blueprint, jsonify, request, session
from src.services.tmdb_service import TMDBService
from src.services.image_service import ImageService
import pandas as pd
from functools import lru_cache
api_bp = Blueprint('api', __name__)
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'}
@@ -32,27 +36,45 @@ def get_poster_url(movie_name, movie_year):
@api_bp.route('/movies', methods=['GET'])
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:
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]
try:
# Si csv_content est une chaîne, il n'est pas nécessaire de décoder
if isinstance(csv_content, bytes):
df = pd.read_csv(io.StringIO(csv_content.decode('utf-8')))
else:
df = pd.read_csv(io.StringIO(csv_content))
# Reste de votre code pour la pagination et le traitement...
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
month = request.args.get('month') # e.g. "2024-01"
df = df.iloc[::-1].reset_index(drop=True)
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)
start_index = (page - 1) * limit
@@ -86,8 +108,6 @@ def get_movies():
@api_bp.route('/posters/<movie_name>/<movie_year>', methods=['GET'])
def get_movie_posters(movie_name, movie_year):
tmdb_service = TMDBService()
movie = tmdb_service.search_movie(movie_name, movie_year)
if movie:
@@ -103,11 +123,7 @@ def allowed_file(filename):
@api_bp.route('/upload-csv', methods=['POST'])
def upload_csv():
user_id = session.get('user_id')
if not user_id:
user_id = str(uuid.uuid4())
session['user_id'] = user_id
user_id = get_user_id()
if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400
@@ -120,11 +136,16 @@ def upload_csv():
try:
df = pd.read_csv(io.StringIO(content.decode('utf-8')))
df = df.rename(columns={
'Date': 'Watched Date',
'Name': 'Name',
'Year': 'Year'
})
if 'Watched Date' in df.columns:
pass
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)
@@ -136,7 +157,7 @@ def upload_csv():
@api_bp.route('/check-csv', methods=['GET'])
def check_csv():
user_id = session.get('user_id')
user_id = get_user_id()
try:
if user_id in user_csvs:
@@ -150,7 +171,7 @@ def check_csv():
@api_bp.route('/delete-csv', methods=['DELETE'])
def delete_csv():
user_id = session.get('user_id')
user_id = get_user_id()
try:
if user_id in user_csvs:
@@ -163,106 +184,71 @@ def delete_csv():
return jsonify({'error': 'An error occurred while deleting the CSV file'}), 500
@api_bp.route('/fetch-diary', methods=['POST'])
def fetch_diary_from_username():
"""
Récupère le diary via l'API publique (api.hugo-pierret.be), transforme en CSV et
le stocke dans user_csvs[user_id] pour que le front continue à utiliser le même flux que l'upload.
"""
def _parse_letterboxd_rss(xml_bytes):
import xml.etree.ElementTree as ET
root = ET.fromstring(xml_bytes)
entries = []
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:
data = request.get_json() or {}
username = data.get('username')
username = data.get('username', '').strip()
if not username:
return jsonify({'error': 'username required'}), 400
user_id = session.get('user_id')
if not user_id:
user_id = str(uuid.uuid4())
session['user_id'] = user_id
user_id = get_user_id()
api_url = f"https://api.hugo-pierret.be/letterboxd/diary?username={username}"
resp = requests.get(api_url)
rss_url = f"https://letterboxd.com/{username}/rss/"
resp = requests.get(rss_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
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()
entries = payload.get('entries', [])
if not entries:
return jsonify({'error': 'No entries returned by upstream API'}), 404
rss_entries = _parse_letterboxd_rss(resp.content)
if not rss_entries:
return jsonify({'error': 'No diary entries found in RSS feed'}), 404
rows = []
import re
for e in entries:
title = e.get('title') or ""
date = e.get('date') or ""
if user_id not in user_csvs:
df = pd.DataFrame(rss_entries)
df = df.sort_values('Watched Date').reset_index(drop=True)
user_csvs[user_id] = df.to_csv(index=False)
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)
if m:
name = m.group('name').strip()
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_content = user_csvs[user_id]
df_existing = pd.read_csv(
io.StringIO(existing_content.decode('utf-8') if isinstance(existing_content, bytes) else existing_content)
)
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:
print(f"Error in download_posters: {str(e)}")
return jsonify({'error': 'An error occurred while fetching movie posters'}), 500
print("Error in sync_rss:", str(e))
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
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 = {
"accept": "application/json",
"Authorization": f"Bearer {self.access_token}"