Compare commits
12 Commits
98d4f308ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b087660721 | |||
| 6e1ad80f33 | |||
| 4af947b440 | |||
| 8941057f0e | |||
| 6ebd69422d | |||
| 9052e04d6c | |||
| 99fbe9d515 | |||
| d050afff13 | |||
| e656893c68 | |||
| 07ecae67fe | |||
| ad8ccb183e | |||
| 511fdcbc26 |
34
.gitea/workflows/deploy.yml
Normal file
34
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Deploy Backend
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
|
||||
TMDB_ACCESS_TOKEN: ${{ secrets.TMDB_ACCESS_TOKEN }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version tag
|
||||
run: echo "VERSION_TAG=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
|
||||
- name: Create shared network if missing
|
||||
run: docker network create poster-picker || true
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker compose build
|
||||
|
||||
- name: Stop old container (if exists)
|
||||
run: docker compose down --remove-orphans || true
|
||||
|
||||
- name: Run compose
|
||||
run: docker compose up -d
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
.env
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal 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.
@@ -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 = {
|
||||
|
||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
backend:
|
||||
build: .
|
||||
image: poster-picker-backend:latest
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
||||
- TMDB_ACCESS_TOKEN=${TMDB_ACCESS_TOKEN}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
expose:
|
||||
- "5000"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- poster-picker
|
||||
|
||||
networks:
|
||||
poster-picker:
|
||||
external: true
|
||||
14
main.py
14
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,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
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user