From 2e83096550a92db681d30c5729b55a4f78d115cb Mon Sep 17 00:00:00 2001 From: Mow910 Date: Mon, 9 Mar 2026 18:31:27 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20d'un=20historique=20des=20vid=C3=A9os?= =?UTF-8?q?=20YouTube=20dans=20la=20base=20de=20donn=C3=A9es=20et=20l'inte?= =?UTF-8?q?rface=20utilisateur.=20Cr=C3=A9ation=20de=20la=20table=20`youtu?= =?UTF-8?q?be=5Fvideo=5Fhistory`=20pour=20stocker=20les=20vid=C3=A9os=20d?= =?UTF-8?q?=C3=A9tect=C3=A9es,=20avec=20des=20fonctionnalit=C3=A9s=20pour?= =?UTF-8?q?=20afficher=20l'historique=20et=20forcer=20l'envoi=20de=20notif?= =?UTF-8?q?ications=20Discord.=20Mise=20=C3=A0=20jour=20des=20mod=C3=A8les?= =?UTF-8?q?,=20des=20routes=20et=20des=20templates=20pour=20int=C3=A9grer?= =?UTF-8?q?=20cette=20nouvelle=20fonctionnalit=C3=A9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/models.py | 15 ++ database/schema.sql | 15 ++ discordbot/youtube.py | 198 +++++++++++++++++++------- webapp/templates/youtube-history.html | 123 ++++++++++++++++ webapp/templates/youtube.html | 6 +- webapp/youtube.py | 44 +++++- 6 files changed, 348 insertions(+), 53 deletions(-) create mode 100644 webapp/templates/youtube-history.html diff --git a/database/models.py b/database/models.py index 75435a8..7b42a4c 100644 --- a/database/models.py +++ b/database/models.py @@ -195,6 +195,21 @@ class YouTubeNotification(db.Model): embed_image = db.Column(db.Boolean, default=True) +class YouTubeVideoHistory(db.Model): + __tablename__ = 'youtube_video_history' + id = db.Column(db.Integer, primary_key=True) + notification_id = db.Column(db.Integer, db.ForeignKey('youtube_notification.id'), nullable=False) + video_id = db.Column(db.String(128), nullable=False) + title = db.Column(db.String(512)) + url = db.Column(db.String(512)) + channel_name = db.Column(db.String(256)) + thumbnail = db.Column(db.String(512)) + published_at = db.Column(db.String(64)) + is_short = db.Column(db.Boolean, default=False) + notified = db.Column(db.Boolean, default=False) + detected_at = db.Column(db.DateTime, default=datetime.utcnow) + + class FreeLootEntry(db.Model): __tablename__ = 'freeloot_entry' entry_id = db.Column(db.String(256), primary_key=True) diff --git a/database/schema.sql b/database/schema.sql index a9379fb..87a1926 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -178,6 +178,21 @@ CREATE TABLE IF NOT EXISTS `webapp_page_permission` ( description VARCHAR(256) NULL ); +CREATE TABLE IF NOT EXISTS `youtube_video_history` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `notification_id` INTEGER NOT NULL, + `video_id` VARCHAR(128) NOT NULL, + `title` VARCHAR(512), + `url` VARCHAR(512), + `channel_name` VARCHAR(256), + `thumbnail` VARCHAR(512), + `published_at` VARCHAR(64), + `is_short` BOOLEAN NOT NULL DEFAULT FALSE, + `notified` BOOLEAN NOT NULL DEFAULT FALSE, + `detected_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`notification_id`) REFERENCES `youtube_notification`(`id`) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS `freeloot_entry` ( entry_id VARCHAR(256) PRIMARY KEY ); diff --git a/discordbot/youtube.py b/discordbot/youtube.py index aced012..dd161fb 100644 --- a/discordbot/youtube.py +++ b/discordbot/youtube.py @@ -2,6 +2,7 @@ import logging import asyncio import xml.etree.ElementTree as ET import requests +import discord from database import db from database.models import YouTubeNotification @@ -24,14 +25,32 @@ async def checkYouTubeVideos(): await _checkChannelVideos(notification, is_first_check=_youtube_first_check) except Exception as e: logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}") + db.session.rollback() continue - # Après la première vérification complète, on désactive le flag if _youtube_first_check: _youtube_first_check = False logger.info("YouTube: première vérification terminée, notifications activées") except Exception as e: logger.error(f"Erreur lors de la vérification YouTube: {e}") + db.session.rollback() + + +def _extract_embed_config(notification: YouTubeNotification) -> dict: + """Extrait toutes les valeurs ORM nécessaires à l'envoi dans un dict plain Python. + Doit être appelé pendant que le contexte Flask est actif.""" + return { + 'notify_channel': notification.notify_channel, + 'message_template': notification.message or '', + 'embed_title': notification.embed_title, + 'embed_description': notification.embed_description, + 'embed_color': notification.embed_color or 'FF0000', + 'embed_footer': notification.embed_footer, + 'embed_author_name': notification.embed_author_name, + 'embed_author_icon': (notification.embed_author_icon or '').strip(), + 'embed_thumbnail': bool(notification.embed_thumbnail), + 'embed_image': bool(notification.embed_image), + } async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False): @@ -84,64 +103,92 @@ async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: if video_title and ('#shorts' in video_title.lower() or '#short' in video_title.lower()): is_short = True + video_data = { + 'title': video_title, + 'url': video_url, + 'published': published_at, + 'channel_name': channel_name, + 'thumbnail': thumbnail, + 'is_short': is_short + } + if notification.video_type == 'all': - videos.append((video_id, { - 'title': video_title, - 'url': video_url, - 'published': published_at, - 'channel_name': channel_name, - 'thumbnail': thumbnail, - 'is_short': is_short - })) + videos.append((video_id, video_data)) elif notification.video_type == 'short' and is_short: - videos.append((video_id, { - 'title': video_title, - 'url': video_url, - 'published': published_at, - 'channel_name': channel_name, - 'thumbnail': thumbnail, - 'is_short': is_short - })) + videos.append((video_id, video_data)) elif notification.video_type == 'video' and not is_short: - videos.append((video_id, { - 'title': video_title, - 'url': video_url, - 'published': published_at, - 'channel_name': channel_name, - 'thumbnail': thumbnail, - 'is_short': is_short - })) + videos.append((video_id, video_data)) videos.sort(key=lambda x: x[1]['published'], reverse=True) if videos: latest_video_id, latest_video = videos[0] - # Si c'est la première vérification après démarrage, on synchronise sans notifier if is_first_check: if not notification.last_video_id or notification.last_video_id != latest_video_id: logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}") + _save_video_history(notification.id, latest_video_id, latest_video, notified=False) notification.last_video_id = latest_video_id db.session.commit() return - # Vérifications normales ensuite if not notification.last_video_id: + _save_video_history(notification.id, latest_video_id, latest_video, notified=False) notification.last_video_id = latest_video_id db.session.commit() return if latest_video_id != notification.last_video_id: logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {notification.channel_id}") - await _notifyVideo(notification, latest_video, latest_video_id) - notification.last_video_id = latest_video_id - db.session.commit() + embed_config = _extract_embed_config(notification) + success = await _notifyVideo(embed_config, latest_video, latest_video_id) + if success: + _save_video_history(notification.id, latest_video_id, latest_video, notified=True) + notification.last_video_id = latest_video_id + db.session.commit() + else: + _save_video_history(notification.id, latest_video_id, latest_video, notified=False) + notification.last_video_id = latest_video_id + db.session.commit() + logger.warning(f"Notification échouée pour {latest_video_id}, vidéo enregistrée comme non notifiée") except Exception as e: logger.error(f"Erreur lors de la vérification des vidéos: {e}") + db.session.rollback() -async def _notifyVideo(notification: YouTubeNotification, video_data: dict, video_id: str): +def _save_video_history(notification_id: int, video_id: str, video_data: dict, notified: bool): + """Enregistre une vidéo dans l'historique (ne fait rien si déjà présente).""" + from database.models import YouTubeVideoHistory + try: + existing = YouTubeVideoHistory.query.filter_by( + notification_id=notification_id, video_id=video_id + ).first() + if existing: + if notified and not existing.notified: + existing.notified = True + db.session.commit() + return + entry = YouTubeVideoHistory( + notification_id=notification_id, + video_id=video_id, + title=video_data.get('title', 'Sans titre'), + url=video_data.get('url', f"https://www.youtube.com/watch?v={video_id}"), + channel_name=video_data.get('channel_name', 'Inconnu'), + thumbnail=video_data.get('thumbnail'), + published_at=video_data.get('published', ''), + is_short=video_data.get('is_short', False), + notified=notified, + ) + db.session.add(entry) + db.session.commit() + except Exception as e: + logger.error(f"Erreur lors de l'enregistrement de l'historique vidéo: {e}") + db.session.rollback() + + +async def _notifyVideo(embed_config: dict, video_data: dict, video_id: str) -> bool: + """Envoie la notification Discord. Retourne True si l'envoi a réussi.""" from discordbot import bot try: channel_name = video_data.get('channel_name', 'Inconnu') @@ -151,8 +198,9 @@ async def _notifyVideo(notification: YouTubeNotification, video_data: dict, vide published_at = video_data.get('published', '') is_short = video_data.get('is_short', False) + message_template = embed_config.get('message_template', '') try: - message = notification.message.format( + message = message_template.format( channel_name=channel_name or 'Inconnu', video_title=video_title or 'Sans titre', video_url=video_url, @@ -161,15 +209,16 @@ async def _notifyVideo(notification: YouTubeNotification, video_data: dict, vide published_at=published_at or '', is_short=is_short ) - except KeyError as e: - logger.error(f"Variable manquante dans le message de notification: {e}") + except (KeyError, AttributeError, ValueError) as e: + logger.error(f"Erreur de formatage du message: {e}") message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})" logger.info(f"Envoi de notification YouTube: {message}") - bot.loop.create_task(_sendMessage(notification, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short)) + return await _sendMessage(embed_config, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short) except Exception as e: logger.error(f"Erreur lors de la notification: {e}") + return False def _format_embed_text(text: str, channel_name: str, video_title: str, video_url: str, video_id: str, thumbnail: str, published_at: str, is_short: bool) -> str: @@ -190,26 +239,25 @@ def _format_embed_text(text: str, channel_name: str, video_title: str, video_url return text -async def _sendMessage(notification: YouTubeNotification, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool): +async def _sendMessage(embed_config: dict, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool) -> bool: + """Envoie le message Discord. Retourne True si l'envoi a réussi.""" from discordbot import bot try: - discord_channel = bot.get_channel(notification.notify_channel) + discord_channel = bot.get_channel(embed_config['notify_channel']) if not discord_channel: - logger.error(f"Canal Discord {notification.notify_channel} introuvable") - return + logger.error(f"Canal Discord {embed_config['notify_channel']} introuvable") + return False - import discord - - embed_title = _format_embed_text(notification.embed_title, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_title else video_title - embed_description = _format_embed_text(notification.embed_description, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_description else None + embed_title_text = _format_embed_text(embed_config['embed_title'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_title'] else video_title + embed_description = _format_embed_text(embed_config['embed_description'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_description'] else None try: - embed_color = int(notification.embed_color or 'FF0000', 16) + embed_color = int(embed_config['embed_color'], 16) except ValueError: embed_color = 0xFF0000 embed = discord.Embed( - title=embed_title, + title=embed_title_text, url=video_url, color=embed_color ) @@ -217,19 +265,19 @@ async def _sendMessage(notification: YouTubeNotification, message: str, video_ur if embed_description: embed.description = embed_description - author_name = _format_embed_text(notification.embed_author_name, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_author_name else channel_name - author_icon_raw = (notification.embed_author_icon or "").strip() + author_name = _format_embed_text(embed_config['embed_author_name'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_author_name'] else channel_name + author_icon_raw = embed_config['embed_author_icon'] author_icon = author_icon_raw if author_icon_raw.startswith(("http://", "https://")) else "https://www.youtube.com/img/desktop/yt_1200.png" embed.set_author(name=author_name, icon_url=author_icon) - if notification.embed_thumbnail and thumbnail: + if embed_config['embed_thumbnail'] and thumbnail: embed.set_thumbnail(url=thumbnail) - if notification.embed_image and thumbnail: + if embed_config['embed_image'] and thumbnail: embed.set_image(url=thumbnail) - if notification.embed_footer: - footer_text = _format_embed_text(notification.embed_footer, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) + if embed_config['embed_footer']: + footer_text = _format_embed_text(embed_config['embed_footer'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if footer_text: embed.set_footer(text=footer_text) @@ -238,6 +286,54 @@ async def _sendMessage(notification: YouTubeNotification, message: str, video_ur else: await discord_channel.send(embed=embed) logger.info(f"Notification YouTube envoyée avec succès") + return True except Exception as e: logger.error(f"Erreur lors de l'envoi du message Discord: {e}") + return False + + +async def _send_video_notification_async(history_id: int) -> tuple[bool, str]: + """Force l'envoi d'une notification pour une vidéo de l'historique. Retourne (succès, message).""" + from database.models import YouTubeVideoHistory + with webapp.app_context(): + history = YouTubeVideoHistory.query.get(history_id) + if not history: + return (False, "Vidéo introuvable dans l'historique.") + + notification = YouTubeNotification.query.get(history.notification_id) + if not notification: + return (False, "Notification YouTube associée introuvable.") + + embed_config = _extract_embed_config(notification) + video_data = { + 'title': history.title or 'Sans titre', + 'url': history.url or f"https://www.youtube.com/watch?v={history.video_id}", + 'channel_name': history.channel_name or 'Inconnu', + 'thumbnail': history.thumbnail or '', + 'published': history.published_at or '', + 'is_short': history.is_short, + } + + success = await _notifyVideo(embed_config, video_data, history.video_id) + if success: + history.notified = True + db.session.commit() + return (True, "Notification envoyée sur Discord.") + else: + db.session.rollback() + return (False, "Échec de l'envoi sur Discord.") + + +def send_video_notification_sync(history_id: int) -> tuple[bool, str]: + """Appel synchrone pour forcer une notification (depuis la webapp).""" + from discordbot import bot + try: + future = asyncio.run_coroutine_threadsafe( + _send_video_notification_async(history_id), + bot.loop, + ) + return future.result(timeout=15) + except Exception as e: + logger.error(f"send_video_notification_sync: {e}") + return (False, str(e)) diff --git a/webapp/templates/youtube-history.html b/webapp/templates/youtube-history.html new file mode 100644 index 0000000..eb6e79f --- /dev/null +++ b/webapp/templates/youtube-history.html @@ -0,0 +1,123 @@ +{% extends "template.html" %} + +{% block content %} +
+
+

Historique des vidéos YouTube

+ + + Retour + +
+ + {% if msg %} +
+ {{ msg }} +
+ + {% endif %} + +
+

+ Historique des vidéos détectées par le bot. Les vidéos non notifiées peuvent être envoyées manuellement sur Discord. +

+

{{ total }} vidéo{{ 's' if total > 1 else '' }} au total.

+
+
+ +{% if history %} +
+ {% for entry in history %} +
+
+ {% if entry.thumbnail %} + + + + {% endif %} +
+
+
+ + {{ entry.title or 'Sans titre' }} + +
+ {% if entry.is_short %} + Short + {% endif %} + {% if entry.notified %} + + + Notifié + + {% else %} + + + Non notifié + + {% endif %} +
+
+
+ {{ entry.channel_name or 'Inconnu' }} + {% if entry.published_at %} + {{ entry.published_at[:10] }} + {% endif %} + {% if notification_map.get(entry.notification_id) %} + {{ notification_map[entry.notification_id].channel_id }} + {% endif %} +
+
+
+ + Voir sur YouTube + +
+ +
+
+
+
+
+ {% endfor %} +
+ +{% if total_pages > 1 %} +
+ {% if page > 1 %} + + Précédent + + {% endif %} + + Page {{ page }} / {{ total_pages }} + + {% if page < total_pages %} + + Suivant + + {% endif %} +
+{% endif %} + +{% else %} +
+ +

Aucune vidéo détectée pour le moment. L'historique se remplira au fur et à mesure des vérifications.

+
+{% endif %} +{% endblock %} diff --git a/webapp/templates/youtube.html b/webapp/templates/youtube.html index b99ae14..e61d1e4 100644 --- a/webapp/templates/youtube.html +++ b/webapp/templates/youtube.html @@ -16,12 +16,16 @@ {% endif %} -
+

Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos. Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous. Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord.

+ + + Historique +
diff --git a/webapp/youtube.py b/webapp/youtube.py index 2d5f8cb..0ed16ab 100644 --- a/webapp/youtube.py +++ b/webapp/youtube.py @@ -5,7 +5,7 @@ from flask import render_template, request, redirect, url_for from webapp import webapp from webapp.auth import require_page, can_write_page from database import db -from database.models import YouTubeNotification +from database.models import YouTubeNotification, YouTubeVideoHistory from discordbot import bot @@ -190,6 +190,48 @@ def delYouTube(id): if not can_write_page("youtube"): return render_template("403.html"), 403 notification = YouTubeNotification.query.get_or_404(id) + YouTubeVideoHistory.query.filter_by(notification_id=id).delete() db.session.delete(notification) db.session.commit() return redirect(url_for("openYouTube")) + + +@webapp.route("/youtube/history") +@require_page("youtube") +def youtubeHistory(): + page = request.args.get('page', 1, type=int) + per_page = 20 + history_query = YouTubeVideoHistory.query.order_by(YouTubeVideoHistory.detected_at.desc()) + total = history_query.count() + history = history_query.offset((page - 1) * per_page).limit(per_page).all() + total_pages = (total + per_page - 1) // per_page + + notification_map = {} + for entry in history: + if entry.notification_id not in notification_map: + notif = YouTubeNotification.query.get(entry.notification_id) + notification_map[entry.notification_id] = notif + + msg = request.args.get('msg') + msg_type = request.args.get('type', 'info') + return render_template( + "youtube-history.html", + history=history, + notification_map=notification_map, + page=page, + total_pages=total_pages, + total=total, + msg=msg, + msg_type=msg_type, + ) + + +@webapp.route("/youtube/notify/", methods=['POST']) +@require_page("youtube") +def forceYouTubeNotify(history_id): + if not can_write_page("youtube"): + return render_template("403.html"), 403 + from discordbot.youtube import send_video_notification_sync + success, message = send_video_notification_sync(history_id) + msg_type = 'success' if success else 'error' + return redirect(url_for("youtubeHistory") + "?" + urlencode({'msg': message, 'type': msg_type}))