main: implement mixxx sqlite reading

This commit is contained in:
2026-05-31 18:21:05 +02:00
parent cb02971480
commit 7c15eec10d
4 changed files with 103 additions and 54 deletions
+59 -51
View File
@@ -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<PossibleResponse>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
struct StageDirectionUpdate {
episode_number: Option<u32>,
narrative: Option<String>,
}
#[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::<u32>() {
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::<i64, _>("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::<f64, _>("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<CrosstermBackend<std::io::Stdout>> = 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<OpenAIConfig> = 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;
}
}
});
+14 -3
View File
@@ -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<String>
pub artifacts: Vec<String>,
pub current_playlist: Vec<PlaylistEntry>
}
#[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<CreateChatCompletionRequest> 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<CreateChatCompletionRequest> 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 } }),