Compare commits

...

4 commits

Author SHA1 Message Date
7fe08e37ec feat: add get_playlist_meta function to extractor
feat: add unavailable error
fix: remove unwraps in extractor
2023-10-08 22:27:29 +02:00
585faffd32 fix: only ISRC-match YTM tracks 2023-10-08 00:46:24 +02:00
a451a5dbe9 feat: add storage of match data 2023-10-08 00:09:35 +02:00
9e4a5de891 refactor id model, fix tests 2023-10-07 20:23:32 +02:00
25 changed files with 995 additions and 633 deletions

1
Cargo.lock generated
View file

@ -2887,6 +2887,7 @@ dependencies = [
"path_macro", "path_macro",
"serde", "serde",
"smart-default", "smart-default",
"time",
"toml", "toml",
] ]

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "insert into track_aliases (src_id, service, track_id) values ($1, $2, $3)\non conflict (src_id, service) do update set track_id=excluded.track_id", "query": "insert into track_aliases (src_id, service, track_id, match_data) values ($1, $2, $3, $4)\non conflict (src_id, service) do update set\ntrack_id = coalesce(excluded.track_id, track_aliases.track_id),\nmatch_data = coalesce(excluded.match_data, track_aliases.match_data)",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -19,10 +19,11 @@
} }
} }
}, },
"Int4" "Int4",
"Jsonb"
] ]
}, },
"nullable": [] "nullable": []
}, },
"hash": "54911be75e68b9ddda44352f492fb7f500e86ef25c12a6840ea9e61b219f0d7c" "hash": "2431c70614cb2e2f51a6e5dcf1798b3346c088eb8aab95a658e6455476347f3c"
} }

View file

@ -203,7 +203,8 @@ ADD CONSTRAINT artist_aliases_artists_fk FOREIGN KEY (artist_id) REFERENCES arti
CREATE TABLE track_aliases ( CREATE TABLE track_aliases (
src_id text NOT NULL, src_id text NOT NULL,
service music_service NOT NULL, service music_service NOT NULL,
track_id integer NOT NULL, track_id integer,
match_data jsonb,
CONSTRAINT track_aliases_pk PRIMARY KEY (src_id, service) CONSTRAINT track_aliases_pk PRIMARY KEY (src_id, service)
); );
COMMENT ON TABLE track_aliases IS E'Track IDs from secondary sources'; COMMENT ON TABLE track_aliases IS E'Track IDs from secondary sources';

View file

@ -6,10 +6,10 @@ use tiraya_utils::config::CONFIG;
use super::{ use super::{
artist::{ArtistId, ArtistJsonb}, artist::{ArtistId, ArtistJsonb},
map_artists, AlbumType, DatePrecision, Id, MusicService, SrcId, SrcIdOwned, TrackSlim, AlbumType, DatePrecision, Id, IdLike, MusicService, SrcId, SrcIdOwned, TrackSlim, TrackSlimRow,
TrackSlimRow,
}; };
use crate::error::{DatabaseError, OptionalRes}; use crate::error::{DatabaseError, OptionalRes};
use crate::util;
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow)]
pub struct Album { pub struct Album {
@ -150,11 +150,11 @@ impl Album {
SrcId(&self.src_id, self.service) SrcId(&self.src_id, self.service)
} }
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
sqlx::query_as!( sqlx::query_as!(
AlbumRow, AlbumRow,
@ -194,7 +194,7 @@ group by b.id"#,
} }
} }
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned())) .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
.map(Album::from) .map(Album::from)
} }
@ -239,11 +239,11 @@ group by b.id"#,
Ok(res.map(|res| SrcIdOwned(res.src_id, res.service))) Ok(res.map(|res| SrcIdOwned(res.src_id, res.service)))
} }
pub async fn resolve_id<'a, E>(id: Id<'_>, exec: E) -> Result<i32, DatabaseError> pub async fn resolve_id<'a, E>(id: impl IdLike, exec: E) -> Result<i32, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => Ok(id), Id::Db(id) => Ok(id),
Id::Src(src_id, srv) => { Id::Src(src_id, srv) => {
let srcid = SrcId(src_id, srv); let srcid = SrcId(src_id, srv);
@ -402,7 +402,7 @@ returning id"#,
} }
impl AlbumUpdate<'_> { impl AlbumUpdate<'_> {
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError> pub async fn update<'a, E>(&self, id: impl IdLike, exec: E) -> Result<(), DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
@ -497,7 +497,7 @@ impl AlbumUpdate<'_> {
if n > 0 { if n > 0 {
query.push(" where "); query.push(" where ");
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
query.push("id="); query.push("id=");
query.push_bind(id); query.push_bind(id);
@ -517,11 +517,11 @@ impl AlbumUpdate<'_> {
} }
impl AlbumSlim { impl AlbumSlim {
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
let row: AlbumSlimRow = match id { let row: AlbumSlimRow = match id.id() {
Id::Db(id) => { Id::Db(id) => {
sqlx::query_as!( sqlx::query_as!(
AlbumSlimRow, AlbumSlimRow,
@ -559,7 +559,7 @@ group by b.id"#,
} }
} }
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))?; .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))?;
Ok(row.into()) Ok(row.into())
} }
@ -583,7 +583,7 @@ impl From<AlbumRow> for Album {
src_id: value.src_id, src_id: value.src_id,
service: value.service, service: value.service,
name: value.name, name: value.name,
artists: map_artists(value.artists, value.ul_artists), artists: util::map_artists(value.artists, value.ul_artists),
release_date: value.release_date, release_date: value.release_date,
release_date_precision: value.release_date_precision, release_date_precision: value.release_date_precision,
album_type: value.album_type, album_type: value.album_type,
@ -604,7 +604,7 @@ impl From<AlbumSlimRow> for AlbumSlim {
src_id: value.src_id, src_id: value.src_id,
service: value.service, service: value.service,
name: value.name, name: value.name,
artists: map_artists(value.artists, value.ul_artists), artists: util::map_artists(value.artists, value.ul_artists),
release_date: value.release_date, release_date: value.release_date,
album_type: value.album_type, album_type: value.album_type,
image_url: value.image_url, image_url: value.image_url,

View file

@ -5,8 +5,8 @@ use time::PrimitiveDateTime;
use tiraya_utils::config::CONFIG; use tiraya_utils::config::CONFIG;
use super::{ use super::{
album::AlbumSlimRow, AlbumSlim, Id, IdOwned, InternalId, MusicService, PlaylistSlim, SrcId, album::AlbumSlimRow, AlbumSlim, Id, IdLike, IdOwned, InternalId, MusicService, PlaylistSlim,
SrcIdOwned, SyncData, TrackSlim, TrackSlimRow, TrackTiny, SrcId, SrcIdOwned, SyncData, TrackSlim, TrackSlimRow, TrackTiny,
}; };
use crate::error::{DatabaseError, OptionalRes}; use crate::error::{DatabaseError, OptionalRes};
@ -88,7 +88,7 @@ pub struct ArtistJsonb {
} }
/// Artist identifier /// Artist identifier
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ArtistId { pub struct ArtistId {
pub id: Option<SrcIdOwned>, pub id: Option<SrcIdOwned>,
pub name: String, pub name: String,
@ -103,11 +103,11 @@ impl Artist {
SrcId(&self.src_id, self.service) SrcId(&self.src_id, self.service)
} }
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
@ -161,7 +161,7 @@ where aa.src_id=$1 and aa.service=$2"#,
} }
} }
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned())) .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
} }
async fn _get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError> async fn _get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
@ -258,10 +258,10 @@ where aa.src_id=$1 and aa.service=$2"#,
} }
pub async fn resolve_id( pub async fn resolve_id(
id: Id<'_>, id: impl IdLike,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<i32, DatabaseError> { ) -> Result<i32, DatabaseError> {
match id { match id.id() {
Id::Db(id) => Ok(id), Id::Db(id) => Ok(id),
Id::Src(src_id, srv) => { Id::Src(src_id, srv) => {
let srcid = SrcId(src_id, srv); let srcid = SrcId(src_id, srv);
@ -273,10 +273,10 @@ where aa.src_id=$1 and aa.service=$2"#,
} }
async fn resolve_both_ids( async fn resolve_both_ids(
id: Id<'_>, id: impl IdLike,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(i32, SrcIdOwned), DatabaseError> { ) -> Result<(i32, SrcIdOwned), DatabaseError> {
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
let srcid = Self::get_src_id(id, &mut **tx) let srcid = Self::get_src_id(id, &mut **tx)
.await? .await?
@ -310,8 +310,8 @@ on conflict (src_id, service) do update set artist_id=excluded.artist_id"#,
} }
pub async fn merge( pub async fn merge(
id_prim: Id<'_>, id_prim: impl IdLike,
id_sec: Id<'_>, id_sec: impl IdLike,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
let id_prim = Self::resolve_id(id_prim, tx).await?; let id_prim = Self::resolve_id(id_prim, tx).await?;
@ -687,7 +687,7 @@ returning id"#,
} }
impl ArtistUpdate<'_> { impl ArtistUpdate<'_> {
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError> pub async fn update<'a, E>(&self, id: impl IdLike, exec: E) -> Result<(), DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
@ -782,7 +782,7 @@ impl ArtistUpdate<'_> {
if n > 0 { if n > 0 {
query.push(" where "); query.push(" where ");
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
query.push("id="); query.push("id=");
query.push_bind(id); query.push_bind(id);
@ -802,11 +802,11 @@ impl ArtistUpdate<'_> {
} }
impl ArtistSlim { impl ArtistSlim {
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => sqlx::query_as!( Id::Db(id) => sqlx::query_as!(
Self, Self,
r#"select src_id, service as "service: _", name, image_url, image_hash from artists where id=$1"#, r#"select src_id, service as "service: _", name, image_url, image_hash from artists where id=$1"#,
@ -824,7 +824,7 @@ where src_id=$1 and service=$2"#,
.fetch_optional(exec) .fetch_optional(exec)
.await, .await,
}? }?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned())) .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
} }
pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError> pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError>

View file

@ -107,14 +107,14 @@ pub enum SyncData {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SyncError { pub enum SyncError {
NotFound, Unavailable,
Other, Other,
} }
impl SyncError { impl SyncError {
pub fn retry(&self, age: Duration) -> bool { pub fn retry(&self, age: Duration) -> bool {
match self { match self {
SyncError::NotFound => age > Duration::days(7), SyncError::Unavailable => age > Duration::days(7),
SyncError::Other => true, SyncError::Other => true,
} }
} }
@ -138,12 +138,12 @@ mod tests {
); );
let err2 = SyncData::Error { let err2 = SyncData::Error {
typ: SyncError::NotFound, typ: SyncError::Unavailable,
msg: "gone".to_string(), msg: "gone".to_string(),
}; };
assert_eq!( assert_eq!(
serde_json::to_string(&err2).unwrap(), serde_json::to_string(&err2).unwrap(),
r#"{"error":{"typ":"not_found","msg":"gone"}}"# r#"{"error":{"typ":"unavailable","msg":"gone"}}"#
); );
let date = SyncData::Date(datetime!(2023-05-12 18:00 UTC)); let date = SyncData::Date(datetime!(2023-05-12 18:00 UTC));

276
crates/db/src/models/id.rs Normal file
View file

@ -0,0 +1,276 @@
use std::{fmt::Write, str::FromStr};
use serde::{de::Visitor, Deserialize, Serialize};
use sqlx::{FromRow, Row};
use crate::error::DatabaseError;
use super::MusicService;
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum Id<'a> {
Db(i32),
Src(&'a str, MusicService),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum IdOwned {
Db(i32),
Src(String, MusicService),
}
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct SrcId<'a>(pub &'a str, pub MusicService);
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SrcIdOwned(pub String, pub MusicService);
#[derive(Debug, Serialize, FromRow)]
pub struct InternalId {
pub id: i32,
pub src_id: String,
pub service: MusicService,
}
impl IdOwned {
pub fn as_id(&self) -> Id<'_> {
match self {
IdOwned::Db(id) => Id::Db(*id),
IdOwned::Src(src_id, srv) => Id::Src(src_id.as_str(), *srv),
}
}
}
impl SrcId<'_> {
pub fn to_owned(&self) -> SrcIdOwned {
SrcIdOwned(self.0.to_owned(), self.1)
}
}
impl SrcIdOwned {
pub fn as_srcid(&self) -> SrcId<'_> {
SrcId(&self.0, self.1)
}
}
impl std::fmt::Display for Id<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Id::Db(id) => id.fmt(f),
Id::Src(src_id, srv) => {
srv.fmt(f)?;
f.write_char(':')?;
src_id.fmt(f)
}
}
}
}
impl std::fmt::Display for IdOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IdOwned::Db(id) => id.fmt(f),
IdOwned::Src(src_id, srv) => {
srv.fmt(f)?;
f.write_char(':')?;
src_id.fmt(f)
}
}
}
}
impl std::fmt::Display for SrcId<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.1.fmt(f)?;
f.write_char(':')?;
self.0.fmt(f)
}
}
impl std::fmt::Display for SrcIdOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.1.fmt(f)?;
f.write_char(':')?;
self.0.fmt(f)
}
}
impl std::fmt::Debug for SrcId<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_char('"')?;
std::fmt::Display::fmt(&self, f)?;
f.write_char('"')
}
}
impl std::fmt::Debug for SrcIdOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_char('"')?;
std::fmt::Display::fmt(&self, f)?;
f.write_char('"')
}
}
impl Serialize for SrcIdOwned {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.to_string().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SrcIdOwned {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SrcIdVisitor;
const EXPECT: &str = "a Tiraya source id";
impl<'de> Visitor<'de> for SrcIdVisitor {
type Value = SrcIdOwned;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(EXPECT)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
parse_src_id(v).ok_or(E::invalid_value(serde::de::Unexpected::Str(v), &EXPECT))
}
}
deserializer.deserialize_str(SrcIdVisitor)
}
}
impl sqlx::FromRow<'_, sqlx::postgres::PgRow> for SrcIdOwned {
fn from_row(row: &sqlx::postgres::PgRow) -> Result<Self, sqlx::Error> {
Ok(Self(row.try_get("src_id")?, row.try_get("service")?))
}
}
impl<'a> PartialEq<IdOwned> for Id<'a> {
fn eq(&self, other: &IdOwned) -> bool {
match self {
Id::Db(id) => match other {
IdOwned::Db(o_id) => id == o_id,
IdOwned::Src(_, _) => false,
},
Id::Src(src_id, srv) => match other {
IdOwned::Db(_) => false,
IdOwned::Src(o_src_id, o_srv) => src_id == o_src_id && srv == o_srv,
},
}
}
}
impl<'a> PartialEq<Id<'a>> for IdOwned {
fn eq(&self, other: &Id<'a>) -> bool {
other.eq(self)
}
}
impl<'a> PartialEq<SrcIdOwned> for SrcId<'a> {
fn eq(&self, other: &SrcIdOwned) -> bool {
self.0 == other.0 && self.1 == other.1
}
}
impl<'a> PartialEq<SrcId<'a>> for SrcIdOwned {
fn eq(&self, other: &SrcId<'a>) -> bool {
other.eq(self)
}
}
impl From<SrcIdOwned> for IdOwned {
fn from(value: SrcIdOwned) -> Self {
Self::Src(value.0, value.1)
}
}
impl From<InternalId> for IdOwned {
fn from(value: InternalId) -> Self {
Self::Db(value.id)
}
}
impl From<InternalId> for SrcIdOwned {
fn from(value: InternalId) -> Self {
SrcIdOwned(value.src_id, value.service)
}
}
impl FromStr for SrcIdOwned {
type Err = DatabaseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_src_id(s).ok_or(DatabaseError::Other("invalid srcid".into()))
}
}
fn parse_src_id(id: &str) -> Option<SrcIdOwned> {
let (srv_str, src_id) = id.split_once(':')?;
let service = serde_plain::from_str::<MusicService>(srv_str).ok()?;
Some(SrcIdOwned(src_id.to_owned(), service))
}
pub trait IdLike {
fn id(&self) -> Id<'_>;
fn to_owned_id(&self) -> IdOwned;
}
impl IdLike for Id<'_> {
fn id(&self) -> Id<'_> {
*self
}
fn to_owned_id(&self) -> IdOwned {
match self {
Id::Db(id) => IdOwned::Db(*id),
Id::Src(src_id, srv) => IdOwned::Src(src_id.to_string(), *srv),
}
}
}
impl IdLike for IdOwned {
fn id(&self) -> Id<'_> {
self.as_id()
}
fn to_owned_id(&self) -> IdOwned {
self.clone()
}
}
impl IdLike for SrcId<'_> {
fn id(&self) -> Id<'_> {
Id::Src(self.0, self.1)
}
fn to_owned_id(&self) -> IdOwned {
IdOwned::Src(self.0.to_owned(), self.1)
}
}
impl IdLike for SrcIdOwned {
fn id(&self) -> Id<'_> {
Id::Src(&self.0, self.1)
}
fn to_owned_id(&self) -> IdOwned {
IdOwned::Src(self.0.to_owned(), self.1)
}
}
impl IdLike for i32 {
fn id(&self) -> Id<'_> {
Id::Db(*self)
}
fn to_owned_id(&self) -> IdOwned {
IdOwned::Db(*self)
}
}

