chore: refactor into a RecordingDataManager, lay the ground work for a RenderManager

This commit is contained in:
2026-05-27 01:28:47 -04:00
parent f86c094dda
commit e72633f26a
22 changed files with 830 additions and 49 deletions

View File

@@ -1,6 +1,9 @@
use crate::{ use crate::{
OneToManyUniqueBTreeMap, UserDataManager, option_ext::OptionExt as _, OneToManyUniqueBTreeMap, UserDataManager,
user_capnp::user::Consent, user_data::RECORD_IF_CONSENT_UNSPECIFIED, 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 async_trait::async_trait;
use futures::FutureExt; use futures::FutureExt;
@@ -26,7 +29,7 @@ struct Handler {
start_instant: Instant, start_instant: Instant,
start_utc: UtcDateTime, start_utc: UtcDateTime,
recording_data: Operator, recording_data_manager: RecordingDataManager,
guild_id: Id<GuildMarker>, guild_id: Id<GuildMarker>,
channel_id: Id<ChannelMarker>, channel_id: Id<ChannelMarker>,
@@ -104,49 +107,44 @@ impl EventHandler for Handler {
let minute = now_utc.minute(); let minute = now_utc.minute();
let second = now_utc.second(); let second = now_utc.second();
let microseconds = now_utc.microsecond(); let microsecond = now_utc.microsecond();
let guild_id = self.guild_id; let guild = self.guild_id;
let channel_id = self.channel_id; let voice_channel = self.channel_id;
let user = user_id let user = user_id;
.as_ref()
.map_or_else(|| "UNKNOWN".into(), ToString::to_string);
let path = format!( let clip = Clip {
"{year}/{month}/{day}/{hour}/{minute}/audio-{second}.{microseconds}-{guild_id}-{channel_id}-{user}.wav" second,
); microsecond,
guild,
voice_channel,
user,
};
let recording = Recording {
year,
month,
day,
hour,
minute,
clip,
};
let channels = self.audio_channels; let channels = self.audio_channels;
let sample_rate = self.audio_sample_rate; 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"); 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 { tokio::spawn(async move {
recording_data.write(&path, buffer).await.expect("TODO"); recording_data_manager
tracing::info!(?path, "successfully wrote the audio!"); .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_channels: Channels,
audio_sample_rate: SampleRate, audio_sample_rate: SampleRate,
guild_id: Id<GuildMarker>, guild_id: Id<GuildMarker>,
recording_data: Operator, recording_data_manager: RecordingDataManager,
songbird: &Songbird, songbird: &Songbird,
user_data_manager: UserDataManager, user_data_manager: UserDataManager,
voice_channel_id: Id<ChannelMarker>, voice_channel_id: Id<ChannelMarker>,
@@ -189,7 +187,7 @@ pub async fn join_and_record(
let handler = Handler { let handler = Handler {
start_instant, start_instant,
start_utc, start_utc,
recording_data, recording_data_manager,
guild_id, guild_id,
channel_id: voice_channel_id, channel_id: voice_channel_id,
known_ssrcs: Default::default(), known_ssrcs: Default::default(),

View File

@@ -123,7 +123,7 @@ pub async fn handle(state: State, interaction: Interaction) {
.audio_channels(state.audio_channels) .audio_channels(state.audio_channels)
.audio_sample_rate(state.audio_sample_rate) .audio_sample_rate(state.audio_sample_rate)
.guild_id(guild_id) .guild_id(guild_id)
.recording_data(state.recording_data) .recording_data_manager(state.recording_data_manager)
.songbird(&state.songbird) .songbird(&state.songbird)
.user_data_manager(state.user_data_manager) .user_data_manager(state.user_data_manager)
.voice_channel_id(voice_channel_id) .voice_channel_id(voice_channel_id)

View File

@@ -1,7 +1,6 @@
use std::{fmt::Debug, sync::Arc}; use std::{fmt::Debug, sync::Arc};
use futures::future::BoxFuture; use futures::future::BoxFuture;
use opendal::Operator;
use patricia_tree::StringPatriciaMap; use patricia_tree::StringPatriciaMap;
use songbird::{ use songbird::{
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 info;
pub mod join; pub mod join;
@@ -42,7 +44,8 @@ pub struct State {
pub discord_opt_out_command_name: Arc<str>, pub discord_opt_out_command_name: Arc<str>,
pub discord_user_id: Id<UserMarker>, pub discord_user_id: Id<UserMarker>,
pub discord_voice_channel_corresponding_text_channel: Arc<GuildVoiceChannelToTextChannel>, pub discord_voice_channel_corresponding_text_channel: Arc<GuildVoiceChannelToTextChannel>,
pub recording_data: Operator, pub recording_data_manager: RecordingDataManager,
pub render_data_manager: RenderDataManager,
pub songbird: Arc<Songbird>, pub songbird: Arc<Songbird>,
pub user_data_manager: UserDataManager, pub user_data_manager: UserDataManager,
pub vcs_sender: VCsSender, pub vcs_sender: VCsSender,

View File

@@ -6,7 +6,10 @@ use twilight_model::{
command::{Command, CommandOption, CommandOptionType, CommandType}, command::{Command, CommandOption, CommandOptionType, CommandType},
interaction::{Interaction, InteractionData, application_command::CommandOptionValue}, interaction::{Interaction, InteractionData, application_command::CommandOptionValue},
}, },
channel::{ChannelType, message::{Embed, MessageFlags}}, channel::{
ChannelType,
message::{Embed, MessageFlags},
},
http::interaction::{InteractionResponse, InteractionResponseType}, http::interaction::{InteractionResponse, InteractionResponseType},
id::{Id, marker::ChannelMarker}, id::{Id, marker::ChannelMarker},
}; };
@@ -122,7 +125,12 @@ enum ParseOptionsError {
impl From<ParseOptionsError> for Embed { impl From<ParseOptionsError> for Embed {
fn from(error: ParseOptionsError) -> Self { 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); tracing::info!(?voice_channel, ?start, ?end);
let duration = end - start;
todo!(); todo!();
} }

View File

@@ -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 { if bot_owner_might_be_listening {
heat = heat.min(999); heat = heat.min(999);
@@ -325,7 +326,7 @@ async fn follow_hottest_vc(
.audio_channels(state.audio_channels) .audio_channels(state.audio_channels)
.audio_sample_rate(state.audio_sample_rate) .audio_sample_rate(state.audio_sample_rate)
.guild_id(guild_id) .guild_id(guild_id)
.recording_data(state.recording_data.clone()) .recording_data_manager(state.recording_data_manager.clone())
.songbird(&state.songbird) .songbird(&state.songbird)
.user_data_manager(state.user_data_manager.clone()) .user_data_manager(state.user_data_manager.clone())
.voice_channel_id(hottest_vc) .voice_channel_id(hottest_vc)

View File

@@ -7,6 +7,8 @@ mod one_to_many_with_data;
mod one_to_one; mod one_to_one;
mod operator_ext; mod operator_ext;
mod option_ext; mod option_ext;
mod recording_data;
mod render_data;
mod storage; mod storage;
mod track_vcs; mod track_vcs;
mod user_data; 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_many_with_data::OneToManyUniqueBTreeMapWithData;
pub use one_to_one::OneToOneBTreeMap; pub use one_to_one::OneToOneBTreeMap;
pub use operator_ext::OperatorExt; pub use operator_ext::OperatorExt;
pub use recording_data::RecordingDataManager;
pub use render_data::RenderDataManager;
pub use storage::Storage; pub use storage::Storage;
pub use track_vcs::{GuildVoiceChannelToTextChannel, VCs, VCsSender, initialize_vcs, update_vcs}; pub use track_vcs::{GuildVoiceChannelToTextChannel, VCs, VCsSender, initialize_vcs, update_vcs};
pub use user_data::UserDataManager; pub use user_data::UserDataManager;

View File

@@ -1,7 +1,8 @@
use clap::Parser; use clap::Parser;
use fomo_reducer::{ use fomo_reducer::{
BotDataManager, CommandRouter, GuildVoiceChannelToTextChannel, State, Storage, UserDataManager, BotDataManager, CommandRouter, GuildVoiceChannelToTextChannel, RecordingDataManager,
VCsSender, all_commands, command, heat_seek, initialize_vcs, update_vcs, RenderDataManager, State, Storage, UserDataManager, VCsSender, all_commands, command,
heat_seek, initialize_vcs, update_vcs,
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use snafu::{OptionExt, ResultExt, Snafu}; use snafu::{OptionExt, ResultExt, Snafu};
@@ -145,6 +146,9 @@ struct AppArgs {
#[arg(long, env)] #[arg(long, env)]
recording_data: Storage, recording_data: Storage,
#[arg(long, env)]
render_data: Storage,
} }
#[derive(Parser)] #[derive(Parser)]
@@ -207,6 +211,7 @@ async fn main() -> Result<(), MainError> {
bot_data, bot_data,
user_data, user_data,
recording_data, recording_data,
render_data,
} = app_args; } = app_args;
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
@@ -340,9 +345,12 @@ async fn main() -> Result<(), MainError> {
let bot_data = bot_data.into_inner(); let bot_data = bot_data.into_inner();
let recording_data = recording_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 user_data = user_data.into_inner();
let bot_data_manager = BotDataManager::new(bot_data); 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 user_data_manager = UserDataManager::new(user_data);
let discord_voice_channel_corresponding_text_channel = { let discord_voice_channel_corresponding_text_channel = {
@@ -377,7 +385,8 @@ async fn main() -> Result<(), MainError> {
discord_opt_out_command_name, discord_opt_out_command_name,
discord_user_id, discord_user_id,
discord_voice_channel_corresponding_text_channel, discord_voice_channel_corresponding_text_channel,
recording_data, recording_data_manager,
render_data_manager,
songbird, songbird,
user_data_manager, user_data_manager,
vcs_sender, vcs_sender,

View File

@@ -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<impl TryStream<Ok = Recording, Error = RecordingEntryError> + Unpin, ListError>
// {
// todo!();
// }
// }
// impl RecordingDataManager {
// pub async fn between_in_vc(
// &self,
// start: UtcDateTime,
// end: UtcDateTime,
// guild_id: Id<GuildMarker>,
// voice_channel_id: Id<ChannelMarker>,
// ) -> Result<impl TryStream<Ok = Recording, Error = RecordingEntryError> + Unpin, ListError>
// {
// todo!();
// Ok(self.between(start, end)?)
// }
// }

111
src/recording_data/clip.rs Normal file
View File

@@ -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<Clip, TakeError> {
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<Self, Self::Err> {
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<impl TryStream<Ok = Clip, Error = ClipEntryError> + 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))))
}
}

57
src/recording_data/day.rs Normal file
View File

@@ -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<impl TryStream<Ok = Day, Error = DayEntryError> + 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),
)
}))
}
}

View File

@@ -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<GuildMarker>;
#[derive(Debug, Snafu)]
pub enum TakeError {
/// guilds are supposed to be followed by -
Malformed,
/// could not parse the guild ID
ParseIdError { source: <Guild as FromStr>::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))
}

View File

@@ -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<impl TryStream<Ok = Hour, Error = HourEntryError> + 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),
)
}))
}
}

