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 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.
+Aucune vidéo détectée pour le moment. L'historique se remplira au fur et à mesure des vérifications.
+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 +