Compare commits
5 commits
c3d7b94086
...
fdf3fa5904
| Author | SHA1 | Date | |
|---|---|---|---|
| fdf3fa5904 | |||
| 0ef9dad320 | |||
| 64ab43bf4f | |||
| f14d29c6f4 | |||
| ee9afa4d1e |
67 changed files with 3191 additions and 563 deletions
4
.env.dev
4
.env.dev
|
|
@ -1,3 +1,3 @@
|
|||
DATABASE_URL="postgres://postgres:1234@localhost/tiraya"
|
||||
RUST_LOG="tiraya_extractor=info"
|
||||
TIRAYA_CONFIG_PATH=""
|
||||
RUST_LOG="info"
|
||||
TIRAYA_CONFIG_PATH="run/config.toml"
|
||||
|
|
|
|||
895
Cargo.lock
generated
895
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
21
Cargo.toml
21
Cargo.toml
|
|
@ -13,25 +13,31 @@ resolver = "2"
|
|||
[workspace.dependencies]
|
||||
aho-corasick = "1.1.1"
|
||||
anyhow = "1.0.71"
|
||||
base64 = "0.21.5"
|
||||
clap = { version = "4.4.5", features = ["derive"] }
|
||||
deunicode = "1.3.2"
|
||||
dotenvy = "0.15.7"
|
||||
env_logger = "0.10.0"
|
||||
hex-literal = "0.4.1"
|
||||
http = "0.2.9"
|
||||
hmac = "0.12.1"
|
||||
image = { version = "0.24.7", features = ["webp-encoder"] }
|
||||
kakasi = "0.1.0"
|
||||
log = "0.4.17"
|
||||
mime = "0.3.17"
|
||||
nonempty-collections = { version = "0.1.3", features = ["serde"] }
|
||||
once_cell = "1.12.0"
|
||||
otvec = { git = "https://code.thetadev.de/ThetaDev/otvec.git" }
|
||||
path-absolutize = "3.1.1"
|
||||
path_macro = "1.0.0"
|
||||
pathetic = "0.3.0"
|
||||
phf = { version = "0.11.1", features = ["macros"] }
|
||||
quick_cache = "0.4.0"
|
||||
rand = "0.8.5"
|
||||
regex = "1.6.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.82"
|
||||
serde_plain = "1.0.1"
|
||||
sha2 = "0.10.8"
|
||||
short-uuid = { git = "https://code.thetadev.de/ThetaDev/short-uuid.git" }
|
||||
similar = "2.2.1"
|
||||
siphasher = "1.0.0"
|
||||
|
|
@ -43,6 +49,7 @@ time = { version = "0.3.15", features = [
|
|||
"serde-well-known",
|
||||
] }
|
||||
thiserror = "1.0.36"
|
||||
tokio-util = "0.7.9"
|
||||
toml = "0.8.2"
|
||||
url = "2.4.1"
|
||||
uuid = { version = "1.4.0", features = ["v4", "serde"] }
|
||||
|
|
@ -67,7 +74,9 @@ sqlx = { version = "0.7.0", default-features = false, features = [
|
|||
|
||||
# Web server
|
||||
axum = "0.6.20"
|
||||
hyper = "0.14.27"
|
||||
headers = "0.3.9"
|
||||
http = "0.2.9"
|
||||
hyper = { version = "0.14.27", features = ["stream"] }
|
||||
tower = "0.4.13"
|
||||
tower-http = { version = "0.4.4", features = ["trace"] }
|
||||
utoipa = "4.0.0"
|
||||
|
|
@ -77,6 +86,7 @@ reqwest = { version = "0.11.11", default-features = false, features = [
|
|||
"rustls-tls-native-roots",
|
||||
"json",
|
||||
"gzip",
|
||||
"stream",
|
||||
] }
|
||||
rustypipe = { git = "https://code.thetadev.de/ThetaDev/rustypipe.git", features = [
|
||||
"rss",
|
||||
|
|
@ -90,9 +100,16 @@ rspotify = { version = "0.12.0", default-features = false, features = [
|
|||
tiraya-api-model = { path = "crates/api-model" }
|
||||
tiraya-db = { path = "crates/db" }
|
||||
tiraya-extractor = { path = "crates/extractor" }
|
||||
tiraya-proxy = { path = "crates/proxy" }
|
||||
tiraya-utils = { path = "crates/utils" }
|
||||
smartcrop = { path = "crates/smartcrop" }
|
||||
|
||||
# Dev dependencies
|
||||
sqlx-database-tester = { path = "crates/sqlx-database-tester" }
|
||||
rstest = { version = "0.18.2", default-features = false }
|
||||
insta = { version = "1.30.0", features = ["ron", "redactions"] }
|
||||
|
||||
# This profile significantly speeds up build time. If debug info is needed you can comment the line
|
||||
# out temporarily, but make sure to leave this in the main branch.
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use crate::TId;
|
|||
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
|
||||
pub struct Artist {
|
||||
/// Artist ID
|
||||
#[cfg_attr(feature = "utoipa", schema(example = "yt:UC_vmjW5e1xEHhYjY2a0kK1A"))]
|
||||
pub id: TId<'static>,
|
||||
/// Artist name
|
||||
#[cfg_attr(feature = "utoipa", schema(example = "Oonagh"))]
|
||||
|
|
@ -24,11 +25,15 @@ pub struct Artist {
|
|||
/// URL of the artist's image
|
||||
#[cfg_attr(
|
||||
feature = "utoipa",
|
||||
schema(
|
||||
example = "https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w226-c-h226-k-c0x00ffffff-no-l90-rj"
|
||||
)
|
||||
schema(example = "/image/artist/yt:UC_vmjW5e1xEHhYjY2a0kK1A?dt=1697217796")
|
||||
)]
|
||||
pub image_url: Option<String>,
|
||||
/// URL of the artist's header image
|
||||
#[cfg_attr(
|
||||
feature = "utoipa",
|
||||
schema(example = "/image/artist_header/yt:UC_vmjW5e1xEHhYjY2a0kK1A?dt=1697217796")
|
||||
)]
|
||||
pub header_image_url: Option<String>,
|
||||
/// Artist's subscriber count
|
||||
#[cfg_attr(feature = "utoipa", schema(example = 36600))]
|
||||
pub subscribers: Option<i64>,
|
||||
|
|
|
|||
|
|
@ -88,10 +88,10 @@ impl<'de> Deserialize<'de> for TId<'static> {
|
|||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct SrcIdVisitor;
|
||||
const EXPECT: &str = "a Tiraya source id";
|
||||
struct TIdVisitor;
|
||||
const EXPECT: &str = "a Tiraya id";
|
||||
|
||||
impl<'de> Visitor<'de> for SrcIdVisitor {
|
||||
impl<'de> Visitor<'de> for TIdVisitor {
|
||||
type Value = TId<'static>;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str(EXPECT)
|
||||
|
|
@ -107,7 +107,7 @@ impl<'de> Deserialize<'de> for TId<'static> {
|
|||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(SrcIdVisitor)
|
||||
deserializer.deserialize_str(TIdVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ impl<'s> utoipa::ToSchema<'s> for TId<'_> {
|
|||
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
|
||||
) {
|
||||
(
|
||||
"SrcId",
|
||||
"TId",
|
||||
utoipa::openapi::RefOr::T(utoipa::openapi::schema::Schema::Object(
|
||||
utoipa::openapi::schema::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::SchemaType::String)
|
||||
|
|
|
|||
|
|
@ -30,4 +30,5 @@ uuid.workspace = true
|
|||
tiraya-api-model = { workspace = true, features = ["utoipa"] }
|
||||
tiraya-db.workspace = true
|
||||
tiraya-extractor.workspace = true
|
||||
tiraya-proxy.workspace = true
|
||||
tiraya-utils.workspace = true
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@ pub enum ApiError {
|
|||
Extractor(#[from] tiraya_extractor::error::ExtractorError),
|
||||
#[error("DB error: {0}")]
|
||||
Database(#[from] tiraya_db::error::DatabaseError),
|
||||
#[error("proxy error: {0}")]
|
||||
Proxy(#[from] tiraya_proxy::error::ProxyError),
|
||||
#[error("{0}")]
|
||||
Signature(#[from] tiraya_utils::SignatureError),
|
||||
#[error("invalid input: {0}")]
|
||||
Input(Cow<'static, str>),
|
||||
#[error("{0}")]
|
||||
NotFound(Cow<'static, str>),
|
||||
#[error("{0}")]
|
||||
Other(Cow<'static, str>),
|
||||
}
|
||||
|
||||
|
|
@ -21,7 +27,10 @@ impl ErrorStatus for ApiError {
|
|||
match self {
|
||||
ApiError::Extractor(e) => e.status(),
|
||||
ApiError::Database(e) => e.status(),
|
||||
ApiError::Proxy(e) => e.status(),
|
||||
ApiError::Signature(_) => StatusCode::FORBIDDEN,
|
||||
ApiError::Input(_) => StatusCode::BAD_REQUEST,
|
||||
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
ApiError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +39,10 @@ impl ErrorStatus for ApiError {
|
|||
match self {
|
||||
ApiError::Extractor(e) => e.kind(),
|
||||
ApiError::Database(e) => e.kind(),
|
||||
ApiError::Proxy(e) => e.kind(),
|
||||
ApiError::Signature(_) => ApiErrorKind::User,
|
||||
ApiError::Input(_) => ApiErrorKind::User,
|
||||
ApiError::NotFound(_) => ApiErrorKind::User,
|
||||
ApiError::Other(_) => ApiErrorKind::Other,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use tiraya_api_model::{
|
|||
TrackPlaybackInfo, TrackSlim, User, UserSlim, UserType,
|
||||
};
|
||||
use tiraya_extractor::Extractor;
|
||||
use tiraya_proxy::Proxy;
|
||||
use tiraya_utils::config::CONFIG;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::Level;
|
||||
|
|
@ -65,6 +66,7 @@ pub struct ApiState(Arc<ApiStateInner>);
|
|||
pub struct ApiStateInner {
|
||||
db: PgPool,
|
||||
xtr: Extractor,
|
||||
proxy: Proxy,
|
||||
}
|
||||
|
||||
impl Deref for ApiState {
|
||||
|
|
@ -81,6 +83,7 @@ pub async fn init_state() -> Result<ApiState, anyhow::Error> {
|
|||
ApiStateInner {
|
||||
db: db.clone(),
|
||||
xtr: Extractor::new(db)?,
|
||||
proxy: Proxy::new()?,
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
|
|
@ -89,7 +92,7 @@ pub async fn init_state() -> Result<ApiState, anyhow::Error> {
|
|||
pub async fn serve() -> Result<(), anyhow::Error> {
|
||||
let state = init_state().await?;
|
||||
let openapi = ApiDoc::openapi();
|
||||
|
||||
// test
|
||||
let app = Router::new()
|
||||
.nest(
|
||||
"/api",
|
||||
|
|
@ -127,16 +130,21 @@ pub async fn serve() -> Result<(), anyhow::Error> {
|
|||
.route(
|
||||
"/user/:id/playlists",
|
||||
routing::get(routes::user::get_user_playlists),
|
||||
)
|
||||
.with_state(state),
|
||||
),
|
||||
)
|
||||
// TMP: move to frontend server
|
||||
.merge(utoipa_rapidoc::RapiDoc::new("/api/openapi.json").path("/api-docs"))
|
||||
.route("/image/proxy", routing::get(routes::image::get_proxy_image))
|
||||
.route(
|
||||
"/image/:kind/:id",
|
||||
routing::get(routes::image::get_local_image),
|
||||
)
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||
.on_response(tower_http::trace::DefaultOnResponse::new().level(Level::INFO)),
|
||||
);
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let address = SocketAddr::from_str(&CONFIG.core.server_address)?;
|
||||
tracing::info!("listening on {address}");
|
||||
|
|
|
|||
86
crates/api/src/routes/image.rs
Normal file
86
crates/api/src/routes/image.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use axum::extract::{Path, Query, State};
|
||||
use hyper::{Body, Response};
|
||||
use serde::Deserialize;
|
||||
use tiraya_db::models::{self as tdb};
|
||||
use tiraya_extractor::parse_validate_tid;
|
||||
use tiraya_utils::{ImageKind, ImageSize};
|
||||
|
||||
use crate::{error::ApiError, ApiState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LocalImageQuery {
|
||||
#[serde(default)]
|
||||
size: ImageSize,
|
||||
/// Validity date
|
||||
vdt: Option<u32>,
|
||||
/// HMAC signature
|
||||
sig: String,
|
||||
}
|
||||
|
||||
pub async fn get_local_image(
|
||||
State(state): State<ApiState>,
|
||||
Path((kind, id)): Path<(ImageKind, String)>,
|
||||
Query(query): Query<LocalImageQuery>,
|
||||
) -> Result<Response<Body>, ApiError> {
|
||||
let tid = parse_validate_tid(&id, kind.entity_type())?;
|
||||
|
||||
let (url, img_date, private) = match kind {
|
||||
ImageKind::Artist | ImageKind::ArtistHeader => {
|
||||
let artist = tdb::Artist::get(tid.as_srcid(), &state.db).await?;
|
||||
if kind == ImageKind::ArtistHeader {
|
||||
(artist.header_image_url, artist.header_image_date, false)
|
||||
} else {
|
||||
(artist.image_url, artist.image_date, false)
|
||||
}
|
||||
}
|
||||
ImageKind::Album => {
|
||||
let album = tdb::Album::get(tid.as_srcid(), &state.db).await?;
|
||||
(album.image_url, album.image_date, false)
|
||||
}
|
||||
ImageKind::Playlist => {
|
||||
let playlist = tdb::Playlist::get(tid.as_srcid(), &state.db).await?;
|
||||
(playlist.image_url, playlist.image_date, false)
|
||||
}
|
||||
ImageKind::User => {
|
||||
let user = tdb::User::get(tid.as_srcid(), &state.db).await?;
|
||||
(user.image_url, user.image_date, false)
|
||||
}
|
||||
};
|
||||
|
||||
tiraya_utils::validate_image_url(&id, Some(kind), private, &query.sig, query.vdt)?;
|
||||
|
||||
let img_date = img_date
|
||||
.ok_or(ApiError::NotFound("no image stored in db".into()))?
|
||||
.assume_utc();
|
||||
|
||||
Ok(state
|
||||
.proxy
|
||||
.local_image(
|
||||
tid.as_srcid(),
|
||||
kind,
|
||||
query.size,
|
||||
url.as_deref(),
|
||||
img_date,
|
||||
private,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProxyImageQuery {
|
||||
/// URL of remote image
|
||||
url: String,
|
||||
/// Validity date
|
||||
vdt: Option<u32>,
|
||||
/// HMAC signature
|
||||
sig: String,
|
||||
}
|
||||
|
||||
pub async fn get_proxy_image(
|
||||
State(state): State<ApiState>,
|
||||
Query(query): Query<ProxyImageQuery>,
|
||||
) -> Result<Response<Body>, ApiError> {
|
||||
tiraya_utils::validate_image_url(&query.url, None, true, &query.sig, query.vdt)?;
|
||||
|
||||
Ok(state.proxy.proxy_image(&query.url).await?)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod album;
|
||||
pub mod artist;
|
||||
pub mod image;
|
||||
pub mod playlist;
|
||||
pub mod track;
|
||||
pub mod user;
|
||||
|
|
|
|||
|
|
@ -521,7 +521,11 @@ CREATE UNIQUE INDEX user_oidc_sub ON public.users USING btree (oidc_sub);
|
|||
CREATE FUNCTION public.set_image_date () RETURNS TRIGGER LANGUAGE plpgsql VOLATILE CALLED ON NULL INPUT SECURITY INVOKER PARALLEL UNSAFE COST 100 AS $$
|
||||
BEGIN
|
||||
IF (NEW IS DISTINCT FROM OLD AND NEW.image_date IS NOT DISTINCT FROM OLD.image_date) THEN
|
||||
NEW.image_date := CURRENT_TIMESTAMP;
|
||||
IF (NEW.image_url IS NULL) THEN
|
||||
NEW.image_date := NULL;
|
||||
ELSE
|
||||
NEW.image_date := CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
|
|
@ -532,7 +536,11 @@ ALTER FUNCTION public.set_image_date () OWNER TO postgres;
|
|||
CREATE FUNCTION public.set_header_image_date () RETURNS TRIGGER LANGUAGE plpgsql VOLATILE CALLED ON NULL INPUT SECURITY INVOKER PARALLEL UNSAFE COST 100 AS $$
|
||||
BEGIN
|
||||
IF (NEW IS DISTINCT FROM OLD AND NEW.header_image_date IS NOT DISTINCT FROM OLD.header_image_date) THEN
|
||||
NEW.header_image_date := CURRENT_TIMESTAMP;
|
||||
IF (NEW.header_image_url IS NULL) THEN
|
||||
NEW.header_image_date := NULL;
|
||||
ELSE
|
||||
NEW.header_image_date := CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
|
|
@ -541,21 +549,21 @@ $$;
|
|||
ALTER FUNCTION public.set_header_image_date () OWNER TO postgres;
|
||||
|
||||
CREATE TRIGGER artists_set_image_date BEFORE
|
||||
UPDATE OF image_url ON public.artists FOR EACH STATEMENT
|
||||
INSERT OR UPDATE OF image_url ON public.artists FOR EACH ROW
|
||||
EXECUTE PROCEDURE public.set_image_date ();
|
||||
|
||||
CREATE TRIGGER artists_set_header_image_date BEFORE
|
||||
UPDATE OF header_image_url ON public.artists FOR EACH STATEMENT
|
||||
INSERT OR UPDATE OF header_image_url ON public.artists FOR EACH ROW
|
||||
EXECUTE PROCEDURE public.set_header_image_date ();
|
||||
|
||||
CREATE TRIGGER albums_set_image_date BEFORE
|
||||
UPDATE OF image_url ON public.albums FOR EACH STATEMENT
|
||||
INSERT OR UPDATE OF image_url ON public.albums FOR EACH ROW
|
||||
EXECUTE PROCEDURE public.set_image_date ();
|
||||
|
||||
CREATE TRIGGER playlists_set_image_date BEFORE
|
||||
UPDATE ON public.playlists FOR EACH STATEMENT
|
||||
INSERT OR UPDATE OF image_url ON public.playlists FOR EACH ROW
|
||||
EXECUTE PROCEDURE public.set_image_date ();
|
||||
|
||||
CREATE TRIGGER users_set_image_date BEFORE
|
||||
UPDATE ON public.users FOR EACH STATEMENT
|
||||
INSERT OR UPDATE OF image_url ON public.users FOR EACH ROW
|
||||
EXECUTE PROCEDURE public.set_image_date ();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use futures::{stream, StreamExt, TryStreamExt};
|
|||
use serde::Serialize;
|
||||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::{Date, PrimitiveDateTime};
|
||||
use tiraya_utils::{config::CONFIG, EntityType};
|
||||
use tiraya_utils::{config::CONFIG, ImageKind};
|
||||
|
||||
use super::{
|
||||
artist::{ArtistJsonb, ArtistTag},
|
||||
|
|
@ -618,7 +618,7 @@ impl From<Album> for tiraya_api_model::Album {
|
|||
let id = tiraya_api_model::TId::new(b.src_id, b.service.into());
|
||||
let image_url = b
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::Album, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::Album, dt, false));
|
||||
|
||||
Self {
|
||||
id,
|
||||
|
|
@ -646,7 +646,7 @@ impl From<AlbumSlim> for tiraya_api_model::AlbumSlim {
|
|||
let id = tiraya_api_model::TId::new(b.src_id, b.service.into());
|
||||
let image_url = b
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::Album, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::Album, dt, false));
|
||||
|
||||
Self {
|
||||
id,
|
||||
|
|
@ -668,7 +668,7 @@ impl From<AlbumTag> for tiraya_api_model::AlbumTag {
|
|||
let id = tiraya_api_model::TId::new(b.src_id, b.service.into());
|
||||
let image_url = b
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::Album, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::Album, dt, false));
|
||||
|
||||
Self {
|
||||
id,
|
||||
|
|
@ -724,6 +724,7 @@ mod tests {
|
|||
assert_eq!(inserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", inserted, {
|
||||
".id" => "[id]",
|
||||
".image_date" => "[date]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
|
@ -779,6 +780,7 @@ mod tests {
|
|||
assert_eq!(upserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", upserted, {
|
||||
".id" => "[id]",
|
||||
".image_date" => "[date]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
|
@ -808,10 +810,14 @@ mod tests {
|
|||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let tracks_vakuum = Album::tracks(ids::ALBUM_ID_VAKUUM, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("album_tracks_vakuum", tracks_vakuum);
|
||||
insta::assert_ron_snapshot!("album_tracks_vakuum", tracks_vakuum, {
|
||||
"[].album.image_date" => "[date]",
|
||||
});
|
||||
|
||||
let tracks_iwwus = Album::tracks(ids::ALBUM_ID_IWWUS, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("album_tracks_iwwus", tracks_iwwus);
|
||||
insta::assert_ron_snapshot!("album_tracks_iwwus", tracks_iwwus, {
|
||||
"[].album.image_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use futures::stream::{self, StreamExt, TryStreamExt};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{types::Json, QueryBuilder};
|
||||
use time::PrimitiveDateTime;
|
||||
use tiraya_utils::{config::CONFIG, EntityType};
|
||||
use tiraya_utils::{config::CONFIG, ImageKind};
|
||||
|
||||
use super::{
|
||||
album::AlbumSlimRow, AlbumSlim, Id, IdLike, IdOwned, InternalId, MusicService, PlaylistSlim,
|
||||
|
|
@ -853,13 +853,17 @@ impl From<Artist> for tiraya_api_model::Artist {
|
|||
let id = tiraya_api_model::TId::new(a.src_id, a.service.into());
|
||||
let image_url = a
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::Artist, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::Artist, dt, false));
|
||||
let header_image_url = a.header_image_date.map(|dt| {
|
||||
tiraya_utils::image_url_local(id.as_ref(), ImageKind::ArtistHeader, dt, false)
|
||||
});
|
||||
|
||||
Self {
|
||||
id,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
image_url,
|
||||
header_image_url,
|
||||
subscribers: a.subscribers,
|
||||
wikipedia_url: a.wikipedia_url,
|
||||
created_at: a.created_at.assume_utc(),
|
||||
|
|
@ -875,7 +879,7 @@ impl From<ArtistSlim> for tiraya_api_model::ArtistSlim {
|
|||
let id = tiraya_api_model::TId::new(a.src_id, a.service.into());
|
||||
let image_url = a
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::Artist, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::Artist, dt, false));
|
||||
|
||||
Self {
|
||||
id,
|
||||
|
|
@ -931,6 +935,8 @@ mod tests {
|
|||
assert_eq!(inserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", inserted, {
|
||||
".id" => "[id]",
|
||||
".image_date" => "[date]",
|
||||
".header_image_date" => "[date]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
|
@ -981,6 +987,8 @@ mod tests {
|
|||
assert_eq!(upserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", upserted, {
|
||||
".id" => "[id]",
|
||||
".image_date" => "[date]",
|
||||
".header_image_date" => "[date]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
|
@ -1035,12 +1043,16 @@ mod tests {
|
|||
let albums_lea = Artist::albums(ids::ARTIST_ID_LEA, false, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!("albums_lea", albums_lea);
|
||||
insta::assert_ron_snapshot!("albums_lea", albums_lea, {
|
||||
"[].image_date" => "[date]"
|
||||
});
|
||||
|
||||
let albums_cyril = Artist::albums(ids::ARTIST_ID_CYRIL, false, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!("albums_cyril", albums_cyril);
|
||||
insta::assert_ron_snapshot!("albums_cyril", albums_cyril, {
|
||||
"[].image_date" => "[date]"
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
|
|
@ -1048,7 +1060,9 @@ mod tests {
|
|||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let tracks = Artist::tracks(ids::ARTIST_ID_LEA, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(tracks);
|
||||
insta::assert_ron_snapshot!(tracks, {
|
||||
"[].album.image_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
|
|
@ -1057,7 +1071,9 @@ mod tests {
|
|||
|
||||
let artist = Artist::get(ids::ARTIST_LEA, &pool).await.unwrap();
|
||||
let tracks = artist.top_tracks(&pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(tracks);
|
||||
insta::assert_ron_snapshot!(tracks, {
|
||||
"[].album.image_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
|
|
@ -1066,7 +1082,9 @@ mod tests {
|
|||
|
||||
let artist = Artist::get(ids::ARTIST_LEA, &pool).await.unwrap();
|
||||
let related = artist.related_artists(&pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(related);
|
||||
insta::assert_ron_snapshot!(related, {
|
||||
"[].image_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
|
|
@ -1075,7 +1093,9 @@ mod tests {
|
|||
|
||||
let artist = Artist::get(ids::ARTIST_LEA, &pool).await.unwrap();
|
||||
let related = artist.related_playlists(&pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!(related);
|
||||
insta::assert_ron_snapshot!(related, {
|
||||
"[].image_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use futures::{stream, StreamExt, TryStreamExt};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||
use tiraya_utils::{config::CONFIG, EntityType};
|
||||
use tiraya_utils::{config::CONFIG, ImageKind};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
|
|
@ -119,7 +119,7 @@ impl Playlist {
|
|||
let id = tiraya_api_model::TId::new(self.src_id, self.service.into());
|
||||
let image_url = self
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::Playlist, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::Playlist, dt, false));
|
||||
|
||||
tiraya_api_model::Playlist {
|
||||
id,
|
||||
|
|
@ -725,7 +725,7 @@ impl From<PlaylistSlim> for tiraya_api_model::PlaylistSlim {
|
|||
let id = tiraya_api_model::TId::new(p.src_id, p.service.into());
|
||||
let image_url = p
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::Playlist, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::Playlist, dt, false));
|
||||
Self {
|
||||
id,
|
||||
name: p.name,
|
||||
|
|
@ -734,7 +734,7 @@ impl From<PlaylistSlim> for tiraya_api_model::PlaylistSlim {
|
|||
{
|
||||
let o_id = tiraya_api_model::TId::new(o_srcid, o_srv.into());
|
||||
let o_image_url = p.image_date.map(|dt| {
|
||||
tiraya_utils::image_url_local(o_id.as_ref(), EntityType::Playlist, dt)
|
||||
tiraya_utils::image_url_local(o_id.as_ref(), ImageKind::Playlist, dt, false)
|
||||
});
|
||||
Some(tiraya_api_model::UserSlim {
|
||||
id: o_id,
|
||||
|
|
@ -793,6 +793,7 @@ mod tests {
|
|||
assert_eq!(inserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", inserted, {
|
||||
".id" => "[id]",
|
||||
".image_date" => "[date]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
|
@ -812,7 +813,9 @@ mod tests {
|
|||
);
|
||||
|
||||
let slim = PlaylistSlim::get(id, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("slim", slim);
|
||||
insta::assert_ron_snapshot!("slim", slim, {
|
||||
".image_date" => "[date]",
|
||||
});
|
||||
|
||||
// Update
|
||||
let clear = PlaylistUpdate {
|
||||
|
|
@ -841,6 +844,7 @@ mod tests {
|
|||
assert_eq!(upserted.id, new_id);
|
||||
insta::assert_ron_snapshot!("crud_inserted", upserted, {
|
||||
".id" => "[id]",
|
||||
".image_date" => "[date]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
});
|
||||
|
|
@ -860,7 +864,9 @@ mod tests {
|
|||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
insta::assert_ron_snapshot!("get_tracks", tracks);
|
||||
insta::assert_ron_snapshot!("get_tracks", tracks, {
|
||||
"[].track.album.image_date" => "[date]",
|
||||
});
|
||||
|
||||
// Check cache
|
||||
let (cache_version, cache_entries) = Playlist::get_cache(ids::PLAYLIST_ID_TEST1, &pool)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ expression: tracks_iwwus
|
|||
release_date: Some((2018, 229)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
@ -43,7 +43,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(2),
|
||||
),
|
||||
|
|
@ -65,7 +65,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(3),
|
||||
),
|
||||
|
|
@ -87,7 +87,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(4),
|
||||
),
|
||||
|
|
@ -109,7 +109,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(5),
|
||||
),
|
||||
|
|
@ -131,7 +131,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(6),
|
||||
),
|
||||
|
|
@ -153,7 +153,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(7),
|
||||
),
|
||||
|
|
@ -175,7 +175,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(8),
|
||||
),
|
||||
|
|
@ -197,7 +197,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(9),
|
||||
),
|
||||
|
|
@ -219,7 +219,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(10),
|
||||
),
|
||||
|
|
@ -241,7 +241,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(11),
|
||||
),
|
||||
|
|
@ -263,7 +263,7 @@ expression: tracks_vakuum
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(12),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Album(
|
|||
album_type: Some(album),
|
||||
by_va: false,
|
||||
image_url: Some("https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w544-h544-l90-rj"),
|
||||
image_date: Some((2023, 284, 14, 10, 5, 0)),
|
||||
image_date: "[date]",
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
hidden: false,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ expression: albums_cyril
|
|||
release_date: Some((2018, 229)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_uRzxugVdRXQ",
|
||||
|
|
@ -35,6 +35,6 @@ expression: albums_cyril
|
|||
release_date: Some((2018, 236)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ expression: albums_lea
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_2Kv3bcPa6zp",
|
||||
|
|
@ -35,6 +35,6 @@ expression: albums_lea
|
|||
release_date: Some((2018, 229)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ Artist(
|
|||
name: "Oonagh",
|
||||
description: Some("Senta-Sofia Delliponti is a German singer, songwriter and actress. Since January 2014, she used the stage name Oonagh, until she changed it to Senta in 2022. Her signature musical style is inspired by the mystical lore of J. R. R. Tolkien\'s universe and by ethnic sounds throughout the world."),
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj"),
|
||||
image_date: Some((2023, 284, 14, 10, 5, 0)),
|
||||
image_date: "[date]",
|
||||
header_image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w1920-h800-p-l90-rj"),
|
||||
header_image_date: Some((2023, 284, 14, 10, 10, 0)),
|
||||
header_image_date: "[date]",
|
||||
subscribers: Some(36400),
|
||||
wikipedia_url: Some("https://en.wikipedia.org/wiki/Oonagh_(singer)"),
|
||||
created_at: "[date]",
|
||||
|
|
|
|||
|
|
@ -8,20 +8,20 @@ expression: related
|
|||
service: yt,
|
||||
name: "Madeline Juno",
|
||||
image_url: Some("https://lh3.googleusercontent.com/HqCLfHryo1bEwL99KJgn909ft_O-YZaJlHgSa3X9p4Yk8RJ3CnxlGgFLPVpdSPbTeqSFnsAyVQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
ArtistSlim(
|
||||
src_id: "UCkQRXVZuBMktEdVyptoUgGg",
|
||||
service: yt,
|
||||
name: "Mark Forster",
|
||||
image_url: Some("https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
ArtistSlim(
|
||||
src_id: "UCA-uIWGyE0n9YvJ-titY8zA",
|
||||
service: yt,
|
||||
name: "LOTTE",
|
||||
image_url: Some("https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ expression: related
|
|||
service: yt,
|
||||
name: "Happy German Pop",
|
||||
image_url: Some("https://lh3.googleusercontent.com/Mx7jwNSTNbl7WGboxuxwJg-W2Bj059MT0WoYVFN5ml477zUyjyaTXLOca1gMgS1VdvtXAQDncPhRwAk=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
owner_src_id: Some("music"),
|
||||
owner_service: Some(yt),
|
||||
owner_name: Some("YouTube Music"),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(6),
|
||||
),
|
||||
|
|
@ -43,7 +43,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(9),
|
||||
),
|
||||
|
|
@ -65,7 +65,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(10),
|
||||
),
|
||||
|
|
@ -91,7 +91,7 @@ expression: tracks
|
|||
release_date: Some((2018, 229)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
@ -113,7 +113,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
source: crates/db/src/models/artist.rs
|
||||
assertion_line: 1061
|
||||
expression: tracks
|
||||
---
|
||||
[
|
||||
|
|
@ -21,7 +22,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
@ -43,7 +44,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(2),
|
||||
),
|
||||
|
|
@ -65,7 +66,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(3),
|
||||
),
|
||||
|
|
@ -87,7 +88,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(4),
|
||||
),
|
||||
|
|
@ -109,7 +110,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(5),
|
||||
),
|
||||
|
|
@ -131,7 +132,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(6),
|
||||
),
|
||||
|
|
@ -153,7 +154,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(7),
|
||||
),
|
||||
|
|
@ -175,7 +176,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(8),
|
||||
),
|
||||
|
|
@ -197,7 +198,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(9),
|
||||
),
|
||||
|
|
@ -219,7 +220,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(10),
|
||||
),
|
||||
|
|
@ -241,7 +242,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(11),
|
||||
),
|
||||
|
|
@ -263,7 +264,7 @@ expression: tracks
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(12),
|
||||
),
|
||||
|
|
@ -289,7 +290,7 @@ expression: tracks
|
|||
release_date: Some((2018, 229)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/F-CYjYxqSsIOx9pafDZNhLHpkdHTcA6eLmQ-2I_Dz7oUsEE610nKxe-4RkrJb_Nd68Qm4hu9lF7e9DM=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Playlist(
|
|||
playlist_type: remote,
|
||||
description: Some("Die besten Party-Songs um so richtig loszulassen."),
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj"),
|
||||
image_date: Some((2023, 284, 14, 10, 5, 0)),
|
||||
image_date: "[date]",
|
||||
image_type: Some(custom),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ expression: tracks
|
|||
release_date: Some((2017, 307)),
|
||||
album_type: Some(ep),
|
||||
image_url: Some("https://lh3.googleusercontent.com/8Yg43icfIuszQqGjqJasncE5ifPrmwOpesgV1BnC5aZ6rjX3yFS_JEz8QldPSzmdFilZm3rUOA4O3Uh2=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: None,
|
||||
)),
|
||||
|
|
@ -48,7 +48,7 @@ expression: tracks
|
|||
release_date: Some((2021, 288)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/CTbhHfdn9T_dvB2_dk7hTfxmDSSM29buoFfnG6D2QqauOTZsSMrD02p6T1ERtYP5Ut-ko9Zk5Q16D5Ng=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: None,
|
||||
)),
|
||||
|
|
@ -74,7 +74,7 @@ expression: tracks
|
|||
release_date: Some((2015, 191)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/dwrJ5NnlZU7CBziLRlTm1uizuolakRAX7g34-eKeqEZQGZgwmvhqcs3TiZClfm7v6a-KYHieitdakpPo=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ PlaylistSlim(
|
|||
service: yt,
|
||||
name: "Party Time",
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj"),
|
||||
image_date: Some((2023, 284, 14, 10, 5, 0)),
|
||||
image_date: "[date]",
|
||||
owner_src_id: Some("music"),
|
||||
owner_service: Some(yt),
|
||||
owner_name: Some("YouTube Music"),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Track(
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: None,
|
||||
isrc: None,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Track(
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
isrc: Some("DEUM71602459"),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ TrackSlim(
|
|||
release_date: Some((2016, 113)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ErQg88lVN0MkWWC5Wpe9vnrmvR5FviRYZ8e9-PWkcp1qnM6oK9bzWwRtATGHpg9AcUW4iKmJ162DSrM2jQ=w544-h544-l90-rj"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ User(
|
|||
user_type: local,
|
||||
oidc_sub: Some("d6d3cee6-1dde-4519-a9b8-2cba16204d7d"),
|
||||
image_url: Some("https://gravatar.com/avatar/da1b215f81e1852d07dd05e95254f75a0dc3cb4a9c6b218502002305f88c9cdd"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
description: Some("Hello World"),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
|
|
|
|||
|
|
@ -813,6 +813,7 @@ mod tests {
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".album.image_date" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = TId(track.src_id, track.service);
|
||||
|
|
@ -827,7 +828,9 @@ mod tests {
|
|||
assert_eq!(Track::get_id(srcid, &pool).await.unwrap().unwrap(), new_id);
|
||||
|
||||
let slim = TrackSlim::get(id, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("slim", slim);
|
||||
insta::assert_ron_snapshot!("slim", slim, {
|
||||
".album.image_date" => "[date]",
|
||||
});
|
||||
|
||||
// Update
|
||||
let mut u_tx = pool.begin().await.unwrap();
|
||||
|
|
@ -856,6 +859,7 @@ mod tests {
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".album.image_date" => "[date]",
|
||||
});
|
||||
|
||||
// Upsert
|
||||
|
|
@ -873,6 +877,7 @@ mod tests {
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".album.image_date" => "[date]",
|
||||
});
|
||||
|
||||
assert!(upserted.updated_at > inserted.updated_at);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use futures::{stream, StreamExt, TryStreamExt};
|
|||
use serde::Serialize;
|
||||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::PrimitiveDateTime;
|
||||
use tiraya_utils::{config::CONFIG, EntityType};
|
||||
use tiraya_utils::{config::CONFIG, ImageKind};
|
||||
|
||||
use crate::error::{DatabaseError, OptionalRes};
|
||||
|
||||
|
|
@ -379,7 +379,7 @@ impl From<User> for tiraya_api_model::User {
|
|||
let id = tiraya_api_model::TId::new(u.src_id, u.service.into());
|
||||
let image_url = u
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::User, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::User, dt, false));
|
||||
|
||||
Self {
|
||||
id,
|
||||
|
|
@ -400,7 +400,7 @@ impl From<UserSlim> for tiraya_api_model::UserSlim {
|
|||
let id = tiraya_api_model::TId::new(u.src_id, u.service.into());
|
||||
let image_url = u
|
||||
.image_date
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), EntityType::User, dt));
|
||||
.map(|dt| tiraya_utils::image_url_local(id.as_ref(), ImageKind::User, dt, false));
|
||||
|
||||
Self {
|
||||
id,
|
||||
|
|
@ -440,6 +440,7 @@ mod tests {
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".image_date" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = TId(user.src_id, user.service);
|
||||
|
|
@ -487,6 +488,7 @@ mod tests {
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".image_date" => "[date]",
|
||||
});
|
||||
|
||||
assert!(upserted.updated_at > inserted.updated_at);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,5 @@ tiraya-utils.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
insta.workspace = true
|
||||
dotenvy.workspace = true
|
||||
rstest.workspace = true
|
||||
sqlx-database-tester.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -1339,6 +1339,7 @@ mod tests {
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".album.image_date" => "[date]",
|
||||
}, @r###"
|
||||
Track(
|
||||
id: "[id]",
|
||||
|
|
@ -1359,8 +1360,8 @@ mod tests {
|
|||
name: "[&] ([&])",
|
||||
release_date: None,
|
||||
album_type: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/6q4F5_ksV55KW1R5QJbAch2UDqgGSLAP3AooszJaIvn882YBp57r7z8W7pi7EBV4rCCmg-kzhBdEyYbf=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/6q4F5_ksV55KW1R5QJbAch2UDqgGSLAP3AooszJaIvn882YBp57r7z8W7pi7EBV4rCCmg-kzhBdEyYbf=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: None,
|
||||
isrc: Some("KRA382152284"),
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ pub enum ImgFormat {
|
|||
static YTM_IMAGE_REGEX: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^https://[a-z\d]+.googleusercontent.com/[\w\d_/-]+").unwrap());
|
||||
|
||||
const IMGFORMAT_COVER: &str = "=w544-h544-p-l90-rj";
|
||||
const IMGFORMAT_COVER: &str = "=w640-h640-p-l90-rj";
|
||||
const IMGFORMAT_HEADER: &str = "=w1920-h800-p-l90-rj";
|
||||
const IMGFORMAT_AVATAR: &str = "=w226-c-h226-k-c0x00ffffff-no-l90-rj";
|
||||
const IMGFORMAT_AVATAR: &str = "=w640-c-h640-k-c0x00ffffff-no-l90-rj";
|
||||
|
||||
pub fn yt_image_url(images: &[rpmodel::Thumbnail], format: ImgFormat) -> Option<Cow<str>> {
|
||||
images
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ Album(
|
|||
release_date_precision: Some(day),
|
||||
album_type: Some(album),
|
||||
by_va: false,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
hidden: false,
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
@ -42,8 +42,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(2),
|
||||
),
|
||||
|
|
@ -64,8 +64,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(3),
|
||||
),
|
||||
|
|
@ -86,8 +86,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(4),
|
||||
),
|
||||
|
|
@ -112,8 +112,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(5),
|
||||
),
|
||||
|
|
@ -134,8 +134,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(6),
|
||||
),
|
||||
|
|
@ -156,8 +156,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(7),
|
||||
),
|
||||
|
|
@ -182,8 +182,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(8),
|
||||
),
|
||||
|
|
@ -204,8 +204,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(9),
|
||||
),
|
||||
|
|
@ -226,8 +226,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(10),
|
||||
),
|
||||
|
|
@ -248,8 +248,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(11),
|
||||
),
|
||||
|
|
@ -270,8 +270,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(12),
|
||||
),
|
||||
|
|
@ -292,8 +292,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(13),
|
||||
),
|
||||
|
|
@ -318,8 +318,8 @@ expression: tracks
|
|||
name: "METAMORPHOSE",
|
||||
release_date: Some((2022, 223)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rSgozijPD8frV6F8YMhD76JpjCoSXXxOZLTVhzi_WSFwWrkt7IUg0LaT1cKOgfyaYzJVKiDnlbqI1Gs=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(14),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ expression: album_tracks
|
|||
name: "고블린 Goblin",
|
||||
release_date: Some((2019, 180)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(1),
|
||||
),
|
||||
|
|
@ -42,8 +42,8 @@ expression: album_tracks
|
|||
name: "고블린 Goblin",
|
||||
release_date: Some((2019, 180)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(2),
|
||||
),
|
||||
|
|
@ -64,8 +64,8 @@ expression: album_tracks
|
|||
name: "고블린 Goblin",
|
||||
release_date: Some((2019, 180)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: Some(3),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2019, 180)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/SCc0SBxnpEBmISPLVLwItpxpGBGwGRrJUa6N3Q6vVNcJVruUZBvtftSwcI0xtElvV4QVaJeQy78ikvO5=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/6na1wmKLQafnd6iae-PDjp0iwqkAmYzs71YpxvYqWjh1JHNjp_XzV9qs8kjyzfArx2k07N50QGOy7sU=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/6na1wmKLQafnd6iae-PDjp0iwqkAmYzs71YpxvYqWjh1JHNjp_XzV9qs8kjyzfArx2k07N50QGOy7sU=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_585fV7eqUP8",
|
||||
|
|
@ -30,8 +30,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2014, 346)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/-lCoUkMabA21YGI3W9IOHIlY727EQdFXpEHRgiE8Ym6NJdv7KFiDeg-KNDjLaBEv8CRTnaV1XA9ED96NBQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/-lCoUkMabA21YGI3W9IOHIlY727EQdFXpEHRgiE8Ym6NJdv7KFiDeg-KNDjLaBEv8CRTnaV1XA9ED96NBQ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_6DuQS3YUCb1",
|
||||
|
|
@ -45,8 +45,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/vg0kpCOgHk0i9GRV-R4GUGSkDAI-GkUCsZcaagzu0gk-T92TdNS4ErKClhu8AM_VmzE48YdI76TmFFnhmQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/vg0kpCOgHk0i9GRV-R4GUGSkDAI-GkUCsZcaagzu0gk-T92TdNS4ErKClhu8AM_VmzE48YdI76TmFFnhmQ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_6PEkIQE7sWY",
|
||||
|
|
@ -60,8 +60,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2008, 1)),
|
||||
album_type: Some(ep),
|
||||
image_url: Some("https://lh3.googleusercontent.com/HwaOgOiccAdCQO6D_savadsnOaLKgWf2lYgD0XrDnbKOFo9pLncA_g7XmKSBZlKZKqtbbOL0tfkZDeXL=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/HwaOgOiccAdCQO6D_savadsnOaLKgWf2lYgD0XrDnbKOFo9pLncA_g7XmKSBZlKZKqtbbOL0tfkZDeXL=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_7nIPO6oeETY",
|
||||
|
|
@ -75,8 +75,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/64om9ZaOZSU3DAfoAOmkYndsmxBuIR6Rx4Jes6-QQO6VOA8YzzHr1nAxqnejaByx5bDfnE4kUvMNFVA=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/64om9ZaOZSU3DAfoAOmkYndsmxBuIR6Rx4Jes6-QQO6VOA8YzzHr1nAxqnejaByx5bDfnE4kUvMNFVA=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_88p7e6nBtgz",
|
||||
|
|
@ -90,8 +90,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ESbDE22-muP-cUw9Y4fHx2H4SDEa3Y6tAsQbAquhAXqBgU5cNqggnp2Bk4rD3dOGqhLTqK9BDxnM99cYJg=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ESbDE22-muP-cUw9Y4fHx2H4SDEa3Y6tAsQbAquhAXqBgU5cNqggnp2Bk4rD3dOGqhLTqK9BDxnM99cYJg=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_8rukEzdytkN",
|
||||
|
|
@ -105,8 +105,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2015, 170)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/3UoWV4lXZs7QzZuZD5L9oX970vTdhryVG8uuKBhorTzZDtxKjlmU_jjIFqdAB1GDmjm_2QfbU1Wo--o=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/3UoWV4lXZs7QzZuZD5L9oX970vTdhryVG8uuKBhorTzZDtxKjlmU_jjIFqdAB1GDmjm_2QfbU1Wo--o=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_BJKvCuKo7nJ",
|
||||
|
|
@ -120,8 +120,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2015, 345)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/OCNckvn0fk10xo3ZpkuxcHNd3LGoqI85YHqFpdqysm0JoaTwVCNHM_BvIZj6Cm7AUt-f6SDSCjyQ4VM=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/OCNckvn0fk10xo3ZpkuxcHNd3LGoqI85YHqFpdqysm0JoaTwVCNHM_BvIZj6Cm7AUt-f6SDSCjyQ4VM=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_EAiIEvINDHB",
|
||||
|
|
@ -135,8 +135,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/IxZJ1yCVf7VvjO7oPYgq8LFyXmiZmdVEEnHcHPbIF4rYNro9dp5HDGMKyyt9iWIjZdZr7V2rlLV9FNP7=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/IxZJ1yCVf7VvjO7oPYgq8LFyXmiZmdVEEnHcHPbIF4rYNro9dp5HDGMKyyt9iWIjZdZr7V2rlLV9FNP7=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_GCnSggBLI1P",
|
||||
|
|
@ -150,8 +150,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/TqW3xBWNgL4nos-XidALLh_UBWux-eZjEaWqIvjhjTBq09DT6V4hkld2kFq32PIFL5fMNV5gyHX39Sk=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/TqW3xBWNgL4nos-XidALLh_UBWux-eZjEaWqIvjhjTBq09DT6V4hkld2kFq32PIFL5fMNV5gyHX39Sk=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_HrCgErOdgCv",
|
||||
|
|
@ -165,8 +165,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2004, 288)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/GJcjQ7UOVyYhO_IIAICtdRoTmDbmD910BTJsOttIvCLb7XcEKl71iw3kHkfDOcmtM2_FBuIHqFvLqG8=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/GJcjQ7UOVyYhO_IIAICtdRoTmDbmD910BTJsOttIvCLb7XcEKl71iw3kHkfDOcmtM2_FBuIHqFvLqG8=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_Md2aZrjaqHX",
|
||||
|
|
@ -180,8 +180,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2021, 308)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/jWrvatZ15MiYl8ZHPlvI3Lpgt5H_4GEJbKsy6bPil5sc6vRauCXR0M4NCSc5je6MOgrYCo2eanHg65iW=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/jWrvatZ15MiYl8ZHPlvI3Lpgt5H_4GEJbKsy6bPil5sc6vRauCXR0M4NCSc5je6MOgrYCo2eanHg65iW=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_NcixJe8alm8",
|
||||
|
|
@ -195,8 +195,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rym4W0oe9pAwMgfaK5y8o2udjtUamzGlTk_rsNUOjGKFm7uReAGK4cS1qCB85vWzXxX1sak1UBrWYcQYRw=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rym4W0oe9pAwMgfaK5y8o2udjtUamzGlTk_rsNUOjGKFm7uReAGK4cS1qCB85vWzXxX1sak1UBrWYcQYRw=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_OW1GOBZ64ap",
|
||||
|
|
@ -214,8 +214,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2018, 257)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/kjwuNyU10wOG4-a2O1bp_qTmUDF_QGP-aMcalXRokj_61Hv9keIbZPO_i4z1j3rHFjZ1_-gWudwbVjcT=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/kjwuNyU10wOG4-a2O1bp_qTmUDF_QGP-aMcalXRokj_61Hv9keIbZPO_i4z1j3rHFjZ1_-gWudwbVjcT=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_Oq0WKqNwSVY",
|
||||
|
|
@ -229,8 +229,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2003, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/sfX9nKCkz3zeJONHw1MtABjshKF4OmGxICiNwizKtxmPDDoaT3NHOZvrt7qvNKOoqnboVGhFjOnVk8nS=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/sfX9nKCkz3zeJONHw1MtABjshKF4OmGxICiNwizKtxmPDDoaT3NHOZvrt7qvNKOoqnboVGhFjOnVk8nS=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_QEClJsuO9xM",
|
||||
|
|
@ -244,8 +244,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/doHoOPr_AO4xjkAYy8OJH-PM6-dFNVOy_SgcZ6Vofqwd1_IFejg4QSedCsxjI1IXnBVffI4T7NB2S-7b=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/doHoOPr_AO4xjkAYy8OJH-PM6-dFNVOy_SgcZ6Vofqwd1_IFejg4QSedCsxjI1IXnBVffI4T7NB2S-7b=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_QyGCcLWExXj",
|
||||
|
|
@ -259,8 +259,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2014, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/4-MpzYNYUovkXgZsI_HSeq7zg_xf8wJ7md9RqLzCwfGMgJf644IbEhlOT8zSVqnHgAwu-gaHHeshGiy1Gg=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/4-MpzYNYUovkXgZsI_HSeq7zg_xf8wJ7md9RqLzCwfGMgJf644IbEhlOT8zSVqnHgAwu-gaHHeshGiy1Gg=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_R3p5kDRIGKL",
|
||||
|
|
@ -274,8 +274,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2006, 160)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/szrICcZuUkWI6AgCAdK28TM14pmQlMFaua5jJKG3ue5gLi7FKRTm_mv8krIBnS_kvgK74jGRpp48Wj2y=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/szrICcZuUkWI6AgCAdK28TM14pmQlMFaua5jJKG3ue5gLi7FKRTm_mv8krIBnS_kvgK74jGRpp48Wj2y=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_T4fJMmrfxXk",
|
||||
|
|
@ -289,8 +289,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2000, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/PbpPsgAK6bjFLHIwLXIfjjqESyGwmV9vaUWmTZg4hIZ5uPy-lZ5cVeBlTUvwMNUFGF9c0fK1hCZ3dxU=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/PbpPsgAK6bjFLHIwLXIfjjqESyGwmV9vaUWmTZg4hIZ5uPy-lZ5cVeBlTUvwMNUFGF9c0fK1hCZ3dxU=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_TiIBQqCFttT",
|
||||
|
|
@ -304,8 +304,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2016, 309)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/upSChGHQG2_w4jJDNEwUKST9YPmkAnUWQtz0rqAZZ4cyvSJiZyfUQXelYY4I0SVOEZCEl6H82hdb0G-qtA=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/upSChGHQG2_w4jJDNEwUKST9YPmkAnUWQtz0rqAZZ4cyvSJiZyfUQXelYY4I0SVOEZCEl6H82hdb0G-qtA=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_U9HLD8nF7H5",
|
||||
|
|
@ -319,8 +319,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2017, 251)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/IFg2uskrhxybnI09bh3-_nSpASmwhaMCR6P4714ZUZtUrIqBfjxEQhlXSiVih5tibNTQ92L6mUufbcU=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/IFg2uskrhxybnI09bh3-_nSpASmwhaMCR6P4714ZUZtUrIqBfjxEQhlXSiVih5tibNTQ92L6mUufbcU=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_U9dMPQUeR9q",
|
||||
|
|
@ -334,8 +334,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/MhxidwsnBnhr34KYGRnVjvObNx7nHgJdq28thow_ZoUdyb_LyjWyasH2nBS-CZNAiMw7R-sJX6CN6UEY3Q=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/MhxidwsnBnhr34KYGRnVjvObNx7nHgJdq28thow_ZoUdyb_LyjWyasH2nBS-CZNAiMw7R-sJX6CN6UEY3Q=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_V0FEmw2pj2u",
|
||||
|
|
@ -349,8 +349,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2017, 272)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/ykaTRlfkf389QHA6J2uQ6m5n2LyajYL5BJfYH5m7PL6A1YH3FfriCiM5LGDGwoOkLVbaw-v8KtwmF7LF=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/ykaTRlfkf389QHA6J2uQ6m5n2LyajYL5BJfYH5m7PL6A1YH3FfriCiM5LGDGwoOkLVbaw-v8KtwmF7LF=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_WYx2c0e95TA",
|
||||
|
|
@ -364,8 +364,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2008, 207)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/VPwOnLq6a0pq9z8dgwLm7L136gGpKAmfkv1i9OCltUWS4H0AADuuuPCg0xVMBZt3bYEWbmjor_jCiO7Y=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/VPwOnLq6a0pq9z8dgwLm7L136gGpKAmfkv1i9OCltUWS4H0AADuuuPCg0xVMBZt3bYEWbmjor_jCiO7Y=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_Wc8Ehka0R0T",
|
||||
|
|
@ -379,8 +379,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2021, 315)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/W0bSbETvftQ_mmKbMwSz5T1WvscpXm-LMoeTvDezzxs3Y3Afx4ItNVuIzLsl0q7u-8OGR4XAee1d_FgP=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/W0bSbETvftQ_mmKbMwSz5T1WvscpXm-LMoeTvDezzxs3Y3Afx4ItNVuIzLsl0q7u-8OGR4XAee1d_FgP=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_Yj49s4xy7fM",
|
||||
|
|
@ -394,8 +394,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2021, 287)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/h3Stg5E_T-_Pq1npyi0Ur_UBHw-b15iatPRrT3pla6im5afGLbTuKnpJ7RomT9LuKvLWti79rntDxj3C=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/h3Stg5E_T-_Pq1npyi0Ur_UBHw-b15iatPRrT3pla6im5afGLbTuKnpJ7RomT9LuKvLWti79rntDxj3C=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_bUKkpmkyv9M",
|
||||
|
|
@ -409,8 +409,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2014, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/L-3v2IX-F-XCM05kob1jDtmnMA_5P-etymAQZKrniBGYY8WkEhxfuF4JVztmXhxqm4hfTqABp0-MPtC9pg=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/L-3v2IX-F-XCM05kob1jDtmnMA_5P-etymAQZKrniBGYY8WkEhxfuF4JVztmXhxqm4hfTqABp0-MPtC9pg=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_baIxpKBcYbF",
|
||||
|
|
@ -424,8 +424,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2003, 202)),
|
||||
album_type: Some(ep),
|
||||
image_url: Some("https://lh3.googleusercontent.com/UVChjqxjOGn6PN0u7C3LPZSgUmx-qGtWQIhvcWEq71a_0jMmz61yVsW0B46qOC4yQ-HWLv7LREqC_9EB=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/UVChjqxjOGn6PN0u7C3LPZSgUmx-qGtWQIhvcWEq71a_0jMmz61yVsW0B46qOC4yQ-HWLv7LREqC_9EB=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_eiYjUXT1Mn3",
|
||||
|
|
@ -439,8 +439,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/rym4W0oe9pAwMgfaK5y8o2udjtUamzGlTk_rsNUOjGKFm7uReAGK4cS1qCB85vWzXxX1sak1UBrWYcQYRw=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/rym4W0oe9pAwMgfaK5y8o2udjtUamzGlTk_rsNUOjGKFm7uReAGK4cS1qCB85vWzXxX1sak1UBrWYcQYRw=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_f4MhYbccbPi",
|
||||
|
|
@ -454,8 +454,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2006, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/HZ_d571tXIHOE9669e-myraGAqETgXSpeymfcbO0ZkCBm47iHot2Hj2z0sD_5Sx-IinggU47JkJe2vfF=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/HZ_d571tXIHOE9669e-myraGAqETgXSpeymfcbO0ZkCBm47iHot2Hj2z0sD_5Sx-IinggU47JkJe2vfF=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_gHlGAdNjEZI",
|
||||
|
|
@ -469,8 +469,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/NM0iXaav_t-u2AvV2Y4B9XVAxRf8YM0Mz-2-yH_Pn9jDnvI1CwoXE_BHqgJ6r2oa96Y-KrwDGX40exwf=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/NM0iXaav_t-u2AvV2Y4B9XVAxRf8YM0Mz-2-yH_Pn9jDnvI1CwoXE_BHqgJ6r2oa96Y-KrwDGX40exwf=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_kW2NAMSZElX",
|
||||
|
|
@ -484,8 +484,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2015, 152)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/d3Yvrk4CD0CfNY0OT3h--oWSXa2bYDlvl6r5dsmpXJXx2tg95KA7aiGM7BOCATy28EgMhYUvRTU52EKq=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/d3Yvrk4CD0CfNY0OT3h--oWSXa2bYDlvl6r5dsmpXJXx2tg95KA7aiGM7BOCATy28EgMhYUvRTU52EKq=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_m5U1xZasDSy",
|
||||
|
|
@ -499,8 +499,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2002, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/igQUOrNM7fh8O0WF49QTtvMPh2gigEZE2CKXHlEMwP045ZXuqJHy74vRdNBgjJBz9sVg5hUr1AMJ2hT0=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/igQUOrNM7fh8O0WF49QTtvMPh2gigEZE2CKXHlEMwP045ZXuqJHy74vRdNBgjJBz9sVg5hUr1AMJ2hT0=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_n1H3JiFyGkv",
|
||||
|
|
@ -514,8 +514,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2015, 44)),
|
||||
album_type: Some(ep),
|
||||
image_url: Some("https://lh3.googleusercontent.com/vCx4vqMGYOyG8oT9aIoulVTLrgupUXyFOOyI1vUR1ZpmJYLAZ9JB42NH_ZfaSdG8yZ61eRq1tKtIklbuHA=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/vCx4vqMGYOyG8oT9aIoulVTLrgupUXyFOOyI1vUR1ZpmJYLAZ9JB42NH_ZfaSdG8yZ61eRq1tKtIklbuHA=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_ohcGTZrqKPZ",
|
||||
|
|
@ -529,8 +529,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2004, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/Xvf8VdonpERHRINU7k1oJP-2eTjEFVbE46R6ipJ5RDN2awMANJC5Ol3mKBUU1nTrMFhmMNbcdDxIDC7MRQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/Xvf8VdonpERHRINU7k1oJP-2eTjEFVbE46R6ipJ5RDN2awMANJC5Ol3mKBUU1nTrMFhmMNbcdDxIDC7MRQ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_pWpeXxATZYb",
|
||||
|
|
@ -544,8 +544,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2014, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/Ylyi5bjPNN2VJcyJqwCwoFuQWeo4hc65Ch84XUtmBRXoVRrxMuieLly00xqPqTm1LfYsrXcPsnQeDGVVmQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/Ylyi5bjPNN2VJcyJqwCwoFuQWeo4hc65Ch84XUtmBRXoVRrxMuieLly00xqPqTm1LfYsrXcPsnQeDGVVmQ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_ptO8gh250LP",
|
||||
|
|
@ -559,8 +559,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2003, 1)),
|
||||
album_type: Some(ep),
|
||||
image_url: Some("https://lh3.googleusercontent.com/YQQzfuqOyELvmir04xmADyqWwBmBCMcVYx9Nkq6biZtdViXY_WRMMJ_FoGB517gg8SUNEsESwSg88Og=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/YQQzfuqOyELvmir04xmADyqWwBmBCMcVYx9Nkq6biZtdViXY_WRMMJ_FoGB517gg8SUNEsESwSg88Og=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_pvcxCdshS61",
|
||||
|
|
@ -574,8 +574,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/64om9ZaOZSU3DAfoAOmkYndsmxBuIR6Rx4Jes6-QQO6VOA8YzzHr1nAxqnejaByx5bDfnE4kUvMNFVA=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/64om9ZaOZSU3DAfoAOmkYndsmxBuIR6Rx4Jes6-QQO6VOA8YzzHr1nAxqnejaByx5bDfnE4kUvMNFVA=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_q6IoDesLIoV",
|
||||
|
|
@ -589,8 +589,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/b9I5GcDn2VLgFpyfaKgPRAs0xFx1UAmZ6pRlzkP8hFoMaaZpXQgUrGLI6JsACdQ2Y3Ogh6wdh3sK9t-L=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/b9I5GcDn2VLgFpyfaKgPRAs0xFx1UAmZ6pRlzkP8hFoMaaZpXQgUrGLI6JsACdQ2Y3Ogh6wdh3sK9t-L=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_qbJv3f0ijrk",
|
||||
|
|
@ -604,8 +604,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2007, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/iMzBerE8lBHAMXPIEFptLCaHqflwvnHd4Yhjs1juj4dc-r-6a0ZdM2351FrqcyaFcAbpThAkVwJSKbc=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/iMzBerE8lBHAMXPIEFptLCaHqflwvnHd4Yhjs1juj4dc-r-6a0ZdM2351FrqcyaFcAbpThAkVwJSKbc=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_rHhaDLqalbT",
|
||||
|
|
@ -619,8 +619,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(ep),
|
||||
image_url: Some("https://lh3.googleusercontent.com/assmdHsG9EJhAW_QUjE-CAWJP4x-CQOzz9G45nAijdPIm_ilrnyelb4jugqQDwnMsmZDAL1rBngq_pYo5g=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/assmdHsG9EJhAW_QUjE-CAWJP4x-CQOzz9G45nAijdPIm_ilrnyelb4jugqQDwnMsmZDAL1rBngq_pYo5g=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_rdrfznTDhSX",
|
||||
|
|
@ -634,8 +634,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2012, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/b9I5GcDn2VLgFpyfaKgPRAs0xFx1UAmZ6pRlzkP8hFoMaaZpXQgUrGLI6JsACdQ2Y3Ogh6wdh3sK9t-L=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/b9I5GcDn2VLgFpyfaKgPRAs0xFx1UAmZ6pRlzkP8hFoMaaZpXQgUrGLI6JsACdQ2Y3Ogh6wdh3sK9t-L=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_saXgTKNPaSu",
|
||||
|
|
@ -649,8 +649,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2014, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/7S1SHVyb1KX2aA1TGFBF7-pT3iDO3EDAHxY1zWTkoPOdcBZVo7pgWpa4wPm-ilzpaFQfrWnbG4ltrfHZ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/7S1SHVyb1KX2aA1TGFBF7-pT3iDO3EDAHxY1zWTkoPOdcBZVo7pgWpa4wPm-ilzpaFQfrWnbG4ltrfHZ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_t4KFABzYzqB",
|
||||
|
|
@ -664,8 +664,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2015, 170)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/BftBs8tXmzihT-AIWrAyx6SUVQXxnDeUHunM6CoiqB7Nrjvb4sL-Ks1c39y-AoJW7v5WyTmMcx0Mu8Q=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/BftBs8tXmzihT-AIWrAyx6SUVQXxnDeUHunM6CoiqB7Nrjvb4sL-Ks1c39y-AoJW7v5WyTmMcx0Mu8Q=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_t6zStv8YrVG",
|
||||
|
|
@ -679,8 +679,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/9RFCskhIpUA2mGkuF8pFJ9DDPvziUCje1aHYvWtm2tWIsHcMT_g8gX66dpD3h7XG3x2_FDL_9tBdO-PNwQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/9RFCskhIpUA2mGkuF8pFJ9DDPvziUCje1aHYvWtm2tWIsHcMT_g8gX66dpD3h7XG3x2_FDL_9tBdO-PNwQ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_uQrWUz0LYC6",
|
||||
|
|
@ -694,8 +694,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2014, 346)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/-lCoUkMabA21YGI3W9IOHIlY727EQdFXpEHRgiE8Ym6NJdv7KFiDeg-KNDjLaBEv8CRTnaV1XA9ED96NBQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/-lCoUkMabA21YGI3W9IOHIlY727EQdFXpEHRgiE8Ym6NJdv7KFiDeg-KNDjLaBEv8CRTnaV1XA9ED96NBQ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_vM0cMpn8pHh",
|
||||
|
|
@ -709,8 +709,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2008, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/PWOJeoHDxhyFGZDDCcaWWhZv9NvpfA5cmp6NlCOQT8u6JUAaGRpR8w8Dp48a38d94YuIWkc61u_zS5tujQ=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/PWOJeoHDxhyFGZDDCcaWWhZv9NvpfA5cmp6NlCOQT8u6JUAaGRpR8w8Dp48a38d94YuIWkc61u_zS5tujQ=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_wgm3k1qxpbF",
|
||||
|
|
@ -724,8 +724,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/TqW3xBWNgL4nos-XidALLh_UBWux-eZjEaWqIvjhjTBq09DT6V4hkld2kFq32PIFL5fMNV5gyHX39Sk=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/TqW3xBWNgL4nos-XidALLh_UBWux-eZjEaWqIvjhjTBq09DT6V4hkld2kFq32PIFL5fMNV5gyHX39Sk=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_wmSecJVDwPB",
|
||||
|
|
@ -739,8 +739,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2008, 1)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/4skDOJJT-BRMjO8p4RWjEN-jJRpY3Ab3lvNCH29FsfEe5JcoDNcypzxqiawxWOmtrQ_uijd31jHV2G58=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/4skDOJJT-BRMjO8p4RWjEN-jJRpY3Ab3lvNCH29FsfEe5JcoDNcypzxqiawxWOmtrQ_uijd31jHV2G58=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_xCehp2mGhCk",
|
||||
|
|
@ -754,8 +754,8 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2010, 1)),
|
||||
album_type: Some(single),
|
||||
image_url: Some("https://lh3.googleusercontent.com/cHXueUGbR6O3pweOINGAVmo0eyhn4hezIPUW2XeMUBhgf-TX3t5LMOP7i9ovnk9zksQ4yxNWVxA0YbElTw=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/cHXueUGbR6O3pweOINGAVmo0eyhn4hezIPUW2XeMUBhgf-TX3t5LMOP7i9ovnk9zksQ4yxNWVxA0YbElTw=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
AlbumSlim(
|
||||
src_id: "MPREb_y5fUQ2toJwT",
|
||||
|
|
@ -769,7 +769,7 @@ expression: albums
|
|||
],
|
||||
release_date: Some((2017, 279)),
|
||||
album_type: Some(album),
|
||||
image_url: Some("https://lh3.googleusercontent.com/FnlWimRGqPLQwPADXKg1TeYKLZjBlOiZFP6WNtegX3ipa4Lik5xbUp7_z8U_j4ulo-etkleRG5BnH6TY=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/FnlWimRGqPLQwPADXKg1TeYKLZjBlOiZFP6WNtegX3ipa4Lik5xbUp7_z8U_j4ulo-etkleRG5BnH6TY=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ Track(
|
|||
name: "Black Mamba",
|
||||
release_date: Some((2020, 322)),
|
||||
album_type: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/MOL4_Ula9hocErkX2xK_7mISFiWvQz51vReT14KCHF9wsqCEH6sO8iilFFelWMn7JOYIk2WFa-gMmw2uvw=w544-h544-p-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://lh3.googleusercontent.com/MOL4_Ula9hocErkX2xK_7mISFiWvQz51vReT14KCHF9wsqCEH6sO8iilFFelWMn7JOYIk2WFa-gMmw2uvw=w640-h640-p-l90-rj"),
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: None,
|
||||
isrc: None,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Playlist(
|
|||
playlist_type: remote,
|
||||
description: None,
|
||||
image_url: Some("https://i.ytimg.com/vi/xwFRUfisow8/hq720.jpg?sqp=-oaymwEXCNUGEOADIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3mmQPbi0yjv5EuH-uNEHj1R_qwkLQ"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
image_type: Some(custom),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ expression: pl_entries
|
|||
release_date: None,
|
||||
album_type: Some(mv),
|
||||
image_url: Some("https://i.ytimg.com/vi/xwFRUfisow8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgZJApJkPyiXlfGTgBcpO5CQhSgQ"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: None,
|
||||
)),
|
||||
|
|
@ -48,7 +48,7 @@ expression: pl_entries
|
|||
release_date: None,
|
||||
album_type: Some(mv),
|
||||
image_url: Some("https://i.ytimg.com/vi/9W6U3g2TecE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mFyr-KPk8m4gyVxEFniPtdHm4emA"),
|
||||
image_date: None,
|
||||
image_date: "[date]",
|
||||
),
|
||||
album_pos: None,
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ User(
|
|||
name: "Tane Robertson",
|
||||
user_type: remote,
|
||||
oidc_sub: None,
|
||||
image_url: Some("https://yt3.googleusercontent.com/ytc/APkrFKaFtP5EzYuiutPOK1n_RwdNpkZ89xHnMxVBL1-w=w226-c-h226-k-c0x00ffffff-no-l90-rj"),
|
||||
image_date: None,
|
||||
image_url: Some("https://yt3.googleusercontent.com/ytc/APkrFKaFtP5EzYuiutPOK1n_RwdNpkZ89xHnMxVBL1-w=w640-c-h640-k-c0x00ffffff-no-l90-rj"),
|
||||
image_date: "[date]",
|
||||
description: Some(""),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
|
|
|
|||
|
|
@ -4,25 +4,19 @@ use tiraya_db::{
|
|||
};
|
||||
use tiraya_extractor::{Extractor, GetStatus};
|
||||
|
||||
fn setup_log() {
|
||||
dotenvy::dotenv().unwrap();
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
|
||||
fn is_send<T: Send>(_t: T) {}
|
||||
fn _is_send<T: Send>(_t: T) {}
|
||||
|
||||
// All public futures must be send
|
||||
#[sqlx_database_tester::test(pool(variable = "pool", migrations = "../db/migrations"))]
|
||||
async fn all_send() {
|
||||
let xtr = Extractor::new(pool).unwrap();
|
||||
async fn _all_send() {
|
||||
let xtr = Extractor::new(sqlx::PgPool::connect("").await.unwrap()).unwrap();
|
||||
let tid = TId("", MusicService::YouTube);
|
||||
is_send(xtr.get_album(tid));
|
||||
is_send(xtr.fetch_artist_albums(1));
|
||||
is_send(xtr.get_artist(tid));
|
||||
is_send(xtr.get_track(tid));
|
||||
is_send(xtr.get_playlist(tid));
|
||||
is_send(xtr.get_playlist_meta(tid));
|
||||
is_send(xtr.get_user(tid));
|
||||
_is_send(xtr.get_album(tid));
|
||||
_is_send(xtr.fetch_artist_albums(1));
|
||||
_is_send(xtr.get_artist(tid));
|
||||
_is_send(xtr.get_track(tid));
|
||||
_is_send(xtr.get_playlist(tid));
|
||||
_is_send(xtr.get_playlist_meta(tid));
|
||||
_is_send(xtr.get_user(tid));
|
||||
}
|
||||
|
||||
mod yt {
|
||||
|
|
@ -54,7 +48,9 @@ From Wikipedia (https://en.wikipedia.org/wiki/Sulli) under Creative Commons Attr
|
|||
xtr.fetch_artist_albums(artist.id).await.unwrap();
|
||||
|
||||
let albums = Artist::albums(artist.id, false, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("artist1_albums", albums);
|
||||
insta::assert_ron_snapshot!("artist1_albums", albums, {
|
||||
"[].image_date" => "[date]"
|
||||
});
|
||||
|
||||
let album = albums.first().unwrap();
|
||||
let album_tracks = Album::tracks(
|
||||
|
|
@ -66,7 +62,9 @@ From Wikipedia (https://en.wikipedia.org/wiki/Sulli) under Creative Commons Attr
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!("artist1_album_tracks", album_tracks)
|
||||
insta::assert_ron_snapshot!("artist1_album_tracks", album_tracks, {
|
||||
"[].album.image_date" => "[date]"
|
||||
})
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool", migrations = "../db/migrations"))]
|
||||
|
|
@ -97,7 +95,9 @@ From Wikipedia (https://en.wikipedia.org/wiki/Unheilig) under Creative Commons A
|
|||
// is not stable between tests.
|
||||
let mut albums = Artist::albums(artist.id, false, &pool).await.unwrap();
|
||||
albums.sort_by(|a, b| a.src_id.cmp(&b.src_id));
|
||||
insta::assert_ron_snapshot!("artist2_albums", albums);
|
||||
insta::assert_ron_snapshot!("artist2_albums", albums, {
|
||||
"[].image_date" => "[date]"
|
||||
});
|
||||
|
||||
let tracks = Artist::primary_tracks(artist.id, Some(true), &pool)
|
||||
.await
|
||||
|
|
@ -111,7 +111,9 @@ From Wikipedia (https://en.wikipedia.org/wiki/Unheilig) under Creative Commons A
|
|||
})
|
||||
.collect::<Vec<_>>();
|
||||
track_names.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.cmp(&b.2)));
|
||||
insta::assert_ron_snapshot!("artist2_tracks", track_names);
|
||||
insta::assert_ron_snapshot!("artist2_tracks", track_names, {
|
||||
"[].album.image_date" => "[date]"
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool", migrations = "../db/migrations"))]
|
||||
|
|
@ -128,10 +130,13 @@ From Wikipedia (https://en.wikipedia.org/wiki/Unheilig) under Creative Commons A
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".image_date" => "[date]",
|
||||
});
|
||||
|
||||
let tracks = Album::tracks(album.id, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("album_tracks", tracks);
|
||||
insta::assert_ron_snapshot!("album_tracks", tracks, {
|
||||
"[].album.image_date" => "[date]"
|
||||
});
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool", migrations = "../db/migrations"))]
|
||||
|
|
@ -148,6 +153,7 @@ From Wikipedia (https://en.wikipedia.org/wiki/Unheilig) under Creative Commons A
|
|||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".album.image_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -169,11 +175,13 @@ From Wikipedia (https://en.wikipedia.org/wiki/Unheilig) under Creative Commons A
|
|||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".last_sync_at" => "[date]",
|
||||
".image_date" => "[date]",
|
||||
});
|
||||
|
||||
let pl_entries = Playlist::get_tracks(playlist.id, &pool).await.unwrap().0;
|
||||
insta::assert_ron_snapshot!("playlist_entries", pl_entries, {
|
||||
"[].added_at" => "[date]"
|
||||
"[].added_at" => "[date]",
|
||||
"[].track.album.image_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +200,7 @@ From Wikipedia (https://en.wikipedia.org/wiki/Unheilig) under Creative Commons A
|
|||
".created_at" => "[date]",
|
||||
".updated_at" => "[date]",
|
||||
".last_sync_at" => "[date]",
|
||||
".image_date" => "[date]",
|
||||
});
|
||||
|
||||
let playlists = User::playlists(user.id, &pool).await.unwrap();
|
||||
|
|
@ -204,7 +213,7 @@ mod sp {
|
|||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool", migrations = "../db/migrations"))]
|
||||
async fn fetch_track() {
|
||||
setup_log();
|
||||
tiraya_utils::test_log();
|
||||
|
||||
let xtr = Extractor::new(pool.clone()).unwrap();
|
||||
|
||||
|
|
|
|||
34
crates/proxy/Cargo.toml
Normal file
34
crates/proxy/Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[package]
|
||||
name = "tiraya-proxy"
|
||||
description = "Tiraya media streaming and caching proxy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
headers.workspace = true
|
||||
http.workspace = true
|
||||
hex-literal.workspace = true
|
||||
hyper.workspace = true
|
||||
image.workspace = true
|
||||
mime.workspace = true
|
||||
once_cell.workspace = true
|
||||
path_macro.workspace = true
|
||||
reqwest.workspace = true
|
||||
rand.workspace = true
|
||||
siphasher.workspace = true
|
||||
smartcrop.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
tiraya-db.workspace = true
|
||||
tiraya-utils.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
57
crates/proxy/src/error.rs
Normal file
57
crates/proxy/src/error.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use http::StatusCode;
|
||||
use tiraya_utils::traits::{ApiErrorKind, ErrorStatus};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ProxyError {
|
||||
#[error("Range not satisfiable. Size: {0} bytes")]
|
||||
RangeNotSatisfiable(u64),
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("http error: {0}")]
|
||||
H2(#[from] http::Error),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("image error: {0}")]
|
||||
Image(#[from] image::error::ImageError),
|
||||
#[error("file not found")]
|
||||
NotFound,
|
||||
#[error("source error: {0}")]
|
||||
Source(Cow<'static, str>),
|
||||
#[error("error: {0}")]
|
||||
Other(Cow<'static, str>),
|
||||
}
|
||||
|
||||
impl ErrorStatus for ProxyError {
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
ProxyError::RangeNotSatisfiable(_) => StatusCode::RANGE_NOT_SATISFIABLE,
|
||||
ProxyError::Http(e) => {
|
||||
if let Some(status) = e.status() {
|
||||
status
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
ProxyError::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(&self) -> ApiErrorKind {
|
||||
match self {
|
||||
ProxyError::RangeNotSatisfiable(_) => ApiErrorKind::User,
|
||||
ProxyError::Http(e) => {
|
||||
if e.is_status() {
|
||||
ApiErrorKind::Src
|
||||
} else {
|
||||
ApiErrorKind::Other
|
||||
}
|
||||
}
|
||||
ProxyError::NotFound => ApiErrorKind::User,
|
||||
ProxyError::Source(_) => ApiErrorKind::Src,
|
||||
_ => ApiErrorKind::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
421
crates/proxy/src/image.rs
Normal file
421
crates/proxy/src/image.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use headers::HeaderMapExt;
|
||||
use hex_literal::hex;
|
||||
use http::header;
|
||||
use hyper::{Body, Response};
|
||||
use image::GenericImageView;
|
||||
use image::{io::Reader as ImageReader, DynamicImage};
|
||||
use path_macro::path;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use siphasher::sip::SipHasher24;
|
||||
use time::OffsetDateTime;
|
||||
use tiraya_db::models::TId;
|
||||
use tiraya_utils::{config::CONFIG, ImageKind, ImageSize};
|
||||
use tokio::fs::File;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::{error::ProxyError, Proxy};
|
||||
|
||||
const RESIZE_FILTER: image::imageops::FilterType = image::imageops::FilterType::Lanczos3;
|
||||
const IMAGE_FORMAT: image::ImageFormat = image::ImageFormat::WebP;
|
||||
|
||||
const SIZE_LG: u32 = 640;
|
||||
const SIZE_MD: u32 = 300;
|
||||
const SIZE_SM: u32 = 64;
|
||||
|
||||
const WIDTH_HEADER_LG: u32 = 1920;
|
||||
const WIDTH_HEADER_MD: u32 = 1080;
|
||||
|
||||
impl Proxy {
|
||||
/// Serve an image from local storage. If it has not been downloaded yet, download
|
||||
/// and convert it.
|
||||
pub async fn local_image(
|
||||
&self,
|
||||
tid: TId<'_>,
|
||||
img_kind: ImageKind,
|
||||
img_size: ImageSize,
|
||||
url: Option<&str>,
|
||||
img_date: OffsetDateTime,
|
||||
private: bool,
|
||||
) -> Result<Response<Body>, ProxyError> {
|
||||
let path = image_path(tid, img_kind, img_size);
|
||||
|
||||
let mut resp = Response::builder();
|
||||
let hmap = resp.headers_mut().unwrap();
|
||||
hmap.insert(header::CONTENT_TYPE, "image/webp".try_into().unwrap());
|
||||
hmap.typed_insert(headers::LastModified::from(SystemTime::from(img_date)));
|
||||
hmap.typed_insert(if private {
|
||||
tiraya_utils::cache_immutable_private()
|
||||
} else {
|
||||
tiraya_utils::cache_immutable_public()
|
||||
});
|
||||
|
||||
// Serve stored image if available and recent
|
||||
if let Ok(file) = File::open(&path).await {
|
||||
let md = file.metadata().await?;
|
||||
let modified = md.modified()?;
|
||||
if modified >= img_date {
|
||||
let stream = ReaderStream::new(file);
|
||||
return Ok(resp.body(Body::wrap_stream(stream))?);
|
||||
}
|
||||
}
|
||||
|
||||
// Download and convert image if it does not exist
|
||||
if let Some(url) = url {
|
||||
self.download_image(tid, img_kind, url).await?;
|
||||
|
||||
let file = File::open(&path).await?;
|
||||
let stream = ReaderStream::new(file);
|
||||
Ok(resp.body(Body::wrap_stream(stream))?)
|
||||
} else {
|
||||
Err(ProxyError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download the image and convert it into the 3 sizes used by Tiraya
|
||||
#[tracing::instrument(skip(self, url))]
|
||||
async fn download_image(
|
||||
&self,
|
||||
tid: TId<'_>,
|
||||
img_kind: ImageKind,
|
||||
url: &str,
|
||||
) -> Result<(), ProxyError> {
|
||||
// Download original version of image
|
||||
let resp = self.http.get(url).send().await?;
|
||||
resp.error_for_status_ref()?;
|
||||
|
||||
let image_format = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(image::ImageFormat::from_mime_type);
|
||||
|
||||
let image_bytes = resp.bytes().await?;
|
||||
tracing::info!(size = image_bytes.len(), "downloaded image");
|
||||
|
||||
// Open image
|
||||
let img_reader = match image_format {
|
||||
Some(image_format) => ImageReader::with_format(Cursor::new(image_bytes), image_format),
|
||||
None => ImageReader::new(Cursor::new(image_bytes)).with_guessed_format()?,
|
||||
};
|
||||
let mut img = img_reader.decode()?;
|
||||
|
||||
// Path for large format
|
||||
let path_lg = image_path(tid, img_kind, ImageSize::Large);
|
||||
std::fs::create_dir_all(
|
||||
path_lg
|
||||
.parent()
|
||||
.ok_or(ProxyError::Other("img path has no parent".into()))?,
|
||||
)?;
|
||||
|
||||
if img_kind == ImageKind::ArtistHeader {
|
||||
let width_lg = img.width().min(WIDTH_HEADER_LG);
|
||||
let mut img_lg = Cow::Borrowed(&img);
|
||||
if img.width() != width_lg {
|
||||
img_lg = Cow::Owned(img.resize(width_lg, u32::MAX, RESIZE_FILTER));
|
||||
}
|
||||
img_lg.save_with_format(&path_lg, IMAGE_FORMAT)?;
|
||||
tracing::debug!("saved lg header image");
|
||||
|
||||
// Medium format
|
||||
let path_md = image_path(tid, img_kind, ImageSize::Medium);
|
||||
let img_md = img.resize(WIDTH_HEADER_MD, u32::MAX, RESIZE_FILTER);
|
||||
img_md.save_with_format(path_md, IMAGE_FORMAT)?;
|
||||
tracing::debug!("saved md header image");
|
||||
} else {
|
||||
// Large format
|
||||
let width_lg = NonZeroU32::new(img.height().min(img.width().min(SIZE_LG))).unwrap();
|
||||
|
||||
// Crop non-square album covers (video thumbnails)
|
||||
if img_kind == ImageKind::Album && img.width() != img.height() {
|
||||
let borders = find_black_borders(&img);
|
||||
if borders != (0, 0) {
|
||||
img = img.crop_imm(
|
||||
0,
|
||||
borders.0,
|
||||
img.width(),
|
||||
img.height() - borders.0 - borders.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
let img_lg = scale_and_crop(&img, width_lg, width_lg);
|
||||
img_lg.save_with_format(&path_lg, IMAGE_FORMAT)?;
|
||||
tracing::debug!("saved lg image");
|
||||
|
||||
// Medium format
|
||||
let path_md = image_path(tid, img_kind, ImageSize::Medium);
|
||||
let img_md = img_lg.resize(SIZE_MD, SIZE_MD, RESIZE_FILTER);
|
||||
img_md.save_with_format(path_md, IMAGE_FORMAT)?;
|
||||
tracing::debug!("saved md image");
|
||||
|
||||
// Small format
|
||||
let path_sm = image_path(tid, img_kind, ImageSize::Small);
|
||||
let img_sm = img_lg.resize(SIZE_SM, SIZE_SM, RESIZE_FILTER);
|
||||
img_sm.save_with_format(path_sm, IMAGE_FORMAT)?;
|
||||
tracing::debug!("saved sm image");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Serve an image from a remote URL without storing it.
|
||||
pub async fn proxy_image(&self, url: &str) -> Result<Response<Body>, ProxyError> {
|
||||
let img_resp = self.http.get(url).send().await?;
|
||||
img_resp.error_for_status_ref()?;
|
||||
let img_headers = img_resp.headers();
|
||||
|
||||
let mut resp = Response::builder();
|
||||
let resp_headers = resp.headers_mut().unwrap();
|
||||
|
||||
// Verify that the remote content is an image
|
||||
let content_type = img_headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.ok_or(ProxyError::Source("no content type".into()))?;
|
||||
if !content_type
|
||||
.to_str()
|
||||
.map(|ct| ct.starts_with("image/"))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
return Err(ProxyError::Source(
|
||||
format!(
|
||||
"invalid content type: `{}`",
|
||||
content_type.to_str().unwrap_or_default()
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
resp_headers.insert(header::CONTENT_TYPE, content_type.clone());
|
||||
if let Some(last_modified) = img_headers.get(header::LAST_MODIFIED) {
|
||||
resp_headers.insert(header::LAST_MODIFIED, last_modified.clone());
|
||||
}
|
||||
resp_headers.typed_insert(tiraya_utils::cache_immutable_private());
|
||||
|
||||
Ok(resp.body(Body::wrap_stream(img_resp.bytes_stream()))?)
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_for_cropping(img: &DynamicImage, nw: u32, nh: u32) -> Cow<'_, DynamicImage> {
|
||||
// Difference between target and source size (positive if img smaller,
|
||||
// negative if larger)
|
||||
let dh = nh.abs_diff(img.height());
|
||||
let dw = nw.abs_diff(img.width());
|
||||
// Side with higher difference has to be resized
|
||||
if dh > dw {
|
||||
Cow::Owned(img.resize(nh, u32::MAX, RESIZE_FILTER))
|
||||
} else if dh != 0 && dw != 0 {
|
||||
Cow::Owned(img.resize(u32::MAX, nw, RESIZE_FILTER))
|
||||
} else {
|
||||
Cow::Borrowed(img)
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_and_crop(img: &DynamicImage, nw: NonZeroU32, nh: NonZeroU32) -> Cow<'_, DynamicImage> {
|
||||
let img_resized = scale_for_cropping(img, nh.get(), nw.get());
|
||||
|
||||
if img_resized.width() == nw.get() && img_resized.height() == nh.get() {
|
||||
img_resized
|
||||
} else if let Ok(crop) = smartcrop::find_best_crop(img_resized.as_ref(), nw, nh) {
|
||||
let c = crop.crop;
|
||||
Cow::Owned(img_resized.crop_imm(c.x, c.y, c.width, c.height))
|
||||
} else {
|
||||
img_resized
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the height of top and bottom black borders of the image
|
||||
fn find_black_borders(img: &DynamicImage) -> (u32, u32) {
|
||||
let mut rng = StdRng::seed_from_u64(0);
|
||||
|
||||
let bh_max = img.height() / 4;
|
||||
|
||||
const N_POINTS: usize = 20; // Number of points to probe
|
||||
const MIN_POINTS: usize = 13; // Number of points where a border must be detected
|
||||
const TARGET_MAX_DELTA: u32 = 2;
|
||||
const TARGET_MIN_FRACTION: f32 = 0.6; // Minimum fraction of points with height within MAX_DELTA
|
||||
const BORDER_MIN_HEIGHT: u32 = 8; // Minimum border height (top and bottom) to cut away
|
||||
|
||||
// Probe height at random points
|
||||
let mut top_heights = Vec::with_capacity(10);
|
||||
let mut bottom_heights = Vec::with_capacity(10);
|
||||
for _ in 0..N_POINTS {
|
||||
let x = rng.gen_range(0..img.width());
|
||||
// Top border
|
||||
for y in 1..bh_max {
|
||||
let px = img.get_pixel(x, y);
|
||||
if !is_black(px) {
|
||||
top_heights.push(y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Black borders go beyond maximum, dont record this point
|
||||
// May be a dark, vertical feature in the image or the black
|
||||
// borders extend beyond the limit
|
||||
|
||||
// Bottom border
|
||||
for y in ((img.height() - bh_max)..img.height() - 1).rev() {
|
||||
let px = img.get_pixel(x, y);
|
||||
if !is_black(px) {
|
||||
bottom_heights.push(img.height() - y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine frequencies of heights
|
||||
// If >=70% of heights are within 2px, crop it to maximum of these heights
|
||||
let consolidate_heights = |heights: &[u32]| {
|
||||
if heights.len() < MIN_POINTS {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut freqs = BTreeMap::<u32, u32>::new();
|
||||
heights.iter().for_each(|h| match freqs.entry(*h) {
|
||||
std::collections::btree_map::Entry::Vacant(e) => {
|
||||
e.insert(1);
|
||||
}
|
||||
std::collections::btree_map::Entry::Occupied(mut e) => {
|
||||
e.insert(*e.get() + 1);
|
||||
}
|
||||
});
|
||||
|
||||
let mut final_height = 0u32;
|
||||
let mut acc = 0u32;
|
||||
let target = (heights.len() as f32 * TARGET_MIN_FRACTION) as u32;
|
||||
for (h, n) in freqs {
|
||||
if h.abs_diff(final_height) > TARGET_MAX_DELTA {
|
||||
if acc >= target {
|
||||
return final_height;
|
||||
}
|
||||
acc = n;
|
||||
} else {
|
||||
acc += n;
|
||||
}
|
||||
final_height = h;
|
||||
}
|
||||
if acc >= target {
|
||||
final_height
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let b_top = consolidate_heights(&top_heights);
|
||||
if b_top < BORDER_MIN_HEIGHT {
|
||||
return (0, 0);
|
||||
}
|
||||
let b_bottom = consolidate_heights(&bottom_heights);
|
||||
if b_bottom < BORDER_MIN_HEIGHT {
|
||||
return (0, 0);
|
||||
}
|
||||
(b_top, b_bottom)
|
||||
}
|
||||
|
||||
fn is_black(c: image::Rgba<u8>) -> bool {
|
||||
let lum = 0.2126 * (1.0 / 255.0) * c.0[0] as f32
|
||||
+ 0.7152 * (1.0 / 255.0) * c.0[1] as f32
|
||||
+ 0.0722 * (1.0 / 255.0) * c.0[2] as f32;
|
||||
|
||||
lum < 0.1
|
||||
}
|
||||
|
||||
fn image_path(tid: TId<'_>, img_kind: ImageKind, img_size: ImageSize) -> PathBuf {
|
||||
// Get hash of srcid
|
||||
let id_hash = SipHasher24::new_with_key(&hex!("e0060fd1ea207d8f43d2bf9bcae63f65"))
|
||||
.hash(tid.0.as_bytes())
|
||||
.to_le_bytes();
|
||||
|
||||
// There is no small artist header image -> fall back to medium image
|
||||
let img_size = if img_kind == ImageKind::ArtistHeader && img_size == ImageSize::Small {
|
||||
ImageSize::Medium
|
||||
} else {
|
||||
img_size
|
||||
};
|
||||
|
||||
path!(
|
||||
&CONFIG.storage.image_dir
|
||||
/ format!("{img_kind}")
|
||||
/ format!("{}", tid.1)
|
||||
/ format!("{:02x}", id_hash[0])
|
||||
/ format!("{:02x}", id_hash[1])
|
||||
/ format!("{}_{}.webp", tid.0, img_size)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use rstest::rstest;
|
||||
use tiraya_db::models::MusicService;
|
||||
|
||||
use super::*;
|
||||
|
||||
static TESTFILES: Lazy<PathBuf> = Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "testfiles"));
|
||||
|
||||
#[test]
|
||||
fn imgpath() {
|
||||
let tid = TId("UC_vmjW5e1xEHhYjY2a0kK1A", MusicService::YouTube);
|
||||
let path = image_path(tid, ImageKind::Artist, ImageSize::Large);
|
||||
let subpath = path.strip_prefix(&CONFIG.storage.image_dir).unwrap();
|
||||
assert_eq!(
|
||||
subpath,
|
||||
Path::new("artist/yt/48/b1/UC_vmjW5e1xEHhYjY2a0kK1A_lg.webp")
|
||||
)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
TId("UC_vmjW5e1xEHhYjY2a0kK1A", MusicService::YouTube),
|
||||
ImageKind::Artist,
|
||||
"https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w640-h640-c-l90-rj"
|
||||
)]
|
||||
#[case(
|
||||
TId("ZeerrnuLi5E", MusicService::YouTube),
|
||||
ImageKind::Album,
|
||||
"https://i.ytimg.com/vi/ZeerrnuLi5E/maxresdefault.jpg"
|
||||
)]
|
||||
#[tokio::test]
|
||||
async fn download_image(#[case] tid: TId<'_>, #[case] img_kind: ImageKind, #[case] url: &str) {
|
||||
_ = std::fs::remove_dir_all(&CONFIG.storage.image_dir);
|
||||
|
||||
let px = Proxy::new().unwrap();
|
||||
px.download_image(tid, img_kind, url).await.unwrap();
|
||||
|
||||
let path_lg = image_path(tid, img_kind, ImageSize::Large);
|
||||
assert_image(&path_lg, SIZE_LG, SIZE_LG);
|
||||
let path_md = image_path(tid, img_kind, ImageSize::Medium);
|
||||
assert_image(&path_md, SIZE_MD, SIZE_MD);
|
||||
let path_sm = image_path(tid, img_kind, ImageSize::Small);
|
||||
assert_image(&path_sm, SIZE_SM, SIZE_SM);
|
||||
}
|
||||
|
||||
fn assert_image(path: &Path, w: u32, h: u32) {
|
||||
let img = ImageReader::open(path).unwrap().decode().unwrap();
|
||||
assert_eq!(img.width(), w);
|
||||
assert_eq!(img.height(), h);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::sintel("tn_sintel.jpg", (79, 81))]
|
||||
#[case::ccc1("tn_ccc1.jpg", (0, 0))]
|
||||
#[case::ccc2("tn_ccc2.jpg", (0, 0))]
|
||||
#[case::border("tn_border.jpg", (40, 41))]
|
||||
#[case::pattern("tn_pattern.jpg", (0, 0))]
|
||||
fn t_black_borders(#[case] file: &str, #[case] expect: (u32, u32)) {
|
||||
let img = ImageReader::open(path!(*TESTFILES / file))
|
||||
.unwrap()
|
||||
.decode()
|
||||
.unwrap();
|
||||
let borders = find_black_borders(&img);
|
||||
assert_eq!(borders, expect);
|
||||
}
|
||||
}
|
||||
21
crates/proxy/src/lib.rs
Normal file
21
crates/proxy/src/lib.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#![warn(clippy::dbg_macro, clippy::todo)]
|
||||
|
||||
use error::ProxyError;
|
||||
use reqwest::{Client, ClientBuilder};
|
||||
|
||||
pub mod error;
|
||||
mod image;
|
||||
|
||||
const DEFAULT_UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0";
|
||||
|
||||
pub struct Proxy {
|
||||
http: Client,
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub fn new() -> Result<Self, ProxyError> {
|
||||
Ok(Self {
|
||||
http: ClientBuilder::new().user_agent(DEFAULT_UA).build()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
4
crates/proxy/testfiles/copyright.txt
Normal file
4
crates/proxy/testfiles/copyright.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Image sources:
|
||||
tn_ccc1: Chaos Computer Club, CC BY 3.0 License, https://www.youtube.com/watch?v=K32c2If14Og
|
||||
tn_ccc2: Chaos Computer Club, CC BY 3.0 License, https://www.youtube.com/watch?v=zv5yEmUpv-Y
|
||||
tn_sintel: Blender Foundation, CC BY 3.0 License, https://www.youtube.com/watch?v=eRsGyueVLvQ
|
||||
BIN
crates/proxy/testfiles/tn_border.jpg
Normal file
BIN
crates/proxy/testfiles/tn_border.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
BIN
crates/proxy/testfiles/tn_ccc1.jpg
Normal file
BIN
crates/proxy/testfiles/tn_ccc1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
BIN
crates/proxy/testfiles/tn_ccc2.jpg
Normal file
BIN
crates/proxy/testfiles/tn_ccc2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
crates/proxy/testfiles/tn_pattern.jpg
Normal file
BIN
crates/proxy/testfiles/tn_pattern.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
crates/proxy/testfiles/tn_sintel.jpg
Normal file
BIN
crates/proxy/testfiles/tn_sintel.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
11
crates/smartcrop/Cargo.toml
Normal file
11
crates/smartcrop/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "smartcrop"
|
||||
description="Clone of smartcrop library in JavaScript"
|
||||
repository="https://github.com/bekh6ex/rust-smartcrop"
|
||||
license = "MIT"
|
||||
version = "0.1.0"
|
||||
authors = ["Aleksey Bekh-Ivanov <6ex@mail.ru>"]
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
image.workspace = true
|
||||
36
crates/smartcrop/src/image.rs
Normal file
36
crates/smartcrop/src/image.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
use image::{imageops, GenericImage, ImageBuffer, Pixel};
|
||||
|
||||
use super::{Image, ResizableImage, RGB};
|
||||
|
||||
impl<I, P> Image for I
|
||||
where
|
||||
I: GenericImage<Pixel = P> + 'static,
|
||||
P: Pixel<Subpixel = u8> + 'static,
|
||||
{
|
||||
fn width(&self) -> u32 {
|
||||
self.width()
|
||||
}
|
||||
|
||||
fn height(&self) -> u32 {
|
||||
self.height()
|
||||
}
|
||||
|
||||
fn get(&self, x: u32, y: u32) -> RGB {
|
||||
let px = self.get_pixel(x, y).to_rgb();
|
||||
|
||||
let r = px[0];
|
||||
let g = px[1];
|
||||
let b = px[2];
|
||||
RGB { r, g, b }
|
||||
}
|
||||
}
|
||||
|
||||
impl<I, P> ResizableImage<ImageBuffer<P, std::vec::Vec<u8>>> for I
|
||||
where
|
||||
I: GenericImage<Pixel = P> + 'static,
|
||||
P: Pixel<Subpixel = u8> + 'static,
|
||||
{
|
||||
fn resize(&self, width: u32, height: u32) -> ImageBuffer<P, std::vec::Vec<u8>> {
|
||||
imageops::resize(self, width, height, imageops::FilterType::Lanczos3)
|
||||
}
|
||||
}
|
||||
538
crates/smartcrop/src/lib.rs
Normal file
538
crates/smartcrop/src/lib.rs
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![warn(clippy::dbg_macro, clippy::todo)]
|
||||
|
||||
mod image;
|
||||
mod math;
|
||||
|
||||
use self::math::*;
|
||||
use std::{num::NonZeroU32, ops::RangeInclusive};
|
||||
|
||||
const PRESCALE: bool = true;
|
||||
const PRESCALE_MIN: f64 = 400.00;
|
||||
const MIN_SCALE: f64 = 1.0;
|
||||
const MAX_SCALE: f64 = 1.0;
|
||||
// STEP * minscale rounded down to the next power of two should be good
|
||||
const STEP: f64 = 8.0;
|
||||
const SCALE_STEP: f64 = 0.1;
|
||||
|
||||
const SCORE_DOWN_SAMPLE: f64 = 8.0;
|
||||
|
||||
const SKIN_WEIGHT: f64 = 1.8;
|
||||
const DETAIL_WEIGHT: f64 = 0.2;
|
||||
|
||||
const SKIN_BRIGHTNESS_RANGE: RangeInclusive<f64> = 0.2..=1.0;
|
||||
const SKIN_THRESHOLD: f64 = 0.8;
|
||||
const SKIN_BIAS: f64 = 0.01;
|
||||
|
||||
const SATURATION_BRIGHTNESS_RANGE: RangeInclusive<f64> = 0.05..=0.9;
|
||||
const SATURATION_THRESHOLD: f64 = 0.4;
|
||||
const SATURATION_BIAS: f64 = 0.2;
|
||||
const SATURATION_WEIGHT: f64 = 0.1;
|
||||
|
||||
pub trait Image: Sized {
|
||||
fn width(&self) -> u32;
|
||||
fn height(&self) -> u32;
|
||||
fn get(&self, x: u32, y: u32) -> RGB;
|
||||
}
|
||||
|
||||
pub trait ResizableImage<I: Image> {
|
||||
fn resize(&self, width: u32, height: u32) -> I;
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub enum Error {
|
||||
ZeroSizedImage,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub struct RGB {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl RGB {
|
||||
pub fn new(r: u8, g: u8, b: u8) -> RGB {
|
||||
RGB { r, g, b }
|
||||
}
|
||||
|
||||
pub fn cie(self: &RGB) -> f64 {
|
||||
//TODO: Change it as soon as https://github.com/jwagner/smartcrop.js/issues/77 is closed
|
||||
0.5126 * self.b as f64 + 0.7152 * self.g as f64 + 0.0722 * self.r as f64
|
||||
}
|
||||
|
||||
pub fn saturation(self: &RGB) -> f64 {
|
||||
let maximum = f64::max(
|
||||
f64::max(self.r as f64 / 255.0, self.g as f64 / 255.0),
|
||||
self.b as f64 / 255.0,
|
||||
);
|
||||
let minimum = f64::min(
|
||||
f64::min(self.r as f64 / 255.0, self.g as f64 / 255.0),
|
||||
self.b as f64 / 255.0,
|
||||
);
|
||||
|
||||
if maximum == minimum {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let l = (maximum + minimum) / 2.0;
|
||||
let d = maximum - minimum;
|
||||
|
||||
if l > 0.5 {
|
||||
d / (2.0 - maximum - minimum)
|
||||
} else {
|
||||
d / (maximum + minimum)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize(&self) -> [f64; 3] {
|
||||
if self.r == self.g && self.g == self.b {
|
||||
let inv_sqrt_3: f64 = 1.0 / 3.0f64.sqrt();
|
||||
return [inv_sqrt_3, inv_sqrt_3, inv_sqrt_3];
|
||||
}
|
||||
|
||||
let r = self.r as f64;
|
||||
let g = self.g as f64;
|
||||
let b = self.b as f64;
|
||||
|
||||
let mag = (r.powi(2) + g.powi(2) + b.powi(2)).sqrt();
|
||||
|
||||
[r / mag, g / mag, b / mag]
|
||||
}
|
||||
}
|
||||
|
||||
// Score contains values that classify matches
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct Score {
|
||||
pub detail: f64,
|
||||
pub saturation: f64,
|
||||
pub skin: f64,
|
||||
pub total: f64,
|
||||
}
|
||||
|
||||
// Crop contains results
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct Crop {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
fn scale(&self, ratio: f64) -> Crop {
|
||||
Crop {
|
||||
x: (self.x as f64 * ratio).round() as u32,
|
||||
y: (self.y as f64 * ratio).round() as u32,
|
||||
width: (self.width as f64 * ratio).round() as u32,
|
||||
height: (self.height as f64 * ratio).round() as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScoredCrop {
|
||||
pub crop: Crop,
|
||||
pub score: Score,
|
||||
}
|
||||
|
||||
impl ScoredCrop {
|
||||
pub fn scale(&self, ratio: f64) -> ScoredCrop {
|
||||
ScoredCrop {
|
||||
crop: self.crop.scale(ratio),
|
||||
score: self.score.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ImageMap {
|
||||
width: u32,
|
||||
height: u32,
|
||||
|
||||
pixels: Vec<Vec<RGB>>,
|
||||
}
|
||||
|
||||
impl ImageMap {
|
||||
fn new(width: u32, height: u32) -> ImageMap {
|
||||
let white = RGB::new(255, 255, 255);
|
||||
let pixels = vec![vec![white; height as usize]; width as usize];
|
||||
ImageMap {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, x: u32, y: u32, color: RGB) {
|
||||
self.pixels[x as usize][y as usize] = color
|
||||
}
|
||||
|
||||
fn get(&self, x: u32, y: u32) -> RGB {
|
||||
self.pixels[x as usize][y as usize]
|
||||
}
|
||||
|
||||
fn down_sample(self, factor: u32) -> Self {
|
||||
let width = (self.width as f64 / factor as f64).floor() as u32;
|
||||
let height = (self.height as f64 / factor as f64).floor() as u32;
|
||||
let mut output = ImageMap::new(width, height);
|
||||
// let data = output.data;
|
||||
let ifactor2: f64 = 1.0 / (factor as f64 * factor as f64);
|
||||
|
||||
let max = |a, b| {
|
||||
if a > b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
};
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let mut r: f64 = 0.0;
|
||||
let mut g: f64 = 0.0;
|
||||
let mut b: f64 = 0.0;
|
||||
|
||||
let mut mr: f64 = 0.0;
|
||||
let mut mg: f64 = 0.0;
|
||||
|
||||
for v in 0..factor {
|
||||
for u in 0..factor {
|
||||
let ix = x * factor + u;
|
||||
let iy = y * factor + v;
|
||||
let icolor = self.get(ix, iy);
|
||||
|
||||
r += icolor.r as f64;
|
||||
g += icolor.g as f64;
|
||||
b += icolor.b as f64;
|
||||
mr = max(mr, icolor.r as f64);
|
||||
mg = max(mg, icolor.g as f64);
|
||||
}
|
||||
}
|
||||
|
||||
// this is some funky magic to preserve detail a bit more for
|
||||
// skin (r) and detail (g). saturation (b) does not get this boost.
|
||||
output.set(
|
||||
x,
|
||||
y,
|
||||
RGB::new(
|
||||
(r * ifactor2 * 0.5 + mr * 0.5).round() as u8,
|
||||
(g * ifactor2 * 0.7 + mg * 0.3).round() as u8,
|
||||
(b * ifactor2).round() as u8,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_best_crop<I: Image + ResizableImage<RI>, RI: Image>(
|
||||
img: &I,
|
||||
width: NonZeroU32,
|
||||
height: NonZeroU32,
|
||||
) -> Result<ScoredCrop, Error> {
|
||||
if img.width() == 0 || img.height() == 0 {
|
||||
return Err(Error::ZeroSizedImage);
|
||||
}
|
||||
|
||||
let width = width.get() as f64;
|
||||
let height = height.get() as f64;
|
||||
|
||||
let scale = f64::min((img.width() as f64) / width, (img.height() as f64) / height);
|
||||
|
||||
// resize image for faster processing
|
||||
if PRESCALE {
|
||||
let f = PRESCALE_MIN / f64::min(img.width() as f64, img.height() as f64);
|
||||
let prescalefactor = f.min(1.0);
|
||||
|
||||
let crop_width = (width * scale * prescalefactor).max(1.0).round() as u32;
|
||||
let crop_height = (height * scale * prescalefactor).max(1.0).round() as u32;
|
||||
let real_min_scale = calculate_real_min_scale(scale);
|
||||
|
||||
let new_width = ((img.width() as f64) * prescalefactor).round() as u32;
|
||||
let new_height = (prescalefactor * img.height() as f64).round() as u32;
|
||||
|
||||
let old_width = img.width() as f64;
|
||||
let old_height = img.height() as f64;
|
||||
|
||||
let img = img.resize(new_width, new_height);
|
||||
|
||||
assert!(img.width() == crop_width || img.height() == crop_height);
|
||||
let top_crop = analyse(
|
||||
&img,
|
||||
NonZeroU32::new(crop_width).unwrap(),
|
||||
NonZeroU32::new(crop_height).unwrap(),
|
||||
real_min_scale,
|
||||
);
|
||||
|
||||
let post_scale_w = img.width() as f64 / old_width;
|
||||
let post_scale_h = img.height() as f64 / old_height;
|
||||
let post_scale_factor = f64::max(post_scale_w, post_scale_h);
|
||||
|
||||
Ok(top_crop.scale(1.0 / post_scale_factor))
|
||||
} else {
|
||||
let crop_width = (width * scale).round() as u32;
|
||||
let crop_height = (height * scale).round() as u32;
|
||||
let real_min_scale = calculate_real_min_scale(scale);
|
||||
|
||||
assert!(img.width() == crop_width || img.height() == crop_height);
|
||||
let top_crop = analyse(
|
||||
img,
|
||||
NonZeroU32::new(crop_width).unwrap(),
|
||||
NonZeroU32::new(crop_height).unwrap(),
|
||||
real_min_scale,
|
||||
);
|
||||
Ok(top_crop)
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_real_min_scale(scale: f64) -> f64 {
|
||||
f64::min(MAX_SCALE, f64::max(1.0 / scale, MIN_SCALE))
|
||||
}
|
||||
|
||||
fn analyse<I: Image>(
|
||||
img: &I,
|
||||
crop_width: NonZeroU32,
|
||||
crop_height: NonZeroU32,
|
||||
real_min_scale: f64,
|
||||
) -> ScoredCrop {
|
||||
assert!(img.width() >= crop_width.get());
|
||||
assert!(img.height() >= crop_height.get());
|
||||
|
||||
let mut o = ImageMap::new(img.width(), img.height());
|
||||
|
||||
edge_detect(img, &mut o);
|
||||
|
||||
skin_detect(img, &mut o);
|
||||
|
||||
saturation_detect(img, &mut o);
|
||||
|
||||
let cs: Vec<Crop> = crops(&o, crop_width.get(), crop_height.get(), real_min_scale);
|
||||
assert!(!cs.is_empty());
|
||||
let score_output = o.down_sample(SCORE_DOWN_SAMPLE as u32);
|
||||
let top_crop: Option<ScoredCrop> = cs
|
||||
.iter()
|
||||
.map(|crop| ScoredCrop {
|
||||
crop: crop.clone(),
|
||||
score: score(&score_output, crop),
|
||||
})
|
||||
.fold(None, |result, scored_crop| {
|
||||
Some(match result {
|
||||
None => scored_crop,
|
||||
Some(result) => {
|
||||
if result.score.total > scored_crop.score.total {
|
||||
result
|
||||
} else {
|
||||
scored_crop
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
top_crop.unwrap()
|
||||
}
|
||||
|
||||
fn edge_detect<I: Image>(i: &I, o: &mut ImageMap) {
|
||||
//TODO check type casts if those are safe
|
||||
|
||||
let w = i.width() as usize;
|
||||
let h = i.height() as usize;
|
||||
let cies = make_cies(i);
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let color = i.get(x as u32, y as u32);
|
||||
|
||||
let lightness = if x == 0 || x >= w - 1 || y == 0 || y >= h - 1 {
|
||||
cies[y * w + x]
|
||||
} else {
|
||||
cies[y * w + x] * 4.0
|
||||
- cies[x + (y - 1) * w]
|
||||
- cies[x - 1 + y * w]
|
||||
- cies[x + 1 + y * w]
|
||||
- cies[x + (y + 1) * w]
|
||||
};
|
||||
|
||||
let g = bounds(lightness);
|
||||
|
||||
let nc = RGB { g, ..color };
|
||||
o.set(x as u32, y as u32, nc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_cies<I: Image>(img: &I) -> Vec<f64> {
|
||||
//TODO `cies()` can probably be made RGB member that will make this function redundant
|
||||
let w = img.width();
|
||||
let h = img.height();
|
||||
let size = w as u64 * h as u64;
|
||||
|
||||
let size = if size > usize::max_value() as u64 {
|
||||
None
|
||||
} else {
|
||||
Some(size as usize)
|
||||
};
|
||||
|
||||
//TODO error handling
|
||||
let mut cies = Vec::with_capacity(size.expect("Too big image dimensions"));
|
||||
|
||||
let mut i: usize = 0;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let color = img.get(x, y);
|
||||
cies.insert(i, color.cie());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
cies
|
||||
}
|
||||
|
||||
fn crops(i: &ImageMap, crop_width: u32, crop_height: u32, real_min_scale: f64) -> Vec<Crop> {
|
||||
let mut crops: Vec<Crop> = vec![];
|
||||
let width = i.width as f64;
|
||||
let height = i.height as f64;
|
||||
|
||||
let min_dimension = f64::min(width, height);
|
||||
|
||||
let crop_w = if crop_width != 0 {
|
||||
crop_width as f64
|
||||
} else {
|
||||
min_dimension
|
||||
};
|
||||
let crop_h = if crop_height != 0 {
|
||||
crop_height as f64
|
||||
} else {
|
||||
min_dimension
|
||||
};
|
||||
|
||||
let y_step = STEP.min(height);
|
||||
let x_step = STEP.min(width);
|
||||
|
||||
let mut scale = MAX_SCALE;
|
||||
loop {
|
||||
if scale < real_min_scale {
|
||||
break;
|
||||
};
|
||||
|
||||
let stepping = |step| (0..).map(f64::from).map(move |i| i * step);
|
||||
|
||||
for y in stepping(y_step).take_while(|y| y + crop_h * scale <= height) {
|
||||
for x in stepping(x_step).take_while(|x| x + crop_w * scale <= width) {
|
||||
crops.push(Crop {
|
||||
x: x.round() as u32,
|
||||
y: y.round() as u32,
|
||||
width: (crop_w * scale).round() as u32,
|
||||
height: (crop_h * scale).round() as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scale -= SCALE_STEP;
|
||||
}
|
||||
|
||||
crops
|
||||
}
|
||||
|
||||
fn score(o: &ImageMap, crop: &Crop) -> Score {
|
||||
let height = o.height as f64;
|
||||
let width = o.width as f64;
|
||||
|
||||
let down_sample = SCORE_DOWN_SAMPLE;
|
||||
let inv_down_sample = 1.0 / down_sample;
|
||||
let output_height_down_sample = height * down_sample;
|
||||
let output_width_down_sample = width * down_sample;
|
||||
|
||||
let mut skin = 0.0;
|
||||
let mut detail = 0.0;
|
||||
let mut saturation = 0.0;
|
||||
|
||||
for y in (0..)
|
||||
.map(|i: u32| i as f64 * SCORE_DOWN_SAMPLE)
|
||||
.take_while(|&y| y < output_height_down_sample)
|
||||
{
|
||||
for x in (0..)
|
||||
.map(|i: u32| i as f64 * SCORE_DOWN_SAMPLE)
|
||||
.take_while(|&x| x < output_width_down_sample)
|
||||
{
|
||||
let orig_x = (x * inv_down_sample).round() as u32;
|
||||
let orig_y = (y * inv_down_sample).round() as u32;
|
||||
|
||||
let color = o.get(orig_x, orig_y);
|
||||
|
||||
let imp = importance(crop, x.round() as u32, y.round() as u32);
|
||||
let det = color.g as f64 / 255.0;
|
||||
|
||||
skin += color.r as f64 / 255.0 * (det + SKIN_BIAS) * imp;
|
||||
detail += det * imp;
|
||||
saturation += color.b as f64 / 255.0 * (det + SATURATION_BIAS) * imp;
|
||||
}
|
||||
}
|
||||
|
||||
let total = (detail * DETAIL_WEIGHT + skin * SKIN_WEIGHT + saturation * SATURATION_WEIGHT)
|
||||
/ crop.width as f64
|
||||
/ crop.height as f64;
|
||||
|
||||
Score {
|
||||
skin,
|
||||
detail,
|
||||
saturation,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
fn skin_detect<I: Image>(i: &I, o: &mut ImageMap) {
|
||||
let w = i.width();
|
||||
let h = i.height();
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let lightness = i.get(x, y).cie() / 255.0;
|
||||
let skin = skin_col(i.get(x, y));
|
||||
|
||||
let nc = if skin > SKIN_THRESHOLD && SKIN_BRIGHTNESS_RANGE.contains(&lightness) {
|
||||
let r = (skin - SKIN_THRESHOLD) * (255.0 / (1.0 - SKIN_THRESHOLD));
|
||||
let RGB { r: _, g, b } = o.get(x, y);
|
||||
|
||||
RGB { r: bounds(r), g, b }
|
||||
} else {
|
||||
let RGB { r: _, g, b } = o.get(x, y);
|
||||
RGB { r: 0, g, b }
|
||||
};
|
||||
|
||||
o.set(x, y, nc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn saturation_detect<I: Image>(i: &I, o: &mut ImageMap) {
|
||||
let w = i.width();
|
||||
let h = i.height();
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let color = i.get(x, y);
|
||||
let lightness = color.cie() / 255.0;
|
||||
let saturation = color.saturation();
|
||||
|
||||
let nc = if saturation > SATURATION_THRESHOLD
|
||||
&& SATURATION_BRIGHTNESS_RANGE.contains(&lightness)
|
||||
{
|
||||
let b =
|
||||
(saturation - SATURATION_THRESHOLD) * (255.0 / (1.0 - SATURATION_THRESHOLD));
|
||||
let RGB { r, g, b: _ } = o.get(x, y);
|
||||
RGB { r, g, b: bounds(b) }
|
||||
} else {
|
||||
let RGB { r, g, b: _ } = o.get(x, y);
|
||||
RGB { r, g, b: 0 }
|
||||
};
|
||||
|
||||
o.set(x, y, nc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
164
crates/smartcrop/src/math.rs
Normal file
164
crates/smartcrop/src/math.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use crate::{Crop, RGB};
|
||||
|
||||
const SKIN_COLOR: RGB = RGB {
|
||||
r: 234,
|
||||
g: 171,
|
||||
b: 132,
|
||||
};
|
||||
const OUTSIDE_IMPORTANCE: f64 = -0.5;
|
||||
const EDGE_RADIUS: f64 = 0.4;
|
||||
const EDGE_WEIGHT: f64 = -20.0;
|
||||
const RULE_OF_THIRDS: bool = true;
|
||||
|
||||
// test
|
||||
fn thirds(x: f64) -> f64 {
|
||||
let x = ((x - (1.0 / 3.0) + 1.0) % 2.0 * 0.5 - 0.5) * 16.0;
|
||||
f64::max(1.0 - x * x, 0.0)
|
||||
}
|
||||
|
||||
pub fn bounds(l: f64) -> u8 {
|
||||
f64::min(f64::max(l, 0.0), 255.0).round() as u8
|
||||
}
|
||||
|
||||
pub fn skin_col(c: RGB) -> f64 {
|
||||
// `K` is needed to avoid breaking BC and make SKIN_COLOR more meaningful
|
||||
const K: f64 = 0.9420138987639984;
|
||||
|
||||
let skin_color: Vec<f64> = SKIN_COLOR.normalize().iter().map(|c| c / K).collect();
|
||||
|
||||
let [r_norm, g_norm, b_norm] = c.normalize();
|
||||
|
||||
let dr = r_norm - skin_color[0];
|
||||
let dg = g_norm - skin_color[1];
|
||||
let db = b_norm - skin_color[2];
|
||||
|
||||
let d = (dr.powi(2) + dg.powi(2) + db.powi(2)).sqrt();
|
||||
|
||||
1.0 - d.min(1.0)
|
||||
}
|
||||
|
||||
pub fn importance(crop: &Crop, x: u32, y: u32) -> f64 {
|
||||
if crop.x > x || x >= crop.x + crop.width || crop.y > y || y >= crop.y + crop.height {
|
||||
return OUTSIDE_IMPORTANCE;
|
||||
}
|
||||
|
||||
let xf = (x - crop.x) as f64 / (crop.width as f64);
|
||||
let yf = (y - crop.y) as f64 / (crop.height as f64);
|
||||
|
||||
let px = (0.5 - xf).abs() * 2.0;
|
||||
let py = (0.5 - yf).abs() * 2.0;
|
||||
|
||||
let dx = f64::max(px - 1.0 + EDGE_RADIUS, 0.0);
|
||||
let dy = f64::max(py - 1.0 + EDGE_RADIUS, 0.0);
|
||||
let d = (dx * dx + dy * dy) * EDGE_WEIGHT;
|
||||
|
||||
let mut s = 1.41 - (px * px + py * py).sqrt();
|
||||
if RULE_OF_THIRDS {
|
||||
s += (f64::max(0.0, s + d + 0.5) * 1.2) * (thirds(px) + thirds(py))
|
||||
}
|
||||
|
||||
s + d
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Crop;
|
||||
|
||||
use super::*;
|
||||
// use proptest::strategy::Strategy;
|
||||
// use proptest::test_runner::Config;
|
||||
|
||||
fn gray(c: u8) -> RGB {
|
||||
RGB::new(c, c, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirds_test() {
|
||||
assert_eq!(0.0, thirds(0.0));
|
||||
assert_eq!(0.0, thirds(0.5));
|
||||
assert_eq!(0.0, thirds(1.0));
|
||||
assert_eq!(1.0, thirds(1.0 / 3.0));
|
||||
assert_eq!(0.9288888888888889, thirds(0.9 / 3.0));
|
||||
assert_eq!(0.9288888888888884, thirds(1.1 / 3.0));
|
||||
assert_eq!(0.7155555555555557, thirds(1.2 / 3.0));
|
||||
assert_eq!(0.3599999999999989, thirds(1.3 / 3.0));
|
||||
assert_eq!(0.0, thirds(1.4 / 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_test() {
|
||||
assert_eq!(0, bounds(-1.0));
|
||||
assert_eq!(0, bounds(0.0));
|
||||
assert_eq!(10, bounds(10.0));
|
||||
assert_eq!(255, bounds(255.0));
|
||||
assert_eq!(255, bounds(255.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cie_test() {
|
||||
assert_eq!(0.0, gray(0).cie());
|
||||
assert_eq!(331.49999999999994, gray(255).cie());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_col_test() {
|
||||
assert_eq!(0.7550795306611966, skin_col(gray(0)));
|
||||
assert_eq!(0.7550795306611966, skin_col(gray(1)));
|
||||
assert_eq!(0.7550795306611966, skin_col(gray(127)));
|
||||
assert_eq!(0.7550795306611966, skin_col(gray(34)));
|
||||
assert_eq!(0.7550795306611966, skin_col(gray(255)));
|
||||
assert_eq!(0.5904611542890027, skin_col(RGB::new(134, 45, 23)));
|
||||
assert_eq!(0.9384288009573658, skin_col(RGB::new(199, 145, 112)));
|
||||
assert_eq!(0.9380840524535538, skin_col(RGB::new(100, 72, 56)));
|
||||
assert_eq!(0.9384445374828501, skin_col(RGB::new(234, 171, 132)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importance_tests() {
|
||||
assert_eq!(
|
||||
-6.404213562373096,
|
||||
importance(
|
||||
&Crop {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
0,
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
fn color() -> impl Strategy<Value = RGB> {
|
||||
(0..=255u8, 0..=255u8, 0..=255u8).prop_map(|(r, g, b)| RGB { r, g, b })
|
||||
}
|
||||
|
||||
fn between_0_and_1() -> impl Strategy<Value = f64> {
|
||||
(0u64..).prop_map(|i| i as f64 / u64::max_value() as f64)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(Config::with_cases(10000))]
|
||||
#[test]
|
||||
fn skin_col_score_is_between_0_and_1(c in color()) {
|
||||
let score = skin_col(c);
|
||||
|
||||
//TODO Change 0.94 to 1.0 when values in formulas are fixed
|
||||
assert!(score >= 0.0 && score <= 0.94);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirds_result_is_within_defined_boundaries(input in between_0_and_1()) {
|
||||
let result = thirds(input);
|
||||
if input > 0.4583333333333332 || input <= 0.2083333333333334 {
|
||||
assert_eq!(result, 0.0);
|
||||
} else {
|
||||
assert!(result > 0.0);
|
||||
assert!(result <= 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
449
crates/smartcrop/src/tests.rs
Normal file
449
crates/smartcrop/src/tests.rs
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
use std::num::NonZeroU32;
|
||||
|
||||
use crate::{
|
||||
analyse, crops, edge_detect, saturation_detect, score, skin_detect, Crop, Image, ImageMap,
|
||||
ResizableImage, Score, MIN_SCALE, RGB,
|
||||
};
|
||||
|
||||
const WHITE: RGB = RGB {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
};
|
||||
const BLACK: RGB = RGB { r: 0, g: 0, b: 0 };
|
||||
const RED: RGB = RGB { r: 255, g: 0, b: 0 };
|
||||
const GREEN: RGB = RGB { r: 0, g: 255, b: 0 };
|
||||
const BLUE: RGB = RGB { r: 0, g: 0, b: 255 };
|
||||
const SKIN: RGB = RGB {
|
||||
r: 255,
|
||||
g: 200,
|
||||
b: 159,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TestImage {
|
||||
w: u32,
|
||||
h: u32,
|
||||
pixels: Vec<Vec<RGB>>,
|
||||
}
|
||||
|
||||
impl TestImage {
|
||||
fn new(w: u32, h: u32, pixels: Vec<Vec<RGB>>) -> TestImage {
|
||||
TestImage { w, h, pixels }
|
||||
}
|
||||
|
||||
fn new_single_pixel(pixel: RGB) -> TestImage {
|
||||
TestImage {
|
||||
w: 1,
|
||||
h: 1,
|
||||
pixels: vec![vec![pixel]],
|
||||
}
|
||||
}
|
||||
|
||||
fn new_from_fn<G>(w: u32, h: u32, generate: G) -> TestImage
|
||||
where
|
||||
G: Fn(u32, u32) -> RGB,
|
||||
{
|
||||
let mut pixels = vec![vec![WHITE; h as usize]; w as usize];
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
pixels[x as usize][y as usize] = generate(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
TestImage { w, h, pixels }
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageMap {
|
||||
fn from_image<I: Image>(image: &I) -> ImageMap {
|
||||
let mut image_map = ImageMap::new(image.width(), image.height());
|
||||
|
||||
for y in 0..image.height() {
|
||||
for x in 0..image.width() {
|
||||
let color = image.get(x, y);
|
||||
image_map.set(x, y, color);
|
||||
}
|
||||
}
|
||||
|
||||
image_map
|
||||
}
|
||||
}
|
||||
|
||||
impl Image for TestImage {
|
||||
fn width(&self) -> u32 {
|
||||
self.w
|
||||
}
|
||||
|
||||
fn height(&self) -> u32 {
|
||||
self.h
|
||||
}
|
||||
|
||||
fn get(&self, x: u32, y: u32) -> RGB {
|
||||
self.pixels[x as usize][y as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl ResizableImage<TestImage> for TestImage {
|
||||
fn resize(&self, width: u32, _height: u32) -> TestImage {
|
||||
if width == self.w {
|
||||
return self.clone();
|
||||
}
|
||||
|
||||
let height = (self.h as f64 * width as f64 / self.w as f64).round() as u32;
|
||||
|
||||
//TODO Implement more or less correct resizing
|
||||
TestImage {
|
||||
w: width,
|
||||
h: height,
|
||||
pixels: self.pixels.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturation_tests() {
|
||||
assert_eq!(0.0, BLACK.saturation());
|
||||
assert_eq!(0.0, WHITE.saturation());
|
||||
assert_eq!(1.0, RGB::new(255, 0, 0).saturation());
|
||||
assert_eq!(1.0, RGB::new(0, 255, 0).saturation());
|
||||
assert_eq!(1.0, RGB::new(0, 0, 255).saturation());
|
||||
assert_eq!(1.0, RGB::new(0, 255, 255).saturation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_map_test() {
|
||||
let mut image_map = ImageMap::new(1, 2);
|
||||
|
||||
assert_eq!(image_map.width, 1);
|
||||
assert_eq!(image_map.height, 2);
|
||||
|
||||
assert_eq!(image_map.get(0, 0), RGB::new(255, 255, 255));
|
||||
assert_eq!(image_map.get(0, 1), RGB::new(255, 255, 255));
|
||||
|
||||
let red = RGB::new(255, 0, 0);
|
||||
image_map.set(0, 0, red);
|
||||
assert_eq!(image_map.get(0, 0), red);
|
||||
|
||||
let green = RGB::new(0, 255, 0);
|
||||
image_map.set(0, 1, green);
|
||||
assert_eq!(image_map.get(0, 1), green);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crops_test() {
|
||||
let real_min_scale = MIN_SCALE;
|
||||
|
||||
let crops = crops(&ImageMap::new(8, 8), 8, 8, real_min_scale);
|
||||
|
||||
assert_eq!(
|
||||
crops[0],
|
||||
Crop {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_test_image_with_single_black_pixel_then_score_is_zero() {
|
||||
let mut i = ImageMap::new(1, 1);
|
||||
i.set(0, 0, RGB::new(0, 0, 0));
|
||||
|
||||
let s = score(
|
||||
&i,
|
||||
&Crop {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
s,
|
||||
Score {
|
||||
detail: 0.0,
|
||||
saturation: 0.0,
|
||||
skin: 0.0,
|
||||
total: 0.0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_test_image_with_single_white_pixel_then_score_is_the_same_as_for_js_version() {
|
||||
let mut i = ImageMap::new(1, 1);
|
||||
i.set(0, 0, RGB::new(255, 255, 255));
|
||||
|
||||
let s = score(
|
||||
&i,
|
||||
&Crop {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
|
||||
let js_version_score = Score {
|
||||
detail: -6.404213562373096,
|
||||
saturation: -7.685056274847715,
|
||||
skin: -6.468255697996827,
|
||||
total: -13.692208596353678,
|
||||
};
|
||||
|
||||
assert_eq!(s, js_version_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_detect_single_pixel_test() {
|
||||
let detect_pixel = |color: RGB| {
|
||||
let image = TestImage::new_single_pixel(color);
|
||||
let mut o = ImageMap::new(1, 1);
|
||||
o.set(0, 0, color);
|
||||
|
||||
skin_detect(&image, &mut o);
|
||||
o.get(0, 0)
|
||||
};
|
||||
|
||||
assert_eq!(detect_pixel(WHITE), RGB::new(0, 255, 255));
|
||||
assert_eq!(detect_pixel(BLACK), RGB::new(0, 0, 0));
|
||||
assert_eq!(detect_pixel(RED), RGB::new(0, 0, 0));
|
||||
assert_eq!(detect_pixel(GREEN), RGB::new(0, 255, 0));
|
||||
assert_eq!(detect_pixel(BLUE), RGB::new(0, 0, 255));
|
||||
assert_eq!(detect_pixel(SKIN), RGB::new(159, 200, 159));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_detect_single_pixel_image_test() {
|
||||
let edge_detect_pixel = |color: RGB| {
|
||||
let image = TestImage::new_single_pixel(color);
|
||||
let mut o = ImageMap::new(1, 1);
|
||||
o.set(0, 0, color);
|
||||
|
||||
edge_detect(&image, &mut o);
|
||||
|
||||
o.get(0, 0)
|
||||
};
|
||||
|
||||
assert_eq!(edge_detect_pixel(BLACK), BLACK);
|
||||
assert_eq!(edge_detect_pixel(WHITE), WHITE);
|
||||
assert_eq!(edge_detect_pixel(RED), RGB::new(255, 18, 0));
|
||||
assert_eq!(edge_detect_pixel(GREEN), RGB::new(0, 182, 0));
|
||||
assert_eq!(edge_detect_pixel(BLUE), RGB::new(0, 131, 255));
|
||||
assert_eq!(edge_detect_pixel(SKIN), RGB::new(255, 243, 159));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_detect_3x3() {
|
||||
let image = TestImage::new(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
vec![RED, GREEN, BLUE],
|
||||
vec![GREEN, BLUE, RED],
|
||||
vec![BLUE, RED, GREEN],
|
||||
],
|
||||
);
|
||||
let mut o = ImageMap::new(3, 3);
|
||||
|
||||
edge_detect(&image, &mut o);
|
||||
|
||||
assert_eq!(
|
||||
o.get(0, 0),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 18,
|
||||
b: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
o.get(0, 0),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 18,
|
||||
b: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(o.get(1, 0), RGB { r: 0, g: 182, b: 0 });
|
||||
assert_eq!(
|
||||
o.get(2, 0),
|
||||
RGB {
|
||||
r: 0,
|
||||
g: 131,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
assert_eq!(o.get(0, 1), RGB { r: 0, g: 182, b: 0 });
|
||||
assert_eq!(
|
||||
o.get(1, 1),
|
||||
RGB {
|
||||
r: 0,
|
||||
g: 121,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
o.get(2, 1),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 18,
|
||||
b: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
o.get(0, 2),
|
||||
RGB {
|
||||
r: 0,
|
||||
g: 131,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
o.get(1, 2),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 18,
|
||||
b: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(o.get(2, 2), RGB { r: 0, g: 182, b: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturation_detect_3x3() {
|
||||
let image = TestImage::new(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
vec![RED, GREEN, BLUE],
|
||||
vec![WHITE, SKIN, BLACK],
|
||||
vec![BLUE, RED, GREEN],
|
||||
],
|
||||
);
|
||||
let mut o = ImageMap::from_image(&image);
|
||||
|
||||
saturation_detect(&image, &mut o);
|
||||
|
||||
assert_eq!(
|
||||
o.get(0, 0),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
o.get(0, 1),
|
||||
RGB {
|
||||
r: 0,
|
||||
g: 255,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
assert_eq!(o.get(0, 2), RGB { r: 0, g: 0, b: 255 });
|
||||
assert_eq!(
|
||||
o.get(1, 0),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
o.get(1, 1),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 200,
|
||||
b: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(o.get(1, 2), RGB { r: 0, g: 0, b: 0 });
|
||||
assert_eq!(o.get(2, 0), RGB { r: 0, g: 0, b: 255 });
|
||||
assert_eq!(
|
||||
o.get(2, 1),
|
||||
RGB {
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
o.get(2, 2),
|
||||
RGB {
|
||||
r: 0,
|
||||
g: 255,
|
||||
b: 255
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_test() {
|
||||
let image = TestImage::new_from_fn(24, 24, |x, y| {
|
||||
let center = 8..16;
|
||||
if center.contains(&x) && center.contains(&y) {
|
||||
SKIN
|
||||
} else {
|
||||
WHITE
|
||||
}
|
||||
});
|
||||
|
||||
let crop = analyse(
|
||||
&image,
|
||||
NonZeroU32::new(8).unwrap(),
|
||||
NonZeroU32::new(8).unwrap(),
|
||||
1.0,
|
||||
);
|
||||
|
||||
assert_eq!(crop.crop.width, 8);
|
||||
assert_eq!(crop.crop.height, 8);
|
||||
assert_eq!(crop.crop.x, 8);
|
||||
assert_eq!(crop.crop.y, 8);
|
||||
assert_eq!(crop.score.saturation, 0.0);
|
||||
assert_eq!(crop.score.detail, -1.7647058823529413);
|
||||
assert_eq!(crop.score.skin, -0.03993215515362048);
|
||||
assert_eq!(crop.score.total, -0.006637797746048519);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_scale_test() {
|
||||
let crop = Crop {
|
||||
x: 2,
|
||||
y: 4,
|
||||
width: 8,
|
||||
height: 16,
|
||||
};
|
||||
|
||||
let scaled_crop = crop.scale(0.5);
|
||||
|
||||
assert_eq!(1, scaled_crop.x);
|
||||
assert_eq!(2, scaled_crop.y);
|
||||
assert_eq!(4, scaled_crop.width);
|
||||
assert_eq!(8, scaled_crop.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_sample_test() {
|
||||
let image = TestImage::new(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
vec![RED, GREEN, BLUE],
|
||||
vec![SKIN, BLUE, RED],
|
||||
vec![BLUE, RED, GREEN],
|
||||
],
|
||||
);
|
||||
|
||||
let image_map = ImageMap::from_image(&image);
|
||||
|
||||
let result = image_map.down_sample(3);
|
||||
|
||||
assert_eq!(result.width, 1);
|
||||
assert_eq!(result.height, 1);
|
||||
assert_eq!(result.get(0, 0), RGB::new(184, 132, 103));
|
||||
}
|
||||
|
|
@ -9,18 +9,28 @@ repository.workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
dotenvy.workspace = true
|
||||
headers.workspace = true
|
||||
http.workspace = true
|
||||
hmac.workspace = true
|
||||
once_cell.workspace = true
|
||||
pathetic.workspace = true
|
||||
path-absolutize.workspace = true
|
||||
path_macro.workspace = true
|
||||
serde.workspace = true
|
||||
serde_plain.workspace = true
|
||||
sha2.workspace = true
|
||||
smart-default.workspace = true
|
||||
time.workspace = true
|
||||
toml.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
tiraya-api-model.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
insta.workspace = true
|
||||
path_macro.workspace = true
|
||||
rstest.workspace = true
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ use std::{
|
|||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Error, Ok};
|
||||
use anyhow::{anyhow, Error};
|
||||
use once_cell::sync::Lazy;
|
||||
use path_absolutize::Absolutize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use smart_default::SmartDefault;
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
|
|
@ -14,6 +15,7 @@ use smart_default::SmartDefault;
|
|||
pub struct Config {
|
||||
pub core: ConfigCore,
|
||||
pub extractor: ConfigExtractor,
|
||||
pub storage: ConfigStorage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, SmartDefault)]
|
||||
|
|
@ -35,6 +37,14 @@ pub struct ConfigCore {
|
|||
/// Maximum number of concurrent requests per db operation
|
||||
#[default(8)]
|
||||
pub db_concurrency: usize,
|
||||
/// Secret string (used to derive HMAC key)
|
||||
///
|
||||
/// Generate using `openssl rand -base64 36` or another secure password generator
|
||||
#[default("INSECURE_ChG7e88XKZorOWAOzVLZgJO+OgAMxTl5Z4ovAefDaawfjthx")]
|
||||
pub secret: String,
|
||||
/// Derived HMAC key
|
||||
#[serde(skip)]
|
||||
pub hmac_key: [u8; 32],
|
||||
}
|
||||
|
||||
/// Music data extractor settings
|
||||
|
|
@ -118,35 +128,47 @@ pub struct ConfigExtractorSpotify {
|
|||
pub market: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, SmartDefault)]
|
||||
#[serde(default)]
|
||||
pub struct ConfigStorage {
|
||||
#[default("images")]
|
||||
pub image_dir: PathBuf,
|
||||
#[default("music")]
|
||||
pub music_dir: PathBuf,
|
||||
#[default("tmp")]
|
||||
pub tmp_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| Config::init().expect("Failed to load config file"));
|
||||
|
||||
impl Config {
|
||||
fn init() -> Result<Self, Error> {
|
||||
let file_path = Self::get_path()?;
|
||||
let de_path = dotenvy::dotenv();
|
||||
let mut file_path = Self::get_path()?;
|
||||
if let Ok(de_path) = de_path {
|
||||
if let Some(de_dir) = de_path.parent() {
|
||||
file_path = file_path.absolutize_from(de_dir)?.to_path_buf();
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Reading config from {}", file_path.display());
|
||||
let cfg = Self::read(file_path)?;
|
||||
std::fs::create_dir_all(&cfg.extractor.data_dir)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn get_path() -> Result<PathBuf, Error> {
|
||||
Ok(PathBuf::try_from(
|
||||
std::env::var("TIRAYA_CONFIG_PATH").unwrap_or_else(|_| "config.toml".to_string()),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_path() -> Result<PathBuf, Error> {
|
||||
Ok(path_macro::path!(
|
||||
env!("CARGO_MANIFEST_DIR") / ".." / ".." / "run" / "config.toml"
|
||||
))
|
||||
}
|
||||
|
||||
fn read<P: AsRef<Path>>(file_path: P) -> Result<Self, Error> {
|
||||
let file_path = file_path.as_ref().absolutize()?.to_path_buf();
|
||||
let cfg_str = std::fs::read_to_string(&file_path)?;
|
||||
let mut cfg = toml::from_str::<Config>(&cfg_str)?;
|
||||
|
||||
// Determine root directory
|
||||
let root_dir = cfg
|
||||
.core
|
||||
.root_dir
|
||||
|
|
@ -159,6 +181,12 @@ impl Config {
|
|||
));
|
||||
}
|
||||
|
||||
let normalize_dir = |dir: &mut PathBuf| -> Result<(), Error> {
|
||||
*dir = dir.absolutize_from(root_dir)?.to_path_buf();
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Validate match threshold
|
||||
if cfg.extractor.match_threshold <= 0.0 || cfg.extractor.match_threshold > 1.0 {
|
||||
return Err(anyhow!(
|
||||
"match_threshold {} must be between 0 and 1",
|
||||
|
|
@ -166,11 +194,16 @@ impl Config {
|
|||
));
|
||||
}
|
||||
|
||||
cfg.extractor.data_dir = cfg
|
||||
.extractor
|
||||
.data_dir
|
||||
.absolutize_from(root_dir)?
|
||||
.to_path_buf();
|
||||
// Normalize directory paths
|
||||
normalize_dir(&mut cfg.extractor.data_dir)?;
|
||||
normalize_dir(&mut cfg.storage.image_dir)?;
|
||||
normalize_dir(&mut cfg.storage.music_dir)?;
|
||||
normalize_dir(&mut cfg.storage.tmp_dir)?;
|
||||
|
||||
// Generate HMAC key
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(cfg.core.secret.as_bytes());
|
||||
cfg.core.hmac_key = hasher.finalize().into();
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
|
@ -191,6 +224,9 @@ mod tests {
|
|||
insta::assert_ron_snapshot!(cfg, {
|
||||
".extractor.data_dir" => "[path]",
|
||||
".extractor.track_type_keywords" => "[track_type_keywords]",
|
||||
".storage.image_dir" => "[path]",
|
||||
".storage.music_dir" => "[path]",
|
||||
".storage.tmp_dir" => "[path]",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
#![warn(clippy::dbg_macro, clippy::todo)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::PrimitiveDateTime;
|
||||
use url::Url;
|
||||
|
||||
mod signed_url;
|
||||
pub use signed_url::{image_url_local, image_url_proxy, validate_image_url, SignatureError};
|
||||
|
||||
pub mod config;
|
||||
pub mod traits;
|
||||
|
|
@ -19,19 +20,68 @@ pub enum EntityType {
|
|||
|
||||
serde_plain::derive_display_from_serialize!(EntityType);
|
||||
|
||||
pub fn image_url_local(
|
||||
src_id: tiraya_api_model::TId<'_>,
|
||||
entity_type: EntityType,
|
||||
date: PrimitiveDateTime,
|
||||
) -> String {
|
||||
format!(
|
||||
"/image/{entity_type}/{src_id}?dt={}",
|
||||
date.assume_utc().unix_timestamp()
|
||||
)
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImageKind {
|
||||
Artist,
|
||||
ArtistHeader,
|
||||
Album,
|
||||
Playlist,
|
||||
User,
|
||||
}
|
||||
|
||||
pub fn image_url_proxy(url: &str) -> String {
|
||||
Url::parse_with_params("/image/proxy", &[("url", url)])
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default()
|
||||
serde_plain::derive_display_from_serialize!(ImageKind);
|
||||
|
||||
impl ImageKind {
|
||||
pub fn entity_type(self) -> EntityType {
|
||||
match self {
|
||||
ImageKind::Artist | ImageKind::ArtistHeader => EntityType::Artist,
|
||||
ImageKind::Album => EntityType::Album,
|
||||
ImageKind::Playlist => EntityType::Playlist,
|
||||
ImageKind::User => EntityType::User,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ImageSize {
|
||||
/// Large images
|
||||
///
|
||||
/// Cover: max. 640px tall, Header: max. 1920px wide
|
||||
#[default]
|
||||
#[serde(rename = "lg")]
|
||||
Large,
|
||||
/// Medium images
|
||||
///
|
||||
/// Cover: 300x300px, Header: 1080px wide
|
||||
#[serde(rename = "md")]
|
||||
Medium,
|
||||
/// Small images
|
||||
///
|
||||
/// Cover: 64x64px
|
||||
#[serde(rename = "sm")]
|
||||
Small,
|
||||
}
|
||||
|
||||
serde_plain::derive_display_from_serialize!(ImageSize);
|
||||
|
||||
/// Setup log for testing
|
||||
pub fn test_log() {
|
||||
_ = tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::new("debug"))
|
||||
.try_init();
|
||||
}
|
||||
|
||||
pub fn cache_immutable_public() -> headers::CacheControl {
|
||||
headers::CacheControl::new()
|
||||
.with_max_age(std::time::Duration::from_secs(31536000))
|
||||
.with_public()
|
||||
.with_immutable()
|
||||
}
|
||||
|
||||
pub fn cache_immutable_private() -> headers::CacheControl {
|
||||
headers::CacheControl::new()
|
||||
.with_max_age(std::time::Duration::from_secs(86400))
|
||||
.with_private()
|
||||
.with_immutable()
|
||||
}
|
||||
|
|
|
|||
233
crates/utils/src/signed_url.rs
Normal file
233
crates/utils/src/signed_url.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use pathetic::Uri;
|
||||
use sha2::Sha256;
|
||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||
|
||||
use crate::{config::CONFIG, ImageKind};
|
||||
|
||||
/// Add a HMAC signature to an image URL
|
||||
///
|
||||
/// Source text of image URL signatures:
|
||||
///
|
||||
/// ```txt
|
||||
/// Format: <SRC>|<Kind>|<ID>|(|<Valid Date in days since epoch>)
|
||||
///
|
||||
/// IMG_LOCAL|artist|yt:UCbpXC82cTLngSiZrysAaE-w
|
||||
/// IMG_LOCAL|playlist|ti:dS38sDj39CJI0kvlrpD0id|19668
|
||||
/// IMG_PROXY|https://example.com/pic.jpg
|
||||
/// ```
|
||||
fn sign_image_url(url: &mut Uri, id: &str, image_kind: Option<ImageKind>, vdt: Option<u32>) {
|
||||
let mut query = url.query_pairs_mut();
|
||||
|
||||
if let Some(vdt) = vdt {
|
||||
query.append_pair("vdt", &vdt.to_string());
|
||||
}
|
||||
|
||||
let sig = image_sig(id, image_kind, vdt);
|
||||
query.append_pair("sig", &sig);
|
||||
}
|
||||
|
||||
fn image_mac(id: &str, image_kind: Option<ImageKind>, vdt: Option<u32>) -> Hmac<Sha256> {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&CONFIG.core.hmac_key).unwrap();
|
||||
|
||||
let sig_txt = if let Some(image_kind) = image_kind {
|
||||
format!("IMG_LOCAL|{image_kind}|{id}")
|
||||
} else {
|
||||
format!("IMG_PROXY|{id}")
|
||||
};
|
||||
mac.update(sig_txt.as_bytes());
|
||||
|
||||
if let Some(vdt) = vdt {
|
||||
mac.update(b"|");
|
||||
mac.update(vdt.to_string().as_bytes());
|
||||
}
|
||||
|
||||
mac
|
||||
}
|
||||
|
||||
fn image_sig(id: &str, image_kind: Option<ImageKind>, vdt: Option<u32>) -> String {
|
||||
let mac = image_mac(id, image_kind, vdt);
|
||||
let sig = mac.finalize().into_bytes();
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig)
|
||||
}
|
||||
|
||||
fn day_since_epoch() -> u32 {
|
||||
u32::try_from((OffsetDateTime::now_utc() - OffsetDateTime::UNIX_EPOCH).whole_days()).unwrap()
|
||||
}
|
||||
|
||||
/// Generate the URL for accessing an image from instance storage
|
||||
pub fn image_url_local(
|
||||
tid: tiraya_api_model::TId<'_>,
|
||||
image_kind: ImageKind,
|
||||
date: PrimitiveDateTime,
|
||||
private: bool,
|
||||
) -> String {
|
||||
let ts = date.assume_utc().unix_timestamp();
|
||||
let tid_str = tid.to_string();
|
||||
|
||||
let mut url = Uri::default().with_path(&format!("/image/{image_kind}/{tid_str}"));
|
||||
url.query_pairs_mut().append_pair("ts", &ts.to_string());
|
||||
|
||||
let vdt = if private {
|
||||
Some(day_since_epoch())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
sign_image_url(&mut url, &tid_str, Some(image_kind), vdt);
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// Generate the URL for accessing an image from a remote server
|
||||
pub fn image_url_proxy(target_url: &str) -> String {
|
||||
let mut url = Uri::default().with_path("/image/proxy");
|
||||
url.query_pairs_mut().append_pair("url", target_url);
|
||||
sign_image_url(&mut url, target_url, None, Some(day_since_epoch()));
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("signature: {0}")]
|
||||
pub struct SignatureError(&'static str);
|
||||
|
||||
/// Validate an image URL with the given parameters
|
||||
pub fn validate_image_url(
|
||||
id: &str,
|
||||
image_kind: Option<ImageKind>,
|
||||
private: bool,
|
||||
sig: &str,
|
||||
vdt: Option<u32>,
|
||||
) -> Result<(), SignatureError> {
|
||||
_validate_image_url(id, image_kind, private, sig, vdt, day_since_epoch())
|
||||
}
|
||||
|
||||
fn _validate_image_url(
|
||||
id: &str,
|
||||
image_kind: Option<ImageKind>,
|
||||
private: bool,
|
||||
sig: &str,
|
||||
vdt: Option<u32>,
|
||||
today: u32,
|
||||
) -> Result<(), SignatureError> {
|
||||
// Private item: check validity date
|
||||
if private {
|
||||
if let Some(vdt) = vdt {
|
||||
if vdt != today && vdt + 1 != today {
|
||||
return Err(SignatureError("expired"));
|
||||
}
|
||||
} else {
|
||||
return Err(SignatureError("missing validity date"));
|
||||
}
|
||||
}
|
||||
|
||||
let sig_dec = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(sig)
|
||||
.map_err(|_| SignatureError("invalid base64"))?;
|
||||
let mac = image_mac(id, image_kind, vdt);
|
||||
|
||||
mac.verify_slice(&sig_dec)
|
||||
.map_err(|_| SignatureError("invalid"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rstest::rstest;
|
||||
use time::macros::datetime;
|
||||
use tiraya_api_model::{MusicService, TId};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn get_arg<'a>(uri: &'a Uri, key: &str) -> Option<Cow<'a, str>> {
|
||||
uri.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
TId::new("UCbpXC82cTLngSiZrysAaE-w", MusicService::YouTube),
|
||||
None,
|
||||
false,
|
||||
"/test?ts=1698842700&sig=OLtpnc8rMJ3Ap-LJ80bM5AaUICv-SyXsy5UtRdPIIgY"
|
||||
)]
|
||||
#[case(
|
||||
TId::new("UCbpXC82cTLngSiZrysAaE-w", MusicService::YouTube),
|
||||
None,
|
||||
true,
|
||||
"/test?ts=1698842700&vdt=19668&sig=BIm03j7nDWHEE4tDIuz-EP0B32bDikQLWfvTQeDoKOc"
|
||||
)]
|
||||
fn t_sign_image_url(
|
||||
#[case] tid: TId<'_>,
|
||||
#[case] image_kind: Option<ImageKind>,
|
||||
#[case] private: bool,
|
||||
#[case] expect: &str,
|
||||
) {
|
||||
let mut url = Uri::new("/test?ts=1698842700").unwrap();
|
||||
sign_image_url(
|
||||
&mut url,
|
||||
&tid.to_string(),
|
||||
image_kind,
|
||||
Some(19668).filter(|_| private),
|
||||
);
|
||||
assert_eq!(url.as_str(), expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_sign_proxy_url() {
|
||||
let mut url = Uri::new("/proxy").unwrap();
|
||||
let target_url = "https://example.com/pic.jpg";
|
||||
sign_image_url(&mut url, target_url, None, Some(19668));
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"/proxy?vdt=19668&sig=LzsEOCxConsJz6QrHCkWx7bb9_3QG0O2OlGSc3XBYHU"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
TId::new("UCbpXC82cTLngSiZrysAaE-w", MusicService::YouTube),
|
||||
ImageKind::Artist,
|
||||
false,
|
||||
"/image/artist/yt:UCbpXC82cTLngSiZrysAaE-w?ts=1698842700&"
|
||||
)]
|
||||
#[case(
|
||||
TId::new("dS38sDj39CJI0kvlrpD0id", MusicService::Tiraya),
|
||||
ImageKind::Playlist,
|
||||
true,
|
||||
"/image/playlist/ty:dS38sDj39CJI0kvlrpD0id?ts=1698842700&"
|
||||
)]
|
||||
fn t_img_url_local(
|
||||
#[case] tid: TId<'_>,
|
||||
#[case] image_kind: ImageKind,
|
||||
#[case] private: bool,
|
||||
#[case] expect: &str,
|
||||
) {
|
||||
let tid_str = tid.to_string();
|
||||
let date = datetime!(2023-11-01 12:45);
|
||||
let res = image_url_local(tid.as_ref(), image_kind, date, private);
|
||||
assert!(res.starts_with(expect), "got: {res}");
|
||||
|
||||
let parsed = Uri::new(&res).unwrap();
|
||||
let sig = get_arg(&parsed, "sig").expect("sig");
|
||||
|
||||
let vdt = get_arg(&parsed, "vdt").map(|s| s.parse().unwrap());
|
||||
assert_eq!(sig, image_sig(&tid_str, Some(image_kind), vdt));
|
||||
|
||||
validate_image_url(&tid_str, Some(image_kind), private, &sig, vdt).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_img_url_proxy() {
|
||||
let target_url = "https://example.com/pic.jpg";
|
||||
let res = image_url_proxy(target_url);
|
||||
assert!(
|
||||
res.starts_with("/image/proxy?url=https%3A%2F%2Fexample.com%2Fpic.jpg&"),
|
||||
"got: {res}"
|
||||
);
|
||||
|
||||
let parsed = Uri::new(&res).unwrap();
|
||||
let sig = get_arg(&parsed, "sig").expect("sig");
|
||||
let vdt = get_arg(&parsed, "vdt").map(|s| s.parse().unwrap());
|
||||
validate_image_url(target_url, None, true, &sig, vdt).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ Config(
|
|||
rust_log: "info",
|
||||
root_dir: None,
|
||||
db_concurrency: 8,
|
||||
secret: "INSECURE_ChG7e88XKZorOWAOzVLZgJO+OgAMxTl5Z4ovAefDaawfjthx",
|
||||
),
|
||||
extractor: ConfigExtractor(
|
||||
artist_stale_h: 24,
|
||||
|
|
@ -37,4 +38,9 @@ Config(
|
|||
market: "US",
|
||||
),
|
||||
),
|
||||
storage: ConfigStorage(
|
||||
image_dir: "[path]",
|
||||
music_dir: "[path]",
|
||||
tmp_dir: "[path]",
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ server_address = "0.0.0.0:8080"
|
|||
# root_dir = ""
|
||||
# Maximum number of concurrent requests per db operation
|
||||
db_concurrency = 8
|
||||
# Secret string (used to derive HMAC key)
|
||||
# Generate using `openssl rand -base64 36` or another secure password generator
|
||||
secret = "INSECURE_ChG7e88XKZorOWAOzVLZgJO+OgAMxTl5Z4ovAefDaawfjthx"
|
||||
|
||||
# Music data extractor settings
|
||||
[extractor]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue