Skip to content

Commit

Permalink
feat(playback): merge anni-player into anni-playback (#47)
Browse files Browse the repository at this point in the history
* feat(playback): add `CachedHttpSource` and `CachedAnnilSource`

* feat(playback): add a player implementation

todo: document

* chore(playback): select needed features only

* fix(playback): stop player before opening track

* feat(common): report detailed error when parsing TrackIdentifier
  • Loading branch information
snylonue authored Aug 3, 2024
1 parent 12e82a6 commit 1faeb2c
Show file tree
Hide file tree
Showing 9 changed files with 578 additions and 1 deletion.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 61 additions & 1 deletion anni-common/src/models.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
use std::borrow::{Borrow, Cow};
use std::fmt::Display;
use std::num::NonZeroU8;
use std::str::{FromStr, Split};

Check warning on line 4 in anni-common/src/models.rs

View workflow job for this annotation

GitHub Actions / build (x86_64-unknown-linux-gnu, ubuntu-latest, anni-x86_64-unknown-linux-gnu.tar.gz)

unused import: `Split`

Check warning on line 4 in anni-common/src/models.rs

View workflow job for this annotation

GitHub Actions / build (x86_64-unknown-linux-musl, ubuntu-latest, anni-x86_64-unknown-linux-musl.tar.gz)

unused import: `Split`

Check warning on line 4 in anni-common/src/models.rs

View workflow job for this annotation

GitHub Actions / build (x86_64-pc-windows-msvc, windows-latest, anni-x86_64-pc-windows-msvc.zip)

unused import: `Split`

Check warning on line 4 in anni-common/src/models.rs

View workflow job for this annotation

GitHub Actions / build (x86_64-apple-darwin, macos-13, anni-x86_64-apple-darwin.tar.gz)

unused import: `Split`

Check warning on line 4 in anni-common/src/models.rs

View workflow job for this annotation

GitHub Actions / build (aarch64-apple-darwin, macos-latest, anni-aarch64-apple-darwin.tar.gz)

unused import: `Split`

use thiserror::Error;

#[derive(Hash, PartialEq, Eq)]
pub struct RawTrackIdentifier<'album_id> {
Expand All @@ -26,6 +30,10 @@ impl<'a> RawTrackIdentifier<'a> {
},
}
}

pub fn copied(&'a self) -> Self {
Self::new(&self.album_id, self.disc_id, self.track_id)
}
}

impl<'a> Clone for RawTrackIdentifier<'a> {
Expand All @@ -38,13 +46,65 @@ impl<'a> Clone for RawTrackIdentifier<'a> {
}
}

impl<'a> Display for RawTrackIdentifier<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}/{}", self.album_id, self.disc_id, self.track_id)
}
}

#[derive(Hash, PartialEq, Eq)]
pub struct TrackIdentifier {
inner: RawTrackIdentifier<'static>,
pub inner: RawTrackIdentifier<'static>,
}

impl<'a> Borrow<RawTrackIdentifier<'a>> for TrackIdentifier {
fn borrow(&self) -> &RawTrackIdentifier<'a> {
&self.inner
}
}

impl FromStr for TrackIdentifier {
type Err = ParseError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut split = s.splitn(2, '/');

let album_id = split.next().ok_or(ParseError::InvalidFormat)?;
let disc_id = split
.next()
.ok_or(ParseError::InvalidFormat)?
.parse()
.map_err(|_| ParseError::InvalidDiscId)?;
let track_id = split
.next()
.ok_or(ParseError::InvalidFormat)?
.parse()
.map_err(|_| ParseError::InvalidTrackId)?;

Ok(RawTrackIdentifier::new(album_id, disc_id, track_id).to_owned())
}
}

impl Display for TrackIdentifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.inner.fmt(f)
}
}

impl Clone for TrackIdentifier {
fn clone(&self) -> Self {
self.inner.to_owned()
}
}

#[derive(Debug, Clone, Copy, Error)]
pub enum ParseError {
#[error("invalid album id")]
InvalidAlbumId,
#[error("invalid disc id")]
InvalidDiscId,
#[error("invalid track id")]
InvalidTrackId,
#[error("invalid format")]
InvalidFormat,
}
2 changes: 2 additions & 0 deletions anni-playback/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ anyhow.workspace = true
once_cell.workspace = true
audiopus = { git = "https://github.com/ProjectAnni/audiopus" }
log.workspace = true
anni-provider = { version = "0.3.1", path = "../anni-provider", default-features = false, features = ["priority"] }
anni-common = { version = "0.2", path = "../anni-common" }

[dev-dependencies]
# used by tui example
Expand Down
1 change: 1 addition & 0 deletions anni-playback/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ pub use controls::Controls;
pub use decoder::*;
pub mod sources;
pub mod types;
pub mod player;

pub use utils::create_unbound_channel;
139 changes: 139 additions & 0 deletions anni-playback/src/player.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
pub use anni_provider::providers::TypedPriorityProvider;

pub use crate::sources::cached_http::provider::AudioQuality;

use crossbeam::channel::Sender;
use reqwest::blocking::Client;

use std::{
path::PathBuf,
sync::{
atomic::AtomicBool,
mpsc::{self, Receiver},
Arc, RwLock,
},
thread,
};

use anni_common::models::TrackIdentifier;

use crate::{
sources::cached_http::{cache::CacheStore, provider::ProviderProxy, CachedAnnilSource},
types::PlayerEvent,
Controls, Decoder,
};

pub struct AnniPlayer {
pub controls: Controls,
pub client: Client,
pub thread_killer: Sender<bool>,
provider: RwLock<TypedPriorityProvider<ProviderProxy>>,
cache_store: CacheStore, // root of cache
}

