Compare commits

...

5 commits

Author SHA1 Message Date
fdf3fa5904 feat: add image proxy 2023-11-08 03:20:38 +01:00
0ef9dad320 feat: add image URL signatures 2023-11-08 02:16:59 +01:00
64ab43bf4f feat: add image endpoint 2023-11-07 21:34:59 +01:00
f14d29c6f4 feat: add tiraya-proxy crate 2023-10-13 22:28:17 +02:00
ee9afa4d1e fix: image_date triggers 2023-10-13 19:21:41 +02:00
67 changed files with 3191 additions and 563 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}");

View 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?)
}

View file

@ -1,5 +1,6 @@
pub mod album;
pub mod artist;
pub mod image;
pub mod playlist;
pub mod track;
pub mod user;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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

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

View 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);
}
}
}
*/
}

View 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));
}

View file

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

View file

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

View file

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

View 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();
}
}

View file

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

View file

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