Compare commits

...

2 commits

Author SHA1 Message Date
044ccd6683 feat: API input error handling 2023-11-10 01:06:27 +01:00
f67c23d9e7 refactor: add back duration_ms column 2023-11-09 23:06:01 +01:00
28 changed files with 779 additions and 631 deletions

13
Cargo.lock generated
View file

@ -199,6 +199,7 @@ checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core",
"axum-macros",
"bitflags 1.3.2", "bitflags 1.3.2",
"bytes", "bytes",
"futures-util", "futures-util",
@ -240,6 +241,18 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "axum-macros"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdca6a10ecad987bda04e95606ef85a5417dcaac1a78455242d72e031e2b6b62"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.39",
]
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.69" version = "0.3.69"

View file

@ -73,7 +73,7 @@ sqlx = { version = "0.7.0", default-features = false, features = [
] } ] }
# Web server # Web server
axum = "0.6.20" axum = { version = "0.6.20", features = ["macros"] }
headers = "0.3.9" headers = "0.3.9"
http = "0.2.9" http = "0.2.9"
hyper = { version = "0.14.27", features = ["stream"] } hyper = { version = "0.14.27", features = ["stream"] }

View file

@ -16,7 +16,7 @@ pub struct ApiError {
} }
/// API error type /// API error type
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "utoipa", derive(ToSchema))] #[cfg_attr(feature = "utoipa", derive(ToSchema))]
pub enum ApiErrorKind { pub enum ApiErrorKind {

View file

@ -20,6 +20,12 @@ pub enum ApiError {
NotFound(Cow<'static, str>), NotFound(Cow<'static, str>),
#[error("{0}")] #[error("{0}")]
Other(Cow<'static, str>), Other(Cow<'static, str>),
#[error("{msg}")]
Custom {
msg: Cow<'static, str>,
status: StatusCode,
kind: ApiErrorKind,
},
} }
impl ErrorStatus for ApiError { impl ErrorStatus for ApiError {
@ -32,6 +38,7 @@ impl ErrorStatus for ApiError {
ApiError::Input(_) => StatusCode::BAD_REQUEST, ApiError::Input(_) => StatusCode::BAD_REQUEST,
ApiError::NotFound(_) => StatusCode::NOT_FOUND, ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Custom { status, .. } => *status,
} }
} }
@ -44,6 +51,7 @@ impl ErrorStatus for ApiError {
ApiError::Input(_) => ApiErrorKind::User, ApiError::Input(_) => ApiErrorKind::User,
ApiError::NotFound(_) => ApiErrorKind::User, ApiError::NotFound(_) => ApiErrorKind::User,
ApiError::Other(_) => ApiErrorKind::Other, ApiError::Other(_) => ApiErrorKind::Other,
ApiError::Custom { kind, .. } => *kind,
} }
} }
} }

65
crates/api/src/extract.rs Normal file
View file

@ -0,0 +1,65 @@
//! These are custom axum extractors which return proper ApiErrors when they receive invalid data
//!
//! Source of example: https://github.com/tokio-rs/axum/blob/3ff45d9c96b5192af6b6ec26eb2a2bfcddd00d7d/examples/customize-extractor-error/src/derive_from_request.rs
use axum::{
extract::FromRequest,
extract::{
rejection::{JsonRejection, PathRejection, QueryRejection},
FromRequestParts,
},
response::IntoResponse,
};
use serde::Serialize;
use tiraya_api_model::ApiErrorKind;
use crate::error::ApiError;
#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Path), rejection(ApiError))]
pub struct Path<T>(pub T);
#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Query), rejection(ApiError))]
pub struct Query<T>(pub T);
#[derive(FromRequest)]
#[from_request(via(axum::extract::Json), rejection(ApiError))]
pub struct Json<T>(pub T);
impl<T: Serialize> IntoResponse for Json<T> {
fn into_response(self) -> axum::response::Response {
let Self(value) = self;
axum::Json(value).into_response()
}
}
impl From<PathRejection> for ApiError {
fn from(rejection: PathRejection) -> Self {
Self::Custom {
msg: rejection.body_text().into(),
status: rejection.status(),
kind: ApiErrorKind::User,
}
}
}
impl From<QueryRejection> for ApiError {
fn from(rejection: QueryRejection) -> Self {
Self::Custom {
msg: rejection.body_text().into(),
status: rejection.status(),
kind: ApiErrorKind::User,
}
}
}
impl From<JsonRejection> for ApiError {
fn from(rejection: JsonRejection) -> Self {
Self::Custom {
msg: rejection.body_text().into(),
status: rejection.status(),
kind: ApiErrorKind::User,
}
}
}

View file

@ -1,4 +1,5 @@
mod error; mod error;
mod extract;
mod routes; mod routes;
use std::{net::SocketAddr, ops::Deref, str::FromStr, sync::Arc}; use std::{net::SocketAddr, ops::Deref, str::FromStr, sync::Arc};
@ -130,7 +131,10 @@ pub async fn serve() -> Result<(), anyhow::Error> {
.route( .route(
"/user/:id/playlists", "/user/:id/playlists",
routing::get(routes::user::get_user_playlists), routing::get(routes::user::get_user_playlists),
), )
.fallback(|| async {
crate::error::ApiError::NotFound("API endpoint not found".into())
}),
) )
// TMP: move to frontend server // TMP: move to frontend server
.merge(utoipa_rapidoc::RapiDoc::new("/api/openapi.json").path("/api-docs")) .merge(utoipa_rapidoc::RapiDoc::new("/api/openapi.json").path("/api-docs"))

View file