View File

@@ -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))
}

View File

@@ -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<impl TryStream<Ok = Minute, Error = MinuteEntryError> + 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),
)
}))
}
}

47
src/recording_data/mod.rs Normal file
View File

@@ -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 },
}

View File

@@ -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<impl TryStream<Ok = Month, Error = MonthEntryError> + 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),
)
}))
}
}

View File

@@ -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<Recording, TakeError> {
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<Self, Self::Err> {
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<i16> {
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!();
}
}

View File

@@ -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))
}

View File

@@ -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<Id<UserMarker>>;
#[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<User, TakeError> {
let user = path.strip_suffix(".wav").context(MalformedSnafu)?;
let user = match user {
"UNKNOWN" => None,
user => Some(user.parse().context(ParseIntegerSnafu)?),
};
Ok(user)
}

View File

@@ -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<ChannelMarker>;
#[derive(Debug, Snafu)]
pub enum TakeError {
/// voice channels are supposed to be followed by -
Malformed,
/// could not parse the voice channel ID
ParseIdError {
source: <VoiceChannel as FromStr>::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))
}

View File

@@ -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<impl TryStream<Ok = Year, Error = YearEntryError> + 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),
)
}))
}
}

12
src/render_data.rs Normal file
View File

@@ -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 }
}
}