mirror of
https://github.com/jhbruhn/jellyfin-radio.git
synced 2025-03-14 19:45:50 +00:00
246 lines
8.2 KiB
Rust
246 lines
8.2 KiB
Rust
use awedio::Sound;
|
|
use envconfig::Envconfig;
|
|
use hyper::server::conn::http1;
|
|
use hyper_util::rt::TokioIo;
|
|
use std::{net::SocketAddr, time::Duration};
|
|
use tokio::net::TcpListener;
|
|
|
|
mod jellyfin;
|
|
mod player;
|
|
mod streamer;
|
|
|
|
#[derive(Envconfig, Clone)]
|
|
struct Config {
|
|
#[envconfig(from = "JELLYFIN_URL")]
|
|
pub jellyfin_url: String,
|
|
|
|
#[envconfig(from = "JELLYFIN_API_KEY")]
|
|
pub jellyfin_api_key: String,
|
|
|
|
#[envconfig(from = "JELLYFIN_COLLECTION_NAME")]
|
|
pub jellyfin_collection_name: String,
|
|
|
|
#[envconfig(from = "PORT", default = "3000")]
|
|
pub port: u16,
|
|
|
|
#[envconfig(from = "HOST", default = "0.0.0.0")]
|
|
pub host: String,
|
|
|
|
#[envconfig(from = "SONG_PREFETCH", default = "2")]
|
|
pub song_prefetch: u32,
|
|
}
|
|
|
|
async fn get_time_file_map(
|
|
folder: &std::path::Path,
|
|
) -> std::collections::HashMap<chrono::NaiveTime, Vec<std::path::PathBuf>> {
|
|
// time announce logic
|
|
let time_files: Vec<std::path::PathBuf> = std::fs::read_dir(folder)
|
|
.unwrap()
|
|
.filter_map(|v| v.ok())
|
|
.filter(|v| !v.path().is_dir())
|
|
.map(|v| v.path().clone())
|
|
.collect();
|
|
|
|
let mut time_map = std::collections::HashMap::new();
|
|
for path in time_files.iter() {
|
|
if let Ok((time, path)) = async {
|
|
let mut name_split = path
|
|
.file_stem()
|
|
.ok_or(anyhow::anyhow!("Wrong file stem!"))?
|
|
.to_str()
|
|
.ok_or(anyhow::anyhow!("Wrong file path!"))?
|
|
.split("_");
|
|
let hour = name_split
|
|
.next()
|
|
.ok_or(anyhow::anyhow!("No hour!"))?
|
|
.parse()
|
|
.unwrap();
|
|
let minute = name_split
|
|
.next()
|
|
.ok_or(anyhow::anyhow!("No minute!"))?
|
|
.parse()
|
|
.unwrap();
|
|
let time = chrono::NaiveTime::from_hms_opt(hour, minute, 0)
|
|
.ok_or(anyhow::anyhow!("Can't parse time"))?;
|
|
Ok::<_, anyhow::Error>((time, path))
|
|
}
|
|
.await
|
|
{
|
|
time_map
|
|
.entry(time)
|
|
.or_insert_with(Vec::new)
|
|
.push(path.clone());
|
|
}
|
|
}
|
|
time_map
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
let config = Config::init_from_env().unwrap();
|
|
|
|
let client =
|
|
jellyfin::JellyfinClient::new(config.jellyfin_url.into(), config.jellyfin_api_key.into());
|
|
|
|
let admin_user = client
|
|
.users()
|
|
.await?
|
|
.into_iter()
|
|
.filter(|u| u.policy.is_administrator)
|
|
.next()
|
|
.expect("No Admin user found!");
|
|
let matched_collection = client
|
|
.views(&admin_user.id)
|
|
.await?
|
|
.into_iter()
|
|
.filter(|c| c.name == config.jellyfin_collection_name)
|
|
.next()
|
|
.expect("Collection not found!");
|
|
|
|
let addr: SocketAddr = SocketAddr::from((
|
|
config.host.parse::<std::net::Ipv4Addr>().unwrap(),
|
|
config.port,
|
|
));
|
|
|
|
let (streamer_backend, mut streamer_manager) = streamer::StreamerBackend::start()?;
|
|
|
|
let (mixer, mixer_controller) = awedio::sounds::SoundMixer::new(2, 48_000).controllable();
|
|
// basic playlist playback
|
|
|
|
let (player, mut player_controller) = player::Player::new(config.song_prefetch);
|
|
let player = Box::new(player);
|
|
|
|
let mut player_mixer_controller = mixer_controller.clone();
|
|
player_mixer_controller.add(player);
|
|
let mut announce_downmix_player_controller = player_controller.clone();
|
|
tokio::task::spawn(async move {
|
|
loop {
|
|
tokio::task::yield_now().await;
|
|
player_controller.wait_for_queue().await;
|
|
|
|
println!("Queuing song");
|
|
|
|
loop {
|
|
let result = async {
|
|
let item = client
|
|
.random_audio(&admin_user.id, &matched_collection.id)
|
|
.await?;
|
|
|
|
println!("Fetching {} - {}", item.artists.join(","), item.name);
|
|
let sound = client.fetch_audio(item).await?;
|
|
println!("Fetched Song!");
|
|
if sound.channel_count() > 2 {
|
|
anyhow::bail!("Too many channels, skipping!");
|
|
}
|
|
player_controller.add(Box::new(sound));
|
|
anyhow::Ok(())
|
|
}
|
|
.await;
|
|
if let Err(e) = result {
|
|
println!("Error fetching new song: {}", e);
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let mut time_announce_mixer_controller = mixer_controller.clone();
|
|
|
|
tokio::task::spawn(async move {
|
|
let time_file_map =
|
|
get_time_file_map(&std::path::PathBuf::from("./interstitials/time")).await;
|
|
loop {
|
|
tokio::task::yield_now().await;
|
|
|
|
let fade_duration = Duration::from_secs(2);
|
|
let fade_steps = 100;
|
|
let fade_minimum_level = 0.1;
|
|
|
|
let fade_steps_max = fade_steps;
|
|
let fade_steps_min = (fade_minimum_level * fade_steps as f32) as u32;
|
|
|
|
let now = chrono::Local::now();
|
|
use itertools::Itertools;
|
|
let next_time = if let Some(next_time) = time_file_map
|
|
.keys()
|
|
.sorted()
|
|
.filter(|k| **k > now.time())
|
|
.next()
|
|
{
|
|
Some(next_time)
|
|
} else {
|
|
time_file_map.keys().sorted().next()
|
|
};
|
|
|
|
if let Some(next_time) = next_time {
|
|
let paths = time_file_map.get(next_time).unwrap(); // definitely exists, we just did the math
|
|
use rand::seq::SliceRandom;
|
|
let next_path = paths.choose(&mut rand::thread_rng());
|
|
if next_path.is_none() {
|
|
continue;
|
|
}
|
|
let next_path = next_path.unwrap();
|
|
|
|
let interstitial_time = if *next_time > now.time() {
|
|
now.date_naive()
|
|
.and_time(*next_time)
|
|
.and_local_timezone(chrono::Local)
|
|
} else {
|
|
now.date_naive()
|
|
.checked_add_days(chrono::Days::new(1))
|
|
.unwrap()
|
|
.and_time(*next_time)
|
|
.and_local_timezone(chrono::Local)
|
|
}
|
|
.unwrap();
|
|
|
|
println!("Next Internstitial time {}", interstitial_time);
|
|
|
|
tokio::time::sleep_until(
|
|
tokio::time::Instant::now() + (interstitial_time - now).to_std().unwrap(),
|
|
)
|
|
.await;
|
|
|
|
println!("Internstitial time {}", interstitial_time);
|
|
|
|
for v in (fade_steps_min..=fade_steps_max).rev() {
|
|
let volume = v as f32 / fade_steps as f32;
|
|
announce_downmix_player_controller.set_volume(volume);
|
|
tokio::time::sleep(fade_duration / (fade_steps_max - fade_steps_min)).await;
|
|
}
|
|
|
|
let (sound, completion_notifier) = awedio::sounds::open_file(next_path.as_path())
|
|
.unwrap()
|
|
.with_async_completion_notifier();
|
|
time_announce_mixer_controller.add(Box::new(sound));
|
|
let _ = completion_notifier.await;
|
|
|
|
for v in fade_steps_min..=fade_steps_max {
|
|
let volume = v as f32 / fade_steps as f32;
|
|
announce_downmix_player_controller.set_volume(volume);
|
|
tokio::time::sleep(fade_duration / (fade_steps_max - fade_steps_min)).await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
streamer_manager.play(Box::new(mixer));
|
|
|
|
let listener = TcpListener::bind(addr).await?;
|
|
println!("Listening on http://{}", addr);
|
|
loop {
|
|
let (tcp, _) = listener.accept().await?;
|
|
let io = TokioIo::new(tcp);
|
|
let backend = streamer_backend.clone();
|
|
|
|
println!("New connection!");
|
|
|
|
tokio::task::spawn(async move {
|
|
if let Err(err) = http1::Builder::new().serve_connection(io, backend).await {
|
|
println!("Error serving connection: {:?}", err);
|
|
}
|
|
});
|
|
}
|
|
}
|