@ -1,14 +1,15 @@
use axum::{ use axum::extract::State;
extract::{Path, Query, State},
Json,
};
use serde::Deserialize; use serde::Deserialize;
use tiraya_api_model::{Album, TrackSlim}; use tiraya_api_model::{Album, TrackSlim};
use tiraya_db::models::{self as tdb}; use tiraya_db::models::{self as tdb};
use tiraya_extractor::parse_validate_tid; use tiraya_extractor::parse_validate_tid;
use tiraya_utils::EntityType; use tiraya_utils::EntityType;
use crate::{error::ApiError, ApiState}; use crate::{
error::ApiError,
extract::{Json, Path, Query},
ApiState,
};
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
#[serde(default)] #[serde(default)]

View file

@ -1,14 +1,15 @@
use axum::{ use axum::extract::State;
extract::{Path, Query, State},
Json,
};
use serde::Deserialize; use serde::Deserialize;
use tiraya_api_model::{AlbumSlim, Artist, ArtistSlim, PlaylistSlim, TrackSlim}; use tiraya_api_model::{AlbumSlim, Artist, ArtistSlim, PlaylistSlim, TrackSlim};
use tiraya_db::models::{self as tdb}; use tiraya_db::models::{self as tdb};
use tiraya_extractor::parse_validate_tid; use tiraya_extractor::parse_validate_tid;
use tiraya_utils::EntityType; use tiraya_utils::EntityType;
use crate::{error::ApiError, ApiState}; use crate::{
error::ApiError,
extract::{Json, Path, Query},
ApiState,
};
/// Get artist /// Get artist
/// ///

View file

