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