File size: 11,934 Bytes
fc53ab2
 
43f84cb
fc53ab2
 
 
9f15b41
fc53ab2
 
43f84cb
fc53ab2
 
43f84cb
4e0cb8b
fc53ab2
 
43f84cb
fc53ab2
43f84cb
4e0cb8b
fc53ab2
 
 
 
 
 
43f84cb
4e0cb8b
fc53ab2
 
 
 
 
 
4e0cb8b
43f84cb
fc53ab2
 
43f84cb
4e0cb8b
 
 
47c91f3
 
 
 
 
 
4e0cb8b
 
47c91f3
fc53ab2
 
43f84cb
fc53ab2
 
 
 
4e0cb8b
fc53ab2
 
 
 
 
 
 
 
 
 
 
43f84cb
4e0cb8b
fc53ab2
 
 
 
 
 
 
 
 
4e0cb8b
fc53ab2
4e0cb8b
fc53ab2
 
 
4e0cb8b
fc53ab2
 
43f84cb
fc53ab2
 
 
43f84cb
fc53ab2
43f84cb
fc53ab2
 
 
4e0cb8b
fc53ab2
 
 
 
4e0cb8b
fc53ab2
 
 
43f84cb
fc53ab2
 
 
 
 
4e0cb8b
 
 
fc53ab2
 
 
 
 
 
 
 
 
 
 
 
4e0cb8b
fc53ab2
 
 
 
 
4e0cb8b
fc53ab2
 
 
 
 
 
43f84cb
4e0cb8b
 
 
 
43f84cb
4e0cb8b
 
43f84cb
4e0cb8b
fc53ab2
4e0cb8b
 
43f84cb
4e0cb8b
 
43f84cb
fc53ab2
4e0cb8b
47c91f3
4e0cb8b
fc53ab2
4e0cb8b
fc53ab2
 
 
 
 
 
4e0cb8b
fc53ab2
4e0cb8b
fc53ab2
 
 
4e0cb8b
fc53ab2
4e0cb8b
fc53ab2
 
 
4e0cb8b
fc53ab2
 
 
 
 
 
 
 
4e0cb8b
 
 
 
fc53ab2
 
 
4e0cb8b
 
 
fc53ab2
43f84cb
4e0cb8b
fc53ab2
4e0cb8b
47c91f3
 
 
fc53ab2
 
 
 
4e0cb8b
 
fc53ab2
 
 
4e0cb8b
 
fc53ab2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory
from flask_sqlalchemy import SQLAlchemy
import os
import requests
from urllib.parse import urlparse
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'une_cle_secrete_par_defaut_pour_dev')

# Configuration de la base de données PostgreSQL
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://Podcast_owner:npg_gFdMDLO9lVa0@ep-delicate-surf-a4v7wopn-pooler.us-east-1.aws.neon.tech/Podcast?sslmode=require'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)

# Configuration du répertoire de cache audio
AUDIO_CACHE_DIR = '/tmp/audio_cache'
try:
    os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
    logger.info(f"Répertoire de cache audio configuré sur : {AUDIO_CACHE_DIR}")
except OSError as e:
    logger.error(f"Impossible de créer le répertoire de cache audio à {AUDIO_CACHE_DIR}: {e}")

# Modèle de base de données pour les Podcasts
class Podcast(db.Model):
    __tablename__ = 'podcast'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(200), nullable=False)
    url = db.Column(db.String(500), nullable=False, unique=True)
    subject = db.Column(db.String(100), nullable=False)
    filename_cache = db.Column(db.String(255), nullable=True) # Nom du fichier dans le cache

    def __repr__(self):
        return f'<Podcast {self.name}>'

# Création des tables de la base de données si elles n'existent pas
# Ceci est déplacé ici pour s'assurer qu'il s'exécute au démarrage de l'application,
# que ce soit via `flask run` ou un serveur WSGI.
with app.app_context():
    try:
        db.create_all()
        logger.info("Tables de base de données vérifiées/créées (si elles n'existaient pas).")
    except Exception as e:
        logger.error(f"Erreur lors de la création des tables de la base de données: {e}")
        # Il est crucial de gérer cette erreur. Si la base de données n'est pas accessible ou
        # si les tables ne peuvent pas être créées, l'application ne fonctionnera pas correctement.

@app.route('/')
def index():
    try:
        podcasts = Podcast.query.order_by(Podcast.name).all()
    except Exception as e:
        logger.error(f"Erreur lors de la récupération des podcasts: {e}")
        flash("Erreur lors du chargement des podcasts depuis la base de données.", "error")
        podcasts = [] # Fournir une liste vide en cas d'erreur pour que le template fonctionne
    return render_template('index.html', podcasts=podcasts)