impl AnniPlayer {
pub fn new(
provider: TypedPriorityProvider<ProviderProxy>,
cache_path: PathBuf,
) -> (Self, Receiver<PlayerEvent>) {
let (controls, receiver, killer) = {
let (sender, receiver) = mpsc::channel();
let controls = Controls::new(sender);
let thread_killer = crate::create_unbound_channel();

thread::Builder::new()
.name("anni-playback-decoder".to_owned())
.spawn({
let controls = controls.clone();
move || {
let decoder = Decoder::new(controls, thread_killer.1);

decoder.start();
}
})
.unwrap();

(controls, receiver, thread_killer.0)
};

(
Self {
controls,
client: Client::new(),
thread_killer: killer,
provider: RwLock::new(provider),
cache_store: CacheStore::new(cache_path),
},
receiver,
)
}

pub fn add_provider(&self, url: String, auth: String, priority: i32) {
let mut provider = self.provider.write().unwrap();

provider.insert(ProviderProxy::new(url, auth, self.client.clone()), priority);
}

pub fn clear_provider(&self) {
let mut provider = self.provider.write().unwrap();

*provider = TypedPriorityProvider::new(vec![]);
}

pub fn open(&self, track: TrackIdentifier, quality: AudioQuality) -> anyhow::Result<()> {
log::info!("loading track: {track}");

self.controls.stop();

let provider = self.provider.read().unwrap();

let buffer_signal = Arc::new(AtomicBool::new(true));
let source = CachedAnnilSource::new(
track,
quality,
&self.cache_store,
self.client.clone(),
&provider,
buffer_signal.clone(),
)?;

self.controls.open(Box::new(source), buffer_signal, false);

Ok(())
}

pub fn open_and_play(
&self,
track: TrackIdentifier,
quality: AudioQuality,
) -> anyhow::Result<()> {
self.open(track, quality)?;
self.play();

Ok(())
}

pub fn play(&self) {
self.controls.play();
}

pub fn pause(&self) {
self.controls.pause();
}

pub fn stop(&self) {
self.controls.stop();
}

pub fn open_file(&self, path: String) -> anyhow::Result<()> {
self.controls.open_file(path, false)
}

pub fn set_volume(&self, volume: f32) {
self.controls.set_volume(volume);
}

pub fn seek(&self, position: u64) {
self.controls.seek(position);
}
}
110 changes: 110 additions & 0 deletions anni-playback/src/sources/cached_http/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use std::{
fs::{self, File}, io::{self, ErrorKind}, path::{Path, PathBuf}
};

use crate::CODEC_REGISTRY;
use symphonia::{
core::{
codecs::DecoderOptions, formats::FormatOptions, io::MediaSourceStream,
meta::MetadataOptions, probe::Hint,
},
default::get_probe,
};

use anni_common::models::RawTrackIdentifier;

#[derive(Debug, Clone)]
pub struct CacheStore {
base: PathBuf,
}

impl CacheStore {
pub fn new(base: PathBuf) -> Self {
Self { base }
}

/// Returns the path to given `track`
pub fn loaction_of(&self, track: RawTrackIdentifier) -> PathBuf {
let mut tmp = self.base.clone();

tmp.extend([
track.album_id.as_ref(),
&format!(
"{}_{}",
track.disc_id.to_string(),
track.track_id.to_string(),
),
]);
tmp
}

/// Attempts to open a cache file corresponding to `track` and validates it.
///
/// On success, returns a `Result<File, File>`.
/// If the cache exists and is valid, opens it in read mode and returns an `Ok(_)`.
/// Otherwise, opens or creates a cache file in append mode and returns an `Err(_)`.
///
/// On error, an [`Error`](std::io::Error) is returned.
pub fn acquire(&self, track: RawTrackIdentifier) -> io::Result<Result<File, File>> {
let path = self.loaction_of(track.copied());

if path.exists() {
if validate_audio(&path).unwrap_or(false) {
return File::open(path).map(|f| Ok(f));
}

log::warn!("cache of {track} exists but is invalid");
}

create_dir_all(path.parent().unwrap())?; // parent of `path` exists

File::options()
.read(true)
.append(true)
.create(true)
.open(path)
.map(|f| Err(f))
}

pub fn add(&self, path: &Path, track: RawTrackIdentifier) -> io::Result<()> {
let location = self.loaction_of(track);

if location.exists() {
Err(ErrorKind::AlreadyExists.into())
} else if validate_audio(path).unwrap_or(false) {
fs::copy(path, location).map(|_| {})
} else {
Err(io::Error::new(ErrorKind::Other, "invalid cache"))
}
}
}

pub fn create_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
match fs::create_dir_all(path.as_ref()) {
Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()),
r => r,
}
}

pub fn validate_audio(p: &Path) -> symphonia::core::errors::Result<bool> {
let source = MediaSourceStream::new(Box::new(File::open(p)?), Default::default());

let format_opts = FormatOptions::default();
let metadata_opts = MetadataOptions::default();

let probed = get_probe().format(&Hint::new(), source, &format_opts, &metadata_opts)?;

let mut format_reader = probed.format;
let track = match format_reader.default_track() {
Some(track) => track,
None => return Ok(false),
};

let mut decoder = CODEC_REGISTRY.make(&track.codec_params, &DecoderOptions { verify: true })?;

while let Ok(packet) = format_reader.next_packet() {
let _ = decoder.decode(&packet)?;
}

Ok(decoder.finalize().verify_ok.unwrap_or(false))
}
Loading

0 comments on commit 1faeb2c

Please sign in to comment.