View file

@ -1,12 +1,8 @@
use std::{fmt::Write, str::FromStr};
use serde::{de::Visitor, Deserialize, Serialize};
use sqlx::{types::Json, FromRow, Row};
mod album; mod album;
mod artist; mod artist;
mod change_operation; mod change_operation;
mod enums; mod enums;
mod id;
mod playlist; mod playlist;
mod playlist_change; mod playlist_change;
mod track; mod track;
@ -18,268 +14,17 @@ pub use enums::{
AlbumType, DatePrecision, EntityType, MusicService, PlaylistImgType, PlaylistType, SyncData, AlbumType, DatePrecision, EntityType, MusicService, PlaylistImgType, PlaylistType, SyncData,
SyncError, UserType, SyncError, UserType,
}; };
pub use id::{Id, IdLike, IdOwned, InternalId, SrcId, SrcIdOwned};
pub use playlist::{ pub use playlist::{
Playlist, PlaylistEntry, PlaylistEntryTrack, PlaylistNew, PlaylistSlim, PlaylistUpdate, Playlist, PlaylistEntry, PlaylistEntryTrack, PlaylistNew, PlaylistSlim, PlaylistUpdate,
}; };
pub use track::{Track, TrackNew, TrackSlim, TrackTiny, TrackUpdate}; pub use track::{
Track, TrackMatchAlbum, TrackMatchData, TrackMatchMeta, TrackNew, TrackSlim, TrackTiny,
TrackUpdate,
};
pub use user::{User, UserItem, UserNew, UserUpdate}; pub use user::{User, UserItem, UserNew, UserUpdate};
pub(crate) use artist::ArtistJsonb;
pub(crate) use change_operation::ChangeOperation; pub(crate) use change_operation::ChangeOperation;
pub(crate) use playlist_change::{PlaylistChange, PlaylistChangeAdd, PlaylistChangeNew}; pub(crate) use playlist_change::{PlaylistChange, PlaylistChangeAdd, PlaylistChangeNew};
pub(crate) use track::TrackSlimRow; pub(crate) use track::TrackSlimRow;
use artist::ArtistJsonb;
use crate::error::DatabaseError;
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum Id<'a> {
Db(i32),
Src(&'a str, MusicService),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum IdOwned {
Db(i32),
Src(String, MusicService),
}
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct SrcId<'a>(pub &'a str, pub MusicService);
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SrcIdOwned(pub String, pub MusicService);
#[derive(Debug, Serialize, FromRow)]
pub struct InternalId {
pub id: i32,
pub src_id: String,
pub service: MusicService,
}
impl Id<'_> {
pub fn to_owned(&self) -> IdOwned {
match self {
Id::Db(id) => IdOwned::Db(*id),
Id::Src(src_id, srv) => IdOwned::Src(src_id.to_string(), *srv),
}
}
}
impl IdOwned {
pub fn as_id(&self) -> Id<'_> {
match self {
IdOwned::Db(id) => Id::Db(*id),
IdOwned::Src(src_id, srv) => Id::Src(src_id.as_str(), *srv),
}
}
}
impl SrcId<'_> {
pub fn to_owned(&self) -> SrcIdOwned {
SrcIdOwned(self.0.to_owned(), self.1)
}
pub fn to_owned_id(&self) -> IdOwned {
IdOwned::Src(self.0.to_owned(), self.1)
}
pub fn id(&self) -> Id {
Id::Src(self.0, self.1)
}
}
impl SrcIdOwned {
pub fn as_srcid(&self) -> SrcId<'_> {
SrcId(&self.0, self.1)
}
pub fn id(&self) -> Id {
Id::Src(&self.0, self.1)
}
}
impl std::fmt::Display for Id<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Id::Db(id) => id.fmt(f),
Id::Src(src_id, srv) => {
srv.fmt(f)?;
f.write_char(':')?;
src_id.fmt(f)
}
}
}
}
impl std::fmt::Display for IdOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IdOwned::Db(id) => id.fmt(f),
IdOwned::Src(src_id, srv) => {
srv.fmt(f)?;
f.write_char(':')?;
src_id.fmt(f)
}
}
}
}
impl std::fmt::Display for SrcId<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.1.fmt(f)?;
f.write_char(':')?;
self.0.fmt(f)
}
}
impl std::fmt::Display for SrcIdOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.1.fmt(f)?;
f.write_char(':')?;
self.0.fmt(f)
}
}
impl std::fmt::Debug for SrcId<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_char('"')?;
std::fmt::Display::fmt(&self, f)?;
f.write_char('"')
}
}
impl std::fmt::Debug for SrcIdOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_char('"')?;
std::fmt::Display::fmt(&self, f)?;
f.write_char('"')
}
}
impl Serialize for SrcIdOwned {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.to_string().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SrcIdOwned {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SrcIdVisitor;
const EXPECT: &str = "a Tiraya source id";
impl<'de> Visitor<'de> for SrcIdVisitor {
type Value = SrcIdOwned;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(EXPECT)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
parse_src_id(v).ok_or(E::invalid_value(serde::de::Unexpected::Str(v), &EXPECT))
}
}
deserializer.deserialize_str(SrcIdVisitor)
}
}
impl sqlx::FromRow<'_, sqlx::postgres::PgRow> for SrcIdOwned {
fn from_row(row: &sqlx::postgres::PgRow) -> Result<Self, sqlx::Error> {
Ok(Self(row.try_get("src_id")?, row.try_get("service")?))
}
}
impl<'a> PartialEq<IdOwned> for Id<'a> {
fn eq(&self, other: &IdOwned) -> bool {
match self {
Id::Db(id) => match other {
IdOwned::Db(o_id) => id == o_id,
IdOwned::Src(_, _) => false,
},
Id::Src(src_id, srv) => match other {
IdOwned::Db(_) => false,
IdOwned::Src(o_src_id, o_srv) => src_id == o_src_id && srv == o_srv,
},
}
}
}
impl<'a> PartialEq<Id<'a>> for IdOwned {
fn eq(&self, other: &Id<'a>) -> bool {
other.eq(self)
}
}
impl<'a> PartialEq<SrcIdOwned> for SrcId<'a> {
fn eq(&self, other: &SrcIdOwned) -> bool {
self.0 == other.0 && self.1 == other.1
}
}
impl<'a> PartialEq<SrcId<'a>> for SrcIdOwned {
fn eq(&self, other: &SrcId<'a>) -> bool {
other.eq(self)
}
}
impl From<SrcIdOwned> for IdOwned {
fn from(value: SrcIdOwned) -> Self {
Self::Src(value.0, value.1)
}
}
impl From<InternalId> for IdOwned {
fn from(value: InternalId) -> Self {
Self::Db(value.id)
}
}
impl From<InternalId> for SrcIdOwned {
fn from(value: InternalId) -> Self {
SrcIdOwned(value.src_id, value.service)
}
}
pub(crate) fn map_artists(
jsonb: Option<Json<Vec<ArtistJsonb>>>,
ul: Option<Vec<String>>,
) -> Vec<ArtistId> {
let mut artists = Vec::new();
if let Some(jsonb) = jsonb {
for a in jsonb.0 {
artists.push(ArtistId {
id: Some(SrcIdOwned(a.src_id, a.service)),
name: a.name,
});
}
}
if let Some(ul) = ul {
for a in ul {
artists.push(ArtistId { id: None, name: a });
}
}
artists
}
fn parse_src_id(id: &str) -> Option<SrcIdOwned> {
let (srv_str, src_id) = id.split_once(':')?;
let service = serde_plain::from_str::<MusicService>(srv_str).ok()?;
Some(SrcIdOwned(src_id.to_owned(), service))
}
impl FromStr for SrcIdOwned {
type Err = DatabaseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_src_id(s).ok_or(DatabaseError::Other("invalid srcid".into()))
}
}