@ -1,11 +1,15 @@
use axum::extract::{Path, Query, State}; use axum::extract::State;
use hyper::{Body, Response}; use hyper::{Body, Response};
use serde::Deserialize; use serde::Deserialize;
use tiraya_db::models::{self as tdb}; use tiraya_db::models::{self as tdb};
use tiraya_extractor::parse_validate_tid; use tiraya_extractor::parse_validate_tid;
use tiraya_utils::{ImageKind, ImageSize}; use tiraya_utils::{ImageKind, ImageSize};
use crate::{error::ApiError, ApiState}; use crate::{
error::ApiError,
extract::{Path, Query},
ApiState,
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LocalImageQuery { pub struct LocalImageQuery {

View file

@ -1,9 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use axum::{ use axum::extract::State;
extract::{Path, Query, State},
Json,
};
use serde::Deserialize; use serde::Deserialize;
use tiraya_api_model::{Playlist, UserSlim}; use tiraya_api_model::{Playlist, UserSlim};
use tiraya_db::models::{self as tdb}; use tiraya_db::models::{self as tdb};
@ -11,7 +8,11 @@ use tiraya_extractor::parse_validate_tid;
use tiraya_utils::EntityType; use tiraya_utils::EntityType;
use uuid::Uuid; use uuid::Uuid;
use crate::{error::ApiError, ApiState}; use crate::{
error::ApiError,
extract::{Json, Path, Query},
ApiState,
};
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
#[serde(default)] #[serde(default)]

View file

@ -1,12 +1,13 @@
use axum::{ use axum::extract::State;
extract::{Path, State},
Json,
};
use tiraya_api_model::Track; use tiraya_api_model::Track;
use tiraya_extractor::parse_validate_tid; use tiraya_extractor::parse_validate_tid;
use tiraya_utils::EntityType; use tiraya_utils::EntityType;
use crate::{error::ApiError, ApiState}; use crate::{
error::ApiError,
extract::{Json, Path},
ApiState,
};
/// Get track /// Get track
/// ///

View file

@ -1,13 +1,14 @@
use axum::{ use axum::extract::State;
extract::{Path, State},
Json,
};
use tiraya_api_model::{PlaylistSlim, User}; use tiraya_api_model::{PlaylistSlim, User};
use tiraya_db::models::{self as tdb}; use tiraya_db::models::{self as tdb};
use tiraya_extractor::parse_validate_tid; use tiraya_extractor::parse_validate_tid;
use tiraya_utils::EntityType; use tiraya_utils::EntityType;
use crate::{error::ApiError, ApiState}; use crate::{
error::ApiError,
extract::{Json, Path},
ApiState,
};
/// Get user /// Get user
/// ///

View file

@ -1,45 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "insert into tracks (src_id, service, name, duration,\n album_id, album_pos, ul_artists, isrc, description, file_size, track_gain, primary_track)\nvalues ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)\non conflict (src_id, service) do update set\n name = excluded.name,\n duration = coalesce(excluded.duration, tracks.duration),\n album_id = excluded.album_id,\n album_pos = coalesce(excluded.album_pos, tracks.album_pos),\n ul_artists = coalesce(excluded.ul_artists, tracks.ul_artists),\n isrc = coalesce(excluded.isrc, tracks.isrc),\n description = coalesce(excluded.description, tracks.description),\n file_size = coalesce(excluded.file_size, tracks.file_size),\n track_gain = coalesce(excluded.track_gain, tracks.track_gain),\n primary_track = coalesce(excluded.primary_track, tracks.primary_track)\nreturning id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "music_service",
"kind": {
"Enum": [
"ty",
"yt",
"sp",
"mx"
]
}
}
},
"Text",
"Int4",
"Int4",
"Int2",
"TextArray",
"Varchar",
"Text",
"Int8",
"Float4",
"Bool"
]
},
"nullable": [
false
]
},
"hash": "2c9bace9d693f5c7af4fa3ab77f021d399974387e2be0ca0107c8706a0a71d82"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "select t.id, t.src_id, t.service as \"service: _\", t.name, t.duration,\n b.id as album_id, b.src_id as album_src_id, b.service as \"album_service: _\", b.name as album_name, b.release_date,\n b.album_type as \"album_type: _\", b.image_url, b.image_date,\n t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,\n t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,\n jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)\n filter (where a.src_id is not null) as \"artists: _\"\nfrom tracks t\n left join artists_tracks art on art.track_id = t.id\n left join artists a on a.id = art.artist_id\n left join albums b on b.id = t.album_id\n join track_aliases ta on ta.track_id=t.id\nwhere ta.src_id=$1 and ta.service=$2\ngroup by (t.id, b.id)", "query": "select t.id, t.src_id, t.service as \"service: _\", t.name, t.duration, t.duration_ms,\n b.id as album_id, b.src_id as album_src_id, b.service as \"album_service: _\", b.name as album_name, b.release_date,\n b.album_type as \"album_type: _\", b.image_url, b.image_date,\n t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,\n t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,\n jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)\n filter (where a.src_id is not null) as \"artists: _\"\nfrom tracks t\n left join artists_tracks art on art.track_id = t.id\n left join artists a on a.id = art.artist_id\n left join albums b on b.id = t.album_id\n join track_aliases ta on ta.track_id=t.id\nwhere ta.src_id=$1 and ta.service=$2\ngroup by (t.id, b.id)",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -42,16 +42,21 @@
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "duration_ms",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "album_id", "name": "album_id",
"type_info": "Int4" "type_info": "Int4"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "album_src_id", "name": "album_src_id",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 7, "ordinal": 8,
"name": "album_service: _", "name": "album_service: _",
"type_info": { "type_info": {
"Custom": { "Custom": {
@ -68,17 +73,17 @@
} }
}, },
{ {
"ordinal": 8, "ordinal": 9,
"name": "album_name", "name": "album_name",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 9, "ordinal": 10,
"name": "release_date", "name": "release_date",
"type_info": "Date" "type_info": "Date"
}, },
{ {
"ordinal": 10, "ordinal": 11,
"name": "album_type: _", "name": "album_type: _",
"type_info": { "type_info": {
"Custom": { "Custom": {
@ -95,77 +100,77 @@
} }
}, },
{ {
"ordinal": 11, "ordinal": 12,
"name": "image_url", "name": "image_url",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 12, "ordinal": 13,
"name": "image_date", "name": "image_date",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 13, "ordinal": 14,
"name": "album_pos", "name": "album_pos",
"type_info": "Int2" "type_info": "Int2"
}, },
{ {
"ordinal": 14, "ordinal": 15,
"name": "ul_artists", "name": "ul_artists",
"type_info": "TextArray" "type_info": "TextArray"
}, },
{ {
"ordinal": 15, "ordinal": 16,
"name": "isrc", "name": "isrc",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 16, "ordinal": 17,
"name": "description", "name": "description",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 17, "ordinal": 18,
"name": "file_size", "name": "file_size",
"type_info": "Int8" "type_info": "Int8"
}, },
{ {
"ordinal": 18, "ordinal": 19,
"name": "track_gain", "name": "track_gain",
"type_info": "Float4" "type_info": "Float4"
}, },
{ {
"ordinal": 19, "ordinal": 20,
"name": "created_at", "name": "created_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 20, "ordinal": 21,
"name": "updated_at", "name": "updated_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 21, "ordinal": 22,
"name": "primary_track", "name": "primary_track",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 22, "ordinal": 23,
"name": "downloaded_at", "name": "downloaded_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 23, "ordinal": 24,
"name": "last_streamed_at", "name": "last_streamed_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 24, "ordinal": 25,
"name": "n_streams", "name": "n_streams",
"type_info": "Int4" "type_info": "Int4"
}, },
{ {
"ordinal": 25, "ordinal": 26,
"name": "artists: _", "name": "artists: _",
"type_info": "Jsonb" "type_info": "Jsonb"
} }
@ -198,6 +203,7 @@
false, false,
false, false,
false, false,
false,
true, true,
true, true,
true, true,
@ -217,5 +223,5 @@
null null
] ]
}, },
"hash": "6cb7208a08d2854472551fa8a771ea47ffc5bf510676b04fb487702ea08673ed" "hash": "5178a0126bd22c1508c7dd630e38213052bf6f8623e59147c38f00d0acf38ff4"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "select t.id, t.src_id, t.service as \"service: _\", t.name, t.duration,\n b.id as album_id, b.src_id as album_src_id, b.service as \"album_service: _\", b.name as album_name, b.release_date,\n b.album_type as \"album_type: _\", b.image_url, b.image_date,\n t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,\n t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,\n jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)\n filter (where a.src_id is not null) as \"artists: _\"\nfrom tracks t\n left join artists_tracks art on art.track_id = t.id\n left join artists a on a.id = art.artist_id\n left join albums b on b.id = t.album_id\nwhere t.src_id=$1 and t.service=$2\ngroup by (t.id, b.id)", "query": "select t.id, t.src_id, t.service as \"service: _\", t.name, t.duration, t.duration_ms,\n b.id as album_id, b.src_id as album_src_id, b.service as \"album_service: _\", b.name as album_name, b.release_date,\n b.album_type as \"album_type: _\", b.image_url, b.image_date,\n t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,\n t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,\n jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)\n filter (where a.src_id is not null) as \"artists: _\"\nfrom tracks t\n left join artists_tracks art on art.track_id = t.id\n left join artists a on a.id = art.artist_id\n left join albums b on b.id = t.album_id\nwhere t.src_id=$1 and t.service=$2\ngroup by (t.id, b.id)",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -42,16 +42,21 @@
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "duration_ms",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "album_id", "name": "album_id",
"type_info": "Int4" "type_info": "Int4"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "album_src_id", "name": "album_src_id",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 7, "ordinal": 8,
"name": "album_service: _", "name": "album_service: _",
"type_info": { "type_info": {
"Custom": { "Custom": {
@ -68,17 +73,17 @@
} }
}, },
{ {
"ordinal": 8, "ordinal": 9,
"name": "album_name", "name": "album_name",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 9, "ordinal": 10,
"name": "release_date", "name": "release_date",
"type_info": "Date" "type_info": "Date"
}, },
{ {
"ordinal": 10, "ordinal": 11,
"name": "album_type: _", "name": "album_type: _",
"type_info": { "type_info": {
"Custom": { "Custom": {
@ -95,77 +100,77 @@
} }
}, },
{ {
"ordinal": 11, "ordinal": 12,
"name": "image_url", "name": "image_url",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 12, "ordinal": 13,
"name": "image_date", "name": "image_date",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 13, "ordinal": 14,
"name": "album_pos", "name": "album_pos",
"type_info": "Int2" "type_info": "Int2"
}, },
{ {
"ordinal": 14, "ordinal": 15,
"name": "ul_artists", "name": "ul_artists",
"type_info": "TextArray" "type_info": "TextArray"
}, },
{ {
"ordinal": 15, "ordinal": 16,
"name": "isrc", "name": "isrc",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 16, "ordinal": 17,
"name": "description", "name": "description",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 17, "ordinal": 18,
"name": "file_size", "name": "file_size",
"type_info": "Int8" "type_info": "Int8"
}, },
{ {
"ordinal": 18, "ordinal": 19,
"name": "track_gain", "name": "track_gain",
"type_info": "Float4" "type_info": "Float4"
}, },
{ {
"ordinal": 19, "ordinal": 20,
"name": "created_at", "name": "created_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 20, "ordinal": 21,
"name": "updated_at", "name": "updated_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 21, "ordinal": 22,
"name": "primary_track", "name": "primary_track",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 22, "ordinal": 23,
"name": "downloaded_at", "name": "downloaded_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 23, "ordinal": 24,
"name": "last_streamed_at", "name": "last_streamed_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 24, "ordinal": 25,
"name": "n_streams", "name": "n_streams",
"type_info": "Int4" "type_info": "Int4"
}, },
{ {
"ordinal": 25, "ordinal": 26,
"name": "artists: _", "name": "artists: _",
"type_info": "Jsonb" "type_info": "Jsonb"
} }
@ -198,6 +203,7 @@
false, false,
false, false,
false, false,
false,
true, true,
true, true,
true, true,
@ -217,5 +223,5 @@
null null
] ]
}, },
"hash": "586725923e6284841ebeac8b2e9b74dc623f2eefe05adf9941ad64880317f360" "hash": "5471630b1ffc7f91c3c3d03b231a695cbf56bd8375aee419b1ff26033b98eae7"
} }

