feat: everything works again. now to break it again
This commit is contained in:
parent
1dfd27f6ae
commit
7a659ced4d
23 changed files with 910 additions and 267 deletions
85
Cargo.lock
generated
85
Cargo.lock
generated
|
|
@ -338,6 +338,26 @@ version = "1.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "audiotags"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f47bdf1acc4fa4113f8aa72bb3bfe62a61443c61d0d997b723ba61ce102c79b"
|
||||
dependencies = [
|
||||
"audiotags-dev-macro",
|
||||
"id3",
|
||||
"metaflac",
|
||||
"mp4ameta",
|
||||
"readme-rustdocifier",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audiotags-dev-macro"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79298591161f312f06327df7963063ee07466be303dcc3084a44ec293cb36e"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.1.0"
|
||||
|
|
@ -966,6 +986,7 @@ checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
|
|||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
|
|
@ -1371,7 +1392,6 @@ dependencies = [
|
|||
"bitflags 2.4.0",
|
||||
"byteorder",
|
||||
"flate2",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1595,6 +1615,17 @@ version = "2.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
|
||||
|
||||
[[package]]
|
||||
name = "metaflac"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1470d3cc1bb0d692af5eb3afb594330b8ba09fd91c32c4e1c6322172a5ba750"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"hex",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1645,6 +1676,22 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mp4ameta"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb23d62e8eb5299a3f79657c70ea9269eac8f6239a76952689bcd06a74057e81"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"mp4ameta_proc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mp4ameta_proc"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07dcca13d1740c0a665f77104803360da0bdb3323ecce2e93fa2c959a6d52806"
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "2.1.0"
|
||||
|
|
@ -1839,9 +1886,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "3.9.1"
|
||||
version = "3.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a54938017eacd63036332b4ae5c8a49fc8c0c1d6d629893057e4f13609edd06"
|
||||
checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
|
@ -2268,11 +2315,11 @@ dependencies = [
|
|||
name = "rave"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"audiotags",
|
||||
"cfg-if",
|
||||
"color-eyre",
|
||||
"entities",
|
||||
"futures-lite",
|
||||
"id3",
|
||||
"futures",
|
||||
"md5",
|
||||
"migration",
|
||||
"once_cell",
|
||||
|
|
@ -2293,6 +2340,12 @@ dependencies = [
|
|||
"url-escape",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "readme-rustdocifier"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ad765b21a08b1a8e5cdce052719188a23772bcbefb3c439f0baaf62c56ceac"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.3.5"
|
||||
|
|
@ -2304,14 +2357,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.9.6"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff"
|
||||
checksum = "d119d7c7ca818f8a53c300863d4f87566aac09943aef5b355bb83969dae75d87"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata 0.3.9",
|
||||
"regex-syntax 0.7.5",
|
||||
"regex-automata 0.4.1",
|
||||
"regex-syntax 0.8.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2325,13 +2378,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.3.9"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9"
|
||||
checksum = "465c6fc0621e4abc4187a2bda0937bfd4f722c2730b29562e19689ea796c9a4b"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax 0.7.5",
|
||||
"regex-syntax 0.8.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2342,9 +2395,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
|||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.7.5"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
|
||||
checksum = "56d84fdd47036b038fc80dd333d10b6aab10d5d31f4a366e20014def75328d33"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
|
|
@ -2708,9 +2761,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.19"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad977052201c6de01a8ef2aa3378c4bd23217a056337d1d6da40468d267a4fb0"
|
||||
checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090"
|
||||
|
||||
[[package]]
|
||||
name = "sentry"
|
||||
|
|
|
|||
12
Justfile
12
Justfile
|
|
@ -15,3 +15,15 @@ run: mount
|
|||
refresh:
|
||||
sea migrate fresh
|
||||
sea generate entity -o ./entities/src --with-serde both --date-time-crate time --lib
|
||||
|
||||
scan:
|
||||
curl -vv "http://localhost:1234/rest/startScan?c=supersonic&f=xml&s=RTA3MKflcW&t=1058b65692a81c4aac17f5aff879891f&u=admin&v=1.8.0" | xq
|
||||
|
||||
scanStatus:
|
||||
curl -vv "http://localhost:1234/rest/getScanStatus?c=supersonic&f=xml&s=RTA3MKflcW&t=1058b65692a81c4aac17f5aff879891f&u=admin&v=1.8.0" | xq
|
||||
|
||||
getAlbumList2:
|
||||
curl -vv "http://localhost:1234/rest/getAlbumList2?c=supersonic&f=xml&s=RTA3MKflcW&t=1058b65692a81c4aac17f5aff879891f&u=admin&v=1.8.0&type=newest" | xq
|
||||
|
||||
getAlbum ALBUM:
|
||||
curl -vv "http://localhost:1234/rest/getAlbum?c=supersonic&f=xml&s=RTA3MKflcW&t=1058b65692a81c4aac17f5aff879891f&u=admin&v=1.8.0&id={{ ALBUM }}" | xq
|
||||
|
|
@ -11,7 +11,7 @@ pub struct Model {
|
|||
pub name: String,
|
||||
pub artist_id: Option<i64>,
|
||||
pub cover_art_id: Option<i64>,
|
||||
pub song_count: i32,
|
||||
pub song_count: i64,
|
||||
pub duration: i64,
|
||||
pub play_count: i64,
|
||||
pub created: TimeDateTimeWithTimeZone,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub struct Model {
|
|||
pub cover_art_id: Option<i64>,
|
||||
pub artist_image_url: Option<String>,
|
||||
pub album_count: i32,
|
||||
pub starred: bool,
|
||||
pub starred: Option<TimeDateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
|
|
|||
|
|
@ -14,9 +14,13 @@ pub struct Model {
|
|||
pub is_dir: bool,
|
||||
pub cover_art_id: Option<i64>,
|
||||
pub created: TimeDateTimeWithTimeZone,
|
||||
pub starred: Option<TimeDateTimeWithTimeZone>,
|
||||
pub duration: i64,
|
||||
pub bit_rate: Option<i64>,
|
||||
pub track_number: Option<i32>,
|
||||
pub size: i64,
|
||||
pub play_count: i64,
|
||||
pub disc_number: i32,
|
||||
pub suffix: String,
|
||||
pub content_type: String,
|
||||
pub is_video: bool,
|
||||
|
|
|
|||
|
|
@ -32,9 +32,8 @@ impl MigrationTrait for Migration {
|
|||
)
|
||||
.col(
|
||||
ColumnDef::new(Artist::Starred)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ impl MigrationTrait for Migration {
|
|||
.col(ColumnDef::new(Album::Name).string().not_null())
|
||||
.col(ColumnDef::new(Album::ArtistId).big_integer().null())
|
||||
.col(ColumnDef::new(Album::CoverArtId).big_integer().null())
|
||||
.col(ColumnDef::new(Album::SongCount).integer().not_null())
|
||||
.col(ColumnDef::new(Album::SongCount).big_integer().not_null())
|
||||
.col(ColumnDef::new(Album::Duration).big_integer().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Album::PlayCount)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,27 @@ impl MigrationTrait for Migration {
|
|||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Track::Starred)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(Track::Duration).big_integer().not_null())
|
||||
.col(ColumnDef::new(Track::BitRate).big_integer().null())
|
||||
.col(ColumnDef::new(Track::TrackNumber).integer().null())
|
||||
.col(ColumnDef::new(Track::Size).big_integer().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Track::PlayCount)
|
||||
.big_integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Track::DiscNumber)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.col(ColumnDef::new(Track::Suffix).string().not_null())
|
||||
.col(ColumnDef::new(Track::ContentType).string().not_null())
|
||||
.col(
|
||||
|
|
@ -111,8 +129,12 @@ pub enum Track {
|
|||
AlbumId,
|
||||
ArtistId,
|
||||
IsDir,
|
||||
TrackNumber,
|
||||
CoverArtId,
|
||||
Created,
|
||||
Starred,
|
||||
PlayCount,
|
||||
DiscNumber,
|
||||
Duration,
|
||||
BitRate,
|
||||
Size,
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ sea-orm = { workspace = true }
|
|||
entities = { workspace = true }
|
||||
migration = { workspace = true }
|
||||
once_cell = { version = "1.18.0", features = ["parking_lot"] }
|
||||
futures-lite = "1.13.0"
|
||||
id3 = { version = "1.8.0", features = ["tokio"] }
|
||||
futures = "0.3"
|
||||
audiotags = "0.4.1"
|
||||
tracing-appender = "0.2.2"
|
||||
sentry = { version = "0.31.7", default-features = false, features = [
|
||||
"backtrace",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
#![warn(clippy::pedantic, clippy::nursery)]
|
||||
#![deny(clippy::unwrap_used)]
|
||||
#![allow(clippy::module_name_repetitions, clippy::too_many_lines)]
|
||||
#![allow(
|
||||
clippy::module_name_repetitions,
|
||||
clippy::too_many_lines,
|
||||
clippy::cast_possible_truncation
|
||||
)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
use crate::{
|
||||
authentication::Authentication,
|
||||
subsonic::{Error, SubsonicResponse},
|
||||
subsonic::{Album as AlbumId3, Child, Error, SubsonicResponse},
|
||||
utils,
|
||||
};
|
||||
|
||||
use entities::prelude::{Album, Track};
|
||||
use entities::prelude::{Album, Artist, Genre, Track};
|
||||
use poem::web::{Data, Query};
|
||||
use poem_ext::db::DbTxn;
|
||||
use sea_orm::{EntityTrait, ModelTrait};
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, instrument, warn};
|
||||
use tracing::{error, instrument};
|
||||
|
||||
#[poem::handler]
|
||||
#[instrument(skip(txn, auth))]
|
||||
|
|
@ -25,10 +25,27 @@ pub async fn get_album(
|
|||
Err(e) => return e,
|
||||
}
|
||||
|
||||
let album = Album::find_by_id(params.id).one(&**txn).await;
|
||||
let album_id = match params.id.strip_prefix("al-").ok_or_else(|| {
|
||||
Error::RequiredParameterMissing(Some("Album IDs must be formatted as `al-{}`".to_string()))
|
||||
}) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return SubsonicResponse::new_error(e),
|
||||
};
|
||||
let album_id = match album_id.parse::<i64>() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error parsing album ID: {e}"
|
||||
);
|
||||
return SubsonicResponse::new_error(Error::Generic(None));
|
||||
}
|
||||
};
|
||||
|
||||
let album = Album::find_by_id(album_id).one(&**txn).await;
|
||||
let Ok(Some(album)) = album else {
|
||||
match album {
|
||||
Ok(Some(_)) => unreachable!("Some(album) covered by `let .. else`"),
|
||||
Ok(Some(_)) => unreachable!("Ok(Some(_)) covered by `let .. else`"),
|
||||
Ok(None) => return SubsonicResponse::new_error(Error::RequestedDataWasNotFound(None)),
|
||||
Err(e) => {
|
||||
error!(
|
||||
|
|
@ -40,6 +57,43 @@ pub async fn get_album(
|
|||
}
|
||||
};
|
||||
|
||||
let artist = album.find_related(Artist).one(&**txn).await;
|
||||
let Ok(artist) = artist else {
|
||||
match artist {
|
||||
Ok(_) => unreachable!("Ok(_) covered by `let .. else`"),
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error getting artist: {e}"
|
||||
);
|
||||
return SubsonicResponse::new_error(Error::Generic(None));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let genre = if let Some(genre_ids) = &album.genre_ids {
|
||||
if genre_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let genre = Genre::find_by_id(genre_ids[0]).one(&**txn).await;
|
||||
|
||||
match genre {
|
||||
Ok(Some(genre)) => Some(genre),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error getting genre: {e}"
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tracks = album.find_related(Track).all(&**txn).await;
|
||||
|
||||
let tracks = match tracks {
|
||||
|
|
@ -53,11 +107,17 @@ pub async fn get_album(
|
|||
}
|
||||
};
|
||||
|
||||
todo!("finish implementing get_album")
|
||||
// SubsonicResponse::new_album(album, tracks)
|
||||
let album = AlbumId3::new(album, artist, genre);
|
||||
let mut tracks = tracks
|
||||
.into_iter()
|
||||
.map(|t| Child::new(&t, &album))
|
||||
.collect::<Vec<_>>();
|
||||
tracks.sort_by_cached_key(|tr| tr.track.unwrap_or_default());
|
||||
|
||||
SubsonicResponse::new_album(album, tracks)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GetAlbumParams {
|
||||
pub id: i32,
|
||||
pub id: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ use poem::{
|
|||
Request,
|
||||
};
|
||||
use poem_ext::db::DbTxn;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use serde::Deserialize;
|
||||
use tracing::instrument;
|
||||
use tracing::{error, instrument};
|
||||
|
||||
use crate::{
|
||||
authentication::Authentication,
|
||||
random_types::SortType,
|
||||
subsonic::{Error, SubsonicResponse},
|
||||
subsonic::{Album as AlbumId3, Error, SubsonicResponse},
|
||||
utils::{self},
|
||||
};
|
||||
|
||||
|
|
@ -51,30 +51,96 @@ pub async fn get_album_list(
|
|||
};
|
||||
|
||||
let album_list = match params.r#type {
|
||||
SortType::Random => get_album_list_random(txn, params).await,
|
||||
SortType::Newest => get_album_list_newest(txn, params).await,
|
||||
SortType::Highest => get_album_list_highest(txn, params).await,
|
||||
SortType::Frequent => get_album_list_frequent(txn, params).await,
|
||||
SortType::Recent => get_album_list_recent(txn, params).await,
|
||||
SortType::AlphabeticalByName => get_album_list_alphabetical_by_name(txn, params).await,
|
||||
SortType::AlphabeticalByArtist => get_album_list_alphabetical_by_artist(txn, params).await,
|
||||
SortType::Starred => get_album_list_starred(txn, params).await,
|
||||
SortType::ByYear => get_album_list_by_year(txn, params).await,
|
||||
SortType::ByGenre => get_album_list_by_genre(txn, params).await,
|
||||
SortType::Random => get_album_list_random(txn.clone(), params).await,
|
||||
SortType::Newest => get_album_list_newest(txn.clone(), params).await,
|
||||
SortType::Highest => get_album_list_highest(txn.clone(), params).await,
|
||||
SortType::Frequent => get_album_list_frequent(txn.clone(), params).await,
|
||||
SortType::Recent => get_album_list_recent(txn.clone(), params).await,
|
||||
SortType::AlphabeticalByName => {
|
||||
get_album_list_alphabetical_by_name(txn.clone(), params).await
|
||||
}
|
||||
SortType::AlphabeticalByArtist => {
|
||||
get_album_list_alphabetical_by_artist(txn.clone(), params).await
|
||||
}
|
||||
SortType::Starred => get_album_list_starred(txn.clone(), params).await,
|
||||
SortType::ByYear => get_album_list_by_year(txn.clone(), params).await,
|
||||
SortType::ByGenre => get_album_list_by_genre(txn.clone(), params).await,
|
||||
};
|
||||
|
||||
todo!()
|
||||
let album_list = match album_list {
|
||||
Ok(a) => albums_to_album_id3(txn, &a).await,
|
||||
Err(e) => return SubsonicResponse::new_error(e),
|
||||
};
|
||||
|
||||
// match album_list {
|
||||
// Ok(a) => {
|
||||
// if req.uri().path().contains("getAlbumList2") {
|
||||
// SubsonicResponse::new_album_list2(a)
|
||||
// } else {
|
||||
// SubsonicResponse::new_album_list(a)
|
||||
// }
|
||||
// }
|
||||
// Err(e) => SubsonicResponse::new_error(e),
|
||||
// }
|
||||
match album_list {
|
||||
Ok(a) => {
|
||||
if req.uri().path().contains("getAlbumList2") {
|
||||
SubsonicResponse::new_album_list2(a)
|
||||
} else {
|
||||
SubsonicResponse::new_album_list(a)
|
||||
}
|
||||
}
|
||||
Err(e) => SubsonicResponse::new_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn albums_to_album_id3(conn: DbTxn, albums: &[album::Model]) -> Result<Vec<AlbumId3>, Error> {
|
||||
let mut album_id3_futures = Vec::with_capacity(albums.len());
|
||||
|
||||
for album in albums {
|
||||
album_id3_futures.push(album_to_album_id3(conn.clone(), album));
|
||||
}
|
||||
|
||||
let result = futures::future::join_all(album_id3_futures).await;
|
||||
|
||||
let mut album_id3s = Vec::with_capacity(result.len());
|
||||
|
||||
for album in result {
|
||||
album_id3s.push(album?);
|
||||
}
|
||||
|
||||
Ok(album_id3s)
|
||||
}
|
||||
|
||||
async fn album_to_album_id3(conn: DbTxn, album: &album::Model) -> Result<AlbumId3, Error> {
|
||||
let artist = album.find_related(Artist).one(&*conn).await;
|
||||
let Ok(artist) = artist else {
|
||||
match artist {
|
||||
Ok(_) => unreachable!("Ok(_) covered by `let .. else`"),
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error getting artist: {e}"
|
||||
);
|
||||
return Err(Error::Generic(None));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let genre = if let Some(genre_ids) = &album.genre_ids {
|
||||
if genre_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let genre = Genre::find_by_id(genre_ids[0]).one(&*conn).await;
|
||||
|
||||
match genre {
|
||||
Ok(Some(genre)) => Some(genre),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error getting genre: {e}"
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(AlbumId3::new(album.clone(), artist, genre))
|
||||
}
|
||||
|
||||
#[instrument(skip(_conn, _params))]
|
||||
|
|
@ -138,6 +204,7 @@ async fn get_album_list_recent(
|
|||
params: GetAlbumListParams,
|
||||
) -> Result<Vec<album::Model>, Error> {
|
||||
let albums = Album::find()
|
||||
.filter(album::Column::Played.is_not_null())
|
||||
.order_by_desc(album::Column::Played)
|
||||
.limit(params.size)
|
||||
.offset(params.offset)
|
||||
|
|
@ -153,7 +220,7 @@ async fn get_album_list_alphabetical_by_name(
|
|||
params: GetAlbumListParams,
|
||||
) -> Result<Vec<album::Model>, Error> {
|
||||
let albums = Album::find()
|
||||
.order_by_desc(album::Column::Name)
|
||||
.order_by_asc(album::Column::Name)
|
||||
.limit(params.size)
|
||||
.offset(params.offset)
|
||||
.all(&*conn)
|
||||
|
|
@ -170,7 +237,7 @@ async fn get_album_list_alphabetical_by_artist(
|
|||
let albums = Album::find()
|
||||
.filter(album::Column::ArtistId.is_not_null())
|
||||
.find_also_related(Artist)
|
||||
.order_by_desc(artist::Column::Name)
|
||||
.order_by_asc(artist::Column::Name)
|
||||
.limit(params.size)
|
||||
.offset(params.offset)
|
||||
.all(&*conn)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
use entities::prelude::Track;
|
||||
use poem::{
|
||||
http::StatusCode,
|
||||
web::{Data, Query},
|
||||
IntoResponse, Response,
|
||||
};
|
||||
use poem_ext::db::DbTxn;
|
||||
use sea_orm::EntityTrait;
|
||||
use serde::Deserialize;
|
||||
use tracing::instrument;
|
||||
use tracing::{error, instrument};
|
||||
|
||||
use crate::{
|
||||
authentication::Authentication,
|
||||
subsonic::{Error, SubsonicResponse},
|
||||
utils::{self},
|
||||
};
|
||||
|
||||
const SONG: &[u8] = include_bytes!("../../../../data.mp3");
|
||||
|
||||
#[poem::handler]
|
||||
#[instrument(skip(txn, auth))]
|
||||
pub async fn stream(
|
||||
Data(txn): Data<&DbTxn>,
|
||||
auth: Authentication,
|
||||
Query(_params): Query<StreamParams>,
|
||||
Query(params): Query<StreamParams>,
|
||||
) -> Response {
|
||||
let u = utils::verify_user(txn.clone(), auth).await;
|
||||
|
||||
|
|
@ -27,16 +28,64 @@ pub async fn stream(
|
|||
Ok(_) => {}
|
||||
Err(e) => return e.into_response(),
|
||||
}
|
||||
let Some(id) = params.id.strip_prefix("tr-") else {
|
||||
return SubsonicResponse::new_error(Error::RequiredParameterMissing(Some(
|
||||
"Track IDs must be formatted as `tr-{}`".to_string(),
|
||||
)))
|
||||
.into_response();
|
||||
};
|
||||
let id = match id.parse::<i64>() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error parsing track ID: {e}"
|
||||
);
|
||||
return SubsonicResponse::new_error(Error::Generic(None)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let track = Track::find_by_id(id).one(&**txn).await;
|
||||
|
||||
let track = match track {
|
||||
Ok(Some(track)) => track,
|
||||
Ok(None) => {
|
||||
return SubsonicResponse::new_error(Error::RequestedDataWasNotFound(None))
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error fetching track: {e}"
|
||||
);
|
||||
return SubsonicResponse::new_error(Error::Generic(None)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let path = track.path;
|
||||
|
||||
let song = tokio::fs::read(path).await;
|
||||
|
||||
let song = match song {
|
||||
Ok(song) => song,
|
||||
Err(e) => {
|
||||
error!(
|
||||
error = &e as &dyn std::error::Error,
|
||||
"Error reading track: {e}"
|
||||
);
|
||||
return SubsonicResponse::new_error(Error::Generic(None)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
poem::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "audio/mpeg")
|
||||
.body(SONG)
|
||||
.body(song)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct StreamParams {
|
||||
pub id: i32,
|
||||
pub id: String,
|
||||
#[serde(rename = "maxBitRate", default)]
|
||||
pub max_bit_rate: Option<i32>,
|
||||
#[serde(default)]
|
||||
|
|
|
|||
164
rave/src/scan.rs
164
rave/src/scan.rs
|
|
@ -1,24 +1,20 @@
|
|||
use color_eyre::{Report, Result};
|
||||
use entities::{
|
||||
album, artist, genre, music_folder,
|
||||
prelude::{Album, Artist, Genre, MusicFolder, Track},
|
||||
track,
|
||||
genre, music_folder,
|
||||
prelude::{Genre, MusicFolder},
|
||||
};
|
||||
use futures_lite::StreamExt;
|
||||
use id3::{Tag, TagLike};
|
||||
use futures::StreamExt;
|
||||
use once_cell::sync::Lazy;
|
||||
use sea_orm::{
|
||||
ActiveModelBehavior, ColumnTrait, ConnectOptions, Database, DatabaseTransaction, EntityTrait,
|
||||
QueryFilter, Set, TransactionTrait,
|
||||
ColumnTrait, ConnectOptions, Database, DatabaseTransaction, EntityTrait, QueryFilter, Set,
|
||||
TransactionTrait,
|
||||
};
|
||||
use std::{
|
||||
convert::Into,
|
||||
ops::ControlFlow,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::{fs::File, sync::RwLock};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, instrument};
|
||||
|
||||
mod walk;
|
||||
|
|
@ -88,6 +84,10 @@ async fn scan() {
|
|||
do_entry(de, txn, state.clone()).await; // test without multithreading
|
||||
}
|
||||
|
||||
// TODO: Go over everything and consolidate duplicates?
|
||||
// This is a bit of a pain, but it's probably worth it
|
||||
// Since if we want to use multithreading, we need to do this
|
||||
|
||||
{
|
||||
let mut stat = STATUS.write().await;
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ async fn scan() {
|
|||
}
|
||||
}
|
||||
|
||||
const VALID_EXTENSIONS: &[&str] = &["mp3"];
|
||||
const VALID_EXTENSIONS: &[&str] = &["mp3", "flac"];
|
||||
|
||||
async fn do_entry(de: walk::DirEntry, txn: DatabaseTransaction, state: Arc<RwLock<ScanState>>) {
|
||||
if let Err(e) = handle_entry(&txn, de.clone(), state).await {
|
||||
|
|
@ -175,99 +175,22 @@ async fn handle_entry(
|
|||
return Ok(());
|
||||
};
|
||||
|
||||
let stem = stem.to_string_lossy();
|
||||
let track = match ext.as_ref() {
|
||||
"mp3" => mp3::handle(tx, path.clone(), stem.to_string_lossy(), state.clone()).await?,
|
||||
"flac" => flac::handle(tx, path.clone(), stem.to_string_lossy(), state.clone()).await?,
|
||||
|
||||
let meta = { File::open(&path).await?.metadata().await? };
|
||||
_ => unreachable!("We already checked the extension"),
|
||||
};
|
||||
|
||||
let tag = Tag::async_read_from_path(&path).await?;
|
||||
|
||||
let artist = find_artist(tx, &tag).await?;
|
||||
|
||||
let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?;
|
||||
|
||||
let mut am = track::ActiveModel::new();
|
||||
|
||||
am.title = Set(tag.title().unwrap_or(&stem).to_string());
|
||||
am.album_id = Set(Some(album.id));
|
||||
am.artist_id = Set(artist.as_ref().map(|c| c.id));
|
||||
am.content_type = Set("audio/mpeg".to_string());
|
||||
am.suffix = Set("mp3".to_string());
|
||||
am.path = Set(path.to_string_lossy().to_string());
|
||||
am.is_dir = Set(false);
|
||||
am.is_video = Set(false);
|
||||
am.duration = Set(tag.duration().map(Into::into).unwrap_or_default());
|
||||
am.created = Set(OffsetDateTime::now_utc());
|
||||
am.size = Set(meta
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("Failed to convert meta len to i64"));
|
||||
|
||||
let model = Track::insert(am).exec_with_returning(tx).await?;
|
||||
|
||||
debug!("Inserted track {:?}", model.id);
|
||||
debug!("Inserted track {:?}", track.id);
|
||||
|
||||
// TODO: figure out how to scan. steal from Gonic if we have to :3
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(tx, tag, state))]
|
||||
async fn find_album(
|
||||
tx: &DatabaseTransaction,
|
||||
artist_id: Option<i64>,
|
||||
tag: &Tag,
|
||||
state: Arc<RwLock<ScanState>>,
|
||||
) -> Result<album::Model, Report> {
|
||||
let Some(album_name) = tag.album() else {
|
||||
return Err(Report::msg("Couldn't get album name from tag"));
|
||||
};
|
||||
|
||||
// if not, search by name
|
||||
let search = Album::find()
|
||||
.filter(album::Column::Name.like(album_name))
|
||||
.one(tx)
|
||||
.await?;
|
||||
|
||||
// if we found one, return it
|
||||
if let Some(search) = search {
|
||||
return Ok(search);
|
||||
}
|
||||
|
||||
// otherwise, create it
|
||||
let mut am = album::ActiveModel::new();
|
||||
|
||||
am.name = Set(album_name.to_string());
|
||||
am.music_folder_id = Set(state.read().await.music_folder_id);
|
||||
am.artist_id = Set(artist_id);
|
||||
am.year = Set(tag.year());
|
||||
|
||||
let genre = tag.genre_parsed();
|
||||
if let Some(genre) = genre {
|
||||
let genre_id = find_or_create_genre(tx, genre.as_ref()).await?;
|
||||
am.genre_ids = Set(Some(vec![genre_id]));
|
||||
}
|
||||
am.song_count = Set(tag
|
||||
.total_tracks()
|
||||
.map(|v| i32::try_from(v).expect("Failed to convert total tracks to i32"))
|
||||
.unwrap_or_default());
|
||||
am.duration = Set(tag.duration().map(Into::into).unwrap_or_default());
|
||||
am.created = Set(OffsetDateTime::now_utc());
|
||||
|
||||
let model = Album::insert(am).exec_with_returning(tx).await;
|
||||
|
||||
// if we failed to insert, return the error
|
||||
let Ok(model) = model else {
|
||||
let err = model.expect_err("somehow not err");
|
||||
error!(
|
||||
error = &err as &dyn std::error::Error,
|
||||
"Failed to insert album {err}"
|
||||
);
|
||||
|
||||
return Err(Report::new(err));
|
||||
};
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
mod flac;
|
||||
mod mp3;
|
||||
|
||||
#[instrument(skip(tx))]
|
||||
async fn find_or_create_genre(tx: &DatabaseTransaction, name: &str) -> Result<i64, Report> {
|
||||
|
|
@ -305,55 +228,8 @@ async fn find_or_create_genre(tx: &DatabaseTransaction, name: &str) -> Result<i6
|
|||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(tx, tag))]
|
||||
async fn find_artist(tx: &DatabaseTransaction, tag: &Tag) -> Result<Option<artist::Model>, Report> {
|
||||
let artist_to_search = match (tag.album_artist(), tag.artists()) {
|
||||
(Some(tag_artist), None) => Some(tag_artist.to_string()),
|
||||
(None, Some(tag_artists)) => Some(tag_artists.join(", ")),
|
||||
(Some(tag_artist), Some(tag_artists)) => {
|
||||
let mut artists = tag_artists.clone();
|
||||
artists.push(tag_artist);
|
||||
Some(artists.join(", "))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
match &artist_to_search {
|
||||
Some(artist_to_search) => {
|
||||
let artist_to_search = artist_to_search.trim();
|
||||
let attempt = Artist::find()
|
||||
.filter(artist::Column::Name.like(artist_to_search))
|
||||
.one(tx)
|
||||
.await?;
|
||||
|
||||
if let Some(attempt) = attempt {
|
||||
Ok(Some(attempt))
|
||||
} else {
|
||||
let am = artist::ActiveModel {
|
||||
name: Set(artist_to_search.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let model = Artist::insert(am).exec_with_returning(tx).await;
|
||||
|
||||
let Ok(model) = model else {
|
||||
let err = model.expect_err("somehow not err");
|
||||
error!(
|
||||
error = &err as &dyn std::error::Error,
|
||||
"Failed to insert artist {err}"
|
||||
);
|
||||
|
||||
return Err(Report::new(err));
|
||||
};
|
||||
|
||||
Ok(Some(model))
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ScanState {
|
||||
pub struct ScanState {
|
||||
pub music_folder_id: i64,
|
||||
}
|
||||
|
||||
|
|
|
|||
190
rave/src/scan/flac.rs
Normal file
190
rave/src/scan/flac.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use audiotags::{AudioTagEdit, FlacTag, Tag};
|
||||
use color_eyre::Report;
|
||||
use entities::{
|
||||
album, artist,
|
||||
prelude::{Album, Artist, Track},
|
||||
track,
|
||||
};
|
||||
use sea_orm::{
|
||||
ActiveModelBehavior, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, Set,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::{fs::File, sync::RwLock};
|
||||
use tracing::{debug, error, instrument};
|
||||
|
||||
use super::ScanState;
|
||||
|
||||
#[instrument(skip(tx, state))]
|
||||
pub async fn handle(
|
||||
tx: &DatabaseTransaction,
|
||||
path: PathBuf,
|
||||
stem: Cow<'_, str>,
|
||||
state: Arc<RwLock<ScanState>>,
|
||||
) -> Result<track::Model, Report> {
|
||||
let meta = { File::open(&path).await?.metadata().await? };
|
||||
|
||||
let tag = {
|
||||
let tag = Tag::default().read_from_path(&path)?;
|
||||
|
||||
FlacTag::from(tag)
|
||||
};
|
||||
|
||||
let artist = find_artist(tx, &tag).await?;
|
||||
|
||||
let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?;
|
||||
|
||||
let mut am = track::ActiveModel::new();
|
||||
|
||||
am.title = Set(tag.title().unwrap_or(&stem).to_string());
|
||||
am.album_id = Set(Some(album.id));
|
||||
am.artist_id = Set(artist.as_ref().map(|c| c.id));
|
||||
am.content_type = Set("audio/mpeg".to_string());
|
||||
am.suffix = Set("mp3".to_string());
|
||||
am.path = Set(path.to_string_lossy().to_string());
|
||||
am.is_dir = Set(false);
|
||||
am.track_number = Set(tag
|
||||
.track_number()
|
||||
.map(|v| i32::try_from(v).expect("failed to convert track to i32")));
|
||||
if let Some(disc_number) = tag
|
||||
.disc_number()
|
||||
.map(|v| i32::try_from(v).expect("failed to convert disc to i32"))
|
||||
{
|
||||
am.disc_number = Set(disc_number);
|
||||
}
|
||||
am.is_video = Set(false);
|
||||
am.duration = {
|
||||
let duration = tag.duration();
|
||||
|
||||
let duration = duration.unwrap_or_default();
|
||||
|
||||
debug!("Duration: {duration}");
|
||||
let duration = duration.round().rem_euclid(2f64.powi(32)) as i64;
|
||||
debug!("math'd duration: {duration}");
|
||||
|
||||
Set(duration)
|
||||
};
|
||||
am.created = Set(OffsetDateTime::now_utc());
|
||||
am.size = Set(meta
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("Failed to convert meta len to i64"));
|
||||
|
||||
let model = Track::insert(am).exec_with_returning(tx).await?;
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
#[instrument(skip(tx, tag, state))]
|
||||
async fn find_album(
|
||||
tx: &DatabaseTransaction,
|
||||
artist_id: Option<i64>,
|
||||
tag: &FlacTag,
|
||||
state: Arc<RwLock<ScanState>>,
|
||||
) -> Result<album::Model, Report> {
|
||||
let Some(album) = tag.album() else {
|
||||
return Err(Report::msg("Couldn't get album name from tag"));
|
||||
};
|
||||
|
||||
// if not, search by name
|
||||
let search = Album::find()
|
||||
.filter(album::Column::Name.like(album.title))
|
||||
.one(tx)
|
||||
.await?;
|
||||
|
||||
// if we found one, return it
|
||||
if let Some(search) = search {
|
||||
return Ok(search);
|
||||
}
|
||||
|
||||
// otherwise, create it
|
||||
let mut am = album::ActiveModel::new();
|
||||
|
||||
am.name = Set(album.title.to_string());
|
||||
am.music_folder_id = Set(state.read().await.music_folder_id);
|
||||
am.artist_id = Set(artist_id);
|
||||
am.year = Set(tag.year());
|
||||
|
||||
let genre = tag.genre();
|
||||
if let Some(genre) = genre {
|
||||
let genre_id = super::find_or_create_genre(tx, genre).await?;
|
||||
am.genre_ids = Set(Some(vec![genre_id]));
|
||||
}
|
||||
am.song_count = Set(tag
|
||||
.total_tracks()
|
||||
.map(|v| i64::try_from(v).expect("Failed to convert total tracks to i32"))
|
||||
.unwrap_or_default());
|
||||
am.duration = Set(tag
|
||||
.duration()
|
||||
.map(|c| c.round().rem_euclid(2f64.powi(32)) as i64) // TODO: figure out how to do this properly
|
||||
.unwrap_or_default());
|
||||
am.created = Set(OffsetDateTime::now_utc());
|
||||
|
||||
let model = Album::insert(am).exec_with_returning(tx).await;
|
||||
|
||||
// if we failed to insert, return the error
|
||||
let Ok(model) = model else {
|
||||
let err = model.expect_err("somehow not err");
|
||||
error!(
|
||||
error = &err as &dyn std::error::Error,
|
||||
"Failed to insert album {err}"
|
||||
);
|
||||
|
||||
return Err(Report::new(err));
|
||||
};
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
#[instrument(skip(tx, tag))]
|
||||
async fn find_artist(
|
||||
tx: &DatabaseTransaction,
|
||||
tag: &FlacTag,
|
||||
) -> Result<Option<artist::Model>, Report> {
|
||||
let artist_to_search = match (tag.album_artist(), tag.artists()) {
|
||||
(Some(tag_artist), None) => Some(tag_artist.to_string()),
|
||||
(None, Some(tag_artists)) => Some(tag_artists.join(", ")),
|
||||
(Some(tag_artist), Some(tag_artists)) => {
|
||||
let mut artists = tag_artists.clone();
|
||||
artists.push(tag_artist);
|
||||
Some(artists.join(", "))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
match &artist_to_search {
|
||||
Some(artist_to_search) => {
|
||||
let artist_to_search = artist_to_search.trim();
|
||||
let attempt = Artist::find()
|
||||
.filter(artist::Column::Name.like(artist_to_search))
|
||||
.one(tx)
|
||||
.await?;
|
||||
|
||||
if let Some(attempt) = attempt {
|
||||
Ok(Some(attempt))
|
||||
} else {
|
||||
let am = artist::ActiveModel {
|
||||
name: Set(artist_to_search.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let model = Artist::insert(am).exec_with_returning(tx).await;
|
||||
|
||||
let Ok(model) = model else {
|
||||
let err = model.expect_err("somehow not err");
|
||||
error!(
|
||||
error = &err as &dyn std::error::Error,
|
||||
"Failed to insert artist {err}"
|
||||
);
|
||||
|
||||
return Err(Report::new(err));
|
||||
};
|
||||
|
||||
Ok(Some(model))
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
190
rave/src/scan/mp3.rs
Normal file
190
rave/src/scan/mp3.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use audiotags::{AudioTagEdit, Id3v2Tag, Tag};
|
||||
use color_eyre::Report;
|
||||
use entities::{
|
||||
album, artist,
|
||||
prelude::{Album, Artist, Track},
|
||||
track,
|
||||
};
|
||||
use sea_orm::{
|
||||
ActiveModelBehavior, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, Set,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::{fs::File, sync::RwLock};
|
||||
use tracing::{debug, error, instrument};
|
||||
|
||||
use super::ScanState;
|
||||
|
||||
#[instrument(skip(tx, state))]
|
||||
pub async fn handle(
|
||||
tx: &DatabaseTransaction,
|
||||
path: PathBuf,
|
||||
stem: Cow<'_, str>,
|
||||
state: Arc<RwLock<ScanState>>,
|
||||
) -> Result<track::Model, Report> {
|
||||
let meta = { File::open(&path).await?.metadata().await? };
|
||||
|
||||
let tag = {
|
||||
let tag = Tag::default().read_from_path(&path)?;
|
||||
|
||||
audiotags::Id3v2Tag::from(tag)
|
||||
};
|
||||
|
||||
let artist = find_artist(tx, &tag).await?;
|
||||
|
||||
let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?;
|
||||
|
||||
let mut am = track::ActiveModel::new();
|
||||
|
||||
am.title = Set(tag.title().unwrap_or(&stem).to_string());
|
||||
am.album_id = Set(Some(album.id));
|
||||
am.artist_id = Set(artist.as_ref().map(|c| c.id));
|
||||
am.content_type = Set("audio/mpeg".to_string());
|
||||
am.suffix = Set("mp3".to_string());
|
||||
am.path = Set(path.to_string_lossy().to_string());
|
||||
am.is_dir = Set(false);
|
||||
am.track_number = Set(tag
|
||||
.track_number()
|
||||
.map(|v| i32::try_from(v).expect("failed to convert track to i32")));
|
||||
if let Some(disc_number) = tag
|
||||
.disc_number()
|
||||
.map(|v| i32::try_from(v).expect("failed to convert disc to i32"))
|
||||
{
|
||||
am.disc_number = Set(disc_number);
|
||||
}
|
||||
am.is_video = Set(false);
|
||||
am.duration = {
|
||||
let duration = tag.duration();
|
||||
|
||||
let duration = duration.unwrap_or_default();
|
||||
|
||||
debug!("Duration: {duration}");
|
||||
let duration = duration.round().rem_euclid(2f64.powi(32)) as i64;
|
||||
debug!("math'd duration: {duration}");
|
||||
|
||||
Set(duration)
|
||||
};
|
||||
am.created = Set(OffsetDateTime::now_utc());
|
||||
am.size = Set(meta
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("Failed to convert meta len to i64"));
|
||||
|
||||
let model = Track::insert(am).exec_with_returning(tx).await?;
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
#[instrument(skip(tx, tag, state))]
|
||||
async fn find_album(
|
||||
tx: &DatabaseTransaction,
|
||||
artist_id: Option<i64>,
|
||||
tag: &Id3v2Tag,
|
||||
state: Arc<RwLock<ScanState>>,
|
||||
) -> Result<album::Model, Report> {
|
||||
let Some(album) = tag.album() else {
|
||||
return Err(Report::msg("Couldn't get album name from tag"));
|
||||
};
|
||||
|
||||
// if not, search by name
|
||||
let search = Album::find()
|
||||
.filter(album::Column::Name.like(album.title))
|
||||
.one(tx)
|
||||
.await?;
|
||||
|
||||
// if we found one, return it
|
||||
if let Some(search) = search {
|
||||
return Ok(search);
|
||||
}
|
||||
|
||||
// otherwise, create it
|
||||
let mut am = album::ActiveModel::new();
|
||||
|
||||
am.name = Set(album.title.to_string());
|
||||
am.music_folder_id = Set(state.read().await.music_folder_id);
|
||||
am.artist_id = Set(artist_id);
|
||||
am.year = Set(tag.year());
|
||||
|
||||
let genre = tag.genre();
|
||||
if let Some(genre) = genre {
|
||||
let genre_id = super::find_or_create_genre(tx, genre).await?;
|
||||
am.genre_ids = Set(Some(vec![genre_id]));
|
||||
}
|
||||
am.song_count = Set(tag
|
||||
.total_tracks()
|
||||
.map(|v| i64::try_from(v).expect("Failed to convert total tracks to i32"))
|
||||
.unwrap_or_default());
|
||||
am.duration = Set(tag
|
||||
.duration()
|
||||
.map(|c| c.round().rem_euclid(2f64.powi(32)) as i64) // TODO: figure out how to do this properly
|
||||
.unwrap_or_default());
|
||||
am.created = Set(OffsetDateTime::now_utc());
|
||||
|
||||
let model = Album::insert(am).exec_with_returning(tx).await;
|
||||
|
||||
// if we failed to insert, return the error
|
||||
let Ok(model) = model else {
|
||||
let err = model.expect_err("somehow not err");
|
||||
error!(
|
||||
error = &err as &dyn std::error::Error,
|
||||
"Failed to insert album {err}"
|
||||
);
|
||||
|
||||
return Err(Report::new(err));
|
||||
};
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
#[instrument(skip(tx, tag))]
|
||||
async fn find_artist(
|
||||
tx: &DatabaseTransaction,
|
||||
tag: &Id3v2Tag,
|
||||
) -> Result<Option<artist::Model>, Report> {
|
||||
let artist_to_search = match (tag.album_artist(), tag.artists()) {
|
||||
(Some(tag_artist), None) => Some(tag_artist.to_string()),
|
||||
(None, Some(tag_artists)) => Some(tag_artists.join(", ")),
|
||||
(Some(tag_artist), Some(tag_artists)) => {
|
||||
let mut artists = tag_artists.clone();
|
||||
artists.push(tag_artist);
|
||||
Some(artists.join(", "))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
match &artist_to_search {
|
||||
Some(artist_to_search) => {
|
||||
let artist_to_search = artist_to_search.trim();
|
||||
let attempt = Artist::find()
|
||||
.filter(artist::Column::Name.like(artist_to_search))
|
||||
.one(tx)
|
||||
.await?;
|
||||
|
||||
if let Some(attempt) = attempt {
|
||||
Ok(Some(attempt))
|
||||
} else {
|
||||
let am = artist::ActiveModel {
|
||||
name: Set(artist_to_search.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let model = Artist::insert(am).exec_with_returning(tx).await;
|
||||
|
||||
let Ok(model) = model else {
|
||||
let err = model.expect_err("somehow not err");
|
||||
error!(
|
||||
error = &err as &dyn std::error::Error,
|
||||
"Failed to insert artist {err}"
|
||||
);
|
||||
|
||||
return Err(Report::new(err));
|
||||
};
|
||||
|
||||
Ok(Some(model))
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
|
@ -7,18 +7,20 @@ use std::{
|
|||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use futures_lite::{future::Boxed as BoxedFut, stream, Future, FutureExt, Stream, StreamExt};
|
||||
use futures::{
|
||||
future::BoxFuture as BoxedFut,
|
||||
stream::{self, BoxStream},
|
||||
Future, FutureExt, Stream, StreamExt,
|
||||
};
|
||||
|
||||
use tokio::fs::{read_dir, ReadDir};
|
||||
|
||||
pub use tokio::io::Result;
|
||||
pub type DirEntry = Arc<tokio::fs::DirEntry>;
|
||||
|
||||
type BoxStream = futures_lite::stream::Boxed<Result<DirEntry>>;
|
||||
|
||||
pub struct WalkDir {
|
||||
root: PathBuf,
|
||||
entries: BoxStream,
|
||||
entries: BoxStream<'static, Result<DirEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -34,7 +36,7 @@ impl WalkDir {
|
|||
root: root.as_ref().to_path_buf(),
|
||||
entries: walk_dir(
|
||||
root,
|
||||
None::<Box<dyn FnMut(DirEntry) -> BoxedFut<Filtering> + Send>>,
|
||||
None::<Box<dyn FnMut(DirEntry) -> BoxedFut<'static, Filtering> + Send>>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +63,10 @@ impl Stream for WalkDir {
|
|||
}
|
||||
}
|
||||
|
||||
fn walk_dir<F, Fut>(root: impl AsRef<Path>, filter: Option<F>) -> BoxStream
|
||||
fn walk_dir<F, Fut>(
|
||||
root: impl AsRef<Path>,
|
||||
filter: Option<F>,
|
||||
) -> BoxStream<'static, Result<DirEntry>>
|
||||
where
|
||||
F: FnMut(DirEntry) -> Fut + Send + 'static,
|
||||
Fut: Future<Output = Filtering> + Send,
|
||||
|
|
@ -90,7 +95,10 @@ enum State<F> {
|
|||
|
||||
type UnfoldState<F> = (Result<DirEntry>, State<F>);
|
||||
|
||||
fn walk<F, Fut>(mut dirs: Vec<ReadDir>, filter: Option<F>) -> BoxedFut<Option<UnfoldState<F>>>
|
||||
fn walk<F, Fut>(
|
||||
mut dirs: Vec<ReadDir>,
|
||||
filter: Option<F>,
|
||||
) -> BoxedFut<'static, Option<UnfoldState<F>>>
|
||||
where
|
||||
F: FnMut(DirEntry) -> Fut + Send + 'static,
|
||||
Fut: Future<Output = Filtering> + Send,
|
||||
|
|
@ -116,7 +124,7 @@ fn walk_entry<F, Fut>(
|
|||
entry: DirEntry,
|
||||
mut dirs: Vec<ReadDir>,
|
||||
mut filter: Option<F>,
|
||||
) -> BoxedFut<Option<UnfoldState<F>>>
|
||||
) -> BoxedFut<'static, Option<UnfoldState<F>>>
|
||||
where
|
||||
F: FnMut(DirEntry) -> Fut + Send + 'static,
|
||||
Fut: Future<Output = Filtering> + Send,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ mod error;
|
|||
mod types;
|
||||
pub use error::Error;
|
||||
pub use types::album::Album;
|
||||
pub use types::child::Child;
|
||||
pub use types::music_folder::MusicFolder;
|
||||
pub use types::track::Track;
|
||||
|
||||
impl IntoResponse for SubsonicResponse {
|
||||
fn into_response(self) -> poem::Response {
|
||||
|
|
@ -53,8 +53,11 @@ impl SubsonicResponse {
|
|||
Self::new(SubResponseType::AlbumList2 { albums })
|
||||
}
|
||||
|
||||
pub fn new_album(album: Album, songs: Vec<Track>) -> Self {
|
||||
Self::new(SubResponseType::Album { album, songs })
|
||||
pub fn new_album(album: Album, songs: Vec<Child>) -> Self {
|
||||
Self::new(SubResponseType::Album {
|
||||
album: Box::new(album),
|
||||
songs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
|
|
@ -107,9 +110,9 @@ pub enum SubResponseType {
|
|||
#[serde(rename = "album")]
|
||||
Album {
|
||||
#[serde(flatten)]
|
||||
album: Album,
|
||||
#[serde(flatten)]
|
||||
songs: Vec<Track>,
|
||||
album: Box<Album>,
|
||||
#[serde(rename = "song")]
|
||||
songs: Vec<Child>,
|
||||
},
|
||||
#[serde(rename = "scanStatus")]
|
||||
ScanStatus {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
pub mod album;
|
||||
pub mod child;
|
||||
pub mod music_folder;
|
||||
pub mod track;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,61 @@
|
|||
// use entities::*;
|
||||
use entities::{album, artist, genre};
|
||||
use serde::Serialize;
|
||||
use time::format_description::well_known::Iso8601;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Album {
|
||||
id: String,
|
||||
name: String,
|
||||
artist: Option<String>,
|
||||
#[serde(rename = "artistId")]
|
||||
artist_id: Option<String>,
|
||||
#[serde(rename = "@id")]
|
||||
pub id: String,
|
||||
#[serde(rename = "@name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "@artist", skip_serializing_if = "Option::is_none")]
|
||||
pub artist: Option<String>,
|
||||
#[serde(rename = "@artistId", skip_serializing_if = "Option::is_none")]
|
||||
pub artist_id: Option<String>,
|
||||
#[serde(rename = "@coverArt", skip_serializing_if = "Option::is_none")]
|
||||
pub cover_art_id: Option<String>,
|
||||
#[serde(rename = "@songCount")]
|
||||
pub song_count: u64,
|
||||
#[serde(rename = "@duration")]
|
||||
pub duration: u64,
|
||||
#[serde(rename = "@playCount")]
|
||||
pub play_count: u64,
|
||||
#[serde(rename = "@created")]
|
||||
pub created: String,
|
||||
#[serde(rename = "@starred", skip_serializing_if = "Option::is_none")]
|
||||
pub starred: Option<String>,
|
||||
#[serde(rename = "@year", skip_serializing_if = "Option::is_none")]
|
||||
pub year: Option<u32>,
|
||||
#[serde(rename = "@genre", skip_serializing_if = "Option::is_none")]
|
||||
pub genre: Option<String>,
|
||||
}
|
||||
|
||||
// impl Album {
|
||||
// pub fn new(
|
||||
// album: album::Model,
|
||||
// artists: Option<artist::Model>,
|
||||
// cover_art: Option<cover_art::Model>,
|
||||
// genres: Vec<genre::Model>,
|
||||
// ) -> Self {
|
||||
// Self {
|
||||
// id: album.id.to_string(),
|
||||
// name: album.name,
|
||||
// artist: artist.map(|a| a.name),
|
||||
// artist_id: artist.map(|a| format!("ar-{}", a.id)),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
impl Album {
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
album: album::Model,
|
||||
artist: Option<artist::Model>,
|
||||
genre: Option<genre::Model>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: format!("al-{}", album.id),
|
||||
name: album.name,
|
||||
artist: artist.clone().map(|c| c.name),
|
||||
artist_id: artist.map(|c| format!("ar-{}", c.id)),
|
||||
cover_art_id: album.cover_art_id.map(|c| format!("ca-{c}")),
|
||||
song_count: album.song_count as u64,
|
||||
duration: album.duration as u64,
|
||||
play_count: album.play_count as u64,
|
||||
created: album
|
||||
.created
|
||||
.format(&Iso8601::DEFAULT)
|
||||
.expect("Failed to format time"),
|
||||
starred: album
|
||||
.starred
|
||||
.map(|c| c.format(&Iso8601::DEFAULT).expect("Failed to format time")),
|
||||
year: album.year.map(|c| c as u32),
|
||||
genre: genre.map(|c| c.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
rave/src/subsonic/types/child.rs
Normal file
86
rave/src/subsonic/types/child.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use entities::track;
|
||||
use serde::Serialize;
|
||||
use time::format_description::well_known::Iso8601;
|
||||
|
||||
use crate::subsonic::Album;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Child {
|
||||
#[serde(rename = "@id")]
|
||||
pub id: String,
|
||||
#[serde(rename = "@isDir")]
|
||||
pub is_dir: bool,
|
||||
#[serde(rename = "@title")]
|
||||
pub title: String,
|
||||
#[serde(rename = "@album", skip_serializing_if = "Option::is_none")]
|
||||
pub album: Option<String>,
|
||||
#[serde(rename = "@track", skip_serializing_if = "Option::is_none")]
|
||||
pub track: Option<u32>,
|
||||
#[serde(rename = "@year", skip_serializing_if = "Option::is_none")]
|
||||
pub year: Option<u32>,
|
||||
#[serde(rename = "@coverArtId", skip_serializing_if = "Option::is_none")]
|
||||
pub cover_art_id: Option<String>,
|
||||
#[serde(rename = "@size")]
|
||||
pub size: u64,
|
||||
#[serde(rename = "@contentType")]
|
||||
pub content_type: String,
|
||||
#[serde(rename = "@suffix")]
|
||||
pub suffix: String,
|
||||
#[serde(rename = "@starred", skip_serializing_if = "Option::is_none")]
|
||||
pub starred: Option<String>,
|
||||
#[serde(rename = "@duration")]
|
||||
pub duration: u64,
|
||||
#[serde(rename = "@bitRate", skip_serializing_if = "Option::is_none")]
|
||||
pub bit_rate: Option<u64>,
|
||||
#[serde(rename = "@path")]
|
||||
pub path: String,
|
||||
#[serde(rename = "@playCount")]
|
||||
pub play_count: u64,
|
||||
#[serde(rename = "@discNumber")]
|
||||
pub disc_number: u32,
|
||||
#[serde(rename = "@created")]
|
||||
pub created: String,
|
||||
#[serde(rename = "@albumId", skip_serializing_if = "Option::is_none")]
|
||||
pub album_id: Option<String>,
|
||||
#[serde(rename = "@artistId", skip_serializing_if = "Option::is_none")]
|
||||
pub artist_id: Option<String>,
|
||||
#[serde(rename = "@type")]
|
||||
pub child_type: String,
|
||||
#[serde(rename = "@isVideo")]
|
||||
pub is_video: bool,
|
||||
}
|
||||
|
||||
impl Child {
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
#[must_use]
|
||||
pub fn new(track: &track::Model, album: &Album) -> Self {
|
||||
Self {
|
||||
id: format!("tr-{}", track.id),
|
||||
is_dir: false,
|
||||
title: track.title.clone(),
|
||||
album: Some(album.name.clone()),
|
||||
track: track.track_number.map(|c| c as u32),
|
||||
year: album.year,
|
||||
cover_art_id: track.cover_art_id.map(|c| format!("ca-{c}")),
|
||||
size: track.size as u64,
|
||||
content_type: track.content_type.clone(),
|
||||
suffix: track.suffix.clone(),
|
||||
starred: track
|
||||
.starred
|
||||
.map(|c| c.format(&Iso8601::DEFAULT).expect("Failed to format date")),
|
||||
duration: track.duration as u64,
|
||||
bit_rate: track.bit_rate.map(|c| c as u64),
|
||||
path: track.path.clone(),
|
||||
play_count: track.play_count as u64,
|
||||
disc_number: track.disc_number as u32,
|
||||
created: track
|
||||
.created
|
||||
.format(&Iso8601::DEFAULT)
|
||||
.expect("Failed to format date"),
|
||||
album_id: Some(format!("al-{}", album.id)),
|
||||
artist_id: album.artist_id.clone(),
|
||||
child_type: "music".to_string(),
|
||||
is_video: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,19 @@
|
|||
use entities::music_folder::Model;
|
||||
use serde::{ser::SerializeStruct, Serialize};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MusicFolder {
|
||||
pub(crate) id: i64,
|
||||
pub(crate) name: String,
|
||||
#[serde(rename = "@id")]
|
||||
pub id: String,
|
||||
#[serde(rename = "@name")]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<Model> for MusicFolder {
|
||||
fn from(value: Model) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
id: format!("mf-{}", value.id),
|
||||
name: value.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for MusicFolder {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut s = serializer.serialize_struct("MusicFolder", 2)?;
|
||||
s.serialize_field("id", &format!("mf-{}", self.id))?;
|
||||
s.serialize_field("name", &self.name)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Track {}
|
||||
Loading…
Reference in a new issue