diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..97f8605 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 85c55eb..315f677 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .env .venv +__pycache__/ +*.pyc +*.pyo diff --git a/Dockerfile b/Dockerfile index a54e14e..ca3791c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # backend/Dockerfile 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 @@ -27,4 +27,4 @@ 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} --bind 0.0.0.0:5000 --access-logfile - --error-logfile - 'main:create_app()'"] \ No newline at end of file +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()'"] \ No newline at end of file diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc index e86d509..b1b02d7 100644 Binary files a/__pycache__/config.cpython-312.pyc and b/__pycache__/config.cpython-312.pyc differ diff --git a/config.py b/config.py index c1c59ee..2897426 100644 --- a/config.py +++ b/config.py @@ -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 = { diff --git a/main.py b/main.py index c9a8760..dc69ac8 100644 --- a/main.py +++ b/main.py @@ -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,18 +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) - + env = os.environ.get('FLASK_ENV', 'default') if env == "production": 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.register_blueprint(sse, url_prefix='/stream') app.register_blueprint(api_bp, url_prefix='/api') return app diff --git a/src/api/__pycache__/routes.cpython-312.pyc b/src/api/__pycache__/routes.cpython-312.pyc index 600a2b7..95b89b5 100644 Binary files a/src/api/__pycache__/routes.cpython-312.pyc and b/src/api/__pycache__/routes.cpython-312.pyc differ diff --git a/src/api/routes.py b/src/api/routes.py index 85a4112..f20d2cf 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,28 +1,30 @@ 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'} user_csvs = {} -LETTERBOXD_API_URL = os.getenv('LETTERBOXD_API_URL') - @lru_cache(maxsize=1000) 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']) 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 @@ -88,8 +108,6 @@ def get_movies(): @api_bp.route('/posters//', methods=['GET']) def get_movie_posters(movie_name, movie_year): - tmdb_service = TMDBService() - movie = tmdb_service.search_movie(movie_name, movie_year) if movie: @@ -105,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 @@ -122,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) @@ -138,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: @@ -152,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: @@ -165,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"{LETTERBOXD_API_URL}/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.+?)\s*\((?P\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 diff --git a/src/services/__pycache__/tmdb_service.cpython-312.pyc b/src/services/__pycache__/tmdb_service.cpython-312.pyc index f23a0b5..fec64db 100644 Binary files a/src/services/__pycache__/tmdb_service.cpython-312.pyc and b/src/services/__pycache__/tmdb_service.cpython-312.pyc differ diff --git a/src/services/file_utils.py b/src/services/file_utils.py deleted file mode 100644 index a7f1f1a..0000000 --- a/src/services/file_utils.py +++ /dev/null @@ -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) diff --git a/src/services/image_service.py b/src/services/image_service.py deleted file mode 100644 index 8f6e516..0000000 --- a/src/services/image_service.py +++ /dev/null @@ -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 diff --git a/src/services/tmdb_service.py b/src/services/tmdb_service.py index f3b2d48..6ab3c90 100644 --- a/src/services/tmdb_service.py +++ b/src/services/tmdb_service.py @@ -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}"