View file

@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "insert into tracks (src_id, service, name, duration, duration_ms,\n album_id, album_pos, ul_artists, isrc, description, file_size, track_gain, primary_track)\nvalues ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\non conflict (src_id, service) do update set\n name = excluded.name,\n duration = case when tracks.duration_ms and not excluded.duration_ms\n then tracks.duration else coalesce(excluded.duration, tracks.duration) end,\n duration_ms = excluded.duration_ms or tracks.duration_ms,\n album_id = excluded.album_id,\n album_pos = coalesce(excluded.album_pos, tracks.album_pos),\n ul_artists = coalesce(excluded.ul_artists, tracks.ul_artists),\n isrc = coalesce(excluded.isrc, tracks.isrc),\n description = coalesce(excluded.description, tracks.description),\n file_size = coalesce(excluded.file_size, tracks.file_size),\n track_gain = coalesce(excluded.track_gain, tracks.track_gain),\n primary_track = coalesce(excluded.primary_track, tracks.primary_track)\nreturning id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "music_service",
"kind": {
"Enum": [
"ty",
"yt",
"sp",
"mx"
]
}
}
},
"Text",
"Int4",
"Bool",
"Int4",
"Int2",
"TextArray",
"Varchar",
"Text",
"Int8",
"Float4",
"Bool"
]
},
"nullable": [
false
]
},
"hash": "b0c1e18c19c896a15971562dd66dfe718cf51c7d15540015076ca1c65ea9a631"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "select t.id, t.src_id, t.service as \"service: _\", t.name, t.duration,\n b.id as album_id, b.src_id as album_src_id, b.service as \"album_service: _\", b.name as album_name, b.release_date,\n b.album_type as \"album_type: _\", b.image_url, b.image_date,\n t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,\n t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,\n jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)\n filter (where a.src_id is not null) as \"artists: _\"\nfrom tracks t\n left join artists_tracks art on art.track_id = t.id\n left join artists a on a.id = art.artist_id\n left join albums b on b.id = t.album_id\nwhere t.id=$1\ngroup by (t.id, b.id)", "query": "select t.id, t.src_id, t.service as \"service: _\", t.name, t.duration, t.duration_ms,\n b.id as album_id, b.src_id as album_src_id, b.service as \"album_service: _\", b.name as album_name, b.release_date,\n b.album_type as \"album_type: _\", b.image_url, b.image_date,\n t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,\n t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,\n jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)\n filter (where a.src_id is not null) as \"artists: _\"\nfrom tracks t\n left join artists_tracks art on art.track_id = t.id\n left join artists a on a.id = art.artist_id\n left join albums b on b.id = t.album_id\nwhere t.id=$1\ngroup by (t.id, b.id)",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -42,16 +42,21 @@
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "duration_ms",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "album_id", "name": "album_id",
"type_info": "Int4" "type_info": "Int4"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "album_src_id", "name": "album_src_id",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 7, "ordinal": 8,
"name": "album_service: _", "name": "album_service: _",
"type_info": { "type_info": {
"Custom": { "Custom": {
@ -68,17 +73,17 @@
} }
}, },
{ {
"ordinal": 8, "ordinal": 9,
"name": "album_name", "name": "album_name",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 9, "ordinal": 10,
"name": "release_date", "name": "release_date",
"type_info": "Date" "type_info": "Date"
}, },
{ {
"ordinal": 10, "ordinal": 11,
"name": "album_type: _", "name": "album_type: _",
"type_info": { "type_info": {
"Custom": { "Custom": {
@ -95,77 +100,77 @@
} }
}, },
{ {
"ordinal": 11, "ordinal": 12,
"name": "image_url", "name": "image_url",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 12, "ordinal": 13,
"name": "image_date", "name": "image_date",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 13, "ordinal": 14,
"name": "album_pos", "name": "album_pos",
"type_info": "Int2" "type_info": "Int2"
}, },
{ {
"ordinal": 14, "ordinal": 15,
"name": "ul_artists", "name": "ul_artists",
"type_info": "TextArray" "type_info": "TextArray"
}, },
{ {
"ordinal": 15, "ordinal": 16,
"name": "isrc", "name": "isrc",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 16, "ordinal": 17,
"name": "description", "name": "description",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 17, "ordinal": 18,
"name": "file_size", "name": "file_size",
"type_info": "Int8" "type_info": "Int8"
}, },
{ {
"ordinal": 18, "ordinal": 19,
"name": "track_gain", "name": "track_gain",
"type_info": "Float4" "type_info": "Float4"
}, },
{ {
"ordinal": 19, "ordinal": 20,
"name": "created_at", "name": "created_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 20, "ordinal": 21,
"name": "updated_at", "name": "updated_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 21, "ordinal": 22,
"name": "primary_track", "name": "primary_track",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 22, "ordinal": 23,
"name": "downloaded_at", "name": "downloaded_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 23, "ordinal": 24,
"name": "last_streamed_at", "name": "last_streamed_at",
"type_info": "Timestamp" "type_info": "Timestamp"
}, },
{ {
"ordinal": 24, "ordinal": 25,
"name": "n_streams", "name": "n_streams",
"type_info": "Int4" "type_info": "Int4"
}, },
{ {
"ordinal": 25, "ordinal": 26,
"name": "artists: _", "name": "artists: _",
"type_info": "Jsonb" "type_info": "Jsonb"
} }
@ -185,6 +190,7 @@
false, false,
false, false,
false, false,
false,
true, true,
true, true,
true, true,
@ -204,5 +210,5 @@
null null
] ]
}, },
"hash": "a5cdaf05833b1bad4b90b6960937795f508f338f088c3570e908f7c2df8c602b" "hash": "c51949fcbd59f9343c883e993cd7bcacffe3e5f5dd32e7ba9075819cceccfb0b"
} }

