From b3b51fa13813c3c10c994d7d15ae70ff2eb47e0f Mon Sep 17 00:00:00 2001 From: Vitaly Date: Wed, 25 Feb 2026 22:19:28 +0300 Subject: [PATCH 01/44] ISSUE-67: Add AI slop detection and auto-skip functionality Implements AI artist detection using Spotify AI Blocker data with Redis caching. Automatically skips AI-generated tracks for premium users, notifies non-premium users. Refactored profanity check queue to generic track check queue. --- Cargo.lock | 22 ++++ Cargo.toml | 1 + src/app.rs | 8 +- src/queue/mod.rs | 17 ++- .../{profanity_check.rs => track_check.rs} | 88 ++++++++++++- src/services/ai_slop_detection/mod.rs | 34 +++++ .../ai_slop_detection/spotify_ai_blocker.rs | 121 ++++++++++++++++++ src/services/mod.rs | 2 + src/tick/user.rs | 4 +- src/workers/queues.rs | 8 +- 10 files changed, 283 insertions(+), 22 deletions(-) rename src/queue/{profanity_check.rs => track_check.rs} (74%) create mode 100644 src/services/ai_slop_detection/mod.rs create mode 100644 src/services/ai_slop_detection/spotify_ai_blocker.rs diff --git a/Cargo.lock b/Cargo.lock index 95379363..9e73089a 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" @@ -3403,6 +3424,7 @@ dependencies = [ "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..780130ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ 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" [dependencies.isolang] features = ["serde"] diff --git a/src/app.rs b/src/app.rs index c6c9bb50..c89a3d9a 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, } @@ -137,6 +138,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> { @@ -354,6 +359,7 @@ impl App { .server_http_address .unwrap_or_else(|| "0.0.0.0:3000".into()), queue_manager, + ai_slop_detection: AISlopDetectionService::new(), }); let app = &*Box::leak(app); diff --git a/src/queue/mod.rs b/src/queue/mod.rs index 0991c961..4df96e30 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 profanity_queue = storage + .make_shared_with_config(RedisConfig::default().set_namespace("rustify:track_check"))?; Ok(Self { storage, - profanity_queue, + track_check_queue: profanity_queue, }) } } diff --git a/src/queue/profanity_check.rs b/src/queue/track_check.rs similarity index 74% rename from src/queue/profanity_check.rs rename to src/queue/track_check.rs index 0b811209..6baa5b25 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}; @@ -11,6 +12,7 @@ use crate::infrastructure::error_handler; use crate::lyrics::SearchResult as _; use crate::services::{ TrackLanguageStatsService, + TrackStatusService, UserService, UserWordWhitelistService, WordStatsService, @@ -23,7 +25,7 @@ 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 +40,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 +51,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 +66,18 @@ 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 { + TrackStatusService::increase_skips(app.db(), user_state.user_id(), data.track.id()) + .await?; + + return Ok(()); + } + + let res = check_pofanity(app, &user_state, &data.track) .await .context("Check lyrics failed")?; @@ -104,7 +117,7 @@ pub struct CheckBadWordsResult { track_name = %track.name_with_artists(), ) )] -pub async fn check( +pub async fn check_pofanity( app: &'static App, state: &UserState, track: &ShortTrack, @@ -222,3 +235,66 @@ 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 { + let is_ai_slop = app + .ai_slop_detection() + .is_track_ai(&mut app.redis_conn().await?, track) + .await?; + + if !is_ai_slop { + return Ok(AISlopCheckResult { + is_ai_slop, + skipped: false, + }); + } + + // AI slop detected + if state.is_spotify_premium().await? { + state + .spotify() + .await + .next_track(None) + .await + .context("Skip current track")?; + + TrackStatusService::increase_skips(app.db(), state.user_id(), track.id()).await?; + + return Ok(AISlopCheckResult { + is_ai_slop, + skipped: true, + }); + } + + // Not premium, cannot skip + let text = t!( + "error.cannot-skip", + locale = state.locale(), + track_name = track.track_tg_link(), + ); + + app.bot().send_message(state.chat_id()?, text).await?; + + Ok(AISlopCheckResult { + is_ai_slop, + 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..991e27b4 --- /dev/null +++ b/src/services/ai_slop_detection/mod.rs @@ -0,0 +1,34 @@ +mod spotify_ai_blocker; + +use spotify_ai_blocker::SpotifyAIBlockerProvider; + +use crate::spotify::ShortTrack; + +pub struct AISlopDetectionService { + spotify_ai_blocker_provider: SpotifyAIBlockerProvider, +} + +impl AISlopDetectionService { + #[must_use] + pub fn new() -> Self { + Self { + spotify_ai_blocker_provider: SpotifyAIBlockerProvider::new(), + } + } + + pub async fn is_track_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + self.spotify_ai_blocker_provider + .is_track_ai(redis_conn, track) + .await + } +} + +impl Default for AISlopDetectionService { + 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..cd6f7912 --- /dev/null +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -0,0 +1,121 @@ +use chrono::Duration; +use redis::AsyncCommands as _; + +use crate::spotify::ShortTrack; + +pub struct SpotifyAIBlockerProvider { + client: reqwest::Client, +} + +#[derive(Debug, serde::Deserialize)] +struct AIArtist { + // artist: String, + id: String, +} + +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"), + } + } + + pub async fn ensure_populated( + &self, + redis_conn: &mut deadpool_redis::Connection, + ) -> anyhow::Result<()> { + let exists: bool = redis_conn.exists("rustify:ai_slop:populated").await?; + + if exists { + return Ok(()); + } + + self.populate(redis_conn).await?; + + let _: () = redis_conn + .set_ex( + "rustify:ai_slop:populated", + 1, + (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, + ) + .await?; + + Ok(()) + } + + async fn populate(&self, redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { + 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()); + + for result in rdr.deserialize() { + let record: AIArtist = result?; + + let _: () = redis_conn + .set_ex( + format!("rustify:ai_slop:artist:{}", record.id), + 1, + Duration::days(1).num_seconds() as _, + ) + .await?; + } + + Ok(()) + } + + async fn is_artist_ai( + redis_conn: &mut deadpool_redis::Connection, + artist_id: &str, + ) -> anyhow::Result { + let exists: bool = redis_conn + .exists(format!("rustify:ai_slop:artist:{artist_id}")) + .await?; + + Ok(exists) + } + + async fn any_artist_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + artist_ids: &[&str], + ) -> anyhow::Result { + Self::ensure_populated(self, redis_conn).await?; + + for artist_id in artist_ids { + if Self::is_artist_ai(redis_conn, artist_id).await? { + return Ok(true); + } + } + + Ok(false) + } + + pub async fn is_track_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + self.any_artist_ai(redis_conn, &track.artist_ids()).await + } +} + +impl Default for SpotifyAIBlockerProvider { + fn default() -> Self { + Self::new() + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 7c95ef5c..e811ef58 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::AISlopDetectionService; pub use magic::MagicService; pub use metrics::MetricsService; pub use notification::NotificationService; diff --git a/src/tick/user.rs b/src/tick/user.rs index d9f749a5..11e72f7c 100644 --- a/src/tick/user.rs +++ b/src/tick/user.rs @@ -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 From 29a5243de0d6adb518b8b2a7ea195284aa2ca943 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 26 Feb 2026 15:43:13 +0300 Subject: [PATCH 02/44] prefix --- .../ai_slop_detection/spotify_ai_blocker.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index cd6f7912..abfd0907 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -13,6 +13,9 @@ struct AIArtist { 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"; + impl SpotifyAIBlockerProvider { #[must_use] pub fn new() -> Self { @@ -32,7 +35,7 @@ impl SpotifyAIBlockerProvider { &self, redis_conn: &mut deadpool_redis::Connection, ) -> anyhow::Result<()> { - let exists: bool = redis_conn.exists("rustify:ai_slop:populated").await?; + let exists: bool = redis_conn.exists(REDIS_KEY_POPULATED).await?; if exists { return Ok(()); @@ -42,7 +45,7 @@ impl SpotifyAIBlockerProvider { let _: () = redis_conn .set_ex( - "rustify:ai_slop:populated", + REDIS_KEY_POPULATED, 1, (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, ) @@ -52,6 +55,8 @@ impl SpotifyAIBlockerProvider { } 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") @@ -68,7 +73,7 @@ impl SpotifyAIBlockerProvider { let _: () = redis_conn .set_ex( - format!("rustify:ai_slop:artist:{}", record.id), + format!("{REDIS_KEY_ARTIST_PREFIX}:{}", record.id), 1, Duration::days(1).num_seconds() as _, ) @@ -83,7 +88,7 @@ impl SpotifyAIBlockerProvider { artist_id: &str, ) -> anyhow::Result { let exists: bool = redis_conn - .exists(format!("rustify:ai_slop:artist:{artist_id}")) + .exists(format!("{REDIS_KEY_ARTIST_PREFIX}:{artist_id}")) .await?; Ok(exists) From 6d1c8732a5fe6a2b97521937961ecb2c890af18d Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 26 Feb 2026 15:43:18 +0300 Subject: [PATCH 03/44] links --- src/spotify/mod.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/spotify/mod.rs b/src/spotify/mod.rs index 80aead46..2a43adcf 100644 --- a/src/spotify/mod.rs +++ b/src/spotify/mod.rs @@ -197,20 +197,12 @@ 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()) } } From 2022207bebe46012ad45a181a29601b2eb0ad0c2 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 26 Feb 2026 16:23:10 +0300 Subject: [PATCH 04/44] ISSUE-67: Add AI slop notification UI with artist page button Replace auto-skip behavior with interactive notification showing dislike/ignore buttons and direct link to artist's Spotify page. Add artist URL tracking to ShortTrack for the new button. --- locales/ai_slop.yml | 27 ++++++++++ locales/inline_buttons.yml | 6 +++ locales/profanity_check.yml | 4 +- src/queue/track_check.rs | 69 +++++++++++++++++-------- src/spotify/mod.rs | 37 ++++++++++++- src/telegram/handlers/inline_buttons.rs | 1 + src/telegram/inline_buttons.rs | 8 ++- 7 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 locales/ai_slop.yml diff --git a/locales/ai_slop.yml b/locales/ai_slop.yml new file mode 100644 index 00000000..5c0978bf --- /dev/null +++ b/locales/ai_slop.yml @@ -0,0 +1,27 @@ +_version: 2 + +ai-slop.alert: + en: |- + 🎵 %{track_name} + Album: %{album_name} + + 💩 This track is most likely AI-generated. You can dislike or ignore similar notifications for the current 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" + + Support real organic hand-made music instead of AI-slop! + ru: |- + 🎵 %{track_name} + Альбом: %{album_name} + + 💩 Этот трек скорее всего создан ИИ. Вы можете дизлайкнуть или отключить подобные уведомления для текущего трека + + Чтобы навсегда скрыть этого исполнителя из рекомендаций: + 🔗 Откройте страницу исполнителя в Spotify + Нажмите на три точки рядом с кнопкой «Подписаться» + Выберите «Не воспроизводить этого исполнителя» + + Поддерживайте настоящую живую музыку вместо ИИ-шлака! diff --git a/locales/inline_buttons.yml b/locales/inline_buttons.yml index b41193ce..60e13d8b 100644 --- a/locales/inline_buttons.yml +++ b/locales/inline_buttons.yml @@ -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/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/src/queue/track_check.rs b/src/queue/track_check.rs index 6baa5b25..5392a97d 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -197,6 +197,7 @@ pub async fn check_pofanity( 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 { @@ -267,31 +268,57 @@ pub async fn check_ai_slop( }); } - // AI slop detected - if state.is_spotify_premium().await? { - state - .spotify() - .await - .next_track(None) - .await - .context("Skip current track")?; + 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![ + InlineButtons::ArtistPage(track.first_artist_url().parse()?) + .into_inline_keyboard_button(state.locale()), + ], + ]; - TrackStatusService::increase_skips(app.db(), state.user_id(), track.id()).await?; + 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(), + ), + ) + .link_preview_options(link_preview_small_top(track.url())) + .reply_markup(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup::new( + keyboard, + ))) + .await?; - return Ok(AISlopCheckResult { - is_ai_slop, - skipped: true, - }); - } + // TODO: Add setting to auto-skip ai-slop + if false { + if state.is_spotify_premium().await? { + state + .spotify() + .await + .next_track(None) + .await + .context("Skip current track")?; + + TrackStatusService::increase_skips(app.db(), state.user_id(), track.id()).await?; + + return Ok(AISlopCheckResult { + is_ai_slop, + skipped: true, + }); + } - // Not premium, cannot skip - let text = t!( - "error.cannot-skip", - locale = state.locale(), - track_name = track.track_tg_link(), - ); + let text = t!( + "error.cannot-skip", + locale = state.locale(), + track_name = track.track_tg_link(), + ); - app.bot().send_message(state.chat_id()?, text).await?; + app.bot().send_message(state.chat_id()?, text).await?; + } Ok(AISlopCheckResult { is_ai_slop, diff --git a/src/spotify/mod.rs b/src/spotify/mod.rs index 2a43adcf..be052fea 100644 --- a/src/spotify/mod.rs +++ b/src/spotify/mod.rs @@ -86,6 +86,7 @@ pub struct ShortTrack { duration_secs: i64, artist_names: Vec, artist_ids: Vec>, + artist_urls: Vec, album_name: String, album_url: String, } @@ -113,18 +114,32 @@ 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, } @@ -172,6 +187,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 +205,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 @@ -204,6 +232,11 @@ impl ShortTrack { pub fn album_tg_link(&self) -> String { 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()) + } } impl From for ShortTrack { diff --git a/src/telegram/handlers/inline_buttons.rs b/src/telegram/handlers/inline_buttons.rs index 7f128195..5a68beb2 100644 --- a/src/telegram/handlers/inline_buttons.rs +++ b/src/telegram/handlers/inline_buttons.rs @@ -151,6 +151,7 @@ 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::ArtistPage(_) => (), } Ok(()) diff --git a/src/telegram/inline_buttons.rs b/src/telegram/inline_buttons.rs index 90f18b08..bcd49529 100644 --- a/src/telegram/inline_buttons.rs +++ b/src/telegram/inline_buttons.rs @@ -3,6 +3,7 @@ use std::fmt::{Display, Formatter}; use std::str::FromStr; use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; +use url::Url; use crate::entity::prelude::TrackStatus; @@ -15,6 +16,7 @@ pub enum InlineButtons { Magic, SkippageEnable(bool), Recommendasion, + ArtistPage(Url), } impl InlineButtons { @@ -34,6 +36,7 @@ impl InlineButtons { t!("skippage.disable-button", locale = locale) } }, + Self::ArtistPage(_) => t!("inline-buttons.artist-page", locale = locale), } } } @@ -81,7 +84,10 @@ impl InlineButtons { #[allow(clippy::from_over_into)] impl Into for InlineButtons { fn into(self) -> InlineKeyboardButtonKind { - InlineKeyboardButtonKind::CallbackData(self.to_string()) + match self { + Self::ArtistPage(url) => InlineKeyboardButtonKind::Url(url), + _ => InlineKeyboardButtonKind::CallbackData(self.to_string()), + } } } From 268d94ce1cc3b3a14275e86c86224cdfe055a280 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 26 Feb 2026 16:34:10 +0300 Subject: [PATCH 05/44] ISSUE-67: Add soul-over-ai provider for AI slop detection Integrate xoundbyte/soul-over-ai artist database as a second detection source. Artist IDs are cached in Redis for 24 hours. --- src/services/ai_slop_detection/mod.rs | 14 +- .../ai_slop_detection/soul_over_ai.rs | 128 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/services/ai_slop_detection/soul_over_ai.rs diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index 991e27b4..2a405662 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -1,11 +1,14 @@ +mod soul_over_ai; mod spotify_ai_blocker; +use soul_over_ai::SoulOverAIProvider; use spotify_ai_blocker::SpotifyAIBlockerProvider; use crate::spotify::ShortTrack; pub struct AISlopDetectionService { spotify_ai_blocker_provider: SpotifyAIBlockerProvider, + soul_over_ai_provider: SoulOverAIProvider, } impl AISlopDetectionService { @@ -13,6 +16,7 @@ impl AISlopDetectionService { pub fn new() -> Self { Self { spotify_ai_blocker_provider: SpotifyAIBlockerProvider::new(), + soul_over_ai_provider: SoulOverAIProvider::new(), } } @@ -21,7 +25,15 @@ impl AISlopDetectionService { redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, ) -> anyhow::Result { - self.spotify_ai_blocker_provider + if self + .spotify_ai_blocker_provider + .is_track_ai(redis_conn, track) + .await? + { + return Ok(true); + } + + self.soul_over_ai_provider .is_track_ai(redis_conn, track) .await } 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..80a9997c --- /dev/null +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -0,0 +1,128 @@ +use chrono::Duration; +use redis::AsyncCommands as _; + +use crate::spotify::ShortTrack; + +pub struct SoulOverAIProvider { + client: reqwest::Client, +} + +#[derive(Debug, serde::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"; + +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"), + } + } + + pub async fn ensure_populated( + &self, + redis_conn: &mut deadpool_redis::Connection, + ) -> anyhow::Result<()> { + let exists: bool = redis_conn.exists(REDIS_KEY_POPULATED).await?; + + if exists { + return Ok(()); + } + + self.populate(redis_conn).await?; + + let _: () = redis_conn + .set_ex( + REDIS_KEY_POPULATED, + 1, + (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, + ) + .await?; + + Ok(()) + } + + 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())?; + + for artist in artists { + let Some(id) = artist.spotify else { + continue; + }; + + let _: () = redis_conn + .set_ex( + format!("{REDIS_KEY_ARTIST_PREFIX}:{}", id), + 1, + Duration::days(1).num_seconds() as _, + ) + .await?; + } + + Ok(()) + } + + 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) + } + + async fn any_artist_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + artist_ids: &[&str], + ) -> anyhow::Result { + Self::ensure_populated(self, redis_conn).await?; + + for artist_id in artist_ids { + if Self::is_artist_ai(redis_conn, artist_id).await? { + return Ok(true); + } + } + + Ok(false) + } + + pub async fn is_track_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + self.any_artist_ai(redis_conn, &track.artist_ids()).await + } +} + +impl Default for SoulOverAIProvider { + fn default() -> Self { + Self::new() + } +} From 9457cca80a2903c43e87e9932e8ea8eafff43fdd Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 27 Feb 2026 15:10:52 +0300 Subject: [PATCH 06/44] ISSUE-67: Add spot-the-ai provider for AI slop detection Integrate spot-the-ai.com API as third detection source. Artist names are hashed with MD5 and cached in Redis for 24 hours. --- Cargo.lock | 7 + Cargo.toml | 1 + src/services/ai_slop_detection/mod.rs | 14 +- .../ai_slop_detection/soul_over_ai.rs | 8 +- src/services/ai_slop_detection/spot_the_ai.rs | 126 ++++++++++++++++++ .../ai_slop_detection/spotify_ai_blocker.rs | 4 +- src/telegram/inline_buttons.rs | 1 + 7 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 src/services/ai_slop_detection/spot_the_ai.rs diff --git a/Cargo.lock b/Cargo.lock index 9e73089a..02791500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2234,6 +2234,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.7.6" @@ -3434,6 +3440,7 @@ dependencies = [ "influxdb", "isolang", "itertools 0.14.0", + "md5", "prometheus", "rand 0.10.0", "redis 0.32.7", diff --git a/Cargo.toml b/Cargo.toml index 780130ab..dcafb2f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ whatlang = { version = "0.18.0", features = ["serde"] } redis = "0.32.7" prometheus = { version = "0.14.0", features = ["push", "process"] } csv = "1.4.0" +md5 = "0.8.0" [dependencies.isolang] features = ["serde"] diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index 2a405662..3cd908fb 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -1,7 +1,9 @@ mod soul_over_ai; +mod spot_the_ai; mod spotify_ai_blocker; use soul_over_ai::SoulOverAIProvider; +use spot_the_ai::SpotTheAIProvider; use spotify_ai_blocker::SpotifyAIBlockerProvider; use crate::spotify::ShortTrack; @@ -9,6 +11,7 @@ use crate::spotify::ShortTrack; pub struct AISlopDetectionService { spotify_ai_blocker_provider: SpotifyAIBlockerProvider, soul_over_ai_provider: SoulOverAIProvider, + spot_the_ai: SpotTheAIProvider, } impl AISlopDetectionService { @@ -17,6 +20,7 @@ impl AISlopDetectionService { Self { spotify_ai_blocker_provider: SpotifyAIBlockerProvider::new(), soul_over_ai_provider: SoulOverAIProvider::new(), + spot_the_ai: SpotTheAIProvider::new(), } } @@ -33,9 +37,15 @@ impl AISlopDetectionService { return Ok(true); } - self.soul_over_ai_provider + if self + .soul_over_ai_provider .is_track_ai(redis_conn, track) - .await + .await? + { + return Ok(true); + } + + self.spot_the_ai.is_track_ai(redis_conn, track).await } } diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 80a9997c..6d91a269 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -31,7 +31,7 @@ impl SoulOverAIProvider { } } - pub async fn ensure_populated( + async fn ensure_populated( &self, redis_conn: &mut deadpool_redis::Connection, ) -> anyhow::Result<()> { @@ -75,7 +75,7 @@ impl SoulOverAIProvider { let _: () = redis_conn .set_ex( - format!("{REDIS_KEY_ARTIST_PREFIX}:{}", id), + format!("{REDIS_KEY_ARTIST_PREFIX}:{id}"), 1, Duration::days(1).num_seconds() as _, ) @@ -101,8 +101,6 @@ impl SoulOverAIProvider { redis_conn: &mut deadpool_redis::Connection, artist_ids: &[&str], ) -> anyhow::Result { - Self::ensure_populated(self, redis_conn).await?; - for artist_id in artist_ids { if Self::is_artist_ai(redis_conn, artist_id).await? { return Ok(true); @@ -117,6 +115,8 @@ impl SoulOverAIProvider { 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 } } diff --git a/src/services/ai_slop_detection/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs new file mode 100644 index 00000000..fda67b85 --- /dev/null +++ b/src/services/ai_slop_detection/spot_the_ai.rs @@ -0,0 +1,126 @@ +use chrono::Duration; +use redis::AsyncCommands as _; + +use crate::spotify::ShortTrack; + +pub struct SpotTheAIProvider { + client: reqwest::Client, +} + +#[derive(Debug, serde::Deserialize)] +struct AIArtists { + artists: Vec, +} + +const REDIS_KEY_POPULATED: &str = "rustify:ai_slop:spot_the_ai:populated"; +const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:spot_the_ai:artist"; + +impl SpotTheAIProvider { + #[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"), + } + } + + async fn ensure_populated( + &self, + redis_conn: &mut deadpool_redis::Connection, + ) -> anyhow::Result<()> { + let exists: bool = redis_conn.exists(REDIS_KEY_POPULATED).await?; + + if exists { + return Ok(()); + } + + self.populate(redis_conn).await?; + + let _: () = redis_conn + .set_ex( + REDIS_KEY_POPULATED, + 1, + (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, + ) + .await?; + + Ok(()) + } + + async fn populate(&self, redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { + tracing::trace!("Populating spot-the-ai DB of AI slop"); + + let res = self + .client + .get("https://spot-the-ai.com/api/list/") + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + let artists: AIArtists = serde_json::from_reader(res.as_ref())?; + + for artist_name in artists.artists { + let _: () = redis_conn + .set_ex( + format!("{REDIS_KEY_ARTIST_PREFIX}:{:?}", md5::compute(artist_name)), + 1, + Duration::days(1).num_seconds() as _, + ) + .await?; + } + + Ok(()) + } + + async fn is_artist_ai( + redis_conn: &mut deadpool_redis::Connection, + artist_name: &str, + ) -> anyhow::Result { + let exists: bool = redis_conn + .exists(format!( + "{REDIS_KEY_ARTIST_PREFIX}:{:?}", + md5::compute(artist_name) + )) + .await?; + + Ok(exists) + } + + async fn any_artist_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + artist_names: &[&str], + ) -> anyhow::Result { + for artist_name in artist_names { + if Self::is_artist_ai(redis_conn, artist_name).await? { + return Ok(true); + } + } + + Ok(false) + } + + 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_names()).await + } +} + +impl Default for SpotTheAIProvider { + 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 index abfd0907..57377bb5 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -99,8 +99,6 @@ impl SpotifyAIBlockerProvider { redis_conn: &mut deadpool_redis::Connection, artist_ids: &[&str], ) -> anyhow::Result { - Self::ensure_populated(self, redis_conn).await?; - for artist_id in artist_ids { if Self::is_artist_ai(redis_conn, artist_id).await? { return Ok(true); @@ -115,6 +113,8 @@ impl SpotifyAIBlockerProvider { 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 } } diff --git a/src/telegram/inline_buttons.rs b/src/telegram/inline_buttons.rs index bcd49529..2b86f7cc 100644 --- a/src/telegram/inline_buttons.rs +++ b/src/telegram/inline_buttons.rs @@ -16,6 +16,7 @@ pub enum InlineButtons { Magic, SkippageEnable(bool), Recommendasion, + // TODO: Think about making separate type of buttons without callback data ArtistPage(Url), } From 1daa0e6db0a2211f687a76995c20813bdacb0db0 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 27 Feb 2026 15:16:13 +0300 Subject: [PATCH 07/44] ISSUE-67: Add error handling for AI slop detection providers Wrap each provider call in error handling to prevent one provider's failure from breaking the entire detection system. Log errors and continue checking remaining providers. --- src/services/ai_slop_detection/mod.rs | 37 +++++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index 3cd908fb..ad0e4b1a 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -29,23 +29,32 @@ impl AISlopDetectionService { redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, ) -> anyhow::Result { - if self - .spotify_ai_blocker_provider - .is_track_ai(redis_conn, track) - .await? - { - return Ok(true); - } + macro_rules! handle_provider { + ($name:expr, $provider:expr) => { + let result = $provider.is_track_ai(redis_conn, track).await; - if self - .soul_over_ai_provider - .is_track_ai(redis_conn, track) - .await? - { - return Ok(true); + match result { + Ok(res) => { + if res { + return Ok(res); + } + }, + Err(err) => { + tracing::error!( + err = ?err, + "Error with {} occurred", + $name + ); + }, + }; + }; } - self.spot_the_ai.is_track_ai(redis_conn, track).await + handle_provider!("Spotify AI Blocker", self.spotify_ai_blocker_provider); + handle_provider!("Soul Over AI", self.soul_over_ai_provider); + handle_provider!("Spot the AI", self.spot_the_ai); + + Ok(false) } } From a322d0b74f685bfe4be3dac0fc8232ec9903382b Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 27 Feb 2026 17:14:38 +0300 Subject: [PATCH 08/44] ISSUE-67: Add SHLabs provider for AI slop detection Integrate shlabs.music API for track-level AI detection. Results cached for 1 year. Requires SHLABS_API_KEY env var. --- src/app.rs | 4 +- src/services/ai_slop_detection/mod.rs | 15 +-- src/services/ai_slop_detection/shlabs.rs | 119 +++++++++++++++++++++++ 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 src/services/ai_slop_detection/shlabs.rs diff --git a/src/app.rs b/src/app.rs index c89a3d9a..d2a22a22 100644 --- a/src/app.rs +++ b/src/app.rs @@ -68,6 +68,8 @@ struct EnvConfig { genius_service_url: String, lyrics_cache_ttl: Option, + shlabs_api_key: Option, + censor_blacklist: Option, censor_whitelist: Option, @@ -359,7 +361,7 @@ impl App { .server_http_address .unwrap_or_else(|| "0.0.0.0:3000".into()), queue_manager, - ai_slop_detection: AISlopDetectionService::new(), + ai_slop_detection: AISlopDetectionService::new(env.shlabs_api_key), }); let app = &*Box::leak(app); diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index ad0e4b1a..6fb5a35a 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -1,3 +1,4 @@ +mod shlabs; mod soul_over_ai; mod spot_the_ai; mod spotify_ai_blocker; @@ -12,15 +13,17 @@ pub struct AISlopDetectionService { spotify_ai_blocker_provider: SpotifyAIBlockerProvider, soul_over_ai_provider: SoulOverAIProvider, spot_the_ai: SpotTheAIProvider, + shlabs: Option, } impl AISlopDetectionService { #[must_use] - pub fn new() -> Self { + pub fn new(shlabs_api_key: Option) -> Self { Self { spotify_ai_blocker_provider: SpotifyAIBlockerProvider::new(), soul_over_ai_provider: SoulOverAIProvider::new(), spot_the_ai: SpotTheAIProvider::new(), + shlabs: shlabs_api_key.map(shlabs::SHLabsProvider::new), } } @@ -54,12 +57,10 @@ impl AISlopDetectionService { handle_provider!("Soul Over AI", self.soul_over_ai_provider); handle_provider!("Spot the AI", self.spot_the_ai); - Ok(false) - } -} + if let Some(shlabs) = &self.shlabs { + handle_provider!("SHLabs", shlabs); + } -impl Default for AISlopDetectionService { - fn default() -> Self { - Self::new() + Ok(false) } } diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs new file mode 100644 index 00000000..7face251 --- /dev/null +++ b/src/services/ai_slop_detection/shlabs.rs @@ -0,0 +1,119 @@ +use chrono::Duration; +use redis::AsyncTypedCommands as _; +use serde_json::json; + +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 = "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_ARTIST_PREFIX: &str = "rustify:ai_slop:shlabs:artist"; + +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 fetch( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + let track_key = format!("{REDIS_KEY_ARTIST_PREFIX}:{}", track.id()); + + if let Some(data) = redis_conn.get(&track_key).await? + && let Ok(data) = serde_json::from_str(&data) + { + return Ok(data); + } + + let res: Root = self + .client + .post("https://shlabs.music/api/v1/detect") + .header("X-API-Key", &self.api_key) + .json(&json!({ + "spotifyTrackId": track.id(), + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let _: () = redis_conn + .set_ex( + &track_key, + serde_json::to_string(&res)?, + Duration::days(365).num_seconds() as _, + ) + .await?; + + Ok(res) + } + + pub async fn is_track_ai( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result { + let res = self.fetch(redis_conn, track).await?; + + Ok(!matches!(res.result.prediction, Prediction::HumanMade)) + } +} From 7af0f3a28fa77325bc73fe7e759f952ba6733fd4 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 28 Feb 2026 16:54:48 +0300 Subject: [PATCH 09/44] ISSUE-67: Add provider attribution to AI slop notifications Track which provider detected AI and display attribution link in notification messages. Add Provider enum with links to sources. --- locales/ai_slop.yml | 4 ++ src/queue/track_check.rs | 15 ++++-- src/services/ai_slop_detection/mod.rs | 68 ++++++++++++++++++++++----- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/locales/ai_slop.yml b/locales/ai_slop.yml index 5c0978bf..30328b1c 100644 --- a/locales/ai_slop.yml +++ b/locales/ai_slop.yml @@ -12,6 +12,8 @@ ai-slop.alert: 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! ru: |- 🎵 %{track_name} @@ -24,4 +26,6 @@ ai-slop.alert: Нажмите на три точки рядом с кнопкой «Подписаться» Выберите «Не воспроизводить этого исполнителя» + Информация предоставлена "%{ai_check_provider}" + Поддерживайте настоящую живую музыку вместо ИИ-шлака! diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index 5392a97d..d6651a3d 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -256,18 +256,22 @@ pub async fn check_ai_slop( state: &UserState, track: &ShortTrack, ) -> anyhow::Result { - let is_ai_slop = app + let ai_detection_result = app .ai_slop_detection() .is_track_ai(&mut app.redis_conn().await?, track) .await?; - if !is_ai_slop { + if !ai_detection_result.is_track_ai { return Ok(AISlopCheckResult { - is_ai_slop, + is_ai_slop: false, skipped: false, }); } + 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())], @@ -285,6 +289,7 @@ pub async fn check_ai_slop( locale = state.locale(), track_name = track.track_tg_link(), album_name = track.album_tg_link(), + ai_check_provider = provider.tg_link(), ), ) .link_preview_options(link_preview_small_top(track.url())) @@ -306,7 +311,7 @@ pub async fn check_ai_slop( TrackStatusService::increase_skips(app.db(), state.user_id(), track.id()).await?; return Ok(AISlopCheckResult { - is_ai_slop, + is_ai_slop: true, skipped: true, }); } @@ -321,7 +326,7 @@ pub async fn check_ai_slop( } Ok(AISlopCheckResult { - is_ai_slop, + is_ai_slop: true, skipped: false, }) } diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index 6fb5a35a..82deb74d 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -10,18 +10,54 @@ use spotify_ai_blocker::SpotifyAIBlockerProvider; use crate::spotify::ShortTrack; pub struct AISlopDetectionService { - spotify_ai_blocker_provider: SpotifyAIBlockerProvider, - soul_over_ai_provider: SoulOverAIProvider, + spotify_ai_blocker: SpotifyAIBlockerProvider, + soul_over_ai: SoulOverAIProvider, spot_the_ai: SpotTheAIProvider, shlabs: Option, } +pub enum Provider { + SpotifyAIBlocker, + SoulOverAI, + SpotTheAI, + SHLabs, +} + +pub struct AISlopDetectionResult { + pub provider: Option, + pub is_track_ai: bool, +} + +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::SpotTheAI => "https://spot-the-ai.com/list/", + 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::SpotTheAI => "SpotAI", + Self::SHLabs => "SubmitHub AI Song Checker", + } + } +} + impl AISlopDetectionService { #[must_use] pub fn new(shlabs_api_key: Option) -> Self { Self { - spotify_ai_blocker_provider: SpotifyAIBlockerProvider::new(), - soul_over_ai_provider: SoulOverAIProvider::new(), + spotify_ai_blocker: SpotifyAIBlockerProvider::new(), + soul_over_ai: SoulOverAIProvider::new(), spot_the_ai: SpotTheAIProvider::new(), shlabs: shlabs_api_key.map(shlabs::SHLabsProvider::new), } @@ -31,36 +67,42 @@ impl AISlopDetectionService { &self, redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, - ) -> anyhow::Result { + ) -> anyhow::Result { macro_rules! handle_provider { - ($name:expr, $provider:expr) => { + ($provider_enum:expr, $provider:expr) => { let result = $provider.is_track_ai(redis_conn, track).await; match result { Ok(res) => { if res { - return Ok(res); + return Ok(AISlopDetectionResult { + provider: Some($provider_enum), + is_track_ai: res, + }); } }, Err(err) => { tracing::error!( err = ?err, "Error with {} occurred", - $name + $provider_enum.name() ); }, }; }; } - handle_provider!("Spotify AI Blocker", self.spotify_ai_blocker_provider); - handle_provider!("Soul Over AI", self.soul_over_ai_provider); - handle_provider!("Spot the AI", self.spot_the_ai); + handle_provider!(Provider::SpotifyAIBlocker, self.spotify_ai_blocker); + handle_provider!(Provider::SoulOverAI, self.soul_over_ai); + handle_provider!(Provider::SpotTheAI, self.spot_the_ai); if let Some(shlabs) = &self.shlabs { - handle_provider!("SHLabs", shlabs); + handle_provider!(Provider::SHLabs, shlabs); } - Ok(false) + Ok(AISlopDetectionResult { + provider: None, + is_track_ai: false, + }) } } From b98322ad4a0c42364f6b0ee3d409d25373e1545d Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 28 Feb 2026 19:05:28 +0300 Subject: [PATCH 10/44] ISSUE-67: Document AI-generated music detection in README Add documentation for the new AI slop detection feature with links to all four detection providers used by the system. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46b4d20a..abc257b1 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 ([Spotify AI Music Blocker](https://github.com/CennoxX/spotify-ai-blocker), [Soul Over AI](https://github.com/xoundbyte/soul-over-ai), [SpotAI](https://spot-the-ai.com/list/), [SubmitHub AI Song Checker](https://www.submithub.com/ai-song-checker)) 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 From 3c286cb934daf2889c2731f8f11b71a123198b02 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 2 Mar 2026 20:42:57 +0300 Subject: [PATCH 11/44] ISSUE-67: Add user configuration for AI slop detection behavior Adds cfg_ai_slop_detection field to user table with three modes: notify (default), ignore, and skip. Integrates the setting into track_check to enable auto-skip functionality when configured. --- ...3_add_user_cfg_ai_slop_detection_field.sql | 2 ++ src/entity/prelude.rs | 1 + src/entity/user.rs | 35 +++++++++++++++++++ src/queue/track_check.rs | 7 ++-- 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 migrations/20260302172253_add_user_cfg_ai_slop_detection_field.sql 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/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..92f832de 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,33 @@ 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 = "notify")] + #[default] + Notify, + #[sea_orm(string_value = "ignore")] + Ignore, + #[sea_orm(string_value = "skip")] + Skip, +} + +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()) + } +} diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index d6651a3d..79c3f95c 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -8,6 +8,7 @@ use teloxide::prelude::*; use teloxide::types::{InlineKeyboardMarkup, ReplyMarkup}; use crate::app::App; +use crate::entity::prelude::UserAISlopDetection; use crate::infrastructure::error_handler; use crate::lyrics::SearchResult as _; use crate::services::{ @@ -298,8 +299,10 @@ pub async fn check_ai_slop( ))) .await?; - // TODO: Add setting to auto-skip ai-slop - if false { + if matches!( + state.user().cfg_ai_slop_detection, + UserAISlopDetection::Skip + ) { if state.is_spotify_premium().await? { state .spotify() From a08c962d8a6e9a2292fd13c4fe5f4c38a33997c9 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 2 Mar 2026 22:03:47 +0300 Subject: [PATCH 12/44] ISSUE-67: Add localization and UI for AI slop detection settings Adds English translations, /ai_slop_detection command with inline keyboard for configuring detection behavior (notify/ignore/skip), and updates welcome message to mention the new feature. --- locales/ai_slop.yml | 36 +++++++++ locales/login.yml | 2 + src/entity/user.rs | 4 +- src/queue/track_check.rs | 68 +++++++++-------- src/services/user.rs | 16 ++++ src/telegram/actions/ai_slop_detection.rs | 90 +++++++++++++++++++++++ src/telegram/actions/mod.rs | 1 + src/telegram/commands.rs | 9 +++ src/telegram/handlers/commands.rs | 3 + src/telegram/handlers/inline_buttons.rs | 3 + src/telegram/inline_buttons.rs | 17 ++++- 11 files changed, 216 insertions(+), 33 deletions(-) create mode 100644 src/telegram/actions/ai_slop_detection.rs diff --git a/locales/ai_slop.yml b/locales/ai_slop.yml index 30328b1c..d0101f48 100644 --- a/locales/ai_slop.yml +++ b/locales/ai_slop.yml @@ -15,6 +15,8 @@ ai-slop.alert: 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} @@ -29,3 +31,37 @@ ai-slop.alert: Информация предоставлена "%{ai_check_provider}" Поддерживайте настоящую живую музыку вместо ИИ-шлака! + +
Хотите отключить уведомления или настроить автопропуск AI-треков? Попробуйте /%{config_command}
+ +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/login.yml b/locales/login.yml index cf0ea05b..993df072 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 and skip AI-generated music • 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/src/entity/user.rs b/src/entity/user.rs index 92f832de..6a51e0bf 100644 --- a/src/entity/user.rs +++ b/src/entity/user.rs @@ -313,13 +313,13 @@ impl Role { )] #[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, - #[sea_orm(string_value = "skip")] - Skip, } impl FromStr for AISlopDetection { diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index 79c3f95c..4a7aa093 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -19,6 +19,7 @@ use crate::services::{ WordStatsService, }; use crate::spotify::ShortTrack; +use crate::telegram::commands::UserCommandDisplay; use crate::telegram::inline_buttons::InlineButtons; use crate::telegram::utils::link_preview_small_top; use crate::user::UserState; @@ -269,36 +270,6 @@ pub async fn check_ai_slop( }); } - 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![ - InlineButtons::ArtistPage(track.first_artist_url().parse()?) - .into_inline_keyboard_button(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(), - ), - ) - .link_preview_options(link_preview_small_top(track.url())) - .reply_markup(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup::new( - keyboard, - ))) - .await?; - if matches!( state.user().cfg_ai_slop_detection, UserAISlopDetection::Skip @@ -326,6 +297,43 @@ pub async fn check_ai_slop( ); app.bot().send_message(state.chat_id()?, text).await?; + } else { + 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![ + InlineButtons::ArtistPage(track.first_artist_url().parse()?) + .into_inline_keyboard_button(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, + ), + ) + .link_preview_options(link_preview_small_top(track.url())) + .reply_markup(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup::new( + keyboard, + ))) + .await?; } Ok(AISlopCheckResult { 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/telegram/actions/ai_slop_detection.rs b/src/telegram/actions/ai_slop_detection.rs new file mode 100644 index 00000000..26645c4d --- /dev/null +++ b/src/telegram/actions/ai_slop_detection.rs @@ -0,0 +1,90 @@ +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 { + 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/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 5a68beb2..ed88dcf7 100644 --- a/src/telegram/handlers/inline_buttons.rs +++ b/src/telegram/handlers/inline_buttons.rs @@ -152,6 +152,9 @@ pub async fn handle(app: &'static App, state: &UserState, q: CallbackQuery) -> a actions::skippage::handle_inline(app, state, q, to_enable).await?; }, InlineButtons::ArtistPage(_) => (), + 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 2b86f7cc..e774f20f 100644 --- a/src/telegram/inline_buttons.rs +++ b/src/telegram/inline_buttons.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; use url::Url; -use crate::entity::prelude::TrackStatus; +use crate::entity::prelude::{TrackStatus, UserAISlopDetection}; #[derive(Deserialize, Serialize, Clone, Debug)] pub enum InlineButtons { @@ -14,6 +14,7 @@ pub enum InlineButtons { Analyze(String), SongLinks(String), Magic, + AISlopDetection(UserAISlopDetection, bool), SkippageEnable(bool), Recommendasion, // TODO: Think about making separate type of buttons without callback data @@ -38,6 +39,20 @@ impl InlineButtons { } }, Self::ArtistPage(_) => t!("inline-buttons.artist-page", 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) + }, + } + }, } } } From 2589fab834d6e13a0ec5459b6b2609912c133bd0 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 3 Mar 2026 12:17:17 +0300 Subject: [PATCH 13/44] ISSUE-67: Refactor AI slop detection logic and improve configuration Adds helper methods for detection modes, fixes Redis key prefix, improves early-exit logic for ignore mode, and updates documentation to clarify configurable behavior. --- README.md | 2 +- locales/login.yml | 4 +- src/app.rs | 4 +- src/entity/user.rs | 17 +++++ src/queue/track_check.rs | 89 ++++++++++++------------ src/services/ai_slop_detection/shlabs.rs | 2 +- src/tick/user.rs | 2 +- 7 files changed, 69 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index abc257b1..3b642806 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Rustify is an intelligent Telegram bot that integrates with Spotify to provide r ### 🎯 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 ([Spotify AI Music Blocker](https://github.com/CennoxX/spotify-ai-blocker), [Soul Over AI](https://github.com/xoundbyte/soul-over-ai), [SpotAI](https://spot-the-ai.com/list/), [SubmitHub AI Song Checker](https://www.submithub.com/ai-song-checker)) and shows notifications with attribution +- **🤖 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/login.yml b/locales/login.yml index 993df072..13755b10 100644 --- a/locales/login.yml +++ b/locales/login.yml @@ -33,7 +33,7 @@ login.invite: Features: • Block unwanted songs ("Dislike") • Notifications for profanity in lyrics (English only) - • Detect and skip AI-generated music + • 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 @@ -49,7 +49,7 @@ login.invite: Возможности: • Блокировка нежелательных песен ("Дизлайк") • Уведомление на нецензурную лексику в тексте (только английский) - • Обнаружение и пропуск ИИ-музыки + • Обнаружение ИИ-музыки (уведомление/игнорирование/авто-пропуск) • Получение текстов песен и их анализ с помощью ИИ • Создание Magic Playlist™✨ из любимых треков • Функция Skippage™ разнообразит ваши впечатления от прослушивания diff --git a/src/app.rs b/src/app.rs index d2a22a22..ea166d97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -345,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, @@ -361,7 +363,7 @@ impl App { .server_http_address .unwrap_or_else(|| "0.0.0.0:3000".into()), queue_manager, - ai_slop_detection: AISlopDetectionService::new(env.shlabs_api_key), + ai_slop_detection: AISlopDetectionService::new(shlabs_api_key), }); let app = &*Box::leak(app); diff --git a/src/entity/user.rs b/src/entity/user.rs index 6a51e0bf..d05485df 100644 --- a/src/entity/user.rs +++ b/src/entity/user.rs @@ -337,3 +337,20 @@ impl TryFrom<&str> for AISlopDetection { 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/queue/track_check.rs b/src/queue/track_check.rs index 4a7aa093..f483f83e 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -8,7 +8,6 @@ use teloxide::prelude::*; use teloxide::types::{InlineKeyboardMarkup, ReplyMarkup}; use crate::app::App; -use crate::entity::prelude::UserAISlopDetection; use crate::infrastructure::error_handler; use crate::lyrics::SearchResult as _; use crate::services::{ @@ -73,9 +72,6 @@ pub async fn consume(data: TrackCheckQueueTask, app: Data<&'static App>) -> anyh .context("Check AI Slop")?; if res.skipped { - TrackStatusService::increase_skips(app.db(), user_state.user_id(), data.track.id()) - .await?; - return Ok(()); } @@ -258,6 +254,13 @@ pub async fn check_ai_slop( 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) @@ -270,10 +273,7 @@ pub async fn check_ai_slop( }); } - if matches!( - state.user().cfg_ai_slop_detection, - UserAISlopDetection::Skip - ) { + if state.user().cfg_ai_slop_detection.is_skip() { if state.is_spotify_premium().await? { state .spotify() @@ -297,45 +297,44 @@ pub async fn check_ai_slop( ); app.bot().send_message(state.chat_id()?, text).await?; - } else { - 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![ - InlineButtons::ArtistPage(track.first_artist_url().parse()?) - .into_inline_keyboard_button(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, - ), - ) - .link_preview_options(link_preview_small_top(track.url())) - .reply_markup(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup::new( - keyboard, - ))) - .await?; + + return Ok(AISlopCheckResult { + is_ai_slop: true, + skipped: true, + }); } + 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![ + InlineButtons::ArtistPage(track.first_artist_url().parse()?) + .into_inline_keyboard_button(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, + ), + ) + .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/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index 7face251..7320a462 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -47,7 +47,7 @@ pub struct Usage { pub monthly_remaining: i64, } -const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:shlabs:artist"; +const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:shlabs:track"; pub struct SHLabsProvider { client: reqwest::Client, diff --git a/src/tick/user.rs b/src/tick/user.rs index 11e72f7c..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(), From 728d87a6ab5847e7c9485c7b0a7fda2f4a7f4829 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 3 Mar 2026 12:21:38 +0300 Subject: [PATCH 14/44] ISSUE-67: Rename REDIS_KEY_ARTIST_PREFIX to REDIS_KEY_TRACK_PREFIX The constant stores track data, not artist data. Rename to reflect actual usage and improve code clarity. --- src/services/ai_slop_detection/shlabs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index 7320a462..f5bfb9b0 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -47,7 +47,7 @@ pub struct Usage { pub monthly_remaining: i64, } -const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:shlabs:track"; +const REDIS_KEY_TRACK_PREFIX: &str = "rustify:ai_slop:shlabs:track"; pub struct SHLabsProvider { client: reqwest::Client, @@ -75,7 +75,7 @@ impl SHLabsProvider { redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, ) -> anyhow::Result { - let track_key = format!("{REDIS_KEY_ARTIST_PREFIX}:{}", track.id()); + 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) From de17cdf85374e920858a7741e61b40696a616fa0 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Wed, 4 Mar 2026 12:21:48 +0300 Subject: [PATCH 15/44] fix --- src/queue/mod.rs | 4 ++-- src/queue/track_check.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/queue/mod.rs b/src/queue/mod.rs index 4df96e30..3aea17cc 100644 --- a/src/queue/mod.rs +++ b/src/queue/mod.rs @@ -29,12 +29,12 @@ impl QueueManager { let mut storage = SharedRedisStorage::new(client).await?; - let profanity_queue = storage + let track_check_queue = storage .make_shared_with_config(RedisConfig::default().set_namespace("rustify:track_check"))?; Ok(Self { storage, - track_check_queue: profanity_queue, + track_check_queue, }) } } diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index f483f83e..4e9cdcd8 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -75,7 +75,7 @@ pub async fn consume(data: TrackCheckQueueTask, app: Data<&'static App>) -> anyh return Ok(()); } - let res = check_pofanity(app, &user_state, &data.track) + let res = check_profanity(app, &user_state, &data.track) .await .context("Check lyrics failed")?; @@ -115,7 +115,7 @@ pub struct CheckBadWordsResult { track_name = %track.name_with_artists(), ) )] -pub async fn check_pofanity( +pub async fn check_profanity( app: &'static App, state: &UserState, track: &ShortTrack, From 976adda9dad9db5fcf2b80a3411a8a78ea7bcdc4 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 6 Mar 2026 13:57:26 +0300 Subject: [PATCH 16/44] added missing --- src/services/ai_slop_detection/shlabs.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index f5bfb9b0..11408f06 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -17,6 +17,8 @@ pub enum Prediction { HumanMade, #[serde(rename = "Pure AI")] PureAI, + #[serde(rename = "Pure AI Generated")] + PureAIGenerated, #[serde(rename = "Processed AI")] ProcessedAI, #[serde(rename = "Processed AI Generated")] From db22ea7da03c6918721049011c7785733fff09a2 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 6 Mar 2026 15:31:19 +0300 Subject: [PATCH 17/44] ISSUE-67: Add AISlopPredict trait and prediction type classification Introduce unified trait for AI detection providers and differentiate between fully AI-generated and AI-processed tracks. Add localized prediction type labels displayed in alert messages. --- Cargo.lock | 1 + Cargo.toml | 1 + locales/ai_slop.yml | 12 +++++- src/queue/track_check.rs | 12 +++++- src/services/ai_slop_detection/mod.rs | 42 +++++++++++++++---- src/services/ai_slop_detection/shlabs.rs | 17 ++++++-- .../ai_slop_detection/soul_over_ai.rs | 19 +++++++++ src/services/ai_slop_detection/spot_the_ai.rs | 19 +++++++++ .../ai_slop_detection/spotify_ai_blocker.rs | 19 +++++++++ src/services/mod.rs | 2 +- 10 files changed, 128 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02791500..982d80ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3425,6 +3425,7 @@ dependencies = [ "apalis", "apalis-redis", "async-openai", + "async-trait", "axum", "backon", "chrono", diff --git a/Cargo.toml b/Cargo.toml index dcafb2f8..71f6da10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ redis = "0.32.7" prometheus = { version = "0.14.0", features = ["push", "process"] } csv = "1.4.0" md5 = "0.8.0" +async-trait = "0.1.89" [dependencies.isolang] features = ["serde"] diff --git a/locales/ai_slop.yml b/locales/ai_slop.yml index d0101f48..eee9fed9 100644 --- a/locales/ai_slop.yml +++ b/locales/ai_slop.yml @@ -5,7 +5,7 @@ ai-slop.alert: 🎵 %{track_name} Album: %{album_name} - 💩 This track is most likely AI-generated. You can dislike or ignore similar notifications for the current track + 💩 This track is most likely %{prediction}. You can dislike or ignore similar notifications for the current track To permanently block this artist from recommendations: 🔗 Open the artist's page in Spotify @@ -21,7 +21,7 @@ ai-slop.alert: 🎵 %{track_name} Альбом: %{album_name} - 💩 Этот трек скорее всего создан ИИ. Вы можете дизлайкнуть или отключить подобные уведомления для текущего трека + 💩 Этот трек скорее всего %{prediction}. Вы можете дизлайкнуть или отключить подобные уведомления для текущего трека Чтобы навсегда скрыть этого исполнителя из рекомендаций: 🔗 Откройте страницу исполнителя в Spotify @@ -34,6 +34,14 @@ ai-slop.alert:
Хотите отключить уведомления или настроить автопропуск 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 diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index 4e9cdcd8..ccd7b40c 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -11,6 +11,7 @@ use crate::app::App; use crate::infrastructure::error_handler; use crate::lyrics::SearchResult as _; use crate::services::{ + AISlopDetectionPrediction, TrackLanguageStatsService, TrackStatusService, UserService, @@ -266,7 +267,7 @@ pub async fn check_ai_slop( .is_track_ai(&mut app.redis_conn().await?, track) .await?; - if !ai_detection_result.is_track_ai { + if !ai_detection_result.prediction.is_track_ai() { return Ok(AISlopCheckResult { is_ai_slop: false, skipped: false, @@ -317,6 +318,14 @@ pub async fn check_ai_slop( ], ]; + let prediction = match ai_detection_result.prediction { + AISlopDetectionPrediction::HumanMade => "Impossible".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()?, @@ -327,6 +336,7 @@ pub async fn check_ai_slop( 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())) diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index 82deb74d..b2635344 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -3,6 +3,7 @@ mod soul_over_ai; mod spot_the_ai; mod spotify_ai_blocker; +use async_trait::async_trait; use soul_over_ai::SoulOverAIProvider; use spot_the_ai::SpotTheAIProvider; use spotify_ai_blocker::SpotifyAIBlockerProvider; @@ -25,7 +26,30 @@ pub enum Provider { pub struct AISlopDetectionResult { pub provider: Option, - pub is_track_ai: bool, + pub prediction: AISlopDetectionPrediction, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum AISlopDetectionPrediction { + HumanMade, + PureAI, + ProcessedAI, +} + +impl AISlopDetectionPrediction { + #[must_use] + pub fn is_track_ai(self) -> bool { + self != Self::HumanMade + } +} + +#[async_trait] +pub trait AISlopPredict { + async fn predict( + &self, + redis_conn: &mut deadpool_redis::Connection, + track: &ShortTrack, + ) -> anyhow::Result; } impl Provider { @@ -70,14 +94,14 @@ impl AISlopDetectionService { ) -> anyhow::Result { macro_rules! handle_provider { ($provider_enum:expr, $provider:expr) => { - let result = $provider.is_track_ai(redis_conn, track).await; + let result = AISlopPredict::predict($provider, redis_conn, track).await; match result { - Ok(res) => { - if res { + Ok(prediction) => { + if prediction.is_track_ai() { return Ok(AISlopDetectionResult { provider: Some($provider_enum), - is_track_ai: res, + prediction, }); } }, @@ -92,9 +116,9 @@ impl AISlopDetectionService { }; } - handle_provider!(Provider::SpotifyAIBlocker, self.spotify_ai_blocker); - handle_provider!(Provider::SoulOverAI, self.soul_over_ai); - handle_provider!(Provider::SpotTheAI, self.spot_the_ai); + handle_provider!(Provider::SpotifyAIBlocker, &self.spotify_ai_blocker); + handle_provider!(Provider::SoulOverAI, &self.soul_over_ai); + handle_provider!(Provider::SpotTheAI, &self.spot_the_ai); if let Some(shlabs) = &self.shlabs { handle_provider!(Provider::SHLabs, shlabs); @@ -102,7 +126,7 @@ impl AISlopDetectionService { Ok(AISlopDetectionResult { provider: None, - is_track_ai: false, + prediction: AISlopDetectionPrediction::HumanMade, }) } } diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index 11408f06..b34eedca 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -1,7 +1,9 @@ +use async_trait::async_trait; use chrono::Duration; use redis::AsyncTypedCommands as _; use serde_json::json; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; use crate::spotify::ShortTrack; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -108,14 +110,23 @@ impl SHLabsProvider { Ok(res) } +} - pub async fn is_track_ai( +#[async_trait] +impl AISlopPredict for SHLabsProvider { + async fn predict( &self, redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, - ) -> anyhow::Result { + ) -> anyhow::Result { let res = self.fetch(redis_conn, track).await?; - Ok(!matches!(res.result.prediction, Prediction::HumanMade)) + 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 index 6d91a269..6983e172 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -1,6 +1,8 @@ +use async_trait::async_trait; use chrono::Duration; use redis::AsyncCommands as _; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; use crate::spotify::ShortTrack; pub struct SoulOverAIProvider { @@ -121,6 +123,23 @@ impl SoulOverAIProvider { } } +#[async_trait] +impl AISlopPredict for SoulOverAIProvider { + async fn predict( + &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/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs index fda67b85..e28c6f27 100644 --- a/src/services/ai_slop_detection/spot_the_ai.rs +++ b/src/services/ai_slop_detection/spot_the_ai.rs @@ -1,6 +1,8 @@ +use async_trait::async_trait; use chrono::Duration; use redis::AsyncCommands as _; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; use crate::spotify::ShortTrack; pub struct SpotTheAIProvider { @@ -119,6 +121,23 @@ impl SpotTheAIProvider { } } +#[async_trait] +impl AISlopPredict for SpotTheAIProvider { + async fn predict( + &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 SpotTheAIProvider { 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 index 57377bb5..84938944 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -1,6 +1,8 @@ +use async_trait::async_trait; use chrono::Duration; use redis::AsyncCommands as _; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; use crate::spotify::ShortTrack; pub struct SpotifyAIBlockerProvider { @@ -119,6 +121,23 @@ impl SpotifyAIBlockerProvider { } } +#[async_trait] +impl AISlopPredict for SpotifyAIBlockerProvider { + async fn predict( + &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 e811ef58..51168f89 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -14,7 +14,7 @@ mod user_word_whitelist; mod word_definition; mod word_stats; -pub use ai_slop_detection::AISlopDetectionService; +pub use ai_slop_detection::{AISlopDetectionPrediction, AISlopDetectionService}; pub use magic::MagicService; pub use metrics::MetricsService; pub use notification::NotificationService; From 4d6d58bb381f7a76b530b97d149e32f5801f5bb3 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Mar 2026 15:38:19 +0300 Subject: [PATCH 18/44] ISSUE-67: Rename AISlopPredict trait and improve button labels Renamed trait to AISlopDetector with detect() method for clarity. Updated inline button text to be action-oriented and explicit about scope. --- locales/inline_buttons.yml | 8 ++++---- src/services/ai_slop_detection/mod.rs | 8 ++++---- src/services/ai_slop_detection/shlabs.rs | 6 +++--- src/services/ai_slop_detection/soul_over_ai.rs | 6 +++--- src/services/ai_slop_detection/spot_the_ai.rs | 6 +++--- src/services/ai_slop_detection/spotify_ai_blocker.rs | 6 +++--- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/locales/inline_buttons.yml b/locales/inline_buttons.yml index 60e13d8b..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: |- diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index b2635344..aebead23 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -44,8 +44,8 @@ impl AISlopDetectionPrediction { } #[async_trait] -pub trait AISlopPredict { - async fn predict( +pub trait AISlopDetector { + async fn detect( &self, redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, @@ -94,7 +94,7 @@ impl AISlopDetectionService { ) -> anyhow::Result { macro_rules! handle_provider { ($provider_enum:expr, $provider:expr) => { - let result = AISlopPredict::predict($provider, redis_conn, track).await; + let result = AISlopDetector::detect($provider, redis_conn, track).await; match result { Ok(prediction) => { @@ -116,8 +116,8 @@ impl AISlopDetectionService { }; } - handle_provider!(Provider::SpotifyAIBlocker, &self.spotify_ai_blocker); handle_provider!(Provider::SoulOverAI, &self.soul_over_ai); + handle_provider!(Provider::SpotifyAIBlocker, &self.spotify_ai_blocker); handle_provider!(Provider::SpotTheAI, &self.spot_the_ai); if let Some(shlabs) = &self.shlabs { diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index b34eedca..9f154780 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -3,7 +3,7 @@ use chrono::Duration; use redis::AsyncTypedCommands as _; use serde_json::json; -use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; use crate::spotify::ShortTrack; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -113,8 +113,8 @@ impl SHLabsProvider { } #[async_trait] -impl AISlopPredict for SHLabsProvider { - async fn predict( +impl AISlopDetector for SHLabsProvider { + async fn detect( &self, redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 6983e172..5a6d495c 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::Duration; use redis::AsyncCommands as _; -use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; use crate::spotify::ShortTrack; pub struct SoulOverAIProvider { @@ -124,8 +124,8 @@ impl SoulOverAIProvider { } #[async_trait] -impl AISlopPredict for SoulOverAIProvider { - async fn predict( +impl AISlopDetector for SoulOverAIProvider { + async fn detect( &self, redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, diff --git a/src/services/ai_slop_detection/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs index e28c6f27..379a8a56 100644 --- a/src/services/ai_slop_detection/spot_the_ai.rs +++ b/src/services/ai_slop_detection/spot_the_ai.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::Duration; use redis::AsyncCommands as _; -use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; use crate::spotify::ShortTrack; pub struct SpotTheAIProvider { @@ -122,8 +122,8 @@ impl SpotTheAIProvider { } #[async_trait] -impl AISlopPredict for SpotTheAIProvider { - async fn predict( +impl AISlopDetector for SpotTheAIProvider { + async fn detect( &self, redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index 84938944..3e0586b1 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::Duration; use redis::AsyncCommands as _; -use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopPredict}; +use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; use crate::spotify::ShortTrack; pub struct SpotifyAIBlockerProvider { @@ -122,8 +122,8 @@ impl SpotifyAIBlockerProvider { } #[async_trait] -impl AISlopPredict for SpotifyAIBlockerProvider { - async fn predict( +impl AISlopDetector for SpotifyAIBlockerProvider { + async fn detect( &self, redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, From fde8ad20b68cd47381a9e7a16ed345db5e392252 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Mar 2026 16:22:24 +0300 Subject: [PATCH 19/44] ISSUE-67: Fix TOCTOU race in AI slop detection providers Add AtomicBool-based synchronization to prevent multiple concurrent population attempts stampeding upstream APIs. Waiters fail fast if population fails rather than retrying recursively. --- .../ai_slop_detection/soul_over_ai.rs | 57 ++++++++++++++---- src/services/ai_slop_detection/spot_the_ai.rs | 57 ++++++++++++++---- .../ai_slop_detection/spotify_ai_blocker.rs | 59 ++++++++++++++----- 3 files changed, 133 insertions(+), 40 deletions(-) diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 5a6d495c..08ba3e97 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -1,5 +1,7 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + use async_trait::async_trait; -use chrono::Duration; +use chrono::{Duration, Utc}; use redis::AsyncCommands as _; use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; @@ -7,6 +9,7 @@ use crate::spotify::ShortTrack; pub struct SoulOverAIProvider { client: reqwest::Client, + populating: AtomicBool, } #[derive(Debug, serde::Deserialize)] @@ -18,6 +21,9 @@ struct AIArtist { 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 { @@ -30,6 +36,7 @@ impl SoulOverAIProvider { ) .build() .expect("Should work"), + populating: AtomicBool::new(false), } } @@ -37,23 +44,47 @@ impl SoulOverAIProvider { &self, redis_conn: &mut deadpool_redis::Connection, ) -> anyhow::Result<()> { - let exists: bool = redis_conn.exists(REDIS_KEY_POPULATED).await?; - - if exists { + if redis_conn.exists(REDIS_KEY_POPULATED).await? { return Ok(()); } - self.populate(redis_conn).await?; + if self + .populating + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let result = self.populate(redis_conn).await; + + if result.is_ok() { + let _: () = redis_conn + .set_ex( + REDIS_KEY_POPULATED, + 1, + (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, + ) + .await?; + } - let _: () = redis_conn - .set_ex( - REDIS_KEY_POPULATED, - 1, - (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, - ) - .await?; + self.populating.store(false, Ordering::SeqCst); - Ok(()) + return result; + } + + let deadline = Utc::now() + POPULATE_TIMEOUT; + + while Utc::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") } async fn populate(&self, redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { diff --git a/src/services/ai_slop_detection/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs index 379a8a56..2305e3e6 100644 --- a/src/services/ai_slop_detection/spot_the_ai.rs +++ b/src/services/ai_slop_detection/spot_the_ai.rs @@ -1,5 +1,7 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + use async_trait::async_trait; -use chrono::Duration; +use chrono::{Duration, Utc}; use redis::AsyncCommands as _; use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; @@ -7,6 +9,7 @@ use crate::spotify::ShortTrack; pub struct SpotTheAIProvider { client: reqwest::Client, + populating: AtomicBool, } #[derive(Debug, serde::Deserialize)] @@ -17,6 +20,9 @@ struct AIArtists { const REDIS_KEY_POPULATED: &str = "rustify:ai_slop:spot_the_ai:populated"; const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:spot_the_ai:artist"; +const RETRY_DELAY: Duration = Duration::milliseconds(100); +const POPULATE_TIMEOUT: Duration = Duration::seconds(20); + impl SpotTheAIProvider { #[must_use] pub fn new() -> Self { @@ -29,6 +35,7 @@ impl SpotTheAIProvider { ) .build() .expect("Should work"), + populating: AtomicBool::new(false), } } @@ -36,23 +43,47 @@ impl SpotTheAIProvider { &self, redis_conn: &mut deadpool_redis::Connection, ) -> anyhow::Result<()> { - let exists: bool = redis_conn.exists(REDIS_KEY_POPULATED).await?; - - if exists { + if redis_conn.exists(REDIS_KEY_POPULATED).await? { return Ok(()); } - self.populate(redis_conn).await?; + if self + .populating + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let result = self.populate(redis_conn).await; + + if result.is_ok() { + let _: () = redis_conn + .set_ex( + REDIS_KEY_POPULATED, + 1, + (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, + ) + .await?; + } - let _: () = redis_conn - .set_ex( - REDIS_KEY_POPULATED, - 1, - (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, - ) - .await?; + self.populating.store(false, Ordering::SeqCst); - Ok(()) + return result; + } + + let deadline = Utc::now() + POPULATE_TIMEOUT; + + while Utc::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") } async fn populate(&self, redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index 3e0586b1..b80722e7 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -1,5 +1,7 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + use async_trait::async_trait; -use chrono::Duration; +use chrono::{Duration, Utc}; use redis::AsyncCommands as _; use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; @@ -7,6 +9,7 @@ use crate::spotify::ShortTrack; pub struct SpotifyAIBlockerProvider { client: reqwest::Client, + populating: AtomicBool, } #[derive(Debug, serde::Deserialize)] @@ -18,6 +21,9 @@ struct AIArtist { 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 { @@ -30,30 +36,55 @@ impl SpotifyAIBlockerProvider { ) .build() .expect("Should work"), + populating: AtomicBool::new(false), } } - pub async fn ensure_populated( + async fn ensure_populated( &self, redis_conn: &mut deadpool_redis::Connection, ) -> anyhow::Result<()> { - let exists: bool = redis_conn.exists(REDIS_KEY_POPULATED).await?; - - if exists { + if redis_conn.exists(REDIS_KEY_POPULATED).await? { return Ok(()); } - self.populate(redis_conn).await?; + if self + .populating + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let result = self.populate(redis_conn).await; + + if result.is_ok() { + let _: () = redis_conn + .set_ex( + REDIS_KEY_POPULATED, + 1, + (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, + ) + .await?; + } - let _: () = redis_conn - .set_ex( - REDIS_KEY_POPULATED, - 1, - (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, - ) - .await?; + self.populating.store(false, Ordering::SeqCst); - Ok(()) + return result; + } + + let deadline = Utc::now() + POPULATE_TIMEOUT; + + while Utc::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") } async fn populate(&self, redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { From abb4062d712ef867ec34643c13aac0d7b8a4413f Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Mar 2026 16:30:43 +0300 Subject: [PATCH 20/44] ISSUE-67: Add tracing instrumentation to AI slop detection Add tracing::instrument attributes to all async functions in AI slop detection providers for better observability and debugging. --- src/services/ai_slop_detection/mod.rs | 1 + src/services/ai_slop_detection/shlabs.rs | 2 ++ src/services/ai_slop_detection/soul_over_ai.rs | 6 ++++++ src/services/ai_slop_detection/spot_the_ai.rs | 6 ++++++ src/services/ai_slop_detection/spotify_ai_blocker.rs | 6 ++++++ 5 files changed, 21 insertions(+) diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index aebead23..c62be0b5 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -87,6 +87,7 @@ impl AISlopDetectionService { } } + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] pub async fn is_track_ai( &self, redis_conn: &mut deadpool_redis::Connection, diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index 9f154780..3b45aa4f 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -74,6 +74,7 @@ impl SHLabsProvider { } } + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] async fn fetch( &self, redis_conn: &mut deadpool_redis::Connection, @@ -114,6 +115,7 @@ impl SHLabsProvider { #[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, diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 08ba3e97..f06e96e2 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -40,6 +40,7 @@ impl SoulOverAIProvider { } } + #[tracing::instrument(skip_all)] async fn ensure_populated( &self, redis_conn: &mut deadpool_redis::Connection, @@ -87,6 +88,7 @@ impl SoulOverAIProvider { 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"); @@ -118,6 +120,7 @@ impl SoulOverAIProvider { Ok(()) } + #[tracing::instrument(skip_all, fields(%artist_id))] async fn is_artist_ai( redis_conn: &mut deadpool_redis::Connection, artist_id: &str, @@ -129,6 +132,7 @@ impl SoulOverAIProvider { Ok(exists) } + #[tracing::instrument(skip_all, fields(artist_ids = artist_ids.join(", ")))] async fn any_artist_ai( &self, redis_conn: &mut deadpool_redis::Connection, @@ -143,6 +147,7 @@ impl SoulOverAIProvider { Ok(false) } + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] pub async fn is_track_ai( &self, redis_conn: &mut deadpool_redis::Connection, @@ -156,6 +161,7 @@ impl SoulOverAIProvider { #[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, diff --git a/src/services/ai_slop_detection/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs index 2305e3e6..808142fa 100644 --- a/src/services/ai_slop_detection/spot_the_ai.rs +++ b/src/services/ai_slop_detection/spot_the_ai.rs @@ -39,6 +39,7 @@ impl SpotTheAIProvider { } } + #[tracing::instrument(skip_all)] async fn ensure_populated( &self, redis_conn: &mut deadpool_redis::Connection, @@ -86,6 +87,7 @@ impl SpotTheAIProvider { 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 spot-the-ai DB of AI slop"); @@ -113,6 +115,7 @@ impl SpotTheAIProvider { Ok(()) } + #[tracing::instrument(skip_all, fields(%artist_name))] async fn is_artist_ai( redis_conn: &mut deadpool_redis::Connection, artist_name: &str, @@ -127,6 +130,7 @@ impl SpotTheAIProvider { Ok(exists) } + #[tracing::instrument(skip_all, fields(artist_names = artist_names.join(", ")))] async fn any_artist_ai( &self, redis_conn: &mut deadpool_redis::Connection, @@ -141,6 +145,7 @@ impl SpotTheAIProvider { Ok(false) } + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] pub async fn is_track_ai( &self, redis_conn: &mut deadpool_redis::Connection, @@ -154,6 +159,7 @@ impl SpotTheAIProvider { #[async_trait] impl AISlopDetector for SpotTheAIProvider { + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] async fn detect( &self, redis_conn: &mut deadpool_redis::Connection, diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index b80722e7..6a2c7c3b 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -40,6 +40,7 @@ impl SpotifyAIBlockerProvider { } } + #[tracing::instrument(skip_all)] async fn ensure_populated( &self, redis_conn: &mut deadpool_redis::Connection, @@ -87,6 +88,7 @@ impl SpotifyAIBlockerProvider { 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"); @@ -116,6 +118,7 @@ impl SpotifyAIBlockerProvider { Ok(()) } + #[tracing::instrument(skip_all, fields(%artist_id))] async fn is_artist_ai( redis_conn: &mut deadpool_redis::Connection, artist_id: &str, @@ -127,6 +130,7 @@ impl SpotifyAIBlockerProvider { Ok(exists) } + #[tracing::instrument(skip_all, fields(artist_ids = artist_ids.join(", ")))] async fn any_artist_ai( &self, redis_conn: &mut deadpool_redis::Connection, @@ -141,6 +145,7 @@ impl SpotifyAIBlockerProvider { Ok(false) } + #[tracing::instrument(skip_all, fields(track_id = %track.id()))] pub async fn is_track_ai( &self, redis_conn: &mut deadpool_redis::Connection, @@ -154,6 +159,7 @@ impl SpotifyAIBlockerProvider { #[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, From 1b869eac72ed5e1982bf19a7e525f72d1fe073ef Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Mar 2026 18:54:57 +0300 Subject: [PATCH 21/44] fix skipped --- src/queue/track_check.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index ccd7b40c..3fa3ee55 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -301,7 +301,7 @@ pub async fn check_ai_slop( return Ok(AISlopCheckResult { is_ai_slop: true, - skipped: true, + skipped: false, }); } From be0a47f5c75c2e386ce4b78ff818f9af8f2fc810 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Mar 2026 19:00:34 +0300 Subject: [PATCH 22/44] ISSUE-67: Fix atomic flag not reset on Redis write failure Ensure populating flag is always reset to false even if set_ex fails, preventing the flag from getting stuck and blocking all future requests. --- src/services/ai_slop_detection/soul_over_ai.rs | 11 +++++++---- src/services/ai_slop_detection/spot_the_ai.rs | 11 +++++++---- src/services/ai_slop_detection/spotify_ai_blocker.rs | 11 +++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index f06e96e2..90d73240 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -56,15 +56,18 @@ impl SoulOverAIProvider { { let result = self.populate(redis_conn).await; - if result.is_ok() { - let _: () = redis_conn + let result = if result.is_ok() { + redis_conn .set_ex( REDIS_KEY_POPULATED, 1, (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, ) - .await?; - } + .await + .map_err(Into::into) + } else { + result + }; self.populating.store(false, Ordering::SeqCst); diff --git a/src/services/ai_slop_detection/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs index 808142fa..2e1c8e07 100644 --- a/src/services/ai_slop_detection/spot_the_ai.rs +++ b/src/services/ai_slop_detection/spot_the_ai.rs @@ -55,15 +55,18 @@ impl SpotTheAIProvider { { let result = self.populate(redis_conn).await; - if result.is_ok() { - let _: () = redis_conn + let result = if result.is_ok() { + redis_conn .set_ex( REDIS_KEY_POPULATED, 1, (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, ) - .await?; - } + .await + .map_err(Into::into) + } else { + result + }; self.populating.store(false, Ordering::SeqCst); diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index 6a2c7c3b..e420bce0 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -56,15 +56,18 @@ impl SpotifyAIBlockerProvider { { let result = self.populate(redis_conn).await; - if result.is_ok() { - let _: () = redis_conn + let result = if result.is_ok() { + redis_conn .set_ex( REDIS_KEY_POPULATED, 1, (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, ) - .await?; - } + .await + .map_err(Into::into) + } else { + result + }; self.populating.store(false, Ordering::SeqCst); From ac8c93bd59b1609c0c15d17432aa23fd7418d099 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Mar 2026 20:03:40 +0300 Subject: [PATCH 23/44] unreachable --- src/queue/track_check.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index 3fa3ee55..d63d0919 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -319,7 +319,7 @@ pub async fn check_ai_slop( ]; let prediction = match ai_detection_result.prediction { - AISlopDetectionPrediction::HumanMade => "Impossible".into(), + 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()) From 6ca7d6b981403256e2873b4457b7b13c330d65ef Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Mar 2026 20:32:31 +0300 Subject: [PATCH 24/44] ISSUE-67: Optimize Redis writes and add auth check Use Redis pipelines for batch cache population in AI slop detection providers instead of per-artist round trips. Add Spotify auth check before showing AI slop detection settings UI. --- .../ai_slop_detection/soul_over_ai.rs | 17 +++++++++------- src/services/ai_slop_detection/spot_the_ai.rs | 20 ++++++++++++------- .../ai_slop_detection/spotify_ai_blocker.rs | 17 +++++++++------- src/telegram/actions/ai_slop_detection.rs | 6 ++++++ 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 90d73240..0151a846 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -106,20 +106,23 @@ impl SoulOverAIProvider { let artists: Vec = serde_json::from_reader(res.as_ref())?; + let expiry_seconds = Duration::days(1).num_seconds() as u64; + let mut pipe = deadpool_redis::redis::Pipeline::with_capacity(artists.len()); + for artist in artists { let Some(id) = artist.spotify else { continue; }; - let _: () = redis_conn - .set_ex( - format!("{REDIS_KEY_ARTIST_PREFIX}:{id}"), - 1, - Duration::days(1).num_seconds() as _, - ) - .await?; + pipe.cmd("SETEX") + .arg(format!("{REDIS_KEY_ARTIST_PREFIX}:{id}")) + .arg(expiry_seconds) + .arg(1) + .ignore(); } + let _: () = pipe.query_async(redis_conn).await?; + Ok(()) } diff --git a/src/services/ai_slop_detection/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs index 2e1c8e07..59412cdf 100644 --- a/src/services/ai_slop_detection/spot_the_ai.rs +++ b/src/services/ai_slop_detection/spot_the_ai.rs @@ -105,16 +105,22 @@ impl SpotTheAIProvider { let artists: AIArtists = serde_json::from_reader(res.as_ref())?; + let expiry_seconds = Duration::days(1).num_seconds() as u64; + let mut pipe = deadpool_redis::redis::Pipeline::with_capacity(artists.artists.len()); + for artist_name in artists.artists { - let _: () = redis_conn - .set_ex( - format!("{REDIS_KEY_ARTIST_PREFIX}:{:?}", md5::compute(artist_name)), - 1, - Duration::days(1).num_seconds() as _, - ) - .await?; + pipe.cmd("SETEX") + .arg(format!( + "{REDIS_KEY_ARTIST_PREFIX}:{:?}", + md5::compute(artist_name) + )) + .arg(expiry_seconds) + .arg(1) + .ignore(); } + let _: () = pipe.query_async(redis_conn).await?; + Ok(()) } diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index e420bce0..10ff13ea 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -106,18 +106,21 @@ impl SpotifyAIBlockerProvider { let mut rdr = csv::Reader::from_reader(res.as_ref()); + let expiry_seconds = Duration::days(1).num_seconds() as u64; + let mut pipe = deadpool_redis::redis::Pipeline::new(); + for result in rdr.deserialize() { let record: AIArtist = result?; - let _: () = redis_conn - .set_ex( - format!("{REDIS_KEY_ARTIST_PREFIX}:{}", record.id), - 1, - Duration::days(1).num_seconds() as _, - ) - .await?; + 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(()) } diff --git a/src/telegram/actions/ai_slop_detection.rs b/src/telegram/actions/ai_slop_detection.rs index 26645c4d..9174b0df 100644 --- a/src/telegram/actions/ai_slop_detection.rs +++ b/src/telegram/actions/ai_slop_detection.rs @@ -76,6 +76,12 @@ pub async fn handle( 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, From 2bc735e2879d6c2aa45f54a122c511aa43fa79ff Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 17:56:32 +0300 Subject: [PATCH 25/44] ISSUE-67: Remove SpotTheAI provider Name-based artist matching produces false positives. Remove the SpotTheAI provider and its md5 dependency. --- Cargo.lock | 7 - Cargo.toml | 1 - src/services/ai_slop_detection/mod.rs | 8 - src/services/ai_slop_detection/spot_the_ai.rs | 191 ------------------ 4 files changed, 207 deletions(-) delete mode 100644 src/services/ai_slop_detection/spot_the_ai.rs diff --git a/Cargo.lock b/Cargo.lock index 982d80ae..6c65cc47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2234,12 +2234,6 @@ dependencies = [ "digest", ] -[[package]] -name = "md5" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" - [[package]] name = "memchr" version = "2.7.6" @@ -3441,7 +3435,6 @@ dependencies = [ "influxdb", "isolang", "itertools 0.14.0", - "md5", "prometheus", "rand 0.10.0", "redis 0.32.7", diff --git a/Cargo.toml b/Cargo.toml index 71f6da10..9bd50dc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,6 @@ whatlang = { version = "0.18.0", features = ["serde"] } redis = "0.32.7" prometheus = { version = "0.14.0", features = ["push", "process"] } csv = "1.4.0" -md5 = "0.8.0" async-trait = "0.1.89" [dependencies.isolang] diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index c62be0b5..56c9a867 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -1,11 +1,9 @@ mod shlabs; mod soul_over_ai; -mod spot_the_ai; mod spotify_ai_blocker; use async_trait::async_trait; use soul_over_ai::SoulOverAIProvider; -use spot_the_ai::SpotTheAIProvider; use spotify_ai_blocker::SpotifyAIBlockerProvider; use crate::spotify::ShortTrack; @@ -13,14 +11,12 @@ use crate::spotify::ShortTrack; pub struct AISlopDetectionService { spotify_ai_blocker: SpotifyAIBlockerProvider, soul_over_ai: SoulOverAIProvider, - spot_the_ai: SpotTheAIProvider, shlabs: Option, } pub enum Provider { SpotifyAIBlocker, SoulOverAI, - SpotTheAI, SHLabs, } @@ -61,7 +57,6 @@ impl Provider { match self { Self::SpotifyAIBlocker => "https://github.com/CennoxX/spotify-ai-blocker", Self::SoulOverAI => "https://github.com/xoundbyte/soul-over-ai", - Self::SpotTheAI => "https://spot-the-ai.com/list/", Self::SHLabs => "https://www.submithub.com/ai-song-checker", } } @@ -70,7 +65,6 @@ impl Provider { match self { Self::SpotifyAIBlocker => "Spotify AI Music Blocker", Self::SoulOverAI => "Soul Over AI", - Self::SpotTheAI => "SpotAI", Self::SHLabs => "SubmitHub AI Song Checker", } } @@ -82,7 +76,6 @@ impl AISlopDetectionService { Self { spotify_ai_blocker: SpotifyAIBlockerProvider::new(), soul_over_ai: SoulOverAIProvider::new(), - spot_the_ai: SpotTheAIProvider::new(), shlabs: shlabs_api_key.map(shlabs::SHLabsProvider::new), } } @@ -119,7 +112,6 @@ impl AISlopDetectionService { handle_provider!(Provider::SoulOverAI, &self.soul_over_ai); handle_provider!(Provider::SpotifyAIBlocker, &self.spotify_ai_blocker); - handle_provider!(Provider::SpotTheAI, &self.spot_the_ai); if let Some(shlabs) = &self.shlabs { handle_provider!(Provider::SHLabs, shlabs); diff --git a/src/services/ai_slop_detection/spot_the_ai.rs b/src/services/ai_slop_detection/spot_the_ai.rs deleted file mode 100644 index 59412cdf..00000000 --- a/src/services/ai_slop_detection/spot_the_ai.rs +++ /dev/null @@ -1,191 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; - -use async_trait::async_trait; -use chrono::{Duration, Utc}; -use redis::AsyncCommands as _; - -use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; -use crate::spotify::ShortTrack; - -pub struct SpotTheAIProvider { - client: reqwest::Client, - populating: AtomicBool, -} - -#[derive(Debug, serde::Deserialize)] -struct AIArtists { - artists: Vec, -} - -const REDIS_KEY_POPULATED: &str = "rustify:ai_slop:spot_the_ai:populated"; -const REDIS_KEY_ARTIST_PREFIX: &str = "rustify:ai_slop:spot_the_ai:artist"; - -const RETRY_DELAY: Duration = Duration::milliseconds(100); -const POPULATE_TIMEOUT: Duration = Duration::seconds(20); - -impl SpotTheAIProvider { - #[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 = Utc::now() + POPULATE_TIMEOUT; - - while Utc::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 spot-the-ai DB of AI slop"); - - let res = self - .client - .get("https://spot-the-ai.com/api/list/") - .send() - .await? - .error_for_status()? - .bytes() - .await?; - - let artists: AIArtists = serde_json::from_reader(res.as_ref())?; - - let expiry_seconds = Duration::days(1).num_seconds() as u64; - let mut pipe = deadpool_redis::redis::Pipeline::with_capacity(artists.artists.len()); - - for artist_name in artists.artists { - pipe.cmd("SETEX") - .arg(format!( - "{REDIS_KEY_ARTIST_PREFIX}:{:?}", - md5::compute(artist_name) - )) - .arg(expiry_seconds) - .arg(1) - .ignore(); - } - - let _: () = pipe.query_async(redis_conn).await?; - - Ok(()) - } - - #[tracing::instrument(skip_all, fields(%artist_name))] - async fn is_artist_ai( - redis_conn: &mut deadpool_redis::Connection, - artist_name: &str, - ) -> anyhow::Result { - let exists: bool = redis_conn - .exists(format!( - "{REDIS_KEY_ARTIST_PREFIX}:{:?}", - md5::compute(artist_name) - )) - .await?; - - Ok(exists) - } - - #[tracing::instrument(skip_all, fields(artist_names = artist_names.join(", ")))] - async fn any_artist_ai( - &self, - redis_conn: &mut deadpool_redis::Connection, - artist_names: &[&str], - ) -> anyhow::Result { - for artist_name in artist_names { - if Self::is_artist_ai(redis_conn, artist_name).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_names()).await - } -} - -#[async_trait] -impl AISlopDetector for SpotTheAIProvider { - #[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 SpotTheAIProvider { - fn default() -> Self { - Self::new() - } -} From cd271d6d7ab2a76466c2cbc80b1accce621006e2 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 17:57:39 +0300 Subject: [PATCH 26/44] ISSUE-67: Use dynamic button labels in action messages Pass translated button labels as template parameters instead of hardcoding text, ensuring message instructions match actual buttons. --- locales/actions.yml | 8 ++++---- src/telegram/actions/dislike.rs | 3 ++- src/telegram/actions/ignore.rs | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) 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/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())) From fc507c43c11c603c1cd67c9478448718584f7b19 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 18:17:02 +0300 Subject: [PATCH 27/44] ISSUE-67: Skip AI detection for tracks released before 2024 Parse album release date from Spotify and skip AI slop detection for tracks released before January 2024, reducing false positives on older music. --- src/services/ai_slop_detection/mod.rs | 46 ++++++++++++++++++++++++--- src/spotify/mod.rs | 17 +++++++++- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/services/ai_slop_detection/mod.rs b/src/services/ai_slop_detection/mod.rs index 56c9a867..22f3e26e 100644 --- a/src/services/ai_slop_detection/mod.rs +++ b/src/services/ai_slop_detection/mod.rs @@ -3,6 +3,7 @@ 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; @@ -20,13 +21,15 @@ pub enum Provider { SHLabs, } +#[derive(Default)] pub struct AISlopDetectionResult { pub provider: Option, pub prediction: AISlopDetectionPrediction, } -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum AISlopDetectionPrediction { + #[default] HumanMade, PureAI, ProcessedAI, @@ -80,6 +83,14 @@ impl AISlopDetectionService { } } + 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, @@ -110,6 +121,10 @@ impl AISlopDetectionService { }; } + 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); @@ -117,9 +132,30 @@ impl AISlopDetectionService { handle_provider!(Provider::SHLabs, shlabs); } - Ok(AISlopDetectionResult { - provider: None, - prediction: AISlopDetectionPrediction::HumanMade, - }) + 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/spotify/mod.rs b/src/spotify/mod.rs index be052fea..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 _}; @@ -89,6 +89,7 @@ pub struct ShortTrack { artist_urls: Vec, album_name: String, album_url: String, + album_release_date: Option, } impl ShortTrack { @@ -142,6 +143,15 @@ impl ShortTrack { .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) + }), } } @@ -237,6 +247,11 @@ impl ShortTrack { 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 + } } impl From for ShortTrack { From 1d044d38f276b08f65b9ec1a7518b971fa2a31b7 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 18:33:43 +0300 Subject: [PATCH 28/44] ISSUE-67: Improve AI slop alert message wording Clarify button actions: "dislike" to "block track" and "current track" to "this track" for better user understanding. --- locales/ai_slop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/ai_slop.yml b/locales/ai_slop.yml index eee9fed9..98e94aa1 100644 --- a/locales/ai_slop.yml +++ b/locales/ai_slop.yml @@ -5,7 +5,7 @@ ai-slop.alert: 🎵 %{track_name} Album: %{album_name} - 💩 This track is most likely %{prediction}. You can dislike or ignore similar notifications for the current track + 💩 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 @@ -21,7 +21,7 @@ ai-slop.alert: 🎵 %{track_name} Альбом: %{album_name} - 💩 Этот трек скорее всего %{prediction}. Вы можете дизлайкнуть или отключить подобные уведомления для текущего трека + 💩 Этот трек скорее всего %{prediction}. Вы можете заблокировать трек или отключить подобные уведомления для этого трека Чтобы навсегда скрыть этого исполнителя из рекомендаций: 🔗 Откройте страницу исполнителя в Spotify From 645ee3a2801c3e4d04d083858a8acd667ef4784b Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 18:40:31 +0300 Subject: [PATCH 29/44] ISSUE-67: Show AI slop config in admin user details Display the AI slop detection configuration setting in the admin user details view. --- src/telegram/actions/admin_users/details.rs | 2 ++ 1 file changed, 2 insertions(+) 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"), From 7dc87032eac71e4626209ba9a9c5fa5b9119df1b Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 18:43:15 +0300 Subject: [PATCH 30/44] ISSUE-67: Move skip counter increment to caller Centralize skip counting in the consume function rather than inside check_ai_slop for better separation of concerns. --- src/queue/track_check.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index d63d0919..6d83522c 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -73,6 +73,10 @@ pub async fn consume(data: TrackCheckQueueTask, app: Data<&'static App>) -> anyh .context("Check AI Slop")?; if res.skipped { + TrackStatusService::increase_skips(app.db(), user_state.user_id(), data.track.id()) + .await + .ok(); + return Ok(()); } @@ -283,8 +287,6 @@ pub async fn check_ai_slop( .await .context("Skip current track")?; - TrackStatusService::increase_skips(app.db(), state.user_id(), track.id()).await?; - return Ok(AISlopCheckResult { is_ai_slop: true, skipped: true, From 13b030a7cae7aa3575982eb0dbbfeb66e7cb5931 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 18:45:18 +0300 Subject: [PATCH 31/44] ISSUE-67: Log errors when incrementing skip counter Replace silent .ok() with tracing::error for better observability when the skip stats increment fails. --- src/queue/track_check.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index 6d83522c..54021d7d 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -73,9 +73,12 @@ pub async fn consume(data: TrackCheckQueueTask, app: Data<&'static App>) -> anyh .context("Check AI Slop")?; if res.skipped { - TrackStatusService::increase_skips(app.db(), user_state.user_id(), data.track.id()) - .await - .ok(); + 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(()); } From b0e76a68834f182f1498192107b666d774f81679 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 19:10:12 +0300 Subject: [PATCH 32/44] ISSUE-67: Add rate limiting for SHLabs provider Pause provider until midnight UTC (max 1 hour) when receiving 429 or when daily quota is exhausted. Return default prediction when rate limited instead of logging errors. --- src/services/ai_slop_detection/shlabs.rs | 56 ++++++++++++++++++++---- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index 3b45aa4f..64de39e2 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use chrono::Duration; +use chrono::{Duration, Timelike as _, Utc}; use redis::AsyncTypedCommands as _; use serde_json::json; @@ -52,6 +52,7 @@ pub struct Usage { } 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, @@ -74,21 +75,47 @@ impl SHLabsProvider { } } + async fn rate_limit(redis_conn: &mut deadpool_redis::Connection) -> anyhow::Result<()> { + 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_hour = Duration::hours(1).num_seconds() as u64; + + tracing::warn!(seconds_until_midnight, "SHLabs rate limited, pausing"); + + let _: () = redis_conn + .set_ex( + REDIS_KEY_RATE_LIMITED, + 1, + seconds_until_midnight.min(seconds_hour), + ) + .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 { + ) -> anyhow::Result> { + if redis_conn.exists(REDIS_KEY_RATE_LIMITED).await? { + return Ok(None); + } + 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(data); + return Ok(Some(data)); } - let res: Root = self + let response = self .client .post("https://shlabs.music/api/v1/detect") .header("X-API-Key", &self.api_key) @@ -96,11 +123,16 @@ impl SHLabsProvider { "spotifyTrackId": track.id(), })) .send() - .await? - .error_for_status()? - .json() .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, @@ -109,7 +141,11 @@ impl SHLabsProvider { ) .await?; - Ok(res) + if res.usage.daily_remaining == 0 { + Self::rate_limit(redis_conn).await.ok(); + } + + Ok(Some(res)) } } @@ -121,7 +157,9 @@ impl AISlopDetector for SHLabsProvider { redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, ) -> anyhow::Result { - let res = self.fetch(redis_conn, track).await?; + let Some(res) = self.fetch(redis_conn, track).await? else { + return Ok(AISlopDetectionPrediction::default()); + }; Ok(match res.result.prediction { Prediction::HumanMade => AISlopDetectionPrediction::HumanMade, From 479f134e3b4b59922558c41cf54e1b75a5e5cd27 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 19:17:41 +0300 Subject: [PATCH 33/44] ISSUE-67: Use Instant for populate timeout Switch from Utc::now() to std::time::Instant for deadline comparison which is more appropriate for measuring elapsed time. --- src/services/ai_slop_detection/spotify_ai_blocker.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index 10ff13ea..f0c37047 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -1,4 +1,5 @@ use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; use async_trait::async_trait; use chrono::{Duration, Utc}; @@ -74,9 +75,9 @@ impl SpotifyAIBlockerProvider { return result; } - let deadline = Utc::now() + POPULATE_TIMEOUT; + let deadline = Instant::now() + POPULATE_TIMEOUT.to_std().expect("positive duration"); - while Utc::now() < deadline { + while Instant::now() < deadline { tokio::time::sleep(RETRY_DELAY.to_std().expect("positive duration")).await; if !self.populating.load(Ordering::SeqCst) { From 7bf623fdb59dcc4f86fbf1d20cfaffa363c45c5b Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 19:21:50 +0300 Subject: [PATCH 34/44] ISSUE-67: Log actual pause duration in rate limit warning Extract pause duration to variable and log it instead of seconds_until_midnight for accurate debugging info. --- src/services/ai_slop_detection/shlabs.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index 64de39e2..5ed8cee6 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -84,14 +84,12 @@ impl SHLabsProvider { let seconds_hour = Duration::hours(1).num_seconds() as u64; - tracing::warn!(seconds_until_midnight, "SHLabs rate limited, pausing"); + 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_until_midnight.min(seconds_hour), - ) + .set_ex(REDIS_KEY_RATE_LIMITED, 1, seconds_pause) .await?; Ok(()) From 209c11a70528376b8072050d7dbeb49b7270e778 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 19:48:16 +0300 Subject: [PATCH 35/44] ISSUE-67: Use Instant for populate timeout in SoulOverAI Switch from Utc::now() to std::time::Instant for deadline comparison which is more appropriate for measuring elapsed time. --- src/services/ai_slop_detection/soul_over_ai.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 0151a846..2d556042 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -1,4 +1,5 @@ use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; use async_trait::async_trait; use chrono::{Duration, Utc}; @@ -74,9 +75,9 @@ impl SoulOverAIProvider { return result; } - let deadline = Utc::now() + POPULATE_TIMEOUT; + let deadline = Instant::now() + POPULATE_TIMEOUT.to_std().expect("to be positive"); - while Utc::now() < deadline { + while Instant::now() < deadline { tokio::time::sleep(RETRY_DELAY.to_std().expect("positive duration")).await; if !self.populating.load(Ordering::SeqCst) { From e8c4693c69748009239da1c18a81a504b9d3cee9 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 19:53:11 +0300 Subject: [PATCH 36/44] ISSUE-67: Remove unused Utc import and use const for hour Clean up imports after switching to Instant for timeout calculations. Move seconds_hour to compile-time const in rate_limit function. --- src/services/ai_slop_detection/shlabs.rs | 6 +++--- src/services/ai_slop_detection/soul_over_ai.rs | 2 +- src/services/ai_slop_detection/spotify_ai_blocker.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index 5ed8cee6..e4017c1f 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -76,15 +76,15 @@ impl SHLabsProvider { } 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_hour = Duration::hours(1).num_seconds() as u64; - - let seconds_pause = seconds_until_midnight.min(seconds_hour); + let seconds_pause = seconds_until_midnight.min(SECONDS_HOUR); tracing::warn!(seconds_pause, "SHLabs rate limited, pausing"); diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 2d556042..244ee9ff 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; use async_trait::async_trait; -use chrono::{Duration, Utc}; +use chrono::Duration; use redis::AsyncCommands as _; use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index f0c37047..d85e0b8a 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; use async_trait::async_trait; -use chrono::{Duration, Utc}; +use chrono::Duration; use redis::AsyncCommands as _; use crate::services::ai_slop_detection::{AISlopDetectionPrediction, AISlopDetector}; From 56fc00b5aa06e838bc9ec3f71444d04f566fd1f7 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 19:59:00 +0300 Subject: [PATCH 37/44] ISSUE-67: Use const for expiry seconds in SoulOverAI populate Convert runtime variable to compile-time constant for cache TTL. --- src/services/ai_slop_detection/soul_over_ai.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 244ee9ff..10387488 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -107,9 +107,10 @@ impl SoulOverAIProvider { let artists: Vec = serde_json::from_reader(res.as_ref())?; - let expiry_seconds = Duration::days(1).num_seconds() as u64; 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; @@ -117,7 +118,7 @@ impl SoulOverAIProvider { pipe.cmd("SETEX") .arg(format!("{REDIS_KEY_ARTIST_PREFIX}:{id}")) - .arg(expiry_seconds) + .arg(EXPIRY_SECONDS) .arg(1) .ignore(); } From 6c53b323064a55aeafb72ce598162887dd936ac8 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 20:02:29 +0300 Subject: [PATCH 38/44] ISSUE-67: Use const for expiry seconds in SpotifyAIBlocker Convert runtime variable to compile-time constant for cache TTL. --- src/services/ai_slop_detection/spotify_ai_blocker.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index d85e0b8a..5a937a0f 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -107,15 +107,16 @@ impl SpotifyAIBlockerProvider { let mut rdr = csv::Reader::from_reader(res.as_ref()); - let expiry_seconds = Duration::days(1).num_seconds() as u64; 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(EXPIRY_SECONDS) .arg(1) .ignore(); } From 3b587c9072790c9ea5a90ab1ced612db8c86ad62 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 20:12:55 +0300 Subject: [PATCH 39/44] ISSUE-67: Separate URL action buttons from callback buttons Extract ArtistPage to new InlineButtonsActions type for URL-based buttons. This separates buttons that open URLs from those with callback data. --- src/queue/track_check.rs | 3 ++- src/telegram/handlers/inline_buttons.rs | 1 - src/telegram/inline_buttons.rs | 9 +------ src/telegram/inline_buttons_actions.rs | 36 +++++++++++++++++++++++++ src/telegram/mod.rs | 1 + 5 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 src/telegram/inline_buttons_actions.rs diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index 54021d7d..23ce5b69 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -21,6 +21,7 @@ use crate::services::{ 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 _; @@ -318,7 +319,7 @@ pub async fn check_ai_slop( 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![ - InlineButtons::ArtistPage(track.first_artist_url().parse()?) + InlineButtonsActions::ArtistPage(track.first_artist_url().parse()?) .into_inline_keyboard_button(state.locale()), ], ]; diff --git a/src/telegram/handlers/inline_buttons.rs b/src/telegram/handlers/inline_buttons.rs index ed88dcf7..5e871a75 100644 --- a/src/telegram/handlers/inline_buttons.rs +++ b/src/telegram/handlers/inline_buttons.rs @@ -151,7 +151,6 @@ 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::ArtistPage(_) => (), InlineButtons::AISlopDetection(status, _) => { actions::ai_slop_detection::handle_inline(app, state, q, status).await?; }, diff --git a/src/telegram/inline_buttons.rs b/src/telegram/inline_buttons.rs index e774f20f..d87cf1d7 100644 --- a/src/telegram/inline_buttons.rs +++ b/src/telegram/inline_buttons.rs @@ -3,7 +3,6 @@ use std::fmt::{Display, Formatter}; use std::str::FromStr; use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; -use url::Url; use crate::entity::prelude::{TrackStatus, UserAISlopDetection}; @@ -17,8 +16,6 @@ pub enum InlineButtons { AISlopDetection(UserAISlopDetection, bool), SkippageEnable(bool), Recommendasion, - // TODO: Think about making separate type of buttons without callback data - ArtistPage(Url), } impl InlineButtons { @@ -38,7 +35,6 @@ impl InlineButtons { t!("skippage.disable-button", locale = locale) } }, - Self::ArtistPage(_) => t!("inline-buttons.artist-page", locale = locale), Self::AISlopDetection(status, selected) => { let mark = if *selected { "✅ " } else { "" }; match status { @@ -100,10 +96,7 @@ impl InlineButtons { #[allow(clippy::from_over_into)] impl Into for InlineButtons { fn into(self) -> InlineKeyboardButtonKind { - match self { - Self::ArtistPage(url) => InlineKeyboardButtonKind::Url(url), - _ => InlineKeyboardButtonKind::CallbackData(self.to_string()), - } + InlineKeyboardButtonKind::CallbackData(self.to_string()) } } 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; From 32617b0309fc51094c9ed60bb920703646a637b4 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 20:18:13 +0300 Subject: [PATCH 40/44] ISSUE-67: Simplify AI slop skip logic for non-premium users Remove explicit "cannot skip" error message. Non-premium users in skip mode now see the standard AI slop notification instead. --- src/queue/track_check.rs | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/src/queue/track_check.rs b/src/queue/track_check.rs index 23ce5b69..33fb9117 100644 --- a/src/queue/track_check.rs +++ b/src/queue/track_check.rs @@ -282,35 +282,22 @@ pub async fn check_ai_slop( }); } - if state.user().cfg_ai_slop_detection.is_skip() { - if 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, - }); - } - - let text = t!( - "error.cannot-skip", - locale = state.locale(), - track_name = track.track_tg_link(), - ); - - app.bot().send_message(state.chat_id()?, text).await?; + 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: false, + 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"); }; From 3e99ce7663a7c51b1a4cc44665ea83377f971f01 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 20:38:22 +0300 Subject: [PATCH 41/44] ISSUE-67: Clean up serde imports Use Deserialize from prelude instead of fully qualified serde::Deserialize. Remove unused import in workers/server.rs. --- src/services/ai_slop_detection/soul_over_ai.rs | 2 +- src/services/ai_slop_detection/spotify_ai_blocker.rs | 2 +- src/workers/server.rs | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index 10387488..afb3c0a7 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -13,7 +13,7 @@ pub struct SoulOverAIProvider { populating: AtomicBool, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Deserialize)] struct AIArtist { // name: String, spotify: Option, diff --git a/src/services/ai_slop_detection/spotify_ai_blocker.rs b/src/services/ai_slop_detection/spotify_ai_blocker.rs index 5a937a0f..45195ddc 100644 --- a/src/services/ai_slop_detection/spotify_ai_blocker.rs +++ b/src/services/ai_slop_detection/spotify_ai_blocker.rs @@ -13,7 +13,7 @@ pub struct SpotifyAIBlockerProvider { populating: AtomicBool, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Deserialize)] struct AIArtist { // artist: String, id: String, 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 _; From dc15ea3936501190f04f1382324515d3ec64afa9 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 8 Mar 2026 20:40:35 +0300 Subject: [PATCH 42/44] ISSUE-67: Check cache before rate limit in SHLabs fetch Reorder rate limit check after cache lookup to return cached results even when rate limited. Also remove unused Serialize import. --- src/lyrics/mod.rs | 1 - src/services/ai_slop_detection/shlabs.rs | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) 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/services/ai_slop_detection/shlabs.rs b/src/services/ai_slop_detection/shlabs.rs index e4017c1f..3769e816 100644 --- a/src/services/ai_slop_detection/shlabs.rs +++ b/src/services/ai_slop_detection/shlabs.rs @@ -101,10 +101,6 @@ impl SHLabsProvider { redis_conn: &mut deadpool_redis::Connection, track: &ShortTrack, ) -> anyhow::Result> { - if redis_conn.exists(REDIS_KEY_RATE_LIMITED).await? { - return Ok(None); - } - let track_key = format!("{REDIS_KEY_TRACK_PREFIX}:{}", track.id()); if let Some(data) = redis_conn.get(&track_key).await? @@ -113,6 +109,10 @@ impl SHLabsProvider { 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") From 48e4262dbb4bdb1fcf12e9350b04039188b28e03 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 9 Mar 2026 15:47:38 +0300 Subject: [PATCH 43/44] exact text --- locales/ai_slop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/ai_slop.yml b/locales/ai_slop.yml index 98e94aa1..2e9ff2c4 100644 --- a/locales/ai_slop.yml +++ b/locales/ai_slop.yml @@ -26,7 +26,7 @@ ai-slop.alert: Чтобы навсегда скрыть этого исполнителя из рекомендаций: 🔗 Откройте страницу исполнителя в Spotify Нажмите на три точки рядом с кнопкой «Подписаться» - Выберите «Не воспроизводить этого исполнителя» + Выберите «Не включать треки этого исполнителя» Информация предоставлена "%{ai_check_provider}" From 12d82990eaa1da905855d10b75f8e55af7e74fb9 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 9 Mar 2026 16:14:00 +0300 Subject: [PATCH 44/44] ISSUE-67: Extract expiry calculation with explanatory comment Add comment explaining 10-minute overlap before cache entries expire. --- src/services/ai_slop_detection/soul_over_ai.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/ai_slop_detection/soul_over_ai.rs b/src/services/ai_slop_detection/soul_over_ai.rs index afb3c0a7..985cebd7 100644 --- a/src/services/ai_slop_detection/soul_over_ai.rs +++ b/src/services/ai_slop_detection/soul_over_ai.rs @@ -58,12 +58,12 @@ impl SoulOverAIProvider { 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, - (Duration::days(1) - Duration::minutes(10)).num_seconds() as _, - ) + .set_ex(REDIS_KEY_POPULATED, 1, expiry_seconds) .await .map_err(Into::into) } else {