Compare commits
4 commits
86a4bd9762
...
7fe08e37ec
Author | SHA1 | Date | |
---|---|---|---|
7fe08e37ec | |||
585faffd32 | |||
a451a5dbe9 | |||
9e4a5de891 |
25 changed files with 995 additions and 633 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2887,6 +2887,7 @@ dependencies = [
|
|||
"path_macro",
|
||||
"serde",
|
||||
"smart-default",
|
||||
"time",
|
||||
"toml",
|
||||
]
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
|
@ -19,10 +19,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Int4"
|
||||
"Int4",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "54911be75e68b9ddda44352f492fb7f500e86ef25c12a6840ea9e61b219f0d7c"
|
||||
"hash": "2431c70614cb2e2f51a6e5dcf1798b3346c088eb8aab95a658e6455476347f3c"
|
||||
}
|
|
@ -203,7 +203,8 @@ ADD CONSTRAINT artist_aliases_artists_fk FOREIGN KEY (artist_id) REFERENCES arti
|
|||
CREATE TABLE track_aliases (
|
||||
src_id text 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)
|
||||
);
|
||||
COMMENT ON TABLE track_aliases IS E'Track IDs from secondary sources';
|
||||
|
|
|
@ -6,10 +6,10 @@ use tiraya_utils::config::CONFIG;
|
|||
|
||||
use super::{
|
||||
artist::{ArtistId, ArtistJsonb},
|
||||
map_artists, AlbumType, DatePrecision, Id, MusicService, SrcId, SrcIdOwned, TrackSlim,
|
||||
TrackSlimRow,
|
||||
AlbumType, DatePrecision, Id, IdLike, MusicService, SrcId, SrcIdOwned, TrackSlim, TrackSlimRow,
|
||||
};
|
||||
use crate::error::{DatabaseError, OptionalRes};
|
||||
use crate::util;
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct Album {
|
||||
|
@ -150,11 +150,11 @@ impl Album {
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
AlbumRow,
|
||||
|
@ -194,7 +194,7 @@ group by b.id"#,
|
|||
}
|
||||
}
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
|
||||
.map(Album::from)
|
||||
}
|
||||
|
||||
|
@ -239,11 +239,11 @@ group by b.id"#,
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(src_id, srv) => {
|
||||
let srcid = SrcId(src_id, srv);
|
||||
|
@ -402,7 +402,7 @@ returning id"#,
|
|||
}
|
||||
|
||||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
|
@ -497,7 +497,7 @@ impl AlbumUpdate<'_> {
|
|||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
|
@ -517,11 +517,11 @@ impl AlbumUpdate<'_> {
|
|||
}
|
||||
|
||||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let row: AlbumSlimRow = match id {
|
||||
let row: AlbumSlimRow = match id.id() {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
AlbumSlimRow,
|
||||
|
@ -559,7 +559,7 @@ group by b.id"#,
|
|||
}
|
||||
}
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))?;
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))?;
|
||||
|
||||
Ok(row.into())
|
||||
}
|
||||
|
@ -583,7 +583,7 @@ impl From<AlbumRow> for Album {
|
|||
src_id: value.src_id,
|
||||
service: value.service,
|
||||
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_precision: value.release_date_precision,
|
||||
album_type: value.album_type,
|
||||
|
@ -604,7 +604,7 @@ impl From<AlbumSlimRow> for AlbumSlim {
|
|||
src_id: value.src_id,
|
||||
service: value.service,
|
||||
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,
|
||||
album_type: value.album_type,
|
||||
image_url: value.image_url,
|
||||
|
|
|
@ -5,8 +5,8 @@ use time::PrimitiveDateTime;
|
|||
use tiraya_utils::config::CONFIG;
|
||||
|
||||
use super::{
|
||||
album::AlbumSlimRow, AlbumSlim, Id, IdOwned, InternalId, MusicService, PlaylistSlim, SrcId,
|
||||
SrcIdOwned, SyncData, TrackSlim, TrackSlimRow, TrackTiny,
|
||||
album::AlbumSlimRow, AlbumSlim, Id, IdLike, IdOwned, InternalId, MusicService, PlaylistSlim,
|
||||
SrcId, SrcIdOwned, SyncData, TrackSlim, TrackSlimRow, TrackTiny,
|
||||
};
|
||||
use crate::error::{DatabaseError, OptionalRes};
|
||||
|
||||
|
@ -88,7 +88,7 @@ pub struct ArtistJsonb {
|
|||
}
|
||||
|
||||
/// Artist identifier
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ArtistId {
|
||||
pub id: Option<SrcIdOwned>,
|
||||
pub name: String,
|
||||
|
@ -103,11 +103,11 @@ impl Artist {
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
|
@ -161,7 +161,7 @@ where aa.src_id=$1 and aa.service=$2"#,
|
|||
}
|
||||
}
|
||||
.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>
|
||||
|
@ -258,10 +258,10 @@ where aa.src_id=$1 and aa.service=$2"#,
|
|||
}
|
||||
|
||||
pub async fn resolve_id(
|
||||
id: Id<'_>,
|
||||
id: impl IdLike,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<i32, DatabaseError> {
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(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(
|
||||
id: Id<'_>,
|
||||
id: impl IdLike,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(i32, SrcIdOwned), DatabaseError> {
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
let srcid = Self::get_src_id(id, &mut **tx)
|
||||
.await?
|
||||
|
@ -310,8 +310,8 @@ on conflict (src_id, service) do update set artist_id=excluded.artist_id"#,
|
|||
}
|
||||
|
||||
pub async fn merge(
|
||||
id_prim: Id<'_>,
|
||||
id_sec: Id<'_>,
|
||||
id_prim: impl IdLike,
|
||||
id_sec: impl IdLike,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let id_prim = Self::resolve_id(id_prim, tx).await?;
|
||||
|
@ -687,7 +687,7 @@ returning id"#,
|
|||
}
|
||||
|
||||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
|
@ -782,7 +782,7 @@ impl ArtistUpdate<'_> {
|
|||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
|
@ -802,11 +802,11 @@ impl ArtistUpdate<'_> {
|
|||
}
|
||||
|
||||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => sqlx::query_as!(
|
||||
Self,
|
||||
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)
|
||||
.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>
|
||||
|
|
|
@ -107,14 +107,14 @@ pub enum SyncData {
|
|||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncError {
|
||||
NotFound,
|
||||
Unavailable,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl SyncError {
|
||||
pub fn retry(&self, age: Duration) -> bool {
|
||||
match self {
|
||||
SyncError::NotFound => age > Duration::days(7),
|
||||
SyncError::Unavailable => age > Duration::days(7),
|
||||
SyncError::Other => true,
|
||||
}
|
||||
}
|
||||
|
@ -138,12 +138,12 @@ mod tests {
|
|||
);
|
||||
|
||||
let err2 = SyncData::Error {
|
||||
typ: SyncError::NotFound,
|
||||
typ: SyncError::Unavailable,
|
||||
msg: "gone".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
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));
|
||||
|
|
276
crates/db/src/models/id.rs
Normal file
276
crates/db/src/models/id.rs
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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 artist;
|
||||
mod change_operation;
|
||||
mod enums;
|
||||
mod id;
|
||||
mod playlist;
|
||||
mod playlist_change;
|
||||
mod track;
|
||||
|
@ -18,268 +14,17 @@ pub use enums::{
|
|||
AlbumType, DatePrecision, EntityType, MusicService, PlaylistImgType, PlaylistType, SyncData,
|
||||
SyncError, UserType,
|
||||
};
|
||||
pub use id::{Id, IdLike, IdOwned, InternalId, SrcId, SrcIdOwned};
|
||||
pub use playlist::{
|
||||
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(crate) use artist::ArtistJsonb;
|
||||
pub(crate) use change_operation::ChangeOperation;
|
||||
pub(crate) use playlist_change::{PlaylistChange, PlaylistChangeAdd, PlaylistChangeNew};
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,8 @@ use tiraya_utils::config::CONFIG;
|
|||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
playlist_change::PlaylistChangeAdd, Id, MusicService, PlaylistChange, PlaylistChangeNew,
|
||||
PlaylistImgType, PlaylistType, SrcId, SrcIdOwned, SyncData, TrackSlim,
|
||||
playlist_change::PlaylistChangeAdd, Id, IdLike, MusicService, PlaylistChange,
|
||||
PlaylistChangeNew, PlaylistImgType, PlaylistType, SrcId, SrcIdOwned, SyncData, TrackSlim,
|
||||
};
|
||||
use crate::{
|
||||
error::{DatabaseError, OptionalRes},
|
||||
|
@ -106,11 +106,11 @@ impl Playlist {
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
|
@ -138,7 +138,7 @@ from playlists where src_id=$1 and service=$2"#,
|
|||
}
|
||||
}
|
||||
.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>
|
||||
|
@ -168,11 +168,11 @@ from playlists where src_id=$1 and service=$2"#,
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(src_id, srv) => {
|
||||
let srcid = SrcId(src_id, srv);
|
||||
|
@ -560,7 +560,7 @@ returning id"#,
|
|||
}
|
||||
|
||||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
|
@ -615,7 +615,7 @@ impl PlaylistUpdate<'_> {
|
|||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
|
@ -639,11 +639,11 @@ impl PlaylistSlim {
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => sqlx::query_as!(
|
||||
PlaylistSlim,
|
||||
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)
|
||||
.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>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use futures::{stream, StreamExt, TryStreamExt};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::{Date, PrimitiveDateTime};
|
||||
use tiraya_utils::config::CONFIG;
|
||||
|
@ -7,9 +7,10 @@ use tiraya_utils::config::CONFIG;
|
|||
use super::{
|
||||
album::AlbumId,
|
||||
artist::{ArtistId, ArtistJsonb},
|
||||
map_artists, AlbumType, Id, MusicService, SrcId, SrcIdOwned,
|
||||
AlbumType, Id, IdLike, MusicService, SrcId, SrcIdOwned,
|
||||
};
|
||||
use crate::error::{DatabaseError, OptionalRes};
|
||||
use crate::util;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Track {
|
||||
|
@ -133,6 +134,35 @@ pub struct TrackTiny {
|
|||
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 {
|
||||
pub fn id(&self) -> Id {
|
||||
Id::Db(self.id)
|
||||
|
@ -142,11 +172,11 @@ impl Track {
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
TrackRow,
|
||||
|
@ -211,7 +241,7 @@ group by t.id"#,
|
|||
}
|
||||
}
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))
|
||||
.map(Track::from)
|
||||
}
|
||||
|
||||
|
@ -312,10 +342,10 @@ where ta.src_id=$1 and ta.service=$2"#,
|
|||
}
|
||||
|
||||
pub async fn resolve_id(
|
||||
id: Id<'_>,
|
||||
id: impl IdLike,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<i32, DatabaseError> {
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(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
|
||||
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!(
|
||||
r#"insert into track_aliases (src_id, service, track_id) values ($1, $2, $3)
|
||||
on conflict (src_id, service) do update set track_id=excluded.track_id"#,
|
||||
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 = coalesce(excluded.track_id, track_aliases.track_id),
|
||||
match_data = coalesce(excluded.match_data, track_aliases.match_data)"#,
|
||||
alias.0,
|
||||
alias.1 as MusicService,
|
||||
id,
|
||||
track_id,
|
||||
match_data_json,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
@ -384,7 +427,7 @@ returning id"#,
|
|||
}
|
||||
|
||||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
|
@ -495,7 +538,7 @@ impl TrackUpdate<'_> {
|
|||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
|
@ -519,12 +562,12 @@ impl TrackSlim {
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let row: TrackSlimRow =
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => sqlx::query_as!(
|
||||
TrackSlimRow,
|
||||
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)?
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned()))?;
|
||||
.ok_or_else(|| DatabaseError::NotFound(id.to_owned_id()))?;
|
||||
|
||||
Ok(row.into())
|
||||
}
|
||||
|
@ -618,7 +661,7 @@ impl From<TrackRow> for Track {
|
|||
service: value.service,
|
||||
name: value.name,
|
||||
duration: value.duration,
|
||||
artists: map_artists(value.artists, value.ul_artists),
|
||||
artists: util::map_artists(value.artists, value.ul_artists),
|
||||
size: value.size,
|
||||
loudness: value.loudness,
|
||||
album_id: value.album_id,
|
||||
|
@ -642,7 +685,7 @@ impl From<TrackSlimRow> for TrackSlim {
|
|||
service: value.service,
|
||||
name: value.name,
|
||||
duration: value.duration,
|
||||
artists: map_artists(value.artists, value.ul_artists),
|
||||
artists: util::map_artists(value.artists, value.ul_artists),
|
||||
album: AlbumId {
|
||||
src_id: value.album_src_id,
|
||||
service: value.album_service,
|
||||
|
@ -778,7 +821,7 @@ mod tests {
|
|||
|
||||
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
|
||||
.unwrap();
|
||||
let track_id = Track::get_id(src_id, &pool).await.unwrap().unwrap();
|
||||
|
|
|
@ -4,7 +4,7 @@ use time::PrimitiveDateTime;
|
|||
|
||||
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)]
|
||||
pub struct User {
|
||||
|
@ -59,11 +59,11 @@ impl User {
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
|
@ -91,7 +91,7 @@ from users where src_id=$1 and service=$2"#,
|
|||
}
|
||||
}
|
||||
.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>
|
||||
|
@ -121,11 +121,11 @@ from users where src_id=$1 and service=$2"#,
|
|||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => Ok(id),
|
||||
Id::Src(src_id, srv) => {
|
||||
let srcid = SrcId(src_id, srv);
|
||||
|
@ -270,7 +270,7 @@ returning id"#,
|
|||
}
|
||||
|
||||
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
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
|
@ -301,7 +301,7 @@ impl UserUpdate<'_> {
|
|||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
match id {
|
||||
match id.id() {
|
||||
Id::Db(id) => {
|
||||
query.push("id=");
|
||||
query.push_bind(id);
|
||||
|
|
|
@ -3,10 +3,13 @@ use std::hash::Hasher;
|
|||
use hex_literal::hex;
|
||||
use similar::algorithms::DiffHook;
|
||||
use siphasher::sip128::{Hasher128, SipHasher};
|
||||
use sqlx::types::Json;
|
||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{ChangeOperation, PlaylistChangeAdd, PlaylistEntry};
|
||||
use crate::models::{
|
||||
ArtistId, ArtistJsonb, ChangeOperation, PlaylistChangeAdd, PlaylistEntry, SrcIdOwned,
|
||||
};
|
||||
|
||||
pub fn primitive_now() -> PrimitiveDateTime {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
@ -159,6 +162,27 @@ pub fn diff_playlist(
|
|||
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)]
|
||||
mod tests {
|
||||
use insta::assert_ron_snapshot;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use http::StatusCode;
|
||||
use tiraya_db::models::{EntityType, MusicService, SrcIdOwned};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
|
@ -15,8 +16,14 @@ pub enum ExtractorError {
|
|||
id: SrcIdOwned,
|
||||
msg: Cow<'static, str>,
|
||||
},
|
||||
#[error("could not find a match for track [{id}]")]
|
||||
NoMatch { id: SrcIdOwned },
|
||||
#[error("{typ} [{id}] not available: {msg}")]
|
||||
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")]
|
||||
NoId,
|
||||
#[error("{0}")]
|
||||
|
@ -54,3 +61,50 @@ impl From<rspotify::ClientError> for ExtractorError {
|
|||
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)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,8 @@ impl std::ops::Deref for Extractor {
|
|||
}
|
||||
}
|
||||
|
||||
const MSG_NOT_EXIST: &str = "does not exist";
|
||||
|
||||
impl Extractor {
|
||||
pub fn new(db: Pool<Postgres>) -> Result<Self, ExtractorError> {
|
||||
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))]
|
||||
pub async fn get_artist(&self, id: SrcId<'_>) -> Result<GetResult<Artist>, ExtractorError> {
|
||||
let artist = Artist::get(id.id(), &self.core.db)
|
||||
.await
|
||||
.to_optional()
|
||||
.unwrap();
|
||||
let artist = Artist::get(id, &self.core.db).await.to_optional()?;
|
||||
|
||||
// Get srcid from fetched artist if available (could be an alias)
|
||||
let srcid = artist
|
||||
|
@ -143,25 +150,23 @@ impl Extractor {
|
|||
status: res.status,
|
||||
})
|
||||
.map_err(ExtractorError::from),
|
||||
Err(e) => match e {
|
||||
ExtractorError::Source(e) => {
|
||||
// Store YT-related error (e.g. artist not available)
|
||||
if let Some(last_update) = last_update {
|
||||
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?;
|
||||
}
|
||||
return Ok(GetResult {
|
||||
c: Artist::get(Id::Db(last_update.id), &self.core.db).await?,
|
||||
status: GetStatus::Error(e),
|
||||
});
|
||||
Err(e) => {
|
||||
// Store YT-related error (e.g. artist not available)
|
||||
if let Some(last_update) = last_update {
|
||||
if let Some(sync_data) = util::error_to_sync_data(&e) {
|
||||
Artist::set_last_sync(last_update.id, sync_data, &self.core.db).await?;
|
||||
}
|
||||
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))]
|
||||
pub async fn fetch_artist_albums(&self, id: i32) -> Result<(), ExtractorError> {
|
||||
let mut n_fetched = 0;
|
||||
|
@ -257,12 +262,53 @@ impl Extractor {
|
|||
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))]
|
||||
pub async fn get_playlist(&self, id: SrcId<'_>) -> Result<GetResult<Playlist>, ExtractorError> {
|
||||
let playlist = Playlist::get(id.id(), &self.core.db)
|
||||
.await
|
||||
.to_optional()
|
||||
.unwrap();
|
||||
let playlist = Playlist::get(id, &self.core.db).await.to_optional()?;
|
||||
|
||||
if id.1 == MusicService::Tiraya {
|
||||
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 {
|
||||
if let Some((last_sync_at, last_sync_data)) = &playlist
|
||||
.last_sync_at
|
||||
|
@ -306,12 +352,6 @@ impl Extractor {
|
|||
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,
|
||||
/// fetching new tracks if necessary.
|
||||
async fn update_playlist(
|
||||
|
@ -338,32 +378,38 @@ impl Extractor {
|
|||
status: res.status,
|
||||
})
|
||||
.map_err(ExtractorError::from),
|
||||
Err(e) => match e {
|
||||
ExtractorError::Source(e) => {
|
||||
// Store YT-related error (e.g. playlist not available)
|
||||
if let Some(last_update) = last_update {
|
||||
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?;
|
||||
}
|
||||
return Ok(GetResult {
|
||||
c: Playlist::get(Id::Db(last_update.id), &self.core.db).await?,
|
||||
status: GetStatus::Error(e),
|
||||
});
|
||||
Err(e) => {
|
||||
// Store YT-related error (e.g. playlist not available)
|
||||
if let Some(last_update) = last_update {
|
||||
if let Some(sync_data) = util::error_to_sync_data(&e) {
|
||||
Playlist::set_last_sync(last_update.id, sync_data, &self.core.db).await?;
|
||||
}
|
||||
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))]
|
||||
pub async fn get_user(&self, id: SrcId<'_>) -> Result<GetResult<User>, ExtractorError> {
|
||||
let user = User::get(id.id(), &self.core.db)
|
||||
.await
|
||||
.to_optional()
|
||||
.unwrap();
|
||||
let user = User::get(id, &self.core.db).await.to_optional()?;
|
||||
|
||||
if id.1 == MusicService::Tiraya {
|
||||
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 {
|
||||
if let Some((last_sync_at, last_sync_data)) =
|
||||
&user.last_sync_at.zip(user.last_sync_data.as_deref())
|
||||
|
@ -433,23 +479,19 @@ impl Extractor {
|
|||
status: res.status,
|
||||
})
|
||||
.map_err(ExtractorError::from),
|
||||
Err(e) => match e {
|
||||
ExtractorError::Source(e) => {
|
||||
// Store YT-related error (e.g. playlist not available)
|
||||
if let Some(last_update) = last_update {
|
||||
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?;
|
||||
}
|
||||
return Ok(GetResult {
|
||||
c: User::get(Id::Db(last_update.id), &self.core.db).await?,
|
||||
status: GetStatus::Error(e),
|
||||
});
|
||||
Err(e) => {
|
||||
// Store YT-related error (e.g. playlist not available)
|
||||
if let Some(last_update) = last_update {
|
||||
if let Some(sync_data) = util::error_to_sync_data(&e) {
|
||||
Playlist::set_last_sync(last_update.id, sync_data, &self.core.db).await?;
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ use time::OffsetDateTime;
|
|||
use tiraya_db::models::{SrcIdOwned, SyncData};
|
||||
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
|
||||
pub struct ExtractorCore {
|
||||
|
@ -32,7 +32,7 @@ pub struct GetResult<T> {
|
|||
pub enum GetStatus {
|
||||
Fetched,
|
||||
Stored,
|
||||
Error(ExtractorSourceError),
|
||||
Error(Box<ExtractorError>),
|
||||
}
|
||||
|
||||
impl PartialEq for GetStatus {
|
||||
|
|
|
@ -33,6 +33,9 @@ pub(crate) trait ServiceExtractor {
|
|||
/// Fetch a new track from the streaming service.
|
||||
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,
|
||||
/// fetching new tracks if necessary.
|
||||
async fn update_playlist(
|
||||
|
|
|
@ -2,8 +2,8 @@ use futures::{stream::TryStreamExt, StreamExt};
|
|||
use path_macro::path;
|
||||
use rspotify::{
|
||||
model::{
|
||||
AlbumId, ArtistId, FullTrack, Market, PlayableItem, PlaylistId, PublicUser,
|
||||
SimplifiedPlaylist, TrackId, UserId,
|
||||
AlbumId, ArtistId as SpotifyArtistId, FullPlaylist, FullTrack, Image, Market, PlayableItem,
|
||||
PlaylistId, PublicUser, SimplifiedPlaylist, TrackId, UserId,
|
||||
},
|
||||
prelude::{BaseClient, Id},
|
||||
ClientCredsSpotify,
|
||||
|
@ -11,15 +11,15 @@ use rspotify::{
|
|||
use std::{collections::HashSet, sync::Arc};
|
||||
use time::OffsetDateTime;
|
||||
use tiraya_db::models::{
|
||||
Artist, MusicService, Playlist, PlaylistEntry, PlaylistImgType, PlaylistNew, PlaylistType,
|
||||
SrcId, SrcIdOwned, Track, User, UserNew, UserType,
|
||||
Artist, ArtistId, EntityType, MusicService, Playlist, PlaylistEntry, PlaylistImgType,
|
||||
PlaylistNew, PlaylistType, SrcId, SrcIdOwned, Track, TrackMatchAlbum, TrackMatchMeta, User,
|
||||
UserNew, UserType,
|
||||
};
|
||||
use tiraya_utils::config::CONFIG;
|
||||
|
||||
use crate::{
|
||||
error::ExtractorError,
|
||||
model::{ExtractorCore, GetResult, LastUpdateState, SyncLastUpdate},
|
||||
util::ArtistIdName,
|
||||
};
|
||||
|
||||
use super::YouTubeExtractor;
|
||||
|
@ -85,7 +85,7 @@ impl SpotifyExtractor {
|
|||
// will only be called on the initial request of the artist, not on updates
|
||||
// (hence no last_update parameter).
|
||||
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
|
||||
.sp
|
||||
.artist_top_tracks(artist_id, Some(self.market))
|
||||
|
@ -143,6 +143,7 @@ impl SpotifyExtractor {
|
|||
}
|
||||
|
||||
Err(ExtractorError::NoMatch {
|
||||
typ: EntityType::Artist,
|
||||
id: a_id.to_owned(),
|
||||
})
|
||||
}
|
||||
|
@ -169,6 +170,16 @@ impl SpotifyExtractor {
|
|||
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(
|
||||
&self,
|
||||
src_id: &str,
|
||||
|
@ -178,35 +189,17 @@ impl SpotifyExtractor {
|
|||
let sp_pl_id = PlaylistId::from_id(src_id)?;
|
||||
let playlist = self
|
||||
.sp
|
||||
.playlist(sp_pl_id.clone_static(), Some(""), None)
|
||||
.playlist(sp_pl_id.clone_static(), None, None)
|
||||
.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 last_update.state.is_current(¤t_state) {
|
||||
return Ok(GetResult::stored(last_update.id));
|
||||
}
|
||||
}
|
||||
|
||||
let image_url = playlist
|
||||
.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_id = self.import_playlist(playlist).await?;
|
||||
|
||||
let playlist_tracks = self
|
||||
.sp
|
||||
|
@ -292,7 +285,7 @@ impl SpotifyExtractor {
|
|||
let user = self.sp.user(sp_user_id.clone_static()).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() {
|
||||
let mut offset = PAGE_LIMIT;
|
||||
|
@ -305,7 +298,7 @@ impl SpotifyExtractor {
|
|||
Some(offset),
|
||||
)
|
||||
.await?;
|
||||
self.import_playlists(playlists.items).await?;
|
||||
self.import_playlist_items(playlists.items).await?;
|
||||
if playlists.next.is_none() {
|
||||
break;
|
||||
}
|
||||
|
@ -319,11 +312,7 @@ impl SpotifyExtractor {
|
|||
|
||||
async fn import_user(&self, user: PublicUser) -> Result<i32, ExtractorError> {
|
||||
let id_owned = SrcIdOwned(user.id.id().to_owned(), MusicService::Spotify);
|
||||
let image_url = user
|
||||
.images
|
||||
.into_iter()
|
||||
.max_by_key(|img| img.height.unwrap_or_default())
|
||||
.map(|img| img.url);
|
||||
let image_url = Self::get_image(user.images);
|
||||
let name = user.display_name.as_deref().unwrap_or(user.id.id());
|
||||
|
||||
self.core
|
||||
|
@ -344,7 +333,10 @@ impl SpotifyExtractor {
|
|||
.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(
|
||||
SrcId(playlist.id.id(), MusicService::Spotify),
|
||||
&self.core.db,
|
||||
|
@ -354,11 +346,7 @@ impl SpotifyExtractor {
|
|||
return Ok(id);
|
||||
}
|
||||
|
||||
let image_url = playlist
|
||||
.images
|
||||
.into_iter()
|
||||
.max_by_key(|img| img.height.unwrap_or_default())
|
||||
.map(|img| img.url);
|
||||
let image_url = Self::get_image(playlist.images);
|
||||
let owner_id = self.import_user(playlist.owner).await?;
|
||||
|
||||
let playlist_n = PlaylistNew {
|
||||
|
@ -381,13 +369,13 @@ impl SpotifyExtractor {
|
|||
Ok(playlist_id)
|
||||
}
|
||||
|
||||
async fn import_playlists(
|
||||
async fn import_playlist_items(
|
||||
&self,
|
||||
playlists: impl IntoIterator<Item = SimplifiedPlaylist>,
|
||||
) -> Result<(), ExtractorError> {
|
||||
futures::stream::iter(playlists.into_iter().map(Ok))
|
||||
.try_for_each_concurrent(CONFIG.core.db_concurrency, |pl| async {
|
||||
self.import_playlist(pl).await?;
|
||||
self.import_playlist_item(pl).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
|
@ -407,24 +395,31 @@ impl SpotifyExtractor {
|
|||
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
|
||||
.match_track(
|
||||
src_id,
|
||||
&track.name,
|
||||
&artists,
|
||||
track.external_ids.get("isrc").map(|s| s.as_str()),
|
||||
)
|
||||
.match_track(TrackMatchMeta {
|
||||
id: src_id.to_owned(),
|
||||
name: track.name,
|
||||
artists: track
|
||||
.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
|
||||
}
|
||||
|
||||
|
@ -444,4 +439,32 @@ impl SpotifyExtractor {
|
|||
})
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,16 +13,17 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time};
|
|||
use tiraya_db::{
|
||||
error::OptionalRes,
|
||||
models::{
|
||||
Album, AlbumNew, AlbumType, AlbumUpdate, Artist, ArtistId, ArtistNew, DatePrecision, Id,
|
||||
MusicService, Playlist, PlaylistEntry, PlaylistImgType, PlaylistNew, PlaylistType, SrcId,
|
||||
SrcIdOwned, Track, TrackNew, TrackUpdate, User, UserNew, UserType,
|
||||
Album, AlbumNew, AlbumType, AlbumUpdate, Artist, ArtistId, ArtistNew, DatePrecision,
|
||||
EntityType, Id, MusicService, Playlist, PlaylistEntry, PlaylistImgType, PlaylistNew,
|
||||
PlaylistType, SrcId, SrcIdOwned, Track, TrackMatchAlbum, TrackMatchData, TrackMatchMeta,
|
||||
TrackNew, TrackUpdate, User, UserNew, UserType,
|
||||
},
|
||||
};
|
||||
use tiraya_utils::config::CONFIG;
|
||||
|
||||
use crate::util::ArtistSrcidName;
|
||||
use crate::{
|
||||
error::ExtractorError,
|
||||
error::{ExtractorError, MapSrcError},
|
||||
model::{ExtractorCore, GetResult, LastUpdateState, SyncLastUpdate},
|
||||
util::{self, ArtistIdName, ImgFormat},
|
||||
GetStatus,
|
||||
|
@ -76,7 +77,12 @@ impl YouTubeExtractor {
|
|||
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!(
|
||||
self.import_track_items(
|
||||
|
@ -144,7 +150,12 @@ impl YouTubeExtractor {
|
|||
}
|
||||
|
||||
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
|
||||
.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> {
|
||||
let query = self.rp.query();
|
||||
let (track, details) = if with_details {
|
||||
let (track, details) =
|
||||
tokio::join!(query.music_details(src_id), self.get_track_details(src_id));
|
||||
(track?.track, details)
|
||||
tokio::join!(query.music_details(src_id), self.get_track_details(src_id))
|
||||
} 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 {
|
||||
Some(track.id.clone())
|
||||
} else {
|
||||
|
@ -249,8 +259,9 @@ impl YouTubeExtractor {
|
|||
"track [yt:{src_id}] got redirected to [yt:{redirected_id}], added alias"
|
||||
);
|
||||
Track::add_alias(
|
||||
track_id,
|
||||
SrcId(&redirected_id, MusicService::YouTube),
|
||||
Some(track_id),
|
||||
None,
|
||||
&self.core.db,
|
||||
)
|
||||
.await?;
|
||||
|
@ -258,6 +269,16 @@ impl YouTubeExtractor {
|
|||
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(
|
||||
&self,
|
||||
src_id: &str,
|
||||
|
@ -265,11 +286,12 @@ impl YouTubeExtractor {
|
|||
) -> Result<GetResult<i32>, ExtractorError> {
|
||||
let query = self.rp.query();
|
||||
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 {
|
||||
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(
|
||||
d_playlist
|
||||
|
@ -283,23 +305,7 @@ impl YouTubeExtractor {
|
|||
}
|
||||
}
|
||||
|
||||
let image_url = util::yt_image_url(&m_playlist.thumbnail, ImgFormat::Cover);
|
||||
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 playlist_id = self.import_playlist(&m_playlist).await?;
|
||||
|
||||
let mut m_tracks = m_playlist.tracks;
|
||||
m_tracks
|
||||
|
@ -351,7 +357,12 @@ impl YouTubeExtractor {
|
|||
src_id: &str,
|
||||
last_update: Option<&SyncLastUpdate>,
|
||||
) -> 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
|
||||
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
|
||||
/// accessible under the other id.
|
||||
pub async fn match_track(
|
||||
&self,
|
||||
src_id: SrcId<'_>,
|
||||
name: &str,
|
||||
artists: &[ArtistIdName],
|
||||
isrc: Option<&str>,
|
||||
) -> Result<i32, ExtractorError> {
|
||||
let track_id = self.get_matching_track(src_id, name, artists, isrc).await?;
|
||||
pub async fn match_track(&self, meta: TrackMatchMeta) -> Result<i32, ExtractorError> {
|
||||
let src_id = meta.id.to_owned();
|
||||
let match_res = self
|
||||
.get_matching_track(
|
||||
src_id.as_srcid(),
|
||||
&meta.name,
|
||||
&meta.artists,
|
||||
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) = isrc.and_then(util::normalize_isrc) {
|
||||
let t_upd = TrackUpdate {
|
||||
isrc: Some(Some(&isrc)),
|
||||
..Default::default()
|
||||
};
|
||||
t_upd.update(Id::Db(track_id), &self.core.db).await?;
|
||||
if let Some(isrc) = match_data
|
||||
.original
|
||||
.isrc
|
||||
.as_deref()
|
||||
.and_then(util::normalize_isrc)
|
||||
{
|
||||
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(
|
||||
&self,
|
||||
src_id: SrcId<'_>,
|
||||
name: &str,
|
||||
artists: &[ArtistIdName],
|
||||
artists: &[ArtistId],
|
||||
isrc: Option<&str>,
|
||||
) -> Result<i32, ExtractorError> {
|
||||
let artist = &artists
|
||||
.first()
|
||||
.ok_or_else(|| ExtractorError::NoMatch {
|
||||
id: src_id.to_owned(),
|
||||
})?
|
||||
.name;
|
||||
) -> Result<Result<(i32, f32), Option<(TrackMatchMeta, f32)>>, ExtractorError> {
|
||||
let artist = if let Some(artist) = artists.first() {
|
||||
&artist.name
|
||||
} else {
|
||||
return Ok(Err(None));
|
||||
};
|
||||
let t1 = self.core.matchmaker.parse_title(name);
|
||||
|
||||
// Attempt to find the track by searching YouTube for its ISRC id
|
||||
|
@ -470,12 +521,14 @@ impl YouTubeExtractor {
|
|||
track
|
||||
} else {
|
||||
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)
|
||||
.await?;
|
||||
return Ok(track.id);
|
||||
if album.album_type != Some(AlbumType::Mv) {
|
||||
self.store_matching_artists(artists, &track.artists).await?;
|
||||
return Ok(Ok((track.id, score)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -513,20 +566,53 @@ impl YouTubeExtractor {
|
|||
track.name,
|
||||
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 {
|
||||
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
|
||||
|
@ -535,8 +621,7 @@ impl YouTubeExtractor {
|
|||
/// Returns the artist id matched to the first given artist
|
||||
async fn store_matching_artists(
|
||||
&self,
|
||||
artists_to_match: &[ArtistIdName],
|
||||
service: MusicService,
|
||||
artists_to_match: &[ArtistId],
|
||||
yt_artists: &[ArtistId],
|
||||
) -> Result<(), ExtractorError> {
|
||||
let mut yt_artists = yt_artists
|
||||
|
@ -555,57 +640,58 @@ impl YouTubeExtractor {
|
|||
.collect::<Vec<_>>();
|
||||
|
||||
for atm in artists_to_match {
|
||||
let atm_srcid = SrcIdOwned(atm.id.to_owned(), service);
|
||||
// Check if the artist has already been matched
|
||||
let atm_id_res = self
|
||||
.core
|
||||
.artist_cache
|
||||
.get_or_insert_async(&atm_srcid, async {
|
||||
Artist::get_id(atm_srcid.as_srcid(), &self.core.db)
|
||||
.await?
|
||||
.ok_or(ExtractorError::NoId)
|
||||
})
|
||||
.await;
|
||||
match atm_id_res {
|
||||
Ok(atm_id) => {
|
||||
// Artist is already matched
|
||||
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)
|
||||
if let Some(atm_srcid) = &atm.id {
|
||||
// Check if the artist has already been matched
|
||||
let atm_id_res = self
|
||||
.core
|
||||
.artist_cache
|
||||
.get_or_insert_async(atm_srcid, async {
|
||||
Artist::get_id(atm_srcid.as_srcid(), &self.core.db)
|
||||
.await?
|
||||
.ok_or(ExtractorError::NoId)
|
||||
})
|
||||
.await;
|
||||
match atm_id_res {
|
||||
Ok(atm_id) => {
|
||||
// Artist is already matched
|
||||
let yt_src_id = Artist::get_src_id(atm_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, yta_id);
|
||||
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?
|
||||
.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(())
|
||||
|
@ -670,6 +756,7 @@ impl YouTubeExtractor {
|
|||
}
|
||||
|
||||
Err(ExtractorError::NoMatch {
|
||||
typ: EntityType::Album,
|
||||
id: src_id.to_owned(),
|
||||
})
|
||||
}
|
||||
|
@ -678,7 +765,12 @@ impl YouTubeExtractor {
|
|||
// Internal functions
|
||||
impl YouTubeExtractor {
|
||||
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
|
||||
.videos
|
||||
.into_iter()
|
||||
|
@ -706,7 +798,7 @@ impl YouTubeExtractor {
|
|||
primary_track: Some(Some(false)),
|
||||
..Default::default()
|
||||
};
|
||||
t_upd.update(Id::Db(id), &self.core.db).await?;
|
||||
t_upd.update(id, &self.core.db).await?;
|
||||
}
|
||||
return Ok(id);
|
||||
}
|
||||
|
@ -996,6 +1088,33 @@ impl YouTubeExtractor {
|
|||
.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
|
||||
///
|
||||
/// 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)),
|
||||
..Default::default()
|
||||
};
|
||||
b_upd.update(Id::Db(album_id), &self.core.db).await?;
|
||||
b_upd.update(album_id, &self.core.db).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
@ -1195,18 +1314,27 @@ mod tests {
|
|||
async fn match_track() {
|
||||
let xtr = YouTubeExtractor::new(ExtractorCore::new(pool.clone()).into()).unwrap();
|
||||
let track_id = xtr
|
||||
.match_track(
|
||||
SrcId("5awNIWVrh2ISfvPd5IUZNh", MusicService::Spotify),
|
||||
"PTT (Paint The Town)",
|
||||
&[ArtistIdName {
|
||||
id: "52zMTJCKluDlFwMQWmccY7".to_owned(),
|
||||
.match_track(TrackMatchMeta {
|
||||
id: SrcIdOwned("5awNIWVrh2ISfvPd5IUZNh".to_owned(), MusicService::Spotify),
|
||||
name: "PTT (Paint The Town)".to_owned(),
|
||||
artists: vec![ArtistId {
|
||||
id: Some(SrcIdOwned(
|
||||
"52zMTJCKluDlFwMQWmccY7".to_owned(),
|
||||
MusicService::Spotify,
|
||||
)),
|
||||
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
|
||||
.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, {
|
||||
".id" => "[id]",
|
||||
".created_at" => "[date]",
|
||||
|
@ -1229,7 +1357,7 @@ mod tests {
|
|||
album_id: 1,
|
||||
album_pos: None,
|
||||
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]",
|
||||
updated_at: "[date]",
|
||||
primary_track: None,
|
||||
|
|
|
@ -19,6 +19,10 @@ static SEPARATORS: phf::Set<char> =
|
|||
|
||||
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 {
|
||||
kw_matcher: AhoCorasick,
|
||||
/// List of numbers indicating the first index of a specific keyword
|
||||
|
@ -60,7 +64,10 @@ impl Matchmaker {
|
|||
}
|
||||
|
||||
Self {
|
||||
kw_matcher: AhoCorasick::new(patterns).unwrap(),
|
||||
kw_matcher: AhoCorasick::builder()
|
||||
.match_kind(aho_corasick::MatchKind::LeftmostLongest)
|
||||
.build(patterns)
|
||||
.unwrap(),
|
||||
kw_positions: positions,
|
||||
match_thr,
|
||||
}
|
||||
|
@ -346,7 +353,10 @@ mod tests {
|
|||
|
||||
static KEYWORDS: Lazy<BTreeMap<&str, Vec<&str>>> = Lazy::new(|| {
|
||||
let mut keywords = BTreeMap::new();
|
||||
keywords.insert("acoustic", vec!["acoustic", "akustik", "unplugged"]);
|
||||
keywords.insert(
|
||||
"acoustic",
|
||||
vec!["acoustic", "akustik", "unplugged", "piano", "pianos"],
|
||||
);
|
||||
keywords.insert(
|
||||
"instrumental",
|
||||
vec![
|
||||
|
@ -372,12 +382,12 @@ mod tests {
|
|||
#[test]
|
||||
fn get_kw_type() {
|
||||
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(1), TYPE_ACOUSTIC);
|
||||
assert_eq!(mm.get_kw_type(3), TYPE_INSTRUMENTAL);
|
||||
assert_eq!(mm.get_kw_type(11), TYPE_LIVE);
|
||||
assert_eq!(mm.get_kw_type(5), TYPE_INSTRUMENTAL);
|
||||
assert_eq!(mm.get_kw_type(13), TYPE_LIVE);
|
||||
assert_eq!(mm.get_kw_type(100), TYPE_LIVE);
|
||||
}
|
||||
|
||||
|
@ -521,6 +531,7 @@ mod tests {
|
|||
#[rstest]
|
||||
#[case(r#"INNOCENCE(Live at 渋谷公会堂)"#, r#"INNOCENCE"#)]
|
||||
#[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) {
|
||||
let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR);
|
||||
let parsed_a = mm.parse_title(a);
|
||||
|
@ -534,7 +545,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[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"#)]
|
||||
fn match_artists(#[case] a: &str, #[case] b: &str) {
|
||||
let mm = Matchmaker::new(KEYWORDS.values(), MATCH_THR);
|
||||
|
|
|
@ -3,7 +3,6 @@ pub mod matchmaker;
|
|||
use std::{borrow::Cow, hash::Hasher};
|
||||
|
||||
use hex_literal::hex;
|
||||
use http::StatusCode;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use rustypipe::model as rpmodel;
|
||||
|
@ -11,7 +10,7 @@ use siphasher::sip128::{Hasher128, SipHasher};
|
|||
use time::{Date, Duration, OffsetDateTime};
|
||||
use tiraya_db::models::{AlbumType, DatePrecision, SrcId, SyncData, SyncError};
|
||||
|
||||
use crate::error::ExtractorSourceError;
|
||||
use crate::error::{ExtractorError, ExtractorSourceError};
|
||||
|
||||
pub struct ArtistSrcidName<'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 {
|
||||
ExtractorSourceError::YouTube(error) => match error {
|
||||
rustypipe::error::Error::Http(_) => None,
|
||||
rustypipe::error::Error::Extraction(
|
||||
rustypipe::error::ExtractionError::NotFound { .. }
|
||||
| rustypipe::error::ExtractionError::VideoUnavailable { .. },
|
||||
) => Some(SyncData::Error {
|
||||
typ: SyncError::NotFound,
|
||||
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,
|
||||
},
|
||||
ExtractorError::Source(error) => match error {
|
||||
ExtractorSourceError::YouTube(error) => match error {
|
||||
rustypipe::error::Error::Extraction(_) => Some(SyncData::Error {
|
||||
typ: SyncError::Other,
|
||||
msg: error.to_string(),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
ExtractorSourceError::Spotify(_) => None,
|
||||
},
|
||||
ExtractorError::Unavailable { msg, .. } => Some(SyncData::Error {
|
||||
typ: SyncError::Unavailable,
|
||||
msg: msg.to_string(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ enum Commands {
|
|||
Album { id: String },
|
||||
Track { id: String },
|
||||
Playlist { id: String },
|
||||
PlaylistMeta { 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 } => {
|
||||
let src_id = SrcIdOwned::from_str(&id).unwrap();
|
||||
let user = ext.get_user(src_id.as_srcid()).await.unwrap().c;
|
||||
|
|
|
@ -13,6 +13,7 @@ once_cell.workspace = true
|
|||
path-absolutize.workspace = true
|
||||
serde.workspace = true
|
||||
smart-default.workspace = true
|
||||
time.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
@ -70,6 +70,12 @@ pub struct ConfigExtractor {
|
|||
/// Must be between 0 (no match) and 1 (perfect match).
|
||||
#[default(0.5)]
|
||||
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 spotify: ConfigExtractorSpotify,
|
||||
|
|
|
@ -11,12 +11,17 @@ Config(
|
|||
extractor: ConfigExtractor(
|
||||
artist_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]",
|
||||
track_type_keywords: "[track_type_keywords]",
|
||||
artist_playlist_excluded_types: [
|
||||
"inst",
|
||||
],
|
||||
match_threshold: 0.5,
|
||||
matchmaker_cfg_name: "default-1",
|
||||
youtube: ConfigExtractorYouTube(
|
||||
language: "en",
|
||||
country: "US",
|
||||
|
@ -27,7 +32,7 @@ Config(
|
|||
enable: true,
|
||||
client_id: "abc123",
|
||||
client_secret: "supersecret",
|
||||
market: None,
|
||||
market: "US",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -28,6 +28,10 @@ artist_playlist_excluded_types = ["inst"]
|
|||
# Threshold value for the matchmaker
|
||||
# Must be between 0 (no match) and 1 (perfect match).
|
||||
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.
|
||||
# The keywords must be contained in the title of the track, excluding the first word.
|
||||
|
@ -45,7 +49,8 @@ inst = [
|
|||
"instr.",
|
||||
]
|
||||
live = ["live"]
|
||||
acoustic = ["acoustic", "akustik", "unplugged"]
|
||||
acoustic = ["acoustic", "akustik", "unplugged", "piano", "pianos"]
|
||||
nightcore = ["nightcore"]
|
||||
|
||||
# Configuration for the individual streaming services
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue