mirror of
https://github.com/jhbruhn/jellyfin-radio.git
synced 2025-03-14 19:45:50 +00:00
Initial commit
This commit is contained in:
commit
186edf9620
10 changed files with 2347 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"rust-analyzer.linkedProjects": [
|
||||||
|
".\\Cargo.toml"
|
||||||
|
]
|
||||||
|
}
|
1675
Cargo.lock
generated
Normal file
1675
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
[package]
|
||||||
|
name = "jellyfin-radio"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
awedio = { version = "0.3", features = ["symphonia-all"], default-features = false }
|
||||||
|
anyhow = "1.0"
|
||||||
|
mp3lame-encoder = { git = "https://github.com/jhbruhn/mp3lame-encoder.git", branch = "send-sync" }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
hyper = { version = "1.2", features = ["server", "http1"] }
|
||||||
|
hyper-util = {version = "0.1", features = ["tokio"] }
|
||||||
|
bytes = "1.5"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
async-broadcast = "0.7"
|
||||||
|
future-bool = "0.1"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
symphonia = { version = "0.5.4", features = ["all"] }
|
||||||
|
envconfig = "0.10"
|
37
Dockerfile
Normal file
37
Dockerfile
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
FROM --platform=$BUILDPLATFORM rust:1.75 as cross
|
||||||
|
ARG TARGETARCH
|
||||||
|
COPY docker/platform.sh .
|
||||||
|
RUN ./platform.sh # should write /.platform and /.compiler
|
||||||
|
RUN rustup target add $(cat /.platform)
|
||||||
|
RUN apt update && apt-get install -y unzip $(cat /.compiler)
|
||||||
|
|
||||||
|
WORKDIR ./jellyfin-radio
|
||||||
|
ADD . ./
|
||||||
|
RUN cargo build --release --target $(cat /.platform)
|
||||||
|
RUN cp ./target/$(cat /.platform)/release/jellyfin-radio /jellyfin-radio.bin # Get rid of this when build --out is stable
|
||||||
|
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
ARG APP=/usr/src/app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y ca-certificates tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV TZ=Etc/UTC \
|
||||||
|
APP_USER=appuser
|
||||||
|
|
||||||
|
RUN groupadd $APP_USER \
|
||||||
|
&& useradd -g $APP_USER $APP_USER \
|
||||||
|
&& mkdir -p ${APP}
|
||||||
|
|
||||||
|
COPY --from=cross /jellyfin-radio.bin ${APP}/jellyfin-radio
|
||||||
|
|
||||||
|
RUN chown -R $APP_USER:$APP_USER ${APP}
|
||||||
|
|
||||||
|
USER $APP_USER
|
||||||
|
WORKDIR ${APP}
|
||||||
|
|
||||||
|
CMD ["./jellyfin-radio"]
|
19
docker/platform.sh
Normal file
19
docker/platform.sh
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Used in Docker build to set platform dependent variables
|
||||||
|
|
||||||
|
case $TARGETARCH in
|
||||||
|
|
||||||
|
"amd64")
|
||||||
|
echo "x86_64-unknown-linux-gnu" > /.platform
|
||||||
|
echo "" > /.compiler
|
||||||
|
;;
|
||||||
|
"arm64")
|
||||||
|
echo "aarch64-unknown-linux-gnu" > /.platform
|
||||||
|
echo "gcc-aarch64-linux-gnu" > /.compiler
|
||||||
|
;;
|
||||||
|
"arm")
|
||||||
|
echo "armv7-unknown-linux-gnueabihf" > /.platform
|
||||||
|
echo "gcc-arm-linux-gnueabihf" > /.compiler
|
||||||
|
;;
|
||||||
|
esac
|
160
src/jellyfin.rs
Normal file
160
src/jellyfin.rs
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
use bytes::Buf;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
pub struct JellyfinClient {
|
||||||
|
api_token: String,
|
||||||
|
base_url: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Audio {
|
||||||
|
#[serde(rename(deserialize = "Id"))]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename(deserialize = "Name"))]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename(deserialize = "Artists"))]
|
||||||
|
pub artists: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct View {
|
||||||
|
#[serde(rename(deserialize = "Name"))]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename(deserialize = "Id"))]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename(deserialize = "CollectionType"))]
|
||||||
|
pub collection_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
#[serde(rename(deserialize = "Name"))]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename(deserialize = "Id"))]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename(deserialize = "Policy"))]
|
||||||
|
pub policy: UserPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserPolicy {
|
||||||
|
#[serde(rename(deserialize = "IsAdministrator"))]
|
||||||
|
pub is_administrator: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JellyfinClient {
|
||||||
|
pub fn new(base_url: String, api_token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url,
|
||||||
|
api_token,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users(&self) -> anyhow::Result<Vec<User>> {
|
||||||
|
let url = format!("{}/Users", self.base_url);
|
||||||
|
let response: Vec<User> = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("MediaBrowser Token=\"{}\"", self.api_token),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn views(&self, user_id: &str) -> anyhow::Result<Vec<View>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ViewList {
|
||||||
|
#[serde(rename(deserialize = "Items"))]
|
||||||
|
items: Vec<View>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/Users/{user_id}/Views", self.base_url);
|
||||||
|
let response: ViewList = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("MediaBrowser Token=\"{}\"", self.api_token),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(response.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn random_audio(&self, user_id: &str, collection_id: &str) -> anyhow::Result<Audio> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AudioList {
|
||||||
|
#[serde(rename(deserialize = "Items"))]
|
||||||
|
items: Vec<Audio>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/Users/{user_id}/Items", self.base_url);
|
||||||
|
let mut response: AudioList = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.query(&[
|
||||||
|
("ParentId", collection_id),
|
||||||
|
("Filters", "IsNotFolder"),
|
||||||
|
("Recursive", "true"),
|
||||||
|
("SortBy", "Random"),
|
||||||
|
("MediaTypes", "Audio"),
|
||||||
|
("Limit", "1"),
|
||||||
|
("ExcludeLocationTypes", "Virtual"),
|
||||||
|
("CollapseBoxSetItems", "false"),
|
||||||
|
])
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("MediaBrowser Token=\"{}\"", self.api_token),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
response.items.pop().ok_or(anyhow::anyhow!("No item found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_audio(&self, audio: Audio) -> anyhow::Result<Box<dyn awedio::Sound>> {
|
||||||
|
let url = format!("{}/Items/{}/Download", self.base_url, audio.id);
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("MediaBrowser Token=\"{}\"", self.api_token),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let filename = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|v| v.split(";").into_iter())
|
||||||
|
.map(|i| {
|
||||||
|
i.filter(|v| v.contains("filename="))
|
||||||
|
.map(|v| v.split("=").collect::<Vec<&str>>()[1])
|
||||||
|
.next()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let extension = filename
|
||||||
|
.and_then(|v| v.rsplit(".").next())
|
||||||
|
.map(String::from)
|
||||||
|
.map(|s| s.replace("\"", ""));
|
||||||
|
let body = response.bytes().await?;
|
||||||
|
|
||||||
|
let decoder = Box::new(awedio::sounds::decoders::SymphoniaDecoder::new(
|
||||||
|
Box::new(symphonia::core::io::ReadOnlySource::new(body.reader())),
|
||||||
|
extension.as_deref(),
|
||||||
|
)?);
|
||||||
|
|
||||||
|
Ok(decoder)
|
||||||
|
}
|
||||||
|
}
|
106
src/main.rs
Normal file
106
src/main.rs
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()?;
|
||||||
|
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
let (player, mut controller) = player::Player::new(config.song_prefetch);
|
||||||
|
let player = Box::new(player);
|
||||||
|
streamer_manager.play(player);
|
||||||
|
loop {
|
||||||
|
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!");
|
||||||
|
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 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();
|
||||||
|
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
if let Err(err) = http1::Builder::new().serve_connection(io, backend).await {
|
||||||
|
println!("Error serving connection: {:?}", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
188
src/player.rs
Normal file
188
src/player.rs
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
use awedio::NextSample;
|
||||||
|
use awedio::Sound;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
/// Heavily Based on awedios SoundList and Controllable implementations
|
||||||
|
|
||||||
|
pub struct Player {
|
||||||
|
sounds: Vec<Box<dyn Sound>>,
|
||||||
|
was_empty: bool,
|
||||||
|
song_prefetch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Command<S> = Box<dyn FnOnce(&mut S) + Send>;
|
||||||
|
|
||||||
|
pub struct PlayerControllable {
|
||||||
|
inner: Player,
|
||||||
|
command_receiver: mpsc::Receiver<Command<Player>>,
|
||||||
|
queue_next_song_sender: tokio::sync::mpsc::Sender<()>,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlayerController {
|
||||||
|
command_sender: mpsc::Sender<Command<Player>>,
|
||||||
|
queue_next_song_receiver: tokio::sync::mpsc::Receiver<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Player {
|
||||||
|
/// Create a new empty Player.
|
||||||
|
pub fn new(song_prefetch: u32) -> (PlayerControllable, PlayerController) {
|
||||||
|
let (queue_next_song_sender, queue_next_song_receiver) = tokio::sync::mpsc::channel(1);
|
||||||
|
let inner = Player {
|
||||||
|
sounds: Vec::new(),
|
||||||
|
was_empty: false,
|
||||||
|
song_prefetch,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (command_sender, command_receiver) = mpsc::channel::<Command<Player>>();
|
||||||
|
let controllable = PlayerControllable {
|
||||||
|
inner,
|
||||||
|
queue_next_song_sender,
|
||||||
|
command_receiver,
|
||||||
|
finished: false,
|
||||||
|
};
|
||||||
|
let controller = PlayerController {
|
||||||
|
command_sender,
|
||||||
|
queue_next_song_receiver,
|
||||||
|
};
|
||||||
|
|
||||||
|
(controllable, controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a Sound to be played after any existing sounds have `Finished`.
|
||||||
|
pub fn add(&mut self, sound: Box<dyn Sound>) {
|
||||||
|
if self.sounds.is_empty() {
|
||||||
|
self.was_empty = true;
|
||||||
|
}
|
||||||
|
self.sounds.push(sound);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_song_playing_or_empty(&self) -> bool {
|
||||||
|
self.sounds.len() <= self.song_prefetch as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returned only when no sounds exist so they shouldn't be used in practice.
|
||||||
|
const DEFAULT_CHANNEL_COUNT: u16 = 2;
|
||||||
|
const DEFAULT_SAMPLE_RATE: u32 = 44100;
|
||||||
|
|
||||||
|
impl Sound for Player {
|
||||||
|
fn channel_count(&self) -> u16 {
|
||||||
|
self.sounds
|
||||||
|
.first()
|
||||||
|
.map(|s| s.channel_count())
|
||||||
|
.unwrap_or(DEFAULT_CHANNEL_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_rate(&self) -> u32 {
|
||||||
|
self.sounds
|
||||||
|
.first()
|
||||||
|
.map(|s| s.sample_rate())
|
||||||
|
.unwrap_or(DEFAULT_SAMPLE_RATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_start_of_batch(&mut self) {
|
||||||
|
for sound in &mut self.sounds {
|
||||||
|
sound.on_start_of_batch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_sample(&mut self) -> Result<NextSample, awedio::Error> {
|
||||||
|
let Some(next_sound) = self.sounds.first_mut() else {
|
||||||
|
return Ok(NextSample::Finished);
|
||||||
|
};
|
||||||
|
if self.was_empty {
|
||||||
|
self.was_empty = false;
|
||||||
|
return Ok(NextSample::MetadataChanged);
|
||||||
|
}
|
||||||
|
let next_sample = match next_sound.next_sample() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
self.sounds.remove(0);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ret = match next_sample {
|
||||||
|
NextSample::Sample(_) | NextSample::MetadataChanged | NextSample::Paused => next_sample,
|
||||||
|
NextSample::Finished => {
|
||||||
|
self.sounds.remove(0);
|
||||||
|
if self.sounds.is_empty() {
|
||||||
|
NextSample::Finished
|
||||||
|
} else {
|
||||||
|
// The next sample might have different metadata. Instead of
|
||||||
|
// normalizing here let downstream normalize.
|
||||||
|
NextSample::MetadataChanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(ret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sound for PlayerControllable {
|
||||||
|
fn channel_count(&self) -> u16 {
|
||||||
|
self.inner.channel_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_rate(&self) -> u32 {
|
||||||
|
self.inner.sample_rate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_sample(&mut self) -> Result<awedio::NextSample, awedio::Error> {
|
||||||
|
let next = self.inner.next_sample()?;
|
||||||
|
match next {
|
||||||
|
awedio::NextSample::Sample(_)
|
||||||
|
| awedio::NextSample::MetadataChanged
|
||||||
|
| awedio::NextSample::Paused => Ok(next),
|
||||||
|
// Since this is controllable we might add another sound later.
|
||||||
|
// Ideally we would do this only if the inner sound can have sounds
|
||||||
|
// added to it but I don't think we can branch on S: AddSound here.
|
||||||
|
// We could add a Sound::is_addable but lets avoid that until we see
|
||||||
|
// a reason why it is necessary.
|
||||||
|
awedio::NextSample::Finished => {
|
||||||
|
if self.finished {
|
||||||
|
Ok(awedio::NextSample::Finished)
|
||||||
|
} else {
|
||||||
|
Ok(awedio::NextSample::Paused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_start_of_batch(&mut self) {
|
||||||
|
loop {
|
||||||
|
match self.command_receiver.try_recv() {
|
||||||
|
Ok(command) => command(&mut self.inner),
|
||||||
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
self.finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.inner.last_song_playing_or_empty() {
|
||||||
|
let _ = self.queue_next_song_sender.try_send(());
|
||||||
|
}
|
||||||
|
self.inner.on_start_of_batch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerController {
|
||||||
|
pub fn send_command(&mut self, command: Command<Player>) {
|
||||||
|
// Ignore the error since it only happens if the receiver
|
||||||
|
// has been dropped which is not expected after it has been
|
||||||
|
// sent to the manager.
|
||||||
|
let _ = self.command_sender.send(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerController {
|
||||||
|
pub fn add(&mut self, sound: Box<dyn Sound>) {
|
||||||
|
self.send_command(Box::new(|s: &mut Player| s.add(sound)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_for_queue(&mut self) {
|
||||||
|
self.queue_next_song_receiver.recv().await;
|
||||||
|
}
|
||||||
|
}
|
132
src/streamer.rs
Normal file
132
src/streamer.rs
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
use awedio::{manager::Manager, Sound};
|
||||||
|
|
||||||
|
use async_broadcast::Receiver;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use core::time::Duration;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
use http_body_util::{combinators::BoxBody, StreamBody};
|
||||||
|
use hyper::body::Frame;
|
||||||
|
use hyper::service::Service;
|
||||||
|
use hyper::{body, Request};
|
||||||
|
use hyper::{Response, StatusCode};
|
||||||
|
|
||||||
|
const SEGMENT_INTERVAL: u64 = 2; // seconds
|
||||||
|
const SAMPLE_RATE: u64 = 48000;
|
||||||
|
const CHANNEL_COUNT: u64 = 2;
|
||||||
|
|
||||||
|
type Chunk = [i16; (SEGMENT_INTERVAL * SAMPLE_RATE * CHANNEL_COUNT) as usize];
|
||||||
|
|
||||||
|
pub struct StreamerBackend {
|
||||||
|
stream_receiver: Receiver<Box<Chunk>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamerBackend {
|
||||||
|
pub fn start() -> anyhow::Result<(Self, Manager)> {
|
||||||
|
let (manager, mut renderer) = Manager::new();
|
||||||
|
renderer.set_output_channel_count_and_sample_rate(CHANNEL_COUNT as u16, SAMPLE_RATE as u32);
|
||||||
|
|
||||||
|
let Ok(awedio::NextSample::MetadataChanged) = renderer.next_sample() else {
|
||||||
|
panic!("expected MetadataChanged event")
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut s, stream_receiver) = async_broadcast::broadcast(3);
|
||||||
|
s.set_overflow(true);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut stream = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
|
||||||
|
Duration::from_secs(SEGMENT_INTERVAL),
|
||||||
|
))
|
||||||
|
.map(move |_| {
|
||||||
|
let mut buffer = [0_i16; (SAMPLE_RATE * SEGMENT_INTERVAL * CHANNEL_COUNT) as usize];
|
||||||
|
renderer.on_start_of_batch();
|
||||||
|
buffer.fill_with(|| {
|
||||||
|
let sample = renderer
|
||||||
|
.next_sample()
|
||||||
|
.expect("renderer should never return an Error");
|
||||||
|
let sample = match sample {
|
||||||
|
awedio::NextSample::Sample(s) => s,
|
||||||
|
awedio::NextSample::MetadataChanged => {
|
||||||
|
unreachable!("we never change metadata mid-batch")
|
||||||
|
}
|
||||||
|
awedio::NextSample::Paused => 0,
|
||||||
|
awedio::NextSample::Finished => 0,
|
||||||
|
};
|
||||||
|
sample
|
||||||
|
});
|
||||||
|
Box::new(buffer)
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
s.broadcast(stream.next().await.expect("Should not end!"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((Self { stream_receiver }, manager))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for StreamerBackend {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
stream_receiver: self.stream_receiver.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service<Request<body::Incoming>> for StreamerBackend {
|
||||||
|
type Response = Response<BoxBody<Bytes, anyhow::Error>>;
|
||||||
|
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
type Future = std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
fn call(&self, _req: Request<body::Incoming>) -> Self::Future {
|
||||||
|
use mp3lame_encoder::{Builder, InterleavedPcm};
|
||||||
|
|
||||||
|
let mut mp3_encoder = Builder::new().expect("Create LAME builder");
|
||||||
|
mp3_encoder
|
||||||
|
.set_num_channels(CHANNEL_COUNT as u8)
|
||||||
|
.expect("set channels");
|
||||||
|
mp3_encoder
|
||||||
|
.set_sample_rate(SAMPLE_RATE as u32)
|
||||||
|
.expect("set sample rate");
|
||||||
|
mp3_encoder
|
||||||
|
.set_brate(mp3lame_encoder::Bitrate::Kbps320)
|
||||||
|
.expect("set brate");
|
||||||
|
mp3_encoder
|
||||||
|
.set_quality(mp3lame_encoder::Quality::Best)
|
||||||
|
.expect("set quality");
|
||||||
|
let mut mp3_encoder = mp3_encoder.build().expect("To initialize LAME encoder");
|
||||||
|
|
||||||
|
//use actual PCM data
|
||||||
|
let watch_stream = self.stream_receiver.clone().map(move |data| {
|
||||||
|
let input = InterleavedPcm(&data.as_slice());
|
||||||
|
let mut mp3_out_buffer: Vec<u8> = Vec::new();
|
||||||
|
mp3_out_buffer.reserve(mp3lame_encoder::max_required_buffer_size(data.len() / 2));
|
||||||
|
let encoded_size = mp3_encoder
|
||||||
|
.encode(input, mp3_out_buffer.spare_capacity_mut())
|
||||||
|
.expect("To encode");
|
||||||
|
unsafe {
|
||||||
|
mp3_out_buffer.set_len(mp3_out_buffer.len().wrapping_add(encoded_size));
|
||||||
|
}
|
||||||
|
anyhow::Ok(Bytes::from(mp3_out_buffer))
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream_body = StreamBody::new(watch_stream.map_ok(Frame::data));
|
||||||
|
|
||||||
|
let boxed_body: BoxBody<Bytes, anyhow::Error> = BoxBody::new(stream_body); //.boxed();
|
||||||
|
Box::pin(async {
|
||||||
|
anyhow::Ok(
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.body(boxed_body)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue