From e72633f26a9ad288e259c7a86a6d19dc3b7c72b1 Mon Sep 17 00:00:00 2001 From: Jacob Date: Wed, 27 May 2026 01:28:47 -0400 Subject: [PATCH] chore: refactor into a `RecordingDataManager`, lay the ground work for a `RenderManager` --- src/call.rs | 74 +++++++------- src/command/join.rs | 2 +- src/command/mod.rs | 9 +- src/command/render.rs | 14 ++- src/heat_seek.rs | 5 +- src/lib.rs | 4 + src/main.rs | 15 ++- src/recording_data/between.rs | 44 +++++++++ src/recording_data/clip.rs | 111 +++++++++++++++++++++ src/recording_data/day.rs | 57 +++++++++++ src/recording_data/guild.rs | 22 +++++ src/recording_data/hour.rs | 58 +++++++++++ src/recording_data/microsecond.rs | 21 ++++ src/recording_data/minute.rs | 59 ++++++++++++ src/recording_data/mod.rs | 47 +++++++++ src/recording_data/month.rs | 55 +++++++++++ src/recording_data/recording.rs | 143 ++++++++++++++++++++++++++++ src/recording_data/second.rs | 25 +++++ src/recording_data/user.rs | 25 +++++ src/recording_data/voice_channel.rs | 26 +++++ src/recording_data/year.rs | 51 ++++++++++ src/render_data.rs | 12 +++ 22 files changed, 830 insertions(+), 49 deletions(-) create mode 100644 src/recording_data/between.rs create mode 100644 src/recording_data/clip.rs create mode 100644 src/recording_data/day.rs create mode 100644 src/recording_data/guild.rs create mode 100644 src/recording_data/hour.rs create mode 100644 src/recording_data/microsecond.rs create mode 100644 src/recording_data/minute.rs create mode 100644 src/recording_data/mod.rs create mode 100644 src/recording_data/month.rs create mode 100644 src/recording_data/recording.rs create mode 100644 src/recording_data/second.rs create mode 100644 src/recording_data/user.rs create mode 100644 src/recording_data/voice_channel.rs create mode 100644 src/recording_data/year.rs create mode 100644 src/render_data.rs diff --git a/src/call.rs b/src/call.rs index 3b2a2f8..ff1861f 100644 --- a/src/call.rs +++ b/src/call.rs @@ -1,6 +1,9 @@ use crate::{ - OneToManyUniqueBTreeMap, UserDataManager, option_ext::OptionExt as _, - user_capnp::user::Consent, user_data::RECORD_IF_CONSENT_UNSPECIFIED, + OneToManyUniqueBTreeMap, UserDataManager, + option_ext::OptionExt as _, + recording_data::{Clip, Recording, RecordingDataManager}, + user_capnp::user::Consent, + user_data::RECORD_IF_CONSENT_UNSPECIFIED, }; use async_trait::async_trait; use futures::FutureExt; @@ -26,7 +29,7 @@ struct Handler { start_instant: Instant, start_utc: UtcDateTime, - recording_data: Operator, + recording_data_manager: RecordingDataManager, guild_id: Id, channel_id: Id, @@ -104,49 +107,44 @@ impl EventHandler for Handler { let minute = now_utc.minute(); let second = now_utc.second(); - let microseconds = now_utc.microsecond(); + let microsecond = now_utc.microsecond(); - let guild_id = self.guild_id; - let channel_id = self.channel_id; + let guild = self.guild_id; + let voice_channel = self.channel_id; - let user = user_id - .as_ref() - .map_or_else(|| "UNKNOWN".into(), ToString::to_string); + let user = user_id; - let path = format!( - "{year}/{month}/{day}/{hour}/{minute}/audio-{second}.{microseconds}-{guild_id}-{channel_id}-{user}.wav" - ); + let clip = Clip { + second, + microsecond, + guild, + voice_channel, + user, + }; + + let recording = Recording { + year, + month, + day, + hour, + minute, + clip, + }; let channels = self.audio_channels; let sample_rate = self.audio_sample_rate; - let wav_spec = WavSpec { - channels, - sample_rate, - bits_per_sample: 16, - sample_format: SampleFormat::Int, - }; - - let mut buffer = Vec::new(); - let writer = Cursor::new(&mut buffer); - - let mut wav_writer = hound::WavWriter::new(writer, wav_spec).expect("TODO"); - - let mut sample_writer = wav_writer.get_i16_writer(pcm.len() as u32); - - for sample in pcm { - sample_writer.write_sample(*sample); - } - sample_writer.flush().expect("TODO"); - - wav_writer.finalize().expect("TODO"); - tracing::info!("going to write the audio shortly"); - let recording_data = self.recording_data.clone(); + let recording_data_manager = self.recording_data_manager.clone(); + let pcm = pcm.clone(); + tokio::spawn(async move { - recording_data.write(&path, buffer).await.expect("TODO"); - tracing::info!(?path, "successfully wrote the audio!"); + recording_data_manager + .write(&recording, &pcm, sample_rate, channels) + .await + .expect("TODO"); + tracing::info!(?recording, "successfully wrote the audio!"); }); } } @@ -174,7 +172,7 @@ pub async fn join_and_record( audio_channels: Channels, audio_sample_rate: SampleRate, guild_id: Id, - recording_data: Operator, + recording_data_manager: RecordingDataManager, songbird: &Songbird, user_data_manager: UserDataManager, voice_channel_id: Id, @@ -189,7 +187,7 @@ pub async fn join_and_record( let handler = Handler { start_instant, start_utc, - recording_data, + recording_data_manager, guild_id, channel_id: voice_channel_id, known_ssrcs: Default::default(), diff --git a/src/command/join.rs b/src/command/join.rs index 13d2989..fc4f858 100644 --- a/src/command/join.rs +++ b/src/command/join.rs @@ -123,7 +123,7 @@ pub async fn handle(state: State, interaction: Interaction) { .audio_channels(state.audio_channels) .audio_sample_rate(state.audio_sample_rate) .guild_id(guild_id) - .recording_data(state.recording_data) + .recording_data_manager(state.recording_data_manager) .songbird(&state.songbird) .user_data_manager(state.user_data_manager) .voice_channel_id(voice_channel_id) diff --git a/src/command/mod.rs b/src/command/mod.rs index 590481e..c7fc7de 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,7 +1,6 @@ use std::{fmt::Debug, sync::Arc}; use futures::future::BoxFuture; -use opendal::Operator; use patricia_tree::StringPatriciaMap; use songbird::{ Songbird, @@ -16,7 +15,10 @@ use twilight_model::{ }, }; -use crate::{BotDataManager, GuildVoiceChannelToTextChannel, UserDataManager, VCsSender}; +use crate::{ + BotDataManager, GuildVoiceChannelToTextChannel, UserDataManager, VCsSender, + recording_data::RecordingDataManager, render_data::RenderDataManager, +}; pub mod info; pub mod join; @@ -42,7 +44,8 @@ pub struct State { pub discord_opt_out_command_name: Arc, pub discord_user_id: Id, pub discord_voice_channel_corresponding_text_channel: Arc, - pub recording_data: Operator, + pub recording_data_manager: RecordingDataManager, + pub render_data_manager: RenderDataManager, pub songbird: Arc, pub user_data_manager: UserDataManager, pub vcs_sender: VCsSender, diff --git a/src/command/render.rs b/src/command/render.rs index 005b2fa..937da35 100644 --- a/src/command/render.rs +++ b/src/command/render.rs @@ -6,7 +6,10 @@ use twilight_model::{ command::{Command, CommandOption, CommandOptionType, CommandType}, interaction::{Interaction, InteractionData, application_command::CommandOptionValue}, }, - channel::{ChannelType, message::{Embed, MessageFlags}}, + channel::{ + ChannelType, + message::{Embed, MessageFlags}, + }, http::interaction::{InteractionResponse, InteractionResponseType}, id::{Id, marker::ChannelMarker}, }; @@ -122,7 +125,12 @@ enum ParseOptionsError { impl From for Embed { fn from(error: ParseOptionsError) -> Self { - EmbedBuilder::new().title("Error parsing options").description(Report::from_error(error).to_string()).validate().unwrap().build() + EmbedBuilder::new() + .title("Error parsing options") + .description(Report::from_error(error).to_string()) + .validate() + .unwrap() + .build() } } @@ -247,5 +255,7 @@ pub async fn handle(state: State, interaction: Interaction) { tracing::info!(?voice_channel, ?start, ?end); + let duration = end - start; + todo!(); } diff --git a/src/heat_seek.rs b/src/heat_seek.rs index 6716a56..da83b24 100644 --- a/src/heat_seek.rs +++ b/src/heat_seek.rs @@ -169,7 +169,8 @@ async fn get_heat( } } - let bot_owner_might_be_listening = bot_owner.is_some_and(|user_data| matches!(user_data.headphone, Headphone::Undeafened)); + let bot_owner_might_be_listening = + bot_owner.is_some_and(|user_data| matches!(user_data.headphone, Headphone::Undeafened)); if bot_owner_might_be_listening { heat = heat.min(999); @@ -325,7 +326,7 @@ async fn follow_hottest_vc( .audio_channels(state.audio_channels) .audio_sample_rate(state.audio_sample_rate) .guild_id(guild_id) - .recording_data(state.recording_data.clone()) + .recording_data_manager(state.recording_data_manager.clone()) .songbird(&state.songbird) .user_data_manager(state.user_data_manager.clone()) .voice_channel_id(hottest_vc) diff --git a/src/lib.rs b/src/lib.rs index 5f37eff..c3cc11b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ mod one_to_many_with_data; mod one_to_one; mod operator_ext; mod option_ext; +mod recording_data; +mod render_data; mod storage; mod track_vcs; mod user_data; @@ -22,6 +24,8 @@ pub use one_to_many::OneToManyUniqueBTreeMap; pub use one_to_many_with_data::OneToManyUniqueBTreeMapWithData; pub use one_to_one::OneToOneBTreeMap; pub use operator_ext::OperatorExt; +pub use recording_data::RecordingDataManager; +pub use render_data::RenderDataManager; pub use storage::Storage; pub use track_vcs::{GuildVoiceChannelToTextChannel, VCs, VCsSender, initialize_vcs, update_vcs}; pub use user_data::UserDataManager; diff --git a/src/main.rs b/src/main.rs index 8827d3a..243f3c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use clap::Parser; use fomo_reducer::{ - BotDataManager, CommandRouter, GuildVoiceChannelToTextChannel, State, Storage, UserDataManager, - VCsSender, all_commands, command, heat_seek, initialize_vcs, update_vcs, + BotDataManager, CommandRouter, GuildVoiceChannelToTextChannel, RecordingDataManager, + RenderDataManager, State, Storage, UserDataManager, VCsSender, all_commands, command, + heat_seek, initialize_vcs, update_vcs, }; use secrecy::{ExposeSecret, SecretString}; use snafu::{OptionExt, ResultExt, Snafu}; @@ -145,6 +146,9 @@ struct AppArgs { #[arg(long, env)] recording_data: Storage, + + #[arg(long, env)] + render_data: Storage, } #[derive(Parser)] @@ -207,6 +211,7 @@ async fn main() -> Result<(), MainError> { bot_data, user_data, recording_data, + render_data, } = app_args; let cancellation_token = CancellationToken::new(); @@ -340,9 +345,12 @@ async fn main() -> Result<(), MainError> { let bot_data = bot_data.into_inner(); let recording_data = recording_data.into_inner(); + let render_data = render_data.into_inner(); let user_data = user_data.into_inner(); let bot_data_manager = BotDataManager::new(bot_data); + let recording_data_manager = RecordingDataManager::new(recording_data); + let render_data_manager = RenderDataManager::new(render_data); let user_data_manager = UserDataManager::new(user_data); let discord_voice_channel_corresponding_text_channel = { @@ -377,7 +385,8 @@ async fn main() -> Result<(), MainError> { discord_opt_out_command_name, discord_user_id, discord_voice_channel_corresponding_text_channel, - recording_data, + recording_data_manager, + render_data_manager, songbird, user_data_manager, vcs_sender, diff --git a/src/recording_data/between.rs b/src/recording_data/between.rs new file mode 100644 index 0000000..f9e4bb1 --- /dev/null +++ b/src/recording_data/between.rs @@ -0,0 +1,44 @@ +use futures::{TryStream, TryStreamExt as _}; +use snafu::{ResultExt as _, Snafu}; +use std::{fmt::Display, str::FromStr}; +use time::UtcDateTime; +use twilight_model::id::{ + Id, + marker::{ChannelMarker, GuildMarker}, +}; + +use super::{ListError, Recording, RecordingDataManager, recording}; + +#[derive(Debug, Snafu)] +pub enum RecordingEntryError { + /// failed to get an entry from the storage operator's lister + ReceiveEntryError { source: opendal::Error }, + + /// failed to parse the entry as a recording + ParseError { source: recording::TakeError }, +} + +// impl RecordingDataManager { +// pub async fn between( +// &self, +// start: UtcDateTime, +// end: UtcDateTime, +// ) -> Result + Unpin, ListError> +// { +// todo!(); +// } +// } + +// impl RecordingDataManager { +// pub async fn between_in_vc( +// &self, +// start: UtcDateTime, +// end: UtcDateTime, +// guild_id: Id, +// voice_channel_id: Id, +// ) -> Result + Unpin, ListError> +// { +// todo!(); +// Ok(self.between(start, end)?) +// } +// } diff --git a/src/recording_data/clip.rs b/src/recording_data/clip.rs new file mode 100644 index 0000000..fe0d276 --- /dev/null +++ b/src/recording_data/clip.rs @@ -0,0 +1,111 @@ +use futures::{TryStream, TryStreamExt as _}; +use snafu::{ResultExt as _, Snafu}; +use std::{fmt::Display, str::FromStr}; + +use super::{ + CreateListerSnafu, Day, Guild, Hour, ListError, Microsecond, Minute, Month, + RecordingDataManager, Second, User, VoiceChannel, Year, guild, microsecond, second, user, + voice_channel, +}; + +#[derive(Debug, Clone)] +pub struct Clip { + pub second: Second, + pub microsecond: Microsecond, + pub guild: Guild, + pub voice_channel: VoiceChannel, + pub user: User, +} + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// could not parse the second out of the clip metadata + TakeSecondError { source: second::TakeError }, + + /// could not parse the microsecond out of the clip metadata + TakeMicrosecondError { source: microsecond::TakeError }, + + /// could not parse the guild out of the clip metadata + TakeGuildError { source: guild::TakeError }, + + /// could not parse the voice channel out of the clip metadata + TakeVoiceChannelError { source: voice_channel::TakeError }, + + /// could not parse the user out of the clip metadata + TakeUserError { source: user::TakeError }, +} + +pub fn take(s: &str) -> Result { + let (second, s) = second::take(s).context(TakeSecondSnafu)?; + let (microsecond, s) = microsecond::take(s).context(TakeMicrosecondSnafu)?; + let (guild, s) = guild::take(s).context(TakeGuildSnafu)?; + let (voice_channel, s) = voice_channel::take(s).context(TakeVoiceChannelSnafu)?; + let user = user::take(s).context(TakeUserSnafu)?; + + Ok(Clip { + second, + microsecond, + guild, + voice_channel, + user, + }) +} + +impl FromStr for Clip { + type Err = TakeError; + + fn from_str(s: &str) -> Result { + take(s) + } +} + +impl Display for Clip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + second, + microsecond, + guild, + voice_channel, + user, + } = self; + + let user = user + .as_ref() + .map_or_else(|| "UNKNOWN".into(), ToString::to_string); + + write!( + f, + "audio-{second}.{microsecond}-{guild}-{voice_channel}-{user}.wav" + ) + } +} + +#[derive(Debug, Snafu)] +pub enum ClipEntryError { + /// failed to get an entry from the storage operator's lister + ReceiveEntryError { source: opendal::Error }, + + /// failed to parse the entry as a clip + ParseError { source: TakeError }, +} + +impl RecordingDataManager { + pub async fn clips( + &self, + year: Year, + month: Month, + day: Day, + hour: Hour, + minute: Minute, + ) -> Result + Unpin, ListError> { + let lister = self + .operator + .lister(&format!("{year}/{month}/{day}/{hour}/{minute}/")) + .await + .context(CreateListerSnafu)?; + + Ok(lister + .map_err(|error| ClipEntryError::ReceiveEntryError { source: error }) + .and_then(|entry| std::future::ready(entry.name().parse().context(ParseSnafu)))) + } +} diff --git a/src/recording_data/day.rs b/src/recording_data/day.rs new file mode 100644 index 0000000..992aee0 --- /dev/null +++ b/src/recording_data/day.rs @@ -0,0 +1,57 @@ +use futures::{TryStream, TryStreamExt as _}; +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; + +use super::{CreateListerSnafu, ListError, Month, RecordingDataManager, Year}; + +pub type Day = u8; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// days are supposed to be directories, but this wasn't (because it didn't end with `/`) + NotADirectory, + + /// could not parse the day as an integer + ParseIntegerError { source: ParseIntError }, +} + +pub fn take(s: &str) -> Result<(Day, &str), TakeError> { + let (day, rest) = s.split_once('/').context(NotADirectorySnafu)?; + + let day = day.parse().context(ParseIntegerSnafu)?; + + Ok((day, rest)) +} + +#[derive(Debug, Snafu)] +pub enum DayEntryError { + /// failed to get an entry from the storage operator's lister + ReceiveEntryError { source: opendal::Error }, + + /// failed to parse the entry as a day + ParseError { source: TakeError }, +} + +impl RecordingDataManager { + pub async fn days( + &self, + year: Year, + month: Month, + ) -> Result + Unpin, ListError> { + let lister = self + .operator + .lister(&format!("{year}/{month}/")) + .await + .context(CreateListerSnafu)?; + + Ok(lister + .map_err(|error| DayEntryError::ReceiveEntryError { source: error }) + .and_then(|entry| { + std::future::ready( + take(entry.name()) + .map(|(day, rest)| day) + .context(ParseSnafu), + ) + })) + } +} diff --git a/src/recording_data/guild.rs b/src/recording_data/guild.rs new file mode 100644 index 0000000..fd912c4 --- /dev/null +++ b/src/recording_data/guild.rs @@ -0,0 +1,22 @@ +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::str::FromStr; +use twilight_model::id::{Id, marker::GuildMarker}; + +pub type Guild = Id; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// guilds are supposed to be followed by - + Malformed, + + /// could not parse the guild ID + ParseIdError { source: ::Err }, +} + +pub fn take(path: &str) -> Result<(Guild, &str), TakeError> { + let (guild, rest) = path.split_once('-').context(MalformedSnafu)?; + + let guild = guild.parse().context(ParseIdSnafu)?; + + Ok((guild, rest)) +} diff --git a/src/recording_data/hour.rs b/src/recording_data/hour.rs new file mode 100644 index 0000000..576e6d8 --- /dev/null +++ b/src/recording_data/hour.rs @@ -0,0 +1,58 @@ +use futures::{TryStream, TryStreamExt as _}; +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; + +use super::{CreateListerSnafu, Day, ListError, Month, RecordingDataManager, Year}; + +pub type Hour = u8; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// hours are supposed to be directories, but this wasn't (because it didn't end with `/`) + NotADirectory, + + /// could not parse the hour as an integer + ParseIntegerError { source: ParseIntError }, +} + +pub fn take(s: &str) -> Result<(Hour, &str), TakeError> { + let (hour, rest) = s.split_once('/').context(NotADirectorySnafu)?; + + let hour = hour.parse().context(ParseIntegerSnafu)?; + + Ok((hour, rest)) +} + +#[derive(Debug, Snafu)] +pub enum HourEntryError { + /// failed to get an entry from the storage operator's lister + ReceiveEntryError { source: opendal::Error }, + + /// failed to parse the entry as a hour + ParseError { source: TakeError }, +} + +impl RecordingDataManager { + pub async fn hours( + &self, + year: Year, + month: Month, + day: Day, + ) -> Result + Unpin, ListError> { + let lister = self + .operator + .lister(&format!("{year}/{month}/{day}/")) + .await + .context(CreateListerSnafu)?; + + Ok(lister + .map_err(|error| HourEntryError::ReceiveEntryError { source: error }) + .and_then(|entry| { + std::future::ready( + take(entry.name()) + .map(|(hour, _rest)| hour) + .context(ParseSnafu), + ) + })) + } +} diff --git a/src/recording_data/microsecond.rs b/src/recording_data/microsecond.rs new file mode 100644 index 0000000..833b57b --- /dev/null +++ b/src/recording_data/microsecond.rs @@ -0,0 +1,21 @@ +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; + +pub type Microsecond = u32; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// microseconds are supposed to be followed by - + Malformed, + + /// could not parse the microsecond as an integer + ParseIntegerError { source: ParseIntError }, +} + +pub fn take(path: &str) -> Result<(Microsecond, &str), TakeError> { + let (microsecond, rest) = path.split_once('-').context(MalformedSnafu)?; + + let microsecond = microsecond.parse().context(ParseIntegerSnafu)?; + + Ok((microsecond, rest)) +} diff --git a/src/recording_data/minute.rs b/src/recording_data/minute.rs new file mode 100644 index 0000000..e73397e --- /dev/null +++ b/src/recording_data/minute.rs @@ -0,0 +1,59 @@ +use futures::{TryStream, TryStreamExt as _}; +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; + +use super::{CreateListerSnafu, Day, Hour, ListError, Month, RecordingDataManager, Year}; + +pub type Minute = u8; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// minutes are supposed to be directories, but this wasn't (because it didn't end with `/`) + NotADirectory, + + /// could not parse the minute as an integer + ParseIntegerError { source: ParseIntError }, +} + +pub fn take(s: &str) -> Result<(Minute, &str), TakeError> { + let (minute, rest) = s.split_once('/').context(NotADirectorySnafu)?; + + let minute = minute.parse().context(ParseIntegerSnafu)?; + + Ok((minute, rest)) +} + +#[derive(Debug, Snafu)] +pub enum MinuteEntryError { + /// failed to get an entry from the storage operator's lister + ReceiveEntryError { source: opendal::Error }, + + /// failed to parse the entry as a minute + ParseError { source: TakeError }, +} + +impl RecordingDataManager { + pub async fn minutes( + &self, + year: Year, + month: Month, + day: Day, + hour: Hour, + ) -> Result + Unpin, ListError> { + let lister = self + .operator + .lister(&format!("{year}/{month}/{day}/{hour}/")) + .await + .context(CreateListerSnafu)?; + + Ok(lister + .map_err(|error| MinuteEntryError::ReceiveEntryError { source: error }) + .and_then(|entry| { + std::future::ready( + take(entry.name()) + .map(|(minute, _rest)| minute) + .context(ParseSnafu), + ) + })) + } +} diff --git a/src/recording_data/mod.rs b/src/recording_data/mod.rs new file mode 100644 index 0000000..25d3ca7 --- /dev/null +++ b/src/recording_data/mod.rs @@ -0,0 +1,47 @@ +use futures::TryStream; +use opendal::Operator; +use snafu::Snafu; + +mod between; +mod clip; +mod day; +mod guild; +mod hour; +mod microsecond; +mod minute; +mod month; +mod recording; +mod second; +mod user; +mod voice_channel; +mod year; + +pub use clip::Clip; +use day::Day; +use guild::Guild; +use hour::Hour; +use microsecond::Microsecond; +use minute::Minute; +use month::Month; +pub use recording::Recording; +use second::Second; +use user::User; +use voice_channel::VoiceChannel; +use year::Year; + +#[derive(Debug, Clone)] +pub struct RecordingDataManager { + operator: Operator, +} + +impl RecordingDataManager { + pub fn new(operator: Operator) -> Self { + Self { operator } + } +} + +#[derive(Debug, Snafu)] +pub enum ListError { + /// error creating a lister through the storage operator + CreateListerError { source: opendal::Error }, +} diff --git a/src/recording_data/month.rs b/src/recording_data/month.rs new file mode 100644 index 0000000..b500326 --- /dev/null +++ b/src/recording_data/month.rs @@ -0,0 +1,55 @@ +use futures::{TryStream, TryStreamExt as _}; +use snafu::{OptionExt as _, ResultExt as _, Snafu}; + +use super::{CreateListerSnafu, ListError, RecordingDataManager, Year}; + +pub use time::Month; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// months are supposed to be directories, but this wasn't (because it didn't end with `/`) + NotADirectory, + + /// could not parse the month as its name + ParseMonthNameError { source: time::error::InvalidVariant }, +} + +pub fn take(s: &str) -> Result<(Month, &str), TakeError> { + let (month, rest) = s.split_once('/').context(NotADirectorySnafu)?; + + let month = month.parse().context(ParseMonthNameSnafu)?; + + Ok((month, rest)) +} + +#[derive(Debug, Snafu)] +pub enum MonthEntryError { + /// failed to get an entry from the storage operator's lister + ReceiveEntryError { source: opendal::Error }, + + /// failed to parse the entry as a month + ParseError { source: TakeError }, +} + +impl RecordingDataManager { + pub async fn months( + &self, + year: Year, + ) -> Result + Unpin, ListError> { + let lister = self + .operator + .lister(&format!("{year}/")) + .await + .context(CreateListerSnafu)?; + + Ok(lister + .map_err(|error| MonthEntryError::ReceiveEntryError { source: error }) + .and_then(|entry| { + std::future::ready( + take(entry.name()) + .map(|(month, rest)| month) + .context(ParseSnafu), + ) + })) + } +} diff --git a/src/recording_data/recording.rs b/src/recording_data/recording.rs new file mode 100644 index 0000000..f562a08 --- /dev/null +++ b/src/recording_data/recording.rs @@ -0,0 +1,143 @@ +use futures::{TryStream, TryStreamExt as _}; +use hound::{SampleFormat, WavSpec}; +use snafu::{ResultExt as _, Snafu}; +use std::{convert::Infallible, fmt::Display, io::Cursor, str::FromStr}; +use time::UtcDateTime; +use twilight_model::id::{ + Id, + marker::{ChannelMarker, GuildMarker}, +}; + +use super::{ + Clip, Day, Hour, ListError, Minute, Month, RecordingDataManager, Year, clip, day, hour, minute, + month, year, +}; + +#[derive(Debug, Clone)] +pub struct Recording { + pub year: Year, + pub month: Month, + pub day: Day, + pub hour: Hour, + pub minute: Minute, + pub clip: Clip, +} + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// could not parse the year out of the recording + TakeYearError { source: year::TakeError }, + + /// could not parse the month out of the recording + TakeMonthError { source: month::TakeError }, + + /// could not parse the day out of the recording + TakeDayError { source: day::TakeError }, + + /// could not parse the hour out of the recording + TakeHourError { source: hour::TakeError }, + + /// could not parse the minute out of the recording + TakeMinuteError { source: minute::TakeError }, + + /// could not parse the clip out of the recording + TakeClipError { source: clip::TakeError }, +} + +pub fn take(s: &str) -> Result { + let (year, s) = year::take(s).context(TakeYearSnafu)?; + let (month, s) = month::take(s).context(TakeMonthSnafu)?; + let (day, s) = day::take(s).context(TakeDaySnafu)?; + let (hour, s) = hour::take(s).context(TakeHourSnafu)?; + let (minute, s) = minute::take(s).context(TakeMinuteSnafu)?; + let clip = clip::take(s).context(TakeClipSnafu)?; + + Ok(Recording { + year, + month, + day, + hour, + minute, + clip, + }) +} + +impl FromStr for Recording { + type Err = TakeError; + + fn from_str(s: &str) -> Result { + take(s) + } +} + +impl Display for Recording { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + year, + month, + day, + hour, + minute, + clip, + } = self; + + write!(f, "{year}/{month}/{day}/{hour}/{minute}/{clip}") + } +} + +impl RecordingDataManager { + pub async fn write( + &self, + recording: &Recording, + samples: &[i16], + sample_rate: u32, + channels: u16, + ) -> Result< + (), + Infallible, // TODO: a real error type + > { + let wav_spec = WavSpec { + channels, + sample_rate, + bits_per_sample: 16, + sample_format: SampleFormat::Int, + }; + + let mut buffer = Vec::new(); + let writer = Cursor::new(&mut buffer); + + let mut wav_writer = hound::WavWriter::new(writer, wav_spec).expect("TODO"); + + let mut sample_writer = wav_writer.get_i16_writer(samples.len() as u32); + + for sample in samples { + sample_writer.write_sample(*sample); + } + sample_writer.flush().expect("TODO"); + + wav_writer.finalize().expect("TODO"); + + let path = recording.to_string(); + + self.operator.write(&path, buffer).await.expect("TODO"); + + Ok(()) + } +} + +impl RecordingDataManager { + pub async fn read(&self, recording: &Recording, sample_rate: u32, channels: u16) -> Vec { + let path = recording.to_string(); + + let buffer = self.operator.read(&path).await.expect("TODO"); + + let wav_spec = WavSpec { + channels, + sample_rate, + bits_per_sample: 16, + sample_format: SampleFormat::Int, + }; + + todo!(); + } +} diff --git a/src/recording_data/second.rs b/src/recording_data/second.rs new file mode 100644 index 0000000..cf1c0fd --- /dev/null +++ b/src/recording_data/second.rs @@ -0,0 +1,25 @@ +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; + +pub type Second = u8; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// seconds are supposed to be preceded by audio- + MalformedPrefix, + + /// seconds are supposed to be followed by . + MalformedSuffix, + + /// could not parse the second as an integer + ParseIntegerError { source: ParseIntError }, +} + +pub fn take(path: &str) -> Result<(Second, &str), TakeError> { + let (_prefix, path) = path.split_once("audio-").context(MalformedPrefixSnafu)?; + let (second, rest) = path.split_once('.').context(MalformedSuffixSnafu)?; + + let second = second.parse().context(ParseIntegerSnafu)?; + + Ok((second, rest)) +} diff --git a/src/recording_data/user.rs b/src/recording_data/user.rs new file mode 100644 index 0000000..a081118 --- /dev/null +++ b/src/recording_data/user.rs @@ -0,0 +1,25 @@ +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; +use twilight_model::id::{Id, marker::UserMarker}; + +pub type User = Option>; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// users are supposed to be terminated by .wav + Malformed, + + /// could not parse the user ID + ParseIntegerError { source: ParseIntError }, +} + +pub fn take(path: &str) -> Result { + let user = path.strip_suffix(".wav").context(MalformedSnafu)?; + + let user = match user { + "UNKNOWN" => None, + user => Some(user.parse().context(ParseIntegerSnafu)?), + }; + + Ok(user) +} diff --git a/src/recording_data/voice_channel.rs b/src/recording_data/voice_channel.rs new file mode 100644 index 0000000..bac9d58 --- /dev/null +++ b/src/recording_data/voice_channel.rs @@ -0,0 +1,26 @@ +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; +use std::str::FromStr; +use twilight_model::id::Id; +use twilight_model::id::marker::ChannelMarker; + +pub type VoiceChannel = Id; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// voice channels are supposed to be followed by - + Malformed, + + /// could not parse the voice channel ID + ParseIdError { + source: ::Err, + }, +} + +pub fn take(path: &str) -> Result<(VoiceChannel, &str), TakeError> { + let (voice_channel, rest) = path.split_once('-').context(MalformedSnafu)?; + + let voice_channel = voice_channel.parse().context(ParseIdSnafu)?; + + Ok((voice_channel, rest)) +} diff --git a/src/recording_data/year.rs b/src/recording_data/year.rs new file mode 100644 index 0000000..4222dd9 --- /dev/null +++ b/src/recording_data/year.rs @@ -0,0 +1,51 @@ +use futures::{TryStream, TryStreamExt as _}; +use snafu::{OptionExt as _, ResultExt as _, Snafu}; +use std::num::ParseIntError; + +use super::{CreateListerSnafu, ListError, RecordingDataManager}; + +pub type Year = i32; + +#[derive(Debug, Snafu)] +pub enum TakeError { + /// years are supposed to be directories, but this wasn't (because it didn't end with `/`) + NotADirectory, + + /// could not parse the year as an integer + ParseIntegerError { source: ParseIntError }, +} + +pub fn take(path: &str) -> Result<(Year, &str), TakeError> { + let (year, rest) = path.split_once('/').context(NotADirectorySnafu)?; + + let year = year.parse().context(ParseIntegerSnafu)?; + + Ok((year, rest)) +} + +#[derive(Debug, Snafu)] +pub enum YearEntryError { + /// failed to get an entry from the storage operator's lister + ReceiveEntryError { source: opendal::Error }, + + /// failed to parse the entry as a year + ParseError { source: TakeError }, +} + +impl RecordingDataManager { + pub async fn years( + &self, + ) -> Result + Unpin, ListError> { + let lister = self.operator.lister("").await.context(CreateListerSnafu)?; + + Ok(lister + .map_err(|error| YearEntryError::ReceiveEntryError { source: error }) + .and_then(|entry| { + std::future::ready( + take(entry.name()) + .map(|(year, _rest)| year) + .context(ParseSnafu), + ) + })) + } +} diff --git a/src/render_data.rs b/src/render_data.rs new file mode 100644 index 0000000..ec4f411 --- /dev/null +++ b/src/render_data.rs @@ -0,0 +1,12 @@ +use opendal::Operator; + +#[derive(Debug, Clone)] +pub struct RenderDataManager { + operator: Operator, +} + +impl RenderDataManager { + pub fn new(operator: Operator) -> Self { + Self { operator } + } +}