View file

@ -30,6 +30,7 @@ CREATE TABLE
service public.music_service NOT NULL, service public.music_service NOT NULL,
NAME TEXT NOT NULL, NAME TEXT NOT NULL,
duration INTEGER, duration INTEGER,
duration_ms bool NOT NULL DEFAULT FALSE,
album_pos SMALLINT, album_pos SMALLINT,
album_id INTEGER NOT NULL, album_id INTEGER NOT NULL,
ul_artists TEXT[], ul_artists TEXT[],
@ -54,7 +55,9 @@ COMMENT ON COLUMN public.tracks.service IS E'Service providing the track';
COMMENT ON COLUMN public.tracks.name IS E'Track name'; COMMENT ON COLUMN public.tracks.name IS E'Track name';
COMMENT ON COLUMN public.tracks.duration IS E'Duration of the track in seconds'; COMMENT ON COLUMN public.tracks.duration IS E'Duration of the track in milliseconds';
COMMENT ON COLUMN public.tracks.duration_ms IS E'True if the duration is in millisecond precision';
COMMENT ON COLUMN public.tracks.file_size IS E'File size in bytes'; COMMENT ON COLUMN public.tracks.file_size IS E'File size in bytes';
@ -548,22 +551,27 @@ $$;
ALTER FUNCTION public.set_header_image_date () OWNER TO postgres; ALTER FUNCTION public.set_header_image_date () OWNER TO postgres;
CREATE TRIGGER artists_set_image_date BEFORE CREATE TRIGGER artists_set_image_date BEFORE INSERT
INSERT OR UPDATE OF image_url ON public.artists FOR EACH ROW OR
UPDATE OF image_url ON public.artists FOR EACH ROW
EXECUTE PROCEDURE public.set_image_date (); EXECUTE PROCEDURE public.set_image_date ();
CREATE TRIGGER artists_set_header_image_date BEFORE CREATE TRIGGER artists_set_header_image_date BEFORE INSERT
INSERT OR UPDATE OF header_image_url ON public.artists FOR EACH ROW OR
UPDATE OF header_image_url ON public.artists FOR EACH ROW
EXECUTE PROCEDURE public.set_header_image_date (); EXECUTE PROCEDURE public.set_header_image_date ();
CREATE TRIGGER albums_set_image_date BEFORE CREATE TRIGGER albums_set_image_date BEFORE INSERT
INSERT OR UPDATE OF image_url ON public.albums FOR EACH ROW OR
UPDATE OF image_url ON public.albums FOR EACH ROW
EXECUTE PROCEDURE public.set_image_date (); EXECUTE PROCEDURE public.set_image_date ();
CREATE TRIGGER playlists_set_image_date BEFORE CREATE TRIGGER playlists_set_image_date BEFORE INSERT
INSERT OR UPDATE OF image_url ON public.playlists FOR EACH ROW OR
UPDATE OF image_url ON public.playlists FOR EACH ROW
EXECUTE PROCEDURE public.set_image_date (); EXECUTE PROCEDURE public.set_image_date ();
CREATE TRIGGER users_set_image_date BEFORE CREATE TRIGGER users_set_image_date BEFORE INSERT
INSERT OR UPDATE OF image_url ON public.users FOR EACH ROW OR
UPDATE OF image_url ON public.users FOR EACH ROW
EXECUTE PROCEDURE public.set_image_date (); EXECUTE PROCEDURE public.set_image_date ();

