feat: everything works again. now to break it again

This commit is contained in:
Lys 2023-10-12 19:18:36 +03:00
parent 1dfd27f6ae
commit 7a659ced4d
Signed by: lyssieth
GPG key ID: C9CF3D614FAA3940
23 changed files with 910 additions and 267 deletions

85
Cargo.lock generated
View file

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

View file

@ -14,4 +14,16 @@ run: mount
refresh:
sea migrate fresh
sea generate entity -o ./entities/src --with-serde both --date-time-crate time --lib
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

View file

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

View file

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

View file

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

View file

@ -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(),
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
pub mod album;
pub mod child;
pub mod music_folder;
pub mod track;

View file

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

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

View file

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

View file

@ -1,4 +0,0 @@
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct Track {}