View file

@ -6,8 +6,8 @@ use tiraya_utils::config::CONFIG;
use uuid::Uuid; use uuid::Uuid;
use super::{ use super::{
playlist_change::PlaylistChangeAdd, Id, MusicService, PlaylistChange, PlaylistChangeNew, playlist_change::PlaylistChangeAdd, Id, IdLike, MusicService, PlaylistChange,
PlaylistImgType, PlaylistType, SrcId, SrcIdOwned, SyncData, TrackSlim, PlaylistChangeNew, PlaylistImgType, PlaylistType, SrcId, SrcIdOwned, SyncData, TrackSlim,
}; };
use crate::{ use crate::{
error::{DatabaseError, OptionalRes}, error::{DatabaseError, OptionalRes},
@ -106,11 +106,11 @@ impl Playlist {
SrcId(&self.src_id, self.service) SrcId(&self.src_id, self.service)
} }
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
@ -138,7 +138,7 @@ from playlists where src_id=$1 and service=$2"#,
} }
} }
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned())) .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
} }
pub async fn get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError> pub async fn get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
@ -168,11 +168,11 @@ from playlists where src_id=$1 and service=$2"#,
Ok(res.map(|res| SrcIdOwned(res.src_id, res.service))) Ok(res.map(|res| SrcIdOwned(res.src_id, res.service)))
} }
pub async fn resolve_id<'a, E>(id: Id<'_>, exec: E) -> Result<i32, DatabaseError> pub async fn resolve_id<'a, E>(id: impl IdLike, exec: E) -> Result<i32, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => Ok(id), Id::Db(id) => Ok(id),
Id::Src(src_id, srv) => { Id::Src(src_id, srv) => {
let srcid = SrcId(src_id, srv); let srcid = SrcId(src_id, srv);
@ -560,7 +560,7 @@ returning id"#,
} }
impl PlaylistUpdate<'_> { impl PlaylistUpdate<'_> {
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError> pub async fn update<'a, E>(&self, id: impl IdLike, exec: E) -> Result<(), DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
@ -615,7 +615,7 @@ impl PlaylistUpdate<'_> {
if n > 0 { if n > 0 {
query.push(" where "); query.push(" where ");
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
query.push("id="); query.push("id=");
query.push_bind(id); query.push_bind(id);
@ -639,11 +639,11 @@ impl PlaylistSlim {
SrcId(&self.src_id, self.service) SrcId(&self.src_id, self.service)
} }
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => sqlx::query_as!( Id::Db(id) => sqlx::query_as!(
PlaylistSlim, PlaylistSlim,
r#"select p.src_id, p.service as "service: _", p.name, p.image_url, p.image_hash, r#"select p.src_id, p.service as "service: _", p.name, p.image_url, p.image_hash,
@ -666,7 +666,7 @@ where p.src_id=$1 and p.service=$2"#,
.fetch_optional(exec) .fetch_optional(exec)
.await, .await,
}? }?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned())) .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
} }
pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError> pub async fn get_vec<'a, E>(ids: &[i32], exec: E) -> Result<Vec<Option<Self>>, DatabaseError>

View file