@app.route('/gestion', methods=['GET', 'POST'])
def gestion():
    if request.method == 'POST':
        name = request.form.get('name')
        url = request.form.get('url')
        subject = request.form.get('subject')

        if not name or not url or not subject:
            flash('Tous les champs sont requis !', 'error')
        else:
            # Vérifier si un podcast avec la même URL existe déjà
            existing_podcast = Podcast.query.filter_by(url=url).first()
            if existing_podcast:
                flash('Un podcast avec cette URL existe déjà.', 'warning')
            else:
                try:
                    new_podcast = Podcast(name=name, url=url, subject=subject)
                    db.session.add(new_podcast)
                    db.session.commit()
                    flash('Podcast ajouté avec succès !', 'success')
                    return redirect(url_for('gestion')) # Rediriger pour éviter la resoumission du formulaire
                except Exception as e:
                    db.session.rollback() # Annuler les changements en cas d'erreur
                    logger.error(f"Erreur lors de l'ajout du podcast: {e}")
                    flash(f"Erreur lors de l'ajout du podcast: {e}", 'error')
    
    # Charger les podcasts pour l'affichage sur la page de gestion (méthode GET ou après POST)
    try:
        podcasts = Podcast.query.order_by(Podcast.name).all()
    except Exception as e:
        logger.error(f"Erreur lors de la récupération des podcasts pour la gestion: {e}")
        flash("Erreur lors du chargement des podcasts pour la gestion.", "error")
        podcasts = []
        
    return render_template('gestion.html', podcasts=podcasts)

@app.route('/delete_podcast/<int:podcast_id>', methods=['POST'])
def delete_podcast(podcast_id):
    try:
        podcast_to_delete = db.session.get(Podcast, podcast_id) # Utilisation de db.session.get pour Flask-SQLAlchemy >= 3.0
        if not podcast_to_delete:
            flash('Podcast non trouvé.', 'error')
            return redirect(url_for('gestion'))

        # Supprimer le fichier cache associé s'il existe
        if podcast_to_delete.filename_cache:
            cached_file_path = os.path.join(AUDIO_CACHE_DIR, podcast_to_delete.filename_cache)
            if os.path.exists(cached_file_path):
                try:
                    os.remove(cached_file_path)
                    logger.info(f"Fichier cache {podcast_to_delete.filename_cache} supprimé.")
                except OSError as e:
                    logger.error(f"Erreur lors de la suppression du fichier cache {podcast_to_delete.filename_cache}: {e}")
                    flash(f'Erreur lors de la suppression du fichier cache {podcast_to_delete.filename_cache}.', 'error')
            else:
                logger.warning(f"Fichier cache {podcast_to_delete.filename_cache} listé dans la DB mais non trouvé sur le disque pour suppression.")


        db.session.delete(podcast_to_delete)
        db.session.commit()
        flash('Podcast supprimé avec succès.', 'success')
    except Exception as e:
        db.session.rollback()
        logger.error(f"Erreur lors de la suppression du podcast ID {podcast_id}: {e}")
        flash(f"Erreur lors de la suppression du podcast: {e}", 'error')
    return redirect(url_for('gestion'))

