From 7c15eec10d8ed41e733d08e27cfbdc9ad65eeeba Mon Sep 17 00:00:00 2001 From: Victoria Fischer Date: Sun, 31 May 2026 18:21:05 +0200 Subject: [PATCH] main: implement mixxx sqlite reading --- Cargo.lock | 29 ++++++++++++++ Cargo.toml | 1 + src/main.rs | 110 +++++++++++++++++++++++++++------------------------ src/scene.rs | 17 ++++++-- 4 files changed, 103 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e44295..8b93d51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "scraper", "serde", "serde_json", + "sqlite", "static-iref", "throbber-widgets-tui", "tokio", @@ -3589,6 +3590,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66e9c01a11936154f3910dbba732c01f8b591543bc4d6672bddee79fd9c4783" +dependencies = [ + "sqlite3-sys", +] + +[[package]] +name = "sqlite3-src" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5b6d3c860886b0a33e69e421796a5f4a27f23597a182c2450f6d7ace5103120" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "sqlite3-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7781d97adc13a1d5081127a9ee29afad8427f3757bd984daf814d8265267039" +dependencies = [ + "sqlite3-src", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index abc6423..15cf0e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ schemars = "1.2.1" scraper = "0.27.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +sqlite = "0.37.0" static-iref = "3.0.0" throbber-widgets-tui = "0.11.0" tokio = { version = "1.52.3", features = ["full"] } diff --git a/src/main.rs b/src/main.rs index 3e44074..2f29d86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; use ratatui::{Frame, layout::{Constraint, Direction, Layout}, widgets::{Block, BorderType, Clear, List, ListDirection, ListItem, ListState, Paragraph, Wrap}}; +use sqlite::OpenFlags; use throbber_widgets_tui::{Throbber, ThrobberState}; use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}}; use tokio::sync::watch; @@ -33,7 +34,7 @@ use futures::{StreamExt, future::FutureExt}; use ratatui::prelude::*; -use crate::scene::{ConversationEntry, Scene}; +use crate::scene::{ConversationEntry, PlaylistEntry, Scene}; mod scene; @@ -62,12 +63,6 @@ struct GeneratedResponses { responses: Vec, } -#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)] -struct StageDirectionUpdate { - episode_number: Option, - narrative: Option, -} - #[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)] struct StageEventArgs { text: String, @@ -260,6 +255,7 @@ impl App { if let Ok(episode_number) = arg.trim().parse::() { self.scene.direction.episode_number = episode_number; self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated episode number: {}", self.scene.direction.episode_number))); + self.reload_mixxx_playlist(); } else { self.scene.insert_conversation(ConversationEntry::SystemMessage("Invalid episode number format. Use /episode [number]".into())); } @@ -323,23 +319,38 @@ impl App { self.is_requesting = true; } + fn reload_mixxx_playlist(&mut self) { + self.scene.direction.current_playlist.clear(); + let connection = sqlite::Connection::open_thread_safe_with_flags("mixxxdb.sqlite", OpenFlags::new().with_read_only()).unwrap(); + let query = "SELECT id FROM Playlists WHERE name = ? ORDER BY id DESC LIMIT 1"; + let mut statement = connection.prepare(query).unwrap(); + statement.bind((1, format!("BFF.fm - Episode {}", self.scene.direction.episode_number).as_str())).unwrap(); + statement.next().unwrap(); + let latest_id = statement.read::("id").unwrap(); + + let query = "SELECT title, artist, album, comment, url, bpm FROM library LEFT JOIN PlaylistTracks ON PlaylistTracks.track_id = library.id WHERE PlaylistTracks.playlist_id = ? ORDER BY position"; + for track in connection.prepare(query).unwrap().into_iter().bind((1, latest_id)).unwrap().map(|row| row.unwrap()) { + let title = track.try_read::<&str, _>("title").unwrap_or("Untitled Track"); + let artist = track.try_read::<&str, _>("artist").unwrap_or("Unknown Artist"); + let album = track.try_read::<&str, _>("album").unwrap_or("Unknown Album"); + let bpm = track.try_read::("bpm").unwrap_or(0.); + self.scene.direction.current_playlist.push(PlaylistEntry { + artist: artist.into(), + album: album.into(), + title: title.into(), + bpm + }); + } + self.scene.insert_conversation(ConversationEntry::SystemMessage("Mixxx playlist reloaded.".into())); + } + fn on_response(&mut self, response: CreateChatCompletionResponse) { self.is_requesting = false; if let Some(calls) = &response.choices[0].message.tool_calls { for call in calls { match call { ChatCompletionMessageToolCalls::Function(call) => { - if call.function.name == "set_stage_direction" { - let args: StageDirectionUpdate = serde_json::from_str(call.function.arguments.as_str()).unwrap(); - if let Some(episode_number) = args.episode_number { - self.scene.direction.episode_number = episode_number; - self.scene.insert_conversation(ConversationEntry::ShipComputer(format!("Updated episode number: {}", self.scene.direction.episode_number))); - } - if let Some(narrative) = args.narrative { - self.scene.direction.narrative = narrative; - self.scene.insert_conversation(ConversationEntry::ShipComputer(format!("Updated stage direction: {}", self.scene.direction.narrative))); - } - } else if call.function.name == "log_stage_event" { + if call.function.name == "log_stage_event" { let args: StageEventArgs = serde_json::from_str(call.function.arguments.as_str()).unwrap(); self.scene.insert_conversation(ConversationEntry::StageDirection(args.text)); } @@ -349,10 +360,15 @@ impl App { } self.regenerate_responses(); } else { - let json_resp: GeneratedResponses = serde_json::from_str(response.choices[0].message.content.as_ref().unwrap().as_str()).unwrap(); - - self.next_reply_options = json_resp.responses; - self.reply_state.select_first(); + if response.choices.is_empty() { + self.scene.insert_conversation(ConversationEntry::SystemMessage("OpenAI returned no responses".into())); + } else if response.choices[0].message.content.is_none() { + self.scene.insert_conversation(ConversationEntry::SystemMessage("OpenAI response did not contain content!".into())); + } else { + let json_resp: GeneratedResponses = serde_json::from_str(response.choices[0].message.content.as_ref().unwrap().as_str()).unwrap(); + self.next_reply_options = json_resp.responses; + self.reply_state.select_first(); + } } } } @@ -360,7 +376,6 @@ impl App { #[tokio::main] async fn main() { color_eyre::install().unwrap(); - let mut terminal: Terminal> = ratatui::init(); let (prediction_in, mut prediction_out) = tokio::sync::watch::channel(None); @@ -369,35 +384,28 @@ async fn main() { tokio::spawn(async move { let client: Client = Client::default(); loop { - prediction_request_out.changed().await.unwrap(); - let request = prediction_request_out.borrow().clone(); - let chat_request = CreateChatCompletionRequest { - tools: Some(vec![ - ChatCompletionTools::Function( - ChatCompletionTool { - function: FunctionObject { - name: "set_stage_direction".into(), - description: Some("Set or update the current stage direction instructions.".into()), - parameters: Some(schema_for!(StageDirectionUpdate).into()), - ..Default::default() + if let Ok(_) = prediction_request_out.changed().await { + let request = prediction_request_out.borrow().clone(); + let chat_request = CreateChatCompletionRequest { + /*tools: Some(vec![ + ChatCompletionTools::Function( + ChatCompletionTool { + function: FunctionObject { + name: "log_stage_event".into(), + description: Some("Log an event in the stage direction.".into()), + parameters: Some(schema_for!(StageEventArgs).into()), + ..Default::default() + } } - } - ), - ChatCompletionTools::Function( - ChatCompletionTool { - function: FunctionObject { - name: "log_stage_event".into(), - description: Some("Log an event in the stage direction.".into()), - parameters: Some(schema_for!(StageEventArgs).into()), - ..Default::default() - } - } - ) - ]), - ..request.into() - }; - let response = client.chat().create(chat_request).await.unwrap(); - prediction_in.send(Some(response)).unwrap(); + ) + ]),*/ + ..request.into() + }; + let response = client.chat().create(chat_request).await.unwrap(); + prediction_in.send(Some(response)).unwrap(); + } else { + return; + } } }); diff --git a/src/scene.rs b/src/scene.rs index bf45e7b..a179cb1 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -1,8 +1,10 @@ use async_openai::types::chat::*; use chrono::Duration; +use crossterm::event::MediaKeyCode::Play; use schemars::schema_for; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sqlite::OpenFlags; use crate::GeneratedResponses; @@ -22,7 +24,16 @@ pub struct StageDirection { pub episode_number: u32, pub time_remaining: Duration, pub narrative: String, - pub artifacts: Vec + pub artifacts: Vec, + pub current_playlist: Vec +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct PlaylistEntry { + pub artist: String, + pub album: String, + pub title: String, + pub bpm: f64 } @@ -40,7 +51,7 @@ impl Scene { impl Into for Scene { fn into(self) -> CreateChatCompletionRequest { - let mut messages = vec![ + let mut messages = vec![ ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: SYSTEM_PROMPT.into(), ..Default::default()}), ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: serde_json::to_string(&self.direction).unwrap().into(), ..Default::default()}), ]; @@ -55,7 +66,7 @@ impl Into for Scene { })); let response_schema: Value = schema_for!(GeneratedResponses).into(); CreateChatCompletionRequest { - model: "gpt-5.4-mini".into(), + model: "gpt-5.4".into(), messages: messages, max_completion_tokens: Some(350), response_format: Some(ResponseFormat::JsonSchema { json_schema: ResponseFormatJsonSchema { description: None, name: "responses".into(), schema: response_schema, strict: None } }),