@ -1,5 +1,5 @@
use futures::{stream, StreamExt, TryStreamExt}; use futures::{stream, StreamExt, TryStreamExt};
use serde::Serialize; use serde::{Deserialize, Serialize};
use sqlx::{types::Json, FromRow, QueryBuilder}; use sqlx::{types::Json, FromRow, QueryBuilder};
use time::{Date, PrimitiveDateTime}; use time::{Date, PrimitiveDateTime};
use tiraya_utils::config::CONFIG; use tiraya_utils::config::CONFIG;
@ -7,9 +7,10 @@ use tiraya_utils::config::CONFIG;
use super::{ use super::{
album::AlbumId, album::AlbumId,
artist::{ArtistId, ArtistJsonb}, artist::{ArtistId, ArtistJsonb},
map_artists, AlbumType, Id, MusicService, SrcId, SrcIdOwned, AlbumType, Id, IdLike, MusicService, SrcId, SrcIdOwned,
}; };
use crate::error::{DatabaseError, OptionalRes}; use crate::error::{DatabaseError, OptionalRes};
use crate::util;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct Track { pub struct Track {
@ -133,6 +134,35 @@ pub struct TrackTiny {
pub duration: Option<i32>, pub duration: Option<i32>,
} }
/// Information stored with an automatically matched track
#[derive(Debug, Serialize, Deserialize)]
pub struct TrackMatchData {
pub matchmaker_version: u16,
pub matchmaker_cfg: String,
pub score: f32,
pub original: TrackMatchMeta,
#[serde(skip_serializing_if = "Option::is_none")]
pub closest: Option<TrackMatchMeta>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TrackMatchMeta {
pub id: SrcIdOwned,
pub name: String,
pub artists: Vec<ArtistId>,
pub album: TrackMatchAlbum,
#[serde(skip_serializing_if = "Option::is_none")]
pub isrc: Option<String>,
pub duration: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TrackMatchAlbum {
pub id: Option<SrcIdOwned>,
pub name: String,
pub image_url: Option<String>,
}
impl Track { impl Track {
pub fn id(&self) -> Id { pub fn id(&self) -> Id {
Id::Db(self.id) Id::Db(self.id)
@ -142,11 +172,11 @@ impl Track {
SrcId(&self.src_id, self.service) SrcId(&self.src_id, self.service)
} }
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
sqlx::query_as!( sqlx::query_as!(
TrackRow, TrackRow,
@ -211,7 +241,7 @@ group by t.id"#,
} }
} }
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned())) .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
.map(Track::from) .map(Track::from)
} }
@ -312,10 +342,10 @@ where ta.src_id=$1 and ta.service=$2"#,
} }
pub async fn resolve_id( pub async fn resolve_id(
id: Id<'_>, id: impl IdLike,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<i32, DatabaseError> { ) -> Result<i32, DatabaseError> {
match id { match id.id() {
Id::Db(id) => Ok(id), Id::Db(id) => Ok(id),
Id::Src(src_id, srv) => { Id::Src(src_id, srv) => {
let srcid = SrcId(src_id, srv); let srcid = SrcId(src_id, srv);
@ -326,16 +356,29 @@ where ta.src_id=$1 and ta.service=$2"#,
} }
} }
pub async fn add_alias<'a, E>(id: i32, alias: SrcId<'_>, exec: E) -> Result<(), DatabaseError> pub async fn add_alias<'a, E>(
alias: SrcId<'_>,
track_id: Option<i32>,
match_data: Option<&TrackMatchData>,
exec: E,
) -> Result<(), DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
let match_data_json = match match_data {
Some(match_data) => Some(serde_json::to_value(match_data)?),
None => None,
};
sqlx::query!( sqlx::query!(
r#"insert into track_aliases (src_id, service, track_id) values ($1, $2, $3) r#"insert into track_aliases (src_id, service, track_id, match_data) values ($1, $2, $3, $4)
on conflict (src_id, service) do update set track_id=excluded.track_id"#, on conflict (src_id, service) do update set
track_id = coalesce(excluded.track_id, track_aliases.track_id),
match_data = coalesce(excluded.match_data, track_aliases.match_data)"#,
alias.0, alias.0,
alias.1 as MusicService, alias.1 as MusicService,
id, track_id,
match_data_json,
) )
.execute(exec) .execute(exec)
.await?; .await?;
@ -384,7 +427,7 @@ returning id"#,
} }
impl TrackUpdate<'_> { impl TrackUpdate<'_> {
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError> pub async fn update<'a, E>(&self, id: impl IdLike, exec: E) -> Result<(), DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
@ -495,7 +538,7 @@ impl TrackUpdate<'_> {
if n > 0 { if n > 0 {
query.push(" where "); query.push(" where ");
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
query.push("id="); query.push("id=");
query.push_bind(id); query.push_bind(id);
@ -519,12 +562,12 @@ impl TrackSlim {
SrcId(&self.src_id, self.service) SrcId(&self.src_id, self.service)
} }
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
let row: TrackSlimRow = let row: TrackSlimRow =
match id { match id.id() {
Id::Db(id) => sqlx::query_as!( Id::Db(id) => sqlx::query_as!(
TrackSlimRow, TrackSlimRow,
r#"select t.src_id, t.service as "service: _", t.name, t.duration, t.album_pos, r#"select t.src_id, t.service as "service: _", t.name, t.duration, t.album_pos,
@ -593,7 +636,7 @@ group by (t.id, b.id)"#,
} }
} }
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))?; .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))?;
Ok(row.into()) Ok(row.into())
} }
@ -618,7 +661,7 @@ impl From<TrackRow> for Track {
service: value.service, service: value.service,
name: value.name, name: value.name,
duration: value.duration, duration: value.duration,
artists: map_artists(value.artists, value.ul_artists), artists: util::map_artists(value.artists, value.ul_artists),
size: value.size, size: value.size,
loudness: value.loudness, loudness: value.loudness,
album_id: value.album_id, album_id: value.album_id,
@ -642,7 +685,7 @@ impl From<TrackSlimRow> for TrackSlim {
service: value.service, service: value.service,
name: value.name, name: value.name,
duration: value.duration, duration: value.duration,
artists: map_artists(value.artists, value.ul_artists), artists: util::map_artists(value.artists, value.ul_artists),
album: AlbumId { album: AlbumId {
src_id: value.album_src_id, src_id: value.album_src_id,
service: value.album_service, service: value.album_service,
@ -778,7 +821,7 @@ mod tests {
let src_id = SrcId("1t2qYCAjUAoGfeFeoBlK51", MusicService::Spotify); let src_id = SrcId("1t2qYCAjUAoGfeFeoBlK51", MusicService::Spotify);
Track::add_alias(ids::TRACK_ID_BMAMBA, src_id, &pool) Track::add_alias(src_id, Some(ids::TRACK_ID_BMAMBA), None, &pool)
.await .await
.unwrap(); .unwrap();
let track_id = Track::get_id(src_id, &pool).await.unwrap().unwrap(); let track_id = Track::get_id(src_id, &pool).await.unwrap().unwrap();

View file

@ -4,7 +4,7 @@ use time::PrimitiveDateTime;
use crate::error::DatabaseError; use crate::error::DatabaseError;
use super::{enums::UserType, Id, MusicService, PlaylistSlim, SrcId, SrcIdOwned, SyncData}; use super::{enums::UserType, Id, IdLike, MusicService, PlaylistSlim, SrcId, SrcIdOwned, SyncData};
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow)]
pub struct User { pub struct User {
@ -59,11 +59,11 @@ impl User {
SrcId(&self.src_id, self.service) SrcId(&self.src_id, self.service)
} }
pub async fn get<'a, E>(id: Id<'_>, exec: E) -> Result<Self, DatabaseError> pub async fn get<'a, E>(id: impl IdLike, exec: E) -> Result<Self, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
@ -91,7 +91,7 @@ from users where src_id=$1 and service=$2"#,
} }
} }
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::NotFound(id.to_owned())) .ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
} }
pub async fn get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError> pub async fn get_id<'a, E>(id: SrcId<'_>, exec: E) -> Result<Option<i32>, DatabaseError>
@ -121,11 +121,11 @@ from users where src_id=$1 and service=$2"#,
Ok(res.map(|res| SrcIdOwned(res.src_id, res.service))) Ok(res.map(|res| SrcIdOwned(res.src_id, res.service)))
} }
pub async fn resolve_id<'a, E>(id: Id<'_>, exec: E) -> Result<i32, DatabaseError> pub async fn resolve_id<'a, E>(id: impl IdLike, exec: E) -> Result<i32, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
match id { match id.id() {
Id::Db(id) => Ok(id), Id::Db(id) => Ok(id),
Id::Src(src_id, srv) => { Id::Src(src_id, srv) => {
let srcid = SrcId(src_id, srv); let srcid = SrcId(src_id, srv);
@ -270,7 +270,7 @@ returning id"#,
} }
impl UserUpdate<'_> { impl UserUpdate<'_> {
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError> pub async fn update<'a, E>(&self, id: impl IdLike, exec: E) -> Result<(), DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{ {
@ -301,7 +301,7 @@ impl UserUpdate<'_> {
if n > 0 { if n > 0 {
query.push(" where "); query.push(" where ");
match id { match id.id() {
Id::Db(id) => { Id::Db(id) => {
query.push("id="); query.push("id=");
query.push_bind(id); query.push_bind(id);

View file

@ -3,10 +3,13 @@ use std::hash::Hasher;
use hex_literal::hex; use hex_literal::hex;
use similar::algorithms::DiffHook; use similar::algorithms::DiffHook;
use siphasher::sip128::{Hasher128, SipHasher}; use siphasher::sip128::{Hasher128, SipHasher};
use sqlx::types::Json;
use time::{OffsetDateTime, PrimitiveDateTime}; use time::{OffsetDateTime, PrimitiveDateTime};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{ChangeOperation, PlaylistChangeAdd, PlaylistEntry}; use crate::models::{
ArtistId, ArtistJsonb, ChangeOperation, PlaylistChangeAdd, PlaylistEntry, SrcIdOwned,
};
pub fn primitive_now() -> PrimitiveDateTime { pub fn primitive_now() -> PrimitiveDateTime {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
@ -159,6 +162,27 @@ pub fn diff_playlist(
hook.changes hook.changes
} }
pub fn map_artists(
jsonb: Option<Json<Vec<ArtistJsonb>>>,
ul: Option<Vec<String>>,
) -> Vec<ArtistId> {
let mut artists = Vec::new();
if let Some(jsonb) = jsonb {
for a in jsonb.0 {
artists.push(ArtistId {
id: Some(SrcIdOwned(a.src_id, a.service)),
name: a.name,
});
}
}
if let Some(ul) = ul {
for a in ul {
artists.push(ArtistId { id: None, name: a });
}
}
artists
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use insta::assert_ron_snapshot; use insta::assert_ron_snapshot;

View file

@ -1,5 +1,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use http::StatusCode;
use tiraya_db::models::{EntityType, MusicService, SrcIdOwned}; use tiraya_db::models::{EntityType, MusicService, SrcIdOwned};
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
@ -15,8 +16,14 @@ pub enum ExtractorError {
id: SrcIdOwned, id: SrcIdOwned,
msg: Cow<'static, str>, msg: Cow<'static, str>,
}, },
#[error("could not find a match for track [{id}]")] #[error("{typ} [{id}] not available: {msg}")]
NoMatch { id: SrcIdOwned }, Unavailable {
typ: EntityType,
id: SrcIdOwned,
msg: Cow<'static, str>,
},
#[error("could not find a match for {typ} [{id}]")]
NoMatch { typ: EntityType, id: SrcIdOwned },
#[error("no id found")] #[error("no id found")]
NoId, NoId,
#[error("{0}")] #[error("{0}")]
@ -54,3 +61,50 @@ impl From<rspotify::ClientError> for ExtractorError {
Self::Source(value.into()) Self::Source(value.into())
} }
} }
pub trait MapSrcError<T> {
fn mperr(self, typ: EntityType, src_id: &str) -> Result<T, ExtractorError>;
}
impl<T> MapSrcError<T> for Result<T, rustypipe::error::Error> {
fn mperr(self, typ: EntityType, src_id: &str) -> Result<T, ExtractorError> {
match self {
Ok(res) => Ok(res),
Err(error) => Err(match error {
rustypipe::error::Error::Extraction(
rustypipe::error::ExtractionError::NotFound { .. }
| rustypipe::error::ExtractionError::VideoUnavailable { .. },
) => ExtractorError::Unavailable {
typ,
id: SrcIdOwned(src_id.to_owned(), MusicService::YouTube),
msg: error.to_string().into(),
},
_ => ExtractorError::Source(ExtractorSourceError::YouTube(error)),
}),
}
}
}
impl<T> MapSrcError<T> for Result<T, rspotify::ClientError> {
fn mperr(self, typ: EntityType, src_id: &str) -> Result<T, ExtractorError> {
match self {
Ok(res) => Ok(res),
Err(error) => Err(match &error {
rspotify::ClientError::Http(http_err) => match http_err.as_ref() {
rspotify::http::HttpError::StatusCode(resp) => match resp.status() {
StatusCode::NOT_FOUND => ExtractorError::Unavailable {
typ,
id: SrcIdOwned(src_id.to_owned(), MusicService::Spotify),
msg: "not found".into(),
},
_ => ExtractorError::Source(ExtractorSourceError::Spotify(error)),
},
rspotify::http::HttpError::Client(_) => {
ExtractorError::Source(ExtractorSourceError::Spotify(error))
}
},
_ => ExtractorError::Source(ExtractorSourceError::Spotify(error)),
}),
}
}
}

View file

@ -44,6 +44,8 @@ impl std::ops::Deref for Extractor {
} }
} }
const MSG_NOT_EXIST: &str = "does not exist";
impl Extractor { impl Extractor {
pub fn new(db: Pool<Postgres>) -> Result<Self, ExtractorError> { pub fn new(db: Pool<Postgres>) -> Result<Self, ExtractorError> {
let core = Arc::new(ExtractorCore::new(db)); let core = Arc::new(ExtractorCore::new(db));
@ -62,12 +64,17 @@ impl Extractor {
)) ))
} }
/// Get the spotify extractor (if configured)
fn sp(&self) -> Result<&SpotifyExtractor, ExtractorError> {
self.sp.as_ref().ok_or(ExtractorError::Other(
"Spotify integration not configured".into(),
))
}
/// Get an artist and a list of their albums from the streaming service
#[tracing::instrument(skip(self))] #[tracing::instrument(skip(self))]
pub async fn get_artist(&self, id: SrcId<'_>) -> Result<GetResult<Artist>, ExtractorError> { pub async fn get_artist(&self, id: SrcId<'_>) -> Result<GetResult<Artist>, ExtractorError> {
let artist = Artist::get(id.id(), &self.core.db) let artist = Artist::get(id, &self.core.db).await.to_optional()?;
.await
.to_optional()
.unwrap();
// Get srcid from fetched artist if available (could be an alias) // Get srcid from fetched artist if available (could be an alias)
let srcid = artist let srcid = artist
@ -143,25 +150,23 @@ impl Extractor {
status: res.status, status: res.status,
}) })
.map_err(ExtractorError::from), .map_err(ExtractorError::from),
Err(e) => match e { Err(e) => {
ExtractorError::Source(e) => { // Store YT-related error (e.g. artist not available)
// Store YT-related error (e.g. artist not available) if let Some(last_update) = last_update {
if let Some(last_update) = last_update { if let Some(sync_data) = util::error_to_sync_data(&e) {
if let Some(sync_data) = util::src_error_to_sync_data(&e) { Artist::set_last_sync(last_update.id, sync_data, &self.core.db).await?;
Artist::set_last_sync(last_update.id, sync_data, &self.core.db).await?;
}
return Ok(GetResult {
c: Artist::get(Id::Db(last_update.id), &self.core.db).await?,
status: GetStatus::Error(e),
});
} }
Err(ExtractorError::Source(e)) return Ok(GetResult {
c: Artist::get(Id::Db(last_update.id), &self.core.db).await?,
status: GetStatus::Error(Box::new(e)),
});
} }
_ => Err(e), Err(e)
}, }
} }
} }
/// Fetch all albums of an artist which have not been fetched before and store the tracks
#[tracing::instrument(skip(self))] #[tracing::instrument(skip(self))]
pub async fn fetch_artist_albums(&self, id: i32) -> Result<(), ExtractorError> { pub async fn fetch_artist_albums(&self, id: i32) -> Result<(), ExtractorError> {
let mut n_fetched = 0; let mut n_fetched = 0;
@ -257,12 +262,53 @@ impl Extractor {
Ok(GetResult::fetched(res)) Ok(GetResult::fetched(res))
} }
#[tracing::instrument(skip(self))]
pub async fn get_playlist_meta(
&self,
id: SrcId<'_>,
) -> Result<GetResult<Playlist>, ExtractorError> {
let playlist = Playlist::get(id, &self.core.db).await.to_optional()?;
if let Some(playlist) = playlist {
Ok(GetResult::stored(playlist))
} else {
let res = match id.1 {
MusicService::YouTube => self.yt.fetch_playlist_meta(id.0).await?,
MusicService::Spotify => self.sp()?.fetch_playlist_meta(id.0).await?,
MusicService::Tiraya => {
return Err(ExtractorError::Unavailable {
typ: EntityType::Playlist,
id: id.to_owned(),
msg: MSG_NOT_EXIST.into(),
});
}
_ => {
return Err(ExtractorError::Unsupported {
typ: EntityType::Track,
srv: id.1,
})
}
};
let playlist = Playlist::get(res, &self.core.db).await?;
Ok(GetResult::fetched(playlist))
}
}
#[tracing::instrument(skip(self))] #[tracing::instrument(skip(self))]
pub async fn get_playlist(&self, id: SrcId<'_>) -> Result<GetResult<Playlist>, ExtractorError> { pub async fn get_playlist(&self, id: SrcId<'_>) -> Result<GetResult<Playlist>, ExtractorError> {
let playlist = Playlist::get(id.id(), &self.core.db) let playlist = Playlist::get(id, &self.core.db).await.to_optional()?;
.await
.to_optional() if id.1 == MusicService::Tiraya {
.unwrap(); return match playlist {
Some(playlist) => Ok(GetResult::stored(playlist)),
None => Err(ExtractorError::Unavailable {
typ: EntityType::Playlist,
id: id.to_owned(),
msg: MSG_NOT_EXIST.into(),
}),
};
}
let last_update = if let Some(playlist) = playlist { let last_update = if let Some(playlist) = playlist {
if let Some((last_sync_at, last_sync_data)) = &playlist if let Some((last_sync_at, last_sync_data)) = &playlist
.last_sync_at .last_sync_at
@ -306,12 +352,6 @@ impl Extractor {
self.update_playlist(id, last_update.as_ref()).await self.update_playlist(id, last_update.as_ref()).await
} }
fn sp(&self) -> Result<&SpotifyExtractor, ExtractorError> {
self.sp.as_ref().ok_or(ExtractorError::Other(
"Spotify integration not configured".into(),
))
}
/// Update the given playlist from the streaming service, /// Update the given playlist from the streaming service,
/// fetching new tracks if necessary. /// fetching new tracks if necessary.
async fn update_playlist( async fn update_playlist(
@ -338,32 +378,38 @@ impl Extractor {
status: res.status, status: res.status,
}) })
.map_err(ExtractorError::from), .map_err(ExtractorError::from),
Err(e) => match e { Err(e) => {
ExtractorError::Source(e) => { // Store YT-related error (e.g. playlist not available)
// Store YT-related error (e.g. playlist not available) if let Some(last_update) = last_update {
if let Some(last_update) = last_update { if let Some(sync_data) = util::error_to_sync_data(&e) {
if let Some(sync_data) = util::src_error_to_sync_data(&e) { Playlist::set_last_sync(last_update.id, sync_data, &self.core.db).await?;
Playlist::set_last_sync(last_update.id, sync_data, &self.core.db)
.await?;
}
return Ok(GetResult {
c: Playlist::get(Id::Db(last_update.id), &self.core.db).await?,
status: GetStatus::Error(e),
});
} }
Err(ExtractorError::Source(e)) return Ok(GetResult {
c: Playlist::get(Id::Db(last_update.id), &self.core.db).await?,
status: GetStatus::Error(Box::new(e)),
});
} }
_ => Err(e), Err(e)
}, }
} }
} }
/// Get an user from the streaming service, updating its playlists if necessary
#[tracing::instrument(skip(self))] #[tracing::instrument(skip(self))]
pub async fn get_user(&self, id: SrcId<'_>) -> Result<GetResult<User>, ExtractorError> { pub async fn get_user(&self, id: SrcId<'_>) -> Result<GetResult<User>, ExtractorError> {
let user = User::get(id.id(), &self.core.db) let user = User::get(id, &self.core.db).await.to_optional()?;
.await
.to_optional() if id.1 == MusicService::Tiraya {
.unwrap(); return match user {
Some(user) => Ok(GetResult::stored(user)),
None => Err(ExtractorError::Unavailable {
typ: EntityType::User,
id: id.to_owned(),
msg: MSG_NOT_EXIST.into(),
}),
};
}
let last_update = if let Some(user) = user { let last_update = if let Some(user) = user {
if let Some((last_sync_at, last_sync_data)) = if let Some((last_sync_at, last_sync_data)) =
&user.last_sync_at.zip(user.last_sync_data.as_deref()) &user.last_sync_at.zip(user.last_sync_data.as_deref())
@ -433,23 +479,19 @@ impl Extractor {
status: res.status, status: res.status,
}) })
.map_err(ExtractorError::from), .map_err(ExtractorError::from),
Err(e) => match e { Err(e) => {
ExtractorError::Source(e) => { // Store YT-related error (e.g. playlist not available)
// Store YT-related error (e.g. playlist not available) if let Some(last_update) = last_update {
if let Some(last_update) = last_update { if let Some(sync_data) = util::error_to_sync_data(&e) {
if let Some(sync_data) = util::src_error_to_sync_data(&e) { Playlist::set_last_sync(last_update.id, sync_data, &self.core.db).await?;
Playlist::set_last_sync(last_update.id, sync_data, &self.core.db)
.await?;
}
return Ok(GetResult {
c: User::get(Id::Db(last_update.id), &self.core.db).await?,
status: GetStatus::Error(e),
});
} }
Err(ExtractorError::Source(e)) return Ok(GetResult {
c: User::get(Id::Db(last_update.id), &self.core.db).await?,
status: GetStatus::Error(Box::new(e)),
});
} }
_ => Err(e), Err(e)
}, }
} }
} }