View file

@ -8,6 +8,7 @@ Track(
service: yt, service: yt,
name: "empty", name: "empty",
duration: None, duration: None,
duration_ms: false,
artists: [], artists: [],
album_id: 1, album_id: 1,
album: AlbumTag( album: AlbumTag(

View file

@ -7,7 +7,8 @@ Track(
src_id: "g0iRiJ_ck48", src_id: "g0iRiJ_ck48",
service: yt, service: yt,
name: "Aulë und Yavanna", name: "Aulë und Yavanna",
duration: Some(216), duration: Some(216481),
duration_ms: true,
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"), id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),

View file

@ -6,7 +6,7 @@ TrackSlim(
src_id: "g0iRiJ_ck48", src_id: "g0iRiJ_ck48",
service: yt, service: yt,
name: "Aulë und Yavanna", name: "Aulë und Yavanna",
duration: Some(216), duration: Some(216481),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"), id: Some("yt:UC_MxOdawj_BStPs4CKBYD0Q"),

View file

@ -19,6 +19,7 @@ pub struct Track {
pub service: MusicService, pub service: MusicService,
pub name: String, pub name: String,
pub duration: Option<i32>, pub duration: Option<i32>,
pub duration_ms: bool,
pub artists: Vec<ArtistTag>, pub artists: Vec<ArtistTag>,
pub album_id: i32, pub album_id: i32,
pub album: AlbumTag, pub album: AlbumTag,
@ -42,6 +43,7 @@ struct TrackRow {
service: MusicService, service: MusicService,
name: String, name: String,
duration: Option<i32>, duration: Option<i32>,
duration_ms: bool,
album_id: i32, album_id: i32,
album_src_id: String, album_src_id: String,
album_service: MusicService, album_service: MusicService,
@ -72,6 +74,7 @@ pub struct TrackNew<'a> {
pub service: MusicService, pub service: MusicService,
pub name: &'a str, pub name: &'a str,
pub duration: Option<i32>, pub duration: Option<i32>,
pub duration_ms: bool,
pub album_id: i32, pub album_id: i32,
pub album_pos: Option<i16>, pub album_pos: Option<i16>,
pub ul_artists: Option<&'a [String]>, pub ul_artists: Option<&'a [String]>,
@ -87,6 +90,7 @@ pub struct TrackNew<'a> {
pub struct TrackUpdate<'a> { pub struct TrackUpdate<'a> {
pub name: Option<&'a str>, pub name: Option<&'a str>,
pub duration: Option<Option<i32>>, pub duration: Option<Option<i32>>,
pub duration_ms: Option<bool>,
pub album_id: Option<i32>, pub album_id: Option<i32>,
pub album_pos: Option<Option<i16>>, pub album_pos: Option<Option<i16>>,
pub ul_artists: Option<&'a [String]>, pub ul_artists: Option<&'a [String]>,
@ -188,7 +192,7 @@ impl Track {
Id::Db(id) => { Id::Db(id) => {
sqlx::query_as!( sqlx::query_as!(
TrackRow, TrackRow,
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
b.id as album_id, b.src_id as album_src_id, b.service as "album_service: _", b.name as album_name, b.release_date, b.id as album_id, b.src_id as album_src_id, b.service as "album_service: _", b.name as album_name, b.release_date,
b.album_type as "album_type: _", b.image_url, b.image_date, b.album_type as "album_type: _", b.image_url, b.image_date,
t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at, t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,
@ -209,7 +213,7 @@ group by (t.id, b.id)"#,
Id::Src(src_id, srv) => { Id::Src(src_id, srv) => {
let res = sqlx::query_as!( let res = sqlx::query_as!(
TrackRow, TrackRow,
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
b.id as album_id, b.src_id as album_src_id, b.service as "album_service: _", b.name as album_name, b.release_date, b.id as album_id, b.src_id as album_src_id, b.service as "album_service: _", b.name as album_name, b.release_date,
b.album_type as "album_type: _", b.image_url, b.image_date, b.album_type as "album_type: _", b.image_url, b.image_date,
t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at, t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,
@ -234,7 +238,7 @@ group by (t.id, b.id)"#,
None => { None => {
sqlx::query_as!( sqlx::query_as!(
TrackRow, TrackRow,
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
b.id as album_id, b.src_id as album_src_id, b.service as "album_service: _", b.name as album_name, b.release_date, b.id as album_id, b.src_id as album_src_id, b.service as "album_service: _", b.name as album_name, b.release_date,
b.album_type as "album_type: _", b.image_url, b.image_date, b.album_type as "album_type: _", b.image_url, b.image_date,
t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at, t.album_pos, t.ul_artists, t.isrc, t.description, t.file_size, t.track_gain, t.created_at, t.updated_at,
@ -409,12 +413,14 @@ impl TrackNew<'_> {
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
let res = sqlx::query!( let res = sqlx::query!(
r#"insert into tracks (src_id, service, name, duration, r#"insert into tracks (src_id, service, name, duration, duration_ms,
album_id, album_pos, ul_artists, isrc, description, file_size, track_gain, primary_track) album_id, album_pos, ul_artists, isrc, description, file_size, track_gain, primary_track)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
on conflict (src_id, service) do update set on conflict (src_id, service) do update set
name = excluded.name, name = excluded.name,
duration = coalesce(excluded.duration, tracks.duration), duration = case when tracks.duration_ms and not excluded.duration_ms
then tracks.duration else coalesce(excluded.duration, tracks.duration) end,
duration_ms = excluded.duration_ms or tracks.duration_ms,
album_id = excluded.album_id, album_id = excluded.album_id,
album_pos = coalesce(excluded.album_pos, tracks.album_pos), album_pos = coalesce(excluded.album_pos, tracks.album_pos),
ul_artists = coalesce(excluded.ul_artists, tracks.ul_artists), ul_artists = coalesce(excluded.ul_artists, tracks.ul_artists),
@ -428,6 +434,7 @@ returning id"#,
self.service as MusicService, self.service as MusicService,
self.name, self.name,
self.duration, self.duration,
self.duration_ms,
self.album_id, self.album_id,
self.album_pos, self.album_pos,
self.ul_artists.as_deref(), self.ul_artists.as_deref(),
@ -464,6 +471,14 @@ impl TrackUpdate<'_> {
query.push_bind(duration); query.push_bind(duration);
n += 1; n += 1;
} }
if let Some(duration_ms) = &self.duration_ms {
if n != 0 {
query.push(", ");
}
query.push("duration_ms=");
query.push_bind(duration_ms);
n += 1;
}
if let Some(album_id) = &self.album_id { if let Some(album_id) = &self.album_id {
if n != 0 { if n != 0 {
query.push(", "); query.push(", ");
@ -678,6 +693,7 @@ impl From<TrackRow> for Track {
service: value.service, service: value.service,
name: value.name, name: value.name,
duration: value.duration, duration: value.duration,
duration_ms: value.duration_ms,
artists: util::map_artists(value.artists, value.ul_artists), artists: util::map_artists(value.artists, value.ul_artists),
album_id: value.album_id, album_id: value.album_id,
album: AlbumTag { album: AlbumTag {
@ -731,7 +747,7 @@ impl From<Track> for tiraya_api_model::Track {
Self { Self {
id: tiraya_api_model::TId::new(t.src_id, t.service.into()), id: tiraya_api_model::TId::new(t.src_id, t.service.into()),
name: t.name, name: t.name,
duration: t.duration, duration: t.duration.map(|d| d / 1000),
artists: t artists: t
.artists .artists
.into_iter() .into_iter()
@ -755,7 +771,7 @@ impl From<TrackSlim> for tiraya_api_model::TrackSlim {
Self { Self {
id: tiraya_api_model::TId::new(t.src_id, t.service.into()), id: tiraya_api_model::TId::new(t.src_id, t.service.into()),
name: t.name, name: t.name,
duration: t.duration, duration: t.duration.map(|d| d / 1000),
artists: t artists: t
.artists .artists
.into_iter() .into_iter()
@ -785,7 +801,8 @@ mod tests {
src_id: "g0iRiJ_ck48", src_id: "g0iRiJ_ck48",
service: MusicService::YouTube, service: MusicService::YouTube,
name: "Aulë und Yavanna", name: "Aulë und Yavanna",
duration: Some(216), duration: Some(216481),
duration_ms: true,
album_id: 1, album_id: 1,
album_pos: Some(1), album_pos: Some(1),
ul_artists: Some(&ul_artists), ul_artists: Some(&ul_artists),
@ -837,6 +854,7 @@ mod tests {
let clear = TrackUpdate { let clear = TrackUpdate {
name: Some("empty"), name: Some("empty"),
duration: Some(None), duration: Some(None),
duration_ms: Some(false),
album_id: None, album_id: None,
album_pos: Some(None), album_pos: Some(None),
ul_artists: Some(&[]), ul_artists: Some(&[]),

View file

@ -850,7 +850,7 @@ impl YouTubeExtractor {
src_id: &track.id, src_id: &track.id,
service: MusicService::YouTube, service: MusicService::YouTube,
name: &track.name, name: &track.name,
duration: track.duration.and_then(|v| v.try_into().ok()), duration: track.duration.and_then(|d| (d * 1000).try_into().ok()),
album_id, album_id,
album_pos: track.track_nr.and_then(|v| v.try_into().ok()), album_pos: track.track_nr.and_then(|v| v.try_into().ok()),
ul_artists: ul_artists.as_deref(), ul_artists: ul_artists.as_deref(),
@ -1346,7 +1346,8 @@ mod tests {
src_id: "voLnMeuXQo4", src_id: "voLnMeuXQo4",
service: yt, service: yt,
name: "PTT (Paint the Town)", name: "PTT (Paint the Town)",
duration: Some(202), duration: Some(202000),
duration_ms: false,
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCa4ZqZPRjz9MYYnfpoh2few"), id: Some("yt:UCa4ZqZPRjz9MYYnfpoh2few"),

View file

@ -7,7 +7,7 @@ expression: tracks
src_id: "QapQgsYqR0o", src_id: "QapQgsYqR0o",
service: yt, service: yt,
name: "747", name: "747",
duration: Some(144), duration: Some(144000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -29,7 +29,7 @@ expression: tracks
src_id: "gNd1pbc0suY", src_id: "gNd1pbc0suY",
service: yt, service: yt,
name: "Süchtig", name: "Süchtig",
duration: Some(192), duration: Some(192000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -51,7 +51,7 @@ expression: tracks
src_id: "DIhEDU66xh8", src_id: "DIhEDU66xh8",
service: yt, service: yt,
name: "Happy End (feat. Sido)", name: "Happy End (feat. Sido)",
duration: Some(138), duration: Some(138000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -73,7 +73,7 @@ expression: tracks
src_id: "oQI0IT2cEfw", src_id: "oQI0IT2cEfw",
service: yt, service: yt,
name: "VIBE", name: "VIBE",
duration: Some(157), duration: Some(157000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -95,7 +95,7 @@ expression: tracks
src_id: "C_pZsCHUqUU", src_id: "C_pZsCHUqUU",
service: yt, service: yt,
name: "Melatonin", name: "Melatonin",
duration: Some(140), duration: Some(140000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -121,7 +121,7 @@ expression: tracks
src_id: "pjCRr1zHpLs", src_id: "pjCRr1zHpLs",
service: yt, service: yt,
name: "Zehenspitzen", name: "Zehenspitzen",
duration: Some(175), duration: Some(175000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -143,7 +143,7 @@ expression: tracks
src_id: "1xwJWeWdpHw", src_id: "1xwJWeWdpHw",
service: yt, service: yt,
name: "Summer Nights", name: "Summer Nights",
duration: Some(178), duration: Some(178000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -165,7 +165,7 @@ expression: tracks
src_id: "2GY7M08AtM4", src_id: "2GY7M08AtM4",
service: yt, service: yt,
name: "Schwarze Herzen", name: "Schwarze Herzen",
duration: Some(145), duration: Some(145000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -191,7 +191,7 @@ expression: tracks
src_id: "hw1bZ0unqgg", src_id: "hw1bZ0unqgg",
service: yt, service: yt,
name: "Stadtbezirk", name: "Stadtbezirk",
duration: Some(170), duration: Some(170000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -213,7 +213,7 @@ expression: tracks
src_id: "c882l-0i1Ds", src_id: "c882l-0i1Ds",
service: yt, service: yt,
name: "No Hard Feelings", name: "No Hard Feelings",
duration: Some(159), duration: Some(159000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -235,7 +235,7 @@ expression: tracks
src_id: "5WRcgP_WDbY", src_id: "5WRcgP_WDbY",
service: yt, service: yt,
name: "Bitte Geh Nicht", name: "Bitte Geh Nicht",
duration: Some(132), duration: Some(132000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -257,7 +257,7 @@ expression: tracks
src_id: "bdXaTyLRhtQ", src_id: "bdXaTyLRhtQ",
service: yt, service: yt,
name: "Als ob du mich liebst (feat. Vanessa Mai)", name: "Als ob du mich liebst (feat. Vanessa Mai)",
duration: Some(142), duration: Some(142000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCymtzNvoLbYPhnZbd2CcCZA"), id: Some("yt:UCymtzNvoLbYPhnZbd2CcCZA"),
@ -279,7 +279,7 @@ expression: tracks
src_id: "u1xwYB0ViHQ", src_id: "u1xwYB0ViHQ",
service: yt, service: yt,
name: "Aus & Vorbei", name: "Aus & Vorbei",
duration: Some(135), duration: Some(135000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"), id: Some("yt:UCFTcSVPYRWlDoHisR-ZKwgw"),
@ -301,7 +301,7 @@ expression: tracks
src_id: "jI9nQeKGf4E", src_id: "jI9nQeKGf4E",
service: yt, service: yt,
name: "Unendlich", name: "Unendlich",
duration: Some(169), duration: Some(169000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCpuUi6e7YMwhSg8Q6erovXg"), id: Some("yt:UCpuUi6e7YMwhSg8Q6erovXg"),

View file

@ -7,7 +7,7 @@ expression: album_tracks
src_id: "BGcUVJXViqQ", src_id: "BGcUVJXViqQ",
service: yt, service: yt,
name: "고블린 Goblin", name: "고블린 Goblin",
duration: Some(194), duration: Some(194000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCfwCE5VhPMGxNPFxtVv7lRw"), id: Some("yt:UCfwCE5VhPMGxNPFxtVv7lRw"),
@ -29,7 +29,7 @@ expression: album_tracks
src_id: "7_Bav4c7UGM", src_id: "7_Bav4c7UGM",
service: yt, service: yt,
name: "온더문 On The Moon", name: "온더문 On The Moon",
duration: Some(256), duration: Some(256000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCfwCE5VhPMGxNPFxtVv7lRw"), id: Some("yt:UCfwCE5VhPMGxNPFxtVv7lRw"),
@ -51,7 +51,7 @@ expression: album_tracks
src_id: "kzUZABVj5UQ", src_id: "kzUZABVj5UQ",
service: yt, service: yt,
name: "도로시 Dorothy", name: "도로시 Dorothy",
duration: Some(241), duration: Some(241000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCfwCE5VhPMGxNPFxtVv7lRw"), id: Some("yt:UCfwCE5VhPMGxNPFxtVv7lRw"),

View file

@ -7,7 +7,8 @@ Track(
src_id: "BL-aIpCLWnU", src_id: "BL-aIpCLWnU",
service: yt, service: yt,
name: "Black Mamba", name: "Black Mamba",
duration: Some(175), duration: Some(175000),
duration_ms: false,
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCEdZAdnnKqbaHOlv8nM6OtA"), id: Some("yt:UCEdZAdnnKqbaHOlv8nM6OtA"),

View file

@ -8,7 +8,7 @@ expression: pl_entries
src_id: "xwFRUfisow8", src_id: "xwFRUfisow8",
service: yt, service: yt,
name: "NXDE - (G)I-DLE Cover Español", name: "NXDE - (G)I-DLE Cover Español",
duration: Some(180), duration: Some(180000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCTr0VOWDDZAR1ij4CjkD0VA"), id: Some("yt:UCTr0VOWDDZAR1ij4CjkD0VA"),
@ -34,7 +34,7 @@ expression: pl_entries
src_id: "9W6U3g2TecE", src_id: "9W6U3g2TecE",
service: yt, service: yt,
name: "SHUT DOWN - BLACKPINK Cover Español", name: "SHUT DOWN - BLACKPINK Cover Español",
duration: Some(177), duration: Some(177000),
artists: [ artists: [
ArtistTag( ArtistTag(
id: Some("yt:UCTr0VOWDDZAR1ij4CjkD0VA"), id: Some("yt:UCTr0VOWDDZAR1ij4CjkD0VA"),