diff --git a/Cargo.lock b/Cargo.lock index 95379363..6c65cc47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -768,6 +768,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.20.11" @@ -3398,11 +3419,13 @@ dependencies = [ "apalis", "apalis-redis", "async-openai", + "async-trait", "axum", "backon", "chrono", "clap", "convert_case", + "csv", "deadpool-redis 0.22.1", "derive_more 2.1.1", "dotenv", diff --git a/Cargo.toml b/Cargo.toml index 42c415ff..9bd50dc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ uuid = "1.20.0" whatlang = { version = "0.18.0", features = ["serde"] } redis = "0.32.7" prometheus = { version = "0.14.0", features = ["push", "process"] } +csv = "1.4.0" +async-trait = "0.1.89" [dependencies.isolang] features = ["serde"] diff --git a/README.md b/README.md index 46b4d20a..3b642806 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ > **📢 Subscribe to the author's telegram channel for updates and more projects:** [**@vtvz_dev**](https://t.me/vtvz_dev) -> A Telegram bot that monitors your Spotify playback, detects profane lyrics, integrates with AI for text analysis, and automatically skips tracks you've disliked +> A Telegram bot that monitors your Spotify playback, detects profane lyrics and AI-generated music, integrates with AI for text analysis, and automatically skips tracks you've disliked [![Rust](https://img.shields.io/badge/rust-nightly-orange.svg)](https://www.rust-lang.org/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -23,13 +23,14 @@ --- -Rustify is an intelligent Telegram bot that integrates with Spotify to provide real-time profanity detection and track management. It continuously monitors what you're listening to, analyzes lyrics for inappropriate content, and automatically skips tracks you've marked with the dislike button. +Rustify is an intelligent Telegram bot that integrates with Spotify to provide real-time profanity detection, AI-generated music detection, and track management. It continuously monitors what you're listening to, analyzes lyrics for inappropriate content, detects AI-generated tracks, and automatically skips tracks you've marked with the dislike button. ## ✨ Features ### 🎯 Core Features - **🔍 Real-time Profanity Detection** - Automatically analyzes song lyrics as you listen using advanced profanity detection algorithms +- **🤖 AI-Generated Music Detection** - Identifies AI-generated tracks using multiple detection providers and shows notifications with attribution - **⏭️ Auto-Skip** - Instantly skips tracks you've marked with dislike - **📊 Multi-Provider Lyrics** - Fetches lyrics from multiple sources (Musixmatch, Genius, LrcLib) for maximum coverage - **🤖 AI-Powered Analysis** - Optional OpenAI-compatible API integration for analyzing song lyrics meaning, storyline, and content themes, plus individual word analysis diff --git a/locales/actions.yml b/locales/actions.yml index f2e27489..a1e533ed 100644 --- a/locales/actions.yml +++ b/locales/actions.yml @@ -52,13 +52,13 @@ actions.dislike: en: |- 👎 Disliked %{track_link} - If you change your mind, press 'Ignore text 🙈' + If you change your mind, press '%{ignore_button_label}' 💡 Do not forget you can send a link to a song to find current status ru: |- 👎 Дизлайкнул %{track_link} - Если передумаете, нажмите 'Игнорировать текст 🙈' + Если передумаете, нажмите '%{ignore_button_label}' 💡 Не забывайте, что можете отправить ссылку на песню, чтобы узнать текущий статус @@ -66,12 +66,12 @@ actions.ignore: en: |- 🙈 Bad words of %{track_link} will be forever ignored - If you change your mind, press 'Dislike 👎' + If you change your mind, press '%{dislike_button_label}' 💡 Do not forget you can send a link to a song to find current status ru: |- 🙈 Плохие слова из %{track_link} будут навсегда проигнорированы - Если передумаете, нажмите 'Не нравится 👎' + Если передумаете, нажмите '%{dislike_button_label}' 💡 Не забывайте, что можете отправить ссылку на песню, чтобы узнать текущий статус diff --git a/locales/ai_slop.yml b/locales/ai_slop.yml new file mode 100644 index 00000000..2e9ff2c4 --- /dev/null +++ b/locales/ai_slop.yml @@ -0,0 +1,75 @@ +_version: 2 + +ai-slop.alert: + en: |- + 🎵 %{track_name} + Album: %{album_name} + + 💩 This track is most likely %{prediction}. You can block track or ignore similar notifications for this track + + To permanently block this artist from recommendations: + 🔗 Open the artist's page in Spotify + Tap the three dots near the "Follow" button + Select "Don't play this artist" + + Information provided by "%{ai_check_provider}" + + Support real organic hand-made music instead of AI-slop! + +
Want to disable these alerts or auto-skip AI tracks? Try /%{config_command}
+ ru: |- + 🎵 %{track_name} + Альбом: %{album_name} + + 💩 Этот трек скорее всего %{prediction}. Вы можете заблокировать трек или отключить подобные уведомления для этого трека + + Чтобы навсегда скрыть этого исполнителя из рекомендаций: + 🔗 Откройте страницу исполнителя в Spotify + Нажмите на три точки рядом с кнопкой «Подписаться» + Выберите «Не включать треки этого исполнителя» + + Информация предоставлена "%{ai_check_provider}" + + Поддерживайте настоящую живую музыку вместо ИИ-шлака! + +
Хотите отключить уведомления или настроить автопропуск AI-треков? Попробуйте /%{config_command}
+ +ai-slop.alert-pure-ai: + en: fully AI-generated + ru: полностью сгенерирован ИИ + +ai-slop.alert-processed-ai: + en: partially generated or processed by AI + ru: частично сгенерирован или обработан ИИ + +ai-slop.setting-description: + en: |- + 💩 AI Music Detection + + Bot automatically checks tracks for AI generation + + Configure bot's reaction to such tracks: + ru: |- + 💩 Обнаружение ИИ-музыки + + Бот автоматически проверяет треки на ИИ-генерацию + + Настройте реакцию бота на такие треки: + +ai-slop.button-notify: + en: |- + %{mark}Notify + ru: |- + %{mark}Уведомлять + +ai-slop.button-ignore: + en: |- + %{mark}Don't notify + ru: |- + %{mark}Не уведомлять + +ai-slop.button-skip: + en: |- + %{mark}Skip automatically + ru: |- + %{mark}Пропускать автоматически diff --git a/locales/inline_buttons.yml b/locales/inline_buttons.yml index b41193ce..a0d1aba6 100644 --- a/locales/inline_buttons.yml +++ b/locales/inline_buttons.yml @@ -2,15 +2,15 @@ _version: 2 inline-buttons.dislike: en: |- - Dislike 👎 + Block track 👎 ru: |- - Не нравится 👎 + Заблокировать трек 👎 inline-buttons.ignore: en: |- - Ignore text 🙈 + Hide alerts for this track 🙈 ru: |- - Игнорировать текст 🙈 + Скрыть уведомления для этого трека 🙈 inline-buttons.analyze: en: |- @@ -18,6 +18,12 @@ inline-buttons.analyze: ru: |- Анализировать текст 🔍 +inline-buttons.artist-page: + en: |- + Go to artist's page 🔗 + ru: |- + Перейти на страницу исполнителя 🔗 + inline-buttons.alert-login: en: |- You need to login first diff --git a/locales/login.yml b/locales/login.yml index cf0ea05b..13755b10 100644 --- a/locales/login.yml +++ b/locales/login.yml @@ -33,6 +33,7 @@ login.invite: Features: • Block unwanted songs ("Dislike") • Notifications for profanity in lyrics (English only) + • Detect AI-generated music (notify/ignore/auto-skip) • Get song lyrics and AI-powered analysis • Create Magic Playlist™✨ from your favorite tracks • Skippage™ function will diverse your listening experience @@ -48,6 +49,7 @@ login.invite: Возможности: • Блокировка нежелательных песен ("Дизлайк") • Уведомление на нецензурную лексику в тексте (только английский) + • Обнаружение ИИ-музыки (уведомление/игнорирование/авто-пропуск) • Получение текстов песен и их анализ с помощью ИИ • Создание Magic Playlist™✨ из любимых треков • Функция Skippage™ разнообразит ваши впечатления от прослушивания diff --git a/locales/profanity_check.yml b/locales/profanity_check.yml index 8e260147..ad118654 100644 --- a/locales/profanity_check.yml +++ b/locales/profanity_check.yml @@ -8,7 +8,7 @@ profanity-check.message: %{lyrics_link_text} - Press 'Ignore text 🙈' to never see this notification for this song again + Press '%{ignore_button_label}' to never see this notification for this song again ru: |- 🚨 Текущая песня (%{track_name}) вероятно содержит нецензурные слова: @@ -16,4 +16,4 @@ profanity-check.message: %{lyrics_link_text} - Нажмите 'Игнорировать текст 🙈', чтобы больше не видеть это уведомление для этой песни + Нажмите '%{ignore_button_label}', чтобы больше не видеть это уведомление для этой песни diff --git a/migrations/20260302172253_add_user_cfg_ai_slop_detection_field.sql b/migrations/20260302172253_add_user_cfg_ai_slop_detection_field.sql new file mode 100644 index 00000000..c93af464 --- /dev/null +++ b/migrations/20260302172253_add_user_cfg_ai_slop_detection_field.sql @@ -0,0 +1,2 @@ +alter table "user" + add cfg_ai_slop_detection text default 'notify' not null; diff --git a/src/app.rs b/src/app.rs index c6c9bb50..ea166d97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use teloxide::requests::RequesterExt as _; use crate::metrics::influx::InfluxClient; use crate::metrics::prometheus::PrometheusClient; use crate::queue::QueueManager; -use crate::services::{SongLinkService, UserService}; +use crate::services::{AISlopDetectionService, SongLinkService, UserService}; use crate::user::UserState; use crate::{lyrics, profanity, spotify}; @@ -32,6 +32,7 @@ pub struct App { dialogue_storage: Arc>, server_http_address: String, song_link: SongLinkService, + ai_slop_detection: AISlopDetectionService, queue_manager: QueueManager, } @@ -67,6 +68,8 @@ struct EnvConfig { genius_service_url: String, lyrics_cache_ttl: Option, + shlabs_api_key: Option, + censor_blacklist: Option, censor_whitelist: Option, @@ -137,6 +140,10 @@ impl App { pub fn queue_manager(&self) -> &QueueManager { &self.queue_manager } + + pub fn ai_slop_detection(&self) -> &AISlopDetectionService { + &self.ai_slop_detection + } } fn init_influx(env: &EnvConfig) -> anyhow::Result> { @@ -338,6 +345,8 @@ impl App { let queue_manager = QueueManager::new(redis_url).await?; + let shlabs_api_key = env.shlabs_api_key.filter(|key| !key.trim().is_empty()); + // Make global static variable to prevent hassle with Arc let app = Box::new(Self { bot, @@ -354,6 +363,7 @@ impl App { .server_http_address .unwrap_or_else(|| "0.0.0.0:3000".into()), queue_manager, + ai_slop_detection: AISlopDetectionService::new(shlabs_api_key), }); let app = &*Box::leak(app); diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs index 66621bc3..c688d4d0 100644 --- a/src/entity/prelude.rs +++ b/src/entity/prelude.rs @@ -19,6 +19,7 @@ pub use super::track_status::{ }; #[allow(unused_imports)] pub use super::user::{ + AISlopDetection as UserAISlopDetection, ActiveModel as UserActiveModel, Column as UserColumn, Entity as UserEntity, diff --git a/src/entity/user.rs b/src/entity/user.rs index d0b434fb..d05485df 100644 --- a/src/entity/user.rs +++ b/src/entity/user.rs @@ -38,6 +38,8 @@ pub struct Model { pub cfg_skip_tracks: bool, pub cfg_skippage_secs: i64, pub cfg_skippage_enabled: bool, + #[sea_orm(enum_name = "CfgAISlopDetection")] + pub cfg_ai_slop_detection: AISlopDetection, pub magic_playlist: Option, pub spotify_state: Uuid, pub ref_code: Option, @@ -84,6 +86,8 @@ pub enum Column { CfgSkipTracks, CfgSkippageSecs, CfgSkippageEnabled, + #[sea_orm(column_name = "cfg_ai_slop_detection")] + CfgAISlopDetection, MagicPlaylist, SpotifyState, RefCode, @@ -126,6 +130,7 @@ impl ColumnTrait for Column { Self::CfgSkipTracks => ColumnType::Boolean.def(), Self::CfgSkippageSecs => ColumnType::BigInteger.def(), Self::CfgSkippageEnabled => ColumnType::Boolean.def(), + Self::CfgAISlopDetection => AISlopDetection::db_type(), Self::MagicPlaylist => ColumnType::Text.def().null(), Self::SpotifyState => ColumnType::Uuid.def(), Self::RefCode => ColumnType::Text.def().null(), @@ -302,3 +307,50 @@ impl Role { matches!(self, Self::Admin) } } + +#[derive( + Debug, Copy, Clone, EnumIter, DeriveActiveEnum, PartialEq, Eq, Default, Serialize, Deserialize, +)] +#[sea_orm(rs_type = "String", db_type = "Text")] +pub enum AISlopDetection { + #[sea_orm(string_value = "skip")] + Skip, + #[sea_orm(string_value = "notify")] + #[default] + Notify, + #[sea_orm(string_value = "ignore")] + Ignore, +} + +impl FromStr for AISlopDetection { + type Err = sea_orm::DbErr; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +impl TryFrom<&str> for AISlopDetection { + type Error = sea_orm::DbErr; + + fn try_from(value: &str) -> Result { + Self::try_from_value(&value.to_owned()) + } +} + +impl AISlopDetection { + #[must_use] + pub fn is_skip(&self) -> bool { + matches!(self, Self::Skip) + } + + #[must_use] + pub fn is_notify(&self) -> bool { + matches!(self, Self::Notify) + } + + #[must_use] + pub fn is_ignore(&self) -> bool { + matches!(self, Self::Ignore) + } +} diff --git a/src/lyrics/mod.rs b/src/lyrics/mod.rs index 243bcc88..5a33b3d6 100644 --- a/src/lyrics/mod.rs +++ b/src/lyrics/mod.rs @@ -3,7 +3,6 @@ use genius::GeniusLocal; use isolang::Language; use lrclib::LrcLib; use musixmatch::Musixmatch; -use serde::Serialize; use strum_macros::Display; use crate::spotify::ShortTrack; diff --git a/src/queue/mod.rs b/src/queue/mod.rs index 0991c961..3aea17cc 100644 --- a/src/queue/mod.rs +++ b/src/queue/mod.rs @@ -5,21 +5,21 @@ use apalis_redis::shared::SharedRedisStorage; use apalis_redis::{RedisConfig, RedisStorage}; use redis::aio::MultiplexedConnection; -pub mod profanity_check; +pub mod track_check; pub struct QueueManager { #[allow(dead_code)] storage: SharedRedisStorage, - profanity_queue: RedisStorage, + track_check_queue: RedisStorage, } impl QueueManager { #[must_use] - pub fn profanity_queue( + pub fn track_check_queue( &self, - ) -> RedisStorage { - self.profanity_queue.clone() + ) -> RedisStorage { + self.track_check_queue.clone() } pub async fn new(redis_url: &str) -> anyhow::Result { @@ -29,13 +29,12 @@ impl QueueManager { let mut storage = SharedRedisStorage::new(client).await?; - let profanity_queue = storage.make_shared_with_config( - RedisConfig::default().set_namespace("rustify:profanity_check"), - )?; + let track_check_queue = storage + .make_shared_with_config(RedisConfig::default().set_namespace("rustify:track_check"))?; Ok(Self { storage, - profanity_queue, + track_check_queue, }) } } diff --git a/src/queue/profanity_check.rs b/src/queue/track_check.rs similarity index 59% rename from src/queue/profanity_check.rs rename to src/queue/track_check.rs index 0b811209..33fb9117 100644 --- a/src/queue/profanity_check.rs +++ b/src/queue/track_check.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; use apalis::prelude::{Data, TaskSink as _}; use isolang::Language; use itertools::Itertools as _; +use rspotify::prelude::OAuthClient as _; use rustrict::Type; use teloxide::prelude::*; use teloxide::types::{InlineKeyboardMarkup, ReplyMarkup}; @@ -10,20 +11,24 @@ use crate::app::App; use crate::infrastructure::error_handler; use crate::lyrics::SearchResult as _; use crate::services::{ + AISlopDetectionPrediction, TrackLanguageStatsService, + TrackStatusService, UserService, UserWordWhitelistService, WordStatsService, }; use crate::spotify::ShortTrack; +use crate::telegram::commands::UserCommandDisplay; use crate::telegram::inline_buttons::InlineButtons; +use crate::telegram::inline_buttons_actions::InlineButtonsActions; use crate::telegram::utils::link_preview_small_top; use crate::user::UserState; use crate::utils::StringUtils as _; use crate::{lyrics, profanity, telegram}; #[derive(Clone, Serialize, Deserialize)] -pub struct ProfanityCheckQueueTask { +pub struct TrackCheckQueueTask { track: ShortTrack, user_id: String, } @@ -38,8 +43,8 @@ pub struct ProfanityCheckQueueTask { )] pub async fn queue(app: &App, user_id: &str, track: &ShortTrack) -> anyhow::Result<()> { app.queue_manager() - .profanity_queue() - .push(ProfanityCheckQueueTask { + .track_check_queue() + .push(TrackCheckQueueTask { track: track.clone(), user_id: user_id.into(), }) @@ -49,7 +54,7 @@ pub async fn queue(app: &App, user_id: &str, track: &ShortTrack) -> anyhow::Resu } #[tracing::instrument(skip_all, fields(user_id = %data.user_id, track_id = %data.track.id()))] -pub async fn consume(data: ProfanityCheckQueueTask, app: Data<&'static App>) -> anyhow::Result<()> { +pub async fn consume(data: TrackCheckQueueTask, app: Data<&'static App>) -> anyhow::Result<()> { let app = *app; let user_state = app.user_state(&data.user_id).await; @@ -64,7 +69,22 @@ pub async fn consume(data: ProfanityCheckQueueTask, app: Data<&'static App>) -> }; let err_wrap = || async { - let res = check(app, &user_state, &data.track) + let res = check_ai_slop(app, &user_state, &data.track) + .await + .context("Check AI Slop")?; + + if res.skipped { + if let Err(err) = + TrackStatusService::increase_skips(app.db(), user_state.user_id(), data.track.id()) + .await + { + tracing::error!(err = ?err, "Error occurred on increasing skipping stats"); + } + + return Ok(()); + } + + let res = check_profanity(app, &user_state, &data.track) .await .context("Check lyrics failed")?; @@ -104,7 +124,7 @@ pub struct CheckBadWordsResult { track_name = %track.name_with_artists(), ) )] -pub async fn check( +pub async fn check_profanity( app: &'static App, state: &UserState, track: &ShortTrack, @@ -184,6 +204,7 @@ pub async fn check( bad_lines = bad_lines.iter().take(lines).join("\n"), lyrics_link = hit.link().trim(), lyrics_link_text = hit.link_text(lines == bad_lines.len()), + ignore_button_label = t!("inline-buttons.ignore", locale = state.locale()), ); if message.chars_len() <= telegram::MESSAGE_MAX_LEN { @@ -222,3 +243,103 @@ pub async fn check( }, } } + +#[derive(Default)] +pub struct AISlopCheckResult { + pub is_ai_slop: bool, + pub skipped: bool, + // pub provider: Option, +} + +#[tracing::instrument( + skip_all, + fields( + track_id = %track.id(), + track_name = %track.name_with_artists(), + ) +)] +pub async fn check_ai_slop( + app: &'static App, + state: &UserState, + track: &ShortTrack, +) -> anyhow::Result { + if state.user().cfg_ai_slop_detection.is_ignore() { + return Ok(AISlopCheckResult { + is_ai_slop: false, + skipped: false, + }); + } + + let ai_detection_result = app + .ai_slop_detection() + .is_track_ai(&mut app.redis_conn().await?, track) + .await?; + + if !ai_detection_result.prediction.is_track_ai() { + return Ok(AISlopCheckResult { + is_ai_slop: false, + skipped: false, + }); + } + + if state.user().cfg_ai_slop_detection.is_skip() && state.is_spotify_premium().await? { + state + .spotify() + .await + .next_track(None) + .await + .context("Skip current track")?; + + return Ok(AISlopCheckResult { + is_ai_slop: true, + skipped: true, + }); + } + + // NOTE: Still notify user about AI-slop when unable to skip + + let Some(provider) = ai_detection_result.provider else { + anyhow::bail!("Provider should be set on positive result"); + }; + + let keyboard = vec![ + vec![InlineButtons::Dislike(track.id().into()).into_inline_keyboard_button(state.locale())], + vec![InlineButtons::Ignore(track.id().into()).into_inline_keyboard_button(state.locale())], + vec![ + InlineButtonsActions::ArtistPage(track.first_artist_url().parse()?) + .into_inline_keyboard_button(state.locale()), + ], + ]; + + let prediction = match ai_detection_result.prediction { + AISlopDetectionPrediction::HumanMade => "unreachable".into(), + AISlopDetectionPrediction::PureAI => t!("ai-slop.alert-pure-ai", locale = state.locale()), + AISlopDetectionPrediction::ProcessedAI => { + t!("ai-slop.alert-processed-ai", locale = state.locale()) + }, + }; + + app.bot() + .send_message( + state.chat_id()?, + t!( + "ai-slop.alert", + locale = state.locale(), + track_name = track.track_tg_link(), + album_name = track.album_tg_link(), + ai_check_provider = provider.tg_link(), + config_command = UserCommandDisplay::AISlopDetection, + prediction = prediction, + ), + ) + .link_preview_options(link_preview_small_top(track.url())) + .reply_markup(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup::new( + keyboard, + ))) + .await?; + + Ok(AISlopCheckResult { + is_ai_slop: true, + skipped: false, + }) +} diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs new file mode 100644 index 00000000..22f3e26e --- /dev/null +++ b/src/services/ai_slop_detection/mod.rs @@ -0,0 +1,161 @@ +mod shlabs; +mod soul_over_ai; +mod spotify_ai_blocker; + +use async_trait::async_trait; +use chrono::NaiveDate; +use soul_over_ai::SoulOverAIProvider; +use spotify_ai_blocker::SpotifyAIBlockerProvider; + +use crate::spotify::ShortTrack; + +pub struct AISlopDetectionService { + spotify_ai_blocker: SpotifyAIBlockerProvider, + soul_over_ai: SoulOverAIProvider, + shlabs: Option, +} + +pub enum Provider { + SpotifyAIBlocker, + SoulOverAI, + SHLabs, +} + +#[derive(Default)] +pub struct AISlopDetectionResult { + pub provider: Option, + pub prediction: AISlopDetectionPrediction, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum AISlopDetectionPrediction { + #[default] + HumanMade, + PureAI, + ProcessedAI, +} + +impl AISlopDetectionPrediction { + #[must_use] + pub fn is_track_ai(self) -> bool { + self != Self::HumanMade + } +} + +#[async_trait] +pub trait AISlopDetector { + async fn detect( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result; +} + +impl Provider { + pub fn tg_link(&self) -> String { + teloxide::utils::html::link(self.link(), self.name()) + } + + pub fn link(&self) -> &str { + match self { + Self::SpotifyAIBlocker => "https://github.com/CennoxX/spotify-ai-blocker", + Self::SoulOverAI => "https://github.com/xoundbyte/soul-over-ai", + Self::SHLabs => "https://www.submithub.com/ai-song-checker", + } + } + + pub fn name(&self) -> &str { + match self { + Self::SpotifyAIBlocker => "Spotify AI Music Blocker", + Self::SoulOverAI => "Soul Over AI", + Self::SHLabs => "SubmitHub AI Song Checker", + } + } +} + +impl AISlopDetectionService { + #[must_use] + pub fn new(shlabs_api_key: Option) -> Self { + Self { + spotify_ai_blocker: SpotifyAIBlockerProvider::new(), + soul_over_ai: SoulOverAIProvider::new(), + shlabs: shlabs_api_key.map(shlabs::SHLabsProvider::new), + } + } + + fn is_before_ai_era(date: Option) -> bool { + if date.is_none() { + return false; + } + + date < NaiveDate::from_ymd_opt(2024, 1, 1) + } + + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] + pub async fn is_track_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + macro_rules! handle_provider { + ($provider_enum:expr, $provider:expr) => { + let result = AISlopDetector::detect($provider, redis_conn, track).await; + + match result { + Ok(prediction) => { + if prediction.is_track_ai() { + return Ok(AISlopDetectionResult { + provider: Some($provider_enum), + prediction, + }); + } + }, + Err(err) => { + tracing::error!( + err = ?err, + "Error with {} occurred", + $provider_enum.name() + ); + }, + }; + }; + } + + if Self::is_before_ai_era(track.album_release_date()) { + return Ok(AISlopDetectionResult::default()); + } + + handle_provider!(Provider::SoulOverAI, &self.soul_over_ai); + handle_provider!(Provider::SpotifyAIBlocker, &self.spotify_ai_blocker); + + if let Some(shlabs) = &self.shlabs { + handle_provider!(Provider::SHLabs, shlabs); + } + + Ok(AISlopDetectionResult::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_before_ai_era() { + assert!(AISlopDetectionService::is_before_ai_era( + NaiveDate::from_ymd_opt(2023, 1, 1) + )); + } + + #[test] + fn test_after_ai_era() { + assert!(!AISlopDetectionService::is_before_ai_era( + NaiveDate::from_ymd_opt(2024, 1, 1) + )); + } + + #[test] + fn test_none_after_ai_era() { + assert!(!AISlopDetectionService::is_before_ai_era(None)); + } +} diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs new file mode 100644 index 00000000..3769e816 --- /dev/null +++ b/src/services/ai_slop_detection/shlabs.rs @@ -0,0 +1,170 @@ +use async_trait::async_trait; +use chrono::{Duration, Timelike as _, Utc}; +use redis::AsyncTypedCommands as _; +use serde_json::json; + +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; +use crate::spotify::ShortTrack; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Root { + pub result: Result, + pub response_time: i64, + pub usage: Usage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Prediction { + #[serde(rename = "Human Made")] + HumanMade, + #[serde(rename = "Pure AI")] + PureAI, + #[serde(rename = "Pure AI Generated")] + PureAIGenerated, + #[serde(rename = "Processed AI")] + ProcessedAI, + #[serde(rename = "Processed AI Generated")] + ProcessedAIGenerated, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Result { + pub duration: f64, + pub probability_ai_generated: f64, + pub prediction: Prediction, + pub confidence_score: Option, + pub spectral_probabilities: Probabilities, + pub temporal_probabilities: Probabilities, + pub most_likely_ai_type: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Probabilities { + pub human: f64, + pub processed_ai: f64, + pub pure_ai: f64, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct Usage { + pub daily_remaining: i64, + pub monthly_remaining: i64, +} + +const REDIS_KEY_TRACK_PREFIX: &str = "rustify:ai_slop:shlabs:track"; +const REDIS_KEY_RATE_LIMITED: &str = "rustify:ai_slop:shlabs:rate_limited"; + +pub struct SHLabsProvider { + client: reqwest::Client, + api_key: String, +} + +impl SHLabsProvider { + #[must_use] + pub fn new(api_key: String) -> Self { + Self { + api_key, + client: reqwest::Client::builder() + .timeout( + Duration::seconds(120) + .to_std() + .expect("It's positive. Will work"), + ) + .build() + .expect("Should work"), + } + } + + async fn rate_limit(redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { + const SECONDS_HOUR: u64 = Duration::hours(1).num_seconds() as u64; + + let now = Utc::now(); + let seconds_until_midnight = (Duration::days(1) + - Duration::seconds(i64::from(now.num_seconds_from_midnight()))) + .num_seconds() + .max(1) as u64; + + let seconds_pause = seconds_until_midnight.min(SECONDS_HOUR); + + tracing::warn!(seconds_pause, "SHLabs rate limited, pausing"); + + let _: () = redis_conn + .set_ex(REDIS_KEY_RATE_LIMITED, 1, seconds_pause) + .await?; + + Ok(()) + } + + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] + async fn fetch( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result> { + let track_key = format!("{REDIS_KEY_TRACK_PREFIX}:{}", track.id()); + + if let Some(data) = redis_conn.get(&track_key).await? + && let Ok(data) = serde_json::from_str(&data) + { + return Ok(Some(data)); + } + + if redis_conn.exists(REDIS_KEY_RATE_LIMITED).await? { + return Ok(None); + } + + let response = self + .client + .post("https://shlabs.music/api/v1/detect") + .header("X-API-Key", &self.api_key) + .json(&json!({ + "spotifyTrackId": track.id(), + })) + .send() + .await?; + + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + Self::rate_limit(redis_conn).await.ok(); + + return Ok(None); + } + + let res: Root = response.error_for_status()?.json().await?; + + let _: () = redis_conn + .set_ex( + &track_key, + serde_json::to_string(&res)?, + Duration::days(365).num_seconds() as _, + ) + .await?; + + if res.usage.daily_remaining == 0 { + Self::rate_limit(redis_conn).await.ok(); + } + + Ok(Some(res)) + } +} + +#[async_trait] +impl AISlopDetector for SHLabsProvider { + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] + async fn detect( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + let Some(res) = self.fetch(redis_conn, track).await? else { + return Ok(AISlopDetectionPrediction::default()); + }; + + Ok(match res.result.prediction { + Prediction::HumanMade => AISlopDetectionPrediction::HumanMade, + Prediction::PureAI | Prediction::PureAIGenerated => AISlopDetectionPrediction::PureAI, + Prediction::ProcessedAI | Prediction::ProcessedAIGenerated => { + AISlopDetectionPrediction::ProcessedAI + }, + }) + } +} diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs new file mode 100644 index 00000000..985cebd7 --- /dev/null +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -0,0 +1,192 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use async_trait::async_trait; +use chrono::Duration; +use redis::AsyncCommands as _; + +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; +use crate::spotify::ShortTrack; + +pub struct SoulOverAIProvider { + client: reqwest::Client, + populating: AtomicBool, +} + +#[derive(Debug, Deserialize)] +struct AIArtist { + // name: String, + spotify: Option, +} + +const REDIS_KEY_POPULATED: &str = "rustify:ai_slop:soul_over_ai:populated"; +const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:soul_over_ai:artist"; + +const RETRY_DELAY: Duration = Duration::milliseconds(100); +const POPULATE_TIMEOUT: Duration = Duration::seconds(20); + +impl SoulOverAIProvider { + #[must_use] + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .timeout( + Duration::seconds(10) + .to_std() + .expect("It's positive. Will work"), + ) + .build() + .expect("Should work"), + populating: AtomicBool::new(false), + } + } + + #[tracing::instrument(skip_all)] + async fn ensure_populated( + &self, + redis_conn: &mut deadpool_redis::Connection, + ) -> anyhow::Result<()> { + if redis_conn.exists(REDIS_KEY_POPULATED).await? { + return Ok(()); + } + + if self + .populating + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let result = self.populate(redis_conn).await; + + let result = if result.is_ok() { + // Expire 10 minutes before all entries to overlap a bit and have room for errors + let expiry_seconds = + (Duration::days(1) - Duration::minutes(10)).num_seconds() as u64; + + redis_conn + .set_ex(REDIS_KEY_POPULATED, 1, expiry_seconds) + .await + .map_err(Into::into) + } else { + result + }; + + self.populating.store(false, Ordering::SeqCst); + + return result; + } + + let deadline = Instant::now() + POPULATE_TIMEOUT.to_std().expect("to be positive"); + + while Instant::now() < deadline { + tokio::time::sleep(RETRY_DELAY.to_std().expect("positive duration")).await; + + if !self.populating.load(Ordering::SeqCst) { + if redis_conn.exists(REDIS_KEY_POPULATED).await? { + return Ok(()); + } + + anyhow::bail!("Population failed by another task"); + } + } + + anyhow::bail!("Timeout waiting for population to complete") + } + + #[tracing::instrument(skip_all)] + async fn populate(&self, redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { + tracing::trace!("Populating soul-over-ai DB of AI slop"); + + let res = self + .client + .get("https://raw.githubusercontent.com/xoundbyte/soul-over-ai/refs/heads/main/dist/artists.json") + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + let artists: Vec = serde_json::from_reader(res.as_ref())?; + + let mut pipe = deadpool_redis::redis::Pipeline::with_capacity(artists.len()); + + const EXPIRY_SECONDS: u64 = Duration::days(1).num_seconds() as u64; + + for artist in artists { + let Some(id) = artist.spotify else { + continue; + }; + + pipe.cmd("SETEX") + .arg(format!("{REDIS_KEY_ARTIST_PREFIX}:{id}")) + .arg(EXPIRY_SECONDS) + .arg(1) + .ignore(); + } + + let _: () = pipe.query_async(redis_conn).await?; + + Ok(()) + } + + #[tracing::instrument(skip_all, fields(%artist_id))] + async fn is_artist_ai( + redis_conn: &mut deadpool_redis::Connection, + artist_id: &str, + ) -> anyhow::Result { + let exists: bool = redis_conn + .exists(format!("{REDIS_KEY_ARTIST_PREFIX}:{artist_id}")) + .await?; + + Ok(exists) + } + + #[tracing::instrument(skip_all, fields(artist_ids = artist_ids.join(", ")))] + async fn any_artist_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + artist_ids: &[&str], + ) -> anyhow::Result { + for artist_id in artist_ids { + if Self::is_artist_ai(redis_conn, artist_id).await? { + return Ok(true); + } + } + + Ok(false) + } + + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] + pub async fn is_track_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + Self::ensure_populated(self, redis_conn).await?; + + self.any_artist_ai(redis_conn, &track.artist_ids()).await + } +} + +#[async_trait] +impl AISlopDetector for SoulOverAIProvider { + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] + async fn detect( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + self.is_track_ai(redis_conn, track).await.map(|res| { + if res { + AISlopDetectionPrediction::PureAI + } else { + AISlopDetectionPrediction::HumanMade + } + }) + } +} + +impl Default for SoulOverAIProvider { + fn default() -> Self { + Self::new() + } +} diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs new file mode 100644 index 00000000..45195ddc --- /dev/null +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -0,0 +1,190 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use async_trait::async_trait; +use chrono::Duration; +use redis::AsyncCommands as _; + +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; +use crate::spotify::ShortTrack; + +pub struct SpotifyAIBlockerProvider { + client: reqwest::Client, + populating: AtomicBool, +} + +#[derive(Debug, Deserialize)] +struct AIArtist { + // artist: String, + id: String, +} + +const REDIS_KEY_POPULATED: &str = "rustify:ai_slop:spotify_ai_blocker:populated"; +const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:spotify_ai_blocker:artist"; + +const RETRY_DELAY: Duration = Duration::milliseconds(100); +const POPULATE_TIMEOUT: Duration = Duration::seconds(20); + +impl SpotifyAIBlockerProvider { + #[must_use] + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .timeout( + Duration::seconds(10) + .to_std() + .expect("It's positive. Will work"), + ) + .build() + .expect("Should work"), + populating: AtomicBool::new(false), + } + } + + #[tracing::instrument(skip_all)] + async fn ensure_populated( + &self, + redis_conn: &mut deadpool_redis::Connection, + ) -> anyhow::Result<()> { + if redis_conn.exists(REDIS_KEY_POPULATED).await? { + return Ok(()); + } + + if self + .populating + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let result = self.populate(redis_conn).await; + + let result = if result.is_ok() { + redis_conn + .set_ex( + REDIS_KEY_POPULATED, + 1, + (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, + ) + .await + .map_err(Into::into) + } else { + result + }; + + self.populating.store(false, Ordering::SeqCst); + + return result; + } + + let deadline = Instant::now() + POPULATE_TIMEOUT.to_std().expect("positive duration"); + + while Instant::now() < deadline { + tokio::time::sleep(RETRY_DELAY.to_std().expect("positive duration")).await; + + if !self.populating.load(Ordering::SeqCst) { + if redis_conn.exists(REDIS_KEY_POPULATED).await? { + return Ok(()); + } + + anyhow::bail!("Population failed by another task"); + } + } + + anyhow::bail!("Timeout waiting for population to complete") + } + + #[tracing::instrument(skip_all)] + async fn populate(&self, redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { + tracing::trace!("Populating spotify-ai-blocker DB of AI slop"); + + let res = self + .client + .get("https://github.com/CennoxX/spotify-ai-blocker/raw/refs/heads/main/SpotifyAiArtists.csv") + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + let mut rdr = csv::Reader::from_reader(res.as_ref()); + + let mut pipe = deadpool_redis::redis::Pipeline::new(); + + const EXPIRY_SECONDS: u64 = Duration::days(1).num_seconds() as u64; + + for result in rdr.deserialize() { + let record: AIArtist = result?; + + pipe.cmd("SETEX") + .arg(format!("{REDIS_KEY_ARTIST_PREFIX}:{}", record.id)) + .arg(EXPIRY_SECONDS) + .arg(1) + .ignore(); + } + + let _: () = pipe.query_async(redis_conn).await?; + + Ok(()) + } + + #[tracing::instrument(skip_all, fields(%artist_id))] + async fn is_artist_ai( + redis_conn: &mut deadpool_redis::Connection, + artist_id: &str, + ) -> anyhow::Result { + let exists: bool = redis_conn + .exists(format!("{REDIS_KEY_ARTIST_PREFIX}:{artist_id}")) + .await?; + + Ok(exists) + } + + #[tracing::instrument(skip_all, fields(artist_ids = artist_ids.join(", ")))] + async fn any_artist_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + artist_ids: &[&str], + ) -> anyhow::Result { + for artist_id in artist_ids { + if Self::is_artist_ai(redis_conn, artist_id).await? { + return Ok(true); + } + } + + Ok(false) + } + + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] + pub async fn is_track_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + Self::ensure_populated(self, redis_conn).await?; + + self.any_artist_ai(redis_conn, &track.artist_ids()).await + } +} + +#[async_trait] +impl AISlopDetector for SpotifyAIBlockerProvider { + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] + async fn detect( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + self.is_track_ai(redis_conn, track).await.map(|res| { + if res { + AISlopDetectionPrediction::PureAI + } else { + AISlopDetectionPrediction::HumanMade + } + }) + } +} + +impl Default for SpotifyAIBlockerProvider { + fn default() -> Self { + Self::new() + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 7c95ef5c..51168f89 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,4 @@ +mod ai_slop_detection; mod magic; mod metrics; mod notification; @@ -13,6 +14,7 @@ mod user_word_whitelist; mod word_definition; mod word_stats; +pub use ai_slop_detection::{AISlopDetectionPrediction, AISlopDetectionService}; pub use magic::MagicService; pub use metrics::MetricsService; pub use notification::NotificationService; diff --git a/src/services/user.rs b/src/services/user.rs index 20658500..4d9e095b 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -238,6 +238,22 @@ impl UserService { Ok(res) } + #[tracing::instrument(skip_all, fields(user_id = %id))] + pub async fn set_cfg_ai_slop_detection( + db: &impl ConnectionTrait, + id: &str, + status: UserAISlopDetection, + ) -> anyhow::Result { + let res = UserEntity::update_many() + .filter(UserColumn::Id.eq(id)) + .col_expr(UserColumn::CfgAISlopDetection, Expr::value(status)) + .col_expr(UserColumn::UpdatedAt, Expr::value(Clock::now())) + .exec(db) + .await?; + + Ok(res) + } + #[tracing::instrument(skip_all, fields(user_id = %id))] pub async fn set_ref_code( db: &impl ConnectionTrait, diff --git a/src/spotify/mod.rs b/src/spotify/mod.rs index 80aead46..2a245d0f 100644 --- a/src/spotify/mod.rs +++ b/src/spotify/mod.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use anyhow::{Context as _, anyhow}; use auth::SpotifyAuthService; -use chrono::Duration; +use chrono::{Duration, NaiveDate}; use deadpool_redis::redis::AsyncCommands as _; pub use errors::SpotifyError; use rspotify::clients::{BaseClient as _, OAuthClient as _}; @@ -86,8 +86,10 @@ pub struct ShortTrack { duration_secs: i64, artist_names: Vec, artist_ids: Vec>, + artist_urls: Vec, album_name: String, album_url: String, + album_release_date: Option, } impl ShortTrack { @@ -113,20 +115,43 @@ impl ShortTrack { .filter_map(|artist| artist.id.clone()) .collect(), + artist_urls: full_track + .artists + .iter() + .map(|artist| { + artist + .external_urls + .get("spotify") + .cloned() + .unwrap_or_else(|| { + "https://open.spotify.com/artist/0gxyHStUsqpMadRV0Di1Qt".into() + }) + }) + .collect(), + url: full_track .external_urls .get("spotify") .cloned() - .unwrap_or_else(|| "https://vtvz.me/".into()), + .unwrap_or_else(|| "https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8".into()), album_url: full_track .album .external_urls .get("spotify") .cloned() - .unwrap_or_else(|| "https://vtvz.me/".into()), + .unwrap_or_else(|| "https://open.spotify.com/album/6eUW0wxWtzkFdaEFsTJto6".into()), album_name: full_track.album.name, + + album_release_date: full_track.album.release_date.and_then(|date| { + let mut parts = date.split('-'); + let year: i32 = parts.next()?.parse().ok()?; + let month: u32 = parts.next().and_then(|m| m.parse().ok()).unwrap_or(1); + let day: u32 = parts.next().and_then(|d| d.parse().ok()).unwrap_or(1); + + NaiveDate::from_ymd_opt(year, month, day) + }), } } @@ -172,6 +197,11 @@ impl ShortTrack { self.artist_ids.iter().map(Id::id).collect() } + #[must_use] + pub fn artist_urls(&self) -> Vec<&str> { + self.artist_urls.iter().map(String::as_str).collect() + } + #[must_use] pub fn artist_raw_ids(&self) -> &[ArtistId<'_>] { &self.artist_ids @@ -185,6 +215,14 @@ impl ShortTrack { .unwrap_or("Rick Astley") } + #[must_use] + pub fn first_artist_url(&self) -> &str { + self.artist_urls() + .first() + .copied() + .unwrap_or("https://open.spotify.com/artist/0gxyHStUsqpMadRV0Di1Qt") + } + #[must_use] pub fn album_name(&self) -> &str { &self.album_name @@ -197,20 +235,22 @@ impl ShortTrack { #[must_use] pub fn track_tg_link(&self) -> String { - format!( - r#"{name}"#, - name = html::escape(self.name_with_artists().as_str()), - link = self.url() - ) + html::link(self.url(), self.name_with_artists().as_str()) } #[must_use] pub fn album_tg_link(&self) -> String { - format!( - r#"{name}"#, - name = html::escape(self.album_name()), - link = self.album_url() - ) + html::link(self.album_url(), self.album_name()) + } + + #[must_use] + pub fn first_artist_tg_link(&self) -> String { + html::link(self.first_artist_url(), self.first_artist_name()) + } + + #[must_use] + pub fn album_release_date(&self) -> Option { + self.album_release_date } } diff --git a/src/telegram/actions/admin_users/details.rs b/src/telegram/actions/admin_users/details.rs index b4e5b7b3..3e8937df 100644 --- a/src/telegram/actions/admin_users/details.rs +++ b/src/telegram/actions/admin_users/details.rs @@ -146,6 +146,7 @@ async fn format_user_details(app: &'static App, user_id: &str) -> anyhow::Result Configuration: • Profanity Check: {check_profanity} • Track Skip: {skip_tracks} + • AI Slop: {ai_slop:?} • Skippage Enabled: {skippage_enabled} • Skippage Duration: {skippage_secs} seconds • Magic Playlist: {magic_playlist} @@ -183,6 +184,7 @@ async fn format_user_details(app: &'static App, user_id: &str) -> anyhow::Result updated_at = user.updated_at.format("%Y-%m-%d %H:%M:%S"), check_profanity = render_bool(user.cfg_check_profanity), skip_tracks = render_bool(user.cfg_skip_tracks), + ai_slop = user.cfg_ai_slop_detection, skippage_enabled = render_bool(user.cfg_skippage_enabled), skippage_secs = user.cfg_skippage_secs, magic_playlist = user.magic_playlist.as_deref().unwrap_or("Not set"), diff --git a/src/telegram/actions/ai_slop_detection.rs b/src/telegram/actions/ai_slop_detection.rs new file mode 100644 index 00000000..9174b0df --- /dev/null +++ b/src/telegram/actions/ai_slop_detection.rs @@ -0,0 +1,96 @@ +use sea_orm::Iterable as _; +use teloxide::payloads::{ + AnswerCallbackQuerySetters as _, + EditMessageReplyMarkupSetters as _, + SendMessageSetters as _, +}; +use teloxide::prelude::Requester as _; +use teloxide::sugar::bot::BotMessagesExt as _; +use teloxide::types::{ + CallbackQuery, + ChatId, + InlineKeyboardButton, + InlineKeyboardMarkup, + ReplyMarkup, +}; + +use crate::app::App; +use crate::entity::prelude::UserAISlopDetection; +use crate::services::UserService; +use crate::telegram::handlers::HandleStatus; +use crate::telegram::inline_buttons::InlineButtons; +use crate::user::UserState; +use crate::utils::teloxide::CallbackQueryExt as _; + +#[tracing::instrument(skip_all, fields(user_id = %state.user_id()))] +pub async fn handle_inline( + app: &'static App, + state: &UserState, + q: CallbackQuery, + status: UserAISlopDetection, +) -> anyhow::Result<()> { + let Some(message) = q.get_message() else { + app.bot() + .answer_callback_query(q.id.clone()) + .text("Inaccessible Message") + .await?; + + return Ok(()); + }; + + app.bot().answer_callback_query(q.id).await?; + + if status != state.user().cfg_ai_slop_detection { + UserService::set_cfg_ai_slop_detection(app.db(), state.user_id(), status).await?; + + app.bot() + .edit_reply_markup(&message) + .reply_markup(InlineKeyboardMarkup::new(get_keyboard( + status, + state.locale(), + ))) + .await?; + } + + Ok(()) +} + +#[must_use] +pub fn get_keyboard( + current_setting: UserAISlopDetection, + locale: &str, +) -> Vec> { + UserAISlopDetection::iter() + .map(|status| { + vec![ + InlineButtons::AISlopDetection(status, current_setting == status) + .into_inline_keyboard_button(locale), + ] + }) + .collect() +} + +#[tracing::instrument(skip_all, fields(user_id = %state.user_id()))] +pub async fn handle( + app: &'static App, + state: &UserState, + chat_id: ChatId, +) -> anyhow::Result { + if !state.is_spotify_authed().await { + super::login::send_login_invite(app, state).await?; + + return Ok(HandleStatus::Handled); + } + + app.bot() + .send_message( + chat_id, + t!("ai-slop.setting-description", locale = state.locale()), + ) + .reply_markup(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup::new( + get_keyboard(state.user().cfg_ai_slop_detection, state.locale()), + ))) + .await?; + + Ok(HandleStatus::Handled) +} diff --git a/src/telegram/actions/dislike.rs b/src/telegram/actions/dislike.rs index 919942ee..c2ffee30 100644 --- a/src/telegram/actions/dislike.rs +++ b/src/telegram/actions/dislike.rs @@ -117,7 +117,8 @@ fn compose_message_text(track: &ShortTrack, locale: &str) -> String { t!( "actions.dislike", locale = locale, - track_link = track.track_tg_link() + track_link = track.track_tg_link(), + ignore_button_label = t!("inline-buttons.ignore", locale = locale), ) .to_string() } diff --git a/src/telegram/actions/ignore.rs b/src/telegram/actions/ignore.rs index 230811a9..136ef319 100644 --- a/src/telegram/actions/ignore.rs +++ b/src/telegram/actions/ignore.rs @@ -45,7 +45,8 @@ pub async fn handle_inline( t!( "actions.ignore", track_link = track.track_tg_link(), - locale = state.locale() + locale = state.locale(), + dislike_button_label = t!("inline-buttons.dislike", locale = state.locale()), ), ) .link_preview_options(link_preview_small_top(track.url())) diff --git a/src/telegram/actions/mod.rs b/src/telegram/actions/mod.rs index 5fea9810..c41b458c 100644 --- a/src/telegram/actions/mod.rs +++ b/src/telegram/actions/mod.rs @@ -1,4 +1,5 @@ pub mod admin_users; +pub mod ai_slop_detection; pub mod analyze; pub mod broadcast; pub mod details; diff --git a/src/telegram/commands.rs b/src/telegram/commands.rs index 89e7b726..f444aa7f 100644 --- a/src/telegram/commands.rs +++ b/src/telegram/commands.rs @@ -61,6 +61,12 @@ pub enum UserCommand { #[command(description = "command.skippage")] Skippage { days: String }, + + #[command( + description = "command.ai-slop-detection", + rename = "ai_slop_detection" + )] + AISlopDetection, } impl UserCommand { @@ -133,6 +139,7 @@ pub enum UserCommandDisplay { Skippage, Language, Recommendasion, + AISlopDetection, } impl std::fmt::Display for UserCommandDisplay { @@ -155,6 +162,7 @@ impl std::fmt::Display for UserCommandDisplay { Self::Skippage => "skippage", Self::Language => "language", Self::Recommendasion => "recommendasion", + Self::AISlopDetection => "ai_slop_detection", }; f.write_str(string) @@ -187,6 +195,7 @@ mod tests { UserCommand::Magic => UserCommandDisplay::Magic, UserCommand::Skippage { .. } => UserCommandDisplay::Skippage, UserCommand::Language => UserCommandDisplay::Language, + UserCommand::AISlopDetection => UserCommandDisplay::AISlopDetection, }; } diff --git a/src/telegram/handlers/commands.rs b/src/telegram/handlers/commands.rs index 69a18393..1d438c20 100644 --- a/src/telegram/handlers/commands.rs +++ b/src/telegram/handlers/commands.rs @@ -106,6 +106,9 @@ pub async fn handle( UserCommand::Recommendasion => { return actions::recommendasion::handle(app, state, m.chat.id).await; }, + UserCommand::AISlopDetection => { + return actions::ai_slop_detection::handle(app, state, m.chat.id).await; + }, UserCommand::Skippage { days } => { return actions::skippage::handle(app, state, m.chat.id, days).await; }, diff --git a/src/telegram/handlers/inline_buttons.rs b/src/telegram/handlers/inline_buttons.rs index 7f128195..5e871a75 100644 --- a/src/telegram/handlers/inline_buttons.rs +++ b/src/telegram/handlers/inline_buttons.rs @@ -151,6 +151,9 @@ pub async fn handle(app: &'static App, state: &UserState, q: CallbackQuery) -> a InlineButtons::SkippageEnable(to_enable) => { actions::skippage::handle_inline(app, state, q, to_enable).await?; }, + InlineButtons::AISlopDetection(status, _) => { + actions::ai_slop_detection::handle_inline(app, state, q, status).await?; + }, } Ok(()) diff --git a/src/telegram/inline_buttons.rs b/src/telegram/inline_buttons.rs index 90f18b08..d87cf1d7 100644 --- a/src/telegram/inline_buttons.rs +++ b/src/telegram/inline_buttons.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; -use crate::entity::prelude::TrackStatus; +use crate::entity::prelude::{TrackStatus, UserAISlopDetection}; #[derive(Deserialize, Serialize, Clone, Debug)] pub enum InlineButtons { @@ -13,6 +13,7 @@ pub enum InlineButtons { Analyze(String), SongLinks(String), Magic, + AISlopDetection(UserAISlopDetection, bool), SkippageEnable(bool), Recommendasion, } @@ -34,6 +35,20 @@ impl InlineButtons { t!("skippage.disable-button", locale = locale) } }, + Self::AISlopDetection(status, selected) => { + let mark = if *selected { "✅ " } else { "" }; + match status { + UserAISlopDetection::Notify => { + t!("ai-slop.button-notify", locale = locale, mark = mark) + }, + UserAISlopDetection::Ignore => { + t!("ai-slop.button-ignore", locale = locale, mark = mark) + }, + UserAISlopDetection::Skip => { + t!("ai-slop.button-skip", locale = locale, mark = mark) + }, + } + }, } } } diff --git a/src/telegram/inline_buttons_actions.rs b/src/telegram/inline_buttons_actions.rs new file mode 100644 index 00000000..7695913e --- /dev/null +++ b/src/telegram/inline_buttons_actions.rs @@ -0,0 +1,36 @@ +use std::borrow::Cow; + +use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; +use url::Url; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub enum InlineButtonsActions { + ArtistPage(Url), +} + +impl InlineButtonsActions { + #[must_use] + pub fn label(&self, locale: &str) -> Cow<'_, str> { + match self { + Self::ArtistPage(_) => t!("inline-buttons.artist-page", locale = locale), + } + } +} + +impl InlineButtonsActions { + #[must_use] + pub fn into_inline_keyboard_button(self, locale: &str) -> InlineKeyboardButton { + let label = self.label(locale); + + InlineKeyboardButton::new(label, self.clone().into()) + } +} + +#[allow(clippy::from_over_into)] +impl Into for InlineButtonsActions { + fn into(self) -> InlineKeyboardButtonKind { + match self { + Self::ArtistPage(url) => InlineKeyboardButtonKind::Url(url), + } + } +} diff --git a/src/telegram/mod.rs b/src/telegram/mod.rs index b8a88d7d..c61f31a3 100644 --- a/src/telegram/mod.rs +++ b/src/telegram/mod.rs @@ -3,6 +3,7 @@ pub mod commands; pub mod commands_admin; pub mod handlers; pub mod inline_buttons; +pub mod inline_buttons_actions; pub mod inline_buttons_admin; pub mod keyboards; pub mod utils; diff --git a/src/tick/user.rs b/src/tick/user.rs index d9f749a5..b60a468f 100644 --- a/src/tick/user.rs +++ b/src/tick/user.rs @@ -62,7 +62,7 @@ pub async fn check(app: &'static App, user_id: &str) -> anyhow::Result { - if state.user().cfg_check_profanity { + if state.user().cfg_check_profanity || !state.user().cfg_ai_slop_detection.is_ignore() { let changed = UserService::sync_current_playing( app.redis_conn().await?, state.user_id(), @@ -74,9 +74,9 @@ pub async fn check(app: &'static App, user_id: &str) -> anyhow::Result {}, diff --git a/src/workers/queues.rs b/src/workers/queues.rs index 63a833fd..a0794541 100644 --- a/src/workers/queues.rs +++ b/src/workers/queues.rs @@ -6,7 +6,7 @@ use apalis::prelude::{Monitor, WorkerBuilder}; use crate as rustify; use crate::app::App; -use crate::queue::profanity_check; +use crate::queue::track_check; pub async fn work() { rustify::infrastructure::logger::init().expect("Logger should be built"); @@ -23,14 +23,14 @@ pub async fn work() { Monitor::new() .register(move |_| { - WorkerBuilder::new("rustify:profanity_check") - .backend(app.queue_manager().profanity_queue()) + WorkerBuilder::new("rustify:track_check") + .backend(app.queue_manager().track_check_queue()) .concurrency(2) // Ordering of timeout and retry matters! .timeout(Duration::from_secs(90)) .retry(RetryPolicy::retries(2)) .data(app) - .build(profanity_check::consume) + .build(track_check::consume) }) .run() .await diff --git a/src/workers/server.rs b/src/workers/server.rs index 45e46b6a..1abeb416 100644 --- a/src/workers/server.rs +++ b/src/workers/server.rs @@ -6,7 +6,6 @@ use axum::response::{IntoResponse as _, Redirect, Response}; use axum::routing::get; use rspotify::clients::OAuthClient as _; use sea_orm::TransactionTrait as _; -use serde::Deserialize; use teloxide::payloads::SendMessageSetters as _; use teloxide::prelude::Requester as _;