View file

@ -6,7 +6,7 @@ use time::OffsetDateTime;
use tiraya_db::models::{SrcIdOwned, SyncData}; use tiraya_db::models::{SrcIdOwned, SyncData};
use tiraya_utils::config::CONFIG; use tiraya_utils::config::CONFIG;
use crate::{error::ExtractorSourceError, util::matchmaker::Matchmaker}; use crate::{error::ExtractorError, util::matchmaker::Matchmaker};
/// Core dependencies for all sevice extractors /// Core dependencies for all sevice extractors
pub struct ExtractorCore { pub struct ExtractorCore {
@ -32,7 +32,7 @@ pub struct GetResult<T> {
pub enum GetStatus { pub enum GetStatus {
Fetched, Fetched,
Stored, Stored,
Error(ExtractorSourceError), Error(Box<ExtractorError>),
} }
impl PartialEq for GetStatus { impl PartialEq for GetStatus {

View file

@ -33,6 +33,9 @@ pub(crate) trait ServiceExtractor {
/// Fetch a new track from the streaming service. /// Fetch a new track from the streaming service.
async fn fetch_track(&self, src_id: &str) -> Result<i32, ExtractorError>; async fn fetch_track(&self, src_id: &str) -> Result<i32, ExtractorError>;
/// Fetch the metadata of a playlist from the streaming service.
async fn fetch_playlist_meta(&self, src_id: &str) -> Result<i32, ExtractorError>;
/// Update the given playlist from the streaming service, /// Update the given playlist from the streaming service,
/// fetching new tracks if necessary. /// fetching new tracks if necessary.
async fn update_playlist( async fn update_playlist(

View file

@ -2,8 +2,8 @@ use futures::{stream::TryStreamExt, StreamExt};
use path_macro::path; use path_macro::path;
use rspotify::{ use rspotify::{
model::{ model::{
AlbumId, ArtistId, FullTrack, Market, PlayableItem, PlaylistId, PublicUser, AlbumId, ArtistId as SpotifyArtistId, FullPlaylist, FullTrack, Image, Market, PlayableItem,
SimplifiedPlaylist, TrackId, UserId, PlaylistId, PublicUser, SimplifiedPlaylist, TrackId, UserId,
}, },
prelude::{BaseClient, Id}, prelude::{BaseClient, Id},
ClientCredsSpotify, ClientCredsSpotify,
@ -11,15 +11,15 @@ use rspotify::{
use std::{collections::HashSet, sync::Arc}; use std::{collections::HashSet, sync::Arc};
use time::OffsetDateTime; use time::OffsetDateTime;
use tiraya_db::models::{ use tiraya_db::models::{
Artist, MusicService, Playlist, PlaylistEntry, PlaylistImgType, PlaylistNew, PlaylistType, Artist, ArtistId, EntityType, MusicService, Playlist, PlaylistEntry, PlaylistImgType,
SrcId, SrcIdOwned, Track, User, UserNew, UserType, PlaylistNew, PlaylistType, SrcId, SrcIdOwned, Track, TrackMatchAlbum, TrackMatchMeta, User,
UserNew, UserType,
}; };
use tiraya_utils::config::CONFIG; use tiraya_utils::config::CONFIG;
use crate::{ use crate::{
error::ExtractorError, error::ExtractorError,
model::{ExtractorCore, GetResult, LastUpdateState, SyncLastUpdate}, model::{ExtractorCore, GetResult, LastUpdateState, SyncLastUpdate},
util::ArtistIdName,
}; };
use super::YouTubeExtractor; use super::YouTubeExtractor;
@ -85,7 +85,7 @@ impl SpotifyExtractor {
// will only be called on the initial request of the artist, not on updates // will only be called on the initial request of the artist, not on updates
// (hence no last_update parameter). // (hence no last_update parameter).
self.load_token().await?; self.load_token().await?;
let artist_id = ArtistId::from_id(src_id)?; let artist_id = SpotifyArtistId::from_id(src_id)?;
let mut top_tracks = self let mut top_tracks = self
.sp .sp
.artist_top_tracks(artist_id, Some(self.market)) .artist_top_tracks(artist_id, Some(self.market))
@ -143,6 +143,7 @@ impl SpotifyExtractor {
} }
Err(ExtractorError::NoMatch { Err(ExtractorError::NoMatch {
typ: EntityType::Artist,
id: a_id.to_owned(), id: a_id.to_owned(),
}) })
} }
@ -169,6 +170,16 @@ impl SpotifyExtractor {
self.import_track(track).await self.import_track(track).await
} }
pub async fn fetch_playlist_meta(&self, src_id: &str) -> Result<i32, ExtractorError> {
self.load_token().await?;
let sp_pl_id = PlaylistId::from_id(src_id)?;
let playlist = self
.sp
.playlist(sp_pl_id.clone_static(), None, None)
.await?;
self.import_playlist(playlist).await
}
pub async fn update_playlist( pub async fn update_playlist(
&self, &self,
src_id: &str, src_id: &str,
@ -178,35 +189,17 @@ impl SpotifyExtractor {
let sp_pl_id = PlaylistId::from_id(src_id)?; let sp_pl_id = PlaylistId::from_id(src_id)?;
let playlist = self let playlist = self
.sp .sp
.playlist(sp_pl_id.clone_static(), Some(""), None) .playlist(sp_pl_id.clone_static(), None, None)
.await?; .await?;
let current_state = LastUpdateState::Version(playlist.snapshot_id); let current_state = LastUpdateState::Version(playlist.snapshot_id.to_owned());
if let Some(last_update) = last_update { if let Some(last_update) = last_update {
if last_update.state.is_current(&current_state) { if last_update.state.is_current(&current_state) {
return Ok(GetResult::stored(last_update.id)); return Ok(GetResult::stored(last_update.id));
} }
} }
let image_url = playlist let playlist_id = self.import_playlist(playlist).await?;
.images
.into_iter()
.max_by_key(|img| img.height.unwrap_or_default())
.map(|img| img.url);
let owner_id = self.import_user(playlist.owner).await?;
let n_playlist = PlaylistNew {
src_id,
service: MusicService::Spotify,
name: &playlist.name,
owner_id: Some(owner_id),
playlist_type: PlaylistType::Remote,
description: playlist.description.as_deref(),
image_url: image_url.as_deref(),
image_type: Some(PlaylistImgType::Custom).filter(|_| image_url.is_some()),
..Default::default()
};
let playlist_id = n_playlist.upsert(&self.core.db).await?;
let playlist_tracks = self let playlist_tracks = self
.sp .sp
@ -292,7 +285,7 @@ impl SpotifyExtractor {
let user = self.sp.user(sp_user_id.clone_static()).await?; let user = self.sp.user(sp_user_id.clone_static()).await?;
let user_id = self.import_user(user).await?; let user_id = self.import_user(user).await?;
self.import_playlists(playlists_p1.items).await?; self.import_playlist_items(playlists_p1.items).await?;
if playlists_p1.next.is_some() { if playlists_p1.next.is_some() {
let mut offset = PAGE_LIMIT; let mut offset = PAGE_LIMIT;
@ -305,7 +298,7 @@ impl SpotifyExtractor {
Some(offset), Some(offset),
) )
.await?; .await?;
self.import_playlists(playlists.items).await?; self.import_playlist_items(playlists.items).await?;
if playlists.next.is_none() { if playlists.next.is_none() {
break; break;
} }
@ -319,11 +312,7 @@ impl SpotifyExtractor {
async fn import_user(&self, user: PublicUser) -> Result<i32, ExtractorError> { async fn import_user(&self, user: PublicUser) -> Result<i32, ExtractorError> {
let id_owned = SrcIdOwned(user.id.id().to_owned(), MusicService::Spotify); let id_owned = SrcIdOwned(user.id.id().to_owned(), MusicService::Spotify);
let image_url = user let image_url = Self::get_image(user.images);
.images
.into_iter()
.max_by_key(|img| img.height.unwrap_or_default())
.map(|img| img.url);
let name = user.display_name.as_deref().unwrap_or(user.id.id()); let name = user.display_name.as_deref().unwrap_or(user.id.id());
self.core self.core
@ -344,7 +333,10 @@ impl SpotifyExtractor {
.await .await
} }
async fn import_playlist(&self, playlist: SimplifiedPlaylist) -> Result<i32, ExtractorError> { async fn import_playlist_item(
&self,
playlist: SimplifiedPlaylist,
) -> Result<i32, ExtractorError> {
if let Some(id) = Playlist::get_id( if let Some(id) = Playlist::get_id(
SrcId(playlist.id.id(), MusicService::Spotify), SrcId(playlist.id.id(), MusicService::Spotify),
&self.core.db, &self.core.db,
@ -354,11 +346,7 @@ impl SpotifyExtractor {
return Ok(id); return Ok(id);
} }
let image_url = playlist let image_url = Self::get_image(playlist.images);
.images
.into_iter()
.max_by_key(|img| img.height.unwrap_or_default())
.map(|img| img.url);
let owner_id = self.import_user(playlist.owner).await?; let owner_id = self.import_user(playlist.owner).await?;
let playlist_n = PlaylistNew { let playlist_n = PlaylistNew {
@ -381,13 +369,13 @@ impl SpotifyExtractor {
Ok(playlist_id) Ok(playlist_id)
} }
async fn import_playlists( async fn import_playlist_items(
&self, &self,
playlists: impl IntoIterator<Item = SimplifiedPlaylist>, playlists: impl IntoIterator<Item = SimplifiedPlaylist>,
) -> Result<(), ExtractorError> { ) -> Result<(), ExtractorError> {
futures::stream::iter(playlists.into_iter().map(Ok)) futures::stream::iter(playlists.into_iter().map(Ok))
.try_for_each_concurrent(CONFIG.core.db_concurrency, |pl| async { .try_for_each_concurrent(CONFIG.core.db_concurrency, |pl| async {
self.import_playlist(pl).await?; self.import_playlist_item(pl).await?;
Ok(()) Ok(())
}) })
.await .await
@ -407,24 +395,31 @@ impl SpotifyExtractor {
return Ok(track_id); return Ok(track_id);
} }
let artists = track
.artists
.into_iter()
.filter_map(|a| {
a.id.as_ref().map(|a_id| ArtistIdName {
id: a_id.id().to_owned(),
name: a.name,
})
})
.collect::<Vec<_>>();
self.yt self.yt
.match_track( .match_track(TrackMatchMeta {
src_id, id: src_id.to_owned(),
&track.name, name: track.name,
&artists, artists: track
track.external_ids.get("isrc").map(|s| s.as_str()), .artists
) .into_iter()
.map(|a| ArtistId {
id: a
.id
.map(|id| SrcIdOwned(id.id().to_owned(), MusicService::Spotify)),
name: a.name,
})
.collect(),
album: TrackMatchAlbum {
id: track
.album
.id
.map(|id| SrcIdOwned(id.id().to_owned(), MusicService::Spotify)),
name: track.album.name,
image_url: Self::get_image(track.album.images),
},
isrc: track.external_ids.get("isrc").cloned(),
duration: track.duration.num_seconds().try_into().ok(),
})
.await .await
} }
@ -444,4 +439,32 @@ impl SpotifyExtractor {
}) })
.await .await
} }
async fn import_playlist(&self, playlist: FullPlaylist) -> Result<i32, ExtractorError> {
let image_url = Self::get_image(playlist.images);
let owner_id = self.import_user(playlist.owner).await?;
let n_playlist = PlaylistNew {
src_id: playlist.id.id(),
service: MusicService::Spotify,
name: &playlist.name,
owner_id: Some(owner_id),
playlist_type: PlaylistType::Remote,
description: playlist.description.as_deref(),
image_url: image_url.as_deref(),
image_type: Some(PlaylistImgType::Custom).filter(|_| image_url.is_some()),
..Default::default()
};
n_playlist
.upsert(&self.core.db)
.await
.map_err(ExtractorError::from)
}
fn get_image(images: Vec<Image>) -> Option<String> {
images
.into_iter()
.max_by_key(|img| img.height.unwrap_or_default())
.map(|img| img.url)
}
} }

View file

@ -13,16 +13,17 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time};
use tiraya_db::{ use tiraya_db::{
error::OptionalRes, error::OptionalRes,
models::{ models::{
Album, AlbumNew, AlbumType, AlbumUpdate, Artist, ArtistId, ArtistNew, DatePrecision, Id, Album, AlbumNew, AlbumType, AlbumUpdate, Artist, ArtistId, ArtistNew, DatePrecision,
MusicService, Playlist, PlaylistEntry, PlaylistImgType, PlaylistNew, PlaylistType, SrcId, EntityType, Id, MusicService, Playlist, PlaylistEntry, PlaylistImgType, PlaylistNew,
SrcIdOwned, Track, TrackNew, TrackUpdate, User, UserNew, UserType, PlaylistType, SrcId, SrcIdOwned, Track, TrackMatchAlbum, TrackMatchData, TrackMatchMeta,
TrackNew, TrackUpdate, User, UserNew, UserType,
}, },
}; };
use tiraya_utils::config::CONFIG; use tiraya_utils::config::CONFIG;
use crate::util::ArtistSrcidName; use crate::util::ArtistSrcidName;
use crate::{ use crate::{
error::ExtractorError, error::{ExtractorError, MapSrcError},
model::{ExtractorCore, GetResult, LastUpdateState, SyncLastUpdate}, model::{ExtractorCore, GetResult, LastUpdateState, SyncLastUpdate},
util::{self, ArtistIdName, ImgFormat}, util::{self, ArtistIdName, ImgFormat},
GetStatus, GetStatus,
@ -76,7 +77,12 @@ impl YouTubeExtractor {
this_update_state = Some(current_state); this_update_state = Some(current_state);
} }
let artist = self.rp.query().music_artist(src_id, true).await?; let artist = self
.rp
.query()
.music_artist(src_id, true)
.await
.mperr(EntityType::Artist, src_id)?;
let (top_track_ids, related_artist_ids, related_playlist_ids) = tokio::join!( let (top_track_ids, related_artist_ids, related_playlist_ids) = tokio::join!(
self.import_track_items( self.import_track_items(
@ -144,7 +150,12 @@ impl YouTubeExtractor {
} }
pub async fn fetch_album(&self, src_id: &str) -> Result<(i32, bool), ExtractorError> { pub async fn fetch_album(&self, src_id: &str) -> Result<(i32, bool), ExtractorError> {
let album = self.rp.query().music_album(src_id).await?; let album = self
.rp
.query()
.music_album(src_id)
.await
.mperr(EntityType::Album, src_id)?;
let (artists, ul_artists) = self let (artists, ul_artists) = self
.split_yt_artists(album.artists, album.artist_id, &[]) .split_yt_artists(album.artists, album.artist_id, &[])
@ -229,12 +240,11 @@ impl YouTubeExtractor {
async fn _fetch_track(&self, src_id: &str, with_details: bool) -> Result<i32, ExtractorError> { async fn _fetch_track(&self, src_id: &str, with_details: bool) -> Result<i32, ExtractorError> {
let query = self.rp.query(); let query = self.rp.query();
let (track, details) = if with_details { let (track, details) = if with_details {
let (track, details) = tokio::join!(query.music_details(src_id), self.get_track_details(src_id))
tokio::join!(query.music_details(src_id), self.get_track_details(src_id));
(track?.track, details)
} else { } else {
(query.music_details(src_id).await?.track, None) (query.music_details(src_id).await, None)
}; };
let track = track.mperr(EntityType::Track, src_id)?.track;
let redirected_id = if track.id != src_id { let redirected_id = if track.id != src_id {
Some(track.id.clone()) Some(track.id.clone())
} else { } else {
@ -249,8 +259,9 @@ impl YouTubeExtractor {
"track [yt:{src_id}] got redirected to [yt:{redirected_id}], added alias" "track [yt:{src_id}] got redirected to [yt:{redirected_id}], added alias"
); );
Track::add_alias( Track::add_alias(
track_id,
SrcId(&redirected_id, MusicService::YouTube), SrcId(&redirected_id, MusicService::YouTube),
Some(track_id),
None,
&self.core.db, &self.core.db,
) )
.await?; .await?;
@ -258,6 +269,16 @@ impl YouTubeExtractor {
Ok(track_id) Ok(track_id)
} }
pub async fn fetch_playlist_meta(&self, src_id: &str) -> Result<i32, ExtractorError> {
let playlist = self
.rp
.query()
.music_playlist(src_id)
.await
.mperr(EntityType::Playlist, src_id)?;
self.import_playlist(&playlist).await
}
pub async fn update_playlist( pub async fn update_playlist(
&self, &self,
src_id: &str, src_id: &str,
@ -265,11 +286,12 @@ impl YouTubeExtractor {
) -> Result<GetResult<i32>, ExtractorError> { ) -> Result<GetResult<i32>, ExtractorError> {
let query = self.rp.query(); let query = self.rp.query();
let (m_playlist, d_playlist) = if src_id.starts_with("RD") { let (m_playlist, d_playlist) = if src_id.starts_with("RD") {
(query.music_playlist(src_id).await?, None) (query.music_playlist(src_id).await, None)
} else { } else {
let (m, d) = tokio::join!(query.music_playlist(src_id), query.playlist(src_id)); let (m, d) = tokio::join!(query.music_playlist(src_id), query.playlist(src_id));
(m?, d.ok()) (m, d.ok())
}; };
let m_playlist = m_playlist.mperr(EntityType::Playlist, src_id)?;
let current_state = LastUpdateState::Date( let current_state = LastUpdateState::Date(
d_playlist d_playlist
@ -283,23 +305,7 @@ impl YouTubeExtractor {
} }
} }
let image_url = util::yt_image_url(&m_playlist.thumbnail, ImgFormat::Cover); let playlist_id = self.import_playlist(&m_playlist).await?;
let owner_id = self
.get_playlist_owner(&m_playlist.channel, m_playlist.from_ytm)
.await?;
let n_playlist = PlaylistNew {
src_id,
service: MusicService::YouTube,
name: &m_playlist.name,
owner_id,
playlist_type: PlaylistType::Remote,
description: m_playlist.description.as_deref(),
image_url: image_url.as_deref(),
image_type: Some(PlaylistImgType::Custom).filter(|_| image_url.is_some()),
..Default::default()
};
let playlist_id = n_playlist.upsert(&self.core.db).await?;
let mut m_tracks = m_playlist.tracks; let mut m_tracks = m_playlist.tracks;
m_tracks m_tracks
@ -351,7 +357,12 @@ impl YouTubeExtractor {
src_id: &str, src_id: &str,
last_update: Option<&SyncLastUpdate>, last_update: Option<&SyncLastUpdate>,
) -> Result<GetResult<i32>, ExtractorError> { ) -> Result<GetResult<i32>, ExtractorError> {
let mut channel = self.rp.query().channel_playlists(src_id).await?; let mut channel = self
.rp
.query()
.channel_playlists(src_id)
.await
.mperr(EntityType::User, src_id)?;
// Check if the channel is actually an artist // Check if the channel is actually an artist
if channel.name.ends_with(YTM_CHANNEL_SUFFIX) && channel.content.is_empty() { if channel.name.ends_with(YTM_CHANNEL_SUFFIX) && channel.content.is_empty() {
@ -409,39 +420,79 @@ impl YouTubeExtractor {
/// ///
/// The given `src_id` is added to the database as an alias, so the YT track becomes /// The given `src_id` is added to the database as an alias, so the YT track becomes
/// accessible under the other id. /// accessible under the other id.
pub async fn match_track( pub async fn match_track(&self, meta: TrackMatchMeta) -> Result<i32, ExtractorError> {
&self, let src_id = meta.id.to_owned();
src_id: SrcId<'_>, let match_res = self
name: &str, .get_matching_track(
artists: &[ArtistIdName], src_id.as_srcid(),
isrc: Option<&str>, &meta.name,
) -> Result<i32, ExtractorError> { &meta.artists,
let track_id = self.get_matching_track(src_id, name, artists, isrc).await?; meta.isrc.as_deref(),
)
.await?;
match match_res {
Ok((track_id, score)) => {
let match_data = TrackMatchData {
matchmaker_version: util::matchmaker::VERSION,
matchmaker_cfg: CONFIG.extractor.matchmaker_cfg_name.to_owned(),
score,
original: meta,
closest: None,
};
Track::add_alias(
src_id.as_srcid(),
Some(track_id),
Some(&match_data),
&self.core.db,
)
.await?;
Track::add_alias(track_id, src_id, &self.core.db).await?; if let Some(isrc) = match_data
if let Some(isrc) = isrc.and_then(util::normalize_isrc) { .original
let t_upd = TrackUpdate { .isrc
isrc: Some(Some(&isrc)), .as_deref()
..Default::default() .and_then(util::normalize_isrc)
}; {
t_upd.update(Id::Db(track_id), &self.core.db).await?; let t_upd = TrackUpdate {
isrc: Some(Some(&isrc)),
..Default::default()
};
t_upd.update(track_id, &self.core.db).await?;
}
Ok(track_id)
}
Err(nmt) => {
if let Some((closest_track, score)) = nmt {
let match_data = TrackMatchData {
matchmaker_version: util::matchmaker::VERSION,
matchmaker_cfg: CONFIG.extractor.matchmaker_cfg_name.to_owned(),
score,
original: meta,
closest: Some(closest_track),
};
Track::add_alias(src_id.as_srcid(), None, Some(&match_data), &self.core.db)
.await?;
}
Err(ExtractorError::NoMatch {
typ: EntityType::Track,
id: src_id,
})
}
} }
Ok(track_id)
} }
async fn get_matching_track( async fn get_matching_track(
&self, &self,
src_id: SrcId<'_>, src_id: SrcId<'_>,
name: &str, name: &str,
artists: &[ArtistIdName], artists: &[ArtistId],
isrc: Option<&str>, isrc: Option<&str>,
) -> Result<i32, ExtractorError> { ) -> Result<Result<(i32, f32), Option<(TrackMatchMeta, f32)>>, ExtractorError> {
let artist = &artists let artist = if let Some(artist) = artists.first() {
.first() &artist.name
.ok_or_else(|| ExtractorError::NoMatch { } else {
id: src_id.to_owned(), return Ok(Err(None));
})? };
.name;
let t1 = self.core.matchmaker.parse_title(name); let t1 = self.core.matchmaker.parse_title(name);
// Attempt to find the track by searching YouTube for its ISRC id // Attempt to find the track by searching YouTube for its ISRC id
@ -470,12 +521,14 @@ impl YouTubeExtractor {
track track
} else { } else {
let track_id = self._fetch_track(&video.id, false).await?; let track_id = self._fetch_track(&video.id, false).await?;
Track::get(Id::Db(track_id), &self.core.db).await? Track::get(track_id, &self.core.db).await?
}; };
let album = Album::get(track.album_id, &self.core.db).await?;
self.store_matching_artists(artists, src_id.1, &track.artists) if album.album_type != Some(AlbumType::Mv) {
.await?; self.store_matching_artists(artists, &track.artists).await?;
return Ok(track.id); return Ok(Ok((track.id, score)));
}
} }
} }
} }
@ -513,20 +566,53 @@ impl YouTubeExtractor {
track.name, track.name,
track.id track.id
); );
return self.import_track_item(track, None, &[], None, false).await; Ok(Ok((
self.import_track_item(track, None, &[], None, false)
.await?,
score,
)))
} else {
tracing::info!(
"could not match track `{name}` [{src_id}] => `{0}` [yt:{1}] (search, {score:.2})",
track.name,
track.id
);
let (album_id, album_name) = if let Some(album) = track.album {
(
Some(SrcIdOwned(album.id, MusicService::YouTube)),
album.name,
)
} else {
(None, track.name.clone())
};
Ok(Err(Some((
TrackMatchMeta {
id: SrcIdOwned(track.id, MusicService::YouTube),
name: track.name,
artists: track
.artists
.into_iter()
.map(|a| ArtistId {
id: a.id.map(|id| SrcIdOwned(id, MusicService::YouTube)),
name: a.name,
})
.collect(),
album: TrackMatchAlbum {
id: album_id,
name: album_name,
image_url: util::yt_image_url(&track.cover, ImgFormat::Cover)
.map(|u| u.into_owned()),
},
isrc: None,
duration: track.duration.and_then(|d| d.try_into().ok()),
},
score,
))))
} }
tracing::info!(
"could not match track `{name}` [{src_id}] => `{0}` [yt:{1}] (search, {score:.2})",
track.name,
track.id
);
} else { } else {
tracing::info!("could not match track `{name}` [{src_id}] (search)"); tracing::info!("could not match track `{name}` [{src_id}] (search)");
Ok(Err(None))
} }
Err(ExtractorError::NoMatch {
id: src_id.to_owned(),
})
} }
/// Try to match the artists from a different music service to the ones of a track /// Try to match the artists from a different music service to the ones of a track
@ -535,8 +621,7 @@ impl YouTubeExtractor {
/// Returns the artist id matched to the first given artist /// Returns the artist id matched to the first given artist
async fn store_matching_artists( async fn store_matching_artists(
&self, &self,
artists_to_match: &[ArtistIdName], artists_to_match: &[ArtistId],
service: MusicService,
yt_artists: &[ArtistId], yt_artists: &[ArtistId],
) -> Result<(), ExtractorError> { ) -> Result<(), ExtractorError> {
let mut yt_artists = yt_artists let mut yt_artists = yt_artists
@ -555,57 +640,58 @@ impl YouTubeExtractor {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for atm in artists_to_match { for atm in artists_to_match {
let atm_srcid = SrcIdOwned(atm.id.to_owned(), service); if let Some(atm_srcid) = &atm.id {
// Check if the artist has already been matched // Check if the artist has already been matched
let atm_id_res = self let atm_id_res = self
.core .core
.artist_cache .artist_cache
.get_or_insert_async(&atm_srcid, async { .get_or_insert_async(atm_srcid, async {
Artist::get_id(atm_srcid.as_srcid(), &self.core.db) Artist::get_id(atm_srcid.as_srcid(), &self.core.db)
.await? .await?
.ok_or(ExtractorError::NoId) .ok_or(ExtractorError::NoId)
}) })
.await; .await;
match atm_id_res { match atm_id_res {
Ok(atm_id) => { Ok(atm_id) => {
// Artist is already matched // Artist is already matched
let yt_src_id = Artist::get_src_id(atm_id, &self.core.db) let yt_src_id = Artist::get_src_id(atm_id, &self.core.db)
.await?
.ok_or(ExtractorError::NoId)?;
if let Some((idx, _)) = yt_artists
.iter()
.enumerate()
.find(|(_, (a, _))| a.id == yt_src_id)
{
yt_artists.swap_remove(idx);
}
}
Err(ExtractorError::NoId) => {
// Match artist
let a1 = self.core.matchmaker.parse_artist(&atm.name);
let (ib, score) = yt_artists
.iter()
.map(|(_, a2)| self.core.matchmaker.match_name(&a1, a2))
.enumerate()
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.unwrap_or_default();
if score > 0.8 {
let yta = yt_artists.swap_remove(ib).0;
let yta_id = Artist::get_id(yta.id, &self.core.db)
.await? .await?
.ok_or(ExtractorError::NoId)?; .ok_or(ExtractorError::NoId)?;
Artist::add_alias(yta_id, atm_srcid.as_srcid(), &self.core.db).await?; if let Some((idx, _)) = yt_artists
tracing::info!( .iter()
"matched artist `{}` [{}] => `{}` [{}] ({score:.2})", .enumerate()
yta.name, .find(|(_, (a, _))| a.id == yt_src_id)
yta.id, {
atm.name, yt_artists.swap_remove(idx);
atm_srcid }
);
self.core.artist_cache.insert(atm_srcid, yta_id);
} }
Err(ExtractorError::NoId) => {
// Match artist
let a1 = self.core.matchmaker.parse_artist(&atm.name);
let (ib, score) = yt_artists
.iter()
.map(|(_, a2)| self.core.matchmaker.match_name(&a1, a2))
.enumerate()
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.unwrap_or_default();
if score > 0.8 {
let yta = yt_artists.swap_remove(ib).0;
let yta_id = Artist::get_id(yta.id, &self.core.db)
.await?
.ok_or(ExtractorError::NoId)?;
Artist::add_alias(yta_id, atm_srcid.as_srcid(), &self.core.db).await?;
tracing::info!(
"matched artist `{}` [{}] => `{}` [{}] ({score:.2})",
yta.name,
yta.id,
atm.name,
atm_srcid
);
self.core.artist_cache.insert(atm_srcid.to_owned(), yta_id);
}
}
Err(e) => return Err(e),
} }
Err(e) => return Err(e),
} }
} }
Ok(()) Ok(())
@ -670,6 +756,7 @@ impl YouTubeExtractor {
} }
Err(ExtractorError::NoMatch { Err(ExtractorError::NoMatch {
typ: EntityType::Album,
id: src_id.to_owned(), id: src_id.to_owned(),
}) })
} }
@ -678,7 +765,12 @@ impl YouTubeExtractor {
// Internal functions // Internal functions
impl YouTubeExtractor { impl YouTubeExtractor {
async fn artist_update_state(&self, id: &str) -> Result<LastUpdateState, ExtractorError> { async fn artist_update_state(&self, id: &str) -> Result<LastUpdateState, ExtractorError> {
let feed = self.rp.query().channel_rss(id).await?; let feed = self
.rp
.query()
.channel_rss(id)
.await
.mperr(EntityType::Artist, id)?;
Ok(feed Ok(feed
.videos .videos
.into_iter() .into_iter()
@ -706,7 +798,7 @@ impl YouTubeExtractor {
primary_track: Some(Some(false)), primary_track: Some(Some(false)),
..Default::default() ..Default::default()
}; };
t_upd.update(Id::Db(id), &self.core.db).await?; t_upd.update(id, &self.core.db).await?;
} }
return Ok(id); return Ok(id);
} }
@ -996,6 +1088,33 @@ impl YouTubeExtractor {
.map_err(ExtractorError::from) .map_err(ExtractorError::from)
} }
/// Import the metadata of a YouTube Music playlist
async fn import_playlist(
&self,
playlist: &rpmodel::MusicPlaylist,
) -> Result<i32, ExtractorError> {
let image_url = util::yt_image_url(&playlist.thumbnail, ImgFormat::Cover);
let owner_id = self
.get_playlist_owner(&playlist.channel, playlist.from_ytm)
.await?;
let n_playlist = PlaylistNew {
src_id: &playlist.id,
service: MusicService::YouTube,
name: &playlist.name,
owner_id,
playlist_type: PlaylistType::Remote,
description: playlist.description.as_deref(),
image_url: image_url.as_deref(),
image_type: Some(PlaylistImgType::Custom).filter(|_| image_url.is_some()),
..Default::default()
};
n_playlist
.upsert(&self.core.db)
.await
.map_err(ExtractorError::from)
}
/// Convert track/album artists scraped from YouTube into the format used by Tiraya /// Convert track/album artists scraped from YouTube into the format used by Tiraya
/// ///
/// YouTube Music's metadata is incomplete and there are a lot of tracks with /// YouTube Music's metadata is incomplete and there are a lot of tracks with
@ -1148,7 +1267,7 @@ impl YouTubeExtractor {
release_date_precision: Some(Some(release_date.1)), release_date_precision: Some(Some(release_date.1)),
..Default::default() ..Default::default()
}; };
b_upd.update(Id::Db(album_id), &self.core.db).await?; b_upd.update(album_id, &self.core.db).await?;
} }
} }
Ok(()) Ok(())
@ -1195,18 +1314,27 @@ mod tests {
async fn match_track() { async fn match_track() {
let xtr = YouTubeExtractor::new(ExtractorCore::new(pool.clone()).into()).unwrap(); let xtr = YouTubeExtractor::new(ExtractorCore::new(pool.clone()).into()).unwrap();
let track_id = xtr let track_id = xtr
.match_track( .match_track(TrackMatchMeta {
SrcId("5awNIWVrh2ISfvPd5IUZNh", MusicService::Spotify), id: SrcIdOwned("5awNIWVrh2ISfvPd5IUZNh".to_owned(), MusicService::Spotify),
"PTT (Paint The Town)", name: "PTT (Paint The Town)".to_owned(),
&[ArtistIdName { artists: vec![ArtistId {
id: "52zMTJCKluDlFwMQWmccY7".to_owned(), id: Some(SrcIdOwned(
"52zMTJCKluDlFwMQWmccY7".to_owned(),
MusicService::Spotify,
)),
name: "LOONA".to_owned(), name: "LOONA".to_owned(),
}], }],
Some("KRA382152284"), album: TrackMatchAlbum {
) id: None,
name: "Paint The Town".to_owned(),
image_url: None,
},
isrc: Some("KRA382152284".to_owned()),
duration: None,
})
.await .await
.unwrap(); .unwrap();
let track = Track::get(Id::Db(track_id), &pool).await.unwrap(); let track = Track::get(track_id, &pool).await.unwrap();
insta::assert_ron_snapshot!(track, { insta::assert_ron_snapshot!(track, {
".id" => "[id]", ".id" => "[id]",
".created_at" => "[date]", ".created_at" => "[date]",
@ -1229,7 +1357,7 @@ mod tests {
album_id: 1, album_id: 1,
album_pos: None, album_pos: None,
isrc: Some("KRA382152284"), isrc: Some("KRA382152284"),
description: Some("Provided to YouTube by Kakao Entertainment\n\nPTT (Paint The Town) (PTT (Paint The Town)) · LOONA\n\n[&]\n\n℗ 2021 BlockBerry Creative,under license to Kakao Entertainment\n\nReleased on: 2021-06-28\n\nAuthor: Ryan S. Jhun\nAuthor: Hanif Hitmanic Sabzevari\nAuthor: Dennis DeKo Kordnejad\nAuthor: YOUHA\nComposer: Ryan S. Jhun\nComposer: Hanif Hitmanic Sabzevari\nComposer: Dennis DeKo Kordnejad\nComposer: YOUHA\nArranger: Ryan S. Jhun\nArranger: Hanif Hitmanic Sabzevari\nArranger: Dennis DeKo Kordnejad\n\nAuto-generated by YouTube."), description: None,
created_at: "[date]", created_at: "[date]",
updated_at: "[date]", updated_at: "[date]",
primary_track: None, primary_track: None,

View file

@ -19,6 +19,10 @@ static SEPARATORS: phf::Set<char> =
static SEPARATORS_NOSPACE: phf::Set<char> = phf::phf_set!('|', 'ᅵ', ''); static SEPARATORS_NOSPACE: phf::Set<char> = phf::phf_set!('|', 'ᅵ', '');
/// The version of the matchmaker. It is incremented with every Tiraya release which
/// includes changes to the matchmaker logic.
pub const VERSION: u16 = 1;
pub struct Matchmaker { pub struct Matchmaker {
kw_matcher: AhoCorasick, kw_matcher: AhoCorasick,
/// List of numbers indicating the first index of a specific keyword /// List of numbers indicating the first index of a specific keyword
@ -60,7 +64,10 @@ impl Matchmaker {
} }
Self { Self {
kw_matcher: AhoCorasick::new(patterns).unwrap(), kw_matcher: AhoCorasick::builder()
.match_kind(aho_corasick::MatchKind::LeftmostLongest)
.build(patterns)
.unwrap(),
kw_positions: positions, kw_positions: positions,
match_thr, match_thr,
} }
@ -346,7 +353,10 @@ mod tests {
static KEYWORDS: Lazy<BTreeMap<&str, Vec<&str>>> = Lazy::new(|| { static KEYWORDS: Lazy<BTreeMap<&str, Vec<&str>>> = Lazy::new(|| {
let mut keywords = BTreeMap::new(); let mut keywords = BTreeMap::new();
keywords.insert("acoustic", vec!["acoustic", "akustik", "unplugged"]); keywords.insert(
"acoustic",
vec!["acoustic", "akustik", "unplugged", "piano", "pianos"],
);
keywords.insert( keywords.insert(
"instrumental", "instrumental",
vec![ vec![
@ -372,12 +382,12 @@ mod tests {
#[test] #[test]
fn get_kw_type() { fn get_kw_type() {
let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR); let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR);
assert_eq!(mm.kw_positions, &[0, 3, 11]); assert_eq!(mm.kw_positions, &[0, 5, 13]);
assert_eq!(mm.get_kw_type(0), TYPE_ACOUSTIC); assert_eq!(mm.get_kw_type(0), TYPE_ACOUSTIC);
assert_eq!(mm.get_kw_type(1), TYPE_ACOUSTIC); assert_eq!(mm.get_kw_type(1), TYPE_ACOUSTIC);
assert_eq!(mm.get_kw_type(3), TYPE_INSTRUMENTAL); assert_eq!(mm.get_kw_type(5), TYPE_INSTRUMENTAL);
assert_eq!(mm.get_kw_type(11), TYPE_LIVE); assert_eq!(mm.get_kw_type(13), TYPE_LIVE);
assert_eq!(mm.get_kw_type(100), TYPE_LIVE); assert_eq!(mm.get_kw_type(100), TYPE_LIVE);
} }
@ -521,6 +531,7 @@ mod tests {
#[rstest] #[rstest]
#[case(r#"INNOCENCE(Live at 渋谷公会堂)"#, r#"INNOCENCE"#)] #[case(r#"INNOCENCE(Live at 渋谷公会堂)"#, r#"INNOCENCE"#)]
#[case(r#"Zu dir"#, r#"Zruck zu dir (Hallo Klaus)"#)] #[case(r#"Zu dir"#, r#"Zruck zu dir (Hallo Klaus)"#)]
#[case(r#"Jump!"#, r#"Jump! (For Two Pianos)"#)]
fn no_match_titles(#[case] a: &str, #[case] b: &str) { fn no_match_titles(#[case] a: &str, #[case] b: &str) {
let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR); let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR);
let parsed_a = mm.parse_title(a); let parsed_a = mm.parse_title(a);
@ -534,7 +545,7 @@ mod tests {
} }
#[rstest] #[rstest]
#[case(r#"Max-Antoine"#, r#"Max Antoine Meisters"#)] #[case(r#"Max-Antoine"#, r#"Max-Antoine Meisters"#)]
#[case(r#"DJ Gollum"#, r#"DJ Gollum feat. Akustikrausch"#)] #[case(r#"DJ Gollum"#, r#"DJ Gollum feat. Akustikrausch"#)]
fn match_artists(#[case] a: &str, #[case] b: &str) { fn match_artists(#[case] a: &str, #[case] b: &str) {
let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR); let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR);

View file

@ -3,7 +3,6 @@ pub mod matchmaker;
use std::{borrow::Cow, hash::Hasher}; use std::{borrow::Cow, hash::Hasher};
use hex_literal::hex; use hex_literal::hex;
use http::StatusCode;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use rustypipe::model as rpmodel; use rustypipe::model as rpmodel;
@ -11,7 +10,7 @@ use siphasher::sip128::{Hasher128, SipHasher};
use time::{Date, Duration, OffsetDateTime}; use time::{Date, Duration, OffsetDateTime};
use tiraya_db::models::{AlbumType, DatePrecision, SrcId, SyncData, SyncError}; use tiraya_db::models::{AlbumType, DatePrecision, SrcId, SyncData, SyncError};
use crate::error::ExtractorSourceError; use crate::error::{ExtractorError, ExtractorSourceError};
pub struct ArtistSrcidName<'a> { pub struct ArtistSrcidName<'a> {
pub id: SrcId<'a>, pub id: SrcId<'a>,
@ -59,36 +58,24 @@ pub fn yt_image_url(images: &[rpmodel::Thumbnail], format: ImgFormat) -> Option<
}) })
} }
pub fn src_error_to_sync_data(error: &ExtractorSourceError) -> Option<SyncData> { /// Convert an extractor error to a SyncData object if the error should be stored in the database
pub fn error_to_sync_data(error: &ExtractorError) -> Option<SyncData> {
match error { match error {
ExtractorSourceError::YouTube(error) => match error { ExtractorError::Source(error) => match error {
rustypipe::error::Error::Http(_) => None, ExtractorSourceError::YouTube(error) => match error {
rustypipe::error::Error::Extraction( rustypipe::error::Error::Extraction(_) => Some(SyncData::Error {
rustypipe::error::ExtractionError::NotFound { .. } typ: SyncError::Other,
| rustypipe::error::ExtractionError::VideoUnavailable { .. }, msg: error.to_string(),
) => Some(SyncData::Error { }),
typ: SyncError::NotFound, _ => None,
msg: error.to_string(),
}),
rustypipe::error::Error::Extraction(error) => Some(SyncData::Error {
typ: SyncError::Other,
msg: error.to_string(),
}),
_ => None,
},
ExtractorSourceError::Spotify(error) => match error {
rspotify::ClientError::Http(error) => match error.as_ref() {
rspotify::http::HttpError::Client(_) => None,
rspotify::http::HttpError::StatusCode(status) => match status.status() {
StatusCode::NOT_FOUND => Some(SyncData::Error {
typ: SyncError::NotFound,
msg: String::new(),
}),
_ => None,
},
}, },
_ => None, ExtractorSourceError::Spotify(_) => None,
}, },
ExtractorError::Unavailable { msg, .. } => Some(SyncData::Error {
typ: SyncError::Unavailable,
msg: msg.to_string(),
}),
_ => None,
} }
} }

View file

@ -17,6 +17,7 @@ enum Commands {
Album { id: String }, Album { id: String },
Track { id: String }, Track { id: String },
Playlist { id: String }, Playlist { id: String },
PlaylistMeta { id: String },
User { id: String }, User { id: String },
} }
@ -77,6 +78,11 @@ async fn run() {
} }
} }
} }
Commands::PlaylistMeta { id } => {
let src_id = SrcIdOwned::from_str(&id).unwrap();
let playlist = ext.get_playlist_meta(src_id.as_srcid()).await.unwrap().c;
dbg!(playlist);
}
Commands::User { id } => { Commands::User { id } => {
let src_id = SrcIdOwned::from_str(&id).unwrap(); let src_id = SrcIdOwned::from_str(&id).unwrap();
let user = ext.get_user(src_id.as_srcid()).await.unwrap().c; let user = ext.get_user(src_id.as_srcid()).await.unwrap().c;

View file

@ -13,6 +13,7 @@ once_cell.workspace = true
path-absolutize.workspace = true path-absolutize.workspace = true
serde.workspace = true serde.workspace = true
smart-default.workspace = true smart-default.workspace = true
time.workspace = true
toml.workspace = true toml.workspace = true
[dev-dependencies] [dev-dependencies]

View file

@ -70,6 +70,12 @@ pub struct ConfigExtractor {
/// Must be between 0 (no match) and 1 (perfect match). /// Must be between 0 (no match) and 1 (perfect match).
#[default(0.5)] #[default(0.5)]
pub match_threshold: f32, pub match_threshold: f32,
/// Give your matchmaker configuration a name
///
/// The name is stored alongside the matches. This allows you to change the
/// configuration and redo the matches already stored in the database.
#[default("default")]
pub matchmaker_cfg_name: String,
pub youtube: ConfigExtractorYouTube, pub youtube: ConfigExtractorYouTube,
pub spotify: ConfigExtractorSpotify, pub spotify: ConfigExtractorSpotify,

View file

@ -11,12 +11,17 @@ Config(
extractor: ConfigExtractor( extractor: ConfigExtractor(
artist_stale_h: 24, artist_stale_h: 24,
playlist_stale_h: 24, playlist_stale_h: 24,
user_stale_h: 24,
playlist_item_limit: 2000,
playlist_item_limit_matchmaker: 500,
user_playlist_limit: 200,
data_dir: "[path]", data_dir: "[path]",
track_type_keywords: "[track_type_keywords]", track_type_keywords: "[track_type_keywords]",
artist_playlist_excluded_types: [ artist_playlist_excluded_types: [
"inst", "inst",
], ],
match_threshold: 0.5, match_threshold: 0.5,
matchmaker_cfg_name: "default-1",
youtube: ConfigExtractorYouTube( youtube: ConfigExtractorYouTube(
language: "en", language: "en",
country: "US", country: "US",
@ -27,7 +32,7 @@ Config(
enable: true, enable: true,
client_id: "abc123", client_id: "abc123",
client_secret: "supersecret", client_secret: "supersecret",
market: None, market: "US",
), ),
), ),
) )

View file

@ -28,6 +28,10 @@ artist_playlist_excluded_types = ["inst"]
# Threshold value for the matchmaker # Threshold value for the matchmaker
# Must be between 0 (no match) and 1 (perfect match). # Must be between 0 (no match) and 1 (perfect match).
match_threshold = 0.5 match_threshold = 0.5
# Give your matchmaker configuration a name
# The name is stored alongside the matches. This allows you to change the
# configuration and redo the matches already stored in the database.
matchmaker_cfg_name = "default-1"
# Track types are used by the matchmaker. Tracks from different types will not match. # Track types are used by the matchmaker. Tracks from different types will not match.
# The keywords must be contained in the title of the track, excluding the first word. # The keywords must be contained in the title of the track, excluding the first word.
@ -45,7 +49,8 @@ inst = [
"instr.", "instr.",
] ]
live = ["live"] live = ["live"]
acoustic = ["acoustic", "akustik", "unplugged"] acoustic = ["acoustic", "akustik", "unplugged", "piano", "pianos"]
nightcore = ["nightcore"]
# Configuration for the individual streaming services # Configuration for the individual streaming services