@app.route('/play/<int:podcast_id>')
def play_podcast_route(podcast_id):
    podcast = db.session.get(Podcast, podcast_id) # Utilisation de db.session.get

    if not podcast:
        logger.warning(f"Tentative de lecture d'un podcast non trouvé: ID {podcast_id}")
        return jsonify({'error': 'Podcast non trouvé'}), 404

    # Vérifier si le fichier est déjà en cache
    if podcast.filename_cache:
        cached_filepath = os.path.join(AUDIO_CACHE_DIR, podcast.filename_cache)
        if os.path.exists(cached_filepath):
            logger.info(f"Service du podcast {podcast.id} depuis le cache: {podcast.filename_cache}")
            audio_url = url_for('serve_cached_audio', filename=podcast.filename_cache)
            return jsonify({'audio_url': audio_url})
        else:
            # Le fichier cache est référencé mais n'existe pas, il faut le re-télécharger
            logger.warning(f"Fichier cache {podcast.filename_cache} pour podcast {podcast.id} non trouvé sur le disque. Re-téléchargement.")
            podcast.filename_cache = None # Marquer comme non-caché pour forcer le re-téléchargement
            # Pas besoin de db.session.commit() ici immédiatement, sera fait après le téléchargement réussi

    # Si le fichier n'est pas en cache ou si le cache était invalide
    final_cached_filepath = None # Initialiser pour la clause finally
    try:
        # Déterminer l'extension à partir de l'URL ou du Content-Type
        parsed_url = urlparse(podcast.url)
        _, url_ext = os.path.splitext(parsed_url.path)
        extension = url_ext if url_ext else '.audio' # Extension par défaut
        
        # Utiliser l'ID du podcast pour un nom de fichier unique et simple
        base_filename = str(podcast.id)
        
        logger.info(f"Téléchargement de {podcast.url} pour le podcast ID {podcast.id}")
        # Timeout: (connect_timeout, read_timeout)
        response = requests.get(podcast.url, stream=True, timeout=(10, 60)) 
        response.raise_for_status() # Lèvera une exception pour les codes d'erreur HTTP (4xx ou 5xx)

        # Essayer d'obtenir une meilleure extension à partir de l'en-tête Content-Type
        content_type = response.headers.get('Content-Type')
        if content_type:
            if 'mpeg' in content_type: extension = '.mp3'
            elif 'ogg' in content_type: extension = '.ogg'
            elif 'wav' in content_type: extension = '.wav'
            elif 'aac' in content_type: extension = '.aac'
            elif 'mp4' in content_type: extension = '.m4a' # Souvent utilisé pour l'audio dans un conteneur mp4

        # Construire le nom de fichier final avec l'extension déterminée
        cached_filename_with_ext = f"{base_filename}{extension}"
        final_cached_filepath = os.path.join(AUDIO_CACHE_DIR, cached_filename_with_ext)

        # Écrire le contenu dans le fichier cache
        with open(final_cached_filepath, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192): # Taille de chunk raisonnable
                f.write(chunk)
        logger.info(f"Téléchargement terminé: {final_cached_filepath}")

        # Mettre à jour la base de données avec le nom du fichier en cache
        podcast.filename_cache = cached_filename_with_ext
        db.session.commit()
        
        audio_url = url_for('serve_cached_audio', filename=cached_filename_with_ext)
        return jsonify({'audio_url': audio_url})

    except requests.exceptions.Timeout:
        logger.error(f"Timeout lors du téléchargement de {podcast.url}")
        # Nettoyer le fichier partiel si le téléchargement a échoué
        if final_cached_filepath and os.path.exists(final_cached_filepath):
            try: os.remove(final_cached_filepath); logger.info(f"Fichier partiel (Timeout) nettoyé : {final_cached_filepath}")
            except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (Timeout) {final_cached_filepath}: {e_clean}")
        return jsonify({'error': 'Le téléchargement du podcast a pris trop de temps.'}), 504
    except requests.exceptions.RequestException as e:
        logger.error(f"Erreur de téléchargement pour {podcast.url}: {e}")
        if final_cached_filepath and os.path.exists(final_cached_filepath):
            try: os.remove(final_cached_filepath); logger.info(f"Fichier partiel (RequestException) nettoyé : {final_cached_filepath}")
            except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (RequestException) {final_cached_filepath}: {e_clean}")
        return jsonify({'error': f'Impossible de télécharger le podcast: {e}'}), 500
    except Exception as e:
        db.session.rollback() # Assurer la cohérence de la session DB
        logger.error(f"Erreur inattendue lors du traitement du podcast {podcast.id}: {e}")
        if final_cached_filepath and os.path.exists(final_cached_filepath):
             try: os.remove(final_cached_filepath); logger.info(f"Fichier partiel (Exception) nettoyé : {final_cached_filepath}")
             except OSError as e_clean: logger.error(f"Erreur nettoyage fichier partiel (Exception) {final_cached_filepath}: {e_clean}")
        return jsonify({'error': f'Erreur inattendue: {e}'}), 500

@app.route('/audio_cache/<path:filename>')
def serve_cached_audio(filename):
    logger.debug(f"Service du fichier cache: {filename} depuis {AUDIO_CACHE_DIR}")
    # Assurez-vous que le chemin est sécurisé et ne permet pas de sortir du répertoire de cache
    # send_from_directory s'en charge généralement.
    return send_from_directory(AUDIO_CACHE_DIR, filename)

if __name__ == '__main__':
    # db.create_all() est maintenant appelé au niveau global du module.
    # La ligne `app.run(...):19:12 =====` dans le log original contenait une syntaxe incorrecte, corrigée ici.
    app.run(debug=True, host='0.0.0.0', port=int(os.environ.get('PORT', 5000)))