Update app.py
Browse files
app.py
CHANGED
|
@@ -11,11 +11,13 @@ logger = logging.getLogger(__name__)
|
|
| 11 |
app = Flask(__name__)
|
| 12 |
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'une_cle_secrete_par_defaut_pour_dev')
|
| 13 |
|
|
|
|
| 14 |
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://Podcast_owner:npg_gFdMDLO9lVa0@ep-delicate-surf-a4v7wopn-pooler.us-east-1.aws.neon.tech/Podcast?sslmode=require'
|
| 15 |
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 16 |
|
| 17 |
db = SQLAlchemy(app)
|
| 18 |
|
|
|
|
| 19 |
AUDIO_CACHE_DIR = '/tmp/audio_cache'
|
| 20 |
try:
|
| 21 |
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
|
@@ -23,26 +25,29 @@ try:
|
|
| 23 |
except OSError as e:
|
| 24 |
logger.error(f"Impossible de créer le répertoire de cache audio à {AUDIO_CACHE_DIR}: {e}")
|
| 25 |
|
|
|
|
| 26 |
class Podcast(db.Model):
|
| 27 |
__tablename__ = 'podcast'
|
| 28 |
id = db.Column(db.Integer, primary_key=True)
|
| 29 |
name = db.Column(db.String(200), nullable=False)
|
| 30 |
url = db.Column(db.String(500), nullable=False, unique=True)
|
| 31 |
subject = db.Column(db.String(100), nullable=False)
|
| 32 |
-
filename_cache = db.Column(db.String(255), nullable=True)
|
| 33 |
|
| 34 |
def __repr__(self):
|
| 35 |
return f'<Podcast {self.name}>'
|
| 36 |
|
| 37 |
-
#
|
|
|
|
|
|
|
| 38 |
with app.app_context():
|
| 39 |
try:
|
| 40 |
db.create_all()
|
| 41 |
logger.info("Tables de base de données vérifiées/créées (si elles n'existaient pas).")
|
| 42 |
except Exception as e:
|
| 43 |
logger.error(f"Erreur lors de la création des tables de la base de données: {e}")
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
|
| 47 |
@app.route('/')
|
| 48 |
def index():
|
|
@@ -51,7 +56,7 @@ def index():
|
|
| 51 |
except Exception as e:
|
| 52 |
logger.error(f"Erreur lors de la récupération des podcasts: {e}")
|
| 53 |
flash("Erreur lors du chargement des podcasts depuis la base de données.", "error")
|
| 54 |
-
podcasts = []
|
| 55 |
return render_template('index.html', podcasts=podcasts)
|
| 56 |
|
| 57 |
@app.route('/gestion', methods=['GET', 'POST'])
|
|
@@ -64,6 +69,7 @@ def gestion():
|
|
| 64 |
if not name or not url or not subject:
|
| 65 |
flash('Tous les champs sont requis !', 'error')
|
| 66 |
else:
|
|
|
|
| 67 |
existing_podcast = Podcast.query.filter_by(url=url).first()
|
| 68 |
if existing_podcast:
|
| 69 |
flash('Un podcast avec cette URL existe déjà.', 'warning')
|
|
@@ -73,12 +79,13 @@ def gestion():
|
|
| 73 |
db.session.add(new_podcast)
|
| 74 |
db.session.commit()
|
| 75 |
flash('Podcast ajouté avec succès !', 'success')
|
| 76 |
-
return redirect(url_for('gestion'))
|
| 77 |
except Exception as e:
|
| 78 |
-
db.session.rollback()
|
| 79 |
logger.error(f"Erreur lors de l'ajout du podcast: {e}")
|
| 80 |
flash(f"Erreur lors de l'ajout du podcast: {e}", 'error')
|
| 81 |
|
|
|
|
| 82 |
try:
|
| 83 |
podcasts = Podcast.query.order_by(Podcast.name).all()
|
| 84 |
except Exception as e:
|
|
@@ -91,11 +98,12 @@ def gestion():
|
|
| 91 |
@app.route('/delete_podcast/<int:podcast_id>', methods=['POST'])
|
| 92 |
def delete_podcast(podcast_id):
|
| 93 |
try:
|
| 94 |
-
podcast_to_delete = db.session.get(Podcast, podcast_id)
|
| 95 |
if not podcast_to_delete:
|
| 96 |
flash('Podcast non trouvé.', 'error')
|
| 97 |
return redirect(url_for('gestion'))
|
| 98 |
|
|
|
|
| 99 |
if podcast_to_delete.filename_cache:
|
| 100 |
cached_file_path = os.path.join(AUDIO_CACHE_DIR, podcast_to_delete.filename_cache)
|
| 101 |
if os.path.exists(cached_file_path):
|
|
@@ -105,6 +113,9 @@ def delete_podcast(podcast_id):
|
|
| 105 |
except OSError as e:
|
| 106 |
logger.error(f"Erreur lors de la suppression du fichier cache {podcast_to_delete.filename_cache}: {e}")
|
| 107 |
flash(f'Erreur lors de la suppression du fichier cache {podcast_to_delete.filename_cache}.', 'error')
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
db.session.delete(podcast_to_delete)
|
| 110 |
db.session.commit()
|
|
@@ -117,12 +128,13 @@ def delete_podcast(podcast_id):
|
|
| 117 |
|
| 118 |
@app.route('/play/<int:podcast_id>')
|
| 119 |
def play_podcast_route(podcast_id):
|
| 120 |
-
podcast = db.session.get(Podcast, podcast_id)
|
| 121 |
|
| 122 |
if not podcast:
|
| 123 |
logger.warning(f"Tentative de lecture d'un podcast non trouvé: ID {podcast_id}")
|
| 124 |
return jsonify({'error': 'Podcast non trouvé'}), 404
|
| 125 |
|
|
|
|
| 126 |
if podcast.filename_cache:
|
| 127 |
cached_filepath = os.path.join(AUDIO_CACHE_DIR, podcast.filename_cache)
|
| 128 |
if os.path.exists(cached_filepath):
|
|
@@ -130,39 +142,47 @@ def play_podcast_route(podcast_id):
|
|
| 130 |
audio_url = url_for('serve_cached_audio', filename=podcast.filename_cache)
|
| 131 |
return jsonify({'audio_url': audio_url})
|
| 132 |
else:
|
| 133 |
-
|
| 134 |
-
podcast.filename_cache
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
|
|
|
| 137 |
try:
|
|
|
|
| 138 |
parsed_url = urlparse(podcast.url)
|
| 139 |
-
|
| 140 |
-
extension =
|
| 141 |
|
| 142 |
-
|
|
|
|
| 143 |
|
| 144 |
logger.info(f"Téléchargement de {podcast.url} pour le podcast ID {podcast.id}")
|
| 145 |
-
#
|
| 146 |
response = requests.get(podcast.url, stream=True, timeout=(10, 60))
|
| 147 |
-
response.raise_for_status()
|
| 148 |
|
| 149 |
-
#
|
| 150 |
content_type = response.headers.get('Content-Type')
|
| 151 |
if content_type:
|
| 152 |
if 'mpeg' in content_type: extension = '.mp3'
|
| 153 |
elif 'ogg' in content_type: extension = '.ogg'
|
| 154 |
elif 'wav' in content_type: extension = '.wav'
|
| 155 |
elif 'aac' in content_type: extension = '.aac'
|
| 156 |
-
elif 'mp4' in content_type: extension = '.m4a' #
|
| 157 |
|
|
|
|
| 158 |
cached_filename_with_ext = f"{base_filename}{extension}"
|
| 159 |
final_cached_filepath = os.path.join(AUDIO_CACHE_DIR, cached_filename_with_ext)
|
| 160 |
|
|
|
|
| 161 |
with open(final_cached_filepath, 'wb') as f:
|
| 162 |
-
for chunk in response.iter_content(chunk_size=8192):
|
| 163 |
f.write(chunk)
|
| 164 |
logger.info(f"Téléchargement terminé: {final_cached_filepath}")
|
| 165 |
|
|
|
|
| 166 |
podcast.filename_cache = cached_filename_with_ext
|
| 167 |
db.session.commit()
|
| 168 |
|
|
@@ -171,32 +191,33 @@ def play_podcast_route(podcast_id):
|
|
| 171 |
|
| 172 |
except requests.exceptions.Timeout:
|
| 173 |
logger.error(f"Timeout lors du téléchargement de {podcast.url}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
return jsonify({'error': 'Le téléchargement du podcast a pris trop de temps.'}), 504
|
| 175 |
except requests.exceptions.RequestException as e:
|
| 176 |
logger.error(f"Erreur de téléchargement pour {podcast.url}: {e}")
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (RequestException) {final_cached_filepath}: {e_clean}")
|
| 181 |
return jsonify({'error': f'Impossible de télécharger le podcast: {e}'}), 500
|
| 182 |
except Exception as e:
|
| 183 |
-
db.session.rollback() #
|
| 184 |
logger.error(f"Erreur inattendue lors du traitement du podcast {podcast.id}: {e}")
|
| 185 |
-
|
| 186 |
-
if final_cached_filepath and os.path.exists(final_cached_filepath) and (not podcast or podcast.filename_cache != os.path.basename(final_cached_filepath)):
|
| 187 |
try: os.remove(final_cached_filepath); logger.info(f"Fichier partiel (Exception) nettoyé : {final_cached_filepath}")
|
| 188 |
except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (Exception) {final_cached_filepath}: {e_clean}")
|
| 189 |
return jsonify({'error': f'Erreur inattendue: {e}'}), 500
|
| 190 |
-
# The finally block for cleanup was a bit complex; simplified by handling cleanup in error cases.
|
| 191 |
-
# If successful, filename_cache is set, so it won't be cleaned.
|
| 192 |
|
| 193 |
@app.route('/audio_cache/<path:filename>')
|
| 194 |
def serve_cached_audio(filename):
|
| 195 |
logger.debug(f"Service du fichier cache: {filename} depuis {AUDIO_CACHE_DIR}")
|
|
|
|
|
|
|
| 196 |
return send_from_directory(AUDIO_CACHE_DIR, filename)
|
| 197 |
|
| 198 |
if __name__ == '__main__':
|
| 199 |
-
#
|
| 200 |
-
#
|
| 201 |
-
# Corrected to a standard app.run() call.
|
| 202 |
app.run(debug=True, host='0.0.0.0', port=int(os.environ.get('PORT', 5000)))
|
|
|
|
| 11 |
app = Flask(__name__)
|
| 12 |
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'une_cle_secrete_par_defaut_pour_dev')
|
| 13 |
|
| 14 |
+
# Configuration de la base de données PostgreSQL
|
| 15 |
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://Podcast_owner:npg_gFdMDLO9lVa0@ep-delicate-surf-a4v7wopn-pooler.us-east-1.aws.neon.tech/Podcast?sslmode=require'
|
| 16 |
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 17 |
|
| 18 |
db = SQLAlchemy(app)
|
| 19 |
|
| 20 |
+
# Configuration du répertoire de cache audio
|
| 21 |
AUDIO_CACHE_DIR = '/tmp/audio_cache'
|
| 22 |
try:
|
| 23 |
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
|
|
|
| 25 |
except OSError as e:
|
| 26 |
logger.error(f"Impossible de créer le répertoire de cache audio à {AUDIO_CACHE_DIR}: {e}")
|
| 27 |
|
| 28 |
+
# Modèle de base de données pour les Podcasts
|
| 29 |
class Podcast(db.Model):
|
| 30 |
__tablename__ = 'podcast'
|
| 31 |
id = db.Column(db.Integer, primary_key=True)
|
| 32 |
name = db.Column(db.String(200), nullable=False)
|
| 33 |
url = db.Column(db.String(500), nullable=False, unique=True)
|
| 34 |
subject = db.Column(db.String(100), nullable=False)
|
| 35 |
+
filename_cache = db.Column(db.String(255), nullable=True) # Nom du fichier dans le cache
|
| 36 |
|
| 37 |
def __repr__(self):
|
| 38 |
return f'<Podcast {self.name}>'
|
| 39 |
|
| 40 |
+
# Création des tables de la base de données si elles n'existent pas
|
| 41 |
+
# Ceci est déplacé ici pour s'assurer qu'il s'exécute au démarrage de l'application,
|
| 42 |
+
# que ce soit via `flask run` ou un serveur WSGI.
|
| 43 |
with app.app_context():
|
| 44 |
try:
|
| 45 |
db.create_all()
|
| 46 |
logger.info("Tables de base de données vérifiées/créées (si elles n'existaient pas).")
|
| 47 |
except Exception as e:
|
| 48 |
logger.error(f"Erreur lors de la création des tables de la base de données: {e}")
|
| 49 |
+
# Il est crucial de gérer cette erreur. Si la base de données n'est pas accessible ou
|
| 50 |
+
# si les tables ne peuvent pas être créées, l'application ne fonctionnera pas correctement.
|
| 51 |
|
| 52 |
@app.route('/')
|
| 53 |
def index():
|
|
|
|
| 56 |
except Exception as e:
|
| 57 |
logger.error(f"Erreur lors de la récupération des podcasts: {e}")
|
| 58 |
flash("Erreur lors du chargement des podcasts depuis la base de données.", "error")
|
| 59 |
+
podcasts = [] # Fournir une liste vide en cas d'erreur pour que le template fonctionne
|
| 60 |
return render_template('index.html', podcasts=podcasts)
|
| 61 |
|
| 62 |
@app.route('/gestion', methods=['GET', 'POST'])
|
|
|
|
| 69 |
if not name or not url or not subject:
|
| 70 |
flash('Tous les champs sont requis !', 'error')
|
| 71 |
else:
|
| 72 |
+
# Vérifier si un podcast avec la même URL existe déjà
|
| 73 |
existing_podcast = Podcast.query.filter_by(url=url).first()
|
| 74 |
if existing_podcast:
|
| 75 |
flash('Un podcast avec cette URL existe déjà.', 'warning')
|
|
|
|
| 79 |
db.session.add(new_podcast)
|
| 80 |
db.session.commit()
|
| 81 |
flash('Podcast ajouté avec succès !', 'success')
|
| 82 |
+
return redirect(url_for('gestion')) # Rediriger pour éviter la resoumission du formulaire
|
| 83 |
except Exception as e:
|
| 84 |
+
db.session.rollback() # Annuler les changements en cas d'erreur
|
| 85 |
logger.error(f"Erreur lors de l'ajout du podcast: {e}")
|
| 86 |
flash(f"Erreur lors de l'ajout du podcast: {e}", 'error')
|
| 87 |
|
| 88 |
+
# Charger les podcasts pour l'affichage sur la page de gestion (méthode GET ou après POST)
|
| 89 |
try:
|
| 90 |
podcasts = Podcast.query.order_by(Podcast.name).all()
|
| 91 |
except Exception as e:
|
|
|
|
| 98 |
@app.route('/delete_podcast/<int:podcast_id>', methods=['POST'])
|
| 99 |
def delete_podcast(podcast_id):
|
| 100 |
try:
|
| 101 |
+
podcast_to_delete = db.session.get(Podcast, podcast_id) # Utilisation de db.session.get pour Flask-SQLAlchemy >= 3.0
|
| 102 |
if not podcast_to_delete:
|
| 103 |
flash('Podcast non trouvé.', 'error')
|
| 104 |
return redirect(url_for('gestion'))
|
| 105 |
|
| 106 |
+
# Supprimer le fichier cache associé s'il existe
|
| 107 |
if podcast_to_delete.filename_cache:
|
| 108 |
cached_file_path = os.path.join(AUDIO_CACHE_DIR, podcast_to_delete.filename_cache)
|
| 109 |
if os.path.exists(cached_file_path):
|
|
|
|
| 113 |
except OSError as e:
|
| 114 |
logger.error(f"Erreur lors de la suppression du fichier cache {podcast_to_delete.filename_cache}: {e}")
|
| 115 |
flash(f'Erreur lors de la suppression du fichier cache {podcast_to_delete.filename_cache}.', 'error')
|
| 116 |
+
else:
|
| 117 |
+
logger.warning(f"Fichier cache {podcast_to_delete.filename_cache} listé dans la DB mais non trouvé sur le disque pour suppression.")
|
| 118 |
+
|
| 119 |
|
| 120 |
db.session.delete(podcast_to_delete)
|
| 121 |
db.session.commit()
|
|
|
|
| 128 |
|
| 129 |
@app.route('/play/<int:podcast_id>')
|
| 130 |
def play_podcast_route(podcast_id):
|
| 131 |
+
podcast = db.session.get(Podcast, podcast_id) # Utilisation de db.session.get
|
| 132 |
|
| 133 |
if not podcast:
|
| 134 |
logger.warning(f"Tentative de lecture d'un podcast non trouvé: ID {podcast_id}")
|
| 135 |
return jsonify({'error': 'Podcast non trouvé'}), 404
|
| 136 |
|
| 137 |
+
# Vérifier si le fichier est déjà en cache
|
| 138 |
if podcast.filename_cache:
|
| 139 |
cached_filepath = os.path.join(AUDIO_CACHE_DIR, podcast.filename_cache)
|
| 140 |
if os.path.exists(cached_filepath):
|
|
|
|
| 142 |
audio_url = url_for('serve_cached_audio', filename=podcast.filename_cache)
|
| 143 |
return jsonify({'audio_url': audio_url})
|
| 144 |
else:
|
| 145 |
+
# Le fichier cache est référencé mais n'existe pas, il faut le re-télécharger
|
| 146 |
+
logger.warning(f"Fichier cache {podcast.filename_cache} pour podcast {podcast.id} non trouvé sur le disque. Re-téléchargement.")
|
| 147 |
+
podcast.filename_cache = None # Marquer comme non-caché pour forcer le re-téléchargement
|
| 148 |
+
# Pas besoin de db.session.commit() ici immédiatement, sera fait après le téléchargement réussi
|
| 149 |
|
| 150 |
+
# Si le fichier n'est pas en cache ou si le cache était invalide
|
| 151 |
+
final_cached_filepath = None # Initialiser pour la clause finally
|
| 152 |
try:
|
| 153 |
+
# Déterminer l'extension à partir de l'URL ou du Content-Type
|
| 154 |
parsed_url = urlparse(podcast.url)
|
| 155 |
+
_, url_ext = os.path.splitext(parsed_url.path)
|
| 156 |
+
extension = url_ext if url_ext else '.audio' # Extension par défaut
|
| 157 |
|
| 158 |
+
# Utiliser l'ID du podcast pour un nom de fichier unique et simple
|
| 159 |
+
base_filename = str(podcast.id)
|
| 160 |
|
| 161 |
logger.info(f"Téléchargement de {podcast.url} pour le podcast ID {podcast.id}")
|
| 162 |
+
# Timeout: (connect_timeout, read_timeout)
|
| 163 |
response = requests.get(podcast.url, stream=True, timeout=(10, 60))
|
| 164 |
+
response.raise_for_status() # Lèvera une exception pour les codes d'erreur HTTP (4xx ou 5xx)
|
| 165 |
|
| 166 |
+
# Essayer d'obtenir une meilleure extension à partir de l'en-tête Content-Type
|
| 167 |
content_type = response.headers.get('Content-Type')
|
| 168 |
if content_type:
|
| 169 |
if 'mpeg' in content_type: extension = '.mp3'
|
| 170 |
elif 'ogg' in content_type: extension = '.ogg'
|
| 171 |
elif 'wav' in content_type: extension = '.wav'
|
| 172 |
elif 'aac' in content_type: extension = '.aac'
|
| 173 |
+
elif 'mp4' in content_type: extension = '.m4a' # Souvent utilisé pour l'audio dans un conteneur mp4
|
| 174 |
|
| 175 |
+
# Construire le nom de fichier final avec l'extension déterminée
|
| 176 |
cached_filename_with_ext = f"{base_filename}{extension}"
|
| 177 |
final_cached_filepath = os.path.join(AUDIO_CACHE_DIR, cached_filename_with_ext)
|
| 178 |
|
| 179 |
+
# Écrire le contenu dans le fichier cache
|
| 180 |
with open(final_cached_filepath, 'wb') as f:
|
| 181 |
+
for chunk in response.iter_content(chunk_size=8192): # Taille de chunk raisonnable
|
| 182 |
f.write(chunk)
|
| 183 |
logger.info(f"Téléchargement terminé: {final_cached_filepath}")
|
| 184 |
|
| 185 |
+
# Mettre à jour la base de données avec le nom du fichier en cache
|
| 186 |
podcast.filename_cache = cached_filename_with_ext
|
| 187 |
db.session.commit()
|
| 188 |
|
|
|
|
| 191 |
|
| 192 |
except requests.exceptions.Timeout:
|
| 193 |
logger.error(f"Timeout lors du téléchargement de {podcast.url}")
|
| 194 |
+
# Nettoyer le fichier partiel si le téléchargement a échoué
|
| 195 |
+
if final_cached_filepath and os.path.exists(final_cached_filepath):
|
| 196 |
+
try: os.remove(final_cached_filepath); logger.info(f"Fichier partiel (Timeout) nettoyé : {final_cached_filepath}")
|
| 197 |
+
except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (Timeout) {final_cached_filepath}: {e_clean}")
|
| 198 |
return jsonify({'error': 'Le téléchargement du podcast a pris trop de temps.'}), 504
|
| 199 |
except requests.exceptions.RequestException as e:
|
| 200 |
logger.error(f"Erreur de téléchargement pour {podcast.url}: {e}")
|
| 201 |
+
if final_cached_filepath and os.path.exists(final_cached_filepath):
|
| 202 |
+
try: os.remove(final_cached_filepath); logger.info(f"Fichier partiel (RequestException) nettoyé : {final_cached_filepath}")
|
| 203 |
+
except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (RequestException) {final_cached_filepath}: {e_clean}")
|
|
|
|
| 204 |
return jsonify({'error': f'Impossible de télécharger le podcast: {e}'}), 500
|
| 205 |
except Exception as e:
|
| 206 |
+
db.session.rollback() # Assurer la cohérence de la session DB
|
| 207 |
logger.error(f"Erreur inattendue lors du traitement du podcast {podcast.id}: {e}")
|
| 208 |
+
if final_cached_filepath and os.path.exists(final_cached_filepath):
|
|
|
|
| 209 |
try: os.remove(final_cached_filepath); logger.info(f"Fichier partiel (Exception) nettoyé : {final_cached_filepath}")
|
| 210 |
except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (Exception) {final_cached_filepath}: {e_clean}")
|
| 211 |
return jsonify({'error': f'Erreur inattendue: {e}'}), 500
|
|
|
|
|
|
|
| 212 |
|
| 213 |
@app.route('/audio_cache/<path:filename>')
|
| 214 |
def serve_cached_audio(filename):
|
| 215 |
logger.debug(f"Service du fichier cache: {filename} depuis {AUDIO_CACHE_DIR}")
|
| 216 |
+
# Assurez-vous que le chemin est sécurisé et ne permet pas de sortir du répertoire de cache
|
| 217 |
+
# send_from_directory s'en charge généralement.
|
| 218 |
return send_from_directory(AUDIO_CACHE_DIR, filename)
|
| 219 |
|
| 220 |
if __name__ == '__main__':
|
| 221 |
+
# db.create_all() est maintenant appelé au niveau global du module.
|
| 222 |
+
# La ligne `app.run(...):19:12 =====` dans le log original contenait une syntaxe incorrecte, corrigée ici.
|
|
|
|
| 223 |
app.run(debug=True, host='0.0.0.0', port=int(os.environ.get('PORT', 5000)))
|